Files
Cyrene_v2/cmd/cyrene/wire_plugin_actions.go
AskaEth c68387a69a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(search): 面板上终于能改「结果条数上限」(原来改了会被静默丢弃)
真缺陷(上一片登记):`maxResults` 在面板上**根本改不了** —— ① 字段表里没有这个
字段(UI 不显示输入框);② `saveProvider` 只并 provider/apiKey/baseUrl/enabled;
③ `mergeSelectionPatch` 只处理 primary/fallback ⇒ **手工塞进 values 也会被静默丢弃**
(文件不变、也不报错)。「我改了它却没变」正是要消灭的那种假话。

- `panel.go`:表单加 `{Key: FieldMaxResults, Type: sdk.FieldNumber, Min: 1}`(**SDK 本就有
  数字类型**,未自造;前端本就渲染 number ⇒ **不需要改前端**)。刻意不设 `Default`
  (设了会让一次「只改 key」的保存把默认值固化进文件、还可能盖掉 `.env`)。
- `websearch_selection.go`:`mergeSelectionPatch` 里按**三态**落 `maxResults` ——
  缺位/null ⇒ **不改动**;正整数(含整数字符串)⇒ 采用;0/负数/小数/空串/非数/
  布尔/数组 ⇒ **400 `invalid_payload`** 且**盘上一字节不动**(绝不静默忽略)。
- `reauth` 回填该值(否则一保存就把上限清掉)。
- `.env.example`:该键的示例值与「默认 5」注释同步为 **10**(代码默认已改;⚠️ `.env` 未动)。

验收(主会话复跑 + 亲自变异):**整树 `go build ./...` 通过**;三包 vet/测试绿;
端到端「面板存 20 ⇒ 模型要 50 也只给 20、要 3 给 3」(走真 action hub → 落盘 →
热重载 → WithSearchMaxResults → 真 dispatcher,未重启后端);
我用 -overlay 把 `f.MaxResults = maxResults` 摘掉 ⇒ 两条用例变红
(「面板给的值必须真的落盘」/「回填的 maxResults = 5, want 20」)。

⚠️ 已知取舍(如实登记):**清空输入框 = 400**(后端按「非数 ⇒ 报错」拒),因此面板上
没有「去掉上限」的手势 —— 要回到 `.env` 兜底得改文件;要支持「清空 = 去掉覆盖」
需另定哨兵语义,未自行发挥。
⚠️ 契约面已变更(面板声明多一个 number 字段 + saveProvider 的 values 多一个可写键 +
响应多一个回显键)⇒ 主会话登记。
2026-10-07 11:10:56 +08:00

192 lines
9.0 KiB
Go
Raw Permalink 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,
}
}