diff --git a/admin_llm_routes.go b/admin_llm_routes.go new file mode 100644 index 0000000..db476bd --- /dev/null +++ b/admin_llm_routes.go @@ -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}) + } +} diff --git a/admin_runtime_settings_routes.go b/admin_runtime_settings_routes.go index efa1c47..046e06c 100644 --- a/admin_runtime_settings_routes.go +++ b/admin_runtime_settings_routes.go @@ -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, + } } diff --git a/bot_direct_message_store.go b/bot_direct_message_store.go index 05c6ccc..1a7f3d8 100644 --- a/bot_direct_message_store.go +++ b/bot_direct_message_store.go @@ -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 } diff --git a/db.go b/db.go index 2f5059c..db02c75 100644 --- a/db.go +++ b/db.go @@ -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 diff --git a/db_write_queue.go b/db_write_queue.go index 074cc72..fc5be49 100644 --- a/db_write_queue.go +++ b/db_write_queue.go @@ -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) }}) diff --git a/llm_store.go b/llm_store.go new file mode 100644 index 0000000..464fe56 --- /dev/null +++ b/llm_store.go @@ -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 +} diff --git a/meshmap_frontend/src/App.vue b/meshmap_frontend/src/App.vue index 0f9d945..bd89e24 100644 --- a/meshmap_frontend/src/App.vue +++ b/meshmap_frontend/src/App.vue @@ -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(() => { MQTT转发 机器人 机器人私聊 + LLM消息队列 签到管理 地图图源 帮助编辑 @@ -598,6 +601,7 @@ onBeforeUnmount(() => { + diff --git a/meshmap_frontend/src/api.ts b/meshmap_frontend/src/api.ts index 3f987a6..aa2dfde 100644 --- a/meshmap_frontend/src/api.ts +++ b/meshmap_frontend/src/api.ts @@ -47,6 +47,8 @@ import type { TextMessage, BotDirectMessage, BotDirectConversationsResponse, + LLMMessage, + LLMMessageStatus, } from './types' async function requestJSON(path: string, init?: RequestInit): Promise { @@ -429,3 +431,38 @@ export type { BotDirectConversation } from './types' export function sendBotMessage(payload: BotSendMessagePayload): Promise { return postJSON('/api/admin/bot/messages', payload) } + +// LLM 消息队列 API +export function getLLMMessages(limit = 100, offset = 0, botId?: number, includeDeleted = false): Promise> { + 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>(`/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' diff --git a/meshmap_frontend/src/components/AdminLLM.vue b/meshmap_frontend/src/components/AdminLLM.vue new file mode 100644 index 0000000..803d611 --- /dev/null +++ b/meshmap_frontend/src/components/AdminLLM.vue @@ -0,0 +1,463 @@ + + + + + diff --git a/meshmap_frontend/src/types.ts b/meshmap_frontend/src/types.ts index f561d66..ac9ba94 100644 --- a/meshmap_frontend/src/types.ts +++ b/meshmap_frontend/src/types.ts @@ -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 +} diff --git a/runtime_settings_store.go b/runtime_settings_store.go index 2004253..eb25703 100644 --- a/runtime_settings_store.go +++ b/runtime_settings_store.go @@ -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) { diff --git a/web.go b/web.go index acd1ba0..113fbc7 100644 --- a/web.go +++ b/web.go @@ -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}})