first commit

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

No files matched your search

Binary file not shown.
+31
View File
@@ -0,0 +1,31 @@
# Build viewsagent for Windows (no console window).
# Usage: .\build.ps1
# Requires: Go 1.24+ and gcc from MSYS2 mingw-w64 (C:\msys64\mingw64\bin).
$ErrorActionPreference = "Stop"
$mingw = "C:\msys64\mingw64\bin"
if (Test-Path $mingw) {
$env:Path = "$mingw;" + $env:Path
}
if (-not (Get-Command gcc -ErrorAction SilentlyContinue)) {
Write-Error "gcc not found. Fyne needs cgo; install mingw-w64-gcc first (see scripts/setup-gcc.sh)."
}
$env:CGO_ENABLED = "1"
if (-not (Test-Path "bin")) {
New-Item -ItemType Directory -Path "bin" | Out-Null
}
Write-Host "==> go test" -ForegroundColor Cyan
go test ./...
if ($LASTEXITCODE -ne 0) { Write-Error "tests failed, build aborted." }
Write-Host "==> go build" -ForegroundColor Cyan
go build -trimpath -ldflags "-H windowsgui -s -w" -o "bin\viewsagent.exe" .\cmd\viewsagent
if ($LASTEXITCODE -ne 0) { Write-Error "build failed." }
$mb = [math]::Round((Get-Item "bin\viewsagent.exe").Length / 1MB, 1)
Write-Host "==> done: bin\viewsagent.exe ($mb MB)" -ForegroundColor Green
+40
View File
@@ -0,0 +1,40 @@
// Command viewsagent 是桌面视觉问答助手。
package main
import (
"io"
"log/slog"
"os"
"path/filepath"
"viewsagent/internal/ui"
)
func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(logWriter(), &slog.HandlerOptions{
Level: slog.LevelInfo,
})))
if err := ui.Run(); err != nil {
slog.Error("启动失败", "err", err)
os.Exit(1)
}
}
// logWriter 同时输出到控制台和配置文件旁边的 app.log,
// 打包成无控制台窗口后也能排查问题。
func logWriter() io.Writer {
dir, err := os.UserConfigDir()
if err != nil {
return os.Stderr
}
base := filepath.Join(dir, "viewsagent")
if err := os.MkdirAll(base, 0o755); err != nil {
return os.Stderr
}
f, err := os.OpenFile(filepath.Join(base, "app.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return os.Stderr
}
return io.MultiWriter(os.Stderr, f)
}
+48
View File
@@ -0,0 +1,48 @@
module viewsagent
go 1.26.0
require (
fyne.io/fyne/v2 v2.8.2
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018
golang.org/x/image v0.47.0
golang.org/x/sys v0.49.0
)
require (
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/FyshOS/fancyfs v0.0.1 // indirect
github.com/anthonynsimon/bild v0.14.0 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect
github.com/fyne-io/glfw-js v0.4.0 // indirect
github.com/fyne-io/image v0.1.1 // indirect
github.com/fyne-io/oksvg v0.2.1-0.20260918172519-a9af55fa95d9 // indirect
github.com/gen2brain/shm v0.1.0 // indirect
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 // indirect
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.2 // indirect
github.com/go-text/render v0.2.1 // indirect
github.com/go-text/typesetting v0.3.5 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jezek/xgb v1.1.1 // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rymdport/portal v0.4.2 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/text v0.43.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+88
View File
@@ -0,0 +1,88 @@
fyne.io/fyne/v2 v2.8.2 h1:gYNJj+ZEPd2wF3M8SlF5iL6febpZkZ1csheRFkuEfHU=
fyne.io/fyne/v2 v2.8.2/go.mod h1:eXHJ/D9Y58UcDw7SkguadCRI64M4flXyjoMn827Q5SI=
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 h1:149/+Wa5EsLLXfyj2pdTmvnQf2VIlgCIwSjcCTHYhIo=
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/FyshOS/fancyfs v0.0.1 h1:kgvm7VvwOMLkYTqSflplp62SlMVWQ2uAoHw9CXwXHYg=
github.com/FyshOS/fancyfs v0.0.1/go.mod h1:S5SHVz/5R72iCXOxCqdcyTPSlg3JxNd0gaHyGBSrY8A=
github.com/anthonynsimon/bild v0.14.0 h1:IFRkmKdNdqmexXHfEU7rPlAmdUZ8BDZEGtGHDnGWync=
github.com/anthonynsimon/bild v0.14.0/go.mod h1:hcvEAyBjTW69qkKJTfpcDQ83sSZHxwOunsseDfeQhUs=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8=
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
github.com/fyne-io/glfw-js v0.4.0 h1:I9hREBeFyI10cNIqbMKYb1PRidyPDgwob8o2la9SfQo=
github.com/fyne-io/glfw-js v0.4.0/go.mod h1:SDchsFZh4n7nVuBoiowOhOgIBdz+qUQVeC1w9fe2yVU=
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
github.com/fyne-io/oksvg v0.2.1-0.20260918172519-a9af55fa95d9 h1:owsdpq6a2KhaAA/hoHGQmFM7kOgKo3srXZVGnbEUzr0=
github.com/fyne-io/oksvg v0.2.1-0.20260918172519-a9af55fa95d9/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
github.com/gen2brain/shm v0.1.0 h1:MwPeg+zJQXN0RM9o+HqaSFypNoNEcNpeoGp0BTSx2YY=
github.com/gen2brain/shm v0.1.0/go.mod h1:UgIcVtvmOu+aCJpqJX7GOtiN7X2ct+TKLg4RTxwPIUA=
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHI=
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.2 h1:ez76TQInhkDIMobWbOFcPJw6FeB7coXRAVZpKwF8NBg=
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.2/go.mod h1:T5Dn0JwIJOX1euPZ/iT4tq6nFYtmukjcYa7937HuYK8=
github.com/go-text/render v0.2.1 h1:qwHhxqGUjjg4L0XyJWj7M7bpY75NZM+kBpv2Yfw5mcg=
github.com/go-text/render v0.2.1/go.mod h1:HCCAq8MUlm/WRcXshBb4K/n+IkjeXQ1c2Ba+yICSm0A=
github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68=
github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2It5K75XkavhTiCKExUfVaVDS1feBnLCru5qeoY=
github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018 h1:NQYgMY188uWrS+E/7xMVpydsI48PMHcc7SfR4OxkDF4=
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018/go.mod h1:Pmpz2BLf55auQZ67u3rvyI2vAQvNetkK/4zYUmpauZQ=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo=
golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+485
View File
@@ -0,0 +1,485 @@
// Package agent 是应用的业务核心:组织消息、控制抓屏监听与调用大模型。
package agent
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"viewsagent/internal/capture"
"viewsagent/internal/config"
"viewsagent/internal/llm"
"viewsagent/internal/vision"
)
// autoPrompt 是自动触发时附加的指令,避免模型对着同一道题反复作答。
const autoPrompt = "画面已更新。请针对当前画面中最新出现的内容作答,不要重复回答之前已经答过的题目;如果没有新内容,只回复「(无新内容)」。"
// ErrBusy 表示上一条回答尚未结束。
var ErrBusy = errors.New("上一条回答还没结束,请稍候")
// State 是引擎对外暴露的运行时快照。
type State struct {
Watching bool
Busy bool
Triggers int
TotalTokens int
LastLatency time.Duration
Region capture.Region
Model string
Profile string
}
// Sink 是引擎向界面推送事件的回调集合。
// 所有回调都可能来自任意 goroutine,实现方必须自行切换到 UI 线程。
type Sink interface {
OnUserMessage(text string, withImage bool)
OnAssistantStart(auto bool)
OnAssistantDelta(delta string)
OnAssistantEnd(usage llm.Usage, err error)
OnState(state State)
}
// Engine 把抓屏、意图与模型调用串起来。
type Engine struct {
mu sync.Mutex
cfg *config.Config
store *config.Store
screen capture.Screen
provider llm.Provider
sink Sink
region capture.Region
watching bool
stopWatch chan struct{}
busy bool
triggers int
lastLatency time.Duration
usage llm.Usage
// history 只保存纯文本,不保留历史图片,避免图片重复计费与内存膨胀。
history []llm.Message
}
// New 创建引擎。
func New(cfg *config.Config, store *config.Store) *Engine {
e := &Engine{
cfg: cfg,
store: store,
region: cfg.Region,
}
if prov, err := llm.New(toOptions(cfg.Active())); err != nil {
slog.Warn("模型接入点不可用", "err", err)
} else {
e.provider = prov
}
return e
}
func toOptions(p config.Profile) llm.Options {
return llm.Options{
Name: p.Name,
BaseURL: p.BaseURL,
APIKey: p.APIKey,
Model: p.Model,
ExtraHeaders: p.ExtraHeaders,
MaxTokens: p.MaxTokens,
Temperature: p.Temperature,
}
}
// SetSink 绑定事件接收方。
func (e *Engine) SetSink(s Sink) {
e.mu.Lock()
e.sink = s
e.mu.Unlock()
e.emitState()
}
// Config 返回当前配置。
func (e *Engine) Config() *config.Config {
e.mu.Lock()
defer e.mu.Unlock()
return e.cfg
}
// Store 返回配置存储。
func (e *Engine) Store() *config.Store { return e.store }
// State 返回当前状态快照。
func (e *Engine) State() State {
e.mu.Lock()
defer e.mu.Unlock()
return e.stateLocked()
}
func (e *Engine) stateLocked() State {
active := config.Profile{}
if e.cfg != nil {
active = e.cfg.Active()
}
return State{
Watching: e.watching,
Busy: e.busy,
Triggers: e.triggers,
TotalTokens: e.usage.TotalTokens,
LastLatency: e.lastLatency,
Region: e.region,
Model: active.Model,
Profile: active.Name,
}
}
func (e *Engine) emitState() {
e.mu.Lock()
sink := e.sink
st := e.stateLocked()
e.mu.Unlock()
if sink != nil {
sink.OnState(st)
}
}
// Region 返回当前监视区域。
func (e *Engine) Region() capture.Region {
e.mu.Lock()
defer e.mu.Unlock()
return e.region
}
// SetRegion 更新监视区域并持久化。
func (e *Engine) SetRegion(r capture.Region) error {
e.mu.Lock()
e.region = r
e.cfg.Region = r
cfg := e.cfg
e.mu.Unlock()
e.emitState()
if e.store != nil {
if err := e.store.Save(cfg); err != nil {
return fmt.Errorf("保存区域失败: %w", err)
}
}
return nil
}
// ApplyConfig 在设置页保存后刷新引擎内部状态。
func (e *Engine) ApplyConfig(cfg *config.Config) error {
prov, err := llm.New(toOptions(cfg.Active()))
if err != nil {
return err
}
e.mu.Lock()
wasWatching := e.watching
// 区域只由框选修改,不被设置页的旧副本覆盖。
cfg.Region = e.region
e.cfg = cfg
e.provider = prov
e.mu.Unlock()
if wasWatching {
e.StopWatch()
if err := e.StartWatch(); err != nil {
slog.Warn("配置变更后恢复监听失败", "err", err)
}
}
e.emitState()
return nil
}
// PingProfile 用给定接入点做一次连通性测试。
func (e *Engine) PingProfile(ctx context.Context, p config.Profile) error {
prov, err := llm.New(toOptions(p))
if err != nil {
return err
}
return prov.Ping(ctx)
}
// Snapshot 抓取当前区域的 JPEG 预览(用于界面缩略图)。
func (e *Engine) Snapshot() ([]byte, error) {
e.mu.Lock()
region := e.region
capCfg := e.cfg.Capture
e.mu.Unlock()
if !region.Valid() {
return nil, fmt.Errorf("还没有框选监视区域")
}
img, err := e.screen.Grab(region)
if err != nil {
return nil, err
}
return vision.EncodeJPEG(img, capCfg.MaxEdge, capCfg.JPEGQuality)
}
// Ask 发起一次手动提问。attach 为 true 时附带当前区域截图。
func (e *Engine) Ask(text string, attach bool) error {
text = strings.TrimSpace(text)
if text == "" {
return errors.New("请输入内容")
}
return e.run(text, attach, false)
}
// StartWatch 开始按帧差自动监听。
func (e *Engine) StartWatch() error {
e.mu.Lock()
if e.watching {
e.mu.Unlock()
return nil
}
if !e.region.Valid() {
e.mu.Unlock()
return errors.New("请先框选需要监视的屏幕区域")
}
stop := make(chan struct{})
e.watching = true
e.stopWatch = stop
e.mu.Unlock()
go e.watchLoop(stop)
e.emitState()
return nil
}
// StopWatch 停止自动监听。
func (e *Engine) StopWatch() {
e.mu.Lock()
if !e.watching {
e.mu.Unlock()
return
}
e.watching = false
if e.stopWatch != nil {
close(e.stopWatch)
e.stopWatch = nil
}
e.mu.Unlock()
e.emitState()
}
// ToggleWatch 在开始/停止之间切换。
func (e *Engine) ToggleWatch() error {
e.mu.Lock()
on := e.watching
e.mu.Unlock()
if on {
e.StopWatch()
return nil
}
return e.StartWatch()
}
// watchLoop 以低频抓屏 + 帧差判断是否需要提问。
func (e *Engine) watchLoop(stop chan struct{}) {
e.mu.Lock()
capCfg := e.cfg.Capture
e.mu.Unlock()
fps := capCfg.FPS
if fps < 0.5 {
fps = 0.5
}
interval := time.Duration(float64(time.Second) / fps)
ticker := time.NewTicker(interval)
defer ticker.Stop()
var last vision.Fingerprint
var haveLast bool
var candidate bool
var stable int
var lastFire time.Time
slog.Info("开始监听区域", "interval", interval, "threshold", capCfg.DiffThreshold)
for {
select {
case <-stop:
slog.Info("停止监听")
return
case <-ticker.C:
}
e.mu.Lock()
region := e.region
e.mu.Unlock()
if !region.Valid() {
continue
}
img, err := e.screen.Grab(region)
if err != nil {
slog.Debug("抓屏失败", "err", err)
continue
}
fp := vision.Of(img)
if !haveLast {
last = fp
haveLast = true
continue
}
if fp.Changed(last, capCfg.DiffThreshold) {
candidate = true
stable = 0
last = fp
continue
}
last = fp
if !candidate {
continue
}
// 画面还在变化时先等它稳定,避免截到翻页/动画的中间帧。
stable++
if stable < capCfg.StableFrames {
continue
}
candidate = false
stable = 0
if time.Since(lastFire) < time.Duration(capCfg.CooldownMs)*time.Millisecond {
continue
}
if e.isBusy() {
continue
}
lastFire = time.Now()
e.mu.Lock()
e.triggers++
e.mu.Unlock()
if err := e.run(autoPrompt, true, true); err != nil && !errors.Is(err, ErrBusy) {
slog.Warn("自动提问失败", "err", err)
}
e.emitState()
}
}
func (e *Engine) isBusy() bool {
e.mu.Lock()
defer e.mu.Unlock()
return e.busy
}
// run 执行一次完整问答。
func (e *Engine) run(text string, attach, auto bool) error {
e.mu.Lock()
if e.busy {
e.mu.Unlock()
return ErrBusy
}
if e.provider == nil {
e.mu.Unlock()
return errors.New("还没有可用的模型接入点,请到设置里填写")
}
e.busy = true
prov := e.provider
region := e.region
capCfg := e.cfg.Capture
sink := e.sink
system := e.cfg.Template(e.cfg.PromptTemplate).System
history := append([]llm.Message(nil), e.history...)
e.mu.Unlock()
// 立刻广播一次,让界面在请求发出前就显示"正在回答"并禁用发送。
e.emitState()
defer func() {
e.mu.Lock()
e.busy = false
e.mu.Unlock()
e.emitState()
}()
var img []byte
if attach && region.Valid() {
shot, err := e.screen.Grab(region)
if err != nil {
slog.Warn("抓取监视区域失败", "err", err)
} else if raw, err := vision.EncodeJPEG(shot, capCfg.MaxEdge, capCfg.JPEGQuality); err != nil {
slog.Warn("图像编码失败", "err", err)
} else {
img = raw
}
}
if sink != nil {
if !auto {
sink.OnUserMessage(text, len(img) > 0)
}
sink.OnAssistantStart(auto)
}
msgs := make([]llm.Message, 0, len(history)+1)
msgs = append(msgs, history...)
msgs = append(msgs, llm.Message{Role: llm.RoleUser, Text: text, Image: img})
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
var reply strings.Builder
start := time.Now()
usage, err := prov.Chat(ctx, system, msgs, func(delta string) error {
reply.WriteString(delta)
if sink != nil {
sink.OnAssistantDelta(delta)
}
return nil
})
elapsed := time.Since(start)
e.mu.Lock()
e.usage.Add(usage)
e.lastLatency = elapsed
if err == nil {
e.appendHistoryLocked(text, len(img) > 0, reply.String())
}
e.mu.Unlock()
if sink != nil {
sink.OnAssistantEnd(usage, err)
}
if err != nil {
return fmt.Errorf("调用模型失败: %w", err)
}
slog.Info("回答完成", "elapsed", elapsed, "tokens", usage.TotalTokens)
return nil
}
// appendHistoryLocked 把一轮问答写入历史,图片只留占位文本。
func (e *Engine) appendHistoryLocked(question string, withImage bool, answer string) {
if withImage {
question += "\n[附带截图]"
}
e.history = append(e.history,
llm.Message{Role: llm.RoleUser, Text: question},
llm.Message{Role: llm.RoleAssistant, Text: answer},
)
limit := e.cfg.Capture.HistoryTurns * 2
if limit < 2 {
limit = 2
}
if len(e.history) > limit {
e.history = append([]llm.Message(nil), e.history[len(e.history)-limit:]...)
}
}
// ResetHistory 清空对话上下文。
func (e *Engine) ResetHistory() {
e.mu.Lock()
e.history = nil
e.mu.Unlock()
}
// Close 释放资源。
func (e *Engine) Close() {
e.StopWatch()
}
+103
View File
@@ -0,0 +1,103 @@
// Package capture 负责屏幕抓取、显示器信息与坐标换算。
package capture
import (
"fmt"
"image"
"math"
"github.com/kbinani/screenshot"
"viewsagent/internal/win32"
)
// Monitor 是显示器信息(物理像素)。
type Monitor = win32.Monitor
// Region 是以物理像素表示的屏幕区域,坐标基于 Windows 虚拟桌面。
type Region struct {
X int `json:"x"`
Y int `json:"y"`
W int `json:"w"`
H int `json:"h"`
}
// Valid 判断区域是否可用于抓屏。
func (r Region) Valid() bool { return r.W > 0 && r.H > 0 }
// Rect 转换为 image.Rectangle。
func (r Region) Rect() image.Rectangle {
return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H)
}
// Inset 向内收缩 d 像素,避免把边缘干扰一起截进去。
func (r Region) Inset(d int) Region {
if d*2 >= r.W || d*2 >= r.H {
return r
}
return Region{X: r.X + d, Y: r.Y + d, W: r.W - 2*d, H: r.H - 2*d}
}
// String 返回 "1024x576@(0,0)" 形式的描述。
func (r Region) String() string {
return fmt.Sprintf("%dx%d@(%d,%d)", r.W, r.H, r.X, r.Y)
}
// Mapping 把 Fyne 逻辑坐标换算成物理像素。
// 由于窗口尺寸由系统缩放决定,比例按实际窗口尺寸反推,避免 125%/150% 缩放下的偏移。
type Mapping struct {
OffsetX float64 // 窗口左上角的物理 X
OffsetY float64 // 窗口左上角的物理 Y
ScaleX float64 // 物理宽 / 逻辑宽
ScaleY float64 // 物理高 / 逻辑高
}
// ToPhysical 把逻辑矩形换算为物理区域。
func (m Mapping) ToPhysical(x, y, w, h float64) Region {
// 用 Round 而不是 +0.5 截断:副屏坐标可能为负,截断会向零取整导致偏移。
return Region{
X: int(math.Round(m.OffsetX + x*m.ScaleX)),
Y: int(math.Round(m.OffsetY + y*m.ScaleY)),
W: int(math.Round(w * m.ScaleX)),
H: int(math.Round(h * m.ScaleY)),
}
}
// ToLogical 是 ToPhysical 的逆运算,用于在逻辑坐标系里定位选区。
func (m Mapping) ToLogical(r Region) (x, y, w, h float64) {
return (float64(r.X) - m.OffsetX) / m.ScaleX,
(float64(r.Y) - m.OffsetY) / m.ScaleY,
float64(r.W) / m.ScaleX,
float64(r.H) / m.ScaleY
}
// Screen 是基于 GDI 的抓屏实现。
type Screen struct{}
// Grab 抓取指定区域。
func (Screen) Grab(r Region) (image.Image, error) {
if !r.Valid() {
return nil, fmt.Errorf("抓屏区域无效: %s", r)
}
img, err := screenshot.CaptureRect(r.Rect())
if err != nil {
return nil, fmt.Errorf("抓取区域 %s 失败: %w", r, err)
}
return img, nil
}
// Desktop 抓取整个虚拟桌面(所有显示器)。
func (Screen) Desktop() (image.Image, error) {
vd := win32.VirtualScreen()
if vd.Width() <= 0 || vd.Height() <= 0 {
return nil, fmt.Errorf("获取虚拟桌面尺寸失败")
}
r := Region{X: vd.Left, Y: vd.Top, W: vd.Width(), H: vd.Height()}
return Screen{}.Grab(r)
}
// Monitors 枚举显示器。
func (Screen) Monitors() []Monitor { return win32.Monitors() }
// VirtualScreen 返回虚拟桌面矩形。
func (Screen) VirtualScreen() win32.Rect { return win32.VirtualScreen() }
+37
View File
@@ -0,0 +1,37 @@
package capture
import "testing"
func TestMappingRoundTrip(t *testing.T) {
// 模拟 125% 缩放的副屏:窗口左上角在 (-1920,0)
m := Mapping{OffsetX: -1920, OffsetY: 0, ScaleX: 1.25, ScaleY: 1.25}
got := m.ToPhysical(100, 100, 400, 200)
want := Region{X: -1795, Y: 125, W: 500, H: 250}
if got != want {
t.Fatalf("逻辑→物理换算错误,期望 %+v,实际 %+v", want, got)
}
x, y, w, h := m.ToLogical(got)
if x != 100 || y != 100 || w != 400 || h != 200 {
t.Fatalf("物理→逻辑逆向换算错误:%v %v %v %v", x, y, w, h)
}
}
func TestRegionValidAndInset(t *testing.T) {
if (Region{}).Valid() {
t.Fatal("零区域不应有效")
}
r := Region{X: 10, Y: 20, W: 100, H: 50}
if !r.Valid() {
t.Fatal("正常区域应有效")
}
in := r.Inset(5)
if in != (Region{X: 15, Y: 25, W: 90, H: 40}) {
t.Fatalf("Inset 结果错误: %+v", in)
}
// 收缩超过一半时保持原样,避免出现负尺寸
if r.Inset(60) != r {
t.Fatal("过度收缩应返回原区域")
}
}
+326
View File
@@ -0,0 +1,326 @@
// Package config 定义应用配置的结构、默认值与磁盘持久化。
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"viewsagent/internal/capture"
)
// Profile 是一个 OpenAI 兼容协议的模型接入点。
type Profile struct {
Name string `json:"name"`
BaseURL string `json:"baseURL"`
APIKey string `json:"apiKey"`
Model string `json:"model"`
ExtraHeaders map[string]string `json:"extraHeaders,omitempty"`
MaxTokens int `json:"maxTokens"`
Temperature float64 `json:"temperature"`
}
// Capture 是抓屏与触发策略参数。
type Capture struct {
FPS float64 `json:"fps"` // 监听帧率,1~5
MaxEdge int `json:"maxEdge"` // 发给模型的图片长边上限
JPEGQuality int `json:"jpegQuality"` // 1~100
DiffThreshold int `json:"diffThreshold"` // 感知哈希汉明距离阈值
CooldownMs int `json:"cooldownMs"` // 两次自动提问的最小间隔
StableFrames int `json:"stableFrames"` // 连续稳定帧数(去抖)
HistoryTurns int `json:"historyTurns"` // 带上的历史文本轮数
}
// Hotkeys 保存全局热键描述,语法如 "Ctrl+Alt+Q"。
type Hotkeys struct {
SelectRegion string `json:"selectRegion"`
ToggleWatch string `json:"toggleWatch"`
}
// Template 是一套系统提示词。
type Template struct {
Key string `json:"key"`
Name string `json:"name"`
System string `json:"system"`
}
// Config 是完整的应用配置。
type Config struct {
ActiveProfile string `json:"activeProfile"`
Profiles []Profile `json:"profiles"`
Capture Capture `json:"capture"`
Hotkeys Hotkeys `json:"hotkeys"`
PromptTemplate string `json:"promptTemplate"`
Templates []Template `json:"templates"`
AlwaysOnTopCard bool `json:"alwaysOnTopCard"`
Region capture.Region `json:"region"`
}
// DefaultTemplates 返回内置提示词模板。
func DefaultTemplates() []Template {
return []Template{
{
Key: "exercise",
Name: "习题解答",
System: `你是屏幕内容实时助手。用户会持续截取一块屏幕区域给你看,用途通常是做练习题、看试卷或读文档。
必须遵守:
1. 只针对画面中"当前这道题"给出答案,不要复述题目。
2. 如果画面没有出现新内容、或你无法辨认题目,只回复「(无新内容)」,绝对不要编造。
3. 先给结论,必要时补一行关键解析,整体控制在 5 行以内。
4. 数学公式只用纯文本和 Unicode 符号(如 x²=4、√2/2、≤、π、∫),禁止 LaTeX、禁止 Markdown 表格。
5. 题干信息不足时,直接说明缺少什么,不要猜。`,
},
{
Key: "tutor",
Name: "逐步讲解",
System: `你是屏幕辅导老师。用户会截取题目区域给你看。
必须遵守:
1. 分步骤讲解解题思路,每一步单独一行,最多 6 步。
2. 公式只用纯文本与 Unicode 符号(如 x²=4、√2/2、≤、π),禁止 LaTeX。
3. 画面没有新内容时只回复「(无新内容)」。
4. 不确定的地方明确指出,不要虚构条件。`,
},
{
Key: "code",
Name: "代码解读",
System: `你是代码解读助手。用户会截取代码区域给你看。
必须遵守:
1. 先用一句话说明这段代码在做什么。
2. 再指出明显的 bug、边界问题或性能风险。
3. 最后给出修改建议,用文字描述,不要输出整段重写的代码。
4. 画面没有新内容时只回复「(无新内容)」。`,
},
{
Key: "translate",
Name: "翻译",
System: `你是翻译助手。识别画面中的外文并翻译成简体中文,保持原有段落结构。
必须遵守:
1. 只输出译文,不要输出原文,也不要解释。
2. 画面没有新内容时只回复「(无新内容)」。`,
},
{
Key: "chat",
Name: "自由对话",
System: `你是桌面视觉助手。用户会把屏幕上的内容截给你看并提问,请用简洁准确的中文回答。
公式使用纯文本与 Unicode 符号,不要使用 LaTeX。`,
},
}
}
// Default 返回一份可直接使用的默认配置。
func Default() *Config {
return &Config{
ActiveProfile: "qwen",
Profiles: []Profile{
{
Name: "qwen", BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
Model: "qwen3-vl-plus", MaxTokens: 1024, Temperature: 0.3,
},
{
Name: "doubao", BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
Model: "doubao-seed-1-6-vision", MaxTokens: 1024, Temperature: 0.3,
},
{
Name: "zhipu", BaseURL: "https://open.bigmodel.cn/api/paas/v4",
Model: "glm-4.6v", MaxTokens: 1024, Temperature: 0.3,
},
{
Name: "kimi", BaseURL: "https://api.moonshot.cn/v1",
Model: "kimi-latest", MaxTokens: 1024, Temperature: 0.3,
},
{
Name: "ollama", BaseURL: "http://localhost:11434/v1",
Model: "qwen2.5vl:7b", MaxTokens: 1024, Temperature: 0.3,
},
},
Capture: Capture{
FPS: 2, MaxEdge: 1280, JPEGQuality: 75,
DiffThreshold: 5, CooldownMs: 3000, StableFrames: 2, HistoryTurns: 6,
},
Hotkeys: Hotkeys{
SelectRegion: "Ctrl+Alt+X",
ToggleWatch: "Ctrl+Alt+Space",
},
PromptTemplate: "exercise",
Templates: DefaultTemplates(),
AlwaysOnTopCard: true,
}
}
// Profile 按名称查找接入点。
func (c *Config) Profile(name string) (Profile, bool) {
for _, p := range c.Profiles {
if p.Name == name {
return p, true
}
}
return Profile{}, false
}
// Active 返回当前启用的接入点,找不到时回退到第一个。
func (c *Config) Active() Profile {
if p, ok := c.Profile(c.ActiveProfile); ok {
return p
}
if len(c.Profiles) > 0 {
return c.Profiles[0]
}
return Profile{}
}
// Template 按 key 查找提示词模板。
func (c *Config) Template(key string) Template {
for _, t := range c.Templates {
if t.Key == key {
return t
}
}
if len(c.Templates) > 0 {
return c.Templates[0]
}
return Template{Key: "chat", Name: "自由对话", System: "你是桌面视觉助手。"}
}
// Clone 返回一份深拷贝,避免设置窗口与引擎共享同一份可变配置。
func (c *Config) Clone() *Config {
if c == nil {
return Default()
}
out := *c
out.Profiles = append([]Profile(nil), c.Profiles...)
for i := range out.Profiles {
if c.Profiles[i].ExtraHeaders != nil {
h := make(map[string]string, len(c.Profiles[i].ExtraHeaders))
for k, v := range c.Profiles[i].ExtraHeaders {
h[k] = v
}
out.Profiles[i].ExtraHeaders = h
}
}
out.Templates = append([]Template(nil), c.Templates...)
return &out
}
// Normalize 补全缺失字段,保证运行期不会出现零值。
func (c *Config) Normalize() {
def := Default()
if len(c.Profiles) == 0 {
c.Profiles = def.Profiles
}
if c.ActiveProfile == "" {
c.ActiveProfile = c.Profiles[0].Name
}
for i := range c.Profiles {
p := &c.Profiles[i]
if p.MaxTokens <= 0 {
p.MaxTokens = 1024
}
if p.Temperature <= 0 {
p.Temperature = 0.3
}
p.BaseURL = strings.TrimRight(strings.TrimSpace(p.BaseURL), "/")
}
if len(c.Templates) == 0 {
c.Templates = def.Templates
}
if c.Capture.FPS <= 0 {
c.Capture.FPS = 2
}
if c.Capture.MaxEdge <= 0 {
c.Capture.MaxEdge = 1280
}
if c.Capture.JPEGQuality <= 0 {
c.Capture.JPEGQuality = 75
}
if c.Capture.DiffThreshold <= 0 {
c.Capture.DiffThreshold = 5
}
if c.Capture.CooldownMs <= 0 {
c.Capture.CooldownMs = 3000
}
if c.Capture.StableFrames <= 0 {
c.Capture.StableFrames = 2
}
if c.Capture.HistoryTurns <= 0 {
c.Capture.HistoryTurns = 6
}
if c.Hotkeys.SelectRegion == "" {
c.Hotkeys.SelectRegion = def.Hotkeys.SelectRegion
}
if c.Hotkeys.ToggleWatch == "" {
c.Hotkeys.ToggleWatch = def.Hotkeys.ToggleWatch
}
if c.PromptTemplate == "" {
c.PromptTemplate = c.Templates[0].Key
}
}
// Store 负责配置文件的读写。
type Store struct {
path string
}
// NewStore 创建配置文件存储,默认位于 %APPDATA%\viewsagent\config.json。
func NewStore() (*Store, error) {
dir, err := os.UserConfigDir()
if err != nil {
return nil, fmt.Errorf("定位用户配置目录失败: %w", err)
}
base := filepath.Join(dir, "viewsagent")
if err := os.MkdirAll(base, 0o755); err != nil {
return nil, fmt.Errorf("创建配置目录失败: %w", err)
}
return &Store{path: filepath.Join(base, "config.json")}, nil
}
// Path 返回配置文件路径。
func (s *Store) Path() string { return s.path }
// Dir 返回配置目录。
func (s *Store) Dir() string { return filepath.Dir(s.path) }
// Load 读取配置;文件不存在或损坏时返回默认配置(损坏文件会被备份)。
func (s *Store) Load() *Config {
raw, err := os.ReadFile(s.path)
if err != nil {
cfg := Default()
cfg.Normalize()
return cfg
}
cfg := Default()
if err := json.Unmarshal(raw, cfg); err != nil {
_ = os.WriteFile(s.path+".broken", raw, 0o644)
fresh := Default()
fresh.Normalize()
return fresh
}
cfg.Normalize()
cfg.migrate()
return cfg
}
// migrate 把旧版本的默认值升级到当前默认值,避免老配置文件一直沿用已知冲突的热键。
func (c *Config) migrate() {
if strings.EqualFold(strings.TrimSpace(c.Hotkeys.SelectRegion), "Ctrl+Alt+Q") {
c.Hotkeys.SelectRegion = "Ctrl+Alt+X"
}
}
// Save 原子写入配置文件。
func (s *Store) Save(cfg *Config) error {
raw, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("序列化配置失败: %w", err)
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return fmt.Errorf("写入临时配置失败: %w", err)
}
return os.Rename(tmp, s.path)
}
+75
View File
@@ -0,0 +1,75 @@
package config
import "testing"
func TestDefaultIsUsable(t *testing.T) {
cfg := Default()
cfg.Normalize()
if len(cfg.Profiles) == 0 {
t.Fatal("默认配置应包含接入点")
}
if cfg.Active().Name != "qwen" {
t.Fatalf("默认接入点应为 qwen,实际 %s", cfg.Active().Name)
}
if cfg.Capture.FPS <= 0 || cfg.Capture.MaxEdge <= 0 || cfg.Capture.CooldownMs <= 0 {
t.Fatalf("抓屏参数不应为零值: %+v", cfg.Capture)
}
if tmpl := cfg.Template("exercise"); tmpl.Key != "exercise" {
t.Fatalf("应能取到习题模板,实际 %s", tmpl.Key)
}
if tmpl := cfg.Template("not-exist"); tmpl.Key == "" {
t.Fatal("未知模板应回退到第一个模板")
}
}
func TestNormalizeFillsZeroValues(t *testing.T) {
cfg := &Config{}
cfg.Normalize()
if cfg.ActiveProfile == "" {
t.Fatal("Normalize 应补全 ActiveProfile")
}
if len(cfg.Templates) == 0 {
t.Fatal("Normalize 应补全模板")
}
if cfg.Capture.JPEGQuality == 0 || cfg.Capture.StableFrames == 0 || cfg.Capture.HistoryTurns == 0 {
t.Fatalf("Normalize 应补全抓屏参数: %+v", cfg.Capture)
}
}
func TestCloneIsIndependent(t *testing.T) {
cfg := Default()
cfg.Normalize()
cp := cfg.Clone()
cp.Profiles[0].Model = "changed"
cp.Templates[0].System = "changed"
cp.Capture.FPS = 9
if cfg.Profiles[0].Model == "changed" {
t.Fatal("Clone 后修改接入点不应影响原配置")
}
if cfg.Templates[0].System == "changed" {
t.Fatal("Clone 后修改模板不应影响原配置")
}
if cfg.Capture.FPS == 9 {
t.Fatal("Clone 后修改抓屏参数不应影响原配置")
}
}
func TestProfileLookup(t *testing.T) {
cfg := Default()
cfg.Normalize()
if _, ok := cfg.Profile("qwen"); !ok {
t.Fatal("应能找到 qwen 接入点")
}
if _, ok := cfg.Profile("不存在"); ok {
t.Fatal("不应找到不存在的接入点")
}
cfg.ActiveProfile = "不存在"
if cfg.Active().Name != cfg.Profiles[0].Name {
t.Fatal("接入点丢失时应回退到第一个")
}
}
+301
View File
@@ -0,0 +1,301 @@
package llm
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// openAI 是 OpenAI 兼容协议(/chat/completions + SSE)的实现。
type openAI struct {
opts Options
http *http.Client
}
func newOpenAI(opts Options) *openAI {
if opts.MaxTokens <= 0 {
opts.MaxTokens = 1024
}
if opts.Temperature <= 0 {
opts.Temperature = 0.3
}
return &openAI{
opts: opts,
http: &http.Client{
// 不设总超时:流式响应可能持续很久,由 ctx 控制。
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
MaxIdleConns: 8,
IdleConnTimeout: 90 * time.Second,
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: 15 * time.Second,
},
},
}
}
type reqMessage struct {
Role string `json:"role"`
Content any `json:"content"`
}
type contentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *imageURL `json:"image_url,omitempty"`
}
type imageURL struct {
URL string `json:"url"`
}
type streamOptions struct {
IncludeUsage bool `json:"include_usage"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []reqMessage `json:"messages"`
Stream bool `json:"stream"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
StreamOptions *streamOptions `json:"stream_options,omitempty"`
}
type streamChunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error"`
}
func (o *openAI) endpoint() string {
base := strings.TrimRight(o.opts.BaseURL, "/")
if strings.HasSuffix(base, "/chat/completions") {
return base
}
return base + "/chat/completions"
}
func (o *openAI) 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 o.opts.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+o.opts.APIKey)
}
for k, v := range o.opts.ExtraHeaders {
req.Header.Set(k, v)
}
}
func (o *openAI) buildBody(system string, msgs []Message, stream bool) ([]byte, error) {
body := chatRequest{
Model: o.opts.Model,
Stream: stream,
MaxTokens: o.opts.MaxTokens,
Temperature: o.opts.Temperature,
}
if stream {
body.StreamOptions = &streamOptions{IncludeUsage: true}
}
if strings.TrimSpace(system) != "" {
body.Messages = append(body.Messages, reqMessage{Role: "system", Content: system})
}
for _, m := range msgs {
role := string(m.Role)
if role == "" {
role = "user"
}
if len(m.Image) == 0 {
body.Messages = append(body.Messages, reqMessage{Role: role, Content: m.Text})
continue
}
text := m.Text
if strings.TrimSpace(text) == "" {
text = "请看图并回答。"
}
parts := []contentPart{
{Type: "text", Text: text},
{
Type: "image_url",
ImageURL: &imageURL{
URL: "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(m.Image),
},
},
}
body.Messages = append(body.Messages, reqMessage{Role: role, Content: parts})
}
raw, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("构造请求失败: %w", err)
}
return raw, nil
}
// Chat 发起一次流式对话。
func (o *openAI) Chat(ctx context.Context, system string, msgs []Message, fn DeltaFunc) (Usage, error) {
if len(msgs) == 0 {
return Usage{}, fmt.Errorf("没有可发送的消息")
}
payload, err := o.buildBody(system, msgs, true)
if err != nil {
return Usage{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.endpoint(), bytes.NewReader(payload))
if err != nil {
return Usage{}, fmt.Errorf("创建请求失败: %w", err)
}
o.setHeaders(req, true)
resp, err := o.http.Do(req)
if err != nil {
return Usage{}, fmt.Errorf("请求 %s 失败: %w", o.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 o.readWhole(resp.Body, fn)
}
var usage Usage
var got strings.Builder
err = readSSE(resp.Body, func(ev sseEvent) error {
if len(ev.Data) == 0 || bytes.Equal(ev.Data, []byte("[DONE]")) {
return nil
}
var chunk streamChunk
if json.Unmarshal(ev.Data, &chunk) != nil {
return nil // 忽略无法解析的心跳/杂项
}
if chunk.Error != nil {
return fmt.Errorf("模型返回错误: %s", chunk.Error.Message)
}
if chunk.Usage != nil {
usage = Usage{
PromptTokens: chunk.Usage.PromptTokens,
CompletionTokens: chunk.Usage.CompletionTokens,
TotalTokens: chunk.Usage.TotalTokens,
}
}
for _, c := range chunk.Choices {
if c.Delta.Content == "" {
continue
}
got.WriteString(c.Delta.Content)
if fn != nil {
if derr := fn(c.Delta.Content); derr != nil {
return derr
}
}
}
return nil
})
if err != nil {
return usage, err
}
if got.Len() == 0 {
return usage, fmt.Errorf("模型没有返回任何内容,请确认 %s 是支持图片输入的视觉模型", o.opts.Model)
}
return usage, nil
}
// readWhole 处理服务端忽略 stream 参数的情况。
func (o *openAI) 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 resp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &resp); err != nil {
return Usage{}, fmt.Errorf("解析响应失败: %w", err)
}
if resp.Error != nil {
return Usage{}, fmt.Errorf("模型返回错误: %s", resp.Error.Message)
}
if len(resp.Choices) == 0 || resp.Choices[0].Message.Content == "" {
return Usage{}, fmt.Errorf("模型返回内容为空")
}
var usage Usage
if resp.Usage != nil {
usage = Usage{
PromptTokens: resp.Usage.PromptTokens,
CompletionTokens: resp.Usage.CompletionTokens,
TotalTokens: resp.Usage.TotalTokens,
}
}
text := resp.Choices[0].Message.Content
if fn != nil {
if err := fn(text); err != nil {
return usage, err
}
}
return usage, nil
}
// Ping 做一次最小代价的连通性检查。
func (o *openAI) Ping(ctx context.Context) error {
payload, err := o.buildBody("", []Message{{Role: RoleUser, Text: "ping"}}, false)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.endpoint(), bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("创建请求失败: %w", err)
}
o.setHeaders(req, false)
resp, err := o.http.Do(req)
if err != nil {
return fmt.Errorf("连接 %s 失败: %w", o.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("服务返回空响应")
}
return nil
}
+71
View File
@@ -0,0 +1,71 @@
// Package llm 提供与 OpenAI 兼容接口的大模型通信能力(含视觉与流式)。
package llm
import (
"context"
"fmt"
)
// Role 是消息角色。
type Role string
// 支持的角色。
const (
RoleUser Role = "user"
RoleAssistant Role = "assistant"
)
// Message 是一条对话消息。Image 非空表示该消息附带一张 JPEG 图片。
type Message struct {
Role Role
Text string
Image []byte
}
// Usage 记录一次调用的 token 用量。
type Usage struct {
PromptTokens int
CompletionTokens int
TotalTokens int
}
// Add 累加用量。
func (u *Usage) Add(o Usage) {
u.PromptTokens += o.PromptTokens
u.CompletionTokens += o.CompletionTokens
u.TotalTokens += o.TotalTokens
}
// DeltaFunc 接收流式增量文本,返回 error 可中断请求。
type DeltaFunc func(delta string) error
// Options 描述一个模型接入点。
type Options struct {
Name string
BaseURL string
APIKey string
Model string
ExtraHeaders map[string]string
MaxTokens int
Temperature float64
}
// Provider 是大模型服务的最小抽象。
// 目前只有 OpenAI 兼容实现,其它私有协议按此接口新增即可。
type Provider interface {
// Chat 发起一次流式对话,system 为系统提示词。
Chat(ctx context.Context, system string, msgs []Message, fn DeltaFunc) (Usage, error)
// Ping 做一次最小代价的连通性检查。
Ping(ctx context.Context) error
}
// New 根据接入点创建 Provider。
func New(opts Options) (Provider, error) {
if opts.BaseURL == "" {
return nil, fmt.Errorf("模型接入点缺少 BaseURL")
}
if opts.Model == "" {
return nil, fmt.Errorf("模型接入点 %s 缺少 model", opts.Name)
}
return newOpenAI(opts), nil
}
+61
View File
@@ -0,0 +1,61 @@
package llm
import (
"bufio"
"bytes"
"io"
)
// sseEvent 是一条 Server-Sent Events 事件。
type sseEvent struct {
Name string
Data []byte
}
// readSSE 逐条解析 SSE 流。兼容 OpenAI 的 "data: {...}" 格式,
// 也兼容心跳注释行(以 ":" 开头)与自定义 event 名。
func readSSE(r io.Reader, handle func(sseEvent) error) error {
br := bufio.NewReaderSize(r, 64*1024)
var data bytes.Buffer
var name string
flush := func() error {
if data.Len() == 0 {
return nil
}
ev := sseEvent{Name: name, Data: bytes.TrimSpace(data.Bytes())}
data.Reset()
name = ""
return handle(ev)
}
for {
line, err := br.ReadBytes('\n')
if len(line) > 0 {
line = bytes.TrimRight(line, "\r\n")
switch {
case len(line) == 0:
if ferr := flush(); ferr != nil {
return ferr
}
case bytes.HasPrefix(line, []byte(":")):
// 注释/心跳,忽略
case bytes.HasPrefix(line, []byte("data:")):
v := bytes.TrimPrefix(bytes.TrimPrefix(line, []byte("data:")), []byte(" "))
if data.Len() > 0 {
data.WriteByte('\n')
}
data.Write(v)
case bytes.HasPrefix(line, []byte("event:")):
name = string(bytes.TrimSpace(bytes.TrimPrefix(line, []byte("event:"))))
}
}
if err != nil {
_ = flush()
if err == io.EOF {
return nil
}
return err
}
}
}
+458
View File
@@ -0,0 +1,458 @@
package ui
import (
"fmt"
"image/png"
"log/slog"
"os"
"strconv"
"strings"
"sync"
"time"
"fyne.io/fyne/v2"
fyneapp "fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"viewsagent/internal/agent"
"viewsagent/internal/capture"
"viewsagent/internal/config"
"viewsagent/internal/llm"
"viewsagent/internal/win32"
)
const mainTitle = "视界 Agent"
// App 是桌面端主程序,同时实现 agent.Sink 接收引擎事件。
type App struct {
fyne fyne.App
win fyne.Window
eng *agent.Engine
chat *chatView
entry *widget.Entry
attach *widget.Check
sendBtn *widget.Button
watchBtn *widget.Button
modelSel *widget.Select
regionLbl *widget.Label
statusLbl *widget.Label
settings *settingsWindow
card *floatCard
overlay *overlay
hotkeyStop func()
// 流式文本合批,避免每个 delta 都触发一次布局
pendMu sync.Mutex
pending strings.Builder
pendScheduled bool
// 最近一条完整回答,用于同步到浮窗
replyMu sync.Mutex
lastReply strings.Builder
quietModelSel bool
}
// Run 启动整个桌面应用。
func Run() error {
store, err := config.NewStore()
if err != nil {
return err
}
cfg := store.Load()
fa := fyneapp.NewWithID("com.viewsagent.desktop")
installTranslations()
eng := agent.New(cfg, store)
a := &App{fyne: fa, eng: eng}
a.build()
eng.SetSink(a)
a.startHotkeys()
a.maybeAutoShot()
defer func() {
if a.hotkeyStop != nil {
a.hotkeyStop()
}
eng.Close()
}()
a.win.ShowAndRun()
return nil
}
// maybeAutoShot 在设置了 VIEWSAGENT_SHOT 时,等界面稳定后把画布导出为 PNG 再退出。
// 只用于开发期核对布局,正常运行不会触发。
func (a *App) maybeAutoShot() {
path := os.Getenv("VIEWSAGENT_SHOT")
if path == "" {
return
}
delay := 1500 * time.Millisecond
if v := os.Getenv("VIEWSAGENT_SHOT_DELAY"); v != "" {
if ms, err := strconv.Atoi(v); err == nil && ms > 0 {
delay = time.Duration(ms) * time.Millisecond
}
}
go func() {
time.Sleep(delay)
fyne.Do(func() {
if img := a.win.Canvas().Capture(); img != nil {
if f, err := os.Create(path); err == nil {
_ = png.Encode(f, img)
_ = f.Close()
}
}
a.fyne.Quit()
})
}()
}
func (a *App) build() {
a.win = a.fyne.NewWindow(mainTitle)
a.win.Resize(fyne.NewSize(520, 780))
a.win.CenterOnScreen()
a.chat = newChatView()
a.card = newFloatCard(a.fyne)
a.settings = newSettingsWindow(a)
cfg := a.eng.Config()
a.modelSel = widget.NewSelect(profileNames(cfg), a.onModelChange)
a.modelSel.PlaceHolder = "请选择接入点"
a.modelSel.SetSelected(cfg.ActiveProfile)
cardBtn := widget.NewButtonWithIcon("", theme.InfoIcon(), a.toggleCard)
cardBtn.Importance = widget.LowImportance
settingsBtn := widget.NewButtonWithIcon("", theme.SettingsIcon(), a.openSettings)
settingsBtn.Importance = widget.LowImportance
toolbar := container.NewHBox(
widget.NewLabel("模型"), a.modelSel,
layout.NewSpacer(), cardBtn, settingsBtn,
)
a.regionLbl = widget.NewLabel("未框选区域")
a.regionLbl.Importance = widget.LowImportance
selectBtn := widget.NewButtonWithIcon("框选", theme.VisibilityOffIcon(), a.startSelection)
previewBtn := widget.NewButtonWithIcon("预览", theme.SearchIcon(), a.showPreview)
previewBtn.Importance = widget.LowImportance
a.watchBtn = widget.NewButtonWithIcon("开始监听", theme.MediaPlayIcon(), a.toggleWatch)
a.watchBtn.Importance = widget.HighImportance
regionRow := container.NewHBox(a.regionLbl, layout.NewSpacer(), previewBtn, selectBtn, a.watchBtn)
a.entry = widget.NewMultiLineEntry()
a.entry.SetPlaceHolder("输入你的要求,例如:请帮我做练习题,实时给出答案")
a.entry.SetMinRowsVisible(2)
a.attach = widget.NewCheck("附带区域截图", nil)
a.attach.SetChecked(true)
a.sendBtn = widget.NewButtonWithIcon("发送", theme.MailSendIcon(), a.send)
a.sendBtn.Importance = widget.HighImportance
clearBtn := widget.NewButtonWithIcon("", theme.ContentClearIcon(), a.clearChat)
clearBtn.Importance = widget.LowImportance
inputRow := container.NewBorder(nil, nil,
container.NewHBox(a.attach),
container.NewHBox(clearBtn, a.sendBtn),
nil,
)
a.statusLbl = widget.NewLabel("就绪")
a.statusLbl.Importance = widget.LowImportance
top := container.NewVBox(toolbar, regionRow, widget.NewSeparator())
bottom := container.NewVBox(widget.NewSeparator(), a.entry, inputRow, a.statusLbl)
a.win.SetContent(container.NewBorder(top, bottom, nil, nil, a.chat.object()))
a.applyState(a.eng.State())
}
func profileNames(cfg *config.Config) []string {
names := make([]string, 0, len(cfg.Profiles))
for _, p := range cfg.Profiles {
names = append(names, p.Name)
}
return names
}
// ---------- 用户操作 ----------
func (a *App) send() {
text := strings.TrimSpace(a.entry.Text)
if text == "" {
return
}
a.entry.SetText("")
if err := a.eng.Ask(text, a.attach.Checked); err != nil {
a.chat.addNotice(err.Error())
}
}
func (a *App) toggleWatch() {
if err := a.eng.ToggleWatch(); err != nil {
a.chat.addNotice(err.Error())
}
}
func (a *App) toggleCard() {
a.card.setRegion(a.eng.Region())
a.card.toggle()
}
func (a *App) openSettings() {
a.settings.show()
}
func (a *App) clearChat() {
a.eng.ResetHistory()
a.chat.clear()
a.chat.addNotice("已清空对话上下文")
}
func (a *App) onModelChange(name string) {
if a.quietModelSel || name == "" {
return
}
cfg := a.eng.Config()
if _, ok := cfg.Profile(name); !ok {
return
}
cfg.ActiveProfile = name
if err := a.eng.ApplyConfig(cfg); err != nil {
a.chat.addNotice("切换模型失败:" + err.Error())
return
}
if err := a.eng.Store().Save(cfg); err != nil {
slog.Warn("保存模型选择失败", "err", err)
}
a.chat.addNotice("已切换模型:" + name)
}
// saveConfig 把引擎里的配置落盘并立即生效。
func (a *App) saveConfig() error {
return a.applyAndSave(a.eng.Config())
}
// applyAndSave 保存并应用一份配置(覆盖模型下拉选项)。
func (a *App) applyAndSave(cfg *config.Config) error {
cfg.Normalize()
if err := a.eng.Store().Save(cfg); err != nil {
return err
}
if err := a.eng.ApplyConfig(cfg); err != nil {
return err
}
a.quietModelSel = true
a.modelSel.Options = profileNames(cfg)
a.modelSel.SetSelected(cfg.ActiveProfile)
a.quietModelSel = false
return nil
}
// ---------- 框选 ----------
func (a *App) startSelection() {
if a.overlay != nil {
return
}
// 先隐藏主窗,避免它出现在冻结帧里挡住要框选的内容
a.win.Hide()
time.Sleep(220 * time.Millisecond)
shot, err := (capture.Screen{}).Desktop()
if err != nil {
a.win.Show()
a.chat.addNotice("抓屏失败:" + err.Error())
return
}
vd := win32.VirtualScreen()
a.overlay = newOverlay(a.fyne, shot, vd, a.onRegionSelected, a.onRegionCancel)
a.overlay.show()
}
func (a *App) onRegionSelected(r capture.Region) {
a.overlay = nil
a.win.Show()
if err := a.eng.SetRegion(r); err != nil {
a.chat.addNotice(err.Error())
return
}
a.card.setRegion(r)
a.chat.addNotice(fmt.Sprintf("已框选监视区域 %s", r))
}
func (a *App) onRegionCancel() {
a.overlay = nil
a.win.Show()
}
func (a *App) showPreview() {
go func() {
raw, err := a.eng.Snapshot()
fyne.Do(func() {
if err != nil {
a.chat.addNotice("预览失败:" + err.Error())
return
}
img := canvas.NewImageFromResource(fyne.NewStaticResource("preview.jpg", raw))
img.FillMode = canvas.ImageFillContain
img.SetMinSize(fyne.NewSize(520, 300))
d := dialog.NewCustom("区域预览", "关闭", img, a.win)
d.Resize(fyne.NewSize(560, 380))
d.Show()
})
}()
}
// ---------- 热键 ----------
func (a *App) startHotkeys() {
cfg := a.eng.Config()
selectSpec := cfg.Hotkeys.SelectRegion
toggleSpec := cfg.Hotkeys.ToggleWatch
stop, warns, err := win32.ListenHotkeys([]string{selectSpec, toggleSpec}, func(spec string) {
fyne.Do(func() {
switch spec {
case selectSpec:
a.startSelection()
case toggleSpec:
a.toggleWatch()
}
})
})
if err != nil {
slog.Warn("全局热键注册失败", "err", err)
a.chat.addNotice("全局热键不可用(请到设置页改快捷键):" + err.Error())
return
}
if len(warns) > 0 {
slog.Warn("部分全局热键注册失败", "warnings", warns)
a.chat.addNotice("部分热键被其他程序占用,已跳过:" + strings.Join(warns, ";"))
}
a.hotkeyStop = stop
}
// ---------- agent.Sink ----------
// OnUserMessage 收到手动提问。
func (a *App) OnUserMessage(text string, withImage bool) {
fyne.Do(func() { a.chat.addUser(text, withImage) })
}
// OnAssistantStart 开始一条助手回答。
func (a *App) OnAssistantStart(auto bool) {
a.replyMu.Lock()
a.lastReply.Reset()
a.replyMu.Unlock()
fyne.Do(func() { a.chat.beginAssistant(auto) })
}
// OnAssistantDelta 收到流式增量,40ms 合批后刷新界面。
func (a *App) OnAssistantDelta(delta string) {
a.replyMu.Lock()
a.lastReply.WriteString(delta)
a.replyMu.Unlock()
a.pendMu.Lock()
a.pending.WriteString(delta)
if a.pendScheduled {
a.pendMu.Unlock()
return
}
a.pendScheduled = true
a.pendMu.Unlock()
time.AfterFunc(40*time.Millisecond, a.flushPending)
}
func (a *App) flushPending() {
a.pendMu.Lock()
text := a.pending.String()
a.pending.Reset()
a.pendScheduled = false
a.pendMu.Unlock()
if text == "" {
return
}
fyne.Do(func() { a.chat.appendDelta(text) })
}
// OnAssistantEnd 结束一条助手回答。
func (a *App) OnAssistantEnd(usage llm.Usage, err error) {
a.flushPending()
a.replyMu.Lock()
text := a.lastReply.String()
a.replyMu.Unlock()
fyne.Do(func() {
a.chat.endAssistant(err)
if err == nil && a.card.visibleNow() {
a.card.update(text)
}
})
}
// OnState 同步引擎状态到界面。
func (a *App) OnState(st agent.State) {
fyne.Do(func() { a.applyState(st) })
}
func (a *App) applyState(st agent.State) {
if st.Watching {
a.watchBtn.SetText("暂停监听")
a.watchBtn.SetIcon(theme.MediaPauseIcon())
} else {
a.watchBtn.SetText("开始监听")
a.watchBtn.SetIcon(theme.MediaPlayIcon())
}
if st.Region.Valid() {
a.regionLbl.SetText(fmt.Sprintf("区域 %d × %d", st.Region.W, st.Region.H))
} else {
a.regionLbl.SetText("未框选区域")
}
a.card.setRegion(st.Region)
if st.Busy {
a.sendBtn.Disable()
} else {
a.sendBtn.Enable()
}
state := "就绪"
switch {
case st.Watching && st.Busy:
state = "监听中 · 正在回答"
case st.Watching:
state = "监听中"
case st.Busy:
state = "正在回答"
}
status := fmt.Sprintf("%s · 模型 %s · 自动触发 %d 次 · 累计 %d tokens",
state, st.Model, st.Triggers, st.TotalTokens)
if st.LastLatency > 0 {
status += fmt.Sprintf(" · 上次 %dms", st.LastLatency.Milliseconds())
}
a.statusLbl.SetText(status)
if st.Profile != "" && a.modelSel.Selected != st.Profile {
a.quietModelSel = true
a.modelSel.SetSelected(st.Profile)
a.quietModelSel = false
}
}
+127
View File
@@ -0,0 +1,127 @@
package ui
import (
"strings"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
"viewsagent/internal/capture"
"viewsagent/internal/win32"
)
// cardTitle 是答案浮窗的标题,win32 靠它找 HWND。
const cardTitle = "viewsagent-card"
// floatCard 是贴在选区旁边的置顶小窗,只显示最新答案。
type floatCard struct {
win fyne.Window
body *widget.RichText
region capture.Region
visible bool
}
func newFloatCard(a fyne.App) *floatCard {
c := &floatCard{win: a.NewWindow(cardTitle)}
rt := widget.NewRichText()
rt.Wrapping = fyne.TextWrapWord
c.body = rt
c.win.SetContent(container.NewBorder(
nil, nil, nil, nil,
container.NewPadded(rt),
))
c.win.Resize(fyne.NewSize(400, 240))
return c
}
// setRegion 记录当前监视区域,用于定位浮窗。
func (c *floatCard) setRegion(r capture.Region) {
c.region = r
}
// update 刷新浮窗内容。
func (c *floatCard) update(text string) {
text = strings.TrimSpace(text)
if text == "" {
text = "(空)"
}
c.body.ParseMarkdown(text)
if c.visible {
c.place()
}
}
// show 显示浮窗。
func (c *floatCard) show() {
if c.visible {
c.place()
c.win.RequestFocus()
return
}
c.visible = true
c.win.Show()
go func() {
time.Sleep(120 * time.Millisecond)
fyne.Do(c.place)
}()
}
// hide 隐藏浮窗。
func (c *floatCard) hide() {
c.visible = false
c.win.Hide()
}
// toggle 切换显示状态。
func (c *floatCard) toggle() {
if c.visible {
c.hide()
return
}
c.show()
}
// visibleNow 返回是否正在显示。
func (c *floatCard) visibleNow() bool { return c.visible }
// place 把浮窗贴到选区下方(放不下就贴上方),并置顶。
func (c *floatCard) place() {
hwnd, err := win32.FindWindowByTitle(cardTitle)
if err != nil {
return
}
_ = win32.SetTopMost(hwnd)
rect, err := win32.WindowRect(hwnd)
if err != nil || rect.Width() <= 0 || rect.Height() <= 0 {
return
}
if !c.region.Valid() {
return
}
mon, _ := win32.MonitorAt(c.region.X+c.region.W/2, c.region.Y+c.region.H/2)
work := mon.Work
if work.Width() <= 0 {
work = mon.Rect
}
gap := int(8 * max(mon.Scale, 1))
x := c.region.X
y := c.region.Y + c.region.H + gap
if y+rect.Height() > work.Bottom {
y = c.region.Y - rect.Height() - gap
}
if y < work.Top {
y = work.Bottom - rect.Height()
}
if x+rect.Width() > work.Right {
x = work.Right - rect.Width()
}
if x < work.Left {
x = work.Left
}
_ = win32.MoveTo(hwnd, win32.Rect{Left: x, Top: y, Right: x + rect.Width(), Bottom: y + rect.Height()})
}
+187
View File
@@ -0,0 +1,187 @@
package ui
import (
"image/color"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
// bubbleKind 区分气泡样式。
type bubbleKind int
const (
bubbleUser bubbleKind = iota
bubbleBot
bubbleError
)
func colorFor(k bubbleKind) color.Color {
switch k {
case bubbleUser:
return colorUserBubble()
case bubbleError:
return colorErrBubble()
default:
return colorBotBubble()
}
}
// bubble 是一条消息气泡:圆角背景 + 角色标题 + 正文。
// 流式阶段用 Label 追加文本,结束后换成 RichText 以获得 Markdown 排版。
type bubble struct {
root *fyne.Container
bg *canvas.Rectangle
body *fyne.Container
head *widget.Label
live *widget.Label
text strings.Builder
}
func newBubble(role string, kind bubbleKind) *bubble {
bg := canvas.NewRectangle(colorFor(kind))
bg.CornerRadius = 10
bg.StrokeColor = colorBorder()
bg.StrokeWidth = 1
head := widget.NewLabelWithStyle(role, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
head.Importance = widget.LowImportance
live := widget.NewLabel("…")
live.Wrapping = fyne.TextWrapWord
body := container.NewVBox(head, live)
root := container.NewStack(bg, container.New(innerPadLayout{}, body))
return &bubble{root: root, bg: bg, body: body, head: head, live: live}
}
// append 追加流式文本。
func (b *bubble) append(delta string) {
b.text.WriteString(delta)
b.live.SetText(b.text.String())
}
// finish 结束流式状态;err 非空时展示错误。
func (b *bubble) finish(err error) {
if err != nil {
b.live.SetText("⚠ " + err.Error())
b.bg.FillColor = colorErrBubble()
b.bg.Refresh()
return
}
text := strings.TrimSpace(b.text.String())
if text == "" {
text = "(没有返回内容)"
}
rt := widget.NewRichTextFromMarkdown(text)
rt.Wrapping = fyne.TextWrapWord
b.body.Objects = []fyne.CanvasObject{b.head, rt}
b.body.Refresh()
}
// innerPadLayout 给气泡留内边距。
type innerPadLayout struct{}
func (innerPadLayout) MinSize(objs []fyne.CanvasObject) fyne.Size {
// 宽度不参与最小尺寸计算:气泡宽度由滚动区的视口决定,
// 否则长文本会把聊天区撑宽,导致换行宽度算大、右侧文字被截断。
h := float32(0)
for _, o := range objs {
h = fyne.Max(h, o.MinSize().Height)
}
pad := theme.Padding() * 2
return fyne.NewSize(0, h+pad)
}
func (innerPadLayout) Layout(objs []fyne.CanvasObject, size fyne.Size) {
pad := theme.Padding() * 2
w := fyne.Max(size.Width-pad*2, 1)
h := fyne.Max(size.Height-pad, 1)
for _, o := range objs {
o.Move(fyne.NewPos(pad, pad/2))
o.Resize(fyne.NewSize(w, h))
}
}
// chatView 是消息列表:可滚动,支持流式追加与自动滚底。
type chatView struct {
list *fyne.Container
scroll *container.Scroll
current *bubble
}
func newChatView() *chatView {
list := container.NewVBox()
scroll := container.NewScroll(list)
scroll.Direction = container.ScrollVerticalOnly
scroll.SetMinSize(fyne.NewSize(380, 300))
return &chatView{list: list, scroll: scroll}
}
// object 返回可嵌入界面的对象。
func (c *chatView) object() fyne.CanvasObject { return c.scroll }
func (c *chatView) appendBubble(b *bubble) {
c.list.Add(b.root)
c.scroll.ScrollToBottom()
}
// addUser 添加一条用户消息。
func (c *chatView) addUser(text string, withImage bool) {
if withImage {
text += "\n(已附带区域截图)"
}
b := newBubble("你", bubbleUser)
b.append(text)
b.finish(nil)
c.appendBubble(b)
}
// beginAssistant 开始一条助手消息。
func (c *chatView) beginAssistant(auto bool) {
role := "助手"
if auto {
role = "助手 · 自动识别"
}
c.current = newBubble(role, bubbleBot)
c.appendBubble(c.current)
}
// appendDelta 追加流式文本。
func (c *chatView) appendDelta(delta string) {
if c.current == nil {
c.beginAssistant(false)
}
c.current.append(delta)
c.scroll.ScrollToBottom()
}
// endAssistant 结束当前助手消息。
func (c *chatView) endAssistant(err error) {
if c.current == nil {
return
}
c.current.finish(err)
c.current = nil
c.scroll.ScrollToBottom()
}
// addNotice 插入一条系统提示。
func (c *chatView) addNotice(text string) {
b := newBubble("系统", bubbleError)
b.append(text)
b.finish(nil)
c.appendBubble(b)
}
// clear 清空消息列表。
func (c *chatView) clear() {
c.list.Objects = nil
c.current = nil
c.list.Refresh()
}
+17
View File
@@ -0,0 +1,17 @@
package ui
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/lang"
)
// zhHansExtra 补上 Fyne 内置中文包里缺失的条目。
// widget.NewSelect 在构造时就会取默认占位符,缺翻译会在日志里刷 Translation failure。
const zhHansExtra = `{
"(Select one)": {"other": "请选择"}
}`
// installTranslations 在创建窗口前调用,注册补充翻译。
func installTranslations() {
_ = lang.AddTranslationsForLocale([]byte(zhHansExtra), fyne.Locale("zh-Hans"))
}
+299
View File
@@ -0,0 +1,299 @@
package ui
import (
"fmt"
"image"
"image/color"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"viewsagent/internal/capture"
"viewsagent/internal/win32"
)
// overlayTitle 是框选窗口标题,win32 需要靠它找到 HWND。
const overlayTitle = "viewsagent-overlay"
// overlay 是一个铺满当前显示器的冻结帧框选层。
//
// 之所以不依赖窗口透明:Windows 下的 OpenGL 窗口透明并不可靠,
// 直接用"全屏截图做背景 + 遮罩"视觉等效,还能保证选区与截图完全对齐。
type overlay struct {
win fyne.Window
vd win32.Rect
mapping capture.Mapping
size fyne.Size
calibrated bool
bg *canvas.Image
dims [4]*canvas.Rectangle
edges [4]*canvas.Rectangle
hint *canvas.Text
catcher *catcher
start, cur fyne.Position
dragging bool
closed bool
onDone func(capture.Region)
onCancel func()
}
// newOverlay 创建框选层。shot 应该是调用前抓好的整屏画面。
func newOverlay(app fyne.App, shot image.Image, vd win32.Rect,
onDone func(capture.Region), onCancel func()) *overlay {
o := &overlay{vd: vd, onDone: onDone, onCancel: onCancel}
o.win = app.NewWindow(overlayTitle)
o.win.SetPadded(false)
o.win.SetFullScreen(true)
o.bg = canvas.NewImageFromImage(shot)
o.bg.FillMode = canvas.ImageFillStretch
objs := []fyne.CanvasObject{o.bg}
for i := range o.dims {
o.dims[i] = canvas.NewRectangle(color.NRGBA{R: 0, G: 0, B: 0, A: 130})
objs = append(objs, o.dims[i])
}
for i := range o.edges {
o.edges[i] = canvas.NewRectangle(color.NRGBA{R: 0x5A, G: 0xC8, B: 0xFF, A: 0xFF})
objs = append(objs, o.edges[i])
}
o.hint = canvas.NewText("拖拽框选区域 · Esc 取消", color.White)
o.hint.TextStyle = fyne.TextStyle{Bold: true}
o.hint.TextSize = 15
o.catcher = newCatcher(o)
objs = append(objs, o.catcher, o.hint)
o.win.SetContent(container.NewWithoutLayout(objs...))
o.win.Canvas().SetOnTypedKey(func(k *fyne.KeyEvent) {
if k.Name == fyne.KeyEscape {
o.cancel()
}
})
return o
}
// show 显示框选层并做一次坐标校准。
func (o *overlay) show() {
o.win.Show()
// 窗口尺寸在 Show 之后才确定,稍等一拍再校准。
go func() {
time.Sleep(90 * time.Millisecond)
fyne.Do(o.calibrate)
}()
}
// calibrate 反推"逻辑坐标 → 物理像素"的比例与原点。
// 宁可依赖实际窗口尺寸,也不假设系统缩放,这样 125%/150% 屏都不会偏。
func (o *overlay) calibrate() {
size := o.win.Canvas().Size()
if size.Width < 10 || size.Height < 10 {
return
}
o.size = size
rect, err := windowRectByTitle(overlayTitle)
if err != nil {
mon, _ := win32.MonitorAtCursor()
rect = mon.Rect
}
if rect.Width() <= 0 || rect.Height() <= 0 {
return
}
o.mapping = capture.Mapping{
OffsetX: float64(rect.Left),
OffsetY: float64(rect.Top),
ScaleX: float64(rect.Width()) / float64(size.Width),
ScaleY: float64(rect.Height()) / float64(size.Height),
}
if o.mapping.ScaleX <= 0 || o.mapping.ScaleY <= 0 {
o.mapping.ScaleX, o.mapping.ScaleY = 1, 1
}
// 把整张虚拟桌面按当前窗口的实际映射铺到画布上。
bx, by, bw, bh := o.mapping.ToLogical(capture.Region{
X: o.vd.Left, Y: o.vd.Top, W: o.vd.Width(), H: o.vd.Height(),
})
o.bg.Move(fyne.NewPos(float32(bx), float32(by)))
o.bg.Resize(fyne.NewSize(float32(bw), float32(bh)))
o.catcher.Move(fyne.NewPos(0, 0))
o.catcher.Resize(size)
o.calibrated = true
o.updateSelection()
}
func windowRectByTitle(title string) (win32.Rect, error) {
hwnd, err := win32.FindWindowByTitle(title)
if err != nil {
return win32.Rect{}, err
}
return win32.WindowRect(hwnd)
}
// selection 返回当前选区(逻辑坐标,已归一化)。
func (o *overlay) selection() (x0, y0, x1, y1 float32) {
x0, y0 = o.start.X, o.start.Y
x1, y1 = o.cur.X, o.cur.Y
if x1 < x0 {
x0, x1 = x1, x0
}
if y1 < y0 {
y0, y1 = y1, y0
}
// 限制在窗口内
x0 = fyne.Max(x0, 0)
y0 = fyne.Max(y0, 0)
x1 = fyne.Min(x1, o.size.Width)
y1 = fyne.Min(y1, o.size.Height)
return
}
// updateSelection 重排遮罩、边框与尺寸提示。
func (o *overlay) updateSelection() {
if o.size.Width <= 0 {
return
}
w, h := o.size.Width, o.size.Height
x0, y0, x1, y1 := o.selection()
selW, selH := x1-x0, y1-y0
if selW < 2 || selH < 2 {
// 尚未框选:整屏压暗
o.dims[0].Move(fyne.NewPos(0, 0))
o.dims[0].Resize(fyne.NewSize(w, h))
for i := 1; i < 4; i++ {
o.dims[i].Resize(fyne.NewSize(0, 0))
}
for _, e := range o.edges {
e.Resize(fyne.NewSize(0, 0))
}
o.hint.Text = "拖拽框选区域 · Esc 取消"
o.hint.Move(fyne.NewPos(theme.Padding()*2, theme.Padding()*2))
o.hint.Refresh()
return
}
// 上下左右四块压暗区域,挖出选区
o.dims[0].Move(fyne.NewPos(0, 0))
o.dims[0].Resize(fyne.NewSize(w, y0))
o.dims[1].Move(fyne.NewPos(0, y1))
o.dims[1].Resize(fyne.NewSize(w, h-y1))
o.dims[2].Move(fyne.NewPos(0, y0))
o.dims[2].Resize(fyne.NewSize(x0, selH))
o.dims[3].Move(fyne.NewPos(x1, y0))
o.dims[3].Resize(fyne.NewSize(w-x1, selH))
// 选区描边
const t = 2
o.edges[0].Move(fyne.NewPos(x0, y0))
o.edges[0].Resize(fyne.NewSize(selW, t))
o.edges[1].Move(fyne.NewPos(x0, y1-t))
o.edges[1].Resize(fyne.NewSize(selW, t))
o.edges[2].Move(fyne.NewPos(x0, y0))
o.edges[2].Resize(fyne.NewSize(t, selH))
o.edges[3].Move(fyne.NewPos(x1-t, y0))
o.edges[3].Resize(fyne.NewSize(t, selH))
phys := o.mapping.ToPhysical(float64(x0), float64(y0), float64(selW), float64(selH))
o.hint.Text = fmt.Sprintf("%d × %d", phys.W, phys.H)
hy := y0 - 26
if hy < 4 {
hy = y0 + 6
}
o.hint.Move(fyne.NewPos(x0+6, hy))
o.hint.Refresh()
}
// finish 确认选区。
func (o *overlay) finish() {
if !o.calibrated {
o.calibrate()
}
x0, y0, x1, y1 := o.selection()
if x1-x0 < 6 || y1-y0 < 6 {
o.cancel()
return
}
r := o.mapping.ToPhysical(float64(x0), float64(y0), float64(x1-x0), float64(y1-y0))
if r.W < 8 || r.H < 8 {
o.cancel()
return
}
done := o.onDone
o.close()
if done != nil {
done(r)
}
}
// cancel 取消框选。
func (o *overlay) cancel() {
cancel := o.onCancel
o.close()
if cancel != nil {
cancel()
}
}
func (o *overlay) close() {
if o.closed {
return
}
o.closed = true
o.win.Close()
}
// catcher 是铺满窗口的事件接收层,负责鼠标拖拽。
type catcher struct {
widget.BaseWidget
o *overlay
}
func newCatcher(o *overlay) *catcher {
c := &catcher{o: o}
c.ExtendBaseWidget(c)
return c
}
func (c *catcher) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(canvas.NewRectangle(color.Transparent))
}
func (c *catcher) Dragged(e *fyne.DragEvent) {
if !c.o.dragging {
return
}
c.o.cur = e.Position
c.o.updateSelection()
}
func (c *catcher) DragEnd() {}
func (c *catcher) MouseDown(e *desktop.MouseEvent) {
c.o.dragging = true
c.o.start = e.Position
c.o.cur = e.Position
c.o.updateSelection()
}
func (c *catcher) MouseUp(e *desktop.MouseEvent) {
if !c.o.dragging {
return
}
c.o.dragging = false
c.o.cur = e.Position
c.o.finish()
}
+376
View File
@@ -0,0 +1,376 @@
package ui
import (
"context"
"fmt"
"os/exec"
"strconv"
"strings"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
"viewsagent/internal/config"
)
const settingsTitle = "viewsagent-settings"
// settingsWindow 是模型、触发策略、提示词与热键的设置面板。
type settingsWindow struct {
app *App
win fyne.Window
cfg *config.Config
// 模型接入点
profileSel *widget.Select
pName *widget.Entry
pBase *widget.Entry
pModel *widget.Entry
pKey *widget.Entry
pMax *widget.Entry
pTemp *widget.Entry
// 提示词
tmplSel *widget.Select
tmplSystem *widget.Entry
// 抓屏与触发
fps *widget.Entry
maxEdge *widget.Entry
quality *widget.Entry
diff *widget.Entry
cooldown *widget.Entry
stable *widget.Entry
turns *widget.Entry
// 热键与其它
hkSelect *widget.Entry
hkToggle *widget.Entry
alwaysTop *widget.Check
}
func newSettingsWindow(app *App) *settingsWindow {
s := &settingsWindow{app: app}
s.win = app.fyne.NewWindow(settingsTitle)
s.win.Resize(fyne.NewSize(620, 620))
s.win.SetContent(container.NewAppTabs(
container.NewTabItem("模型接入", s.buildProfileTab()),
container.NewTabItem("提示词", s.buildTemplateTab()),
container.NewTabItem("抓屏与触发", s.buildCaptureTab()),
container.NewTabItem("其它", s.buildMiscTab()),
))
return s
}
func (s *settingsWindow) show() {
s.reload()
s.win.Show()
}
// reload 从引擎当前配置重新载入界面。
func (s *settingsWindow) reload() {
// 用副本编辑,避免与引擎并发读写同一份配置。
s.cfg = s.app.eng.Config().Clone()
names := make([]string, 0, len(s.cfg.Profiles))
for _, p := range s.cfg.Profiles {
names = append(names, p.Name)
}
s.profileSel.Options = names
s.profileSel.SetSelected(s.cfg.ActiveProfile)
s.applyProfile()
keys := make([]string, 0, len(s.cfg.Templates))
for _, t := range s.cfg.Templates {
keys = append(keys, t.Key)
}
s.tmplSel.Options = keys
s.tmplSel.SetSelected(s.cfg.PromptTemplate)
s.applyTemplate()
c := s.cfg.Capture
s.fps.SetText(fmt.Sprintf("%g", c.FPS))
s.maxEdge.SetText(strconv.Itoa(c.MaxEdge))
s.quality.SetText(strconv.Itoa(c.JPEGQuality))
s.diff.SetText(strconv.Itoa(c.DiffThreshold))
s.cooldown.SetText(strconv.Itoa(c.CooldownMs))
s.stable.SetText(strconv.Itoa(c.StableFrames))
s.turns.SetText(strconv.Itoa(c.HistoryTurns))
s.hkSelect.SetText(s.cfg.Hotkeys.SelectRegion)
s.hkToggle.SetText(s.cfg.Hotkeys.ToggleWatch)
s.alwaysTop.SetChecked(s.cfg.AlwaysOnTopCard)
}
func entry(label string, obj fyne.CanvasObject) fyne.CanvasObject {
return container.NewBorder(nil, nil, widget.NewLabel(label), nil, obj)
}
func (s *settingsWindow) buildProfileTab() fyne.CanvasObject {
s.profileSel = widget.NewSelect(nil, func(string) { s.applyProfile() })
s.profileSel.PlaceHolder = "请选择接入点"
s.pName = widget.NewEntry()
s.pBase = widget.NewEntry()
s.pBase.SetPlaceHolder("https://dashscope.aliyuncs.com/compatible-mode/v1")
s.pModel = widget.NewEntry()
s.pModel.SetPlaceHolder("qwen3-vl-plus")
s.pKey = widget.NewPasswordEntry()
s.pMax = widget.NewEntry()
s.pTemp = widget.NewEntry()
form := container.NewVBox(
entry("接入点", s.profileSel),
entry("名称", s.pName),
entry("BaseURL", s.pBase),
entry("模型", s.pModel),
entry("API Key", s.pKey),
entry("max_tokens", s.pMax),
entry("temperature", s.pTemp),
)
addBtn := widget.NewButton("新增接入点", func() {
name := strings.TrimSpace(s.pName.Text)
if name == "" {
name = fmt.Sprintf("custom-%d", len(s.cfg.Profiles)+1)
}
if _, exists := s.cfg.Profile(name); exists {
dialog.ShowInformation("重名", "已有同名接入点,请换一个名字", s.win)
return
}
s.cfg.Profiles = append(s.cfg.Profiles, config.Profile{
Name: name, BaseURL: s.pBase.Text, Model: s.pModel.Text,
APIKey: s.pKey.Text, MaxTokens: 1024, Temperature: 0.3,
})
s.cfg.ActiveProfile = name
s.reload()
})
testBtn := widget.NewButton("测试连接", func() {
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),
)
}
func (s *settingsWindow) buildTemplateTab() fyne.CanvasObject {
s.tmplSel = widget.NewSelect(nil, func(string) { s.applyTemplate() })
s.tmplSel.PlaceHolder = "请选择模板"
s.tmplSystem = widget.NewMultiLineEntry()
s.tmplSystem.SetMinRowsVisible(14)
s.tmplSystem.Wrapping = fyne.TextWrapWord
saveBtn := widget.NewButton("保存提示词", func() {
key := s.tmplSel.Selected
for i := range s.cfg.Templates {
if s.cfg.Templates[i].Key == key {
s.cfg.Templates[i].System = s.tmplSystem.Text
}
}
if err := s.app.applyAndSave(s.cfg); err != nil {
dialog.ShowError(err, s.win)
return
}
dialog.ShowInformation("已保存", "提示词已更新", s.win)
})
return container.NewBorder(
container.NewVBox(entry("模板", s.tmplSel), widget.NewSeparator()),
container.NewVBox(widget.NewSeparator(), container.NewHBox(saveBtn)),
nil, nil,
s.tmplSystem,
)
}
func (s *settingsWindow) buildCaptureTab() fyne.CanvasObject {
s.fps = widget.NewEntry()
s.maxEdge = widget.NewEntry()
s.quality = widget.NewEntry()
s.diff = widget.NewEntry()
s.cooldown = widget.NewEntry()
s.stable = widget.NewEntry()
s.turns = widget.NewEntry()
form := container.NewVBox(
entry("监听帧率 (fps)", s.fps),
entry("图片长边上限", s.maxEdge),
entry("JPEG 质量", s.quality),
entry("变化阈值", s.diff),
entry("冷却时间 (ms)", s.cooldown),
entry("稳定帧数", s.stable),
entry("历史轮数", s.turns),
widget.NewLabel("说明:变化阈值越小越灵敏(默认 5);冷却时间决定两次自动提问的最小间隔。"),
)
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(saveBtn)),
nil, nil,
container.NewVScroll(form),
)
}
func (s *settingsWindow) buildMiscTab() fyne.CanvasObject {
s.hkSelect = widget.NewEntry()
s.hkToggle = widget.NewEntry()
s.alwaysTop = widget.NewCheck("显示置顶答案浮窗", nil)
openDir := widget.NewButton("打开配置目录", func() {
dir := s.app.eng.Store().Dir()
if err := exec.Command("explorer", dir).Start(); err != nil {
dialog.ShowError(err, 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)
})
form := container.NewVBox(
entry("框选热键", s.hkSelect),
entry("监听开关热键", s.hkToggle),
s.alwaysTop,
container.NewHBox(openDir),
widget.NewLabel("热键语法示例:Ctrl+Alt+Q、Ctrl+Shift+F2"),
)
return container.NewBorder(nil,
container.NewVBox(widget.NewSeparator(), container.NewHBox(saveBtn)),
nil, nil,
form,
)
}
// applyProfile 把选中的接入点填进输入框。
func (s *settingsWindow) applyProfile() {
if s.cfg == nil {
return
}
p, ok := s.cfg.Profile(s.profileSel.Selected)
if !ok {
return
}
s.pName.SetText(p.Name)
s.pBase.SetText(p.BaseURL)
s.pModel.SetText(p.Model)
s.pKey.SetText(p.APIKey)
s.pMax.SetText(strconv.Itoa(p.MaxTokens))
s.pTemp.SetText(fmt.Sprintf("%g", p.Temperature))
}
// applyTemplate 把选中的模板填进编辑框。
func (s *settingsWindow) applyTemplate() {
if s.cfg == nil {
return
}
for _, t := range s.cfg.Templates {
if t.Key == s.tmplSel.Selected {
s.tmplSystem.SetText(t.System)
return
}
}
}
// readProfile 用界面上的值构造一个接入点(不落盘)。
func (s *settingsWindow) readProfile() config.Profile {
return config.Profile{
Name: strings.TrimSpace(s.pName.Text),
BaseURL: strings.TrimSpace(s.pBase.Text),
Model: strings.TrimSpace(s.pModel.Text),
APIKey: strings.TrimSpace(s.pKey.Text),
MaxTokens: atoiDefault(s.pMax.Text, 1024),
Temperature: atofDefault(s.pTemp.Text, 0.3),
}
}
// commitAll 把界面上的所有值写回配置对象。
func (s *settingsWindow) commitAll() {
p := s.readProfile()
for i := range s.cfg.Profiles {
if s.cfg.Profiles[i].Name == s.profileSel.Selected {
p.Name = s.cfg.Profiles[i].Name
s.cfg.Profiles[i] = p
}
}
if s.profileSel.Selected != "" {
s.cfg.ActiveProfile = s.profileSel.Selected
}
if key := s.tmplSel.Selected; key != "" {
s.cfg.PromptTemplate = key
}
s.cfg.Capture.FPS = atofDefault(s.fps.Text, 2)
s.cfg.Capture.MaxEdge = atoiDefault(s.maxEdge.Text, 1280)
s.cfg.Capture.JPEGQuality = atoiDefault(s.quality.Text, 75)
s.cfg.Capture.DiffThreshold = atoiDefault(s.diff.Text, 5)
s.cfg.Capture.CooldownMs = atoiDefault(s.cooldown.Text, 3000)
s.cfg.Capture.StableFrames = atoiDefault(s.stable.Text, 2)
s.cfg.Capture.HistoryTurns = atoiDefault(s.turns.Text, 6)
s.cfg.Hotkeys.SelectRegion = strings.TrimSpace(s.hkSelect.Text)
s.cfg.Hotkeys.ToggleWatch = strings.TrimSpace(s.hkToggle.Text)
s.cfg.AlwaysOnTopCard = s.alwaysTop.Checked
s.cfg.Normalize()
}
func atoiDefault(s string, def int) int {
v, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return def
}
return v
}
func atofDefault(s string, def float64) float64 {
v, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil {
return def
}
return v
}
+41
View File
@@ -0,0 +1,41 @@
package ui
import (
"image/color"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/theme"
)
// lookupColor 从当前主题取色。
func lookupColor(name fyne.ThemeColorName) color.Color {
if app := fyne.CurrentApp(); app != nil {
s := app.Settings()
return s.Theme().Color(name, s.ThemeVariant())
}
return theme.DefaultTheme().Color(name, theme.VariantDark)
}
// withAlpha 调整颜色透明度。
func withAlpha(c color.Color, a uint8) color.Color {
n := color.NRGBAModel.Convert(c).(color.NRGBA)
n.A = a
return n
}
// 界面配色集中在这里,方便统一调整。
func colorUserBubble() color.Color {
return withAlpha(lookupColor(theme.ColorNamePrimary), 0x3A)
}
func colorBotBubble() color.Color {
return withAlpha(lookupColor(theme.ColorNameInputBackground), 0xF2)
}
func colorErrBubble() color.Color {
return withAlpha(lookupColor(theme.ColorNameError), 0x28)
}
func colorBorder() color.Color {
return withAlpha(lookupColor(theme.ColorNameInputBorder), 0xB0)
}
+25
View File
@@ -0,0 +1,25 @@
package vision
import (
"context"
"image"
)
// Detection 是一条本地检测结果。
type Detection struct {
Label string
Score float32
Box image.Rectangle
}
// Detector 是本地视觉检测器(例如 YOLO)的抽象。
//
// 第一版不启用:屏幕理解的准确率由视觉大模型直接读取截图来保证,
// 帧差指纹负责"要不要问"。二期若需要隐私打码或题目区域定位,
// 实现该接口挂到 agent 上即可,主链路不用改。
type Detector interface {
// Name 返回检测器名称,用于日志。
Name() string
// Detect 对整帧图像做检测。
Detect(ctx context.Context, img image.Image) ([]Detection, error)
}
+45
View File
@@ -0,0 +1,45 @@
package vision
import (
"bytes"
"fmt"
"image"
"image/jpeg"
"golang.org/x/image/draw"
)
// EncodeJPEG 把图像等比缩放到长边不超过 maxEdge 后编码为 JPEG。
// 视觉模型对分辨率不敏感,但对 token 很敏感,这一步是成本控制的关键。
func EncodeJPEG(img image.Image, maxEdge, quality int) ([]byte, error) {
if img == nil {
return nil, fmt.Errorf("待编码图像为空")
}
if maxEdge <= 0 {
maxEdge = 1280
}
if quality <= 0 || quality > 100 {
quality = 75
}
b := img.Bounds()
w, h := b.Dx(), b.Dy()
if w <= 0 || h <= 0 {
return nil, fmt.Errorf("图像尺寸无效: %dx%d", w, h)
}
target := img
if longest := max(w, h); longest > maxEdge {
scale := float64(maxEdge) / float64(longest)
nw, nh := max(int(float64(w)*scale+0.5), 1), max(int(float64(h)*scale+0.5), 1)
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
target = dst
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, target, &jpeg.Options{Quality: quality}); err != nil {
return nil, fmt.Errorf("JPEG 编码失败: %w", err)
}
return buf.Bytes(), nil
}
+81
View File
@@ -0,0 +1,81 @@
// Package vision 提供帧差指纹、图片编码与(可选的)本地检测接口。
package vision
import (
"image"
"golang.org/x/image/draw"
)
// Fingerprint 是用于判断"画面有没有变化"的感知哈希。
type Fingerprint struct {
AHash uint64 // 均值哈希
DHash uint64 // 差分哈希
Mean uint8 // 平均亮度
}
// grayGrid 把图像缩放到 w×h 的灰度图。
func grayGrid(src image.Image, w, h int) *image.Gray {
dst := image.NewGray(image.Rect(0, 0, w, h))
draw.ApproxBiLinear.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Src, nil)
return dst
}
// Of 计算图像指纹。
func Of(img image.Image) Fingerprint {
if img == nil {
return Fingerprint{}
}
small := grayGrid(img, 8, 8)
var sum, n int
for _, v := range small.Pix {
sum += int(v)
n++
}
avg := sum / max(n, 1)
mean := uint8(min(avg, 255))
var aHash uint64
for i, v := range small.Pix {
if int(v) > avg {
aHash |= 1 << uint(i)
}
}
wide := grayGrid(img, 9, 8)
var dHash uint64
for y := 0; y < 8; y++ {
for x := 0; x < 8; x++ {
l := wide.GrayAt(x, y).Y
r := wide.GrayAt(x+1, y).Y
if l > r {
dHash |= 1 << uint(y*8+x)
}
}
}
return Fingerprint{AHash: aHash, DHash: dHash, Mean: mean}
}
// Distance 返回两个指纹的汉明距离(取两种哈希的较大值,偏向灵敏)。
func (f Fingerprint) Distance(o Fingerprint) int {
return max(bitsSet(f.AHash^o.AHash), bitsSet(f.DHash^o.DHash))
}
// Changed 判断画面是否发生了足以触发提问的变化。
func (f Fingerprint) Changed(o Fingerprint, threshold int) bool {
if threshold <= 0 {
threshold = 5
}
return f.Distance(o) > threshold
}
func bitsSet(v uint64) int {
n := 0
for v != 0 {
v &= v - 1
n++
}
return n
}
+80
View File
@@ -0,0 +1,80 @@
package vision
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"testing"
)
func solid(w, h int, c color.Gray) *image.Gray {
img := image.NewGray(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.SetGray(x, y, c)
}
}
return img
}
func TestFingerprintIdentical(t *testing.T) {
a := solid(120, 80, color.Gray{Y: 40})
b := solid(120, 80, color.Gray{Y: 40})
if d := Of(a).Distance(Of(b)); d != 0 {
t.Fatalf("相同画面应无差异,实际距离 %d", d)
}
if Of(a).Changed(Of(b), 5) {
t.Fatal("相同画面不应判定为变化")
}
}
func TestFingerprintDetectsChange(t *testing.T) {
base := solid(120, 80, color.Gray{Y: 20})
changed := image.NewGray(image.Rect(0, 0, 120, 80))
for y := 0; y < 80; y++ {
for x := 60; x < 120; x++ {
changed.SetGray(x, y, color.Gray{Y: 250})
}
}
if !Of(changed).Changed(Of(base), 5) {
t.Fatal("右半边变白应被判定为变化")
}
}
func TestEncodeJPEGResizesLongEdge(t *testing.T) {
src := solid(2000, 400, color.Gray{Y: 128})
raw, err := EncodeJPEG(src, 1280, 75)
if err != nil {
t.Fatalf("编码失败: %v", err)
}
if len(raw) == 0 {
t.Fatal("编码结果为空")
}
img, err := jpeg.Decode(bytes.NewReader(raw))
if err != nil {
t.Fatalf("解码失败: %v", err)
}
b := img.Bounds()
if b.Dx() != 1280 {
t.Fatalf("长边应缩放到 1280,实际宽 %d", b.Dx())
}
if b.Dy() != 256 {
t.Fatalf("等比缩放后高应为 256,实际 %d", b.Dy())
}
}
func TestEncodeJPEGKeepsSmallImage(t *testing.T) {
src := solid(64, 32, color.Gray{Y: 200})
raw, err := EncodeJPEG(src, 1280, 75)
if err != nil {
t.Fatalf("编码失败: %v", err)
}
img, err := jpeg.Decode(bytes.NewReader(raw))
if err != nil {
t.Fatalf("解码失败: %v", err)
}
if img.Bounds().Dx() != 64 {
t.Fatalf("小图不应放大,实际宽 %d", img.Bounds().Dx())
}
}
+188
View File
@@ -0,0 +1,188 @@
package win32
import (
"fmt"
"runtime"
"strings"
"unsafe"
"golang.org/x/sys/windows"
)
var (
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
procRegisterHotKey = user32.NewProc("RegisterHotKey")
procUnregisterHotKey = user32.NewProc("UnregisterHotKey")
procGetMessageW = user32.NewProc("GetMessageW")
procPostThreadMessageW = user32.NewProc("PostThreadMessageW")
// GetCurrentThreadId 位于 kernel32,不在 user32。
procGetCurrentThreadID = kernel32.NewProc("GetCurrentThreadId")
)
const (
wmHotkey = 0x0312
wmQuit = 0x0012
modAlt = 0x0001
modControl = 0x0002
modShift = 0x0004
modWin = 0x0008
modNoRepeat = 0x4000
)
// msg 对应 Win32 的 MSG 结构(x/sys/windows 未导出该类型)。
type msg struct {
HWnd uintptr
Message uint32
_ uint32
WParam uintptr
LParam uintptr
Time uint32
Pt struct {
X, Y int32
}
}
// Hotkey 是一组解析后的热键定义。
type Hotkey struct {
ID int
Mods uint32
VK uint32
Spec string
}
var namedKeys = map[string]uint32{
"space": 0x20, "esc": 0x1B, "escape": 0x1B, "enter": 0x0D, "tab": 0x09,
"backspace": 0x08, "delete": 0x2E, "insert": 0x2D, "home": 0x24, "end": 0x23,
"pageup": 0x21, "pagedown": 0x22, "left": 0x25, "up": 0x26, "right": 0x27, "down": 0x28,
"f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73, "f5": 0x74, "f6": 0x75,
"f7": 0x76, "f8": 0x77, "f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B,
}
// ParseHotkey 解析 "Ctrl+Alt+Q" 形式的热键描述。
func ParseHotkey(spec string) (Hotkey, error) {
parts := strings.Split(strings.TrimSpace(spec), "+")
if len(parts) == 0 {
return Hotkey{}, fmt.Errorf("热键为空")
}
hk := Hotkey{Mods: modNoRepeat, Spec: spec}
for _, raw := range parts {
p := strings.ToLower(strings.TrimSpace(raw))
switch p {
case "ctrl", "control":
hk.Mods |= modControl
case "alt":
hk.Mods |= modAlt
case "shift":
hk.Mods |= modShift
case "win", "meta":
hk.Mods |= modWin
case "":
return Hotkey{}, fmt.Errorf("热键 %q 含空段", spec)
default:
if hk.VK != 0 {
return Hotkey{}, fmt.Errorf("热键 %q 只能有一个主键", spec)
}
if vk, ok := namedKeys[p]; ok {
hk.VK = vk
continue
}
if len(p) == 1 && p[0] >= 'a' && p[0] <= 'z' {
hk.VK = uint32(p[0] - 'a' + 'A')
continue
}
if len(p) == 1 && p[0] >= '0' && p[0] <= '9' {
hk.VK = uint32(p[0])
continue
}
return Hotkey{}, fmt.Errorf("无法识别的主键 %q", raw)
}
}
if hk.VK == 0 {
return Hotkey{}, fmt.Errorf("热键 %q 缺少主键", spec)
}
return hk, nil
}
// ListenHotkeys 在独立 OS 线程上注册全局热键并泵送消息。
// onFire 运行在该线程上,调用方应自行转到 UI 线程。
// 返回的 stop 函数可安全调用一次。
//
// 单个热键被其它程序占用时只跳过它并在 warnings 里说明,其余热键照常生效;
// 只有全部注册失败才返回 err。
func ListenHotkeys(specs []string, onFire func(spec string)) (stop func(), warnings []string, err error) {
if len(specs) == 0 {
return func() {}, nil, nil
}
type registration struct {
hotkeys []Hotkey
tid uint32
warnings []string
}
ready := make(chan error, 1)
done := make(chan struct{})
reg := make(chan registration, 1)
go func() {
runtime.LockOSThread()
tid, _, _ := procGetCurrentThreadID.Call()
hotkeys := make([]Hotkey, 0, len(specs))
var warns []string
for i, spec := range specs {
if strings.TrimSpace(spec) == "" {
continue
}
hk, perr := ParseHotkey(spec)
if perr != nil {
warns = append(warns, perr.Error())
continue
}
hk.ID = 100 + i
if ok, _, e := procRegisterHotKey.Call(0, uintptr(hk.ID), uintptr(hk.Mods), uintptr(hk.VK)); ok == 0 {
warns = append(warns, fmt.Sprintf("%s 已被其它程序占用(%v)", spec, e))
continue
}
hotkeys = append(hotkeys, hk)
}
if len(hotkeys) == 0 {
ready <- fmt.Errorf("所有全局热键都无法注册:%s", strings.Join(warns, ";"))
return
}
reg <- registration{hotkeys: hotkeys, tid: uint32(tid), warnings: warns}
ready <- nil
var m msg
for {
r, _, _ := procGetMessageW.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0)
if int32(r) <= 0 {
break
}
if m.Message != wmHotkey {
continue
}
id := int(m.WParam)
for _, hk := range hotkeys {
if hk.ID == id {
onFire(hk.Spec)
break
}
}
}
for _, hk := range hotkeys {
procUnregisterHotKey.Call(0, uintptr(hk.ID))
}
close(done)
}()
if err := <-ready; err != nil {
return func() {}, nil, err
}
r := <-reg
return func() {
procPostThreadMessageW.Call(uintptr(r.tid), wmQuit, 0, 0)
<-done
}, r.warnings, nil
}
+230
View File
@@ -0,0 +1,230 @@
// Package win32 封装 Fyne 未提供的 Windows 原生能力:
// 显示器枚举、窗口置顶/移动、全局热键、DPI 查询。
package win32
import (
"fmt"
"sync"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
var (
user32 = windows.NewLazySystemDLL("user32.dll")
shcore = windows.NewLazySystemDLL("shcore.dll")
procFindWindowW = user32.NewProc("FindWindowW")
procGetWindowRect = user32.NewProc("GetWindowRect")
procSetWindowPos = user32.NewProc("SetWindowPos")
procGetCursorPos = user32.NewProc("GetCursorPos")
procEnumDisplayMonitors = user32.NewProc("EnumDisplayMonitors")
procGetMonitorInfoW = user32.NewProc("GetMonitorInfoW")
procGetDpiForMonitor = shcore.NewProc("GetDpiForMonitor")
)
const (
swpNoSize = 0x0001
swpNoMove = 0x0002
swpNoZOrder = 0x0004
swpNoActivate = 0x0010
swpShowWindow = 0x0040
hwndTopMost = ^uintptr(0) // (HWND)-1
mdtEffectiveDPI = 0
monitorInfoPrimary = 1
)
// Rect 是以物理像素表示的屏幕矩形。
type Rect struct {
Left, Top, Right, Bottom int
}
// Width 返回矩形宽度。
func (r Rect) Width() int { return r.Right - r.Left }
// Height 返回矩形高度。
func (r Rect) Height() int { return r.Bottom - r.Top }
// Contains 判断点是否落在矩形内。
func (r Rect) Contains(x, y int) bool {
return x >= r.Left && x < r.Right && y >= r.Top && y < r.Bottom
}
type nativeRect struct {
Left, Top, Right, Bottom int32
}
// Monitor 描述一台显示器。
type Monitor struct {
Index int
Rect Rect // 完整屏幕区域(物理像素)
Work Rect // 去掉任务栏的可用区域
Primary bool
Scale float64 // 系统缩放,例如 1.25
}
type nativeMonitorInfo struct {
CbSize uint32
RcMonitor nativeRect
RcWork nativeRect
DwFlags uint32
}
type point struct {
X, Y int32
}
var (
enumMu sync.Mutex
enumSink *[]Monitor
enumCallback uintptr
)
// Monitors 枚举当前所有显示器,坐标已换算为物理像素。
func Monitors() []Monitor {
enumMu.Lock()
defer enumMu.Unlock()
var out []Monitor
enumSink = &out
if enumCallback == 0 {
enumCallback = windows.NewCallback(enumProc)
}
procEnumDisplayMonitors.Call(0, 0, enumCallback, 0)
enumSink = nil
for i := range out {
out[i].Index = i
}
return out
}
func enumProc(hMonitor, _, _, _ uintptr) uintptr {
if enumSink == nil {
return 1
}
info := nativeMonitorInfo{CbSize: uint32(unsafe.Sizeof(nativeMonitorInfo{}))}
if r, _, _ := procGetMonitorInfoW.Call(hMonitor, uintptr(unsafe.Pointer(&info))); r == 0 {
return 1
}
m := Monitor{
Rect: Rect{
Left: int(info.RcMonitor.Left),
Top: int(info.RcMonitor.Top),
Right: int(info.RcMonitor.Right),
Bottom: int(info.RcMonitor.Bottom),
},
Work: Rect{
Left: int(info.RcWork.Left),
Top: int(info.RcWork.Top),
Right: int(info.RcWork.Right),
Bottom: int(info.RcWork.Bottom),
},
Primary: info.DwFlags&monitorInfoPrimary != 0,
Scale: 1,
}
var dpiX, dpiY uint32
if hr, _, _ := procGetDpiForMonitor.Call(hMonitor, mdtEffectiveDPI,
uintptr(unsafe.Pointer(&dpiX)), uintptr(unsafe.Pointer(&dpiY))); hr == 0 && dpiX > 0 {
m.Scale = float64(dpiX) / 96
}
*enumSink = append(*enumSink, m)
return 1
}
// VirtualScreen 返回所有显示器的并集,作为虚拟桌面坐标参考。
func VirtualScreen() Rect {
ms := Monitors()
if len(ms) == 0 {
return Rect{}
}
union := ms[0].Rect
for _, m := range ms[1:] {
if m.Rect.Left < union.Left {
union.Left = m.Rect.Left
}
if m.Rect.Top < union.Top {
union.Top = m.Rect.Top
}
if m.Rect.Right > union.Right {
union.Right = m.Rect.Right
}
if m.Rect.Bottom > union.Bottom {
union.Bottom = m.Rect.Bottom
}
}
return union
}
// CursorPos 返回鼠标当前位置(物理像素)。
func CursorPos() (int, int) {
var p point
if r, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&p))); r == 0 {
return 0, 0
}
return int(p.X), int(p.Y)
}
// MonitorAt 返回包含指定点的显示器,找不到时退化为第一台。
func MonitorAt(x, y int) (Monitor, bool) {
ms := Monitors()
for _, m := range ms {
if m.Rect.Contains(x, y) {
return m, true
}
}
if len(ms) > 0 {
return ms[0], false
}
return Monitor{}, false
}
// MonitorAtCursor 返回鼠标所在的显示器。
func MonitorAtCursor() (Monitor, bool) {
x, y := CursorPos()
return MonitorAt(x, y)
}
// FindWindowByTitle 按标题查找顶层窗口句柄。
func FindWindowByTitle(title string) (syscall.Handle, error) {
p, err := windows.UTF16PtrFromString(title)
if err != nil {
return 0, err
}
r, _, e := procFindWindowW.Call(0, uintptr(unsafe.Pointer(p)))
if r == 0 {
return 0, fmt.Errorf("未找到标题为 %q 的窗口: %w", title, e)
}
return syscall.Handle(r), nil
}
// WindowRect 返回窗口在屏幕上的物理像素矩形。
func WindowRect(hwnd syscall.Handle) (Rect, error) {
var r nativeRect
if ok, _, e := procGetWindowRect.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&r))); ok == 0 {
return Rect{}, fmt.Errorf("GetWindowRect 失败: %w", e)
}
return Rect{Left: int(r.Left), Top: int(r.Top), Right: int(r.Right), Bottom: int(r.Bottom)}, nil
}
// SetTopMost 让窗口保持置顶但不抢焦点。
func SetTopMost(hwnd syscall.Handle) error {
if ok, _, e := procSetWindowPos.Call(uintptr(hwnd), hwndTopMost, 0, 0, 0, 0,
swpNoMove|swpNoSize|swpNoActivate); ok == 0 {
return fmt.Errorf("SetWindowPos(TOPMOST) 失败: %w", e)
}
return nil
}
// MoveTo 把窗口移动到指定物理矩形(不改变 Z 序)。
func MoveTo(hwnd syscall.Handle, r Rect) error {
if ok, _, e := procSetWindowPos.Call(uintptr(hwnd), 0,
uintptr(r.Left), uintptr(r.Top),
uintptr(r.Width()), uintptr(r.Height()),
swpNoZOrder|swpNoActivate|swpShowWindow); ok == 0 {
return fmt.Errorf("SetWindowPos(MoveTo) 失败: %w", e)
}
return nil
}
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# 通过清华 TUNA 镜像为 MSYS2 安装 mingw-w64 gcc(Fyne/cgo 必需)
set -e
for f in /etc/pacman.d/mirrorlist.*; do
sed -i 's|repo.msys2.org|mirrors.tuna.tsinghua.edu.cn/msys2|g' "$f"
done
echo "--- mirror ---"
grep -m1 '^Server' /etc/pacman.d/mirrorlist.mingw64 || true
echo "--- sync db ---"
pacman -Sy --noconfirm
echo "--- install gcc ---"
pacman -S --noconfirm --needed mingw-w64-x86_64-gcc
echo "--- verify ---"
/mingw64/bin/gcc.exe --version | head -n 1