优化代码

This commit is contained in:
hero920103 committed 2026-10-28 08:47:09 +08:00
1 parent 3553790475
commit eda7e8bc96
23 files changed
+3113 -342

No files matched your search

+171 -43
View File
@@ -1,7 +1,9 @@
package ui
import (
"context"
"fmt"
"image"
"image/png"
"log/slog"
"os"
@@ -45,16 +47,19 @@ type App struct {
settings *settingsWindow
card *floatCard
overlay *overlay
regionFrm *regionFrame
hotkeyStop func()
// 流式文本合批,避免每个 delta 都触发一次布局
pendMu sync.Mutex
pending strings.Builder
pendingText strings.Builder
pendingThink strings.Builder
pendScheduled bool
// 最近一条完整回答,用于同步到浮窗
replyMu sync.Mutex
lastReply strings.Builder
replyMu sync.Mutex
lastReply strings.Builder
lastCardSync time.Time
quietModelSel bool
}
@@ -69,6 +74,8 @@ func Run() error {
fa := fyneapp.NewWithID("com.viewsagent.desktop")
installTranslations()
// 主题要在建界面之前定下来,否则首帧会用默认配色渲染。
applyThemeMode(cfg.Theme)
eng := agent.New(cfg, store)
a := &App{fyne: fa, eng: eng}
@@ -81,6 +88,7 @@ func Run() error {
if a.hotkeyStop != nil {
a.hotkeyStop()
}
a.regionFrm.close()
eng.Close()
}()
@@ -88,11 +96,15 @@ func Run() error {
return nil
}
// maybeAutoShot 在设置了 VIEWSAGENT_SHOT 时,等界面稳定后把画布导出为 PNG 再退出。
// maybeAutoShot 在设置了 VIEWSAGENT_SHOT / VIEWSAGENT_SHOT_SETTINGS / VIEWSAGENT_ASK 时,
// 等界面稳定后把画布导出为 PNG 再退出。
// VIEWSAGENT_SHOT_TAB 可指定设置窗口要停在哪个标签(如「其它」)。
// 只用于开发期核对布局,正常运行不会触发。
func (a *App) maybeAutoShot() {
path := os.Getenv("VIEWSAGENT_SHOT")
if path == "" {
settingsPath := os.Getenv("VIEWSAGENT_SHOT_SETTINGS")
ask := os.Getenv("VIEWSAGENT_ASK")
if path == "" && settingsPath == "" && ask == "" {
return
}
delay := 1500 * time.Millisecond
@@ -103,18 +115,46 @@ func (a *App) maybeAutoShot() {
}
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 img := a.win.Canvas().Capture(); img != nil {
if f, err := os.Create(path); err == nil {
_ = png.Encode(f, img)
_ = f.Close()
}
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))
@@ -122,13 +162,16 @@ func (a *App) build() {
a.chat = newChatView()
a.card = newFloatCard(a.fyne)
a.regionFrm = newRegionFrame()
a.settings = newSettingsWindow(a)
cfg := a.eng.Config()
a.modelSel = widget.NewSelect(profileNames(cfg), a.onModelChange)
a.modelSel = widget.NewSelect(cfg.ModelNames(), a.onModelChange)
a.modelSel.PlaceHolder = "请选择接入点"
a.modelSel.SetSelected(cfg.ActiveProfile)
a.quietModelSel = true
a.modelSel.SetSelected(cfg.Active)
a.quietModelSel = false
cardBtn := widget.NewButtonWithIcon("", theme.InfoIcon(), a.toggleCard)
cardBtn.Importance = widget.LowImportance
@@ -136,7 +179,7 @@ func (a *App) build() {
settingsBtn.Importance = widget.LowImportance
toolbar := container.NewHBox(
widget.NewLabel("模型"), a.modelSel,
widget.NewLabel("接入点"), a.modelSel,
layout.NewSpacer(), cardBtn, settingsBtn,
)
@@ -177,14 +220,14 @@ func (a *App) build() {
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)
// 开发期自检:把取景框内部状态写到文件,方便排查。
if p := os.Getenv("VIEWSAGENT_FRAME_DEBUG"); p != "" {
go func() {
time.Sleep(800 * time.Millisecond)
_ = os.WriteFile(p, []byte(a.regionFrm.Debug()), 0o644)
}()
}
return names
}
// ---------- 用户操作 ----------
@@ -195,15 +238,46 @@ func (a *App) send() {
return
}
a.entry.SetText("")
if err := a.eng.Ask(text, a.attach.Checked); err != nil {
a.chat.addNotice(err.Error())
}
// 请求必须放到后台:网络请求期间 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 err := a.eng.ToggleWatch(); err != nil {
a.chat.addNotice(err.Error())
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() {
@@ -221,23 +295,23 @@ func (a *App) clearChat() {
a.chat.addNotice("已清空对话上下文")
}
// onModelChange 切换生效的接入点。
func (a *App) onModelChange(name string) {
if a.quietModelSel || name == "" {
return
}
cfg := a.eng.Config()
if _, ok := cfg.Profile(name); !ok {
cfg := a.eng.Config().Clone()
if !cfg.SetActive(name) {
return
}
cfg.ActiveProfile = name
if err := a.eng.ApplyConfig(cfg); err != nil {
a.chat.addNotice("切换模型失败:" + err.Error())
a.chat.addNotice("切换接入点失败:" + err.Error())
return
}
if err := a.eng.Store().Save(cfg); err != nil {
slog.Warn("保存模型选择失败", "err", err)
slog.Warn("保存接入点选择失败", "err", err)
}
a.chat.addNotice("已切换模型:" + name)
a.chat.addNotice("已切换到接入点 " + name)
}
// saveConfig 把引擎里的配置落盘并立即生效。
@@ -245,7 +319,7 @@ func (a *App) saveConfig() error {
return a.applyAndSave(a.eng.Config())
}
// applyAndSave 保存并应用一份配置(覆盖模型下拉选项)。
// applyAndSave 保存并应用一份配置(覆盖接入点下拉选项)。
func (a *App) applyAndSave(cfg *config.Config) error {
cfg.Normalize()
if err := a.eng.Store().Save(cfg); err != nil {
@@ -255,9 +329,12 @@ func (a *App) applyAndSave(cfg *config.Config) error {
return err
}
a.quietModelSel = true
a.modelSel.Options = profileNames(cfg)
a.modelSel.SetSelected(cfg.ActiveProfile)
a.modelSel.Options = cfg.ModelNames()
a.modelSel.SetSelected(cfg.Active)
a.quietModelSel = false
// 主题变了就立刻换肤,不用重启。
applyThemeMode(cfg.Theme)
a.chat.retheme()
return nil
}
@@ -267,6 +344,8 @@ func (a *App) startSelection() {
if a.overlay != nil {
return
}
// 框选过程中整屏都是选区浮层,先把常驻红框收起来,免得两层边框打架。
a.regionFrm.suspend()
// 先隐藏主窗,避免它出现在冻结帧里挡住要框选的内容
a.win.Hide()
time.Sleep(220 * time.Millisecond)
@@ -274,6 +353,7 @@ func (a *App) startSelection() {
shot, err := (capture.Screen{}).Desktop()
if err != nil {
a.win.Show()
a.regionFrm.resume(a.eng.Region())
a.chat.addNotice("抓屏失败:" + err.Error())
return
}
@@ -286,16 +366,19 @@ 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() {
@@ -361,13 +444,21 @@ func (a *App) OnAssistantStart(auto bool) {
}
// OnAssistantDelta 收到流式增量,40ms 合批后刷新界面。
func (a *App) OnAssistantDelta(delta string) {
a.replyMu.Lock()
a.lastReply.WriteString(delta)
a.replyMu.Unlock()
// 思考过程与正式回答分两路合批,保证两边的顺序都不乱。
func (a *App) OnAssistantDelta(delta llm.Delta) {
if delta.Text != "" {
a.replyMu.Lock()
a.lastReply.WriteString(delta.Text)
a.replyMu.Unlock()
}
a.pendMu.Lock()
a.pending.WriteString(delta)
if delta.Text != "" {
a.pendingText.WriteString(delta.Text)
}
if delta.Reasoning != "" {
a.pendingThink.WriteString(delta.Reasoning)
}
if a.pendScheduled {
a.pendMu.Unlock()
return
@@ -380,15 +471,42 @@ func (a *App) OnAssistantDelta(delta string) {
func (a *App) flushPending() {
a.pendMu.Lock()
text := a.pending.String()
a.pending.Reset()
text := a.pendingText.String()
think := a.pendingThink.String()
a.pendingText.Reset()
a.pendingThink.Reset()
a.pendScheduled = false
a.pendMu.Unlock()
if text == "" {
if text == "" && think == "" {
return
}
fyne.Do(func() { a.chat.appendDelta(text) })
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 结束一条助手回答。
@@ -427,6 +545,8 @@ func (a *App) applyState(st agent.State) {
a.regionLbl.SetText("未框选区域")
}
a.card.setRegion(st.Region)
// 屏幕上的红框跟着选区走,始终提示当前盯的是哪一块。
a.regionFrm.apply(st.Region)
if st.Busy {
a.sendBtn.Disable()
@@ -443,8 +563,16 @@ func (a *App) applyState(st agent.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, st.Model, st.Triggers, st.TotalTokens)
state, label, st.Triggers, st.TotalTokens)
if st.LastLatency > 0 {
status += fmt.Sprintf(" · 上次 %dms", st.LastLatency.Milliseconds())
}