package search // 本文件是 web-search 插件向**面板**声明的管理页面:design/09 §5.2 的 `adminPanels`, // 形状即 `pkg/plugins/sdk` 的 `Panel`。 // // 声明只说三件事:**要什么字段、怎么渲染、动作叫什么**。它 // // - 不注入任何 JS/CSS/HTML、 // - 不给 URL(只有动作名,走宿主的通用动作端点分发)、 // - 不开任何路由。 // // 这三条是 design/09 §5.2.1 的红线:声明而非编码、声明里塞不进请求面(防 SSRF)、 // 面板数据一律经插件管理器的端点。渲染与流程由宿主负责,本文件只负责「说得清楚」。 import "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" // PanelPluginID 是本插件在面板体系里的 id。它与可变源的文件名**同源**: // `$DATA_DIR/config/plugins/web-search.json`(design/13 §2.1:文件名 = 插件 id), // 两边由 panel_test.go 钉住。 const PanelPluginID = "web-search" // PanelIDProviders 是「搜索提供商」页的面板 id。 // 前端路由为 `/admin/plugin/web-search/providers`(design/09 §5.7 C)。 const PanelIDProviders = "providers" // 本插件声明的**全部**动作名(design/09 §5.2 示例给出的那四个)。 // // 表里的动作名一律取自这里,通用动作端点也只接受这几个名字(未声明 ⇒ 404/403, // design/09 §5.2.1 B)。把「声明」与「动作名」放同一个文件,是为了让「表里写了一个 // 没实现的动作」这种半渲染在 review 时一眼可见 —— 它们就挨着。 const ( // ActionListProviders 列出全部 provider 配置(表格区块的数据源)。 ActionListProviders = "plugin.listProviders" // ActionSaveProvider 新增 / 修改一条 provider 配置(表单提交)。 // 语义是 upsert:按 `provider` 名就位替换或追加。 ActionSaveProvider = "plugin.saveProvider" // ActionTestProvider 测试连通:对一条 provider 配置真发一次最小检索(见 probe.go)。 ActionTestProvider = "plugin.testProvider" // ActionReauthProvider 重配凭据:把该条带回表单。写侧的「未改动」必须用显式哨兵表达, // **不能**靠空字符串(那会静默清空凭据,design/09 §5.4.2)。 ActionReauthProvider = "plugin.reauthProvider" ) // ActionNames 返回本插件声明的动作名,顺序与 AdminPanels 里出现的顺序一致 // (表格数据源 → 表单提交 → 行内动作)。调用方(插件管理器的动作端点)据此建白名单: // 白名单外的动作名一律拒,绝不「先执行再看结果」。 func ActionNames() []string { return []string{ActionListProviders, ActionSaveProvider, ActionTestProvider, ActionReauthProvider} } // 表单里三个**文件级**字段的 key(切片 W3;裁决 2026-10-06 #2;`maxResults` 为后续切片增补)。 // // 与其余字段的区别:`provider` / `apiKey` / `baseUrl` / `enabled` 描述**一条 provider 条目**, // 而这三个描述**整份配置** —— design/27 §3 的「主后端 + 降级链」(可变源里的文件级 // `provider` / `fallback`)与单次检索的条数上限。它们必须与组合根动作面读的 key 逐字一致 // (那里引用这些常量,不另写字面量):声明里写一个 key、处理器读另一个,表现就是 // 「我选了主提供商但它没变」/「我改了上限但它没变」。 const ( // FieldPrimary 是主 provider 的字段 key。 FieldPrimary = "primary" // FieldFallback 是降级链的字段 key(多选,按顺序依次尝试)。 FieldFallback = "fallback" // FieldMaxResults 是单次检索条数上限的字段 key,**逐字等于**可变源 `web-search.json` 的 // 顶层键名(panel_test.go 用一次 marshal 钉住这层同源)。 // // 运行期语义(io 包的 resultLimit):它是**真上限** —— 模型给的 `count` 只能要得**更少**; // 面板里存了它,就压过 `.env` 的 `WEB_SEARCH_MAX_RESULTS`(那是**播种层**的键, // 见 design/13 §2.1 与 cmd/cyrene/wire_io_tools.go 的 effectiveSearchConfig)。 FieldMaxResults = "maxResults" ) // AdminPanels 返回本插件声明的管理页面(design/09 §5.2 示例的 providers 页)。 // // 两处相对示例的**明确增补**(示例是形状示范,不是字段清单的穷举;两处都被本片的任务书 // 要求): // - 表单多一个 `enabled` 开关:任务书要求每家 provider **独立启用态**; // - 表单的 `baseUrl` 用示例的 `endpoint` 语义(searxng 的端点 = 那家的基地址), // 名字跟可变源的字段名走,免得面板字段名与文件字段名对不上。 // // 第三处增补(切片 W3;裁决 2026-10-06 #2):表单末尾两个**文件级**字段 // `primary` / `fallback`(主 provider 与降级链)。它们跟同一条 form 走 submit 而不是另开 // 一个 form 区块,是因为前端的「带回表单」(reauth)只在面板**恰好一个** form 时转交: // 加第二个 form 会让既有的「重配凭据」整条回填失灵(那是客户端行为,本片不改前端)。 // // 第四处增补(切片:结果条数上限真能改):表单末尾再加一个**文件级**数字字段 // `maxResults`(结果条数上限),理由与第三处逐条相同(同一张 form、同一套三态写回)。 // 它此前只活在可变源与 `.env` 里 —— 面板上没有输入框(改不了),手工塞进提交体又会被写侧 // 静默丢弃,于是「上限到底是 10 还是 20」只能靠手改文件。三个字段的写侧语义见 // `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,**保存后立即生效**" + "(下一轮对话即用新配置,无需重启)。", Group: sdk.GroupPlugins, Order: 20, Icon: "search", // 未启用的插件 ⇒ 入口不出现(design/09 §5.2.1 nav.item)。web-search 是编进二进制的 // 内置能力,没有卸载路径 ⇒ 恒为启用;这里写条件是为了让「启用事实」由插件管理器 // 给出(单一事实来源),而不是由面板猜。 When: &sdk.When{Enabled: boolFlag(true)}, Sections: []sdk.Section{ { Type: sdk.SectionForm, ID: "add", Title: "添加 / 修改提供商", Fields: []sdk.Field{ {Key: "provider", Label: "提供商", Type: sdk.FieldSelect, OptionsFrom: "plugin:providers", Required: true}, // secret 字段刻意不标 required:它同时承担「编辑既有条目」的输入, // 而编辑时不重填凭据是常态(写侧哨兵语义,见 ActionReauthProvider)。 {Key: "apiKey", Label: "API Key", Type: sdk.FieldSecret, Placeholder: "留空 = 保持原值"}, {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: "按顺序依次尝试(逗号分隔);留空 = 不改动"}, // 结果条数上限:**运行期真上限**,填了就压过 `.env` 的 WEB_SEARCH_MAX_RESULTS // (那一项只是播种层)。类型是 number(闭集里只有它接受 min/max),min=1 // 让前端的数字控件先拦住 0 / 负数;写侧对 ≤0 与非整数一律 400,不静默忽略。 {Key: FieldMaxResults, Label: "结果条数上限(默认 10)", Type: sdk.FieldNumber, Min: floatFlag(1), Placeholder: "填了它压过 .env;模型要更多也只给这么多"}, }, Submit: &sdk.Submit{Action: ActionSaveProvider}, }, { Type: sdk.SectionTable, ID: "list", Title: "已配置的提供商", Source: &sdk.Source{Action: ActionListProviders}, // 列 key 与 panelrow.go 的行字段同源;凭据不进任何列(读侧无明文)。 Columns: []sdk.Column{ {Key: "provider", Label: "提供商"}, {Key: "state", Label: "状态"}, }, RowActions: []sdk.RowAction{ {ID: "test", Label: "测试连通", Action: ActionTestProvider}, {ID: "reauth", Label: "重配凭据", Action: ActionReauthProvider}, }, }, }, }} } // boolFlag 是 `&true` / `&false` 的简写(sdk.When.Enabled 是三态指针)。 func boolFlag(v bool) *bool { return &v } // floatFlag 是 `&1.0` 的简写(sdk.Field.Min / Max 是 `*float64`:缺省表示没声明边界)。 func floatFlag(v float64) *float64 { return &v }