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:
AskaEth committed 2026-10-06 14:12:23 +08:00
1 parent a150211468
commit 2e1c3ed3fd
13 files changed
+2043 -204

No files matched your search

+413
View File
@@ -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)
}