mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 15:31:27 +00:00
fix: update exportUtils to use embedded html template
This commit is contained in:
parent
feeae875a0
commit
660017706f
2 changed files with 244 additions and 65 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue