优化代码

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

+546 -148
View File
@@ -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 {