diff --git a/internal/ai/service.go b/internal/ai/service.go index 7b71e8d..e451f67 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -15,6 +15,7 @@ import ( "meshtastic_mqtt_server/internal/llm" storepkg "meshtastic_mqtt_server/internal/store" "meshtastic_mqtt_server/internal/toolmanager" + "meshtastic_mqtt_server/internal/topicrouter" "meshtastic_mqtt_server/internal/toolrouter" "gorm.io/gorm" @@ -34,6 +35,12 @@ type ToolRouterStore interface { GetLLMToolRouter() (*storepkg.LLMToolRouterRecord, error) } +// TopicRouterStore 是 ai 服务依赖的话题选择持久化接口; +// 通常由 *store.Store 实现(GetLLMTopicConfig)。 +type TopicRouterStore interface { + GetLLMTopicConfig() (*storepkg.LLMTopicConfigRecord, error) +} + // Config holds the AI service configuration type Config struct { LLMProviders []llm.ProviderConfig @@ -42,16 +49,18 @@ type Config struct { ConsoleLog bool ToolConfigStore ToolConfigStore ToolRouterStore ToolRouterStore + TopicRouterStore TopicRouterStore } // Service manages all AI-related components type Service struct { - LLMState *llm.State - ToolRouter *toolrouter.State - ToolMgr *toolmanager.Manager - ConvStore *conversation.Store - AutoReply *autoreply.Service - MsgQueue *autoreply.DBMessageQueue + LLMState *llm.State + ToolRouter *toolrouter.State + TopicRouter *topicrouter.State + ToolMgr *toolmanager.Manager + ConvStore *conversation.Store + AutoReply *autoreply.Service + MsgQueue *autoreply.DBMessageQueue enabled bool } @@ -91,6 +100,41 @@ func toolRouterConfigFromRecord(r *storepkg.LLMToolRouterRecord) *toolrouter.Con } } +// topicRouterConfigAdapter 把 TopicRouterStore 适配成 topicrouter.ConfigStore, +// 每次 LoadTopicConfig 都从 DB 拉取最新一行 llm_topic_config。 +type topicRouterConfigAdapter struct { + store TopicRouterStore +} + +// LoadTopicConfig 实现 topicrouter.ConfigStore。 +// 当 DB 没有记录时返回 nil + nil,由 topicrouter 内部回退到内存默认值。 +func (a *topicRouterConfigAdapter) LoadTopicConfig() (*topicrouter.Config, error) { + if a == nil || a.store == nil { + return nil, errors.New("topic router store is not configured") + } + record, err := a.store.GetLLMTopicConfig() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return topicRouterConfigFromRecord(record), nil +} + +func topicRouterConfigFromRecord(r *storepkg.LLMTopicConfigRecord) *topicrouter.Config { + if r == nil { + return nil + } + return &topicrouter.Config{ + Enabled: r.Enabled, + OpenAIName: r.OpenAIName, + Timeout: r.Timeout, + MaxTokens: r.MaxTokens, + SystemPrompt: r.SystemPrompt, + } +} + // NewService creates a new AI service func NewService(cfg Config, db *gorm.DB, botSender autoreply.BotSender) (*Service, error) { if !cfg.Enabled { @@ -132,6 +176,23 @@ func NewService(cfg Config, db *gorm.DB, botSender autoreply.BotSender) (*Servic return nil, fmt.Errorf("failed to initialize tool router: %w", err) } + // 初始化话题选择 router:同样优先从 DB 读取已保存配置,支持保存即生效。 + var ( + topicRouterCfg *topicrouter.Config + topicRouterOptions []topicrouter.Option + ) + if cfg.TopicRouterStore != nil { + topicAdapter := &topicRouterConfigAdapter{store: cfg.TopicRouterStore} + if loaded, loadErr := topicAdapter.LoadTopicConfig(); loadErr == nil && loaded != nil { + topicRouterCfg = loaded + } + topicRouterOptions = append(topicRouterOptions, topicrouter.WithConfigStore(topicAdapter)) + } + topicRouter, err := topicrouter.NewState(topicRouterCfg, llmState, topicRouterOptions...) + if err != nil { + return nil, fmt.Errorf("failed to initialize topic router: %w", err) + } + // Load tools toolMgr, err := toolmanager.Load(agentsDir, agenttool.LoadOptions{}) if err != nil { @@ -148,6 +209,7 @@ func NewService(cfg Config, db *gorm.DB, botSender autoreply.BotSender) (*Servic autoReply := autoreply.NewService( llmState, toolRouter, + topicRouter, toolMgr, convStore, msgQueue, @@ -157,13 +219,14 @@ func NewService(cfg Config, db *gorm.DB, botSender autoreply.BotSender) (*Servic ) return &Service{ - LLMState: llmState, - ToolRouter: toolRouter, - ToolMgr: toolMgr, - ConvStore: convStore, - AutoReply: autoReply, - MsgQueue: msgQueue, - enabled: true, + LLMState: llmState, + ToolRouter: toolRouter, + TopicRouter: topicRouter, + ToolMgr: toolMgr, + ConvStore: convStore, + AutoReply: autoReply, + MsgQueue: msgQueue, + enabled: true, }, nil } diff --git a/internal/autoreply/service.go b/internal/autoreply/service.go index 7742317..10ec2b8 100644 --- a/internal/autoreply/service.go +++ b/internal/autoreply/service.go @@ -15,6 +15,7 @@ import ( "meshtastic_mqtt_server/internal/message" "meshtastic_mqtt_server/internal/stream" "meshtastic_mqtt_server/internal/toolmanager" + "meshtastic_mqtt_server/internal/topicrouter" "meshtastic_mqtt_server/internal/toolrouter" "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" @@ -77,6 +78,7 @@ type ToolConfigStore interface { type Service struct { llmState *llm.State toolRouter *toolrouter.State + topicRouter *topicrouter.State toolMgr *toolmanager.Manager convStore *conversation.Store msgQueue MessageQueue @@ -94,6 +96,7 @@ type Service struct { func NewService( llmState *llm.State, toolRouter *toolrouter.State, + topicRouter *topicrouter.State, toolMgr *toolmanager.Manager, convStore *conversation.Store, msgQueue MessageQueue, @@ -104,6 +107,7 @@ func NewService( return &Service{ llmState: llmState, toolRouter: toolRouter, + topicRouter: topicRouter, toolMgr: toolMgr, convStore: convStore, msgQueue: msgQueue, @@ -327,6 +331,7 @@ func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) { // Run the tool loop to get augmented messages - pass system prompt to tool router // Tool loop will handle system prompt and tool calling var augmentedMessages []*model.ChatCompletionMessage + toolUsed := false if enableTool && toolCount > 0 { routerProfile := s.toolRouter.RouterProfile(profile) routerModel := profile.Config.Model @@ -334,7 +339,7 @@ func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) { routerModel = routerProfile.Config.Model } s.logf("msg=%d router_model=%s tool_loop start", msg.ID, routerModel) - augmentedMessages, err = toolrouter.RunAgentToolLoop(procCtx, s.toolRouter, profile, systemPrompt, conv.Messages, s.toolMgr, s.emit(msg.ID, routerModel)) + augmentedMessages, toolUsed, err = toolrouter.RunAgentToolLoop(procCtx, s.toolRouter, profile, systemPrompt, conv.Messages, s.toolMgr, s.emit(msg.ID, routerModel)) if err != nil { s.logf("msg=%d WARN tool_loop err=%v", msg.ID, err) // Continue with original messages if tool loop fails @@ -343,6 +348,27 @@ func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) { s.logf("msg=%d completion start has_system_prompt=%t augmented=%d", msg.ID, systemPrompt != "", len(augmentedMessages)) + // 若工具路由未实际调用任何工具,则进入话题选择判定: + // 命中(REPLY/放行)才进入主回复,未命中则丢弃不回复。 + if !toolUsed { + shouldReply, judgeErr := topicrouter.Judge(procCtx, s.topicRouter, profile, conv.Messages) + if judgeErr != nil { + s.logf("msg=%d WARN topic_judge err=%v (放行)", msg.ID, judgeErr) + } + if !shouldReply { + s.logf("msg=%d topic_judge=IGNORE → 丢弃不回复", msg.ID) + // 把刚加入会话的用户消息弹出,避免它残留在上下文里被下一次回复附带回答。 + if popped, popErr := s.convStore.PopLastMessage(conv.ID); popErr != nil { + s.logf("msg=%d WARN pop_discarded_message err=%v", msg.ID, popErr) + } else if popped.Content != "" { + s.logf("msg=%d pop_discarded_message content=%q", msg.ID, truncate(popped.Content, 200)) + } + _ = s.msgQueue.MarkAsProcessed(msg.ID, "") + return + } + s.logf("msg=%d topic_judge=REPLY → 进入主回复", msg.ID) + } + // Use augmented messages from tool loop (already includes system prompt and tool results) // If augmented messages is empty or nil, fallback to original messages with system prompt var reply string diff --git a/internal/conversation/store.go b/internal/conversation/store.go index 17b25f7..1e1245a 100644 --- a/internal/conversation/store.go +++ b/internal/conversation/store.go @@ -180,6 +180,25 @@ func (s *Store) AddMessage(convID string, msg message.ChatMessage) error { return s.Save(conv) } +// PopLastMessage 移除会话中最后一条消息(例如被话题选择丢弃、不应保留在上下文里的用户消息)。 +// 若会话没有消息则不做任何改动。返回移除的消息内容,便于调用方记录日志。 +func (s *Store) PopLastMessage(convID string) (message.ChatMessage, error) { + conv, err := s.Get(convID) + if err != nil { + return message.ChatMessage{}, err + } + if len(conv.Messages) == 0 { + return message.ChatMessage{}, nil + } + last := conv.Messages[len(conv.Messages)-1] + conv.Messages = conv.Messages[:len(conv.Messages)-1] + // 若弹出后没有消息了,重置标题,避免残留被丢弃消息的文本。 + if len(conv.Messages) == 0 { + conv.Title = "新对话" + } + return last, s.Save(conv) +} + // atomicWriteJSON writes JSON to a file atomically func atomicWriteJSON(path string, v any) error { tmp := path + ".tmp" diff --git a/internal/llmadmin/admin_llm_routes.go b/internal/llmadmin/admin_llm_routes.go index cd5d973..974f494 100644 --- a/internal/llmadmin/admin_llm_routes.go +++ b/internal/llmadmin/admin_llm_routes.go @@ -35,6 +35,10 @@ func RegisterRoutes(r *gin.RouterGroup, store *storepkg.Store) { group.GET("/tool-router", handleGetLLMToolRouter(store)) group.PUT("/tool-router", handleUpdateLLMToolRouter(store)) + // LLM Topic Config - 话题选择配置 + group.GET("/topic-config", handleGetLLMTopicConfig(store)) + group.PUT("/topic-config", handleUpdateLLMTopicConfig(store)) + // LLM Primary Config - 主 AI 回复配置 group.GET("/primary-config", handleGetLLMPrimaryConfig(store)) group.PUT("/primary-config", handleUpdateLLMPrimaryConfig(store)) @@ -503,6 +507,124 @@ func llmToolRouterDTO(row storepkg.LLMToolRouterRecord) map[string]any { } } +// ============================================ +// LLM Topic Config Handlers - 话题选择配置 +// ============================================ + +func handleGetLLMTopicConfig(store *storepkg.Store) gin.HandlerFunc { + return func(c *gin.Context) { + record, err := store.GetLLMTopicConfig() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "topic config not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"item": llmTopicConfigDTO(*record)}) + } +} + +func handleUpdateLLMTopicConfig(store *storepkg.Store) gin.HandlerFunc { + return func(c *gin.Context) { + record, err := store.GetLLMTopicConfig() + 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"` + OpenAIName *string `json:"openai_name"` + Timeout *int `json:"timeout"` + MaxTokens *int `json:"max_tokens"` + SystemPrompt *string `json:"system_prompt"` + } + 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.OpenAIName != nil { + updates["openai_name"] = *req.OpenAIName + } + 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 len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + if record == nil { + // 创建新配置 + newRecord := &storepkg.LLMTopicConfigRecord{ + Enabled: req.Enabled != nil && *req.Enabled, + OpenAIName: "", + Timeout: 30, + MaxTokens: 512, + SystemPrompt: "", + } + if req.OpenAIName != nil { + newRecord.OpenAIName = *req.OpenAIName + } + 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 err := store.CreateLLMTopicConfig(newRecord); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + record = newRecord + } else { + // 更新现有配置 + if err := store.UpdateLLMTopicConfig(record.ID, updates); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + record, err = store.GetLLMTopicConfig() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmTopicConfigDTO(*record)}) + } +} + +func llmTopicConfigDTO(row storepkg.LLMTopicConfigRecord) map[string]any { + return map[string]any{ + "id": row.ID, + "enabled": row.Enabled, + "openai_name": row.OpenAIName, + "timeout": row.Timeout, + "max_tokens": row.MaxTokens, + "system_prompt": row.SystemPrompt, + "created_at": row.CreatedAt, + "updated_at": row.UpdatedAt, + } +} + // ============================================ // LLM Primary Config Handlers // ============================================ diff --git a/internal/store/db.go b/internal/store/db.go index ea7eafc..75047f2 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -494,6 +494,22 @@ func (LLMToolRouterRecord) TableName() string { return "llm_tool_router" } +// LLMTopicConfigRecord 保存话题选择的配置 +type LLMTopicConfigRecord struct { + ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` + Enabled bool `gorm:"column:enabled;not null;index"` // 是否启用话题选择 + OpenAIName string `gorm:"column:openai_name;size:64;not null"` // 使用的 LLM 提供商名称(关联 llm_providers.name) + Timeout int `gorm:"column:timeout;not null;default:30"` // 话题判定超时时间(秒) + MaxTokens int `gorm:"column:max_tokens;not null;default:512"` // 话题判定最大 token 数 + SystemPrompt string `gorm:"column:system_prompt;type:text;not null"` // 系统提示词 + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"` +} + +func (LLMTopicConfigRecord) TableName() string { + return "llm_topic_config" +} + // LLMPrimaryConfigRecord 保存主 AI 回复的配置 type LLMPrimaryConfigRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` @@ -666,6 +682,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_topic_config", model: &LLMTopicConfigRecord{}}, {label: "llm_primary_config", model: &LLMPrimaryConfigRecord{}}, {label: "nodeinfo", model: &NodeInfoRecord{}}, {label: "map_report", model: &MapReportRecord{}}, @@ -713,6 +730,9 @@ func (s *Store) migrate() error { if err := txStore.EnsureDefaultLLMToolRouter(); err != nil { return err } + if err := txStore.EnsureDefaultLLMTopicConfig(); err != nil { + return err + } if err := txStore.EnsureDefaultLLMPrimaryConfig(); err != nil { return err } diff --git a/internal/store/llm_store.go b/internal/store/llm_store.go index 0ff3e66..3bf2a8d 100644 --- a/internal/store/llm_store.go +++ b/internal/store/llm_store.go @@ -139,6 +139,59 @@ func (s *Store) EnsureDefaultLLMToolRouter() error { return s.CreateLLMToolRouter(defaultConfig) } +// ============================================ +// LLM Topic Config (llm_topic_config) - 话题选择配置 +// ============================================ + +// GetLLMTopicConfig 获取当前激活的话题选择配置 +func (s *Store) GetLLMTopicConfig() (*LLMTopicConfigRecord, error) { + var record LLMTopicConfigRecord + // 默认取第一条记录(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 topic config: %w", err) + } + return &record, nil +} + +// CreateLLMTopicConfig 创建话题选择配置 +func (s *Store) CreateLLMTopicConfig(record *LLMTopicConfigRecord) error { + if err := s.db.Create(record).Error; err != nil { + return fmt.Errorf("create llm topic config: %w", err) + } + return nil +} + +// UpdateLLMTopicConfig 更新话题选择配置 +func (s *Store) UpdateLLMTopicConfig(id uint64, updates map[string]any) error { + if err := s.db.Model(&LLMTopicConfigRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil { + return fmt.Errorf("update llm topic config %d: %w", id, err) + } + return nil +} + +// EnsureDefaultLLMTopicConfig 确保存在默认话题选择配置 +func (s *Store) EnsureDefaultLLMTopicConfig() error { + _, err := s.GetLLMTopicConfig() + if err == nil { + return nil // 已存在 + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + // 创建默认配置(默认未启用) + defaultConfig := &LLMTopicConfigRecord{ + Enabled: false, + OpenAIName: "", + Timeout: 30, + MaxTokens: 512, + SystemPrompt: "你是一个话题过滤器,判断用户最新消息是否属于应当回复的话题范围。\n如果应当回复,请输出 REPLY;如果不应当回复,请输出 IGNORE。\n只输出 REPLY 或 IGNORE,不要输出任何其他内容。", + } + return s.CreateLLMTopicConfig(defaultConfig) +} + // ============================================ // LLM Primary Config (llm_primary_config) - 主 AI 回复配置 // ============================================ diff --git a/internal/toolrouter/loop.go b/internal/toolrouter/loop.go index 35b24e6..04363e4 100644 --- a/internal/toolrouter/loop.go +++ b/internal/toolrouter/loop.go @@ -19,10 +19,13 @@ const maxAgentToolIterations = 6 // RunAgentToolLoop runs the agent tool calling loop // systemPrompt is the primary system prompt from LLM config -func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, systemPrompt string, chatMessages []message.ChatMessage, manager *toolmanager.Manager, emit stream.EmitFunc) ([]*model.ChatCompletionMessage, error) { +// The third return value toolUsed indicates whether at least one tool was actually +// invoked during the loop (i.e. the model selected a tool). Callers use it to decide +// whether to skip downstream gating (e.g. topic selection). +func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, systemPrompt string, chatMessages []message.ChatMessage, manager *toolmanager.Manager, emit stream.EmitFunc) ([]*model.ChatCompletionMessage, bool, error) { finalMessages, err := buildArkMessages(chatMessages) if err != nil { - return nil, err + return nil, false, err } routerProfile := profile if state != nil { @@ -40,7 +43,7 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s } finalMessages = append([]*model.ChatCompletionMessage{systemMessage}, finalMessages...) } - return finalMessages, nil + return finalMessages, false, nil } decisionMessages := append([]*model.ChatCompletionMessage(nil), finalMessages...) @@ -72,7 +75,7 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s finalMessages = append([]*model.ChatCompletionMessage{systemMessage}, finalMessages...) decisionMessages = append([]*model.ChatCompletionMessage{systemMessage}, decisionMessages...) } - return finalMessages, nil + return finalMessages, false, nil } // 每轮调用都重新加载最新配置,确保管理员在 /admin/llm/api 保存后立即生效 cfg := state.effectiveConfig() @@ -102,6 +105,8 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s } decisionMessages = append([]*model.ChatCompletionMessage{routerSystemMessage}, decisionMessages...) } + // toolUsed 记录本轮是否真的执行了至少一次工具调用,供调用方决定是否跳过话题选择等后续门控。 + toolUsed := false for i := 0; i < maxAgentToolIterations; i++ { if emit != nil { emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "request", Status: "running", Message: fmt.Sprintf("正在进行第 %d 轮工具判断", i+1), Data: map[string]any{"iteration": i + 1, "max_iterations": maxAgentToolIterations, "tools": availableNames}}) @@ -115,13 +120,13 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s ParallelToolCalls: BoolPtr(false), }, time.Duration(cfg.Timeout)*time.Second) if err != nil { - return finalMessages, err + return finalMessages, toolUsed, err } if tracker := stream.TrackerFromContext(ctx); tracker != nil { tracker.AddTool(resp.Usage.PromptTokens, resp.Usage.CompletionTokens) } if len(resp.Choices) == 0 { - return finalMessages, nil + return finalMessages, toolUsed, nil } choice := resp.Choices[0] if emit != nil { @@ -135,7 +140,7 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s if emit != nil { emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "request", Status: "success", Message: "模型未请求工具,进入回答生成"}) } - return finalMessages, nil + return finalMessages, toolUsed, nil } callNames := make([]string, 0, len(calls)) for _, call := range calls { @@ -146,6 +151,8 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s if emit != nil { emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "tool_calls", Status: "running", Message: fmt.Sprintf("模型请求调用 %d 个工具", len(calls)), Data: map[string]any{"tools": callNames, "iteration": i + 1}}) } + // 模型确实请求了工具调用,标记 toolUsed=true + toolUsed = true assistantMessage := &model.ChatCompletionMessage{Role: "assistant", ToolCalls: calls, Content: choice.Message.Content} finalMessages = append(finalMessages, assistantMessage) decisionMessages = append(decisionMessages, assistantMessage) @@ -160,7 +167,7 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s limitText := "工具调用轮数已达到上限。请基于已有工具结果回答,并说明可能未完成全部工具调用。" limitMessage := &model.ChatCompletionMessage{Role: "system", Content: &model.ChatCompletionMessageContent{StringValue: &limitText}} finalMessages = append(finalMessages, limitMessage) - return finalMessages, nil + return finalMessages, toolUsed, nil } func buildArkMessages(chatMessages []message.ChatMessage) ([]*model.ChatCompletionMessage, error) { diff --git a/internal/topicrouter/state.go b/internal/topicrouter/state.go new file mode 100644 index 0000000..607f4a7 --- /dev/null +++ b/internal/topicrouter/state.go @@ -0,0 +1,213 @@ +package topicrouter + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "meshtastic_mqtt_server/internal/completion" + "meshtastic_mqtt_server/internal/llm" + "meshtastic_mqtt_server/internal/message" + + "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" +) + +// DefaultSystemPrompt 是话题选择判定模型的默认系统提示词。 +// 模型被要求只输出 REPLY 或 IGNORE:REPLY 表示应当回复,IGNORE 表示应当丢弃。 +const DefaultSystemPrompt = "你是一个话题过滤器,判断用户最新消息是否属于应当回复的话题范围。\n如果应当回复,请输出 REPLY;如果不应当回复,请输出 IGNORE。\n只输出 REPLY 或 IGNORE,不要输出任何其他内容。" + +// Config holds the topic selection configuration +type Config struct { + Enabled bool + OpenAIName string + Timeout int + MaxTokens int + SystemPrompt string +} + +// ConfigStore 定义从持久化层读取最新话题选择配置的能力。 +// 每次 Judge 都会调用 LoadTopicConfig,从而保证管理员在 /admin/llm/api +// 修改配置后立即生效,无需重启。 +type ConfigStore interface { + LoadTopicConfig() (*Config, error) +} + +// State manages the topic router state +type State struct { + cfg *Config + ai *llm.State + store ConfigStore +} + +// Option is a function that configures the State +type Option func(*State) + +// WithConfigStore 注入运行时配置加载器,State 会在每次需要时拉取最新配置。 +func WithConfigStore(store ConfigStore) Option { + return func(s *State) { + s.store = store + } +} + +// NewState creates a new topic router state +func NewState(cfg *Config, ai *llm.State, options ...Option) (*State, error) { + if cfg == nil { + cfg = &Config{ + Enabled: false, + Timeout: 30, + MaxTokens: 512, + SystemPrompt: DefaultSystemPrompt, + } + } + if ai == nil { + return nil, errors.New("topic router requires an LLM state") + } + if cfg.Enabled && strings.TrimSpace(cfg.OpenAIName) != "" { + if _, err := ai.GetProfile(cfg.OpenAIName); err != nil { + return nil, fmt.Errorf("invalid LLM provider name in topic router: %w", err) + } + } + state := &State{cfg: cfg, ai: ai} + for _, option := range options { + option(state) + } + return state, nil +} + +// effectiveConfig 返回当前生效的配置:优先从 store 加载最新值,加载失败时回退到内存 cfg。 +// 调用方拿到的永远是非 nil 指针;内存 cfg 也保持同步以便其它读取点。 +func (s *State) effectiveConfig() *Config { + if s == nil { + return &Config{} + } + if s.store != nil { + if latest, err := s.store.LoadTopicConfig(); err == nil && latest != nil { + s.cfg = latest + return latest + } + } + if s.cfg == nil { + return &Config{} + } + return s.cfg +} + +// Profile 返回话题选择使用的 LLM profile,OpenAIName 为空时回退到 fallback(主 profile)。 +func (s *State) Profile(fallback *llm.Profile) *llm.Profile { + if s == nil || s.ai == nil { + return fallback + } + cfg := s.effectiveConfig() + name := strings.TrimSpace(cfg.OpenAIName) + if name == "" { + return fallback + } + profile, err := s.ai.GetProfile(name) + if err != nil { + return fallback + } + return profile +} + +// Config returns a copy of the current configuration +func (s *State) Config() Config { + if s == nil { + return Config{} + } + return *s.effectiveConfig() +} + +// Judge 对最近一条用户消息做话题判定。 +// 返回值 shouldReply:true 表示命中/放行(应进入主回复),false 表示应丢弃不回复。 +// 当话题选择未启用、未配置提供商,或判定调用失败时,一律放行(返回 true), +// 避免判定接口故障导致所有未命中工具的消息被丢弃。 +func Judge(ctx context.Context, state *State, fallback *llm.Profile, messages []message.ChatMessage) (bool, error) { + if state == nil { + return true, nil + } + cfg := state.effectiveConfig() + if !cfg.Enabled { + return true, nil + } + + profile := state.Profile(fallback) + if profile == nil || profile.Client == nil { + // 未配置话题选择的 AI 提供商,回退到放行 + return true, nil + } + + // 取最后一条用户消息作为判定输入 + userText := lastUserMessage(messages) + if strings.TrimSpace(userText) == "" { + return true, nil + } + + systemPrompt := strings.TrimSpace(cfg.SystemPrompt) + if systemPrompt == "" { + systemPrompt = DefaultSystemPrompt + } + + arkMessages := make([]*model.ChatCompletionMessage, 0, 2) + arkMessages = append(arkMessages, &model.ChatCompletionMessage{ + Role: "system", + Content: &model.ChatCompletionMessageContent{ + StringValue: &systemPrompt, + }, + }) + arkMessages = append(arkMessages, &model.ChatCompletionMessage{ + Role: "user", + Content: &model.ChatCompletionMessageContent{ + StringValue: &userText, + }, + }) + + maxTokens := cfg.MaxTokens + if maxTokens <= 0 { + maxTokens = 512 + } + timeout := time.Duration(cfg.Timeout) * time.Second + if cfg.Timeout <= 0 { + timeout = 30 * time.Second + } + + resp, err := completion.CompleteChat(ctx, profile, model.CreateChatCompletionRequest{ + Model: profile.Config.Model, + Messages: arkMessages, + MaxTokens: &maxTokens, + }, timeout) + if err != nil { + // 判定调用失败时放行,避免接口故障导致全部丢消息 + return true, err + } + if len(resp.Choices) == 0 { + return true, nil + } + + text := "" + if resp.Choices[0].Message.Content != nil && resp.Choices[0].Message.Content.StringValue != nil { + text = *resp.Choices[0].Message.Content.StringValue + } + // 解析模型输出:包含 REPLY 即命中(忽略大小写) + upper := strings.ToUpper(strings.TrimSpace(text)) + if strings.Contains(upper, "REPLY") { + return true, nil + } + return false, nil +} + +// lastUserMessage 返回消息列表中最后一条 role 为 user 的消息内容。 +func lastUserMessage(messages []message.ChatMessage) string { + for i := len(messages) - 1; i >= 0; i-- { + msg := messages[i] + role := msg.Role + if role == "" { + role = "user" + } + if role == "user" { + return msg.Content + } + } + return "" +} diff --git a/main.go b/main.go index 3cc70da..b0024f8 100644 --- a/main.go +++ b/main.go @@ -417,12 +417,13 @@ func run(cfg *configpkg.Config) error { ) aiService, err = ai.NewService(ai.Config{ - LLMProviders: providerConfigs, - DataDir: cfg.AI.DataDir, - Enabled: cfg.AI.Enabled, - ConsoleLog: cfg.ConsoleLog.LLM, - ToolConfigStore: store, - ToolRouterStore: store, + LLMProviders: providerConfigs, + DataDir: cfg.AI.DataDir, + Enabled: cfg.AI.Enabled, + ConsoleLog: cfg.ConsoleLog.LLM, + ToolConfigStore: store, + ToolRouterStore: store, + TopicRouterStore: store, }, store.DB(), botSenderAdapter) if err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to initialize AI service: %v\n", err) diff --git a/meshmap_frontend/src/api.ts b/meshmap_frontend/src/api.ts index 64eaf23..6ec38ba 100644 --- a/meshmap_frontend/src/api.ts +++ b/meshmap_frontend/src/api.ts @@ -54,6 +54,8 @@ import type { LLMProviderResponse, LLMPlatformRouterPayload, LLMPlatformRouterResponse, + LLMTopicConfigPayload, + LLMTopicConfigResponse, LLMPrimaryConfigPayload, LLMPrimaryConfigResponse, } from './types' @@ -526,6 +528,15 @@ export function updateLLMToolRouter(payload: Partial): return putJSON('/api/admin/llm/tool-router', payload) } +// LLM Topic Config API - 话题选择配置 +export function getLLMTopicConfig(): Promise { + return getJSON('/api/admin/llm/topic-config') +} + +export function updateLLMTopicConfig(payload: Partial): Promise { + return putJSON('/api/admin/llm/topic-config', payload) +} + // LLM Primary Config API - 主 AI 回复配置 export function getLLMPrimaryConfig(): Promise { return getJSON('/api/admin/llm/primary-config') @@ -536,4 +547,4 @@ export function updateLLMPrimaryConfig(payload: Partial } // 静默使用未导出类型,避免 TS6133(未使用的导入)。 -export type { LLMMessage, LLMMessageStatus, LLMProvider, LLMPlatformRouter, LLMPrimaryConfig } from './types' +export type { LLMMessage, LLMMessageStatus, LLMProvider, LLMPlatformRouter, LLMTopicConfig, LLMPrimaryConfig } from './types' diff --git a/meshmap_frontend/src/components/AdminLLMApi.vue b/meshmap_frontend/src/components/AdminLLMApi.vue index cd3db50..446fc45 100644 --- a/meshmap_frontend/src/components/AdminLLMApi.vue +++ b/meshmap_frontend/src/components/AdminLLMApi.vue @@ -5,12 +5,14 @@ import { deleteLLMProvider, getLLMProviders, getLLMToolRouter, + getLLMTopicConfig, getLLMPrimaryConfig, updateLLMProvider, updateLLMToolRouter, + updateLLMTopicConfig, updateLLMPrimaryConfig, } from '../api' -import type { LLMPlatformRouter, LLMProvider, LLMPrimaryConfig } from '../types' +import type { LLMPlatformRouter, LLMTopicConfig, LLMProvider, LLMPrimaryConfig } from '../types' const loading = ref(false) const error = ref('') @@ -44,6 +46,18 @@ const toolRouterForm = ref({ system_prompt: '', }) +// Topic Config 相关 - 话题选择配置 +const topicConfig = ref(null) +const editingTopicConfig = ref(false) + +const topicConfigForm = ref({ + enabled: false, + openai_name: '', + timeout: 30, + max_tokens: 512, + system_prompt: '', +}) + // Primary AI Config 相关 - 主 AI 回复配置 const primaryConfig = ref(null) const editingPrimaryConfig = ref(false) @@ -98,6 +112,16 @@ async function loadPrimaryConfig() { } } +async function loadTopicConfig() { + try { + const response = await getLLMTopicConfig() + topicConfig.value = response.item + } catch (err) { + // 如果不存在,使用默认值 + console.warn('Topic config not found, using defaults') + } +} + function openCreateProvider() { isCreatingProvider.value = true providerForm.value = { @@ -203,6 +227,35 @@ async function saveToolRouter() { } } +function openEditTopicConfig() { + if (topicConfig.value) { + topicConfigForm.value = { + enabled: topicConfig.value.enabled, + openai_name: topicConfig.value.openai_name, + timeout: topicConfig.value.timeout, + max_tokens: topicConfig.value.max_tokens, + system_prompt: topicConfig.value.system_prompt, + } + } + editingTopicConfig.value = true +} + +function closeTopicConfigForm() { + editingTopicConfig.value = false +} + +async function saveTopicConfig() { + try { + await updateLLMTopicConfig(topicConfigForm.value) + success.value = '更新成功' + clearSuccess() + closeTopicConfigForm() + await loadTopicConfig() + } catch (err) { + error.value = err instanceof Error ? err.message : String(err) + } +} + function openEditPrimaryConfig() { if (primaryConfig.value) { primaryConfigForm.value = { @@ -236,6 +289,7 @@ async function savePrimaryConfig() { onMounted(() => { loadProviders() loadToolRouter() + loadTopicConfig() loadPrimaryConfig() }) @@ -386,6 +440,90 @@ onMounted(() => { + +
+
+
+

话题选择配置

+

当工具路由未命中任何工具时,由话题选择判断是否回复。命中(输出 REPLY)才进入主回复,否则丢弃不回复。

+
+ +
+ +
+
+ +

未启用时,未命中工具的消息一律进入主回复(不做过滤)

+
+ +
+
+ + +

选择用于话题判定的 AI 提供商配置,留空则使用主回复配置

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

要求模型对应当回复的消息输出 REPLY,否则输出 IGNORE

+
+ +
+ + +
+
+ +
+
+ + {{ topicConfig.enabled ? '已启用' : '已停用' }} + +
+
+
+ 使用的 AI 配置 + {{ topicConfig.openai_name || '未设置(使用主回复配置)' }} +
+
+ 超时时间 + {{ topicConfig.timeout }} 秒 +
+
+ 最大 Token 数 + {{ topicConfig.max_tokens }} +
+
+ 系统提示词 +
{{ topicConfig.system_prompt }}
+
+
+
+ +
+

暂无话题选择配置,点击上方按钮进行配置。

+
+
+
diff --git a/meshmap_frontend/src/types.ts b/meshmap_frontend/src/types.ts index 00580c3..a18198b 100644 --- a/meshmap_frontend/src/types.ts +++ b/meshmap_frontend/src/types.ts @@ -669,6 +669,30 @@ export interface LLMPlatformRouterResponse { item: LLMPlatformRouter } +// LLM Topic Config 相关类型 - 话题选择配置 +export interface LLMTopicConfig { + id: number + enabled: boolean + openai_name: string + timeout: number + max_tokens: number + system_prompt: string + created_at: string + updated_at: string +} + +export interface LLMTopicConfigPayload { + enabled: boolean + openai_name: string + timeout: number + max_tokens: number + system_prompt: string +} + +export interface LLMTopicConfigResponse { + item: LLMTopicConfig +} + // LLM Primary Config 相关类型 - 主 AI 回复配置 export interface LLMPrimaryConfig { id: number