feat(search): provider 热重载 + primary/fallback(W3)

- 保存被包成事务:快照 → 落盘 → 按新配置重建工具 → 原子替换注册表条目(registry.ReplaceTool
  窄方法,保留运行期 Policy);下一轮即生效,不要求重启
- 若新配置会让后端下次起不来(主家未配齐 fail-fast)⇒ 文件按字节回滚 + 409 config_unstartable
- 声明面补 primary + fallback[](可配可生效);否决「另开 form」方案(会破坏既有 reauth 回填)
- 主 provider 解析口径核查结论:没被 PLG-4 改坏(顺手修了一处日志缺陷)
- 4 个变异自检(丢 Policy/不换条目/回滚 no-op/忽略 fallback)
This commit is contained in:
AskaEth committed 2026-10-06 15:59:12 +08:00
1 parent 5ee5f9126f
commit a30f4a49ff
12 files changed
+1696 -16

No files matched your search

+19 -1
View File
@@ -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
}