// Package config 定义应用配置的结构、默认值与磁盘持久化。 package config import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "viewsagent/internal/capture" "viewsagent/internal/llm" ) // Profile 描述一份模型接入配置。Name 是用户自己起的名字, // 既是左侧列表的显示文本,也是 Config.Active 引用的键。 type Profile struct { Name string `json:"name"` Format string `json:"format"` BaseURL string `json:"baseURL"` APIKey string `json:"apiKey"` Model string `json:"model"` ExtraHeaders map[string]string `json:"extraHeaders,omitempty"` MaxTokens int `json:"maxTokens"` Temperature float64 `json:"temperature"` } // NewProfile 按接口格式生成一份空白模型配置,只带好该格式必需的采样默认值。 // BaseURL 与模型名留空,由用户按自己用的服务商填写。 func NewProfile(name, format string) Profile { if format != llm.FormatAnthropic { format = llm.FormatOpenAI } return Profile{ Name: name, Format: format, MaxTokens: 1024, Temperature: 0.3, } } // Capture 是抓屏与触发策略参数。 type Capture struct { FPS float64 `json:"fps"` // 监听帧率,1~5 MaxEdge int `json:"maxEdge"` // 发给模型的图片长边上限 JPEGQuality int `json:"jpegQuality"` // 1~100 DiffThreshold int `json:"diffThreshold"` // 感知哈希汉明距离阈值 CooldownMs int `json:"cooldownMs"` // 两次自动提问的最小间隔 StableFrames int `json:"stableFrames"` // 连续稳定帧数(去抖) HistoryTurns int `json:"historyTurns"` // 带上的历史文本轮数 } // Hotkeys 保存全局热键描述,语法如 "Ctrl+Alt+Q"。 type Hotkeys struct { SelectRegion string `json:"selectRegion"` ToggleWatch string `json:"toggleWatch"` } // Template 是一套系统提示词。 type Template struct { Key string `json:"key"` Name string `json:"name"` System string `json:"system"` } // Config 是完整的应用配置。 type Config struct { // Models 是全部已保存的模型配置,Active 指向其中生效的那一条。 Models []Profile `json:"models"` Active string `json:"active"` // Theme 是界面主题:system 跟随系统、contrast 黑白高对比、light 浅色高对比。 Theme string `json:"theme"` Capture Capture `json:"capture"` Hotkeys Hotkeys `json:"hotkeys"` PromptTemplate string `json:"promptTemplate"` Templates []Template `json:"templates"` AlwaysOnTopCard bool `json:"alwaysOnTopCard"` Region capture.Region `json:"region"` } // DefaultTemplates 返回内置提示词模板。 func DefaultTemplates() []Template { return []Template{ { Key: "exercise", Name: "习题解答", System: `你是屏幕内容实时助手。用户会持续截取一块屏幕区域给你看,用途通常是做练习题、看试卷或读文档。 必须遵守: 1. 只针对画面中"当前这道题"给出答案,不要复述题目。 2. 如果画面没有出现新内容、或你无法辨认题目,只回复「(无新内容)」,绝对不要编造。 3. 先给结论,必要时补一行关键解析,整体控制在 5 行以内。 4. 数学公式只用纯文本和 Unicode 符号(如 x²=4、√2/2、≤、π、∫),禁止 LaTeX、禁止 Markdown 表格。 5. 题干信息不足时,直接说明缺少什么,不要猜。`, }, { Key: "tutor", Name: "逐步讲解", System: `你是屏幕辅导老师。用户会截取题目区域给你看。 必须遵守: 1. 分步骤讲解解题思路,每一步单独一行,最多 6 步。 2. 公式只用纯文本与 Unicode 符号(如 x²=4、√2/2、≤、π),禁止 LaTeX。 3. 画面没有新内容时只回复「(无新内容)」。 4. 不确定的地方明确指出,不要虚构条件。`, }, { Key: "code", Name: "代码解读", System: `你是代码解读助手。用户会截取代码区域给你看。 必须遵守: 1. 先用一句话说明这段代码在做什么。 2. 再指出明显的 bug、边界问题或性能风险。 3. 最后给出修改建议,用文字描述,不要输出整段重写的代码。 4. 画面没有新内容时只回复「(无新内容)」。`, }, { Key: "translate", Name: "翻译", System: `你是翻译助手。识别画面中的外文并翻译成简体中文,保持原有段落结构。 必须遵守: 1. 只输出译文,不要输出原文,也不要解释。 2. 画面没有新内容时只回复「(无新内容)」。`, }, { Key: "chat", Name: "自由对话", System: `你是桌面视觉助手。用户会把屏幕上的内容截给你看并提问,请用简洁准确的中文回答。 公式使用纯文本与 Unicode 符号,不要使用 LaTeX。`, }, } } // Default 返回一份默认配置。 // Models 故意留空:由 Normalize 补一个空白条目,这样旧配置文件反序列化后 // 仍能通过 len(Models)==0 判断出走迁移分支。 func Default() *Config { return &Config{ Capture: Capture{ FPS: 2, MaxEdge: 1280, JPEGQuality: 75, DiffThreshold: 5, CooldownMs: 3000, StableFrames: 2, HistoryTurns: 6, }, Hotkeys: Hotkeys{ SelectRegion: "Ctrl+Alt+X", ToggleWatch: "Ctrl+Alt+Space", }, PromptTemplate: "exercise", Templates: DefaultTemplates(), AlwaysOnTopCard: true, } } // ActiveModel 返回当前生效的模型配置。 func (c *Config) ActiveModel() (Profile, bool) { for i := range c.Models { if c.Models[i].Name == c.Active { return c.Models[i], true } } return Profile{}, false } // ModelNames 返回全部模型名字,顺序即列表顺序。 func (c *Config) ModelNames() []string { names := make([]string, 0, len(c.Models)) for _, m := range c.Models { names = append(names, m.Name) } return names } // ProfileIndex 按名字返回接入点在列表里的下标,找不到返回 -1。 func (c *Config) ProfileIndex(name string) int { for i := range c.Models { if c.Models[i].Name == name { return i } } return -1 } // SetActive 把指定接入点设为生效项;名字不存在时返回 false。 func (c *Config) SetActive(name string) bool { if c.ProfileIndex(name) < 0 { return false } c.Active = name return true } // AddProfile 追加一份接入点,名字为空或冲突时自动改名,并返回真正落库的副本。 func (c *Config) AddProfile(p Profile) Profile { p.Name = strings.TrimSpace(p.Name) if p.Name == "" { p.Name = "接入点" } p.Name = c.UniqueName(p.Name) c.Models = append(c.Models, p) return p } // UniqueName 基于前缀生成一个在现有模型里不重复的名字。 func (c *Config) UniqueName(prefix string) string { used := make(map[string]bool, len(c.Models)) for _, m := range c.Models { used[m.Name] = true } if !used[prefix] { return prefix } for n := 2; ; n++ { name := fmt.Sprintf("%s (%d)", prefix, n) if !used[name] { return name } } } // RemoveModel 删除指定名字的模型;删掉生效那条时把 Active 置空,由 Normalize 重新兜底。 func (c *Config) RemoveModel(name string) bool { for i := range c.Models { if c.Models[i].Name != name { continue } c.Models = append(c.Models[:i], c.Models[i+1:]...) if c.Active == name { c.Active = "" } return true } return false } // Template 按 key 查找提示词模板。 func (c *Config) Template(key string) Template { for _, t := range c.Templates { if t.Key == key { return t } } if len(c.Templates) > 0 { return c.Templates[0] } return Template{Key: "chat", Name: "自由对话", System: "你是桌面视觉助手。"} } // Clone 返回一份深拷贝,避免设置窗口与引擎共享同一份可变配置。 func (c *Config) Clone() *Config { if c == nil { cfg := Default() cfg.Normalize() return cfg } out := *c out.Templates = append([]Template(nil), c.Templates...) out.Models = make([]Profile, len(c.Models)) for i, m := range c.Models { out.Models[i] = m if m.ExtraHeaders != nil { h := make(map[string]string, len(m.ExtraHeaders)) for k, v := range m.ExtraHeaders { h[k] = v } out.Models[i].ExtraHeaders = h } } return &out } // Normalize 补全缺失字段,保证运行期不会出现零值。 func (c *Config) Normalize() { if len(c.Models) == 0 { c.Models = []Profile{NewProfile("接入点 1", llm.FormatOpenAI)} } for i := range c.Models { m := &c.Models[i] m.Name = strings.TrimSpace(m.Name) if m.Name == "" { m.Name = fmt.Sprintf("接入点 %d", i+1) } if m.Format != llm.FormatAnthropic { m.Format = llm.FormatOpenAI } m.BaseURL = strings.TrimRight(strings.TrimSpace(m.BaseURL), "/") m.Model = strings.TrimSpace(m.Model) if m.MaxTokens <= 0 { m.MaxTokens = 1024 } if m.Temperature <= 0 { m.Temperature = 0.3 } } // 名字必须唯一,否则 Active 无法定位到确定的那一条。 used := make(map[string]bool, len(c.Models)) for i := range c.Models { base := c.Models[i].Name name := base for n := 2; used[name]; n++ { name = fmt.Sprintf("%s (%d)", base, n) } c.Models[i].Name = name used[name] = true } if _, ok := c.ActiveModel(); !ok { c.Active = c.Models[0].Name } if len(c.Templates) == 0 { c.Templates = DefaultTemplates() } if c.Capture.FPS <= 0 { c.Capture.FPS = 2 } if c.Capture.MaxEdge <= 0 { c.Capture.MaxEdge = 1280 } if c.Capture.JPEGQuality <= 0 { c.Capture.JPEGQuality = 75 } if c.Capture.DiffThreshold <= 0 { c.Capture.DiffThreshold = 5 } if c.Capture.CooldownMs <= 0 { c.Capture.CooldownMs = 3000 } if c.Capture.StableFrames <= 0 { c.Capture.StableFrames = 2 } if c.Capture.HistoryTurns <= 0 { c.Capture.HistoryTurns = 6 } if c.Hotkeys.SelectRegion == "" { c.Hotkeys.SelectRegion = "Ctrl+Alt+X" } switch c.Theme { case "contrast", "light": default: c.Theme = "system" } if c.Hotkeys.ToggleWatch == "" { c.Hotkeys.ToggleWatch = "Ctrl+Alt+Space" } if c.PromptTemplate == "" { c.PromptTemplate = c.Templates[0].Key } } // Store 负责配置文件的读写。 type Store struct { path string } // NewStore 创建配置文件存储,默认位于 %APPDATA%\viewsagent\config.json。 func NewStore() (*Store, error) { dir, err := os.UserConfigDir() if err != nil { return nil, fmt.Errorf("定位用户配置目录失败: %w", err) } base := filepath.Join(dir, "viewsagent") if err := os.MkdirAll(base, 0o755); err != nil { return nil, fmt.Errorf("创建配置目录失败: %w", err) } return &Store{path: filepath.Join(base, "config.json")}, nil } // Path 返回配置文件路径。 func (s *Store) Path() string { return s.path } // Dir 返回配置目录。 func (s *Store) Dir() string { return filepath.Dir(s.path) } // Load 读取配置;文件不存在或损坏时返回默认配置(损坏文件会被备份)。 func (s *Store) Load() *Config { raw, err := os.ReadFile(s.path) if err != nil { cfg := Default() cfg.Normalize() return cfg } cfg := Default() if err := json.Unmarshal(raw, cfg); err != nil { _ = os.WriteFile(s.path+".broken", raw, 0o644) fresh := Default() fresh.Normalize() return fresh } var legacy legacyConfig _ = json.Unmarshal(raw, &legacy) cfg.migrateLegacy(legacy) cfg.Normalize() cfg.migrate() return cfg } // legacyConfig 是历史版本的两种配置形状,仅用于迁移。 type legacyConfig struct { // 上一版:单份模型 + 厂商 key。 Vendor string `json:"vendor"` Model Profile `json:"model"` // 更早版本:多接入点。 ActiveProfile string `json:"activeProfile"` Profiles []Profile `json:"profiles"` } // migrateLegacy 把旧版配置折叠成 Models 列表。 // 旧格式一律是 OpenAI 兼容,Anthropic 格式是这次才引入的。 func (c *Config) migrateLegacy(l legacyConfig) { if len(c.Models) > 0 { return } switch { case len(l.Profiles) > 0: c.Models = append([]Profile(nil), l.Profiles...) c.Active = l.ActiveProfile case l.Model.BaseURL != "" || l.Model.Model != "": c.Models = []Profile{l.Model} c.Active = l.Model.Name if c.Active == "" { c.Active = l.Vendor } } for i := range c.Models { if c.Models[i].Format == "" { c.Models[i].Format = llm.FormatOpenAI } } } // migrate 把旧版本的默认值升级到当前默认值,避免老配置文件一直沿用已知冲突的热键。 func (c *Config) migrate() { if strings.EqualFold(strings.TrimSpace(c.Hotkeys.SelectRegion), "Ctrl+Alt+Q") { c.Hotkeys.SelectRegion = "Ctrl+Alt+X" } } // Save 原子写入配置文件。 func (s *Store) Save(cfg *Config) error { raw, err := json.MarshalIndent(cfg, "", " ") if err != nil { return fmt.Errorf("序列化配置失败: %w", err) } tmp := s.path + ".tmp" if err := os.WriteFile(tmp, raw, 0o600); err != nil { return fmt.Errorf("写入临时配置失败: %w", err) } return os.Rename(tmp, s.path) }