Files
Cyrene_v2/cmd/cyrene/websearch_action_data.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

441 lines
18 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
// 本文件是「搜索提供商」页**五个动作本体**的数据面(切片 L1/PLG-2 的执行面接线):
// 可变源的读写(含失败分类)、表格行 / 下拉候选 / 掩码回填的翻译、以及请求体形状的解析。
//
// 上游事实(`plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` /
// `plugin.reauthProvider` / `plugin.providers`)由组合根登记到插件管理器上,登记入口在
// `wire_plugin_actions.go`,探针的传输层绑定在 `websearch_probe.go`。
//
// 三条边界(与登记面同):只经 runtimeconfig 读写可变源;凭据只出掩码;形状不对就报错。
import (
"context"
"fmt"
"net/http"
"strings"
"time"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// --- 四个动作 + 一个候选来源 -------------------------------------------------
// listProviders 是表格区块的数据源:把可变源里的各条配置翻译成面板行
// (provider / state / enabled;**凭据与地址都不进行数据**)。
func (e webSearchActionEnv) listProviders(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
f, err := e.loadFile()
if err != nil {
return nil, err
}
return map[string]any{"rows": providerRows(f)}, nil
}
// listProviderOptions 给出下拉候选:本期全部已登记的 provider 名(含尚未配置的家)。
//
// 给全部而不是「已配置的家」的理由见 search.ProviderOptions:表单的用途就是**新增**
// 一家,只列已配置的会让第一次配置无从选起。
func (e webSearchActionEnv) listProviderOptions(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
return map[string]any{"options": search.ProviderOptions()}, nil
}
// saveProvider 新增 / 修改一条 provider 配置(表单提交,写动作)。
//
// 三态写回(与前端 `schema/values.ts:planSubmit` 逐条同义):
// - apiKey **缺位** ⇒ 保留原值(面板只持有掩码,「留空 = 不改」必须是真的);
// - apiKey **空串** ⇒ 显式清空(前端只有勾了「清除」才会送出空串);
// - baseUrl / enabled 缺位或空串 ⇒ 保留;给出(含 false / 空地址)⇒ 采用。
//
// 同一次提交里还可能带两个**文件级**字段(`primary` / `fallback`,切片 W3):它们不是这一条
// provider 的属性,而是「谁先用 / 失败后按什么顺序降级」,因此合并到文件级而不是条目上
// (解析与校验在 websearch_selection.go)。缺位 = 不改动,与其余字段同一套哨兵语义。
//
// 落盘之后由组合根的热重载事务接管(`websearch_reload.go`:重建工具 + 换注册表条目,
// 失败则连同本次落盘一起回滚)—— 本函数只负责「把改动存进去」这一件事。
//
// 只接受**已登记**的名字:未登记的家存进去也永远不会被选中(注册表按名字取),
// 那会让「我明明配了 tavily」变成一句假话 —— 所以在这里就拒。
func (e webSearchActionEnv) saveProvider(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
values, err := actionValuesObject(req.Payload)
if err != nil {
return nil, err
}
name, err := requiredProviderName(values)
if err != nil {
return nil, err
}
if err := requireKnownProvider(name); err != nil {
return nil, err
}
cur, err := e.loadFile()
if err != nil {
return nil, err
}
prev, existed := findProviderEntry(cur, name)
entry, err := mergeProviderEntry(prev, values)
if err != nil {
return nil, err
}
entry.Provider = name
next, setPrimary, err := e.mergeSelectionPatch(cur, values)
if err != nil {
return nil, err
}
final := runtimeconfig.UpsertProviderEntry(next, entry)
// 🔴 校验必须在**并条之后**(顺序理由见 websearch_selection.go 的 mergeSelectionPatch):
// 主 provider 配没配齐,取决于**这次刚落进文件的凭据** —— 拿旧文件校验会把
// 「第一次填 key + 同一次提交里选它」整笔回滚,连 key 一起丢。
if setPrimary {
if err := e.checkPrimaryUsable(final, final.Provider); err != nil {
return nil, err
}
}
if err := runtimeconfig.SaveWebSearch(e.path, final); err != nil {
e.log.Error("web-search panel config write failed", "path", e.path, "provider", name, "error", err)
return nil, pluginmgr.NewActionError(actionCodeConfigWriteFailed,
"面板配置写不进去(磁盘或权限问题),本次改动**没有**生效", http.StatusInternalServerError, err)
}
e.log.Info("web-search provider saved from panel",
"path", e.path, "provider", name, "created", !existed, "enabled", entry.IsEnabled())
return map[string]any{
"saved": true,
"provider": name,
"created": !existed,
"enabled": entry.IsEnabled(),
// 文件级选择的**落盘后**取值(没给 primary/fallback 时也如实回显当前值):
// 面板按它回显「谁先用」,也让「这次保存有没有动到选择」有可机读的答案。
"primary": strings.ToLower(strings.TrimSpace(final.Provider)),
"fallback": selectionNames(final.Fallback),
}, nil
}
// testProvider 对一条配置**真发一次最小检索**(行内动作「测试连通」)。
//
// 凭据与地址都从可变源读(面板里刚存下的就是它),不从前端传上来的行数据读
// —— 行数据里没有 key(列表列刻意不带凭据),也不该由客户端指定要探的目标。
//
// 失败一律回**结构化非 2xx**:面板的行内动作只在失败时显示文案(成功分支只说
// 「已完成」),把「探不通」放进 200 的 data 里等于让界面报一句假成功。
func (e webSearchActionEnv) testProvider(ctx context.Context, req pluginmgr.ActionRequest) (any, error) {
name, err := providerNameOf(req.Payload)
if err != nil {
return nil, err
}
if err := requireKnownProvider(name); err != nil {
return nil, err
}
f, err := e.loadFile()
if err != nil {
return nil, err
}
entry, _ := findProviderEntry(f, name)
base := e.effectiveBaseURL(name, entry)
target, err := newProbeTarget(base)
if err != nil {
// 地址不合法 / 为空 ⇒ 这条没配齐(探针自己也会这么判,但这里能给出更准的话)。
return nil, pluginmgr.NewActionError(search.ProbeNotConfigured,
"这条没配齐:接口地址为空或不是 http(s) 绝对地址", http.StatusBadRequest, err)
}
cfg := search.Config{
APIKey: entry.APIKey,
BaseURL: base,
Client: target.client(e.probeTimeout(f)),
MaxBytes: webSearchProbeMaxBytes,
Guard: target.guard,
}
res := search.Probe(ctx, name, cfg, 0)
if !res.OK {
e.log.Warn("web-search panel probe failed",
"provider", name, "code", res.ErrorCode, "latencyMs", res.LatencyMS)
return nil, pluginmgr.NewActionError(res.ErrorCode, res.Error, probeErrorStatus(res.ErrorCode), nil)
}
e.log.Info("web-search panel probe ok", "provider", name, "count", res.Count, "latencyMs", res.LatencyMS)
return res, nil
}
// reauthProvider 把一条配置**带回表单**(行内动作「重配凭据」,design/09 §5.2.1 示例的
// reauth 语义):返回 `{values:{...}}`,前端把它灌进唯一的 form 区块。
//
// 两处口径:
// - apiKey 回**掩码**(读侧无明文,§5.4.2):前端对 secret 字段一律不回填输入框,
// 只把它算作「盘上已有值」⇒ 留空 = 不改动、要清空必须显式勾选;
// - 其余字段回**盘上那条的原样**,不做默认值回填:回填代码默认地址会让一次
// 「只改 key」的保存把默认地址固化进文件(面板显示的是「该条」,不是「生效值」)。
func (e webSearchActionEnv) reauthProvider(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
name, err := providerNameOf(req.Payload)
if err != nil {
return nil, err
}
if err := requireKnownProvider(name); err != nil {
return nil, err
}
f, err := e.loadFile()
if err != nil {
return nil, err
}
entry, exists := findProviderEntry(f, name)
values := map[string]any{
"provider": name,
"baseUrl": entry.BaseURL,
"enabled": !exists || entry.IsEnabled(),
}
// 两个文件级字段一并回填:不然用户「清空降级链」在前端的三态写回里会落成缺位
// (= 不改动),「我清掉了它」就成了一句假话。
addSelectionValues(values, f)
if masked, ok := maskedProviderEntry(f, name); ok && strings.TrimSpace(masked.APIKey) != "" {
values["apiKey"] = masked.APIKey
}
return map[string]any{"values": values}, nil
}
// --- 可变源读写(含失败分类) -------------------------------------------------
// loadFile 读可变源。
//
// 文件**缺失或为空** = 「还没配过」(启动期已由 `.env` 播种;极端情况下面板先于启动期
// 被调用)⇒ 返回空文件,不是错误。
// 文件**存在但不合法** ⇒ 结构化失败:绝不把损坏配置当成空配置 —— 那会让一次保存
// 静默盖掉一份人工写坏但可救回的内容(与启动期的处置同向:回落但不覆盖)。
func (e webSearchActionEnv) loadFile() (runtimeconfig.WebSearchFile, error) {
f, found, err := runtimeconfig.LoadWebSearch(e.path)
if err != nil {
e.log.Error("web-search panel config unreadable", "path", e.path, "error", err)
return runtimeconfig.WebSearchFile{}, pluginmgr.NewActionError(actionCodeConfigUnreadable,
"面板配置读不出来(文件损坏或权限不足),本次**没有**改动它;详见服务端日志",
http.StatusInternalServerError, err)
}
if !found {
return runtimeconfig.WebSearchFile{
Version: runtimeconfig.WebSearchVersion,
Providers: []runtimeconfig.ProviderEntry{},
}, nil
}
return f, nil
}
// providerRows 把各条配置翻译成面板表格行(凭据与地址都不进行数据)。
func providerRows(f runtimeconfig.WebSearchFile) []search.ProviderRow {
inputs := make([]search.ProviderInput, 0, len(f.Providers))
for _, p := range f.Providers {
inputs = append(inputs, search.ProviderInput{
Provider: p.Provider,
APIKey: p.APIKey,
BaseURL: p.BaseURL,
Enabled: p.IsEnabled(),
})
}
return search.BuildRows(inputs)
}
// findProviderEntry 按名字取一条配置(大小写与首尾空白不敏感,与
// UpsertProviderEntry 的匹配键同一口径)。
func findProviderEntry(f runtimeconfig.WebSearchFile, name string) (runtimeconfig.ProviderEntry, bool) {
want := strings.ToLower(strings.TrimSpace(name))
for _, p := range f.Providers {
if strings.EqualFold(strings.TrimSpace(p.Provider), want) {
return p, true
}
}
return runtimeconfig.ProviderEntry{}, false
}
// maskedProviderEntry 取某条配置的**掩码副本**(复用 runtimeconfig 的唯一掩码出口,
// 不在这里另写一份 maskToken)。
func maskedProviderEntry(f runtimeconfig.WebSearchFile, name string) (runtimeconfig.ProviderEntry, bool) {
return findProviderEntry(runtimeconfig.MaskedWebSearchFile(f), name)
}
// effectiveBaseURL 给出一条配置**实际会用的**基地址:盘上有就用盘上的,没有就回落到
// 代码默认值(各云家的厂商默认地址)。
//
// searxng 不回落(空地址 = 关掉这家,回落会让「清空地址」变成假话)—— 与
// `effectiveSearchConfig` 的同一条权威边界。
func (e webSearchActionEnv) effectiveBaseURL(name string, entry runtimeconfig.ProviderEntry) string {
if base := strings.TrimSpace(entry.BaseURL); base != "" {
return base
}
if strings.EqualFold(name, search.NameSearXNG) {
return ""
}
return e.defaultBaseURL(name)
}
// defaultBaseURL 取某家云 provider 的代码默认基地址(来自 `.env` 解析后的配置,未写该键
// 时就是标签默认值)。名字表复用组合根既有那张(`searchProviderVector`),不新造一份。
func (e webSearchActionEnv) defaultBaseURL(name string) string {
for _, p := range searchProviderVector(e.envCfg) {
if strings.EqualFold(p.Name, name) {
return strings.TrimSpace(p.BaseURL)
}
}
return ""
}
// probeTimeout 给出一次探针的超时上限:面板文件的 timeout 优先(面板里刚存的就是它),
// 其次 `.env`/代码默认值,最后是包内兜底 —— 与工具运行时的裁决顺序一致。
func (e webSearchActionEnv) probeTimeout(f runtimeconfig.WebSearchFile) time.Duration {
if d, err := f.TimeoutDuration(); err == nil && d > 0 {
return d
}
if e.envCfg.Timeout > 0 {
return e.envCfg.Timeout
}
return webSearchProbeTimeout
}
// --- 载荷解析(形状不对就报错,不静默当空) ------------------------------------
// actionObject 取请求体的 JSON 对象。空体(nil)返回 (nil, nil):少数动作不需要参数。
func actionObject(payload any) (map[string]any, error) {
if payload == nil {
return nil, nil
}
obj, ok := payload.(map[string]any)
if !ok {
return nil, invalidPayloadActionError("请求体必须是一个 JSON 对象")
}
return obj, nil
}
// actionValuesObject 取 `{values:{...}}`(表单提交的形状,见前端 formState.useFormSubmit)。
func actionValuesObject(payload any) (map[string]any, error) {
obj, err := actionObject(payload)
if err != nil {
return nil, err
}
if obj == nil {
return nil, invalidPayloadActionError("缺少 values 对象(表单提交的形状是 {values:{...}})")
}
values, ok := obj["values"].(map[string]any)
if !ok {
return nil, invalidPayloadActionError("缺少 values 对象(表单提交的形状是 {values:{...}})")
}
return values, nil
}
// providerNameOf 从载荷里取 provider 名(三种**显式**来源,不猜字段):
// 行内动作给 `{row:{provider}}`(前端 tableState),也可给 `{values:{provider}}`
// (与表单同一形状),或顶层直接给 `provider`。
func providerNameOf(payload any) (string, error) {
obj, err := actionObject(payload)
if err != nil {
return "", err
}
if obj == nil {
return "", invalidPayloadActionError("缺少 provider 名(行内动作给 row.provider)")
}
for _, key := range []string{"row", "values", "provider"} {
switch v := obj[key].(type) {
case string:
if key == "provider" && strings.TrimSpace(v) != "" {
return normalizeProviderName(v), nil
}
case map[string]any:
if name := actionString(v, "provider"); name != "" {
return normalizeProviderName(name), nil
}
}
}
return "", invalidPayloadActionError("缺少 provider 名(行内动作给 row.provider,表单给 values.provider)")
}
// requiredProviderName 取表单里的 provider 名(必填)。
func requiredProviderName(values map[string]any) (string, error) {
name := actionString(values, "provider")
if name == "" {
return "", invalidPayloadActionError("provider 必填:请选择 / 填写要配置的提供商名")
}
return normalizeProviderName(name), nil
}
// requireKnownProvider 拒掉本期没登记的名字(存了也永远不会被选中;探它只会得到
// 「未登记」)。错误码与文案与 search 的探针口径一致,前端可按同一个码本地化。
func requireKnownProvider(name string) error {
if search.Known(name) {
return nil
}
return pluginmgr.NewActionError(actionCodeUnknownProvider,
fmt.Sprintf("未登记的搜索提供商 %q(本期接入位:%s)", name, strings.Join(search.KnownNames(), ", ")),
http.StatusBadRequest, nil)
}
// actionString 取一个字符串字段(去首尾空白;非字符串与空串都算「没给」)。
func actionString(m map[string]any, key string) string {
s, ok := m[key].(string)
if !ok {
return ""
}
return strings.TrimSpace(s)
}
// normalizeProviderName 归一 provider 名(小写 + 去空白),与注册表/可变源的匹配键同口径。
func normalizeProviderName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
// mergeProviderEntry 把表单补丁合并进一条配置(三态写回,见 saveProvider 的注释)。
// enabled 给了非布尔值时**显式报错**:静默保留会让「我关了它」变成假话。
func mergeProviderEntry(prev runtimeconfig.ProviderEntry, values map[string]any) (runtimeconfig.ProviderEntry, error) {
out := prev
if v, ok := values["apiKey"]; ok {
key, err := actionOptionalString(v, "apiKey")
if err != nil {
return runtimeconfig.ProviderEntry{}, err
}
out.APIKey = key
}
if v, ok := values["baseUrl"]; ok {
base, err := actionOptionalString(v, "baseUrl")
if err != nil {
return runtimeconfig.ProviderEntry{}, err
}
out.BaseURL = base
}
on, set, err := actionOptionalBool(values["enabled"], "enabled")
if err != nil {
return runtimeconfig.ProviderEntry{}, err
}
if set {
out.Enabled = runtimeconfig.EnableFlag(on)
}
return out, nil
}
// actionOptionalString 校验一个可选字符串字段:缺位 ⇒ 空串(调用方按「未给」处理);
// 非字符串 ⇒ 报错(把类型错静默当成空串 = 一次保存悄悄清空了某个字段)。
func actionOptionalString(v any, key string) (string, error) {
if v == nil {
return "", nil
}
s, ok := v.(string)
if !ok {
return "", invalidPayloadActionError(fmt.Sprintf("%s 必须是字符串", key))
}
return strings.TrimSpace(s), nil
}
// actionOptionalBool 解析可选开关:缺位 / nil / 空串 ⇒ 未改动;bool ⇒ 采用;
// 其它类型 ⇒ 报错(理由同 actionOptionalString)。
func actionOptionalBool(v any, key string) (value bool, set bool, err error) {
switch raw := v.(type) {
case nil:
return false, false, nil
case bool:
return raw, true, nil
case string:
if strings.TrimSpace(raw) == "" {
return false, false, nil
}
return false, false, invalidPayloadActionError(fmt.Sprintf("%s 必须是布尔值", key))
default:
return false, false, invalidPayloadActionError(fmt.Sprintf("%s 必须是布尔值", key))
}
}
// invalidPayloadActionError 造一个 400 invalid_payload(形状错 = 调用方错,不是上游错)。
func invalidPayloadActionError(message string) error {
return pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload, message, http.StatusBadRequest, nil)
}