feat(search): 面板上终于能改「结果条数上限」(原来改了会被静默丢弃)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

真缺陷(上一片登记):`maxResults` 在面板上**根本改不了** —— ① 字段表里没有这个
字段(UI 不显示输入框);② `saveProvider` 只并 provider/apiKey/baseUrl/enabled;
③ `mergeSelectionPatch` 只处理 primary/fallback ⇒ **手工塞进 values 也会被静默丢弃**
(文件不变、也不报错)。「我改了它却没变」正是要消灭的那种假话。

- `panel.go`:表单加 `{Key: FieldMaxResults, Type: sdk.FieldNumber, Min: 1}`(**SDK 本就有
  数字类型**,未自造;前端本就渲染 number ⇒ **不需要改前端**)。刻意不设 `Default`
  (设了会让一次「只改 key」的保存把默认值固化进文件、还可能盖掉 `.env`)。
- `websearch_selection.go`:`mergeSelectionPatch` 里按**三态**落 `maxResults` ——
  缺位/null ⇒ **不改动**;正整数(含整数字符串)⇒ 采用;0/负数/小数/空串/非数/
  布尔/数组 ⇒ **400 `invalid_payload`** 且**盘上一字节不动**(绝不静默忽略)。
- `reauth` 回填该值(否则一保存就把上限清掉)。
- `.env.example`:该键的示例值与「默认 5」注释同步为 **10**(代码默认已改;⚠️ `.env` 未动)。

验收(主会话复跑 + 亲自变异):**整树 `go build ./...` 通过**;三包 vet/测试绿;
端到端「面板存 20 ⇒ 模型要 50 也只给 20、要 3 给 3」(走真 action hub → 落盘 →
热重载 → WithSearchMaxResults → 真 dispatcher,未重启后端);
我用 -overlay 把 `f.MaxResults = maxResults` 摘掉 ⇒ 两条用例变红
(「面板给的值必须真的落盘」/「回填的 maxResults = 5, want 20」)。

⚠️ 已知取舍(如实登记):**清空输入框 = 400**(后端按「非数 ⇒ 报错」拒),因此面板上
没有「去掉上限」的手势 —— 要回到 `.env` 兜底得改文件;要支持「清空 = 去掉覆盖」
需另定哨兵语义,未自行发挥。
⚠️ 契约面已变更(面板声明多一个 number 字段 + saveProvider 的 values 多一个可写键 +
响应多一个回显键)⇒ 主会话登记。
This commit is contained in:
AskaEth committed 2026-10-07 11:10:56 +08:00
1 parent 8325d1bb8c
commit c68387a69a
8 files changed
+387 -31

No files matched your search

+30 -8
View File
@@ -48,17 +48,25 @@ func ActionNames() []string {
return []string{ActionListProviders, ActionSaveProvider, ActionTestProvider, ActionReauthProvider}
}
// 表单里两个**文件级**字段的 key(切片 W3;裁决 2026-10-06 #2)。
// 表单里三个**文件级**字段的 key(切片 W3;裁决 2026-10-06 #2;`maxResults` 为后续切片增补)。
//
// 与其余字段的区别:`provider` / `apiKey` / `baseUrl` / `enabled` 描述**一条 provider 条目**,
// 而这两个描述**整份配置的选择** —— design/27 §3 的「主后端 + 降级链」(可变源里的文件级
// `provider` / `fallback`)。它们必须与组合根动作面读的 key 逐字一致(那里引用这两个常量,
// 不另写字面量):声明里写一个 key、处理器读另一个,表现就是「我选了主提供商但它没变」。
// 而这三个描述**整份配置** —— design/27 §3 的「主后端 + 降级链」(可变源里的文件级
// `provider` / `fallback`)与单次检索的条数上限。它们必须与组合根动作面读的 key 逐字一致
// (那里引用这些常量,不另写字面量):声明里写一个 key、处理器读另一个,表现就是
// 「我选了主提供商但它没变」/「我改了上限但它没变」。
const (
// FieldPrimary 是主 provider 的字段 key。
FieldPrimary = "primary"
// FieldFallback 是降级链的字段 key(多选,按顺序依次尝试)。
FieldFallback = "fallback"
// FieldMaxResults 是单次检索条数上限的字段 key,**逐字等于**可变源 `web-search.json` 的
// 顶层键名(panel_test.go 用一次 marshal 钉住这层同源)。
//
// 运行期语义(io 包的 resultLimit):它是**真上限** —— 模型给的 `count` 只能要得**更少**;
// 面板里存了它,就压过 `.env` 的 `WEB_SEARCH_MAX_RESULTS`(那是**播种层**的键,
// 见 design/13 §2.1 与 cmd/cyrene/wire_io_tools.go 的 effectiveSearchConfig)。
FieldMaxResults = "maxResults"
)
// AdminPanels 返回本插件声明的管理页面(design/09 §5.2 示例的 providers 页)。
@@ -73,7 +81,12 @@ const (
// `primary` / `fallback`(主 provider 与降级链)。它们跟同一条 form 走 submit 而不是另开
// 一个 form 区块,是因为前端的「带回表单」(reauth)只在面板**恰好一个** form 时转交:
// 加第二个 form 会让既有的「重配凭据」整条回填失灵(那是客户端行为,本片不改前端)。
// 两个字段的写侧语义见 `cmd/cyrene/websearch_selection.go`。
//
// 第四处增补(切片:结果条数上限真能改):表单末尾再加一个**文件级**数字字段
// `maxResults`(结果条数上限),理由与第三处逐条相同(同一张 form、同一套三态写回)。
// 它此前只活在可变源与 `.env` 里 —— 面板上没有输入框(改不了),手工塞进提交体又会被写侧
// 静默丢弃,于是「上限到底是 10 还是 20」只能靠手改文件。三个字段的写侧语义见
// `cmd/cyrene/websearch_selection.go`。
func AdminPanels() []sdk.Panel {
return []sdk.Panel{{
ID: PanelIDProviders,
@@ -104,15 +117,21 @@ func AdminPanels() []sdk.Panel {
{Key: "baseUrl", Label: "接口地址", Type: sdk.FieldText,
Placeholder: "https://..."},
{Key: "enabled", Label: "启用", Type: sdk.FieldSwitch},
// 下面两项是**文件级**的(不是上面这条 provider 的字段):谁先用、
// 失败后按什么顺序降级。留空 = 不改动;primary 写成 off / none = 关掉
// 整把 web-search(模型看不见它)。
// 下面**三项**是**文件级**的(不是上面这条 provider 的字段):谁先用、
// 失败后按什么顺序降级、单次最多几条。留空 = 不改动;primary 写成
// off / none = 关掉整把 web-search(模型看不见它)。
{Key: FieldPrimary, Label: "主提供商", Type: sdk.FieldSelect,
OptionsFrom: "plugin:providers",
Placeholder: "留空 = 不改动;写 off / none = 关掉 web-search"},
{Key: FieldFallback, Label: "降级链", Type: sdk.FieldMultiSelect,
OptionsFrom: "plugin:providers",
Placeholder: "按顺序依次尝试(逗号分隔);留空 = 不改动"},
// 结果条数上限:**运行期真上限**,填了就压过 `.env` 的 WEB_SEARCH_MAX_RESULTS
// (那一项只是播种层)。类型是 number(闭集里只有它接受 min/max),min=1
// 让前端的数字控件先拦住 0 / 负数;写侧对 ≤0 与非整数一律 400,不静默忽略。
{Key: FieldMaxResults, Label: "结果条数上限(默认 10)", Type: sdk.FieldNumber,
Min: floatFlag(1),
Placeholder: "填了它压过 .env;模型要更多也只给这么多"},
},
Submit: &sdk.Submit{Action: ActionSaveProvider},
},
@@ -137,3 +156,6 @@ func AdminPanels() []sdk.Panel {
// boolFlag 是 `&true` / `&false` 的简写(sdk.When.Enabled 是三态指针)。
func boolFlag(v bool) *bool { return &v }
// floatFlag 是 `&1.0` 的简写(sdk.Field.Min / Max 是 `*float64`:缺省表示没声明边界)。
func floatFlag(v float64) *float64 { return &v }
+44
View File
@@ -349,3 +349,47 @@ func contains(list []string, want string) bool {
}
return false
}
// 判据 ⑪(切片:结果条数上限真能改):表单里必须有承载「结果条数上限」的 **number** 字段。
//
// 来由是一次真机缺陷:可变源与 `.env` 都认 `maxResults`,但声明里没有这个字段 ⇒ 面板上
// 根本没有输入框(「改不了」),而手工把它塞进提交体又会被写侧静默丢弃。声明字段是这条链
// 的头一环,因此在这里钉住它的类型、边界与 key 同源。
func TestAdminPanelsDeclareMaxResultsField(t *testing.T) {
t.Parallel()
form := adminPanelsFormSection(t)
var got *sdk.Field
for i := range form.Fields {
if form.Fields[i].Key == FieldMaxResults {
got = &form.Fields[i]
}
}
if got == nil {
t.Fatalf("表单里缺 %q 字段(上限在面板上就改不了): %+v", FieldMaxResults, form.Fields)
}
if got.Type != sdk.FieldNumber {
t.Errorf("字段 %q 类型 = %q, want %q(闭集里只有 number 是数字控件)", got.Key, got.Type, sdk.FieldNumber)
}
if got.Min == nil || *got.Min < 1 {
t.Errorf("字段 %q 的 min = %v, want ≥1(0 / 负数在文件里是「未声明」,面板不该把它当成一个合法上限)",
got.Key, got.Min)
}
if strings.TrimSpace(got.Label) == "" || strings.TrimSpace(got.Placeholder) == "" {
t.Errorf("字段 %q 缺标签或说明(label=%q placeholder=%q):「它会压过 .env、是运行期上限」"+
"这件事必须在界面上说清", got.Key, got.Label, got.Placeholder)
}
// key 必须与**落盘真源**同源:声明写一个 key、文件里叫另一个 = 「我改了上限但它没变」。
raw, err := json.Marshal(runtimeconfig.WebSearchFile{MaxResults: 3})
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(raw), `"`+FieldMaxResults+`"`) {
t.Errorf("字段 key %q 与 web-search.json 的键名对不上(落盘真源 = %s)", FieldMaxResults, raw)
}
// 不得撞上条目级字段:撞了就是「我在改谁」要猜。
for _, taken := range []string{"provider", "apiKey", "baseUrl", "enabled"} {
if FieldMaxResults == taken {
t.Fatalf("文件级字段的 key 撞上了条目级字段 %q", taken)
}
}
}