动因:`.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)两片随后串行落地。
428 lines
18 KiB
Go
428 lines
18 KiB
Go
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`,切片 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 给出一条配置**实际会用的**基地址:盘上有就用盘上的,没有就回落到
|
||
// **该接入位的代码默认基地址**(`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)
|
||
}
|