Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 89 additions & 24 deletions packages/web-templates/src/export-html/build.mjs
Original file line number Diff line number Diff line change
@@ -1,40 +1,89 @@
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { build } from 'esbuild';
import { build as viteBuild } from 'vite';
import { buildConfig } from './esbuild.config.mjs';
import prettier from 'prettier';

const require = createRequire(import.meta.url);
const assetsDir = dirname(fileURLToPath(import.meta.url));
const srcDir = join(assetsDir, 'src');
const assetsDistDir = join(assetsDir, 'dist');
const generatedDir = join(assetsDir, '..', 'generated');
const webuiDir = join(assetsDir, '..', '..', '..', 'webui');
await mkdir(generatedDir, { recursive: true });
await mkdir(assetsDistDir, { recursive: true });

const templateModulePath = join(generatedDir, 'exportHtmlTemplate.ts');
const packageJsonPath = join(assetsDir, 'package.json');
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
const dependencyVersions = packageJson?.dependencies ?? {};

const getDependencyVersion = (name) => {
const version = dependencyVersions[name];
if (!version) {
throw new Error(`Missing ${name} dependency version in package.json.`);
}
// Handle various version formats:
// - "^0.1.0" -> "0.1.0"
// - "~1.2.3" -> "1.2.3"
// - "latest" -> "latest"
// - "^0.1.0@latest" -> "0.1.0" (remove npm tag suffix)
const versionWithoutPrefix = version.replace(/^[^0-9a-zA-Z]+/, '');
// Remove npm tag suffix (e.g., "0.1.0@latest" -> "0.1.0")
return versionWithoutPrefix.replace(/@.+$/, '');
};
const webuiVersion = getDependencyVersion('@qwen-code/webui');
const reactUmdVersion = '18.2.0';
const reactDomUmdVersion = '18.2.0';

// Build the @qwen-code/webui viewer as a UMD bundle from webui sources and
// inline it into the template below. The exported HTML must stay
// self-contained: @qwen-code/webui is not published as part of the release
// flow, and resolving it from a CDN at view time would retroactively change
// how every already-generated export renders. Building it here (instead of
// reading packages/webui/dist) keeps the template build independent of
// webui's own build, which runs after web-templates in scripts/build.js and
// is skipped entirely by --cli-only builds.
const webuiDistDir = join(assetsDistDir, 'webui');
await viteBuild({
configFile: false,
root: webuiDir,
logLevel: 'warn',
css: {
postcss: {
plugins: [
require('tailwindcss')({
presets: [require(join(webuiDir, 'tailwind.preset.cjs'))],
content: [join(webuiDir, 'src', '**', '*.{ts,tsx}')],
}),
require('autoprefixer'),
],
},
},
build: {
outDir: webuiDistDir,
emptyOutDir: true,
minify: true,
sourcemap: false,
cssCodeSplit: false,
lib: {
entry: join(webuiDir, 'src', 'index.ts'),
name: 'QwenCodeWebUI',
formats: ['umd'],
fileName: () => 'index.umd.js',
},
rollupOptions: {
external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
'react-dom/client': 'ReactDOM',
'react/jsx-runtime': 'ReactJSXRuntime',
},
assetFileNames: 'styles.[ext]',
},
},
},
});
const webuiJs = (await readFile(join(webuiDistDir, 'index.umd.js'), 'utf8'))
// Escape for inlining into a <script> block, mirroring the CLI's
// escapeJsonForHtml: a literal "</script" would terminate the tag.
.replace(/<\/script/gi, '<\\/script');
Comment thread
yiliang114 marked this conversation as resolved.
// The bundle inevitably contains `<!--` (markdown-it's HTML regexes). If a
// `<script` byte ever appears after it, HTML5 script-data double-escaping
// swallows the rest of the template — fail the build instead of every export.
if (/<script/i.test(webuiJs)) {
throw new Error(
'webui UMD bundle contains a <script sequence; refusing to inline.',
);
}
const webuiCss = await readFile(join(webuiDistDir, 'styles.css'), 'utf8');

const buildResult = await build(buildConfig);

const jsBundle = buildResult.outputFiles.find((file) =>
Expand All @@ -54,14 +103,30 @@ const htmlTemplate = await readFile(join(srcDir, 'index.html'), 'utf8');
const faviconSvg = await readFile(join(srcDir, 'favicon.svg'), 'utf8');
const faviconData = encodeURIComponent(faviconSvg.trim());

// Function-form replacers: the bundles are untrusted replacement content,
// and a string replacement would interpret `$&`/`$'`/`` $` `` sequences in
// them as substitution patterns, corrupting the inlined code.
const htmlOutput = htmlTemplate
.replace('__INLINE_CSS__', css.trim())
.replace('__INLINE_SCRIPT__', jsBundle.text.trim())
.replace('__INLINE_CSS__', () => css.trim())
.replace('__INLINE_SCRIPT__', () => jsBundle.text.trim())
.replace('__WEBUI_UMD_JS__', () => webuiJs.trim())
.replace('__WEBUI_CSS__', () => webuiCss.trim())
.replaceAll('__REACT_UMD_VERSION__', reactUmdVersion)
.replaceAll('__REACT_DOM_UMD_VERSION__', reactDomUmdVersion)
.replaceAll('__WEBUI_VERSION__', webuiVersion)
.replace('__FAVICON_SVG__', faviconSvg.trim())
.replace('__FAVICON_DATA__', faviconData);
.replace('__FAVICON_SVG__', () => faviconSvg.trim())
.replace('__FAVICON_DATA__', () => faviconData);

// A dropped or renamed .replace() above would otherwise still exit 0 and
// ship a template that throws at view time.
const residualPlaceholder =
/__(INLINE_CSS|INLINE_SCRIPT|WEBUI_UMD_JS|WEBUI_CSS|FAVICON_SVG|FAVICON_DATA)__/.exec(
htmlOutput,
);
if (residualPlaceholder) {
throw new Error(
`Unreplaced placeholder ${residualPlaceholder[0]} in export HTML template.`,
);
}

const templateModule = `/**
* @license
Expand Down
3 changes: 0 additions & 3 deletions packages/web-templates/src/export-html/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@
"scripts": {
"build": "node build.mjs"
},
"dependencies": {
"@qwen-code/webui": "latest"
},
"devDependencies": {
"esbuild": "^0.25.0"
}
Expand Down
11 changes: 6 additions & 5 deletions packages/web-templates/src/export-html/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,12 @@
window['react/jsx-runtime'] = jsxRuntime;
window['react/jsx-dev-runtime'] = jsxRuntime;
</script>
<script src="https://unpkg.com/@qwen-code/webui@__WEBUI_VERSION__/dist/index.umd.js"></script>
<link
rel="stylesheet"
href="https://unpkg.com/@qwen-code/webui@__WEBUI_VERSION__/dist/styles.css"
/>
<script>
__WEBUI_UMD_JS__;
</script>
<style>
__WEBUI_CSS__
</style>
<title>Qwen Code Chat Export</title>
<script>
// Pre-hydrate FOUC guard: must run before <style> below applies layout.
Expand Down
8 changes: 7 additions & 1 deletion packages/web-templates/src/export-html/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ declare const QwenCodeWebUI: {
messages: unknown[];
autoScroll: boolean;
theme: string;
showExpandControl?: boolean;
}) => React.ReactNode;
PlatformProvider: (props: {
value: unknown;
Expand Down Expand Up @@ -76,7 +77,12 @@ const App = () => {
<div className="content-wrapper">
<div className="chat-container">
<PlatformProvider value={platformContext}>
<ChatViewer messages={messages} autoScroll={false} theme={theme} />
<ChatViewer
messages={messages}
autoScroll={false}
theme={theme}
showExpandControl
/>
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
</PlatformProvider>
</div>
{metadata && <MetadataSidebar metadata={metadata} />}
Expand Down
38 changes: 38 additions & 0 deletions packages/webui/src/components/ChatViewer/ChatViewer.css
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,44 @@
scroll-behavior: smooth;
}

/* ===========================
Global expand/collapse control
=========================== */
.chat-viewer-expand-control {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 20px 0;
flex-shrink: 0;
}

.chat-viewer-expand-control-button {
background: transparent;
border: 1px solid var(--app-input-border, rgba(255, 255, 255, 0.2));
border-radius: 4px;
color: var(--app-secondary-foreground, rgba(255, 255, 255, 0.6));
cursor: pointer;
font-size: 11px;
line-height: 1.4;
padding: 3px 10px;
transition:
color 0.15s ease,
border-color 0.15s ease,
background-color 0.15s ease;
}

.chat-viewer-expand-control-button:hover {
color: var(--app-primary-foreground, #cccccc);
border-color: var(--app-primary-foreground, #cccccc);
background-color: var(--app-input-background, rgba(255, 255, 255, 0.06));
}

@media (max-width: 600px) {
.chat-viewer-expand-control {
padding: 8px 12px 0;
}
}

/* Dark theme scrollbar styling */
.chat-viewer-messages::-webkit-scrollbar {
width: 8px;
Expand Down
Loading
Loading