实现每机器人独立 LLM 消息队列

主要变更:
- 后端:为 botNodeRecord 添加 llm_queue_enabled 和 llm_include_channel_messages 字段
- 后端:移除全局 LLM 队列开关,完全基于每个机器人的设置
- 后端:重写 enqueueChannelMessageToLLM,为每个启用了「包含频道消息」的机器人创建独立队列记录
- 后端:频道消息不再使用 bot_id=0,每条消息都有明确的机器人归属
- 前端:AdminLLM.vue 完全重写,添加机器人 LLM 设置面板
- 前端:消息列表表格新增「机器人」列,显示机器人名称和节点 ID
- 前端:消息类型判断逻辑改为使用 channel_id 区分频道/私聊
- 前后端类型同步更新
This commit is contained in:
2026-06-17 20:51:22 +08:00
parent e2bf6aa173
commit b0a9f52096
6 changed files with 253 additions and 112 deletions
+4 -2
View File
@@ -19,6 +19,8 @@ type botNodeRequest struct {
PSK string `json:"psk"`
NodeInfoBroadcastEnabled bool `json:"nodeinfo_broadcast_enabled"`
NodeInfoBroadcastIntervalSeconds int64 `json:"nodeinfo_broadcast_interval_seconds"`
LLMQueueEnabled bool `json:"llm_queue_enabled"`
LLMIncludeChannelMessages bool `json:"llm_include_channel_messages"`
}
type botSendMessageRequest struct {
@@ -253,7 +255,7 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
}
func botNodeInputFromRequest(req botNodeRequest) botNodeInput {
return botNodeInput{NodeNum: req.NodeNum, LongName: req.LongName, ShortName: req.ShortName, Enabled: req.Enabled, DefaultChannelID: req.DefaultChannelID, TopicPrefix: req.TopicPrefix, PSK: req.PSK, NodeInfoBroadcastEnabled: req.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: req.NodeInfoBroadcastIntervalSeconds}
return botNodeInput{NodeNum: req.NodeNum, LongName: req.LongName, ShortName: req.ShortName, Enabled: req.Enabled, DefaultChannelID: req.DefaultChannelID, TopicPrefix: req.TopicPrefix, PSK: req.PSK, NodeInfoBroadcastEnabled: req.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: req.NodeInfoBroadcastIntervalSeconds, LLMQueueEnabled: req.LLMQueueEnabled, LLMIncludeChannelMessages: req.LLMIncludeChannelMessages}
}
func parseBotID(c *gin.Context, message string) (uint64, bool) {
@@ -299,7 +301,7 @@ func writeBotNodeMutationResponse(c *gin.Context, status int, row *botNodeRecord
}
func botNodeDTO(row botNodeRecord) gin.H {
return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": row.NodeNum, "long_name": row.LongName, "short_name": row.ShortName, "enabled": row.Enabled, "default_channel_id": row.DefaultChannelID, "topic_prefix": row.TopicPrefix, "psk": row.PSK, "public_key": row.PublicKey, "private_key_set": row.PrivateKey != "", "nodeinfo_broadcast_enabled": row.NodeInfoBroadcastEnabled, "nodeinfo_broadcast_interval_seconds": row.NodeInfoBroadcastIntervalSeconds, "last_nodeinfo_broadcast_at": row.LastNodeInfoBroadcastAt, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": row.NodeNum, "long_name": row.LongName, "short_name": row.ShortName, "enabled": row.Enabled, "default_channel_id": row.DefaultChannelID, "topic_prefix": row.TopicPrefix, "psk": row.PSK, "public_key": row.PublicKey, "private_key_set": row.PrivateKey != "", "nodeinfo_broadcast_enabled": row.NodeInfoBroadcastEnabled, "nodeinfo_broadcast_interval_seconds": row.NodeInfoBroadcastIntervalSeconds, "last_nodeinfo_broadcast_at": row.LastNodeInfoBroadcastAt, "llm_queue_enabled": row.LLMQueueEnabled, "llm_include_channel_messages": row.LLMIncludeChannelMessages, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
}
func botMessageDTO(row botMessageRecord) gin.H {
+5 -1
View File
@@ -39,6 +39,8 @@ type botNodeInput struct {
PSK string
NodeInfoBroadcastEnabled bool
NodeInfoBroadcastIntervalSeconds int64
LLMQueueEnabled bool
LLMIncludeChannelMessages bool
}
type botMessageListOptions struct {
@@ -126,6 +128,8 @@ func (s *store) UpdateBotNode(id uint64, input botNodeInput) (*botNodeRecord, er
"psk": row.PSK,
"nodeinfo_broadcast_enabled": row.NodeInfoBroadcastEnabled,
"nodeinfo_broadcast_interval_seconds": row.NodeInfoBroadcastIntervalSeconds,
"llm_queue_enabled": row.LLMQueueEnabled,
"llm_include_channel_messages": row.LLMIncludeChannelMessages,
"updated_at": time.Now(),
}
if err := s.db.Model(&botNodeRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
@@ -276,7 +280,7 @@ func (s *store) normalizedBotNodeRecord(input botNodeInput) (*botNodeRecord, err
if err := validateBotNodeNum(nodeNum); err != nil {
return nil, err
}
return &botNodeRecord{NodeID: mqtpp.NodeNumToID(uint32(nodeNum)), NodeNum: nodeNum, LongName: longName, ShortName: shortName, Enabled: input.Enabled, DefaultChannelID: channelID, TopicPrefix: topicPrefix, PSK: psk, NodeInfoBroadcastEnabled: input.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: interval}, nil
return &botNodeRecord{NodeID: mqtpp.NodeNumToID(uint32(nodeNum)), NodeNum: nodeNum, LongName: longName, ShortName: shortName, Enabled: input.Enabled, DefaultChannelID: channelID, TopicPrefix: topicPrefix, PSK: psk, NodeInfoBroadcastEnabled: input.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: interval, LLMQueueEnabled: input.LLMQueueEnabled, LLMIncludeChannelMessages: input.LLMIncludeChannelMessages}, nil
}
func populateBotNodeKeys(row *botNodeRecord) error {
+12
View File
@@ -266,6 +266,8 @@ type botNodeRecord struct {
NodeInfoBroadcastIntervalSeconds int64 `gorm:"column:nodeinfo_broadcast_interval_seconds;not null"`
LastNodeInfoBroadcastAt *time.Time `gorm:"column:last_nodeinfo_broadcast_at;index"`
LastPacketID int64 `gorm:"column:last_packet_id;not null"`
LLMQueueEnabled bool `gorm:"column:llm_queue_enabled;not null;default:1;index"`
LLMIncludeChannelMessages bool `gorm:"column:llm_include_channel_messages;not null;default:0;index"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"`
}
@@ -667,6 +669,16 @@ func migrateBotNodePSK(tx *gorm.DB, migrator gorm.Migrator, driver string) error
return fmt.Errorf("migrate bot_nodes last_nodeinfo_broadcast_at column: %w", err)
}
}
if !migrator.HasColumn(&botNodeRecord{}, "LLMQueueEnabled") {
if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN llm_queue_enabled numeric NOT NULL DEFAULT 1").Error; err != nil {
return fmt.Errorf("migrate bot_nodes llm_queue_enabled column: %w", err)
}
}
if !migrator.HasColumn(&botNodeRecord{}, "LLMIncludeChannelMessages") {
if err := tx.Exec("ALTER TABLE bot_nodes ADD COLUMN llm_include_channel_messages numeric NOT NULL DEFAULT 0").Error; err != nil {
return fmt.Errorf("migrate bot_nodes llm_include_channel_messages column: %w", err)
}
}
return nil
}
+39 -44
View File
@@ -27,24 +27,19 @@ type LLMMessageQueueInput struct {
// EnqueueLLMMessage 将消息添加到 LLM 队列
func (s *store) EnqueueLLMMessage(input LLMMessageQueueInput) (*llmMessageQueueRecord, error) {
// 检查 LLM 队列是否启用
enabled, err := s.GetBoolRuntimeSetting(runtimeSettingLLMQueueEnabled, true)
if err != nil {
return nil, fmt.Errorf("check llm queue enabled: %w", err)
}
if !enabled {
return nil, nil // 静默返回,不报错
var err error
if input.BotID == 0 {
return nil, nil // bot_id 为 0 的消息不再入队
}
// 如果是频道消息,检查是否启用了频道消息入队
if input.BotID == 0 {
includeChannel, err := s.GetBoolRuntimeSetting(runtimeSettingLLMQueueIncludeChannel, false)
if err != nil {
return nil, fmt.Errorf("check llm include channel: %w", err)
}
if !includeChannel {
return nil, nil // 频道消息入队未启用,静默返回
}
// 检查机器人级别的 LLM 队列设置
bot, err := s.GetBotNode(input.BotID)
if err != nil {
return nil, nil // 机器人不存在,静默返回
}
if !bot.LLMQueueEnabled {
return nil, nil // 机器人的 LLM 队列未启用,静默返回
}
if input.FromNodeID == "" {
@@ -54,21 +49,10 @@ func (s *store) EnqueueLLMMessage(input LLMMessageQueueInput) (*llmMessageQueueR
return nil, fmt.Errorf("text is required")
}
// 检查是否存在重复消息
// 检查是否存在重复消息:相同 bot_id + packet_id 且未删除
var existing llmMessageQueueRecord
if input.BotID > 0 {
// 机器人私聊:相同 bot_id + packet_id 且未删除
err = s.db.Where("bot_id = ? AND packet_id = ? AND deleted_at IS NULL", input.BotID, input.PacketID).
Take(&existing).Error
} else {
// 频道消息:相同 from_node_id + channel_id + packet_id 且未删除
channelID := ""
if input.ChannelID != nil {
channelID = *input.ChannelID
}
err = s.db.Where("bot_id = 0 AND from_node_id = ? AND channel_id = ? AND packet_id = ? AND deleted_at IS NULL", input.FromNodeID, channelID, input.PacketID).
Take(&existing).Error
}
err = s.db.Where("bot_id = ? AND packet_id = ? AND deleted_at IS NULL", input.BotID, input.PacketID).
Take(&existing).Error
if err == nil {
// 重复消息,直接返回已存在的
return &existing, nil
@@ -214,6 +198,7 @@ func llmMessageDTO(row llmMessageQueueRecord) map[string]any {
}
// enqueueChannelMessageToLLM 将频道消息添加到 LLM 队列
// 为每个启用了「包含频道消息」的机器人都创建一条独立的队列记录
func enqueueChannelMessageToLLM(s *store, record map[string]any) error {
if s == nil {
return nil
@@ -261,20 +246,30 @@ func enqueueChannelMessageToLLM(s *store, record map[string]any) error {
contentPtr = &s
}
_, _ = s.EnqueueLLMMessage(LLMMessageQueueInput{
BotID: 0, // 0 表示频道消息
BotNodeID: "",
BotNodeNum: 0,
FromNodeID: fromNodeID,
FromNodeNum: fromNodeNum,
LongName: longName,
ShortName: shortName,
Text: text,
PacketID: packetID,
ChannelID: channelID,
Topic: topic,
ContentJSON: contentPtr,
})
// 查询所有启用了 LLM 队列且包含频道消息的机器人
var bots []botNodeRecord
err = s.db.Where("llm_queue_enabled = ? AND llm_include_channel_messages = ?", true, true).Find(&bots).Error
if err != nil {
return fmt.Errorf("query bots for channel message enqueue: %w", err)
}
// 为每个符合条件的机器人创建一条队列记录
for _, bot := range bots {
_, _ = s.EnqueueLLMMessage(LLMMessageQueueInput{
BotID: bot.ID,
BotNodeID: bot.NodeID,
BotNodeNum: bot.NodeNum,
FromNodeID: fromNodeID,
FromNodeNum: fromNodeNum,
LongName: longName,
ShortName: shortName,
Text: text,
PacketID: packetID,
ChannelID: channelID,
Topic: topic,
ContentJSON: contentPtr,
})
}
return nil
}
+189 -65
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { deleteLLMMessage, deleteLLMMessagesByBot, getAdminRuntimeSettings, getBotNodes, getLLMMessages, updateAdminRuntimeSettings } from '../api'
import type { AdminRuntimeSettings, BotNode, LLMMessage, ListResponse } from '../types'
import { deleteLLMMessage, deleteLLMMessagesByBot, getBotNodes, getLLMMessages, updateBotNode } from '../api'
import type { BotNode, LLMMessage, ListResponse } from '../types'
const loading = ref(false)
const savingSettings = ref(false)
const savingBotId = ref<number | null>(null)
const error = ref('')
const messages = ref<LLMMessage[]>([])
const botNodes = ref<BotNode[]>([])
@@ -13,7 +13,6 @@ const limit = 50
const offset = ref(0)
const selectedBotId = ref<number | ''>('')
const includeDeleted = ref(false)
const settings = ref<AdminRuntimeSettings | null>(null)
const statusColors: Record<string, string> = {
pending: 'background-color: #fff3cd;',
@@ -29,51 +28,13 @@ const statusLabels: Record<string, string> = {
error: '错误',
}
async function loadSettings() {
try {
const response = await getAdminRuntimeSettings()
settings.value = response.item
} catch (err) {
console.error('加载设置失败:', err)
}
}
async function toggleQueueEnabled() {
if (!settings.value || savingSettings.value) return
savingSettings.value = true
try {
await updateAdminRuntimeSettings({
allow_encrypted_forwarding: settings.value.allow_encrypted_forwarding,
llm_queue_enabled: !settings.value.llm_queue_enabled,
llm_include_channel_messages: settings.value.llm_include_channel_messages,
})
settings.value.llm_queue_enabled = !settings.value.llm_queue_enabled
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
savingSettings.value = false
}
}
async function toggleIncludeChannel() {
if (!settings.value || savingSettings.value) return
savingSettings.value = true
try {
await updateAdminRuntimeSettings({
allow_encrypted_forwarding: settings.value.allow_encrypted_forwarding,
llm_queue_enabled: settings.value.llm_queue_enabled,
llm_include_channel_messages: !settings.value.llm_include_channel_messages,
})
settings.value.llm_include_channel_messages = !settings.value.llm_include_channel_messages
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
savingSettings.value = false
}
}
function getMessageType(msg: LLMMessage): string {
return msg.bot_id === 0 ? '频道' : '私聊'
return msg.channel_id ? '频道' : '私聊'
}
function getBotName(botId: number): string {
const bot = botNodes.value.find(b => b.id === botId)
return bot ? bot.long_name : '-'
}
async function loadBotNodes() {
@@ -85,6 +46,52 @@ async function loadBotNodes() {
}
}
async function toggleBotLLMQueue(bot: BotNode) {
savingBotId.value = bot.id
try {
await updateBotNode(bot.id, {
long_name: bot.long_name,
short_name: bot.short_name,
enabled: bot.enabled,
default_channel_id: bot.default_channel_id,
topic_prefix: bot.topic_prefix,
psk: bot.psk,
nodeinfo_broadcast_enabled: bot.nodeinfo_broadcast_enabled,
nodeinfo_broadcast_interval_seconds: bot.nodeinfo_broadcast_interval_seconds,
llm_queue_enabled: !bot.llm_queue_enabled,
llm_include_channel_messages: bot.llm_include_channel_messages,
})
bot.llm_queue_enabled = !bot.llm_queue_enabled
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
savingBotId.value = null
}
}
async function toggleBotIncludeChannel(bot: BotNode) {
savingBotId.value = bot.id
try {
await updateBotNode(bot.id, {
long_name: bot.long_name,
short_name: bot.short_name,
enabled: bot.enabled,
default_channel_id: bot.default_channel_id,
topic_prefix: bot.topic_prefix,
psk: bot.psk,
nodeinfo_broadcast_enabled: bot.nodeinfo_broadcast_enabled,
nodeinfo_broadcast_interval_seconds: bot.nodeinfo_broadcast_interval_seconds,
llm_queue_enabled: bot.llm_queue_enabled,
llm_include_channel_messages: !bot.llm_include_channel_messages,
})
bot.llm_include_channel_messages = !bot.llm_include_channel_messages
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
savingBotId.value = null
}
}
async function loadMessages(resetOffset = false) {
if (resetOffset) {
offset.value = 0
@@ -165,7 +172,6 @@ function goNext() {
}
onMounted(() => {
loadSettings()
loadBotNodes()
loadMessages()
})
@@ -175,21 +181,42 @@ onMounted(() => {
<div class="admin-llm">
<h2>LLM 消息队列</h2>
<div class="admin-llm-section">
<h3>机器人 LLM 设置</h3>
<p class="section-desc">每个机器人可以独立启用或禁用 LLM 消息队列启用后该机器人收到的私聊消息将被加入队列</p>
<div class="bot-settings-grid">
<div v-for="bot in botNodes" :key="bot.id" class="bot-settings-card">
<div class="bot-header">
<strong>{{ bot.long_name }}</strong>
<span class="bot-node-id">{{ bot.node_id }}</span>
</div>
<div class="bot-settings">
<label class="setting-item">
<input
type="checkbox"
:checked="bot.llm_queue_enabled"
@change="toggleBotLLMQueue(bot)"
:disabled="savingBotId === bot.id"
/>
<span>启用 LLM 队列</span>
</label>
<label class="setting-item">
<input
type="checkbox"
:checked="bot.llm_include_channel_messages"
@change="toggleBotIncludeChannel(bot)"
:disabled="savingBotId === bot.id || !bot.llm_queue_enabled"
/>
<span>包含频道消息</span>
</label>
</div>
<div v-if="savingBotId === bot.id" class="saving-indicator">保存中...</div>
</div>
</div>
</div>
<div class="admin-llm-toolbar">
<div class="admin-llm-filter">
<label>
<input type="checkbox" :checked="settings?.llm_queue_enabled ?? false" @change="toggleQueueEnabled" :disabled="savingSettings" />
启用 LLM 消息队列
</label>
</div>
<div class="admin-llm-filter">
<label>
<input type="checkbox" :checked="settings?.llm_include_channel_messages ?? false" @change="toggleIncludeChannel" :disabled="savingSettings || !settings?.llm_queue_enabled" />
包含频道消息
</label>
</div>
<div class="admin-llm-filter">
<label>选择机器人</label>
<select v-model="selectedBotId" @change="loadMessages(true)">
@@ -226,6 +253,7 @@ onMounted(() => {
<thead>
<tr>
<th>ID</th>
<th>机器人</th>
<th>类型</th>
<th>状态</th>
<th>来自节点</th>
@@ -239,6 +267,12 @@ onMounted(() => {
<tbody>
<tr v-for="msg in messages" :key="msg.id" :style="statusColors[msg.status]">
<td>{{ msg.id }}</td>
<td class="bot-name-cell">
<div class="bot-info">
<div class="bot-long-name">{{ getBotName(msg.bot_id) }}</div>
<div class="bot-node-id" v-if="msg.bot_id !== 0">{{ msg.bot_node_id }}</div>
</div>
</td>
<td>
<span class="type-badge" :class="getMessageType(msg) === '频道' ? 'channel' : 'direct'">
{{ getMessageType(msg) }}
@@ -255,7 +289,7 @@ onMounted(() => {
<div class="node-id">{{ msg.from_node_id }}</div>
</div>
</td>
<td class="channel-cell">{{ msg.channel_id || (msg.bot_id === 0 ? '默认频道' : '-') }}</td>
<td class="channel-cell">{{ msg.channel_id || '-' }}</td>
<td class="message-text">{{ msg.text }}</td>
<td>{{ formatTime(msg.received_at) }}</td>
<td>{{ formatTime(msg.processed_at) }}</td>
@@ -266,7 +300,7 @@ onMounted(() => {
</td>
</tr>
<tr v-if="messages.length === 0">
<td colspan="9" class="empty-state">暂无消息</td>
<td colspan="10" class="empty-state">暂无消息</td>
</tr>
</tbody>
</table>
@@ -290,6 +324,76 @@ onMounted(() => {
font-size: 1.5rem;
}
.admin-llm-section {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.admin-llm-section h3 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.section-desc {
margin: 0 0 1rem;
color: #666;
font-size: 0.9rem;
}
.bot-settings-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.bot-settings-card {
background: white;
padding: 1rem;
border-radius: 6px;
border: 1px solid #e9ecef;
}
.bot-header {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #eee;
}
.bot-node-id {
font-size: 0.8rem;
color: #666;
font-family: monospace;
}
.bot-settings {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.setting-item {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
}
.setting-item input {
cursor: pointer;
}
.saving-indicator {
margin-top: 0.5rem;
font-size: 0.8rem;
color: #0d6efd;
}
.admin-llm-toolbar {
display: flex;
gap: 1rem;
@@ -359,6 +463,26 @@ onMounted(() => {
white-space: nowrap;
}
.bot-name-cell {
min-width: 150px;
}
.bot-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.bot-long-name {
font-weight: 500;
}
.bot-node-id {
font-size: 0.8rem;
color: #666;
font-family: monospace;
}
.admin-llm-table {
width: 100%;
border-collapse: collapse;
+4
View File
@@ -530,6 +530,8 @@ export interface BotNode {
nodeinfo_broadcast_enabled: boolean
nodeinfo_broadcast_interval_seconds: number
last_nodeinfo_broadcast_at: string | null
llm_queue_enabled: boolean
llm_include_channel_messages: boolean
created_at: string
updated_at: string
}
@@ -544,6 +546,8 @@ export interface BotNodePayload {
psk?: string
nodeinfo_broadcast_enabled?: boolean
nodeinfo_broadcast_interval_seconds?: number
llm_queue_enabled?: boolean
llm_include_channel_messages?: boolean
}
export interface BotNodeMutationResponse {