775 lines
22 KiB
Go
775 lines
22 KiB
Go
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
|
||
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
|
||
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))
|
||
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.profileList.Refresh()
|
||
idx := s.cfg.ProfileIndex(s.cfg.Active)
|
||
if idx < 0 {
|
||
idx = 0
|
||
}
|
||
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 {
|
||
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.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)) }
|
||
|
||
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.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.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 = "请选择模板"
|
||
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.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("冷却时间 (ms)", s.cooldown),
|
||
entry("稳定帧数", s.stable),
|
||
entry("历史轮数", s.turns),
|
||
widget.NewLabel("说明:变化阈值越小越灵敏(默认 5);冷却时间决定两次自动提问的最小间隔。"),
|
||
)
|
||
|
||
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.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
|
||
}
|