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)) } }