mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 15:31:27 +00:00
feat(export): support html/json/jsonl/md export
This commit is contained in:
parent
660017706f
commit
a4630d39e4
15 changed files with 1358 additions and 791 deletions
205
packages/cli/src/ui/utils/export/collect.ts
Normal file
205
packages/cli/src/ui/utils/export/collect.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
/**
|
||||
* @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 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;
|
||||
default:
|
||||
// Ignore other update types
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setActiveRecordId(recordId: string | null): void {
|
||||
this.activeRecordId = recordId;
|
||||
}
|
||||
|
||||
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: new Date(toolCall.timestamp || Date.now()).toISOString(),
|
||||
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 flushCurrentMessage(): void {
|
||||
if (!this.currentMessage) return;
|
||||
|
||||
const uuid = this.getMessageUuid();
|
||||
this.messages.push({
|
||||
uuid,
|
||||
sessionId: this.sessionId,
|
||||
timestamp: new Date(this.currentMessage.timestamp).toISOString(),
|
||||
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,
|
||||
};
|
||||
}
|
||||
55
packages/cli/src/ui/utils/export/formatters/html.ts
Normal file
55
packages/cli/src/ui/utils/export/formatters/html.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* @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(/&/g, '\\u0026')
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/>/g, '\\u003e');
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the HTML template.
|
||||
* Currently we use an embedded html string.
|
||||
* Consider using online html template in the future.
|
||||
*/
|
||||
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 html = template.replace(
|
||||
/<script id="chat-data" type="application\/json">\s*\/\/ DATA_PLACEHOLDER:.*?\s*<\/script>/s,
|
||||
`<script id="chat-data" type="application/json">\n${escapedJsonData}\n </script>`,
|
||||
);
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts ExportSessionData to HTML format.
|
||||
*/
|
||||
export function toHtml(sessionData: ExportSessionData): string {
|
||||
const template = loadHtmlTemplate();
|
||||
return injectDataIntoHtmlTemplate(template, sessionData);
|
||||
}
|
||||
362
packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts
Normal file
362
packages/cli/src/ui/utils/export/formatters/htmlTemplate.ts
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
const FAVICON_SVG =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 141.38 140"><defs><linearGradient id="qwen-gradient" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#60a5fa"/><stop offset="100%" stop-color="#a855f7"/></linearGradient></defs><path fill="url(#qwen-gradient)" d="m140.93 85-16.35-28.33-1.93-3.34 8.66-15a3.323 3.323 0 0 0 0-3.34l-9.62-16.67c-.3-.51-.72-.93-1.22-1.22s-1.07-.45-1.67-.45H82.23l-8.66-15a3.33 3.33 0 0 0-2.89-1.67H51.43c-.59 0-1.17.16-1.66.45-.5.29-.92.71-1.22 1.22L32.19 29.98l-1.92 3.33H12.96c-.59 0-1.17.16-1.66.45-.5.29-.93.71-1.22 1.22L.45 51.66a3.323 3.323 0 0 0 0 3.34l18.28 31.67-8.66 15a3.32 3.32 0 0 0 0 3.34l9.62 16.67c.3.51.72.93 1.22 1.22s1.07.45 1.67.45h36.56l8.66 15a3.35 3.35 0 0 0 2.89 1.67h19.25a3.34 3.34 0 0 0 2.89-1.67l18.28-31.67h17.32c.6 0 1.17-.16 1.67-.45s.92-.71 1.22-1.22l9.62-16.67a3.323 3.323 0 0 0 0-3.34ZM51.44 3.33 61.07 20l-9.63 16.66h76.98l-9.62 16.66H45.67l-11.54-20zM57.21 120H22.58l9.63-16.67h19.25l-38.5-66.67h19.25l9.62 16.67L68.78 100l-11.55 20Zm61.59-33.34-9.62-16.67-38.49 66.67-9.63-16.67 9.63-16.66 26.94-46.67h23.1l17.32 30z"/></svg>';
|
||||
|
||||
export const HTML_TEMPLATE = `<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,${encodeURIComponent(FAVICON_SVG)}">
|
||||
<title>Qwen Code Chat Export</title>
|
||||
<!-- Load React and ReactDOM from CDN -->
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
|
||||
<!-- Manually create the jsxRuntime object to satisfy the dependency -->
|
||||
<script>
|
||||
// Provide a minimal JSX runtime for builds that expect react/jsx-runtime globals.
|
||||
const withKey = (props, key) =>
|
||||
key == null ? props : Object.assign({}, props, { key });
|
||||
const jsx = (type, props, key) => React.createElement(type, withKey(props, key));
|
||||
const jsxRuntime = {
|
||||
Fragment: React.Fragment,
|
||||
jsx,
|
||||
jsxs: jsx,
|
||||
jsxDEV: jsx
|
||||
};
|
||||
|
||||
window.ReactJSXRuntime = jsxRuntime;
|
||||
window['react/jsx-runtime'] = jsxRuntime;
|
||||
window['react/jsx-dev-runtime'] = jsxRuntime;
|
||||
</script>
|
||||
|
||||
<!-- Load the webui library from CDN -->
|
||||
<script src="https://unpkg.com/@qwen-code/webui@0.1.0-beta.3/dist/index.umd.js"></script>
|
||||
|
||||
<!-- Load the CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/@qwen-code/webui@0.1.0-beta.3/dist/styles.css">
|
||||
|
||||
<!-- Load Google Font for Logo -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #18181b;
|
||||
--bg-secondary: #27272a;
|
||||
--text-primary: #f4f4f5;
|
||||
--text-secondary: #a1a1aa;
|
||||
--border-color: #3f3f46;
|
||||
--accent-color: #3b82f6;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.page-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: rgba(24, 24, 27, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Logo Styles */
|
||||
.logo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-family: 'Press Start 2P', cursive;
|
||||
font-weight: 400;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.05em;
|
||||
position: relative;
|
||||
color: white; /* Fallback */
|
||||
}
|
||||
|
||||
.logo-text-inner {
|
||||
background: linear-gradient(to right, #60a5fa, #a855f7);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Echo effect */
|
||||
.logo-text::before,
|
||||
.logo-text::after {
|
||||
content: attr(data-text);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
background: none;
|
||||
-webkit-text-fill-color: transparent;
|
||||
-webkit-text-stroke: 1px rgba(96, 165, 250, 0.3);
|
||||
}
|
||||
|
||||
.logo-text::before {
|
||||
transform: translate(2px, 2px);
|
||||
-webkit-text-stroke: 1px rgba(168, 85, 247, 0.3);
|
||||
}
|
||||
|
||||
.logo-text::after {
|
||||
transform: translate(4px, 4px);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.logo-sub {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
padding: 40px 20px;
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 5px;
|
||||
border: 2px solid var(--bg-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #52525b;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.chat-container {
|
||||
max-width: 100%;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 12px 16px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.meta {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.chat-container {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="page-wrapper">
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<div class="logo-icon">${FAVICON_SVG}</div>
|
||||
<div class="logo">
|
||||
<div class="logo-text" data-text="QWEN">
|
||||
<span class="logo-text-inner">QWEN</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Session Id</span>
|
||||
<span id="session-id" class="font-mono">-</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Export Time</span>
|
||||
<span id="session-date">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="chat-root-no-babel" class="chat-container"></div>
|
||||
</div>
|
||||
|
||||
<script id="chat-data" type="application/json">
|
||||
// DATA_PLACEHOLDER: Chat export data will be injected here
|
||||
</script>
|
||||
|
||||
<script>
|
||||
const chatDataElement = document.getElementById('chat-data');
|
||||
const chatData = chatDataElement?.textContent
|
||||
? JSON.parse(chatDataElement.textContent)
|
||||
: {};
|
||||
const rawMessages = Array.isArray(chatData.messages) ? chatData.messages : [];
|
||||
const messages = rawMessages.filter((record) => record && record.type !== 'system');
|
||||
|
||||
// Populate metadata
|
||||
const sessionIdElement = document.getElementById('session-id');
|
||||
if (sessionIdElement && chatData.sessionId) {
|
||||
sessionIdElement.textContent = chatData.sessionId;
|
||||
}
|
||||
|
||||
const sessionDateElement = document.getElementById('session-date');
|
||||
if (sessionDateElement && chatData.startTime) {
|
||||
try {
|
||||
const date = new Date(chatData.startTime);
|
||||
sessionDateElement.textContent = date.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch (e) {
|
||||
sessionDateElement.textContent = chatData.startTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the ChatViewer and Platform components from the global object
|
||||
const { ChatViewer, PlatformProvider } = QwenCodeWebUI;
|
||||
|
||||
// Define a minimal platform context for web usage
|
||||
const platformContext = {
|
||||
platform: 'web',
|
||||
postMessage: (message) => {
|
||||
console.log('Posted message:', message);
|
||||
},
|
||||
onMessage: (handler) => {
|
||||
window.addEventListener('message', handler);
|
||||
return () => window.removeEventListener('message', handler);
|
||||
},
|
||||
openFile: (path) => {
|
||||
console.log('Opening file:', path);
|
||||
},
|
||||
getResourceUrl: (resource) => {
|
||||
return null;
|
||||
},
|
||||
features: {
|
||||
canOpenFile: false,
|
||||
canCopy: true
|
||||
}
|
||||
};
|
||||
|
||||
// Render the ChatViewer component without Babel
|
||||
const rootElementNoBabel = document.getElementById('chat-root-no-babel');
|
||||
|
||||
// Create the ChatViewer element wrapped with PlatformProvider using React.createElement (no JSX)
|
||||
const ChatAppNoBabel = React.createElement(PlatformProvider, { value: platformContext },
|
||||
React.createElement(ChatViewer, {
|
||||
messages,
|
||||
autoScroll: false,
|
||||
theme: 'dark'
|
||||
})
|
||||
);
|
||||
|
||||
ReactDOM.render(ChatAppNoBabel, rootElementNoBabel);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
`;
|
||||
15
packages/cli/src/ui/utils/export/formatters/json.ts
Normal file
15
packages/cli/src/ui/utils/export/formatters/json.ts
Normal 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);
|
||||
}
|
||||
31
packages/cli/src/ui/utils/export/formatters/jsonl.ts
Normal file
31
packages/cli/src/ui/utils/export/formatters/jsonl.ts
Normal 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');
|
||||
}
|
||||
86
packages/cli/src/ui/utils/export/formatters/markdown.ts
Normal file
86
packages/cli/src/ui/utils/export/formatters/markdown.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* @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**: ${sessionData.sessionId}\n`);
|
||||
lines.push(`**Start Time**: ${sessionData.startTime}\n`);
|
||||
lines.push(`**Exported**: ${new Date().toISOString()}\n`);
|
||||
lines.push('---\n');
|
||||
|
||||
// Process each message
|
||||
for (const message of sessionData.messages) {
|
||||
if (message.type === 'user') {
|
||||
lines.push('## User\n');
|
||||
const text = extractTextFromMessage(message);
|
||||
lines.push(`${text}\n`);
|
||||
} else if (message.type === 'assistant') {
|
||||
lines.push('## Assistant\n');
|
||||
const text = extractTextFromMessage(message);
|
||||
lines.push(`${text}\n`);
|
||||
} else if (message.type === 'tool_call') {
|
||||
lines.push('## Tool Call\n');
|
||||
if (message.toolCall) {
|
||||
const title =
|
||||
typeof message.toolCall.title === 'string'
|
||||
? message.toolCall.title
|
||||
: JSON.stringify(message.toolCall.title);
|
||||
lines.push(`**Tool**: ${title}\n`);
|
||||
lines.push(`**Status**: ${message.toolCall.status}\n`);
|
||||
|
||||
if (message.toolCall.content && message.toolCall.content.length > 0) {
|
||||
lines.push('```\n');
|
||||
for (const contentItem of message.toolCall.content) {
|
||||
if (contentItem.type === 'content' && contentItem['content']) {
|
||||
const contentData = contentItem['content'] as {
|
||||
type: string;
|
||||
text?: string;
|
||||
};
|
||||
if (contentData.type === 'text' && contentData.text) {
|
||||
lines.push(contentData.text);
|
||||
}
|
||||
} else if (contentItem.type === 'diff') {
|
||||
lines.push(`Diff for: ${contentItem['path']}\n`);
|
||||
lines.push(`${contentItem['newText']}\n`);
|
||||
}
|
||||
}
|
||||
lines.push('\n```\n');
|
||||
}
|
||||
}
|
||||
} else if (message.type === 'system') {
|
||||
// Skip system messages or format them minimally
|
||||
lines.push('_[System message]_\n');
|
||||
}
|
||||
|
||||
lines.push('\n');
|
||||
}
|
||||
|
||||
return lines.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
18
packages/cli/src/ui/utils/export/index.ts
Normal file
18
packages/cli/src/ui/utils/export/index.ts
Normal 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';
|
||||
291
packages/cli/src/ui/utils/export/normalize.ts
Normal file
291
packages/cli/src/ui/utils/export/normalize.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Part } from '@google/genai';
|
||||
import { ExitPlanModeTool } 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 toolCallId = toolCallResult?.callId ?? record.uuid;
|
||||
const toolName = extractToolNameFromRecord(record);
|
||||
const { kind, title, locations } = resolveToolMetadata(
|
||||
config,
|
||||
toolName,
|
||||
(toolCallResult as { args?: Record<string, unknown> } | undefined)?.args,
|
||||
);
|
||||
const rawInput = normalizeRawInput(
|
||||
(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 '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
54
packages/cli/src/ui/utils/export/types.ts
Normal file
54
packages/cli/src/ui/utils/export/types.ts
Normal 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[];
|
||||
}
|
||||
13
packages/cli/src/ui/utils/export/utils.ts
Normal file
13
packages/cli/src/ui/utils/export/utils.ts
Normal 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 `export-${timestamp}.${extension}`;
|
||||
}
|
||||
|
|
@ -1,401 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
extractTextFromContent,
|
||||
transformToMarkdown,
|
||||
loadHtmlTemplate,
|
||||
prepareExportData,
|
||||
injectDataIntoHtmlTemplate,
|
||||
generateExportFilename,
|
||||
} from './exportUtils.js';
|
||||
import type { ChatRecord } from '@qwen-code/qwen-code-core';
|
||||
import type { Part, Content } from '@google/genai';
|
||||
|
||||
describe('exportUtils', () => {
|
||||
describe('extractTextFromContent', () => {
|
||||
it('should return empty string for undefined content', () => {
|
||||
expect(extractTextFromContent(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for content without parts', () => {
|
||||
expect(extractTextFromContent({} as Content)).toBe('');
|
||||
});
|
||||
|
||||
it('should extract text from text parts', () => {
|
||||
const content: Content = {
|
||||
parts: [{ text: 'Hello' }, { text: 'World' }] as Part[],
|
||||
};
|
||||
expect(extractTextFromContent(content)).toBe('Hello\nWorld');
|
||||
});
|
||||
|
||||
it('should format function call parts', () => {
|
||||
const content: Content = {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'testFunction',
|
||||
args: { param1: 'value1' },
|
||||
},
|
||||
},
|
||||
] as Part[],
|
||||
};
|
||||
const result = extractTextFromContent(content);
|
||||
expect(result).toContain('[Function Call: testFunction]');
|
||||
expect(result).toContain('"param1": "value1"');
|
||||
});
|
||||
|
||||
it('should format function response parts', () => {
|
||||
const content: Content = {
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'testFunction',
|
||||
response: { result: 'success' },
|
||||
},
|
||||
},
|
||||
] as Part[],
|
||||
};
|
||||
const result = extractTextFromContent(content);
|
||||
expect(result).toContain('[Function Response: testFunction]');
|
||||
expect(result).toContain('"result": "success"');
|
||||
});
|
||||
|
||||
it('should handle mixed part types', () => {
|
||||
const content: Content = {
|
||||
parts: [
|
||||
{ text: 'Start' },
|
||||
{
|
||||
functionCall: {
|
||||
name: 'call',
|
||||
args: {},
|
||||
},
|
||||
},
|
||||
{ text: 'End' },
|
||||
] as Part[],
|
||||
};
|
||||
const result = extractTextFromContent(content);
|
||||
expect(result).toContain('Start');
|
||||
expect(result).toContain('[Function Call: call]');
|
||||
expect(result).toContain('End');
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformToMarkdown', () => {
|
||||
const mockMessages: ChatRecord[] = [
|
||||
{
|
||||
uuid: 'uuid-1',
|
||||
parentUuid: null,
|
||||
sessionId: 'test-session-id',
|
||||
timestamp: '2025-01-01T00:00:00Z',
|
||||
type: 'user',
|
||||
cwd: '/test',
|
||||
version: '1.0.0',
|
||||
message: {
|
||||
parts: [{ text: 'Hello, how are you?' }] as Part[],
|
||||
} as Content,
|
||||
},
|
||||
{
|
||||
uuid: 'uuid-2',
|
||||
parentUuid: 'uuid-1',
|
||||
sessionId: 'test-session-id',
|
||||
timestamp: '2025-01-01T00:00:01Z',
|
||||
type: 'assistant',
|
||||
cwd: '/test',
|
||||
version: '1.0.0',
|
||||
message: {
|
||||
parts: [{ text: 'I am doing well, thank you!' }] as Part[],
|
||||
} as Content,
|
||||
},
|
||||
];
|
||||
|
||||
it('should transform messages to markdown format', () => {
|
||||
const result = transformToMarkdown(
|
||||
mockMessages,
|
||||
'test-session-id',
|
||||
'2025-01-01T00:00:00Z',
|
||||
);
|
||||
|
||||
expect(result).toContain('# Chat Session Export');
|
||||
expect(result).toContain('**Session ID**: test-session-id');
|
||||
expect(result).toContain('**Start Time**: 2025-01-01T00:00:00Z');
|
||||
expect(result).toContain('## User');
|
||||
expect(result).toContain('Hello, how are you?');
|
||||
expect(result).toContain('## Assistant');
|
||||
expect(result).toContain('I am doing well, thank you!');
|
||||
});
|
||||
|
||||
it('should include exported timestamp', () => {
|
||||
const before = new Date().toISOString();
|
||||
const result = transformToMarkdown(
|
||||
mockMessages,
|
||||
'test-session-id',
|
||||
'2025-01-01T00:00:00Z',
|
||||
);
|
||||
const after = new Date().toISOString();
|
||||
|
||||
expect(result).toContain('**Exported**:');
|
||||
const exportedMatch = result.match(/\*\*Exported\*\*: (.+)/);
|
||||
expect(exportedMatch).toBeTruthy();
|
||||
if (exportedMatch) {
|
||||
const exportedTime = exportedMatch[1].trim();
|
||||
expect(exportedTime >= before).toBe(true);
|
||||
expect(exportedTime <= after).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should format tool_result messages', () => {
|
||||
const messages: ChatRecord[] = [
|
||||
{
|
||||
uuid: 'uuid-3',
|
||||
parentUuid: 'uuid-2',
|
||||
sessionId: 'test-session-id',
|
||||
timestamp: '2025-01-01T00:00:02Z',
|
||||
type: 'tool_result',
|
||||
cwd: '/test',
|
||||
version: '1.0.0',
|
||||
toolCallResult: {
|
||||
resultDisplay: 'Tool output',
|
||||
},
|
||||
message: {
|
||||
parts: [{ text: 'Additional info' }] as Part[],
|
||||
} as Content,
|
||||
},
|
||||
];
|
||||
|
||||
const result = transformToMarkdown(
|
||||
messages,
|
||||
'test-session-id',
|
||||
'2025-01-01T00:00:00Z',
|
||||
);
|
||||
|
||||
expect(result).toContain('## Tool Result');
|
||||
expect(result).toContain('```');
|
||||
expect(result).toContain('Tool output');
|
||||
expect(result).toContain('Additional info');
|
||||
});
|
||||
|
||||
it('should format tool_result with JSON resultDisplay', () => {
|
||||
const messages: ChatRecord[] = [
|
||||
{
|
||||
uuid: 'uuid-4',
|
||||
parentUuid: 'uuid-3',
|
||||
sessionId: 'test-session-id',
|
||||
timestamp: '2025-01-01T00:00:03Z',
|
||||
type: 'tool_result',
|
||||
cwd: '/test',
|
||||
version: '1.0.0',
|
||||
toolCallResult: {
|
||||
resultDisplay: '{"key": "value"}',
|
||||
},
|
||||
message: {} as Content,
|
||||
},
|
||||
];
|
||||
|
||||
const result = transformToMarkdown(
|
||||
messages,
|
||||
'test-session-id',
|
||||
'2025-01-01T00:00:00Z',
|
||||
);
|
||||
|
||||
expect(result).toContain('## Tool Result');
|
||||
expect(result).toContain('```');
|
||||
expect(result).toContain('"key": "value"');
|
||||
});
|
||||
|
||||
it('should handle chat compression system messages', () => {
|
||||
const messages: ChatRecord[] = [
|
||||
{
|
||||
uuid: 'uuid-5',
|
||||
parentUuid: null,
|
||||
sessionId: 'test-session-id',
|
||||
timestamp: '2025-01-01T00:00:04Z',
|
||||
type: 'system',
|
||||
subtype: 'chat_compression',
|
||||
cwd: '/test',
|
||||
version: '1.0.0',
|
||||
message: {} as Content,
|
||||
},
|
||||
];
|
||||
|
||||
const result = transformToMarkdown(
|
||||
messages,
|
||||
'test-session-id',
|
||||
'2025-01-01T00:00:00Z',
|
||||
);
|
||||
|
||||
expect(result).toContain('_[Chat history compressed]_');
|
||||
});
|
||||
|
||||
it('should skip system messages without subtype', () => {
|
||||
const messages: ChatRecord[] = [
|
||||
{
|
||||
uuid: 'uuid-6',
|
||||
parentUuid: null,
|
||||
sessionId: 'test-session-id',
|
||||
timestamp: '2025-01-01T00:00:05Z',
|
||||
type: 'system',
|
||||
cwd: '/test',
|
||||
version: '1.0.0',
|
||||
message: {} as Content,
|
||||
},
|
||||
];
|
||||
|
||||
const result = transformToMarkdown(
|
||||
messages,
|
||||
'test-session-id',
|
||||
'2025-01-01T00:00:00Z',
|
||||
);
|
||||
|
||||
expect(result).not.toContain('## System');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadHtmlTemplate', () => {
|
||||
it('should load HTML template from bundled constant', async () => {
|
||||
const result = await loadHtmlTemplate();
|
||||
|
||||
expect(result).toContain('<!DOCTYPE html>');
|
||||
expect(result).toContain('Qwen Code Chat Export');
|
||||
expect(result).toContain('id="chat-data"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareExportData', () => {
|
||||
it('should prepare export data from conversation', () => {
|
||||
const conversation = {
|
||||
sessionId: 'test-session-id',
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
message: {
|
||||
parts: [{ text: 'Hello' }] as Part[],
|
||||
} as Content,
|
||||
},
|
||||
] as ChatRecord[],
|
||||
};
|
||||
|
||||
const result = prepareExportData(conversation);
|
||||
|
||||
expect(result).toEqual({
|
||||
sessionId: 'test-session-id',
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
messages: conversation.messages,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('injectDataIntoHtmlTemplate', () => {
|
||||
it('should inject JSON data into HTML template', () => {
|
||||
const template = `
|
||||
<html>
|
||||
<body>
|
||||
<script id="chat-data" type="application/json">
|
||||
// DATA_PLACEHOLDER: Your JSONL data will be injected here
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const data = {
|
||||
sessionId: 'test-session-id',
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
messages: [] as ChatRecord[],
|
||||
};
|
||||
|
||||
const result = injectDataIntoHtmlTemplate(template, data);
|
||||
|
||||
expect(result).toContain(
|
||||
'<script id="chat-data" type="application/json">',
|
||||
);
|
||||
expect(result).toContain('"sessionId": "test-session-id"');
|
||||
expect(result).toContain('"startTime": "2025-01-01T00:00:00Z"');
|
||||
expect(result).not.toContain('DATA_PLACEHOLDER');
|
||||
});
|
||||
|
||||
it('should handle template with whitespace around placeholder', () => {
|
||||
const template = `<script id="chat-data" type="application/json">\n// DATA_PLACEHOLDER: Your JSONL data will be injected here\n</script>`;
|
||||
|
||||
const data = {
|
||||
sessionId: 'test',
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
messages: [] as ChatRecord[],
|
||||
};
|
||||
|
||||
const result = injectDataIntoHtmlTemplate(template, data);
|
||||
|
||||
expect(result).toContain('"sessionId": "test"');
|
||||
expect(result).not.toContain('DATA_PLACEHOLDER');
|
||||
});
|
||||
|
||||
it('should escape unsafe JSON sequences in HTML', () => {
|
||||
const template = `
|
||||
<html>
|
||||
<body>
|
||||
<script id="chat-data" type="application/json">
|
||||
// DATA_PLACEHOLDER: Your JSONL data will be injected here
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const data = {
|
||||
sessionId: 'test-session-id',
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
messages: [
|
||||
{
|
||||
uuid: 'uuid-1',
|
||||
parentUuid: null,
|
||||
sessionId: 'test-session-id',
|
||||
timestamp: '2025-01-01T00:00:00Z',
|
||||
type: 'user',
|
||||
cwd: '/test',
|
||||
version: '1.0.0',
|
||||
message: {
|
||||
parts: [{ text: '</script><div>unsafe</div>' }] as Part[],
|
||||
} as Content,
|
||||
},
|
||||
] as ChatRecord[],
|
||||
};
|
||||
|
||||
const result = injectDataIntoHtmlTemplate(template, data);
|
||||
|
||||
expect(result).toContain('\\u003c/script');
|
||||
expect(result).not.toContain('</script><div>unsafe</div>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateExportFilename', () => {
|
||||
it('should generate filename with timestamp and extension', () => {
|
||||
const filename = generateExportFilename('md');
|
||||
|
||||
expect(filename).toMatch(
|
||||
/^export-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.md$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use provided extension', () => {
|
||||
const filename1 = generateExportFilename('html');
|
||||
const filename2 = generateExportFilename('json');
|
||||
|
||||
expect(filename1).toMatch(/\.html$/);
|
||||
expect(filename2).toMatch(/\.json$/);
|
||||
});
|
||||
|
||||
it('should replace colons and dots in timestamp', () => {
|
||||
const filename = generateExportFilename('md');
|
||||
|
||||
expect(filename).not.toContain(':');
|
||||
// The filename should contain a dot only for the extension
|
||||
expect(filename.split('.').length).toBe(2);
|
||||
// Check that timestamp part (before extension) doesn't contain dots
|
||||
const timestampPart = filename.split('.')[0];
|
||||
expect(timestampPart).not.toContain('.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,349 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Part, Content } from '@google/genai';
|
||||
import type { ChatRecord } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const HTML_TEMPLATE = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Qwen Code Chat Export</title>
|
||||
<!-- Load React and ReactDOM from CDN -->
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
|
||||
<!-- Manually create the jsxRuntime object to satisfy the dependency -->
|
||||
<script>
|
||||
// Provide a minimal JSX runtime for builds that expect react/jsx-runtime globals.
|
||||
const withKey = (props, key) =>
|
||||
key == null ? props : Object.assign({}, props, { key });
|
||||
const jsx = (type, props, key) => React.createElement(type, withKey(props, key));
|
||||
const jsxRuntime = {
|
||||
Fragment: React.Fragment,
|
||||
jsx,
|
||||
jsxs: jsx,
|
||||
jsxDEV: jsx
|
||||
};
|
||||
|
||||
window.ReactJSXRuntime = jsxRuntime;
|
||||
window['react/jsx-runtime'] = jsxRuntime;
|
||||
window['react/jsx-dev-runtime'] = jsxRuntime;
|
||||
</script>
|
||||
|
||||
<!-- Load the webui library from CDN -->
|
||||
<script src="https://unpkg.com/@qwen-code/webui@0.1.0-beta.2/dist/index.umd.js"></script>
|
||||
|
||||
<!-- Load the CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/@qwen-code/webui@0.1.0-beta.2/dist/styles.css">
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f9fafb;
|
||||
color: #111827;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.page-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px 0;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 16px 32px;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="page-wrapper">
|
||||
<div class="header">
|
||||
<h1>Qwen Code Chat Export</h1>
|
||||
<div class="meta">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Session ID:</span>
|
||||
<span id="session-id">-</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Date:</span>
|
||||
<span id="session-date">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="chat-root-no-babel" class="chat-container"></div>
|
||||
</div>
|
||||
|
||||
<script id="chat-data" type="application/json">
|
||||
// DATA_PLACEHOLDER: Chat export data will be injected here
|
||||
</script>
|
||||
|
||||
<script>
|
||||
const chatDataElement = document.getElementById('chat-data');
|
||||
const chatData = chatDataElement?.textContent
|
||||
? JSON.parse(chatDataElement.textContent)
|
||||
: {};
|
||||
const rawMessages = Array.isArray(chatData.messages) ? chatData.messages : [];
|
||||
const messages = rawMessages.filter((record) => record && record.type !== 'system');
|
||||
|
||||
// Populate metadata
|
||||
const sessionIdElement = document.getElementById('session-id');
|
||||
if (sessionIdElement && chatData.sessionId) {
|
||||
sessionIdElement.textContent = chatData.sessionId;
|
||||
}
|
||||
|
||||
const sessionDateElement = document.getElementById('session-date');
|
||||
if (sessionDateElement && chatData.startTime) {
|
||||
try {
|
||||
const date = new Date(chatData.startTime);
|
||||
sessionDateElement.textContent = date.toLocaleString();
|
||||
} catch (e) {
|
||||
sessionDateElement.textContent = chatData.startTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the ChatViewer and Platform components from the global object
|
||||
const { ChatViewer, PlatformProvider } = QwenCodeWebUI;
|
||||
|
||||
// Define a minimal platform context for web usage
|
||||
const platformContext = {
|
||||
platform: 'web',
|
||||
postMessage: (message) => {
|
||||
// In a web context, you might want to handle messages differently
|
||||
console.log('Posted message:', message);
|
||||
},
|
||||
onMessage: (handler) => {
|
||||
// In a web context, you might listen for custom events
|
||||
window.addEventListener('message', handler);
|
||||
return () => window.removeEventListener('message', handler);
|
||||
},
|
||||
openFile: (path) => {
|
||||
console.log('Opening file:', path);
|
||||
},
|
||||
getResourceUrl: (resource) => {
|
||||
// Return URLs for platform-specific resources
|
||||
return null; // Use default resources
|
||||
},
|
||||
features: {
|
||||
canOpenFile: false,
|
||||
canCopy: true
|
||||
}
|
||||
};
|
||||
|
||||
// Render the ChatViewer component without Babel
|
||||
const rootElementNoBabel = document.getElementById('chat-root-no-babel');
|
||||
|
||||
// Create the ChatViewer element wrapped with PlatformProvider using React.createElement (no JSX)
|
||||
const ChatAppNoBabel = React.createElement(PlatformProvider, { value: platformContext },
|
||||
React.createElement(ChatViewer, {
|
||||
messages,
|
||||
autoScroll: false,
|
||||
theme: 'light'
|
||||
})
|
||||
);
|
||||
|
||||
ReactDOM.render(ChatAppNoBabel, rootElementNoBabel);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
`;
|
||||
|
||||
function escapeJsonForHtml(json: string): string {
|
||||
return json
|
||||
.replace(/&/g, '\\u0026')
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/>/g, '\\u003e');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts text content from a Content object's parts.
|
||||
*/
|
||||
export function extractTextFromContent(content: Content | undefined): string {
|
||||
if (!content?.parts) return '';
|
||||
|
||||
const textParts: string[] = [];
|
||||
for (const part of content.parts as Part[]) {
|
||||
if ('text' in part) {
|
||||
const textPart = part as { text: string };
|
||||
textParts.push(textPart.text);
|
||||
} else if ('functionCall' in part) {
|
||||
const fnPart = part as { functionCall: { name: string; args: unknown } };
|
||||
textParts.push(
|
||||
`[Function Call: ${fnPart.functionCall.name}]\n${JSON.stringify(fnPart.functionCall.args, null, 2)}`,
|
||||
);
|
||||
} else if ('functionResponse' in part) {
|
||||
const fnResPart = part as {
|
||||
functionResponse: { name: string; response: unknown };
|
||||
};
|
||||
textParts.push(
|
||||
`[Function Response: ${fnResPart.functionResponse.name}]\n${JSON.stringify(fnResPart.functionResponse.response, null, 2)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return textParts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms ChatRecord messages to markdown format.
|
||||
*/
|
||||
export function transformToMarkdown(
|
||||
messages: ChatRecord[],
|
||||
sessionId: string,
|
||||
startTime: string,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Add header with metadata
|
||||
lines.push('# Chat Session Export\n');
|
||||
lines.push(`**Session ID**: ${sessionId}\n`);
|
||||
lines.push(`**Start Time**: ${startTime}\n`);
|
||||
lines.push(`**Exported**: ${new Date().toISOString()}\n`);
|
||||
lines.push('---\n');
|
||||
|
||||
// Process each message
|
||||
for (const record of messages) {
|
||||
if (record.type === 'user') {
|
||||
lines.push('## User\n');
|
||||
const text = extractTextFromContent(record.message);
|
||||
lines.push(`${text}\n`);
|
||||
} else if (record.type === 'assistant') {
|
||||
lines.push('## Assistant\n');
|
||||
const text = extractTextFromContent(record.message);
|
||||
lines.push(`${text}\n`);
|
||||
} else if (record.type === 'tool_result') {
|
||||
lines.push('## Tool Result\n');
|
||||
if (record.toolCallResult) {
|
||||
const resultDisplay = record.toolCallResult.resultDisplay;
|
||||
if (resultDisplay) {
|
||||
lines.push('```\n');
|
||||
lines.push(
|
||||
typeof resultDisplay === 'string'
|
||||
? resultDisplay
|
||||
: JSON.stringify(resultDisplay, null, 2),
|
||||
);
|
||||
lines.push('\n```\n');
|
||||
}
|
||||
}
|
||||
const text = extractTextFromContent(record.message);
|
||||
if (text) {
|
||||
lines.push(`${text}\n`);
|
||||
}
|
||||
} else if (record.type === 'system') {
|
||||
// Skip system messages or format them minimally
|
||||
if (record.subtype === 'chat_compression') {
|
||||
lines.push('_[Chat history compressed]_\n');
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('\n');
|
||||
}
|
||||
|
||||
return lines.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the HTML template from a bundled string constant.
|
||||
*/
|
||||
export async function loadHtmlTemplate(): Promise<string> {
|
||||
return HTML_TEMPLATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares export data from conversation.
|
||||
*/
|
||||
export function prepareExportData(conversation: {
|
||||
sessionId: string;
|
||||
startTime: string;
|
||||
messages: ChatRecord[];
|
||||
}): {
|
||||
sessionId: string;
|
||||
startTime: string;
|
||||
messages: ChatRecord[];
|
||||
} {
|
||||
return {
|
||||
sessionId: conversation.sessionId,
|
||||
startTime: conversation.startTime,
|
||||
messages: conversation.messages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects JSON data into the HTML template.
|
||||
*/
|
||||
export function injectDataIntoHtmlTemplate(
|
||||
template: string,
|
||||
data: {
|
||||
sessionId: string;
|
||||
startTime: string;
|
||||
messages: ChatRecord[];
|
||||
},
|
||||
): string {
|
||||
const jsonData = JSON.stringify(data, null, 2);
|
||||
const escapedJsonData = escapeJsonForHtml(jsonData);
|
||||
const html = template.replace(
|
||||
/<script id="chat-data" type="application\/json">\s*\/\/ DATA_PLACEHOLDER:.*?\s*<\/script>/s,
|
||||
`<script id="chat-data" type="application/json">\n${escapedJsonData}\n </script>`,
|
||||
);
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a filename with timestamp for export files.
|
||||
*/
|
||||
export function generateExportFilename(extension: string): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
return `export-${timestamp}.${extension}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue