Files
meshtastic_mqtt_server/internal/llm/state.go
T
kevinandClaude Fable 5 2f83308dce 修复 LLM Provider 配置热更新问题
问题:在 /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 <noreply@anthropic.com>
2026-06-23 21:13:31 +08:00

272 lines
6.7 KiB
Go

package llm
import (
"errors"
"fmt"
"strings"
"sync"
"time"
ark "github.com/volcengine/volcengine-go-sdk/service/arkruntime"
)
// Profile represents an LLM provider configuration with client
type Profile struct {
Config ProviderConfig
Client *ark.Client
}
// ProviderConfig holds the configuration for an LLM provider
type ProviderConfig struct {
Name string
Active bool
APIKey string
BaseURL string
Model string
Timeout int
ContextWindowTokens int
}
// State manages LLM profiles
type State struct {
mu sync.RWMutex
profiles map[string]*Profile
order []string
activeName string
}
// NewState creates a new LLM state from provider configurations
func NewState(configs []ProviderConfig) (*State, error) {
state := &State{
profiles: make(map[string]*Profile, len(configs)),
order: make([]string, 0, len(configs)),
}
for _, item := range configs {
name := strings.TrimSpace(item.Name)
if name == "" {
return nil, errors.New("llm provider name cannot be empty")
}
if strings.TrimSpace(item.APIKey) == "" {
return nil, fmt.Errorf("llm provider %s api_key is required", name)
}
if strings.TrimSpace(item.Model) == "" {
return nil, fmt.Errorf("llm provider %s model is required", name)
}
if strings.TrimSpace(item.BaseURL) == "" {
return nil, fmt.Errorf("llm provider %s base_url is required", name)
}
if item.Timeout <= 0 {
item.Timeout = 120
}
if _, ok := state.profiles[name]; ok {
return nil, fmt.Errorf("duplicate llm provider name: %s", name)
}
state.profiles[name] = &Profile{
Config: item,
Client: ark.NewClientWithApiKey(
item.APIKey,
ark.WithBaseUrl(item.BaseURL),
ark.WithTimeout(time.Duration(item.Timeout)*time.Second),
),
}
state.order = append(state.order, name)
if item.Active && state.activeName == "" {
state.activeName = name
}
}
if len(state.order) == 0 {
return nil, errors.New("at least one llm provider is required")
}
if state.activeName == "" {
state.activeName = state.order[0]
}
return state, nil
}
// ActiveProfile returns the currently active LLM profile
func (s *State) ActiveProfile() *Profile {
s.mu.RLock()
defer s.mu.RUnlock()
return s.profiles[s.activeName]
}
// GetProfile returns a profile by name, or the active profile if name is empty
func (s *State) GetProfile(name string) (*Profile, error) {
s.mu.RLock()
defer s.mu.RUnlock()
name = strings.TrimSpace(name)
if name == "" {
return s.profiles[s.activeName], nil
}
profile, ok := s.profiles[name]
if !ok {
return nil, fmt.Errorf("llm provider not found: %s", name)
}
return profile, nil
}
// SwitchActive changes the active LLM profile
func (s *State) SwitchActive(name string) (*Profile, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("llm provider name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
profile, ok := s.profiles[name]
if !ok {
return nil, fmt.Errorf("llm provider not found: %s", name)
}
s.activeName = name
return profile, nil
}
// ListProfiles returns all LLM profiles
func (s *State) ListProfiles() []ProviderConfig {
s.mu.RLock()
defer s.mu.RUnlock()
profiles := make([]ProviderConfig, 0, len(s.order))
for _, name := range s.order {
profile := s.profiles[name]
cfg := profile.Config
cfg.APIKey = "" // Hide API key in listings
cfg.Active = name == s.activeName
profiles = append(profiles, cfg)
}
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
}