From 2f83308dcee9150a3c1f3adc28edcd8ed984b06f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=A0=E9=97=BB=E9=A3=8E?= Date: Tue, 23 Jun 2026 21:13:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20LLM=20Provider=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E7=83=AD=E6=9B=B4=E6=96=B0=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:在 /admin/llm/api 页面修改 AI 提供商配置后,配置没有立即生效,AI 依然使用旧的提供商工作 根本原因: - LLM Provider 配置在程序启动时加载到内存 (llm.State) - 管理后台修改配置时,只更新了数据库,内存配置未更新 - AI 继续使用内存中的旧配置 解决方案: 1. 为 llm.State 添加动态更新方法 (UpdateProvider/AddProvider/RemoveProvider) 2. 在 AI Service 中暴露配置更新接口,支持运行时重新加载 3. 在配置保存后自动触发内存配置重新加载 4. 创建新的 LLM Client 使新配置立即生效 关键特性: - 线程安全:使用 sync.RWMutex 保护并发访问 - 容错处理:重新加载失败不影响数据库更新 - 无需重启:配置修改后立即生效 - 完整测试:添加单元测试验证功能 修改文件: - internal/llm/state.go: 添加配置更新方法 - internal/ai/service.go: 添加配置重新加载接口 - internal/llmadmin/admin_llm_routes.go: 配置更新时触发重新加载 - internal/web/web.go: 传递 AI Service 到路由 - main.go: 连接组件 - internal/llm/state_test.go: 新增单元测试 - internal/web/map_tile_proxy_routes_test.go: 修复测试 测试: - ✅ 所有现有测试通过 - ✅ 新增测试覆盖核心功能 - ✅ 项目成功编译 Co-Authored-By: Claude Fable 5 --- CHANGELOG_LLM_CONFIG_FIX.md | 113 +++++++++++++++ internal/ai/service.go | 66 +++++++++ internal/llm/state.go | 134 +++++++++++++++++ internal/llm/state_test.go | 160 +++++++++++++++++++++ internal/llmadmin/admin_llm_routes.go | 77 +++++++++- internal/web/map_tile_proxy_routes_test.go | 8 +- internal/web/web.go | 19 ++- main.go | 2 +- 8 files changed, 561 insertions(+), 18 deletions(-) create mode 100644 CHANGELOG_LLM_CONFIG_FIX.md create mode 100644 internal/llm/state_test.go diff --git a/CHANGELOG_LLM_CONFIG_FIX.md b/CHANGELOG_LLM_CONFIG_FIX.md new file mode 100644 index 0000000..fed6e51 --- /dev/null +++ b/CHANGELOG_LLM_CONFIG_FIX.md @@ -0,0 +1,113 @@ +# LLM Provider 配置热更新修复 + +## 问题描述 + +在 `/admin/llm/api` 页面修改 AI 提供商配置后,配置没有立即生效,AI 依然使用旧的提供商在工作。 + +## 根本原因 + +1. **LLM Provider 配置在程序启动时加载到内存** (`llm.State`) +2. **通过管理后台修改配置时,只更新了数据库**,内存中的配置没有更新 +3. **AI 继续使用内存中的旧配置**工作 + +## 解决方案 + +### 1. 为 `llm.State` 添加动态更新方法 + +在 `internal/llm/state.go` 中添加了三个新方法: + +- **`UpdateProvider(config ProviderConfig) error`** - 更新现有 provider 的配置 +- **`AddProvider(config ProviderConfig) error`** - 添加新的 provider +- **`RemoveProvider(name string) error`** - 删除 provider + +这些方法会: +- 更新内存中的配置 +- 重新创建 LLM Client(使用新的 API Key、BaseURL 等) +- 管理 active provider 的切换 + +### 2. 在 AI Service 中暴露配置更新接口 + +在 `internal/ai/service.go` 中添加了: + +```go +func (s *Service) ReloadLLMProvider(config interface{}) error +func (s *Service) AddLLMProvider(config interface{}) error +func (s *Service) RemoveLLMProvider(name string) error +``` + +这些方法接受 `interface{}` 类型(来自 HTTP handler 的 map),转换为 `llm.ProviderConfig`,然后调用 `llm.State` 的相应方法。 + +### 3. 在管理路由中触发配置重新加载 + +修改了 `internal/llmadmin/admin_llm_routes.go`: + +- 添加了 `LLMProviderReloader` 接口 +- `RegisterRoutes` 接受 `aiService LLMProviderReloader` 参数 +- `handleCreateLLMProvider`、`handleUpdateLLMProvider`、`handleDeleteLLMProvider` 在数据库操作成功后,调用 `aiService` 的相应方法更新内存配置 + +### 4. 连接所有组件 + +修改了: +- `internal/web/web.go` - 添加 `LLMProviderReloader` 接口,并在路由注册时传递 `aiService` +- `main.go` - 将 `aiService` 传递给 web router + +## 关键特性 + +### 线程安全 +所有 `llm.State` 的更新操作都使用 `sync.RWMutex` 保护,确保并发安全。 + +### 容错处理 +如果配置重新加载失败(例如 AI Service 未启用),不会导致请求失败。数据库已经更新,只会返回一个警告信息。 + +### Active Provider 管理 +- 更新 provider 为 active 时,自动切换到该 provider +- 删除当前 active provider 时,自动切换到第一个可用的 provider +- 不允许删除最后一个 provider + +## 测试 + +创建了 `internal/llm/state_test.go`,包含以下测试: +- `TestUpdateProvider` - 验证配置更新功能 +- `TestAddProvider` - 验证添加新 provider +- `TestRemoveProvider` - 验证删除 provider + +所有测试都通过 ✅ + +## 使用效果 + +修复后,在 `/admin/llm/api` 页面修改 AI 提供商配置时: +1. 配置保存到数据库 +2. 立即更新内存中的配置 +3. 创建新的 LLM Client +4. **无需重启服务**,配置立即生效 + +## 影响范围 + +### 修改的文件 +- `internal/llm/state.go` - 添加配置更新方法 +- `internal/ai/service.go` - 添加配置重新加载接口 +- `internal/llmadmin/admin_llm_routes.go` - 在配置更新时触发重新加载 +- `internal/web/web.go` - 传递 AI Service 到路由 +- `main.go` - 连接组件 + +### 新增的文件 +- `internal/llm/state_test.go` - 单元测试 + +### 兼容性 +- 完全向后兼容 +- 如果 AI Service 未启用(`aiService == nil`),配置更新仍然正常工作,只是不会触发重新加载 +- 不影响现有功能 + +## 类似功能参考 + +该实现参考了项目中已有的 ToolRouter 和 TopicRouter 的配置热更新机制,它们通过 `ConfigStore` 接口在每次使用时从数据库重新加载配置。 + +不同之处在于: +- **ToolRouter/TopicRouter**: 每次使用时从 DB 读取(配置小,读取频率低) +- **LLM Provider**: 在配置更新时主动重新加载(Client 创建有开销,不适合频繁创建) + +## 未来改进建议 + +1. 添加配置验证:在更新前验证 API Key、BaseURL 是否可用 +2. 添加配置变更日志:记录谁在何时修改了配置 +3. 支持配置回滚:保存历史版本,出问题时可以快速回滚 diff --git a/internal/ai/service.go b/internal/ai/service.go index 163551f..9bddc1e 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -258,3 +258,69 @@ func (s *Service) Stop() { func (s *Service) Enabled() bool { return s.enabled } + +// ReloadLLMProvider reloads a specific LLM provider configuration +func (s *Service) ReloadLLMProvider(config interface{}) error { + if !s.enabled || s.LLMState == nil { + return nil + } + providerConfig, err := convertToProviderConfig(config) + if err != nil { + return err + } + return s.LLMState.UpdateProvider(providerConfig) +} + +// AddLLMProvider adds a new LLM provider +func (s *Service) AddLLMProvider(config interface{}) error { + if !s.enabled || s.LLMState == nil { + return nil + } + providerConfig, err := convertToProviderConfig(config) + if err != nil { + return err + } + return s.LLMState.AddProvider(providerConfig) +} + +// RemoveLLMProvider removes an LLM provider +func (s *Service) RemoveLLMProvider(name string) error { + if !s.enabled || s.LLMState == nil { + return nil + } + return s.LLMState.RemoveProvider(name) +} + +// convertToProviderConfig converts a map to llm.ProviderConfig +func convertToProviderConfig(config interface{}) (llm.ProviderConfig, error) { + m, ok := config.(map[string]interface{}) + if !ok { + return llm.ProviderConfig{}, fmt.Errorf("invalid config type: expected map[string]interface{}") + } + + pc := llm.ProviderConfig{} + + if v, ok := m["Name"].(string); ok { + pc.Name = v + } + if v, ok := m["Active"].(bool); ok { + pc.Active = v + } + if v, ok := m["APIKey"].(string); ok { + pc.APIKey = v + } + if v, ok := m["BaseURL"].(string); ok { + pc.BaseURL = v + } + if v, ok := m["Model"].(string); ok { + pc.Model = v + } + if v, ok := m["Timeout"].(int); ok { + pc.Timeout = v + } + if v, ok := m["ContextWindowTokens"].(int); ok { + pc.ContextWindowTokens = v + } + + return pc, nil +} diff --git a/internal/llm/state.go b/internal/llm/state.go index 685bd6c..d65dcc8 100644 --- a/internal/llm/state.go +++ b/internal/llm/state.go @@ -135,3 +135,137 @@ func (s *State) ListProfiles() []ProviderConfig { } return profiles } + +// UpdateProvider updates an existing provider's configuration +func (s *State) UpdateProvider(config ProviderConfig) error { + name := strings.TrimSpace(config.Name) + if name == "" { + return errors.New("llm provider name cannot be empty") + } + if strings.TrimSpace(config.APIKey) == "" { + return fmt.Errorf("llm provider %s api_key is required", name) + } + if strings.TrimSpace(config.Model) == "" { + return fmt.Errorf("llm provider %s model is required", name) + } + if strings.TrimSpace(config.BaseURL) == "" { + return fmt.Errorf("llm provider %s base_url is required", name) + } + if config.Timeout <= 0 { + config.Timeout = 120 + } + + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.profiles[name]; !ok { + return fmt.Errorf("llm provider not found: %s", name) + } + + // Create new client with updated config + s.profiles[name] = &Profile{ + Config: config, + Client: ark.NewClientWithApiKey( + config.APIKey, + ark.WithBaseUrl(config.BaseURL), + ark.WithTimeout(time.Duration(config.Timeout)*time.Second), + ), + } + + // Update active status if needed + if config.Active && s.activeName != name { + s.activeName = name + } else if !config.Active && s.activeName == name { + // If we're deactivating the current active provider, switch to the first available + for _, otherName := range s.order { + if otherName != name { + s.activeName = otherName + break + } + } + } + + return nil +} + +// AddProvider adds a new provider to the state +func (s *State) AddProvider(config ProviderConfig) error { + name := strings.TrimSpace(config.Name) + if name == "" { + return errors.New("llm provider name cannot be empty") + } + if strings.TrimSpace(config.APIKey) == "" { + return fmt.Errorf("llm provider %s api_key is required", name) + } + if strings.TrimSpace(config.Model) == "" { + return fmt.Errorf("llm provider %s model is required", name) + } + if strings.TrimSpace(config.BaseURL) == "" { + return fmt.Errorf("llm provider %s base_url is required", name) + } + if config.Timeout <= 0 { + config.Timeout = 120 + } + + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.profiles[name]; ok { + return fmt.Errorf("llm provider already exists: %s", name) + } + + s.profiles[name] = &Profile{ + Config: config, + Client: ark.NewClientWithApiKey( + config.APIKey, + ark.WithBaseUrl(config.BaseURL), + ark.WithTimeout(time.Duration(config.Timeout)*time.Second), + ), + } + s.order = append(s.order, name) + + // Set as active if it's the first one or explicitly marked active + if len(s.profiles) == 1 || config.Active { + s.activeName = name + } + + return nil +} + +// RemoveProvider removes a provider from the state +func (s *State) RemoveProvider(name string) error { + name = strings.TrimSpace(name) + if name == "" { + return errors.New("llm provider name cannot be empty") + } + + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.profiles[name]; !ok { + return fmt.Errorf("llm provider not found: %s", name) + } + + // Don't allow removing the last provider + if len(s.profiles) == 1 { + return errors.New("cannot remove the last llm provider") + } + + delete(s.profiles, name) + + // Remove from order + newOrder := make([]string, 0, len(s.order)-1) + for _, n := range s.order { + if n != name { + newOrder = append(newOrder, n) + } + } + s.order = newOrder + + // If we removed the active provider, switch to the first available + if s.activeName == name { + s.activeName = s.order[0] + } + + return nil +} diff --git a/internal/llm/state_test.go b/internal/llm/state_test.go new file mode 100644 index 0000000..11b9d7f --- /dev/null +++ b/internal/llm/state_test.go @@ -0,0 +1,160 @@ +package llm + +import ( + "testing" +) + +func TestUpdateProvider(t *testing.T) { + // Create initial state with one provider + configs := []ProviderConfig{ + { + Name: "test-provider", + Active: true, + APIKey: "test-key", + BaseURL: "https://test.example.com", + Model: "test-model", + Timeout: 120, + ContextWindowTokens: 4096, + }, + } + + state, err := NewState(configs) + if err != nil { + t.Fatalf("failed to create state: %v", err) + } + + // Get the initial profile + profile := state.ActiveProfile() + if profile.Config.APIKey != "test-key" { + t.Errorf("expected APIKey 'test-key', got '%s'", profile.Config.APIKey) + } + + // Update the provider with new config + updatedConfig := ProviderConfig{ + Name: "test-provider", + Active: true, + APIKey: "new-key", + BaseURL: "https://new.example.com", + Model: "new-model", + Timeout: 60, + ContextWindowTokens: 8192, + } + + err = state.UpdateProvider(updatedConfig) + if err != nil { + t.Fatalf("failed to update provider: %v", err) + } + + // Verify the update + profile = state.ActiveProfile() + if profile.Config.APIKey != "new-key" { + t.Errorf("expected updated APIKey 'new-key', got '%s'", profile.Config.APIKey) + } + if profile.Config.BaseURL != "https://new.example.com" { + t.Errorf("expected updated BaseURL 'https://new.example.com', got '%s'", profile.Config.BaseURL) + } + if profile.Config.Model != "new-model" { + t.Errorf("expected updated Model 'new-model', got '%s'", profile.Config.Model) + } + if profile.Config.Timeout != 60 { + t.Errorf("expected updated Timeout 60, got %d", profile.Config.Timeout) + } +} + +func TestAddProvider(t *testing.T) { + // Create initial state with one provider + configs := []ProviderConfig{ + { + Name: "provider1", + Active: true, + APIKey: "key1", + BaseURL: "https://example1.com", + Model: "model1", + Timeout: 120, + ContextWindowTokens: 4096, + }, + } + + state, err := NewState(configs) + if err != nil { + t.Fatalf("failed to create state: %v", err) + } + + // Add a second provider + newConfig := ProviderConfig{ + Name: "provider2", + Active: false, + APIKey: "key2", + BaseURL: "https://example2.com", + Model: "model2", + Timeout: 60, + ContextWindowTokens: 8192, + } + + err = state.AddProvider(newConfig) + if err != nil { + t.Fatalf("failed to add provider: %v", err) + } + + // Verify the new provider exists + profile, err := state.GetProfile("provider2") + if err != nil { + t.Fatalf("failed to get provider2: %v", err) + } + if profile.Config.Name != "provider2" { + t.Errorf("expected name 'provider2', got '%s'", profile.Config.Name) + } + + // Verify active provider is still provider1 + activeProfile := state.ActiveProfile() + if activeProfile.Config.Name != "provider1" { + t.Errorf("expected active provider 'provider1', got '%s'", activeProfile.Config.Name) + } +} + +func TestRemoveProvider(t *testing.T) { + // Create initial state with two providers + configs := []ProviderConfig{ + { + Name: "provider1", + Active: true, + APIKey: "key1", + BaseURL: "https://example1.com", + Model: "model1", + Timeout: 120, + ContextWindowTokens: 4096, + }, + { + Name: "provider2", + Active: false, + APIKey: "key2", + BaseURL: "https://example2.com", + Model: "model2", + Timeout: 60, + ContextWindowTokens: 8192, + }, + } + + state, err := NewState(configs) + if err != nil { + t.Fatalf("failed to create state: %v", err) + } + + // Remove provider2 + err = state.RemoveProvider("provider2") + if err != nil { + t.Fatalf("failed to remove provider: %v", err) + } + + // Verify provider2 is gone + _, err = state.GetProfile("provider2") + if err == nil { + t.Error("expected error when getting removed provider, got nil") + } + + // Try to remove the last provider (should fail) + err = state.RemoveProvider("provider1") + if err == nil { + t.Error("expected error when removing last provider, got nil") + } +} diff --git a/internal/llmadmin/admin_llm_routes.go b/internal/llmadmin/admin_llm_routes.go index 974f494..54eae9f 100644 --- a/internal/llmadmin/admin_llm_routes.go +++ b/internal/llmadmin/admin_llm_routes.go @@ -13,7 +13,14 @@ import ( "meshtastic_mqtt_server/internal/webutil" ) -func RegisterRoutes(r *gin.RouterGroup, store *storepkg.Store) { +// LLMProviderReloader is the interface for reloading LLM provider configuration +type LLMProviderReloader interface { + ReloadLLMProvider(config interface{}) error + AddLLMProvider(config interface{}) error + RemoveLLMProvider(name string) error +} + +func RegisterRoutes(r *gin.RouterGroup, store *storepkg.Store, aiService LLMProviderReloader) { group := r.Group("/llm") { // LLM Message Queue @@ -27,9 +34,9 @@ func RegisterRoutes(r *gin.RouterGroup, store *storepkg.Store) { // LLM Providers group.GET("/providers", handleListLLMProviders(store)) group.GET("/providers/:name", handleGetLLMProvider(store)) - group.POST("/providers", handleCreateLLMProvider(store)) - group.PUT("/providers/:name", handleUpdateLLMProvider(store)) - group.DELETE("/providers/:name", handleDeleteLLMProvider(store)) + group.POST("/providers", handleCreateLLMProvider(store, aiService)) + group.PUT("/providers/:name", handleUpdateLLMProvider(store, aiService)) + group.DELETE("/providers/:name", handleDeleteLLMProvider(store, aiService)) // LLM Tool Router group.GET("/tool-router", handleGetLLMToolRouter(store)) @@ -254,7 +261,7 @@ func handleGetLLMProvider(store *storepkg.Store) gin.HandlerFunc { } } -func handleCreateLLMProvider(store *storepkg.Store) gin.HandlerFunc { +func handleCreateLLMProvider(store *storepkg.Store, aiService LLMProviderReloader) gin.HandlerFunc { return func(c *gin.Context) { var req struct { Name string `json:"name"` @@ -290,11 +297,33 @@ func handleCreateLLMProvider(store *storepkg.Store) gin.HandlerFunc { return } + // Reload AI service with new provider + if aiService != nil { + providerConfig := map[string]interface{}{ + "Name": record.Name, + "Active": record.Active, + "APIKey": record.APIKey, + "BaseURL": record.BaseURL, + "Model": record.Model, + "Timeout": record.Timeout, + "ContextWindowTokens": record.ContextWindowTokens, + } + if err := aiService.AddLLMProvider(providerConfig); err != nil { + // Log warning but don't fail the request - database is already updated + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "item": llmProviderDTO(*record), + "warning": "provider created but failed to reload AI service: " + err.Error(), + }) + return + } + } + c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmProviderDTO(*record)}) } } -func handleUpdateLLMProvider(store *storepkg.Store) gin.HandlerFunc { +func handleUpdateLLMProvider(store *storepkg.Store, aiService LLMProviderReloader) gin.HandlerFunc { return func(c *gin.Context) { name := c.Param("name") if name == "" { @@ -355,11 +384,33 @@ func handleUpdateLLMProvider(store *storepkg.Store) gin.HandlerFunc { return } + // Reload AI service with updated provider + if aiService != nil { + providerConfig := map[string]interface{}{ + "Name": record.Name, + "Active": record.Active, + "APIKey": record.APIKey, + "BaseURL": record.BaseURL, + "Model": record.Model, + "Timeout": record.Timeout, + "ContextWindowTokens": record.ContextWindowTokens, + } + if err := aiService.ReloadLLMProvider(providerConfig); err != nil { + // Log warning but don't fail the request - database is already updated + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "item": llmProviderDTO(*record), + "warning": "provider updated but failed to reload AI service: " + err.Error(), + }) + return + } + } + c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmProviderDTO(*record)}) } } -func handleDeleteLLMProvider(store *storepkg.Store) gin.HandlerFunc { +func handleDeleteLLMProvider(store *storepkg.Store, aiService LLMProviderReloader) gin.HandlerFunc { return func(c *gin.Context) { name := c.Param("name") if name == "" { @@ -372,6 +423,18 @@ func handleDeleteLLMProvider(store *storepkg.Store) gin.HandlerFunc { return } + // Remove provider from AI service + if aiService != nil { + if err := aiService.RemoveLLMProvider(name); err != nil { + // Log warning but don't fail the request - database is already updated + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "warning": "provider deleted but failed to reload AI service: " + err.Error(), + }) + return + } + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) } } diff --git a/internal/web/map_tile_proxy_routes_test.go b/internal/web/map_tile_proxy_routes_test.go index 77d7bcc..4941839 100644 --- a/internal/web/map_tile_proxy_routes_test.go +++ b/internal/web/map_tile_proxy_routes_test.go @@ -38,7 +38,7 @@ func TestMapTileProxyFetchesAndCaches(t *testing.T) { } cacheDir := t.TempDir() - router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: cacheDir}, false, st, nil, nil, nil, nil, nil, nil) + router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: cacheDir}, false, st, nil, nil, nil, nil, nil, nil, nil) url := "/api/map/" + row.URLTemplateHash + "?x=1&y=2&z=3" for i := 0; i < 2; i++ { @@ -75,7 +75,7 @@ func TestMapTileProxyRejectsInvalidCoordinates(t *testing.T) { t.Fatalf("CreateMapTileSource() error = %v", err) } - router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil) + router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil, nil) cases := []string{ "/api/map/" + row.URLTemplateHash + "?y=0&z=0", @@ -106,7 +106,7 @@ func TestMapTileProxyUnknownAndDisabledSource(t *testing.T) { t.Fatalf("CreateMapTileSource(proxy disabled) error = %v", err) } - router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil) + router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil, nil) cases := []string{ "/api/map/not-a-hash?x=0&y=0&z=0", @@ -147,7 +147,7 @@ func TestMapTileProxyUpstreamStatus(t *testing.T) { t.Fatalf("CreateMapTileSource(500) error = %v", err) } - router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil) + router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil, nil) cases := []struct { url string diff --git a/internal/web/web.go b/internal/web/web.go index 9b7c0d8..41543cd 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -27,10 +27,17 @@ import ( "meshtastic_mqtt_server/internal/webutil" ) -func NewHTTPServer(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender) *http.Server { +// LLMProviderReloader is the interface for reloading LLM provider configuration +type LLMProviderReloader interface { + ReloadLLMProvider(config interface{}) error + AddLLMProvider(config interface{}) error + RemoveLLMProvider(name string) error +} + +func NewHTTPServer(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender, aiService LLMProviderReloader) *http.Server { return &http.Server{ Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)), - Handler: NewRouter(cfg, consoleLog, store, sessions, mqttStatus, blocking, forwarder, settings, botSender), + Handler: NewRouter(cfg, consoleLog, store, sessions, mqttStatus, blocking, forwarder, settings, botSender, aiService), } } @@ -60,7 +67,7 @@ func ServeUnixSocket(server *http.Server, socketPath string) error { return server.Serve(listener) } -func NewRouter(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender) *gin.Engine { +func NewRouter(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender, aiService LLMProviderReloader) *gin.Engine { r := gin.New() if consoleLog { r.Use(gin.Logger(), gin.Recovery()) @@ -69,7 +76,7 @@ func NewRouter(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store, } api := r.Group("/api") registerAPIRoutes(api, store, cfg.MapTileCacheDir) - registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder, settings, botSender) + registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder, settings, botSender, aiService) registerStaticRoutes(r, cfg.StaticDir) return r } @@ -163,7 +170,7 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str }) } -func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender) { +func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender, aiService LLMProviderReloader) { type loginRequest struct { Username string `json:"username"` Password string `json:"password"` @@ -230,7 +237,7 @@ func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Ma mappkg.RegisterAdminRoutes(protected, store) helppkg.RegisterAdminRoutes(protected, store) botpkg.RegisterRoutes(protected, store, botSender) - llmadminpkg.RegisterRoutes(protected, store) + llmadminpkg.RegisterRoutes(protected, store, aiService) protected.GET("/me", func(c *gin.Context) { claims := c.MustGet("admin_claims").(*auth.SessionClaims) c.JSON(http.StatusOK, gin.H{"user": auth.AdminUserDTO{Username: claims.Username, Role: claims.Role}}) diff --git a/main.go b/main.go index b2d8b6a..ae1e1ae 100644 --- a/main.go +++ b/main.go @@ -458,7 +458,7 @@ func run(cfg *configpkg.Config) error { return err } mqttStatus := webpkg.MQTTRuntimeStatus{Server: server, Address: mqttAddr, TLS: cfg.MQTT.TLS.Enabled, Stats: messageStats, ClientStats: clientStats, DBQueue: dbQueue} - handler := webpkg.NewRouter(cfg.Web, cfg.ConsoleLog.Web, store, sessions, mqttStatus, blocking, forwardManager, settings, botSender) + handler := webpkg.NewRouter(cfg.Web, cfg.ConsoleLog.Web, store, sessions, mqttStatus, blocking, forwardManager, settings, botSender, aiService) webAddresses := []string{} if cfg.Web.PortEnabled { httpServer := &http.Server{