From eda7e8bc960b36e94cd8799762778a9a40267f55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=AB=E5=9C=B0=E5=83=A7?= <357099073@qq.com> Date: Wed, 28 Oct 2026 08:47:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codegraph/.gitignore | 5 + build.ps1 | 6 +- internal/agent/engine.go | 104 +++-- internal/agent/engine_test.go | 83 ++++ internal/config/config.go | 249 ++++++++--- internal/config/config_test.go | 202 ++++++++- internal/llm/anthropic.go | 341 +++++++++++++++ internal/llm/openai.go | 61 ++- internal/llm/provider.go | 71 ++- internal/llm/stream_test.go | 139 ++++++ internal/ui/app.go | 214 +++++++-- internal/ui/chat.go | 113 ++++- internal/ui/indicator.go | 102 +++++ internal/ui/overlay.go | 3 +- internal/ui/settings.go | 694 +++++++++++++++++++++++------- internal/ui/styles.go | 35 +- internal/ui/styles_test.go | 79 ++++ internal/ui/theme.go | 164 +++++++ internal/ui/theme_test.go | 117 +++++ internal/win32/frame.go | 541 +++++++++++++++++++++++ internal/win32/frame_test.go | 118 +++++ internal/win32/win32.go | 14 + cmd/viewsagent/main.go => main.go | 0 23 files changed, 3113 insertions(+), 342 deletions(-) create mode 100644 .codegraph/.gitignore create mode 100644 internal/agent/engine_test.go create mode 100644 internal/llm/anthropic.go create mode 100644 internal/llm/stream_test.go create mode 100644 internal/ui/indicator.go create mode 100644 internal/ui/styles_test.go create mode 100644 internal/ui/theme.go create mode 100644 internal/ui/theme_test.go create mode 100644 internal/win32/frame.go create mode 100644 internal/win32/frame_test.go rename cmd/viewsagent/main.go => main.go (100%) diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..d20c0fe --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/build.ps1 b/build.ps1 index 25f8fc3..c5a3dab 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,10 +1,10 @@ # Build viewsagent for Windows (no console window). # Usage: .\build.ps1 -# Requires: Go 1.24+ and gcc from MSYS2 mingw-w64 (C:\msys64\mingw64\bin). +# Requires: Go 1.24+ and gcc from MSYS2 mingw-w64 (E:\Tools\msys64\mingw64\bin). $ErrorActionPreference = "Stop" -$mingw = "C:\msys64\mingw64\bin" +$mingw = "E:\Tools\msys64\mingw64\bin" if (Test-Path $mingw) { $env:Path = "$mingw;" + $env:Path } @@ -24,7 +24,7 @@ go test ./... if ($LASTEXITCODE -ne 0) { Write-Error "tests failed, build aborted." } Write-Host "==> go build" -ForegroundColor Cyan -go build -trimpath -ldflags "-H windowsgui -s -w" -o "bin\viewsagent.exe" .\cmd\viewsagent +go build -trimpath -ldflags "-H windowsgui -s -w" -o "bin\viewsagent.exe" . if ($LASTEXITCODE -ne 0) { Write-Error "build failed." } $mb = [math]::Round((Get-Item "bin\viewsagent.exe").Length / 1MB, 1) diff --git a/internal/agent/engine.go b/internal/agent/engine.go index ff67475..89176f1 100644 --- a/internal/agent/engine.go +++ b/internal/agent/engine.go @@ -30,8 +30,9 @@ type State struct { TotalTokens int LastLatency time.Duration Region capture.Region - Model string - Profile string + Model string // 生效接入点里的模型名 + Profile string // 生效接入点的名字 + Format string // 生效接入点的接口格式 } // Sink 是引擎向界面推送事件的回调集合。 @@ -39,7 +40,7 @@ type State struct { type Sink interface { OnUserMessage(text string, withImage bool) OnAssistantStart(auto bool) - OnAssistantDelta(delta string) + OnAssistantDelta(delta llm.Delta) OnAssistantEnd(usage llm.Usage, err error) OnState(state State) } @@ -72,10 +73,12 @@ func New(cfg *config.Config, store *config.Store) *Engine { store: store, region: cfg.Region, } - if prov, err := llm.New(toOptions(cfg.Active())); err != nil { - slog.Warn("模型接入点不可用", "err", err) - } else { - e.provider = prov + if active, ok := cfg.ActiveModel(); ok { + if prov, err := llm.New(toOptions(active)); err != nil { + slog.Warn("模型不可用", "err", err) + } else { + e.provider = prov + } } return e } @@ -83,6 +86,7 @@ func New(cfg *config.Config, store *config.Store) *Engine { func toOptions(p config.Profile) llm.Options { return llm.Options{ Name: p.Name, + Format: p.Format, BaseURL: p.BaseURL, APIKey: p.APIKey, Model: p.Model, @@ -120,7 +124,7 @@ func (e *Engine) State() State { func (e *Engine) stateLocked() State { active := config.Profile{} if e.cfg != nil { - active = e.cfg.Active() + active, _ = e.cfg.ActiveModel() } return State{ Watching: e.watching, @@ -131,6 +135,7 @@ func (e *Engine) stateLocked() State { Region: e.region, Model: active.Model, Profile: active.Name, + Format: active.Format, } } @@ -169,16 +174,28 @@ func (e *Engine) SetRegion(r capture.Region) error { } // ApplyConfig 在设置页保存后刷新引擎内部状态。 +// 接入点还没填完整时不算失败:配置照样生效,等用户补齐后自动恢复。 func (e *Engine) ApplyConfig(cfg *config.Config) error { - prov, err := llm.New(toOptions(cfg.Active())) - if err != nil { - return err + if cfg == nil { + return errors.New("配置为空") } e.mu.Lock() wasWatching := e.watching // 区域只由框选修改,不被设置页的旧副本覆盖。 cfg.Region = e.region e.cfg = cfg + e.mu.Unlock() + + var prov llm.Provider + if active, ok := cfg.ActiveModel(); ok { + p, err := llm.New(toOptions(active)) + if err != nil { + slog.Warn("模型初始化失败", "err", err) + } else { + prov = p + } + } + e.mu.Lock() e.provider = prov e.mu.Unlock() @@ -192,8 +209,8 @@ func (e *Engine) ApplyConfig(cfg *config.Config) error { return nil } -// PingProfile 用给定接入点做一次连通性测试。 -func (e *Engine) PingProfile(ctx context.Context, p config.Profile) error { +// PingModel 用给定的模型配置做一次连通性测试。 +func (e *Engine) PingModel(ctx context.Context, p config.Profile) error { prov, err := llm.New(toOptions(p)) if err != nil { return err @@ -201,6 +218,43 @@ func (e *Engine) PingProfile(ctx context.Context, p config.Profile) error { return prov.Ping(ctx) } +// PingActive 用当前生效的接入点做一次连通性测试。 +func (e *Engine) PingActive(ctx context.Context) error { + e.mu.Lock() + p, _ := e.cfg.ActiveModel() + e.mu.Unlock() + return e.PingModel(ctx, p) +} + +// EnsureReady 检查模型是否已经接好,供监听前预检。 +func (e *Engine) EnsureReady() error { + e.mu.Lock() + m, ok := e.cfg.ActiveModel() + ready := e.provider != nil + e.mu.Unlock() + + if !ok { + return errors.New("还没有接入模型,请到设置里添加接入点并填写 BaseURL 与模型名") + } + if m.BaseURL == "" || m.Model == "" { + return errors.New("还没有接入模型,请到设置里填写 BaseURL 与模型名") + } + if m.APIKey == "" && !isLocalEndpoint(m.BaseURL) { + return errors.New("还没有接入模型,请到设置里填写 API Key") + } + if !ready { + return errors.New("模型初始化失败,请到设置里检查 BaseURL 与模型名") + } + return nil +} + +// isLocalEndpoint 判断是不是本机服务,本机服务通常不需要 API Key。 +func isLocalEndpoint(baseURL string) bool { + return strings.Contains(baseURL, "localhost") || + strings.Contains(baseURL, "127.0.0.1") || + strings.Contains(baseURL, "[::1]") +} + // Snapshot 抓取当前区域的 JPEG 预览(用于界面缩略图)。 func (e *Engine) Snapshot() ([]byte, error) { e.mu.Lock() @@ -229,6 +283,9 @@ func (e *Engine) Ask(text string, attach bool) error { // StartWatch 开始按帧差自动监听。 func (e *Engine) StartWatch() error { + if err := e.EnsureReady(); err != nil { + return err + } e.mu.Lock() if e.watching { e.mu.Unlock() @@ -264,18 +321,6 @@ func (e *Engine) StopWatch() { e.emitState() } -// ToggleWatch 在开始/停止之间切换。 -func (e *Engine) ToggleWatch() error { - e.mu.Lock() - on := e.watching - e.mu.Unlock() - if on { - e.StopWatch() - return nil - } - return e.StartWatch() -} - // watchLoop 以低频抓屏 + 帧差判断是否需要提问。 func (e *Engine) watchLoop(stop chan struct{}) { e.mu.Lock() @@ -378,7 +423,7 @@ func (e *Engine) run(text string, attach, auto bool) error { } if e.provider == nil { e.mu.Unlock() - return errors.New("还没有可用的模型接入点,请到设置里填写") + return errors.New("还没有接入模型,请到设置里填写") } e.busy = true prov := e.provider @@ -427,8 +472,11 @@ func (e *Engine) run(text string, attach, auto bool) error { var reply strings.Builder start := time.Now() - usage, err := prov.Chat(ctx, system, msgs, func(delta string) error { - reply.WriteString(delta) + usage, err := prov.Chat(ctx, system, msgs, func(delta llm.Delta) error { + // 思考过程只往界面送,不进历史:历史里留正文就够了。 + if delta.Text != "" { + reply.WriteString(delta.Text) + } if sink != nil { sink.OnAssistantDelta(delta) } diff --git a/internal/agent/engine_test.go b/internal/agent/engine_test.go new file mode 100644 index 0000000..51051e4 --- /dev/null +++ b/internal/agent/engine_test.go @@ -0,0 +1,83 @@ +package agent + +import ( + "testing" + + "viewsagent/internal/capture" + "viewsagent/internal/config" + "viewsagent/internal/llm" +) + +// readyCfg 造一份只差一个接入点的配置。 +func readyCfg(p config.Profile) *config.Config { + cfg := &config.Config{Models: []config.Profile{p}} + cfg.Active = p.Name + cfg.Normalize() + return cfg +} + +func TestEnsureReadyRejectsIncompleteModel(t *testing.T) { + e := New(&config.Config{Models: []config.Profile{{Name: "空"}}}, nil) + if err := e.EnsureReady(); err == nil { + t.Fatal("模型未配置时应拒绝监听") + } +} + +func TestEnsureReadyRequiresAPIKey(t *testing.T) { + e := New(readyCfg(config.Profile{ + Name: "远程", BaseURL: "https://api.example.com/v1", Model: "vl-model", + }), nil) + if err := e.EnsureReady(); err == nil { + t.Fatal("远程服务缺少 API Key 时应拒绝监听") + } +} + +func TestEnsureReadyAllowsLocalWithoutKey(t *testing.T) { + e := New(readyCfg(config.Profile{ + Name: "本地", BaseURL: "http://localhost:11434/v1", Model: "qwen2.5vl:7b", + }), nil) + if err := e.EnsureReady(); err != nil { + t.Fatalf("本地服务不应要求 API Key: %v", err) + } +} + +func TestEnsureReadyPassesWithKey(t *testing.T) { + e := New(readyCfg(config.Profile{ + Name: "远程", BaseURL: "https://api.example.com/v1", Model: "vl-model", APIKey: "sk-test", + }), nil) + if err := e.EnsureReady(); err != nil { + t.Fatalf("配置完整时应通过检测: %v", err) + } +} + +func TestStartWatchRefusesWhenModelNotReady(t *testing.T) { + e := New(readyCfg(config.Profile{ + Name: "远程", BaseURL: "https://api.example.com/v1", Model: "vl-model", + }), nil) + e.region = capture.Region{X: 0, Y: 0, W: 100, H: 100} + + if err := e.StartWatch(); err == nil { + t.Fatal("模型未接好时不应开始监听") + } + if e.State().Watching { + t.Fatal("监听不应被启动") + } +} + +func TestActiveProfileIsUsed(t *testing.T) { + cfg := &config.Config{Models: []config.Profile{ + {Name: "甲", Format: llm.FormatOpenAI, BaseURL: "https://a.example/v1", Model: "a-vl", APIKey: "sk-a"}, + {Name: "乙", Format: llm.FormatAnthropic, BaseURL: "https://b.example", Model: "b-vl", APIKey: "sk-b"}, + }} + cfg.Active = "乙" + cfg.Normalize() + + e := New(cfg, nil) + st := e.State() + if st.Profile != "乙" || st.Model != "b-vl" { + t.Fatalf("状态应反映生效接入点: %+v", st) + } + if st.Format != llm.FormatAnthropic { + t.Fatalf("状态应带出接口格式: %s", st.Format) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 13801e0..748d5ea 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,11 +9,14 @@ import ( "strings" "viewsagent/internal/capture" + "viewsagent/internal/llm" ) -// Profile 是一个 OpenAI 兼容协议的模型接入点。 +// 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"` @@ -22,6 +25,20 @@ type Profile struct { 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 @@ -48,8 +65,11 @@ type Template struct { // Config 是完整的应用配置。 type Config struct { - ActiveProfile string `json:"activeProfile"` - Profiles []Profile `json:"profiles"` + // 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"` @@ -113,32 +133,11 @@ func DefaultTemplates() []Template { } } -// Default 返回一份可直接使用的默认配置。 +// Default 返回一份默认配置。 +// Models 故意留空:由 Normalize 补一个空白条目,这样旧配置文件反序列化后 +// 仍能通过 len(Models)==0 判断出走迁移分支。 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, @@ -153,25 +152,85 @@ func Default() *Config { } } -// Profile 按名称查找接入点。 -func (c *Config) Profile(name string) (Profile, bool) { - for _, p := range c.Profiles { - if p.Name == name { - return p, 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 } -// Active 返回当前启用的接入点,找不到时回退到第一个。 -func (c *Config) Active() Profile { - if p, ok := c.Profile(c.ActiveProfile); ok { - return p +// ModelNames 返回全部模型名字,顺序即列表顺序。 +func (c *Config) ModelNames() []string { + names := make([]string, 0, len(c.Models)) + for _, m := range c.Models { + names = append(names, m.Name) } - if len(c.Profiles) > 0 { - return c.Profiles[0] + 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 Profile{} + 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 查找提示词模板。 @@ -190,44 +249,66 @@ func (c *Config) Template(key string) Template { // Clone 返回一份深拷贝,避免设置窗口与引擎共享同一份可变配置。 func (c *Config) Clone() *Config { if c == nil { - return Default() + cfg := Default() + cfg.Normalize() + return cfg } 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 { + 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.Profiles[i].ExtraHeaders = h + out.Models[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 len(c.Models) == 0 { + c.Models = []Profile{NewProfile("接入点 1", llm.FormatOpenAI)} } - 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 + 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 p.Temperature <= 0 { - p.Temperature = 0.3 + 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 } - p.BaseURL = strings.TrimRight(strings.TrimSpace(p.BaseURL), "/") } + // 名字必须唯一,否则 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 = def.Templates + c.Templates = DefaultTemplates() } if c.Capture.FPS <= 0 { c.Capture.FPS = 2 @@ -251,10 +332,15 @@ func (c *Config) Normalize() { c.Capture.HistoryTurns = 6 } if c.Hotkeys.SelectRegion == "" { - c.Hotkeys.SelectRegion = def.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 = def.Hotkeys.ToggleWatch + c.Hotkeys.ToggleWatch = "Ctrl+Alt+Space" } if c.PromptTemplate == "" { c.PromptTemplate = c.Templates[0].Key @@ -300,11 +386,48 @@ func (s *Store) Load() *Config { 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") { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3ce87b4..5168428 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,16 +1,23 @@ package config -import "testing" +import ( + "os" + "path/filepath" + "strings" + "testing" + + "viewsagent/internal/llm" +) func TestDefaultIsUsable(t *testing.T) { cfg := Default() cfg.Normalize() - if len(cfg.Profiles) == 0 { - t.Fatal("默认配置应包含接入点") + if len(cfg.Models) == 0 { + t.Fatal("Normalize 后应至少有一份接入点") } - if cfg.Active().Name != "qwen" { - t.Fatalf("默认接入点应为 qwen,实际 %s", cfg.Active().Name) + if _, ok := cfg.ActiveModel(); !ok { + t.Fatal("Normalize 后应有生效接入点") } if cfg.Capture.FPS <= 0 || cfg.Capture.MaxEdge <= 0 || cfg.Capture.CooldownMs <= 0 { t.Fatalf("抓屏参数不应为零值: %+v", cfg.Capture) @@ -27,8 +34,14 @@ func TestNormalizeFillsZeroValues(t *testing.T) { cfg := &Config{} cfg.Normalize() - if cfg.ActiveProfile == "" { - t.Fatal("Normalize 应补全 ActiveProfile") + if len(cfg.Models) == 0 { + t.Fatal("Normalize 应补全接入点列表") + } + if cfg.Models[0].Format != llm.FormatOpenAI { + t.Fatalf("默认接入点应为 OpenAI 兼容格式,实际 %s", cfg.Models[0].Format) + } + if cfg.Models[0].MaxTokens <= 0 || cfg.Models[0].Temperature <= 0 { + t.Fatalf("Normalize 应补全采样默认值: %+v", cfg.Models[0]) } if len(cfg.Templates) == 0 { t.Fatal("Normalize 应补全模板") @@ -38,17 +51,72 @@ func TestNormalizeFillsZeroValues(t *testing.T) { } } +func TestNormalizeKeepsCustomModel(t *testing.T) { + cfg := Default() + cfg.Models = []Profile{{ + Name: "我的网关", Format: llm.FormatAnthropic, + BaseURL: "https://my-gateway.example.com", Model: "my-vl-model", + }} + cfg.Normalize() + + p := cfg.Models[0] + if p.BaseURL != "https://my-gateway.example.com" || p.Model != "my-vl-model" { + t.Fatalf("Normalize 不应覆盖手填的模型配置: %+v", p) + } + if p.Format != llm.FormatAnthropic { + t.Fatalf("Normalize 不应改写接口格式: %s", p.Format) + } +} + +func TestNormalizeForcesUniqueNames(t *testing.T) { + cfg := &Config{Models: []Profile{ + {Name: "同名", Format: llm.FormatOpenAI}, + {Name: "同名", Format: llm.FormatOpenAI}, + }} + cfg.Normalize() + + if cfg.Models[0].Name == cfg.Models[1].Name { + t.Fatalf("重名接入点应被自动改名: %+v", cfg.Models) + } + if _, ok := cfg.ActiveModel(); !ok { + t.Fatal("改名后仍应有生效接入点") + } +} + +func TestNormalizeTheme(t *testing.T) { + cases := map[string]string{ + "": "system", + "system": "system", + "contrast": "contrast", + "light": "light", + "dark": "system", + "Contrast": "system", + "nonsense": "system", + } + for in, want := range cases { + cfg := &Config{Theme: in} + cfg.Normalize() + if cfg.Theme != want { + t.Fatalf("主题 %q 应归一化为 %q,实际 %q", in, want, cfg.Theme) + } + } +} + func TestCloneIsIndependent(t *testing.T) { cfg := Default() cfg.Normalize() cp := cfg.Clone() - cp.Profiles[0].Model = "changed" + cp.Models[0].Model = "changed" + cp.Models[0].ExtraHeaders = map[string]string{"X-Test": "1"} cp.Templates[0].System = "changed" cp.Capture.FPS = 9 - if cfg.Profiles[0].Model == "changed" { - t.Fatal("Clone 后修改接入点不应影响原配置") + if cfg.Models[0].Model == "changed" { + t.Fatal("Clone 后修改模型不应影响原配置") + } + if len(cfg.Models[0].ExtraHeaders) != 0 { + t.Fatal("Clone 后修改请求头不应影响原配置") } if cfg.Templates[0].System == "changed" { t.Fatal("Clone 后修改模板不应影响原配置") @@ -58,18 +126,114 @@ func TestCloneIsIndependent(t *testing.T) { } } -func TestProfileLookup(t *testing.T) { +func TestAddProfileMakesUniqueName(t *testing.T) { cfg := Default() cfg.Normalize() - if _, ok := cfg.Profile("qwen"); !ok { - t.Fatal("应能找到 qwen 接入点") + added := cfg.AddProfile(NewProfile("接入点", llm.FormatAnthropic)) + if cfg.ProfileIndex(added.Name) < 0 { + t.Fatalf("新增的接入点应能在列表里找到: %+v", cfg.Models) } - if _, ok := cfg.Profile("不存在"); ok { - t.Fatal("不应找到不存在的接入点") - } - cfg.ActiveProfile = "不存在" - if cfg.Active().Name != cfg.Profiles[0].Name { - t.Fatal("接入点丢失时应回退到第一个") + again := cfg.AddProfile(NewProfile("接入点", llm.FormatOpenAI)) + if again.Name == added.Name { + t.Fatalf("重名接入点应自动改名: %s", again.Name) + } +} + +func TestSetActive(t *testing.T) { + cfg := Default() + cfg.Models = []Profile{{Name: "a"}, {Name: "b"}} + cfg.Normalize() + + if !cfg.SetActive("b") { + t.Fatal("应能切换到存在的接入点") + } + if p, ok := cfg.ActiveModel(); !ok || p.Name != "b" { + t.Fatalf("生效接入点应为 b,实际 %+v", cfg) + } + if cfg.SetActive("不存在") { + t.Fatal("不存在的接入点不应被设为生效") + } +} + +func TestRemoveModelClearsActive(t *testing.T) { + cfg := Default() + cfg.Models = []Profile{{Name: "a"}, {Name: "b"}} + cfg.Active = "a" + cfg.Normalize() + + if !cfg.RemoveModel("a") { + t.Fatal("应能删除接入点") + } + if cfg.Active != "" { + t.Fatalf("删掉生效接入点后应清空 Active,实际 %s", cfg.Active) + } + if _, ok := cfg.ActiveModel(); ok { + t.Fatal("删掉生效接入点后不应再能取到它") + } +} + +func TestMigrateLegacyProfiles(t *testing.T) { + cfg := Default() + cfg.migrateLegacy(legacyConfig{ + ActiveProfile: "doubao", + Profiles: []Profile{ + {Name: "qwen", BaseURL: "https://qwen.example/v1", Model: "qwen-vl"}, + {Name: "doubao", BaseURL: "https://doubao.example/v1", Model: "doubao-vl", APIKey: "sk-old"}, + }, + }) + cfg.Normalize() + + active, ok := cfg.ActiveModel() + if !ok { + t.Fatal("应迁移出原启用接入点") + } + if active.Model != "doubao-vl" { + t.Fatalf("应迁移原启用接入点的模型,实际 %s", active.Model) + } + if active.APIKey != "sk-old" { + t.Fatal("应迁移原 API Key") + } + if active.Format != llm.FormatOpenAI { + t.Fatalf("旧格式一律按 OpenAI 兼容迁移,实际 %s", active.Format) + } +} + +func TestStoreLoadMigratesLegacyFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + legacy := `{ + "activeProfile": "doubao", + "profiles": [ + {"name": "qwen", "baseURL": "https://qwen.example/v1", "model": "qwen-vl"}, + {"name": "doubao", "baseURL": "https://doubao.example/v1", "model": "doubao-vl", "apiKey": "sk-legacy"} + ] +}` + if err := os.WriteFile(path, []byte(legacy), 0o644); err != nil { + t.Fatal(err) + } + + s := &Store{path: path} + cfg := s.Load() + + active, ok := cfg.ActiveModel() + if !ok { + t.Fatal("应迁移出生效接入点") + } + if active.Model != "doubao-vl" || active.APIKey != "sk-legacy" { + t.Fatalf("应迁移原模型配置: %+v", active) + } + + if err := s.Save(cfg); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "activeProfile") || strings.Contains(string(raw), "profiles") { + t.Fatalf("保存后不应再写入旧接入点字段: %s", raw) + } + if !strings.Contains(string(raw), `"models"`) { + t.Fatalf("保存后应写入新的接入点列表: %s", raw) } } diff --git a/internal/llm/anthropic.go b/internal/llm/anthropic.go new file mode 100644 index 0000000..ddfb326 --- /dev/null +++ b/internal/llm/anthropic.go @@ -0,0 +1,341 @@ +package llm + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// DefaultAnthropicVersion 是 anthropic-version 请求头的默认值。 +const DefaultAnthropicVersion = "2023-06-01" + +// anthropic 是 Anthropic Messages 协议(/v1/messages + SSE)的实现。 +// 与 OpenAI 兼容格式的三处关键差异:鉴权用 x-api-key 而非 Bearer; +// system 是请求体顶层字段而非一条消息;图片走 source.base64 而非 data URL。 +type anthropic struct { + opts Options + http *http.Client +} + +func newAnthropic(opts Options) *anthropic { + if opts.MaxTokens <= 0 { + opts.MaxTokens = 1024 + } + if opts.Temperature <= 0 { + opts.Temperature = 0.3 + } + return &anthropic{opts: opts, http: newHTTPClient()} +} + +type anthropicRequest struct { + Model string `json:"model"` + Messages []anthropicMessage `json:"messages"` + System string `json:"system,omitempty"` + Stream bool `json:"stream"` + MaxTokens int `json:"max_tokens"` + Temperature float64 `json:"temperature,omitempty"` +} + +type anthropicMessage struct { + Role string `json:"role"` + Content any `json:"content"` +} + +type anthropicBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Source *anthropicImage `json:"source,omitempty"` +} + +type anthropicImage struct { + Type string `json:"type"` + MediaType string `json:"media_type"` + Data string `json:"data"` +} + +type anthropicUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +// anthropicEvent 覆盖流式与非流式两种响应体,用不到的字段留空即可。 +type anthropicEvent struct { + Type string `json:"type"` + Message *struct { + Usage *anthropicUsage `json:"usage"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + Thinking string `json:"thinking"` + } `json:"content"` + } `json:"message"` + Delta *struct { + Type string `json:"type"` + Text string `json:"text"` + // thinking_delta 里思考文本在 thinking 字段。 + Thinking string `json:"thinking"` + } `json:"delta"` + Usage *anthropicUsage `json:"usage"` + Error *struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` +} + +// endpoint 兼容三种写法:官方根地址、已带 /v1、以及直接给全 /messages 的代理。 +func (a *anthropic) endpoint() string { + base := strings.TrimRight(a.opts.BaseURL, "/") + switch { + case strings.HasSuffix(base, "/messages"): + return base + case strings.HasSuffix(base, "/v1"): + return base + "/messages" + default: + return base + "/v1/messages" + } +} + +func (a *anthropic) setHeaders(req *http.Request, stream bool) { + req.Header.Set("Content-Type", "application/json") + if stream { + req.Header.Set("Accept", "text/event-stream") + } else { + req.Header.Set("Accept", "application/json") + } + if a.opts.APIKey != "" { + req.Header.Set("x-api-key", a.opts.APIKey) + } + if a.opts.ExtraHeaders["anthropic-version"] == "" { + req.Header.Set("anthropic-version", DefaultAnthropicVersion) + } + for k, v := range a.opts.ExtraHeaders { + req.Header.Set(k, v) + } +} + +func (a *anthropic) buildBody(system string, msgs []Message, stream bool) ([]byte, error) { + body := anthropicRequest{ + Model: a.opts.Model, + Stream: stream, + MaxTokens: a.opts.MaxTokens, + Temperature: a.opts.Temperature, + System: strings.TrimSpace(system), + } + for _, m := range msgs { + role := "user" + if m.Role == RoleAssistant { + role = "assistant" + } + if len(m.Image) == 0 { + body.Messages = append(body.Messages, anthropicMessage{Role: role, Content: m.Text}) + continue + } + text := m.Text + if strings.TrimSpace(text) == "" { + text = "请看图并回答。" + } + blocks := []anthropicBlock{ + {Type: "text", Text: text}, + { + Type: "image", + Source: &anthropicImage{ + Type: "base64", + MediaType: "image/jpeg", + Data: base64.StdEncoding.EncodeToString(m.Image), + }, + }, + } + body.Messages = append(body.Messages, anthropicMessage{Role: role, Content: blocks}) + } + if len(body.Messages) == 0 { + return nil, fmt.Errorf("没有可发送的消息") + } + raw, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("构造请求失败: %w", err) + } + return raw, nil +} + +// Chat 发起一次流式对话。 +func (a *anthropic) Chat(ctx context.Context, system string, msgs []Message, fn DeltaFunc) (Usage, error) { + if len(msgs) == 0 { + return Usage{}, fmt.Errorf("没有可发送的消息") + } + payload, err := a.buildBody(system, msgs, true) + if err != nil { + return Usage{}, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(payload)) + if err != nil { + return Usage{}, fmt.Errorf("创建请求失败: %w", err) + } + a.setHeaders(req, true) + + resp, err := a.http.Do(req) + if err != nil { + return Usage{}, fmt.Errorf("请求 %s 失败: %w", a.opts.Name, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return Usage{}, fmt.Errorf("模型返回 HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + + // 部分代理会忽略 stream 参数直接返回完整 JSON,这里做兼容。 + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "event-stream") { + return a.readWhole(resp.Body, fn) + } + + var usage Usage + var got strings.Builder + var gotReasoning strings.Builder + err = readSSE(resp.Body, func(ev sseEvent) error { + if len(ev.Data) == 0 { + return nil + } + var e anthropicEvent + if json.Unmarshal(ev.Data, &e) != nil { + return nil // 忽略无法解析的心跳/杂项 + } + switch e.Type { + case "error": + msg := "未知错误" + if e.Error != nil && e.Error.Message != "" { + msg = e.Error.Message + } + return fmt.Errorf("模型返回错误: %s", msg) + case "message_start": + if e.Message != nil && e.Message.Usage != nil { + usage.PromptTokens = e.Message.Usage.InputTokens + usage.CompletionTokens = e.Message.Usage.OutputTokens + } + case "content_block_delta": + if e.Delta == nil { + return nil + } + // thinking_delta 把思考文本放在 thinking 字段,text_delta 才是正式回答。 + if e.Delta.Thinking != "" { + gotReasoning.WriteString(e.Delta.Thinking) + if fn != nil { + if derr := fn(Delta{Reasoning: e.Delta.Thinking}); derr != nil { + return derr + } + } + return nil + } + // input_json_delta 等其它类型走别的字段,直接跳过。 + if e.Delta.Text == "" { + return nil + } + got.WriteString(e.Delta.Text) + if fn != nil { + if derr := fn(Delta{Text: e.Delta.Text}); derr != nil { + return derr + } + } + case "message_delta": + // 结束前的这一帧才带最终的 output_tokens。 + if e.Usage != nil && e.Usage.OutputTokens > 0 { + usage.CompletionTokens = e.Usage.OutputTokens + } + } + return nil + }) + if err != nil { + return usage, err + } + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + if got.Len() == 0 && gotReasoning.Len() == 0 { + return usage, fmt.Errorf("模型没有返回任何内容,请确认 %s 是支持图片输入的视觉模型", a.opts.Model) + } + return usage, nil +} + +// readWhole 处理服务端忽略 stream 参数的情况。 +func (a *anthropic) readWhole(r io.Reader, fn DeltaFunc) (Usage, error) { + raw, err := io.ReadAll(io.LimitReader(r, 8<<20)) + if err != nil { + return Usage{}, fmt.Errorf("读取响应失败: %w", err) + } + var e anthropicEvent + if err := json.Unmarshal(raw, &e); err != nil { + return Usage{}, fmt.Errorf("解析响应失败: %w", err) + } + if e.Error != nil { + return Usage{}, fmt.Errorf("模型返回错误: %s", e.Error.Message) + } + + var usage Usage + var text strings.Builder + var thinking strings.Builder + if e.Message != nil { + if e.Message.Usage != nil { + usage.PromptTokens = e.Message.Usage.InputTokens + usage.CompletionTokens = e.Message.Usage.OutputTokens + } + for _, b := range e.Message.Content { + switch b.Type { + case "text": + text.WriteString(b.Text) + case "thinking": + thinking.WriteString(b.Thinking) + } + } + } + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + if text.Len() == 0 && thinking.Len() == 0 { + return usage, fmt.Errorf("模型返回内容为空") + } + if fn != nil { + if thinking.Len() > 0 { + if err := fn(Delta{Reasoning: thinking.String()}); err != nil { + return usage, err + } + } + if text.Len() > 0 { + if err := fn(Delta{Text: text.String()}); err != nil { + return usage, err + } + } + } + return usage, nil +} + +// Ping 做一次最小代价的连通性检查。 +func (a *anthropic) Ping(ctx context.Context) error { + payload, err := a.buildBody("", []Message{{Role: RoleUser, Text: "ping"}}, false) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("创建请求失败: %w", err) + } + a.setHeaders(req, false) + + resp, err := a.http.Do(req) + if err != nil { + return fmt.Errorf("连接 %s 失败: %w", a.opts.BaseURL, err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + if len(raw) == 0 { + return fmt.Errorf("服务返回空响应") + } + // 有些代理会把错误塞进 200 的响应体里。 + var probe anthropicEvent + if json.Unmarshal(raw, &probe) == nil && probe.Error != nil { + return fmt.Errorf("模型返回错误: %s", probe.Error.Message) + } + return nil +} diff --git a/internal/llm/openai.go b/internal/llm/openai.go index c27b39a..abd9334 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -9,7 +9,6 @@ import ( "io" "net/http" "strings" - "time" ) // openAI 是 OpenAI 兼容协议(/chat/completions + SSE)的实现。 @@ -25,19 +24,7 @@ func newOpenAI(opts Options) *openAI { if opts.Temperature <= 0 { opts.Temperature = 0.3 } - return &openAI{ - opts: opts, - http: &http.Client{ - // 不设总超时:流式响应可能持续很久,由 ctx 控制。 - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - MaxIdleConns: 8, - IdleConnTimeout: 90 * time.Second, - ForceAttemptHTTP2: true, - TLSHandshakeTimeout: 15 * time.Second, - }, - }, - } + return &openAI{opts: opts, http: newHTTPClient()} } type reqMessage struct { @@ -72,6 +59,10 @@ type streamChunk struct { Choices []struct { Delta struct { Content string `json:"content"` + // 思考内容:DeepSeek-R1 / Qwen3 等推理模型走 reasoning_content, + // 也有网关用 reasoning,两个字段都收。 + Reasoning string `json:"reasoning_content"` + ReasoningAlt string `json:"reasoning"` } `json:"delta"` FinishReason string `json:"finish_reason"` } `json:"choices"` @@ -186,6 +177,7 @@ func (o *openAI) Chat(ctx context.Context, system string, msgs []Message, fn Del var usage Usage var got strings.Builder + var gotReasoning strings.Builder err = readSSE(resp.Body, func(ev sseEvent) error { if len(ev.Data) == 0 || bytes.Equal(ev.Data, []byte("[DONE]")) { return nil @@ -205,12 +197,24 @@ func (o *openAI) Chat(ctx context.Context, system string, msgs []Message, fn Del } } for _, c := range chunk.Choices { + reasoning := c.Delta.Reasoning + if reasoning == "" { + reasoning = c.Delta.ReasoningAlt + } + if reasoning != "" { + gotReasoning.WriteString(reasoning) + if fn != nil { + if derr := fn(Delta{Reasoning: reasoning}); derr != nil { + return derr + } + } + } if c.Delta.Content == "" { continue } got.WriteString(c.Delta.Content) if fn != nil { - if derr := fn(c.Delta.Content); derr != nil { + if derr := fn(Delta{Text: c.Delta.Content}); derr != nil { return derr } } @@ -220,7 +224,7 @@ func (o *openAI) Chat(ctx context.Context, system string, msgs []Message, fn Del if err != nil { return usage, err } - if got.Len() == 0 { + if got.Len() == 0 && gotReasoning.Len() == 0 { return usage, fmt.Errorf("模型没有返回任何内容,请确认 %s 是支持图片输入的视觉模型", o.opts.Model) } return usage, nil @@ -236,6 +240,9 @@ func (o *openAI) readWhole(r io.Reader, fn DeltaFunc) (Usage, error) { Choices []struct { Message struct { Content string `json:"content"` + // 非流式响应里思考内容通常挂在 message 上。 + ReasoningContent string `json:"reasoning_content"` + Reasoning string `json:"reasoning"` } `json:"message"` } `json:"choices"` Usage *struct { @@ -253,7 +260,11 @@ func (o *openAI) readWhole(r io.Reader, fn DeltaFunc) (Usage, error) { if resp.Error != nil { return Usage{}, fmt.Errorf("模型返回错误: %s", resp.Error.Message) } - if len(resp.Choices) == 0 || resp.Choices[0].Message.Content == "" { + if len(resp.Choices) == 0 { + return Usage{}, fmt.Errorf("模型返回内容为空") + } + msg := resp.Choices[0].Message + if msg.Content == "" && msg.ReasoningContent == "" && msg.Reasoning == "" { return Usage{}, fmt.Errorf("模型返回内容为空") } var usage Usage @@ -264,10 +275,20 @@ func (o *openAI) readWhole(r io.Reader, fn DeltaFunc) (Usage, error) { TotalTokens: resp.Usage.TotalTokens, } } - text := resp.Choices[0].Message.Content if fn != nil { - if err := fn(text); err != nil { - return usage, err + reasoning := msg.ReasoningContent + if reasoning == "" { + reasoning = msg.Reasoning + } + if reasoning != "" { + if err := fn(Delta{Reasoning: reasoning}); err != nil { + return usage, err + } + } + if msg.Content != "" { + if err := fn(Delta{Text: msg.Content}); err != nil { + return usage, err + } } } return usage, nil diff --git a/internal/llm/provider.go b/internal/llm/provider.go index 1e2da33..372a066 100644 --- a/internal/llm/provider.go +++ b/internal/llm/provider.go @@ -1,11 +1,36 @@ -// Package llm 提供与 OpenAI 兼容接口的大模型通信能力(含视觉与流式)。 +// Package llm 提供大模型通信能力(含视觉与流式), +// 支持 OpenAI Chat Completions 与 Anthropic Messages 两种接口格式。 package llm import ( "context" "fmt" + "net/http" + "time" ) +// 接口格式。它决定请求体结构、鉴权头与流式事件的解析方式, +// 也是配置里唯一需要用户先选的东西。 +const ( + // FormatOpenAI 是 OpenAI Chat Completions 兼容格式(POST {base}/chat/completions)。 + FormatOpenAI = "openai" + // FormatAnthropic 是 Anthropic Messages 兼容格式(POST {base}/v1/messages)。 + FormatAnthropic = "anthropic" +) + +// FormatLabel 返回接口格式的中文名,供界面显示。 +func FormatLabel(format string) string { + switch format { + case FormatAnthropic: + return "Anthropic 兼容" + default: + return "OpenAI 兼容" + } +} + +// Formats 返回全部受支持的接口格式,顺序即界面下拉顺序。 +func Formats() []string { return []string{FormatOpenAI, FormatAnthropic} } + // Role 是消息角色。 type Role string @@ -36,12 +61,21 @@ func (u *Usage) Add(o Usage) { u.TotalTokens += o.TotalTokens } -// DeltaFunc 接收流式增量文本,返回 error 可中断请求。 -type DeltaFunc func(delta string) error +// Delta 是一次流式增量。 +// Text 是正式回答,Reasoning 是模型吐出来的思考过程;两者都按原样透传, +// 服务端吐什么、界面就显示什么。 +type Delta struct { + Text string + Reasoning string +} -// Options 描述一个模型接入点。 +// DeltaFunc 接收流式增量,返回 error 可中断请求。 +type DeltaFunc func(delta Delta) error + +// Options 描述一个模型的接入参数。 type Options struct { Name string + Format string BaseURL string APIKey string Model string @@ -51,7 +85,6 @@ type Options struct { } // Provider 是大模型服务的最小抽象。 -// 目前只有 OpenAI 兼容实现,其它私有协议按此接口新增即可。 type Provider interface { // Chat 发起一次流式对话,system 为系统提示词。 Chat(ctx context.Context, system string, msgs []Message, fn DeltaFunc) (Usage, error) @@ -59,13 +92,33 @@ type Provider interface { Ping(ctx context.Context) error } -// New 根据接入点创建 Provider。 +// New 根据模型配置创建 Provider,按 Format 分派到对应协议实现。 func New(opts Options) (Provider, error) { if opts.BaseURL == "" { - return nil, fmt.Errorf("模型接入点缺少 BaseURL") + return nil, fmt.Errorf("模型配置缺少 BaseURL") } if opts.Model == "" { - return nil, fmt.Errorf("模型接入点 %s 缺少 model", opts.Name) + return nil, fmt.Errorf("模型 %s 缺少模型名", opts.Name) + } + switch opts.Format { + case "", FormatOpenAI: + return newOpenAI(opts), nil + case FormatAnthropic: + return newAnthropic(opts), nil + default: + return nil, fmt.Errorf("未知的接口格式 %q", opts.Format) + } +} + +// newHTTPClient 返回不设总超时的客户端:流式响应可能持续很久,由 ctx 控制。 +func newHTTPClient() *http.Client { + return &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + MaxIdleConns: 8, + IdleConnTimeout: 90 * time.Second, + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: 15 * time.Second, + }, } - return newOpenAI(opts), nil } diff --git a/internal/llm/stream_test.go b/internal/llm/stream_test.go new file mode 100644 index 0000000..f5a5085 --- /dev/null +++ b/internal/llm/stream_test.go @@ -0,0 +1,139 @@ +package llm + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// sseServer 起一个假的 SSE 服务端,按脚本逐条吐数据。 +func sseServer(t *testing.T, chunks []string, gap time.Duration) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + for _, c := range chunks { + fmt.Fprint(w, c) + if flusher != nil { + flusher.Flush() + } + time.Sleep(gap) + } + })) +} + +func openAIChunk(content, reasoning string) string { + delta := fmt.Sprintf(`{"choices":[{"delta":{"content":%q,"reasoning_content":%q}}]}`, content, reasoning) + return "data: " + delta + "\n\n" +} + +func TestOpenAIStreamsIncrementally(t *testing.T) { + srv := sseServer(t, []string{ + openAIChunk("第一段", ""), + openAIChunk("第二段", ""), + openAIChunk("第三段", ""), + }, 60*time.Millisecond) + defer srv.Close() + + prov, err := New(Options{Format: FormatOpenAI, BaseURL: srv.URL, Model: "m", APIKey: "k"}) + if err != nil { + t.Fatal(err) + } + + type ev struct { + text string + at time.Duration + } + var evs []ev + start := time.Now() + _, err = prov.Chat(context.Background(), "", []Message{{Role: RoleUser, Text: "hi"}}, func(d Delta) error { + evs = append(evs, ev{text: d.Text, at: time.Since(start)}) + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(evs) != 3 { + t.Fatalf("应收到 3 段增量,实际 %d", len(evs)) + } + // 第一段必须在服务端还没吐完时就到,否则就是"一次性返回"。 + if evs[0].at > 50*time.Millisecond { + t.Fatalf("第一段增量到达太晚(%v),说明没有真正流式", evs[0].at) + } + if got := evs[0].text + evs[1].text + evs[2].text; got != "第一段第二段第三段" { + t.Fatalf("增量内容不对: %q", got) + } +} + +func TestOpenAIParsesReasoning(t *testing.T) { + srv := sseServer(t, []string{ + openAIChunk("", "先想一下…"), + openAIChunk("", "再想一下…"), + openAIChunk("正式回答", ""), + }, 10*time.Millisecond) + defer srv.Close() + + prov, err := New(Options{Format: FormatOpenAI, BaseURL: srv.URL, Model: "m"}) + if err != nil { + t.Fatal(err) + } + + var think, text strings.Builder + _, err = prov.Chat(context.Background(), "", []Message{{Role: RoleUser, Text: "hi"}}, func(d Delta) error { + think.WriteString(d.Reasoning) + text.WriteString(d.Text) + return nil + }) + if err != nil { + t.Fatal(err) + } + if think.String() != "先想一下…再想一下…" { + t.Fatalf("思考内容解析不对: %q", think.String()) + } + if text.String() != "正式回答" { + t.Fatalf("正文解析不对: %q", text.String()) + } +} + +func TestAnthropicStreamsThinkingAndText(t *testing.T) { + srv := sseServer(t, []string{ + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"我在想…\"}}\n\n", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"答案\"}}\n\n", + "event: message_delta\ndata: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":12}}\n\n", + }, 60*time.Millisecond) + defer srv.Close() + + prov, err := New(Options{Format: FormatAnthropic, BaseURL: srv.URL, Model: "m"}) + if err != nil { + t.Fatal(err) + } + + var think, text strings.Builder + start := time.Now() + var firstAt time.Duration + _, err = prov.Chat(context.Background(), "", []Message{{Role: RoleUser, Text: "hi"}}, func(d Delta) error { + if firstAt == 0 { + firstAt = time.Since(start) + } + think.WriteString(d.Reasoning) + text.WriteString(d.Text) + return nil + }) + if err != nil { + t.Fatal(err) + } + if think.String() != "我在想…" { + t.Fatalf("思考内容解析不对: %q", think.String()) + } + if text.String() != "答案" { + t.Fatalf("正文解析不对: %q", text.String()) + } + if firstAt > 50*time.Millisecond { + t.Fatalf("第一段增量到达太晚(%v),说明没有真正流式", firstAt) + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 17510ab..37a1cb6 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -1,7 +1,9 @@ package ui import ( + "context" "fmt" + "image" "image/png" "log/slog" "os" @@ -45,16 +47,19 @@ type App struct { settings *settingsWindow card *floatCard overlay *overlay + regionFrm *regionFrame hotkeyStop func() // 流式文本合批,避免每个 delta 都触发一次布局 pendMu sync.Mutex - pending strings.Builder + pendingText strings.Builder + pendingThink strings.Builder pendScheduled bool // 最近一条完整回答,用于同步到浮窗 - replyMu sync.Mutex - lastReply strings.Builder + replyMu sync.Mutex + lastReply strings.Builder + lastCardSync time.Time quietModelSel bool } @@ -69,6 +74,8 @@ func Run() error { fa := fyneapp.NewWithID("com.viewsagent.desktop") installTranslations() + // 主题要在建界面之前定下来,否则首帧会用默认配色渲染。 + applyThemeMode(cfg.Theme) eng := agent.New(cfg, store) a := &App{fyne: fa, eng: eng} @@ -81,6 +88,7 @@ func Run() error { if a.hotkeyStop != nil { a.hotkeyStop() } + a.regionFrm.close() eng.Close() }() @@ -88,11 +96,15 @@ func Run() error { return nil } -// maybeAutoShot 在设置了 VIEWSAGENT_SHOT 时,等界面稳定后把画布导出为 PNG 再退出。 +// maybeAutoShot 在设置了 VIEWSAGENT_SHOT / VIEWSAGENT_SHOT_SETTINGS / VIEWSAGENT_ASK 时, +// 等界面稳定后把画布导出为 PNG 再退出。 +// VIEWSAGENT_SHOT_TAB 可指定设置窗口要停在哪个标签(如「其它」)。 // 只用于开发期核对布局,正常运行不会触发。 func (a *App) maybeAutoShot() { path := os.Getenv("VIEWSAGENT_SHOT") - if path == "" { + settingsPath := os.Getenv("VIEWSAGENT_SHOT_SETTINGS") + ask := os.Getenv("VIEWSAGENT_ASK") + if path == "" && settingsPath == "" && ask == "" { return } delay := 1500 * time.Millisecond @@ -103,18 +115,46 @@ func (a *App) maybeAutoShot() { } go func() { time.Sleep(delay) + if settingsPath != "" { + fyne.Do(func() { a.settings.show() }) + time.Sleep(delay) + if tab := os.Getenv("VIEWSAGENT_SHOT_TAB"); tab != "" { + fyne.Do(func() { a.settings.selectTabByName(tab) }) + time.Sleep(delay) + } + } + if ask != "" { + fyne.Do(func() { + a.entry.SetText(ask) + a.send() + }) + time.Sleep(delay) + } fyne.Do(func() { - if img := a.win.Canvas().Capture(); img != nil { - if f, err := os.Create(path); err == nil { - _ = png.Encode(f, img) - _ = f.Close() - } + if path != "" { + writePNG(path, a.win.Canvas().Capture()) + } + if settingsPath != "" { + writePNG(settingsPath, a.settings.win.Canvas().Capture()) } a.fyne.Quit() }) }() } +// writePNG 把画布导出成 PNG 文件。 +func writePNG(path string, img image.Image) { + if img == nil { + return + } + f, err := os.Create(path) + if err != nil { + return + } + defer f.Close() + _ = png.Encode(f, img) +} + func (a *App) build() { a.win = a.fyne.NewWindow(mainTitle) a.win.Resize(fyne.NewSize(520, 780)) @@ -122,13 +162,16 @@ func (a *App) build() { a.chat = newChatView() a.card = newFloatCard(a.fyne) + a.regionFrm = newRegionFrame() a.settings = newSettingsWindow(a) cfg := a.eng.Config() - a.modelSel = widget.NewSelect(profileNames(cfg), a.onModelChange) + a.modelSel = widget.NewSelect(cfg.ModelNames(), a.onModelChange) a.modelSel.PlaceHolder = "请选择接入点" - a.modelSel.SetSelected(cfg.ActiveProfile) + a.quietModelSel = true + a.modelSel.SetSelected(cfg.Active) + a.quietModelSel = false cardBtn := widget.NewButtonWithIcon("", theme.InfoIcon(), a.toggleCard) cardBtn.Importance = widget.LowImportance @@ -136,7 +179,7 @@ func (a *App) build() { settingsBtn.Importance = widget.LowImportance toolbar := container.NewHBox( - widget.NewLabel("模型"), a.modelSel, + widget.NewLabel("接入点"), a.modelSel, layout.NewSpacer(), cardBtn, settingsBtn, ) @@ -177,14 +220,14 @@ func (a *App) build() { a.win.SetContent(container.NewBorder(top, bottom, nil, nil, a.chat.object())) a.applyState(a.eng.State()) -} -func profileNames(cfg *config.Config) []string { - names := make([]string, 0, len(cfg.Profiles)) - for _, p := range cfg.Profiles { - names = append(names, p.Name) + // 开发期自检:把取景框内部状态写到文件,方便排查。 + if p := os.Getenv("VIEWSAGENT_FRAME_DEBUG"); p != "" { + go func() { + time.Sleep(800 * time.Millisecond) + _ = os.WriteFile(p, []byte(a.regionFrm.Debug()), 0o644) + }() } - return names } // ---------- 用户操作 ---------- @@ -195,15 +238,46 @@ func (a *App) send() { return } a.entry.SetText("") - if err := a.eng.Ask(text, a.attach.Checked); err != nil { - a.chat.addNotice(err.Error()) - } + // 请求必须放到后台:网络请求期间 UI 线程不能被占住, + // 否则流式增量只能排队等全部返回后一次性刷新。 + go func() { + if err := a.eng.Ask(text, a.attach.Checked); err != nil { + fyne.Do(func() { a.chat.addNotice(err.Error()) }) + } + }() } func (a *App) toggleWatch() { - if err := a.eng.ToggleWatch(); err != nil { - a.chat.addNotice(err.Error()) + if a.eng.State().Watching { + a.eng.StopWatch() + return } + if err := a.eng.EnsureReady(); err != nil { + a.chat.addNotice(err.Error()) + dialog.ShowError(err, a.win) + return + } + // 监听前先探测一次模型连通性,通不过就不启动监听。 + a.watchBtn.Disable() + a.statusLbl.SetText("正在检测模型连接 …") + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + err := a.eng.PingActive(ctx) + fyne.Do(func() { + a.watchBtn.Enable() + if err != nil { + a.statusLbl.SetText("就绪") + a.chat.addNotice("模型连接检测失败,未开始监听:" + err.Error()) + return + } + if err := a.eng.StartWatch(); err != nil { + a.chat.addNotice(err.Error()) + return + } + a.chat.addNotice("模型检测通过,已开始监听") + }) + }() } func (a *App) toggleCard() { @@ -221,23 +295,23 @@ func (a *App) clearChat() { a.chat.addNotice("已清空对话上下文") } +// onModelChange 切换生效的接入点。 func (a *App) onModelChange(name string) { if a.quietModelSel || name == "" { return } - cfg := a.eng.Config() - if _, ok := cfg.Profile(name); !ok { + cfg := a.eng.Config().Clone() + if !cfg.SetActive(name) { return } - cfg.ActiveProfile = name if err := a.eng.ApplyConfig(cfg); err != nil { - a.chat.addNotice("切换模型失败:" + err.Error()) + a.chat.addNotice("切换接入点失败:" + err.Error()) return } if err := a.eng.Store().Save(cfg); err != nil { - slog.Warn("保存模型选择失败", "err", err) + slog.Warn("保存接入点选择失败", "err", err) } - a.chat.addNotice("已切换模型:" + name) + a.chat.addNotice("已切换到接入点 " + name) } // saveConfig 把引擎里的配置落盘并立即生效。 @@ -245,7 +319,7 @@ func (a *App) saveConfig() error { return a.applyAndSave(a.eng.Config()) } -// applyAndSave 保存并应用一份配置(覆盖模型下拉选项)。 +// applyAndSave 保存并应用一份配置(覆盖接入点下拉选项)。 func (a *App) applyAndSave(cfg *config.Config) error { cfg.Normalize() if err := a.eng.Store().Save(cfg); err != nil { @@ -255,9 +329,12 @@ func (a *App) applyAndSave(cfg *config.Config) error { return err } a.quietModelSel = true - a.modelSel.Options = profileNames(cfg) - a.modelSel.SetSelected(cfg.ActiveProfile) + a.modelSel.Options = cfg.ModelNames() + a.modelSel.SetSelected(cfg.Active) a.quietModelSel = false + // 主题变了就立刻换肤,不用重启。 + applyThemeMode(cfg.Theme) + a.chat.retheme() return nil } @@ -267,6 +344,8 @@ func (a *App) startSelection() { if a.overlay != nil { return } + // 框选过程中整屏都是选区浮层,先把常驻红框收起来,免得两层边框打架。 + a.regionFrm.suspend() // 先隐藏主窗,避免它出现在冻结帧里挡住要框选的内容 a.win.Hide() time.Sleep(220 * time.Millisecond) @@ -274,6 +353,7 @@ func (a *App) startSelection() { shot, err := (capture.Screen{}).Desktop() if err != nil { a.win.Show() + a.regionFrm.resume(a.eng.Region()) a.chat.addNotice("抓屏失败:" + err.Error()) return } @@ -286,16 +366,19 @@ func (a *App) onRegionSelected(r capture.Region) { a.overlay = nil a.win.Show() if err := a.eng.SetRegion(r); err != nil { + a.regionFrm.resume(a.eng.Region()) a.chat.addNotice(err.Error()) return } a.card.setRegion(r) + a.regionFrm.resume(r) a.chat.addNotice(fmt.Sprintf("已框选监视区域 %s", r)) } func (a *App) onRegionCancel() { a.overlay = nil a.win.Show() + a.regionFrm.resume(a.eng.Region()) } func (a *App) showPreview() { @@ -361,13 +444,21 @@ func (a *App) OnAssistantStart(auto bool) { } // OnAssistantDelta 收到流式增量,40ms 合批后刷新界面。 -func (a *App) OnAssistantDelta(delta string) { - a.replyMu.Lock() - a.lastReply.WriteString(delta) - a.replyMu.Unlock() +// 思考过程与正式回答分两路合批,保证两边的顺序都不乱。 +func (a *App) OnAssistantDelta(delta llm.Delta) { + if delta.Text != "" { + a.replyMu.Lock() + a.lastReply.WriteString(delta.Text) + a.replyMu.Unlock() + } a.pendMu.Lock() - a.pending.WriteString(delta) + if delta.Text != "" { + a.pendingText.WriteString(delta.Text) + } + if delta.Reasoning != "" { + a.pendingThink.WriteString(delta.Reasoning) + } if a.pendScheduled { a.pendMu.Unlock() return @@ -380,15 +471,42 @@ func (a *App) OnAssistantDelta(delta string) { func (a *App) flushPending() { a.pendMu.Lock() - text := a.pending.String() - a.pending.Reset() + text := a.pendingText.String() + think := a.pendingThink.String() + a.pendingText.Reset() + a.pendingThink.Reset() a.pendScheduled = false a.pendMu.Unlock() - if text == "" { + if text == "" && think == "" { return } - fyne.Do(func() { a.chat.appendDelta(text) }) + fyne.Do(func() { + if think != "" { + a.chat.appendThinking(think) + } + if text != "" { + a.chat.appendDelta(text) + } + a.syncCard() + }) +} + +// syncCard 让置顶浮窗跟着流式内容一起刷新。 +// 浮窗每次都要重排 Markdown,这里限流到 5 次/秒,结束时会再补一次完整刷新。 +func (a *App) syncCard() { + if !a.card.visibleNow() { + return + } + now := time.Now() + if now.Sub(a.lastCardSync) < 200*time.Millisecond { + return + } + a.lastCardSync = now + a.replyMu.Lock() + text := a.lastReply.String() + a.replyMu.Unlock() + a.card.update(text) } // OnAssistantEnd 结束一条助手回答。 @@ -427,6 +545,8 @@ func (a *App) applyState(st agent.State) { a.regionLbl.SetText("未框选区域") } a.card.setRegion(st.Region) + // 屏幕上的红框跟着选区走,始终提示当前盯的是哪一块。 + a.regionFrm.apply(st.Region) if st.Busy { a.sendBtn.Disable() @@ -443,8 +563,16 @@ func (a *App) applyState(st agent.State) { case st.Busy: state = "正在回答" } + label := st.Model + if st.Profile != "" { + if label != "" { + label = st.Profile + " · " + label + } else { + label = st.Profile + } + } status := fmt.Sprintf("%s · 模型 %s · 自动触发 %d 次 · 累计 %d tokens", - state, st.Model, st.Triggers, st.TotalTokens) + state, label, st.Triggers, st.TotalTokens) if st.LastLatency > 0 { status += fmt.Sprintf(" · 上次 %dms", st.LastLatency.Milliseconds()) } diff --git a/internal/ui/chat.go b/internal/ui/chat.go index 9e77db1..d534f23 100644 --- a/internal/ui/chat.go +++ b/internal/ui/chat.go @@ -31,13 +31,29 @@ func colorFor(k bubbleKind) color.Color { } } +// borderFor 返回气泡描边色:错误提示用更重的描边,保证一眼能认出。 +func borderFor(k bubbleKind) color.Color { + if k == bubbleError { + return colorStrongBorder() + } + return colorBorder() +} + // bubble 是一条消息气泡:圆角背景 + 角色标题 + 正文。 // 流式阶段用 Label 追加文本,结束后换成 RichText 以获得 Markdown 排版。 +// 思考过程单独占一块(可折叠),不与正式回答混在一起。 type bubble struct { root *fyne.Container bg *canvas.Rectangle body *fyne.Container head *widget.Label + kind bubbleKind + + thinkBtn *widget.Button + thinkText *widget.RichText + thinkBox *fyne.Container + think strings.Builder + live *widget.Label text strings.Builder } @@ -45,7 +61,7 @@ type bubble struct { func newBubble(role string, kind bubbleKind) *bubble { bg := canvas.NewRectangle(colorFor(kind)) bg.CornerRadius = 10 - bg.StrokeColor = colorBorder() + bg.StrokeColor = borderFor(kind) bg.StrokeWidth = 1 head := widget.NewLabelWithStyle(role, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) @@ -57,7 +73,62 @@ func newBubble(role string, kind bubbleKind) *bubble { body := container.NewVBox(head, live) root := container.NewStack(bg, container.New(innerPadLayout{}, body)) - return &bubble{root: root, bg: bg, body: body, head: head, live: live} + return &bubble{root: root, bg: bg, body: body, head: head, kind: kind, live: live} +} + +// retheme 主题切换后重新上色。 +func (b *bubble) retheme() { + b.bg.FillColor = colorFor(b.kind) + b.bg.StrokeColor = borderFor(b.kind) + b.bg.Refresh() +} + +// ensureThinking 建出思考过程区块,并插在角色标题与正文之间。 +func (b *bubble) ensureThinking() { + if b.thinkBox != nil { + return + } + b.thinkText = widget.NewRichText() + b.thinkText.Wrapping = fyne.TextWrapWord + + b.thinkBtn = widget.NewButton("▾ 思考过程", func() { b.toggleThinking() }) + b.thinkBtn.Importance = widget.LowImportance + b.thinkBtn.Alignment = widget.ButtonAlignLeading + + b.thinkBox = container.NewVBox(b.thinkBtn, b.thinkText) + objs := b.body.Objects + next := make([]fyne.CanvasObject, 0, len(objs)+1) + next = append(next, objs[0], b.thinkBox) + next = append(next, objs[1:]...) + b.body.Objects = next + b.body.Refresh() +} + +// toggleThinking 折叠/展开思考过程。 +func (b *bubble) toggleThinking() { + if b.thinkText == nil { + return + } + if b.thinkText.Visible() { + b.thinkText.Hide() + b.thinkBtn.SetText("▸ 思考过程") + return + } + b.thinkText.Show() + b.thinkBtn.SetText("▾ 思考过程") +} + +// appendThinking 追加思考过程文本。 +func (b *bubble) appendThinking(delta string) { + b.ensureThinking() + b.think.WriteString(delta) + b.thinkText.Segments = []widget.RichTextSegment{ + &widget.TextSegment{ + Text: b.think.String(), + Style: widget.RichTextStyle{TextStyle: fyne.TextStyle{Italic: true}}, + }, + } + b.thinkText.Refresh() } // append 追加流式文本。 @@ -76,12 +147,27 @@ func (b *bubble) finish(err error) { } text := strings.TrimSpace(b.text.String()) if text == "" { - text = "(没有返回内容)" + if b.think.Len() > 0 { + text = "(模型只给出了思考过程,没有输出回答)" + } else { + text = "(没有返回内容)" + } } rt := widget.NewRichTextFromMarkdown(text) rt.Wrapping = fyne.TextWrapWord - b.body.Objects = []fyne.CanvasObject{b.head, rt} + objs := b.body.Objects + next := make([]fyne.CanvasObject, 0, len(objs)) + next = append(next, objs[0]) + if b.thinkBox != nil { + next = append(next, b.thinkBox) + } + next = append(next, rt) + b.body.Objects = next b.body.Refresh() + // 思考过程默认折起来,把正文让到前面;点标题还能再展开。 + if b.thinkText != nil && b.thinkText.Visible() { + b.toggleThinking() + } } // innerPadLayout 给气泡留内边距。 @@ -113,6 +199,7 @@ type chatView struct { list *fyne.Container scroll *container.Scroll current *bubble + bubbles []*bubble } func newChatView() *chatView { @@ -127,10 +214,18 @@ func newChatView() *chatView { func (c *chatView) object() fyne.CanvasObject { return c.scroll } func (c *chatView) appendBubble(b *bubble) { + c.bubbles = append(c.bubbles, b) c.list.Add(b.root) c.scroll.ScrollToBottom() } +// retheme 主题切换后给所有气泡重新上色。 +func (c *chatView) retheme() { + for _, b := range c.bubbles { + b.retheme() + } +} + // addUser 添加一条用户消息。 func (c *chatView) addUser(text string, withImage bool) { if withImage { @@ -161,6 +256,15 @@ func (c *chatView) appendDelta(delta string) { c.scroll.ScrollToBottom() } +// appendThinking 追加思考过程文本。 +func (c *chatView) appendThinking(delta string) { + if c.current == nil { + c.beginAssistant(false) + } + c.current.appendThinking(delta) + c.scroll.ScrollToBottom() +} + // endAssistant 结束当前助手消息。 func (c *chatView) endAssistant(err error) { if c.current == nil { @@ -182,6 +286,7 @@ func (c *chatView) addNotice(text string) { // clear 清空消息列表。 func (c *chatView) clear() { c.list.Objects = nil + c.bubbles = nil c.current = nil c.list.Refresh() } diff --git a/internal/ui/indicator.go b/internal/ui/indicator.go new file mode 100644 index 0000000..7066bbd --- /dev/null +++ b/internal/ui/indicator.go @@ -0,0 +1,102 @@ +package ui + +import ( + "log/slog" + "sync" + + "viewsagent/internal/capture" + "viewsagent/internal/win32" +) + +// regionFrame 是常驻的取景框提示。 +// +// 屏幕上一直显示当前框选区域的红框,让使用者随时知道自己在看哪一块; +// 这个框被 SetWindowDisplayAffinity 排除在截屏与录屏之外, +// 所以模型看到的画面、录屏软件录到的画面里都不会有它。 +type regionFrame struct { + frame *win32.Frame + + mu sync.Mutex + last capture.Region + hasLast bool + suspended bool +} + +// newRegionFrame 创建取景框。创建失败时返回 nil,调用方自行决定是否降级。 +func newRegionFrame() *regionFrame { + f, err := win32.NewFrame() + if err != nil { + slog.Warn("取景框创建失败,屏幕将不再显示选区边框", "err", err) + return nil + } + return ®ionFrame{frame: f} +} + +// apply 把取景框移到指定区域;区域无效时隐藏。 +func (f *regionFrame) apply(r capture.Region) { + if f == nil { + return + } + f.mu.Lock() + if f.suspended { + f.mu.Unlock() + return + } + if f.hasLast && f.last == r { + f.mu.Unlock() + return + } + f.last = r + f.hasLast = true + f.mu.Unlock() + + if !r.Valid() { + f.frame.Hide() + return + } + f.frame.SetRegion(win32.Rect{ + Left: r.X, + Top: r.Y, + Right: r.X + r.W, + Bottom: r.Y + r.H, + }) +} + +// suspend 临时隐藏取景框(框选过程中全屏有选区浮层,避免两层边框打架)。 +func (f *regionFrame) suspend() { + if f == nil { + return + } + f.mu.Lock() + f.suspended = true + f.hasLast = false + f.mu.Unlock() + f.frame.Hide() +} + +// resume 恢复取景框,并按当前区域重新落位。 +func (f *regionFrame) resume(r capture.Region) { + if f == nil { + return + } + f.mu.Lock() + f.suspended = false + f.mu.Unlock() + f.apply(r) +} + +// close 销毁取景框窗口。 +func (f *regionFrame) close() { + if f == nil { + return + } + f.frame.Close() +} + +// Debug 返回取景框内部状态,供自检使用。 +func (f *regionFrame) Debug() string { + if f == nil || f.frame == nil { + return "取景框未创建" + } + return f.frame.Debug() +} diff --git a/internal/ui/overlay.go b/internal/ui/overlay.go index 84e8d69..b6ae2e1 100644 --- a/internal/ui/overlay.go +++ b/internal/ui/overlay.go @@ -64,7 +64,8 @@ func newOverlay(app fyne.App, shot image.Image, vd win32.Rect, objs = append(objs, o.dims[i]) } for i := range o.edges { - o.edges[i] = canvas.NewRectangle(color.NRGBA{R: 0x5A, G: 0xC8, B: 0xFF, A: 0xFF}) + // 描边跟着主题走:黑白主题下是纯白/纯黑,不会残留彩色。 + o.edges[i] = canvas.NewRectangle(lookupColor(theme.ColorNameForeground)) objs = append(objs, o.edges[i]) } o.hint = canvas.NewText("拖拽框选区域 · Esc 取消", color.White) diff --git a/internal/ui/settings.go b/internal/ui/settings.go index 97c9a92..b476279 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -2,8 +2,10 @@ package ui import ( "context" + "errors" "fmt" "os/exec" + "sort" "strconv" "strings" "time" @@ -11,9 +13,11 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "viewsagent/internal/config" + "viewsagent/internal/llm" ) const settingsTitle = "viewsagent-settings" @@ -24,14 +28,21 @@ type settingsWindow struct { win fyne.Window cfg *config.Config - // 模型接入点 - profileSel *widget.Select - pName *widget.Entry - pBase *widget.Entry - pModel *widget.Entry - pKey *widget.Entry - pMax *widget.Entry - pTemp *widget.Entry + // 模型接入:左边是接入点列表,右边是当前接入点的接口详情。 + profileList *widget.List + selIndex int + loading bool // 回填表单时抑制回写 + + formatSel *widget.Select + nameEntry *widget.Entry + baseEntry *widget.Entry + modelEntry *widget.Entry + keyEntry *widget.Entry + maxEntry *widget.Entry + tempEntry *widget.Entry + headerEntry *widget.Entry + authLabel *widget.Label + previewHost *fyne.Container // 提示词 tmplSel *widget.Select @@ -50,18 +61,24 @@ type settingsWindow struct { hkSelect *widget.Entry hkToggle *widget.Entry alwaysTop *widget.Check + themeSel *widget.Select + + // tabs 是设置窗口的标签容器,开发期截图时用来切页。 + tabs *container.AppTabs } func newSettingsWindow(app *App) *settingsWindow { s := &settingsWindow{app: app} + s.cfg = app.eng.Config().Clone() s.win = app.fyne.NewWindow(settingsTitle) - s.win.Resize(fyne.NewSize(620, 620)) - s.win.SetContent(container.NewAppTabs( - container.NewTabItem("模型接入", s.buildProfileTab()), + s.win.Resize(fyne.NewSize(900, 700)) + s.tabs = container.NewAppTabs( + container.NewTabItem("模型", s.buildModelTab()), container.NewTabItem("提示词", s.buildTemplateTab()), container.NewTabItem("抓屏与触发", s.buildCaptureTab()), container.NewTabItem("其它", s.buildMiscTab()), - )) + ) + s.win.SetContent(s.tabs) return s } @@ -70,18 +87,32 @@ func (s *settingsWindow) show() { s.win.Show() } +// selectTabByName 切到指定名字的标签页,找不到就保持当前页。 +func (s *settingsWindow) selectTabByName(name string) { + for _, item := range s.tabs.Items { + if item.Text == name { + s.tabs.SelectTab(item) + return + } + } +} + // reload 从引擎当前配置重新载入界面。 func (s *settingsWindow) reload() { // 用副本编辑,避免与引擎并发读写同一份配置。 s.cfg = s.app.eng.Config().Clone() - names := make([]string, 0, len(s.cfg.Profiles)) - for _, p := range s.cfg.Profiles { - names = append(names, p.Name) + s.loading = true + s.profileList.Refresh() + idx := s.cfg.ProfileIndex(s.cfg.Active) + if idx < 0 { + idx = 0 } - s.profileSel.Options = names - s.profileSel.SetSelected(s.cfg.ActiveProfile) - s.applyProfile() + s.selIndex = idx + s.profileList.Select(idx) + // 选中的项没变化时 List 不会再回调,这里显式回填一次表单。 + s.loadProfileToForm() + s.loading = false keys := make([]string, 0, len(s.cfg.Templates)) for _, t := range s.cfg.Templates { @@ -103,88 +134,366 @@ func (s *settingsWindow) reload() { s.hkSelect.SetText(s.cfg.Hotkeys.SelectRegion) s.hkToggle.SetText(s.cfg.Hotkeys.ToggleWatch) s.alwaysTop.SetChecked(s.cfg.AlwaysOnTopCard) + s.themeSel.Options = themeOptions() + s.themeSel.SetSelected(themeLabel(s.cfg.Theme)) } -func entry(label string, obj fyne.CanvasObject) fyne.CanvasObject { - return container.NewBorder(nil, nil, widget.NewLabel(label), nil, obj) +// ---------- 模型页 ---------- + +// buildModelTab 组装模型页:左边是接入点列表,右边是当前接入点的接口详情。 +func (s *settingsWindow) buildModelTab() fyne.CanvasObject { + s.buildModelFields() + + split := container.NewHSplit(s.buildProfilePane(), s.buildDetailPane()) + split.Offset = 0.24 + return split } -func (s *settingsWindow) buildProfileTab() fyne.CanvasObject { - s.profileSel = widget.NewSelect(nil, func(string) { s.applyProfile() }) - s.profileSel.PlaceHolder = "请选择接入点" - s.pName = widget.NewEntry() - s.pBase = widget.NewEntry() - s.pBase.SetPlaceHolder("https://dashscope.aliyuncs.com/compatible-mode/v1") - s.pModel = widget.NewEntry() - s.pModel.SetPlaceHolder("qwen3-vl-plus") - s.pKey = widget.NewPasswordEntry() - s.pMax = widget.NewEntry() - s.pTemp = widget.NewEntry() +// buildModelFields 创建接口详情的输入控件。 +func (s *settingsWindow) buildModelFields() { + s.formatSel = widget.NewSelect(formatLabels(), func(string) { s.onFormatChanged() }) + s.formatSel.PlaceHolder = "请选择接口格式" + + s.nameEntry = widget.NewEntry() + s.nameEntry.SetPlaceHolder("例如:本地 Ollama") + s.nameEntry.OnChanged = func(string) { s.commitProfile() } + + s.baseEntry = widget.NewEntry() + s.baseEntry.OnChanged = func(string) { s.commitProfile() } + + s.modelEntry = widget.NewEntry() + s.modelEntry.OnChanged = func(string) { s.commitProfile() } + + s.keyEntry = widget.NewPasswordEntry() + s.keyEntry.OnChanged = func(string) { s.commitProfile() } + + s.maxEntry = widget.NewEntry() + s.maxEntry.OnChanged = func(string) { s.commitProfile() } + + s.tempEntry = widget.NewEntry() + s.tempEntry.OnChanged = func(string) { s.commitProfile() } + + s.headerEntry = widget.NewMultiLineEntry() + s.headerEntry.SetMinRowsVisible(3) + s.headerEntry.SetPlaceHolder("每行一条,例如:\nX-Api-Key: sk-xxx") + s.headerEntry.OnChanged = func(string) { s.commitProfile() } + + s.previewHost = container.NewVBox() +} + +// buildProfilePane 造左侧的接入点列表。 +func (s *settingsWindow) buildProfilePane() fyne.CanvasObject { + s.profileList = widget.NewList( + func() int { + if s.cfg == nil { + return 0 + } + return len(s.cfg.Models) + }, + func() fyne.CanvasObject { + name := widget.NewLabel("接入点") + name.Truncation = fyne.TextTruncateEllipsis + meta := widget.NewLabel("") + meta.Importance = widget.LowImportance + meta.Truncation = fyne.TextTruncateEllipsis + return container.NewVBox(name, meta) + }, + func(id widget.ListItemID, obj fyne.CanvasObject) { s.updateProfileRow(id, obj) }, + ) + s.profileList.OnSelected = func(id widget.ListItemID) { s.selectProfile(int(id)) } + + addBtn := widget.NewButtonWithIcon("新增", theme.ContentAddIcon(), func() { s.askNewFormat() }) + dupBtn := widget.NewButtonWithIcon("复制", theme.ContentCopyIcon(), func() { s.duplicateProfile() }) + delBtn := widget.NewButtonWithIcon("删除", theme.DeleteIcon(), func() { s.deleteProfile() }) + useBtn := widget.NewButton("设为生效", func() { s.activateProfile() }) + + top := container.NewVBox(widget.NewLabel("接入点"), widget.NewSeparator()) + bottom := container.NewVBox( + widget.NewSeparator(), + container.NewHBox(addBtn, dupBtn, delBtn), + useBtn, + ) + return container.NewBorder(top, bottom, nil, nil, s.profileList) +} + +// buildDetailPane 造右侧的接口详情。 +func (s *settingsWindow) buildDetailPane() fyne.CanvasObject { + s.authLabel = widget.NewLabel("API Key") form := container.NewVBox( - entry("接入点", s.profileSel), - entry("名称", s.pName), - entry("BaseURL", s.pBase), - entry("模型", s.pModel), - entry("API Key", s.pKey), - entry("max_tokens", s.pMax), - entry("temperature", s.pTemp), + entry("接口格式", s.formatSel), + entry("配置名称", s.nameEntry), + entry("BaseURL", s.baseEntry), + entry("模型名", s.modelEntry), + container.NewBorder(nil, nil, s.authLabel, nil, s.keyEntry), + entry("max_tokens", s.maxEntry), + entry("temperature", s.tempEntry), + entry("额外请求头", s.headerEntry), + widget.NewSeparator(), + widget.NewCard("请求协议", "", s.previewHost), ) - addBtn := widget.NewButton("新增接入点", func() { - name := strings.TrimSpace(s.pName.Text) - if name == "" { - name = fmt.Sprintf("custom-%d", len(s.cfg.Profiles)+1) - } - if _, exists := s.cfg.Profile(name); exists { - dialog.ShowInformation("重名", "已有同名接入点,请换一个名字", s.win) - return - } - s.cfg.Profiles = append(s.cfg.Profiles, config.Profile{ - Name: name, BaseURL: s.pBase.Text, Model: s.pModel.Text, - APIKey: s.pKey.Text, MaxTokens: 1024, Temperature: 0.3, - }) - s.cfg.ActiveProfile = name - s.reload() - }) + testBtn := widget.NewButton("测试连接", func() { s.testConnection() }) + saveBtn := widget.NewButton("保存并应用", func() { s.saveAll() }) + bottom := container.NewVBox(widget.NewSeparator(), container.NewHBox(testBtn, saveBtn)) - testBtn := widget.NewButton("测试连接", func() { - p := s.readProfile() - btn := widget.NewLabel("正在连接 " + p.BaseURL + " …") - s.win.Canvas().Content().Refresh() - d := dialog.NewCustomWithoutButtons("测试连接", btn, s.win) - d.Show() - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) - defer cancel() - err := s.app.eng.PingProfile(ctx, p) - fyne.Do(func() { - d.Hide() - if err != nil { - dialog.ShowError(fmt.Errorf("连接失败:%w", err), s.win) - return - } - dialog.ShowInformation("连接成功", "模型 "+p.Model+" 可用", s.win) - }) - }() - }) - - saveBtn := widget.NewButton("保存并应用", func() { - s.commitAll() - if err := s.app.applyAndSave(s.cfg); err != nil { - dialog.ShowError(err, s.win) - return - } - dialog.ShowInformation("已保存", "配置已写入并立即生效", s.win) - }) - - return container.NewBorder(nil, - container.NewVBox(widget.NewSeparator(), container.NewHBox(addBtn, testBtn, saveBtn)), - nil, nil, - container.NewVScroll(form), - ) + return container.NewBorder(nil, bottom, nil, nil, container.NewVScroll(form)) } +// updateProfileRow 刷新列表里的一行。 +func (s *settingsWindow) updateProfileRow(id widget.ListItemID, obj fyne.CanvasObject) { + rows := obj.(*fyne.Container).Objects + name := rows[0].(*widget.Label) + meta := rows[1].(*widget.Label) + + p, ok := s.profileAt(int(id)) + if !ok { + name.SetText("") + meta.SetText("") + return + } + name.SetText(p.Name) + detail := llm.FormatLabel(p.Format) + if p.Model != "" { + detail += " · " + p.Model + } else { + detail += " · 未填模型名" + } + if p.Name == s.cfg.Active { + name.SetText("● " + p.Name) + name.Importance = widget.HighImportance + } else { + name.Importance = widget.MediumImportance + } + meta.SetText(detail) +} + +// profileAt 取列表里的第 i 份接入点。 +func (s *settingsWindow) profileAt(i int) (config.Profile, bool) { + if s.cfg == nil || i < 0 || i >= len(s.cfg.Models) { + return config.Profile{}, false + } + return s.cfg.Models[i], true +} + +// selectProfile 切换左侧选中的接入点,右侧随之刷新。 +func (s *settingsWindow) selectProfile(idx int) { + if _, ok := s.profileAt(idx); !ok { + return + } + s.selIndex = idx + s.loadProfileToForm() +} + +// loadProfileToForm 把选中接入点的值回填到右侧表单。 +func (s *settingsWindow) loadProfileToForm() { + p, ok := s.profileAt(s.selIndex) + if !ok { + return + } + prev := s.loading + s.loading = true + s.nameEntry.SetText(p.Name) + s.formatSel.SetSelected(llm.FormatLabel(p.Format)) + s.baseEntry.SetText(p.BaseURL) + s.modelEntry.SetText(p.Model) + s.keyEntry.SetText(p.APIKey) + s.maxEntry.SetText(strconv.Itoa(p.MaxTokens)) + s.tempEntry.SetText(fmt.Sprintf("%g", p.Temperature)) + s.headerEntry.SetText(formatHeaders(p.ExtraHeaders)) + s.loading = prev + s.applyFormatHints(p.Format) +} + +// commitProfile 把右侧表单的值写回当前接入点。 +func (s *settingsWindow) commitProfile() { + if s.loading || s.cfg == nil { + return + } + if _, ok := s.profileAt(s.selIndex); !ok { + return + } + p := s.cfg.Models[s.selIndex] + oldName := p.Name + // 名字被清空时保留原名,否则生效项会指向一个空名字。 + name := strings.TrimSpace(s.nameEntry.Text) + if name == "" { + name = oldName + } + p.Name = name + p.Format = s.selectedFormat() + p.BaseURL = strings.TrimRight(strings.TrimSpace(s.baseEntry.Text), "/") + p.Model = strings.TrimSpace(s.modelEntry.Text) + p.APIKey = strings.TrimSpace(s.keyEntry.Text) + p.MaxTokens = atoiDefault(s.maxEntry.Text, 1024) + p.Temperature = atofDefault(s.tempEntry.Text, 0.3) + p.ExtraHeaders = parseHeaders(s.headerEntry.Text) + s.cfg.Models[s.selIndex] = p + + // 生效项改名时,Active 也要跟着改,否则会指向不存在的接入点。 + if oldName != p.Name && s.cfg.Active == oldName { + s.cfg.Active = p.Name + } + s.profileList.RefreshItem(s.selIndex) +} + +// formProfile 用界面上的值构造一份接入点(不落盘)。 +func (s *settingsWindow) formProfile() config.Profile { + if _, ok := s.profileAt(s.selIndex); !ok { + if p, found := s.cfg.ActiveModel(); found { + return p + } + return config.Profile{Format: llm.FormatOpenAI} + } + return config.Profile{ + Name: strings.TrimSpace(s.nameEntry.Text), + Format: s.selectedFormat(), + BaseURL: strings.TrimRight(strings.TrimSpace(s.baseEntry.Text), "/"), + Model: strings.TrimSpace(s.modelEntry.Text), + APIKey: strings.TrimSpace(s.keyEntry.Text), + MaxTokens: atoiDefault(s.maxEntry.Text, 1024), + Temperature: atofDefault(s.tempEntry.Text, 0.3), + ExtraHeaders: parseHeaders(s.headerEntry.Text), + } +} + +// selectedFormat 取下拉里选中的接口格式。 +func (s *settingsWindow) selectedFormat() string { + for _, f := range llm.Formats() { + if llm.FormatLabel(f) == s.formatSel.Selected { + return f + } + } + return llm.FormatOpenAI +} + +// onFormatChanged 切换接口格式:字段提示与协议说明跟着变。 +func (s *settingsWindow) onFormatChanged() { + if s.loading { + return + } + s.commitProfile() + s.applyFormatHints(s.selectedFormat()) +} + +// applyFormatHints 按接口格式刷新占位符、鉴权标签与协议说明。 +func (s *settingsWindow) applyFormatHints(format string) { + hint := hintFor(format) + s.baseEntry.SetPlaceHolder(hint.basePlaceholder) + s.modelEntry.SetPlaceHolder(hint.modelPlaceholder) + s.keyEntry.SetPlaceHolder(hint.keyPlaceholder) + s.authLabel.SetText(hint.authLabel) + + s.previewHost.RemoveAll() + for _, line := range hint.protocol { + key := widget.NewLabel(line[0]) + key.Importance = widget.LowImportance + s.previewHost.Add(container.NewBorder(nil, nil, key, nil, widget.NewLabel(line[1]))) + } +} + +// askNewFormat 新增接入点前先选接口格式。 +func (s *settingsWindow) askNewFormat() { + var d dialog.Dialog + btns := container.NewVBox( + widget.NewButton(llm.FormatLabel(llm.FormatOpenAI), func() { + d.Hide() + s.addProfile(llm.FormatOpenAI) + }), + widget.NewButton(llm.FormatLabel(llm.FormatAnthropic), func() { + d.Hide() + s.addProfile(llm.FormatAnthropic) + }), + widget.NewLabel("先选接口格式,再按该格式填写地址与鉴权。"), + ) + d = dialog.NewCustom("新增接入点", "取消", btns, s.win) + d.Show() +} + +// addProfile 按选定格式新增一份接入点。 +func (s *settingsWindow) addProfile(format string) { + p := s.cfg.AddProfile(config.NewProfile("接入点", format)) + s.profileList.Refresh() + s.profileList.Select(s.cfg.ProfileIndex(p.Name)) +} + +// duplicateProfile 复制当前接入点。 +func (s *settingsWindow) duplicateProfile() { + p, ok := s.profileAt(s.selIndex) + if !ok { + return + } + cp := p + cp.Name = s.cfg.UniqueName(p.Name + " 副本") + cp.ExtraHeaders = copyHeaders(p.ExtraHeaders) + s.cfg.Models = append(s.cfg.Models, cp) + s.profileList.Refresh() + s.profileList.Select(s.cfg.ProfileIndex(cp.Name)) +} + +// deleteProfile 删除当前接入点。 +func (s *settingsWindow) deleteProfile() { + p, ok := s.profileAt(s.selIndex) + if !ok { + return + } + dialog.NewConfirm("删除接入点", "确定删除「"+p.Name+"」?", func(confirmed bool) { + if !confirmed { + return + } + s.cfg.RemoveModel(p.Name) + s.profileList.Refresh() + if s.cfg.ProfileIndex(s.cfg.Active) < 0 { + s.cfg.Active = "" + } + if len(s.cfg.Models) == 0 { + s.selIndex = -1 + return + } + idx := s.selIndex + if idx >= len(s.cfg.Models) { + idx = len(s.cfg.Models) - 1 + } + s.profileList.Select(idx) + }, s.win) +} + +// activateProfile 把当前接入点设为生效项。 +func (s *settingsWindow) activateProfile() { + p, ok := s.profileAt(s.selIndex) + if !ok { + return + } + s.cfg.Active = p.Name + s.profileList.Refresh() +} + +// testConnection 用当前表单的值测试一次连通性。 +func (s *settingsWindow) testConnection() { + p := s.formProfile() + if p.BaseURL == "" || p.Model == "" { + dialog.ShowError(errors.New("请先填写 BaseURL 与模型名"), s.win) + return + } + d := dialog.NewCustomWithoutButtons("测试连接", widget.NewLabel("正在连接 "+p.BaseURL+" …"), s.win) + d.Show() + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + err := s.app.eng.PingModel(ctx, p) + fyne.Do(func() { + d.Hide() + if err != nil { + dialog.ShowError(fmt.Errorf("连接失败:%w", err), s.win) + return + } + dialog.ShowInformation("连接成功", llm.FormatLabel(p.Format)+" 接口可用:"+p.Model, s.win) + }) + }() +} + +// ---------- 其它页 ---------- + func (s *settingsWindow) buildTemplateTab() fyne.CanvasObject { s.tmplSel = widget.NewSelect(nil, func(string) { s.applyTemplate() }) s.tmplSel.PlaceHolder = "请选择模板" @@ -193,12 +502,7 @@ func (s *settingsWindow) buildTemplateTab() fyne.CanvasObject { s.tmplSystem.Wrapping = fyne.TextWrapWord saveBtn := widget.NewButton("保存提示词", func() { - key := s.tmplSel.Selected - for i := range s.cfg.Templates { - if s.cfg.Templates[i].Key == key { - s.cfg.Templates[i].System = s.tmplSystem.Text - } - } + s.commitTemplate() if err := s.app.applyAndSave(s.cfg); err != nil { dialog.ShowError(err, s.win) return @@ -235,12 +539,7 @@ func (s *settingsWindow) buildCaptureTab() fyne.CanvasObject { ) saveBtn := widget.NewButton("保存并应用", func() { - s.commitAll() - if err := s.app.applyAndSave(s.cfg); err != nil { - dialog.ShowError(err, s.win) - return - } - dialog.ShowInformation("已保存", "抓屏与触发参数已更新", s.win) + s.saveAll() }) return container.NewBorder(nil, @@ -254,6 +553,8 @@ func (s *settingsWindow) buildMiscTab() fyne.CanvasObject { s.hkSelect = widget.NewEntry() s.hkToggle = widget.NewEntry() s.alwaysTop = widget.NewCheck("显示置顶答案浮窗", nil) + s.themeSel = widget.NewSelect(themeOptions(), func(string) {}) + s.themeSel.PlaceHolder = "请选择主题" openDir := widget.NewButton("打开配置目录", func() { dir := s.app.eng.Store().Dir() @@ -263,20 +564,17 @@ func (s *settingsWindow) buildMiscTab() fyne.CanvasObject { }) saveBtn := widget.NewButton("保存并应用", func() { - s.commitAll() - if err := s.app.applyAndSave(s.cfg); err != nil { - dialog.ShowError(err, s.win) - return - } - dialog.ShowInformation("已保存", "热键修改需要重启程序生效", s.win) + s.saveAll() }) form := container.NewVBox( + entry("主题", s.themeSel), entry("框选热键", s.hkSelect), entry("监听开关热键", s.hkToggle), s.alwaysTop, container.NewHBox(openDir), widget.NewLabel("热键语法示例:Ctrl+Alt+Q、Ctrl+Shift+F2"), + widget.NewLabel("黑白高对比:纯黑底纯白字,去掉所有彩色,文字与背景对比最强。"), ) return container.NewBorder(nil, @@ -286,23 +584,6 @@ func (s *settingsWindow) buildMiscTab() fyne.CanvasObject { ) } -// applyProfile 把选中的接入点填进输入框。 -func (s *settingsWindow) applyProfile() { - if s.cfg == nil { - return - } - p, ok := s.cfg.Profile(s.profileSel.Selected) - if !ok { - return - } - s.pName.SetText(p.Name) - s.pBase.SetText(p.BaseURL) - s.pModel.SetText(p.Model) - s.pKey.SetText(p.APIKey) - s.pMax.SetText(strconv.Itoa(p.MaxTokens)) - s.pTemp.SetText(fmt.Sprintf("%g", p.Temperature)) -} - // applyTemplate 把选中的模板填进编辑框。 func (s *settingsWindow) applyTemplate() { if s.cfg == nil { @@ -316,35 +597,39 @@ func (s *settingsWindow) applyTemplate() { } } -// readProfile 用界面上的值构造一个接入点(不落盘)。 -func (s *settingsWindow) readProfile() config.Profile { - return config.Profile{ - Name: strings.TrimSpace(s.pName.Text), - BaseURL: strings.TrimSpace(s.pBase.Text), - Model: strings.TrimSpace(s.pModel.Text), - APIKey: strings.TrimSpace(s.pKey.Text), - MaxTokens: atoiDefault(s.pMax.Text, 1024), - Temperature: atofDefault(s.pTemp.Text, 0.3), +// ---------- 提交 ---------- + +// saveAll 把界面上的所有值写回配置并落盘生效。 +func (s *settingsWindow) saveAll() { + s.commitProfile() + s.commitTemplate() + s.commitCapture() + s.commitMisc() + s.cfg.Normalize() + if err := s.app.applyAndSave(s.cfg); err != nil { + dialog.ShowError(err, s.win) + return } + s.reload() + dialog.ShowInformation("已保存", "配置已写入并立即生效", s.win) } -// commitAll 把界面上的所有值写回配置对象。 -func (s *settingsWindow) commitAll() { - p := s.readProfile() - for i := range s.cfg.Profiles { - if s.cfg.Profiles[i].Name == s.profileSel.Selected { - p.Name = s.cfg.Profiles[i].Name - s.cfg.Profiles[i] = p +// commitTemplate 把模板编辑框的内容写回配置。 +func (s *settingsWindow) commitTemplate() { + key := s.tmplSel.Selected + if key == "" { + return + } + for i := range s.cfg.Templates { + if s.cfg.Templates[i].Key == key { + s.cfg.Templates[i].System = s.tmplSystem.Text } } - if s.profileSel.Selected != "" { - s.cfg.ActiveProfile = s.profileSel.Selected - } - - if key := s.tmplSel.Selected; key != "" { - s.cfg.PromptTemplate = key - } + s.cfg.PromptTemplate = key +} +// commitCapture 把抓屏与触发参数写回配置。 +func (s *settingsWindow) commitCapture() { s.cfg.Capture.FPS = atofDefault(s.fps.Text, 2) s.cfg.Capture.MaxEdge = atoiDefault(s.maxEdge.Text, 1280) s.cfg.Capture.JPEGQuality = atoiDefault(s.quality.Text, 75) @@ -352,11 +637,124 @@ func (s *settingsWindow) commitAll() { s.cfg.Capture.CooldownMs = atoiDefault(s.cooldown.Text, 3000) s.cfg.Capture.StableFrames = atoiDefault(s.stable.Text, 2) s.cfg.Capture.HistoryTurns = atoiDefault(s.turns.Text, 6) +} +// commitMisc 把热键、浮窗选项与主题写回配置。 +func (s *settingsWindow) commitMisc() { s.cfg.Hotkeys.SelectRegion = strings.TrimSpace(s.hkSelect.Text) s.cfg.Hotkeys.ToggleWatch = strings.TrimSpace(s.hkToggle.Text) s.cfg.AlwaysOnTopCard = s.alwaysTop.Checked - s.cfg.Normalize() + s.cfg.Theme = themeFromLabel(s.themeSel.Selected) +} + +// ---------- 小组件与解析 ---------- + +func entry(label string, obj fyne.CanvasObject) fyne.CanvasObject { + return container.NewBorder(nil, nil, widget.NewLabel(label), nil, obj) +} + +// formatLabels 返回接口格式下拉的可选项,只有两种兼容格式。 +func formatLabels() []string { + labels := make([]string, 0, len(llm.Formats())) + for _, f := range llm.Formats() { + labels = append(labels, llm.FormatLabel(f)) + } + return labels +} + +// formatHint 描述一种接口格式下的字段提示与协议差异。 +type formatHint struct { + basePlaceholder string + modelPlaceholder string + keyPlaceholder string + authLabel string + protocol [][2]string +} + +// hintFor 返回接口格式对应的提示。 +func hintFor(format string) formatHint { + if format == llm.FormatAnthropic { + return formatHint{ + basePlaceholder: "https://api.anthropic.com", + modelPlaceholder: "claude-3-5-sonnet-20241022", + keyPlaceholder: "sk-ant-…", + authLabel: "API Key(x-api-key)", + protocol: [][2]string{ + {"端点", "POST {BaseURL}/v1/messages"}, + {"鉴权", "x-api-key: ,默认附带 anthropic-version: " + llm.DefaultAnthropicVersion}, + {"提示词", "请求体顶层 system 字段"}, + {"图片", "source.base64"}, + {"参数", "max_tokens 必填"}, + }, + } + } + return formatHint{ + basePlaceholder: "https://dashscope.aliyuncs.com/compatible-mode/v1", + modelPlaceholder: "qwen3-vl-plus", + keyPlaceholder: "sk-…", + authLabel: "API Key(Bearer)", + protocol: [][2]string{ + {"端点", "POST {BaseURL}/chat/completions"}, + {"鉴权", "Authorization: Bearer "}, + {"提示词", "作为一条 system 消息"}, + {"图片", "image_url(data URL)"}, + {"参数", "max_tokens 可选"}, + }, + } +} + +// parseHeaders 解析"每行一条 Key: Value"的额外请求头。 +func parseHeaders(text string) map[string]string { + out := map[string]string{} + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + key, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key == "" { + continue + } + out[key] = value + } + if len(out) == 0 { + return nil + } + return out +} + +// formatHeaders 把额外请求头还原成每行一条的文本,顺序固定。 +func formatHeaders(h map[string]string) string { + if len(h) == 0 { + return "" + } + keys := make([]string, 0, len(h)) + for k := range h { + keys = append(keys, k) + } + sort.Strings(keys) + lines := make([]string, 0, len(keys)) + for _, k := range keys { + lines = append(lines, k+": "+h[k]) + } + return strings.Join(lines, "\n") +} + +// copyHeaders 深拷贝一份请求头表。 +func copyHeaders(h map[string]string) map[string]string { + if len(h) == 0 { + return nil + } + out := make(map[string]string, len(h)) + for k, v := range h { + out[k] = v + } + return out } func atoiDefault(s string, def int) int { diff --git a/internal/ui/styles.go b/internal/ui/styles.go index dbc772a..13ab783 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -23,19 +23,46 @@ func withAlpha(c color.Color, a uint8) color.Color { return n } +// mix 按比例把 b 混进 a,用来按前景/背景派生灰度层级。 +// 这样气泡底色永远跟着主题走,文字与底色对比也始终够。 +func mix(a, b color.Color, t float32) color.Color { + an := color.NRGBAModel.Convert(a).(color.NRGBA) + bn := color.NRGBAModel.Convert(b).(color.NRGBA) + blend := func(x, y uint8) uint8 { + return uint8(float32(x) + (float32(y)-float32(x))*t) + } + return color.NRGBA{ + R: blend(an.R, bn.R), + G: blend(an.G, bn.G), + B: blend(an.B, bn.B), + A: 0xFF, + } +} + // 界面配色集中在这里,方便统一调整。 +// 所有颜色都从当前主题派生,因此黑白主题下自动变成纯灰度。 + +// colorUserBubble 是用户气泡:比助手气泡略深一层,文字仍是前景色。 func colorUserBubble() color.Color { - return withAlpha(lookupColor(theme.ColorNamePrimary), 0x3A) + return mix(lookupColor(theme.ColorNameBackground), lookupColor(theme.ColorNameForeground), 0.18) } +// colorBotBubble 是助手气泡:贴着画布底色,只比背景亮一点点。 func colorBotBubble() color.Color { - return withAlpha(lookupColor(theme.ColorNameInputBackground), 0xF2) + return mix(lookupColor(theme.ColorNameBackground), lookupColor(theme.ColorNameForeground), 0.06) } +// colorErrBubble 是提示/错误气泡:中等灰度,靠边框区分。 func colorErrBubble() color.Color { - return withAlpha(lookupColor(theme.ColorNameError), 0x28) + return mix(lookupColor(theme.ColorNameBackground), lookupColor(theme.ColorNameForeground), 0.10) } +// colorBorder 是气泡描边。 func colorBorder() color.Color { - return withAlpha(lookupColor(theme.ColorNameInputBorder), 0xB0) + return mix(lookupColor(theme.ColorNameBackground), lookupColor(theme.ColorNameForeground), 0.45) +} + +// colorStrongBorder 是需要格外醒目的描边(错误提示用)。 +func colorStrongBorder() color.Color { + return lookupColor(theme.ColorNameForeground) } diff --git a/internal/ui/styles_test.go b/internal/ui/styles_test.go new file mode 100644 index 0000000..4e4fda2 --- /dev/null +++ b/internal/ui/styles_test.go @@ -0,0 +1,79 @@ +package ui + +import ( + "image/color" + "testing" + + "fyne.io/fyne/v2/test" +) + +// nrgba 把颜色转成不透明的 NRGBA,便于直接比较。 +func nrgba(c color.Color) color.NRGBA { + return color.NRGBAModel.Convert(c).(color.NRGBA) +} + +func TestBubbleColorsFollowTheme(t *testing.T) { + test.NewApp() + + applyThemeMode(ThemeContrast) + darkBot := nrgba(colorFor(bubbleBot)) + darkErr := nrgba(colorFor(bubbleError)) + darkBorder := nrgba(borderFor(bubbleUser)) + darkStrong := nrgba(borderFor(bubbleError)) + + // 黑白主题:气泡底色应贴近纯黑,文字侧仍是纯白前景。 + if darkBot.R > 0x30 { + t.Fatalf("黑白主题下助手气泡应贴近纯黑,实际 %v", darkBot) + } + if darkStrong.R != 0xFF { + t.Fatalf("黑白主题下错误气泡描边应为纯白,实际 %v", darkStrong) + } + if darkBorder.R >= darkStrong.R { + t.Fatalf("错误描边应比普通描边更亮:普通 %v,错误 %v", darkBorder, darkStrong) + } + + applyThemeMode(ThemeLight) + lightBot := nrgba(colorFor(bubbleBot)) + lightErr := nrgba(colorFor(bubbleError)) + lightStrong := nrgba(borderFor(bubbleError)) + + if lightBot.R < 0xD0 { + t.Fatalf("浅色主题下助手气泡应贴近纯白,实际 %v", lightBot) + } + if lightStrong.R != 0x00 { + t.Fatalf("浅色主题下错误气泡描边应为纯黑,实际 %v", lightStrong) + } + if lightBot.R == darkBot.R { + t.Fatal("切换主题后气泡底色应发生变化") + } + if lightErr.R == darkErr.R { + t.Fatal("切换主题后错误气泡底色应发生变化") + } +} + +func TestChatViewRethemeRepaintsBubbles(t *testing.T) { + test.NewApp() + applyThemeMode(ThemeContrast) + + c := newChatView() + b := newBubble("助手", bubbleBot) + c.appendBubble(b) + + before := nrgba(b.bg.FillColor) + beforeStroke := nrgba(b.bg.StrokeColor) + + applyThemeMode(ThemeLight) + c.retheme() + + after := nrgba(b.bg.FillColor) + afterStroke := nrgba(b.bg.StrokeColor) + if before == after { + t.Fatalf("换肤后气泡底色应改变:%v -> %v", before, after) + } + if beforeStroke == afterStroke { + t.Fatalf("换肤后气泡描边应改变:%v -> %v", beforeStroke, afterStroke) + } + if after != nrgba(colorFor(bubbleBot)) { + t.Fatalf("换肤后底色应与当前主题一致:%v != %v", after, nrgba(colorFor(bubbleBot))) + } +} diff --git a/internal/ui/theme.go b/internal/ui/theme.go new file mode 100644 index 0000000..65e4981 --- /dev/null +++ b/internal/ui/theme.go @@ -0,0 +1,164 @@ +package ui + +import ( + "image/color" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/theme" +) + +// 主题模式。 +const ( + ThemeSystem = "system" // 跟随系统 + ThemeContrast = "contrast" // 黑白高对比 + ThemeLight = "light" // 浅色高对比 +) + +// themeLabel 返回主题模式的中文名。 +func themeLabel(mode string) string { + switch mode { + case ThemeContrast: + return "黑白高对比" + case ThemeLight: + return "浅色高对比" + default: + return "跟随系统" + } +} + +// themeOptions 返回设置页下拉里的可选项。 +func themeOptions() []string { + return []string{ + themeLabel(ThemeSystem), + themeLabel(ThemeContrast), + themeLabel(ThemeLight), + } +} + +// themeFromLabel 由中文名反查主题模式。 +func themeFromLabel(label string) string { + for _, mode := range []string{ThemeSystem, ThemeContrast, ThemeLight} { + if themeLabel(mode) == label { + return mode + } + } + return ThemeSystem +} + +// applyThemeMode 按主题模式切换界面主题。 +func applyThemeMode(mode string) { + app := fyne.CurrentApp() + if app == nil { + return + } + switch mode { + case ThemeContrast: + app.Settings().SetTheme(newMonoTheme(darkPalette())) + case ThemeLight: + app.Settings().SetTheme(newMonoTheme(lightPalette())) + default: + app.Settings().SetTheme(theme.DefaultTheme()) + } +} + +// monoTheme 是一套纯灰度主题:所有颜色都去掉彩度,只靠明暗区分层级, +// 文字与背景的对比拉到最高。 +type monoTheme struct { + base fyne.Theme + colors map[fyne.ThemeColorName]color.Color +} + +// newMonoTheme 用给定配色造一个主题,其余项沿用默认主题。 +func newMonoTheme(palette map[fyne.ThemeColorName]color.Color) fyne.Theme { + return &monoTheme{base: theme.DefaultTheme(), colors: palette} +} + +func (t *monoTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color { + if c, ok := t.colors[name]; ok { + return c + } + return t.base.Color(name, variant) +} + +func (t *monoTheme) Font(style fyne.TextStyle) fyne.Resource { return t.base.Font(style) } + +func (t *monoTheme) Icon(name fyne.ThemeIconName) fyne.Resource { return t.base.Icon(name) } + +func (t *monoTheme) Size(name fyne.ThemeSizeName) float32 { return t.base.Size(name) } + +// rgb 生成不透明灰度色。 +func rgb(v uint8) color.NRGBA { + return color.NRGBA{R: v, G: v, B: v, A: 0xFF} +} + +// darkPalette 是黑白高对比(纯黑底)的配色。 +func darkPalette() map[fyne.ThemeColorName]color.Color { + return map[fyne.ThemeColorName]color.Color{ + theme.ColorNameBackground: rgb(0x00), + theme.ColorNameForeground: rgb(0xFF), + theme.ColorNameDisabled: rgb(0x8C), + theme.ColorNameDisabledButton: rgb(0x1A), + theme.ColorNameButton: rgb(0x2E), + theme.ColorNameHover: rgb(0x4A), + theme.ColorNamePressed: rgb(0x1E), + theme.ColorNameFocus: rgb(0xFF), + theme.ColorNamePrimary: rgb(0xFF), + theme.ColorNameForegroundOnPrimary: rgb(0x00), + theme.ColorNameSelection: rgb(0xFF), + theme.ColorNameInputBackground: rgb(0x0B), + theme.ColorNameInputBorder: rgb(0xE6), + theme.ColorNamePlaceHolder: rgb(0xA6), + theme.ColorNameSeparator: rgb(0x5A), + theme.ColorNameOverlayBackground: rgb(0x11), + theme.ColorNameMenuBackground: rgb(0x16), + theme.ColorNameHeaderBackground: rgb(0x1C), + theme.ColorNameInnerWindowBorder: rgb(0x3A), + theme.ColorNameInnerWindowBorderInactive: rgb(0x26), + theme.ColorNameScrollBar: rgb(0x9A), + theme.ColorNameScrollBarBackground: rgb(0x1A), + theme.ColorNameShadow: rgb(0x00), + theme.ColorNameError: rgb(0xFF), + theme.ColorNameForegroundOnError: rgb(0x00), + theme.ColorNameSuccess: rgb(0xFF), + theme.ColorNameForegroundOnSuccess: rgb(0x00), + theme.ColorNameWarning: rgb(0xFF), + theme.ColorNameForegroundOnWarning: rgb(0x00), + theme.ColorNameHyperlink: rgb(0xFF), + } +} + +// lightPalette 是浅色高对比(纯白底)的配色。 +func lightPalette() map[fyne.ThemeColorName]color.Color { + return map[fyne.ThemeColorName]color.Color{ + theme.ColorNameBackground: rgb(0xFF), + theme.ColorNameForeground: rgb(0x00), + theme.ColorNameDisabled: rgb(0x70), + theme.ColorNameDisabledButton: rgb(0xF0), + theme.ColorNameButton: rgb(0xE0), + theme.ColorNameHover: rgb(0xC8), + theme.ColorNamePressed: rgb(0xD4), + theme.ColorNameFocus: rgb(0x00), + theme.ColorNamePrimary: rgb(0x00), + theme.ColorNameForegroundOnPrimary: rgb(0xFF), + theme.ColorNameSelection: rgb(0x00), + theme.ColorNameInputBackground: rgb(0xFF), + theme.ColorNameInputBorder: rgb(0x00), + theme.ColorNamePlaceHolder: rgb(0x70), + theme.ColorNameSeparator: rgb(0x9A), + theme.ColorNameOverlayBackground: rgb(0xF4), + theme.ColorNameMenuBackground: rgb(0xFA), + theme.ColorNameHeaderBackground: rgb(0xEF), + theme.ColorNameInnerWindowBorder: rgb(0xC0), + theme.ColorNameInnerWindowBorderInactive: rgb(0xD8), + theme.ColorNameScrollBar: rgb(0x55), + theme.ColorNameScrollBarBackground: rgb(0xED), + theme.ColorNameShadow: rgb(0x00), + theme.ColorNameError: rgb(0x00), + theme.ColorNameForegroundOnError: rgb(0xFF), + theme.ColorNameSuccess: rgb(0x00), + theme.ColorNameForegroundOnSuccess: rgb(0xFF), + theme.ColorNameWarning: rgb(0x00), + theme.ColorNameForegroundOnWarning: rgb(0xFF), + theme.ColorNameHyperlink: rgb(0x00), + } +} diff --git a/internal/ui/theme_test.go b/internal/ui/theme_test.go new file mode 100644 index 0000000..a28215e --- /dev/null +++ b/internal/ui/theme_test.go @@ -0,0 +1,117 @@ +package ui + +import ( + "image/color" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/test" + "fyne.io/fyne/v2/theme" +) + +// isGray 判断颜色是否是无彩度的灰度色。 +func isGray(c color.Color) bool { + n := color.NRGBAModel.Convert(c).(color.NRGBA) + return n.R == n.G && n.G == n.B +} + +func TestThemeLabelRoundTrip(t *testing.T) { + for _, mode := range []string{ThemeSystem, ThemeContrast, ThemeLight} { + label := themeLabel(mode) + if got := themeFromLabel(label); got != mode { + t.Fatalf("主题 %q 的中文名 %q 反查应为 %q,实际 %q", mode, label, mode, got) + } + } + if got := themeFromLabel("不存在的名字"); got != ThemeSystem { + t.Fatalf("未知中文名应回落到 %q,实际 %q", ThemeSystem, got) + } + if len(themeOptions()) != 3 { + t.Fatalf("主题下拉应有 3 个选项,实际 %v", themeOptions()) + } +} + +func TestDarkPaletteIsPureBlackOnWhite(t *testing.T) { + p := darkPalette() + + black := color.NRGBA{R: 0, G: 0, B: 0, A: 0xFF} + white := color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF} + if got := p[theme.ColorNameBackground]; got != black { + t.Fatalf("黑白主题背景应为纯黑,实际 %v", got) + } + if got := p[theme.ColorNameForeground]; got != white { + t.Fatalf("黑白主题文字应为纯白,实际 %v", got) + } + for name, c := range p { + if !isGray(c) { + t.Fatalf("黑白主题不应出现彩色:%s = %v", name, c) + } + } +} + +func TestLightPaletteIsPureWhiteOnBlack(t *testing.T) { + p := lightPalette() + + black := color.NRGBA{R: 0, G: 0, B: 0, A: 0xFF} + white := color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF} + if got := p[theme.ColorNameBackground]; got != white { + t.Fatalf("浅色主题背景应为纯白,实际 %v", got) + } + if got := p[theme.ColorNameForeground]; got != black { + t.Fatalf("浅色主题文字应为纯黑,实际 %v", got) + } + for name, c := range p { + if !isGray(c) { + t.Fatalf("浅色高对比主题不应出现彩色:%s = %v", name, c) + } + } +} + +func TestMonoThemeFallsBackToDefault(t *testing.T) { + th := newMonoTheme(darkPalette()) + + black := color.NRGBA{R: 0, G: 0, B: 0, A: 0xFF} + // 调色板里有的走调色板。 + if got := th.Color(theme.ColorNameBackground, theme.VariantDark); got != black { + t.Fatalf("背景色应取自调色板,实际 %v", got) + } + // 调色板里没有的沿用默认主题。 + if got := th.Color(theme.ColorNameOverlayBackground, theme.VariantDark); got == nil { + t.Fatal("未覆盖的颜色名应回落到默认主题") + } + if th.Font(fyne.TextStyle{}) == nil || th.Size(theme.SizeNamePadding) <= 0 { + t.Fatal("字体与尺寸应沿用默认主题") + } +} + +func TestApplyThemeModeSwitchesTheme(t *testing.T) { + app := test.NewApp() + + black := color.NRGBA{R: 0, G: 0, B: 0, A: 0xFF} + white := color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF} + + applyThemeMode(ThemeContrast) + mt, ok := app.Settings().Theme().(*monoTheme) + if !ok { + t.Fatalf("黑白模式应切到 monoTheme,实际 %T", app.Settings().Theme()) + } + if got := mt.Color(theme.ColorNameBackground, app.Settings().ThemeVariant()); got != black { + t.Fatalf("黑白模式背景应为纯黑,实际 %v", got) + } + + applyThemeMode(ThemeLight) + mt, ok = app.Settings().Theme().(*monoTheme) + if !ok { + t.Fatalf("浅色模式应切到 monoTheme,实际 %T", app.Settings().Theme()) + } + if got := mt.Color(theme.ColorNameBackground, app.Settings().ThemeVariant()); got != white { + t.Fatalf("浅色模式背景应为纯白,实际 %v", got) + } + + applyThemeMode(ThemeSystem) + if _, ok := app.Settings().Theme().(*monoTheme); ok { + t.Fatal("跟随系统应还原默认主题") + } + if got := app.Settings().Theme().Color(theme.ColorNameBackground, app.Settings().ThemeVariant()); got == nil { + t.Fatal("默认主题应能取到背景色") + } +} diff --git a/internal/win32/frame.go b/internal/win32/frame.go new file mode 100644 index 0000000..83ee108 --- /dev/null +++ b/internal/win32/frame.go @@ -0,0 +1,541 @@ +package win32 + +// 取景框:屏幕上的一个红框,常驻显示当前框选区域。 +// +// 三个关键点: +// 1. 用 WS_EX_LAYERED 分层窗口自己画,框外像素 alpha=0,既不遮挡下方内容, +// 也不需要 Fyne 的透明窗口支持(OpenGL 窗口改颜色键并不可靠); +// 2. WS_EX_TRANSPARENT + WM_NCHITTEST 返回 HTTRANSPARENT,鼠标事件直接穿透, +// 不会挡住底下的点击; +// 3. SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE) 让它不出现在截屏和录屏 +// 画面里,包括本程序自己抓屏用的 GDI BitBlt(Windows 10 2004+)。 + +import ( + "fmt" + "os" + "sync" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + frameWNDClass = "ViewsAgentFrameWnd" + + wsExLayered = 0x00080000 + wsExTransparent = 0x00000020 + wsExToolWindow = 0x00000080 + wsExNoActivate = 0x08000000 + + wsPopup = 0x80000000 + + swShowNoActivate = 4 + + ulwAlpha = 0x02 + + acSrcOver = 0x00 + acSrcAlpha = 0x01 + + biRgb = 0x00 + dibRgbColors = 0x00 + + // wdaExcludeFromCapture 让窗口从截屏/录屏里消失(Windows 10 2004+)。 + wdaExcludeFromCapture = 0x11 + wdaNone = 0x00 + + wmDestroy = 0x0002 + wmEraseBkgnd = 0x0014 + wmNCHitTest = 0x0084 + wmFrameRedraw = 0x8001 // WM_APP + 1 + + // htTransparent 让鼠标事件穿透到下层窗口。 + htTransparent = ^uintptr(0) +) + +var ( + gdi32 = windows.NewLazySystemDLL("gdi32.dll") + + procCreateCompatibleDC = gdi32.NewProc("CreateCompatibleDC") + procCreateDIBSection = gdi32.NewProc("CreateDIBSection") + procSelectObject = gdi32.NewProc("SelectObject") + procDeleteObject = gdi32.NewProc("DeleteObject") + procDeleteDC = gdi32.NewProc("DeleteDC") + + procGetDC = user32.NewProc("GetDC") + procReleaseDC = user32.NewProc("ReleaseDC") + procCreateWindowExW = user32.NewProc("CreateWindowExW") + procRegisterClassExW = user32.NewProc("RegisterClassExW") + procDefWindowProcW = user32.NewProc("DefWindowProcW") + procTranslateMessage = user32.NewProc("TranslateMessage") + procDispatchMessageW = user32.NewProc("DispatchMessageW") + procPostMessageW = user32.NewProc("PostMessageW") + procDestroyWindow = user32.NewProc("DestroyWindow") + procShowWindow = user32.NewProc("ShowWindow") + procUpdateLayered = user32.NewProc("UpdateLayeredWindow") + procSetDisplayAffinity = user32.NewProc("SetWindowDisplayAffinity") + procGetDisplayAffinity = user32.NewProc("GetWindowDisplayAffinity") + procIsWindowVisible = user32.NewProc("IsWindowVisible") +) + +type bitmapInfoHeader struct { + Size uint32 + Width int32 + Height int32 + Planes uint16 + BitCount uint16 + Compression uint32 + SizeImage uint32 + XPelsPerMeter int32 + YPelsPerMeter int32 + ClrUsed uint32 + ClrImportant uint32 +} + +type bitmapInfo struct { + Header bitmapInfoHeader + Colors [1]uint32 +} + +type blendFunction struct { + BlendOp byte + BlendFlags byte + SourceConstantAlpha byte + AlphaFormat byte +} + +type winPoint struct { + X, Y int32 +} + +type winSize struct { + Cx, Cy int32 +} + +type wndClassEx struct { + Size uint32 + Style uint32 + WndProc uintptr + ClsExtra int32 + WndExtra int32 + Instance windows.Handle + Icon windows.Handle + Cursor windows.Handle + Background windows.Handle + MenuName *uint16 + ClassName *uint16 + IconSmall windows.Handle +} + +// Frame 是屏幕上的取景框。内部自带一条消息线程, +// 窗口消息必须有人泵,否则窗口不会响应。 +type Frame struct { + mu sync.Mutex + hwnd syscall.Handle + region Rect + shown bool + + memDC syscall.Handle + dib syscall.Handle + oldBmp syscall.Handle + bits unsafe.Pointer + w, h int + buf []uint32 + threadID uintptr + + lastULW uintptr + lastErr uintptr + + ready chan error + done chan struct{} +} + +// frames 记录窗口句柄到 Frame 的映射,窗口过程里要用。 +var ( + framesMu sync.Mutex + frames = map[syscall.Handle]*Frame{} +) + +// NewFrame 创建一个取景框。此时还没有区域,调用 SetRegion 才会显示。 +func NewFrame() (*Frame, error) { + f := &Frame{ + ready: make(chan error, 1), + done: make(chan struct{}), + } + go f.loop() + if err := <-f.ready; err != nil { + return nil, err + } + return f, nil +} + +// loop 在独立线程上建窗口并跑消息循环。 +func (f *Frame) loop() { + className, err := windows.UTF16PtrFromString(frameWNDClass) + if err != nil { + f.ready <- err + close(f.done) + return + } + + wc := wndClassEx{ + Size: uint32(unsafe.Sizeof(wndClassEx{})), + Style: 0x0008, // CS_DBLCLKS 无所谓,主要是别带 CS_HREDRAW/CS_VREDRAW + WndProc: windows.NewCallback(frameWndProc), + ClassName: className, + } + if atom, _, _ := procRegisterClassExW.Call(uintptr(unsafe.Pointer(&wc))); atom == 0 { + // 1410 = ERROR_CLASS_ALREADY_EXISTS,重复注册不算错。 + if code := lastErrorCode(); code != 1410 { + err = fmt.Errorf("RegisterClassEx 失败: %w", syscall.Errno(code)) + f.ready <- err + close(f.done) + return + } + } + + style := uintptr(wsExLayered | wsExTransparent | wsExToolWindow | wsExNoActivate) + hwnd, _, _ := procCreateWindowExW.Call( + style, + uintptr(unsafe.Pointer(className)), + 0, + uintptr(wsPopup), + 0, 0, 1, 1, + 0, 0, 0, 0, + ) + if hwnd == 0 { + f.ready <- fmt.Errorf("CreateWindowEx 失败: %w", syscall.Errno(lastErrorCode())) + close(f.done) + return + } + + f.mu.Lock() + f.hwnd = syscall.Handle(hwnd) + f.threadID, _, _ = procGetCurrentThreadID.Call() + f.mu.Unlock() + framesMu.Lock() + frames[syscall.Handle(hwnd)] = f + framesMu.Unlock() + + // 关键:让窗口从截屏/录屏里排除出去。 + // 开发自检时可用 VIEWSAGENT_FRAME_NO_EXCLUDE=1 临时关掉,方便截图核对。 + if os.Getenv("VIEWSAGENT_FRAME_NO_EXCLUDE") == "" { + procSetDisplayAffinity.Call(hwnd, wdaExcludeFromCapture) + } + + // 准备离屏绘图用的 DC。 + hdcScreen, _, _ := procGetDC.Call(0) + if hdcScreen != 0 { + memDC, _, _ := procCreateCompatibleDC.Call(hdcScreen) + f.mu.Lock() + f.memDC = syscall.Handle(memDC) + f.mu.Unlock() + procReleaseDC.Call(0, hdcScreen) + } + + f.ready <- nil + + var msg msg + for { + r, _, _ := procGetMessageW.Call(uintptr(unsafe.Pointer(&msg)), 0, 0, 0) + if int32(r) <= 0 { + break + } + procTranslateMessage.Call(uintptr(unsafe.Pointer(&msg))) + procDispatchMessageW.Call(uintptr(unsafe.Pointer(&msg))) + } + close(f.done) +} + +// SetRegion 把取景框移到指定区域并重画。区域无效时隐藏。 +func (f *Frame) SetRegion(r Rect) { + f.mu.Lock() + f.region = r + f.shown = r.Width() > 0 && r.Height() > 0 + hwnd := f.hwnd + f.mu.Unlock() + + if hwnd == 0 { + return + } + if r.Width() <= 0 || r.Height() <= 0 { + procShowWindow.Call(uintptr(hwnd), 0) // SW_HIDE + return + } + procShowWindow.Call(uintptr(hwnd), swShowNoActivate) + procPostMessageW.Call(uintptr(hwnd), wmFrameRedraw, 0, 0) +} + +// Hide 隐藏取景框。 +func (f *Frame) Hide() { + f.mu.Lock() + f.shown = false + hwnd := f.hwnd + f.mu.Unlock() + if hwnd != 0 { + procShowWindow.Call(uintptr(hwnd), 0) + } +} + +// SetCaptureExclusion 开关某个窗口的"不出现在截屏/录屏里"。 +// 关掉之后窗口会被录进去,只用于自检和调试。 +func SetCaptureExclusion(hwnd syscall.Handle, on bool) { + if hwnd == 0 { + return + } + affinity := uintptr(wdaExcludeFromCapture) + if !on { + affinity = wdaNone + } + procSetDisplayAffinity.Call(uintptr(hwnd), affinity) +} + +// SetCaptureExclusion 开关"不出现在截屏/录屏里"。 +// 关掉之后取景框会被录进去,只用于自检和调试。 +func (f *Frame) SetCaptureExclusion(on bool) { + f.mu.Lock() + hwnd := f.hwnd + f.mu.Unlock() + SetCaptureExclusion(hwnd, on) +} + +// FindFrameWindow 按窗口类找到取景框窗口,给外部工具(如自检脚本)用。 +func FindFrameWindow() (syscall.Handle, error) { + return FindWindowByClass(frameWNDClass) +} + +// IsWindowVisible 返回窗口是否可见。 +func IsWindowVisible(hwnd syscall.Handle) bool { + r, _, _ := procIsWindowVisible.Call(uintptr(hwnd)) + return r != 0 +} + +// DisplayAffinity 返回窗口的截屏亲和性设置。 +func DisplayAffinity(hwnd syscall.Handle) uint32 { + var v uint32 + r, _, _ := procGetDisplayAffinity.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&v))) + if r == 0 { + return 0 + } + return v +} + +// Debug 返回取景框的内部状态,用于自检排障。 +func (f *Frame) Debug() string { + f.mu.Lock() + defer f.mu.Unlock() + red := 0 + if f.bits != nil && len(f.buf) > 0 { + for _, v := range unsafe.Slice((*uint32)(f.bits), len(f.buf)) { + if v == 0xFFFF0000 { + red++ + } + } + } + return fmt.Sprintf("w=%d h=%d memDC=%v dib=%v bits=%v buf=%d 红像素=%d 区域=%+v ULW=%v err=%d", + f.w, f.h, f.memDC != 0, f.dib != 0, f.bits != nil, len(f.buf), red, f.region, f.lastULW, f.lastErr) +} + +// FrameDebug 返回取景框的内部状态,用于自检排障。 +func FrameDebug(hwnd syscall.Handle) string { + framesMu.Lock() + f := frames[hwnd] + framesMu.Unlock() + if f == nil { + return "找不到取景框实例" + } + f.mu.Lock() + defer f.mu.Unlock() + red := 0 + if f.bits != nil && len(f.buf) > 0 { + for _, v := range unsafe.Slice((*uint32)(f.bits), len(f.buf)) { + if v == 0xFFFF0000 { + red++ + } + } + } + return fmt.Sprintf("w=%d h=%d memDC=%v dib=%v bits=%v buf=%d 红像素=%d", + f.w, f.h, f.memDC != 0, f.dib != 0, f.bits != nil, len(f.buf), red) +} + +// Close 销毁取景框。 +func (f *Frame) Close() { + f.mu.Lock() + hwnd := f.hwnd + threadID := f.threadID + f.hwnd = 0 + memDC := f.memDC + dib := f.dib + oldBmp := f.oldBmp + f.memDC, f.dib, f.oldBmp = 0, 0, 0 + f.mu.Unlock() + + if hwnd != 0 { + framesMu.Lock() + delete(frames, syscall.Handle(hwnd)) + framesMu.Unlock() + procDestroyWindow.Call(uintptr(hwnd)) + // 让消息循环退出,否则这条线程会一直挂着。 + if threadID != 0 { + procPostThreadMessageW.Call(threadID, wmQuit, 0, 0) + } + } + if memDC != 0 { + if oldBmp != 0 { + procSelectObject.Call(uintptr(memDC), uintptr(oldBmp)) + } + if dib != 0 { + procDeleteObject.Call(uintptr(dib)) + } + procDeleteDC.Call(uintptr(memDC)) + } +} + +// redraw 重新绘制取景框(运行在窗口自己的线程上)。 +func (f *Frame) redraw() { + f.mu.Lock() + r := f.region + memDC := f.memDC + hwnd := f.hwnd + f.mu.Unlock() + + w, h := r.Width(), r.Height() + if w <= 0 || h <= 0 || memDC == 0 || hwnd == 0 { + return + } + f.ensureBitmap(w, h) + + // 画框:四边实心红条,中间留空(全透明)。 + const thickness = 2 + red := uint32(0xFFFF0000) // DIB 是 BGRA 顺序,A=255 R=255 G=0 B=0 + for i := range f.buf { + f.buf[i] = 0 + } + fillRect(f.buf, w, h, 0, 0, w, thickness, red) + fillRect(f.buf, w, h, 0, h-thickness, w, h, red) + fillRect(f.buf, w, h, 0, 0, thickness, h, red) + fillRect(f.buf, w, h, w-thickness, 0, w, h, red) + copy(unsafe.Slice((*uint32)(f.bits), len(f.buf)), f.buf) + + hdcScreen, _, _ := procGetDC.Call(0) + if hdcScreen == 0 { + return + } + defer procReleaseDC.Call(0, hdcScreen) + + blend := blendFunction{BlendOp: acSrcOver, SourceConstantAlpha: 255, AlphaFormat: acSrcAlpha} + dst := winPoint{X: int32(r.Left), Y: int32(r.Top)} + size := winSize{Cx: int32(w), Cy: int32(h)} + src := winPoint{} + ok, _, _ := procUpdateLayered.Call( + uintptr(hwnd), + hdcScreen, + uintptr(unsafe.Pointer(&dst)), + uintptr(unsafe.Pointer(&size)), + uintptr(memDC), + uintptr(unsafe.Pointer(&src)), + 0, + uintptr(unsafe.Pointer(&blend)), + ulwAlpha, + ) + f.mu.Lock() + f.lastULW = ok + f.lastErr = lastErrorCode() + f.mu.Unlock() + // 保持在最上层,但不抢焦点。 + procSetWindowPos.Call(uintptr(hwnd), hwndTopMost, 0, 0, 0, 0, swpNoMove|swpNoSize|swpNoActivate) +} + +// ensureBitmap 保证离屏位图尺寸与区域一致。 +func (f *Frame) ensureBitmap(w, h int) { + f.mu.Lock() + defer f.mu.Unlock() + if f.dib != 0 && f.w == w && f.h == h { + return + } + if f.dib != 0 { + procSelectObject.Call(uintptr(f.memDC), uintptr(f.oldBmp)) + procDeleteObject.Call(uintptr(f.dib)) + f.dib = 0 + f.bits = nil + } + bmi := bitmapInfo{} + bmi.Header.Size = uint32(unsafe.Sizeof(bitmapInfoHeader{})) + bmi.Header.Width = int32(w) + bmi.Header.Height = -int32(h) // 负数表示自上而下 + bmi.Header.Planes = 1 + bmi.Header.BitCount = 32 + bmi.Header.Compression = biRgb + + var bits unsafe.Pointer + dib, _, _ := procCreateDIBSection.Call( + uintptr(f.memDC), + uintptr(unsafe.Pointer(&bmi)), + dibRgbColors, + uintptr(unsafe.Pointer(&bits)), + 0, 0, + ) + if dib == 0 { + return + } + old, _, _ := procSelectObject.Call(uintptr(f.memDC), dib) + f.dib = syscall.Handle(dib) + f.oldBmp = syscall.Handle(old) + f.bits = bits + f.w, f.h = w, h + f.buf = make([]uint32, w*h) +} + +// fillRect 在像素缓冲里填一块矩形。 +func fillRect(buf []uint32, w, h, x0, y0, x1, y1 int, color uint32) { + if x0 < 0 { + x0 = 0 + } + if y0 < 0 { + y0 = 0 + } + if x1 > w { + x1 = w + } + if y1 > h { + y1 = h + } + for y := y0; y < y1; y++ { + row := y * w + for x := x0; x < x1; x++ { + buf[row+x] = color + } + } +} + +// frameWndProc 是取景框的窗口过程。 +func frameWndProc(hwnd uintptr, msg uint32, wparam, lparam uintptr) uintptr { + switch msg { + case wmNCHitTest: + // 鼠标穿透:让下层窗口收到点击。 + return htTransparent + case wmEraseBkgnd: + // 不擦背景:整个窗口由分层位图定义。 + return 1 + case wmFrameRedraw: + framesMu.Lock() + f := frames[syscall.Handle(hwnd)] + framesMu.Unlock() + if f != nil { + f.redraw() + } + return 0 + case wmDestroy: + return 0 + } + r, _, _ := procDefWindowProcW.Call(hwnd, uintptr(msg), wparam, lparam) + return r +} + +// lastErrorCode 取最近一次 Win32 调用的错误码。 +func lastErrorCode() uintptr { + if err, ok := windows.GetLastError().(syscall.Errno); ok { + return uintptr(err) + } + return 0 +} diff --git a/internal/win32/frame_test.go b/internal/win32/frame_test.go new file mode 100644 index 0000000..5f653c5 --- /dev/null +++ b/internal/win32/frame_test.go @@ -0,0 +1,118 @@ +//go:build windows + +package win32_test + +import ( + "image" + "image/png" + "os" + "path/filepath" + "testing" + "time" + + "viewsagent/internal/capture" + "viewsagent/internal/win32" +) + +// TestFrameVisibleButNotCaptured 验证取景框的两个关键性质: +// 1. 它确实显示在屏幕上(截屏能看到红框); +// 2. 打开"排除截屏"后,它不会被抓屏录进去(模型看到的画面里没有红框)。 +// +// 这是个需要桌面会话的集成测试,默认跳过: +// +// VIEWSAGENT_UI_TEST=1 go test ./internal/win32/ -run Frame +func TestFrameVisibleButNotCaptured(t *testing.T) { + if os.Getenv("VIEWSAGENT_UI_TEST") == "" { + t.Skip("需要桌面会话,设置 VIEWSAGENT_UI_TEST=1 后运行") + } + + f, err := win32.NewFrame() + if err != nil { + t.Skipf("无法创建取景框窗口(可能没有桌面会话): %v", err) + } + defer f.Close() + + region := capture.Region{X: 120, Y: 120, W: 420, H: 300} + f.SetRegion(win32.Rect{ + Left: region.X, Top: region.Y, + Right: region.X + region.W, Bottom: region.Y + region.H, + }) + time.Sleep(400 * time.Millisecond) + + // 关闭排除:截屏里应该能看到红框,说明它真的贴在屏幕上。 + f.SetCaptureExclusion(false) + time.Sleep(300 * time.Millisecond) + visible := grabRegion(t, region) + saveShot(t, "frame-visible.png", visible) + if n := countRedInBorder(visible, 3); n == 0 { + t.Fatal("取景框没有出现在屏幕上:关掉排除后截屏里也找不到红线") + } else { + t.Logf("边框窄带里有 %d 个红像素,取景框已显示在屏幕上", n) + } + + // 打开排除:截屏里不应再有红线。 + f.SetCaptureExclusion(true) + time.Sleep(300 * time.Millisecond) + hidden := grabRegion(t, region) + saveShot(t, "frame-excluded.png", hidden) + if n := countRedInBorder(hidden, 3); n != 0 { + t.Fatalf("取景框被录进截屏了,边框窄带里有 %d 个红像素", n) + } + t.Log("取景框已被排除在截屏之外") +} + +// grabRegion 抓取一块屏幕区域。 +func grabRegion(t *testing.T, r capture.Region) image.Image { + t.Helper() + img, err := (capture.Screen{}).Grab(r) + if err != nil { + t.Fatalf("抓屏失败: %v", err) + } + return img +} + +// countRedInBorder 只统计区域四边 band 像素宽的窄带里的红色像素。 +// 取景框只画在这一圈上,这样不会把页面里本来就有的红色(图标之类)算进来。 +func countRedInBorder(img image.Image, band int) int { + b := img.Bounds() + isRed := func(x, y int) bool { + r, g, bl, _ := img.At(x, y).RGBA() + return r > 0xB000 && g < 0x5000 && bl < 0x5000 + } + n := 0 + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + nearEdge := x < b.Min.X+band || x >= b.Max.X-band || + y < b.Min.Y+band || y >= b.Max.Y-band + if nearEdge && isRed(x, y) { + n++ + } + } + } + return n +} + +// saveShot 把截图写到 VIEWSAGENT_FRAME_SHOT_DIR 指定的目录,方便肉眼核对。 +func saveShot(t *testing.T, name string, img image.Image) { + t.Helper() + dir := os.Getenv("VIEWSAGENT_FRAME_SHOT_DIR") + if dir == "" { + return + } + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Logf("创建截图目录失败: %v", err) + return + } + path := filepath.Join(dir, name) + f, err := os.Create(path) + if err != nil { + t.Logf("创建截图文件失败: %v", err) + return + } + defer f.Close() + if err := png.Encode(f, img); err != nil { + t.Logf("写入截图失败: %v", err) + return + } + t.Logf("截图已保存: %s", path) +} diff --git a/internal/win32/win32.go b/internal/win32/win32.go index 76762da..009109f 100644 --- a/internal/win32/win32.go +++ b/internal/win32/win32.go @@ -16,6 +16,7 @@ var ( shcore = windows.NewLazySystemDLL("shcore.dll") procFindWindowW = user32.NewProc("FindWindowW") + procFindWindowExW = user32.NewProc("FindWindowExW") procGetWindowRect = user32.NewProc("GetWindowRect") procSetWindowPos = user32.NewProc("SetWindowPos") procGetCursorPos = user32.NewProc("GetCursorPos") @@ -200,6 +201,19 @@ func FindWindowByTitle(title string) (syscall.Handle, error) { return syscall.Handle(r), nil } +// FindWindowByClass 按窗口类名查找顶层窗口句柄。 +func FindWindowByClass(class string) (syscall.Handle, error) { + p, err := windows.UTF16PtrFromString(class) + if err != nil { + return 0, err + } + r, _, e := procFindWindowExW.Call(0, 0, uintptr(unsafe.Pointer(p)), 0) + if r == 0 { + return 0, fmt.Errorf("未找到类名为 %q 的窗口: %w", class, e) + } + return syscall.Handle(r), nil +} + // WindowRect 返回窗口在屏幕上的物理像素矩形。 func WindowRect(hwnd syscall.Handle) (Rect, error) { var r nativeRect diff --git a/cmd/viewsagent/main.go b/main.go similarity index 100% rename from cmd/viewsagent/main.go rename to main.go