Files
deskview/internal/ui/settings.go
T
2026-10-28 08:47:09 +08:00

775 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}