// Phân tích đầu ra của MiniCPM5: khối … và lời gọi tool dạng XML: // // // // Bản gốc dùng DOMParser (xmldom). Worker không có DOMParser nên ở đây dùng một // parser XML nhỏ, chặt chẽ, chỉ hiểu đúng cú pháp trên. import type { AgentMessage, AssistantContent, TextContent, ToolCall, ToolDef } from '../shared/types'; // ───────────────────────── Thinking ───────────────────────── export interface ThinkingSplit { thinking: string; answer: string; complete: boolean; } /** * Tách phần suy nghĩ và phần trả lời. Với `thinkingPrefilled`, chat template đã * mở sẵn thẻ , nên đầu ra bắt đầu ngay trong phần suy nghĩ. */ export function splitThinking(text: string, opts: { thinkingPrefilled?: boolean } = {}): ThinkingSplit { const prefilled = opts.thinkingPrefilled ?? false; const open = /^\s*\n?/.exec(text); if (!prefilled && !open) return { thinking: '', answer: text, complete: true }; const start = open ? open[0].length : 0; const end = text.indexOf('', start); if (end < 0) return { thinking: text.slice(start), answer: '', complete: false }; return { thinking: text.slice(start, end), answer: text.slice(end + ''.length), complete: true }; } // ───────────────────────── Vị trí lời gọi tool ───────────────────────── /** Tìm `= fence.length) fence = null; } else { fence = marker; } i += line.length + 1; continue; } } if (fence) { i++; continue; } if (text[i] === '`') { let n = 0; while (text[i + n] === '`') n++; if (inlineTicks) { if (inlineTicks === n) inlineTicks = 0; } else { inlineTicks = n; } i += n; continue; } if (!inlineTicks && text.startsWith('` tương ứng, bỏ qua `` nằm trong CDATA. */ export function findFunctionEnd(text: string, from: number): number { let n = from; while (n < text.length) { const cdata = text.indexOf('', n); if (close < 0) throw new Error('Incomplete tool call. No command was executed. Try again with a shorter task.'); if (cdata < 0 || cdata > close) return close + ''.length; const cdataEnd = text.indexOf(']]>', cdata + 9); if (cdataEnd < 0) throw new Error('Incomplete CDATA in tool call. No command was executed.'); n = cdataEnd + 3; } throw new Error('Incomplete tool call. No command was executed. Try again with a shorter task.'); } // ───────────────────────── Parser XML tối giản ───────────────────────── const MALFORMED = 'Malformed model tool call.'; function decodeEntities(s: string): string { return s.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|lt|gt|amp|quot|apos);/g, (_m, ref: string) => { switch (ref) { case 'lt': return '<'; case 'gt': return '>'; case 'amp': return '&'; case 'quot': return '"'; case 'apos': return "'"; default: { const code = ref[1] === 'x' ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10); if (!Number.isFinite(code) || code > 0x10ffff) throw new Error(MALFORMED); return String.fromCodePoint(code); } } }); } interface OpenTag { attrs: Record; selfClosing: boolean; end: number; } /** Đọc thẻ mở bắt đầu tại `pos` (text[pos] === '<'), ví dụ ``. */ function readOpenTag(xml: string, pos: number, tag: string): OpenTag { if (!xml.startsWith('<' + tag, pos)) throw new Error(MALFORMED); let i = pos + 1 + tag.length; const attrs: Record = Object.create(null); for (;;) { while (/\s/.test(xml[i] ?? '')) i++; if (xml[i] === '>') return { attrs, selfClosing: false, end: i + 1 }; if (xml[i] === '/' && xml[i + 1] === '>') return { attrs, selfClosing: true, end: i + 2 }; const m = /^([A-Za-z_][\w.-]*)\s*=\s*("([^"<]*)"|'([^'<]*)')/.exec(xml.slice(i)); if (!m) throw new Error(MALFORMED); const name = m[1]; if (name in attrs) throw new Error(MALFORMED); attrs[name] = decodeEntities(m[3] ?? m[4] ?? ''); i += m[0].length; } } export interface ParsedCall { name: string; params: Array<{ name: string; text: string }>; } /** Phân tích đúng một phần tử `……`. */ export function parseFunctionXml(xml: string): ParsedCall { if (!xml.startsWith('', i)) { i += ''.length; if (i !== xml.length) throw new Error(MALFORMED); return { name, params }; } if (xml[i] !== '<') throw new Error('Unexpected content in tool call.'); if (!xml.startsWith('/]/.test(xml[i + 6] ?? '')) { // Có thể là phần tử lạ hoặc XML hỏng. if (/^<[A-Za-z]/.test(xml.slice(i))) throw new Error('Unexpected content in tool call.'); throw new Error(MALFORMED); } const open = readOpenTag(xml, i, 'param'); const pname = open.attrs.name; if (pname === undefined) throw new Error(MALFORMED); i = open.end; let text = ''; if (!open.selfClosing) { for (;;) { if (i >= xml.length) throw new Error(MALFORMED); if (xml.startsWith('', i)) { i += ''.length; break; } if (xml.startsWith('', i + 9); if (end < 0) throw new Error(MALFORMED); text += xml.slice(i + 9, end); i = end + 3; continue; } if (xml[i] === '<') throw new Error('Tool values containing XML must use CDATA.'); const next = xml.indexOf('<', i); if (next < 0) throw new Error(MALFORMED); text += decodeEntities(xml.slice(i, next)); i = next; } } params.push({ name: pname, text }); } } // ───────────────────────── Đầu ra mô hình → nội dung assistant ───────────────────────── /** * Chuyển đầu ra thô của mô hình thành danh sách nội dung: thinking, text và toolCall. * Ném lỗi (không thực thi tool nào) nếu lời gọi tool sai cú pháp. */ export function parseAssistantOutput( raw: string, tools: ToolDef[], opts: { thinkingPrefilled?: boolean } = {}, ): AssistantContent[] { const text = raw.replace(/<\|im_end\|>$|<\/s>$/, ''); const split = splitThinking(text, opts); if (!split.complete) { throw new Error('The model used its response budget before finishing its thoughts. Try a smaller task.'); } const answer = split.answer; const out: AssistantContent[] = []; if (split.thinking.trim()) out.push({ type: 'thinking', thinking: split.thinking.trim() }); let pos = 0; while (pos < answer.length) { const start = findFunctionStart(answer, pos); if (start < 0) { const rest = answer.slice(pos).trim(); if (rest) out.push({ type: 'text', text: rest }); break; } const before = answer.slice(pos, start).trim(); if (before) out.push({ type: 'text', text: before }); const end = findFunctionEnd(answer, start); const parsed = parseFunctionXml(answer.slice(start, end)); const tool = tools.find((t) => t.name === parsed.name); if (!tool) throw new Error('Unknown model tool: ' + parsed.name); const args: Record = Object.create(null); for (const p of parsed.params) { if (Object.hasOwn(args, p.name) || !Object.hasOwn(tool.parameters.properties, p.name)) { throw new Error('Unknown or duplicate tool parameter: ' + p.name); } args[p.name] = tool.parameters.properties[p.name].type === 'string' ? p.text : JSON.parse(p.text); } const call: ToolCall = { type: 'toolCall', id: crypto.randomUUID(), name: parsed.name, arguments: args }; out.push(call); pos = end; } return out; } // ───────────────────────── Lịch sử chat → chat template ───────────────────────── const textOf = (content: string | TextContent[] | undefined): string => typeof content === 'string' ? content : (content ?? []).filter((c) => c.type === 'text').map((c) => c.text).join('\n'); export interface TemplateMessage { role: 'system' | 'user' | 'assistant' | 'tool'; content: string; reasoning_content?: string; tool_calls?: Array<{ type: 'function'; function: { name: string; arguments: Record } }>; } /** Bỏ các lượt assistant lỗi/bị dừng rồi dựng danh sách message cho chat template. */ export function toTemplateMessages(systemPrompt: string, messages: AgentMessage[]): TemplateMessage[] { const kept = messages.filter((m) => m.role !== 'assistant' || !['error', 'aborted'].includes(m.stopReason ?? '')); return [ { role: 'system', content: systemPrompt }, ...kept.map((m): TemplateMessage => { if (m.role === 'toolResult') { return { role: 'tool', content: JSON.stringify({ name: m.toolName, isError: m.isError, output: textOf(m.content) }), }; } if (m.role === 'assistant') { return { role: 'assistant', reasoning_content: m.content .filter((c): c is Extract => c.type === 'thinking') .map((c) => c.thinking) .join('\n'), content: m.content.map((c) => (c.type === 'text' ? c.text : c.type === 'toolCall' ? '' : '')).join(''), tool_calls: m.content .filter((c): c is ToolCall => c.type === 'toolCall') .map((c) => ({ type: 'function', function: { name: c.name, arguments: c.arguments } })), }; } return { role: 'user', content: m.content }; }), ]; }