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 }