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 }