真缺陷(上一片登记):`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 多一个可写键 +
响应多一个回显键)⇒ 主会话登记。
192 lines
9.0 KiB
Go
192 lines
9.0 KiB
Go
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,
|
||
}
|
||
}
|