587 lines
14 KiB
Go
587 lines
14 KiB
Go
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
|
||
}
|
||
}
|