import type { AssistantMessage, Message, ToolCall, ToolResultMessage } from "../types";
import type { DialectRenderOptions, DialectToolResult } from "./types";
export function renderToolResponseResults(results: readonly DialectToolResult[]): string {
return results.map(result => `\n${result.text}\n`).join("\n");
}
export function kimiCallId(name: string, id: string, index: number): string {
const trimmed = id.trim();
return trimmed.startsWith("functions.") ? trimmed : `functions.${name}:${index}`;
}
export function harmonyRecipient(name: string): string {
return name.startsWith("functions.") ? name : `functions.${name}`;
}
export function stringifyJson(value: unknown): string {
return JSON.stringify(value) ?? "null";
}
export function escapeXmlAttr(value: string): string {
return value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
}
export function escapeXmlText(value: string): string {
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
}
export type AssistantTranscriptParts = {
readonly text: string;
readonly thinking: string;
readonly toolCalls: readonly ToolCall[];
};
export type ToolCallRenderer = (calls: readonly ToolCall[], options?: DialectRenderOptions) => string;
export type ToolResultRenderer = (results: readonly DialectToolResult[], options?: DialectRenderOptions) => string;
export type ChatMlTranscriptConfig = {
readonly bos?: string;
readonly toolResultRole: "tool" | "user";
readonly renderThinking: (text: string) => string;
readonly renderCalls: ToolCallRenderer;
readonly renderResultsBody: ToolResultRenderer;
};
export type LegacyTextTranscriptConfig = {
readonly renderThinking: (text: string) => string;
readonly renderCalls: ToolCallRenderer;
readonly renderResults: ToolResultRenderer;
};
export function renderChatMlTranscript(
messages: readonly Message[],
options: DialectRenderOptions,
config: ChatMlTranscriptConfig,
): string {
if (messages.length === 0) return "";
let out = config.bos ?? "";
for (let i = 0; i < messages.length; ) {
const message = messages[i]!;
if (message.role === "assistant") {
const parts = assistantTranscriptParts(message);
out += chatMlTurn(
"assistant",
`${config.renderThinking(parts.thinking)}${parts.text}${config.renderCalls(parts.toolCalls, options)}`,
);
i++;
continue;
}
if (message.role === "toolResult") {
const run = collectToolResultRun(messages, i);
out += chatMlTurn(config.toolResultRole, config.renderResultsBody(run.results, options));
i = run.next;
continue;
}
const role = message.role === "developer" ? "system" : message.role;
out += chatMlTurn(role, messageContentText(message.content));
i++;
}
return out;
}
export function renderLegacyTextTranscript(
messages: readonly Message[],
options: DialectRenderOptions,
config: LegacyTextTranscriptConfig,
): string {
let out = "";
for (let i = 0; i < messages.length; ) {
const message = messages[i]!;
if (message.role === "assistant") {
const parts = assistantTranscriptParts(message);
out = appendLegacySegment(
out,
`Assistant: ${config.renderThinking(parts.thinking)}${parts.text}${config.renderCalls(parts.toolCalls, options)}`,
);
i++;
continue;
}
if (message.role === "toolResult") {
const run = collectToolResultRun(messages, i);
out = appendLegacySegment(out, `Human: ${config.renderResults(run.results, options)}`);
i = run.next;
continue;
}
const text = messageContentText(message.content);
out = message.role === "developer" ? appendLegacyPlain(out, text) : appendLegacySegment(out, `Human: ${text}`);
i++;
}
return out;
}
export function assistantTranscriptParts(message: AssistantMessage): AssistantTranscriptParts {
let text = "";
const thinking: string[] = [];
const toolCalls: ToolCall[] = [];
for (const block of message.content) {
if (block.type === "text") text += block.text;
else if (block.type === "thinking") thinking.push(block.thinking);
else if (block.type === "toolCall") toolCalls.push(block);
}
return { text, thinking: thinking.join("\n"), toolCalls };
}
export function collectToolResultRun(
messages: readonly Message[],
start: number,
): { readonly results: readonly DialectToolResult[]; readonly next: number } {
const results: DialectToolResult[] = [];
let next = start;
while (next < messages.length && messages[next]!.role === "toolResult") {
results.push(toolResultToDialectResult(messages[next] as ToolResultMessage, results.length));
next++;
}
return { results, next };
}
function toolResultToDialectResult(message: ToolResultMessage, index: number): DialectToolResult {
return {
id: message.toolCallId,
name: message.toolName,
index,
text: messageContentText(message.content),
isError: message.isError,
};
}
export function messageContentText(
content: string | readonly { readonly type: string; readonly text?: string; readonly mimeType?: string }[],
): string {
if (typeof content === "string") return content;
let text = "";
for (const block of content) {
if (block.type === "text" && block.text !== undefined) text += block.text;
else if (block.type === "image") text += block.mimeType ? `[Image: ${block.mimeType}]` : "[Image]";
}
return text;
}
function isAsciiWhitespace(code: number): boolean {
return code === 9 || code === 10 || code === 11 || code === 12 || code === 13 || code === 32;
}
function trimAsciiStart(text: string, start: number, end: number): number {
let cursor = start;
while (cursor < end && isAsciiWhitespace(text.charCodeAt(cursor))) cursor++;
return cursor;
}
function trimAsciiEnd(text: string, start: number, end: number): number {
let cursor = end;
while (cursor > start && isAsciiWhitespace(text.charCodeAt(cursor - 1))) cursor--;
return cursor;
}
function findDelimitedThinkingClose(open: string, close: string, text: string, start: number, end: number): number {
let depth = 1;
let cursor = start;
while (cursor < end) {
const nextClose = text.indexOf(close, cursor);
if (nextClose < 0 || nextClose >= end) return -1;
const nextOpen = text.indexOf(open, cursor);
if (nextOpen >= 0 && nextOpen < nextClose) {
depth++;
cursor = nextOpen + open.length;
continue;
}
depth--;
if (depth === 0) return nextClose;
cursor = nextClose + close.length;
}
return -1;
}
function unwrapDelimitedThinking(open: string, close: string, text: string): string {
const end = trimAsciiEnd(text, 0, text.length);
let cursor = trimAsciiStart(text, 0, end);
if (cursor >= end || !text.startsWith(open, cursor)) return text;
const segments: string[] = [];
while (cursor < end) {
if (!text.startsWith(open, cursor)) return text;
const innerStart = cursor + open.length;
const innerEnd = findDelimitedThinkingClose(open, close, text, innerStart, end);
if (innerEnd < 0) return text;
const trimmedInnerEnd = trimAsciiEnd(text, innerStart, innerEnd);
const trimmedInnerStart = trimAsciiStart(text, innerStart, trimmedInnerEnd);
segments.push(unwrapDelimitedThinking(open, close, text.slice(trimmedInnerStart, trimmedInnerEnd)));
cursor = trimAsciiStart(text, innerEnd + close.length, end);
}
return segments.join("\n");
}
export function renderDelimitedThinking(open: string, close: string, text: string): string {
if (!text) return "";
return `${open}\n${unwrapDelimitedThinking(open, close, text)}\n${close}`;
}
export function chatMlTurn(role: "assistant" | "system" | "tool" | "user", body: string): string {
return `<|im_start|>${role}\n${body}<|im_end|>\n`;
}
export function kimiTurn(role: "assistant" | "system" | "user", name: string, body: string): string {
return `<|im_${role}|>${name}<|im_middle|>${body}<|im_end|>`;
}
export function gemmaTurn(role: "model" | "system" | "user", body: string): string {
return `<|turn>${role}\n${body}`;
}
export function geminiTurn(role: "model" | "user", body: string): string {
return `${role}\n${body}\n`;
}
export function joinUserBodies(left: string, right: string): string {
if (!left) return right;
if (!right) return left;
return `${left}\n${right}`;
}
function appendLegacyPlain(out: string, text: string): string {
if (!text) return out;
return out ? `${out}\n\n${text}` : text;
}
function appendLegacySegment(out: string, segment: string): string {
return `${out}\n\n${segment}`;
}