diff --git a/admin_llm_routes.go b/admin_llm_routes.go index 0bcee7f..a86a7e7 100644 --- a/admin_llm_routes.go +++ b/admin_llm_routes.go @@ -31,6 +31,10 @@ func registerAdminLLMRoutes(r *gin.RouterGroup, store *store) { // LLM Tool Router group.GET("/tool-router", handleGetLLMToolRouter(store)) group.PUT("/tool-router", handleUpdateLLMToolRouter(store)) + + // LLM Primary Config - 主 AI 回复配置 + group.GET("/primary-config", handleGetLLMPrimaryConfig(store)) + group.PUT("/primary-config", handleUpdateLLMPrimaryConfig(store)) } } @@ -495,3 +499,130 @@ func llmToolRouterDTO(row llmToolRouterRecord) map[string]any { "updated_at": row.UpdatedAt, } } + +// ============================================ +// LLM Primary Config Handlers +// ============================================ + +func handleGetLLMPrimaryConfig(store *store) gin.HandlerFunc { + return func(c *gin.Context) { + record, err := store.GetLLMPrimaryConfig() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "primary config not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"item": llmPrimaryConfigDTO(*record)}) + } +} + +func handleUpdateLLMPrimaryConfig(store *store) gin.HandlerFunc { + return func(c *gin.Context) { + record, err := store.GetLLMPrimaryConfig() + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var req struct { + Enabled *bool `json:"enabled"` + ProviderName *string `json:"provider_name"` + Timeout *int `json:"timeout"` + MaxTokens *int `json:"max_tokens"` + SystemPrompt *string `json:"system_prompt"` + EnableTool *bool `json:"enable_tool"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + updates := make(map[string]any) + if req.Enabled != nil { + updates["enabled"] = *req.Enabled + } + if req.ProviderName != nil { + updates["provider_name"] = *req.ProviderName + } + if req.Timeout != nil { + updates["timeout"] = *req.Timeout + } + if req.MaxTokens != nil { + updates["max_tokens"] = *req.MaxTokens + } + if req.SystemPrompt != nil { + updates["system_prompt"] = *req.SystemPrompt + } + if req.EnableTool != nil { + updates["enable_tool"] = *req.EnableTool + } + + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + if record == nil { + // 创建新配置 + newRecord := &llmPrimaryConfigRecord{ + Enabled: req.Enabled != nil && *req.Enabled, + ProviderName: "", + Timeout: 120, + MaxTokens: 1024, + SystemPrompt: "", + EnableTool: false, + } + if req.ProviderName != nil { + newRecord.ProviderName = *req.ProviderName + } + if req.Timeout != nil { + newRecord.Timeout = *req.Timeout + } + if req.MaxTokens != nil { + newRecord.MaxTokens = *req.MaxTokens + } + if req.SystemPrompt != nil { + newRecord.SystemPrompt = *req.SystemPrompt + } + if req.EnableTool != nil { + newRecord.EnableTool = *req.EnableTool + } + if err := store.CreateLLMPrimaryConfig(newRecord); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + record = newRecord + } else { + // 更新现有配置 + if err := store.UpdateLLMPrimaryConfig(record.ID, updates); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + record, err = store.GetLLMPrimaryConfig() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmPrimaryConfigDTO(*record)}) + } +} + +func llmPrimaryConfigDTO(row llmPrimaryConfigRecord) map[string]any { + return map[string]any{ + "id": row.ID, + "enabled": row.Enabled, + "provider_name": row.ProviderName, + "timeout": row.Timeout, + "max_tokens": row.MaxTokens, + "system_prompt": row.SystemPrompt, + "enable_tool": row.EnableTool, + "created_at": row.CreatedAt, + "updated_at": row.UpdatedAt, + } +} diff --git a/autoreply/service.go b/autoreply/service.go index b158090..f0ebace 100644 --- a/autoreply/service.go +++ b/autoreply/service.go @@ -6,6 +6,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" "meshtastic_mqtt_server/completion" "meshtastic_mqtt_server/conversation" @@ -164,13 +165,32 @@ func (s *Service) processQueue(ctx context.Context) { } } +// printJSON outputs a structured log message (imported from main package pattern) +func printJSON(v any) { + fmt.Printf("%+v\n", v) +} + // processMessage processes a single queued message func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) { // Mark message as processing if err := s.msgQueue.MarkAsProcessing(msg.ID); err != nil { + printJSON(map[string]any{ + "event": "llm_process_failed", + "msg_id": msg.ID, + "step": "mark_as_processing", + "error": err.Error(), + }) return } + printJSON(map[string]any{ + "event": "llm_process_start", + "msg_id": msg.ID, + "bot_id": msg.BotID, + "from_node_id": msg.FromNodeID, + "text": msg.Text, + }) + // Create processing context with timeout procCtx, cancel := context.WithTimeout(ctx, MaxProcessingTime) defer cancel() @@ -178,7 +198,9 @@ func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) { // Get or create conversation for this bot conv, err := s.convStore.GetOrCreateForBot(msg.BotID, msg.BotNodeID, msg.FromNodeID) if err != nil { - _ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("failed to get conversation: %v", err)) + errMsg := fmt.Sprintf("failed to get conversation: %v", err) + printJSON(map[string]any{"event": "llm_process_failed", "msg_id": msg.ID, "step": "get_conversation", "error": errMsg}) + _ = s.msgQueue.MarkAsFailed(msg.ID, errMsg) return } @@ -188,21 +210,34 @@ func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) { Content: s.formatUserMessage(msg), } if err := s.convStore.AddMessage(conv.ID, userMsg); err != nil { - _ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("failed to add message: %v", err)) + errMsg := fmt.Sprintf("failed to add message: %v", err) + printJSON(map[string]any{"event": "llm_process_failed", "msg_id": msg.ID, "step": "add_message", "error": errMsg}) + _ = s.msgQueue.MarkAsFailed(msg.ID, errMsg) return } // Get the LLM profile profile := s.llmState.ActiveProfile() if profile == nil { - _ = s.msgQueue.MarkAsFailed(msg.ID, "no active LLM profile") + errMsg := "no active LLM profile - check if LLM providers are configured" + printJSON(map[string]any{"event": "llm_process_failed", "msg_id": msg.ID, "step": "get_profile", "error": errMsg}) + _ = s.msgQueue.MarkAsFailed(msg.ID, errMsg) return } + printJSON(map[string]any{ + "event": "llm_process_profile", + "msg_id": msg.ID, + "model": profile.Config.Model, + "base_url": profile.Config.BaseURL, + }) + // Reload conversation to get updated messages conv, err = s.convStore.Get(conv.ID) if err != nil { - _ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("failed to reload conversation: %v", err)) + errMsg := fmt.Sprintf("failed to reload conversation: %v", err) + printJSON(map[string]any{"event": "llm_process_failed", "msg_id": msg.ID, "step": "reload_conversation", "error": errMsg}) + _ = s.msgQueue.MarkAsFailed(msg.ID, errMsg) return } @@ -211,17 +246,55 @@ func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) { _ = augmentedMessages // We'll use this in the future with proper tool support // For now, use simple completion since we don't have tools registered yet + printJSON(map[string]any{"event": "llm_process_completion_start", "msg_id": msg.ID}) reply, err := completion.CompleteText(procCtx, profile, conv.Messages, 512) if err != nil { - _ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("LLM completion failed: %v", err)) + errMsg := fmt.Sprintf("LLM completion failed: %v", err) + printJSON(map[string]any{"event": "llm_process_failed", "msg_id": msg.ID, "step": "llm_completion", "error": errMsg}) + _ = s.msgQueue.MarkAsFailed(msg.ID, errMsg) return } - // Truncate reply for Meshtastic + printJSON(map[string]any{ + "event": "llm_process_completion_success", + "msg_id": msg.ID, + "reply_len": len(reply), + }) + + // Clean and validate reply text + reply = cleanReplyText(reply) + printJSON(map[string]any{ + "event": "llm_process_text_cleaned", + "msg_id": msg.ID, + "cleaned_len": len(reply), + }) + + // Truncate reply for Meshtastic (UTF-8 safe truncation) if len([]byte(reply)) > MaxReplyLength { - reply = string([]byte(reply)[:MaxReplyLength-3]) + "..." + reply = truncateUTF8(reply, MaxReplyLength-3) + "..." + printJSON(map[string]any{ + "event": "llm_process_text_truncated", + "msg_id": msg.ID, + "truncated_len": len(reply), + }) } + // Final UTF-8 validation before sending + if !utf8.ValidString(reply) { + printJSON(map[string]any{ + "event": "llm_process_utf8_warning", + "msg_id": msg.ID, + "message": "final text still invalid, using fallback", + }) + reply = "抱歉,我暂时无法回复。请稍后再试。" + } + printJSON(map[string]any{ + "event": "llm_process_final_check", + "msg_id": msg.ID, + "valid_utf8": utf8.ValidString(reply), + "final_len": len(reply), + }) + // Add assistant reply to conversation assistantMsg := message.ChatMessage{ Role: "assistant", @@ -232,13 +305,21 @@ func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) { } // Send the reply via the bot + printJSON(map[string]any{"event": "llm_process_send_start", "msg_id": msg.ID, "to_node_num": msg.FromNodeNum}) if err := s.botSender.SendText(procCtx, msg.BotID, msg.FromNodeNum, reply); err != nil { - _ = s.msgQueue.MarkAsFailed(msg.ID, fmt.Sprintf("failed to send reply: %v", err)) + errMsg := fmt.Sprintf("failed to send reply: %v", err) + printJSON(map[string]any{"event": "llm_process_failed", "msg_id": msg.ID, "step": "send_reply", "error": errMsg}) + _ = s.msgQueue.MarkAsFailed(msg.ID, errMsg) return } // Mark message as processed _ = s.msgQueue.MarkAsProcessed(msg.ID, reply) + printJSON(map[string]any{ + "event": "llm_process_success", + "msg_id": msg.ID, + "reply": reply, + }) } // formatUserMessage formats the incoming message for the LLM @@ -256,3 +337,71 @@ func (s *Service) formatUserMessage(msg QueuedMessage) string { sb.WriteString(msg.Text) return sb.String() } + +// cleanReplyText cleans LLM reply text to ensure it's valid for Meshtastic +func cleanReplyText(text string) string { + // Force convert to valid UTF-8 using rune-by-rune processing + v := make([]rune, 0, len(text)) + for i, r := range text { + if r == utf8.RuneError { + _, size := utf8.DecodeRuneInString(text[i:]) + if size == 1 { + continue // Skip invalid runes + } + } + // Skip all problematic characters + if r < 32 && r != '\n' && r != '\r' && r != '\t' { + continue + } + if r == 65533 || r == 0xfffd { // Unicode replacement character + continue + } + v = append(v, r) + } + text = string(v) + + // Additional cleanup - use only explicitly allowed ASCII printable + CJK + var sb strings.Builder + for _, r := range text { + switch { + case r == '\n': + sb.WriteRune(' ') // Replace newlines with space for Meshtastic + case r == '\r' || r == '\t': + continue + case r >= 32 && r <= 126: // Printable ASCII + sb.WriteRune(r) + case r >= 0x4E00 && r <= 0x9FFF: // CJK Unified Ideographs + sb.WriteRune(r) + case r >= 0x3400 && r <= 0x4DBF: // CJK Extension A + sb.WriteRune(r) + case r >= 0x20000 && r <= 0x2A6DF: // CJK Extension B + sb.WriteRune(r) + case r >= 0xFF01 && r <= 0xFF5E: // Fullwidth ASCII variants + sb.WriteRune(r) + case r == 0x3002 || r == 0xFF1F || r == 0xFF01 || r == 0xFF0C || r == 0xFF1A: // Fullwidth punctuation + sb.WriteRune(r) + default: + continue // Skip all other characters + } + } + + result := strings.TrimSpace(sb.String()) + if result == "" { + result = "抱歉,我无法回复此消息。" + } + return result +} + +// truncateUTF8 safely truncates a UTF-8 string to max bytes without breaking in the middle of a rune +func truncateUTF8(s string, maxBytes int) string { + if len([]byte(s)) <= maxBytes { + return s + } + bytes := []byte(s) + for i := maxBytes; i > 0; i-- { + if utf8.RuneStart(bytes[i]) { + return string(bytes[:i]) + } + } + return "" +} diff --git a/bot_direct_message_store.go b/bot_direct_message_store.go index 1a7f3d8..05f7396 100644 --- a/bot_direct_message_store.go +++ b/bot_direct_message_store.go @@ -294,7 +294,7 @@ func insertInboundBotDirectMessage(s *store, record map[string]any, clientInfo m longName := nullableString(record["long_name"]) shortName := nullableString(record["short_name"]) channelID := nullableString(record["channel_id"]) - _, _ = s.EnqueueLLMMessage(LLMMessageQueueInput{ + _, err = s.EnqueueLLMMessage(LLMMessageQueueInput{ BotID: bot.ID, BotNodeID: bot.NodeID, BotNodeNum: bot.NodeNum, @@ -308,6 +308,15 @@ func insertInboundBotDirectMessage(s *store, record map[string]any, clientInfo m Topic: topic, ContentJSON: contentPtr, }) + if err != nil { + printJSON(map[string]any{ + "event": "llm_queue_enqueue_failed", + "bot_id": bot.ID, + "from": peerNodeID, + "text": text, + "error": err.Error(), + }) + } return nil } diff --git a/conversation/store.go b/conversation/store.go index df5df83..475b320 100644 --- a/conversation/store.go +++ b/conversation/store.go @@ -88,14 +88,10 @@ func (s *Store) Get(id string) (*message.Conversation, error) { func (s *Store) GetOrCreateForBot(botID uint64, botNodeID string, peerNodeID string) (*message.Conversation, error) { // Try to find an existing conversation with this peer convs, err := s.ListForBot(botID) - if err == nil { - for _, conv := range convs { - // Simple matching: use peer node ID in the future if needed - // For now, just use the most recent conversation - if conv.BotID == botID && len(conv.Messages) > 0 { - return s.Get(conv.ID) - } - } + if err == nil && len(convs) > 0 { + // Use the most recent conversation (List already sorts by UpdatedAt desc) + // Note: List returns convs with Messages = nil, so we need to reload + return s.Get(convs[0].ID) } // Create a new conversation return s.Create(botID, botNodeID) diff --git a/db.go b/db.go index 7f1768f..1cc74bc 100644 --- a/db.go +++ b/db.go @@ -494,6 +494,23 @@ func (llmToolRouterRecord) TableName() string { return "llm_tool_router" } +// llmPrimaryConfigRecord 保存主 AI 回复的配置 +type llmPrimaryConfigRecord struct { + ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` + Enabled bool `gorm:"column:enabled;not null;index"` // 是否启用 AI 回复 + ProviderName string `gorm:"column:provider_name;size:64;not null"` // 使用的 LLM 提供商名称(关联 llm_providers.name) + Timeout int `gorm:"column:timeout;not null;default:120"` // 请求超时时间(秒) + MaxTokens int `gorm:"column:max_tokens;not null;default:1024"` // 回复最大 token 数 + SystemPrompt string `gorm:"column:system_prompt;type:text;not null"` // 默认系统提示词 + EnableTool bool `gorm:"column:enable_tool;not null;default:false"` // 是否启用工具调用 + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` +} + +func (llmPrimaryConfigRecord) TableName() string { + return "llm_primary_config" +} + type positionRecord struct { AppendPacketFields `gorm:"embedded"` MQTTClientRecordFields `gorm:"embedded"` @@ -627,6 +644,7 @@ func (s *store) migrate() error { {label: "llm_message_queue", model: &llmMessageQueueRecord{}}, {label: "llm_providers", model: &llmProviderRecord{}}, {label: "llm_tool_router", model: &llmToolRouterRecord{}}, + {label: "llm_primary_config", model: &llmPrimaryConfigRecord{}}, {label: "nodeinfo", model: &nodeInfoRecord{}}, {label: "map_report", model: &mapReportRecord{}}, {label: "text_message", model: &textMessageRecord{}}, @@ -673,6 +691,9 @@ func (s *store) migrate() error { if err := txStore.EnsureDefaultLLMToolRouter(); err != nil { return err } + if err := txStore.EnsureDefaultLLMPrimaryConfig(); err != nil { + return err + } return nil }) } @@ -725,6 +746,12 @@ func migrateBotNodePSK(tx *gorm.DB, migrator gorm.Migrator, driver string) error return fmt.Errorf("migrate bot_nodes llm_include_channel_messages column: %w", err) } } + // 迁移 LLM 消息队列 reply 列 + if migrator.HasTable(&llmMessageQueueRecord{}) && !migrator.HasColumn(&llmMessageQueueRecord{}, "Reply") { + if err := tx.Exec("ALTER TABLE llm_message_queue ADD COLUMN reply text").Error; err != nil { + return fmt.Errorf("migrate llm_message_queue reply column: %w", err) + } + } return nil } diff --git a/llm_store.go b/llm_store.go index 5d4561f..1577a88 100644 --- a/llm_store.go +++ b/llm_store.go @@ -75,12 +75,12 @@ func (s *store) EnsureDefaultLLMProvider() error { } // 创建默认配置 defaultConfig := &llmProviderRecord{ - Name: "default", - Active: true, - APIKey: "", - BaseURL: "https://ark.cn-beijing.volces.com/api/v3", - Model: "", - Timeout: 120, + Name: "default", + Active: true, + APIKey: "", + BaseURL: "https://ark.cn-beijing.volces.com/api/v3", + Model: "", + Timeout: 120, ContextWindowTokens: 262144, } return s.CreateLLMProvider(defaultConfig) @@ -139,6 +139,60 @@ func (s *store) EnsureDefaultLLMToolRouter() error { return s.CreateLLMToolRouter(defaultConfig) } +// ============================================ +// LLM Primary Config (llm_primary_config) - 主 AI 回复配置 +// ============================================ + +// GetLLMPrimaryConfig 获取当前激活的主 AI 回复配置 +func (s *store) GetLLMPrimaryConfig() (*llmPrimaryConfigRecord, error) { + var record llmPrimaryConfigRecord + // 默认取第一条记录(ID 最小的),因为通常只需要一个配置 + if err := s.db.Order("id ASC").First(&record).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + return nil, fmt.Errorf("get llm primary config: %w", err) + } + return &record, nil +} + +// CreateLLMPrimaryConfig 创建主 AI 回复配置 +func (s *store) CreateLLMPrimaryConfig(record *llmPrimaryConfigRecord) error { + if err := s.db.Create(record).Error; err != nil { + return fmt.Errorf("create llm primary config: %w", err) + } + return nil +} + +// UpdateLLMPrimaryConfig 更新主 AI 回复配置 +func (s *store) UpdateLLMPrimaryConfig(id uint64, updates map[string]any) error { + if err := s.db.Model(&llmPrimaryConfigRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { + return fmt.Errorf("update llm primary config %d: %w", id, err) + } + return nil +} + +// EnsureDefaultLLMPrimaryConfig 确保存在默认主 AI 回复配置 +func (s *store) EnsureDefaultLLMPrimaryConfig() error { + _, err := s.GetLLMPrimaryConfig() + if err == nil { + return nil // 已存在 + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + // 创建默认配置 + defaultConfig := &llmPrimaryConfigRecord{ + Enabled: false, + ProviderName: "", + Timeout: 120, + MaxTokens: 1024, + SystemPrompt: "你是一个 Meshtastic 网络助手。请简洁回答用户问题。\n回答要简短清晰,适合在低带宽无线电环境传输。每次回复限制在200 bytes以内。", + EnableTool: false, + } + return s.CreateLLMPrimaryConfig(defaultConfig) +} + // LLMMessageQueueInput 是添加 LLM 队列消息的输入 type LLMMessageQueueInput struct { BotID uint64 // 0 表示频道消息 @@ -179,12 +233,24 @@ func (s *store) EnqueueLLMMessage(input LLMMessageQueueInput) (*llmMessageQueueR return nil, fmt.Errorf("text is required") } - // 检查是否存在重复消息:相同 bot_id + packet_id 且未删除 + // 检查是否存在重复消息 + // packet_id > 0: 用 bot_id + packet_id 去重(频道消息) + // packet_id = 0: 用 bot_id + from_node_id + text 去重(私聊消息,可能没有 packet_id) + // 只排除 pending/processing 状态的消息,允许 error 状态的消息重新入队 var existing llmMessageQueueRecord - err = s.db.Where("bot_id = ? AND packet_id = ? AND deleted_at IS NULL", input.BotID, input.PacketID). - Take(&existing).Error + if input.PacketID > 0 { + // 频道消息:用 bot_id + packet_id 去重 + err = s.db.Where("bot_id = ? AND packet_id = ? AND deleted_at IS NULL AND status IN (?, ?)", + input.BotID, input.PacketID, llmMessageStatusPending, llmMessageStatusProcessing). + Take(&existing).Error + } else { + // 私聊消息:用 bot_id + from_node_id + text 去重(避免同一人连续发相同内容被拒绝) + err = s.db.Where("bot_id = ? AND from_node_id = ? AND text = ? AND deleted_at IS NULL AND status IN (?, ?)", + input.BotID, input.FromNodeID, input.Text, llmMessageStatusPending, llmMessageStatusProcessing). + Take(&existing).Error + } if err == nil { - // 重复消息,直接返回已存在的 + // 存在正在处理或待处理的相同消息,直接返回 return &existing, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { @@ -377,15 +443,16 @@ func enqueueChannelMessageToLLM(s *store, record map[string]any) error { } // 查询所有启用了 LLM 队列且包含频道消息的机器人 + // SQLite 中 numeric 布尔值用 1/0 存储,必须用整数查询 var bots []botNodeRecord - err = s.db.Where("llm_queue_enabled = ? AND llm_include_channel_messages = ?", true, true).Find(&bots).Error + err = s.db.Where("llm_queue_enabled = ? AND llm_include_channel_messages = ?", 1, 1).Find(&bots).Error if err != nil { return fmt.Errorf("query bots for channel message enqueue: %w", err) } // 为每个符合条件的机器人创建一条队列记录 for _, bot := range bots { - _, _ = s.EnqueueLLMMessage(LLMMessageQueueInput{ + _, err = s.EnqueueLLMMessage(LLMMessageQueueInput{ BotID: bot.ID, BotNodeID: bot.NodeID, BotNodeNum: bot.NodeNum, @@ -399,6 +466,15 @@ func enqueueChannelMessageToLLM(s *store, record map[string]any) error { Topic: topic, ContentJSON: contentPtr, }) + if err != nil { + printJSON(map[string]any{ + "event": "llm_queue_enqueue_failed", + "bot_id": bot.ID, + "from": fromNodeID, + "text": text, + "error": err.Error(), + }) + } } return nil diff --git a/meshmap_frontend/src/api.ts b/meshmap_frontend/src/api.ts index e0a6730..bcadf37 100644 --- a/meshmap_frontend/src/api.ts +++ b/meshmap_frontend/src/api.ts @@ -54,6 +54,8 @@ import type { LLMProviderResponse, LLMPlatformRouterPayload, LLMPlatformRouterResponse, + LLMPrimaryConfigPayload, + LLMPrimaryConfigResponse, } from './types' async function requestJSON(path: string, init?: RequestInit): Promise { @@ -504,5 +506,14 @@ export function updateLLMToolRouter(payload: Partial): return putJSON('/api/admin/llm/tool-router', payload) } +// LLM Primary Config API - 主 AI 回复配置 +export function getLLMPrimaryConfig(): Promise { + return getJSON('/api/admin/llm/primary-config') +} + +export function updateLLMPrimaryConfig(payload: Partial): Promise { + return putJSON('/api/admin/llm/primary-config', payload) +} + // 静默使用未导出类型,避免 TS6133(未使用的导入)。 -export type { LLMMessage, LLMMessageStatus, LLMProvider, LLMPlatformRouter } from './types' +export type { LLMMessage, LLMMessageStatus, LLMProvider, LLMPlatformRouter, LLMPrimaryConfig } from './types' diff --git a/meshmap_frontend/src/components/AdminLLMApi.vue b/meshmap_frontend/src/components/AdminLLMApi.vue index 264192a..cd3db50 100644 --- a/meshmap_frontend/src/components/AdminLLMApi.vue +++ b/meshmap_frontend/src/components/AdminLLMApi.vue @@ -5,10 +5,12 @@ import { deleteLLMProvider, getLLMProviders, getLLMToolRouter, + getLLMPrimaryConfig, updateLLMProvider, updateLLMToolRouter, + updateLLMPrimaryConfig, } from '../api' -import type { LLMPlatformRouter, LLMProvider } from '../types' +import type { LLMPlatformRouter, LLMProvider, LLMPrimaryConfig } from '../types' const loading = ref(false) const error = ref('') @@ -42,6 +44,19 @@ const toolRouterForm = ref({ system_prompt: '', }) +// Primary AI Config 相关 - 主 AI 回复配置 +const primaryConfig = ref(null) +const editingPrimaryConfig = ref(false) + +const primaryConfigForm = ref({ + enabled: false, + provider_name: '', + timeout: 120, + max_tokens: 1024, + system_prompt: '', + enable_tool: false, +}) + const activeProviders = computed(() => providers.value.filter((p) => p.active)) function clearSuccess() { @@ -73,6 +88,16 @@ async function loadToolRouter() { } } +async function loadPrimaryConfig() { + try { + const response = await getLLMPrimaryConfig() + primaryConfig.value = response.item + } catch (err) { + // 如果不存在,使用默认值 + console.warn('Primary AI config not found, using defaults') + } +} + function openCreateProvider() { isCreatingProvider.value = true providerForm.value = { @@ -178,9 +203,40 @@ async function saveToolRouter() { } } +function openEditPrimaryConfig() { + if (primaryConfig.value) { + primaryConfigForm.value = { + enabled: primaryConfig.value.enabled, + provider_name: primaryConfig.value.provider_name, + timeout: primaryConfig.value.timeout, + max_tokens: primaryConfig.value.max_tokens, + system_prompt: primaryConfig.value.system_prompt, + enable_tool: primaryConfig.value.enable_tool, + } + } + editingPrimaryConfig.value = true +} + +function closePrimaryConfigForm() { + editingPrimaryConfig.value = false +} + +async function savePrimaryConfig() { + try { + await updateLLMPrimaryConfig(primaryConfigForm.value) + success.value = '更新成功' + clearSuccess() + closePrimaryConfigForm() + await loadPrimaryConfig() + } catch (err) { + error.value = err instanceof Error ? err.message : String(err) + } +} + onMounted(() => { loadProviders() loadToolRouter() + loadPrimaryConfig() }) @@ -330,6 +386,101 @@ onMounted(() => { + +
+
+
+

主 AI 回复配置

+

配置机器人自动回复消息的核心 AI 设置。

+
+ +
+ +
+
+ +
+ +
+
+ + +

选择用于自动回复消息的 AI 提供商配置

+
+
+ + +

选择是否让 AI 在回复中调用工具(如计算器等)

+
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +

用于指导 AI 如何回复用户消息的系统提示词

+
+ +
+ + +
+
+ +
+
+ + {{ primaryConfig.enabled ? '已启用' : '已停用' }} + +
+
+
+ 使用的 AI 配置 + {{ primaryConfig.provider_name || '未设置' }} +
+
+ 超时时间 + {{ primaryConfig.timeout }} 秒 +
+
+ 最大 Token 数 + {{ primaryConfig.max_tokens }} +
+
+ 工具调用 + {{ primaryConfig.enable_tool ? '已启用' : '未启用' }} +
+
+ 系统提示词 +
{{ primaryConfig.system_prompt }}
+
+
+
+ +
+

暂无主 AI 回复配置,点击上方按钮进行配置。

+
+
+