Files
Cyrene_v2/cmd/cyrene/websearch_selection.go
T
AskaEth c68387a69a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 多一个可写键 +
响应多一个回显键)⇒ 主会话登记。
2026-10-07 11:10:56 +08:00

284 lines
13 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 main
// 本文件是「搜索提供商」页表单里**文件级字段**的解析与落盘(切片 W3;
// `DECISIONS-2026-10-06` 裁决 #2:声明面必须能配「谁先用」——`primary` + `fallback[]`;
// 后续切片又加了 `maxResults`:结果条数上限也得能在面板上改)。
//
// 它们与其余字段(provider / apiKey / baseUrl / enabled)的区别:
//
// - 其余字段描述**一条 provider 条目**(providers[] 里的一个元素);
// - 这三个描述**整份配置**:`primary` / `fallback` 是 design/27 §3 的「主后端 + 降级链」,
// `maxResults` 是单次检索的条数上限 —— 三者都是可变源顶层的同名键。
//
// 为什么挂在**同一个** form 区块里,而不是另开一个:前端的「带回表单」(reauth)只在面板
// **恰好一个** form 区块时转交(`frontend/web/src/admin/plugin/PluginPanel.tsx` 的
// `singleForm`),加第二个 form 会让既有的「重配凭据」回填整条失灵。因此这些项作为文件级
// 字段进既有表单,回填值由 `reauthProvider` 一并给出(见 addSelectionValues 的调用点)。
//
// 字段 key 刻意不叫 `provider`:表单里的 `provider` 已经是「这一条 provider 的名字」,
// 两个语义不同的东西共用一个 key 会让「我在改谁」变成一句要猜的话。
import (
"fmt"
"math"
"net/http"
"strconv"
"strings"
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"
)
// 表单里三个文件级字段的 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 在本文件里还没配齐」——
// 这不是一次可以放着不管的保存:那份配置会让后端**下次启动直接失败**(design/27 §3 的
// fail-fast),所以在写盘之前就拒掉,而不是先写进盘里等着下次起不来。
const actionCodePrimaryNotConfigured = "primary_not_configured"
// 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、又在**同一次
// 提交**里把它选为主 provider 时,校验看不到那个还没落盘的 key,于是整笔(**连同刚填的 key**)
// 被 400 `primary_not_configured` 回滚 —— 界面上表现为「怎么填都存不进去」,而盘上文件
// mtime 一动不动(2026-10-07 09:50 日志连续两次 `plugin.saveProvider … code=primary_not_configured`)。
// 调用点见 websearch_action_data.go 的 saveProvider:先 `UpsertProviderEntry`,再校验,最后写盘。
func (e webSearchActionEnv) mergeSelectionPatch(
f runtimeconfig.WebSearchFile, values map[string]any,
) (runtimeconfig.WebSearchFile, bool, error) {
primary, setPrimary, err := selectionPrimary(values)
if err != nil {
return f, false, err
}
fallback, setFallback, err := selectionFallback(values)
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
}
// checkPrimaryUsable 判定一个主 provider 名在**落盘后的文件**里是否可用。
//
// 判定口径与工具接线同源(resolveSearchProviders + resolveBaseURL):云家看 API Key、
// searxng 看接口地址,基地址缺位时回落**该接入位的代码默认基地址**;条目被显式停用
// 视同没配(装配期会跳过它)。判据本身**不放松**:真的没配齐的主家仍必须被拒。
func (e webSearchActionEnv) checkPrimaryUsable(f runtimeconfig.WebSearchFile, name string) error {
// 显式关闭(空串 / off / none)永远合法:它表示「不要这把工具」,工具不进注册面,
// 后端照常启动(与 WEB_SEARCH_PROVIDER 的既有语义逐字一致)。
if config.IsWebSearchOffSentinel(name) {
return nil
}
if err := requireKnownProvider(name); err != nil {
return err
}
entry, found := findProviderEntry(f, name)
if !found || !entry.IsEnabled() {
return primaryNotConfiguredError(name)
}
// 基地址的进位链与装配期**同一个函数**:条目里的值 → 该接入位的默认基地址 → 空。
// 云家的可用性只看 key(地址取默认值也照样能打通),searxng 的默认值是空
// —— 因此「searxng 主家必须有地址」这条判据与既往逐字一致。
if !search.Configured(name, entry.APIKey, resolveBaseURL(name, entry.BaseURL)) {
return primaryNotConfiguredError(name)
}
return nil
}
// primaryNotConfiguredError 造「主家没配齐」这一条的 400:文案与码只有一份
// (两个判据分支共用,免得两处措辞漂移)。
func primaryNotConfiguredError(name string) error {
return pluginmgr.NewActionError(actionCodePrimaryNotConfigured,
fmt.Sprintf("主提供商 %q 还没配齐(缺 API Key / 接口地址,或这条被停用了):"+
"先把它配好再选它 —— 否则这份配置会让后端下一次启动直接失败", name),
http.StatusBadRequest, nil)
}
// selectionNames 返回降级链的线形状(**非 nil 空切片**:nil 会在 JSON 里变成 null,
// 而「一条降级都没有」与「这个字段没值」在前端不该长得不一样)。
func selectionNames(names []string) []string {
if names == nil {
return []string{}
}
return names
}
// addSelectionValues 把盘上的**文件级取值**回填进 `reauth` 的 values。
//
// 回填的必要性在前端的三态写回里:只有 `initial` 有值,用户「清空降级链」才会被送成
// 显式空(`planSubmit`),否则会落成「缺位 = 不改动」——「我清掉了它」就成了一句假话。
// `maxResults` 同理:不回填的话表单里那个框是空的,用户看不见当前上限、也就无从改起。
func addSelectionValues(values map[string]any, f runtimeconfig.WebSearchFile) {
values[selectionPrimaryKey] = f.Provider
fb := f.Fallback
if fb == nil {
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。
func selectionPrimary(values map[string]any) (string, bool, error) {
raw, ok := values[selectionPrimaryKey]
if !ok || raw == nil {
return "", false, nil
}
s, ok := raw.(string)
if !ok {
return "", false, invalidPayloadActionError(
fmt.Sprintf("%s 必须是字符串(提供商名)", selectionPrimaryKey))
}
return normalizeProviderName(s), true, nil
}
// selectionFallback 解析降级链字段:缺位 ⇒ (…, false, nil);形状不对 / 名字未登记 ⇒ 400。
func selectionFallback(values map[string]any) ([]string, bool, error) {
raw, ok := values[selectionFallbackKey]
if !ok || raw == nil {
return nil, false, nil
}
items, err := selectionItems(raw)
if err != nil {
return nil, false, err
}
out := make([]string, 0, len(items))
seen := make(map[string]bool, len(items))
for _, item := range items {
name := normalizeProviderName(item)
if name == "" || seen[name] {
continue
}
// 未登记的家存进降级链也永远不会被选中(注册表按名字取)⇒ 这里就拒,与
// 条目保存的 requireKnownProvider 同一个码与同一句文案。
if err := requireKnownProvider(name); err != nil {
return nil, false, err
}
seen[name] = true
out = append(out, name)
}
return out, true, nil
}
// selectionItems 把多选值规范化成字符串切片。
//
// 三种形状都接受:
// - `[]string`(Go 侧直接构造的载荷,测试与内部调用用);
// - `[]any`(JSON 解码后的数组 —— 前端 multiSelect 送的就是它);
// - 逗号 / 顿号 / 换行分隔的字符串(与前端控件「逗号分隔」的呈现同源,人工 curl 友好)。
//
// 其余类型一律 400:静默当空数组 = 一次保存悄悄清空降级链(与 apiKey 的类型错同一条口径)。
func selectionItems(raw any) ([]string, error) {
switch v := raw.(type) {
case []string:
return v, nil
case []any:
out := make([]string, 0, len(v))
for i, item := range v {
s, ok := item.(string)
if !ok {
return nil, invalidPayloadActionError(
fmt.Sprintf("%s[%d] 必须是字符串(提供商名)", selectionFallbackKey, i))
}
out = append(out, s)
}
return out, nil
case string:
return strings.FieldsFunc(v, func(r rune) bool {
return r == ',' || r == ',' || r == '、' || r == ';' || r == '\n'
}), nil
default:
return nil, invalidPayloadActionError(
fmt.Sprintf("%s 必须是字符串数组(如 [\"bocha\",\"zhipu\"])", selectionFallbackKey))
}
}