commit 35537904755f3d25185477c5d495cde7e2779a3c Author: lzq <357099073@qq.com> Date: Tue Oct 27 17:58:28 2026 +0800 first commit diff --git a/bin/viewsagent.exe b/bin/viewsagent.exe new file mode 100644 index 0000000..ee08ad2 Binary files /dev/null and b/bin/viewsagent.exe differ diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..25f8fc3 --- /dev/null +++ b/build.ps1 @@ -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 diff --git a/cmd/viewsagent/main.go b/cmd/viewsagent/main.go new file mode 100644 index 0000000..2f43922 --- /dev/null +++ b/cmd/viewsagent/main.go @@ -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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4bd7b46 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..af74444 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/agent/engine.go b/internal/agent/engine.go new file mode 100644 index 0000000..ff67475 --- /dev/null +++ b/internal/agent/engine.go @@ -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() +} diff --git a/internal/capture/capture.go b/internal/capture/capture.go new file mode 100644 index 0000000..0d90d05 --- /dev/null +++ b/internal/capture/capture.go @@ -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() } diff --git a/internal/capture/capture_test.go b/internal/capture/capture_test.go new file mode 100644 index 0000000..ab18a5f --- /dev/null +++ b/internal/capture/capture_test.go @@ -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("过度收缩应返回原区域") + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..13801e0 --- /dev/null +++ b/internal/config/config.go @@ -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) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..3ce87b4 --- /dev/null +++ b/internal/config/config_test.go @@ -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("接入点丢失时应回退到第一个") + } +} diff --git a/internal/llm/openai.go b/internal/llm/openai.go new file mode 100644 index 0000000..c27b39a --- /dev/null +++ b/internal/llm/openai.go @@ -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 +} diff --git a/internal/llm/provider.go b/internal/llm/provider.go new file mode 100644 index 0000000..1e2da33 --- /dev/null +++ b/internal/llm/provider.go @@ -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 +} diff --git a/internal/llm/sse.go b/internal/llm/sse.go new file mode 100644 index 0000000..a87afda --- /dev/null +++ b/internal/llm/sse.go @@ -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 + } + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go new file mode 100644 index 0000000..17510ab --- /dev/null +++ b/internal/ui/app.go @@ -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 + } +} diff --git a/internal/ui/card.go b/internal/ui/card.go new file mode 100644 index 0000000..55d9840 --- /dev/null +++ b/internal/ui/card.go @@ -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()}) +} diff --git a/internal/ui/chat.go b/internal/ui/chat.go new file mode 100644 index 0000000..9e77db1 --- /dev/null +++ b/internal/ui/chat.go @@ -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() +} diff --git a/internal/ui/i18n.go b/internal/ui/i18n.go new file mode 100644 index 0000000..1c7b185 --- /dev/null +++ b/internal/ui/i18n.go @@ -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")) +} diff --git a/internal/ui/overlay.go b/internal/ui/overlay.go new file mode 100644 index 0000000..84e8d69 --- /dev/null +++ b/internal/ui/overlay.go @@ -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() +} diff --git a/internal/ui/settings.go b/internal/ui/settings.go new file mode 100644 index 0000000..97c9a92 --- /dev/null +++ b/internal/ui/settings.go @@ -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 +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go new file mode 100644 index 0000000..dbc772a --- /dev/null +++ b/internal/ui/styles.go @@ -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) +} diff --git a/internal/vision/detector.go b/internal/vision/detector.go new file mode 100644 index 0000000..15506e4 --- /dev/null +++ b/internal/vision/detector.go @@ -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) +} diff --git a/internal/vision/encode.go b/internal/vision/encode.go new file mode 100644 index 0000000..b0c6937 --- /dev/null +++ b/internal/vision/encode.go @@ -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 +} diff --git a/internal/vision/hash.go b/internal/vision/hash.go new file mode 100644 index 0000000..444cbf8 --- /dev/null +++ b/internal/vision/hash.go @@ -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 +} diff --git a/internal/vision/hash_test.go b/internal/vision/hash_test.go new file mode 100644 index 0000000..f7efb3e --- /dev/null +++ b/internal/vision/hash_test.go @@ -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()) + } +} diff --git a/internal/win32/hotkey.go b/internal/win32/hotkey.go new file mode 100644 index 0000000..cdaa9dd --- /dev/null +++ b/internal/win32/hotkey.go @@ -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 +} diff --git a/internal/win32/win32.go b/internal/win32/win32.go new file mode 100644 index 0000000..76762da --- /dev/null +++ b/internal/win32/win32.go @@ -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 +} diff --git a/scripts/setup-gcc.sh b/scripts/setup-gcc.sh new file mode 100644 index 0000000..92e4ff0 --- /dev/null +++ b/scripts/setup-gcc.sh @@ -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