优化代码
This commit is contained in:
1 parent
3553790475
commit
eda7e8bc96
23 files changed
+3113
-342
No files matched your search
+171
-43
@@ -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())
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user