323 lines
9.0 KiB
TypeScript
323 lines
9.0 KiB
TypeScript
export interface AgentStepItem {
|
||
title: string;
|
||
content: string;
|
||
}
|
||
|
||
export interface AgentParsed {
|
||
steps: AgentStepItem[];
|
||
thinkingMarkdown: string;
|
||
answerMarkdown: string;
|
||
thinkingComplete: boolean;
|
||
}
|
||
|
||
const STEP_RE = /<step\s+title="([^"]+)">([\s\S]*?)<\/step>/g;
|
||
const ANSWER_RE = /<answer>([\s\S]*?)<\/answer>/;
|
||
const THINKING_COMMENT_RE = /<!--\s*thinking:\s*([\s\S]*?)\s*-->/i;
|
||
const WAITING_PREFIX = '仍在等待模型首个 token...';
|
||
|
||
const stripCodeFence = (raw: string): string => {
|
||
const trimmed = raw.trim();
|
||
const match = /```(?:json)?\s*([\s\S]*?)```/i.exec(trimmed);
|
||
if (match) return match[1].trim();
|
||
return trimmed.replace(/^```(?:json)?\s*/i, '').trim();
|
||
};
|
||
|
||
const stripThinkTags = (
|
||
text: string,
|
||
): { remainder: string; thinkBlock: string; closed: boolean } => {
|
||
const start = text.indexOf('<think>');
|
||
if (start === -1) return { remainder: text, thinkBlock: '', closed: false };
|
||
const after = text.slice(start + '<think>'.length);
|
||
const endIdx = after.indexOf('</think>');
|
||
if (endIdx === -1) {
|
||
return {
|
||
remainder: text.slice(0, start),
|
||
thinkBlock: after,
|
||
closed: false,
|
||
};
|
||
}
|
||
return {
|
||
remainder: text.slice(0, start) + after.slice(endIdx + '</think>'.length),
|
||
thinkBlock: after.slice(0, endIdx),
|
||
closed: true,
|
||
};
|
||
};
|
||
|
||
const mergeStep = (steps: AgentStepItem[], nextStep: AgentStepItem) => {
|
||
const existingIndex = steps.findIndex((step) => step.title === nextStep.title);
|
||
if (existingIndex === -1) {
|
||
steps.push(nextStep);
|
||
return;
|
||
}
|
||
|
||
const existing = steps[existingIndex];
|
||
const nextIsWaiting = nextStep.content.startsWith(WAITING_PREFIX);
|
||
const existingLines = existing.content
|
||
.split('\n')
|
||
.map((line) => line.trim())
|
||
.filter(Boolean);
|
||
const stableLines = existingLines.filter((line) => !line.startsWith(WAITING_PREFIX));
|
||
|
||
if (nextIsWaiting) {
|
||
const mergedLines = stableLines.length > 0 ? [...stableLines, nextStep.content] : [nextStep.content];
|
||
steps[existingIndex] = {
|
||
...existing,
|
||
content: mergedLines.join('\n'),
|
||
};
|
||
return;
|
||
}
|
||
|
||
const normalizedLines = stableLines.includes(nextStep.content)
|
||
? stableLines
|
||
: [...stableLines, nextStep.content];
|
||
steps[existingIndex] = {
|
||
...existing,
|
||
content: normalizedLines.join('\n'),
|
||
};
|
||
};
|
||
|
||
const extractSteps = (text: string): { steps: AgentStepItem[]; remainder: string } => {
|
||
const steps: AgentStepItem[] = [];
|
||
const remainder = text.replace(STEP_RE, (_, title: string, content: string) => {
|
||
mergeStep(steps, { title: title.trim(), content: content.trim() });
|
||
return '';
|
||
});
|
||
return { steps, remainder };
|
||
};
|
||
|
||
const extractTaggedSection = (
|
||
text: string,
|
||
re: RegExp,
|
||
): { value: string; remainder: string } => {
|
||
const match = re.exec(text);
|
||
if (!match) return { value: '', remainder: text };
|
||
return {
|
||
value: match[1].trim(),
|
||
remainder: text.replace(match[0], '').trim(),
|
||
};
|
||
};
|
||
|
||
const extractJsonStringField = (
|
||
text: string,
|
||
field: string,
|
||
): { value: string; complete: boolean; matched: boolean } => {
|
||
const re = new RegExp(`"${field}"\\s*:\\s*"`);
|
||
const match = re.exec(text);
|
||
if (!match) return { value: '', complete: false, matched: false };
|
||
let i = match.index + match[0].length;
|
||
let value = '';
|
||
while (i < text.length) {
|
||
const ch = text[i];
|
||
if (ch === '\\') {
|
||
if (i + 1 >= text.length) return { value, complete: false, matched: true };
|
||
const next = text[i + 1];
|
||
switch (next) {
|
||
case 'n':
|
||
value += '\n';
|
||
break;
|
||
case 't':
|
||
value += '\t';
|
||
break;
|
||
case 'r':
|
||
value += '\r';
|
||
break;
|
||
case '"':
|
||
value += '"';
|
||
break;
|
||
case '\\':
|
||
value += '\\';
|
||
break;
|
||
case '/':
|
||
value += '/';
|
||
break;
|
||
case 'b':
|
||
value += '\b';
|
||
break;
|
||
case 'f':
|
||
value += '\f';
|
||
break;
|
||
case 'u':
|
||
if (i + 5 < text.length) {
|
||
const hex = text.slice(i + 2, i + 6);
|
||
value += String.fromCharCode(parseInt(hex, 16));
|
||
i += 6;
|
||
continue;
|
||
}
|
||
return { value, complete: false, matched: true };
|
||
default:
|
||
value += next;
|
||
}
|
||
i += 2;
|
||
} else if (ch === '"') {
|
||
return { value, complete: true, matched: true };
|
||
} else {
|
||
value += ch;
|
||
i++;
|
||
}
|
||
}
|
||
return { value, complete: false, matched: true };
|
||
};
|
||
|
||
const extractItems = (text: string): unknown[] => {
|
||
const re = /"items"\s*:\s*\[/;
|
||
const match = re.exec(text);
|
||
if (!match) return [];
|
||
let i = match.index + match[0].length;
|
||
const out: unknown[] = [];
|
||
while (i < text.length) {
|
||
while (i < text.length && /[\s,]/.test(text[i])) i++;
|
||
if (i >= text.length) break;
|
||
if (text[i] === ']') break;
|
||
if (text[i] !== '{') {
|
||
i++;
|
||
continue;
|
||
}
|
||
let depth = 0;
|
||
let inStr = false;
|
||
let escape = false;
|
||
let j = i;
|
||
for (; j < text.length; j++) {
|
||
const c = text[j];
|
||
if (escape) {
|
||
escape = false;
|
||
continue;
|
||
}
|
||
if (inStr) {
|
||
if (c === '\\') escape = true;
|
||
else if (c === '"') inStr = false;
|
||
} else if (c === '"') inStr = true;
|
||
else if (c === '{') depth++;
|
||
else if (c === '}') {
|
||
depth--;
|
||
if (depth === 0) {
|
||
j++;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (depth !== 0) break;
|
||
try {
|
||
out.push(JSON.parse(text.slice(i, j)));
|
||
} catch {
|
||
// ignore partial item
|
||
}
|
||
i = j;
|
||
}
|
||
return out;
|
||
};
|
||
|
||
const itemToMarkdown = (item: unknown, idx: number): string => {
|
||
if (item == null) return `${idx + 1}.`;
|
||
if (typeof item !== 'object') return `${idx + 1}. ${String(item)}`;
|
||
const o = item as Record<string, unknown>;
|
||
const title =
|
||
(typeof o.title === 'string' && o.title) ||
|
||
(typeof o.expected_behavior === 'string' && o.expected_behavior) ||
|
||
(typeof o.name === 'string' && o.name) ||
|
||
`条目 ${idx + 1}`;
|
||
const body =
|
||
(typeof o.content === 'string' && o.content) ||
|
||
(typeof o.reason === 'string' && o.reason) ||
|
||
'';
|
||
|
||
const lines: string[] = [`**${idx + 1}. ${title}**`];
|
||
if (body) lines.push(body);
|
||
|
||
if (typeof o.expected_behavior === 'string' && typeof o.driving_factor === 'string') {
|
||
if (!o.title) {
|
||
lines.length = 0;
|
||
lines.push(`**${idx + 1}. ${o.expected_behavior}**`);
|
||
}
|
||
lines.push(`_驱动因素_:${o.driving_factor}`);
|
||
}
|
||
|
||
if (Array.isArray(o.references) && o.references.length > 0) {
|
||
const refs = (o.references as unknown[])
|
||
.filter((x) => typeof x === 'string' || typeof x === 'number')
|
||
.map((x) => `\`${String(x)}\``)
|
||
.join(' ');
|
||
if (refs) lines.push(`引用:${refs}`);
|
||
}
|
||
|
||
return lines.join('\n\n');
|
||
};
|
||
|
||
const itemsToMarkdown = (items: unknown[]): string =>
|
||
items.map((item, idx) => itemToMarkdown(item, idx)).join('\n\n---\n\n');
|
||
|
||
export const parseAgentResponse = (raw: string): AgentParsed => {
|
||
if (!raw) {
|
||
return {
|
||
steps: [],
|
||
thinkingMarkdown: '',
|
||
answerMarkdown: '',
|
||
thinkingComplete: false,
|
||
};
|
||
}
|
||
|
||
const stripped = stripThinkTags(raw);
|
||
const stepsResult = extractSteps(stripped.remainder);
|
||
let body = stripCodeFence(stepsResult.remainder);
|
||
const extraThinking = stripped.thinkBlock;
|
||
let thinkingComplete = stripped.closed;
|
||
|
||
const answerSection = extractTaggedSection(body, ANSWER_RE);
|
||
body = answerSection.remainder;
|
||
const thinkingCommentSection = extractTaggedSection(body, THINKING_COMMENT_RE);
|
||
body = thinkingCommentSection.remainder;
|
||
|
||
if (body) {
|
||
const start = body.indexOf('{');
|
||
if (start !== -1) body = body.slice(start);
|
||
}
|
||
|
||
if (body) {
|
||
try {
|
||
const obj = JSON.parse(body) as Record<string, unknown>;
|
||
if (obj && typeof obj === 'object') {
|
||
const thinking = typeof obj.thinking === 'string' ? obj.thinking : '';
|
||
const items = Array.isArray(obj.items) ? (obj.items as unknown[]) : [];
|
||
const answerMarkdown = items.length > 0 ? itemsToMarkdown(items) : answerSection.value;
|
||
return {
|
||
steps: stepsResult.steps,
|
||
thinkingMarkdown: [extraThinking, thinking, thinkingCommentSection.value]
|
||
.filter(Boolean)
|
||
.join('\n\n'),
|
||
answerMarkdown,
|
||
thinkingComplete: true,
|
||
};
|
||
}
|
||
} catch {
|
||
// fall through
|
||
}
|
||
}
|
||
|
||
const thinkingField = extractJsonStringField(body, 'thinking');
|
||
const items = extractItems(body);
|
||
|
||
if (thinkingField.complete) thinkingComplete = true;
|
||
|
||
let fallbackAnswer = answerSection.value || body.trim();
|
||
if (items.length > 0) {
|
||
fallbackAnswer = itemsToMarkdown(items);
|
||
}
|
||
|
||
if (!thinkingField.matched && items.length === 0 && !extraThinking) {
|
||
return {
|
||
steps: stepsResult.steps,
|
||
thinkingMarkdown: '',
|
||
answerMarkdown: fallbackAnswer,
|
||
thinkingComplete: false,
|
||
};
|
||
}
|
||
|
||
return {
|
||
steps: stepsResult.steps,
|
||
thinkingMarkdown: [extraThinking, thinkingField.value, thinkingCommentSection.value]
|
||
.filter(Boolean)
|
||
.join('\n\n'),
|
||
answerMarkdown: fallbackAnswer,
|
||
thinkingComplete,
|
||
};
|
||
};
|