动因:`.env` 已冻结(基础键不加/不改/不删),而「加一家 provider」原本会逼出 新的 env 键(一家一个 struct 字段 + searchProviderVector 逐家读 + 逐家写死的 switch)⇒ 想接腾讯云元宝/秘塔就只能破红线。面板本来就该是插件配置的一等来源 ($DATA_DIR/config/plugins/web-search.json),故让**文件成为权威**。 - plugins/tools/io/search/registry.go:接入位表加 defaultBaseURL(「没配 baseUrl 回落代码默认值」从此只有一个出处);新增 Descriptor(s)/DefaultBaseURL。 - cmd/cyrene/websearch_providers.go(新):按接入位表逐家算出生效投影 —— 加一家时这里**零改动**,这正是本片的核心;顺手把 wire_io_tools.go 从 430 行 降到 342(它逼近 §10 的 500 行上限)。 - cmd/cyrene/wire_plugin_actions.go / websearch_action_data.go / websearch_selection.go:基地址判定统一走「文件值 → 接入位默认值」的进位链; checkPrimaryUsable 判据不放松(真没配齐仍 400 primary_not_configured)。 🔴 **一处对我任务书的纠正(子代理提出、我采纳)**:我要求「既有 6 家在文件里 没有该条目时仍回落 .env」。仓库现状**没有**这层回落,且有回归用例把它钉死 (TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv:「文件里没有 的家不得从 .env 复活」)。本片按「不改变既有行为」实现 —— 我的要求才是错的: 文件一旦存在就是权威,env 只在文件缺失/为空时播种。 验收(主会话复跑 + 亲自变异):go build ./... + 两包 vet 绿;search 包与 cmd/cyrene 全绿;我用 -overlay 让文件里的 apiKey 被忽略 ⇒ 4 条用例变红 (含「无 env 字段的家只认文件也能进注册表」这条核心判据)。 ⚠️ 新家仍未接:腾讯云元宝(tencent_wsa)/ 秘塔(metaso)两片随后串行落地。
213 lines
9.6 KiB
Go
213 lines
9.6 KiB
Go
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 名在**落盘后的文件**里是否可用。
|
||
//
|
||
// 判定口径与工具接线同源(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`),否则会落成「缺位 = 不改动」——「我清掉了它」就成了一句假话。
|
||
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))
|
||
}
|
||
}
|