From a30f4a49ff06d72515ed1ac697b48b570c23f5a6 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 15:59:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(search):=20provider=20=E7=83=AD=E9=87=8D?= =?UTF-8?q?=E8=BD=BD=20+=20primary/fallback=EF=BC=88W3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保存被包成事务:快照 → 落盘 → 按新配置重建工具 → 原子替换注册表条目(registry.ReplaceTool 窄方法,保留运行期 Policy);下一轮即生效,不要求重启 - 若新配置会让后端下次起不来(主家未配齐 fail-fast)⇒ 文件按字节回滚 + 409 config_unstartable - 声明面补 primary + fallback[](可配可生效);否决「另开 form」方案(会破坏既有 reauth 回填) - 主 provider 解析口径核查结论:没被 PLG-4 改坏(顺手修了一处日志缺陷) - 4 个变异自检(丢 Policy/不换条目/回滚 no-op/忽略 fallback) --- cmd/cyrene/websearch_action_data.go | 20 +- cmd/cyrene/websearch_reload.go | 257 +++++++++++ cmd/cyrene/websearch_reload_test.go | 406 ++++++++++++++++++ cmd/cyrene/websearch_selection.go | 203 +++++++++ cmd/cyrene/websearch_selection_test.go | 282 ++++++++++++ cmd/cyrene/websearch_testenv_test.go | 211 +++++++++ cmd/cyrene/wire_io_tools.go | 72 +++- cmd/cyrene/wire_plugin_actions.go | 13 +- internal/capability/registry/registry.go | 38 ++ .../capability/registry/replacetool_test.go | 101 +++++ plugins/tools/io/search/panel.go | 32 +- plugins/tools/io/search/panel_test.go | 77 ++++ 12 files changed, 1696 insertions(+), 16 deletions(-) create mode 100644 cmd/cyrene/websearch_reload.go create mode 100644 cmd/cyrene/websearch_reload_test.go create mode 100644 cmd/cyrene/websearch_selection.go create mode 100644 cmd/cyrene/websearch_selection_test.go create mode 100644 cmd/cyrene/websearch_testenv_test.go create mode 100644 internal/capability/registry/replacetool_test.go diff --git a/cmd/cyrene/websearch_action_data.go b/cmd/cyrene/websearch_action_data.go index 6ce4204..abb73da 100644 --- a/cmd/cyrene/websearch_action_data.go +++ b/cmd/cyrene/websearch_action_data.go @@ -48,6 +48,13 @@ func (e webSearchActionEnv) listProviderOptions(_ context.Context, _ pluginmgr.A // - apiKey **空串** ⇒ 显式清空(前端只有勾了「清除」才会送出空串); // - baseUrl / enabled 缺位或空串 ⇒ 保留;给出(含 false / 空地址)⇒ 采用。 // +// 同一次提交里还可能带两个**文件级**字段(`primary` / `fallback`,切片 W3):它们不是这一条 +// provider 的属性,而是「谁先用 / 失败后按什么顺序降级」,因此合并到文件级而不是条目上 +// (解析与校验在 websearch_selection.go)。缺位 = 不改动,与其余字段同一套哨兵语义。 +// +// 落盘之后由组合根的热重载事务接管(`websearch_reload.go`:重建工具 + 换注册表条目, +// 失败则连同本次落盘一起回滚)—— 本函数只负责「把改动存进去」这一件事。 +// // 只接受**已登记**的名字:未登记的家存进去也永远不会被选中(注册表按名字取), // 那会让「我明明配了 tavily」变成一句假话 —— 所以在这里就拒。 func (e webSearchActionEnv) saveProvider(_ context.Context, req pluginmgr.ActionRequest) (any, error) { @@ -72,7 +79,11 @@ func (e webSearchActionEnv) saveProvider(_ context.Context, req pluginmgr.Action return nil, err } entry.Provider = name - if err := runtimeconfig.SaveWebSearch(e.path, runtimeconfig.UpsertProviderEntry(cur, entry)); err != nil { + next, err := e.applySelectionPatch(cur, values) + if err != nil { + return nil, err + } + if err := runtimeconfig.SaveWebSearch(e.path, runtimeconfig.UpsertProviderEntry(next, 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) @@ -84,6 +95,10 @@ func (e webSearchActionEnv) saveProvider(_ context.Context, req pluginmgr.Action "provider": name, "created": !existed, "enabled": entry.IsEnabled(), + // 文件级选择的**落盘后**取值(没给 primary/fallback 时也如实回显当前值): + // 面板按它回显「谁先用」,也让「这次保存有没有动到选择」有可机读的答案。 + "primary": strings.ToLower(strings.TrimSpace(next.Provider)), + "fallback": selectionNames(next.Fallback), }, nil } @@ -157,6 +172,9 @@ func (e webSearchActionEnv) reauthProvider(_ context.Context, req pluginmgr.Acti "baseUrl": entry.BaseURL, "enabled": !exists || entry.IsEnabled(), } + // 两个文件级字段一并回填:不然用户「清空降级链」在前端的三态写回里会落成缺位 + // (= 不改动),「我清掉了它」就成了一句假话。 + addSelectionValues(values, f) if masked, ok := maskedProviderEntry(f, name); ok && strings.TrimSpace(masked.APIKey) != "" { values["apiKey"] = masked.APIKey } diff --git a/cmd/cyrene/websearch_reload.go b/cmd/cyrene/websearch_reload.go new file mode 100644 index 0000000..a11863a --- /dev/null +++ b/cmd/cyrene/websearch_reload.go @@ -0,0 +1,257 @@ +package main + +// 本文件是「搜索提供商」页的**热重载**(切片 W3;`DECISIONS-2026-10-06` 裁决 #1): +// 面板保存后,**重建** web-search 工具并**替换**能力注册表里的条目,使改动在下一轮对话 +// 即生效 —— 不再要求「重启后端」。 +// +// 为什么必须做(而不是把「重启后生效」写进面板文案):面板的意义就是「不改 `.env`、 +// 不重启」,留着「重启后生效」等于把配置面做成摆设(裁决 #1 的理由原文)。design/09 §5.5 +// 的端到端判据也正是「面板加一家 ⇒ 不改 `.env`、不重启 ⇒ 下一次搜索真的走它」。 +// +// 三条口径: +// +// 1. **一次保存 = 一次事务**:写可变源 → 重建工具 → 替换注册表条目。任何一步失败都把 +// 文件**回滚**到保存前那一份,绝不留「盘上是新的、内存是旧的」或「盘上的配置让后端 +// 下次起不来」这两种半状态。 +// 2. **重建走启动期同一条路**(`buildWebSearchTool` → `webSearchOptions`):重载后的工具 +// 与启动期构造的逐字段同源,不是第二份接线逻辑(避免两处语义漂移)。 +// 3. **替换是原子的、保留策略**:走 `registry.ReplaceTool` 而不是 Remove+Register +// (详见该方法注释:中间无空窗,且分组/陌生人可达标记不被重置)。 +// +// 触发面只有一处:`wire_plugin_actions.go` 把**写可变源**的那个动作(`plugin.saveProvider`) +// 包上 `webSearchReloader.wrap`。动作本体(websearch_action_data.go 的 saveProvider)保持 +// 「只负责把这条存进去」的单一职责,重载与回滚都在本文件。 + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "sync" + + pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" +) + +// webSearchReloadLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。 +const webSearchReloadLogSource = "cmd/cyrene/websearch_reload" + +// actionCodeConfigUnstartable 报告「这次保存的结果会让后端**下一次启动直接失败**」—— +// 文件已回滚,改动**没有**生效(406 / 409 这类「与当前状态冲突」的档,不是 500 内部错)。 +const actionCodeConfigUnstartable = "config_unstartable" + +// webSearchRollbackFileMode 是回滚写回时的文件权限:与 runtimeconfig 落盘可变源的 0600 +// 同口径(那份文件带 API Key)。硬编码成 0600 而不是读对方的常量,是因为那个常量未导出, +// 而变化方向是「收紧」,不存在比 0600 更松的风险。 +const webSearchRollbackFileMode os.FileMode = 0o600 + +// webSearchReloadOutcome 报告一次热重载的结局,并作为动作响应里可机读的事实。 +type webSearchReloadOutcome struct { + // Applied 报告这次保存**真的触发了**热重载(false = 没有能力注册表可换, + // 例如测试夹具;此时只落了盘,运行中的工具没动)。 + Applied bool `json:"applied"` + // Registered 报告保存之后 web-search **在不在模型的工具面里**(Applied 为 true + // 时才有意义):关掉主 provider / 端点会让它整把消失,这是设计语义,不是失败。 + Registered bool `json:"registered"` +} + +// webSearchReloader 把「保存可变源 → 重建工具 → 换注册表条目」做成一次事务。 +// +// 锁的粒度是**整次保存**(含动作本体那一步):两个并发的面板保存如果交错,可能出现 +// 「A 的写 + B 的重载」这种盘上与内存对不上的组合。面板保存是低频动作,串行化的代价 +// 可以忽略,换来的是「最后一次保存定终局」这条可证的顺序。 +type webSearchReloader struct { + mu sync.Mutex + app *app + path string + log *slog.Logger +} + +// webSearchActionHandlersWithHotReload 在既有的动作处理器表之上,给**写可变源**的动作 +// 套上热重载事务(其余动作不动:`testProvider` / `reauthProvider` 不改文件,无需重载)。 +// +// 包在 `wireBuiltinPluginActions` 里调用(登记入口一处决定「谁会被热重载」),因此 +// 动作名到处理器的映射关系在这里仍然可见,不额外引入第二张表。 +func webSearchActionHandlersWithHotReload( + env webSearchActionEnv, a *app, log *slog.Logger, +) map[string]pluginmgr.ActionHandler { + handlers := webSearchActionHandlers(env) + r := &webSearchReloader{app: a, path: env.path, log: logger.WithSource(log, webSearchReloadLogSource)} + for _, action := range []string{search.ActionSaveProvider} { + if inner, ok := handlers[action]; ok && inner != nil { + handlers[action] = r.wrap(inner) + } + } + return handlers +} + +// wrap 把一次「写可变源的保存」变成事务: +// +// ① 快照文件(回滚的凭据); +// ② 跑动作本体(它负责把改动落盘); +// ③ 按**落盘后的配置**重建工具并替换注册表条目; +// ④ ③ 失败 ⇒ 回滚文件(内存没动过,因此回滚后两处一致)并回结构化错误。 +func (r *webSearchReloader) wrap(inner pluginmgr.ActionHandler) pluginmgr.ActionHandler { + return func(ctx context.Context, req pluginmgr.ActionRequest) (any, error) { + r.mu.Lock() + defer r.mu.Unlock() + + snap, err := r.snapshot() + if err != nil { + return nil, pluginmgr.NewActionError(actionCodeConfigUnreadable, + "面板配置读不出来,本次**没有**改动它;详见服务端日志", http.StatusInternalServerError, err) + } + res, err := inner(ctx, req) + if err != nil { + return nil, err + } + outcome, rerr := r.reload() + if rerr == nil { + return withReloadOutcome(res, outcome), nil + } + if rberr := r.rollback(snap); rberr != nil { + // 回滚都失败了 ⇒ 只能如实说「盘上可能已经变了」,绝不假装改动没发生。 + r.log.Error("web-search config rollback failed after an unstartable save", + "path", r.path, "reloadError", rerr, "rollbackError", rberr) + return nil, pluginmgr.NewActionError(actionCodeConfigWriteFailed, + "这次改动会让 web-search 起不来,且回滚也失败了:请人工检查 "+ + r.path+"(详见服务端日志)", http.StatusInternalServerError, errors.Join(rerr, rberr)) + } + r.log.Warn("web-search panel save rolled back: the resulting config cannot start", + "path", r.path, "error", rerr) + return nil, pluginmgr.NewActionError(actionCodeConfigUnstartable, + unstartableMessage(rerr), http.StatusConflict, rerr) + } +} + +// reload 按落盘后的配置重建工具并替换注册表条目。 +// +// 三种结局:换掉(工具该在)/摘掉(当前配置下它不该在注册面里,与启动期语义一致)/ +// 报错(这份配置起不来 ⇒ 调用方回滚)。没有能力注册表时如实报告 Applied=false。 +func (r *webSearchReloader) reload() (webSearchReloadOutcome, error) { + if r.app == nil || r.app.cfg == nil || r.app.caps == nil { + // 没有注册表可换(测试夹具 / 装配未完成):只落了盘,工具没动。 + // 不静默当成「换过了」——否则响应里的 registered 会是一句假话。 + r.log.Debug("web-search hot reload skipped: no capability registry", + "path", r.path, "effect", "the running tool keeps the startup wiring") + return webSearchReloadOutcome{}, nil + } + tool, err := r.app.buildWebSearchTool(webSearchCausePanelSave) + if err != nil { + return webSearchReloadOutcome{}, err + } + if tool == nil { + return webSearchReloadOutcome{Applied: true}, r.app.removeSearchTool(r.log) + } + if err := r.app.replaceSearchTool(tool); err != nil { + return webSearchReloadOutcome{}, err + } + r.log.Info("web-search tool hot-reloaded after panel save", + "path", r.path, "action", search.ActionSaveProvider, + "effect", "the next turn uses the new wiring; no restart needed") + return webSearchReloadOutcome{Applied: true, Registered: true}, nil +} + +// replaceSearchTool 把重建出来的工具放进注册面:**已登记就就地替换**(原子、保留策略), +// 之前不在注册面里(例如上一次保存把它关了)就新增。 +func (a *app) replaceSearchTool(t sdk.Tool) error { + err := a.caps.ReplaceTool(t) + if err == nil { + return nil + } + if !errors.Is(err, registry.ErrNotFound) { + return fmt.Errorf("web-search: replace tool: %w", err) + } + if rerr := a.caps.RegisterTool(t); rerr != nil { + return fmt.Errorf("web-search: register tool: %w", rerr) + } + return nil +} + +// removeSearchTool 把 web-search 从注册面摘掉(当前配置下它不该在:端点/主 provider 被 +// 显式关闭,或选定 provider 没配齐)。本来就不在时是幂等的。 +func (a *app) removeSearchTool(log *slog.Logger) error { + err := a.caps.Remove(webSearchToolName) + if err != nil && !errors.Is(err, registry.ErrNotFound) { + return fmt.Errorf("web-search: remove tool: %w", err) + } + log.Info("web-search tool removed from the registry after panel save", + "action", search.ActionSaveProvider, + "effect", "the model no longer sees a web-search tool aimed at a closed provider") + return nil +} + +// fileSnapshot 是可变源在**一次保存之前**的样子(回滚的凭据)。 +// +// 存原始字节而不是解析后的结构:回滚的语义是「一字不差地回到那一份」。经结构往返 +// (Load + Save)会丢掉人工写在文件里的、本包不认识的键 —— 那正是「损坏配置可人工救回」 +// 场景里最不该丢的东西。 +type fileSnapshot struct { + // exists 报告保存前文件在不在(不存在时回滚 = 把它删掉)。 + exists bool + // raw 是保存前的原始内容(exists 为 false 时无意义)。 + raw []byte +} + +// snapshot 读一份保存前的快照。文件不存在 = 合法的「还没配过」状态,不是错误。 +func (r *webSearchReloader) snapshot() (fileSnapshot, error) { + raw, err := os.ReadFile(r.path) + if err != nil { + if os.IsNotExist(err) { + return fileSnapshot{}, nil + } + return fileSnapshot{}, fmt.Errorf("web-search: snapshot %s: %w", r.path, err) + } + return fileSnapshot{exists: true, raw: raw}, nil +} + +// rollback 把文件恢复成快照那一份(temp+rename,与 runtimeconfig 的落盘口径同源: +// 并发读者永远看不到半个文件)。 +func (r *webSearchReloader) rollback(snap fileSnapshot) error { + if !snap.exists { + if err := os.Remove(r.path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("web-search: rollback remove %s: %w", r.path, err) + } + return nil + } + tmp := r.path + ".rollback.tmp" + if err := os.WriteFile(tmp, snap.raw, webSearchRollbackFileMode); err != nil { + return fmt.Errorf("web-search: rollback write %s: %w", tmp, err) + } + if err := os.Rename(tmp, r.path); err != nil { + return fmt.Errorf("web-search: rollback rename %s: %w", r.path, err) + } + return nil +} + +// unstartableMessage 把「重建失败」的病因翻成**能照着改**的一句话:主 provider 未配齐 +// 与名字未登记是两种不同的修法,笼统说一句「配置不可用」等于让用户自己猜。 +func unstartableMessage(err error) string { + switch { + case errors.Is(err, search.ErrNotConfigured): + return "这次改动会让 web-search 没有可用的后端(主提供商未配齐:缺 API Key 或接口地址)," + + "后端下次启动会直接失败 ⇒ 已回滚,改动**没有**生效。请先给主提供商补齐凭据,或改选一家已配齐的。" + case errors.Is(err, search.ErrUnknownProvider): + return "这次改动指向了一个未登记的搜索提供商(名字写错了 / 本期没做这一家)," + + "后端下次启动会直接失败 ⇒ 已回滚,改动**没有**生效。" + default: + return "这次改动会让 web-search 起不来(后端下次启动会直接失败)⇒ 已回滚,改动**没有**生效;" + + "详见服务端日志。" + } +} + +// withReloadOutcome 把热重载的结局挂到动作响应上(响应体是对象时追加一个 `hotReload` +// 字段;不是对象时原样返回 —— 宁可少一个字段,也不把结果包成另一种形状)。 +func withReloadOutcome(res any, outcome webSearchReloadOutcome) any { + m, ok := res.(map[string]any) + if !ok { + return res + } + m["hotReload"] = outcome + return m +} diff --git a/cmd/cyrene/websearch_reload_test.go b/cmd/cyrene/websearch_reload_test.go new file mode 100644 index 0000000..5cf6488 --- /dev/null +++ b/cmd/cyrene/websearch_reload_test.go @@ -0,0 +1,406 @@ +package main + +// 本文件钉住切片 W3 的**热重载**(`DECISIONS-2026-10-06` 裁决 #1): +// +// - 面板保存 ⇒ 重建 web-search 工具 ⇒ 替换注册表条目 ⇒ **下一轮即生效** +// (design/09 §5.5 的端到端判据:不改 `.env`、不重启,下一次搜索真的走新配置); +// - 主 provider / 降级链经同一个表单落到文件级字段后**立刻接上工具**; +// - 保存结果会让后端**下次起不来**时:非 2xx + 结构化码 + **按字节回滚**; +// - 用户真正拿到的那一面(管理端点 HTTP)也逐条核对。 +// +// 夹具与假上游在 websearch_testenv_test.go;面板选择字段与解析口径在 +// websearch_selection_test.go。 + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" + pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" +) + +// --- ① 保存后热重载:下一次搜索真的走新配置 ------------------------------- + +// TestWebSearchHotReloadPointsAtSavedEndpoint 是热重载的主判据(design/09 §5.5): +// 面板把 searxng 的地址改成另一个上游 ⇒ **不重启** ⇒ 下一轮搜索必须打到新那家, +// 且旧那家不再收到请求。同时钉住「注册表里的值是真的换了一把」(不是改了旧对象)。 +func TestWebSearchHotReloadPointsAtSavedEndpoint(t *testing.T) { + old := newWSFakeSearxng(t, "old") + fresh := newWSFakeSearxng(t, "fresh") + + env := newReloadTestEnv(t, + "WEB_SEARCH_PROVIDER="+search.NameSearXNG, + "WEB_SEARCH_ENDPOINT="+old.url, + ) + if _, err := env.search(t, "cyrene"); err != nil { + t.Fatalf("启动期搜索失败: %v", err) + } + if len(old.hits) == 0 { + t.Fatal("启动期没打到配置里的上游 —— 接线本身有问题") + } + before := env.toolValue(t) + old.drain() + + outcome := env.mustSave(t, map[string]any{ + "provider": search.NameSearXNG, "baseUrl": fresh.url, "enabled": true, + }) + if !outcome.Applied || !outcome.Registered { + t.Fatalf("hotReload = %+v, want applied 且 registered", outcome) + } + if env.toolValue(t) == before { + t.Fatal("注册表里的 web-search 还是启动期那一把(没被替换)") + } + got, err := env.search(t, "cyrene") + if err != nil { + t.Fatalf("保存后搜索失败: %v", err) + } + if len(fresh.hits) == 0 { + t.Fatal("保存后的搜索没打到新上游 —— 改动没生效(还得重启)") + } + old.noHit(t) + if got["provider"] != search.NameSearXNG { + t.Fatalf("provider = %v, want %q", got["provider"], search.NameSearXNG) + } +} + +// TestWebSearchHotReloadKeepsCapabilityPolicy:热替换必须**保留**能力面策略 +// (分组/陌生人可达标记是运行期配置,不属于工具实现)。丢了它 = 一次面板保存让某个群的 +// 工具面悄悄缩水。 +func TestWebSearchHotReloadKeepsCapabilityPolicy(t *testing.T) { + up := newWSFakeSearxng(t, "a") + env := newReloadTestEnv(t, + "WEB_SEARCH_PROVIDER="+search.NameSearXNG, + "WEB_SEARCH_ENDPOINT="+up.url, + ) + if err := env.app.caps.SetAllowInStranger(webSearchToolName, true); err != nil { + t.Fatalf("SetAllowInStranger: %v", err) + } + env.mustSave(t, map[string]any{ + "provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, + }) + pol, err := env.app.caps.Policy(webSearchToolName) + if err != nil { + t.Fatalf("Policy: %v", err) + } + if !pol.AllowInStranger { + t.Fatal("热替换丢掉了 AllowInStranger(策略是运行期配置,必须沿用)") + } +} + +// --- ② 主 provider / 降级链:面板可配且立刻生效 ----------------------------- + +// TestWebSearchHotReloadSwitchesPrimaryAndFallback 钉住裁决 #2 的端到端语义: +// 面板选中谁当主家,下一次搜索就走谁;主家不可用时按降级链前进并**如实标注**实际应答的家。 +func TestWebSearchHotReloadSwitchesPrimaryAndFallback(t *testing.T) { + searxng := newWSFakeUpstream(t, http.StatusInternalServerError, `{"error":"boom"}`) + bocha := newWSFakeUpstream(t, http.StatusOK, + `{"webPages":{"value":[{"name":"博查结果","url":"https://b.example/1","summary":"正文"}]}}`) + + env := newReloadTestEnv(t, + "WEB_SEARCH_PROVIDER="+search.NameSearXNG, + "WEB_SEARCH_ENDPOINT="+searxng.url, + "WEB_SEARCH_BOCHA_API_KEY=sk-bocha", + "WEB_SEARCH_BOCHA_BASE_URL="+bocha.url, + ) + + // 选 bocha 当主家(文件级 primary),降级链留 searxng:两者都在盘上、都已配齐。 + outcome := env.mustSave(t, map[string]any{ + "provider": search.NameBocha, "apiKey": "sk-bocha", "baseUrl": bocha.url, "enabled": true, + "primary": search.NameBocha, + "fallback": []any{search.NameSearXNG}, + }) + if !outcome.Applied { + t.Fatalf("hotReload = %+v, want applied", outcome) + } + got, err := env.search(t, "cyrene") + if err != nil { + t.Fatalf("切换主家后搜索失败: %v", err) + } + if got["provider"] != search.NameBocha { + t.Fatalf("provider = %v, want %q(面板选的主家必须立刻生效)", got["provider"], search.NameBocha) + } + if len(bocha.hits) == 0 { + t.Fatal("选中的新主家一个请求都没收到") + } + + // 把主家换回 searxng(它 500)⇒ 必须**降级到 bocha** 并如实标注是谁答的。 + bocha.drain() + searxng.drain() + env.mustSave(t, map[string]any{ + "provider": search.NameSearXNG, "baseUrl": searxng.url, "enabled": true, + "primary": search.NameSearXNG, + "fallback": []any{search.NameBocha}, + }) + got, err = env.search(t, "cyrene") + if err != nil { + t.Fatalf("降级链搜索失败: %v", err) + } + if got["provider"] != search.NameBocha { + t.Fatalf("provider = %v, want %q(主力 500 时必须前进一格并如实标注)", got["provider"], search.NameBocha) + } + if len(searxng.hits) == 0 { + t.Fatal("降级链没先试主力 searxng") + } +} + +// TestWebSearchHotReloadTogglesToolByPrimary 钉住「关掉就整把消失、再开就回来」: +// 主 provider 写成 off 哨兵 ⇒ 模型看不见这把工具(与启动期语义一致);改回一家可用的 +// ⇒ 同一进程内它又回到注册面。 +func TestWebSearchHotReloadTogglesToolByPrimary(t *testing.T) { + up := newWSFakeSearxng(t, "a") + env := newReloadTestEnv(t, + "WEB_SEARCH_PROVIDER="+search.NameSearXNG, + "WEB_SEARCH_ENDPOINT="+up.url, + ) + off := env.mustSave(t, map[string]any{ + "provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, + "primary": "off", + }) + if !off.Applied || off.Registered { + t.Fatalf("hotReload = %+v, want applied=true registered=false", off) + } + if wsHasTool(env.app, webSearchToolName) { + t.Fatalf("主 provider 写成 off 后 web-search 不得留在注册面里: %v", toolNames(env.app)) + } + back := env.mustSave(t, map[string]any{ + "provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, + "primary": search.NameSearXNG, + }) + if !back.Applied || !back.Registered { + t.Fatalf("hotReload = %+v, want applied=true registered=true", back) + } + if _, err := env.search(t, "cyrene"); err != nil { + t.Fatalf("改回可用主家后搜索应当成功: %v", err) + } +} + +// --- ③ 回滚:不让面板存出一份「下次起不来」的配置 --------------------------- + +// TestWebSearchHotReloadRollsBackUnstartableSave 是安全网的判据:面板把**当前主家**的 +// 凭据清空后,这份配置会让后端下次启动直接失败(design/27 §3 的 fail-fast)⇒ 保存必须 +// 诚实失败、把文件**按字节**回滚,并且运行中的工具必须仍是原来那一把。 +func TestWebSearchHotReloadRollsBackUnstartableSave(t *testing.T) { + bocha := newWSFakeUpstream(t, http.StatusOK, + `{"webPages":{"value":[{"name":"博查","url":"https://b.example/1","summary":"正文"}]}}`) + env := newReloadTestEnv(t, + "WEB_SEARCH_PROVIDER="+search.NameBocha, + "WEB_SEARCH_BOCHA_API_KEY=sk-keep", + "WEB_SEARCH_BOCHA_BASE_URL="+bocha.url, + ) + before := env.rawFile(t) + toolBefore := env.toolValue(t) + + _, err := env.save(map[string]any{"provider": search.NameBocha, "apiKey": ""}) + ae, ok := pluginmgr.AsActionError(err) + if !ok { + t.Fatalf("err = %v,期望结构化 ActionError(清空主家凭据会让后端起不来)", err) + } + if ae.Code != actionCodeConfigUnstartable { + t.Fatalf("失败码 = %q, want %q", ae.Code, actionCodeConfigUnstartable) + } + if ae.Status < 400 || ae.Status > 599 { + t.Fatalf("失败状态 = %d,必须是非 2xx 的结构化失败", ae.Status) + } + if strings.Contains(ae.Message, "sk-keep") { + t.Fatalf("失败文案泄漏了凭据: %q", ae.Message) + } + + if got := env.rawFile(t); !bytes.Equal(got, before) { + t.Fatalf("回滚后文件与保存前不逐字节相同:\nbefore=%s\nafter =%s", before, got) + } + if env.toolValue(t) != toolBefore { + t.Fatal("被回滚的保存却把注册表里的工具换掉了(盘上与内存不一致)") + } + got, err := env.search(t, "cyrene") + if err != nil { + t.Fatalf("回滚后搜索应当照常成功: %v", err) + } + if got["provider"] != search.NameBocha { + t.Fatalf("回滚后 provider = %v, want %q", got["provider"], search.NameBocha) + } + if out := env.out.String(); !strings.Contains(out, "rolled back") { + t.Fatalf("回滚必须留下可诊断的日志: %q", out) + } +} + +// TestWebSearchReloadWithoutRegistryIsHonest:没有能力注册表(夹具 / 装配未完成)时, +// 热重载**如实报告 applied=false**,绝不假装换过了 —— 否则响应里的 registered 会是一句假话。 +func TestWebSearchReloadWithoutRegistryIsHonest(t *testing.T) { + cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) + b := bus.New() + defer b.Close() + mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog()) + defer mgr.Close(context.Background()) + + // 故意不设 caps:这正是既有动作夹具(actionTestEnv)的形状。 + a := &app{cfg: cfg, log: testLog(), bus: b, plugins: mgr} + wirePanelHub(a) + res, err := mgr.Panels().Dispatch(context.Background(), pluginmgr.ActionRequest{ + PluginID: search.PanelPluginID, Action: search.ActionSaveProvider, + Payload: map[string]any{"values": map[string]any{ + "provider": search.NameSearXNG, "baseUrl": "http://127.0.0.1:38471", "enabled": true, + }}, + }) + if err != nil { + t.Fatalf("保存失败: %v", err) + } + outcome, ok := res.(map[string]any)["hotReload"].(webSearchReloadOutcome) + if !ok { + t.Fatalf("响应里没有 hotReload: %#v", res) + } + if outcome.Applied || outcome.Registered { + t.Fatalf("没有注册表可换时必须如实报 applied=false: %+v", outcome) + } +} + +// --- ④ 用户真正拿到的那一面:管理端点(HTTP)--------------------------------- + +// adminActionHTTP 把组合根装配出来的动作端点架起来,返回 base URL 与 owner 令牌。 +// +// 网关这一段(鉴权 → 声明 → 分发 → 状态码映射)不是本片改的,但用户拿到的是它: +// 只在动作中枢上验,没法证明「契约里那个 409 真的到得了客户端」。 +func adminActionHTTP(t *testing.T, env *reloadTestEnv) (string, string) { + t.Helper() + jwtSvc := auth.New(config.AuthConfig{JWTSecret: obsTestSecret}) + gw, err := gateway.New(&config.Config{ + Auth: config.AuthConfig{JWTSecret: obsTestSecret}, + }, gateway.Deps{Log: testLog(), Auth: jwtSvc, Plugins: env.app.plugins}) + if err != nil { + t.Fatalf("gateway.New: %v", err) + } + t.Cleanup(gw.Close) + ts := httptest.NewServer(gw.Handler()) + t.Cleanup(ts.Close) + tok, err := jwtSvc.Sign("acct", auth.RoleOwner, time.Hour) + if err != nil { + t.Fatalf("sign token: %v", err) + } + return ts.URL + "/api/v1/admin/plugins/" + search.PanelPluginID + "/actions/" + search.ActionSaveProvider, tok +} + +// TestWebSearchHotReloadOverAdminHTTP 核对成功路径的 HTTP 契约:200 信封里的 +// `result.hotReload` 必须如实,而且保存后下一次搜索**真的**打到新上游。 +func TestWebSearchHotReloadOverAdminHTTP(t *testing.T) { + cloud := newWSFakeUpstream(t, http.StatusOK, + `{"webPages":{"value":[{"name":"博查","url":"https://b.example/1","summary":"正文"}]}}`) + env := newReloadTestEnv(t, + "WEB_SEARCH_PROVIDER="+search.NameBocha, + "WEB_SEARCH_BOCHA_API_KEY=sk-keep", + "WEB_SEARCH_BOCHA_BASE_URL="+cloud.url, + ) + url, tok := adminActionHTTP(t, env) + + status, raw := wsPostAction(t, url, tok, map[string]any{"values": map[string]any{ + "provider": search.NameSearXNG, "baseUrl": cloud.url, "enabled": true, + "primary": search.NameBocha, + }}) + if status != http.StatusOK { + t.Fatalf("保存状态 = %d, want 200(body=%s)", status, raw) + } + var body struct { + Data struct { + Action string `json:"action"` + Kind string `json:"kind"` + Result struct { + Saved bool `json:"saved"` + HotReload struct { + Applied bool `json:"applied"` + Registered bool `json:"registered"` + } `json:"hotReload"` + } `json:"result"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("decode: %v (body=%s)", err, raw) + } + if body.Data.Kind != string(pluginmgr.ActionWrite) { + t.Errorf("kind = %q, want %q(写动作必须按写面鉴权)", body.Data.Kind, pluginmgr.ActionWrite) + } + if !body.Data.Result.Saved || !body.Data.Result.HotReload.Applied || !body.Data.Result.HotReload.Registered { + t.Fatalf("响应 envelope 里的结果不如实: %s", raw) + } + got, err := env.search(t, "cyrene") + if err != nil { + t.Fatalf("HTTP 保存后搜索失败: %v", err) + } + if got["provider"] != search.NameBocha { + t.Fatalf("provider = %v, want %q(HTTP 保存也必须热重载)", got["provider"], search.NameBocha) + } +} + +// TestWebSearchSaveOverAdminHTTPRollsBackUnstartable 核对失败路径的 HTTP 契约: +// **非 2xx** + `{error:{code,message}}`,且盘上文件真的回到了保存前。 +func TestWebSearchSaveOverAdminHTTPRollsBackUnstartable(t *testing.T) { + cloud := newWSFakeUpstream(t, http.StatusOK, + `{"webPages":{"value":[{"name":"博查","url":"https://b.example/1","summary":"正文"}]}}`) + env := newReloadTestEnv(t, + "WEB_SEARCH_PROVIDER="+search.NameBocha, + "WEB_SEARCH_BOCHA_API_KEY=sk-keep", + "WEB_SEARCH_BOCHA_BASE_URL="+cloud.url, + ) + url, tok := adminActionHTTP(t, env) + before := env.rawFile(t) + + status, raw := wsPostAction(t, url, tok, map[string]any{"values": map[string]any{ + "provider": search.NameBocha, "apiKey": "", + }}) + if status != http.StatusConflict { + t.Fatalf("状态 = %d, want 409(body=%s)", status, raw) + } + var body struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("decode error body: %v (body=%s)", err, raw) + } + if body.Error.Code != actionCodeConfigUnstartable { + t.Fatalf("错误码 = %q, want %q(body=%s)", body.Error.Code, actionCodeConfigUnstartable, raw) + } + if strings.Contains(body.Error.Message, "sk-keep") || body.Error.Message == "" { + t.Fatalf("错误文案 = %q(不得为空、不得含凭据)", body.Error.Message) + } + if got := env.rawFile(t); !bytes.Equal(got, before) { + t.Fatalf("HTTP 层的回滚不彻底:\nbefore=%s\nafter =%s", before, got) + } +} + +// wsPostAction 向管理动作端点发一次 POST(Bearer + JSON 体),返回状态码与响应体。 +func wsPostAction(t *testing.T, url, token string, payload any) (int, []byte) { + t.Helper() + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(raw)) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST %s: %v", url, err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read %s: %v", url, err) + } + return resp.StatusCode, body +} diff --git a/cmd/cyrene/websearch_selection.go b/cmd/cyrene/websearch_selection.go new file mode 100644 index 0000000..d10fba1 --- /dev/null +++ b/cmd/cyrene/websearch_selection.go @@ -0,0 +1,203 @@ +package main + +// 本文件是「搜索提供商」页表单里**两个文件级字段**的解析与落盘(切片 W3; +// `DECISIONS-2026-10-06` 裁决 #2:声明面必须能配「谁先用」——`primary` + `fallback[]`)。 +// +// 它们与其余字段(provider / apiKey / baseUrl / enabled)的区别: +// +// - 其余字段描述**一条 provider 条目**(providers[] 里的一个元素); +// - `primary` / `fallback` 描述**整份配置的选择**(文件级的 `provider` / `fallback`, +// 即 design/27 §3 的「主后端 + 降级链」)。 +// +// 为什么挂在**同一个** form 区块里,而不是另开一个:前端的「带回表单」(reauth)只在面板 +// **恰好一个** form 区块时转交(`frontend/web/src/admin/plugin/PluginPanel.tsx` 的 +// `singleForm`),加第二个 form 会让既有的「重配凭据」回填整条失灵。因此这两项作为文件级 +// 字段进既有表单,回填值由 `reauthProvider` 一并给出(见 addSelectionValues 的调用点)。 +// +// 字段 key 刻意不叫 `provider`:表单里的 `provider` 已经是「这一条 provider 的名字」, +// 两个语义不同的东西共用一个 key 会让「我在改谁」变成一句要猜的话。 + +import ( + "fmt" + "net/http" + "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/plugins/tools/io/search" +) + +// 表单里两个文件级字段的 key 由**声明侧**给出(search.FieldPrimary / search.FieldFallback): +// 它们必须与 panel.go 里写下的字段声明逐字一致(本文件读的正是这些 key),而 +// 「声明里写一个 key、处理器里读另一个」这类漂移只有共用一个常量才拦得住 —— +// 在 cmd/cyrene 里再写一遍字面量就等于第二份真源。 +const ( + // selectionPrimaryKey 是主 provider 的字段 key(落到文件级 `provider`)。 + selectionPrimaryKey = search.FieldPrimary + // selectionFallbackKey 是降级链的字段 key(落到文件级 `fallback`)。 + selectionFallbackKey = search.FieldFallback +) + +// actionCodePrimaryNotConfigured 报告「选中的主 provider 在本文件里还没配齐」—— +// 这不是一次可以放着不管的保存:那份配置会让后端**下次启动直接失败**(design/27 §3 的 +// fail-fast),所以在写盘之前就拒掉,而不是先写进盘里等着下次起不来。 +const actionCodePrimaryNotConfigured = "primary_not_configured" + +// applySelectionPatch 把表单里的主 provider / 降级链合并进文件(三态:**缺位 = 不改动**)。 +// +// 与其余字段同一套哨兵语义(websearch_action_data.go 的 saveProvider 注释): +// `primary` 缺位 ⇒ 保留盘上的;给了值(含显式空串 / off / none)⇒ 采用 —— 空串与 +// `off`/`none` 是本项目**唯一一套**关闭语义(config.IsWebSearchOffSentinel),面板不另造一套。 +// +// 校验放在写盘之前(主 provider 必须已配齐),因为「先存下来、下次启动才发现起不来」 +// 是本项目明确要避免的失败形态(AGENTS §10 的「诚实性」与 design/27 §3 的 fail-fast)。 +func (e webSearchActionEnv) applySelectionPatch( + f runtimeconfig.WebSearchFile, values map[string]any, +) (runtimeconfig.WebSearchFile, error) { + primary, setPrimary, err := selectionPrimary(values) + if err != nil { + return f, err + } + fallback, setFallback, err := selectionFallback(values) + if err != nil { + return f, err + } + if setPrimary { + f.Provider = primary + } + if setFallback { + f.Fallback = fallback + } + if setPrimary { + if err := e.checkPrimaryUsable(f, f.Provider); err != nil { + return f, err + } + } + return f, nil +} + +// checkPrimaryUsable 判定一个主 provider 名在**落盘后的文件**里是否可用。 +// +// 判定口径与工具接线同源(searchProviderSpecs + effectiveBaseURL):云家看 API Key、 +// searxng 看接口地址,基地址缺位时回落到代码默认值;条目被显式停用视同没配 +// (effectiveSearchConfig 会跳过它)。 +func (e webSearchActionEnv) checkPrimaryUsable(f runtimeconfig.WebSearchFile, name string) error { + // 显式关闭(空串 / off / none)永远合法:它表示「不要这把工具」,工具不进注册面, + // 后端照常启动(与 WEB_SEARCH_PROVIDER 的既有语义逐字一致)。 + if config.IsWebSearchOffSentinel(name) { + return nil + } + if err := requireKnownProvider(name); err != nil { + return err + } + entry, found := findProviderEntry(f, name) + base := "" + if found { + base = e.effectiveBaseURL(name, entry) + } + if !found || !entry.IsEnabled() || !search.Configured(name, entry.APIKey, base) { + return pluginmgr.NewActionError(actionCodePrimaryNotConfigured, + fmt.Sprintf("主提供商 %q 还没配齐(缺 API Key / 接口地址,或这条被停用了):"+ + "先把它配好再选它 —— 否则这份配置会让后端下一次启动直接失败", name), + http.StatusBadRequest, nil) + } + return nil +} + +// selectionNames 返回降级链的线形状(**非 nil 空切片**:nil 会在 JSON 里变成 null, +// 而「一条降级都没有」与「这个字段没值」在前端不该长得不一样)。 +func selectionNames(names []string) []string { + if names == nil { + return []string{} + } + return names +} + +// addSelectionValues 把盘上的文件级选择回填进 `reauth` 的 values。 +// +// 回填的必要性在前端的三态写回里:只有 `initial` 有值,用户「清空降级链」才会被送成 +// 显式空(`planSubmit`),否则会落成「缺位 = 不改动」——「我清掉了它」就成了一句假话。 +func addSelectionValues(values map[string]any, f runtimeconfig.WebSearchFile) { + values[selectionPrimaryKey] = f.Provider + fb := f.Fallback + if fb == nil { + fb = []string{} + } + values[selectionFallbackKey] = fb +} + +// selectionPrimary 解析主 provider 字段:缺位 ⇒ (…, false, nil);非字符串 ⇒ 400。 +func selectionPrimary(values map[string]any) (string, bool, error) { + raw, ok := values[selectionPrimaryKey] + if !ok || raw == nil { + return "", false, nil + } + s, ok := raw.(string) + if !ok { + return "", false, invalidPayloadActionError( + fmt.Sprintf("%s 必须是字符串(提供商名)", selectionPrimaryKey)) + } + return normalizeProviderName(s), true, nil +} + +// selectionFallback 解析降级链字段:缺位 ⇒ (…, false, nil);形状不对 / 名字未登记 ⇒ 400。 +func selectionFallback(values map[string]any) ([]string, bool, error) { + raw, ok := values[selectionFallbackKey] + if !ok || raw == nil { + return nil, false, nil + } + items, err := selectionItems(raw) + if err != nil { + return nil, false, err + } + out := make([]string, 0, len(items)) + seen := make(map[string]bool, len(items)) + for _, item := range items { + name := normalizeProviderName(item) + if name == "" || seen[name] { + continue + } + // 未登记的家存进降级链也永远不会被选中(注册表按名字取)⇒ 这里就拒,与 + // 条目保存的 requireKnownProvider 同一个码与同一句文案。 + if err := requireKnownProvider(name); err != nil { + return nil, false, err + } + seen[name] = true + out = append(out, name) + } + return out, true, nil +} + +// selectionItems 把多选值规范化成字符串切片。 +// +// 三种形状都接受: +// - `[]string`(Go 侧直接构造的载荷,测试与内部调用用); +// - `[]any`(JSON 解码后的数组 —— 前端 multiSelect 送的就是它); +// - 逗号 / 顿号 / 换行分隔的字符串(与前端控件「逗号分隔」的呈现同源,人工 curl 友好)。 +// +// 其余类型一律 400:静默当空数组 = 一次保存悄悄清空降级链(与 apiKey 的类型错同一条口径)。 +func selectionItems(raw any) ([]string, error) { + switch v := raw.(type) { + case []string: + return v, nil + case []any: + out := make([]string, 0, len(v)) + for i, item := range v { + s, ok := item.(string) + if !ok { + return nil, invalidPayloadActionError( + fmt.Sprintf("%s[%d] 必须是字符串(提供商名)", selectionFallbackKey, i)) + } + out = append(out, s) + } + return out, nil + case string: + return strings.FieldsFunc(v, func(r rune) bool { + return r == ',' || r == ',' || r == '、' || r == ';' || r == '\n' + }), nil + default: + return nil, invalidPayloadActionError( + fmt.Sprintf("%s 必须是字符串数组(如 [\"bocha\",\"zhipu\"])", selectionFallbackKey)) + } +} diff --git a/cmd/cyrene/websearch_selection_test.go b/cmd/cyrene/websearch_selection_test.go new file mode 100644 index 0000000..0a35117 --- /dev/null +++ b/cmd/cyrene/websearch_selection_test.go @@ -0,0 +1,282 @@ +package main + +// 本文件钉住切片 W3 的另外三条判据(`DECISIONS-2026-10-06` 裁决 #2 / 点 ③ / 点 ④): +// +// - **主 provider / 降级链可配**:两个文件级字段(`primary` / `fallback`)经表单落到 +// 可变源,回填(reauth)也带着它们;没配齐的主家**写盘之前**就拒掉; +// - **主 provider 解析口径**(配了可变源 / 只配 env / 都没配)各选谁; +// - **探针失败如实**:非 2xx + 结构化码,不存在「200 带 ok:false」这条路径。 +// +// 夹具与假上游在 websearch_testenv_test.go;热重载本体在 websearch_reload_test.go。 + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + 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" +) + +// --- ① 面板校验:选一个没配齐的主家当场拒掉 --------------------------------- + +// TestWebSearchSelectionRejectsUnusablePrimary 钉住「写盘之前就拦」:主 provider 选了 +// 一家没配齐的(缺 key)必须回 400 primary_not_configured,且**盘上一个字节都没变** +// (这不是「存下来再说」,那份配置会让后端下次起不来)。 +func TestWebSearchSelectionRejectsUnusablePrimary(t *testing.T) { + env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) + before := env.rawFile(t) + + _, err := env.save(map[string]any{ + "provider": search.NameSearXNG, "enabled": true, "primary": search.NameZhipu, + }) + ae, ok := pluginmgr.AsActionError(err) + if !ok || ae.Code != actionCodePrimaryNotConfigured || ae.Status != http.StatusBadRequest { + t.Fatalf("err = %#v, want 400 %s", err, actionCodePrimaryNotConfigured) + } + if got := env.rawFile(t); !bytes.Equal(got, before) { + t.Fatalf("被拒的保存动了盘上的文件:\nbefore=%s\nafter =%s", before, got) + } + + // 未登记的名字:与条目级保存同一个码同一句口径。 + _, err = env.save(map[string]any{ + "provider": search.NameSearXNG, "enabled": true, "primary": "tavily", + }) + if ae, ok := pluginmgr.AsActionError(err); !ok || ae.Code != actionCodeUnknownProvider { + t.Fatalf("err = %#v, want %s", err, actionCodeUnknownProvider) + } + // 降级链里的未登记名字同样拒(它永远不会被选中,存下去就是假话)。 + _, err = env.save(map[string]any{ + "provider": search.NameSearXNG, "enabled": true, "fallback": []any{"tavily"}, + }) + if ae, ok := pluginmgr.AsActionError(err); !ok || ae.Code != actionCodeUnknownProvider { + t.Fatalf("err = %#v, want %s", err, actionCodeUnknownProvider) + } + // 形状错(不是数组)⇒ 400,绝不静默当空数组清空降级链。 + _, err = env.save(map[string]any{ + "provider": search.NameSearXNG, "enabled": true, "fallback": 42, + }) + if ae, ok := pluginmgr.AsActionError(err); !ok || ae.Code != pluginmgr.ActionCodeInvalidPayload { + t.Fatalf("err = %#v, want %s", err, pluginmgr.ActionCodeInvalidPayload) + } +} + +// TestWebSearchSelectionAcceptsConfiguredCloudFallback 钉住「降级链允许放还没配齐的家」: +// 未配置的家在运行期会被跳过(不是错误),所以面板存得下去;而主家必须是配齐的。 +// 同时钉住归一化:去空白、去重、丢空项。 +func TestWebSearchSelectionAcceptsConfiguredCloudFallback(t *testing.T) { + up := newWSFakeSearxng(t, "a") + env := newReloadTestEnv(t, + "WEB_SEARCH_PROVIDER="+search.NameSearXNG, + "WEB_SEARCH_ENDPOINT="+up.url, + ) + env.mustSave(t, map[string]any{ + "provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, + "fallback": []any{search.NameZhipu, " bocha ", search.NameZhipu, ""}, + }) + if got := env.loadFile(t).Fallback; strings.Join(got, ",") != search.NameZhipu+","+search.NameBocha { + t.Fatalf("降级链落盘 = %v, want [zhipu bocha]", got) + } +} + +// TestWebSearchReauthEchoesSelection:表单回填必须带上两个文件级字段,否则用户 +// 「清空降级链」在前端的三态写回里会落成「缺位 = 不改动」。 +func TestWebSearchReauthEchoesSelection(t *testing.T) { + up := newWSFakeSearxng(t, "a") + env := newReloadTestEnv(t, + "WEB_SEARCH_PROVIDER="+search.NameSearXNG, + "WEB_SEARCH_ENDPOINT="+up.url, + ) + env.mustSave(t, map[string]any{ + "provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, + "primary": search.NameSearXNG, "fallback": []any{search.NameBocha}, + }) + out, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{ + PluginID: search.PanelPluginID, Action: search.ActionReauthProvider, + Payload: map[string]any{"row": map[string]any{"provider": search.NameSearXNG}}, + }) + if err != nil { + t.Fatalf("reauth: %v", err) + } + values, _ := out.(map[string]any)["values"].(map[string]any) + if values == nil { + t.Fatalf("reauth 结果缺 values: %#v", out) + } + if values[search.FieldPrimary] != search.NameSearXNG { + t.Fatalf("回填的 primary = %v, want %q", values[search.FieldPrimary], search.NameSearXNG) + } + fb, ok := values[search.FieldFallback].([]string) + if !ok || strings.Join(fb, ",") != search.NameBocha { + t.Fatalf("回填的 fallback = %#v, want [%s]", values[search.FieldFallback], search.NameBocha) + } +} + +// --- ② 主 provider 解析口径(三情形 + 清空)--------------------------------- + +// TestWebSearchPrimaryResolutionFromEnvOnly:**只配 `.env`**(可变源文件缺失)⇒ 选 `.env` +// 那家,来源标 `env`,且播种真的落了盘。 +func TestWebSearchPrimaryResolutionFromEnvOnly(t *testing.T) { + var out bytes.Buffer + cfg := wsLoadConfig(t, + "WEB_SEARCH_PROVIDER="+search.NameBocha, + "WEB_SEARCH_BOCHA_API_KEY=sk-env", + ) + wsSurfaceApp(t, cfg, &out) + if got := wsLogField(t, out.String(), "primary"); got != search.NameBocha { + t.Fatalf("primary = %q, want %q(.env 是唯一来源)", got, search.NameBocha) + } + if got := wsLogField(t, out.String(), "configSource"); got != webSearchSourceEnv { + t.Fatalf("configSource = %q, want %q", got, webSearchSourceEnv) + } + if _, found, err := runtimeconfig.LoadWebSearch(wsPanelPath(cfg)); err != nil || !found { + t.Fatalf("文件缺失时必须按 .env 播种落盘: found=%v err=%v", found, err) + } +} + +// TestWebSearchPrimaryResolutionPanelWins:**配了可变源** ⇒ 选文件里那家(`.env` 说的 +// 另一家不作数),来源标 panel。 +func TestWebSearchPrimaryResolutionPanelWins(t *testing.T) { + var out bytes.Buffer + cfg := wsLoadConfig(t, + "WEB_SEARCH_PROVIDER="+search.NameZhipu, + "WEB_SEARCH_ZHIPU_API_KEY=sk-env-zhipu", + ) + if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{ + Version: runtimeconfig.WebSearchVersion, + Provider: search.NameBocha, + Providers: []runtimeconfig.ProviderEntry{ + {Provider: search.NameBocha, APIKey: "sk-panel", Enabled: runtimeconfig.EnableFlag(true)}, + }, + }); err != nil { + t.Fatalf("写面板配置: %v", err) + } + a := wsSurfaceApp(t, cfg, &out) + if !wsHasTool(a, webSearchToolName) { + t.Fatalf("面板里 bocha 已配齐,工具应在注册面里: %v", toolNames(a)) + } + if got := wsLogField(t, out.String(), "primary"); got != search.NameBocha { + t.Fatalf("primary = %q, want %q(面板文件是唯一来源,.env 说的 zhipu 不作数)", got, search.NameBocha) + } + if got := wsLogField(t, out.String(), "configSource"); got != webSearchSourcePanel { + t.Fatalf("configSource = %q, want %q", got, webSearchSourcePanel) + } +} + +// TestWebSearchPrimaryResolutionDefaultsToSearxng:**都没配** ⇒ 代码默认 searxng +// (本机兜底端点),工具照常在注册面里。 +func TestWebSearchPrimaryResolutionDefaultsToSearxng(t *testing.T) { + var out bytes.Buffer + cfg := wsLoadConfig(t) + a := wsSurfaceApp(t, cfg, &out) + if !wsHasTool(a, webSearchToolName) { + t.Fatalf("都没配时代码默认(searxng 本机兜底)应当生效: %v", toolNames(a)) + } + if got := wsLogField(t, out.String(), "primary"); got != search.NameSearXNG { + t.Fatalf("primary = %q, want %q(代码默认值)", got, search.NameSearXNG) + } +} + +// TestWebSearchPrimaryResolutionClearedFileMeansOff:文件里把 provider 清空 ⇒ **关闭** +// (不是回落 `.env`)——「清空 = 关」是文件形状里写死的语义,面板与 `.env` 不许有两套。 +func TestWebSearchPrimaryResolutionClearedFileMeansOff(t *testing.T) { + var out bytes.Buffer + cfg := wsLoadConfig(t, + "WEB_SEARCH_PROVIDER="+search.NameBocha, + "WEB_SEARCH_BOCHA_API_KEY=sk-env", + ) + if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{ + Version: runtimeconfig.WebSearchVersion, + // 显式清空主 provider:哨兵语义 = 关掉整把工具。 + Provider: "off", + Providers: []runtimeconfig.ProviderEntry{ + {Provider: search.NameBocha, APIKey: "sk-panel", Enabled: runtimeconfig.EnableFlag(true)}, + }, + }); err != nil { + t.Fatalf("写面板配置: %v", err) + } + a := wsSurfaceApp(t, cfg, &out) + if wsHasTool(a, webSearchToolName) { + t.Fatalf("文件里显式关闭(off)⇒ 工具不得进注册面: %v", toolNames(a)) + } + if out := out.String(); !strings.Contains(out, "web-search tool not registered") { + t.Fatalf("关闭必须留下可诊断的 WARN: %q", out) + } +} + +// --- ③ 探针失败如实:非 2xx + 结构化码,绝不「200 带 ok:false」---------------- + +// TestWebSearchProbeFailuresAreStructuredNon2xx 钉住裁决 #4(点 ④):每一次探针失败都必须是 +// **结构化非 2xx**。前端行内动作只在失败时显示文案,把「探不通」放进 200 的 data 里 +// 等于让界面报一句假成功。 +// +// 探针的成功/失败分类由 search.Probe 给出(那里已有单测),这里查的是**动作面**有没有 +// 把失败原样交出去:码稳定、状态码落在 4xx/5xx、文案不带上游原文。 +func TestWebSearchProbeFailuresAreStructuredNon2xx(t *testing.T) { + cases := []struct { + name string + status int + body string + wantCode string + want int + }{ + {"凭据被拒", http.StatusUnauthorized, `{"error":"bad key sk-leak"}`, search.ProbeUnauthorized, http.StatusBadGateway}, + {"被限流", http.StatusTooManyRequests, `{}`, search.ProbeRateLimited, http.StatusBadGateway}, + {"上游 5xx", http.StatusInternalServerError, `{}`, search.ProbeUpstream, http.StatusBadGateway}, + {"应答不是预期结构", http.StatusOK, `not json`, search.ProbeBadResponse, http.StatusBadGateway}, + {"连通但零结果", http.StatusOK, `{"results":[]}`, search.ProbeNoResults, http.StatusBadGateway}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + up := newWSFakeUpstream(t, tc.status, tc.body) + env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) + env.mustSave(t, map[string]any{ + "provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, + }) + out, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{ + PluginID: search.PanelPluginID, Action: search.ActionTestProvider, + Payload: map[string]any{"row": map[string]any{"provider": search.NameSearXNG}}, + }) + if err == nil { + // 「200 带 ok:false」正是这条判据要堵的路:要么成功(ok=true 的结果), + // 要么结构化失败,没有第三种。 + if res, ok := out.(search.ProbeResult); ok && !res.OK { + t.Fatalf("探针失败却回了成功响应(200 + ok:false):%+v", res) + } + t.Fatalf("探针失败却回了 200(结果 = %#v)", out) + } + ae, ok := pluginmgr.AsActionError(err) + if !ok { + t.Fatalf("err = %v,期望结构化 ActionError", err) + } + if ae.Code != tc.wantCode { + t.Fatalf("失败码 = %q, want %q", ae.Code, tc.wantCode) + } + if ae.Status != tc.want { + t.Fatalf("失败状态 = %d, want %d(非 2xx 才算如实)", ae.Status, tc.want) + } + raw, _ := json.Marshal(map[string]any{"code": ae.Code, "message": ae.Message}) + if strings.Contains(string(raw), "sk-leak") { + t.Fatalf("失败文案泄漏了上游原文: %s", raw) + } + }) + } +} + +// TestWebSearchProbeNotConfiguredIs400:没配齐是**调用方/配置**的问题(400), +// 不是上游不可用(502)—— 前端据此区分「该改配置」还是「该重试」。 +func TestWebSearchProbeNotConfiguredIs400(t *testing.T) { + env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) + _, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{ + PluginID: search.PanelPluginID, Action: search.ActionTestProvider, + Payload: map[string]any{"row": map[string]any{"provider": search.NameZhipu}}, + }) + ae, ok := pluginmgr.AsActionError(err) + if !ok || ae.Code != search.ProbeNotConfigured || ae.Status != http.StatusBadRequest { + t.Fatalf("err = %#v, want 400 %s", err, search.ProbeNotConfigured) + } +} diff --git a/cmd/cyrene/websearch_testenv_test.go b/cmd/cyrene/websearch_testenv_test.go new file mode 100644 index 0000000..7f3a715 --- /dev/null +++ b/cmd/cyrene/websearch_testenv_test.go @@ -0,0 +1,211 @@ +package main + +// 本文件是切片 W3 那几组测试的**夹具**(`newReloadTestEnv` / 假上游 / 日志字段读取): +// 单独成一个测试文件,是为了让三个主题文件(热重载、面板选择字段、解析口径与探针) +// 各自都在 AGENTS §10 的文件行数之内,且共享的接线只写一份。 +// +// 夹具的关键差别(相对 wire_plugin_actions_test.go 的 actionTestEnv):它带 +// `a.caps` / `a.dispatcher` —— 热重载换的就是注册表里的条目,没有能力面的夹具 +// 根本证明不了这件事(那正是 L1/PLG-2 当时如实登记的缺口)。 + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" + pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" + iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" +) + +// reloadTestEnv 是一个「组合根真的接好了能力面 + 面板动作面」的夹具。 +type reloadTestEnv struct { + app *app + hub *pluginmgr.Panels + disp *tool.Dispatcher + path string + out *bytes.Buffer +} + +func newReloadTestEnv(t *testing.T, extra ...string) *reloadTestEnv { + t.Helper() + cfg := wsLoadConfig(t, extra...) + b := bus.New() + t.Cleanup(func() { _ = b.Close() }) + mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog()) + t.Cleanup(func() { mgr.Close(context.Background()) }) + + var out bytes.Buffer + caps := registry.New() + disp, err := tool.New(caps) + if err != nil { + t.Fatalf("tool.New: %v", err) + } + a := &app{ + cfg: cfg, + log: logger.New(logger.Options{Out: &out, Level: "debug"}), + bus: b, + plugins: mgr, + caps: caps, + dispatcher: disp, + } + // 启动期那条路(与 wsSurfaceApp 同源):ioToolOptions → iotools.New → 注册面。 + set, err := iotools.New(cfg, a.ioToolOptions()...) + if err != nil { + t.Fatalf("iotools.New: %v", err) + } + for _, tl := range set { + if rerr := disp.Register(tl); rerr != nil { + t.Fatalf("register %s: %v", tl.Name(), rerr) + } + } + wirePanelHub(a) + if mgr.Panels() == nil { + t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上") + } + return &reloadTestEnv{ + app: a, hub: mgr.Panels(), disp: disp, + path: runtimeconfig.WebSearchConfigPath(cfg.DataDir), out: &out, + } +} + +// save 走保存动作(表单形状 `{values:{...}}`),返回结果的**原始 any 与错误**。 +func (e *reloadTestEnv) save(values map[string]any) (any, error) { + return e.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{ + PluginID: search.PanelPluginID, + Action: search.ActionSaveProvider, + Payload: map[string]any{"values": values}, + }) +} + +// mustSave 要求保存成功,并返回响应里的 `hotReload` 块(缺了就失败:它是本片新增的 +// 「这次保存有没有热重载」的可机读事实)。 +func (e *reloadTestEnv) mustSave(t *testing.T, values map[string]any) webSearchReloadOutcome { + t.Helper() + res, err := e.save(values) + if err != nil { + t.Fatalf("保存失败: %v", err) + } + m, ok := res.(map[string]any) + if !ok { + t.Fatalf("保存结果类型 = %T", res) + } + outcome, ok := m["hotReload"].(webSearchReloadOutcome) + if !ok { + t.Fatalf("响应里没有 hotReload(热重载没被接线): %#v", m) + } + return outcome +} + +// search 走**分派器**(模型真正调工具的那条路)打一次 web-search,返回线格式结果。 +func (e *reloadTestEnv) search(t *testing.T, query string) (map[string]any, error) { + t.Helper() + got, err := e.disp.Invoke(context.Background(), webSearchToolName, map[string]any{"query": query}) + if err != nil { + return nil, err + } + m, ok := got.(map[string]any) + if !ok { + t.Fatalf("工具返回值类型 = %T", got) + } + return m, nil +} + +// toolValue 取注册面里那一把 web-search 的**当前**值(不在注册面里就失败)。 +func (e *reloadTestEnv) toolValue(t *testing.T) any { + t.Helper() + cap, err := e.app.caps.Get(webSearchToolName) + if err != nil { + t.Fatalf("web-search 不在注册面里: %v(工具面 = %v)", err, toolNames(e.app)) + } + return cap.Value +} + +// rawFile 读可变源的**原始字节**(「字节级没变」是回滚判据里最强的那一种)。 +func (e *reloadTestEnv) rawFile(t *testing.T) []byte { + t.Helper() + raw, err := os.ReadFile(e.path) + if err != nil { + t.Fatalf("read %s: %v", e.path, err) + } + return raw +} + +// loadFile 读盘上的可变源。 +func (e *reloadTestEnv) loadFile(t *testing.T) runtimeconfig.WebSearchFile { + t.Helper() + f, found, err := runtimeconfig.LoadWebSearch(e.path) + if err != nil || !found { + t.Fatalf("LoadWebSearch(%s): found=%v err=%v", e.path, found, err) + } + return f +} + +// wsFakeUpstream 是一个假搜索上游:记下每一次命中,按预设状态码与响应体作答。 +type wsFakeUpstream struct { + url string + hits chan *http.Request +} + +func newWSFakeUpstream(t *testing.T, status int, body string) *wsFakeUpstream { + t.Helper() + hits := make(chan *http.Request, 8) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits <- r + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return &wsFakeUpstream{url: srv.URL, hits: hits} +} + +// newWSFakeSearxng 是一个「答一条结果」的 searxng 假上游(成功路径用)。 +func newWSFakeSearxng(t *testing.T, title string) *wsFakeUpstream { + t.Helper() + return newWSFakeUpstream(t, http.StatusOK, + `{"results":[{"title":"`+title+`","url":"http://x.example/1","content":"`+title+`"}]}`) +} + +// drain 清掉已经排队的命中(用于「重载之后打的是不是新那家」的判定)。 +func (f *wsFakeUpstream) drain() { + for { + select { + case <-f.hits: + default: + return + } + } +} + +// noHit 断言此刻没有新的命中(重载后**不该**再打到旧那家)。 +func (f *wsFakeUpstream) noHit(t *testing.T) { + t.Helper() + select { + case r := <-f.hits: + t.Fatalf("旧上游又收到一个请求(%s)—— 重载没真的换掉工具", r.URL) + default: + } +} + +// wsLogField 从启动日志里取一个字段值(slog TextHandler 的 `key=value`)。 +func wsLogField(t *testing.T, out, key string) string { + t.Helper() + re := regexp.MustCompile(`(?:^|\s)` + regexp.QuoteMeta(key) + `=(\S+)`) + m := re.FindStringSubmatch(out) + if m == nil { + t.Fatalf("启动日志里找不到 %s=…: %q", key, out) + } + return strings.Trim(m[1], `"`) +} diff --git a/cmd/cyrene/wire_io_tools.go b/cmd/cyrene/wire_io_tools.go index f6b56f0..6df8b81 100644 --- a/cmd/cyrene/wire_io_tools.go +++ b/cmd/cyrene/wire_io_tools.go @@ -23,6 +23,11 @@ package main // **启动期报错**,绝不静默回落到兜底。 // 5. **超时不另造默认值**:`WEB_SEARCH_TIMEOUT` / 面板的 `timeout` 未声明时沿用 io 包既有 // 超时;负值是笔误,按未配置处理并记一条带键名的 WARN(不静默)。 +// +// 同一套解析也服务**热重载**(切片 W3,裁决 2026-10-06 #1):面板保存后由 +// `buildWebSearchTool` 按**当前落盘配置**重建这一把工具并替换注册表条目,因此 +// 「启动期的接线」与「保存后的接线」恒为同一条路径(不是两份语义)。两份调用的差别 +// 只有日志里的 cause 字段(startup / panel-save),便于排障时区分。 import ( "log/slog" @@ -32,6 +37,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io" "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" ) @@ -57,6 +63,21 @@ const ( webSearchSourcePanel = "panel" ) +// 解析一次 web-search 配置的**触发场景**(只进日志的 cause 字段)。 +const ( + // webSearchCauseStartup 是启动期装配(`ioToolOptions`)。 + webSearchCauseStartup = "startup" + // webSearchCausePanelSave 是面板保存后的热重载(`buildWebSearchTool`,见 websearch_reload.go)。 + webSearchCausePanelSave = "panel-save" +) + +// webSearchToolName 是 web-search 工具在能力注册表里的名字。 +// +// 它与 io 包 `func (s *webSearchTool) Name()` 里的字面量同源,而那个名字没有导出常量 +// (`plugins/tools/io` 不在本片文件面内,不去改它):因此这里用字面量,并由 +// websearch_reload_test.go 钉住 —— 名字一旦漂移,热重载会立刻找不到工具(测试红)。 +const webSearchToolName = "web-search" + // ioToolOptions 组装 io 工具集的全部选项。wire_runtime 把它原样交给 iotools.New, // 因此「注册面里有哪些 io 工具」只由这一处决定。 func (a *app) ioToolOptions() []iotools.Option { @@ -64,12 +85,41 @@ func (a *app) ioToolOptions() []iotools.Option { iotools.WithKnowledgeRetriever(a.retriever), iotools.WithLogger(a.log), } - return append(opts, a.webSearchOptions()...) + return append(opts, a.webSearchOptions(webSearchCauseStartup)...) } -// webSearchOptions 把生效配置翻译成 io 选项。 +// buildWebSearchTool 按**当前生效配置**构造这一把 web-search 工具(面板保存后的热重载用它 +// 重建工具,见 websearch_reload.go)。 +// +// 三种返回值,调用方必须分开处置: +// - (tool, nil) ⇒ 正常,工具该在注册面里; +// - (nil, nil) ⇒ 当前配置下它**不该在注册面里**(端点/主 provider 显式关闭,或选定 +// provider 没配齐被 WithoutSearch 摘掉)—— 调用方据此把旧条目摘掉; +// - (nil, err) ⇒ 这份配置会让后端**下次启动直接失败**(主 provider 未登记/未配置, +// design/27 §3 的 fail-fast)—— 调用方必须如实报错,绝不留下一个起不来的文件。 +// +// 为什么要构造整份 io 工具集再挑出 web-search:io 包的公开构造入口只有 +// `iotools.New(cfg, opts...)`(整份集合),没有「只造 web-search」的入口,而 +// `plugins/tools/io` 不在本片文件面内。代价是一次 New(几个工具对象 + 一个 http.Client +// 壳;Transport 是共享的 http.DefaultTransport,不新开连接池)—— 面板保存是低频动作, +// 付得起,换来的是**重载后的工具与启动期构造的逐字段同源**(不是第二份接线逻辑)。 +func (a *app) buildWebSearchTool(cause string) (sdk.Tool, error) { + set, err := iotools.New(a.cfg, a.webSearchOptions(cause)...) + if err != nil { + return nil, err + } + for _, t := range set { + if t.Name() == webSearchToolName { + return t, nil + } + } + return nil, nil +} + +// webSearchOptions 把生效配置翻译成 io 选项。cause 说明**为什么**解析这一次(启动装配 / +// 面板保存后的热重载),只进日志:同一个函数被两处调用,日志里必须能分清是哪一次。 // 端点/后端「关闭 / 非法 / 可用」三种去处都在这里定死(ResolveProvider/ResolveEndpoint)。 -func (a *app) webSearchOptions() []iotools.Option { +func (a *app) webSearchOptions(cause string) []iotools.Option { log := logger.WithSource(a.log, ioLogSource) ws, source := a.effectiveWebSearch(log) @@ -77,7 +127,8 @@ func (a *app) webSearchOptions() []iotools.Option { if decision := ws.ResolveProvider(); decision.Disabled { // 配置值**绝不写进日志**(只记键名与原因)。 log.Warn("web-search tool not registered", - "env", webSearchProviderEnv, "reason", config.WebSearchReasonOff, "source", source, + "selectorKey", webSearchProviderEnv, "reason", config.WebSearchReasonOff, "configSource", source, + "cause", cause, "effect", "the model will not see a web-search tool at all") return []iotools.Option{iotools.WithoutSearch()} } @@ -89,14 +140,17 @@ func (a *app) webSearchOptions() []iotools.Option { // 错误,因此不 fatal,只留一条带键名的 WARN。 if endpoint := ws.ResolveEndpoint(); !endpoint.Enabled && selectsSearXNG(ws) { log.Warn("web-search tool not registered", - "env", webSearchEndpointEnv, "reason", endpoint.Reason, "source", source, + "endpointKey", webSearchEndpointEnv, "reason", endpoint.Reason, "configSource", source, + "cause", cause, "effect", "the model will not see a web-search tool at all") return []iotools.Option{iotools.WithoutSearch()} } - log.Info("web-search providers configured", - "env", webSearchProviderEnv, "primary", strings.ToLower(strings.TrimSpace(ws.Provider)), - "env_fallback", webSearchFallbackEnv, "fallback", ws.Fallback, "source", source, + // primary 是**生效值**(可能来自面板文件、也可能来自 `.env` 播种)⇒ 它旁边的键名只是 + // 「这个键在哪份配置里」,而 source 才是「本次的值从哪来」。两者都打出来,不混为一谈。 + log.Info("web-search providers configured", "cause", cause, + "selectorKey", webSearchProviderEnv, "primary", strings.ToLower(strings.TrimSpace(ws.Provider)), + "fallbackKey", webSearchFallbackEnv, "fallback", ws.Fallback, "configSource", source, "available", availableProviderNames(specs)) opts := []iotools.Option{ @@ -111,7 +165,7 @@ func (a *app) webSearchOptions() []iotools.Option { opts = append(opts, iotools.WithSearchTimeout(ws.Timeout)) case ws.Timeout < 0: log.Warn("web-search timeout is negative, treated as unset", - "env", webSearchTimeoutEnv, "source", source, + "key", webSearchTimeoutEnv, "configSource", source, "cause", cause, "effect", "io package default timeout applies") } return opts diff --git a/cmd/cyrene/wire_plugin_actions.go b/cmd/cyrene/wire_plugin_actions.go index aea35c7..d8abc07 100644 --- a/cmd/cyrene/wire_plugin_actions.go +++ b/cmd/cyrene/wire_plugin_actions.go @@ -8,8 +8,10 @@ package main // // 三个文件的分工(都在本包): // - 本文件:登记入口、处理器环境、动作名映射、「声明了却没实现」的装配期核对; -// - `websearch_action_data.go`:五个动作的业务数据面(可变源的读写与翻译、载荷解析); -// - `websearch_probe.go`:探针的传输层绑定(HTTP 客户端 + 同主机守卫 + 状态映射)。 +// - `websearch_action_data.go`:动作本体的业务数据面(可变源的读写与翻译、载荷解析); +// - `websearch_probe.go`:探针的传输层绑定(HTTP 客户端 + 同主机守卫 + 状态映射); +// - `websearch_reload.go`:保存后的**热重载**事务(重建工具 + 换注册表条目 + 失败回滚); +// - `websearch_selection.go`:表单里两个文件级字段(主 provider / 降级链)的读写。 // // 三条边界: // @@ -20,8 +22,9 @@ package main // 3. **凭据不出后端**:读侧只回掩码(`MaskedWebSearchFile`),日志只记 provider 名与 // 结果码,绝不记 key / 地址。 // -// 本片不做的事(如实登记,见报告 §未做):不热替换**运行中**的 web-search 工具 —— -// 面板保存后工具仍用启动期解析出的 provider 接线,改动在下次重启生效。 +// 热重载(切片 W3;裁决 2026-10-06 #1):保存动作被 `webSearchActionHandlersWithHotReload` +// 包上事务 —— 落盘成功后重建 web-search 工具并替换注册表条目,**下一轮对话即生效** +// (design/09 §5.5 的端到端判据「面板加一家 ⇒ 不改 `.env`、不重启 ⇒ 下一次搜索真的走它」)。 import ( "log/slog" @@ -68,7 +71,7 @@ func wireBuiltinPluginActions(mgr *pluginmgr.Manager, hub *pluginmgr.Panels, a * "pluginId", search.PanelPluginID, "effect", "panel actions will answer 501") return } - handlers := webSearchActionHandlers(env) + handlers := webSearchActionHandlersWithHotReload(env, a, log) for optionsFrom, handler := range webSearchOptionsHandlers(env) { name := pluginmgr.OptionsActionName(optionsFrom) if name == "" { diff --git a/internal/capability/registry/registry.go b/internal/capability/registry/registry.go index da3c36d..1ab9cea 100644 --- a/internal/capability/registry/registry.go +++ b/internal/capability/registry/registry.go @@ -237,6 +237,44 @@ func (r *Registry) RegisterTool(t sdk.Tool) error { }) } +// ReplaceTool 用**同名**的 t 就地换掉已登记的工具条目(热替换,design/09 §5.5: +// 面板保存后下一轮即用新接线,不必重启)。 +// +// 与「Remove + Register」的两处实质差别,正是这个窄方法存在的理由: +// - **原子**:替换在一次写锁内完成,读者中间看不到「这个名字不存在」的空窗 +// (Remove+Register 之间若有 Invoke 落到这个名字上,会得到 ErrUnknownTool); +// - **保留 Policy**:分组/陌生人的可达标记是**运行期可改的配置**(§11 §3), +// 不属于工具实现。初始注册时带的标记没有进 persisted 快照,Remove+Register +// 会把它悄悄重置成零值(= 退回 owner-only),用户会看到「面板保存后某个群 +// 忽然不能用了」。这里原样沿用内存里的既有标记。 +// +// 没有同名条目时返回 ErrNotFound(**不**顺手新增:热替换的语义是「换掉现在这把」, +// 名字打错时静默插入会掩盖调用方的错);既有条目不是 KindTool 时返回 ErrInvalidArgument。 +func (r *Registry) ReplaceTool(t sdk.Tool) error { + if t == nil || t.Name() == "" { + return fmt.Errorf("registry: replace tool: %w", ErrInvalidArgument) + } + name := t.Name() + r.mu.Lock() + defer r.mu.Unlock() + cur, ok := r.caps[name] + if !ok { + return fmt.Errorf("registry: replace %q: %w", name, ErrNotFound) + } + if cur.Kind != KindTool { + return fmt.Errorf("registry: replace %q: 既有能力类型是 %q,不是工具: %w", name, cur.Kind, ErrInvalidArgument) + } + r.caps[name] = Capability{ + Name: name, + Kind: KindTool, + Description: t.Description(), + // Policy 沿用旧条目的:它是可编辑的运行期配置,不属于工具的构造产出。 + Policy: cur.Policy, + Value: t, + } + return nil +} + // RegisterSkill registers s as a KindSkill capability. func (r *Registry) RegisterSkill(s sdk.Skill) error { if s == nil { diff --git a/internal/capability/registry/replacetool_test.go b/internal/capability/registry/replacetool_test.go new file mode 100644 index 0000000..ed72c6d --- /dev/null +++ b/internal/capability/registry/replacetool_test.go @@ -0,0 +1,101 @@ +package registry + +import ( + "context" + "errors" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件钉住切片 W3 起的**窄方法** `ReplaceTool`(热替换,design/09 §5.5): +// 面板保存后重建出来的同名工具要能就地换掉旧条目,且换完不丢「用户改过的策略」。 +// +// 单独成文件的理由:registry_test.go 在 HEAD 上就没过 gofmt(存量), +// 本片不动它(不顺手格式化会产生与主题无关的 diff)。 + +// stubTool 是一个名字可指定的最小工具(只用于注册/替换的判定)。 +type stubTool struct { + name string + desc string +} + +func (s stubTool) Name() string { return s.name } +func (s stubTool) Description() string { return s.desc } +func (s *stubTool) Parameters() (map[string]any, error) { + return map[string]any{"type": "object"}, nil +} +func (s *stubTool) Execute(_ context.Context, _ any) (any, error) { return nil, nil } + +var _ sdk.Tool = (*stubTool)(nil) + +// TestReplaceToolSwapsValueAndKeepsPolicy 是热替换的主判据: +// - Value 换成新工具(调用方随后 Invoke 拿到的就是新的那一把); +// - Description 跟着新工具走(它是工具实现的事实); +// - **Policy 原样保留** —— 分组/陌生人可达标记是运行期配置,不属于工具实现; +// 丢掉它 = 一次面板保存让某个群忽然不能用了。 +func TestReplaceToolSwapsValueAndKeepsPolicy(t *testing.T) { + r := New() + if err := r.RegisterTool(&stubTool{name: "web-search", desc: "old"}); err != nil { + t.Fatalf("register: %v", err) + } + if err := r.SetAllowInGroup("web-search", true); err != nil { + t.Fatalf("SetAllowInGroup: %v", err) + } + + next := &stubTool{name: "web-search", desc: "new"} + if err := r.ReplaceTool(next); err != nil { + t.Fatalf("ReplaceTool: %v", err) + } + cap, err := r.Get("web-search") + if err != nil { + t.Fatalf("Get: %v", err) + } + if cap.Value != sdk.Tool(next) { + t.Fatalf("替换后 Value = %#v, want 新的那一把", cap.Value) + } + if cap.Description != "new" { + t.Fatalf("替换后 Description = %q, want %q(描述属于工具实现,跟着换)", cap.Description, "new") + } + if !cap.Policy.AllowInGroup { + t.Fatal("替换丢掉了 AllowInGroup:运行期策略不属于工具构造产出,必须沿用") + } + if cap.Kind != KindTool { + t.Fatalf("替换后 Kind = %q, want %q", cap.Kind, KindTool) + } +} + +// TestReplaceToolReportsMissingAndInvalid:两种**不该被静默吞掉**的调用错误。 +func TestReplaceToolReportsMissingAndInvalid(t *testing.T) { + r := New() + err := r.ReplaceTool(&stubTool{name: "web-search"}) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("替换不存在的名字 err = %v, want ErrNotFound(不顺手新增:名字打错时静默插入会掩盖调用方的错)", err) + } + if err := r.RegisterTool(&stubTool{name: "nope"}); err != nil { + t.Fatalf("register: %v", err) + } + if err := r.ReplaceTool(nil); !errors.Is(err, ErrInvalidArgument) { + t.Fatalf("替换成 nil err = %v, want ErrInvalidArgument", err) + } +} + +// TestReplaceToolRefusesNonToolEntry:同名条目不是工具时拒绝,绝不悄悄改掉它的 Kind +// (把一条渠道/MCP 能力换成工具会让「这条能力是什么」变成一件靠时间猜的事)。 +func TestReplaceToolRefusesNonToolEntry(t *testing.T) { + r := New() + if err := r.Register(Capability{Name: "web-search", Kind: KindMCP, Value: struct{}{}}); err != nil { + t.Fatalf("register: %v", err) + } + err := r.ReplaceTool(&stubTool{name: "web-search"}) + if !errors.Is(err, ErrInvalidArgument) { + t.Fatalf("err = %v, want ErrInvalidArgument", err) + } + cap, gerr := r.Get("web-search") + if gerr != nil { + t.Fatalf("Get: %v", gerr) + } + if cap.Kind != KindMCP { + t.Fatalf("被拒的替换却改了 Kind:%q", cap.Kind) + } +} diff --git a/plugins/tools/io/search/panel.go b/plugins/tools/io/search/panel.go index 8a1e777..8452a57 100644 --- a/plugins/tools/io/search/panel.go +++ b/plugins/tools/io/search/panel.go @@ -48,6 +48,19 @@ func ActionNames() []string { return []string{ActionListProviders, ActionSaveProvider, ActionTestProvider, ActionReauthProvider} } +// 表单里两个**文件级**字段的 key(切片 W3;裁决 2026-10-06 #2)。 +// +// 与其余字段的区别:`provider` / `apiKey` / `baseUrl` / `enabled` 描述**一条 provider 条目**, +// 而这两个描述**整份配置的选择** —— design/27 §3 的「主后端 + 降级链」(可变源里的文件级 +// `provider` / `fallback`)。它们必须与组合根动作面读的 key 逐字一致(那里引用这两个常量, +// 不另写字面量):声明里写一个 key、处理器读另一个,表现就是「我选了主提供商但它没变」。 +const ( + // FieldPrimary 是主 provider 的字段 key。 + FieldPrimary = "primary" + // FieldFallback 是降级链的字段 key(多选,按顺序依次尝试)。 + FieldFallback = "fallback" +) + // AdminPanels 返回本插件声明的管理页面(design/09 §5.2 示例的 providers 页)。 // // 两处相对示例的**明确增补**(示例是形状示范,不是字段清单的穷举;两处都被本片的任务书 @@ -55,12 +68,20 @@ func ActionNames() []string { // - 表单多一个 `enabled` 开关:任务书要求每家 provider **独立启用态**; // - 表单的 `baseUrl` 用示例的 `endpoint` 语义(searxng 的端点 = 那家的基地址), // 名字跟可变源的字段名走,免得面板字段名与文件字段名对不上。 +// +// 第三处增补(切片 W3;裁决 2026-10-06 #2):表单末尾两个**文件级**字段 +// `primary` / `fallback`(主 provider 与降级链)。它们跟同一条 form 走 submit 而不是另开 +// 一个 form 区块,是因为前端的「带回表单」(reauth)只在面板**恰好一个** form 时转交: +// 加第二个 form 会让既有的「重配凭据」整条回填失灵(那是客户端行为,本片不改前端)。 +// 两个字段的写侧语义见 `cmd/cyrene/websearch_selection.go`。 func AdminPanels() []sdk.Panel { return []sdk.Panel{{ ID: PanelIDProviders, Title: "搜索提供商", Description: "为 web-search 能力配置多个第三方搜索后端:每条独立凭据与基地址," + - "可单独启停并测试连通。改动落 $DATA_DIR/config/plugins/web-search.json,重启后仍然生效。", + "可单独启停并测试连通,并指定先用哪家、失败后按什么顺序降级。" + + "改动落 $DATA_DIR/config/plugins/web-search.json,**保存后立即生效**" + + "(下一轮对话即用新配置,无需重启)。", Group: sdk.GroupPlugins, Order: 20, Icon: "search", @@ -83,6 +104,15 @@ func AdminPanels() []sdk.Panel { {Key: "baseUrl", Label: "接口地址", Type: sdk.FieldText, Placeholder: "https://..."}, {Key: "enabled", Label: "启用", Type: sdk.FieldSwitch}, + // 下面两项是**文件级**的(不是上面这条 provider 的字段):谁先用、 + // 失败后按什么顺序降级。留空 = 不改动;primary 写成 off / none = 关掉 + // 整把 web-search(模型看不见它)。 + {Key: FieldPrimary, Label: "主提供商", Type: sdk.FieldSelect, + OptionsFrom: "plugin:providers", + Placeholder: "留空 = 不改动;写 off / none = 关掉 web-search"}, + {Key: FieldFallback, Label: "降级链", Type: sdk.FieldMultiSelect, + OptionsFrom: "plugin:providers", + Placeholder: "按顺序依次尝试(逗号分隔);留空 = 不改动"}, }, Submit: &sdk.Submit{Action: ActionSaveProvider}, }, diff --git a/plugins/tools/io/search/panel_test.go b/plugins/tools/io/search/panel_test.go index 8a767b8..b3f8e05 100644 --- a/plugins/tools/io/search/panel_test.go +++ b/plugins/tools/io/search/panel_test.go @@ -207,6 +207,83 @@ func TestDeclaredActionNamesMatchDesignExample(t *testing.T) { } } +// 判据 ⑨(切片 W3;裁决 2026-10-06 #2):表单必须能配「谁先用」——`primary` 与 `fallback` +// 两个**文件级**字段就位,且都从 `plugin:providers` 拉候选(否则控件降级成文本框, +// 用户得自己记住 provider 名)。 +func TestAdminPanelsDeclareSelectionFields(t *testing.T) { + t.Parallel() + form := adminPanelsFormSection(t) + byKey := make(map[string]sdk.Field, len(form.Fields)) + for _, f := range form.Fields { + byKey[f.Key] = f + } + cases := []struct { + key string + wantType sdk.FieldType + wantOptions string + }{ + {FieldPrimary, sdk.FieldSelect, "plugin:providers"}, + {FieldFallback, sdk.FieldMultiSelect, "plugin:providers"}, + } + for _, tc := range cases { + f, ok := byKey[tc.key] + if !ok { + t.Errorf("表单里缺 %q 字段(主 provider / 降级链就没法配了): %+v", tc.key, byKey) + continue + } + if f.Type != tc.wantType { + t.Errorf("字段 %q 类型 = %q, want %q", tc.key, f.Type, tc.wantType) + } + if f.OptionsFrom != tc.wantOptions { + t.Errorf("字段 %q 的 optionsFrom = %q, want %q(下拉必须拉得到候选)", tc.key, f.OptionsFrom, tc.wantOptions) + } + } + // 两个 key 不能互相撞,也不能撞上条目级字段:撞了就是「我在改谁」要猜。 + if FieldPrimary == FieldFallback { + t.Fatalf("两个文件级字段的 key 相同(%q)", FieldPrimary) + } + for _, taken := range []string{"provider", "apiKey", "baseUrl", "enabled"} { + if FieldPrimary == taken || FieldFallback == taken { + t.Fatalf("文件级字段的 key 撞上了条目级字段 %q", taken) + } + } +} + +// 判据 ⑩(切片 W3):面板**恰好一个 form 区块**这条约束是有原因的,钉在这里免得后人 +// 顺手拆成两个表单: +// +// 前端的「带回表单」(reauth)只在恰好一个 form 时转交(`PluginPanel.tsx` 的 singleForm: +// 两个表单没有归属,宁可不填也不猜)。第二处增补因此必须挂在既有表单上 —— 拆成两个 form +// 会让「重配凭据」的回填整条失灵(那是客户端行为,改它不在本片文件面内)。 +func TestAdminPanelsKeepASingleFormSection(t *testing.T) { + t.Parallel() + forms := 0 + for _, p := range AdminPanels() { + for _, s := range p.Sections { + if s.Type == sdk.SectionForm { + forms++ + } + } + } + if forms != 1 { + t.Fatalf("面板里有 %d 个 form 区块,want 1(多一个会让前端的 reauth 回填失效)", forms) + } +} + +// adminPanelsFormSection 取唯一那个 form 区块(没有就失败)。 +func adminPanelsFormSection(t *testing.T) sdk.Section { + t.Helper() + for _, p := range AdminPanels() { + for _, s := range p.Sections { + if s.Type == sdk.SectionForm { + return s + } + } + } + t.Fatal("面板里没有 form 区块") + return sdk.Section{} +} + // contains 是字符串切片成员判定(测试里只此一处,不值得抽包)。 func contains(list []string, want string) bool { for _, s := range list {