first commit
This commit is contained in:
commit
3553790475
27 files changed
+3839
No files matched your search
Binary file not shown.
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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("过度收缩应返回原区域")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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("接入点丢失时应回退到第一个")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user