This commit is contained in:
2026-06-20 01:45:24 +08:00
parent 94835a5f1d
commit fd766be731
12 changed files with 727 additions and 30 deletions
+76 -13
View File
@@ -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
}
+27 -1
View File
@@ -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
+19
View File
@@ -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"
+122
View File
@@ -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
// ============================================
+20
View File
@@ -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
}
+53
View File
@@ -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 回复配置
// ============================================
+15 -8
View File
@@ -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) {
+213
View File
@@ -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 或 IGNOREREPLY 表示应当回复,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 profileOpenAIName 为空时回退到 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 对最近一条用户消息做话题判定。
// 返回值 shouldReplytrue 表示命中/放行(应进入主回复),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 ""
}
+7 -6
View File
@@ -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)
+12 -1
View File
@@ -54,6 +54,8 @@ import type {
LLMProviderResponse,
LLMPlatformRouterPayload,
LLMPlatformRouterResponse,
LLMTopicConfigPayload,
LLMTopicConfigResponse,
LLMPrimaryConfigPayload,
LLMPrimaryConfigResponse,
} from './types'
@@ -526,6 +528,15 @@ export function updateLLMToolRouter(payload: Partial<LLMPlatformRouterPayload>):
return putJSON<LLMPlatformRouterResponse>('/api/admin/llm/tool-router', payload)
}
// LLM Topic Config API - 话题选择配置
export function getLLMTopicConfig(): Promise<LLMTopicConfigResponse> {
return getJSON<LLMTopicConfigResponse>('/api/admin/llm/topic-config')
}
export function updateLLMTopicConfig(payload: Partial<LLMTopicConfigPayload>): Promise<LLMTopicConfigResponse> {
return putJSON<LLMTopicConfigResponse>('/api/admin/llm/topic-config', payload)
}
// LLM Primary Config API - 主 AI 回复配置
export function getLLMPrimaryConfig(): Promise<LLMPrimaryConfigResponse> {
return getJSON<LLMPrimaryConfigResponse>('/api/admin/llm/primary-config')
@@ -536,4 +547,4 @@ export function updateLLMPrimaryConfig(payload: Partial<LLMPrimaryConfigPayload>
}
// 静默使用未导出类型,避免 TS6133(未使用的导入)。
export type { LLMMessage, LLMMessageStatus, LLMProvider, LLMPlatformRouter, LLMPrimaryConfig } from './types'
export type { LLMMessage, LLMMessageStatus, LLMProvider, LLMPlatformRouter, LLMTopicConfig, LLMPrimaryConfig } from './types'
+139 -1
View File
@@ -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<LLMTopicConfig | null>(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<LLMPrimaryConfig | null>(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()
})
</script>
@@ -386,6 +440,90 @@ onMounted(() => {
</div>
</div>
<!-- Topic Config 配置 - 话题选择配置 -->
<div class="admin-section">
<div class="section-header">
<div>
<h3>话题选择配置</h3>
<p class="section-desc">当工具路由未命中任何工具时由话题选择判断是否回复命中输出 REPLY才进入主回复否则丢弃不回复</p>
</div>
<button v-if="!editingTopicConfig" class="admin-button" @click="openEditTopicConfig">编辑配置</button>
</div>
<div v-if="editingTopicConfig" class="tool-router-form">
<div class="form-group">
<label>
<input type="checkbox" v-model="topicConfigForm.enabled" />
启用话题选择
</label>
<p class="form-hint">未启用时未命中工具的消息一律进入主回复不做过滤</p>
</div>
<div class="form-row">
<div class="form-group">
<label>使用的 AI 配置</label>
<select v-model="topicConfigForm.openai_name" class="form-input">
<option value="">请选择</option>
<option v-for="p in activeProviders" :key="p.name" :value="p.name">{{ p.name }}</option>
</select>
<p class="form-hint">选择用于话题判定的 AI 提供商配置留空则使用主回复配置</p>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>超时时间</label>
<input type="number" v-model.number="topicConfigForm.timeout" class="form-input" min="1" />
</div>
<div class="form-group">
<label>最大 Token </label>
<input type="number" v-model.number="topicConfigForm.max_tokens" class="form-input" min="1" />
</div>
</div>
<div class="form-group">
<label>系统提示词</label>
<textarea v-model="topicConfigForm.system_prompt" class="form-textarea" rows="6"></textarea>
<p class="form-hint">要求模型对应当回复的消息输出 REPLY否则输出 IGNORE</p>
</div>
<div class="form-actions">
<button class="admin-button admin-button-secondary" @click="closeTopicConfigForm">取消</button>
<button class="admin-button" @click="saveTopicConfig">保存</button>
</div>
</div>
<div v-else-if="topicConfig" class="tool-router-display">
<div class="router-status">
<span class="status-badge" :class="{ active: topicConfig.enabled, inactive: !topicConfig.enabled }">
{{ topicConfig.enabled ? '已启用' : '已停用' }}
</span>
</div>
<div class="router-details">
<div class="detail-row">
<span class="detail-label">使用的 AI 配置</span>
<span class="detail-value">{{ topicConfig.openai_name || '未设置(使用主回复配置)' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">超时时间</span>
<span class="detail-value">{{ topicConfig.timeout }} </span>
</div>
<div class="detail-row">
<span class="detail-label">最大 Token </span>
<span class="detail-value">{{ topicConfig.max_tokens }}</span>
</div>
<div class="detail-row full-width">
<span class="detail-label">系统提示词</span>
<pre class="detail-value system-prompt">{{ topicConfig.system_prompt }}</pre>
</div>
</div>
</div>
<div v-else class="empty-state">
<p>暂无话题选择配置点击上方按钮进行配置</p>
</div>
</div>
<!-- Primary AI Config 配置 - AI 回复配置 -->
<div class="admin-section">
<div class="section-header">
+24
View File
@@ -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