ituoke/internal/agent/config.go

240 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}