feat(plugins): 面板动作执行器接线 —— 动作真可用,不再 501 兜底(L1)
- 插件管理器加生产动作处理器登记面(RegisterActions);未登记处理器/未声明动作仍 404 - web-search 四个动作真读写真源 + 真发 HTTP 探针:plugin.listProviders/saveProvider/ testProvider/reauthProvider(配置落 $DATA_DIR/config/plugins/web-search.json,0600) - 失败回结构化错误码(不含上游原文)+ 审计带 code 字段;501 文案改为「未登记处理器」 - 拆出 panelactions.go(动作索引推导/OptionsActionName/三闸门分发),panels.go 519→347 行 缺口(如实):面板保存后运行中的工具**不热重载**(下一片做);声明面缺 primary/fallback (按 DECISIONS-2026-10-06 #1/#2 补)。
This commit is contained in:
1 parent
a150211468
commit
2e1c3ed3fd
13 files changed
+2043
-204
No files matched your search
@@ -0,0 +1,413 @@
|
||||
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 / 空地址)⇒ 采用。
|
||||
//
|
||||
// 只接受**已登记**的名字:未登记的家存进去也永远不会被选中(注册表按名字取),
|
||||
// 那会让「我明明配了 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
|
||||
if err := runtimeconfig.SaveWebSearch(e.path, runtimeconfig.UpsertProviderEntry(cur, entry)); 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(),
|
||||
}, 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(),
|
||||
}
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user