Files
2026-10-28 17:39:33 +08:00

859 lines
25 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ui
import (
"context"
"errors"
"fmt"
"os/exec"
"sort"
"strconv"
"strings"
"time"
"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"
// settingsWindow 是模型、触发策略、提示词与热键的设置面板。
type settingsWindow struct {
app *App
win fyne.Window
cfg *config.Config
// 模型接入:左边是接入点列表,右边是当前接入点的接口详情。
profileList *widget.List
emptyHint *widget.Label
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
tmplSystem *widget.Entry
// 抓屏与触发
fps *widget.Entry
maxEdge *widget.Entry
quality *widget.Entry
diff *widget.Entry
sens *widget.Entry
cooldown *widget.Entry
stable *widget.Entry
turns *widget.Entry
// 热键与其它
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(900, 700))
// 点关闭只是隐藏窗口:Fyne 的窗口一旦 Close 就被销毁,
// 工具栏的设置按钮会再也打不开它。
s.win.SetCloseIntercept(func() { s.win.Hide() })
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
}
func (s *settingsWindow) show() {
s.reload()
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()
s.loading = true
s.selIndex = -1
s.syncSelection()
s.loading = false
s.refreshList()
keys := make([]string, 0, len(s.cfg.Templates))
for _, t := range s.cfg.Templates {
keys = append(keys, t.Key)
}
s.tmplSel.Options = keys
s.tmplSel.SetSelected(s.cfg.PromptTemplate)
s.applyTemplate()
c := s.cfg.Capture
s.fps.SetText(fmt.Sprintf("%g", c.FPS))
s.maxEdge.SetText(strconv.Itoa(c.MaxEdge))
s.quality.SetText(strconv.Itoa(c.JPEGQuality))
s.diff.SetText(strconv.Itoa(c.DiffThreshold))
s.sens.SetText(fmt.Sprintf("%g", c.DiffSensitivity))
s.cooldown.SetText(strconv.Itoa(c.CooldownMs))
s.stable.SetText(strconv.Itoa(c.StableFrames))
s.turns.SetText(strconv.Itoa(c.HistoryTurns))
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))
}
// ---------- 模型页 ----------
// buildModelTab 组装模型页:左边是接入点列表,右边是当前接入点的接口详情。
func (s *settingsWindow) buildModelTab() fyne.CanvasObject {
s.buildModelFields()
split := container.NewHSplit(s.buildProfilePane(), s.buildDetailPane())
split.Offset = 0.24
return split
}
// 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)) }
s.emptyHint = widget.NewLabel("还没有接入点,点「新增」添加一个")
s.emptyHint.Importance = widget.LowImportance
s.emptyHint.Hide()
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(), s.emptyHint)
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.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),
)
testBtn := widget.NewButton("测试连接", func() { s.testConnection() })
saveBtn := widget.NewButton("保存并应用", func() { s.saveAll() })
bottom := container.NewVBox(widget.NewSeparator(), container.NewHBox(testBtn, saveBtn))
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.selectInList(s.cfg.ProfileIndex(p.Name))
s.applyNow("已新增接入点 " + 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.selectInList(s.cfg.ProfileIndex(cp.Name))
s.applyNow("已复制为接入点 " + cp.Name)
}
// selectInList 选中列表里的第 i 项,并把右边的表单切过去。
func (s *settingsWindow) selectInList(i int) {
if i < 0 || i >= len(s.cfg.Models) {
return
}
s.selIndex = i
// 先取消再选,保证 OnSelected 一定回调,表单随之刷新。
s.profileList.UnselectAll()
s.profileList.Select(i)
}
// deleteProfile 删除当前接入点。
// 删除立即生效:直接应用到引擎并落盘,关掉设置窗口也不会"复活"。
func (s *settingsWindow) deleteProfile() {
p, ok := s.profileAt(s.selIndex)
if !ok {
return
}
// 注意用 ShowConfirm:NewConfirm 只创建不显示,点「删除」会毫无反应。
dialog.ShowConfirm("删除接入点", "确定删除「"+p.Name+"」?", func(confirmed bool) {
if !confirmed {
return
}
if !s.cfg.RemoveModel(p.Name) {
return
}
if s.cfg.ProfileIndex(s.cfg.Active) < 0 {
s.cfg.Active = ""
}
s.applyNow("已删除接入点 " + p.Name)
}, s.win)
}
// activateProfile 把当前接入点设为生效项。
func (s *settingsWindow) activateProfile() {
p, ok := s.profileAt(s.selIndex)
if !ok {
return
}
s.cfg.Active = p.Name
s.applyNow("已切换到接入点 " + p.Name)
}
// applyNow 把当前改动立即应用到引擎并落盘。
//
// 接入点列表的增删改属于结构变化,必须当场生效:只改设置页里的副本的话,
// 关掉窗口就丢了,用户会以为"删不掉"。
func (s *settingsWindow) applyNow(notice string) {
// 配置里至少要保留一个接入点,删光后 Normalize 会补一个空白项。
emptied := len(s.cfg.Models) == 0
if err := s.app.applyAndSave(s.cfg); err != nil {
fyne.Do(func() { s.app.chat.addNotice("操作失败:" + err.Error()) })
return
}
s.syncSelection()
s.refreshList()
if emptied {
notice += "(接入点已清空,点「新增」可以再加一个)"
}
if notice != "" {
fyne.Do(func() { s.app.chat.addNotice(notice) })
}
}
// syncSelection 让列表选中项、右边表单和配置保持一致。
//
// 不能只靠 List.Select 的回调:Fyne 在 id 没变时会直接返回,
// 不会触发 OnSelected,表单就会继续显示已经删掉的接入点。
func (s *settingsWindow) syncSelection() {
if len(s.cfg.Models) == 0 {
s.selIndex = -1
s.profileList.UnselectAll()
s.clearProfileForm()
return
}
idx := s.selIndex
if idx < 0 || idx >= len(s.cfg.Models) {
idx = s.cfg.ProfileIndex(s.cfg.Active)
}
if idx < 0 || idx >= len(s.cfg.Models) {
idx = 0
}
s.selectInList(idx)
}
// refreshList 刷新列表,并按接入点数量显示/隐藏空列表提示。
func (s *settingsWindow) refreshList() {
s.profileList.Refresh()
if s.emptyHint == nil {
return
}
if len(s.cfg.Models) == 0 {
s.emptyHint.Show()
} else {
s.emptyHint.Hide()
}
}
// clearProfileForm 清空右边的表单(一个接入点都没有时用)。
func (s *settingsWindow) clearProfileForm() {
prev := s.loading
s.loading = true
s.nameEntry.SetText("")
s.baseEntry.SetText("")
s.modelEntry.SetText("")
s.keyEntry.SetText("")
s.maxEntry.SetText("")
s.tempEntry.SetText("")
s.headerEntry.SetText("")
s.loading = prev
}
// 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 = "请选择模板"
s.tmplSystem = widget.NewMultiLineEntry()
s.tmplSystem.SetMinRowsVisible(14)
s.tmplSystem.Wrapping = fyne.TextWrapWord
saveBtn := widget.NewButton("保存提示词", func() {
s.commitTemplate()
if err := s.app.applyAndSave(s.cfg); err != nil {
dialog.ShowError(err, s.win)
return
}
dialog.ShowInformation("已保存", "提示词已更新", s.win)
})
return container.NewBorder(
container.NewVBox(entry("模板", s.tmplSel), widget.NewSeparator()),
container.NewVBox(widget.NewSeparator(), container.NewHBox(saveBtn)),
nil, nil,
s.tmplSystem,
)
}
func (s *settingsWindow) buildCaptureTab() fyne.CanvasObject {
s.fps = widget.NewEntry()
s.maxEdge = widget.NewEntry()
s.quality = widget.NewEntry()
s.diff = widget.NewEntry()
s.sens = widget.NewEntry()
s.cooldown = widget.NewEntry()
s.stable = widget.NewEntry()
s.turns = widget.NewEntry()
form := container.NewVBox(
entry("监听帧率 (fps)", s.fps),
entry("图片长边上限", s.maxEdge),
entry("JPEG 质量", s.quality),
entry("变化阈值", s.diff),
entry("变化灵敏度", s.sens),
entry("冷却时间 (ms)", s.cooldown),
entry("稳定帧数", s.stable),
entry("历史轮数", s.turns),
widget.NewLabel("说明:变化阈值越小越灵敏(默认 5,基于感知哈希);变化灵敏度是细节网格的归一化平均差阈值(0~1,越小越灵敏,默认 0.01),专门用来捕捉「布局没变、只是文字/题目变了」这类更新。两者任一超阈值即触发。"),
)
saveBtn := widget.NewButton("保存并应用", func() {
s.saveAll()
})
return container.NewBorder(nil,
container.NewVBox(widget.NewSeparator(), container.NewHBox(saveBtn)),
nil, nil,
container.NewVScroll(form),
)
}
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()
if err := exec.Command("explorer", dir).Start(); err != nil {
dialog.ShowError(err, s.win)
}
})
saveBtn := widget.NewButton("保存并应用", func() {
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,
container.NewVBox(widget.NewSeparator(), container.NewHBox(saveBtn)),
nil, nil,
form,
)
}
// applyTemplate 把选中的模板填进编辑框。
func (s *settingsWindow) applyTemplate() {
if s.cfg == nil {
return
}
for _, t := range s.cfg.Templates {
if t.Key == s.tmplSel.Selected {
s.tmplSystem.SetText(t.System)
return
}
}
}
// ---------- 提交 ----------
// 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)
}
// 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
}
}
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)
s.cfg.Capture.DiffThreshold = atoiDefault(s.diff.Text, 5)
s.cfg.Capture.DiffSensitivity = atofDefault(s.sens.Text, 0.01)
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.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 {
v, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return def
}
return v
}
func atofDefault(s string, def float64) float64 {
v, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil {
return def
}
return v
}