Files
Cyrene_v2/plugins/tools/io/search/panel.go
T
AskaEth c68387a69a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(search): 面板上终于能改「结果条数上限」(原来改了会被静默丢弃)
真缺陷(上一片登记):`maxResults` 在面板上**根本改不了** —— ① 字段表里没有这个
字段(UI 不显示输入框);② `saveProvider` 只并 provider/apiKey/baseUrl/enabled;
③ `mergeSelectionPatch` 只处理 primary/fallback ⇒ **手工塞进 values 也会被静默丢弃**
(文件不变、也不报错)。「我改了它却没变」正是要消灭的那种假话。

- `panel.go`:表单加 `{Key: FieldMaxResults, Type: sdk.FieldNumber, Min: 1}`(**SDK 本就有
  数字类型**,未自造;前端本就渲染 number ⇒ **不需要改前端**)。刻意不设 `Default`
  (设了会让一次「只改 key」的保存把默认值固化进文件、还可能盖掉 `.env`)。
- `websearch_selection.go`:`mergeSelectionPatch` 里按**三态**落 `maxResults` ——
  缺位/null ⇒ **不改动**;正整数(含整数字符串)⇒ 采用;0/负数/小数/空串/非数/
  布尔/数组 ⇒ **400 `invalid_payload`** 且**盘上一字节不动**(绝不静默忽略)。
- `reauth` 回填该值(否则一保存就把上限清掉)。
- `.env.example`:该键的示例值与「默认 5」注释同步为 **10**(代码默认已改;⚠️ `.env` 未动)。

验收(主会话复跑 + 亲自变异):**整树 `go build ./...` 通过**;三包 vet/测试绿;
端到端「面板存 20 ⇒ 模型要 50 也只给 20、要 3 给 3」(走真 action hub → 落盘 →
热重载 → WithSearchMaxResults → 真 dispatcher,未重启后端);
我用 -overlay 把 `f.MaxResults = maxResults` 摘掉 ⇒ 两条用例变红
(「面板给的值必须真的落盘」/「回填的 maxResults = 5, want 20」)。

⚠️ 已知取舍(如实登记):**清空输入框 = 400**(后端按「非数 ⇒ 报错」拒),因此面板上
没有「去掉上限」的手势 —— 要回到 `.env` 兜底得改文件;要支持「清空 = 去掉覆盖」
需另定哨兵语义,未自行发挥。
⚠️ 契约面已变更(面板声明多一个 number 字段 + saveProvider 的 values 多一个可写键 +
响应多一个回显键)⇒ 主会话登记。
2026-10-07 11:10:56 +08:00

162 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }