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