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, } }