Files
Cyrene_v2/cmd/cyrene/wire_plugin_actions.go
T
AskaEth bc1dee3f56
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
refactor(search): provider 配置面去 env 化 —— 加新家不再需要动 .env
动因:`.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)两片随后串行落地。
2026-10-07 10:18:58 +08:00

192 lines
9.0 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
// 本文件是**内置插件动作面的登记入口**(design/09 §5.2.1「动作分发」;切片 L1/PLG-2 的
// 执行面接线):把编译进二进制的插件声明的动作处理器登记到插件管理器上。首个使用者是
// web-search 的「搜索提供商」页(§5.5 dogfood):四个动作
// plugin.listProviders / plugin.saveProvider / plugin.testProvider / plugin.reauthProvider,
// 外加下拉候选来源 `plugin:providers`(前端按 `optionsFrom` 推出这个名字)。
//
// 三个文件的分工(都在本包):
// - 本文件:登记入口、处理器环境、动作名映射、「声明了却没实现」的装配期核对;
// - `websearch_action_data.go`:动作本体的业务数据面(可变源的读写与翻译、载荷解析);
// - `websearch_probe.go`:探针的传输层绑定(HTTP 客户端 + 同主机守卫 + 状态映射);
// - `websearch_reload.go`:保存后的**热重载**事务(重建工具 + 换注册表条目 + 失败回滚);
// - `websearch_selection.go`:表单里两个文件级字段(主 provider / 降级链)的读写。
//
// 三条边界:
//
// 1. **数据面只读写可变源**:`$DATA_DIR/config/plugins/web-search.json`(design/13 §2.1),
// 经 runtimeconfig 的读写入口(0600 + temp+rename),宿主自己不开新路径、不猜形状;
// 2. **探针真发一次请求**:走 `search.Probe`(同一家 provider 构造、同一份归一化),
// 不靠「配置看起来齐了」假装连通(§5.5 的「测试连通」);
// 3. **凭据不出后端**:读侧只回掩码(`MaskedWebSearchFile`),日志只记 provider 名与
// 结果码,绝不记 key / 地址。
//
// 热重载(切片 W3;裁决 2026-10-06 #1):保存动作被 `webSearchActionHandlersWithHotReload`
// 包上事务 —— 落盘成功后重建 web-search 工具并替换注册表条目,**下一轮对话即生效**
// (design/09 §5.5 的端到端判据「面板加一家 ⇒ 不改 `.env`、不重启 ⇒ 下一次搜索真的走它」)。
import (
"log/slog"
"sort"
"strings"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// actionWireLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。
const actionWireLogSource = "cmd/cyrene/wire_plugin_actions"
// 动作失败码(宿主级,且只在动作面这组文件里使用)。领域自己的码来自 search(探针码)
// 与 pluginmgr.ActionCodeInvalidPayload。
const (
// actionCodeUnknownProvider 报告请求点名了一家本期没登记的 provider。
actionCodeUnknownProvider = "unknown_provider"
// actionCodeConfigUnreadable 报告面板配置存在但读不出来(损坏/权限)。
actionCodeConfigUnreadable = "config_unreadable"
// actionCodeConfigWriteFailed 报告面板配置写不进去(磁盘/权限)。
actionCodeConfigWriteFailed = "config_write_failed"
)
// wireBuiltinPluginActions 把内置插件的动作处理器登记到插件管理器上(§5.2.1:
// 插件在组合根注册自己的动作处理器)。
//
// 登记之后旧账要结清:**声明里写了但没人实现**的动作会如实记一条 WARN(端点对它的
// 结算就是 501)。这正是 §5.2.1「表里写了一个没实现的动作,review 时一眼可见」的
// 运行期版本 —— 装配期就把差异说出来,而不是等用户点出一个 501。
//
// 登记失败(命名空间非法 / 中枢未接线)只记 ERROR、不阻断启动:一个坏的动作面不该
// 让整个后端起不来(与面板声明的处置一致:该面板不注册 + 显式报错)。
func wireBuiltinPluginActions(mgr *pluginmgr.Manager, hub *pluginmgr.Panels, a *app, log *slog.Logger) {
if mgr == nil || hub == nil {
return
}
env, ok := a.webSearchActionEnv(log)
if !ok {
log.Warn("builtin plugin actions not registered: no config/data dir",
"pluginId", search.PanelPluginID, "effect", "panel actions will answer 501")
return
}
handlers := webSearchActionHandlersWithHotReload(env, a, log)
for optionsFrom, handler := range webSearchOptionsHandlers(env) {
name := pluginmgr.OptionsActionName(optionsFrom)
if name == "" {
log.Error("optionsFrom 无法映射成动作名,候选来源未接线",
"pluginId", search.PanelPluginID, "optionsFrom", optionsFrom)
continue
}
handlers[name] = handler
}
if err := mgr.RegisterActions(search.PanelPluginID, handlers); err != nil {
log.Error("builtin plugin actions not registered; panel actions will answer 501",
"pluginId", search.PanelPluginID, "error", err)
return
}
log.Info("builtin plugin actions registered",
"pluginId", search.PanelPluginID, "actions", registeredActionNames(hub))
warnImpliedButMissing(hub, log)
}
// warnImpliedButMissing 把「声明里有、登记里没有」的动作与候选来源如实报出来
// (前者的结算就是 501 action_not_implemented;后者会让前端静默降级成文本框)。
func warnImpliedButMissing(hub *pluginmgr.Panels, log *slog.Logger) {
registered := make(map[string]bool)
for _, name := range hub.ActionHandlerNames(search.PanelPluginID) {
registered[name] = true
}
for _, name := range hub.DeclaredActions(search.PanelPluginID) {
if !registered[name] {
log.Warn("declared plugin action has no handler; it will answer 501",
"pluginId", search.PanelPluginID, "action", name)
}
}
for _, src := range declaredOptionsSources(search.AdminPanels()) {
name := pluginmgr.OptionsActionName(src)
if name == "" || !registered[name] {
log.Warn("declared optionsFrom has no handler; the control degrades to a text input",
"pluginId", search.PanelPluginID, "optionsFrom", src, "action", name)
}
}
}
// registeredActionNames 返回已登记的处理器的动作名(诊断日志用,已排序)。
func registeredActionNames(hub *pluginmgr.Panels) []string {
names := hub.ActionHandlerNames(search.PanelPluginID)
sort.Strings(names)
return names
}
// declaredOptionsSources 从声明里收集全部 `optionsFrom` 取值(去重、排序)。
//
// 用途是**反向核对**:声明里出现了一个没有实现的候选来源时,装配期就要说出来 ——
// 否则前端只会静默降级成文本框,没人知道是「声明多写了一个」。
func declaredOptionsSources(panels []sdk.Panel) []string {
seen := make(map[string]bool)
for _, panel := range panels {
for _, s := range panel.Sections {
for _, f := range s.Fields {
if key := strings.TrimSpace(f.OptionsFrom); key != "" {
seen[key] = true
}
}
}
}
out := make([]string, 0, len(seen))
for key := range seen {
out = append(out, key)
}
sort.Strings(out)
return out
}
// webSearchActionEnv 是 web-search 动作处理器共享的接线事实(一次装配、多动作复用)。
//
// 刻意只装**在多个动作间共享**的东西(日志出口、可变源路径、`.env` 解析后的配置),
// 每次调用各自读文件:动作之间不共享「当前配置」这份可变状态,就不会出现
// 「A 动作读到的是 B 动作刚改之前的那份」。
type webSearchActionEnv struct {
log *slog.Logger
// path 是可变源落点 `$DATA_DIR/config/plugins/web-search.json`。
path string
// envCfg 是 `.env` 解析结果(**只用来取标量默认值**:探针超时;各家的默认基地址改由
// 接入位表给出,见 resolveBaseURL)。
envCfg config.WebSearchConfig
}
// webSearchActionEnv 构造处理器环境。ok=false 表示没有配置/数据目录
// (未装配完成或测试夹具),此时不登记动作,由调用方如实告警。
func (a *app) webSearchActionEnv(log *slog.Logger) (webSearchActionEnv, bool) {
if a.cfg == nil {
return webSearchActionEnv{}, false
}
return webSearchActionEnv{
log: log,
path: runtimeconfig.WebSearchConfigPath(resolveDataDir(a.cfg)),
envCfg: a.cfg.WebSearch,
}, true
}
// webSearchActionHandlers 返回 web-search 插件声明的**四个动作**的处理器。
func webSearchActionHandlers(env webSearchActionEnv) map[string]pluginmgr.ActionHandler {
return map[string]pluginmgr.ActionHandler{
search.ActionListProviders: env.listProviders,
search.ActionSaveProvider: env.saveProvider,
search.ActionTestProvider: env.testProvider,
search.ActionReauthProvider: env.reauthProvider,
}
}
// webSearchOptionsHandlers 返回「下拉候选来源」的处理器(键 = 声明里的 `optionsFrom` 取值)。
//
// 映射成什么动作名由 `pluginmgr.OptionsActionName` 一处决定(与前端
// `resolveOptionsAction` 同源):两处若分叉,声明的下拉就会去调一个宿主不认识的名字。
func webSearchOptionsHandlers(env webSearchActionEnv) map[string]pluginmgr.ActionHandler {
return map[string]pluginmgr.ActionHandler{
"plugin:providers": env.listProviderOptions,
}
}