Files
deskview/internal/ui/app.go
T
2026-10-28 08:47:09 +08:00

587 lines
14 KiB
Go
Raw 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/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
regionFrm *regionFrame
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
}
// Run 启动整个桌面应用。
func Run() 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.build()
eng.SetSink(a)
a.startHotkeys()
a.maybeAutoShot()
defer func() {
if a.hotkeyStop != nil {
a.hotkeyStop()
}
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()
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
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())
// 开发期自检:把取景框内部状态写到文件,方便排查。
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()
}
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.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.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))
} else {
a.regionLbl.SetText("未框选区域")
}
a.card.setRegion(st.Region)
// 屏幕上的红框跟着选区走,始终提示当前盯的是哪一块。
a.regionFrm.apply(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 = "正在回答"
}
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
}
}