新增 RAG 引用展示模式:支持技能结果按编号引用/列表/表格渲染

- 前端新增 skill-display 适配层(runtime/display-adapter)与可视化适配面板,
  支持从技能返回 JSON 自动识别字段并配置 citation/list/table/card 展示
- 正文 [[N]] 编号渲染为可点击徽章,点击不跳 URL 而是定位到右侧参考文献面板;
  参考卡片样式重做(圆形编号、悬停高亮、过滤掉 "result" 占位类型)
- bash 工具调用按命令中的 skills/{public,custom}/<name>/ 路径反查展示适配,
  解决调用名为 "bash" 时无法匹配到具体技能的问题
- 工具结果 JSON 解析放宽:能从 stderr/exit code 噪声尾巴里抢救出 JSON 主体
- 展示适配自动识别接入 LLM:读 SKILL.md 让模型推断真实返回结构,失败回退 mock
- 展示适配面板修复保存回显:保存后表单跟随服务端最新值刷新,样例文本不再被抹掉
- 设置页加 "RAG 引用模式" 开关(默认开),关联 lead-agent prompt 注入引用规则
This commit is contained in:
吉鑫升 committed 2026-05-25 18:19:52 +08:00
1 parent 9a63a4b7ca
commit 8892637b97
26 files changed
+2732 -470

No files matched your search

@@ -0,0 +1,209 @@
import type { SkillDisplayConfig } from "@/core/skills/type";
export interface DisplaySourceItem {
id?: string;
title: string;
type: string;
snippet: string;
source?: string;
time?: string;
url?: string;
content?: string;
score?: number;
raw: Record<string, unknown>;
}
export const emptyDisplayConfig = (): SkillDisplayConfig => ({
enabled: false,
mode: "none",
resultPath: "results",
titleField: null,
summaryField: null,
contentField: null,
sourceField: null,
typeField: null,
timeField: null,
scoreField: null,
urlField: null,
idField: null,
linkStrategy: "auto",
linkTemplate: null,
citationToken: "doubleBracket",
maxItems: 8,
customInstruction: "",
tableColumns: [],
});
export const mockKnowledgeSearchV2Result = {
success: true,
query: "latest news",
count: 3,
results: [
{
index: 1,
recUuid: "rec-001",
relevance_score: 0.982,
title: "Knowledge result one",
source_category: "knowledge/news",
source: "internal news library",
publish_time: "2026-05-25",
content_preview: "Mock result used to verify citation numbers, source cards, and recUuid template links.",
content_length: 1200,
},
{
index: 2,
recUuid: "rec-002",
relevance_score: 0.951,
title: "Knowledge result two",
source_category: "knowledge/news",
source: "internal news library",
publish_time: "2026-05-24",
content_preview: "Mock result used to verify citation ordering and detail preview.",
content_length: 980,
},
{
index: 3,
recUuid: "rec-003",
relevance_score: 0.921,
title: "Knowledge result three",
source_category: "knowledge/commentary",
source: "internal knowledge library",
publish_time: "2026-05-23",
content_preview: "Mock result used to verify list mode and custom display instruction previews.",
content_length: 760,
},
],
};
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"],
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", "id", "uuid", "docId", "taskId", "recordId"],
};
function getByPath(value: unknown, path: string): 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);
}
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>[] };
}
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;
}
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 found = findFirstArray(samplePayload) ?? { path: "results", items: [] };
const sample = found.items[0] ?? {};
const inferred = emptyDisplayConfig();
inferred.enabled = true;
inferred.mode = skillName === "knowledge-search-v2" ? "citation" : "list";
inferred.resultPath = found.path || "results";
inferred.maxItems = inferred.mode === "citation" ? 8 : 20;
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.typeField = inferred.typeField || "source_category";
inferred.timeField = inferred.timeField || "publish_time";
inferred.scoreField = inferred.scoreField || "relevance_score";
inferred.idField = inferred.idField || "recUuid";
inferred.linkStrategy = "id";
inferred.linkTemplate = "https://xxx.xxx.xxx?recuuid={recUuid}";
}
inferred.tableColumns = buildDefaultTableColumns(sample, inferred);
return inferred;
}
function buildDefaultTableColumns(
sample: Record<string, unknown>,
config: SkillDisplayConfig,
): NonNullable<SkillDisplayConfig["tableColumns"]> {
const preferred = [
config.titleField,
config.typeField,
config.sourceField,
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;
return String(value);
}
function renderTemplate(template: string, item: Record<string, unknown>): string {
return template.replace(/\{([^}]+)\}/g, (_match, key: string) => encodeURIComponent(stringify(item[key])));
}
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]) : "";
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 rawItems = getByPath(payload, config.resultPath);
if (!Array.isArray(rawItems)) return [];
return rawItems
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object")
.slice(0, config.maxItems)
.map((item, index) => ({
id: config.idField ? stringify(item[config.idField]) : undefined,
title: config.titleField ? stringify(item[config.titleField], `Result ${index + 1}`) : `Result ${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,
time: config.timeField ? stringify(item[config.timeField]) : undefined,
score: config.scoreField && typeof item[config.scoreField] === "number" ? item[config.scoreField] as number : undefined,
url: resolveLink(item, config),
raw: item,
}));
}
@@ -0,0 +1,350 @@
import type { Message } from "@langchain/langgraph-sdk";
import { normalizeSearchResults } from "@/core/messages/search-results";
import type { Skill, SkillDisplayConfig } from "@/core/skills/type";
import {
normalizeDisplaySources,
type DisplaySourceItem,
} from "./display-adapter";
export type RuntimeDisplaySource = DisplaySourceItem & {
index: number;
skillName: string;
mode: SkillDisplayConfig["mode"];
tableColumns?: SkillDisplayConfig["tableColumns"];
};
export type SkillDisplayMap = Map<string, SkillDisplayConfig>;
export function buildSkillDisplayMap(skills: Skill[]): SkillDisplayMap {
const map = new Map<string, SkillDisplayConfig>();
for (const skill of skills) {
const display = skill.display;
if (!display?.enabled || display.mode === "none") {
continue;
}
map.set(skill.name, display);
}
return map;
}
export function findDisplayConfigForTool(
toolName: string,
displays: SkillDisplayMap,
toolArgs?: Record<string, unknown>,
): { skillName: string; display: SkillDisplayConfig } | null {
const exact = displays.get(toolName);
if (exact) {
return { skillName: toolName, display: exact };
}
const normalizedTool = normalizeName(toolName);
for (const [skillName, display] of displays) {
const normalizedSkill = normalizeName(skillName);
if (
normalizedTool === normalizedSkill ||
normalizedTool.endsWith(normalizedSkill) ||
normalizedTool.includes(normalizedSkill)
) {
return { skillName, display };
}
}
// bash/python skill invocations don't carry the skill name in the tool name —
// the path lives inside the command/args (e.g. `python3 skills/custom/demo/scripts/run.py`).
// Scan stringy args for the `skills/{public,custom}/<name>/` shape so display
// configs still resolve. Mirrors the backend `_SKILL_PATH_RE` in
// tool_metrics_middleware.py.
if (toolArgs) {
const skillFromArgs = extractSkillNameFromArgs(toolArgs);
if (skillFromArgs) {
const direct = displays.get(skillFromArgs);
if (direct) {
return { skillName: skillFromArgs, display: direct };
}
}
}
return null;
}
const SKILL_PATH_RE = /[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)/;
function extractSkillNameFromArgs(args: Record<string, unknown>): string | undefined {
for (const value of Object.values(args)) {
if (typeof value !== "string") continue;
const match = SKILL_PATH_RE.exec(value);
if (match) {
return match[1];
}
}
return undefined;
}
export function collectRuntimeDisplaySources(
messages: Message[],
displays: SkillDisplayMap,
options: {
afterMessageId?: string;
beforeMessageId?: string;
includeModes?: SkillDisplayConfig["mode"][];
} = {},
): RuntimeDisplaySource[] {
const start =
options.afterMessageId === undefined
? 0
: Math.max(
messages.findIndex((message) => message.id === options.afterMessageId) + 1,
0,
);
const limit =
options.beforeMessageId === undefined
? messages.length
: Math.max(
messages.findIndex((message) => message.id === options.beforeMessageId),
0,
);
const allowedModes = options.includeModes
? new Set(options.includeModes)
: null;
const sources: RuntimeDisplaySource[] = [];
let citationIndex = 0;
const displayIndexes = new Map<string, number>();
for (let index = start; index < limit; index++) {
const message = messages[index];
if (!message || message.type !== "ai") {
continue;
}
for (const toolCall of message.tool_calls ?? []) {
const match = findDisplayConfigForTool(
toolCall.name,
displays,
toolCall.args as Record<string, unknown> | undefined,
);
const effectiveMode = match?.display.mode ?? "citation";
if (allowedModes && !allowedModes.has(effectiveMode)) {
continue;
}
const result = findToolResultPayload(toolCall.id, messages);
if (result === undefined) {
continue;
}
const normalized = match
? normalizeDisplaySources(result, match.display)
: normalizeDefaultDisplaySources(result);
for (const item of normalized) {
const skillName = match?.skillName ?? toolCall.name;
const displayKey = `${skillName}:${effectiveMode}`;
const displayIndex = (displayIndexes.get(displayKey) ?? 0) + 1;
displayIndexes.set(displayKey, displayIndex);
sources.push({
...item,
index: effectiveMode === "citation" ? ++citationIndex : displayIndex,
skillName,
mode: effectiveMode,
tableColumns: match?.display.tableColumns,
});
}
}
}
return sources;
}
export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceItem[] {
const normalized = normalizeSearchResults(
typeof payload === "string" || isRecord(payload)
? (payload as string | Record<string, unknown>)
: undefined,
);
return normalized.map((item, index) => ({
id: item.url ?? String(index + 1),
title: item.title ?? item.source ?? `结果 ${index + 1}`,
type: "result",
snippet: item.snippet ?? item.content ?? "",
content: item.content ?? item.snippet,
source: item.source,
time: item.published_date ?? item.publishedDate ?? item.date,
url: item.url,
raw: item as unknown as Record<string, unknown>,
}));
}
export function applyInlineCitations(
content: string,
sources: RuntimeDisplaySource[],
): string {
if (!content || sources.length === 0) {
return content;
}
const citationSources = sources.filter((source) => source.mode === "citation");
if (citationSources.length === 0) {
return content;
}
const byIndex = new Map(citationSources.map((source) => [source.index, source]));
const byUrl = new Map<string, RuntimeDisplaySource>();
const byTitle = new Map<string, RuntimeDisplaySource>();
for (const source of citationSources) {
if (source.url) {
byUrl.set(source.url, source);
}
if (source.title) {
byTitle.set(source.title.trim(), source);
}
}
const normalized = normalizeExistingCitationLinks(content, byIndex, byUrl, byTitle);
const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g;
let cursor = 0;
let next = "";
for (const match of normalized.matchAll(markdownLinkPattern)) {
const start = match.index ?? 0;
next += replaceCitationTokens(normalized.slice(cursor, start), byIndex);
next += match[0];
cursor = start + match[0].length;
}
next += replaceCitationTokens(normalized.slice(cursor), byIndex);
return next;
}
function replaceCitationTokens(
content: string,
byIndex: Map<number, RuntimeDisplaySource>,
): string {
return content
.replace(/\[\[(\d+)\]\]/g, (match, rawIndex: string) =>
citationMarkdown(match, Number(rawIndex), byIndex),
)
.replace(/(^|[^:\[!])\[(\d+)\](?!\()/g, (match, prefix: string, rawIndex: string) =>
`${prefix}${citationMarkdown(match.slice(prefix.length), Number(rawIndex), byIndex)}`,
);
}
function normalizeExistingCitationLinks(
content: string,
byIndex: Map<number, RuntimeDisplaySource>,
byUrl: Map<string, RuntimeDisplaySource>,
byTitle: Map<string, RuntimeDisplaySource>,
): string {
// Resolve every `[citation:LABEL](URL)` link — whether LABEL is a number
// ("[1]", "1") or a free-form title — to a numbered reference link that
// CitationLink renders as a badge and scrolls to the right-side panel.
return content.replace(
/\[citation:([^\]]+)\]\(([^)]+)\)(?:\([^)]+\))*/g,
(match, rawLabel: string, rawUrl: string) => {
const label = rawLabel.trim();
const url = rawUrl.trim();
const numberMatch = label.match(/^\[?(\d+)\]?$/);
if (numberMatch) {
const index = Number(numberMatch[1]);
if (byIndex.has(index)) {
return `[citation:[${index}]](#reference-${index})`;
}
}
const sourceByUrl = byUrl.get(url);
if (sourceByUrl) {
return `[citation:[${sourceByUrl.index}]](#reference-${sourceByUrl.index})`;
}
const sourceByTitle = byTitle.get(label);
if (sourceByTitle) {
return `[citation:[${sourceByTitle.index}]](#reference-${sourceByTitle.index})`;
}
return match;
},
);
}
function citationMarkdown(
fallback: string,
index: number,
byIndex: Map<number, RuntimeDisplaySource>,
): string {
const source = byIndex.get(index);
if (!source) {
return fallback;
}
return `[citation:[${index}]](#reference-${index})`;
}
function normalizeName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function findToolResultPayload(
toolCallId: string | undefined,
messages: Message[],
): unknown {
if (!toolCallId) {
return undefined;
}
const toolMessage = messages.find(
(message) => message.type === "tool" && message.tool_call_id === toolCallId,
);
if (!toolMessage) {
return undefined;
}
const content = extractToolText(toolMessage.content);
if (!content) {
return undefined;
}
try {
return JSON.parse(content) as unknown;
} catch {
// bash stdout on the local sandbox can be suffixed with
// "\nStd Error: ..." / "\nExit Code: N" (see local_sandbox.execute_command),
// and skills sometimes print informational lines around their JSON payload.
// Salvage the first balanced JSON object or array we can find before
// falling back to the raw string.
const recovered = extractJSONFromText(content);
if (recovered !== undefined) {
return recovered;
}
return content;
}
}
function extractJSONFromText(text: string): unknown {
const candidates: Array<[number, number]> = [];
const firstObj = text.indexOf("{");
if (firstObj !== -1) candidates.push([firstObj, text.lastIndexOf("}")]);
const firstArr = text.indexOf("[");
if (firstArr !== -1) candidates.push([firstArr, text.lastIndexOf("]")]);
candidates.sort(([a], [b]) => a - b);
for (const [start, end] of candidates) {
if (start < 0 || end <= start) continue;
const slice = text.slice(start, end + 1);
try {
return JSON.parse(slice);
} catch {
// try the next candidate
}
}
return undefined;
}
function extractToolText(content: unknown): string {
if (typeof content === "string") {
return content.trim();
}
if (Array.isArray(content)) {
return content
.map((part) => {
if (typeof part === "string") {
return part;
}
if (part && typeof part === "object" && "text" in part) {
const text = (part as { text?: unknown }).text;
return typeof text === "string" ? text : "";
}
return "";
})
.join("\n")
.trim();
}
return "";
}