技能展示适配:支持多结果集(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

@@ -1,4 +1,7 @@
import type { SkillDisplayConfig } from "@/core/skills/type";
import type {
SkillDisplayConfig,
SkillDisplayResultSetConfig,
} from "@/core/skills/type";
const DEFAULT_CITATION_MAX_ITEMS = 100;
const DEFAULT_LIST_MAX_ITEMS = 20;
@@ -15,6 +18,10 @@ export interface DisplaySourceItem {
url?: string;
content?: string;
score?: number;
displayMode?: SkillDisplayConfig["mode"];
resultSetKey?: string;
resultSetLabel?: string | null;
tableColumns?: SkillDisplayConfig["tableColumns"];
raw: Record<string, unknown>;
}
@@ -38,6 +45,7 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
maxItems: DEFAULT_CITATION_MAX_ITEMS,
customInstruction: "",
tableColumns: [],
resultSets: [],
});
const candidateFields: Record<string, string[]> = {
@@ -53,7 +61,7 @@ const candidateFields: Record<string, string[]> = {
idField: ["recUuid", "rec_uuid", "uuid", "docId", "taskId", "recordId", "id"],
};
function getByPath(value: unknown, path: string): unknown {
function getByPath(value: unknown, path: string | null | undefined): unknown {
if (!path) return value;
return path.split(".").reduce<unknown>((acc, key) => {
if (acc && typeof acc === "object") {
@@ -63,19 +71,55 @@ function getByPath(value: unknown, path: string): unknown {
}, value);
}
function findFirstArray(
value: unknown,
path = "",
): { path: string; items: Record<string, unknown>[] } | null {
if (Array.isArray(value) && value.every((item) => item && typeof item === "object")) {
return { path, items: value as Record<string, unknown>[] };
export type ResultArrayCandidate = {
key: string;
label: string;
path: string;
items: Record<string, unknown>[];
};
export function findResultArrayCandidates(samplePayload: unknown): ResultArrayCandidate[] {
const candidates: ResultArrayCandidate[] = [];
const seen = new Set<string>();
function visit(value: unknown, path = "", depth = 0) {
if (depth > 8 || candidates.length >= 20) return;
if (Array.isArray(value)) {
const objectItems = value.filter(
(item): item is Record<string, unknown> =>
!!item && typeof item === "object" && !Array.isArray(item),
);
if (
(value.length === 0 || objectItems.length > 0) &&
objectItems.length === value.length &&
!seen.has(path)
) {
seen.add(path);
candidates.push({
key: buildResultSetKey(path || "root"),
label: buildResultSetLabel(path || "根数组", objectItems.length),
path,
items: objectItems,
});
}
return;
}
if (!value || typeof value !== "object" || Array.isArray(value)) return;
for (const [key, child] of Object.entries(value)) {
visit(child, path ? `${path}.${key}` : key, depth + 1);
}
}
if (!value || typeof value !== "object") return null;
for (const [key, child] of Object.entries(value)) {
const found = findFirstArray(child, path ? `${path}.${key}` : key);
if (found) return found;
}
return null;
visit(samplePayload);
return candidates;
}
function buildResultSetKey(path: string): string {
return path.replace(/[^a-zA-Z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "root";
}
function buildResultSetLabel(path: string, count: number): string {
return `${path}(${count} 条)`;
}
function pickField(sample: Record<string, unknown>, candidates: string[]): string | null {
@@ -93,13 +137,31 @@ function pickField(sample: Record<string, unknown>, candidates: string[]): strin
}
export function inferDisplayConfig(samplePayload: unknown, skillName?: string): SkillDisplayConfig {
const found = findFirstArray(samplePayload) ?? { path: "results", items: [] };
const sample = found.items[0] ?? {};
const candidates = findResultArrayCandidates(samplePayload);
const found = candidates[0] ?? {
key: "results",
label: "results(0 条)",
path: "results",
items: [],
};
const inferred = buildConfigFromCandidate(found, skillName);
inferred.resultSets =
candidates.length > 1
? candidates.map((candidate) => buildResultSetConfigFromCandidate(candidate, skillName))
: [];
return inferred;
}
function buildConfigFromCandidate(
candidate: ResultArrayCandidate,
skillName?: string,
): SkillDisplayConfig {
const sample = candidate.items[0] ?? {};
const inferred = emptyDisplayConfig();
inferred.enabled = true;
inferred.mode = skillName === "knowledge-search-v2" ? "citation" : "list";
inferred.resultPath = found.path || "results";
inferred.maxItems = inferMaxItems(found.items.length, inferred.mode);
inferred.resultPath = candidate.path || "results";
inferred.maxItems = inferMaxItems(candidate.items.length, inferred.mode);
for (const [target, candidates] of Object.entries(candidateFields)) {
(inferred as unknown as Record<string, string | null>)[target] = pickField(sample, candidates);
@@ -123,6 +185,65 @@ export function inferDisplayConfig(samplePayload: unknown, skillName?: string):
return inferred;
}
function buildResultSetConfigFromCandidate(
candidate: ResultArrayCandidate,
skillName?: string,
): SkillDisplayResultSetConfig {
const config = buildConfigFromCandidate(candidate, skillName);
return {
key: candidate.key,
label: candidate.label,
enabled: true,
mode: config.mode,
resultPath: config.resultPath,
titleField: config.titleField,
summaryField: config.summaryField,
contentField: config.contentField,
sourceField: config.sourceField,
authorField: config.authorField,
typeField: config.typeField,
timeField: config.timeField,
scoreField: config.scoreField,
urlField: config.urlField,
idField: config.idField,
linkStrategy: config.linkStrategy,
linkTemplate: config.linkTemplate,
citationToken: config.citationToken,
maxItems: config.maxItems,
customInstruction: config.customInstruction,
tableColumns: config.tableColumns,
};
}
export function displayConfigFromResultSet(
base: SkillDisplayConfig,
resultSet: SkillDisplayResultSetConfig,
): SkillDisplayConfig {
return {
...base,
enabled: resultSet.enabled,
mode: resultSet.mode,
resultPath: resultSet.resultPath,
titleField: resultSet.titleField,
summaryField: resultSet.summaryField,
contentField: resultSet.contentField,
sourceField: resultSet.sourceField,
authorField: resultSet.authorField,
typeField: resultSet.typeField,
timeField: resultSet.timeField,
scoreField: resultSet.scoreField,
urlField: resultSet.urlField,
idField: resultSet.idField,
linkStrategy: resultSet.linkStrategy,
linkTemplate: resultSet.linkTemplate,
citationToken: resultSet.citationToken,
maxItems: resultSet.maxItems,
customInstruction: resultSet.customInstruction,
tableColumns: resultSet.tableColumns,
resultSets: [],
};
}
function buildDefaultTableColumns(
sample: Record<string, unknown>,
config: SkillDisplayConfig,
@@ -161,13 +282,13 @@ function stringify(value: unknown, fallback = ""): string {
function renderTemplate(template: string, item: Record<string, unknown>): string {
return template.replace(/\{([^}]+)\}/g, (_match, key: string) =>
encodeURIComponent(stringify(item[key])),
encodeURIComponent(stringify(getByPath(item, key.trim()))),
);
}
function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig): string | undefined {
const url = config.urlField ? stringify(item[config.urlField]) : "";
const id = config.idField ? stringify(item[config.idField]) : "";
const url = config.urlField ? stringify(getByPath(item, config.urlField)) : "";
const id = config.idField ? stringify(getByPath(item, config.idField)) : "";
if (config.linkStrategy === "url") return url || undefined;
if (config.linkStrategy === "id") {
return id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined;
@@ -176,6 +297,30 @@ function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig):
}
export function normalizeDisplaySources(payload: unknown, config: SkillDisplayConfig): DisplaySourceItem[] {
const configuredResultSets = config.resultSets ?? [];
const resultSets = configuredResultSets.filter(
(resultSet) => resultSet.enabled && resultSet.mode !== "none",
);
if (configuredResultSets.length > 0) {
return resultSets.flatMap((resultSet) =>
normalizeDisplaySourcesForConfig(
payload,
displayConfigFromResultSet(config, resultSet),
resultSet,
),
);
}
if (!config.enabled || config.mode === "none") {
return [];
}
return normalizeDisplaySourcesForConfig(payload, config);
}
function normalizeDisplaySourcesForConfig(
payload: unknown,
config: SkillDisplayConfig,
resultSet?: SkillDisplayResultSetConfig,
): DisplaySourceItem[] {
const rawItems = getByPath(payload, config.resultPath);
if (!Array.isArray(rawItems)) return [];
const maxItems = resolveMaxItems(config, rawItems.length);
@@ -183,19 +328,23 @@ export function normalizeDisplaySources(payload: unknown, config: SkillDisplayCo
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object")
.slice(0, maxItems)
.map((item, index) => ({
id: config.idField ? stringify(item[config.idField]) : undefined,
title: config.titleField ? stringify(item[config.titleField], `结果 ${index + 1}`) : `结果 ${index + 1}`,
type: config.typeField ? stringify(item[config.typeField], "result") : "result",
snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
content: config.contentField ? stringify(item[config.contentField]) : undefined,
source: config.sourceField ? stringify(item[config.sourceField]) : undefined,
author: config.authorField ? stringify(item[config.authorField]) : undefined,
time: config.timeField ? stringify(item[config.timeField]) : undefined,
id: config.idField ? stringify(getByPath(item, config.idField)) : undefined,
title: config.titleField ? stringify(getByPath(item, config.titleField), `结果 ${index + 1}`) : `结果 ${index + 1}`,
type: config.typeField ? stringify(getByPath(item, config.typeField), "result") : "result",
snippet: config.summaryField ? stringify(getByPath(item, config.summaryField)) : "",
content: config.contentField ? stringify(getByPath(item, config.contentField)) : undefined,
source: config.sourceField ? stringify(getByPath(item, config.sourceField)) : undefined,
author: config.authorField ? stringify(getByPath(item, config.authorField)) : undefined,
time: config.timeField ? stringify(getByPath(item, config.timeField)) : undefined,
score:
config.scoreField && typeof item[config.scoreField] === "number"
? (item[config.scoreField] as number)
config.scoreField && typeof getByPath(item, config.scoreField) === "number"
? (getByPath(item, config.scoreField) as number)
: undefined,
url: resolveLink(item, config),
displayMode: config.mode,
resultSetKey: resultSet?.key,
resultSetLabel: resultSet?.label,
tableColumns: config.tableColumns,
raw: item,
}));
}
@@ -202,8 +202,8 @@ export function collectRuntimeDisplaySources(
normalized.map((item) => ({
...item,
skillName: match?.skillName ?? toolCall.name,
mode: effectiveMode,
tableColumns: match?.display.tableColumns,
mode: item.displayMode ?? effectiveMode,
tableColumns: item.tableColumns ?? match?.display.tableColumns,
})),
seenCitationKeys,
() => ++citationIndex,
@@ -242,8 +242,8 @@ export function collectRuntimeDisplaySources(
normalized.map((item) => ({
...item,
skillName: match?.skillName ?? toolName,
mode: effectiveMode,
tableColumns: match?.display.tableColumns,
mode: item.displayMode ?? effectiveMode,
tableColumns: item.tableColumns ?? match?.display.tableColumns,
})),
seenCitationKeys,
() => ++citationIndex,
+7 -1
View File
@@ -12,7 +12,9 @@ export interface SkillDisplayTableColumn {
width?: string | null;
}
export interface SkillDisplayConfig {
export interface SkillDisplayResultSetConfig {
key: string;
label?: string | null;
enabled: boolean;
mode: SkillDisplayMode;
resultPath: string;
@@ -34,6 +36,10 @@ export interface SkillDisplayConfig {
tableColumns?: SkillDisplayTableColumn[];
}
export interface SkillDisplayConfig extends Omit<SkillDisplayResultSetConfig, "key" | "label"> {
resultSets?: SkillDisplayResultSetConfig[];
}
export interface Skill {
name: string;
description: string;