diff --git a/.env.example b/.env.example index de2ab30..59f00a5 100644 --- a/.env.example +++ b/.env.example @@ -234,9 +234,10 @@ WEB_SEARCH_PROVIDER=searxng # 只在该家「不可用」(无 key / 鉴权失败 / 限流 / 额度 / 网络)时前进一格;结果里会 # 如实标注**实际应答**的 provider,绝不拿兜底冒充主力。空结果**不**触发前进。 # WEB_SEARCH_FALLBACK=zhipu,bocha,searxng -# WEB_SEARCH_MAX_RESULTS = 单次检索返回条数上限。默认 5;provider 自身的条数上限 -# (如博查 50、IQS 固定 10)优先于本键的下限。 -WEB_SEARCH_MAX_RESULTS=5 +# WEB_SEARCH_MAX_RESULTS = 单次检索返回条数上限(**播种层**的键:面板文件一旦落盘, +# 就以 $DATA_DIR/config/plugins/web-search.json 的 maxResults 为准,改面板即生效、不用重启)。 +# 默认 10;provider 自身的条数上限(如博查 50、IQS 固定 10)优先于本键的下限。 +WEB_SEARCH_MAX_RESULTS=10 # WEB_SEARCH_ENDPOINT = **searxng provider** 的端点(SearXNG 兼容,GET + format=json)。 # 默认值 http://127.0.0.1:38471 是**本机兜底**:scripts/searxng/settings.yml 就是它的 # 配套配置(docker 起法见该文件头注释),无需任何第三方 API key、开箱即用 —— 默认指向 diff --git a/cmd/cyrene/websearch_action_data.go b/cmd/cyrene/websearch_action_data.go index 79a80a5..3b06bcb 100644 --- a/cmd/cyrene/websearch_action_data.go +++ b/cmd/cyrene/websearch_action_data.go @@ -52,9 +52,11 @@ func (e webSearchActionEnv) listProviderOptions(_ context.Context, _ pluginmgr.A // - apiKey **空串** ⇒ 显式清空(前端只有勾了「清除」才会送出空串); // - baseUrl / enabled 缺位或空串 ⇒ 保留;给出(含 false / 空地址)⇒ 采用。 // -// 同一次提交里还可能带两个**文件级**字段(`primary` / `fallback`,切片 W3):它们不是这一条 -// provider 的属性,而是「谁先用 / 失败后按什么顺序降级」,因此合并到文件级而不是条目上 -// (解析与校验在 websearch_selection.go)。缺位 = 不改动,与其余字段同一套哨兵语义。 +// 同一次提交里还可能带三个**文件级**字段(`primary` / `fallback` / `maxResults`):它们不是 +// 这一条 provider 的属性,而是「谁先用 / 失败后按什么顺序降级 / 单次最多几条」,因此合并到 +// 文件级而不是条目上(解析与校验在 websearch_selection.go)。缺位 = 不改动;但给出了**不合法** +// 的值(如 `maxResults` ≤0 / 非整数)⇒ 整笔 400,**绝不静默丢弃** —— 静默丢弃会让用户以为 +// 上限已经改了,而跑的还是旧的那个(那正是这一片要消灭的那条「我改了它却没变」)。 // // 落盘之后由组合根的热重载事务接管(`websearch_reload.go`:重建工具 + 换注册表条目, // 失败则连同本次落盘一起回滚)—— 本函数只负责「把改动存进去」这一件事。 @@ -108,10 +110,12 @@ func (e webSearchActionEnv) saveProvider(_ context.Context, req pluginmgr.Action "provider": name, "created": !existed, "enabled": entry.IsEnabled(), - // 文件级选择的**落盘后**取值(没给 primary/fallback 时也如实回显当前值): - // 面板按它回显「谁先用」,也让「这次保存有没有动到选择」有可机读的答案。 + // 文件级取值的**落盘后**样子(没给这几个键时也如实回显当前值):面板按它回显 + // 「谁先用」,也让「这次保存有没有动到选择 / 上限」有可机读的答案。 "primary": strings.ToLower(strings.TrimSpace(final.Provider)), "fallback": selectionNames(final.Fallback), + // maxResults 的 0 = 文件里未声明(由 `.env` / 代码默认兜底),原样回显,不编一个假值。 + "maxResults": final.MaxResults, }, nil } @@ -167,6 +171,10 @@ func (e webSearchActionEnv) testProvider(ctx context.Context, req pluginmgr.Acti // 只把它算作「盘上已有值」⇒ 留空 = 不改动、要清空必须显式勾选; // - 其余字段回**盘上那条的原样**,不做默认值回填:回填代码默认地址会让一次 // 「只改 key」的保存把默认地址固化进文件(面板显示的是「该条」,不是「生效值」)。 +// +// 文件级字段(`maxResults` 在内)也按同一条口径回填:**盘上有值才回**。上限不回填的话表单 +// 里那个框是空的,用户既看不见当前上限、也无从改起;而盘上没写(0 = 未声明)时**不回** 0 —— +// 0 会被前端当成一个值送回来,写侧又拒 ≤0,那会让「只改 key」的保存整笔进不去。 func (e webSearchActionEnv) reauthProvider(_ context.Context, req pluginmgr.ActionRequest) (any, error) { name, err := providerNameOf(req.Payload) if err != nil { @@ -185,8 +193,9 @@ func (e webSearchActionEnv) reauthProvider(_ context.Context, req pluginmgr.Acti "baseUrl": entry.BaseURL, "enabled": !exists || entry.IsEnabled(), } - // 两个文件级字段一并回填:不然用户「清空降级链」在前端的三态写回里会落成缺位 - // (= 不改动),「我清掉了它」就成了一句假话。 + // 三个文件级字段一并回填(主 provider / 降级链 / 条数上限):不然用户「清空降级链」在前端 + // 的三态写回里会落成缺位(= 不改动),「我清掉了它」就成了一句假话;上限同理 —— + // 不回填就看不见盘上的值,改上限也就无从下手。 addSelectionValues(values, f) if masked, ok := maskedProviderEntry(f, name); ok && strings.TrimSpace(masked.APIKey) != "" { values["apiKey"] = masked.APIKey diff --git a/cmd/cyrene/websearch_maxresults_test.go b/cmd/cyrene/websearch_maxresults_test.go index fb02821..d0264f9 100644 --- a/cmd/cyrene/websearch_maxresults_test.go +++ b/cmd/cyrene/websearch_maxresults_test.go @@ -4,16 +4,22 @@ package main // (主人 2026-10-07 裁定;口径见 design/27 §3 与 `cmd/cyrene/wire_io_tools.go` 的 // `effectiveSearchConfig` —— 面板文件是可变源,`.env` 只播种)。 // +// 上半部分直接写盘(测投影:文件里的 `maxResults` 就是真上限);下半部分走**面板动作** +// (`plugin.saveProvider` 带上 `maxResults` ⇒ 热重载 ⇒ 下一次搜索真的最多那么多条), +// 并钉住三态:缺位 = 不改动、正整数 = 采用、其余 = 400 invalid_payload(绝不静默丢弃)。 +// // 夹具一律写进**本用例自己的** `$DATA_DIR`(`wsLoadConfig` 的 `t.TempDir()`): // 生产文件 `data/config/plugins/web-search.json` 不在测试的触碰范围内。 import ( + "bytes" "context" "fmt" "net/http" "strings" "testing" + pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" @@ -118,3 +124,163 @@ func TestWebSearchDefaultsToTenWithoutPanelOrEnvGivesTen(t *testing.T) { t.Fatalf("count = %d, want 10(代码默认值)", got) } } + +// --- 面板动作这一侧:存得进去、真的生效、非法值如实报错 ------------------------- + +// wsMaxResultsEnv 是一份「`.env` 给 envMaxResults、假上游答 30 条」的面板动作夹具 +// (热重载面 + 分派器都在:`newReloadTestEnv`)。`.env` 那个值是留给「面板压过 `.env`」 +// 这条判据当对照用的。 +func wsMaxResultsEnv(t *testing.T, envMaxResults int) (*reloadTestEnv, *wsFakeUpstream) { + t.Helper() + up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30)) + env := newReloadTestEnv(t, + "WEB_SEARCH_PROVIDER="+search.NameSearXNG, + "WEB_SEARCH_ENDPOINT="+up.url, + fmt.Sprintf("WEB_SEARCH_MAX_RESULTS=%d", envMaxResults), + ) + return env, up +} + +// TestWebSearchPanelSaveAppliesMaxResults 是本片的主判据(端到端):面板提交里带上 +// `maxResults: 20` ⇒ 落盘 20 ⇒ **热重载**后下一次搜索真的最多 20 条 —— `.env` 的 5 不作数, +// 也没有重启后端。 +func TestWebSearchPanelSaveAppliesMaxResults(t *testing.T) { + env, up := wsMaxResultsEnv(t, 5) + outcome := env.mustSave(t, map[string]any{ + "provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, + "maxResults": 20, + }) + if !outcome.Applied || !outcome.Registered { + t.Fatalf("hotReload = %+v, want applied 且 registered", outcome) + } + if got := env.loadFile(t).MaxResults; got != 20 { + t.Fatalf("盘上 maxResults = %d, want 20(面板给的值必须真的落盘)", got) + } + if got := wsSearchCount(t, env.app, map[string]any{"query": "cyrene", "count": 50}); got != 20 { + t.Fatalf("count = %d, want 20(模型要 50,上限是面板刚存下的 20 —— 且没重启)", got) + } + if got := wsSearchCount(t, env.app, map[string]any{"query": "cyrene", "count": 3}); got != 3 { + t.Fatalf("count = %d, want 3(模型要得更少时尊重模型)", got) + } +} + +// TestWebSearchPanelSaveWithoutMaxResultsKeepsStoredValue 钉住三态的第一态:提交里**缺位** +// ⇒ 盘上那个上限一个字节都不动(「我没改它」必须真的是没改),运行中的生效值也照旧。 +// +// 前置的 20 是**直接写盘**的(不经过写侧):这条判据只关心「缺位的那次保存有没有动它」, +// 不该依赖另一条用例的能力。 +func TestWebSearchPanelSaveWithoutMaxResultsKeepsStoredValue(t *testing.T) { + env, up := wsMaxResultsEnv(t, 5) + if err := runtimeconfig.SaveWebSearch(env.path, runtimeconfig.WebSearchFile{ + Version: runtimeconfig.WebSearchVersion, + Provider: search.NameSearXNG, + MaxResults: 20, + Providers: []runtimeconfig.ProviderEntry{ + {Provider: search.NameSearXNG, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)}, + }, + }); err != nil { + t.Fatalf("写面板配置: %v", err) + } + env.mustSave(t, map[string]any{ + "provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, + }) + if got := env.loadFile(t).MaxResults; got != 20 { + t.Fatalf("缺位的一次保存把上限改成了 %d, want 20(缺位 = 不改动)", got) + } + if got := wsSearchCount(t, env.app, map[string]any{"query": "cyrene", "count": 50}); got != 20 { + t.Fatalf("count = %d, want 20(上限没被动过,热重载后的生效值也该照旧)", got) + } +} + +// TestWebSearchPanelSaveRejectsInvalidMaxResults 钉住三态的第三态:给了值却用不了 ⇒ +// **400 invalid_payload**,盘上一个字节都不动 —— 绝不静默忽略。静默忽略正是这一片要消灭的 +// 那条「我改了它却没变」(旧行为就是:手塞进去的值被写侧无声丢掉,文件不变、也不报错)。 +func TestWebSearchPanelSaveRejectsInvalidMaxResults(t *testing.T) { + cases := []struct { + name string + value any + }{ + {"零", 0}, + {"负数", -3}, + {"小数", 2.5}, + {"空串(前端清空输入框送的就是它)", ""}, + {"非数字字符串", "twenty"}, + {"布尔", true}, + {"数组", []any{20}}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + env, up := wsMaxResultsEnv(t, 5) + before := env.rawFile(t) + _, err := env.save(map[string]any{ + "provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, + "maxResults": tc.value, + }) + ae, ok := pluginmgr.AsActionError(err) + if !ok { + t.Fatalf("err = %v, want 结构化 ActionError(非法上限绝不能被静默忽略)", err) + } + if ae.Code != pluginmgr.ActionCodeInvalidPayload || ae.Status != http.StatusBadRequest { + t.Fatalf("失败 = %d/%s, want 400/%s", ae.Status, ae.Code, pluginmgr.ActionCodeInvalidPayload) + } + if got := env.rawFile(t); !bytes.Equal(got, before) { + t.Fatalf("被拒的保存动了盘上的文件:\nbefore=%s\nafter =%s", before, got) + } + }) + } +} + +// TestWebSearchReauthEchoesMaxResults:回填必须带上盘上的上限 —— 不回填的话表单里那个框是空的, +// 用户既看不见当前上限,也无从改起。 +func TestWebSearchReauthEchoesMaxResults(t *testing.T) { + env, up := wsMaxResultsEnv(t, 5) + env.mustSave(t, map[string]any{ + "provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, "maxResults": 20, + }) + values := wsReauthValues(t, env, search.NameSearXNG) + if got := values[search.FieldMaxResults]; got != 20 { + t.Fatalf("回填的 %s = %#v, want 20", search.FieldMaxResults, got) + } +} + +// TestWebSearchReauthOmitsUnsetMaxResults:盘上**没给**上限(`maxResults` 缺位 ⇒ Go 侧 0 = +// 未声明,由 `.env` 兜底)时不得回填一个 0 —— 0 会被前端当成一个值送回来,而写侧拒 ≤0, +// 那会让「只改 key」的一次保存整笔进不去。 +func TestWebSearchReauthOmitsUnsetMaxResults(t *testing.T) { + env, up := wsMaxResultsEnv(t, 5) + if err := runtimeconfig.SaveWebSearch(env.path, runtimeconfig.WebSearchFile{ + Version: runtimeconfig.WebSearchVersion, + Provider: search.NameSearXNG, + // 刻意不写 maxResults(= 未声明)。 + Providers: []runtimeconfig.ProviderEntry{ + {Provider: search.NameSearXNG, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)}, + }, + }); err != nil { + t.Fatalf("写面板配置: %v", err) + } + if got := env.loadFile(t).MaxResults; got != 0 { + t.Fatalf("前置不成立:盘上 maxResults = %d, want 0(未声明)", got) + } + values := wsReauthValues(t, env, search.NameSearXNG) + if raw, ok := values[search.FieldMaxResults]; ok { + t.Fatalf("未声明时回填了 %#v(前端会把它当值送回,写侧又拒 ≤0)", raw) + } +} + +// wsReauthValues 走 reauth 动作,取出回填的 values。 +func wsReauthValues(t *testing.T, env *reloadTestEnv, provider string) map[string]any { + t.Helper() + out, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{ + PluginID: search.PanelPluginID, Action: search.ActionReauthProvider, + Payload: map[string]any{"row": map[string]any{"provider": provider}}, + }) + if err != nil { + t.Fatalf("reauth: %v", err) + } + values, _ := out.(map[string]any)["values"].(map[string]any) + if values == nil { + t.Fatalf("reauth 结果缺 values: %#v", out) + } + return values +} diff --git a/cmd/cyrene/websearch_selection.go b/cmd/cyrene/websearch_selection.go index 8c594f4..edb26f3 100644 --- a/cmd/cyrene/websearch_selection.go +++ b/cmd/cyrene/websearch_selection.go @@ -1,17 +1,18 @@ package main -// 本文件是「搜索提供商」页表单里**两个文件级字段**的解析与落盘(切片 W3; -// `DECISIONS-2026-10-06` 裁决 #2:声明面必须能配「谁先用」——`primary` + `fallback[]`)。 +// 本文件是「搜索提供商」页表单里**文件级字段**的解析与落盘(切片 W3; +// `DECISIONS-2026-10-06` 裁决 #2:声明面必须能配「谁先用」——`primary` + `fallback[]`; +// 后续切片又加了 `maxResults`:结果条数上限也得能在面板上改)。 // // 它们与其余字段(provider / apiKey / baseUrl / enabled)的区别: // // - 其余字段描述**一条 provider 条目**(providers[] 里的一个元素); -// - `primary` / `fallback` 描述**整份配置的选择**(文件级的 `provider` / `fallback`, -// 即 design/27 §3 的「主后端 + 降级链」)。 +// - 这三个描述**整份配置**:`primary` / `fallback` 是 design/27 §3 的「主后端 + 降级链」, +// `maxResults` 是单次检索的条数上限 —— 三者都是可变源顶层的同名键。 // // 为什么挂在**同一个** form 区块里,而不是另开一个:前端的「带回表单」(reauth)只在面板 // **恰好一个** form 区块时转交(`frontend/web/src/admin/plugin/PluginPanel.tsx` 的 -// `singleForm`),加第二个 form 会让既有的「重配凭据」回填整条失灵。因此这两项作为文件级 +// `singleForm`),加第二个 form 会让既有的「重配凭据」回填整条失灵。因此这些项作为文件级 // 字段进既有表单,回填值由 `reauthProvider` 一并给出(见 addSelectionValues 的调用点)。 // // 字段 key 刻意不叫 `provider`:表单里的 `provider` 已经是「这一条 provider 的名字」, @@ -19,7 +20,9 @@ package main import ( "fmt" + "math" "net/http" + "strconv" "strings" pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" @@ -28,15 +31,17 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" ) -// 表单里两个文件级字段的 key 由**声明侧**给出(search.FieldPrimary / search.FieldFallback): -// 它们必须与 panel.go 里写下的字段声明逐字一致(本文件读的正是这些 key),而 -// 「声明里写一个 key、处理器里读另一个」这类漂移只有共用一个常量才拦得住 —— +// 表单里三个文件级字段的 key 由**声明侧**给出(search.FieldPrimary / FieldFallback / +// FieldMaxResults):它们必须与 panel.go 里写下的字段声明逐字一致(本文件读的正是这些 key), +// 而「声明里写一个 key、处理器里读另一个」这类漂移只有共用一个常量才拦得住 —— // 在 cmd/cyrene 里再写一遍字面量就等于第二份真源。 const ( // selectionPrimaryKey 是主 provider 的字段 key(落到文件级 `provider`)。 selectionPrimaryKey = search.FieldPrimary // selectionFallbackKey 是降级链的字段 key(落到文件级 `fallback`)。 selectionFallbackKey = search.FieldFallback + // selectionMaxResultsKey 是结果条数上限的字段 key(落到文件级 `maxResults`)。 + selectionMaxResultsKey = search.FieldMaxResults ) // actionCodePrimaryNotConfigured 报告「选中的主 provider 在本文件里还没配齐」—— @@ -44,13 +49,17 @@ const ( // fail-fast),所以在写盘之前就拒掉,而不是先写进盘里等着下次起不来。 const actionCodePrimaryNotConfigured = "primary_not_configured" -// mergeSelectionPatch 把表单里的主 provider / 降级链合并进文件(三态:**缺位 = 不改动**), -// 并回报「这次有没有给出 `primary`」—— **校验不在这里**。 +// mergeSelectionPatch 把表单里的三个**文件级**字段(主 provider / 降级链 / 结果条数上限) +// 合并进文件(三态:**缺位 = 不改动**),并回报「这次有没有给出 `primary`」 +// —— **校验不在这里**。 // // 与其余字段同一套哨兵语义(websearch_action_data.go 的 saveProvider 注释): // `primary` 缺位 ⇒ 保留盘上的;给了值(含显式空串 / off / none)⇒ 采用 —— 空串与 // `off`/`none` 是本项目**唯一一套**关闭语义(config.IsWebSearchOffSentinel),面板不另造一套。 // +// `maxResults` 的第三态**比另外两个严**:它没有「空串 = 关闭」这种解释,因此 ≤0 与非整数 +// 一律 400(口径与理由见 selectionMaxResults)—— 那正是这一片要消灭的「我改了它却没变」。 +// // 🔴 **顺序硬约束(缺陷修复 2026-10-07,真机报障)**:主 provider 的可用性校验必须由调用方 // 在「**本次这条 provider 条目已经并进文件之后**」做(`checkPrimaryUsable(final, …)`)。 // 本函数此前自己顺手校验,用的是**盘上的旧文件** ⇒ 面板上第一次给某家填 key、又在**同一次 @@ -69,12 +78,19 @@ func (e webSearchActionEnv) mergeSelectionPatch( if err != nil { return f, false, err } + maxResults, setMaxResults, err := selectionMaxResults(values) + if err != nil { + return f, false, err + } if setPrimary { f.Provider = primary } if setFallback { f.Fallback = fallback } + if setMaxResults { + f.MaxResults = maxResults + } return f, setPrimary, nil } @@ -123,10 +139,11 @@ func selectionNames(names []string) []string { return names } -// addSelectionValues 把盘上的文件级选择回填进 `reauth` 的 values。 +// addSelectionValues 把盘上的**文件级取值**回填进 `reauth` 的 values。 // // 回填的必要性在前端的三态写回里:只有 `initial` 有值,用户「清空降级链」才会被送成 // 显式空(`planSubmit`),否则会落成「缺位 = 不改动」——「我清掉了它」就成了一句假话。 +// `maxResults` 同理:不回填的话表单里那个框是空的,用户看不见当前上限、也就无从改起。 func addSelectionValues(values map[string]any, f runtimeconfig.WebSearchFile) { values[selectionPrimaryKey] = f.Provider fb := f.Fallback @@ -134,6 +151,60 @@ func addSelectionValues(values map[string]any, f runtimeconfig.WebSearchFile) { fb = []string{} } values[selectionFallbackKey] = fb + // 上限只在盘上**真的给过**时才回填(`maxResults` 的 0 = 未声明):回填一个 0 会被前端 + // 当成一个值送回来,而写侧拒 ≤0 —— 用户「什么都没改」的一次保存就整笔进不去了。 + if f.MaxResults > 0 { + values[selectionMaxResultsKey] = f.MaxResults + } +} + +// selectionMaxResults 解析结果条数上限字段(落点:文件级 `maxResults`)。**三态**: +// +// - 缺位 / JSON null ⇒ 不改动; +// - 正整数 ⇒ 采用(落盘后由热重载事务接上工具,下一次搜索就用它); +// - 其余(0、负数、小数、空串、非数字字符串、布尔、数组…)⇒ **400 invalid_payload**。 +// +// 为什么第三态必须报错,而不是像 `enabled` 那样「空串 = 未改动」:这一片的来由正是 +// 「我改了它却没变」—— 手工塞进提交体的上限被写侧静默丢弃(文件不变、也不报错)。既然 +// 面板上给了这个输入框,任何**给了值却没被采用**的提交都必须说得出原因;否则用户以为上限 +// 是 20,跑的还是 10。0 / 负数在这里也没有第二种解释:文件形状里 `maxResults` 的 0 = +// 「未声明」(`omitempty` 不落盘),把 0 当「去掉上限」会让「去掉」与「从没配过」长得一模一样。 +func selectionMaxResults(values map[string]any) (int, bool, error) { + raw, ok := values[selectionMaxResultsKey] + if !ok || raw == nil { + return 0, false, nil + } + n, ok := wholeNumber(raw) + if !ok || n < 1 { + // %q + Sprint:字符串会带引号(空串才看得见),数字/布尔照原样。 + return 0, false, invalidPayloadActionError(fmt.Sprintf( + "%s 必须是正整数(≥1),收到 %q:这个框不能靠留空或 0 去掉上限,请填一个具体条数", + selectionMaxResultsKey, fmt.Sprint(raw))) + } + return n, true, nil +} + +// wholeNumber 把一个补丁值还原成整数:JSON 数字(float64,必须**没有小数部分**)、Go 侧构造的 +// int、以及整数字符串(与 `wire_browser_panel_data.go` 的 patchInt 同一取舍:某些控件会把值以 +// 字符串送出)。其余类型一律拒 —— 不做静默转换,那正是本片要消灭的那类假成功。 +func wholeNumber(raw any) (int, bool) { + switch v := raw.(type) { + case float64: + if v != math.Trunc(v) { + return 0, false + } + return int(v), true + case int: + return v, true + case string: + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return 0, false + } + return n, true + default: + return 0, false + } } // selectionPrimary 解析主 provider 字段:缺位 ⇒ (…, false, nil);非字符串 ⇒ 400。 diff --git a/cmd/cyrene/wire_plugin_actions.go b/cmd/cyrene/wire_plugin_actions.go index 3d92a01..b503e54 100644 --- a/cmd/cyrene/wire_plugin_actions.go +++ b/cmd/cyrene/wire_plugin_actions.go @@ -11,7 +11,7 @@ package main // - `websearch_action_data.go`:动作本体的业务数据面(可变源的读写与翻译、载荷解析); // - `websearch_probe.go`:探针的传输层绑定(HTTP 客户端 + 同主机守卫 + 状态映射); // - `websearch_reload.go`:保存后的**热重载**事务(重建工具 + 换注册表条目 + 失败回滚); -// - `websearch_selection.go`:表单里两个文件级字段(主 provider / 降级链)的读写。 +// - `websearch_selection.go`:表单里文件级字段(主 provider / 降级链 / 条数上限)的读写。 // // 三条边界: // diff --git a/internal/config/websearch_providers_test.go b/internal/config/websearch_providers_test.go index 9b45245..b0a3395 100644 --- a/internal/config/websearch_providers_test.go +++ b/internal/config/websearch_providers_test.go @@ -3,13 +3,15 @@ package config import ( "os" "path/filepath" + "strconv" "strings" "testing" ) // 本文件钉住切片 WS1-B 的搜索配置键(design/27 §3): // WEB_SEARCH_PROVIDER / _FALLBACK / _MAX_RESULTS 与各家独立一组键 -// (WEB_SEARCH__API_KEY / _BASE_URL),以及 ResolveProvider 的关闭语义。 +// (WEB_SEARCH__API_KEY / _BASE_URL),以及 ResolveProvider 的关闭语义; +// 末尾还钉住 `.env.example` 的取值与代码默认值同口径(模板写错就是一句假话)。 // wsTestLoad 写一份只含必填键加 extra 行的 .env,加载出配置。 func wsTestLoad(t *testing.T, extra ...string) (*Config, error) { @@ -126,3 +128,44 @@ func TestWebSearchConfig_ResolveProvider(t *testing.T) { }) } } + +// TestWebSearchMaxResults_EnvExampleMatchesCodeDefault 是「配置事实 ↔ 模板」守卫: +// `.env.example` 是给人照抄的模板,它给出的取值必须等于代码默认值 —— 模板写 5 而代码默认 +// 已改成 10(2026-10-07 的口径调整漏改了模板),照抄模板的人拿到的就不是默认行为。 +// +// ⚠️ 只读模板;`.env` 已冻结(基础键不加/不改/不删),本用例不碰它。 +func TestWebSearchMaxResults_EnvExampleMatchesCodeDefault(t *testing.T) { + cfg, err := wsTestLoad(t) + if err != nil { + t.Fatalf("Load: %v", err) + } + raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example")) + if err != nil { + t.Fatalf("read .env.example: %v", err) + } + const key = "WEB_SEARCH_MAX_RESULTS" + value, err := strconv.Atoi(wsEnvExampleValue(t, string(raw), key)) + if err != nil { + t.Fatalf(".env.example 的 %s 取值不是整数: %v", key, err) + } + if value != cfg.WebSearch.MaxResults { + t.Fatalf(".env.example 的 %s = %d,代码默认 = %d(模板与代码必须同口径)", + key, value, cfg.WebSearch.MaxResults) + } +} + +// wsEnvExampleValue 取 `.env.example` 里某个键的**生效**取值(跳过注释行)。 +func wsEnvExampleValue(t *testing.T, raw, key string) string { + t.Helper() + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "#") { + continue + } + if rest, ok := strings.CutPrefix(line, key+"="); ok { + return strings.TrimSpace(rest) + } + } + t.Fatalf(".env.example 里没有生效的 %s=…(模板必须给出可照抄的取值)", key) + return "" +} diff --git a/plugins/tools/io/search/panel.go b/plugins/tools/io/search/panel.go index 8452a57..69bc9d1 100644 --- a/plugins/tools/io/search/panel.go +++ b/plugins/tools/io/search/panel.go @@ -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 } diff --git a/plugins/tools/io/search/panel_test.go b/plugins/tools/io/search/panel_test.go index ef865e7..6aaa440 100644 --- a/plugins/tools/io/search/panel_test.go +++ b/plugins/tools/io/search/panel_test.go @@ -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) + } + } +}