Files
Cyrene_v2/cmd/cyrene/websearch_selection.go
T
AskaEth aa0bf8b5a0
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(websearch): 面板「填 key + 同一次选它当主 provider」不再被自己的校验回滚
真机报障(2026-10-07 09:50):面板上给 zhipu 填了 API Key,保存却一直回
400 primary_not_configured;盘上配置文件 mtime 一动不动 —— 连刚填的 key
一起被回滚,界面表现为「怎么填都存不进去」。

根因(顺序错误):saveProvider 先 applySelectionPatch(内含「主 provider 必须
已配齐」的校验),后 UpsertProviderEntry。校验看的是**盘上的旧文件**,而这次
提交里的 key 还没落盘 ⇒ 校验判它没配齐,整笔拒绝。

修法:把校验从 applySelectionPatch 里摘出来,改名 mergeSelectionPatch(只合并
文件级字段 + 回报有没有给 primary),由 saveProvider 在 UpsertProviderEntry
**之后**对最终文件校验,再写盘。判据本身一字未改 —— 「真的没配齐的主家照样拒」。

验证:cmd/cyrene 整包测试绿;新增回归 TestWebSearchSaveProviderAndPrimaryInOneSubmit
(同一次提交填 key + 选主家 ⇒ 200 且盘上 key 与 primary 都在;反向:真没配齐仍 400
且文件字节不变);用 -overlay 把校验改回「拿旧文件校验」⇒ 该用例变红且报错文案
与主人看到的逐字相同。
2026-10-07 09:56:37 +08:00

205 lines
9.0 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[]`)。
//
// 它们与其余字段(provider / apiKey / baseUrl / enabled)的区别:
//
// - 其余字段描述**一条 provider 条目**(providers[] 里的一个元素);
// - `primary` / `fallback` 描述**整份配置的选择**(文件级的 `provider` / `fallback`,
// 即 design/27 §3 的「主后端 + 降级链」)。
//
// 为什么挂在**同一个** form 区块里,而不是另开一个:前端的「带回表单」(reauth)只在面板
// **恰好一个** form 区块时转交(`frontend/web/src/admin/plugin/PluginPanel.tsx` 的
// `singleForm`),加第二个 form 会让既有的「重配凭据」回填整条失灵。因此这两项作为文件级
// 字段进既有表单,回填值由 `reauthProvider` 一并给出(见 addSelectionValues 的调用点)。
//
// 字段 key 刻意不叫 `provider`:表单里的 `provider` 已经是「这一条 provider 的名字」,
// 两个语义不同的东西共用一个 key 会让「我在改谁」变成一句要猜的话。
import (
"fmt"
"net/http"
"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 / search.FieldFallback):
// 它们必须与 panel.go 里写下的字段声明逐字一致(本文件读的正是这些 key),而
// 「声明里写一个 key、处理器里读另一个」这类漂移只有共用一个常量才拦得住 ——
// 在 cmd/cyrene 里再写一遍字面量就等于第二份真源。
const (
// selectionPrimaryKey 是主 provider 的字段 key(落到文件级 `provider`)。
selectionPrimaryKey = search.FieldPrimary
// selectionFallbackKey 是降级链的字段 key(落到文件级 `fallback`)。
selectionFallbackKey = search.FieldFallback
)
// 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),面板不另造一套。
//
// 🔴 **顺序硬约束(缺陷修复 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
}
if setPrimary {
f.Provider = primary
}
if setFallback {
f.Fallback = fallback
}
return f, setPrimary, nil
}
// checkPrimaryUsable 判定一个主 provider 名在**落盘后的文件**里是否可用。
//
// 判定口径与工具接线同源(searchProviderSpecs + effectiveBaseURL):云家看 API Key、
// searxng 看接口地址,基地址缺位时回落到代码默认值;条目被显式停用视同没配
// (effectiveSearchConfig 会跳过它)。
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)
base := ""
if found {
base = e.effectiveBaseURL(name, entry)
}
if !found || !entry.IsEnabled() || !search.Configured(name, entry.APIKey, base) {
return pluginmgr.NewActionError(actionCodePrimaryNotConfigured,
fmt.Sprintf("主提供商 %q 还没配齐(缺 API Key / 接口地址,或这条被停用了):"+
"先把它配好再选它 —— 否则这份配置会让后端下一次启动直接失败", name),
http.StatusBadRequest, nil)
}
return nil
}
// selectionNames 返回降级链的线形状(**非 nil 空切片**:nil 会在 JSON 里变成 null,
// 而「一条降级都没有」与「这个字段没值」在前端不该长得不一样)。
func selectionNames(names []string) []string {
if names == nil {
return []string{}
}
return names
}
// addSelectionValues 把盘上的文件级选择回填进 `reauth` 的 values。
//
// 回填的必要性在前端的三态写回里:只有 `initial` 有值,用户「清空降级链」才会被送成
// 显式空(`planSubmit`),否则会落成「缺位 = 不改动」——「我清掉了它」就成了一句假话。
func addSelectionValues(values map[string]any, f runtimeconfig.WebSearchFile) {
values[selectionPrimaryKey] = f.Provider
fb := f.Fallback
if fb == nil {
fb = []string{}
}
values[selectionFallbackKey] = fb
}
// 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))
}
}