优化代码

This commit is contained in:
hero920103 committed 2026-10-28 08:47:09 +08:00
1 parent 3553790475
commit eda7e8bc96
23 files changed
+3113 -342

No files matched your search

+5
View File
@@ -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
+3 -3
View File
@@ -1,10 +1,10 @@
# Build viewsagent for Windows (no console window). # Build viewsagent for Windows (no console window).
# Usage: .\build.ps1 # 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" $ErrorActionPreference = "Stop"
$mingw = "C:\msys64\mingw64\bin" $mingw = "E:\Tools\msys64\mingw64\bin"
if (Test-Path $mingw) { if (Test-Path $mingw) {
$env:Path = "$mingw;" + $env:Path $env:Path = "$mingw;" + $env:Path
} }
@@ -24,7 +24,7 @@ go test ./...
if ($LASTEXITCODE -ne 0) { Write-Error "tests failed, build aborted." } if ($LASTEXITCODE -ne 0) { Write-Error "tests failed, build aborted." }
Write-Host "==> go build" -ForegroundColor Cyan 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." } if ($LASTEXITCODE -ne 0) { Write-Error "build failed." }
$mb = [math]::Round((Get-Item "bin\viewsagent.exe").Length / 1MB, 1) $mb = [math]::Round((Get-Item "bin\viewsagent.exe").Length / 1MB, 1)
+76 -28
View File
@@ -30,8 +30,9 @@ type State struct {
TotalTokens int TotalTokens int
LastLatency time.Duration LastLatency time.Duration
Region capture.Region Region capture.Region
Model string Model string // 生效接入点里的模型名
Profile string Profile string // 生效接入点的名字
Format string // 生效接入点的接口格式
} }
// Sink 是引擎向界面推送事件的回调集合。 // Sink 是引擎向界面推送事件的回调集合。
@@ -39,7 +40,7 @@ type State struct {
type Sink interface { type Sink interface {
OnUserMessage(text string, withImage bool) OnUserMessage(text string, withImage bool)
OnAssistantStart(auto bool) OnAssistantStart(auto bool)
OnAssistantDelta(delta string) OnAssistantDelta(delta llm.Delta)
OnAssistantEnd(usage llm.Usage, err error) OnAssistantEnd(usage llm.Usage, err error)
OnState(state State) OnState(state State)
} }
@@ -72,10 +73,12 @@ func New(cfg *config.Config, store *config.Store) *Engine {
store: store, store: store,
region: cfg.Region, region: cfg.Region,
} }
if prov, err := llm.New(toOptions(cfg.Active())); err != nil { if active, ok := cfg.ActiveModel(); ok {
slog.Warn("模型接入点不可用", "err", err) if prov, err := llm.New(toOptions(active)); err != nil {
} else { slog.Warn("模型不可用", "err", err)
e.provider = prov } else {
e.provider = prov
}
} }
return e return e
} }
@@ -83,6 +86,7 @@ func New(cfg *config.Config, store *config.Store) *Engine {
func toOptions(p config.Profile) llm.Options { func toOptions(p config.Profile) llm.Options {
return llm.Options{ return llm.Options{
Name: p.Name, Name: p.Name,
Format: p.Format,
BaseURL: p.BaseURL, BaseURL: p.BaseURL,
APIKey: p.APIKey, APIKey: p.APIKey,
Model: p.Model, Model: p.Model,
@@ -120,7 +124,7 @@ func (e *Engine) State() State {
func (e *Engine) stateLocked() State { func (e *Engine) stateLocked() State {
active := config.Profile{} active := config.Profile{}
if e.cfg != nil { if e.cfg != nil {
active = e.cfg.Active() active, _ = e.cfg.ActiveModel()
} }
return State{ return State{
Watching: e.watching, Watching: e.watching,
@@ -131,6 +135,7 @@ func (e *Engine) stateLocked() State {
Region: e.region, Region: e.region,
Model: active.Model, Model: active.Model,
Profile: active.Name, Profile: active.Name,
Format: active.Format,
} }
} }
@@ -169,16 +174,28 @@ func (e *Engine) SetRegion(r capture.Region) error {
} }
// ApplyConfig 在设置页保存后刷新引擎内部状态。 // ApplyConfig 在设置页保存后刷新引擎内部状态。
// 接入点还没填完整时不算失败:配置照样生效,等用户补齐后自动恢复。
func (e *Engine) ApplyConfig(cfg *config.Config) error { func (e *Engine) ApplyConfig(cfg *config.Config) error {
prov, err := llm.New(toOptions(cfg.Active())) if cfg == nil {
if err != nil { return errors.New("配置为空")
return err
} }
e.mu.Lock() e.mu.Lock()
wasWatching := e.watching wasWatching := e.watching
// 区域只由框选修改,不被设置页的旧副本覆盖。 // 区域只由框选修改,不被设置页的旧副本覆盖。
cfg.Region = e.region cfg.Region = e.region
e.cfg = cfg 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.provider = prov
e.mu.Unlock() e.mu.Unlock()
@@ -192,8 +209,8 @@ func (e *Engine) ApplyConfig(cfg *config.Config) error {
return nil return nil
} }
// PingProfile 用给定接入点做一次连通性测试。 // PingModel 用给定的模型配置做一次连通性测试。
func (e *Engine) PingProfile(ctx context.Context, p config.Profile) error { func (e *Engine) PingModel(ctx context.Context, p config.Profile) error {
prov, err := llm.New(toOptions(p)) prov, err := llm.New(toOptions(p))
if err != nil { if err != nil {
return err return err
@@ -201,6 +218,43 @@ func (e *Engine) PingProfile(ctx context.Context, p config.Profile) error {
return prov.Ping(ctx) 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 预览(用于界面缩略图)。 // Snapshot 抓取当前区域的 JPEG 预览(用于界面缩略图)。
func (e *Engine) Snapshot() ([]byte, error) { func (e *Engine) Snapshot() ([]byte, error) {
e.mu.Lock() e.mu.Lock()
@@ -229,6 +283,9 @@ func (e *Engine) Ask(text string, attach bool) error {
// StartWatch 开始按帧差自动监听。 // StartWatch 开始按帧差自动监听。
func (e *Engine) StartWatch() error { func (e *Engine) StartWatch() error {
if err := e.EnsureReady(); err != nil {
return err
}
e.mu.Lock() e.mu.Lock()
if e.watching { if e.watching {
e.mu.Unlock() e.mu.Unlock()
@@ -264,18 +321,6 @@ func (e *Engine) StopWatch() {
e.emitState() 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 以低频抓屏 + 帧差判断是否需要提问。 // watchLoop 以低频抓屏 + 帧差判断是否需要提问。
func (e *Engine) watchLoop(stop chan struct{}) { func (e *Engine) watchLoop(stop chan struct{}) {
e.mu.Lock() e.mu.Lock()
@@ -378,7 +423,7 @@ func (e *Engine) run(text string, attach, auto bool) error {
} }
if e.provider == nil { if e.provider == nil {
e.mu.Unlock() e.mu.Unlock()
return errors.New("还没有可用的模型接入点,请到设置里填写") return errors.New("还没有接入模型,请到设置里填写")
} }
e.busy = true e.busy = true
prov := e.provider prov := e.provider
@@ -427,8 +472,11 @@ func (e *Engine) run(text string, attach, auto bool) error {
var reply strings.Builder var reply strings.Builder
start := time.Now() start := time.Now()
usage, err := prov.Chat(ctx, system, msgs, func(delta string) error { usage, err := prov.Chat(ctx, system, msgs, func(delta llm.Delta) error {
reply.WriteString(delta) // 思考过程只往界面送,不进历史:历史里留正文就够了。
if delta.Text != "" {
reply.WriteString(delta.Text)
}
if sink != nil { if sink != nil {
sink.OnAssistantDelta(delta) sink.OnAssistantDelta(delta)
} }
+83
View File
@@ -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)
}
}
+186 -63
View File
@@ -9,11 +9,14 @@ import (
"strings" "strings"
"viewsagent/internal/capture" "viewsagent/internal/capture"
"viewsagent/internal/llm"
) )
// Profile 是一个 OpenAI 兼容协议的模型接入点。 // Profile 描述一份模型接入配置。Name 是用户自己起的名字,
// 既是左侧列表的显示文本,也是 Config.Active 引用的键。
type Profile struct { type Profile struct {
Name string `json:"name"` Name string `json:"name"`
Format string `json:"format"`
BaseURL string `json:"baseURL"` BaseURL string `json:"baseURL"`
APIKey string `json:"apiKey"` APIKey string `json:"apiKey"`
Model string `json:"model"` Model string `json:"model"`
@@ -22,6 +25,20 @@ type Profile struct {
Temperature float64 `json:"temperature"` 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 是抓屏与触发策略参数。 // Capture 是抓屏与触发策略参数。
type Capture struct { type Capture struct {
FPS float64 `json:"fps"` // 监听帧率,1~5 FPS float64 `json:"fps"` // 监听帧率,1~5
@@ -48,8 +65,11 @@ type Template struct {
// Config 是完整的应用配置。 // Config 是完整的应用配置。
type Config struct { type Config struct {
ActiveProfile string `json:"activeProfile"` // Models 是全部已保存的模型配置,Active 指向其中生效的那一条。
Profiles []Profile `json:"profiles"` Models []Profile `json:"models"`
Active string `json:"active"`
// Theme 是界面主题:system 跟随系统、contrast 黑白高对比、light 浅色高对比。
Theme string `json:"theme"`
Capture Capture `json:"capture"` Capture Capture `json:"capture"`
Hotkeys Hotkeys `json:"hotkeys"` Hotkeys Hotkeys `json:"hotkeys"`
PromptTemplate string `json:"promptTemplate"` PromptTemplate string `json:"promptTemplate"`
@@ -113,32 +133,11 @@ func DefaultTemplates() []Template {
} }
} }
// Default 返回一份可直接使用的默认配置。 // Default 返回一份默认配置。
// Models 故意留空:由 Normalize 补一个空白条目,这样旧配置文件反序列化后
// 仍能通过 len(Models)==0 判断出走迁移分支。
func Default() *Config { func Default() *Config {
return &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{ Capture: Capture{
FPS: 2, MaxEdge: 1280, JPEGQuality: 75, FPS: 2, MaxEdge: 1280, JPEGQuality: 75,
DiffThreshold: 5, CooldownMs: 3000, StableFrames: 2, HistoryTurns: 6, DiffThreshold: 5, CooldownMs: 3000, StableFrames: 2, HistoryTurns: 6,
@@ -153,25 +152,85 @@ func Default() *Config {
} }
} }
// Profile 按名称查找接入点。 // ActiveModel 返回当前生效的模型配置。
func (c *Config) Profile(name string) (Profile, bool) { func (c *Config) ActiveModel() (Profile, bool) {
for _, p := range c.Profiles { for i := range c.Models {
if p.Name == name { if c.Models[i].Name == c.Active {
return p, true return c.Models[i], true
} }
} }
return Profile{}, false return Profile{}, false
} }
// Active 返回当前启用的接入点,找不到时回退到第一个。 // ModelNames 返回全部模型名字,顺序即列表顺序。
func (c *Config) Active() Profile { func (c *Config) ModelNames() []string {
if p, ok := c.Profile(c.ActiveProfile); ok { names := make([]string, 0, len(c.Models))
return p for _, m := range c.Models {
names = append(names, m.Name)
} }
if len(c.Profiles) > 0 { return names
return c.Profiles[0] }
// 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 查找提示词模板。 // Template 按 key 查找提示词模板。
@@ -190,44 +249,66 @@ func (c *Config) Template(key string) Template {
// Clone 返回一份深拷贝,避免设置窗口与引擎共享同一份可变配置。 // Clone 返回一份深拷贝,避免设置窗口与引擎共享同一份可变配置。
func (c *Config) Clone() *Config { func (c *Config) Clone() *Config {
if c == nil { if c == nil {
return Default() cfg := Default()
cfg.Normalize()
return cfg
} }
out := *c out := *c
out.Profiles = append([]Profile(nil), c.Profiles...) out.Templates = append([]Template(nil), c.Templates...)
for i := range out.Profiles { out.Models = make([]Profile, len(c.Models))
if c.Profiles[i].ExtraHeaders != nil { for i, m := range c.Models {
h := make(map[string]string, len(c.Profiles[i].ExtraHeaders)) out.Models[i] = m
for k, v := range c.Profiles[i].ExtraHeaders { if m.ExtraHeaders != nil {
h := make(map[string]string, len(m.ExtraHeaders))
for k, v := range m.ExtraHeaders {
h[k] = v h[k] = v
} }
out.Profiles[i].ExtraHeaders = h out.Models[i].ExtraHeaders = h
} }
} }
out.Templates = append([]Template(nil), c.Templates...)
return &out return &out
} }
// Normalize 补全缺失字段,保证运行期不会出现零值。 // Normalize 补全缺失字段,保证运行期不会出现零值。
func (c *Config) Normalize() { func (c *Config) Normalize() {
def := Default() if len(c.Models) == 0 {
if len(c.Profiles) == 0 { c.Models = []Profile{NewProfile("接入点 1", llm.FormatOpenAI)}
c.Profiles = def.Profiles
} }
if c.ActiveProfile == "" { for i := range c.Models {
c.ActiveProfile = c.Profiles[0].Name m := &c.Models[i]
} m.Name = strings.TrimSpace(m.Name)
for i := range c.Profiles { if m.Name == "" {
p := &c.Profiles[i] m.Name = fmt.Sprintf("接入点 %d", i+1)
if p.MaxTokens <= 0 {
p.MaxTokens = 1024
} }
if p.Temperature <= 0 { if m.Format != llm.FormatAnthropic {
p.Temperature = 0.3 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 { if len(c.Templates) == 0 {
c.Templates = def.Templates c.Templates = DefaultTemplates()
} }
if c.Capture.FPS <= 0 { if c.Capture.FPS <= 0 {
c.Capture.FPS = 2 c.Capture.FPS = 2
@@ -251,10 +332,15 @@ func (c *Config) Normalize() {
c.Capture.HistoryTurns = 6 c.Capture.HistoryTurns = 6
} }
if c.Hotkeys.SelectRegion == "" { 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 == "" { if c.Hotkeys.ToggleWatch == "" {
c.Hotkeys.ToggleWatch = def.Hotkeys.ToggleWatch c.Hotkeys.ToggleWatch = "Ctrl+Alt+Space"
} }
if c.PromptTemplate == "" { if c.PromptTemplate == "" {
c.PromptTemplate = c.Templates[0].Key c.PromptTemplate = c.Templates[0].Key
@@ -300,11 +386,48 @@ func (s *Store) Load() *Config {
fresh.Normalize() fresh.Normalize()
return fresh return fresh
} }
var legacy legacyConfig
_ = json.Unmarshal(raw, &legacy)
cfg.migrateLegacy(legacy)
cfg.Normalize() cfg.Normalize()
cfg.migrate() cfg.migrate()
return cfg 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 把旧版本的默认值升级到当前默认值,避免老配置文件一直沿用已知冲突的热键。 // migrate 把旧版本的默认值升级到当前默认值,避免老配置文件一直沿用已知冲突的热键。
func (c *Config) migrate() { func (c *Config) migrate() {
if strings.EqualFold(strings.TrimSpace(c.Hotkeys.SelectRegion), "Ctrl+Alt+Q") { if strings.EqualFold(strings.TrimSpace(c.Hotkeys.SelectRegion), "Ctrl+Alt+Q") {
+183 -19
View File
@@ -1,16 +1,23 @@
package config package config
import "testing" import (
"os"
"path/filepath"
"strings"
"testing"
"viewsagent/internal/llm"
)
func TestDefaultIsUsable(t *testing.T) { func TestDefaultIsUsable(t *testing.T) {
cfg := Default() cfg := Default()
cfg.Normalize() cfg.Normalize()
if len(cfg.Profiles) == 0 { if len(cfg.Models) == 0 {
t.Fatal("默认配置应包含接入点") t.Fatal("Normalize 后应至少有一份接入点")
} }
if cfg.Active().Name != "qwen" { if _, ok := cfg.ActiveModel(); !ok {
t.Fatalf("默认接入点应为 qwen,实际 %s", cfg.Active().Name) t.Fatal("Normalize 后应有生效接入点")
} }
if cfg.Capture.FPS <= 0 || cfg.Capture.MaxEdge <= 0 || cfg.Capture.CooldownMs <= 0 { if cfg.Capture.FPS <= 0 || cfg.Capture.MaxEdge <= 0 || cfg.Capture.CooldownMs <= 0 {
t.Fatalf("抓屏参数不应为零值: %+v", cfg.Capture) t.Fatalf("抓屏参数不应为零值: %+v", cfg.Capture)
@@ -27,8 +34,14 @@ func TestNormalizeFillsZeroValues(t *testing.T) {
cfg := &Config{} cfg := &Config{}
cfg.Normalize() cfg.Normalize()
if cfg.ActiveProfile == "" { if len(cfg.Models) == 0 {
t.Fatal("Normalize 应补全 ActiveProfile") 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 { if len(cfg.Templates) == 0 {
t.Fatal("Normalize 应补全模板") 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) { func TestCloneIsIndependent(t *testing.T) {
cfg := Default() cfg := Default()
cfg.Normalize() cfg.Normalize()
cp := cfg.Clone() 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.Templates[0].System = "changed"
cp.Capture.FPS = 9 cp.Capture.FPS = 9
if cfg.Profiles[0].Model == "changed" { if cfg.Models[0].Model == "changed" {
t.Fatal("Clone 后修改接入点不应影响原配置") t.Fatal("Clone 后修改模型不应影响原配置")
}
if len(cfg.Models[0].ExtraHeaders) != 0 {
t.Fatal("Clone 后修改请求头不应影响原配置")
} }
if cfg.Templates[0].System == "changed" { if cfg.Templates[0].System == "changed" {
t.Fatal("Clone 后修改模板不应影响原配置") 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 := Default()
cfg.Normalize() cfg.Normalize()
if _, ok := cfg.Profile("qwen"); !ok { added := cfg.AddProfile(NewProfile("接入点", llm.FormatAnthropic))
t.Fatal("应能找到 qwen 接入点") if cfg.ProfileIndex(added.Name) < 0 {
t.Fatalf("新增的接入点应能在列表里找到: %+v", cfg.Models)
} }
if _, ok := cfg.Profile("不存在"); ok { again := cfg.AddProfile(NewProfile("接入点", llm.FormatOpenAI))
t.Fatal("不应找到不存在的接入点") if again.Name == added.Name {
} t.Fatalf("重名接入点应自动改名: %s", again.Name)
cfg.ActiveProfile = "不存在" }
if cfg.Active().Name != cfg.Profiles[0].Name { }
t.Fatal("接入点丢失时应回退到第一个")
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)
} }
} }
+341
View File
@@ -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
}
+41 -20
View File
@@ -9,7 +9,6 @@ import (
"io" "io"
"net/http" "net/http"
"strings" "strings"
"time"
) )
// openAI 是 OpenAI 兼容协议(/chat/completions + SSE)的实现。 // openAI 是 OpenAI 兼容协议(/chat/completions + SSE)的实现。
@@ -25,19 +24,7 @@ func newOpenAI(opts Options) *openAI {
if opts.Temperature <= 0 { if opts.Temperature <= 0 {
opts.Temperature = 0.3 opts.Temperature = 0.3
} }
return &openAI{ return &openAI{opts: opts, http: newHTTPClient()}
opts: opts,
http: &http.Client{
// 不设总超时:流式响应可能持续很久,由 ctx 控制。
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
MaxIdleConns: 8,
IdleConnTimeout: 90 * time.Second,
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: 15 * time.Second,
},
},
}
} }
type reqMessage struct { type reqMessage struct {
@@ -72,6 +59,10 @@ type streamChunk struct {
Choices []struct { Choices []struct {
Delta struct { Delta struct {
Content string `json:"content"` Content string `json:"content"`
// 思考内容:DeepSeek-R1 / Qwen3 等推理模型走 reasoning_content,
// 也有网关用 reasoning,两个字段都收。
Reasoning string `json:"reasoning_content"`
ReasoningAlt string `json:"reasoning"`
} `json:"delta"` } `json:"delta"`
FinishReason string `json:"finish_reason"` FinishReason string `json:"finish_reason"`
} `json:"choices"` } `json:"choices"`
@@ -186,6 +177,7 @@ func (o *openAI) Chat(ctx context.Context, system string, msgs []Message, fn Del
var usage Usage var usage Usage
var got strings.Builder var got strings.Builder
var gotReasoning strings.Builder
err = readSSE(resp.Body, func(ev sseEvent) error { err = readSSE(resp.Body, func(ev sseEvent) error {
if len(ev.Data) == 0 || bytes.Equal(ev.Data, []byte("[DONE]")) { if len(ev.Data) == 0 || bytes.Equal(ev.Data, []byte("[DONE]")) {
return nil return nil
@@ -205,12 +197,24 @@ func (o *openAI) Chat(ctx context.Context, system string, msgs []Message, fn Del
} }
} }
for _, c := range chunk.Choices { 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 == "" { if c.Delta.Content == "" {
continue continue
} }
got.WriteString(c.Delta.Content) got.WriteString(c.Delta.Content)
if fn != nil { if fn != nil {
if derr := fn(c.Delta.Content); derr != nil { if derr := fn(Delta{Text: c.Delta.Content}); derr != nil {
return derr return derr
} }
} }
@@ -220,7 +224,7 @@ func (o *openAI) Chat(ctx context.Context, system string, msgs []Message, fn Del
if err != nil { if err != nil {
return usage, err return usage, err
} }
if got.Len() == 0 { if got.Len() == 0 && gotReasoning.Len() == 0 {
return usage, fmt.Errorf("模型没有返回任何内容,请确认 %s 是支持图片输入的视觉模型", o.opts.Model) return usage, fmt.Errorf("模型没有返回任何内容,请确认 %s 是支持图片输入的视觉模型", o.opts.Model)
} }
return usage, nil return usage, nil
@@ -236,6 +240,9 @@ func (o *openAI) readWhole(r io.Reader, fn DeltaFunc) (Usage, error) {
Choices []struct { Choices []struct {
Message struct { Message struct {
Content string `json:"content"` Content string `json:"content"`
// 非流式响应里思考内容通常挂在 message 上。
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
} `json:"message"` } `json:"message"`
} `json:"choices"` } `json:"choices"`
Usage *struct { Usage *struct {
@@ -253,7 +260,11 @@ func (o *openAI) readWhole(r io.Reader, fn DeltaFunc) (Usage, error) {
if resp.Error != nil { if resp.Error != nil {
return Usage{}, fmt.Errorf("模型返回错误: %s", resp.Error.Message) 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("模型返回内容为空") return Usage{}, fmt.Errorf("模型返回内容为空")
} }
var usage Usage var usage Usage
@@ -264,10 +275,20 @@ func (o *openAI) readWhole(r io.Reader, fn DeltaFunc) (Usage, error) {
TotalTokens: resp.Usage.TotalTokens, TotalTokens: resp.Usage.TotalTokens,
} }
} }
text := resp.Choices[0].Message.Content
if fn != nil { if fn != nil {
if err := fn(text); err != nil { reasoning := msg.ReasoningContent
return usage, err 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 return usage, nil
+62 -9
View File
@@ -1,11 +1,36 @@
// Package llm 提供与 OpenAI 兼容接口的大模型通信能力(含视觉与流式)。 // Package llm 提供大模型通信能力(含视觉与流式),
// 支持 OpenAI Chat Completions 与 Anthropic Messages 两种接口格式。
package llm package llm
import ( import (
"context" "context"
"fmt" "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 是消息角色。 // Role 是消息角色。
type Role string type Role string
@@ -36,12 +61,21 @@ func (u *Usage) Add(o Usage) {
u.TotalTokens += o.TotalTokens u.TotalTokens += o.TotalTokens
} }
// DeltaFunc 接收流式增量文本,返回 error 可中断请求。 // Delta 是一次流式增量。
type DeltaFunc func(delta string) error // Text 是正式回答,Reasoning 是模型吐出来的思考过程;两者都按原样透传,
// 服务端吐什么、界面就显示什么。
type Delta struct {
Text string
Reasoning string
}
// Options 描述一个模型接入点。 // DeltaFunc 接收流式增量,返回 error 可中断请求。
type DeltaFunc func(delta Delta) error
// Options 描述一个模型的接入参数。
type Options struct { type Options struct {
Name string Name string
Format string
BaseURL string BaseURL string
APIKey string APIKey string
Model string Model string
@@ -51,7 +85,6 @@ type Options struct {
} }
// Provider 是大模型服务的最小抽象。 // Provider 是大模型服务的最小抽象。
// 目前只有 OpenAI 兼容实现,其它私有协议按此接口新增即可。
type Provider interface { type Provider interface {
// Chat 发起一次流式对话,system 为系统提示词。 // Chat 发起一次流式对话,system 为系统提示词。
Chat(ctx context.Context, system string, msgs []Message, fn DeltaFunc) (Usage, error) Chat(ctx context.Context, system string, msgs []Message, fn DeltaFunc) (Usage, error)
@@ -59,13 +92,33 @@ type Provider interface {
Ping(ctx context.Context) error Ping(ctx context.Context) error
} }
// New 根据接入点创建 Provider。 // New 根据模型配置创建 Provider,按 Format 分派到对应协议实现。
func New(opts Options) (Provider, error) { func New(opts Options) (Provider, error) {
if opts.BaseURL == "" { if opts.BaseURL == "" {
return nil, fmt.Errorf("模型接入点缺少 BaseURL") return nil, fmt.Errorf("模型配置缺少 BaseURL")
} }
if opts.Model == "" { 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
} }
+139
View File
@@ -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)
}
}
+171 -43
View File
@@ -1,7 +1,9 @@
package ui package ui
import ( import (
"context"
"fmt" "fmt"
"image"
"image/png" "image/png"
"log/slog" "log/slog"
"os" "os"
@@ -45,16 +47,19 @@ type App struct {
settings *settingsWindow settings *settingsWindow
card *floatCard card *floatCard
overlay *overlay overlay *overlay
regionFrm *regionFrame
hotkeyStop func() hotkeyStop func()
// 流式文本合批,避免每个 delta 都触发一次布局 // 流式文本合批,避免每个 delta 都触发一次布局
pendMu sync.Mutex pendMu sync.Mutex
pending strings.Builder pendingText strings.Builder
pendingThink strings.Builder
pendScheduled bool pendScheduled bool
// 最近一条完整回答,用于同步到浮窗 // 最近一条完整回答,用于同步到浮窗
replyMu sync.Mutex replyMu sync.Mutex
lastReply strings.Builder lastReply strings.Builder
lastCardSync time.Time
quietModelSel bool quietModelSel bool
} }
@@ -69,6 +74,8 @@ func Run() error {
fa := fyneapp.NewWithID("com.viewsagent.desktop") fa := fyneapp.NewWithID("com.viewsagent.desktop")
installTranslations() installTranslations()
// 主题要在建界面之前定下来,否则首帧会用默认配色渲染。
applyThemeMode(cfg.Theme)
eng := agent.New(cfg, store) eng := agent.New(cfg, store)
a := &App{fyne: fa, eng: eng} a := &App{fyne: fa, eng: eng}
@@ -81,6 +88,7 @@ func Run() error {
if a.hotkeyStop != nil { if a.hotkeyStop != nil {
a.hotkeyStop() a.hotkeyStop()
} }
a.regionFrm.close()
eng.Close() eng.Close()
}() }()
@@ -88,11 +96,15 @@ func Run() error {
return nil return nil
} }
// maybeAutoShot 在设置了 VIEWSAGENT_SHOT 时,等界面稳定后把画布导出为 PNG 再退出。 // maybeAutoShot 在设置了 VIEWSAGENT_SHOT / VIEWSAGENT_SHOT_SETTINGS / VIEWSAGENT_ASK 时,
// 等界面稳定后把画布导出为 PNG 再退出。
// VIEWSAGENT_SHOT_TAB 可指定设置窗口要停在哪个标签(如「其它」)。
// 只用于开发期核对布局,正常运行不会触发。 // 只用于开发期核对布局,正常运行不会触发。
func (a *App) maybeAutoShot() { func (a *App) maybeAutoShot() {
path := os.Getenv("VIEWSAGENT_SHOT") path := os.Getenv("VIEWSAGENT_SHOT")
if path == "" { settingsPath := os.Getenv("VIEWSAGENT_SHOT_SETTINGS")
ask := os.Getenv("VIEWSAGENT_ASK")
if path == "" && settingsPath == "" && ask == "" {
return return
} }
delay := 1500 * time.Millisecond delay := 1500 * time.Millisecond
@@ -103,18 +115,46 @@ func (a *App) maybeAutoShot() {
} }
go func() { go func() {
time.Sleep(delay) 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() { fyne.Do(func() {
if img := a.win.Canvas().Capture(); img != nil { if path != "" {
if f, err := os.Create(path); err == nil { writePNG(path, a.win.Canvas().Capture())
_ = png.Encode(f, img) }
_ = f.Close() if settingsPath != "" {
} writePNG(settingsPath, a.settings.win.Canvas().Capture())
} }
a.fyne.Quit() 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() { func (a *App) build() {
a.win = a.fyne.NewWindow(mainTitle) a.win = a.fyne.NewWindow(mainTitle)
a.win.Resize(fyne.NewSize(520, 780)) a.win.Resize(fyne.NewSize(520, 780))
@@ -122,13 +162,16 @@ func (a *App) build() {
a.chat = newChatView() a.chat = newChatView()
a.card = newFloatCard(a.fyne) a.card = newFloatCard(a.fyne)
a.regionFrm = newRegionFrame()
a.settings = newSettingsWindow(a) a.settings = newSettingsWindow(a)
cfg := a.eng.Config() 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.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 := widget.NewButtonWithIcon("", theme.InfoIcon(), a.toggleCard)
cardBtn.Importance = widget.LowImportance cardBtn.Importance = widget.LowImportance
@@ -136,7 +179,7 @@ func (a *App) build() {
settingsBtn.Importance = widget.LowImportance settingsBtn.Importance = widget.LowImportance
toolbar := container.NewHBox( toolbar := container.NewHBox(
widget.NewLabel("模型"), a.modelSel, widget.NewLabel("接入点"), a.modelSel,
layout.NewSpacer(), cardBtn, settingsBtn, 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.win.SetContent(container.NewBorder(top, bottom, nil, nil, a.chat.object()))
a.applyState(a.eng.State()) a.applyState(a.eng.State())
}
func profileNames(cfg *config.Config) []string { // 开发期自检:把取景框内部状态写到文件,方便排查。
names := make([]string, 0, len(cfg.Profiles)) if p := os.Getenv("VIEWSAGENT_FRAME_DEBUG"); p != "" {
for _, p := range cfg.Profiles { go func() {
names = append(names, p.Name) time.Sleep(800 * time.Millisecond)
_ = os.WriteFile(p, []byte(a.regionFrm.Debug()), 0o644)
}()
} }
return names
} }
// ---------- 用户操作 ---------- // ---------- 用户操作 ----------
@@ -195,15 +238,46 @@ func (a *App) send() {
return return
} }
a.entry.SetText("") a.entry.SetText("")
if err := a.eng.Ask(text, a.attach.Checked); err != nil { // 请求必须放到后台:网络请求期间 UI 线程不能被占住,
a.chat.addNotice(err.Error()) // 否则流式增量只能排队等全部返回后一次性刷新。
} 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() { func (a *App) toggleWatch() {
if err := a.eng.ToggleWatch(); err != nil { if a.eng.State().Watching {
a.chat.addNotice(err.Error()) 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() { func (a *App) toggleCard() {
@@ -221,23 +295,23 @@ func (a *App) clearChat() {
a.chat.addNotice("已清空对话上下文") a.chat.addNotice("已清空对话上下文")
} }
// onModelChange 切换生效的接入点。
func (a *App) onModelChange(name string) { func (a *App) onModelChange(name string) {
if a.quietModelSel || name == "" { if a.quietModelSel || name == "" {
return return
} }
cfg := a.eng.Config() cfg := a.eng.Config().Clone()
if _, ok := cfg.Profile(name); !ok { if !cfg.SetActive(name) {
return return
} }
cfg.ActiveProfile = name
if err := a.eng.ApplyConfig(cfg); err != nil { if err := a.eng.ApplyConfig(cfg); err != nil {
a.chat.addNotice("切换模型失败:" + err.Error()) a.chat.addNotice("切换接入点失败:" + err.Error())
return return
} }
if err := a.eng.Store().Save(cfg); err != nil { 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 把引擎里的配置落盘并立即生效。 // saveConfig 把引擎里的配置落盘并立即生效。
@@ -245,7 +319,7 @@ func (a *App) saveConfig() error {
return a.applyAndSave(a.eng.Config()) return a.applyAndSave(a.eng.Config())
} }
// applyAndSave 保存并应用一份配置(覆盖模型下拉选项)。 // applyAndSave 保存并应用一份配置(覆盖接入点下拉选项)。
func (a *App) applyAndSave(cfg *config.Config) error { func (a *App) applyAndSave(cfg *config.Config) error {
cfg.Normalize() cfg.Normalize()
if err := a.eng.Store().Save(cfg); err != nil { if err := a.eng.Store().Save(cfg); err != nil {
@@ -255,9 +329,12 @@ func (a *App) applyAndSave(cfg *config.Config) error {
return err return err
} }
a.quietModelSel = true a.quietModelSel = true
a.modelSel.Options = profileNames(cfg) a.modelSel.Options = cfg.ModelNames()
a.modelSel.SetSelected(cfg.ActiveProfile) a.modelSel.SetSelected(cfg.Active)
a.quietModelSel = false a.quietModelSel = false
// 主题变了就立刻换肤,不用重启。
applyThemeMode(cfg.Theme)
a.chat.retheme()
return nil return nil
} }
@@ -267,6 +344,8 @@ func (a *App) startSelection() {
if a.overlay != nil { if a.overlay != nil {
return return
} }
// 框选过程中整屏都是选区浮层,先把常驻红框收起来,免得两层边框打架。
a.regionFrm.suspend()
// 先隐藏主窗,避免它出现在冻结帧里挡住要框选的内容 // 先隐藏主窗,避免它出现在冻结帧里挡住要框选的内容
a.win.Hide() a.win.Hide()
time.Sleep(220 * time.Millisecond) time.Sleep(220 * time.Millisecond)
@@ -274,6 +353,7 @@ func (a *App) startSelection() {
shot, err := (capture.Screen{}).Desktop() shot, err := (capture.Screen{}).Desktop()
if err != nil { if err != nil {
a.win.Show() a.win.Show()
a.regionFrm.resume(a.eng.Region())
a.chat.addNotice("抓屏失败:" + err.Error()) a.chat.addNotice("抓屏失败:" + err.Error())
return return
} }
@@ -286,16 +366,19 @@ func (a *App) onRegionSelected(r capture.Region) {
a.overlay = nil a.overlay = nil
a.win.Show() a.win.Show()
if err := a.eng.SetRegion(r); err != nil { if err := a.eng.SetRegion(r); err != nil {
a.regionFrm.resume(a.eng.Region())
a.chat.addNotice(err.Error()) a.chat.addNotice(err.Error())
return return
} }
a.card.setRegion(r) a.card.setRegion(r)
a.regionFrm.resume(r)
a.chat.addNotice(fmt.Sprintf("已框选监视区域 %s", r)) a.chat.addNotice(fmt.Sprintf("已框选监视区域 %s", r))
} }
func (a *App) onRegionCancel() { func (a *App) onRegionCancel() {
a.overlay = nil a.overlay = nil
a.win.Show() a.win.Show()
a.regionFrm.resume(a.eng.Region())
} }
func (a *App) showPreview() { func (a *App) showPreview() {
@@ -361,13 +444,21 @@ func (a *App) OnAssistantStart(auto bool) {
} }
// OnAssistantDelta 收到流式增量,40ms 合批后刷新界面。 // OnAssistantDelta 收到流式增量,40ms 合批后刷新界面。
func (a *App) OnAssistantDelta(delta string) { // 思考过程与正式回答分两路合批,保证两边的顺序都不乱。
a.replyMu.Lock() func (a *App) OnAssistantDelta(delta llm.Delta) {
a.lastReply.WriteString(delta) if delta.Text != "" {
a.replyMu.Unlock() a.replyMu.Lock()
a.lastReply.WriteString(delta.Text)
a.replyMu.Unlock()
}
a.pendMu.Lock() 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 { if a.pendScheduled {
a.pendMu.Unlock() a.pendMu.Unlock()
return return
@@ -380,15 +471,42 @@ func (a *App) OnAssistantDelta(delta string) {
func (a *App) flushPending() { func (a *App) flushPending() {
a.pendMu.Lock() a.pendMu.Lock()
text := a.pending.String() text := a.pendingText.String()
a.pending.Reset() think := a.pendingThink.String()
a.pendingText.Reset()
a.pendingThink.Reset()
a.pendScheduled = false a.pendScheduled = false
a.pendMu.Unlock() a.pendMu.Unlock()
if text == "" { if text == "" && think == "" {
return 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 结束一条助手回答。 // OnAssistantEnd 结束一条助手回答。
@@ -427,6 +545,8 @@ func (a *App) applyState(st agent.State) {
a.regionLbl.SetText("未框选区域") a.regionLbl.SetText("未框选区域")
} }
a.card.setRegion(st.Region) a.card.setRegion(st.Region)
// 屏幕上的红框跟着选区走,始终提示当前盯的是哪一块。
a.regionFrm.apply(st.Region)
if st.Busy { if st.Busy {
a.sendBtn.Disable() a.sendBtn.Disable()
@@ -443,8 +563,16 @@ func (a *App) applyState(st agent.State) {
case st.Busy: case st.Busy:
state = "正在回答" 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", 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 { if st.LastLatency > 0 {
status += fmt.Sprintf(" · 上次 %dms", st.LastLatency.Milliseconds()) status += fmt.Sprintf(" · 上次 %dms", st.LastLatency.Milliseconds())
} }
+109 -4
View File
@@ -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 是一条消息气泡:圆角背景 + 角色标题 + 正文。 // bubble 是一条消息气泡:圆角背景 + 角色标题 + 正文。
// 流式阶段用 Label 追加文本,结束后换成 RichText 以获得 Markdown 排版。 // 流式阶段用 Label 追加文本,结束后换成 RichText 以获得 Markdown 排版。
// 思考过程单独占一块(可折叠),不与正式回答混在一起。
type bubble struct { type bubble struct {
root *fyne.Container root *fyne.Container
bg *canvas.Rectangle bg *canvas.Rectangle
body *fyne.Container body *fyne.Container
head *widget.Label head *widget.Label
kind bubbleKind
thinkBtn *widget.Button
thinkText *widget.RichText
thinkBox *fyne.Container
think strings.Builder
live *widget.Label live *widget.Label
text strings.Builder text strings.Builder
} }
@@ -45,7 +61,7 @@ type bubble struct {
func newBubble(role string, kind bubbleKind) *bubble { func newBubble(role string, kind bubbleKind) *bubble {
bg := canvas.NewRectangle(colorFor(kind)) bg := canvas.NewRectangle(colorFor(kind))
bg.CornerRadius = 10 bg.CornerRadius = 10
bg.StrokeColor = colorBorder() bg.StrokeColor = borderFor(kind)
bg.StrokeWidth = 1 bg.StrokeWidth = 1
head := widget.NewLabelWithStyle(role, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) 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) body := container.NewVBox(head, live)
root := container.NewStack(bg, container.New(innerPadLayout{}, body)) 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 追加流式文本。 // append 追加流式文本。
@@ -76,12 +147,27 @@ func (b *bubble) finish(err error) {
} }
text := strings.TrimSpace(b.text.String()) text := strings.TrimSpace(b.text.String())
if text == "" { if text == "" {
text = "(没有返回内容)" if b.think.Len() > 0 {
text = "(模型只给出了思考过程,没有输出回答)"
} else {
text = "(没有返回内容)"
}
} }
rt := widget.NewRichTextFromMarkdown(text) rt := widget.NewRichTextFromMarkdown(text)
rt.Wrapping = fyne.TextWrapWord 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() b.body.Refresh()
// 思考过程默认折起来,把正文让到前面;点标题还能再展开。
if b.thinkText != nil && b.thinkText.Visible() {
b.toggleThinking()
}
} }
// innerPadLayout 给气泡留内边距。 // innerPadLayout 给气泡留内边距。
@@ -113,6 +199,7 @@ type chatView struct {
list *fyne.Container list *fyne.Container
scroll *container.Scroll scroll *container.Scroll
current *bubble current *bubble
bubbles []*bubble
} }
func newChatView() *chatView { func newChatView() *chatView {
@@ -127,10 +214,18 @@ func newChatView() *chatView {
func (c *chatView) object() fyne.CanvasObject { return c.scroll } func (c *chatView) object() fyne.CanvasObject { return c.scroll }
func (c *chatView) appendBubble(b *bubble) { func (c *chatView) appendBubble(b *bubble) {
c.bubbles = append(c.bubbles, b)
c.list.Add(b.root) c.list.Add(b.root)
c.scroll.ScrollToBottom() c.scroll.ScrollToBottom()
} }
// retheme 主题切换后给所有气泡重新上色。
func (c *chatView) retheme() {
for _, b := range c.bubbles {
b.retheme()
}
}
// addUser 添加一条用户消息。 // addUser 添加一条用户消息。
func (c *chatView) addUser(text string, withImage bool) { func (c *chatView) addUser(text string, withImage bool) {
if withImage { if withImage {
@@ -161,6 +256,15 @@ func (c *chatView) appendDelta(delta string) {
c.scroll.ScrollToBottom() 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 结束当前助手消息。 // endAssistant 结束当前助手消息。
func (c *chatView) endAssistant(err error) { func (c *chatView) endAssistant(err error) {
if c.current == nil { if c.current == nil {
@@ -182,6 +286,7 @@ func (c *chatView) addNotice(text string) {
// clear 清空消息列表。 // clear 清空消息列表。
func (c *chatView) clear() { func (c *chatView) clear() {
c.list.Objects = nil c.list.Objects = nil
c.bubbles = nil
c.current = nil c.current = nil
c.list.Refresh() c.list.Refresh()
} }
+102
View File
@@ -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 &regionFrame{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()
}
+2 -1
View File
@@ -64,7 +64,8 @@ func newOverlay(app fyne.App, shot image.Image, vd win32.Rect,
objs = append(objs, o.dims[i]) objs = append(objs, o.dims[i])
} }
for i := range o.edges { 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]) objs = append(objs, o.edges[i])
} }
o.hint = canvas.NewText("拖拽框选区域 · Esc 取消", color.White) o.hint = canvas.NewText("拖拽框选区域 · Esc 取消", color.White)
+546 -148
View File
@@ -2,8 +2,10 @@ package ui
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os/exec" "os/exec"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -11,9 +13,11 @@ import (
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
"viewsagent/internal/config" "viewsagent/internal/config"
"viewsagent/internal/llm"
) )
const settingsTitle = "viewsagent-settings" const settingsTitle = "viewsagent-settings"
@@ -24,14 +28,21 @@ type settingsWindow struct {
win fyne.Window win fyne.Window
cfg *config.Config cfg *config.Config
// 模型接入点 // 模型接入:左边是接入点列表,右边是当前接入点的接口详情。
profileSel *widget.Select profileList *widget.List
pName *widget.Entry selIndex int
pBase *widget.Entry loading bool // 回填表单时抑制回写
pModel *widget.Entry
pKey *widget.Entry formatSel *widget.Select
pMax *widget.Entry nameEntry *widget.Entry
pTemp *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 tmplSel *widget.Select
@@ -50,18 +61,24 @@ type settingsWindow struct {
hkSelect *widget.Entry hkSelect *widget.Entry
hkToggle *widget.Entry hkToggle *widget.Entry
alwaysTop *widget.Check alwaysTop *widget.Check
themeSel *widget.Select
// tabs 是设置窗口的标签容器,开发期截图时用来切页。
tabs *container.AppTabs
} }
func newSettingsWindow(app *App) *settingsWindow { func newSettingsWindow(app *App) *settingsWindow {
s := &settingsWindow{app: app} s := &settingsWindow{app: app}
s.cfg = app.eng.Config().Clone()
s.win = app.fyne.NewWindow(settingsTitle) s.win = app.fyne.NewWindow(settingsTitle)
s.win.Resize(fyne.NewSize(620, 620)) s.win.Resize(fyne.NewSize(900, 700))
s.win.SetContent(container.NewAppTabs( s.tabs = container.NewAppTabs(
container.NewTabItem("模型接入", s.buildProfileTab()), container.NewTabItem("模型", s.buildModelTab()),
container.NewTabItem("提示词", s.buildTemplateTab()), container.NewTabItem("提示词", s.buildTemplateTab()),
container.NewTabItem("抓屏与触发", s.buildCaptureTab()), container.NewTabItem("抓屏与触发", s.buildCaptureTab()),
container.NewTabItem("其它", s.buildMiscTab()), container.NewTabItem("其它", s.buildMiscTab()),
)) )
s.win.SetContent(s.tabs)
return s return s
} }
@@ -70,18 +87,32 @@ func (s *settingsWindow) show() {
s.win.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 从引擎当前配置重新载入界面。 // reload 从引擎当前配置重新载入界面。
func (s *settingsWindow) reload() { func (s *settingsWindow) reload() {
// 用副本编辑,避免与引擎并发读写同一份配置。 // 用副本编辑,避免与引擎并发读写同一份配置。
s.cfg = s.app.eng.Config().Clone() s.cfg = s.app.eng.Config().Clone()
names := make([]string, 0, len(s.cfg.Profiles)) s.loading = true
for _, p := range s.cfg.Profiles { s.profileList.Refresh()
names = append(names, p.Name) idx := s.cfg.ProfileIndex(s.cfg.Active)
if idx < 0 {
idx = 0
} }
s.profileSel.Options = names s.selIndex = idx
s.profileSel.SetSelected(s.cfg.ActiveProfile) s.profileList.Select(idx)
s.applyProfile() // 选中的项没变化时 List 不会再回调,这里显式回填一次表单。
s.loadProfileToForm()
s.loading = false
keys := make([]string, 0, len(s.cfg.Templates)) keys := make([]string, 0, len(s.cfg.Templates))
for _, t := range 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.hkSelect.SetText(s.cfg.Hotkeys.SelectRegion)
s.hkToggle.SetText(s.cfg.Hotkeys.ToggleWatch) s.hkToggle.SetText(s.cfg.Hotkeys.ToggleWatch)
s.alwaysTop.SetChecked(s.cfg.AlwaysOnTopCard) 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 { // buildModelFields 创建接口详情的输入控件。
s.profileSel = widget.NewSelect(nil, func(string) { s.applyProfile() }) func (s *settingsWindow) buildModelFields() {
s.profileSel.PlaceHolder = "请选择接入点" s.formatSel = widget.NewSelect(formatLabels(), func(string) { s.onFormatChanged() })
s.pName = widget.NewEntry() s.formatSel.PlaceHolder = "请选择接口格式"
s.pBase = widget.NewEntry()
s.pBase.SetPlaceHolder("https://dashscope.aliyuncs.com/compatible-mode/v1") s.nameEntry = widget.NewEntry()
s.pModel = widget.NewEntry() s.nameEntry.SetPlaceHolder("例如:本地 Ollama")
s.pModel.SetPlaceHolder("qwen3-vl-plus") s.nameEntry.OnChanged = func(string) { s.commitProfile() }
s.pKey = widget.NewPasswordEntry()
s.pMax = widget.NewEntry() s.baseEntry = widget.NewEntry()
s.pTemp = 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( form := container.NewVBox(
entry("接入点", s.profileSel), entry("接口格式", s.formatSel),
entry("名称", s.pName), entry("配置名称", s.nameEntry),
entry("BaseURL", s.pBase), entry("BaseURL", s.baseEntry),
entry("模型", s.pModel), entry("模型名", s.modelEntry),
entry("API Key", s.pKey), container.NewBorder(nil, nil, s.authLabel, nil, s.keyEntry),
entry("max_tokens", s.pMax), entry("max_tokens", s.maxEntry),
entry("temperature", s.pTemp), entry("temperature", s.tempEntry),
entry("额外请求头", s.headerEntry),
widget.NewSeparator(),
widget.NewCard("请求协议", "", s.previewHost),
) )
addBtn := widget.NewButton("新增接入点", func() { testBtn := widget.NewButton("测试连接", func() { s.testConnection() })
name := strings.TrimSpace(s.pName.Text) saveBtn := widget.NewButton("保存并应用", func() { s.saveAll() })
if name == "" { bottom := container.NewVBox(widget.NewSeparator(), container.NewHBox(testBtn, saveBtn))
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() { return container.NewBorder(nil, bottom, nil, nil, container.NewVScroll(form))
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),
)
} }
// 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 { func (s *settingsWindow) buildTemplateTab() fyne.CanvasObject {
s.tmplSel = widget.NewSelect(nil, func(string) { s.applyTemplate() }) s.tmplSel = widget.NewSelect(nil, func(string) { s.applyTemplate() })
s.tmplSel.PlaceHolder = "请选择模板" s.tmplSel.PlaceHolder = "请选择模板"
@@ -193,12 +502,7 @@ func (s *settingsWindow) buildTemplateTab() fyne.CanvasObject {
s.tmplSystem.Wrapping = fyne.TextWrapWord s.tmplSystem.Wrapping = fyne.TextWrapWord
saveBtn := widget.NewButton("保存提示词", func() { saveBtn := widget.NewButton("保存提示词", func() {
key := s.tmplSel.Selected s.commitTemplate()
for i := range s.cfg.Templates {
if s.cfg.Templates[i].Key == key {
s.cfg.Templates[i].System = s.tmplSystem.Text
}
}
if err := s.app.applyAndSave(s.cfg); err != nil { if err := s.app.applyAndSave(s.cfg); err != nil {
dialog.ShowError(err, s.win) dialog.ShowError(err, s.win)
return return
@@ -235,12 +539,7 @@ func (s *settingsWindow) buildCaptureTab() fyne.CanvasObject {
) )
saveBtn := widget.NewButton("保存并应用", func() { saveBtn := widget.NewButton("保存并应用", func() {
s.commitAll() s.saveAll()
if err := s.app.applyAndSave(s.cfg); err != nil {
dialog.ShowError(err, s.win)
return
}
dialog.ShowInformation("已保存", "抓屏与触发参数已更新", s.win)
}) })
return container.NewBorder(nil, return container.NewBorder(nil,
@@ -254,6 +553,8 @@ func (s *settingsWindow) buildMiscTab() fyne.CanvasObject {
s.hkSelect = widget.NewEntry() s.hkSelect = widget.NewEntry()
s.hkToggle = widget.NewEntry() s.hkToggle = widget.NewEntry()
s.alwaysTop = widget.NewCheck("显示置顶答案浮窗", nil) s.alwaysTop = widget.NewCheck("显示置顶答案浮窗", nil)
s.themeSel = widget.NewSelect(themeOptions(), func(string) {})
s.themeSel.PlaceHolder = "请选择主题"
openDir := widget.NewButton("打开配置目录", func() { openDir := widget.NewButton("打开配置目录", func() {
dir := s.app.eng.Store().Dir() dir := s.app.eng.Store().Dir()
@@ -263,20 +564,17 @@ func (s *settingsWindow) buildMiscTab() fyne.CanvasObject {
}) })
saveBtn := widget.NewButton("保存并应用", func() { saveBtn := widget.NewButton("保存并应用", func() {
s.commitAll() s.saveAll()
if err := s.app.applyAndSave(s.cfg); err != nil {
dialog.ShowError(err, s.win)
return
}
dialog.ShowInformation("已保存", "热键修改需要重启程序生效", s.win)
}) })
form := container.NewVBox( form := container.NewVBox(
entry("主题", s.themeSel),
entry("框选热键", s.hkSelect), entry("框选热键", s.hkSelect),
entry("监听开关热键", s.hkToggle), entry("监听开关热键", s.hkToggle),
s.alwaysTop, s.alwaysTop,
container.NewHBox(openDir), container.NewHBox(openDir),
widget.NewLabel("热键语法示例:Ctrl+Alt+Q、Ctrl+Shift+F2"), widget.NewLabel("热键语法示例:Ctrl+Alt+Q、Ctrl+Shift+F2"),
widget.NewLabel("黑白高对比:纯黑底纯白字,去掉所有彩色,文字与背景对比最强。"),
) )
return container.NewBorder(nil, 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 把选中的模板填进编辑框。 // applyTemplate 把选中的模板填进编辑框。
func (s *settingsWindow) applyTemplate() { func (s *settingsWindow) applyTemplate() {
if s.cfg == nil { if s.cfg == nil {
@@ -316,35 +597,39 @@ func (s *settingsWindow) applyTemplate() {
} }
} }
// readProfile 用界面上的值构造一个接入点(不落盘)。 // ---------- 提交 ----------
func (s *settingsWindow) readProfile() config.Profile {
return config.Profile{ // saveAll 把界面上的所有值写回配置并落盘生效。
Name: strings.TrimSpace(s.pName.Text), func (s *settingsWindow) saveAll() {
BaseURL: strings.TrimSpace(s.pBase.Text), s.commitProfile()
Model: strings.TrimSpace(s.pModel.Text), s.commitTemplate()
APIKey: strings.TrimSpace(s.pKey.Text), s.commitCapture()
MaxTokens: atoiDefault(s.pMax.Text, 1024), s.commitMisc()
Temperature: atofDefault(s.pTemp.Text, 0.3), 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 把界面上的所有值写回配置对象。 // commitTemplate 把模板编辑框的内容写回配置。
func (s *settingsWindow) commitAll() { func (s *settingsWindow) commitTemplate() {
p := s.readProfile() key := s.tmplSel.Selected
for i := range s.cfg.Profiles { if key == "" {
if s.cfg.Profiles[i].Name == s.profileSel.Selected { return
p.Name = s.cfg.Profiles[i].Name }
s.cfg.Profiles[i] = p 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.PromptTemplate = key
s.cfg.ActiveProfile = s.profileSel.Selected }
}
if key := s.tmplSel.Selected; key != "" {
s.cfg.PromptTemplate = key
}
// commitCapture 把抓屏与触发参数写回配置。
func (s *settingsWindow) commitCapture() {
s.cfg.Capture.FPS = atofDefault(s.fps.Text, 2) s.cfg.Capture.FPS = atofDefault(s.fps.Text, 2)
s.cfg.Capture.MaxEdge = atoiDefault(s.maxEdge.Text, 1280) s.cfg.Capture.MaxEdge = atoiDefault(s.maxEdge.Text, 1280)
s.cfg.Capture.JPEGQuality = atoiDefault(s.quality.Text, 75) 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.CooldownMs = atoiDefault(s.cooldown.Text, 3000)
s.cfg.Capture.StableFrames = atoiDefault(s.stable.Text, 2) s.cfg.Capture.StableFrames = atoiDefault(s.stable.Text, 2)
s.cfg.Capture.HistoryTurns = atoiDefault(s.turns.Text, 6) 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.SelectRegion = strings.TrimSpace(s.hkSelect.Text)
s.cfg.Hotkeys.ToggleWatch = strings.TrimSpace(s.hkToggle.Text) s.cfg.Hotkeys.ToggleWatch = strings.TrimSpace(s.hkToggle.Text)
s.cfg.AlwaysOnTopCard = s.alwaysTop.Checked 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: <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 <API Key>"},
{"提示词", "作为一条 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 { func atoiDefault(s string, def int) int {
+31 -4
View File
@@ -23,19 +23,46 @@ func withAlpha(c color.Color, a uint8) color.Color {
return n 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 { 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 { 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 { 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 { 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)
} }
+79
View File
@@ -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)))
}
}
+164
View File
@@ -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),
}
}
+117
View File
@@ -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("默认主题应能取到背景色")
}
}
+541
View File
@@ -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
}
+118
View File
@@ -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)
}
+14
View File
@@ -16,6 +16,7 @@ var (
shcore = windows.NewLazySystemDLL("shcore.dll") shcore = windows.NewLazySystemDLL("shcore.dll")
procFindWindowW = user32.NewProc("FindWindowW") procFindWindowW = user32.NewProc("FindWindowW")
procFindWindowExW = user32.NewProc("FindWindowExW")
procGetWindowRect = user32.NewProc("GetWindowRect") procGetWindowRect = user32.NewProc("GetWindowRect")
procSetWindowPos = user32.NewProc("SetWindowPos") procSetWindowPos = user32.NewProc("SetWindowPos")
procGetCursorPos = user32.NewProc("GetCursorPos") procGetCursorPos = user32.NewProc("GetCursorPos")
@@ -200,6 +201,19 @@ func FindWindowByTitle(title string) (syscall.Handle, error) {
return syscall.Handle(r), nil 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 返回窗口在屏幕上的物理像素矩形。 // WindowRect 返回窗口在屏幕上的物理像素矩形。
func WindowRect(hwnd syscall.Handle) (Rect, error) { func WindowRect(hwnd syscall.Handle) (Rect, error) {
var r nativeRect var r nativeRect
View File
File renamed without changes.