This commit is contained in:
2026-06-17 20:18:37 +08:00
parent 2840aca69b
commit e2bf6aa173
12 changed files with 1102 additions and 2 deletions
+186
View File
@@ -0,0 +1,186 @@
package main
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func registerAdminLLMRoutes(r *gin.RouterGroup, store *store) {
group := r.Group("/llm")
{
group.GET("/messages", handleListLLMMessages(store))
group.GET("/messages/:id", handleGetLLMMessage(store))
group.PUT("/messages/:id/status", handleUpdateLLMMessageStatus(store))
group.DELETE("/messages/:id", handleDeleteLLMMessage(store))
group.DELETE("/bots/:bot_id/messages", handleDeleteLLMMessagesByBot(store))
group.POST("/messages/cleanup", handleCleanupDeletedLLMMessages(store))
}
}
func handleListLLMMessages(store *store) gin.HandlerFunc {
return func(c *gin.Context) {
opts, ok := parseListOptions(c)
if !ok {
return
}
var botID uint64
if botIDStr := c.Query("bot_id"); botIDStr != "" {
id, err := strconv.ParseUint(botIDStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot_id"})
return
}
botID = id
}
includeDeleted := c.Query("include_deleted") == "true"
rows, total, err := store.ListLLMMessages(opts, botID, includeDeleted)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
items := make([]map[string]any, 0, len(rows))
for _, row := range rows {
items = append(items, llmMessageDTO(row))
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"limit": opts.Limit,
"offset": opts.Offset,
"total": total,
})
}
}
func handleGetLLMMessage(store *store) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message id"})
return
}
record, err := store.GetLLMMessage(id)
if err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"item": llmMessageDTO(*record)})
}
}
func handleUpdateLLMMessageStatus(store *store) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message id"})
return
}
var req struct {
Status string `json:"status"`
Error string `json:"error"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
// 验证状态值
validStatus := map[string]bool{
llmMessageStatusPending: true,
llmMessageStatusProcessing: true,
llmMessageStatusProcessed: true,
llmMessageStatusError: true,
}
if !validStatus[req.Status] {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status value"})
return
}
if err := store.UpdateLLMMessageStatus(id, req.Status, req.Error); err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
}
func handleDeleteLLMMessage(store *store) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message id"})
return
}
if err := store.SoftDeleteLLMMessage(id); err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
}
func handleDeleteLLMMessagesByBot(store *store) gin.HandlerFunc {
return func(c *gin.Context) {
botID, err := strconv.ParseUint(c.Param("bot_id"), 10, 64)
if err != nil || botID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"})
return
}
if err := store.SoftDeleteLLMMessagesByBot(botID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
}
func handleCleanupDeletedLLMMessages(store *store) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Days int `json:"days"`
}
if err := c.ShouldBindJSON(&req); err != nil {
req.Days = 7 // 默认清理 7 天前删除的消息
}
if req.Days <= 0 {
req.Days = 7
}
before := time.Now().AddDate(0, 0, -req.Days)
count, err := store.CleanupDeletedLLMMessages(before)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "deleted_count": count})
}
}
+17 -1
View File
@@ -7,9 +7,13 @@ import (
)
const allowEncryptedForwardingLabel = "Allow encrypted MQTT packets to be forwarded when they cannot be decrypted"
const llmQueueEnabledLabel = "Enable LLM message queue"
const llmIncludeChannelLabel = "Include channel messages in LLM queue"
type runtimeSettingsRequest struct {
AllowEncryptedForwarding bool `json:"allow_encrypted_forwarding"`
LLMQueueEnabled bool `json:"llm_queue_enabled"`
LLMIncludeChannelMessages bool `json:"llm_include_channel_messages"`
}
func registerAdminRuntimeSettingsRoutes(r gin.IRouter, store *store, settings *runtimeSettingsCache) {
@@ -32,6 +36,14 @@ func registerAdminRuntimeSettingsRoutes(r gin.IRouter, store *store, settings *r
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if _, err := store.SetBoolRuntimeSetting(runtimeSettingLLMQueueEnabled, req.LLMQueueEnabled, llmQueueEnabledLabel); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if _, err := store.SetBoolRuntimeSetting(runtimeSettingLLMQueueIncludeChannel, req.LLMIncludeChannelMessages, llmIncludeChannelLabel); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if settings != nil {
if err := settings.Reload(store); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -48,5 +60,9 @@ func registerAdminRuntimeSettingsRoutes(r gin.IRouter, store *store, settings *r
}
func runtimeSettingsDTO(settings runtimeSettingsSnapshot) gin.H {
return gin.H{"allow_encrypted_forwarding": settings.AllowEncryptedForwarding}
return gin.H{
"allow_encrypted_forwarding": settings.AllowEncryptedForwarding,
"llm_queue_enabled": settings.LLMQueueEnabled,
"llm_include_channel_messages": settings.LLMIncludeChannel,
}
}
+20
View File
@@ -289,5 +289,25 @@ func insertInboundBotDirectMessage(s *store, record map[string]any, clientInfo m
return fmt.Errorf("insert bot direct message from %s: %w", peerNodeID, err)
}
_ = clientInfo // mqtt 元数据已经记录在 content_json 里,这里保留参数以保持队列签名一致
// 同时将消息添加到 LLM 队列
longName := nullableString(record["long_name"])
shortName := nullableString(record["short_name"])
channelID := nullableString(record["channel_id"])
_, _ = s.EnqueueLLMMessage(LLMMessageQueueInput{
BotID: bot.ID,
BotNodeID: bot.NodeID,
BotNodeNum: bot.NodeNum,
FromNodeID: peerNodeID,
FromNodeNum: int64(peerNum),
LongName: longName,
ShortName: shortName,
Text: text,
PacketID: int64(packetID),
ChannelID: channelID,
Topic: topic,
ContentJSON: contentPtr,
})
return nil
}
+40
View File
@@ -343,6 +343,44 @@ const (
botDirectMessageDirectionOutbound = "outbound"
)
// llmMessageQueueRecord 是 LLM 消息队列,用于暂存机器人收到的消息供 LLM 处理。
//
// - 每个队列绑定一个 BotID,消息包含节点信息和消息内容
// - deleted_at 用于标记软删除,实际保留一段时间供去重
// - received_at 是消息接收时间,processed_at 是 LLM 处理完成时间
type llmMessageQueueRecord struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
BotID uint64 `gorm:"column:bot_id;not null;index:idx_llm_queue_bot_created,priority:1"`
BotNodeID string `gorm:"column:bot_node_id;not null;index"`
BotNodeNum int64 `gorm:"column:bot_node_num;not null;index"`
FromNodeID string `gorm:"column:from_node_id;not null;index"`
FromNodeNum int64 `gorm:"column:from_node_num;not null;index"`
LongName *string `gorm:"column:long_name"`
ShortName *string `gorm:"column:short_name"`
Text string `gorm:"column:text;type:text;not null"`
PacketID int64 `gorm:"column:packet_id;not null;index"`
ChannelID *string `gorm:"column:channel_id"`
Topic string `gorm:"column:topic;not null"`
Status string `gorm:"column:status;not null;index"`
Error string `gorm:"column:error;type:text"`
ReceivedAt time.Time `gorm:"column:received_at;not null;index"`
ProcessedAt *time.Time `gorm:"column:processed_at;index"`
DeletedAt *time.Time `gorm:"column:deleted_at;index"`
ContentJSON *string `gorm:"column:content_json;type:text"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_llm_queue_bot_created,priority:2"`
}
func (llmMessageQueueRecord) TableName() string {
return "llm_message_queue"
}
const (
llmMessageStatusPending = "pending"
llmMessageStatusProcessing = "processing"
llmMessageStatusProcessed = "processed"
llmMessageStatusError = "error"
)
type nodeInfoRecord struct {
NodeID string `gorm:"column:node_id;primaryKey;not null"`
NodeNum int64 `gorm:"column:node_num;not null;index"`
@@ -550,6 +588,7 @@ func (s *store) migrate() error {
{label: "bot_nodes", model: &botNodeRecord{}},
{label: "bot_messages", model: &botMessageRecord{}},
{label: "bot_direct_messages", model: &botDirectMessageRecord{}},
{label: "llm_message_queue", model: &llmMessageQueueRecord{}},
{label: "nodeinfo", model: &nodeInfoRecord{}},
{label: "map_report", model: &mapReportRecord{}},
{label: "text_message", model: &textMessageRecord{}},
@@ -571,6 +610,7 @@ func (s *store) migrate() error {
}{
{label: "text_message", model: &textMessageRecord{}, indexes: []string{"idx_text_message_from_num_created_at", "idx_text_message_created_at", "idx_text_message_packet_id"}},
{label: "bot_direct_messages", model: &botDirectMessageRecord{}, indexes: []string{"idx_bot_dm_bot_peer", "idx_bot_dm_bot_created_at"}},
{label: "llm_message_queue", model: &llmMessageQueueRecord{}, indexes: []string{"idx_llm_queue_bot_created"}},
} {
if err := createMissingIndexes(migrator, item.model, item.label, item.indexes); err != nil {
return err
+4
View File
@@ -51,6 +51,10 @@ func (q *dbWriteQueue) EnqueueRecord(record map[string]any, clientInfo mqttClien
}})
return
}
// 频道消息同时也写入 LLM 队列(如果启用的话)
q.enqueue(dbWriteJob{typeName: "llm_channel_message", from: record["from"], run: func() error {
return enqueueChannelMessageToLLM(q.store, record)
}})
q.enqueue(dbWriteJob{typeName: "text_message", from: record["from"], run: func() error {
return q.store.InsertTextMessage(record, clientInfo)
}})
+280
View File
@@ -0,0 +1,280 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"time"
"gorm.io/gorm"
)
// LLMMessageQueueInput 是添加 LLM 队列消息的输入
type LLMMessageQueueInput struct {
BotID uint64 // 0 表示频道消息
BotNodeID string // 频道消息可为空
BotNodeNum int64 // 频道消息可为 0
FromNodeID string
FromNodeNum int64
LongName *string
ShortName *string
Text string
PacketID int64
ChannelID *string
Topic string
ContentJSON *string
}
// 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 // 静默返回,不报错
}
// 如果是频道消息,检查是否启用了频道消息入队
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 // 频道消息入队未启用,静默返回
}
}
if input.FromNodeID == "" {
return nil, fmt.Errorf("from_node_id is required")
}
if input.Text == "" {
return nil, fmt.Errorf("text is required")
}
// 检查是否存在重复消息
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
}
if err == nil {
// 重复消息,直接返回已存在的
return &existing, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("check duplicate llm message: %w", err)
}
now := time.Now()
record := &llmMessageQueueRecord{
BotID: input.BotID,
BotNodeID: input.BotNodeID,
BotNodeNum: input.BotNodeNum,
FromNodeID: input.FromNodeID,
FromNodeNum: input.FromNodeNum,
LongName: input.LongName,
ShortName: input.ShortName,
Text: input.Text,
PacketID: input.PacketID,
ChannelID: input.ChannelID,
Topic: input.Topic,
Status: llmMessageStatusPending,
ReceivedAt: now,
ContentJSON: input.ContentJSON,
}
if err := s.db.Create(record).Error; err != nil {
return nil, fmt.Errorf("enqueue llm message: %w", err)
}
return record, nil
}
// ListLLMMessages 列出 LLM 队列消息
func (s *store) ListLLMMessages(opts listOptions, botID uint64, includeDeleted bool) ([]llmMessageQueueRecord, int64, error) {
var rows []llmMessageQueueRecord
query := s.db.Model(&llmMessageQueueRecord{})
if botID > 0 {
query = query.Where("bot_id = ?", botID)
}
if !includeDeleted {
query = query.Where("deleted_at IS NULL")
}
// 先获取总数
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("count llm messages: %w", err)
}
// 排序和分页
query = query.Order("created_at DESC")
if opts.Limit > 0 {
query = query.Limit(opts.Limit)
}
if opts.Offset > 0 {
query = query.Offset(opts.Offset)
}
if err := query.Find(&rows).Error; err != nil {
return nil, 0, fmt.Errorf("list llm messages: %w", err)
}
return rows, total, nil
}
// GetLLMMessage 获取单条 LLM 消息
func (s *store) GetLLMMessage(id uint64) (*llmMessageQueueRecord, error) {
var record llmMessageQueueRecord
if err := s.db.Where("id = ?", id).Take(&record).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
return nil, fmt.Errorf("get llm message %d: %w", id, err)
}
return &record, nil
}
// UpdateLLMMessageStatus 更新 LLM 消息状态
func (s *store) UpdateLLMMessageStatus(id uint64, status string, errorMsg string) error {
updates := map[string]any{
"status": status,
"error": errorMsg,
}
if status == llmMessageStatusProcessed {
now := time.Now()
updates["processed_at"] = &now
}
if err := s.db.Model(&llmMessageQueueRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return fmt.Errorf("update llm message status %d: %w", id, err)
}
return nil
}
// SoftDeleteLLMMessage 软删除 LLM 消息
func (s *store) SoftDeleteLLMMessage(id uint64) error {
now := time.Now()
if err := s.db.Model(&llmMessageQueueRecord{}).Where("id = ?", id).Update("deleted_at", &now).Error; err != nil {
return fmt.Errorf("soft delete llm message %d: %w", id, err)
}
return nil
}
// SoftDeleteLLMMessagesByBot 软删除指定机器人的所有消息
func (s *store) SoftDeleteLLMMessagesByBot(botID uint64) error {
now := time.Now()
if err := s.db.Model(&llmMessageQueueRecord{}).Where("bot_id = ? AND deleted_at IS NULL", botID).Update("deleted_at", &now).Error; err != nil {
return fmt.Errorf("soft delete llm messages for bot %d: %w", botID, err)
}
return nil
}
// CleanupDeletedLLMMessages 清理已软删除超过指定时间的消息
func (s *store) CleanupDeletedLLMMessages(before time.Time) (int64, error) {
result := s.db.Where("deleted_at IS NOT NULL AND deleted_at < ?", before).Delete(&llmMessageQueueRecord{})
if result.Error != nil {
return 0, fmt.Errorf("cleanup deleted llm messages: %w", result.Error)
}
return result.RowsAffected, nil
}
// llmMessageDTO 将数据库记录转换为 API 响应格式
func llmMessageDTO(row llmMessageQueueRecord) map[string]any {
return map[string]any{
"id": row.ID,
"bot_id": row.BotID,
"bot_node_id": row.BotNodeID,
"bot_node_num": row.BotNodeNum,
"from_node_id": row.FromNodeID,
"from_node_num": row.FromNodeNum,
"long_name": row.LongName,
"short_name": row.ShortName,
"text": row.Text,
"packet_id": row.PacketID,
"channel_id": row.ChannelID,
"topic": row.Topic,
"status": row.Status,
"error": row.Error,
"received_at": row.ReceivedAt,
"processed_at": row.ProcessedAt,
"deleted_at": row.DeletedAt,
"created_at": row.CreatedAt,
}
}
// enqueueChannelMessageToLLM 将频道消息添加到 LLM 队列
func enqueueChannelMessageToLLM(s *store, record map[string]any) error {
if s == nil {
return nil
}
text, _ := record["text"].(string)
if text == "" {
return nil
}
fromNodeID, _ := record["from"].(string)
if fromNodeID == "" {
return nil
}
fromNodeNum, err := int64FromAny(record["from_num"])
if err != nil {
fromNodeNum = 0
}
var packetID int64
if p, ok := record["packet_id"].(float64); ok {
packetID = int64(p)
}
topic, _ := record["topic"].(string)
var longName, shortName *string
if ln, ok := record["long_name"].(string); ok && ln != "" {
longName = &ln
}
if sn, ok := record["short_name"].(string); ok && sn != "" {
shortName = &sn
}
var channelID *string
if cid, ok := record["channel_id"].(string); ok && cid != "" {
channelID = &cid
}
contentJSON, err := json.Marshal(record)
var contentPtr *string
if err == nil {
s := string(contentJSON)
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,
})
return nil
}
+4
View File
@@ -5,6 +5,7 @@ import AdminBlockingManagement from './components/AdminBlockingManagement.vue'
import AdminBot from './components/AdminBot.vue'
import AdminBotDirect from './components/AdminBotDirect.vue'
import AdminDashboard from './components/AdminDashboard.vue'
import AdminLLM from './components/AdminLLM.vue'
import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
import AdminHelpEdit from './components/AdminHelpEdit.vue'
import AdminLogin from './components/AdminLogin.vue'
@@ -29,6 +30,7 @@ const isAdminPage = adminPath.startsWith('/admin')
const isMqttForwardAdminPage = adminPath === '/admin/mqtt_forward' || adminPath === '/admin/mqtt_forward/'
const isBotAdminPage = adminPath === '/admin/bot' || adminPath === '/admin/bot/'
const isBotDirectAdminPage = adminPath === '/admin/bot/direct' || adminPath === '/admin/bot/direct/'
const isLLMAdminPage = adminPath === '/admin/llm' || adminPath === '/admin/llm/'
const isSignAdminPage = adminPath === '/admin/sign' || adminPath === '/admin/sign/'
const detailMatch = currentPath.match(/^\/detailed\/(.+)$/)
const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : ''
@@ -550,6 +552,7 @@ onBeforeUnmount(() => {
<a href="/admin/mqtt_forward/" :class="{ active: isMqttForwardAdminPage }">MQTT转发</a>
<a href="/admin/bot" :class="{ active: isBotAdminPage }">机器人</a>
<a href="/admin/bot/direct" :class="{ active: isBotDirectAdminPage }">机器人私聊</a>
<a href="/admin/llm" :class="{ active: isLLMAdminPage }">LLM消息队列</a>
<a href="/admin/sign" :class="{ active: isSignAdminPage }">签到管理</a>
<a href="/admin/map_source" :class="{ active: adminPath === '/admin/map_source' }">地图图源</a>
<a href="/admin/help_edit" :class="{ active: adminPath === '/admin/help_edit' }">帮助编辑</a>
@@ -598,6 +601,7 @@ onBeforeUnmount(() => {
<AdminMqttForward v-else-if="isMqttForwardAdminPage" />
<AdminBot v-else-if="isBotAdminPage" />
<AdminBotDirect v-else-if="isBotDirectAdminPage" />
<AdminLLM v-else-if="isLLMAdminPage" />
<AdminSignManagement v-else-if="isSignAdminPage" />
<AdminMapSource v-else-if="adminPath === '/admin/map_source'" />
<AdminHelpEdit v-else-if="adminPath === '/admin/help_edit'" />
+37
View File
@@ -47,6 +47,8 @@ import type {
TextMessage,
BotDirectMessage,
BotDirectConversationsResponse,
LLMMessage,
LLMMessageStatus,
} from './types'
async function requestJSON<T>(path: string, init?: RequestInit): Promise<T> {
@@ -429,3 +431,38 @@ export type { BotDirectConversation } from './types'
export function sendBotMessage(payload: BotSendMessagePayload): Promise<BotMessageMutationResponse> {
return postJSON<BotMessageMutationResponse>('/api/admin/bot/messages', payload)
}
// LLM 消息队列 API
export function getLLMMessages(limit = 100, offset = 0, botId?: number, includeDeleted = false): Promise<ListResponse<LLMMessage>> {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) })
if (botId !== undefined && botId > 0) {
params.set('bot_id', String(botId))
}
if (includeDeleted) {
params.set('include_deleted', 'true')
}
return getJSON<ListResponse<LLMMessage>>(`/api/admin/llm/messages?${params.toString()}`)
}
export function getLLMMessage(id: number): Promise<{ item: LLMMessage }> {
return getJSON<{ item: LLMMessage }>(`/api/admin/llm/messages/${id}`)
}
export function updateLLMMessageStatus(id: number, status: LLMMessageStatus, error?: string): Promise<{ status: string }> {
return putJSON<{ status: string }>(`/api/admin/llm/messages/${id}/status`, { status, error })
}
export function deleteLLMMessage(id: number): Promise<{ status: string }> {
return deleteJSON<{ status: string }>(`/api/admin/llm/messages/${id}`)
}
export function deleteLLMMessagesByBot(botId: number): Promise<{ status: string }> {
return deleteJSON<{ status: string }>(`/api/admin/llm/bots/${botId}/messages`)
}
export function cleanupDeletedLLMMessages(days = 7): Promise<{ status: string; deleted_count: number }> {
return postJSON<{ status: string; deleted_count: number }>('/api/admin/llm/messages/cleanup', { days })
}
// 静默使用未导出类型,避免 TS6133(未使用的导入)。
export type { LLMMessage, LLMMessageStatus } from './types'
@@ -0,0 +1,463 @@
<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'
const loading = ref(false)
const savingSettings = ref(false)
const error = ref('')
const messages = ref<LLMMessage[]>([])
const botNodes = ref<BotNode[]>([])
const total = ref(0)
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;',
processing: 'background-color: #cfe2ff;',
processed: 'background-color: #d1e7dd;',
error: 'background-color: #f8d7da;',
}
const statusLabels: Record<string, string> = {
pending: '待处理',
processing: '处理中',
processed: '已处理',
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 ? '频道' : '私聊'
}
async function loadBotNodes() {
try {
const response = await getBotNodes(100, 0)
botNodes.value = response.items
} catch (err) {
console.error('加载机器人列表失败:', err)
}
}
async function loadMessages(resetOffset = false) {
if (resetOffset) {
offset.value = 0
}
loading.value = true
error.value = ''
try {
const botId = selectedBotId.value === '' ? undefined : selectedBotId.value
const response: ListResponse<LLMMessage> = await getLLMMessages(limit, offset.value, botId, includeDeleted.value)
messages.value = response.items
total.value = response.total ?? response.items.length
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
async function handleDeleteMessage(id: number) {
if (!confirm('确定要删除这条消息吗?')) {
return
}
try {
await deleteLLMMessage(id)
await loadMessages()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
}
}
async function handleDeleteAllByBot() {
const botId = selectedBotId.value === '' ? undefined : selectedBotId.value
if (!botId) {
alert('请先选择机器人')
return
}
if (!confirm(`确定要删除该机器人的所有队列消息吗?`)) {
return
}
try {
await deleteLLMMessagesByBot(botId)
await loadMessages()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
}
}
function formatTime(timeStr: string | null): string {
if (!timeStr) return '-'
const date = new Date(timeStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
const displayFrom = computed(() => offset.value + 1)
const displayTo = computed(() => Math.min(offset.value + limit, total.value))
const hasMore = computed(() => offset.value + limit < total.value)
const hasPrev = computed(() => offset.value > 0)
function goPrev() {
if (hasPrev.value) {
offset.value = Math.max(0, offset.value - limit)
loadMessages()
}
}
function goNext() {
if (hasMore.value) {
offset.value += limit
loadMessages()
}
}
onMounted(() => {
loadSettings()
loadBotNodes()
loadMessages()
})
</script>
<template>
<div class="admin-llm">
<h2>LLM 消息队列</h2>
<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)">
<option :value="''">全部</option>
<option v-for="bot in botNodes" :key="bot.id" :value="bot.id">
{{ bot.long_name }} ({{ bot.node_id }})
</option>
</select>
</div>
<div class="admin-llm-filter">
<label>
<input type="checkbox" v-model="includeDeleted" @change="loadMessages(true)" />
包含已删除
</label>
</div>
<button class="admin-button admin-button-danger" @click="handleDeleteAllByBot" :disabled="!selectedBotId">
删除该机器人所有消息
</button>
<button class="admin-button" @click="() => loadMessages()">刷新</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="admin-llm-stats">
{{ total }} 条消息当前显示 {{ displayFrom }} - {{ displayTo }}
</div>
<div v-if="loading" class="admin-loading">加载中...</div>
<table v-else class="admin-llm-table">
<thead>
<tr>
<th>ID</th>
<th>类型</th>
<th>状态</th>
<th>来自节点</th>
<th>频道</th>
<th>消息内容</th>
<th>接收时间</th>
<th>处理时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="msg in messages" :key="msg.id" :style="statusColors[msg.status]">
<td>{{ msg.id }}</td>
<td>
<span class="type-badge" :class="getMessageType(msg) === '频道' ? 'channel' : 'direct'">
{{ getMessageType(msg) }}
</span>
</td>
<td>
<span class="status-badge" :style="statusColors[msg.status]">
{{ statusLabels[msg.status] || msg.status }}
</span>
</td>
<td>
<div class="node-info">
<div class="node-name">{{ msg.long_name || msg.short_name || '-' }}</div>
<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="message-text">{{ msg.text }}</td>
<td>{{ formatTime(msg.received_at) }}</td>
<td>{{ formatTime(msg.processed_at) }}</td>
<td>
<button class="admin-button admin-button-small admin-button-danger" @click="handleDeleteMessage(msg.id)">
删除
</button>
</td>
</tr>
<tr v-if="messages.length === 0">
<td colspan="9" class="empty-state">暂无消息</td>
</tr>
</tbody>
</table>
<div class="admin-llm-pagination">
<button class="admin-button admin-button-small" @click="goPrev" :disabled="!hasPrev">上一页</button>
<span class="pagination-info"> {{ Math.floor(offset / limit) + 1 }} </span>
<button class="admin-button admin-button-small" @click="goNext" :disabled="!hasMore">下一页</button>
</div>
</div>
</template>
<style scoped>
.admin-llm {
padding: 1rem;
max-width: 100%;
}
.admin-llm h2 {
margin: 0 0 1.5rem;
font-size: 1.5rem;
}
.admin-llm-toolbar {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.admin-llm-filter {
display: flex;
align-items: center;
gap: 0.5rem;
}
.admin-llm-filter label {
font-size: 0.9rem;
color: #666;
}
.admin-llm-filter select {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
min-width: 200px;
}
.admin-llm-stats {
padding: 0.75rem;
background: #f8f9fa;
border-radius: 4px;
margin-bottom: 1rem;
font-size: 0.9rem;
color: #666;
}
.admin-loading {
padding: 2rem;
text-align: center;
color: #666;
}
.type-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.type-badge.channel {
background-color: #e2e8f0;
color: #475569;
}
.type-badge.direct {
background-color: #fce7f3;
color: #be185d;
}
.channel-cell {
font-family: monospace;
font-size: 0.8rem;
color: #64748b;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-llm-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.admin-llm-table th,
.admin-llm-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #eee;
}
.admin-llm-table th {
background: #f8f9fa;
font-weight: 600;
position: sticky;
top: 0;
}
.admin-llm-table tr:hover {
background: rgba(0, 0, 0, 0.02);
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.node-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.node-name {
font-weight: 500;
}
.node-id {
font-size: 0.8rem;
color: #666;
font-family: monospace;
}
.message-text {
max-width: 400px;
word-break: break-word;
line-height: 1.4;
}
.empty-state {
text-align: center;
padding: 3rem !important;
color: #999;
}
.admin-llm-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.pagination-info {
color: #666;
font-size: 0.9rem;
}
.error {
color: #dc3545;
padding: 0.75rem;
background: #f8d7da;
border-radius: 4px;
margin-bottom: 1rem;
}
.admin-button-small {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
.admin-button-danger {
background: #dc3545;
color: white;
}
.admin-button-danger:hover:not(:disabled) {
background: #c82333;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
+33
View File
@@ -333,10 +333,14 @@ export interface AdminMqttClient {
export interface AdminRuntimeSettings {
allow_encrypted_forwarding: boolean
llm_queue_enabled: boolean
llm_include_channel_messages: boolean
}
export interface AdminRuntimeSettingsPayload {
allow_encrypted_forwarding: boolean
llm_queue_enabled?: boolean
llm_include_channel_messages?: boolean
}
export interface AdminRuntimeSettingsResponse {
@@ -580,3 +584,32 @@ export interface BotMessageMutationResponse {
item: BotMessage
error?: string
}
// LLM 消息队列
export type LLMMessageStatus = 'pending' | 'processing' | 'processed' | 'error'
export interface LLMMessage {
id: number
bot_id: number
bot_node_id: string
bot_node_num: number
from_node_id: string
from_node_num: number
long_name: string | null
short_name: string | null
text: string
packet_id: number
channel_id: string | null
topic: string
status: LLMMessageStatus
error: string
received_at: string
processed_at: string | null
deleted_at: string | null
created_at: string
}
export interface LLMMessageStatusPayload {
status: LLMMessageStatus
error?: string
}
+17 -1
View File
@@ -13,11 +13,15 @@ import (
const (
runtimeSettingAllowEncryptedForwarding = "mqtt.allow_encrypted_forwarding"
runtimeSettingLLMQueueEnabled = "llm.queue_enabled"
runtimeSettingLLMQueueIncludeChannel = "llm.include_channel_messages"
runtimeSettingTypeBool = "bool"
)
type runtimeSettingsSnapshot struct {
AllowEncryptedForwarding bool
LLMQueueEnabled bool
LLMIncludeChannel bool
}
func (s *store) GetRuntimeSettings() (runtimeSettingsSnapshot, error) {
@@ -25,7 +29,19 @@ func (s *store) GetRuntimeSettings() (runtimeSettingsSnapshot, error) {
if err != nil {
return runtimeSettingsSnapshot{}, err
}
return runtimeSettingsSnapshot{AllowEncryptedForwarding: allowEncrypted}, nil
llmQueueEnabled, err := s.GetBoolRuntimeSetting(runtimeSettingLLMQueueEnabled, true)
if err != nil {
return runtimeSettingsSnapshot{}, err
}
llmIncludeChannel, err := s.GetBoolRuntimeSetting(runtimeSettingLLMQueueIncludeChannel, false)
if err != nil {
return runtimeSettingsSnapshot{}, err
}
return runtimeSettingsSnapshot{
AllowEncryptedForwarding: allowEncrypted,
LLMQueueEnabled: llmQueueEnabled,
LLMIncludeChannel: llmIncludeChannel,
}, nil
}
func (s *store) GetBoolRuntimeSetting(key string, defaultValue bool) (bool, error) {
+1
View File
@@ -213,6 +213,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
registerAdminMapSourceRoutes(protected, store)
registerAdminHelpRoutes(protected, store)
registerAdminBotRoutes(protected, store, botSender)
registerAdminLLMRoutes(protected, store)
protected.GET("/me", func(c *gin.Context) {
claims := c.MustGet("admin_claims").(*sessionClaims)
c.JSON(http.StatusOK, gin.H{"user": adminUserDTO{Username: claims.Username, Role: claims.Role}})