diff --git a/cmd/cyrene/websearch_action_data.go b/cmd/cyrene/websearch_action_data.go index c5ea889..069c1c7 100644 --- a/cmd/cyrene/websearch_action_data.go +++ b/cmd/cyrene/websearch_action_data.go @@ -33,7 +33,11 @@ func (e webSearchActionEnv) listProviders(_ context.Context, _ pluginmgr.ActionR return map[string]any{"rows": providerRows(f)}, nil } -// listProviderOptions 给出下拉候选:本期全部已登记的 provider 名(含尚未配置的家)。 +// listProviderOptions 给出下拉候选:本期全部已登记的 provider(含尚未配置的家)。 +// +// 每项是 `{value,label}`(search.ProviderOption):**`value` 才是落盘的值** —— 它逐字等于 +// search.KnownNames(),会被写进 web-search.json 并被注册表按名字取用;`label` 只是显示名 +// (中文 + 括注 id),前端拿它渲染下拉与表格,任何写盘路径都不该碰它。 // // 给全部而不是「已配置的家」的理由见 search.ProviderOptions:表单的用途就是**新增** // 一家,只列已配置的会让第一次配置无从选起。 diff --git a/cmd/cyrene/wire_plugin_actions_test.go b/cmd/cyrene/wire_plugin_actions_test.go index d0b8f50..a943b55 100644 --- a/cmd/cyrene/wire_plugin_actions_test.go +++ b/cmd/cyrene/wire_plugin_actions_test.go @@ -393,6 +393,9 @@ func TestWebSearchActionsRejectUnknownProviderAndBadPayload(t *testing.T) { // TestWebSearchOptionsActionFeedsDeclaredOptionsFrom 锁死「声明的下拉一定拉得到候选」: // 声明里每个 optionsFrom 映射出的动作名都**已登记且已声明**(否则前端只能降级成文本框)。 +// +// 候选的形状是 `{value,label}`(切片 LBL-1):这里连**显示名非空**一起钉住 —— 只有值的 +// 候选会让面板下拉与表格里全是英文 id(主人 2026-10-06 报障的那个形状)。 func TestWebSearchOptionsActionFeedsDeclaredOptionsFrom(t *testing.T) { env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) @@ -413,16 +416,31 @@ func TestWebSearchOptionsActionFeedsDeclaredOptionsFrom(t *testing.T) { t.Errorf("候选来源 %q 调不动: %v", src, err) continue } - options, _ := out.(map[string]any)["options"].([]string) + options, _ := out.(map[string]any)["options"].([]search.ProviderOption) if len(options) == 0 { t.Errorf("候选来源 %q 回了空候选(表单下拉会空着)", src) } - if strings.Join(options, ",") != strings.Join(search.ProviderOptions(), ",") { - t.Errorf("候选 = %v, want %v", options, search.ProviderOptions()) + got, want := optionValues(options), optionValues(search.ProviderOptions()) + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("候选值 = %v, want %v(value 逐字等于 KnownNames())", got, want) + } + for _, o := range options { + if strings.TrimSpace(o.Label) == "" { + t.Errorf("候选来源 %q 的 %q 没有显示名(下拉里会是空白项)", src, o.Value) + } } } } +// optionValues 取出结构化候选的**值**(落盘真源),丢掉显示名。 +func optionValues(opts []search.ProviderOption) []string { + out := make([]string, 0, len(opts)) + for _, o := range opts { + out = append(out, o.Value) + } + return out +} + // TestWebSearchActionsUnwiredAnswers501 锁定降级口径:**没有配置**(测试夹具/装配未完成) // 时不登记任何处理器 ⇒ 已声明的动作诚实回 501(ErrActionUnavailable), // 绝不假装成功地回一份空列表。 diff --git a/frontend/web/src/admin/plugin/FormSection.tsx b/frontend/web/src/admin/plugin/FormSection.tsx index 2a2b8c6..8ef5595 100644 --- a/frontend/web/src/admin/plugin/FormSection.tsx +++ b/frontend/web/src/admin/plugin/FormSection.tsx @@ -6,8 +6,9 @@ * ② 提交前先在本地跑必填校验(`schema/values.planSubmit`),拦下「填了却起不来」; * ③ viewer 一律看不到表单(后端已在快照里裁掉,这里是第二道闸)。 * - * 状态与规则在 `formState.ts`(三个 hook),字段呈现与说明文案在 `FormFields.tsx`; - * 本文件只剩「拼装 + 提交按钮 + 反馈行」,因此每个函数都在 80 行以内。 + * 状态与规则在 `formState.ts`(值状态 + 提交两个 hook;候选由面板级拉好传进来), + * 字段呈现与说明文案在 `FormFields.tsx`;本文件只剩「拼装 + 提交按钮 + 反馈行」, + * 因此每个函数都在 80 行以内。 */ import { useMemo, type ReactNode } from "react"; @@ -17,10 +18,10 @@ import { useAuditStore } from "../audit"; import { Notice } from "../components"; import type { SchemaField } from "../schema/fieldTypes"; import { FormFieldsGrid } from "./FormFields"; -import { useFieldOptions, useFormSubmit, useFormValues, type FormFlash } from "./formState"; +import { useFormSubmit, useFormValues, type FormFlash } from "./formState"; import { canWrite } from "./nav"; import { SectionHeader } from "./SectionHeader"; -import type { PanelFieldDto, PanelSectionDto } from "./types"; +import type { PanelFieldDto, PanelOptionDto, PanelSectionDto } from "./types"; /** 声明字段 → 通用 schema(`options` 由 `optionsFrom` 拉到后由栅格填进来)。 */ export function panelFieldToSchema(f: PanelFieldDto): SchemaField { @@ -58,9 +59,22 @@ export interface PanelFormSectionProps { /** 行内动作「带回表单」的值(design/09 §5.2.1 示例的 reauth);`null` = 没有。 * 只认显式 `values` / `formValues`(见 `types.formValuesOfResult`),不做形状猜测。 */ prefill?: Record | null; + /** 下拉候选(面板级只拉一次,见 `formState.useFieldOptions`):`optionsFrom` → 候选项。 + * 本组件**不再自己拉** —— 同一份候选两处各拉一次会重复请求,且表格那份拿它做显示名。 */ + options: Record; + /** `optionsFrom` → 失败原因(取不到候选不是致命错误,但要如实说出来)。 */ + optionErrors: Record; } -export function PanelFormSection({ pluginId, section, role, onReload, prefill = null }: PanelFormSectionProps): ReactNode { +export function PanelFormSection({ + pluginId, + section, + role, + onReload, + prefill = null, + options, + optionErrors, +}: PanelFormSectionProps): ReactNode { const log = useAuditStore((s) => s.log); // 依赖键用 JSON 串:快照每 30s 重取一次,对象身份每次都变,直接依赖对象会把用户正在 // 输入的内容重置掉(「打着字被清空」是比不刷新更糟的体验)。 @@ -72,7 +86,6 @@ export function PanelFormSection({ pluginId, section, role, onReload, prefill = ); const fields = useMemo(() => (section.fields ?? []).map((f) => panelFieldToSchema(f)), [fieldsKey]); const state = useFormValues(fields, fieldsKey, external); - const { options, optionErrors } = useFieldOptions(pluginId, section.fields ?? []); const action = section.submit?.action ?? ""; const { submit, busy, flash } = useFormSubmit({ pluginId, action, fields, state, onReload, log }); diff --git a/frontend/web/src/admin/plugin/PluginPanel.tsx b/frontend/web/src/admin/plugin/PluginPanel.tsx index 71db7d1..7c488c9 100644 --- a/frontend/web/src/admin/plugin/PluginPanel.tsx +++ b/frontend/web/src/admin/plugin/PluginPanel.tsx @@ -4,20 +4,24 @@ * - `PluginPanelHost`(容器):从快照里找当前 URL 指向的面板,负责「加载中 / 取数失败 / * 没这个面板」三种状态。找不到就是找不到 —— 未启用、`when` 不满足、或该插件根本没 * 声明这个页面,直接输 URL 也进不来(快照是按角色与 `when` 过滤后的结果); - * - `PluginPanelView`(纯呈现):只吃 `panel` + `role`,因此可以在单测里直接渲染, - * 不依赖路由、快照与浏览器环境(`--format=cjs` 的渲染测试就是打它)。 + * - `PluginPanelView`(呈现 + 取候选):只吃 `panel` + `role`,因此可以在单测里直接渲染, + * 不依赖路由、快照与浏览器环境(`--format=cjs` 的渲染测试就是打它)。它另持一份 + * **下拉候选**状态(面板级只拉一次);服务端渲染不跑 effect ⇒ 测试里候选是空的, + * 标签相关的断言请直接打 `PanelFormSection`(可显式传 `options`)或纯函数。 */ import type { ReactNode } from "react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Button, EmptyState, Spinner, Tag } from "../../components/primitives"; import type { Role } from "../../lib/types"; import { Notice, PageHeader } from "../components"; import { PanelFormSection } from "./FormSection"; import { PanelLogSection, PanelStatusSection } from "./ReadOnlySections"; import { PanelTableSection } from "./TableSection"; +import { useFieldOptions } from "./formState"; import { findPanelEntry, viewerVisibleSections } from "./nav"; -import type { PanelEntryDto, PanelSectionDto } from "./types"; +import { optionLabelIndex } from "./tableState"; +import type { PanelEntryDto, PanelFieldDto, PanelOptionDto, PanelSectionDto } from "./types"; import type { PluginTarget } from "../router"; /** 区块分发(闭集):form / table / status / log 四种,未知类型如实说明而不是半渲染。 */ @@ -28,6 +32,9 @@ function SectionBlock({ onReload, formPrefill, onRowValues, + options, + optionErrors, + optionLabels, }: { pluginId: string; section: PanelSectionDto; @@ -35,6 +42,9 @@ function SectionBlock({ onReload: () => void; formPrefill: Record | null; onRowValues?: (values: Record) => void; + options: Record; + optionErrors: Record; + optionLabels: ReadonlyMap; }): ReactNode { switch (section.type) { case "form": @@ -45,6 +55,8 @@ function SectionBlock({ role={role} onReload={onReload} prefill={formPrefill} + options={options} + optionErrors={optionErrors} /> ); case "table": @@ -55,6 +67,7 @@ function SectionBlock({ role={role} onReload={onReload} onValues={onRowValues} + optionLabels={optionLabels} /> ); case "status": @@ -75,16 +88,31 @@ export interface PluginPanelViewProps { onReload: () => void; } +/** 面板里声明过的**全部**字段(不只是当前角色可见的区块)。 + * + * 候选来源(`optionsFrom`)是**声明事实**:用整个面板的字段去拉,能拉到的候选最多。 + * **已知边界**:viewer 的快照在服务端就已把 form 区块整体摘掉(`paneldecl.filterPanel`), + * 因此 viewer 拿不到候选 ⇒ 表格照样显示裸 id(`cellDisplayText` 的回落),不空白、也不猜。 */ +function declaredFieldsOf(panel: PanelEntryDto): PanelFieldDto[] { + return (panel.sections ?? []).flatMap((s) => s.fields ?? []); +} + /** 插件页的纯呈现:标题 + 描述 + 按角色复检后的区块列表。 * * 行内动作「带回表单」(design/09 §5.2.1 示例的 reauth)在**这里**接线:表格返回一份 * 显式值对象时把它交给表单。只在「恰好一个 form 区块」时转交 —— 两个表单就没有归属, - * 宁可不填也不猜(猜错就是把 A 条的数据填进 B 条的表单)。 */ + * 宁可不填也不猜(猜错就是把 A 条的数据填进 B 条的表单)。 + * + * 下拉候选也在**这里**拉一次(`useFieldOptions`)并分发给 form 与 table:表单要它渲染 + * 下拉,表格要它把行里的裸值翻成显示名。候选是 `{value,label}`,显示用 label、写回只用 + * value(见 `controls.tsx` 的 SelectControl / MultiSelectControl)。 */ export function PluginPanelView({ panel, role, onReload }: PluginPanelViewProps): ReactNode { const [prefill, setPrefill] = useState | null>(null); const sections = viewerVisibleSections(panel, role); const formSections = sections.filter((s) => s.type === "form"); const singleForm = formSections.length === 1 ? formSections[0].id : null; + const { options, optionErrors } = useFieldOptions(panel.pluginId, declaredFieldsOf(panel)); + const optionLabels = useMemo(() => optionLabelIndex(options), [options]); return (
setPrefill(values)} + options={options} + optionErrors={optionErrors} + optionLabels={optionLabels} /> ))} {sections.length === 0 ? ( diff --git a/frontend/web/src/admin/plugin/ProviderOptionLabels.render.test.ts b/frontend/web/src/admin/plugin/ProviderOptionLabels.render.test.ts new file mode 100644 index 0000000..ab5b608 --- /dev/null +++ b/frontend/web/src/admin/plugin/ProviderOptionLabels.render.test.ts @@ -0,0 +1,169 @@ +// 搜索提供商「值 / 显示名分离」的真实渲染测试(切片 LBL-1)。 +// +// 为什么需要它:主人报障「中文搜索接口在管理后台下拉菜单里全给的英文名」——修好之后最容易 +// 走样的两条恰恰是**呈现**与**写回**的分界,而这两条都是「一改就写坏配置文件」的形状: +// ① 候选 `{"value":"zhipu","label":"智谱 BigModel(zhipu)"}` ⇒ 下拉/多选里显示中文, +// 而 **option 的 value 必须还是英文 id**(value 会被写进 web-search.json,注册表按 +// 名字取 provider;写成 label 就是「配置里存了中文名,后端一家都不认识」); +// ② 老形状(候选是**裸字符串**)必须仍然可用 —— 别的插件/老声明还在用它,且**绝不能 +// 因为取不到标签就渲染成空白项**; +// ③ 表格行里的裸 id 要显示成中文标签,**未知 id 原样显示**(不吞、不丢、不空白)。 +// +// 跑法(与 PluginPanel.render.test.ts 同): +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/admin/plugin/ProviderOptionLabels.render.test.ts \ +// --bundle --platform=node --format=cjs \ +// --outfile=/tmp/cyrene-provider-option-labels.render.test.cjs \ +// && node /tmp/cyrene-provider-option-labels.render.test.cjs +// +// 🔴 渲染类测试必须 `--format=cjs`:`--format=esm` 会整批崩在 bundle 深处。 +// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。 +// +// 范围说明:`PanelTableSection` 的行靠 effect 取(服务端渲染不跑 effect ⇒ 表里没有行), +// 所以表格那一档打的是它渲染单元格时用的纯函数(`cellDisplayText` / `optionLabelIndex`), +// 而不是 HTML 快照。 + +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { splitListValue, toggleListOption } from "../schema/values"; +import { PanelFormSection } from "./FormSection"; +import { cellDisplayText, optionLabelIndex } from "./tableState"; +import { decodeOptions, type PanelOptionDto, type PanelSectionDto } from "./types"; + +let checks = 0; + +function ok(cond: boolean, what: string): void { + checks += 1; + if (!cond) throw new Error(`FAILED: ${what}`); +} + +function eq(got: T, want: T, what: string): void { + checks += 1; + if (got !== want) throw new Error(`FAILED: ${what}\n got = ${JSON.stringify(got)}\n want = ${JSON.stringify(want)}`); +} + +function has(haystack: string, needle: string): boolean { + return haystack.includes(needle); +} + +/** 把 `` 的**文本**取出来 —— 「显示名 ↔ 落盘值」配对的唯一硬证据。 + * 属性顺序(React 可能补 `selected`)由 `[^>]*` 容错。 */ +function optionText(html: string, value: string): string | null { + const m = new RegExp(`]*\\bvalue="${value}"[^>]*>([^<]*)`).exec(html); + return m === null ? null : m[1]; +} + +/** web-search 的 `providers` 页表单(与 `plugins/tools/io/search/panel.go` 同形): + * provider 单选下拉、fallback 多选(降级链),两者都从 `plugin:providers` 拉候选。 */ +const FORM: PanelSectionDto = { + type: "form", + id: "add", + title: "添加 / 修改提供商", + fields: [ + { key: "provider", label: "提供商", type: "select", optionsFrom: "plugin:providers", required: true }, + { key: "fallback", label: "降级链", type: "multiSelect", optionsFrom: "plugin:providers", placeholder: "按顺序依次尝试" }, + ], + submit: { action: "plugin.saveProvider" }, + values: { provider: "zhipu", fallback: "bocha" }, +}; + +/** 后端 `plugin.providers` 动作**新**形状解出来的候选(`{value,label}`)。 */ +const NEW_SHAPE: Record = { + "plugin:providers": decodeOptions({ + options: [ + { value: "zhipu", label: "智谱 BigModel(zhipu)" }, + { value: "bocha", label: "博查 Bocha(bocha)" }, + ], + }), +}; + +/** 后端**老**形状(裸字符串数组)解出来的候选 —— 向后兼容的那一半。 */ +const OLD_SHAPE: Record = { + "plugin:providers": decodeOptions({ options: ["searxng", "zhipu"] }), +}; + +function renderForm(options: Record): string { + return renderToStaticMarkup( + createElement(PanelFormSection, { + pluginId: "web-search", + section: FORM, + role: "owner", + onReload: () => {}, + options, + optionErrors: {}, + }), + ); +} + +// ── ① 新形状:显示中文标签,提交/落盘的是英文 id ─────────────────────────────── +{ + const html = renderForm(NEW_SHAPE); + + eq(optionText(html, "zhipu"), "智谱 BigModel(zhipu)", "候选的 value 是 id,显示名是中文标签"); + eq(optionText(html, "bocha"), "博查 Bocha(bocha)", "第二家同样:值 → 显示名配对正确"); + ok(has(html, ""), "不出现「有值但没字」的空 option"); +} + +// ── ④ 表格列:把行里的裸值翻成中文标签,未知 id 原样显示 ────────────────────── +{ + const labels = optionLabelIndex(NEW_SHAPE); + eq(cellDisplayText("zhipu", labels), "智谱 BigModel(zhipu)", "表格里的 provider 显示中文标签(标签里已括注 id)"); + eq(cellDisplayText("tavily", labels), "tavily", "未知 id 原样显示(不吞、不丢)"); + eq(cellDisplayText("", labels), "—", "空值仍是占位符,不变成空白单元格"); + eq(cellDisplayText(undefined, labels), "—", "缺字段同上"); + eq( + JSON.stringify([...optionLabelIndex({ a: [{ value: "x", label: " " }], b: [{ value: "y", label: "Y" }] })]), + JSON.stringify([["y", "Y"]]), + "空白标签不进索引(免得表里出现空白单元格),跨来源合并成一张表", + ); +} + +// ── ⑤ 多选写回:写的是 id(不是中文标签) ───────────────────────────────────── +{ + const ZHIPU = { value: "zhipu", label: "智谱 BigModel(zhipu)" }; + const BOCHA = { value: "bocha", label: "博查 Bocha(bocha)" }; + eq(toggleListOption("bocha", ZHIPU), "bocha, zhipu", "点选把候选项的 id 追加到降级链末尾(不是它的中文标签)"); + eq(toggleListOption("zhipu, bocha", ZHIPU), "bocha", "再点一次移出该 id,其余保持次序"); + eq(toggleListOption("", BOCHA), "bocha", "空值起手可加入"); + eq(JSON.stringify(splitListValue("zhipu,bocha\nsearxng")), JSON.stringify(["zhipu", "bocha", "searxng"]), "逗号 / 中文逗号 / 换行都当分隔符(与提交解析同一处口径)"); +} + +console.log(`provider option labels render test: ${checks} checks passed`); diff --git a/frontend/web/src/admin/plugin/TableSection.tsx b/frontend/web/src/admin/plugin/TableSection.tsx index c172af9..f764ee8 100644 --- a/frontend/web/src/admin/plugin/TableSection.tsx +++ b/frontend/web/src/admin/plugin/TableSection.tsx @@ -2,8 +2,12 @@ * * 复用既有 `AdminTable`(含窗口化与空/错/加载三态),所以这里只做三件事:把列声明翻成 * `AdminColumn`、把行内动作接上宿主动作端点、把反馈画出来(含 viewer 的只读说明)。 - * 状态机在 `tableState.ts`;纯助手 `cellText` / `rowIdentity` 从那里复出口(单测按 - * 本模块路径 import,换家不必改测试)。 + * 状态机在 `tableState.ts`;纯助手 `cellText` / `rowIdentity` / `cellDisplayText` 从那里 + * 复出口(单测按本模块路径 import,换家不必改测试)。 + * + * 单元格的**中文显示名**:行里的 `provider` 这类裸值经 `cellDisplayText` 查一次 + * `optionLabels`(面板级候选,来自声明的 `optionsFrom`)⇒ 有标签显示标签、没有就原样显示值。 + * 查找**只影响显示**:行内动作照旧把原始 row(值为 id)送回后端,配置文件里永远不会出现标签。 * * 对 viewer:后端快照已摘掉行内动作,这里**再摘一次**(纵深防御,见 `nav.canWrite`)。 */ @@ -15,12 +19,23 @@ import { useAuditStore } from "../audit"; import { AdminTable, Notice, type AdminColumn } from "../components"; import { canWrite } from "./nav"; import { SectionHeader } from "./SectionHeader"; -import { cellText, rowIdentity, useRowActionRunner, useTableRows, type PanelRow, type TableFlash } from "./tableState"; +import { + cellDisplayText, + cellText, + rowIdentity, + useRowActionRunner, + useTableRows, + type PanelRow, + type TableFlash, +} from "./tableState"; import type { PanelSectionDto } from "./types"; -export { cellText, rowIdentity }; +export { cellText, rowIdentity, cellDisplayText }; export type { PanelRow }; +/** 没有候选时的空索引(常量:避免每次渲染新建 Map 造成列定义无谓变化)。 */ +const EMPTY_LABELS: ReadonlyMap = new Map(); + /** 反馈行:错误码与文案分两处(§5.4.6),成功/失败用不同色。 */ function TableFlashRow({ flash }: { flash: TableFlash | null }): ReactNode { if (!flash) return null; @@ -41,19 +56,24 @@ export interface PanelTableSectionProps { onReload: () => void; /** 行内动作返回显式值对象时回调(reauth「把该条带回表单」,见 `formValuesOfResult`)。 */ onValues?: (values: Record) => void; + /** `value → label` 索引(面板级候选,见 `optionLabelIndex`):把行里的裸值翻成显示名。 + * 候选来自**声明面的字段**(`optionsFrom`),viewer 的快照里没有写表单 ⇒ 取不到就显示 + * 值本身,不空白、也不猜。 */ + optionLabels?: ReadonlyMap; } -export function PanelTableSection({ pluginId, section, role, onReload, onValues }: PanelTableSectionProps): ReactNode { +export function PanelTableSection({ pluginId, section, role, onReload, onValues, optionLabels }: PanelTableSectionProps): ReactNode { const log = useAuditStore((s) => s.log); const { rows, loading, error, load } = useTableRows(pluginId, section.source?.action ?? ""); const { busy, flash, run } = useRowActionRunner({ pluginId, onReload, reloadRows: load, log, onValues }); const rowActions = section.rowActions ?? []; const canAct = canWrite(role) && rowActions.length > 0; + const labels = optionLabels ?? EMPTY_LABELS; const columns: AdminColumn[] = (section.columns ?? []).map((c) => ({ key: c.key, title: c.label ?? c.key, - render: (row) => {cellText(row.data[c.key])}, + render: (row) => {cellDisplayText(row.data[c.key], labels)}, })); if (canAct) { columns.push({ diff --git a/frontend/web/src/admin/plugin/formState.ts b/frontend/web/src/admin/plugin/formState.ts index 2836f0f..52c42a9 100644 --- a/frontend/web/src/admin/plugin/formState.ts +++ b/frontend/web/src/admin/plugin/formState.ts @@ -97,7 +97,15 @@ export interface FieldOptionsState { optionErrors: Record; } -/** 下拉候选:按 `optionsFrom` 逐条取一次(取不到就降级成文本框,由调用方在说明里写明)。 */ +/** 下拉候选:按 `optionsFrom` 逐条取一次(取不到就降级成文本框,由调用方在说明里写明)。 + * + * 两条口径: + * - **形状兼容**由 `types.decodeOptions` 兜住:候选项可以是**裸字符串**(老声明 / 别的 + * 插件还在用的老形状)或 `{value,label}` 对象,两种都归一成 `{value,label}`。控件显示 + * `label`、**只有 `value` 会进表单值** —— label 一旦落盘就是把配置文件写坏。 + * - **面板级只调一次**(`PluginPanelView`):表单拿它渲染下拉,表格拿它把行里的裸值翻成 + * 中文显示名(`tableState.optionLabelIndex` 查表)。分两处各拉一次就是同一份候选发两次 + * 请求,且表格那份会因「本区块没有字段」而拉不到来源。 */ export function useFieldOptions(pluginId: string, declared: readonly PanelFieldDto[]): FieldOptionsState { const [options, setOptions] = useState>({}); const [optionErrors, setOptionErrors] = useState>({}); diff --git a/frontend/web/src/admin/plugin/tableState.ts b/frontend/web/src/admin/plugin/tableState.ts index 040e10f..b9b378f 100644 --- a/frontend/web/src/admin/plugin/tableState.ts +++ b/frontend/web/src/admin/plugin/tableState.ts @@ -7,7 +7,7 @@ import { useCallback, useEffect, useState } from "react"; import { actionFailure } from "./actions"; import { runPluginAction } from "./panelsApi"; -import { decodeRows, formValuesOfResult } from "./types"; +import { decodeRows, formValuesOfResult, type PanelOptionDto } from "./types"; export interface PanelRow { key: string; @@ -33,6 +33,29 @@ export function cellText(v: unknown): string { } } +/** 候选项的 `value → label` 索引(各来源合并):表格把行里的**裸值**翻成显示名用。 + * + * **只用于显示**:落盘/写回的永远是 value(label 进配置文件就是写坏配置)。同值多来源 + * 冲突时先到者胜(一个面板里通常只有一个来源);空值或空标签一律跳过 —— 免得在表里 + * 留下一条空白单元格。 */ +export function optionLabelIndex(options: Record): Map { + const index = new Map(); + for (const list of Object.values(options)) { + for (const o of list) { + if (o.value === "" || o.label.trim() === "") continue; + if (!index.has(o.value)) index.set(o.value, o.label); + } + } + return index; +} + +/** 单元格的显示文本:值命中候选 ⇒ 显示 label(标签里括注了 id,人工核对对得上); + * **查不到 ⇒ 原样显示值本身** —— 未知 id 不吞、不丢,也绝不显示成一个空白项。 */ +export function cellDisplayText(v: unknown, labels: ReadonlyMap): string { + const text = cellText(v); + return labels.get(text) ?? text; +} + /** 行身份:插件给了真实键就用它,否则用**零填充下标** —— `AdminTable` 会按 rowKey 排序, * 裸下标的自然序("10" < "2")会把后端给的顺序打乱。 */ export function rowIdentity(row: Record, index: number): string { diff --git a/frontend/web/src/admin/schema/controls.tsx b/frontend/web/src/admin/schema/controls.tsx index a7654f1..b553a68 100644 --- a/frontend/web/src/admin/schema/controls.tsx +++ b/frontend/web/src/admin/schema/controls.tsx @@ -4,9 +4,9 @@ * (AGENTS §10);这里每个控件都是「一个类型、十来行」,分发器留在 `fields.tsx`。 */ -import { Switch } from "../../components/primitives"; +import { Button, Switch } from "../../components/primitives"; import type { SchemaField, SchemaOption } from "./fieldTypes"; -import { fieldLabel } from "./values"; +import { fieldLabel, splitListValue, toggleListOption } from "./values"; export const inputCls = "w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"; @@ -95,7 +95,10 @@ export function SwitchControl({ field, value, onChange, disabled }: ControlProps } /** 下拉:有候选才给下拉。自由输入一个未知值会被后端静默回落默认(值域受后端归一化约束), - * 没有候选时降级成文本框(调用方会在字段说明里写明原因),而不是给一个空下拉。 */ + * 没有候选时降级成文本框(调用方会在字段说明里写明原因),而不是给一个空下拉。 + * + * 候选项是 `{value,label}`:**显示 `label`(中文名)、提交 `value`(落盘的 id)** —— + * `value` 写错一个字母就是「配了不生效」,`label` 进配置文件则是直接把配置写坏。 */ export function SelectControl({ field, value, onChange, disabled, invalid, options }: ControlProps): React.ReactNode { const opts = options ?? field.options ?? []; if (opts.length === 0) return ; @@ -119,20 +122,55 @@ export function SelectControl({ field, value, onChange, disabled, invalid, optio ); } -/** 多选:逗号分隔的文本框,占位符列出候选(候选多时下拉反而难选,且候选可能为空)。 */ +/** 多选:**有候选就画成「候选开关」**(点一下加入 / 移出,显示 `label`、写进去的是 `value`); + * 没有候选就退回手填文本框(与旧行为逐字一致),逗号分隔。 + * + * 为什么不是只给一个文本框:候选原来只回英文 id,主人看到的就是「全是英文名」;而把中文 + * 显示名填进文本框又会写坏配置(落盘的值是 provider 名,后端按名字取)。开关让显示与落盘 + * 各归各位。 + * + * 文本框保留:它显示并允许直接编辑**真正落盘的值**(逗号分隔),也是「候选里没有的家 / + * 顺序调整」的唯一出口。 */ export function MultiSelectControl({ field, value, onChange, disabled, invalid, options }: ControlProps): React.ReactNode { const opts = options ?? field.options ?? []; - const placeholder = opts.length > 0 ? opts.map((o) => o.value).join(", ") : field.placeholder; + if (opts.length === 0) { + return ; + } + const selected = splitListValue(value); return ( - onChange(e.target.value)} - placeholder={placeholder} - disabled={disabled} - aria-label={fieldLabel(field)} - className={invalid ? inputClsError : inputCls} - /> +
+
+ {opts.map((o) => { + const on = selected.includes(o.value); + return ( + + ); + })} +
+ onChange(e.target.value)} + placeholder={field.placeholder} + disabled={disabled} + aria-label={fieldLabel(field)} + className={invalid ? inputClsError : inputCls} + /> + + 上面点选的是显示名,下面这行是真正落盘的值(英文 id、逗号分隔,顺序即先后顺序),也可以直接改。 + +
); } diff --git a/frontend/web/src/admin/schema/values.ts b/frontend/web/src/admin/schema/values.ts index 273402a..3750572 100644 --- a/frontend/web/src/admin/schema/values.ts +++ b/frontend/web/src/admin/schema/values.ts @@ -15,7 +15,7 @@ * (design/09 §5.4.2 的哨兵语义)。 */ -import type { SchemaField, SchemaFieldType } from "./fieldTypes"; +import type { SchemaField, SchemaFieldType, SchemaOption } from "./fieldTypes"; /** 表单值一律以字符串承载(与渠道配置表单同一口径):控件层只认 string, * 提交时再按声明的 type 还原成后端要的类型。 */ @@ -29,6 +29,24 @@ export function fieldLabel(f: SchemaField): string { return label !== "" ? label : f.key; } +/** 多选字段值的分隔口径(逗号 / 中文逗号 / 换行)。**只此一处**:装载、提交解析与 + * 「点选候选开关」三处共用,各写一份就会出现「面板看着有值、后端收到空数组」。 */ +export function splitListValue(text: string): string[] { + return text.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean); +} + +/** 候选开关的取值:点一下把候选项的 **`value`(英文 id)** 加入 / 移出多选值。 + * + * **写进去的必须是 `value`,不是 `label`(中文显示名)** —— 这个字段的值会落进 + * `web-search.json` 的降级链,后端按名字取 provider,写中文名等于写坏配置。参数收整个 + * 候选项(只读它的 `value`)就是为了让这条分离在函数签名上看得见。 + * 顺序 = 加入顺序(降级链按序生效),因此从值末尾追加、移出时保持其余相对次序。 */ +export function toggleListOption(text: string, option: SchemaOption): string { + const items = splitListValue(text); + const next = items.includes(option.value) ? items.filter((x) => x !== option.value) : [...items, option.value]; + return next.join(", "); +} + /** 敏感字段:读侧永不回显明文,写侧留空 = 不改动(design/09 §5.4.2)。 */ export function isSensitive(f: SchemaField): boolean { return f.type === "secret"; @@ -129,7 +147,7 @@ export function parseFieldValue(f: SchemaField, raw: string): { ok: true; value: case "switch": return { ok: true, value: text === "true" }; case "multiSelect": - return { ok: true, value: text.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean) }; + return { ok: true, value: splitListValue(text) }; case "kv": { let parsed: unknown; try { diff --git a/plugins/tools/io/search/panel_test.go b/plugins/tools/io/search/panel_test.go index b3f8e05..ef865e7 100644 --- a/plugins/tools/io/search/panel_test.go +++ b/plugins/tools/io/search/panel_test.go @@ -2,7 +2,6 @@ package search import ( "encoding/json" - "sort" "strings" "testing" @@ -174,19 +173,76 @@ func TestBuildRowsStates(t *testing.T) { } } -// 判据 ⑦:下拉选项 = 本期内置的全部已登记家(稳定排序),且**含尚未配置的家** +// 判据 ⑦:下拉候选 = 本期内置的全部已登记家(稳定排序),且**含尚未配置的家** // —— 表单的用途就是第一次配置,只列已配置的会让用户无从选起。 -func TestProviderOptionsAreAllKnownNames(t *testing.T) { +// +// 本判据同时钉住「值 / 显示名分离」(主人 2026-10-06 报障:下拉里全是英文名): +// - `value` 必须**逐字**等于 KnownNames()(它会被写进 web-search.json、被注册表按名取用); +// - `label` 必须非空(否则下拉里是一条空白项),且**括注了 id**(表格/下拉里同时看得见 +// 中文名与值,人工核对才对得上)。 +func TestProviderOptionsCarryValuesAndLabels(t *testing.T) { t.Parallel() got := ProviderOptions() - want := append([]string(nil), KnownNames()...) - sort.Strings(want) - if strings.Join(got, ",") != strings.Join(want, ",") { - t.Fatalf("ProviderOptions() = %v, want %v", got, want) + want := KnownNames() + if strings.Join(providerOptionValues(got), ",") != strings.Join(want, ",") { + t.Fatalf("候选的 value = %v, want %v(值必须逐字等于 KnownNames())", providerOptionValues(got), want) } if len(got) == 0 { t.Fatal("选项为空:表单的下拉一上来就选不了东西") } + for _, o := range got { + if strings.TrimSpace(o.Label) == "" { + t.Errorf("%q 没有显示名:下拉里会是一条空白项", o.Value) + } + if !strings.Contains(o.Label, o.Value) { + t.Errorf("%q 的显示名 %q 里没有括注 id(面板上就看不到真正落盘的值了)", o.Value, o.Label) + } + } +} + +// 判据 ⑦b:标签表与接入位表**必须逐家对齐**两件事 —— 表里的键都是已登记的家(写错的名字 +// 会被静默忽略,标签就白加了),且每家都给了**非空**标签(判据⑦只对 ProviderOptions 的 +// 现存成员成立;这里直接查表,防的是「标签写了个不存在的 id」)。 +func TestProviderLabelsCoverEveryKnownName(t *testing.T) { + t.Parallel() + labels := providerLabels() + for name, label := range labels { + if !Known(name) { + t.Errorf("标签表里的键 %q 不是本期内置的接入位(这个标签永远不会被用到)", name) + } + if strings.TrimSpace(label) == "" { + t.Errorf("%q 的标签是空白", name) + } + } + for _, name := range KnownNames() { + if _, ok := labels[name]; !ok { + t.Errorf("已登记的接入位 %q 没有标签:下拉与表格里只会显示英文 id", name) + } + } +} + +// 判据 ⑦c:**未知 id 原样透传** —— 新增接入位而忘加标签(或以后有人手写一个名字)时, +// 面板显示成 id,不崩、不丢、也不编一个假中文名。 +func TestProviderLabelFallsBackToID(t *testing.T) { + t.Parallel() + for _, name := range []string{"tavily", "zhipu_x", "", " ", "ZHIPU_X"} { + if got := ProviderLabel(name); got != name { + t.Errorf("ProviderLabel(%q) = %q, want 原样回", name, got) + } + } + // 大小写与首尾空白不敏感(与 Known / Configured 同口径):配置里写 `Zhipu` 也认。 + if got, want := ProviderLabel(" Zhipu "), providerLabels()[NameZhipu]; got != want { + t.Errorf("ProviderLabel(的大小写/空白容忍口径) = %q, want %q", got, want) + } +} + +// providerOptionValues 取出候选的**值**(落盘真源),丢掉显示名。 +func providerOptionValues(opts []ProviderOption) []string { + out := make([]string, 0, len(opts)) + for _, o := range opts { + out = append(out, o.Value) + } + return out } // 判据 ⑧:动作名是与插件管理器的**对外契约**(设计示例里的那四个字面量)。 diff --git a/plugins/tools/io/search/paneldata.go b/plugins/tools/io/search/paneldata.go index e01bb34..481db1c 100644 --- a/plugins/tools/io/search/paneldata.go +++ b/plugins/tools/io/search/paneldata.go @@ -79,9 +79,62 @@ func providerState(in ProviderInput) string { return StateOK } -// ProviderOptions 返回 `optionsFrom: plugin:providers` 的选项值 —— 本期**全部已登记**的 -// provider 名(含尚未配置的家),稳定排序。 +// ProviderOption 是下拉候选的一项。**值 / 显示名分离**是本类型的全部意义: +// +// - `Value` 是**唯一真源** —— 它会被写进 `$DATA_DIR/config/plugins/web-search.json`, +// 并被注册表按名字取用(`Registry.Lookup`),大小写与下划线不许动; +// - `Label` 只是**给人看**的显示名(中文名 + 括注 id),任何写盘路径都不得使用它。 +// +// 为什么需要这个类型(此前只回 `["searxng", …]`):面板的候选只回 ASCII id 时,管理页的 +// 下拉与表格里全是英文名(主人 2026-10-06 报障:「全给的英文名,你甚至不愿意带个括号把 +// 中文名写上去」)。但候选是**声明面**,不能因此把中文写进配置 —— 所以值仍回 id, +// 中文名另走一路(`Label`),由前端只负责显示。 +type ProviderOption struct { + // Value 是 provider 名,逐字等于 `KnownNames()` 的输出。 + Value string `json:"value"` + // Label 是显示名,形如「智谱 BigModel(zhipu)」:中文名 + 括注 id,人工核对时能对上。 + Label string `json:"label"` +} + +// providerLabels 是 id → 显示名。键用 provider 名常量(与 registry.definitions() 同源), +// 免得「新加一家但标签表用字面量写」时两边各写一份、悄悄分叉。 +// +// 这里**不**给未知 id 兜一个假名字:`ProviderLabel` 一律原样回(见下)。 +func providerLabels() map[string]string { + return map[string]string{ + NameSearXNG: "SearXNG 本地兜底(searxng)", + NameZhipu: "智谱 BigModel(zhipu)", + NameAliyunIQS: "阿里云 IQS 信息查询(aliyun_iqs)", + NameBocha: "博查 Bocha(bocha)", + NameVolcengine: "火山引擎·豆包搜索(volcengine)", + NameBaiduQianfan: "百度千帆 AI 搜索(baidu_qianfan)", + } +} + +// ProviderLabel 给出一个 provider 名的显示名:标签表里有就用它(大小写与首尾空白不敏感, +// 与 `Known` 同口径),**取不到一律原样回** —— 新增接入位而忘了加标签时,面板显示成 id, +// 不崩、不丢,也不编一个假中文名出来。空白名照原样回空白。 +func ProviderLabel(name string) string { + label := strings.TrimSpace(providerLabels()[strings.ToLower(strings.TrimSpace(name))]) + if label == "" { + return name + } + return label +} + +// ProviderOptions 返回 `optionsFrom: plugin:providers` 的候选项 —— 本期**全部已登记**的 +// provider(含尚未配置的家),稳定排序,每项带值 + 显示名。 // // 为什么给全部而不是「已配置的家」:表单的用途就是**新增**一家,只列已配置的会让 // 「第一次配置」无从选起。可用性不靠下拉限制,靠列表的 `state` 列如实呈现。 -func ProviderOptions() []string { return KnownNames() } +// +// 宿主把返回值原样放进动作响应(`{"options":[{"value":…,"label":…}]}`),前端只拿 `label` +// 显示、只把 `value` 写回。 +func ProviderOptions() []ProviderOption { + names := KnownNames() + out := make([]ProviderOption, 0, len(names)) + for _, name := range names { + out = append(out, ProviderOption{Value: name, Label: ProviderLabel(name)}) + } + return out +}