first commit

This commit is contained in:
hero920103 committed 2026-10-27 17:58:28 +08:00
commit 3553790475
27 files changed
+3839

No files matched your search

+326
View File
@@ -0,0 +1,326 @@
// Package config 定义应用配置的结构、默认值与磁盘持久化。
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"viewsagent/internal/capture"
)
// Profile 是一个 OpenAI 兼容协议的模型接入点。
type Profile struct {
Name string `json:"name"`
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"`
}
// 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 {
ActiveProfile string `json:"activeProfile"`
Profiles []Profile `json:"profiles"`
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 返回一份可直接使用的默认配置。
func Default() *Config {
return &Config{
ActiveProfile: "qwen",
Profiles: []Profile{
{
Name: "qwen", BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
Model: "qwen3-vl-plus", MaxTokens: 1024, Temperature: 0.3,
},
{
Name: "doubao", BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
Model: "doubao-seed-1-6-vision", MaxTokens: 1024, Temperature: 0.3,
},
{
Name: "zhipu", BaseURL: "https://open.bigmodel.cn/api/paas/v4",
Model: "glm-4.6v", MaxTokens: 1024, Temperature: 0.3,
},
{
Name: "kimi", BaseURL: "https://api.moonshot.cn/v1",
Model: "kimi-latest", MaxTokens: 1024, Temperature: 0.3,
},
{
Name: "ollama", BaseURL: "http://localhost:11434/v1",
Model: "qwen2.5vl:7b", MaxTokens: 1024, Temperature: 0.3,
},
},
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,
}
}
// Profile 按名称查找接入点。
func (c *Config) Profile(name string) (Profile, bool) {
for _, p := range c.Profiles {
if p.Name == name {
return p, true
}
}
return Profile{}, false
}
// Active 返回当前启用的接入点,找不到时回退到第一个。
func (c *Config) Active() Profile {
if p, ok := c.Profile(c.ActiveProfile); ok {
return p
}
if len(c.Profiles) > 0 {
return c.Profiles[0]
}
return Profile{}
}
// 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 {
return Default()
}
out := *c
out.Profiles = append([]Profile(nil), c.Profiles...)
for i := range out.Profiles {
if c.Profiles[i].ExtraHeaders != nil {
h := make(map[string]string, len(c.Profiles[i].ExtraHeaders))
for k, v := range c.Profiles[i].ExtraHeaders {
h[k] = v
}
out.Profiles[i].ExtraHeaders = h
}
}
out.Templates = append([]Template(nil), c.Templates...)
return &out
}
// Normalize 补全缺失字段,保证运行期不会出现零值。
func (c *Config) Normalize() {
def := Default()
if len(c.Profiles) == 0 {
c.Profiles = def.Profiles
}
if c.ActiveProfile == "" {
c.ActiveProfile = c.Profiles[0].Name
}
for i := range c.Profiles {
p := &c.Profiles[i]
if p.MaxTokens <= 0 {
p.MaxTokens = 1024
}
if p.Temperature <= 0 {
p.Temperature = 0.3
}
p.BaseURL = strings.TrimRight(strings.TrimSpace(p.BaseURL), "/")
}
if len(c.Templates) == 0 {
c.Templates = def.Templates
}
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 = def.Hotkeys.SelectRegion
}
if c.Hotkeys.ToggleWatch == "" {
c.Hotkeys.ToggleWatch = def.Hotkeys.ToggleWatch
}
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
}
cfg.Normalize()
cfg.migrate()
return cfg
}
// 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)
}
+75
View File
@@ -0,0 +1,75 @@
package config
import "testing"
func TestDefaultIsUsable(t *testing.T) {
cfg := Default()
cfg.Normalize()
if len(cfg.Profiles) == 0 {
t.Fatal("默认配置应包含接入点")
}
if cfg.Active().Name != "qwen" {
t.Fatalf("默认接入点应为 qwen,实际 %s", cfg.Active().Name)
}
if cfg.Capture.FPS <= 0 || cfg.Capture.MaxEdge <= 0 || cfg.Capture.CooldownMs <= 0 {
t.Fatalf("抓屏参数不应为零值: %+v", cfg.Capture)
}
if tmpl := cfg.Template("exercise"); tmpl.Key != "exercise" {
t.Fatalf("应能取到习题模板,实际 %s", tmpl.Key)
}
if tmpl := cfg.Template("not-exist"); tmpl.Key == "" {
t.Fatal("未知模板应回退到第一个模板")
}
}
func TestNormalizeFillsZeroValues(t *testing.T) {
cfg := &Config{}
cfg.Normalize()
if cfg.ActiveProfile == "" {
t.Fatal("Normalize 应补全 ActiveProfile")
}
if len(cfg.Templates) == 0 {
t.Fatal("Normalize 应补全模板")
}
if cfg.Capture.JPEGQuality == 0 || cfg.Capture.StableFrames == 0 || cfg.Capture.HistoryTurns == 0 {
t.Fatalf("Normalize 应补全抓屏参数: %+v", cfg.Capture)
}
}
func TestCloneIsIndependent(t *testing.T) {
cfg := Default()
cfg.Normalize()
cp := cfg.Clone()
cp.Profiles[0].Model = "changed"
cp.Templates[0].System = "changed"
cp.Capture.FPS = 9
if cfg.Profiles[0].Model == "changed" {
t.Fatal("Clone 后修改接入点不应影响原配置")
}
if cfg.Templates[0].System == "changed" {
t.Fatal("Clone 后修改模板不应影响原配置")
}
if cfg.Capture.FPS == 9 {
t.Fatal("Clone 后修改抓屏参数不应影响原配置")
}
}
func TestProfileLookup(t *testing.T) {
cfg := Default()
cfg.Normalize()
if _, ok := cfg.Profile("qwen"); !ok {
t.Fatal("应能找到 qwen 接入点")
}
if _, ok := cfg.Profile("不存在"); ok {
t.Fatal("不应找到不存在的接入点")
}
cfg.ActiveProfile = "不存在"
if cfg.Active().Name != cfg.Profiles[0].Name {
t.Fatal("接入点丢失时应回退到第一个")
}
}