fix: update exportUtils to use embedded html template

This commit is contained in:
mingholy.lmh 2026-01-26 13:32:30 +08:00
parent feeae875a0
commit 660017706f
2 changed files with 244 additions and 65 deletions

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import {
extractTextFromContent,
transformToMarkdown,
@ -256,51 +256,12 @@ describe('exportUtils', () => {
});
describe('loadHtmlTemplate', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('should load HTML template from URL', async () => {
const mockTemplate = '<html><body>Test Template</body></html>';
const mockResponse = {
ok: true,
text: vi.fn().mockResolvedValue(mockTemplate),
};
vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response);
it('should load HTML template from bundled constant', async () => {
const result = await loadHtmlTemplate();
expect(result).toBe(mockTemplate);
expect(fetch).toHaveBeenCalledWith(
'https://raw.githubusercontent.com/QwenLM/qwen-code/main/template_portable.html',
);
});
it('should throw error when fetch fails', async () => {
const mockResponse = {
ok: false,
status: 404,
statusText: 'Not Found',
};
vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response);
await expect(loadHtmlTemplate()).rejects.toThrow(
'Failed to fetch HTML template: 404 Not Found',
);
});
it('should throw error when network request fails', async () => {
const networkError = new Error('Network error');
vi.mocked(fetch).mockRejectedValue(networkError);
await expect(loadHtmlTemplate()).rejects.toThrow(
'Failed to load HTML template',
);
await expect(loadHtmlTemplate()).rejects.toThrow('Network error');
expect(result).toContain('<!DOCTYPE html>');
expect(result).toContain('Qwen Code Chat Export');
expect(result).toContain('id="chat-data"');
});
});
@ -371,6 +332,42 @@ describe('exportUtils', () => {
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', () => {

View file

@ -7,8 +7,205 @@
import type { Part, Content } from '@google/genai';
import type { ChatRecord } from '@qwen-code/qwen-code-core';
const HTML_TEMPLATE_URL =
'https://raw.githubusercontent.com/QwenLM/qwen-code/main/template_portable.html';
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.
@ -98,26 +295,10 @@ export function transformToMarkdown(
}
/**
* Loads the HTML template from a remote URL via fetch.
* Throws an error if the fetch fails.
* Loads the HTML template from a bundled string constant.
*/
export async function loadHtmlTemplate(): Promise<string> {
try {
const response = await fetch(HTML_TEMPLATE_URL);
if (!response.ok) {
throw new Error(
`Failed to fetch HTML template: ${response.status} ${response.statusText}`,
);
}
const template = await response.text();
return template;
} catch (error) {
throw new Error(
`Failed to load HTML template from ${HTML_TEMPLATE_URL}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
return HTML_TEMPLATE;
}
/**
@ -151,9 +332,10 @@ export function injectDataIntoHtmlTemplate(
},
): 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${jsonData}\n </script>`,
`<script id="chat-data" type="application/json">\n${escapedJsonData}\n </script>`,
);
return html;
}