feat(admin): 搜索提供商下拉/多选/表格显示中文名(值仍是 ASCII id)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人报障:「为什么中文搜索接口……下拉菜单里全给的英文名?你甚至不愿意带个括号
把中文名写上去」。根因不是漏写文案,而是整条链路没有「显示名」的位置:字段
声明只有 optionsFrom,候选动作回的就是 KnownNames() 的纯 id 数组。

- 后端:候选改为对象数组 {value,label};label 表与接入位表同源(键用
  NameSearXNG 等常量),未知 id 原样回。value 逐字等于 KnownNames()。
- 前端:下拉/多选「显示 label、写 value」;表格单元格按 value→label 索引显示,
  查不到回落显示原值。多选从裸输入框改成「候选开关 + 落盘值输入框」。
  解码器本就兼容裸字符串/{value,label}/{id} 三形状 ⇒ 其他插件零影响。

中文名逐字:SearXNG 本地兜底(searxng)/ 智谱 BigModel(zhipu)/
阿里云 IQS 信息查询(aliyun_iqs)/ 博查 Bocha(bocha)/
火山引擎·豆包搜索(volcengine)/ 百度千帆 AI 搜索(baidu_qianfan)。

验收(主会话复跑 + 亲自变异):go build ./... + vet 绿;search 包测试绿;
tsc --noEmit 0 错;新渲染测试 26 checks + 既有 plugin panel 55 / data 90 /
schema values 40 / channel config 70 全绿。我用 sed 把 values.ts 的
option.value 全换成 option.label ⇒ 渲染测试红(「点选把候选项的 id 追加到
降级链末尾」got="bocha, 智谱 BigModel(zhipu)")—— 这正是会把配置文件写坏的
错法;还原后 md5 一致且绿。

⚠️ 契约面变更(我在 Docs/api-client.md 登记):plugin.providers 候选响应形状
字符串数组 → 对象数组;label 仅供显示、value 才是落盘值。
⚠️ 已知边界:viewer 角色看不到 form 区块 ⇒ 表格对 viewer 仍显示裸 id(未做)。
⚠️ 真机浏览器点击未跑(项目无 jsdom)⇒ 建议刷新管理页点一次确认观感。
This commit is contained in:
AskaEth committed 2026-10-07 10:08:19 +08:00
1 parent 8051906afa
commit c46053e276
12 files changed
+500 -49

No files matched your search

+5 -1
View File
@@ -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:表单的用途就是**新增**
// 一家,只列已配置的会让第一次配置无从选起。
+21 -3
View File
@@ -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),
// 绝不假装成功地回一份空列表。
+19 -6
View File
@@ -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<string, unknown> | null;
/** 下拉候选(面板级只拉一次,见 `formState.useFieldOptions`):`optionsFrom` → 候选项。
* 本组件**不再自己拉** —— 同一份候选两处各拉一次会重复请求,且表格那份拿它做显示名。 */
options: Record<string, readonly PanelOptionDto[]>;
/** `optionsFrom` → 失败原因(取不到候选不是致命错误,但要如实说出来)。 */
optionErrors: Record<string, string>;
}
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 });
+36 -5
View File
@@ -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<string, unknown> | null;
onRowValues?: (values: Record<string, unknown>) => void;
options: Record<string, readonly PanelOptionDto[]>;
optionErrors: Record<string, string>;
optionLabels: ReadonlyMap<string, string>;
}): 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<Record<string, unknown> | 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 (
<div className="space-y-4">
<PageHeader
@@ -101,6 +129,9 @@ export function PluginPanelView({ panel, role, onReload }: PluginPanelViewProps)
onReload={onReload}
formPrefill={section.type === "form" && section.id === singleForm ? prefill : null}
onRowValues={singleForm === null ? undefined : (values) => setPrefill(values)}
options={options}
optionErrors={optionErrors}
optionLabels={optionLabels}
/>
))}
{sections.length === 0 ? (
@@ -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<T>(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);
}
/** 把 `<option value="…">文本</option>` 的**文本**取出来 —— 「显示名 ↔ 落盘值」配对的唯一硬证据。
* 属性顺序(React 可能补 `selected`)由 `[^>]*` 容错。 */
function optionText(html: string, value: string): string | null {
const m = new RegExp(`<option[^>]*\\bvalue="${value}"[^>]*>([^<]*)</option>`).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<string, readonly PanelOptionDto[]> = {
"plugin:providers": decodeOptions({
options: [
{ value: "zhipu", label: "智谱 BigModel(zhipu)" },
{ value: "bocha", label: "博查 Bocha(bocha)" },
],
}),
};
/** 后端**老**形状(裸字符串数组)解出来的候选 —— 向后兼容的那一半。 */
const OLD_SHAPE: Record<string, readonly PanelOptionDto[]> = {
"plugin:providers": decodeOptions({ options: ["searxng", "zhipu"] }),
};
function renderForm(options: Record<string, readonly PanelOptionDto[]>): 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 value=\"zhipu\""), "option 的 value 就是落盘的 id(不是 label)");
ok(
optionText(html, "智谱 BigModel(zhipu)") === null,
"中文标签**没有**变成任何 option 的 value(那就是「把 label 写进配置文件」的错法)",
);
ok(!has(html, 'value="智谱'), "整个表单里没有任何控件的值是中文标签(多选开关同理)");
// 多选(降级链):候选项显示中文,写回的是 id。
ok(has(html, ">智谱 BigModel(zhipu)</button>"), "多选候选项显示中文标签");
ok(has(html, 'title="zhipu"'), "多选候选项把落盘的 id 挂在 title 上(看得见、可核对)");
ok(!has(html, 'title="智谱'), "title 里也不是中文名(挂的是真正的值)");
ok(has(html, 'value="bocha"'), "多选下面那行是真正落盘的值(当前降级链 = bocha)");
ok(has(html, "英文 id"), "并如实说明「下面这行是真正落盘的值」");
}
// ── ② 老形状(裸字符串候选):仍然可用,且不渲染空白项 ────────────────────────
{
eq(
JSON.stringify(OLD_SHAPE["plugin:providers"]),
JSON.stringify([
{ value: "searxng", label: "searxng" },
{ value: "zhipu", label: "zhipu" },
]),
"裸字符串候选归一成 {value,label},label 回落成值本身",
);
const html = renderForm(OLD_SHAPE);
eq(optionText(html, "searxng"), "searxng", "老形状下拉照旧可用:没有中文标签就显示值");
ok(!has(html, "<option value=\"\">"), "没有空白项(下拉里绝不出现一条空白候选)");
ok(has(html, 'title="searxng"'), "多选项也照旧(显示值、写回值)");
}
// ── ③ 标签缺失 / 空串:一律回落显示值,绝不显示空白 ───────────────────────────
{
const decoded = decodeOptions({ options: [{ value: "bocha" }, { value: "tavily", label: "" }] });
eq(JSON.stringify(decoded), JSON.stringify([{ value: "bocha", label: "bocha" }, { value: "tavily", label: "tavily" }]), "缺 label / 空 label 都回落成 value");
const html = renderForm({ "plugin:providers": decoded });
eq(optionText(html, "bocha"), "bocha", "缺标签的下拉项显示值本身");
ok(!has(html, "<option></option>"), "不出现「有值但没字」的空 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`);
+26 -6
View File
@@ -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<string, string> = 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<string, unknown>) => void;
/** `value → label` 索引(面板级候选,见 `optionLabelIndex`):把行里的裸值翻成显示名。
* 候选来自**声明面的字段**(`optionsFrom`),viewer 的快照里没有写表单 ⇒ 取不到就显示
* 值本身,不空白、也不猜。 */
optionLabels?: ReadonlyMap<string, string>;
}
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<PanelRow>[] = (section.columns ?? []).map((c) => ({
key: c.key,
title: c.label ?? c.key,
render: (row) => <span className="block max-w-xs truncate">{cellText(row.data[c.key])}</span>,
render: (row) => <span className="block max-w-xs truncate">{cellDisplayText(row.data[c.key], labels)}</span>,
}));
if (canAct) {
columns.push({
+9 -1
View File
@@ -97,7 +97,15 @@ export interface FieldOptionsState {
optionErrors: Record<string, string>;
}
/** 下拉候选:按 `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<Record<string, readonly PanelOptionDto[]>>({});
const [optionErrors, setOptionErrors] = useState<Record<string, string>>({});
+24 -1
View File
@@ -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<string, readonly PanelOptionDto[]>): Map<string, string> {
const index = new Map<string, string>();
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, string>): string {
const text = cellText(v);
return labels.get(text) ?? text;
}
/** 行身份:插件给了真实键就用它,否则用**零填充下标** —— `AdminTable` 会按 rowKey 排序,
* 裸下标的自然序("10" < "2")会把后端给的顺序打乱。 */
export function rowIdentity(row: Record<string, unknown>, index: number): string {
+52 -14
View File
@@ -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 <TextControl field={field} value={value} onChange={onChange} disabled={disabled} invalid={invalid} />;
@@ -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 <TextControl field={field} value={value} onChange={onChange} disabled={disabled} invalid={invalid} />;
}
const selected = splitListValue(value);
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
aria-label={fieldLabel(field)}
className={invalid ? inputClsError : inputCls}
/>
<div className="space-y-1">
<div className="flex flex-wrap gap-1">
{opts.map((o) => {
const on = selected.includes(o.value);
return (
<Button
key={o.value}
type="button"
size="sm"
variant={on ? "soft" : "ghost"}
disabled={disabled}
aria-pressed={on}
title={o.value}
onClick={() => onChange(toggleListOption(value, o))}
>
{o.label}
</Button>
);
})}
</div>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
disabled={disabled}
aria-label={fieldLabel(field)}
className={invalid ? inputClsError : inputCls}
/>
<span className="block text-[11px] text-[var(--mu-text-mid)]">
上面点选的是显示名,下面这行是真正落盘的值(英文 id、逗号分隔,顺序即先后顺序),也可以直接改。
</span>
</div>
);
}
+20 -2
View File
@@ -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 {
+63 -7
View File
@@ -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
}
// 判据 ⑧:动作名是与插件管理器的**对外契约**(设计示例里的那四个字面量)。
+56 -3
View File
@@ -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
}