240 lines
7.9 KiB
Go
240 lines
7.9 KiB
Go
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
|
||
}
|