first commit
This commit is contained in:
commit
3553790475
27 files changed
+3839
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user