first commit

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

No files matched your search

+458
View File
@@ -0,0 +1,458 @@
package ui
import (
"fmt"
"image/png"
"log/slog"
"os"
"strconv"
"strings"
"sync"
"time"
"fyne.io/fyne/v2"
fyneapp "fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"viewsagent/internal/agent"
"viewsagent/internal/capture"
"viewsagent/internal/config"
"viewsagent/internal/llm"
"viewsagent/internal/win32"
)
const mainTitle = "视界 Agent"
// App 是桌面端主程序,同时实现 agent.Sink 接收引擎事件。
type App struct {
fyne fyne.App
win fyne.Window
eng *agent.Engine
chat *chatView
entry *widget.Entry
attach *widget.Check
sendBtn *widget.Button
watchBtn *widget.Button
modelSel *widget.Select
regionLbl *widget.Label
statusLbl *widget.Label
settings *settingsWindow
card *floatCard
overlay *overlay
hotkeyStop func()
// 流式文本合批,避免每个 delta 都触发一次布局
pendMu sync.Mutex
pending strings.Builder
pendScheduled bool
// 最近一条完整回答,用于同步到浮窗
replyMu sync.Mutex
lastReply strings.Builder
quietModelSel bool
}
// Run 启动整个桌面应用。
func Run() error {
store, err := config.NewStore()
if err != nil {
return err
}
cfg := store.Load()
fa := fyneapp.NewWithID("com.viewsagent.desktop")
installTranslations()
eng := agent.New(cfg, store)
a := &App{fyne: fa, eng: eng}
a.build()
eng.SetSink(a)
a.startHotkeys()
a.maybeAutoShot()
defer func() {
if a.hotkeyStop != nil {
a.hotkeyStop()
}
eng.Close()
}()
a.win.ShowAndRun()
return nil
}
// maybeAutoShot 在设置了 VIEWSAGENT_SHOT 时,等界面稳定后把画布导出为 PNG 再退出。
// 只用于开发期核对布局,正常运行不会触发。
func (a *App) maybeAutoShot() {
path := os.Getenv("VIEWSAGENT_SHOT")
if path == "" {
return
}
delay := 1500 * time.Millisecond
if v := os.Getenv("VIEWSAGENT_SHOT_DELAY"); v != "" {
if ms, err := strconv.Atoi(v); err == nil && ms > 0 {
delay = time.Duration(ms) * time.Millisecond
}
}
go func() {
time.Sleep(delay)
fyne.Do(func() {
if img := a.win.Canvas().Capture(); img != nil {
if f, err := os.Create(path); err == nil {
_ = png.Encode(f, img)
_ = f.Close()
}
}
a.fyne.Quit()
})
}()
}
func (a *App) build() {
a.win = a.fyne.NewWindow(mainTitle)
a.win.Resize(fyne.NewSize(520, 780))
a.win.CenterOnScreen()
a.chat = newChatView()
a.card = newFloatCard(a.fyne)
a.settings = newSettingsWindow(a)
cfg := a.eng.Config()
a.modelSel = widget.NewSelect(profileNames(cfg), a.onModelChange)
a.modelSel.PlaceHolder = "请选择接入点"
a.modelSel.SetSelected(cfg.ActiveProfile)
cardBtn := widget.NewButtonWithIcon("", theme.InfoIcon(), a.toggleCard)
cardBtn.Importance = widget.LowImportance
settingsBtn := widget.NewButtonWithIcon("", theme.SettingsIcon(), a.openSettings)
settingsBtn.Importance = widget.LowImportance
toolbar := container.NewHBox(
widget.NewLabel("模型"), a.modelSel,
layout.NewSpacer(), cardBtn, settingsBtn,
)
a.regionLbl = widget.NewLabel("未框选区域")
a.regionLbl.Importance = widget.LowImportance
selectBtn := widget.NewButtonWithIcon("框选", theme.VisibilityOffIcon(), a.startSelection)
previewBtn := widget.NewButtonWithIcon("预览", theme.SearchIcon(), a.showPreview)
previewBtn.Importance = widget.LowImportance
a.watchBtn = widget.NewButtonWithIcon("开始监听", theme.MediaPlayIcon(), a.toggleWatch)
a.watchBtn.Importance = widget.HighImportance
regionRow := container.NewHBox(a.regionLbl, layout.NewSpacer(), previewBtn, selectBtn, a.watchBtn)
a.entry = widget.NewMultiLineEntry()
a.entry.SetPlaceHolder("输入你的要求,例如:请帮我做练习题,实时给出答案")
a.entry.SetMinRowsVisible(2)
a.attach = widget.NewCheck("附带区域截图", nil)
a.attach.SetChecked(true)
a.sendBtn = widget.NewButtonWithIcon("发送", theme.MailSendIcon(), a.send)
a.sendBtn.Importance = widget.HighImportance
clearBtn := widget.NewButtonWithIcon("", theme.ContentClearIcon(), a.clearChat)
clearBtn.Importance = widget.LowImportance
inputRow := container.NewBorder(nil, nil,
container.NewHBox(a.attach),
container.NewHBox(clearBtn, a.sendBtn),
nil,
)
a.statusLbl = widget.NewLabel("就绪")
a.statusLbl.Importance = widget.LowImportance
top := container.NewVBox(toolbar, regionRow, widget.NewSeparator())
bottom := container.NewVBox(widget.NewSeparator(), a.entry, inputRow, a.statusLbl)
a.win.SetContent(container.NewBorder(top, bottom, nil, nil, a.chat.object()))
a.applyState(a.eng.State())
}
func profileNames(cfg *config.Config) []string {
names := make([]string, 0, len(cfg.Profiles))
for _, p := range cfg.Profiles {
names = append(names, p.Name)
}
return names
}
// ---------- 用户操作 ----------
func (a *App) send() {
text := strings.TrimSpace(a.entry.Text)
if text == "" {
return
}
a.entry.SetText("")
if err := a.eng.Ask(text, a.attach.Checked); err != nil {
a.chat.addNotice(err.Error())
}
}
func (a *App) toggleWatch() {
if err := a.eng.ToggleWatch(); err != nil {
a.chat.addNotice(err.Error())
}
}
func (a *App) toggleCard() {
a.card.setRegion(a.eng.Region())
a.card.toggle()
}
func (a *App) openSettings() {
a.settings.show()
}
func (a *App) clearChat() {
a.eng.ResetHistory()
a.chat.clear()
a.chat.addNotice("已清空对话上下文")
}
func (a *App) onModelChange(name string) {
if a.quietModelSel || name == "" {
return
}
cfg := a.eng.Config()
if _, ok := cfg.Profile(name); !ok {
return
}
cfg.ActiveProfile = name
if err := a.eng.ApplyConfig(cfg); err != nil {
a.chat.addNotice("切换模型失败:" + err.Error())
return
}
if err := a.eng.Store().Save(cfg); err != nil {
slog.Warn("保存模型选择失败", "err", err)
}
a.chat.addNotice("已切换模型:" + name)
}
// saveConfig 把引擎里的配置落盘并立即生效。
func (a *App) saveConfig() error {
return a.applyAndSave(a.eng.Config())
}
// applyAndSave 保存并应用一份配置(覆盖模型下拉选项)。
func (a *App) applyAndSave(cfg *config.Config) error {
cfg.Normalize()
if err := a.eng.Store().Save(cfg); err != nil {
return err
}
if err := a.eng.ApplyConfig(cfg); err != nil {
return err
}
a.quietModelSel = true
a.modelSel.Options = profileNames(cfg)
a.modelSel.SetSelected(cfg.ActiveProfile)
a.quietModelSel = false
return nil
}
// ---------- 框选 ----------
func (a *App) startSelection() {
if a.overlay != nil {
return
}
// 先隐藏主窗,避免它出现在冻结帧里挡住要框选的内容
a.win.Hide()
time.Sleep(220 * time.Millisecond)
shot, err := (capture.Screen{}).Desktop()
if err != nil {
a.win.Show()
a.chat.addNotice("抓屏失败:" + err.Error())
return
}
vd := win32.VirtualScreen()
a.overlay = newOverlay(a.fyne, shot, vd, a.onRegionSelected, a.onRegionCancel)
a.overlay.show()
}
func (a *App) onRegionSelected(r capture.Region) {
a.overlay = nil
a.win.Show()
if err := a.eng.SetRegion(r); err != nil {
a.chat.addNotice(err.Error())
return
}
a.card.setRegion(r)
a.chat.addNotice(fmt.Sprintf("已框选监视区域 %s", r))
}
func (a *App) onRegionCancel() {
a.overlay = nil
a.win.Show()
}
func (a *App) showPreview() {
go func() {
raw, err := a.eng.Snapshot()
fyne.Do(func() {
if err != nil {
a.chat.addNotice("预览失败:" + err.Error())
return
}
img := canvas.NewImageFromResource(fyne.NewStaticResource("preview.jpg", raw))
img.FillMode = canvas.ImageFillContain
img.SetMinSize(fyne.NewSize(520, 300))
d := dialog.NewCustom("区域预览", "关闭", img, a.win)
d.Resize(fyne.NewSize(560, 380))
d.Show()
})
}()
}
// ---------- 热键 ----------
func (a *App) startHotkeys() {
cfg := a.eng.Config()
selectSpec := cfg.Hotkeys.SelectRegion
toggleSpec := cfg.Hotkeys.ToggleWatch
stop, warns, err := win32.ListenHotkeys([]string{selectSpec, toggleSpec}, func(spec string) {
fyne.Do(func() {
switch spec {
case selectSpec:
a.startSelection()
case toggleSpec:
a.toggleWatch()
}
})
})
if err != nil {
slog.Warn("全局热键注册失败", "err", err)
a.chat.addNotice("全局热键不可用(请到设置页改快捷键):" + err.Error())
return
}
if len(warns) > 0 {
slog.Warn("部分全局热键注册失败", "warnings", warns)
a.chat.addNotice("部分热键被其他程序占用,已跳过:" + strings.Join(warns, ";"))
}
a.hotkeyStop = stop
}
// ---------- agent.Sink ----------
// OnUserMessage 收到手动提问。
func (a *App) OnUserMessage(text string, withImage bool) {
fyne.Do(func() { a.chat.addUser(text, withImage) })
}
// OnAssistantStart 开始一条助手回答。
func (a *App) OnAssistantStart(auto bool) {
a.replyMu.Lock()
a.lastReply.Reset()
a.replyMu.Unlock()
fyne.Do(func() { a.chat.beginAssistant(auto) })
}
// OnAssistantDelta 收到流式增量,40ms 合批后刷新界面。
func (a *App) OnAssistantDelta(delta string) {
a.replyMu.Lock()
a.lastReply.WriteString(delta)
a.replyMu.Unlock()
a.pendMu.Lock()
a.pending.WriteString(delta)
if a.pendScheduled {
a.pendMu.Unlock()
return
}
a.pendScheduled = true
a.pendMu.Unlock()
time.AfterFunc(40*time.Millisecond, a.flushPending)
}
func (a *App) flushPending() {
a.pendMu.Lock()
text := a.pending.String()
a.pending.Reset()
a.pendScheduled = false
a.pendMu.Unlock()
if text == "" {
return
}
fyne.Do(func() { a.chat.appendDelta(text) })
}
// OnAssistantEnd 结束一条助手回答。
func (a *App) OnAssistantEnd(usage llm.Usage, err error) {
a.flushPending()
a.replyMu.Lock()
text := a.lastReply.String()
a.replyMu.Unlock()
fyne.Do(func() {
a.chat.endAssistant(err)
if err == nil && a.card.visibleNow() {
a.card.update(text)
}
})
}
// OnState 同步引擎状态到界面。
func (a *App) OnState(st agent.State) {
fyne.Do(func() { a.applyState(st) })
}
func (a *App) applyState(st agent.State) {
if st.Watching {
a.watchBtn.SetText("暂停监听")
a.watchBtn.SetIcon(theme.MediaPauseIcon())
} else {
a.watchBtn.SetText("开始监听")
a.watchBtn.SetIcon(theme.MediaPlayIcon())
}
if st.Region.Valid() {
a.regionLbl.SetText(fmt.Sprintf("区域 %d × %d", st.Region.W, st.Region.H))
} else {
a.regionLbl.SetText("未框选区域")
}
a.card.setRegion(st.Region)
if st.Busy {
a.sendBtn.Disable()
} else {
a.sendBtn.Enable()
}
state := "就绪"
switch {
case st.Watching && st.Busy:
state = "监听中 · 正在回答"
case st.Watching:
state = "监听中"
case st.Busy:
state = "正在回答"
}
status := fmt.Sprintf("%s · 模型 %s · 自动触发 %d 次 · 累计 %d tokens",
state, st.Model, st.Triggers, st.TotalTokens)
if st.LastLatency > 0 {
status += fmt.Sprintf(" · 上次 %dms", st.LastLatency.Milliseconds())
}
a.statusLbl.SetText(status)
if st.Profile != "" && a.modelSel.Selected != st.Profile {
a.quietModelSel = true
a.modelSel.SetSelected(st.Profile)
a.quietModelSel = false
}
}
+127
View File
@@ -0,0 +1,127 @@
package ui
import (
"strings"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
"viewsagent/internal/capture"
"viewsagent/internal/win32"
)
// cardTitle 是答案浮窗的标题,win32 靠它找 HWND。
const cardTitle = "viewsagent-card"
// floatCard 是贴在选区旁边的置顶小窗,只显示最新答案。
type floatCard struct {
win fyne.Window
body *widget.RichText
region capture.Region
visible bool
}
func newFloatCard(a fyne.App) *floatCard {
c := &floatCard{win: a.NewWindow(cardTitle)}
rt := widget.NewRichText()
rt.Wrapping = fyne.TextWrapWord
c.body = rt
c.win.SetContent(container.NewBorder(
nil, nil, nil, nil,
container.NewPadded(rt),
))
c.win.Resize(fyne.NewSize(400, 240))
return c
}
// setRegion 记录当前监视区域,用于定位浮窗。
func (c *floatCard) setRegion(r capture.Region) {
c.region = r
}
// update 刷新浮窗内容。
func (c *floatCard) update(text string) {
text = strings.TrimSpace(text)
if text == "" {
text = "(空)"
}
c.body.ParseMarkdown(text)
if c.visible {
c.place()
}
}
// show 显示浮窗。
func (c *floatCard) show() {
if c.visible {
c.place()
c.win.RequestFocus()
return
}
c.visible = true
c.win.Show()
go func() {
time.Sleep(120 * time.Millisecond)
fyne.Do(c.place)
}()
}
// hide 隐藏浮窗。
func (c *floatCard) hide() {
c.visible = false
c.win.Hide()
}
// toggle 切换显示状态。
func (c *floatCard) toggle() {
if c.visible {
c.hide()
return
}
c.show()
}
// visibleNow 返回是否正在显示。
func (c *floatCard) visibleNow() bool { return c.visible }
// place 把浮窗贴到选区下方(放不下就贴上方),并置顶。
func (c *floatCard) place() {
hwnd, err := win32.FindWindowByTitle(cardTitle)
if err != nil {
return
}
_ = win32.SetTopMost(hwnd)
rect, err := win32.WindowRect(hwnd)
if err != nil || rect.Width() <= 0 || rect.Height() <= 0 {
return
}
if !c.region.Valid() {
return
}
mon, _ := win32.MonitorAt(c.region.X+c.region.W/2, c.region.Y+c.region.H/2)
work := mon.Work
if work.Width() <= 0 {
work = mon.Rect
}
gap := int(8 * max(mon.Scale, 1))
x := c.region.X
y := c.region.Y + c.region.H + gap
if y+rect.Height() > work.Bottom {
y = c.region.Y - rect.Height() - gap
}
if y < work.Top {
y = work.Bottom - rect.Height()
}
if x+rect.Width() > work.Right {
x = work.Right - rect.Width()
}
if x < work.Left {
x = work.Left
}
_ = win32.MoveTo(hwnd, win32.Rect{Left: x, Top: y, Right: x + rect.Width(), Bottom: y + rect.Height()})
}
+187
View File
@@ -0,0 +1,187 @@
package ui
import (
"image/color"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
// bubbleKind 区分气泡样式。
type bubbleKind int
const (
bubbleUser bubbleKind = iota
bubbleBot
bubbleError
)
func colorFor(k bubbleKind) color.Color {
switch k {
case bubbleUser:
return colorUserBubble()
case bubbleError:
return colorErrBubble()
default:
return colorBotBubble()
}
}
// bubble 是一条消息气泡:圆角背景 + 角色标题 + 正文。
// 流式阶段用 Label 追加文本,结束后换成 RichText 以获得 Markdown 排版。
type bubble struct {
root *fyne.Container
bg *canvas.Rectangle
body *fyne.Container
head *widget.Label
live *widget.Label
text strings.Builder
}
func newBubble(role string, kind bubbleKind) *bubble {
bg := canvas.NewRectangle(colorFor(kind))
bg.CornerRadius = 10
bg.StrokeColor = colorBorder()
bg.StrokeWidth = 1
head := widget.NewLabelWithStyle(role, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
head.Importance = widget.LowImportance
live := widget.NewLabel("…")
live.Wrapping = fyne.TextWrapWord
body := container.NewVBox(head, live)
root := container.NewStack(bg, container.New(innerPadLayout{}, body))
return &bubble{root: root, bg: bg, body: body, head: head, live: live}
}
// append 追加流式文本。
func (b *bubble) append(delta string) {
b.text.WriteString(delta)
b.live.SetText(b.text.String())
}
// finish 结束流式状态;err 非空时展示错误。
func (b *bubble) finish(err error) {
if err != nil {
b.live.SetText("⚠ " + err.Error())
b.bg.FillColor = colorErrBubble()
b.bg.Refresh()
return
}
text := strings.TrimSpace(b.text.String())
if text == "" {
text = "(没有返回内容)"
}
rt := widget.NewRichTextFromMarkdown(text)
rt.Wrapping = fyne.TextWrapWord
b.body.Objects = []fyne.CanvasObject{b.head, rt}
b.body.Refresh()
}
// innerPadLayout 给气泡留内边距。
type innerPadLayout struct{}
func (innerPadLayout) MinSize(objs []fyne.CanvasObject) fyne.Size {
// 宽度不参与最小尺寸计算:气泡宽度由滚动区的视口决定,
// 否则长文本会把聊天区撑宽,导致换行宽度算大、右侧文字被截断。
h := float32(0)
for _, o := range objs {
h = fyne.Max(h, o.MinSize().Height)
}
pad := theme.Padding() * 2
return fyne.NewSize(0, h+pad)
}
func (innerPadLayout) Layout(objs []fyne.CanvasObject, size fyne.Size) {
pad := theme.Padding() * 2
w := fyne.Max(size.Width-pad*2, 1)
h := fyne.Max(size.Height-pad, 1)
for _, o := range objs {
o.Move(fyne.NewPos(pad, pad/2))
o.Resize(fyne.NewSize(w, h))
}
}
// chatView 是消息列表:可滚动,支持流式追加与自动滚底。
type chatView struct {
list *fyne.Container
scroll *container.Scroll
current *bubble
}
func newChatView() *chatView {
list := container.NewVBox()
scroll := container.NewScroll(list)
scroll.Direction = container.ScrollVerticalOnly
scroll.SetMinSize(fyne.NewSize(380, 300))
return &chatView{list: list, scroll: scroll}
}
// object 返回可嵌入界面的对象。
func (c *chatView) object() fyne.CanvasObject { return c.scroll }
func (c *chatView) appendBubble(b *bubble) {
c.list.Add(b.root)
c.scroll.ScrollToBottom()
}
// addUser 添加一条用户消息。
func (c *chatView) addUser(text string, withImage bool) {
if withImage {
text += "\n(已附带区域截图)"
}
b := newBubble("你", bubbleUser)
b.append(text)
b.finish(nil)
c.appendBubble(b)
}
// beginAssistant 开始一条助手消息。
func (c *chatView) beginAssistant(auto bool) {
role := "助手"
if auto {
role = "助手 · 自动识别"
}
c.current = newBubble(role, bubbleBot)
c.appendBubble(c.current)
}
// appendDelta 追加流式文本。
func (c *chatView) appendDelta(delta string) {
if c.current == nil {
c.beginAssistant(false)
}
c.current.append(delta)
c.scroll.ScrollToBottom()
}
// endAssistant 结束当前助手消息。
func (c *chatView) endAssistant(err error) {
if c.current == nil {
return
}
c.current.finish(err)
c.current = nil
c.scroll.ScrollToBottom()
}
// addNotice 插入一条系统提示。
func (c *chatView) addNotice(text string) {
b := newBubble("系统", bubbleError)
b.append(text)
b.finish(nil)
c.appendBubble(b)
}
// clear 清空消息列表。
func (c *chatView) clear() {
c.list.Objects = nil
c.current = nil
c.list.Refresh()
}
+17
View File
@@ -0,0 +1,17 @@
package ui
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/lang"
)
// zhHansExtra 补上 Fyne 内置中文包里缺失的条目。
// widget.NewSelect 在构造时就会取默认占位符,缺翻译会在日志里刷 Translation failure。
const zhHansExtra = `{
"(Select one)": {"other": "请选择"}
}`
// installTranslations 在创建窗口前调用,注册补充翻译。
func installTranslations() {
_ = lang.AddTranslationsForLocale([]byte(zhHansExtra), fyne.Locale("zh-Hans"))
}
+299
View File
@@ -0,0 +1,299 @@
package ui
import (
"fmt"
"image"
"image/color"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"viewsagent/internal/capture"
"viewsagent/internal/win32"
)
// overlayTitle 是框选窗口标题,win32 需要靠它找到 HWND。
const overlayTitle = "viewsagent-overlay"
// overlay 是一个铺满当前显示器的冻结帧框选层。
//
// 之所以不依赖窗口透明:Windows 下的 OpenGL 窗口透明并不可靠,
// 直接用"全屏截图做背景 + 遮罩"视觉等效,还能保证选区与截图完全对齐。
type overlay struct {
win fyne.Window
vd win32.Rect
mapping capture.Mapping
size fyne.Size
calibrated bool
bg *canvas.Image
dims [4]*canvas.Rectangle
edges [4]*canvas.Rectangle
hint *canvas.Text
catcher *catcher
start, cur fyne.Position
dragging bool
closed bool
onDone func(capture.Region)
onCancel func()
}
// newOverlay 创建框选层。shot 应该是调用前抓好的整屏画面。
func newOverlay(app fyne.App, shot image.Image, vd win32.Rect,
onDone func(capture.Region), onCancel func()) *overlay {
o := &overlay{vd: vd, onDone: onDone, onCancel: onCancel}
o.win = app.NewWindow(overlayTitle)
o.win.SetPadded(false)
o.win.SetFullScreen(true)
o.bg = canvas.NewImageFromImage(shot)
o.bg.FillMode = canvas.ImageFillStretch
objs := []fyne.CanvasObject{o.bg}
for i := range o.dims {
o.dims[i] = canvas.NewRectangle(color.NRGBA{R: 0, G: 0, B: 0, A: 130})
objs = append(objs, o.dims[i])
}
for i := range o.edges {
o.edges[i] = canvas.NewRectangle(color.NRGBA{R: 0x5A, G: 0xC8, B: 0xFF, A: 0xFF})
objs = append(objs, o.edges[i])
}
o.hint = canvas.NewText("拖拽框选区域 · Esc 取消", color.White)
o.hint.TextStyle = fyne.TextStyle{Bold: true}
o.hint.TextSize = 15
o.catcher = newCatcher(o)
objs = append(objs, o.catcher, o.hint)
o.win.SetContent(container.NewWithoutLayout(objs...))
o.win.Canvas().SetOnTypedKey(func(k *fyne.KeyEvent) {
if k.Name == fyne.KeyEscape {
o.cancel()
}
})
return o
}
// show 显示框选层并做一次坐标校准。
func (o *overlay) show() {
o.win.Show()
// 窗口尺寸在 Show 之后才确定,稍等一拍再校准。
go func() {
time.Sleep(90 * time.Millisecond)
fyne.Do(o.calibrate)
}()
}
// calibrate 反推"逻辑坐标 → 物理像素"的比例与原点。
// 宁可依赖实际窗口尺寸,也不假设系统缩放,这样 125%/150% 屏都不会偏。
func (o *overlay) calibrate() {
size := o.win.Canvas().Size()
if size.Width < 10 || size.Height < 10 {
return
}
o.size = size
rect, err := windowRectByTitle(overlayTitle)
if err != nil {
mon, _ := win32.MonitorAtCursor()
rect = mon.Rect
}
if rect.Width() <= 0 || rect.Height() <= 0 {
return
}
o.mapping = capture.Mapping{
OffsetX: float64(rect.Left),
OffsetY: float64(rect.Top),
ScaleX: float64(rect.Width()) / float64(size.Width),
ScaleY: float64(rect.Height()) / float64(size.Height),
}
if o.mapping.ScaleX <= 0 || o.mapping.ScaleY <= 0 {
o.mapping.ScaleX, o.mapping.ScaleY = 1, 1
}
// 把整张虚拟桌面按当前窗口的实际映射铺到画布上。
bx, by, bw, bh := o.mapping.ToLogical(capture.Region{
X: o.vd.Left, Y: o.vd.Top, W: o.vd.Width(), H: o.vd.Height(),
})
o.bg.Move(fyne.NewPos(float32(bx), float32(by)))
o.bg.Resize(fyne.NewSize(float32(bw), float32(bh)))
o.catcher.Move(fyne.NewPos(0, 0))
o.catcher.Resize(size)
o.calibrated = true
o.updateSelection()
}
func windowRectByTitle(title string) (win32.Rect, error) {
hwnd, err := win32.FindWindowByTitle(title)
if err != nil {
return win32.Rect{}, err
}
return win32.WindowRect(hwnd)
}
// selection 返回当前选区(逻辑坐标,已归一化)。
func (o *overlay) selection() (x0, y0, x1, y1 float32) {
x0, y0 = o.start.X, o.start.Y
x1, y1 = o.cur.X, o.cur.Y
if x1 < x0 {
x0, x1 = x1, x0
}
if y1 < y0 {
y0, y1 = y1, y0
}
// 限制在窗口内
x0 = fyne.Max(x0, 0)
y0 = fyne.Max(y0, 0)
x1 = fyne.Min(x1, o.size.Width)
y1 = fyne.Min(y1, o.size.Height)
return
}
// updateSelection 重排遮罩、边框与尺寸提示。
func (o *overlay) updateSelection() {
if o.size.Width <= 0 {
return
}
w, h := o.size.Width, o.size.Height
x0, y0, x1, y1 := o.selection()
selW, selH := x1-x0, y1-y0
if selW < 2 || selH < 2 {
// 尚未框选:整屏压暗
o.dims[0].Move(fyne.NewPos(0, 0))
o.dims[0].Resize(fyne.NewSize(w, h))
for i := 1; i < 4; i++ {
o.dims[i].Resize(fyne.NewSize(0, 0))
}
for _, e := range o.edges {
e.Resize(fyne.NewSize(0, 0))
}
o.hint.Text = "拖拽框选区域 · Esc 取消"
o.hint.Move(fyne.NewPos(theme.Padding()*2, theme.Padding()*2))
o.hint.Refresh()
return
}
// 上下左右四块压暗区域,挖出选区
o.dims[0].Move(fyne.NewPos(0, 0))
o.dims[0].Resize(fyne.NewSize(w, y0))
o.dims[1].Move(fyne.NewPos(0, y1))
o.dims[1].Resize(fyne.NewSize(w, h-y1))
o.dims[2].Move(fyne.NewPos(0, y0))
o.dims[2].Resize(fyne.NewSize(x0, selH))
o.dims[3].Move(fyne.NewPos(x1, y0))
o.dims[3].Resize(fyne.NewSize(w-x1, selH))
// 选区描边
const t = 2
o.edges[0].Move(fyne.NewPos(x0, y0))
o.edges[0].Resize(fyne.NewSize(selW, t))
o.edges[1].Move(fyne.NewPos(x0, y1-t))
o.edges[1].Resize(fyne.NewSize(selW, t))
o.edges[2].Move(fyne.NewPos(x0, y0))
o.edges[2].Resize(fyne.NewSize(t, selH))
o.edges[3].Move(fyne.NewPos(x1-t, y0))
o.edges[3].Resize(fyne.NewSize(t, selH))
phys := o.mapping.ToPhysical(float64(x0), float64(y0), float64(selW), float64(selH))
o.hint.Text = fmt.Sprintf("%d × %d", phys.W, phys.H)
hy := y0 - 26
if hy < 4 {
hy = y0 + 6
}
o.hint.Move(fyne.NewPos(x0+6, hy))
o.hint.Refresh()
}
// finish 确认选区。
func (o *overlay) finish() {
if !o.calibrated {
o.calibrate()
}
x0, y0, x1, y1 := o.selection()
if x1-x0 < 6 || y1-y0 < 6 {
o.cancel()
return
}
r := o.mapping.ToPhysical(float64(x0), float64(y0), float64(x1-x0), float64(y1-y0))
if r.W < 8 || r.H < 8 {
o.cancel()
return
}
done := o.onDone
o.close()
if done != nil {
done(r)
}
}
// cancel 取消框选。
func (o *overlay) cancel() {
cancel := o.onCancel
o.close()
if cancel != nil {
cancel()
}
}
func (o *overlay) close() {
if o.closed {
return
}
o.closed = true
o.win.Close()
}
// catcher 是铺满窗口的事件接收层,负责鼠标拖拽。
type catcher struct {
widget.BaseWidget
o *overlay
}
func newCatcher(o *overlay) *catcher {
c := &catcher{o: o}
c.ExtendBaseWidget(c)
return c
}
func (c *catcher) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(canvas.NewRectangle(color.Transparent))
}
func (c *catcher) Dragged(e *fyne.DragEvent) {
if !c.o.dragging {
return
}
c.o.cur = e.Position
c.o.updateSelection()
}
func (c *catcher) DragEnd() {}
func (c *catcher) MouseDown(e *desktop.MouseEvent) {
c.o.dragging = true
c.o.start = e.Position
c.o.cur = e.Position
c.o.updateSelection()
}
func (c *catcher) MouseUp(e *desktop.MouseEvent) {
if !c.o.dragging {
return
}
c.o.dragging = false
c.o.cur = e.Position
c.o.finish()
}
+376
View File
@@ -0,0 +1,376 @@
package ui
import (
"context"
"fmt"
"os/exec"
"strconv"
"strings"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
"viewsagent/internal/config"
)
const settingsTitle = "viewsagent-settings"
// settingsWindow 是模型、触发策略、提示词与热键的设置面板。
type settingsWindow struct {
app *App
win fyne.Window
cfg *config.Config
// 模型接入点
profileSel *widget.Select
pName *widget.Entry
pBase *widget.Entry
pModel *widget.Entry
pKey *widget.Entry
pMax *widget.Entry
pTemp *widget.Entry
// 提示词
tmplSel *widget.Select
tmplSystem *widget.Entry
// 抓屏与触发
fps *widget.Entry
maxEdge *widget.Entry
quality *widget.Entry
diff *widget.Entry
cooldown *widget.Entry
stable *widget.Entry
turns *widget.Entry
// 热键与其它
hkSelect *widget.Entry
hkToggle *widget.Entry
alwaysTop *widget.Check
}
func newSettingsWindow(app *App) *settingsWindow {
s := &settingsWindow{app: app}
s.win = app.fyne.NewWindow(settingsTitle)
s.win.Resize(fyne.NewSize(620, 620))
s.win.SetContent(container.NewAppTabs(
container.NewTabItem("模型接入", s.buildProfileTab()),
container.NewTabItem("提示词", s.buildTemplateTab()),
container.NewTabItem("抓屏与触发", s.buildCaptureTab()),
container.NewTabItem("其它", s.buildMiscTab()),
))
return s
}
func (s *settingsWindow) show() {
s.reload()
s.win.Show()
}
// reload 从引擎当前配置重新载入界面。
func (s *settingsWindow) reload() {
// 用副本编辑,避免与引擎并发读写同一份配置。
s.cfg = s.app.eng.Config().Clone()
names := make([]string, 0, len(s.cfg.Profiles))
for _, p := range s.cfg.Profiles {
names = append(names, p.Name)
}
s.profileSel.Options = names
s.profileSel.SetSelected(s.cfg.ActiveProfile)
s.applyProfile()
keys := make([]string, 0, len(s.cfg.Templates))
for _, t := range s.cfg.Templates {
keys = append(keys, t.Key)
}
s.tmplSel.Options = keys
s.tmplSel.SetSelected(s.cfg.PromptTemplate)
s.applyTemplate()
c := s.cfg.Capture
s.fps.SetText(fmt.Sprintf("%g", c.FPS))
s.maxEdge.SetText(strconv.Itoa(c.MaxEdge))
s.quality.SetText(strconv.Itoa(c.JPEGQuality))
s.diff.SetText(strconv.Itoa(c.DiffThreshold))
s.cooldown.SetText(strconv.Itoa(c.CooldownMs))
s.stable.SetText(strconv.Itoa(c.StableFrames))
s.turns.SetText(strconv.Itoa(c.HistoryTurns))
s.hkSelect.SetText(s.cfg.Hotkeys.SelectRegion)
s.hkToggle.SetText(s.cfg.Hotkeys.ToggleWatch)
s.alwaysTop.SetChecked(s.cfg.AlwaysOnTopCard)
}
func entry(label string, obj fyne.CanvasObject) fyne.CanvasObject {
return container.NewBorder(nil, nil, widget.NewLabel(label), nil, obj)
}
func (s *settingsWindow) buildProfileTab() fyne.CanvasObject {
s.profileSel = widget.NewSelect(nil, func(string) { s.applyProfile() })
s.profileSel.PlaceHolder = "请选择接入点"
s.pName = widget.NewEntry()
s.pBase = widget.NewEntry()
s.pBase.SetPlaceHolder("https://dashscope.aliyuncs.com/compatible-mode/v1")
s.pModel = widget.NewEntry()
s.pModel.SetPlaceHolder("qwen3-vl-plus")
s.pKey = widget.NewPasswordEntry()
s.pMax = widget.NewEntry()
s.pTemp = widget.NewEntry()
form := container.NewVBox(
entry("接入点", s.profileSel),
entry("名称", s.pName),
entry("BaseURL", s.pBase),
entry("模型", s.pModel),
entry("API Key", s.pKey),
entry("max_tokens", s.pMax),
entry("temperature", s.pTemp),
)
addBtn := widget.NewButton("新增接入点", func() {
name := strings.TrimSpace(s.pName.Text)
if name == "" {
name = fmt.Sprintf("custom-%d", len(s.cfg.Profiles)+1)
}
if _, exists := s.cfg.Profile(name); exists {
dialog.ShowInformation("重名", "已有同名接入点,请换一个名字", s.win)
return
}
s.cfg.Profiles = append(s.cfg.Profiles, config.Profile{
Name: name, BaseURL: s.pBase.Text, Model: s.pModel.Text,
APIKey: s.pKey.Text, MaxTokens: 1024, Temperature: 0.3,
})
s.cfg.ActiveProfile = name
s.reload()
})
testBtn := widget.NewButton("测试连接", func() {
p := s.readProfile()
btn := widget.NewLabel("正在连接 " + p.BaseURL + " …")
s.win.Canvas().Content().Refresh()
d := dialog.NewCustomWithoutButtons("测试连接", btn, s.win)
d.Show()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
err := s.app.eng.PingProfile(ctx, p)
fyne.Do(func() {
d.Hide()
if err != nil {
dialog.ShowError(fmt.Errorf("连接失败:%w", err), s.win)
return
}
dialog.ShowInformation("连接成功", "模型 "+p.Model+" 可用", s.win)
})
}()
})
saveBtn := widget.NewButton("保存并应用", func() {
s.commitAll()
if err := s.app.applyAndSave(s.cfg); err != nil {
dialog.ShowError(err, s.win)
return
}
dialog.ShowInformation("已保存", "配置已写入并立即生效", s.win)
})
return container.NewBorder(nil,
container.NewVBox(widget.NewSeparator(), container.NewHBox(addBtn, testBtn, saveBtn)),
nil, nil,
container.NewVScroll(form),
)
}
func (s *settingsWindow) buildTemplateTab() fyne.CanvasObject {
s.tmplSel = widget.NewSelect(nil, func(string) { s.applyTemplate() })
s.tmplSel.PlaceHolder = "请选择模板"
s.tmplSystem = widget.NewMultiLineEntry()
s.tmplSystem.SetMinRowsVisible(14)
s.tmplSystem.Wrapping = fyne.TextWrapWord
saveBtn := widget.NewButton("保存提示词", func() {
key := s.tmplSel.Selected
for i := range s.cfg.Templates {
if s.cfg.Templates[i].Key == key {
s.cfg.Templates[i].System = s.tmplSystem.Text
}
}
if err := s.app.applyAndSave(s.cfg); err != nil {
dialog.ShowError(err, s.win)
return
}
dialog.ShowInformation("已保存", "提示词已更新", s.win)
})
return container.NewBorder(
container.NewVBox(entry("模板", s.tmplSel), widget.NewSeparator()),
container.NewVBox(widget.NewSeparator(), container.NewHBox(saveBtn)),
nil, nil,
s.tmplSystem,
)
}
func (s *settingsWindow) buildCaptureTab() fyne.CanvasObject {
s.fps = widget.NewEntry()
s.maxEdge = widget.NewEntry()
s.quality = widget.NewEntry()
s.diff = widget.NewEntry()
s.cooldown = widget.NewEntry()
s.stable = widget.NewEntry()
s.turns = widget.NewEntry()
form := container.NewVBox(
entry("监听帧率 (fps)", s.fps),
entry("图片长边上限", s.maxEdge),
entry("JPEG 质量", s.quality),
entry("变化阈值", s.diff),
entry("冷却时间 (ms)", s.cooldown),
entry("稳定帧数", s.stable),
entry("历史轮数", s.turns),
widget.NewLabel("说明:变化阈值越小越灵敏(默认 5);冷却时间决定两次自动提问的最小间隔。"),
)
saveBtn := widget.NewButton("保存并应用", func() {
s.commitAll()
if err := s.app.applyAndSave(s.cfg); err != nil {
dialog.ShowError(err, s.win)
return
}
dialog.ShowInformation("已保存", "抓屏与触发参数已更新", s.win)
})
return container.NewBorder(nil,
container.NewVBox(widget.NewSeparator(), container.NewHBox(saveBtn)),
nil, nil,
container.NewVScroll(form),
)
}
func (s *settingsWindow) buildMiscTab() fyne.CanvasObject {
s.hkSelect = widget.NewEntry()
s.hkToggle = widget.NewEntry()
s.alwaysTop = widget.NewCheck("显示置顶答案浮窗", nil)
openDir := widget.NewButton("打开配置目录", func() {
dir := s.app.eng.Store().Dir()
if err := exec.Command("explorer", dir).Start(); err != nil {
dialog.ShowError(err, s.win)
}
})
saveBtn := widget.NewButton("保存并应用", func() {
s.commitAll()
if err := s.app.applyAndSave(s.cfg); err != nil {
dialog.ShowError(err, s.win)
return
}
dialog.ShowInformation("已保存", "热键修改需要重启程序生效", s.win)
})
form := container.NewVBox(
entry("框选热键", s.hkSelect),
entry("监听开关热键", s.hkToggle),
s.alwaysTop,
container.NewHBox(openDir),
widget.NewLabel("热键语法示例:Ctrl+Alt+Q、Ctrl+Shift+F2"),
)
return container.NewBorder(nil,
container.NewVBox(widget.NewSeparator(), container.NewHBox(saveBtn)),
nil, nil,
form,
)
}
// applyProfile 把选中的接入点填进输入框。
func (s *settingsWindow) applyProfile() {
if s.cfg == nil {
return
}
p, ok := s.cfg.Profile(s.profileSel.Selected)
if !ok {
return
}
s.pName.SetText(p.Name)
s.pBase.SetText(p.BaseURL)
s.pModel.SetText(p.Model)
s.pKey.SetText(p.APIKey)
s.pMax.SetText(strconv.Itoa(p.MaxTokens))
s.pTemp.SetText(fmt.Sprintf("%g", p.Temperature))
}
// applyTemplate 把选中的模板填进编辑框。
func (s *settingsWindow) applyTemplate() {
if s.cfg == nil {
return
}
for _, t := range s.cfg.Templates {
if t.Key == s.tmplSel.Selected {
s.tmplSystem.SetText(t.System)
return
}
}
}
// readProfile 用界面上的值构造一个接入点(不落盘)。
func (s *settingsWindow) readProfile() config.Profile {
return config.Profile{
Name: strings.TrimSpace(s.pName.Text),
BaseURL: strings.TrimSpace(s.pBase.Text),
Model: strings.TrimSpace(s.pModel.Text),
APIKey: strings.TrimSpace(s.pKey.Text),
MaxTokens: atoiDefault(s.pMax.Text, 1024),
Temperature: atofDefault(s.pTemp.Text, 0.3),
}
}
// commitAll 把界面上的所有值写回配置对象。
func (s *settingsWindow) commitAll() {
p := s.readProfile()
for i := range s.cfg.Profiles {
if s.cfg.Profiles[i].Name == s.profileSel.Selected {
p.Name = s.cfg.Profiles[i].Name
s.cfg.Profiles[i] = p
}
}
if s.profileSel.Selected != "" {
s.cfg.ActiveProfile = s.profileSel.Selected
}
if key := s.tmplSel.Selected; key != "" {
s.cfg.PromptTemplate = key
}
s.cfg.Capture.FPS = atofDefault(s.fps.Text, 2)
s.cfg.Capture.MaxEdge = atoiDefault(s.maxEdge.Text, 1280)
s.cfg.Capture.JPEGQuality = atoiDefault(s.quality.Text, 75)
s.cfg.Capture.DiffThreshold = atoiDefault(s.diff.Text, 5)
s.cfg.Capture.CooldownMs = atoiDefault(s.cooldown.Text, 3000)
s.cfg.Capture.StableFrames = atoiDefault(s.stable.Text, 2)
s.cfg.Capture.HistoryTurns = atoiDefault(s.turns.Text, 6)
s.cfg.Hotkeys.SelectRegion = strings.TrimSpace(s.hkSelect.Text)
s.cfg.Hotkeys.ToggleWatch = strings.TrimSpace(s.hkToggle.Text)
s.cfg.AlwaysOnTopCard = s.alwaysTop.Checked
s.cfg.Normalize()
}
func atoiDefault(s string, def int) int {
v, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return def
}
return v
}
func atofDefault(s string, def float64) float64 {
v, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil {
return def
}
return v
}
+41
View File
@@ -0,0 +1,41 @@
package ui
import (
"image/color"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/theme"
)
// lookupColor 从当前主题取色。
func lookupColor(name fyne.ThemeColorName) color.Color {
if app := fyne.CurrentApp(); app != nil {
s := app.Settings()
return s.Theme().Color(name, s.ThemeVariant())
}
return theme.DefaultTheme().Color(name, theme.VariantDark)
}
// withAlpha 调整颜色透明度。
func withAlpha(c color.Color, a uint8) color.Color {
n := color.NRGBAModel.Convert(c).(color.NRGBA)
n.A = a
return n
}
// 界面配色集中在这里,方便统一调整。
func colorUserBubble() color.Color {
return withAlpha(lookupColor(theme.ColorNamePrimary), 0x3A)
}
func colorBotBubble() color.Color {
return withAlpha(lookupColor(theme.ColorNameInputBackground), 0xF2)
}
func colorErrBubble() color.Color {
return withAlpha(lookupColor(theme.ColorNameError), 0x28)
}
func colorBorder() color.Color {
return withAlpha(lookupColor(theme.ColorNameInputBorder), 0xB0)
}