377 lines
10 KiB
Go
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
|
|
}
|