#!/usr/bin/env node
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import readline from 'node:readline';
const FAVICON_SVG =
'';
const HTML_TEMPLATE = `
Qwen Code Chat Export
`;
function escapeJsonForHtml(json) {
return json
.replace(/&/g, '\\u0026')
.replace(//g, '\\u003e');
}
function injectDataIntoHtmlTemplate(template, data) {
const jsonData = JSON.stringify(data, null, 2);
const escapedJsonData = escapeJsonForHtml(jsonData);
return template.replace(
/`,
);
}
function toHtml(sessionData) {
return injectDataIntoHtmlTemplate(HTML_TEMPLATE, sessionData);
}
function printUsage(exitCode) {
const msg = `
Usage:
node scripts/export-html-from-chatrecord-jsonl.js [--out ]
node scripts/export-html-from-chatrecord-jsonl.js - [--out ]
Notes:
- Input JSONL is expected to be "one ChatRecord per line".
- For convenience, this also supports JSONL generated by the existing "toJsonl" formatter
(first line is { type: "session_metadata", ... } then one ExportMessage per line).
`;
console.error(msg.trimEnd());
process.exit(exitCode);
}
function parseArgs(argv) {
const out = {
input: null,
output: null,
};
const args = argv.slice(2);
if (args.length === 0) return out;
out.input = args[0] ?? null;
for (let i = 1; i < args.length; i += 1) {
const a = args[i];
if (a === '--out' || a === '-o') {
out.output = args[i + 1] ?? null;
i += 1;
continue;
}
if (a === '--help' || a === '-h') {
printUsage(0);
}
}
return out;
}
function safeJsonParse(line) {
try {
return JSON.parse(line);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
throw new Error(
`Invalid JSONL line: ${message}\nLine: ${line.slice(0, 200)}`,
);
}
}
async function readJsonlObjects(inputPath) {
const objects = [];
const inputStream =
inputPath === '-'
? process.stdin
: fs.createReadStream(inputPath, { encoding: 'utf8' });
const rl = readline.createInterface({
input: inputStream,
crlfDelay: Infinity,
});
for await (const rawLine of rl) {
const line = String(rawLine).trim();
if (!line) continue;
objects.push(safeJsonParse(line));
}
return objects;
}
function looksLikeChatRecord(obj) {
if (!obj || typeof obj !== 'object') return false;
const r = obj;
return (
typeof r.uuid === 'string' &&
'parentUuid' in r &&
typeof r.sessionId === 'string' &&
typeof r.timestamp === 'string' &&
typeof r.type === 'string' &&
typeof r.cwd === 'string' &&
typeof r.version === 'string'
);
}
function looksLikeExportJsonl(objects) {
if (!Array.isArray(objects) || objects.length === 0) return false;
const first = objects[0];
return (
!!first &&
typeof first === 'object' &&
first.type === 'session_metadata' &&
typeof first.sessionId === 'string' &&
typeof first.startTime === 'string'
);
}
function computeStartTimeFromRecords(records) {
let min = Number.POSITIVE_INFINITY;
for (const r of records) {
const t = Date.parse(r.timestamp);
if (Number.isFinite(t)) min = Math.min(min, t);
}
if (!Number.isFinite(min)) {
return new Date().toISOString();
}
return new Date(min).toISOString();
}
function extractToolNameFromRecord(record) {
const parts = record?.message?.parts;
if (!Array.isArray(parts)) return '';
for (const part of parts) {
if (part && typeof part === 'object' && 'functionResponse' in part) {
const fr = part.functionResponse;
if (fr && typeof fr === 'object' && typeof fr.name === 'string') {
return fr.name;
}
}
}
return '';
}
const TOOL_NAME_MIGRATION = {
search_file_content: 'grep_search',
replace: 'edit',
};
const TOOL_DISPLAY_NAME_BY_NAME = {
edit: 'Edit',
write_file: 'WriteFile',
read_file: 'ReadFile',
read_many_files: 'ReadManyFiles',
grep_search: 'Grep',
glob: 'Glob',
run_shell_command: 'Shell',
todo_write: 'TodoWrite',
save_memory: 'SaveMemory',
task: 'Task',
skill: 'Skill',
exit_plan_mode: 'ExitPlanMode',
web_fetch: 'WebFetch',
web_search: 'WebSearch',
list_directory: 'ListFiles',
};
const TOOL_KIND_BY_NAME = {
read_file: 'read',
read_many_files: 'read',
skill: 'read',
edit: 'edit',
write_file: 'edit',
write: 'edit',
delete: 'delete',
move: 'move',
rename: 'move',
grep_search: 'search',
glob: 'search',
web_search: 'search',
list_directory: 'search',
run_shell_command: 'execute',
bash: 'execute',
web_fetch: 'fetch',
todo_write: 'think',
save_memory: 'think',
plan: 'think',
exit_plan_mode: 'switch_mode',
task: 'other',
};
function normalizeToolName(toolName) {
if (!toolName) return '';
return TOOL_NAME_MIGRATION[toolName] ?? toolName;
}
function resolveToolKind(toolName) {
const normalizedName = normalizeToolName(toolName);
return TOOL_KIND_BY_NAME[normalizedName] ?? 'other';
}
function resolveToolTitle(toolName) {
const normalizedName = normalizeToolName(toolName);
return (
TOOL_DISPLAY_NAME_BY_NAME[normalizedName] ?? normalizedName ?? 'tool_call'
);
}
function normalizeRawInput(value) {
if (typeof value === 'string') return value;
if (typeof value === 'object' && value !== null) return value;
return undefined;
}
/**
* Extract locations from rawInput or toolCallResult for file-related tool calls.
* This ensures the exported data matches ACP format, enabling file links in UI.
*
* @param {object|undefined} rawInput - The raw input arguments of the tool call
* @param {object|undefined} toolCallResult - The tool call result object
* @returns {Array<{path: string, line?: number}>|undefined} - Locations array or undefined
*/
function extractLocations(rawInput, toolCallResult) {
const locations = [];
// Extract from rawInput - common path field names used by various tools
if (rawInput && typeof rawInput === 'object') {
// read_file, write_file, edit tool use file_path
if (typeof rawInput.file_path === 'string' && rawInput.file_path) {
locations.push({ path: rawInput.file_path });
}
// some tools use just 'path'
else if (typeof rawInput.path === 'string' && rawInput.path) {
locations.push({ path: rawInput.path });
}
// glob/grep tools use 'pattern' with optional 'path' as search root
else if (typeof rawInput.pattern === 'string' && rawInput.pattern) {
// For search tools, the pattern itself isn't a file path, skip
}
// run_shell_command might have 'command' but no file path
}
// Extract from toolCallResult.resultDisplay if available
if (toolCallResult && typeof toolCallResult === 'object') {
const display = toolCallResult.resultDisplay;
if (display && typeof display === 'object') {
if (typeof display.fileName === 'string' && display.fileName) {
// Avoid duplicates
if (!locations.some((loc) => loc.path === display.fileName)) {
locations.push({ path: display.fileName });
}
}
}
}
return locations.length > 0 ? locations : undefined;
}
function extractDiffContent(resultDisplay) {
if (!resultDisplay || typeof resultDisplay !== 'object') return null;
const display = resultDisplay;
if ('fileName' in display && 'newContent' in display) {
return [
{
type: 'diff',
path: display.fileName,
oldText: display.originalContent ?? '',
newText: display.newContent,
},
];
}
return null;
}
function transformPartsToToolCallContent(parts) {
const content = [];
for (const part of parts ?? []) {
if (part && typeof part === 'object' && 'text' in part && part.text) {
content.push({
type: 'content',
content: { type: 'text', text: part.text },
});
continue;
}
if (
part &&
typeof part === 'object' &&
'functionResponse' in part &&
part.functionResponse
) {
const fr = part.functionResponse;
const response =
fr.response && typeof fr.response === 'object' ? fr.response : {};
const outputField = response.output;
const errorField = response.error;
const responseText =
typeof outputField === 'string'
? outputField
: typeof errorField === 'string'
? errorField
: JSON.stringify(response);
content.push({
type: 'content',
content: { type: 'text', text: responseText },
});
}
}
return content;
}
function mergeToolCallData(existing, incoming) {
if (!existing.content || existing.content.length === 0) {
existing.content = incoming.content;
}
if (existing.status === 'pending' || existing.status === 'in_progress') {
existing.status = incoming.status;
}
if (!existing.rawInput && incoming.rawInput) {
existing.rawInput = incoming.rawInput;
}
if ((!existing.title || existing.title === '') && incoming.title) {
existing.title = incoming.title;
}
if ((!existing.kind || existing.kind === 'other') && incoming.kind) {
existing.kind = incoming.kind;
}
if (
(!existing.locations || existing.locations.length === 0) &&
incoming.locations?.length
) {
existing.locations = incoming.locations;
}
if (!existing.timestamp && incoming.timestamp) {
existing.timestamp = incoming.timestamp;
}
}
function convertChatRecordsToSessionData(records) {
if (!Array.isArray(records) || records.length === 0) {
return {
sessionId: 'unknown-session',
startTime: new Date().toISOString(),
messages: [],
};
}
const sessionId = records[0]?.sessionId ?? 'unknown-session';
const startTime = computeStartTimeFromRecords(records);
const messages = [];
const toolCallIndexById = new Map();
let currentMessage = null;
function flushCurrentMessage() {
if (!currentMessage) return;
messages.push({
uuid: currentMessage.uuid,
parentUuid: currentMessage.parentUuid,
sessionId: currentMessage.sessionId,
timestamp: currentMessage.timestamp,
type: currentMessage.type,
message: {
role: currentMessage.role,
parts: currentMessage.parts,
},
model: currentMessage.model,
});
currentMessage = null;
}
function handleMessageChunk(
record,
roleType,
content,
messageRole = roleType,
) {
if (!content || content.type !== 'text' || !content.text) return;
if (
currentMessage &&
(currentMessage.type !== roleType || currentMessage.role !== messageRole)
) {
flushCurrentMessage();
}
if (
currentMessage &&
currentMessage.type === roleType &&
currentMessage.role === messageRole
) {
currentMessage.parts.push({ text: content.text });
return;
}
currentMessage = {
uuid: record.uuid,
parentUuid: record.parentUuid,
sessionId: record.sessionId,
timestamp: record.timestamp,
type: roleType,
role: messageRole,
parts: [{ text: content.text }],
model: record.model,
};
}
function addOrMergeToolCallMessage(toolCallMessage) {
const id = toolCallMessage?.toolCall?.toolCallId;
if (!id) {
messages.push(toolCallMessage);
return;
}
const existingIndex = toolCallIndexById.get(id);
if (existingIndex === undefined) {
toolCallIndexById.set(id, messages.length);
messages.push(toolCallMessage);
return;
}
const existing = messages[existingIndex];
if (!existing || existing.type !== 'tool_call' || !existing.toolCall) {
return;
}
mergeToolCallData(existing.toolCall, toolCallMessage.toolCall);
}
for (const record of records) {
if (!record || typeof record !== 'object') continue;
switch (record.type) {
case 'user': {
for (const part of record.message?.parts ?? []) {
if (part && typeof part === 'object' && 'text' in part && part.text) {
handleMessageChunk(
record,
'user',
{ type: 'text', text: part.text },
'user',
);
}
}
break;
}
case 'assistant': {
for (const part of record.message?.parts ?? []) {
if (part && typeof part === 'object' && 'text' in part && part.text) {
const isThought = (part.thought ?? false) === true;
handleMessageChunk(
record,
'assistant',
{ type: 'text', text: part.text },
isThought ? 'thinking' : 'assistant',
);
continue;
}
if (
part &&
typeof part === 'object' &&
'functionCall' in part &&
part.functionCall
) {
flushCurrentMessage();
const fc = part.functionCall;
const toolName = normalizeToolName(
typeof fc.name === 'string' ? fc.name : '',
);
// Match ToolCallEmitter behavior: skip tool_call start event for todo_write.
if (toolName === 'todo_write') {
continue;
}
const toolCallId =
typeof fc.id === 'string' && fc.id
? fc.id
: `${toolName || 'tool'}-${record.uuid}`;
const rawInput = normalizeRawInput(fc.args);
const toolCallMessage = {
uuid: record.uuid,
parentUuid: record.parentUuid,
sessionId: record.sessionId,
timestamp: record.timestamp,
type: 'tool_call',
toolCall: {
toolCallId,
kind: resolveToolKind(toolName),
title: resolveToolTitle(toolName),
status: 'in_progress',
rawInput,
locations: extractLocations(rawInput, undefined),
timestamp: Date.parse(record.timestamp),
},
};
addOrMergeToolCallMessage(toolCallMessage);
}
}
break;
}
case 'tool_result': {
flushCurrentMessage();
const toolCallResult = record.toolCallResult ?? {};
const toolCallId = toolCallResult.callId ?? record.uuid;
const toolName = normalizeToolName(extractToolNameFromRecord(record));
const rawInput = normalizeRawInput(toolCallResult.args);
const content =
extractDiffContent(toolCallResult.resultDisplay) ??
transformPartsToToolCallContent(record.message?.parts ?? []);
const toolCallMessage = {
uuid: record.uuid,
parentUuid: record.parentUuid,
sessionId: record.sessionId,
timestamp: record.timestamp,
type: 'tool_call',
toolCall: {
toolCallId,
kind: resolveToolKind(toolName),
title: resolveToolTitle(toolName),
status: toolCallResult.error ? 'failed' : 'completed',
rawInput,
content,
locations: extractLocations(rawInput, toolCallResult),
timestamp: Date.parse(record.timestamp),
},
};
addOrMergeToolCallMessage(toolCallMessage);
break;
}
default: {
// Skip system records or unknown types.
break;
}
}
}
flushCurrentMessage();
return { sessionId, startTime, messages };
}
function buildSessionDataFromExportJsonl(objects) {
const first = objects[0];
const sessionId = first.sessionId;
const startTime = first.startTime;
const messages = objects.slice(1);
return { sessionId, startTime, messages };
}
function defaultOutPathForInput(inputPath) {
if (!inputPath || inputPath === '-')
return path.resolve(process.cwd(), 'export.html');
const base = path.basename(inputPath, path.extname(inputPath));
const dir = path.dirname(inputPath);
return path.resolve(dir, `${base}.html`);
}
async function main() {
const { input, output } = parseArgs(process.argv);
if (!input) {
printUsage(1);
}
const objects = await readJsonlObjects(input);
if (objects.length === 0) {
throw new Error('Input JSONL is empty.');
}
let sessionData;
if (looksLikeExportJsonl(objects)) {
sessionData = buildSessionDataFromExportJsonl(objects);
} else if (objects.every(looksLikeChatRecord)) {
sessionData = convertChatRecordsToSessionData(objects);
} else if (objects.some(looksLikeChatRecord)) {
// Mixed input: keep only ChatRecord-like entries for best-effort export.
const records = objects.filter(looksLikeChatRecord);
sessionData = convertChatRecordsToSessionData(records);
} else {
throw new Error(
'Unrecognized JSONL format (expected ChatRecord-per-line).',
);
}
const html = toHtml(sessionData);
const outPath = output ? path.resolve(output) : defaultOutPathForInput(input);
await fsp.mkdir(path.dirname(outPath), { recursive: true });
await fsp.writeFile(outPath, html, 'utf8');
console.log(`Wrote HTML export to: ${outPath}`);
}
main().catch((err) => {
const message = err instanceof Error ? err.message : String(err);
console.error(message);
process.exitCode = 1;
});