技能展示适配:支持多结果集(result sets)渲染

- 后端 extensions_config 新增 SkillDisplayResultSetConfig,SkillDisplayConfig 增加 resultSets 字段
- threads 引用响应增加 resultSetKey / resultSetLabel,runtime/references 适配多结果集
- 前端 skill-display 适配器/运行时/类型支持多结果集渲染,聊天框、消息组、技能展示面板相应更新

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-01 16:42:39 +08:00
1 parent 00e74a5e45
commit c860ee85f7
10 files changed
+517 -78

No files matched your search

@@ -16,7 +16,11 @@ import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import type { SkillDisplayConfig } from "@/core/skills/type";
import {
displayConfigFromResultSet,
normalizeDisplaySources,
} from "@/core/skill-display/display-adapter";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
@@ -603,9 +607,10 @@ function adaptBackendSourceWithSkillDisplay(
if (!match) {
return null;
}
const display = findDisplayConfigForBackendSource(source, match.display);
const normalized = normalizeDisplaySources(
buildPayloadAtPath(match.display.resultPath || "results", [rawItem]),
match.display,
buildPayloadAtPath(display.resultPath || "results", [rawItem]),
display,
)[0];
if (!normalized) {
return null;
@@ -614,13 +619,27 @@ function adaptBackendSourceWithSkillDisplay(
...source,
...normalized,
skillName: match.skillName,
mode: match.display.mode,
tableColumns: match.display.tableColumns,
mode: display.mode,
tableColumns: display.tableColumns,
index: source.index,
raw: source.raw,
};
}
function findDisplayConfigForBackendSource(
source: BackendReferenceSource,
display: SkillDisplayConfig,
) {
const resultSetKey = source.resultSetKey;
if (resultSetKey) {
const resultSet = display.resultSets?.find((item) => item.key === resultSetKey);
if (resultSet) {
return displayConfigFromResultSet(display, resultSet);
}
}
return { ...display, resultSets: [] };
}
function findBackendSourceDisplay(
source: BackendReferenceSource,
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
@@ -258,12 +258,12 @@ function ToolCall({
null);
const displayItems =
displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: displayMatch.display.mode,
}))
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))
: [];
if (displayItems.length > 0) {
return (
@@ -17,6 +17,7 @@ import {
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
displayConfigFromResultSet,
emptyDisplayConfig,
inferDisplayConfig,
normalizeDisplaySources,
@@ -27,9 +28,29 @@ import {
getSkillDisplaySample,
} from "@/core/skills/api";
import { useSetSkillDisplay } from "@/core/skills/hooks";
import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type";
import type {
Skill,
SkillDisplayConfig,
SkillDisplayMode,
SkillDisplayResultSetConfig,
SkillLinkStrategy,
} from "@/core/skills/type";
const fieldLabels: Array<[keyof SkillDisplayConfig, string]> = [
type DisplayFieldKey =
| "resultPath"
| "titleField"
| "summaryField"
| "contentField"
| "sourceField"
| "authorField"
| "typeField"
| "timeField"
| "scoreField"
| "urlField"
| "idField"
| "linkTemplate";
const fieldLabels: Array<[DisplayFieldKey, string]> = [
["resultPath", "结果列表路径"],
["titleField", "标题字段"],
["summaryField", "摘要字段"],
@@ -71,14 +92,56 @@ function formatSample(sample: Record<string, unknown> | null | undefined) {
return sample ? JSON.stringify(sample, null, 2) : "";
}
function firstResultSetKey(display: SkillDisplayConfig | null | undefined) {
return display?.resultSets?.[0]?.key ?? null;
}
export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
const queryClient = useQueryClient();
const setDisplay = useSetSkillDisplay();
const lastSkillNameRef = useRef(skill.name);
const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig());
const [activeResultSetKey, setActiveResultSetKey] = useState<string | null>(() =>
firstResultSetKey(skill.display),
);
const [sampleText, setSampleText] = useState(() => formatSample(skill.displaySample));
const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2));
const [tableColumnsText, setTableColumnsText] = useState(() => {
const display = skill.display ?? emptyDisplayConfig();
const resultSet = display.resultSets?.[0];
return JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2);
});
const [loadingDbConfig, setLoadingDbConfig] = useState(false);
const resultSets = draft.resultSets ?? [];
const activeResultSet =
resultSets.find((resultSet) => resultSet.key === activeResultSetKey) ?? null;
const editingConfig = activeResultSet ?? draft;
const previewConfig = activeResultSet
? displayConfigFromResultSet(draft, activeResultSet)
: draft;
function updateEditingConfig<K extends keyof SkillDisplayResultSetConfig>(
key: K,
value: SkillDisplayResultSetConfig[K],
) {
if (!activeResultSet) {
setDraft(updateConfig(draft, key as keyof SkillDisplayConfig, value as never));
return;
}
setDraft({
...draft,
resultSets: resultSets.map((resultSet) =>
resultSet.key === activeResultSet.key
? { ...resultSet, [key]: value }
: resultSet,
),
});
}
function selectResultSet(key: string) {
const resultSet = resultSets.find((item) => item.key === key) ?? null;
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? draft).tableColumns ?? [], null, 2));
}
// Echo server state into the form — on mount, on skill switch, AND after
// a successful save (the refetched skill object has a new `display` identity).
@@ -88,7 +151,9 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
useEffect(() => {
const display = skill.display ?? emptyDisplayConfig();
setDraft(display);
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
const resultSet = display.resultSets?.[0];
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
}, [skill.name, skill.display]);
useEffect(() => {
@@ -101,8 +166,10 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
return;
}
const display = fresh.display ?? emptyDisplayConfig();
const resultSet = display.resultSets?.[0];
setDraft(display);
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
setSampleText(formatSample(fresh.displaySample));
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], fresh);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
@@ -186,8 +253,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
}, [sampleText]);
const previewItems = useMemo(() => (
samplePayload ? normalizeDisplaySources(samplePayload, draft) : []
), [draft, samplePayload]);
samplePayload ? normalizeDisplaySources(samplePayload, previewConfig) : []
), [previewConfig, samplePayload]);
function generateDraft() {
if (!samplePayload) {
@@ -196,8 +263,14 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
}
const nextDraft = inferDisplayConfig(samplePayload, skill.name);
setDraft(nextDraft);
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
toast.success("已生成展示适配草稿");
const resultSet = nextDraft.resultSets?.[0];
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? nextDraft).tableColumns ?? [], null, 2));
toast.success(
resultSet
? `已识别 ${nextDraft.resultSets?.length ?? 0} 个结果集,可分别配置`
: "已生成展示适配草稿",
);
}
async function loadSampleAndGenerate() {
@@ -219,8 +292,14 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
);
const nextDraft = inferDisplayConfig(response.sample, skill.name);
setDraft(nextDraft);
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
toast.success("已获取样例并生成展示适配");
const resultSet = nextDraft.resultSets?.[0];
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? nextDraft).tableColumns ?? [], null, 2));
toast.success(
resultSet
? `已获取样例,并识别 ${nextDraft.resultSets?.length ?? 0} 个结果集`
: "已获取样例并生成展示适配",
);
} catch (err) {
generateDraft();
toast.error(err instanceof Error ? err.message : String(err));
@@ -235,8 +314,10 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
sample: samplePayload ?? undefined,
});
const display = saved.display ?? emptyDisplayConfig();
const resultSet = display.resultSets?.find((item) => item.key === activeResultSetKey) ?? display.resultSets?.[0];
setDraft(display);
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
setSampleText(formatSample(saved.displaySample));
toast.success("展示适配已保存");
} catch (err) {
@@ -248,6 +329,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
try {
await setDisplay.mutateAsync({ skillName: skill.name, display: null, sample: null });
setDraft(emptyDisplayConfig());
setActiveResultSetKey(null);
setTableColumnsText(JSON.stringify([], null, 2));
toast.success("展示适配已清除");
} catch (err) {
@@ -283,8 +365,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
<label className="space-y-1.5">
<span className="text-xs text-muted-foreground">展示方式</span>
<Select
value={draft.mode}
onValueChange={(value) => setDraft(updateConfig(draft, "mode", value as SkillDisplayMode))}
value={editingConfig.mode}
onValueChange={(value) => updateEditingConfig("mode", value as SkillDisplayMode)}
>
<SelectTrigger className="w-full">
<SelectValue />
@@ -300,8 +382,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
<label className="space-y-1.5">
<span className="text-xs text-muted-foreground">跳转方式</span>
<Select
value={draft.linkStrategy}
onValueChange={(value) => setDraft(updateConfig(draft, "linkStrategy", value as SkillLinkStrategy))}
value={editingConfig.linkStrategy}
onValueChange={(value) => updateEditingConfig("linkStrategy", value as SkillLinkStrategy)}
>
<SelectTrigger className="w-full">
<SelectValue />
@@ -320,26 +402,58 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
type="number"
min={1}
max={100}
value={draft.maxItems}
onChange={(event) => setDraft(updateConfig(draft, "maxItems", Number(event.currentTarget.value) || 1))}
value={editingConfig.maxItems}
onChange={(event) => updateEditingConfig("maxItems", Number(event.currentTarget.value) || 1)}
/>
</label>
</div>
{resultSets.length > 0 && (
<div className="mt-4 grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
<label className="space-y-1.5">
<span className="text-xs text-muted-foreground">结果集配置</span>
<Select
value={activeResultSet?.key ?? resultSets[0]?.key}
onValueChange={selectResultSet}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{resultSets.map((resultSet) => (
<SelectItem key={resultSet.key} value={resultSet.key}>
{resultSet.label || resultSet.resultPath}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
{activeResultSet && (
<label className="flex items-center justify-end gap-2 pb-2">
<span className="text-xs text-muted-foreground">启用当前结果集</span>
<Switch
checked={activeResultSet.enabled}
onCheckedChange={(checked) => updateEditingConfig("enabled", checked)}
/>
</label>
)}
</div>
)}
<div className="mt-4 grid gap-3 md:grid-cols-2">
{fieldLabels.map(([field, label]) => (
<label key={field} className="space-y-1.5">
<span className="text-xs text-muted-foreground">{label}</span>
<Input
value={(draft[field] as string | number | boolean | null | undefined)?.toString() ?? ""}
onChange={(event) => setDraft(updateConfig(draft, field, event.currentTarget.value as never))}
value={(editingConfig[field] as string | number | boolean | null | undefined)?.toString() ?? ""}
onChange={(event) => updateEditingConfig(field, event.currentTarget.value as never)}
placeholder={field === "linkTemplate" ? "https://xxx.xxx.xxx?recuuid={recUuid}" : undefined}
/>
</label>
))}
</div>
{draft.mode === "table" && (
{editingConfig.mode === "table" && (
<label className="mt-4 block space-y-1.5">
<span className="text-xs text-muted-foreground">表格列配置 JSON</span>
<Textarea
@@ -349,7 +463,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
try {
const value = JSON.parse(event.currentTarget.value);
if (Array.isArray(value)) {
setDraft(updateConfig(draft, "tableColumns", value));
updateEditingConfig("tableColumns", value);
}
} catch {
// Keep typing invalid JSON without crashing the panel.
@@ -361,12 +475,12 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
</label>
)}
{draft.mode === "custom" && (
{editingConfig.mode === "custom" && (
<label className="mt-4 block space-y-1.5">
<span className="text-xs text-muted-foreground">自定义展示描述</span>
<Textarea
value={draft.customInstruction}
onChange={(event) => setDraft(updateConfig(draft, "customInstruction", event.currentTarget.value))}
value={editingConfig.customInstruction}
onChange={(event) => updateEditingConfig("customInstruction", event.currentTarget.value)}
placeholder="例如:按 publish_time 时间倒序显示为时间线,每条包含标题、摘要、来源和查看原文按钮。"
className="min-h-24"
/>