475 lines
14 KiB
Go
475 lines
14 KiB
Go
// 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"` // 感知哈希汉明距离阈值
|
|
DiffSensitivity float64 `json:"diffSensitivity"` // 细节网格归一化平均差阈值(越小越灵敏)
|
|
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, DiffSensitivity: 0.01, 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: "你是桌面视觉助手。"}
|
|
}
|
|
|
|
// ensureModel 在没有任何接入点时补一个空白项。
|
|
//
|
|
// 只用于首次运行(还没有配置文件)时给用户一个可以填的入口;
|
|
// 用户主动删光之后不该被自动塞回来。
|
|
func (c *Config) ensureModel() {
|
|
if len(c.Models) > 0 {
|
|
return
|
|
}
|
|
c.Models = []Profile{NewProfile("接入点 1", llm.FormatOpenAI)}
|
|
c.Active = c.Models[0].Name
|
|
}
|
|
|
|
// Clone 返回一份深拷贝,避免设置窗口与引擎共享同一份可变配置。
|
|
func (c *Config) Clone() *Config {
|
|
if c == nil {
|
|
cfg := Default()
|
|
cfg.Normalize()
|
|
cfg.ensureModel()
|
|
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 补全缺失字段,保证运行期不会出现零值。
|
|
//
|
|
// 注意这里**不再**自动补接入点:用户主动删光之后不该又被塞回来,
|
|
// 否则"删除"看起来像没生效。首次运行的空白接入点由 ensureModel 负责。
|
|
func (c *Config) Normalize() {
|
|
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
|
|
}
|
|
// 一个接入点都没有时,Active 留空即可。
|
|
if len(c.Models) > 0 {
|
|
if _, ok := c.ActiveModel(); !ok {
|
|
c.Active = c.Models[0].Name
|
|
}
|
|
} else {
|
|
c.Active = ""
|
|
}
|
|
|
|
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.DiffSensitivity <= 0 {
|
|
c.Capture.DiffSensitivity = 0.01
|
|
}
|
|
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()
|
|
// 首次运行:给一个空白接入点作为填写入口。
|
|
cfg.ensureModel()
|
|
return cfg
|
|
}
|
|
cfg := Default()
|
|
if err := json.Unmarshal(raw, cfg); err != nil {
|
|
_ = os.WriteFile(s.path+".broken", raw, 0o644)
|
|
fresh := Default()
|
|
fresh.Normalize()
|
|
fresh.ensureModel()
|
|
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)
|
|
}
|