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 } }