- 后端 extensions_config 新增 SkillDisplayResultSetConfig,SkillDisplayConfig 增加 resultSets 字段 - threads 引用响应增加 resultSetKey / resultSetLabel,runtime/references 适配多结果集 - 前端 skill-display 适配器/运行时/类型支持多结果集渲染,聊天框、消息组、技能展示面板相应更新 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1043 lines
29 KiB
TypeScript
1043 lines
29 KiB
TypeScript
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"];
|
|
};
|
|
|
|
type RuntimeDisplaySourceDraft = Omit<RuntimeDisplaySource, "index">;
|
|
|
|
export type SkillDisplayMap = Map<string, SkillDisplayConfig>;
|
|
|
|
type RuntimeToolCall = {
|
|
id?: string;
|
|
name: string;
|
|
args?: Record<string, unknown>;
|
|
};
|
|
|
|
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 && canInferSkillFromToolArgs(toolName)) {
|
|
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)[\\/]([^\\/]+)(?:[\\/]|$)/;
|
|
const TOOL_ARG_SKILL_MATCH_EXCLUDES = new Set([
|
|
"read_file",
|
|
"readfile",
|
|
"ls",
|
|
"write_file",
|
|
"writefile",
|
|
"str_replace",
|
|
"strreplace",
|
|
"web_fetch",
|
|
"webfetch",
|
|
]);
|
|
|
|
function canInferSkillFromToolArgs(toolName: string): boolean {
|
|
const normalized = normalizeName(toolName);
|
|
if (
|
|
TOOL_ARG_SKILL_MATCH_EXCLUDES.has(toolName) ||
|
|
TOOL_ARG_SKILL_MATCH_EXCLUDES.has(normalized)
|
|
) {
|
|
return false;
|
|
}
|
|
return (
|
|
normalized.includes("bash") ||
|
|
normalized.includes("shell") ||
|
|
normalized.includes("python") ||
|
|
normalized.includes("execute") ||
|
|
normalized.includes("terminal")
|
|
);
|
|
}
|
|
|
|
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;
|
|
includeBeforeMessage?: boolean;
|
|
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) +
|
|
(options.includeBeforeMessage ? 1 : 0),
|
|
0,
|
|
);
|
|
const allowedModes = options.includeModes
|
|
? new Set(options.includeModes)
|
|
: null;
|
|
const sources: RuntimeDisplaySource[] = [];
|
|
let citationIndex = 0;
|
|
const seenCitationKeys = new Map<string, RuntimeDisplaySource>();
|
|
const consumedToolMessageIds = new Set<string>();
|
|
const consumedToolMessages = new Set<Message>();
|
|
|
|
for (let index = start; index < limit; index++) {
|
|
const message = messages[index];
|
|
if (!message || message.type !== "ai") {
|
|
continue;
|
|
}
|
|
for (const toolCall of extractRuntimeToolCalls(message)) {
|
|
const toolMatch = findDisplayConfigForTool(
|
|
toolCall.name,
|
|
displays,
|
|
toolCall.args,
|
|
);
|
|
const toolMessage = findToolResultMessage(
|
|
toolCall,
|
|
messages,
|
|
index,
|
|
consumedToolMessageIds,
|
|
consumedToolMessages,
|
|
);
|
|
if (!toolMessage) {
|
|
continue;
|
|
}
|
|
const result = parseToolMessagePayload(toolMessage);
|
|
if (result === undefined) {
|
|
continue;
|
|
}
|
|
if (toolMessage.id) {
|
|
consumedToolMessageIds.add(toolMessage.id);
|
|
}
|
|
consumedToolMessages.add(toolMessage);
|
|
if (isDisplaySamplePayload(result)) {
|
|
continue;
|
|
}
|
|
const match = toolMatch;
|
|
const effectiveMode = match?.display.mode ?? "citation";
|
|
if (allowedModes && !allowedModes.has(effectiveMode)) {
|
|
continue;
|
|
}
|
|
const normalized = match
|
|
? normalizeDisplaySources(result, match.display)
|
|
: shouldUseDefaultCitationMode(
|
|
toolCall.name,
|
|
result,
|
|
toolCall.args,
|
|
)
|
|
? normalizeDefaultDisplaySources(result)
|
|
: [];
|
|
appendDisplaySources(
|
|
sources,
|
|
normalized.map((item) => ({
|
|
...item,
|
|
skillName: match?.skillName ?? toolCall.name,
|
|
mode: item.displayMode ?? effectiveMode,
|
|
tableColumns: item.tableColumns ?? match?.display.tableColumns,
|
|
})),
|
|
seenCitationKeys,
|
|
() => ++citationIndex,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (let index = start; index < limit; index++) {
|
|
const message = messages[index];
|
|
if (
|
|
!message ||
|
|
message.type !== "tool" ||
|
|
consumedToolMessages.has(message) ||
|
|
consumedToolMessageIds.has(message.id ?? "")
|
|
) {
|
|
continue;
|
|
}
|
|
const result = parseToolMessagePayload(message);
|
|
if (result === undefined || isDisplaySamplePayload(result)) {
|
|
continue;
|
|
}
|
|
const toolName = extractToolMessageName(message);
|
|
const match = findDisplayConfigForPayload(toolName, result, displays);
|
|
const effectiveMode = match?.display.mode ?? "citation";
|
|
if (allowedModes && !allowedModes.has(effectiveMode)) {
|
|
continue;
|
|
}
|
|
const normalized = match
|
|
? normalizeDisplaySources(result, match.display)
|
|
: shouldUseDefaultCitationMode(toolName, result) ||
|
|
normalizeDefaultDisplaySources(result).length > 0
|
|
? normalizeDefaultDisplaySources(result)
|
|
: [];
|
|
appendDisplaySources(
|
|
sources,
|
|
normalized.map((item) => ({
|
|
...item,
|
|
skillName: match?.skillName ?? toolName,
|
|
mode: item.displayMode ?? effectiveMode,
|
|
tableColumns: item.tableColumns ?? match?.display.tableColumns,
|
|
})),
|
|
seenCitationKeys,
|
|
() => ++citationIndex,
|
|
);
|
|
}
|
|
return sources;
|
|
}
|
|
|
|
function appendDisplaySources(
|
|
target: RuntimeDisplaySource[],
|
|
drafts: RuntimeDisplaySourceDraft[],
|
|
seenCitationKeys: Map<string, RuntimeDisplaySource>,
|
|
nextCitationIndex: () => number,
|
|
) {
|
|
for (const draft of drafts) {
|
|
if (draft.mode === "citation") {
|
|
const source = {
|
|
...draft,
|
|
index: nextCitationIndex(),
|
|
};
|
|
const key = citationSourceKey(draft);
|
|
if (key) {
|
|
seenCitationKeys.set(`${key}:${source.index}`, source);
|
|
}
|
|
target.push(source);
|
|
continue;
|
|
}
|
|
|
|
target.push({
|
|
...draft,
|
|
index: nextCitationIndex(),
|
|
});
|
|
}
|
|
}
|
|
|
|
export function reindexRuntimeDisplaySources(
|
|
sources: RuntimeDisplaySource[],
|
|
globalSources: RuntimeDisplaySource[],
|
|
): RuntimeDisplaySource[] {
|
|
if (sources.length === 0 || globalSources.length === 0) {
|
|
return sources;
|
|
}
|
|
const globalByKey = new Map(
|
|
globalSources.map((source) => [citationSourceKey(source), source]),
|
|
);
|
|
return sources.map((source) => {
|
|
const global = globalByKey.get(citationSourceKey(source));
|
|
return global
|
|
? {
|
|
...source,
|
|
index: global.index,
|
|
}
|
|
: source;
|
|
});
|
|
}
|
|
|
|
function citationSourceKey(source: RuntimeDisplaySourceDraft): string {
|
|
const raw = source.raw ?? {};
|
|
const id =
|
|
source.id ||
|
|
source.url ||
|
|
stringifyMaybe(raw.recUuid) ||
|
|
stringifyMaybe(raw.uuid) ||
|
|
stringifyMaybe(raw.docId) ||
|
|
stringifyMaybe(raw.id);
|
|
if (id) {
|
|
return normalizeCitationKey(String(id));
|
|
}
|
|
const fallback = [
|
|
source.skillName,
|
|
source.title,
|
|
source.source,
|
|
source.time,
|
|
source.content,
|
|
source.snippet,
|
|
]
|
|
.filter(Boolean)
|
|
.join("|");
|
|
return normalizeCitationKey(fallback) || normalizeCitationKey(JSON.stringify(raw).slice(0, 300));
|
|
}
|
|
|
|
function extractRuntimeToolCalls(message: Message): RuntimeToolCall[] {
|
|
const calls: RuntimeToolCall[] = [];
|
|
const direct = (message as { tool_calls?: unknown }).tool_calls;
|
|
if (Array.isArray(direct)) {
|
|
for (const raw of direct) {
|
|
const call = normalizeRuntimeToolCall(raw);
|
|
if (call) {
|
|
calls.push(call);
|
|
}
|
|
}
|
|
}
|
|
|
|
const kwargs = (message as { additional_kwargs?: Record<string, unknown> })
|
|
.additional_kwargs;
|
|
const extra = kwargs?.tool_calls;
|
|
if (Array.isArray(extra)) {
|
|
for (const raw of extra) {
|
|
const call = normalizeRuntimeToolCall(raw);
|
|
if (call && !calls.some((item) => item.id && item.id === call.id)) {
|
|
calls.push(call);
|
|
}
|
|
}
|
|
}
|
|
|
|
const chunks = (message as { tool_call_chunks?: unknown }).tool_call_chunks;
|
|
if (Array.isArray(chunks)) {
|
|
for (const raw of chunks) {
|
|
const call = normalizeRuntimeToolCall(raw);
|
|
if (call && !calls.some((item) => item.id && item.id === call.id)) {
|
|
calls.push(call);
|
|
}
|
|
}
|
|
}
|
|
|
|
return calls;
|
|
}
|
|
|
|
function normalizeRuntimeToolCall(raw: unknown): RuntimeToolCall | null {
|
|
if (!isRecord(raw)) {
|
|
return null;
|
|
}
|
|
const fn = isRecord(raw.function) ? raw.function : null;
|
|
const name =
|
|
stringifyMaybe(raw.name) ??
|
|
stringifyMaybe(fn?.name) ??
|
|
stringifyMaybe(raw.tool_name);
|
|
if (!name) {
|
|
return null;
|
|
}
|
|
return {
|
|
id:
|
|
stringifyMaybe(raw.id) ??
|
|
stringifyMaybe(raw.tool_call_id) ??
|
|
stringifyMaybe(raw.call_id),
|
|
name,
|
|
args: normalizeToolArgs(raw.args ?? raw.arguments ?? fn?.arguments),
|
|
};
|
|
}
|
|
|
|
function normalizeToolArgs(value: unknown): Record<string, unknown> | undefined {
|
|
if (isRecord(value)) {
|
|
return value;
|
|
}
|
|
if (typeof value === "string") {
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return isRecord(parsed) ? parsed : undefined;
|
|
} catch {
|
|
return { command: value };
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function findDisplayConfigForPayload(
|
|
toolName: string,
|
|
_payload: unknown,
|
|
displays: SkillDisplayMap,
|
|
): { skillName: string; display: SkillDisplayConfig } | null {
|
|
return findDisplayConfigForTool(toolName, displays);
|
|
}
|
|
|
|
export function isUnconfiguredSkillInvocation(
|
|
toolName: string,
|
|
displays: SkillDisplayMap | undefined,
|
|
toolArgs?: Record<string, unknown>,
|
|
): boolean {
|
|
if (!toolArgs || !canInferSkillFromToolArgs(toolName)) {
|
|
return false;
|
|
}
|
|
const skillName = extractSkillNameFromArgs(toolArgs);
|
|
return !!skillName && !displays?.has(skillName);
|
|
}
|
|
|
|
export function isDisplaySamplePayload(payload: unknown): boolean {
|
|
if (!isRecord(payload)) {
|
|
return false;
|
|
}
|
|
if (payload.__displaySample === true || payload.displaySample === true) {
|
|
return true;
|
|
}
|
|
if (payload.query === "sample query") {
|
|
return true;
|
|
}
|
|
const items =
|
|
getArrayByPath(payload, "results") ??
|
|
getArrayByPath(payload, "data") ??
|
|
getArrayByPath(payload, "items");
|
|
const first = items?.find((item): item is Record<string, unknown> => isRecord(item));
|
|
if (!first) {
|
|
return false;
|
|
}
|
|
const text = Object.values(first)
|
|
.filter((value) => typeof value === "string")
|
|
.join("\n")
|
|
.toLowerCase();
|
|
return (
|
|
/^sample-/i.test(String(first.id ?? "")) ||
|
|
text.includes("mock result") ||
|
|
text.includes("knowledge result one")
|
|
);
|
|
}
|
|
|
|
function getArrayByPath(payload: unknown, path: string): unknown[] | null {
|
|
const value = path.split(".").reduce<unknown>((acc, key) => {
|
|
if (acc && typeof acc === "object") {
|
|
return (acc as Record<string, unknown>)[key];
|
|
}
|
|
return undefined;
|
|
}, payload);
|
|
return Array.isArray(value) ? value : null;
|
|
}
|
|
|
|
export function shouldUseDefaultCitationMode(
|
|
toolName: string,
|
|
payload: unknown,
|
|
toolArgs?: Record<string, unknown>,
|
|
): boolean {
|
|
const normalizedName = normalizeName(toolName);
|
|
const isSearchLikeTool =
|
|
normalizedName.includes("search") ||
|
|
normalizedName.includes("retriev") ||
|
|
normalizedName.includes("knowledge") ||
|
|
normalizedName.includes("query");
|
|
const isSkillInvocation = !!(
|
|
toolArgs &&
|
|
canInferSkillFromToolArgs(toolName) &&
|
|
extractSkillNameFromArgs(toolArgs)
|
|
);
|
|
if (!isSearchLikeTool && !isSkillInvocation) {
|
|
return false;
|
|
}
|
|
|
|
return normalizeDefaultDisplaySources(payload).length > 0;
|
|
}
|
|
|
|
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.recUuid ??
|
|
item.uuid ??
|
|
item.docId ??
|
|
item.id ??
|
|
item.url,
|
|
title: item.title ?? item.source ?? `结果 ${index + 1}`,
|
|
type: "result",
|
|
snippet: item.snippet ?? item.content ?? "",
|
|
content: item.content ?? item.snippet,
|
|
source: item.source,
|
|
author: item.author,
|
|
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 referenceSources = sources.filter((source) => source.mode !== "none");
|
|
if (referenceSources.length === 0) {
|
|
return content;
|
|
}
|
|
const byIndex = new Map(referenceSources.map((source) => [source.index, source]));
|
|
const byUrl = new Map<string, RuntimeDisplaySource>();
|
|
const byTitle = new Map<string, RuntimeDisplaySource>();
|
|
for (const source of referenceSources) {
|
|
if (source.url) {
|
|
byUrl.set(source.url, source);
|
|
}
|
|
if (source.title) {
|
|
byTitle.set(source.title.trim(), source);
|
|
}
|
|
}
|
|
|
|
const normalized = normalizeExistingCitationLinks(
|
|
content,
|
|
byIndex,
|
|
byUrl,
|
|
byTitle,
|
|
referenceSources,
|
|
);
|
|
|
|
const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g;
|
|
let cursor = 0;
|
|
let next = "";
|
|
let replaced = false;
|
|
for (const match of normalized.matchAll(markdownLinkPattern)) {
|
|
const start = match.index ?? 0;
|
|
const replacedSlice = replaceCitationTokens(
|
|
normalized.slice(cursor, start),
|
|
byIndex,
|
|
referenceSources,
|
|
);
|
|
if (replacedSlice !== normalized.slice(cursor, start)) {
|
|
replaced = true;
|
|
}
|
|
next += replacedSlice;
|
|
next += match[0];
|
|
cursor = start + match[0].length;
|
|
}
|
|
const tail = replaceCitationTokens(normalized.slice(cursor), byIndex, referenceSources);
|
|
if (tail !== normalized.slice(cursor)) {
|
|
replaced = true;
|
|
}
|
|
next += tail;
|
|
if (replaced || hasCitationMarkdown(next)) {
|
|
return next;
|
|
}
|
|
return appendCitationSummary(next, referenceSources);
|
|
}
|
|
|
|
function replaceCitationTokens(
|
|
content: string,
|
|
byIndex: Map<number, RuntimeDisplaySource>,
|
|
orderedSources: RuntimeDisplaySource[],
|
|
): string {
|
|
const normalized = normalizeLooseCitationTokens(content, byIndex, orderedSources);
|
|
const withDoubleBracketCitations = normalized
|
|
.replace(/\[\[(\d+)\]\]/g, (match, rawIndex: string) =>
|
|
citationMarkdown(match, Number(rawIndex), byIndex, orderedSources),
|
|
);
|
|
return replaceBracketCitationTokens(
|
|
withDoubleBracketCitations,
|
|
byIndex,
|
|
orderedSources,
|
|
);
|
|
}
|
|
|
|
function replaceBracketCitationTokens(
|
|
content: string,
|
|
byIndex: Map<number, RuntimeDisplaySource>,
|
|
orderedSources: RuntimeDisplaySource[],
|
|
): string {
|
|
const tokenPattern = /\\?\[(\d{1,3})\\?\](?!\()/g;
|
|
let cursor = 0;
|
|
let next = "";
|
|
for (const match of content.matchAll(tokenPattern)) {
|
|
const start = match.index ?? 0;
|
|
const previous = start > 0 ? content[start - 1] : "";
|
|
if (previous === ":" || previous === "[" || previous === "!") {
|
|
continue;
|
|
}
|
|
next += content.slice(cursor, start);
|
|
next += citationMarkdown(match[0], Number(match[1]), byIndex, orderedSources);
|
|
cursor = start + match[0].length;
|
|
}
|
|
next += content.slice(cursor);
|
|
return next;
|
|
}
|
|
|
|
function normalizeLooseCitationTokens(
|
|
content: string,
|
|
byIndex: Map<number, RuntimeDisplaySource>,
|
|
orderedSources: RuntimeDisplaySource[],
|
|
): string {
|
|
const isValidIndex = (index: number) =>
|
|
Number.isInteger(index) &&
|
|
index > 0 &&
|
|
(byIndex.has(index) || !!orderedSources[index - 1]);
|
|
const toCitationRun = (raw: string) => {
|
|
const parts = raw.match(/\\?\[\d{1,3}\\?\]|\d{1,3}/g) ?? [];
|
|
const indexes = parts.map((part) => Number(part.replace(/\D/g, "")));
|
|
if (indexes.length === 0 || indexes.some((index) => !isValidIndex(index))) {
|
|
return raw;
|
|
}
|
|
return indexes.map((index) => `[${index}]`).join("");
|
|
};
|
|
|
|
let next = content.replace(
|
|
/(^|[^\d\[\]\\])((?:\d{1,3}|\\?\[\d{1,3}\\?\])(?:\\?\[\d{1,3}\\?\]|\d{1,3})+)/g,
|
|
(match, prefix: string, rawRun: string) => {
|
|
if (!rawRun.includes("[")) {
|
|
return match;
|
|
}
|
|
return `${prefix}${toCitationRun(rawRun)}`;
|
|
},
|
|
);
|
|
|
|
next = next.replace(
|
|
/(^|[\s\uFF0C\u3002\uFF1B\uFF1A\u3001,!?!?)])(\d{1,3})(?=([\s\uFF0C\u3002\uFF1B\uFF1A\u3001,!?!?)]|$))/g,
|
|
(match, prefix: string, rawIndex: string) => {
|
|
const index = Number(rawIndex);
|
|
if (!isValidIndex(index)) {
|
|
return match;
|
|
}
|
|
return `${prefix}[${index}]`;
|
|
},
|
|
);
|
|
return next;
|
|
}
|
|
|
|
function normalizeExistingCitationLinks(
|
|
content: string,
|
|
byIndex: Map<number, RuntimeDisplaySource>,
|
|
byUrl: Map<string, RuntimeDisplaySource>,
|
|
byTitle: Map<string, RuntimeDisplaySource>,
|
|
orderedSources: 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]);
|
|
const source = byIndex.get(index) ?? orderedSources[index - 1];
|
|
if (source) {
|
|
return citationLinkMarkdown(source);
|
|
}
|
|
}
|
|
const sourceByUrl = byUrl.get(url);
|
|
if (sourceByUrl) {
|
|
return citationLinkMarkdown(sourceByUrl);
|
|
}
|
|
const sourceByTitle = byTitle.get(label);
|
|
if (sourceByTitle) {
|
|
return citationLinkMarkdown(sourceByTitle);
|
|
}
|
|
return match;
|
|
},
|
|
);
|
|
}
|
|
|
|
function citationMarkdown(
|
|
fallback: string,
|
|
index: number,
|
|
byIndex: Map<number, RuntimeDisplaySource>,
|
|
orderedSources: RuntimeDisplaySource[],
|
|
): string {
|
|
const source = byIndex.get(index) ?? orderedSources[index - 1];
|
|
if (!source) {
|
|
return fallback;
|
|
}
|
|
return citationLinkMarkdown(source);
|
|
}
|
|
|
|
function hasCitationMarkdown(content: string): boolean {
|
|
return /\[citation:(?:\[[^\]]+\]|\d+)\]\(#reference-[^)]+\)/.test(content);
|
|
}
|
|
|
|
function appendCitationSummary(
|
|
content: string,
|
|
citationSources: RuntimeDisplaySource[],
|
|
): string {
|
|
const citations = citationSources
|
|
.slice(0, 5)
|
|
.map((source) => citationLinkMarkdown(source))
|
|
.join(" ");
|
|
if (!citations) {
|
|
return content;
|
|
}
|
|
const trimmed = content.trimEnd();
|
|
return `${trimmed}${trimmed ? "\n\n" : ""}参考来源:${citations}`;
|
|
}
|
|
|
|
function citationLinkMarkdown(source: RuntimeDisplaySource): string {
|
|
return `[citation:${source.index}](#reference-${source.index})`;
|
|
}
|
|
|
|
function normalizeName(name: string): string {
|
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
}
|
|
|
|
function normalizeCitationKey(value: string): string {
|
|
return value
|
|
.toLowerCase()
|
|
.normalize("NFKC")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function stringifyMaybe(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim() ? value : undefined;
|
|
}
|
|
|
|
function findToolResultMessage(
|
|
toolCall: RuntimeToolCall,
|
|
messages: Message[],
|
|
toolCallMessageIndex = -1,
|
|
consumedToolMessageIds?: Set<string>,
|
|
consumedToolMessages?: Set<Message>,
|
|
): Message | undefined {
|
|
const byId = findToolMessageById(toolCall.id, messages);
|
|
if (
|
|
byId &&
|
|
!consumedToolMessages?.has(byId) &&
|
|
(!byId.id || !consumedToolMessageIds?.has(byId.id))
|
|
) {
|
|
return byId;
|
|
}
|
|
return findToolMessageByName(
|
|
toolCall,
|
|
messages,
|
|
toolCallMessageIndex,
|
|
consumedToolMessageIds,
|
|
consumedToolMessages,
|
|
);
|
|
}
|
|
|
|
function parseToolMessagePayload(toolMessage: Message): unknown {
|
|
const structured = extractStructuredToolPayload(toolMessage.content);
|
|
if (structured !== undefined) {
|
|
return structured;
|
|
}
|
|
const content = extractToolText(toolMessage.content);
|
|
if (!content) {
|
|
return undefined;
|
|
}
|
|
const parsed = parseJSONLikeText(content);
|
|
if (parsed !== undefined) {
|
|
return parsed;
|
|
}
|
|
return content;
|
|
}
|
|
|
|
function extractToolMessageName(message: Message): string {
|
|
return (
|
|
stringifyMaybe((message as { name?: unknown }).name) ??
|
|
stringifyMaybe(
|
|
(message as { additional_kwargs?: Record<string, unknown> }).additional_kwargs
|
|
?.name,
|
|
) ??
|
|
"tool"
|
|
);
|
|
}
|
|
|
|
function findToolMessageById(
|
|
toolCallId: string | undefined,
|
|
messages: Message[],
|
|
): Message | undefined {
|
|
if (!toolCallId) {
|
|
return undefined;
|
|
}
|
|
return messages.find((message) => {
|
|
if (message.type !== "tool") {
|
|
return false;
|
|
}
|
|
const candidates = [
|
|
(message as { tool_call_id?: unknown }).tool_call_id,
|
|
(message as { id?: unknown }).id,
|
|
(message as { additional_kwargs?: Record<string, unknown> }).additional_kwargs
|
|
?.tool_call_id,
|
|
(message as { additional_kwargs?: Record<string, unknown> }).additional_kwargs
|
|
?.id,
|
|
];
|
|
return candidates.some((candidate) => candidate === toolCallId);
|
|
});
|
|
}
|
|
|
|
function findToolMessageByName(
|
|
toolCall: RuntimeToolCall,
|
|
messages: Message[],
|
|
toolCallMessageIndex: number,
|
|
consumedToolMessageIds?: Set<string>,
|
|
consumedToolMessages?: Set<Message>,
|
|
): Message | undefined {
|
|
const start = Math.max(toolCallMessageIndex + 1, 0);
|
|
for (let index = start; index < messages.length; index++) {
|
|
const message = messages[index];
|
|
if (!message) {
|
|
continue;
|
|
}
|
|
if (index > start && (message.type === "human" || message.type === "ai")) {
|
|
break;
|
|
}
|
|
if (message.type !== "tool") {
|
|
continue;
|
|
}
|
|
if (message.id && consumedToolMessageIds?.has(message.id)) {
|
|
continue;
|
|
}
|
|
if (consumedToolMessages?.has(message)) {
|
|
continue;
|
|
}
|
|
const name =
|
|
stringifyMaybe((message as { name?: unknown }).name) ??
|
|
stringifyMaybe(
|
|
(message as { additional_kwargs?: Record<string, unknown> }).additional_kwargs
|
|
?.name,
|
|
);
|
|
const messageToolCallId = stringifyMaybe(
|
|
(message as { tool_call_id?: unknown }).tool_call_id,
|
|
);
|
|
if (messageToolCallId && toolCall.id && messageToolCallId !== toolCall.id) {
|
|
continue;
|
|
}
|
|
if (name && normalizeName(name) !== normalizeName(toolCall.name)) {
|
|
continue;
|
|
}
|
|
const payload = extractStructuredToolPayload(message.content);
|
|
if (payload !== undefined) {
|
|
return message;
|
|
}
|
|
const text = extractToolText(message.content);
|
|
if (text && parseJSONLikeText(text) !== undefined) {
|
|
return message;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function extractStructuredToolPayload(content: unknown): unknown {
|
|
if (isRecord(content)) {
|
|
return unwrapToolPayloadObject(content);
|
|
}
|
|
if (Array.isArray(content)) {
|
|
for (const part of content) {
|
|
if (!isRecord(part)) {
|
|
continue;
|
|
}
|
|
if (isRecord(part.json)) {
|
|
return unwrapToolPayloadObject(part.json);
|
|
}
|
|
const unwrapped = unwrapToolPayloadObject(part);
|
|
if (unwrapped !== undefined) {
|
|
return unwrapped;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function unwrapToolPayloadObject(value: Record<string, unknown>): unknown {
|
|
if (
|
|
Array.isArray(value.results) ||
|
|
Array.isArray(value.data) ||
|
|
Array.isArray(value.items)
|
|
) {
|
|
return value;
|
|
}
|
|
for (const key of ["result", "output", "stdout", "stderr", "content", "text"]) {
|
|
const nested = value[key];
|
|
if (typeof nested === "string") {
|
|
const parsed = parseJSONLikeText(nested);
|
|
if (parsed !== undefined) {
|
|
return parsed;
|
|
}
|
|
}
|
|
if (isRecord(nested)) {
|
|
const unwrapped = unwrapToolPayloadObject(nested);
|
|
if (unwrapped !== undefined) {
|
|
return unwrapped;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function parseJSONLikeText(text: string): unknown {
|
|
const trimmed = text.trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
return JSON.parse(trimmed) as unknown;
|
|
} catch {
|
|
return extractJSONFromText(trimmed) ?? extractSearchPayloadFromText(trimmed);
|
|
}
|
|
}
|
|
|
|
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 extractSearchPayloadFromText(text: string): unknown {
|
|
if (!text.includes('"results"') || !text.includes('"recUuid"')) {
|
|
return undefined;
|
|
}
|
|
const results: Record<string, unknown>[] = [];
|
|
const objectPattern = /\{\s*"index"\s*:\s*\d+[\s\S]*?\n\s*\}/g;
|
|
for (const match of text.matchAll(objectPattern)) {
|
|
try {
|
|
const item = JSON.parse(match[0]) as unknown;
|
|
if (isRecord(item) && item.recUuid) {
|
|
results.push(item);
|
|
}
|
|
} catch {
|
|
const item = extractSearchItemFromText(match[0]);
|
|
if (item) {
|
|
results.push(item);
|
|
}
|
|
}
|
|
}
|
|
if (results.length === 0) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
success: true,
|
|
query: extractStringField(text, "query") ?? "",
|
|
count: results.length,
|
|
source: extractStringField(text, "source") ?? "tool-output",
|
|
results,
|
|
};
|
|
}
|
|
|
|
function extractSearchItemFromText(text: string): Record<string, unknown> | null {
|
|
const recUuid = extractStringField(text, "recUuid");
|
|
if (!recUuid) {
|
|
return null;
|
|
}
|
|
const score = extractNumberField(text, "relevance_score");
|
|
return {
|
|
index: extractNumberField(text, "index") ?? undefined,
|
|
recUuid,
|
|
relevance_score: score,
|
|
title: extractStringField(text, "title") ?? recUuid,
|
|
source_category: extractStringField(text, "source_category") ?? "",
|
|
source: extractStringField(text, "source") ?? "",
|
|
publish_time: extractStringField(text, "publish_time") ?? "",
|
|
content_preview: extractStringField(text, "content_preview") ?? "",
|
|
content_length: extractNumberField(text, "content_length") ?? undefined,
|
|
};
|
|
}
|
|
|
|
function extractStringField(text: string, field: string): string | undefined {
|
|
const pattern = new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`);
|
|
const match = pattern.exec(text);
|
|
if (!match) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
return JSON.parse(`"${match[1]}"`) as string;
|
|
} catch {
|
|
return match[1];
|
|
}
|
|
}
|
|
|
|
function extractNumberField(text: string, field: string): number | undefined {
|
|
const pattern = new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`);
|
|
const match = pattern.exec(text);
|
|
if (!match) {
|
|
return undefined;
|
|
}
|
|
const value = Number(match[1]);
|
|
return Number.isFinite(value) ? value : undefined;
|
|
}
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
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 "";
|
|
}
|