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(含尚未配置的家)。 // // 每项是 `{value,label}`(search.ProviderOption):**`value` 才是落盘的值** —— 它逐字等于 // search.KnownNames(),会被写进 web-search.json 并被注册表按名字取用;`label` 只是显示名 // (中文 + 括注 id),前端拿它渲染下拉与表格,任何写盘路径都不该碰它。 // // 给全部而不是「已配置的家」的理由见 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` / `maxResults`):它们不是 // 这一条 provider 的属性,而是「谁先用 / 失败后按什么顺序降级 / 单次最多几条」,因此合并到 // 文件级而不是条目上(解析与校验在 websearch_selection.go)。缺位 = 不改动;但给出了**不合法** // 的值(如 `maxResults` ≤0 / 非整数)⇒ 整笔 400,**绝不静默丢弃** —— 静默丢弃会让用户以为 // 上限已经改了,而跑的还是旧的那个(那正是这一片要消灭的那条「我改了它却没变」)。 // // 落盘之后由组合根的热重载事务接管(`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": strings.ToLower(strings.TrimSpace(final.Provider)), "fallback": selectionNames(final.Fallback), // maxResults 的 0 = 文件里未声明(由 `.env` / 代码默认兜底),原样回显,不编一个假值。 "maxResults": final.MaxResults, }, 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」的保存把默认地址固化进文件(面板显示的是「该条」,不是「生效值」)。 // // 文件级字段(`maxResults` 在内)也按同一条口径回填:**盘上有值才回**。上限不回填的话表单 // 里那个框是空的,用户既看不见当前上限、也无从改起;而盘上没写(0 = 未声明)时**不回** 0 —— // 0 会被前端当成一个值送回来,写侧又拒 ≤0,那会让「只改 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(), } // 三个文件级字段一并回填(主 provider / 降级链 / 条数上限):不然用户「清空降级链」在前端 // 的三态写回里会落成缺位(= 不改动),「我清掉了它」就成了一句假话;上限同理 —— // 不回填就看不见盘上的值,改上限也就无从下手。 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 给出一条配置**实际会用的**基地址:盘上有就用盘上的,没有就回落到 // **该接入位的代码默认基地址**(`plugins/tools/io/search` 的接入位表,唯一出处)。 // // 进位链与装配期**同一个函数**(resolveBaseURL):searxng 的默认基地址是空 —— 它的地址 // 为空本身就是「关掉这家」,回落会让「清空地址」变成假话。 func (e webSearchActionEnv) effectiveBaseURL(name string, entry runtimeconfig.ProviderEntry) string { return resolveBaseURL(name, entry.BaseURL) } // 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) }