diff --git a/internal/ai/service.go b/internal/ai/service.go index 1faa1d8..7b71e8d 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -2,6 +2,7 @@ package ai import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -12,6 +13,7 @@ import ( "meshtastic_mqtt_server/internal/autoreply" "meshtastic_mqtt_server/internal/conversation" "meshtastic_mqtt_server/internal/llm" + storepkg "meshtastic_mqtt_server/internal/store" "meshtastic_mqtt_server/internal/toolmanager" "meshtastic_mqtt_server/internal/toolrouter" @@ -24,13 +26,22 @@ type ToolConfigStore interface { GetLLMPrimaryConfigEnableTool() (bool, error) } +// ToolRouterStore 是 ai 服务依赖的 ToolRouter 持久化接口; +// 通常由 *store.Store 实现(GetLLMToolRouter)。 +// 通过本接口我们可以让 toolrouter 在每轮调用时拉取最新配置, +// 让 /admin/llm/api 中的修改在保存后立即生效(无需重启)。 +type ToolRouterStore interface { + GetLLMToolRouter() (*storepkg.LLMToolRouterRecord, error) +} + // Config holds the AI service configuration type Config struct { - LLMProviders []llm.ProviderConfig - DataDir string - Enabled bool - ConsoleLog bool - ToolConfigStore ToolConfigStore + LLMProviders []llm.ProviderConfig + DataDir string + Enabled bool + ConsoleLog bool + ToolConfigStore ToolConfigStore + ToolRouterStore ToolRouterStore } // Service manages all AI-related components @@ -45,6 +56,41 @@ type Service struct { enabled bool } +// toolRouterConfigAdapter 把 ToolRouterStore 适配成 toolrouter.ConfigStore, +// 每次 LoadToolRouterConfig 都从 DB 拉取最新一行 llm_tool_router。 +type toolRouterConfigAdapter struct { + store ToolRouterStore +} + +// LoadToolRouterConfig 实现 toolrouter.ConfigStore。 +// 当 DB 没有记录时返回 nil + nil,由 toolrouter 内部回退到内存默认值。 +func (a *toolRouterConfigAdapter) LoadToolRouterConfig() (*toolrouter.Config, error) { + if a == nil || a.store == nil { + return nil, errors.New("tool router store is not configured") + } + record, err := a.store.GetLLMToolRouter() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return toolRouterConfigFromRecord(record), nil +} + +func toolRouterConfigFromRecord(r *storepkg.LLMToolRouterRecord) *toolrouter.Config { + if r == nil { + return nil + } + return &toolrouter.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 { @@ -67,14 +113,21 @@ func NewService(cfg Config, db *gorm.DB, botSender autoreply.BotSender) (*Servic return nil, fmt.Errorf("failed to initialize LLM state: %w", err) } - // Initialize tool router - toolRouterCfg := &toolrouter.Config{ - Enabled: true, - Timeout: 30, - MaxTokens: 512, - SystemPrompt: "你是一个智能助手,可以调用工具来回答用户问题。\n用户正在通过 Mesh 网络与你对话,请保持回答简洁明了。\n工具结果优先于模型内置知识。", + // 初始化 tool router:优先从 DB 读取已保存的配置,避免硬编码 prompt 把 + // 用户在 /admin/llm/api 配置好的内容覆盖掉。 + var ( + toolRouterCfg *toolrouter.Config + toolRouterOptions []toolrouter.Option + ) + if cfg.ToolRouterStore != nil { + adapter := &toolRouterConfigAdapter{store: cfg.ToolRouterStore} + // 启动时拉一次作为初始 cfg;失败或为空时让 toolrouter.NewState 走内置默认值。 + if loaded, loadErr := adapter.LoadToolRouterConfig(); loadErr == nil && loaded != nil { + toolRouterCfg = loaded + } + toolRouterOptions = append(toolRouterOptions, toolrouter.WithConfigStore(adapter)) } - toolRouter, err := toolrouter.NewState(toolRouterCfg, llmState) + toolRouter, err := toolrouter.NewState(toolRouterCfg, llmState, toolRouterOptions...) if err != nil { return nil, fmt.Errorf("failed to initialize tool router: %w", err) } diff --git a/internal/toolrouter/loop.go b/internal/toolrouter/loop.go index df40de4..e3bf03f 100644 --- a/internal/toolrouter/loop.go +++ b/internal/toolrouter/loop.go @@ -60,8 +60,8 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s if emit != nil { emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "prepare", Status: "success", Message: "已准备可用工具", Data: map[string]any{"tools": availableNames, "tool_descriptions": toolDescriptions}}) } - if state == nil || state.cfg == nil { - // No tool router config, but we have tools - use primary system prompt + if state == nil { + // No tool router state, but we have tools - use primary system prompt if strings.TrimSpace(systemPrompt) != "" { systemMessage := &model.ChatCompletionMessage{ Role: "system", @@ -74,8 +74,10 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s } return finalMessages, nil } + // 每轮调用都重新加载最新配置,确保管理员在 /admin/llm/api 保存后立即生效 + cfg := state.effectiveConfig() // Use tool router system prompt if available, otherwise fall back to primary system prompt - prompt := strings.TrimSpace(state.cfg.SystemPrompt) + prompt := strings.TrimSpace(cfg.SystemPrompt) if prompt == "" { prompt = strings.TrimSpace(systemPrompt) } @@ -96,11 +98,11 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s resp, err := completion.CompleteChat(ctx, routerProfile, model.CreateChatCompletionRequest{ Model: routerProfile.Config.Model, Messages: decisionMessages, - MaxTokens: &state.cfg.MaxTokens, + MaxTokens: &cfg.MaxTokens, Tools: definitions, ToolChoice: model.ToolChoiceStringTypeAuto, ParallelToolCalls: BoolPtr(false), - }, time.Duration(state.cfg.Timeout)*time.Second) + }, time.Duration(cfg.Timeout)*time.Second) if err != nil { return finalMessages, err } diff --git a/internal/toolrouter/state.go b/internal/toolrouter/state.go index c060275..c87e2f0 100644 --- a/internal/toolrouter/state.go +++ b/internal/toolrouter/state.go @@ -17,15 +17,30 @@ type Config struct { SystemPrompt string } +// ConfigStore 定义从持久化层读取最新 ToolRouter 配置的能力。 +// 每次 RunAgentToolLoop 都会调用 LoadToolRouterConfig,从而保证管理员 +// 在 /admin/llm/api 修改配置后立即生效,无需重启。 +type ConfigStore interface { + LoadToolRouterConfig() (*Config, error) +} + // State manages the tool router state type State struct { - cfg *Config - ai *llm.State + 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 tool router state func NewState(cfg *Config, ai *llm.State, options ...Option) (*State, error) { if cfg == nil { @@ -51,12 +66,31 @@ func NewState(cfg *Config, ai *llm.State, options ...Option) (*State, error) { 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.LoadToolRouterConfig(); err == nil && latest != nil { + s.cfg = latest + return latest + } + } + if s.cfg == nil { + return &Config{} + } + return s.cfg +} + // RouterProfile returns the LLM profile configured for the tool router func (s *State) RouterProfile(fallback *llm.Profile) *llm.Profile { - if s == nil || s.cfg == nil || s.ai == nil { + if s == nil || s.ai == nil { return fallback } - name := strings.TrimSpace(s.cfg.OpenAIName) + cfg := s.effectiveConfig() + name := strings.TrimSpace(cfg.OpenAIName) if name == "" { return fallback } @@ -69,8 +103,8 @@ func (s *State) RouterProfile(fallback *llm.Profile) *llm.Profile { // Config returns a copy of the current configuration func (s *State) Config() Config { - if s == nil || s.cfg == nil { + if s == nil { return Config{} } - return *s.cfg + return *s.effectiveConfig() } diff --git a/main.go b/main.go index fb5ff22..3cc70da 100644 --- a/main.go +++ b/main.go @@ -422,6 +422,7 @@ func run(cfg *configpkg.Config) error { Enabled: cfg.AI.Enabled, ConsoleLog: cfg.ConsoleLog.LLM, ToolConfigStore: store, + ToolRouterStore: store, }, store.DB(), botSenderAdapter) if err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to initialize AI service: %v\n", err)