Merge origin/main into refactor/read-many-files-util

Resolved conflicts by:
- index.ts: Adopted main's organized structure, added readManyFiles.js export
- atCommandProcessor.ts: Kept refactored readManyFiles utility approach
- atCommandProcessor.test.ts: Kept tests for refactored approach
This commit is contained in:
tanzhenxin 2026-02-05 19:27:29 +08:00
commit 42da41381a
350 changed files with 20541 additions and 5735 deletions

View file

@ -0,0 +1,266 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import type { Config, ChatRecord } from '@qwen-code/qwen-code-core';
import type { SessionContext } from '../../../acp-integration/session/types.js';
import type * as acp from '../../../acp-integration/acp.js';
import { HistoryReplayer } from '../../../acp-integration/session/HistoryReplayer.js';
import type { ExportMessage, ExportSessionData } from './types.js';
/**
* Export session context that captures session updates into export messages.
* Implements SessionContext to work with HistoryReplayer.
*/
class ExportSessionContext implements SessionContext {
readonly sessionId: string;
readonly config: Config;
private messages: ExportMessage[] = [];
private currentMessage: {
type: 'user' | 'assistant';
role: 'user' | 'assistant' | 'thinking';
parts: Array<{ text: string }>;
timestamp: number;
} | null = null;
private activeRecordId: string | null = null;
private activeRecordTimestamp: string | null = null;
private toolCallMap: Map<string, ExportMessage['toolCall']> = new Map();
constructor(sessionId: string, config: Config) {
this.sessionId = sessionId;
this.config = config;
}
async sendUpdate(update: acp.SessionUpdate): Promise<void> {
switch (update.sessionUpdate) {
case 'user_message_chunk':
this.handleMessageChunk('user', update.content);
break;
case 'agent_message_chunk':
this.handleMessageChunk('assistant', update.content);
break;
case 'agent_thought_chunk':
this.handleMessageChunk('assistant', update.content, 'thinking');
break;
case 'tool_call':
this.flushCurrentMessage();
this.handleToolCallStart(update);
break;
case 'tool_call_update':
this.handleToolCallUpdate(update);
break;
case 'plan':
this.flushCurrentMessage();
this.handlePlanUpdate(update);
break;
default:
// Ignore other update types
break;
}
}
setActiveRecordId(recordId: string | null, timestamp?: string): void {
this.activeRecordId = recordId;
this.activeRecordTimestamp = timestamp ?? null;
}
private getMessageTimestamp(): string {
return this.activeRecordTimestamp ?? new Date().toISOString();
}
private getMessageUuid(): string {
return this.activeRecordId ?? randomUUID();
}
private handleMessageChunk(
role: 'user' | 'assistant',
content: { type: string; text?: string },
messageRole: 'user' | 'assistant' | 'thinking' = role,
): void {
if (content.type !== 'text' || !content.text) return;
// If we're starting a new message type, flush the previous one
if (
this.currentMessage &&
(this.currentMessage.type !== role ||
this.currentMessage.role !== messageRole)
) {
this.flushCurrentMessage();
}
// Add to current message or create new one
if (
this.currentMessage &&
this.currentMessage.type === role &&
this.currentMessage.role === messageRole
) {
this.currentMessage.parts.push({ text: content.text });
} else {
this.currentMessage = {
type: role,
role: messageRole,
parts: [{ text: content.text }],
timestamp: Date.now(),
};
}
}
private handleToolCallStart(update: acp.ToolCall): void {
const toolCall: ExportMessage['toolCall'] = {
toolCallId: update.toolCallId,
kind: update.kind || 'other',
title:
typeof update.title === 'string' ? update.title : update.title || '',
status: update.status || 'pending',
rawInput: update.rawInput as string | object | undefined,
locations: update.locations,
timestamp: Date.now(),
};
this.toolCallMap.set(update.toolCallId, toolCall);
// Immediately add tool call to messages to preserve order
const uuid = this.getMessageUuid();
this.messages.push({
uuid,
sessionId: this.sessionId,
timestamp: this.getMessageTimestamp(),
type: 'tool_call',
toolCall,
});
}
private handleToolCallUpdate(update: {
toolCallId: string;
status?: 'pending' | 'in_progress' | 'completed' | 'failed' | null;
title?: string | null;
content?: Array<{ type: string; [key: string]: unknown }> | null;
kind?: string | null;
}): void {
const toolCall = this.toolCallMap.get(update.toolCallId);
if (toolCall) {
// Update the tool call in place
if (update.status) toolCall.status = update.status;
if (update.content) toolCall.content = update.content;
if (update.title)
toolCall.title = typeof update.title === 'string' ? update.title : '';
}
}
private handlePlanUpdate(update: {
entries: Array<{
content: string;
status: 'pending' | 'in_progress' | 'completed';
priority?: string;
}>;
}): void {
// Create a tool_call message for plan updates (TodoWriteTool)
// This ensures todos appear at the correct position in the chat
const uuid = this.getMessageUuid();
const timestamp = this.getMessageTimestamp();
// Format entries as markdown checklist text for UpdatedPlanToolCall.parsePlanEntries
const todoText = update.entries
.map((entry) => {
const checkbox =
entry.status === 'completed'
? '[x]'
: entry.status === 'in_progress'
? '[-]'
: '[ ]';
return `- ${checkbox} ${entry.content}`;
})
.join('\n');
const todoContent = [
{
type: 'content' as const,
content: {
type: 'text',
text: todoText,
},
},
];
this.messages.push({
uuid,
sessionId: this.sessionId,
timestamp,
type: 'tool_call',
toolCall: {
toolCallId: uuid, // Use the same uuid as toolCallId for plan updates
kind: 'todowrite',
title: 'TodoWrite',
status: 'completed',
content: todoContent,
timestamp: Date.parse(timestamp),
},
});
}
private flushCurrentMessage(): void {
if (!this.currentMessage) return;
const uuid = this.getMessageUuid();
this.messages.push({
uuid,
sessionId: this.sessionId,
timestamp: this.getMessageTimestamp(),
type: this.currentMessage.type,
message: {
role: this.currentMessage.role,
parts: this.currentMessage.parts,
},
});
this.currentMessage = null;
}
flushMessages(): void {
this.flushCurrentMessage();
}
getMessages(): ExportMessage[] {
return this.messages;
}
}
/**
* Collects session data from ChatRecord[] using HistoryReplayer.
* Returns the raw ExportSessionData (SSOT) without normalization.
*/
export async function collectSessionData(
conversation: {
sessionId: string;
startTime: string;
messages: ChatRecord[];
},
config: Config,
): Promise<ExportSessionData> {
// Create export session context
const exportContext = new ExportSessionContext(
conversation.sessionId,
config,
);
// Create history replayer with export context
const replayer = new HistoryReplayer(exportContext);
// Replay chat records to build export messages
await replayer.replay(conversation.messages);
// Flush any buffered messages
exportContext.flushMessages();
// Get the export messages
const messages = exportContext.getMessages();
return {
sessionId: conversation.sessionId,
startTime: conversation.startTime,
messages,
};
}

View file

@ -0,0 +1,83 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { ExportSessionData } from '../types.js';
import { HTML_TEMPLATE } from './htmlTemplate.js';
/**
* Escapes JSON for safe embedding in HTML.
*/
function escapeJsonForHtml(json: string): string {
return json
.replace(/<\/script/gi, '<\\/script')
.replace(/&/g, '\\u0026')
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
/**
* Loads the HTML template built from assets.
*/
export function loadHtmlTemplate(): string {
return HTML_TEMPLATE;
}
/**
* Injects JSON data into the HTML template.
*/
export function injectDataIntoHtmlTemplate(
template: string,
data: {
sessionId: string;
startTime: string;
messages: unknown[];
},
): string {
const jsonData = JSON.stringify(data, null, 2);
const escapedJsonData = escapeJsonForHtml(jsonData);
const idAttribute = 'id="chat-data"';
const idIndex = template.indexOf(idAttribute);
if (idIndex === -1) {
return template;
}
const openTagStart = template.lastIndexOf('<script', idIndex);
if (openTagStart === -1) {
return template;
}
const openTagEnd = template.indexOf('>', idIndex);
if (openTagEnd === -1) {
return template;
}
const closeTagStart = template.indexOf('</script>', openTagEnd);
if (closeTagStart === -1) {
return template;
}
const lineStart = template.lastIndexOf('\n', openTagStart);
const lineIndent =
lineStart === -1 ? '' : template.slice(lineStart + 1, openTagStart);
const indentedJson = escapedJsonData
.split('\n')
.map((line) => `${lineIndent}${line}`)
.join('\n');
const before = template.slice(0, openTagEnd + 1);
const after = template.slice(closeTagStart);
return `${before}\n${indentedJson}\n${after}`;
}
/**
* Converts ExportSessionData to HTML format.
*/
export function toHtml(sessionData: ExportSessionData): string {
const template = loadHtmlTemplate();
return injectDataIntoHtmlTemplate(template, sessionData);
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,15 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { ExportSessionData } from '../types.js';
/**
* Converts ExportSessionData to JSON format.
* Outputs a single JSON object containing the entire session.
*/
export function toJson(sessionData: ExportSessionData): string {
return JSON.stringify(sessionData, null, 2);
}

View file

@ -0,0 +1,31 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { ExportSessionData } from '../types.js';
/**
* Converts ExportSessionData to JSONL (JSON Lines) format.
* Each message is output as a separate JSON object on its own line.
*/
export function toJsonl(sessionData: ExportSessionData): string {
const lines: string[] = [];
// Add session metadata as the first line
lines.push(
JSON.stringify({
type: 'session_metadata',
sessionId: sessionData.sessionId,
startTime: sessionData.startTime,
}),
);
// Add each message as a separate line
for (const message of sessionData.messages) {
lines.push(JSON.stringify(message));
}
return lines.join('\n');
}

View file

@ -0,0 +1,225 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { ExportSessionData, ExportMessage } from '../types.js';
/**
* Converts ExportSessionData to markdown format.
*/
export function toMarkdown(sessionData: ExportSessionData): string {
const lines: string[] = [];
// Add header with metadata
lines.push('# Chat Session Export\n');
lines.push(`- **Session ID**: \`${sanitizeText(sessionData.sessionId)}\``);
lines.push(`- **Start Time**: ${sanitizeText(sessionData.startTime)}`);
lines.push(`- **Exported**: ${new Date().toISOString()}`);
lines.push('\n---\n');
// Process each message
for (const message of sessionData.messages) {
if (message.type === 'user') {
lines.push('## User\n');
lines.push(formatMessageContent(message));
} else if (message.type === 'assistant') {
lines.push('## Assistant\n');
lines.push(formatMessageContent(message));
} else if (message.type === 'tool_call') {
lines.push(formatToolCall(message));
} else if (message.type === 'system') {
lines.push('### System\n');
// Format as blockquote
const text = formatMessageContent(message);
lines.push(`> ${text.replace(/\n/g, '\n> ')}`);
}
lines.push('\n');
}
return lines.join('\n');
}
function formatMessageContent(message: ExportMessage): string {
const text = extractTextFromMessage(message);
// Special handling for "Content from referenced files"
// We look for the pattern: --- Content from referenced files --- ... --- End of content ---
// and wrap the inner content in code blocks if possible.
// Note: This simple regex replacement might be fragile if nested, but usually this marker is top-level.
// We'll use a replacer function to handle the wrapping.
const processedText = text.replace(
/--- Content from referenced files ---\n([\s\S]*?)\n--- End of content ---/g,
(match, content) =>
`\n> **Referenced Files:**\n\n${createCodeBlock(content)}\n`,
);
return processedText;
}
function formatToolCall(message: ExportMessage): string {
if (!message.toolCall) return '';
const lines: string[] = [];
const { title, status, rawInput, content, locations } = message.toolCall;
const titleStr = typeof title === 'string' ? title : JSON.stringify(title);
lines.push(`### Tool: ${sanitizeText(titleStr)}`);
lines.push(`**Status**: ${sanitizeText(status)}\n`);
// Input
if (rawInput) {
lines.push('**Input:**');
const inputStr =
typeof rawInput === 'string'
? rawInput
: JSON.stringify(rawInput, null, 2);
lines.push(createCodeBlock(inputStr, 'json'));
lines.push('');
}
// Locations
if (locations && locations.length > 0) {
lines.push('**Affected Files:**');
for (const loc of locations) {
const lineSuffix = loc.line ? `:${loc.line}` : '';
lines.push(`- \`${sanitizeText(loc.path)}${lineSuffix}\``);
}
lines.push('');
}
// Output Content
if (content && content.length > 0) {
lines.push('**Output:**');
for (const item of content) {
if (item.type === 'content' && item['content']) {
const contentData = item['content'] as { type: string; text?: string };
if (contentData.type === 'text' && contentData.text) {
// Try to infer language from locations if available and if there is only one location
// or if the tool title suggests a file operation.
let language = '';
if (locations && locations.length === 1 && locations[0].path) {
language = getLanguageFromPath(locations[0].path);
}
lines.push(createCodeBlock(contentData.text, language));
}
} else if (item.type === 'diff') {
const path = item['path'] as string;
const diffText = item['newText'] as string;
lines.push(`\n*Diff for \`${sanitizeText(path)}\`:*`);
lines.push(createCodeBlock(diffText, 'diff'));
}
}
}
return lines.join('\n');
}
/**
* Extracts text content from an export message.
*/
function extractTextFromMessage(message: ExportMessage): string {
if (!message.message?.parts) return '';
const textParts: string[] = [];
for (const part of message.message.parts) {
if ('text' in part) {
textParts.push(part.text);
}
}
return textParts.join('\n');
}
/**
* Creates a markdown code block with dynamic fence length to avoid escaping issues.
* Does NOT escape HTML content inside the block, as that would break code readability.
* Security is handled by the fence.
*/
function createCodeBlock(content: string, language: string = ''): string {
const fence = buildFence(content);
return `${fence}${language}\n${content}\n${fence}`;
}
/**
* Sanitizes text to prevent HTML injection while preserving Markdown.
* Only escapes < and & to avoid breaking Markdown structures like code blocks (if used inline) or quotes.
*/
function sanitizeText(value: string): string {
return (value ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;');
}
/**
* Calculates the necessary fence length for a code block.
* Ensures the fence is longer than any sequence of backticks in the content.
*/
function buildFence(value: string): string {
const matches = (value ?? '').match(/`+/g);
const maxRun = matches
? Math.max(...matches.map((match) => match.length))
: 0;
const fenceLength = Math.max(3, maxRun + 1);
return '`'.repeat(fenceLength);
}
/**
* Simple helper to guess language from file extension.
*/
function getLanguageFromPath(path: string): string {
const ext = path.split('.').pop()?.toLowerCase();
switch (ext) {
case 'ts':
case 'tsx':
return 'typescript';
case 'js':
case 'jsx':
case 'mjs':
case 'cjs':
return 'javascript';
case 'py':
return 'python';
case 'rb':
return 'ruby';
case 'go':
return 'go';
case 'rs':
return 'rust';
case 'java':
return 'java';
case 'c':
case 'cpp':
case 'h':
case 'hpp':
return 'cpp';
case 'cs':
return 'csharp';
case 'html':
return 'html';
case 'css':
return 'css';
case 'json':
return 'json';
case 'md':
return 'markdown';
case 'sh':
case 'bash':
case 'zsh':
return 'bash';
case 'yaml':
case 'yml':
return 'yaml';
case 'xml':
return 'xml';
case 'sql':
return 'sql';
default:
return '';
}
}

View file

@ -0,0 +1,18 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export type { ExportMessage, ExportSessionData } from './types.js';
export { collectSessionData } from './collect.js';
export { normalizeSessionData } from './normalize.js';
export { toMarkdown } from './formatters/markdown.js';
export {
toHtml,
loadHtmlTemplate,
injectDataIntoHtmlTemplate,
} from './formatters/html.js';
export { toJson } from './formatters/json.js';
export { toJsonl } from './formatters/jsonl.js';
export { generateExportFilename } from './utils.js';

View file

@ -0,0 +1,324 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { Part } from '@google/genai';
import { ExitPlanModeTool, ToolNames } from '@qwen-code/qwen-code-core';
import type { ChatRecord, Config, Kind } from '@qwen-code/qwen-code-core';
import type { ExportMessage, ExportSessionData } from './types.js';
/**
* Normalizes export session data by merging tool call information from tool_result records.
* This ensures the SSOT contains complete tool call metadata.
*/
export function normalizeSessionData(
sessionData: ExportSessionData,
originalRecords: ChatRecord[],
config: Config,
): ExportSessionData {
const normalized = [...sessionData.messages];
const toolCallIndexById = new Map<string, number>();
// Build index of tool call messages
normalized.forEach((message, index) => {
if (message.type === 'tool_call' && message.toolCall?.toolCallId) {
toolCallIndexById.set(message.toolCall.toolCallId, index);
}
});
// Merge tool result information into tool call messages
for (const record of originalRecords) {
if (record.type !== 'tool_result') continue;
const toolCallMessage = buildToolCallMessageFromResult(record, config);
if (!toolCallMessage?.toolCall) continue;
const existingIndex = toolCallIndexById.get(
toolCallMessage.toolCall.toolCallId,
);
if (existingIndex === undefined) {
// No existing tool call, add this one
toolCallIndexById.set(
toolCallMessage.toolCall.toolCallId,
normalized.length,
);
normalized.push(toolCallMessage);
continue;
}
// Merge into existing tool call
const existingMessage = normalized[existingIndex];
if (existingMessage.type !== 'tool_call' || !existingMessage.toolCall) {
continue;
}
mergeToolCallData(existingMessage.toolCall, toolCallMessage.toolCall);
}
return {
...sessionData,
messages: normalized,
};
}
/**
* Merges incoming tool call data into existing tool call.
*/
function mergeToolCallData(
existing: NonNullable<ExportMessage['toolCall']>,
incoming: NonNullable<ExportMessage['toolCall']>,
): void {
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.kind || existing.kind === 'other') {
existing.kind = incoming.kind;
}
if ((!existing.title || existing.title === '') && incoming.title) {
existing.title = incoming.title;
}
if (
(!existing.locations || existing.locations.length === 0) &&
incoming.locations &&
incoming.locations.length > 0
) {
existing.locations = incoming.locations;
}
}
/**
* Builds a tool call message from a tool_result ChatRecord.
*/
function buildToolCallMessageFromResult(
record: ChatRecord,
config: Config,
): ExportMessage | null {
const toolCallResult = record.toolCallResult;
const toolName = extractToolNameFromRecord(record);
// Skip todo_write tool - it's already handled by plan update in collect.ts
// This prevents duplicate todo messages in the export
if (toolName === ToolNames.TODO_WRITE) {
return null;
}
const toolCallId = toolCallResult?.callId ?? record.uuid;
const functionCallArgs = extractFunctionCallArgs(record);
const { kind, title, locations } = resolveToolMetadata(
config,
toolName,
functionCallArgs ??
(toolCallResult as { args?: Record<string, unknown> } | undefined)?.args,
);
const rawInput = normalizeRawInput(
functionCallArgs ??
(toolCallResult as { args?: unknown } | undefined)?.args,
);
const content =
extractDiffContent(toolCallResult?.resultDisplay) ??
transformPartsToToolCallContent(record.message?.parts ?? []);
return {
uuid: record.uuid,
parentUuid: record.parentUuid,
sessionId: record.sessionId,
timestamp: record.timestamp,
type: 'tool_call',
toolCall: {
toolCallId,
kind,
title,
status: toolCallResult?.error ? 'failed' : 'completed',
rawInput,
content,
locations,
timestamp: Date.parse(record.timestamp),
},
};
}
/**
* Extracts tool name from a ChatRecord.
*/
function extractToolNameFromRecord(record: ChatRecord): string {
if (!record.message?.parts) {
return '';
}
for (const part of record.message.parts) {
if ('functionResponse' in part && part.functionResponse?.name) {
return part.functionResponse.name;
}
}
return '';
}
/**
* Extracts function call args from a ChatRecord.
*/
function extractFunctionCallArgs(
record: ChatRecord,
): Record<string, unknown> | undefined {
if (!record.message?.parts) {
return undefined;
}
for (const part of record.message.parts) {
if ('functionCall' in part && part.functionCall?.args) {
return part.functionCall.args as Record<string, unknown>;
}
}
return undefined;
}
/**
* Resolves tool metadata (kind, title, locations) from tool registry.
*/
function resolveToolMetadata(
config: Config,
toolName: string,
args?: Record<string, unknown>,
): {
kind: string;
title: string | object;
locations?: Array<{ path: string; line?: number | null }>;
} {
const toolRegistry = config.getToolRegistry?.();
const tool = toolName ? toolRegistry?.getTool?.(toolName) : undefined;
let title: string | object = tool?.displayName ?? toolName ?? 'tool_call';
let locations: Array<{ path: string; line?: number | null }> | undefined;
const kind = mapToolKind(tool?.kind as Kind | undefined, toolName);
if (tool && args) {
try {
const invocation = tool.build(args);
title = `${title}: ${invocation.getDescription()}`;
locations = invocation.toolLocations().map((loc) => ({
path: loc.path,
line: loc.line ?? null,
}));
} catch {
// Keep defaults on build failure
}
}
return { kind, title, locations };
}
/**
* Maps tool kind to allowed export kinds.
*/
function mapToolKind(kind: Kind | undefined, toolName?: string): string {
if (toolName && toolName === ExitPlanModeTool.Name) {
return 'switch_mode';
}
if (toolName && toolName === ToolNames.TODO_WRITE) {
return 'todowrite';
}
const allowedKinds = new Set<string>([
'read',
'edit',
'delete',
'move',
'search',
'execute',
'think',
'fetch',
'other',
]);
if (kind && allowedKinds.has(kind)) {
return kind;
}
return 'other';
}
/**
* Extracts diff content from tool result display.
*/
function extractDiffContent(
resultDisplay: unknown,
): Array<{ type: string; [key: string]: unknown }> | null {
if (!resultDisplay || typeof resultDisplay !== 'object') {
return null;
}
const display = resultDisplay as Record<string, unknown>;
if ('fileName' in display && 'newContent' in display) {
return [
{
type: 'diff',
path: display['fileName'] as string,
oldText: (display['originalContent'] as string) ?? '',
newText: display['newContent'] as string,
},
];
}
return null;
}
/**
* Normalizes raw input to string or object.
*/
function normalizeRawInput(value: unknown): string | object | undefined {
if (typeof value === 'string') return value;
if (typeof value === 'object' && value !== null) return value;
return undefined;
}
/**
* Transforms Parts to tool call content array.
*/
function transformPartsToToolCallContent(
parts: Part[],
): Array<{ type: string; [key: string]: unknown }> {
const content: Array<{ type: string; [key: string]: unknown }> = [];
for (const part of parts) {
if ('text' in part && part.text) {
content.push({
type: 'content',
content: { type: 'text', text: part.text },
});
continue;
}
if ('functionResponse' in part && part.functionResponse) {
const response = part.functionResponse.response as Record<
string,
unknown
>;
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;
}

View file

@ -0,0 +1,54 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Universal export message format - SSOT for all export formats.
* This is format-agnostic and contains all information needed for any export type.
*/
export interface ExportMessage {
uuid: string;
parentUuid?: string | null;
sessionId?: string;
timestamp: string;
type: 'user' | 'assistant' | 'system' | 'tool_call';
/** For user/assistant messages */
message?: {
role?: string;
parts?: Array<{ text: string }>;
content?: string;
};
/** Model used for assistant messages */
model?: string;
/** For tool_call messages */
toolCall?: {
toolCallId: string;
kind: string;
title: string | object;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
rawInput?: string | object;
content?: Array<{
type: string;
[key: string]: unknown;
}>;
locations?: Array<{
path: string;
line?: number | null;
}>;
timestamp?: number;
};
}
/**
* Complete export session data - the single source of truth.
*/
export interface ExportSessionData {
sessionId: string;
startTime: string;
messages: ExportMessage[];
}

View file

@ -0,0 +1,13 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Generates a filename with timestamp for export files.
*/
export function generateExportFilename(extension: string): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
return `qwen-code-export-${timestamp}.${extension}`;
}