Files
2026-10-28 17:39:33 +08:00

673 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ui
import (
"context"
"fmt"
"image"
"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/logging"
"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
frameBtn *widget.Button
settings *settingsWindow
card *floatCard
overlay *overlay
regionFrm *regionFrame
logs *logWindow
hotkeyStop func()
// 流式文本合批,避免每个 delta 都触发一次布局
pendMu sync.Mutex
pendingText strings.Builder
pendingThink strings.Builder
pendScheduled bool
// 最近一条完整回答,用于同步到浮窗
replyMu sync.Mutex
lastReply strings.Builder
lastCardSync time.Time
quietModelSel bool
// frameBtnShown 记录取景框按钮当前的图标状态,避免重复刷新。
frameBtnShown bool
}
// Run 启动整个桌面应用。logs 是程序自己的日志缓冲,
// 界面上的「日志」按钮直接读它展示。
func Run(logs *logging.Buffer) error {
store, err := config.NewStore()
if err != nil {
return err
}
cfg := store.Load()
fa := fyneapp.NewWithID("com.viewsagent.desktop")
installTranslations()
// 主题要在建界面之前定下来,否则首帧会用默认配色渲染。
applyThemeMode(cfg.Theme)
eng := agent.New(cfg, store)
a := &App{fyne: fa, eng: eng}
a.logs = newLogWindow(fa, logs, store.Dir())
a.build()
// 取景框上的「确定 / 取消」直接改监视区域。
a.regionFrm.onApply = a.onFrameApply
a.regionFrm.onCancel = a.onFrameCancel
eng.SetSink(a)
a.startHotkeys()
a.maybeAutoShot()
defer func() {
if a.hotkeyStop != nil {
a.hotkeyStop()
}
a.logs.close()
// 取景框是原生窗口,不随 Fyne 窗口一起消失,
// 必须在这里显式销毁,否则屏幕上会留下一个关不掉的红色线框。
a.regionFrm.close()
eng.Close()
}()
a.win.ShowAndRun()
return nil
}
// maybeAutoShot 在设置了 VIEWSAGENT_SHOT / VIEWSAGENT_SHOT_SETTINGS / VIEWSAGENT_ASK 时,
// 等界面稳定后把画布导出为 PNG 再退出。
// VIEWSAGENT_SHOT_TAB 可指定设置窗口要停在哪个标签(如「其它」)。
// 只用于开发期核对布局,正常运行不会触发。
func (a *App) maybeAutoShot() {
path := os.Getenv("VIEWSAGENT_SHOT")
settingsPath := os.Getenv("VIEWSAGENT_SHOT_SETTINGS")
ask := os.Getenv("VIEWSAGENT_ASK")
if path == "" && settingsPath == "" && ask == "" {
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)
if settingsPath != "" {
fyne.Do(func() { a.settings.show() })
time.Sleep(delay)
if tab := os.Getenv("VIEWSAGENT_SHOT_TAB"); tab != "" {
fyne.Do(func() { a.settings.selectTabByName(tab) })
time.Sleep(delay)
}
}
if ask != "" {
fyne.Do(func() {
a.entry.SetText(ask)
a.send()
})
time.Sleep(delay)
}
fyne.Do(func() {
if path != "" {
writePNG(path, a.win.Canvas().Capture())
}
if settingsPath != "" {
writePNG(settingsPath, a.settings.win.Canvas().Capture())
}
a.fyne.Quit()
})
}()
}
// writePNG 把画布导出成 PNG 文件。
func writePNG(path string, img image.Image) {
if img == nil {
return
}
f, err := os.Create(path)
if err != nil {
return
}
defer f.Close()
_ = png.Encode(f, img)
}
func (a *App) build() {
a.win = a.fyne.NewWindow(mainTitle)
a.win.Resize(fyne.NewSize(520, 780))
a.win.CenterOnScreen()
// 主窗体是 master:点它的关闭按钮就直接退出整个程序。
// 否则 Fyne 只会在最后一个窗口关闭时才退出,卡片/设置/日志窗口
// 还活着,程序会变成后台僵尸,取景框的红色线框也留在屏幕上。
a.win.SetMaster()
a.chat = newChatView()
a.card = newFloatCard(a.fyne)
a.regionFrm = newRegionFrame()
a.settings = newSettingsWindow(a)
cfg := a.eng.Config()
a.modelSel = widget.NewSelect(cfg.ModelNames(), a.onModelChange)
a.modelSel.PlaceHolder = "请选择接入点"
a.quietModelSel = true
a.modelSel.SetSelected(cfg.Active)
a.quietModelSel = false
cardBtn := widget.NewButtonWithIcon("", theme.InfoIcon(), a.toggleCard)
cardBtn.Importance = widget.LowImportance
logBtn := widget.NewButtonWithIcon("", theme.DocumentIcon(), a.showLogs)
logBtn.Importance = widget.LowImportance
settingsBtn := widget.NewButtonWithIcon("", theme.SettingsIcon(), a.openSettings)
settingsBtn.Importance = widget.LowImportance
toolbar := container.NewHBox(
widget.NewLabel("接入点"), a.modelSel,
layout.NewSpacer(), cardBtn, logBtn, 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.frameBtn = widget.NewButtonWithIcon("取景框", theme.VisibilityIcon(), a.toggleFrame)
a.frameBtn.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.frameBtn, 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())
// 开发期自检:把取景框内部状态写到文件,方便排查。
if p := os.Getenv("VIEWSAGENT_FRAME_DEBUG"); p != "" {
go func() {
time.Sleep(800 * time.Millisecond)
_ = os.WriteFile(p, []byte(a.regionFrm.Debug()), 0o644)
}()
}
}
// ---------- 用户操作 ----------
func (a *App) send() {
text := strings.TrimSpace(a.entry.Text)
if text == "" {
return
}
a.entry.SetText("")
// 请求必须放到后台:网络请求期间 UI 线程不能被占住,
// 否则流式增量只能排队等全部返回后一次性刷新。
go func() {
if err := a.eng.Ask(text, a.attach.Checked); err != nil {
fyne.Do(func() { a.chat.addNotice(err.Error()) })
}
}()
}
func (a *App) toggleWatch() {
if a.eng.State().Watching {
a.eng.StopWatch()
return
}
if err := a.eng.EnsureReady(); err != nil {
a.chat.addNotice(err.Error())
dialog.ShowError(err, a.win)
return
}
// 监听前先探测一次模型连通性,通不过就不启动监听。
a.watchBtn.Disable()
a.statusLbl.SetText("正在检测模型连接 …")
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
err := a.eng.PingActive(ctx)
fyne.Do(func() {
a.watchBtn.Enable()
if err != nil {
a.statusLbl.SetText("就绪")
a.chat.addNotice("模型连接检测失败,未开始监听:" + err.Error())
return
}
if err := a.eng.StartWatch(); err != nil {
a.chat.addNotice(err.Error())
return
}
a.chat.addNotice("模型检测通过,已开始监听")
})
}()
}
func (a *App) toggleCard() {
a.card.setRegion(a.eng.Region())
a.card.toggle()
}
func (a *App) openSettings() {
a.settings.show()
}
// showLogs 打开运行日志窗口。
func (a *App) showLogs() {
if a.logs == nil {
return
}
a.logs.show()
}
// toggleFrame 显示或隐藏屏幕上的取景框。
func (a *App) toggleFrame() {
if a.regionFrm == nil {
return
}
if a.regionFrm.toggle() {
a.chat.addNotice("已显示取景框:拖动边框可调整范围,右上角有确定/取消")
} else {
a.chat.addNotice("已隐藏取景框")
}
a.updateFrameBtn()
}
// updateFrameBtn 同步取景框按钮的图标与可用状态。
func (a *App) updateFrameBtn() {
if a.frameBtn == nil {
return
}
visible := a.regionFrm != nil && a.regionFrm.visible()
if visible == a.frameBtnShown {
return
}
a.frameBtnShown = visible
if visible {
a.frameBtn.SetIcon(theme.VisibilityIcon())
} else {
a.frameBtn.SetIcon(theme.VisibilityOffIcon())
}
}
// onFrameApply 取景框上点了确定:把拖出来的范围应用成监视区域。
func (a *App) onFrameApply(r capture.Region) {
if err := a.eng.SetRegion(r); err != nil {
fyne.Do(func() { a.chat.addNotice(err.Error()) })
return
}
fyne.Do(func() { a.chat.addNotice(fmt.Sprintf("已应用监视区域 %s", r)) })
}
// onFrameCancel 取景框上点了取消:红框已经隐藏,这里只提示一句。
func (a *App) onFrameCancel() {
fyne.Do(func() {
a.chat.addNotice("已取消取景框调整,监视区域保持不变")
a.updateFrameBtn()
})
}
func (a *App) clearChat() {
a.eng.ResetHistory()
a.chat.clear()
a.chat.addNotice("已清空对话上下文")
}
// onModelChange 切换生效的接入点。
func (a *App) onModelChange(name string) {
if a.quietModelSel || name == "" {
return
}
cfg := a.eng.Config().Clone()
if !cfg.SetActive(name) {
return
}
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 = cfg.ModelNames()
a.modelSel.SetSelected(cfg.Active)
a.quietModelSel = false
// 主题变了就立刻换肤,不用重启。
applyThemeMode(cfg.Theme)
a.chat.retheme()
return nil
}
// ---------- 框选 ----------
func (a *App) startSelection() {
if a.overlay != nil {
return
}
// 框选过程中整屏都是选区浮层,先把常驻红框收起来,免得两层边框打架。
a.regionFrm.suspend()
// 先隐藏主窗,避免它出现在冻结帧里挡住要框选的内容
a.win.Hide()
time.Sleep(220 * time.Millisecond)
shot, err := (capture.Screen{}).Desktop()
if err != nil {
a.win.Show()
a.regionFrm.resume(a.eng.Region())
a.chat.addNotice("抓屏失败:" + err.Error())
return
}
vd := win32.VirtualScreen()
// 把上一次的选区带进去,打开就能直接移动或调整边界。
a.overlay = newOverlay(a.fyne, shot, vd, a.eng.Region(), 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.regionFrm.resume(a.eng.Region())
a.chat.addNotice(err.Error())
return
}
a.card.setRegion(r)
a.regionFrm.resume(r)
// 重新框选后把红框显示出来(之前点过取消的话,这里恢复)。
a.regionFrm.show()
a.chat.addNotice(fmt.Sprintf("已框选监视区域 %s", r))
}
func (a *App) onRegionCancel() {
a.overlay = nil
a.win.Show()
a.regionFrm.resume(a.eng.Region())
}
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 llm.Delta) {
if delta.Text != "" {
a.replyMu.Lock()
a.lastReply.WriteString(delta.Text)
a.replyMu.Unlock()
}
a.pendMu.Lock()
if delta.Text != "" {
a.pendingText.WriteString(delta.Text)
}
if delta.Reasoning != "" {
a.pendingThink.WriteString(delta.Reasoning)
}
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.pendingText.String()
think := a.pendingThink.String()
a.pendingText.Reset()
a.pendingThink.Reset()
a.pendScheduled = false
a.pendMu.Unlock()
if text == "" && think == "" {
return
}
fyne.Do(func() {
if think != "" {
a.chat.appendThinking(think)
}
if text != "" {
a.chat.appendDelta(text)
}
a.syncCard()
})
}
// syncCard 让置顶浮窗跟着流式内容一起刷新。
// 浮窗每次都要重排 Markdown,这里限流到 5 次/秒,结束时会再补一次完整刷新。
func (a *App) syncCard() {
if !a.card.visibleNow() {
return
}
now := time.Now()
if now.Sub(a.lastCardSync) < 200*time.Millisecond {
return
}
a.lastCardSync = now
a.replyMu.Lock()
text := a.lastReply.String()
a.replyMu.Unlock()
a.card.update(text)
}
// OnAssistantEnd 结束一条助手回答。
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))
if a.frameBtn != nil {
a.frameBtn.Enable()
}
} else {
a.regionLbl.SetText("未框选区域")
if a.frameBtn != nil {
a.frameBtn.Disable()
}
}
a.card.setRegion(st.Region)
// 屏幕上的红框跟着选区走,始终提示当前盯的是哪一块。
a.regionFrm.apply(st.Region)
a.updateFrameBtn()
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 = "正在回答"
}
label := st.Model
if st.Profile != "" {
if label != "" {
label = st.Profile + " · " + label
} else {
label = st.Profile
}
}
status := fmt.Sprintf("%s · 模型 %s · 自动触发 %d 次 · 累计 %d tokens",
state, label, 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
}
}