Files
deskview/internal/ui/settings.go
T
2026-10-27 17:58:28 +08:00

377 lines
10 KiB
Go

package ui
import (
"context"
"fmt"
"os/exec"
"strconv"
"strings"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
"viewsagent/internal/config"
)
const settingsTitle = "viewsagent-settings"
// settingsWindow 是模型、触发策略、提示词与热键的设置面板。
type settingsWindow struct {
app *App
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
// 提示词
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
}
func newSettingsWindow(app *App) *settingsWindow {
s := &settingsWindow{app: app}
s.win = app.fyne.NewWindow(settingsTitle)
s.win.Resize(fyne.NewSize(620, 620))
s.win.SetContent(container.NewAppTabs(
container.NewTabItem("模型接入", s.buildProfileTab()),
container.NewTabItem("提示词", s.buildTemplateTab()),
container.NewTabItem("抓屏与触发", s.buildCaptureTab()),
container.NewTabItem("其它", s.buildMiscTab()),
))
return s
}
func (s *settingsWindow) show() {
s.reload()
s.win.Show()
}
// 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.profileSel.Options = names
s.profileSel.SetSelected(s.cfg.ActiveProfile)
s.applyProfile()
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)
}
func entry(label string, obj fyne.CanvasObject) fyne.CanvasObject {
return container.NewBorder(nil, nil, widget.NewLabel(label), nil, obj)
}
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()
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),
)
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() {
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),
)
}
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() {
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
}
}
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.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(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)
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.commitAll()
if err := s.app.applyAndSave(s.cfg); err != nil {
dialog.ShowError(err, s.win)
return
}
dialog.ShowInformation("已保存", "热键修改需要重启程序生效", s.win)
})
form := container.NewVBox(
entry("框选热键", s.hkSelect),
entry("监听开关热键", s.hkToggle),
s.alwaysTop,
container.NewHBox(openDir),
widget.NewLabel("热键语法示例:Ctrl+Alt+Q、Ctrl+Shift+F2"),
)
return container.NewBorder(nil,
container.NewVBox(widget.NewSeparator(), container.NewHBox(saveBtn)),
nil, nil,
form,
)
}
// 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 {
return
}
for _, t := range s.cfg.Templates {
if t.Key == s.tmplSel.Selected {
s.tmplSystem.SetText(t.System)
return
}
}
}
// 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),
}
}
// 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
}
}
if s.profileSel.Selected != "" {
s.cfg.ActiveProfile = s.profileSel.Selected
}
if key := s.tmplSel.Selected; key != "" {
s.cfg.PromptTemplate = key
}
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)
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()
}
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
}