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: ,默认附带 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 "}, {"提示词", "作为一条 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 }