优化代码
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())
|
||||
}
|
||||
|
||||
+109
-4
@@ -31,13 +31,29 @@ func colorFor(k bubbleKind) color.Color {
|
||||
}
|
||||
}
|
||||
|
||||
// borderFor 返回气泡描边色:错误提示用更重的描边,保证一眼能认出。
|
||||
func borderFor(k bubbleKind) color.Color {
|
||||
if k == bubbleError {
|
||||
return colorStrongBorder()
|
||||
}
|
||||
return colorBorder()
|
||||
}
|
||||
|
||||
// bubble 是一条消息气泡:圆角背景 + 角色标题 + 正文。
|
||||
// 流式阶段用 Label 追加文本,结束后换成 RichText 以获得 Markdown 排版。
|
||||
// 思考过程单独占一块(可折叠),不与正式回答混在一起。
|
||||
type bubble struct {
|
||||
root *fyne.Container
|
||||
bg *canvas.Rectangle
|
||||
body *fyne.Container
|
||||
head *widget.Label
|
||||
kind bubbleKind
|
||||
|
||||
thinkBtn *widget.Button
|
||||
thinkText *widget.RichText
|
||||
thinkBox *fyne.Container
|
||||
think strings.Builder
|
||||
|
||||
live *widget.Label
|
||||
text strings.Builder
|
||||
}
|
||||
@@ -45,7 +61,7 @@ type bubble struct {
|
||||
func newBubble(role string, kind bubbleKind) *bubble {
|
||||
bg := canvas.NewRectangle(colorFor(kind))
|
||||
bg.CornerRadius = 10
|
||||
bg.StrokeColor = colorBorder()
|
||||
bg.StrokeColor = borderFor(kind)
|
||||
bg.StrokeWidth = 1
|
||||
|
||||
head := widget.NewLabelWithStyle(role, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||
@@ -57,7 +73,62 @@ func newBubble(role string, kind bubbleKind) *bubble {
|
||||
body := container.NewVBox(head, live)
|
||||
root := container.NewStack(bg, container.New(innerPadLayout{}, body))
|
||||
|
||||
return &bubble{root: root, bg: bg, body: body, head: head, live: live}
|
||||
return &bubble{root: root, bg: bg, body: body, head: head, kind: kind, live: live}
|
||||
}
|
||||
|
||||
// retheme 主题切换后重新上色。
|
||||
func (b *bubble) retheme() {
|
||||
b.bg.FillColor = colorFor(b.kind)
|
||||
b.bg.StrokeColor = borderFor(b.kind)
|
||||
b.bg.Refresh()
|
||||
}
|
||||
|
||||
// ensureThinking 建出思考过程区块,并插在角色标题与正文之间。
|
||||
func (b *bubble) ensureThinking() {
|
||||
if b.thinkBox != nil {
|
||||
return
|
||||
}
|
||||
b.thinkText = widget.NewRichText()
|
||||
b.thinkText.Wrapping = fyne.TextWrapWord
|
||||
|
||||
b.thinkBtn = widget.NewButton("▾ 思考过程", func() { b.toggleThinking() })
|
||||
b.thinkBtn.Importance = widget.LowImportance
|
||||
b.thinkBtn.Alignment = widget.ButtonAlignLeading
|
||||
|
||||
b.thinkBox = container.NewVBox(b.thinkBtn, b.thinkText)
|
||||
objs := b.body.Objects
|
||||
next := make([]fyne.CanvasObject, 0, len(objs)+1)
|
||||
next = append(next, objs[0], b.thinkBox)
|
||||
next = append(next, objs[1:]...)
|
||||
b.body.Objects = next
|
||||
b.body.Refresh()
|
||||
}
|
||||
|
||||
// toggleThinking 折叠/展开思考过程。
|
||||
func (b *bubble) toggleThinking() {
|
||||
if b.thinkText == nil {
|
||||
return
|
||||
}
|
||||
if b.thinkText.Visible() {
|
||||
b.thinkText.Hide()
|
||||
b.thinkBtn.SetText("▸ 思考过程")
|
||||
return
|
||||
}
|
||||
b.thinkText.Show()
|
||||
b.thinkBtn.SetText("▾ 思考过程")
|
||||
}
|
||||
|
||||
// appendThinking 追加思考过程文本。
|
||||
func (b *bubble) appendThinking(delta string) {
|
||||
b.ensureThinking()
|
||||
b.think.WriteString(delta)
|
||||
b.thinkText.Segments = []widget.RichTextSegment{
|
||||
&widget.TextSegment{
|
||||
Text: b.think.String(),
|
||||
Style: widget.RichTextStyle{TextStyle: fyne.TextStyle{Italic: true}},
|
||||
},
|
||||
}
|
||||
b.thinkText.Refresh()
|
||||
}
|
||||
|
||||
// append 追加流式文本。
|
||||
@@ -76,12 +147,27 @@ func (b *bubble) finish(err error) {
|
||||
}
|
||||
text := strings.TrimSpace(b.text.String())
|
||||
if text == "" {
|
||||
text = "(没有返回内容)"
|
||||
if b.think.Len() > 0 {
|
||||
text = "(模型只给出了思考过程,没有输出回答)"
|
||||
} else {
|
||||
text = "(没有返回内容)"
|
||||
}
|
||||
}
|
||||
rt := widget.NewRichTextFromMarkdown(text)
|
||||
rt.Wrapping = fyne.TextWrapWord
|
||||
b.body.Objects = []fyne.CanvasObject{b.head, rt}
|
||||
objs := b.body.Objects
|
||||
next := make([]fyne.CanvasObject, 0, len(objs))
|
||||
next = append(next, objs[0])
|
||||
if b.thinkBox != nil {
|
||||
next = append(next, b.thinkBox)
|
||||
}
|
||||
next = append(next, rt)
|
||||
b.body.Objects = next
|
||||
b.body.Refresh()
|
||||
// 思考过程默认折起来,把正文让到前面;点标题还能再展开。
|
||||
if b.thinkText != nil && b.thinkText.Visible() {
|
||||
b.toggleThinking()
|
||||
}
|
||||
}
|
||||
|
||||
// innerPadLayout 给气泡留内边距。
|
||||
@@ -113,6 +199,7 @@ type chatView struct {
|
||||
list *fyne.Container
|
||||
scroll *container.Scroll
|
||||
current *bubble
|
||||
bubbles []*bubble
|
||||
}
|
||||
|
||||
func newChatView() *chatView {
|
||||
@@ -127,10 +214,18 @@ func newChatView() *chatView {
|
||||
func (c *chatView) object() fyne.CanvasObject { return c.scroll }
|
||||
|
||||
func (c *chatView) appendBubble(b *bubble) {
|
||||
c.bubbles = append(c.bubbles, b)
|
||||
c.list.Add(b.root)
|
||||
c.scroll.ScrollToBottom()
|
||||
}
|
||||
|
||||
// retheme 主题切换后给所有气泡重新上色。
|
||||
func (c *chatView) retheme() {
|
||||
for _, b := range c.bubbles {
|
||||
b.retheme()
|
||||
}
|
||||
}
|
||||
|
||||
// addUser 添加一条用户消息。
|
||||
func (c *chatView) addUser(text string, withImage bool) {
|
||||
if withImage {
|
||||
@@ -161,6 +256,15 @@ func (c *chatView) appendDelta(delta string) {
|
||||
c.scroll.ScrollToBottom()
|
||||
}
|
||||
|
||||
// appendThinking 追加思考过程文本。
|
||||
func (c *chatView) appendThinking(delta string) {
|
||||
if c.current == nil {
|
||||
c.beginAssistant(false)
|
||||
}
|
||||
c.current.appendThinking(delta)
|
||||
c.scroll.ScrollToBottom()
|
||||
}
|
||||
|
||||
// endAssistant 结束当前助手消息。
|
||||
func (c *chatView) endAssistant(err error) {
|
||||
if c.current == nil {
|
||||
@@ -182,6 +286,7 @@ func (c *chatView) addNotice(text string) {
|
||||
// clear 清空消息列表。
|
||||
func (c *chatView) clear() {
|
||||
c.list.Objects = nil
|
||||
c.bubbles = nil
|
||||
c.current = nil
|
||||
c.list.Refresh()
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"viewsagent/internal/capture"
|
||||
"viewsagent/internal/win32"
|
||||
)
|
||||
|
||||
// regionFrame 是常驻的取景框提示。
|
||||
//
|
||||
// 屏幕上一直显示当前框选区域的红框,让使用者随时知道自己在看哪一块;
|
||||
// 这个框被 SetWindowDisplayAffinity 排除在截屏与录屏之外,
|
||||
// 所以模型看到的画面、录屏软件录到的画面里都不会有它。
|
||||
type regionFrame struct {
|
||||
frame *win32.Frame
|
||||
|
||||
mu sync.Mutex
|
||||
last capture.Region
|
||||
hasLast bool
|
||||
suspended bool
|
||||
}
|
||||
|
||||
// newRegionFrame 创建取景框。创建失败时返回 nil,调用方自行决定是否降级。
|
||||
func newRegionFrame() *regionFrame {
|
||||
f, err := win32.NewFrame()
|
||||
if err != nil {
|
||||
slog.Warn("取景框创建失败,屏幕将不再显示选区边框", "err", err)
|
||||
return nil
|
||||
}
|
||||
return ®ionFrame{frame: f}
|
||||
}
|
||||
|
||||
// apply 把取景框移到指定区域;区域无效时隐藏。
|
||||
func (f *regionFrame) apply(r capture.Region) {
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
if f.suspended {
|
||||
f.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if f.hasLast && f.last == r {
|
||||
f.mu.Unlock()
|
||||
return
|
||||
}
|
||||
f.last = r
|
||||
f.hasLast = true
|
||||
f.mu.Unlock()
|
||||
|
||||
if !r.Valid() {
|
||||
f.frame.Hide()
|
||||
return
|
||||
}
|
||||
f.frame.SetRegion(win32.Rect{
|
||||
Left: r.X,
|
||||
Top: r.Y,
|
||||
Right: r.X + r.W,
|
||||
Bottom: r.Y + r.H,
|
||||
})
|
||||
}
|
||||
|
||||
// suspend 临时隐藏取景框(框选过程中全屏有选区浮层,避免两层边框打架)。
|
||||
func (f *regionFrame) suspend() {
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.suspended = true
|
||||
f.hasLast = false
|
||||
f.mu.Unlock()
|
||||
f.frame.Hide()
|
||||
}
|
||||
|
||||
// resume 恢复取景框,并按当前区域重新落位。
|
||||
func (f *regionFrame) resume(r capture.Region) {
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.suspended = false
|
||||
f.mu.Unlock()
|
||||
f.apply(r)
|
||||
}
|
||||
|
||||
// close 销毁取景框窗口。
|
||||
func (f *regionFrame) close() {
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
f.frame.Close()
|
||||
}
|
||||
|
||||
// Debug 返回取景框内部状态,供自检使用。
|
||||
func (f *regionFrame) Debug() string {
|
||||
if f == nil || f.frame == nil {
|
||||
return "取景框未创建"
|
||||
}
|
||||
return f.frame.Debug()
|
||||
}
|
||||
@@ -64,7 +64,8 @@ func newOverlay(app fyne.App, shot image.Image, vd win32.Rect,
|
||||
objs = append(objs, o.dims[i])
|
||||
}
|
||||
for i := range o.edges {
|
||||
o.edges[i] = canvas.NewRectangle(color.NRGBA{R: 0x5A, G: 0xC8, B: 0xFF, A: 0xFF})
|
||||
// 描边跟着主题走:黑白主题下是纯白/纯黑,不会残留彩色。
|
||||
o.edges[i] = canvas.NewRectangle(lookupColor(theme.ColorNameForeground))
|
||||
objs = append(objs, o.edges[i])
|
||||
}
|
||||
o.hint = canvas.NewText("拖拽框选区域 · Esc 取消", color.White)
|
||||
|
||||
+546
-148
@@ -2,8 +2,10 @@ package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -11,9 +13,11 @@ import (
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"viewsagent/internal/config"
|
||||
"viewsagent/internal/llm"
|
||||
)
|
||||
|
||||
const settingsTitle = "viewsagent-settings"
|
||||
@@ -24,14 +28,21 @@ type settingsWindow struct {
|
||||
win fyne.Window
|
||||
cfg *config.Config
|
||||
|
||||
// 模型接入点
|
||||
profileSel *widget.Select
|
||||
pName *widget.Entry
|
||||
pBase *widget.Entry
|
||||
pModel *widget.Entry
|
||||
pKey *widget.Entry
|
||||
pMax *widget.Entry
|
||||
pTemp *widget.Entry
|
||||
// 模型接入:左边是接入点列表,右边是当前接入点的接口详情。
|
||||
profileList *widget.List
|
||||
selIndex int
|
||||
loading bool // 回填表单时抑制回写
|
||||
|
||||
formatSel *widget.Select
|
||||
nameEntry *widget.Entry
|
||||
baseEntry *widget.Entry
|
||||
modelEntry *widget.Entry
|
||||
keyEntry *widget.Entry
|
||||
maxEntry *widget.Entry
|
||||
tempEntry *widget.Entry
|
||||
headerEntry *widget.Entry
|
||||
authLabel *widget.Label
|
||||
previewHost *fyne.Container
|
||||
|
||||
// 提示词
|
||||
tmplSel *widget.Select
|
||||
@@ -50,18 +61,24 @@ type settingsWindow struct {
|
||||
hkSelect *widget.Entry
|
||||
hkToggle *widget.Entry
|
||||
alwaysTop *widget.Check
|
||||
themeSel *widget.Select
|
||||
|
||||
// tabs 是设置窗口的标签容器,开发期截图时用来切页。
|
||||
tabs *container.AppTabs
|
||||
}
|
||||
|
||||
func newSettingsWindow(app *App) *settingsWindow {
|
||||
s := &settingsWindow{app: app}
|
||||
s.cfg = app.eng.Config().Clone()
|
||||
s.win = app.fyne.NewWindow(settingsTitle)
|
||||
s.win.Resize(fyne.NewSize(620, 620))
|
||||
s.win.SetContent(container.NewAppTabs(
|
||||
container.NewTabItem("模型接入", s.buildProfileTab()),
|
||||
s.win.Resize(fyne.NewSize(900, 700))
|
||||
s.tabs = container.NewAppTabs(
|
||||
container.NewTabItem("模型", s.buildModelTab()),
|
||||
container.NewTabItem("提示词", s.buildTemplateTab()),
|
||||
container.NewTabItem("抓屏与触发", s.buildCaptureTab()),
|
||||
container.NewTabItem("其它", s.buildMiscTab()),
|
||||
))
|
||||
)
|
||||
s.win.SetContent(s.tabs)
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -70,18 +87,32 @@ func (s *settingsWindow) show() {
|
||||
s.win.Show()
|
||||
}
|
||||
|
||||
// selectTabByName 切到指定名字的标签页,找不到就保持当前页。
|
||||
func (s *settingsWindow) selectTabByName(name string) {
|
||||
for _, item := range s.tabs.Items {
|
||||
if item.Text == name {
|
||||
s.tabs.SelectTab(item)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reload 从引擎当前配置重新载入界面。
|
||||
func (s *settingsWindow) reload() {
|
||||
// 用副本编辑,避免与引擎并发读写同一份配置。
|
||||
s.cfg = s.app.eng.Config().Clone()
|
||||
|
||||
names := make([]string, 0, len(s.cfg.Profiles))
|
||||
for _, p := range s.cfg.Profiles {
|
||||
names = append(names, p.Name)
|
||||
s.loading = true
|
||||
s.profileList.Refresh()
|
||||
idx := s.cfg.ProfileIndex(s.cfg.Active)
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
s.profileSel.Options = names
|
||||
s.profileSel.SetSelected(s.cfg.ActiveProfile)
|
||||
s.applyProfile()
|
||||
s.selIndex = idx
|
||||
s.profileList.Select(idx)
|
||||
// 选中的项没变化时 List 不会再回调,这里显式回填一次表单。
|
||||
s.loadProfileToForm()
|
||||
s.loading = false
|
||||
|
||||
keys := make([]string, 0, len(s.cfg.Templates))
|
||||
for _, t := range s.cfg.Templates {
|
||||
@@ -103,88 +134,366 @@ func (s *settingsWindow) reload() {
|
||||
s.hkSelect.SetText(s.cfg.Hotkeys.SelectRegion)
|
||||
s.hkToggle.SetText(s.cfg.Hotkeys.ToggleWatch)
|
||||
s.alwaysTop.SetChecked(s.cfg.AlwaysOnTopCard)
|
||||
s.themeSel.Options = themeOptions()
|
||||
s.themeSel.SetSelected(themeLabel(s.cfg.Theme))
|
||||
}
|
||||
|
||||
func entry(label string, obj fyne.CanvasObject) fyne.CanvasObject {
|
||||
return container.NewBorder(nil, nil, widget.NewLabel(label), nil, obj)
|
||||
// ---------- 模型页 ----------
|
||||
|
||||
// buildModelTab 组装模型页:左边是接入点列表,右边是当前接入点的接口详情。
|
||||
func (s *settingsWindow) buildModelTab() fyne.CanvasObject {
|
||||
s.buildModelFields()
|
||||
|
||||
split := container.NewHSplit(s.buildProfilePane(), s.buildDetailPane())
|
||||
split.Offset = 0.24
|
||||
return split
|
||||
}
|
||||
|
||||
func (s *settingsWindow) buildProfileTab() fyne.CanvasObject {
|
||||
s.profileSel = widget.NewSelect(nil, func(string) { s.applyProfile() })
|
||||
s.profileSel.PlaceHolder = "请选择接入点"
|
||||
s.pName = widget.NewEntry()
|
||||
s.pBase = widget.NewEntry()
|
||||
s.pBase.SetPlaceHolder("https://dashscope.aliyuncs.com/compatible-mode/v1")
|
||||
s.pModel = widget.NewEntry()
|
||||
s.pModel.SetPlaceHolder("qwen3-vl-plus")
|
||||
s.pKey = widget.NewPasswordEntry()
|
||||
s.pMax = widget.NewEntry()
|
||||
s.pTemp = widget.NewEntry()
|
||||
// buildModelFields 创建接口详情的输入控件。
|
||||
func (s *settingsWindow) buildModelFields() {
|
||||
s.formatSel = widget.NewSelect(formatLabels(), func(string) { s.onFormatChanged() })
|
||||
s.formatSel.PlaceHolder = "请选择接口格式"
|
||||
|
||||
s.nameEntry = widget.NewEntry()
|
||||
s.nameEntry.SetPlaceHolder("例如:本地 Ollama")
|
||||
s.nameEntry.OnChanged = func(string) { s.commitProfile() }
|
||||
|
||||
s.baseEntry = widget.NewEntry()
|
||||
s.baseEntry.OnChanged = func(string) { s.commitProfile() }
|
||||
|
||||
s.modelEntry = widget.NewEntry()
|
||||
s.modelEntry.OnChanged = func(string) { s.commitProfile() }
|
||||
|
||||
s.keyEntry = widget.NewPasswordEntry()
|
||||
s.keyEntry.OnChanged = func(string) { s.commitProfile() }
|
||||
|
||||
s.maxEntry = widget.NewEntry()
|
||||
s.maxEntry.OnChanged = func(string) { s.commitProfile() }
|
||||
|
||||
s.tempEntry = widget.NewEntry()
|
||||
s.tempEntry.OnChanged = func(string) { s.commitProfile() }
|
||||
|
||||
s.headerEntry = widget.NewMultiLineEntry()
|
||||
s.headerEntry.SetMinRowsVisible(3)
|
||||
s.headerEntry.SetPlaceHolder("每行一条,例如:\nX-Api-Key: sk-xxx")
|
||||
s.headerEntry.OnChanged = func(string) { s.commitProfile() }
|
||||
|
||||
s.previewHost = container.NewVBox()
|
||||
}
|
||||
|
||||
// buildProfilePane 造左侧的接入点列表。
|
||||
func (s *settingsWindow) buildProfilePane() fyne.CanvasObject {
|
||||
s.profileList = widget.NewList(
|
||||
func() int {
|
||||
if s.cfg == nil {
|
||||
return 0
|
||||
}
|
||||
return len(s.cfg.Models)
|
||||
},
|
||||
func() fyne.CanvasObject {
|
||||
name := widget.NewLabel("接入点")
|
||||
name.Truncation = fyne.TextTruncateEllipsis
|
||||
meta := widget.NewLabel("")
|
||||
meta.Importance = widget.LowImportance
|
||||
meta.Truncation = fyne.TextTruncateEllipsis
|
||||
return container.NewVBox(name, meta)
|
||||
},
|
||||
func(id widget.ListItemID, obj fyne.CanvasObject) { s.updateProfileRow(id, obj) },
|
||||
)
|
||||
s.profileList.OnSelected = func(id widget.ListItemID) { s.selectProfile(int(id)) }
|
||||
|
||||
addBtn := widget.NewButtonWithIcon("新增", theme.ContentAddIcon(), func() { s.askNewFormat() })
|
||||
dupBtn := widget.NewButtonWithIcon("复制", theme.ContentCopyIcon(), func() { s.duplicateProfile() })
|
||||
delBtn := widget.NewButtonWithIcon("删除", theme.DeleteIcon(), func() { s.deleteProfile() })
|
||||
useBtn := widget.NewButton("设为生效", func() { s.activateProfile() })
|
||||
|
||||
top := container.NewVBox(widget.NewLabel("接入点"), widget.NewSeparator())
|
||||
bottom := container.NewVBox(
|
||||
widget.NewSeparator(),
|
||||
container.NewHBox(addBtn, dupBtn, delBtn),
|
||||
useBtn,
|
||||
)
|
||||
return container.NewBorder(top, bottom, nil, nil, s.profileList)
|
||||
}
|
||||
|
||||
// buildDetailPane 造右侧的接口详情。
|
||||
func (s *settingsWindow) buildDetailPane() fyne.CanvasObject {
|
||||
s.authLabel = widget.NewLabel("API Key")
|
||||
|
||||
form := container.NewVBox(
|
||||
entry("接入点", s.profileSel),
|
||||
entry("名称", s.pName),
|
||||
entry("BaseURL", s.pBase),
|
||||
entry("模型", s.pModel),
|
||||
entry("API Key", s.pKey),
|
||||
entry("max_tokens", s.pMax),
|
||||
entry("temperature", s.pTemp),
|
||||
entry("接口格式", s.formatSel),
|
||||
entry("配置名称", s.nameEntry),
|
||||
entry("BaseURL", s.baseEntry),
|
||||
entry("模型名", s.modelEntry),
|
||||
container.NewBorder(nil, nil, s.authLabel, nil, s.keyEntry),
|
||||
entry("max_tokens", s.maxEntry),
|
||||
entry("temperature", s.tempEntry),
|
||||
entry("额外请求头", s.headerEntry),
|
||||
widget.NewSeparator(),
|
||||
widget.NewCard("请求协议", "", s.previewHost),
|
||||
)
|
||||
|
||||
addBtn := widget.NewButton("新增接入点", func() {
|
||||
name := strings.TrimSpace(s.pName.Text)
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("custom-%d", len(s.cfg.Profiles)+1)
|
||||
}
|
||||
if _, exists := s.cfg.Profile(name); exists {
|
||||
dialog.ShowInformation("重名", "已有同名接入点,请换一个名字", s.win)
|
||||
return
|
||||
}
|
||||
s.cfg.Profiles = append(s.cfg.Profiles, config.Profile{
|
||||
Name: name, BaseURL: s.pBase.Text, Model: s.pModel.Text,
|
||||
APIKey: s.pKey.Text, MaxTokens: 1024, Temperature: 0.3,
|
||||
})
|
||||
s.cfg.ActiveProfile = name
|
||||
s.reload()
|
||||
})
|
||||
testBtn := widget.NewButton("测试连接", func() { s.testConnection() })
|
||||
saveBtn := widget.NewButton("保存并应用", func() { s.saveAll() })
|
||||
bottom := container.NewVBox(widget.NewSeparator(), container.NewHBox(testBtn, saveBtn))
|
||||
|
||||
testBtn := widget.NewButton("测试连接", func() {
|
||||
p := s.readProfile()
|
||||
btn := widget.NewLabel("正在连接 " + p.BaseURL + " …")
|
||||
s.win.Canvas().Content().Refresh()
|
||||
d := dialog.NewCustomWithoutButtons("测试连接", btn, s.win)
|
||||
d.Show()
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
defer cancel()
|
||||
err := s.app.eng.PingProfile(ctx, p)
|
||||
fyne.Do(func() {
|
||||
d.Hide()
|
||||
if err != nil {
|
||||
dialog.ShowError(fmt.Errorf("连接失败:%w", err), s.win)
|
||||
return
|
||||
}
|
||||
dialog.ShowInformation("连接成功", "模型 "+p.Model+" 可用", s.win)
|
||||
})
|
||||
}()
|
||||
})
|
||||
|
||||
saveBtn := widget.NewButton("保存并应用", func() {
|
||||
s.commitAll()
|
||||
if err := s.app.applyAndSave(s.cfg); err != nil {
|
||||
dialog.ShowError(err, s.win)
|
||||
return
|
||||
}
|
||||
dialog.ShowInformation("已保存", "配置已写入并立即生效", s.win)
|
||||
})
|
||||
|
||||
return container.NewBorder(nil,
|
||||
container.NewVBox(widget.NewSeparator(), container.NewHBox(addBtn, testBtn, saveBtn)),
|
||||
nil, nil,
|
||||
container.NewVScroll(form),
|
||||
)
|
||||
return container.NewBorder(nil, bottom, nil, nil, container.NewVScroll(form))
|
||||
}
|
||||
|
||||
// updateProfileRow 刷新列表里的一行。
|
||||
func (s *settingsWindow) updateProfileRow(id widget.ListItemID, obj fyne.CanvasObject) {
|
||||
rows := obj.(*fyne.Container).Objects
|
||||
name := rows[0].(*widget.Label)
|
||||
meta := rows[1].(*widget.Label)
|
||||
|
||||
p, ok := s.profileAt(int(id))
|
||||
if !ok {
|
||||
name.SetText("")
|
||||
meta.SetText("")
|
||||
return
|
||||
}
|
||||
name.SetText(p.Name)
|
||||
detail := llm.FormatLabel(p.Format)
|
||||
if p.Model != "" {
|
||||
detail += " · " + p.Model
|
||||
} else {
|
||||
detail += " · 未填模型名"
|
||||
}
|
||||
if p.Name == s.cfg.Active {
|
||||
name.SetText("● " + p.Name)
|
||||
name.Importance = widget.HighImportance
|
||||
} else {
|
||||
name.Importance = widget.MediumImportance
|
||||
}
|
||||
meta.SetText(detail)
|
||||
}
|
||||
|
||||
// profileAt 取列表里的第 i 份接入点。
|
||||
func (s *settingsWindow) profileAt(i int) (config.Profile, bool) {
|
||||
if s.cfg == nil || i < 0 || i >= len(s.cfg.Models) {
|
||||
return config.Profile{}, false
|
||||
}
|
||||
return s.cfg.Models[i], true
|
||||
}
|
||||
|
||||
// selectProfile 切换左侧选中的接入点,右侧随之刷新。
|
||||
func (s *settingsWindow) selectProfile(idx int) {
|
||||
if _, ok := s.profileAt(idx); !ok {
|
||||
return
|
||||
}
|
||||
s.selIndex = idx
|
||||
s.loadProfileToForm()
|
||||
}
|
||||
|
||||
// loadProfileToForm 把选中接入点的值回填到右侧表单。
|
||||
func (s *settingsWindow) loadProfileToForm() {
|
||||
p, ok := s.profileAt(s.selIndex)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
prev := s.loading
|
||||
s.loading = true
|
||||
s.nameEntry.SetText(p.Name)
|
||||
s.formatSel.SetSelected(llm.FormatLabel(p.Format))
|
||||
s.baseEntry.SetText(p.BaseURL)
|
||||
s.modelEntry.SetText(p.Model)
|
||||
s.keyEntry.SetText(p.APIKey)
|
||||
s.maxEntry.SetText(strconv.Itoa(p.MaxTokens))
|
||||
s.tempEntry.SetText(fmt.Sprintf("%g", p.Temperature))
|
||||
s.headerEntry.SetText(formatHeaders(p.ExtraHeaders))
|
||||
s.loading = prev
|
||||
s.applyFormatHints(p.Format)
|
||||
}
|
||||
|
||||
// commitProfile 把右侧表单的值写回当前接入点。
|
||||
func (s *settingsWindow) commitProfile() {
|
||||
if s.loading || s.cfg == nil {
|
||||
return
|
||||
}
|
||||
if _, ok := s.profileAt(s.selIndex); !ok {
|
||||
return
|
||||
}
|
||||
p := s.cfg.Models[s.selIndex]
|
||||
oldName := p.Name
|
||||
// 名字被清空时保留原名,否则生效项会指向一个空名字。
|
||||
name := strings.TrimSpace(s.nameEntry.Text)
|
||||
if name == "" {
|
||||
name = oldName
|
||||
}
|
||||
p.Name = name
|
||||
p.Format = s.selectedFormat()
|
||||
p.BaseURL = strings.TrimRight(strings.TrimSpace(s.baseEntry.Text), "/")
|
||||
p.Model = strings.TrimSpace(s.modelEntry.Text)
|
||||
p.APIKey = strings.TrimSpace(s.keyEntry.Text)
|
||||
p.MaxTokens = atoiDefault(s.maxEntry.Text, 1024)
|
||||
p.Temperature = atofDefault(s.tempEntry.Text, 0.3)
|
||||
p.ExtraHeaders = parseHeaders(s.headerEntry.Text)
|
||||
s.cfg.Models[s.selIndex] = p
|
||||
|
||||
// 生效项改名时,Active 也要跟着改,否则会指向不存在的接入点。
|
||||
if oldName != p.Name && s.cfg.Active == oldName {
|
||||
s.cfg.Active = p.Name
|
||||
}
|
||||
s.profileList.RefreshItem(s.selIndex)
|
||||
}
|
||||
|
||||
// formProfile 用界面上的值构造一份接入点(不落盘)。
|
||||
func (s *settingsWindow) formProfile() config.Profile {
|
||||
if _, ok := s.profileAt(s.selIndex); !ok {
|
||||
if p, found := s.cfg.ActiveModel(); found {
|
||||
return p
|
||||
}
|
||||
return config.Profile{Format: llm.FormatOpenAI}
|
||||
}
|
||||
return config.Profile{
|
||||
Name: strings.TrimSpace(s.nameEntry.Text),
|
||||
Format: s.selectedFormat(),
|
||||
BaseURL: strings.TrimRight(strings.TrimSpace(s.baseEntry.Text), "/"),
|
||||
Model: strings.TrimSpace(s.modelEntry.Text),
|
||||
APIKey: strings.TrimSpace(s.keyEntry.Text),
|
||||
MaxTokens: atoiDefault(s.maxEntry.Text, 1024),
|
||||
Temperature: atofDefault(s.tempEntry.Text, 0.3),
|
||||
ExtraHeaders: parseHeaders(s.headerEntry.Text),
|
||||
}
|
||||
}
|
||||
|
||||
// selectedFormat 取下拉里选中的接口格式。
|
||||
func (s *settingsWindow) selectedFormat() string {
|
||||
for _, f := range llm.Formats() {
|
||||
if llm.FormatLabel(f) == s.formatSel.Selected {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return llm.FormatOpenAI
|
||||
}
|
||||
|
||||
// onFormatChanged 切换接口格式:字段提示与协议说明跟着变。
|
||||
func (s *settingsWindow) onFormatChanged() {
|
||||
if s.loading {
|
||||
return
|
||||
}
|
||||
s.commitProfile()
|
||||
s.applyFormatHints(s.selectedFormat())
|
||||
}
|
||||
|
||||
// applyFormatHints 按接口格式刷新占位符、鉴权标签与协议说明。
|
||||
func (s *settingsWindow) applyFormatHints(format string) {
|
||||
hint := hintFor(format)
|
||||
s.baseEntry.SetPlaceHolder(hint.basePlaceholder)
|
||||
s.modelEntry.SetPlaceHolder(hint.modelPlaceholder)
|
||||
s.keyEntry.SetPlaceHolder(hint.keyPlaceholder)
|
||||
s.authLabel.SetText(hint.authLabel)
|
||||
|
||||
s.previewHost.RemoveAll()
|
||||
for _, line := range hint.protocol {
|
||||
key := widget.NewLabel(line[0])
|
||||
key.Importance = widget.LowImportance
|
||||
s.previewHost.Add(container.NewBorder(nil, nil, key, nil, widget.NewLabel(line[1])))
|
||||
}
|
||||
}
|
||||
|
||||
// askNewFormat 新增接入点前先选接口格式。
|
||||
func (s *settingsWindow) askNewFormat() {
|
||||
var d dialog.Dialog
|
||||
btns := container.NewVBox(
|
||||
widget.NewButton(llm.FormatLabel(llm.FormatOpenAI), func() {
|
||||
d.Hide()
|
||||
s.addProfile(llm.FormatOpenAI)
|
||||
}),
|
||||
widget.NewButton(llm.FormatLabel(llm.FormatAnthropic), func() {
|
||||
d.Hide()
|
||||
s.addProfile(llm.FormatAnthropic)
|
||||
}),
|
||||
widget.NewLabel("先选接口格式,再按该格式填写地址与鉴权。"),
|
||||
)
|
||||
d = dialog.NewCustom("新增接入点", "取消", btns, s.win)
|
||||
d.Show()
|
||||
}
|
||||
|
||||
// addProfile 按选定格式新增一份接入点。
|
||||
func (s *settingsWindow) addProfile(format string) {
|
||||
p := s.cfg.AddProfile(config.NewProfile("接入点", format))
|
||||
s.profileList.Refresh()
|
||||
s.profileList.Select(s.cfg.ProfileIndex(p.Name))
|
||||
}
|
||||
|
||||
// duplicateProfile 复制当前接入点。
|
||||
func (s *settingsWindow) duplicateProfile() {
|
||||
p, ok := s.profileAt(s.selIndex)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cp := p
|
||||
cp.Name = s.cfg.UniqueName(p.Name + " 副本")
|
||||
cp.ExtraHeaders = copyHeaders(p.ExtraHeaders)
|
||||
s.cfg.Models = append(s.cfg.Models, cp)
|
||||
s.profileList.Refresh()
|
||||
s.profileList.Select(s.cfg.ProfileIndex(cp.Name))
|
||||
}
|
||||
|
||||
// deleteProfile 删除当前接入点。
|
||||
func (s *settingsWindow) deleteProfile() {
|
||||
p, ok := s.profileAt(s.selIndex)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
dialog.NewConfirm("删除接入点", "确定删除「"+p.Name+"」?", func(confirmed bool) {
|
||||
if !confirmed {
|
||||
return
|
||||
}
|
||||
s.cfg.RemoveModel(p.Name)
|
||||
s.profileList.Refresh()
|
||||
if s.cfg.ProfileIndex(s.cfg.Active) < 0 {
|
||||
s.cfg.Active = ""
|
||||
}
|
||||
if len(s.cfg.Models) == 0 {
|
||||
s.selIndex = -1
|
||||
return
|
||||
}
|
||||
idx := s.selIndex
|
||||
if idx >= len(s.cfg.Models) {
|
||||
idx = len(s.cfg.Models) - 1
|
||||
}
|
||||
s.profileList.Select(idx)
|
||||
}, s.win)
|
||||
}
|
||||
|
||||
// activateProfile 把当前接入点设为生效项。
|
||||
func (s *settingsWindow) activateProfile() {
|
||||
p, ok := s.profileAt(s.selIndex)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.cfg.Active = p.Name
|
||||
s.profileList.Refresh()
|
||||
}
|
||||
|
||||
// testConnection 用当前表单的值测试一次连通性。
|
||||
func (s *settingsWindow) testConnection() {
|
||||
p := s.formProfile()
|
||||
if p.BaseURL == "" || p.Model == "" {
|
||||
dialog.ShowError(errors.New("请先填写 BaseURL 与模型名"), s.win)
|
||||
return
|
||||
}
|
||||
d := dialog.NewCustomWithoutButtons("测试连接", widget.NewLabel("正在连接 "+p.BaseURL+" …"), s.win)
|
||||
d.Show()
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
defer cancel()
|
||||
err := s.app.eng.PingModel(ctx, p)
|
||||
fyne.Do(func() {
|
||||
d.Hide()
|
||||
if err != nil {
|
||||
dialog.ShowError(fmt.Errorf("连接失败:%w", err), s.win)
|
||||
return
|
||||
}
|
||||
dialog.ShowInformation("连接成功", llm.FormatLabel(p.Format)+" 接口可用:"+p.Model, s.win)
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// ---------- 其它页 ----------
|
||||
|
||||
func (s *settingsWindow) buildTemplateTab() fyne.CanvasObject {
|
||||
s.tmplSel = widget.NewSelect(nil, func(string) { s.applyTemplate() })
|
||||
s.tmplSel.PlaceHolder = "请选择模板"
|
||||
@@ -193,12 +502,7 @@ func (s *settingsWindow) buildTemplateTab() fyne.CanvasObject {
|
||||
s.tmplSystem.Wrapping = fyne.TextWrapWord
|
||||
|
||||
saveBtn := widget.NewButton("保存提示词", func() {
|
||||
key := s.tmplSel.Selected
|
||||
for i := range s.cfg.Templates {
|
||||
if s.cfg.Templates[i].Key == key {
|
||||
s.cfg.Templates[i].System = s.tmplSystem.Text
|
||||
}
|
||||
}
|
||||
s.commitTemplate()
|
||||
if err := s.app.applyAndSave(s.cfg); err != nil {
|
||||
dialog.ShowError(err, s.win)
|
||||
return
|
||||
@@ -235,12 +539,7 @@ func (s *settingsWindow) buildCaptureTab() fyne.CanvasObject {
|
||||
)
|
||||
|
||||
saveBtn := widget.NewButton("保存并应用", func() {
|
||||
s.commitAll()
|
||||
if err := s.app.applyAndSave(s.cfg); err != nil {
|
||||
dialog.ShowError(err, s.win)
|
||||
return
|
||||
}
|
||||
dialog.ShowInformation("已保存", "抓屏与触发参数已更新", s.win)
|
||||
s.saveAll()
|
||||
})
|
||||
|
||||
return container.NewBorder(nil,
|
||||
@@ -254,6 +553,8 @@ func (s *settingsWindow) buildMiscTab() fyne.CanvasObject {
|
||||
s.hkSelect = widget.NewEntry()
|
||||
s.hkToggle = widget.NewEntry()
|
||||
s.alwaysTop = widget.NewCheck("显示置顶答案浮窗", nil)
|
||||
s.themeSel = widget.NewSelect(themeOptions(), func(string) {})
|
||||
s.themeSel.PlaceHolder = "请选择主题"
|
||||
|
||||
openDir := widget.NewButton("打开配置目录", func() {
|
||||
dir := s.app.eng.Store().Dir()
|
||||
@@ -263,20 +564,17 @@ func (s *settingsWindow) buildMiscTab() fyne.CanvasObject {
|
||||
})
|
||||
|
||||
saveBtn := widget.NewButton("保存并应用", func() {
|
||||
s.commitAll()
|
||||
if err := s.app.applyAndSave(s.cfg); err != nil {
|
||||
dialog.ShowError(err, s.win)
|
||||
return
|
||||
}
|
||||
dialog.ShowInformation("已保存", "热键修改需要重启程序生效", s.win)
|
||||
s.saveAll()
|
||||
})
|
||||
|
||||
form := container.NewVBox(
|
||||
entry("主题", s.themeSel),
|
||||
entry("框选热键", s.hkSelect),
|
||||
entry("监听开关热键", s.hkToggle),
|
||||
s.alwaysTop,
|
||||
container.NewHBox(openDir),
|
||||
widget.NewLabel("热键语法示例:Ctrl+Alt+Q、Ctrl+Shift+F2"),
|
||||
widget.NewLabel("黑白高对比:纯黑底纯白字,去掉所有彩色,文字与背景对比最强。"),
|
||||
)
|
||||
|
||||
return container.NewBorder(nil,
|
||||
@@ -286,23 +584,6 @@ func (s *settingsWindow) buildMiscTab() fyne.CanvasObject {
|
||||
)
|
||||
}
|
||||
|
||||
// applyProfile 把选中的接入点填进输入框。
|
||||
func (s *settingsWindow) applyProfile() {
|
||||
if s.cfg == nil {
|
||||
return
|
||||
}
|
||||
p, ok := s.cfg.Profile(s.profileSel.Selected)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.pName.SetText(p.Name)
|
||||
s.pBase.SetText(p.BaseURL)
|
||||
s.pModel.SetText(p.Model)
|
||||
s.pKey.SetText(p.APIKey)
|
||||
s.pMax.SetText(strconv.Itoa(p.MaxTokens))
|
||||
s.pTemp.SetText(fmt.Sprintf("%g", p.Temperature))
|
||||
}
|
||||
|
||||
// applyTemplate 把选中的模板填进编辑框。
|
||||
func (s *settingsWindow) applyTemplate() {
|
||||
if s.cfg == nil {
|
||||
@@ -316,35 +597,39 @@ func (s *settingsWindow) applyTemplate() {
|
||||
}
|
||||
}
|
||||
|
||||
// readProfile 用界面上的值构造一个接入点(不落盘)。
|
||||
func (s *settingsWindow) readProfile() config.Profile {
|
||||
return config.Profile{
|
||||
Name: strings.TrimSpace(s.pName.Text),
|
||||
BaseURL: strings.TrimSpace(s.pBase.Text),
|
||||
Model: strings.TrimSpace(s.pModel.Text),
|
||||
APIKey: strings.TrimSpace(s.pKey.Text),
|
||||
MaxTokens: atoiDefault(s.pMax.Text, 1024),
|
||||
Temperature: atofDefault(s.pTemp.Text, 0.3),
|
||||
// ---------- 提交 ----------
|
||||
|
||||
// saveAll 把界面上的所有值写回配置并落盘生效。
|
||||
func (s *settingsWindow) saveAll() {
|
||||
s.commitProfile()
|
||||
s.commitTemplate()
|
||||
s.commitCapture()
|
||||
s.commitMisc()
|
||||
s.cfg.Normalize()
|
||||
if err := s.app.applyAndSave(s.cfg); err != nil {
|
||||
dialog.ShowError(err, s.win)
|
||||
return
|
||||
}
|
||||
s.reload()
|
||||
dialog.ShowInformation("已保存", "配置已写入并立即生效", s.win)
|
||||
}
|
||||
|
||||
// commitAll 把界面上的所有值写回配置对象。
|
||||
func (s *settingsWindow) commitAll() {
|
||||
p := s.readProfile()
|
||||
for i := range s.cfg.Profiles {
|
||||
if s.cfg.Profiles[i].Name == s.profileSel.Selected {
|
||||
p.Name = s.cfg.Profiles[i].Name
|
||||
s.cfg.Profiles[i] = p
|
||||
// commitTemplate 把模板编辑框的内容写回配置。
|
||||
func (s *settingsWindow) commitTemplate() {
|
||||
key := s.tmplSel.Selected
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
for i := range s.cfg.Templates {
|
||||
if s.cfg.Templates[i].Key == key {
|
||||
s.cfg.Templates[i].System = s.tmplSystem.Text
|
||||
}
|
||||
}
|
||||
if s.profileSel.Selected != "" {
|
||||
s.cfg.ActiveProfile = s.profileSel.Selected
|
||||
}
|
||||
|
||||
if key := s.tmplSel.Selected; key != "" {
|
||||
s.cfg.PromptTemplate = key
|
||||
}
|
||||
s.cfg.PromptTemplate = key
|
||||
}
|
||||
|
||||
// commitCapture 把抓屏与触发参数写回配置。
|
||||
func (s *settingsWindow) commitCapture() {
|
||||
s.cfg.Capture.FPS = atofDefault(s.fps.Text, 2)
|
||||
s.cfg.Capture.MaxEdge = atoiDefault(s.maxEdge.Text, 1280)
|
||||
s.cfg.Capture.JPEGQuality = atoiDefault(s.quality.Text, 75)
|
||||
@@ -352,11 +637,124 @@ func (s *settingsWindow) commitAll() {
|
||||
s.cfg.Capture.CooldownMs = atoiDefault(s.cooldown.Text, 3000)
|
||||
s.cfg.Capture.StableFrames = atoiDefault(s.stable.Text, 2)
|
||||
s.cfg.Capture.HistoryTurns = atoiDefault(s.turns.Text, 6)
|
||||
}
|
||||
|
||||
// commitMisc 把热键、浮窗选项与主题写回配置。
|
||||
func (s *settingsWindow) commitMisc() {
|
||||
s.cfg.Hotkeys.SelectRegion = strings.TrimSpace(s.hkSelect.Text)
|
||||
s.cfg.Hotkeys.ToggleWatch = strings.TrimSpace(s.hkToggle.Text)
|
||||
s.cfg.AlwaysOnTopCard = s.alwaysTop.Checked
|
||||
s.cfg.Normalize()
|
||||
s.cfg.Theme = themeFromLabel(s.themeSel.Selected)
|
||||
}
|
||||
|
||||
// ---------- 小组件与解析 ----------
|
||||
|
||||
func entry(label string, obj fyne.CanvasObject) fyne.CanvasObject {
|
||||
return container.NewBorder(nil, nil, widget.NewLabel(label), nil, obj)
|
||||
}
|
||||
|
||||
// formatLabels 返回接口格式下拉的可选项,只有两种兼容格式。
|
||||
func formatLabels() []string {
|
||||
labels := make([]string, 0, len(llm.Formats()))
|
||||
for _, f := range llm.Formats() {
|
||||
labels = append(labels, llm.FormatLabel(f))
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
// formatHint 描述一种接口格式下的字段提示与协议差异。
|
||||
type formatHint struct {
|
||||
basePlaceholder string
|
||||
modelPlaceholder string
|
||||
keyPlaceholder string
|
||||
authLabel string
|
||||
protocol [][2]string
|
||||
}
|
||||
|
||||
// hintFor 返回接口格式对应的提示。
|
||||
func hintFor(format string) formatHint {
|
||||
if format == llm.FormatAnthropic {
|
||||
return formatHint{
|
||||
basePlaceholder: "https://api.anthropic.com",
|
||||
modelPlaceholder: "claude-3-5-sonnet-20241022",
|
||||
keyPlaceholder: "sk-ant-…",
|
||||
authLabel: "API Key(x-api-key)",
|
||||
protocol: [][2]string{
|
||||
{"端点", "POST {BaseURL}/v1/messages"},
|
||||
{"鉴权", "x-api-key: <API Key>,默认附带 anthropic-version: " + llm.DefaultAnthropicVersion},
|
||||
{"提示词", "请求体顶层 system 字段"},
|
||||
{"图片", "source.base64"},
|
||||
{"参数", "max_tokens 必填"},
|
||||
},
|
||||
}
|
||||
}
|
||||
return formatHint{
|
||||
basePlaceholder: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
modelPlaceholder: "qwen3-vl-plus",
|
||||
keyPlaceholder: "sk-…",
|
||||
authLabel: "API Key(Bearer)",
|
||||
protocol: [][2]string{
|
||||
{"端点", "POST {BaseURL}/chat/completions"},
|
||||
{"鉴权", "Authorization: Bearer <API Key>"},
|
||||
{"提示词", "作为一条 system 消息"},
|
||||
{"图片", "image_url(data URL)"},
|
||||
{"参数", "max_tokens 可选"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// parseHeaders 解析"每行一条 Key: Value"的额外请求头。
|
||||
func parseHeaders(text string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formatHeaders 把额外请求头还原成每行一条的文本,顺序固定。
|
||||
func formatHeaders(h map[string]string) string {
|
||||
if len(h) == 0 {
|
||||
return ""
|
||||
}
|
||||
keys := make([]string, 0, len(h))
|
||||
for k := range h {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
lines := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
lines = append(lines, k+": "+h[k])
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// copyHeaders 深拷贝一份请求头表。
|
||||
func copyHeaders(h map[string]string) map[string]string {
|
||||
if len(h) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(h))
|
||||
for k, v := range h {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func atoiDefault(s string, def int) int {
|
||||
|
||||
+31
-4
@@ -23,19 +23,46 @@ func withAlpha(c color.Color, a uint8) color.Color {
|
||||
return n
|
||||
}
|
||||
|
||||
// mix 按比例把 b 混进 a,用来按前景/背景派生灰度层级。
|
||||
// 这样气泡底色永远跟着主题走,文字与底色对比也始终够。
|
||||
func mix(a, b color.Color, t float32) color.Color {
|
||||
an := color.NRGBAModel.Convert(a).(color.NRGBA)
|
||||
bn := color.NRGBAModel.Convert(b).(color.NRGBA)
|
||||
blend := func(x, y uint8) uint8 {
|
||||
return uint8(float32(x) + (float32(y)-float32(x))*t)
|
||||
}
|
||||
return color.NRGBA{
|
||||
R: blend(an.R, bn.R),
|
||||
G: blend(an.G, bn.G),
|
||||
B: blend(an.B, bn.B),
|
||||
A: 0xFF,
|
||||
}
|
||||
}
|
||||
|
||||
// 界面配色集中在这里,方便统一调整。
|
||||
// 所有颜色都从当前主题派生,因此黑白主题下自动变成纯灰度。
|
||||
|
||||
// colorUserBubble 是用户气泡:比助手气泡略深一层,文字仍是前景色。
|
||||
func colorUserBubble() color.Color {
|
||||
return withAlpha(lookupColor(theme.ColorNamePrimary), 0x3A)
|
||||
return mix(lookupColor(theme.ColorNameBackground), lookupColor(theme.ColorNameForeground), 0.18)
|
||||
}
|
||||
|
||||
// colorBotBubble 是助手气泡:贴着画布底色,只比背景亮一点点。
|
||||
func colorBotBubble() color.Color {
|
||||
return withAlpha(lookupColor(theme.ColorNameInputBackground), 0xF2)
|
||||
return mix(lookupColor(theme.ColorNameBackground), lookupColor(theme.ColorNameForeground), 0.06)
|
||||
}
|
||||
|
||||
// colorErrBubble 是提示/错误气泡:中等灰度,靠边框区分。
|
||||
func colorErrBubble() color.Color {
|
||||
return withAlpha(lookupColor(theme.ColorNameError), 0x28)
|
||||
return mix(lookupColor(theme.ColorNameBackground), lookupColor(theme.ColorNameForeground), 0.10)
|
||||
}
|
||||
|
||||
// colorBorder 是气泡描边。
|
||||
func colorBorder() color.Color {
|
||||
return withAlpha(lookupColor(theme.ColorNameInputBorder), 0xB0)
|
||||
return mix(lookupColor(theme.ColorNameBackground), lookupColor(theme.ColorNameForeground), 0.45)
|
||||
}
|
||||
|
||||
// colorStrongBorder 是需要格外醒目的描边(错误提示用)。
|
||||
func colorStrongBorder() color.Color {
|
||||
return lookupColor(theme.ColorNameForeground)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"fyne.io/fyne/v2/test"
|
||||
)
|
||||
|
||||
// nrgba 把颜色转成不透明的 NRGBA,便于直接比较。
|
||||
func nrgba(c color.Color) color.NRGBA {
|
||||
return color.NRGBAModel.Convert(c).(color.NRGBA)
|
||||
}
|
||||
|
||||
func TestBubbleColorsFollowTheme(t *testing.T) {
|
||||
test.NewApp()
|
||||
|
||||
applyThemeMode(ThemeContrast)
|
||||
darkBot := nrgba(colorFor(bubbleBot))
|
||||
darkErr := nrgba(colorFor(bubbleError))
|
||||
darkBorder := nrgba(borderFor(bubbleUser))
|
||||
darkStrong := nrgba(borderFor(bubbleError))
|
||||
|
||||
// 黑白主题:气泡底色应贴近纯黑,文字侧仍是纯白前景。
|
||||
if darkBot.R > 0x30 {
|
||||
t.Fatalf("黑白主题下助手气泡应贴近纯黑,实际 %v", darkBot)
|
||||
}
|
||||
if darkStrong.R != 0xFF {
|
||||
t.Fatalf("黑白主题下错误气泡描边应为纯白,实际 %v", darkStrong)
|
||||
}
|
||||
if darkBorder.R >= darkStrong.R {
|
||||
t.Fatalf("错误描边应比普通描边更亮:普通 %v,错误 %v", darkBorder, darkStrong)
|
||||
}
|
||||
|
||||
applyThemeMode(ThemeLight)
|
||||
lightBot := nrgba(colorFor(bubbleBot))
|
||||
lightErr := nrgba(colorFor(bubbleError))
|
||||
lightStrong := nrgba(borderFor(bubbleError))
|
||||
|
||||
if lightBot.R < 0xD0 {
|
||||
t.Fatalf("浅色主题下助手气泡应贴近纯白,实际 %v", lightBot)
|
||||
}
|
||||
if lightStrong.R != 0x00 {
|
||||
t.Fatalf("浅色主题下错误气泡描边应为纯黑,实际 %v", lightStrong)
|
||||
}
|
||||
if lightBot.R == darkBot.R {
|
||||
t.Fatal("切换主题后气泡底色应发生变化")
|
||||
}
|
||||
if lightErr.R == darkErr.R {
|
||||
t.Fatal("切换主题后错误气泡底色应发生变化")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatViewRethemeRepaintsBubbles(t *testing.T) {
|
||||
test.NewApp()
|
||||
applyThemeMode(ThemeContrast)
|
||||
|
||||
c := newChatView()
|
||||
b := newBubble("助手", bubbleBot)
|
||||
c.appendBubble(b)
|
||||
|
||||
before := nrgba(b.bg.FillColor)
|
||||
beforeStroke := nrgba(b.bg.StrokeColor)
|
||||
|
||||
applyThemeMode(ThemeLight)
|
||||
c.retheme()
|
||||
|
||||
after := nrgba(b.bg.FillColor)
|
||||
afterStroke := nrgba(b.bg.StrokeColor)
|
||||
if before == after {
|
||||
t.Fatalf("换肤后气泡底色应改变:%v -> %v", before, after)
|
||||
}
|
||||
if beforeStroke == afterStroke {
|
||||
t.Fatalf("换肤后气泡描边应改变:%v -> %v", beforeStroke, afterStroke)
|
||||
}
|
||||
if after != nrgba(colorFor(bubbleBot)) {
|
||||
t.Fatalf("换肤后底色应与当前主题一致:%v != %v", after, nrgba(colorFor(bubbleBot)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
)
|
||||
|
||||
// 主题模式。
|
||||
const (
|
||||
ThemeSystem = "system" // 跟随系统
|
||||
ThemeContrast = "contrast" // 黑白高对比
|
||||
ThemeLight = "light" // 浅色高对比
|
||||
)
|
||||
|
||||
// themeLabel 返回主题模式的中文名。
|
||||
func themeLabel(mode string) string {
|
||||
switch mode {
|
||||
case ThemeContrast:
|
||||
return "黑白高对比"
|
||||
case ThemeLight:
|
||||
return "浅色高对比"
|
||||
default:
|
||||
return "跟随系统"
|
||||
}
|
||||
}
|
||||
|
||||
// themeOptions 返回设置页下拉里的可选项。
|
||||
func themeOptions() []string {
|
||||
return []string{
|
||||
themeLabel(ThemeSystem),
|
||||
themeLabel(ThemeContrast),
|
||||
themeLabel(ThemeLight),
|
||||
}
|
||||
}
|
||||
|
||||
// themeFromLabel 由中文名反查主题模式。
|
||||
func themeFromLabel(label string) string {
|
||||
for _, mode := range []string{ThemeSystem, ThemeContrast, ThemeLight} {
|
||||
if themeLabel(mode) == label {
|
||||
return mode
|
||||
}
|
||||
}
|
||||
return ThemeSystem
|
||||
}
|
||||
|
||||
// applyThemeMode 按主题模式切换界面主题。
|
||||
func applyThemeMode(mode string) {
|
||||
app := fyne.CurrentApp()
|
||||
if app == nil {
|
||||
return
|
||||
}
|
||||
switch mode {
|
||||
case ThemeContrast:
|
||||
app.Settings().SetTheme(newMonoTheme(darkPalette()))
|
||||
case ThemeLight:
|
||||
app.Settings().SetTheme(newMonoTheme(lightPalette()))
|
||||
default:
|
||||
app.Settings().SetTheme(theme.DefaultTheme())
|
||||
}
|
||||
}
|
||||
|
||||
// monoTheme 是一套纯灰度主题:所有颜色都去掉彩度,只靠明暗区分层级,
|
||||
// 文字与背景的对比拉到最高。
|
||||
type monoTheme struct {
|
||||
base fyne.Theme
|
||||
colors map[fyne.ThemeColorName]color.Color
|
||||
}
|
||||
|
||||
// newMonoTheme 用给定配色造一个主题,其余项沿用默认主题。
|
||||
func newMonoTheme(palette map[fyne.ThemeColorName]color.Color) fyne.Theme {
|
||||
return &monoTheme{base: theme.DefaultTheme(), colors: palette}
|
||||
}
|
||||
|
||||
func (t *monoTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
|
||||
if c, ok := t.colors[name]; ok {
|
||||
return c
|
||||
}
|
||||
return t.base.Color(name, variant)
|
||||
}
|
||||
|
||||
func (t *monoTheme) Font(style fyne.TextStyle) fyne.Resource { return t.base.Font(style) }
|
||||
|
||||
func (t *monoTheme) Icon(name fyne.ThemeIconName) fyne.Resource { return t.base.Icon(name) }
|
||||
|
||||
func (t *monoTheme) Size(name fyne.ThemeSizeName) float32 { return t.base.Size(name) }
|
||||
|
||||
// rgb 生成不透明灰度色。
|
||||
func rgb(v uint8) color.NRGBA {
|
||||
return color.NRGBA{R: v, G: v, B: v, A: 0xFF}
|
||||
}
|
||||
|
||||
// darkPalette 是黑白高对比(纯黑底)的配色。
|
||||
func darkPalette() map[fyne.ThemeColorName]color.Color {
|
||||
return map[fyne.ThemeColorName]color.Color{
|
||||
theme.ColorNameBackground: rgb(0x00),
|
||||
theme.ColorNameForeground: rgb(0xFF),
|
||||
theme.ColorNameDisabled: rgb(0x8C),
|
||||
theme.ColorNameDisabledButton: rgb(0x1A),
|
||||
theme.ColorNameButton: rgb(0x2E),
|
||||
theme.ColorNameHover: rgb(0x4A),
|
||||
theme.ColorNamePressed: rgb(0x1E),
|
||||
theme.ColorNameFocus: rgb(0xFF),
|
||||
theme.ColorNamePrimary: rgb(0xFF),
|
||||
theme.ColorNameForegroundOnPrimary: rgb(0x00),
|
||||
theme.ColorNameSelection: rgb(0xFF),
|
||||
theme.ColorNameInputBackground: rgb(0x0B),
|
||||
theme.ColorNameInputBorder: rgb(0xE6),
|
||||
theme.ColorNamePlaceHolder: rgb(0xA6),
|
||||
theme.ColorNameSeparator: rgb(0x5A),
|
||||
theme.ColorNameOverlayBackground: rgb(0x11),
|
||||
theme.ColorNameMenuBackground: rgb(0x16),
|
||||
theme.ColorNameHeaderBackground: rgb(0x1C),
|
||||
theme.ColorNameInnerWindowBorder: rgb(0x3A),
|
||||
theme.ColorNameInnerWindowBorderInactive: rgb(0x26),
|
||||
theme.ColorNameScrollBar: rgb(0x9A),
|
||||
theme.ColorNameScrollBarBackground: rgb(0x1A),
|
||||
theme.ColorNameShadow: rgb(0x00),
|
||||
theme.ColorNameError: rgb(0xFF),
|
||||
theme.ColorNameForegroundOnError: rgb(0x00),
|
||||
theme.ColorNameSuccess: rgb(0xFF),
|
||||
theme.ColorNameForegroundOnSuccess: rgb(0x00),
|
||||
theme.ColorNameWarning: rgb(0xFF),
|
||||
theme.ColorNameForegroundOnWarning: rgb(0x00),
|
||||
theme.ColorNameHyperlink: rgb(0xFF),
|
||||
}
|
||||
}
|
||||
|
||||
// lightPalette 是浅色高对比(纯白底)的配色。
|
||||
func lightPalette() map[fyne.ThemeColorName]color.Color {
|
||||
return map[fyne.ThemeColorName]color.Color{
|
||||
theme.ColorNameBackground: rgb(0xFF),
|
||||
theme.ColorNameForeground: rgb(0x00),
|
||||
theme.ColorNameDisabled: rgb(0x70),
|
||||
theme.ColorNameDisabledButton: rgb(0xF0),
|
||||
theme.ColorNameButton: rgb(0xE0),
|
||||
theme.ColorNameHover: rgb(0xC8),
|
||||
theme.ColorNamePressed: rgb(0xD4),
|
||||
theme.ColorNameFocus: rgb(0x00),
|
||||
theme.ColorNamePrimary: rgb(0x00),
|
||||
theme.ColorNameForegroundOnPrimary: rgb(0xFF),
|
||||
theme.ColorNameSelection: rgb(0x00),
|
||||
theme.ColorNameInputBackground: rgb(0xFF),
|
||||
theme.ColorNameInputBorder: rgb(0x00),
|
||||
theme.ColorNamePlaceHolder: rgb(0x70),
|
||||
theme.ColorNameSeparator: rgb(0x9A),
|
||||
theme.ColorNameOverlayBackground: rgb(0xF4),
|
||||
theme.ColorNameMenuBackground: rgb(0xFA),
|
||||
theme.ColorNameHeaderBackground: rgb(0xEF),
|
||||
theme.ColorNameInnerWindowBorder: rgb(0xC0),
|
||||
theme.ColorNameInnerWindowBorderInactive: rgb(0xD8),
|
||||
theme.ColorNameScrollBar: rgb(0x55),
|
||||
theme.ColorNameScrollBarBackground: rgb(0xED),
|
||||
theme.ColorNameShadow: rgb(0x00),
|
||||
theme.ColorNameError: rgb(0x00),
|
||||
theme.ColorNameForegroundOnError: rgb(0xFF),
|
||||
theme.ColorNameSuccess: rgb(0x00),
|
||||
theme.ColorNameForegroundOnSuccess: rgb(0xFF),
|
||||
theme.ColorNameWarning: rgb(0x00),
|
||||
theme.ColorNameForegroundOnWarning: rgb(0xFF),
|
||||
theme.ColorNameHyperlink: rgb(0x00),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/test"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
)
|
||||
|
||||
// isGray 判断颜色是否是无彩度的灰度色。
|
||||
func isGray(c color.Color) bool {
|
||||
n := color.NRGBAModel.Convert(c).(color.NRGBA)
|
||||
return n.R == n.G && n.G == n.B
|
||||
}
|
||||
|
||||
func TestThemeLabelRoundTrip(t *testing.T) {
|
||||
for _, mode := range []string{ThemeSystem, ThemeContrast, ThemeLight} {
|
||||
label := themeLabel(mode)
|
||||
if got := themeFromLabel(label); got != mode {
|
||||
t.Fatalf("主题 %q 的中文名 %q 反查应为 %q,实际 %q", mode, label, mode, got)
|
||||
}
|
||||
}
|
||||
if got := themeFromLabel("不存在的名字"); got != ThemeSystem {
|
||||
t.Fatalf("未知中文名应回落到 %q,实际 %q", ThemeSystem, got)
|
||||
}
|
||||
if len(themeOptions()) != 3 {
|
||||
t.Fatalf("主题下拉应有 3 个选项,实际 %v", themeOptions())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDarkPaletteIsPureBlackOnWhite(t *testing.T) {
|
||||
p := darkPalette()
|
||||
|
||||
black := color.NRGBA{R: 0, G: 0, B: 0, A: 0xFF}
|
||||
white := color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}
|
||||
if got := p[theme.ColorNameBackground]; got != black {
|
||||
t.Fatalf("黑白主题背景应为纯黑,实际 %v", got)
|
||||
}
|
||||
if got := p[theme.ColorNameForeground]; got != white {
|
||||
t.Fatalf("黑白主题文字应为纯白,实际 %v", got)
|
||||
}
|
||||
for name, c := range p {
|
||||
if !isGray(c) {
|
||||
t.Fatalf("黑白主题不应出现彩色:%s = %v", name, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightPaletteIsPureWhiteOnBlack(t *testing.T) {
|
||||
p := lightPalette()
|
||||
|
||||
black := color.NRGBA{R: 0, G: 0, B: 0, A: 0xFF}
|
||||
white := color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}
|
||||
if got := p[theme.ColorNameBackground]; got != white {
|
||||
t.Fatalf("浅色主题背景应为纯白,实际 %v", got)
|
||||
}
|
||||
if got := p[theme.ColorNameForeground]; got != black {
|
||||
t.Fatalf("浅色主题文字应为纯黑,实际 %v", got)
|
||||
}
|
||||
for name, c := range p {
|
||||
if !isGray(c) {
|
||||
t.Fatalf("浅色高对比主题不应出现彩色:%s = %v", name, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonoThemeFallsBackToDefault(t *testing.T) {
|
||||
th := newMonoTheme(darkPalette())
|
||||
|
||||
black := color.NRGBA{R: 0, G: 0, B: 0, A: 0xFF}
|
||||
// 调色板里有的走调色板。
|
||||
if got := th.Color(theme.ColorNameBackground, theme.VariantDark); got != black {
|
||||
t.Fatalf("背景色应取自调色板,实际 %v", got)
|
||||
}
|
||||
// 调色板里没有的沿用默认主题。
|
||||
if got := th.Color(theme.ColorNameOverlayBackground, theme.VariantDark); got == nil {
|
||||
t.Fatal("未覆盖的颜色名应回落到默认主题")
|
||||
}
|
||||
if th.Font(fyne.TextStyle{}) == nil || th.Size(theme.SizeNamePadding) <= 0 {
|
||||
t.Fatal("字体与尺寸应沿用默认主题")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyThemeModeSwitchesTheme(t *testing.T) {
|
||||
app := test.NewApp()
|
||||
|
||||
black := color.NRGBA{R: 0, G: 0, B: 0, A: 0xFF}
|
||||
white := color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}
|
||||
|
||||
applyThemeMode(ThemeContrast)
|
||||
mt, ok := app.Settings().Theme().(*monoTheme)
|
||||
if !ok {
|
||||
t.Fatalf("黑白模式应切到 monoTheme,实际 %T", app.Settings().Theme())
|
||||
}
|
||||
if got := mt.Color(theme.ColorNameBackground, app.Settings().ThemeVariant()); got != black {
|
||||
t.Fatalf("黑白模式背景应为纯黑,实际 %v", got)
|
||||
}
|
||||
|
||||
applyThemeMode(ThemeLight)
|
||||
mt, ok = app.Settings().Theme().(*monoTheme)
|
||||
if !ok {
|
||||
t.Fatalf("浅色模式应切到 monoTheme,实际 %T", app.Settings().Theme())
|
||||
}
|
||||
if got := mt.Color(theme.ColorNameBackground, app.Settings().ThemeVariant()); got != white {
|
||||
t.Fatalf("浅色模式背景应为纯白,实际 %v", got)
|
||||
}
|
||||
|
||||
applyThemeMode(ThemeSystem)
|
||||
if _, ok := app.Settings().Theme().(*monoTheme); ok {
|
||||
t.Fatal("跟随系统应还原默认主题")
|
||||
}
|
||||
if got := app.Settings().Theme().Color(theme.ColorNameBackground, app.Settings().ThemeVariant()); got == nil {
|
||||
t.Fatal("默认主题应能取到背景色")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user