1255 lines
35 KiB
TypeScript
1255 lines
35 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 = /\\?\[\s*([0-9\uFF10-\uFF19]{1,3})\s*\\?\](?!\()/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(normalizeAsciiDigits(match[1] ?? "")),
|
||
byIndex,
|
||
orderedSources,
|
||
);
|
||
cursor = start + match[0].length;
|
||
}
|
||
next += content.slice(cursor);
|
||
return next;
|
||
}
|
||
|
||
function normalizeAsciiDigits(value: string): string {
|
||
return value.replace(/[\uFF10-\uFF19]/g, (digit) =>
|
||
String.fromCharCode(digit.charCodeAt(0) - 0xfee0),
|
||
);
|
||
}
|
||
|
||
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("");
|
||
};
|
||
|
||
// 全角数字转半角:123 → 123(仅用于括号内的编号识别)
|
||
const toAsciiDigits = (value: string) =>
|
||
value.replace(/[0-9]/g, (digit) =>
|
||
String.fromCharCode(digit.charCodeAt(0) - 0xfee0),
|
||
);
|
||
|
||
// 全角括号写法(含全角数字):
|
||
// 【1】/【1】 → [1]
|
||
// 【1,2】/【1,2】/【1、2】 → [1][2]
|
||
// 【1-3】/【2~5】 → [1][2][3]
|
||
// 三种形态互斥(单编号内不含分隔符),且都要求编号能对应到参考来源,
|
||
// 避免把正文里的【…】误判成引用。转成半角后交给下面的半角逻辑统一处理。
|
||
|
||
// 全角单编号:【1】/【1】
|
||
let next = content
|
||
.replace(/[/g, "[")
|
||
.replace(/]/g, "]")
|
||
.replace(/[,、]/g, ",")
|
||
.replace(
|
||
/【\s*([0-90-9]{1,3})\s*】/g,
|
||
(match, rawIndex: string) => {
|
||
const index = Number(normalizeAsciiDigits(rawIndex));
|
||
return isValidIndex(index) ? `[${index}]` : match;
|
||
},
|
||
);
|
||
|
||
// 全角合并编号:【1,2】/【1,2】/【1、2】
|
||
next = next.replace(
|
||
/【\s*([0-90-9]{1,3}(?:\s*[,,、]\s*[0-90-9]{1,3})+)\s*】/g,
|
||
(match, rawList: string) => {
|
||
const indexes = rawList
|
||
.split(/[,,、]/)
|
||
.map((part) => Number(normalizeAsciiDigits(part.trim())));
|
||
if (indexes.some((index) => !isValidIndex(index))) {
|
||
return match;
|
||
}
|
||
return indexes.map((index) => `[${index}]`).join("");
|
||
},
|
||
);
|
||
|
||
// 全角区间编号:【1-3】/【2~5】
|
||
next = next.replace(
|
||
/【\s*([0-90-9]{1,3})\s*[-–~~]\s*([0-90-9]{1,3})\s*】/g,
|
||
(match, rawStart: string, rawEnd: string) => {
|
||
const start = Number(normalizeAsciiDigits(rawStart));
|
||
const end = Number(normalizeAsciiDigits(rawEnd));
|
||
if (
|
||
end <= start ||
|
||
end - start > 20 ||
|
||
!isValidIndex(start) ||
|
||
!isValidIndex(end)
|
||
) {
|
||
return match;
|
||
}
|
||
const tokens: string[] = [];
|
||
for (let index = start; index <= end; index++) {
|
||
tokens.push(`[${index}]`);
|
||
}
|
||
return tokens.join("");
|
||
},
|
||
);
|
||
|
||
// 合并写法:[1,2,3] / [1,2] / [1、2] → [1][2][3]
|
||
next = next.replace(
|
||
/\\?\[(\d{1,3}(?:\s*[,,、]\s*\d{1,3})+)\\?\](?!\()/g,
|
||
(match, rawList: string) => {
|
||
const indexes = rawList.split(/[,,、]/).map((part) => Number(part.trim()));
|
||
if (indexes.some((index) => !isValidIndex(index))) {
|
||
return match;
|
||
}
|
||
return indexes.map((index) => `[${index}]`).join("");
|
||
},
|
||
);
|
||
|
||
// 区间写法:[1-3] / [2~5] → [1][2][3]
|
||
next = next.replace(
|
||
/\\?\[(\d{1,3})\s*[-–~~]\s*(\d{1,3})\\?\](?!\()/g,
|
||
(match, rawStart: string, rawEnd: string) => {
|
||
const start = Number(rawStart);
|
||
const end = Number(rawEnd);
|
||
if (
|
||
end <= start ||
|
||
end - start > 20 ||
|
||
!isValidIndex(start) ||
|
||
!isValidIndex(end)
|
||
) {
|
||
return match;
|
||
}
|
||
const tokens: string[] = [];
|
||
for (let index = start; index <= end; index++) {
|
||
tokens.push(`[${index}]`);
|
||
}
|
||
return tokens.join("");
|
||
},
|
||
);
|
||
|
||
next = next.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 {
|
||
const directCandidate = findDisplayPayloadCandidate(value, 0);
|
||
if (directCandidate !== undefined) {
|
||
return directCandidate;
|
||
}
|
||
for (const nested of Object.values(value)) {
|
||
if (typeof nested === "string") {
|
||
const parsed = parseJSONLikeText(nested);
|
||
if (parsed !== undefined) {
|
||
if (isRecord(parsed)) {
|
||
const structured = findDisplayPayloadCandidate(parsed, 0);
|
||
if (structured !== undefined) {
|
||
return structured;
|
||
}
|
||
}
|
||
return parsed;
|
||
}
|
||
continue;
|
||
}
|
||
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;
|
||
}
|
||
const fenced = extractFencedJSON(trimmed);
|
||
if (fenced) {
|
||
try {
|
||
return JSON.parse(fenced) as unknown;
|
||
} catch {
|
||
// Fall through to best-effort parsing.
|
||
}
|
||
}
|
||
try {
|
||
return JSON.parse(trimmed) as unknown;
|
||
} catch {
|
||
return extractJSONFromText(trimmed) ?? extractSearchPayloadFromText(trimmed);
|
||
}
|
||
}
|
||
|
||
function extractJSONFromText(text: string): unknown {
|
||
for (const candidate of iterateBalancedJsonCandidates(text)) {
|
||
try {
|
||
return JSON.parse(candidate);
|
||
} catch {
|
||
// try the next candidate
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function *iterateBalancedJsonCandidates(text: string): Generator<string> {
|
||
for (let index = 0; index < text.length; index++) {
|
||
const char = text[index];
|
||
if (char !== "{" && char !== "[") {
|
||
continue;
|
||
}
|
||
const candidate = extractBalancedJson(text, index);
|
||
if (candidate) {
|
||
yield candidate;
|
||
}
|
||
}
|
||
}
|
||
|
||
function extractBalancedJson(text: string, start: number): string | null {
|
||
const opening = text[start];
|
||
if (opening !== "{" && opening !== "[") {
|
||
return null;
|
||
}
|
||
const closing = opening === "{" ? "}" : "]";
|
||
let depth = 0;
|
||
let inString = false;
|
||
let escaped = false;
|
||
for (let index = start; index < text.length; index++) {
|
||
const char = text[index];
|
||
if (inString) {
|
||
if (escaped) {
|
||
escaped = false;
|
||
} else if (char === "\\") {
|
||
escaped = true;
|
||
} else if (char === "\"") {
|
||
inString = false;
|
||
}
|
||
continue;
|
||
}
|
||
if (char === "\"") {
|
||
inString = true;
|
||
continue;
|
||
}
|
||
if (char === opening) {
|
||
depth += 1;
|
||
continue;
|
||
}
|
||
if (char === closing) {
|
||
depth -= 1;
|
||
if (depth === 0) {
|
||
return text.slice(start, index + 1);
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function extractFencedJSON(text: string): string | null {
|
||
const match = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
|
||
return match?.[1]?.trim() ?? null;
|
||
}
|
||
|
||
function findDisplayPayloadCandidate(value: unknown, depth: number): unknown {
|
||
if (depth > 8) {
|
||
return undefined;
|
||
}
|
||
if (Array.isArray(value)) {
|
||
const objectItems = value.filter(
|
||
(item): item is Record<string, unknown> =>
|
||
isRecord(item),
|
||
);
|
||
if (value.length === 0 || objectItems.length === value.length) {
|
||
return { results: objectItems };
|
||
}
|
||
for (const item of value) {
|
||
const nested = findDisplayPayloadCandidate(item, depth + 1);
|
||
if (nested !== undefined) {
|
||
return nested;
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
if (!isRecord(value)) {
|
||
return undefined;
|
||
}
|
||
for (const key of ["results", "data", "items", "docs", "records", "rows", "list", "hits", "sources"]) {
|
||
const nested = value[key];
|
||
if (!Array.isArray(nested)) {
|
||
continue;
|
||
}
|
||
const objectItems = nested.filter(
|
||
(item): item is Record<string, unknown> =>
|
||
isRecord(item),
|
||
);
|
||
if (nested.length === 0 || objectItems.length === nested.length) {
|
||
return { ...value, [key]: objectItems };
|
||
}
|
||
}
|
||
for (const nested of Object.values(value)) {
|
||
const candidate = findDisplayPayloadCandidate(nested, depth + 1);
|
||
if (candidate !== undefined) {
|
||
return 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 "";
|
||
}
|