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

+31 -1
View File
@@ -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},
},
+77
View File
@@ -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 {