package agent import ( "crypto/sha256" "encoding/json" "errors" "fmt" "math" "net" "net/url" "os" "path/filepath" "strings" "github.com/zalando/go-keyring" ) type ModelConfig struct { Enabled bool `json:"enabled"` Provider string `json:"provider"` BaseURL string `json:"baseURL"` Model string `json:"model"` SystemPrompt string `json:"systemPrompt"` UseTemperature bool `json:"useTemperature"` Temperature float64 `json:"temperature"` MaxTokens int `json:"maxTokens"` ContextTurns int `json:"contextTurns"` TimeoutSeconds int `json:"timeoutSeconds"` ToolThinking bool `json:"toolThinking"` } // APIKey is write-only. Responses never include the actual credential. type ModelConfigInput struct { Config ModelConfig `json:"config"` APIKey string `json:"apiKey"` ClearAPIKey bool `json:"clearAPIKey"` } type ModelConfigView struct { Config ModelConfig `json:"config"` HasAPIKey bool `json:"hasAPIKey"` } type secretStore interface { Get(string) (string, error) Set(string, string) error Delete(string) error } type systemSecrets struct{} func (systemSecrets) Get(id string) (string, error) { return keyring.Get("com.ituoke.model", id) } func (systemSecrets) Set(id, key string) error { return keyring.Set("com.ituoke.model", id, key) } func (systemSecrets) Delete(id string) error { return keyring.Delete("com.ituoke.model", id) } const legacySystemPrompt = "你是 iTuoke 的中文助手。请根据用户提供的信息回答,信息不足时明确说明。你目前只能进行文字对话,无法读取手机画面或执行手机操作,不要声称已执行操作。" const defaultSystemPrompt = "你是 iTuoke 的中文助手。请根据用户需求和可用工具完成任务,信息不足时明确说明。需要执行操作时使用本轮提供的工具,并根据真实返回结果判断是否成功;未执行或执行失败时如实说明。" func defaultModelConfig() ModelConfig { return ModelConfig{Provider: "openai", BaseURL: "https://api.openai.com/v1", Temperature: 0.7, ContextTurns: 10, TimeoutSeconds: 120, SystemPrompt: defaultSystemPrompt} } func optionalKey(provider string) bool { return provider == "ollama" || provider == "custom" } func credentialID(c ModelConfig) string { return fmt.Sprintf("%x", sha256.Sum256([]byte(c.BaseURL))) } func normalizeConfig(c ModelConfig, requireModel bool) (ModelConfig, error) { c.BaseURL = strings.TrimRight(strings.TrimSpace(c.BaseURL), "/") c.BaseURL = strings.TrimSuffix(c.BaseURL, "/chat/completions") c.Model = strings.TrimSpace(c.Model) c.SystemPrompt = strings.TrimSpace(c.SystemPrompt) switch c.Provider { case "openai", "deepseek", "qwen", "ollama", "custom": default: return c, fmt.Errorf("请选择支持的模型服务商") } u, err := url.Parse(c.BaseURL) if err != nil || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Scheme != "https" && u.Scheme != "http") { return c, fmt.Errorf("请填写有效的 API 基础地址,不包含密钥、查询参数或片段") } if u.Scheme == "http" { ip := net.ParseIP(u.Hostname()) if u.Hostname() != "localhost" && (ip == nil || (!ip.IsLoopback() && !ip.IsPrivate())) { return c, fmt.Errorf("公网模型地址请使用 HTTPS") } } if requireModel && c.Model == "" { return c, fmt.Errorf("请填写或选择模型名称") } if len(c.Model) > 256 || len(c.SystemPrompt) > 16000 { return c, fmt.Errorf("模型名称或系统提示词过长") } if c.TimeoutSeconds < 10 || c.TimeoutSeconds > 600 { return c, fmt.Errorf("请求超时应为 10–600 秒") } if c.ContextTurns < 0 || c.ContextTurns > 30 { return c, fmt.Errorf("上下文轮数应为 0–30") } if c.MaxTokens < 0 || c.MaxTokens > 65536 { return c, fmt.Errorf("输出上限应为 0–65536,0 表示跟随服务默认值") } if math.IsNaN(c.Temperature) || math.IsInf(c.Temperature, 0) || c.Temperature < 0 || c.Temperature > 2 { return c, fmt.Errorf("Temperature 应为 0–2") } return c, nil } func (s *Service) loadConfig() { raw, err := os.ReadFile(s.configPath) if errors.Is(err, os.ErrNotExist) { return } if err == nil { err = json.Unmarshal(raw, &s.config) if s.config.SystemPrompt == legacySystemPrompt { s.config.SystemPrompt = defaultSystemPrompt } } if err != nil { s.loadErr = fmt.Errorf("模型配置文件读取失败: %w", err) } } func (s *Service) GetModelConfig() (ModelConfigView, error) { s.mu.Lock() defer s.mu.Unlock() view := ModelConfigView{Config: s.config} if s.loadErr != nil { return view, s.loadErr } key, err := s.secrets.Get(credentialID(s.config)) if err != nil && !errors.Is(err, keyring.ErrNotFound) { return view, fmt.Errorf("无法读取系统凭据,请检查凭据管理器或钥匙串是否可用") } view.HasAPIKey = key != "" return view, nil } func (s *Service) apiKey(c ModelConfig) (string, error) { key, err := s.secrets.Get(credentialID(c)) if err != nil && !errors.Is(err, keyring.ErrNotFound) { return "", fmt.Errorf("无法读取系统凭据,请检查凭据管理器或钥匙串") } if key == "" && !optionalKey(c.Provider) { return "", fmt.Errorf("请填写 API Key,或确认该服务地址已保存密钥") } return key, nil } func (s *Service) requestConfig(input ModelConfigInput, requireModel bool) (ModelConfig, string, error) { c, err := normalizeConfig(input.Config, requireModel) if err != nil { return c, "", err } if input.ClearAPIKey && strings.TrimSpace(input.APIKey) != "" { return c, "", fmt.Errorf("清除密钥和填写新密钥不能同时进行") } key := strings.TrimSpace(input.APIKey) if len(key) > 2500 || strings.ContainsAny(key, "\r\n") { return c, "", fmt.Errorf("API Key 格式无效") } if key == "" && !input.ClearAPIKey { key, err = s.apiKey(c) } if err != nil { return c, "", err } if key == "" && !optionalKey(c.Provider) { return c, "", fmt.Errorf("请填写 API Key") } return c, key, nil } func writeConfig(path string, c ModelConfig) error { if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return err } f, err := os.CreateTemp(filepath.Dir(path), "model-*.tmp") if err != nil { return err } defer os.Remove(f.Name()) if err := json.NewEncoder(f).Encode(c); err != nil { f.Close() return err } if err := f.Close(); err != nil { return err } return os.Rename(f.Name(), path) } func (s *Service) SaveModelConfig(input ModelConfigInput) (ModelConfigView, error) { s.mu.Lock() defer s.mu.Unlock() c, err := normalizeConfig(input.Config, input.Config.Enabled) if err != nil { return ModelConfigView{}, err } id := credentialID(c) oldKey, err := s.secrets.Get(id) if err != nil && !errors.Is(err, keyring.ErrNotFound) { return ModelConfigView{}, fmt.Errorf("无法访问系统凭据存储") } key := strings.TrimSpace(input.APIKey) if len(key) > 2500 || strings.ContainsAny(key, "\r\n") || (input.ClearAPIKey && key != "") { return ModelConfigView{}, fmt.Errorf("API Key 格式或清除选项无效") } if key == "" && !input.ClearAPIKey { key = oldKey } if c.Enabled && key == "" && !optionalKey(c.Provider) { return ModelConfigView{}, fmt.Errorf("启用此服务前请填写 API Key") } changed := key != oldKey if changed { if key == "" { err = s.secrets.Delete(id) } else { err = s.secrets.Set(id, key) } if err != nil && !errors.Is(err, keyring.ErrNotFound) { return ModelConfigView{}, fmt.Errorf("密钥保存失败,请检查系统凭据管理器或钥匙串") } } if err := writeConfig(s.configPath, c); err != nil { if changed { if oldKey == "" { _ = s.secrets.Delete(id) } else { _ = s.secrets.Set(id, oldKey) } } return ModelConfigView{}, fmt.Errorf("模型配置保存失败: %w", err) } s.config = c s.loadErr = nil s.generation++ s.history = make(map[string][]modelMessage) return ModelConfigView{Config: c, HasAPIKey: key != ""}, nil }