feat(search): 面板上终于能改「结果条数上限」(原来改了会被静默丢弃)
真缺陷(上一片登记):`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:
1 parent
8325d1bb8c
commit
c68387a69a
8 files changed
+387
-31
No files matched your search
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user