- 后端 extensions_config 新增 SkillDisplayResultSetConfig,SkillDisplayConfig 增加 resultSets 字段 - threads 引用响应增加 resultSetKey / resultSetLabel,runtime/references 适配多结果集 - 前端 skill-display 适配器/运行时/类型支持多结果集渲染,聊天框、消息组、技能展示面板相应更新 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
import type {
|
||
SkillDisplayConfig,
|
||
SkillDisplayResultSetConfig,
|
||
} from "@/core/skills/type";
|
||
|
||
const DEFAULT_CITATION_MAX_ITEMS = 100;
|
||
const DEFAULT_LIST_MAX_ITEMS = 20;
|
||
const MAX_DISPLAY_ITEMS = 100;
|
||
|
||
export interface DisplaySourceItem {
|
||
id?: string;
|
||
title: string;
|
||
type: string;
|
||
snippet: string;
|
||
source?: string;
|
||
author?: string;
|
||
time?: string;
|
||
url?: string;
|
||
content?: string;
|
||
score?: number;
|
||
displayMode?: SkillDisplayConfig["mode"];
|
||
resultSetKey?: string;
|
||
resultSetLabel?: string | null;
|
||
tableColumns?: SkillDisplayConfig["tableColumns"];
|
||
raw: Record<string, unknown>;
|
||
}
|
||
|
||
export const emptyDisplayConfig = (): SkillDisplayConfig => ({
|
||
enabled: false,
|
||
mode: "none",
|
||
resultPath: "results",
|
||
titleField: null,
|
||
summaryField: null,
|
||
contentField: null,
|
||
sourceField: null,
|
||
authorField: null,
|
||
typeField: null,
|
||
timeField: null,
|
||
scoreField: null,
|
||
urlField: null,
|
||
idField: null,
|
||
linkStrategy: "auto",
|
||
linkTemplate: null,
|
||
citationToken: "doubleBracket",
|
||
maxItems: DEFAULT_CITATION_MAX_ITEMS,
|
||
customInstruction: "",
|
||
tableColumns: [],
|
||
resultSets: [],
|
||
});
|
||
|
||
const candidateFields: Record<string, string[]> = {
|
||
titleField: ["title", "m_title", "name", "subject", "headline"],
|
||
summaryField: ["summary", "snippet", "content_preview", "description", "abstract"],
|
||
contentField: ["content", "page_content", "text", "content_preview", "body"],
|
||
sourceField: ["source", "source_name", "site", "from", "origin"],
|
||
authorField: ["author", "authors", "creator", "writer", "byline", "publisher", "owner"],
|
||
typeField: ["type", "category", "source_category", "source1"],
|
||
timeField: ["publish_time", "m_publish", "updatedAt", "createdAt", "date", "time"],
|
||
scoreField: ["relevance_score", "rerank_score", "score", "similarity"],
|
||
urlField: ["url", "link", "href", "source_url"],
|
||
idField: ["recUuid", "rec_uuid", "uuid", "docId", "taskId", "recordId", "id"],
|
||
};
|
||
|
||
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") {
|
||
return (acc as Record<string, unknown>)[key];
|
||
}
|
||
return undefined;
|
||
}, value);
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
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 {
|
||
const keys = Object.keys(sample);
|
||
for (const candidate of candidates) {
|
||
const exact = keys.find((key) => key === candidate);
|
||
if (exact) return exact;
|
||
}
|
||
for (const candidate of candidates) {
|
||
const lowered = candidate.toLowerCase();
|
||
const fuzzy = keys.find((key) => key.toLowerCase().includes(lowered));
|
||
if (fuzzy) return fuzzy;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function inferDisplayConfig(samplePayload: unknown, skillName?: string): SkillDisplayConfig {
|
||
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 = 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);
|
||
}
|
||
|
||
if (skillName === "knowledge-search-v2") {
|
||
inferred.titleField = inferred.titleField || "title";
|
||
inferred.summaryField = inferred.summaryField || "content_preview";
|
||
inferred.contentField = inferred.contentField || "content_preview";
|
||
inferred.sourceField = inferred.sourceField || "source";
|
||
inferred.authorField = inferred.authorField || pickField(sample, candidateFields.authorField);
|
||
inferred.typeField = inferred.typeField || "source_category";
|
||
inferred.timeField = inferred.timeField || "publish_time";
|
||
inferred.scoreField = inferred.scoreField || "relevance_score";
|
||
inferred.idField = pickField(sample, ["recUuid", "rec_uuid", "uuid", "id"]) || inferred.idField;
|
||
inferred.linkStrategy = inferred.urlField ? "auto" : "id";
|
||
inferred.linkTemplate = "https://xxx.xxx.xxx?recuuid={recUuid}";
|
||
}
|
||
|
||
inferred.tableColumns = buildDefaultTableColumns(sample, inferred);
|
||
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,
|
||
): NonNullable<SkillDisplayConfig["tableColumns"]> {
|
||
const preferred = [
|
||
config.titleField,
|
||
config.typeField,
|
||
config.sourceField,
|
||
config.authorField,
|
||
config.timeField,
|
||
config.scoreField,
|
||
].filter((field): field is string => !!field);
|
||
const fallback = Object.keys(sample)
|
||
.filter((field) => !preferred.includes(field))
|
||
.slice(0, Math.max(0, 6 - preferred.length));
|
||
return [...preferred, ...fallback].slice(0, 8).map((field) => ({
|
||
label: field,
|
||
field,
|
||
}));
|
||
}
|
||
|
||
function stringify(value: unknown, fallback = ""): string {
|
||
if (value === null || value === undefined) return fallback;
|
||
if (Array.isArray(value)) {
|
||
const parts = value
|
||
.map((item) =>
|
||
typeof item === "string" || typeof item === "number" || typeof item === "boolean"
|
||
? String(item).trim()
|
||
: "",
|
||
)
|
||
.filter(Boolean);
|
||
return parts.length > 0 ? parts.join("、") : fallback;
|
||
}
|
||
return String(value);
|
||
}
|
||
|
||
function renderTemplate(template: string, item: Record<string, unknown>): string {
|
||
return template.replace(/\{([^}]+)\}/g, (_match, key: string) =>
|
||
encodeURIComponent(stringify(getByPath(item, key.trim()))),
|
||
);
|
||
}
|
||
|
||
function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig): string | undefined {
|
||
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;
|
||
}
|
||
return url || (id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined) || undefined;
|
||
}
|
||
|
||
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);
|
||
return rawItems
|
||
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object")
|
||
.slice(0, maxItems)
|
||
.map((item, index) => ({
|
||
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 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,
|
||
}));
|
||
}
|
||
|
||
function inferMaxItems(
|
||
itemCount: number,
|
||
mode: SkillDisplayConfig["mode"],
|
||
): number {
|
||
const fallback =
|
||
mode === "citation" ? DEFAULT_CITATION_MAX_ITEMS : DEFAULT_LIST_MAX_ITEMS;
|
||
return Math.min(
|
||
MAX_DISPLAY_ITEMS,
|
||
Math.max(fallback, itemCount || fallback),
|
||
);
|
||
}
|
||
|
||
function resolveMaxItems(config: SkillDisplayConfig, itemCount: number): number {
|
||
const configured = Number(config.maxItems) || 0;
|
||
if (config.mode === "citation" && configured <= 8 && itemCount > configured) {
|
||
return Math.min(itemCount, MAX_DISPLAY_ITEMS);
|
||
}
|
||
return Math.min(
|
||
MAX_DISPLAY_ITEMS,
|
||
Math.max(1, configured || inferMaxItems(itemCount, config.mode)),
|
||
);
|
||
}
|