feat: add html template

This commit is contained in:
pomelo-nwu committed 2026-01-28 19:56:20 +08:00
1 parent acf5824a65
commit 021680cd70
3 files changed
+475 -7

No files matched your search

+4 -1
View File
@@ -65,4 +65,7 @@ docs-site/.next
docs-site/content
# python cache
__pycache__/
__pycache__/
integration-tests/concurrent-runner/output/
integration-tests/concurrent-runner/task-*
@@ -0,0 +1,277 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<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/[email protected]/dist/index.umd.js"></script>
<!-- Load the CSS -->
<link rel="stylesheet" href="https://unpkg.com/@qwen-code/[email protected]/dist/styles.css">
<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;
}
h1 {
font-size: 16px;
font-weight: 600;
margin: 0;
color: var(--text-primary);
letter-spacing: -0.02em;
}
.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">
<h1>Qwen Code Export</h1>
</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">
</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>
+194 -6
View File
@@ -9,6 +9,7 @@ the Qwen CLI in parallel with status tracking and output capture.
from __future__ import annotations
import argparse
import html
import asyncio
import json
import os
@@ -93,6 +94,7 @@ class RunRecord:
prompt_results: List[PromptResult] = field(default_factory=list)
diff_file: Optional[str] = None # Path to git diff output
session_log_file: Optional[str] = None # Path to session log (chat recording)
session_html_file: Optional[str] = None # Path to rendered chat HTML
session_id: Optional[str] = None # Session ID (UUID from chat recording)
def to_dict(self) -> Dict[str, Any]:
@@ -111,6 +113,7 @@ class RunRecord:
"error_message": self.error_message,
"diff_file": self.diff_file,
"session_log_file": self.session_log_file,
"session_html_file": self.session_html_file,
"session_id": self.session_id,
"prompt_results": [
{
@@ -142,6 +145,7 @@ class RunRecord:
error_message=data.get("error_message"),
diff_file=data.get("diff_file"),
session_log_file=data.get("session_log_file"),
session_html_file=data.get("session_html_file"),
session_id=data.get("session_id"),
)
@@ -248,7 +252,7 @@ class GitWorktreeManager:
return result.stdout
async def collect_session_log(self, worktree_dir: Path, output_dir: Path) -> Optional[Tuple[Path, str]]:
async def collect_session_log(self, worktree_dir: Path, output_dir: Path) -> Optional[Tuple[Path, str, Path]]:
"""Collect the session log file from the worktree's chat recording.
Session logs are stored at:
@@ -257,7 +261,7 @@ class GitWorktreeManager:
Where projectId is the sanitized worktree path.
Returns:
Tuple of (output_path, session_id) or None if not found.
Tuple of (output_path, session_id, rendered_html_path) or None if not found.
"""
import re
@@ -293,6 +297,8 @@ class GitWorktreeManager:
# Read the original file, modify cwd field, and write to output
# cwd should be the actual current working dir (where runner is executed)
actual_cwd = str(Path.cwd())
messages = []
start_time = None
async with aiofiles.open(session_log, 'r') as src, aiofiles.open(output_log, 'w') as dst:
async for line in src:
line = line.strip()
@@ -300,6 +306,9 @@ class GitWorktreeManager:
try:
record = json.loads(line)
record['cwd'] = actual_cwd
messages.append(record)
if not start_time and 'time' in record:
start_time = record['time']
await dst.write(json.dumps(record, ensure_ascii=False) + '\n')
except json.JSONDecodeError:
# If line is not valid JSON, write it as-is
@@ -307,7 +316,38 @@ class GitWorktreeManager:
self.console.print(f"[dim]Session log copied: {session_log.name}[/dim]")
return output_log, session_id
# Generate rendered HTML
rendered_html_path = chats_output_dir / f"{session_id}.html"
try:
template_path = Path(__file__).parent / "render-chat-temp.html"
if template_path.exists():
async with aiofiles.open(template_path, 'r') as f:
template_content = await f.read()
chat_data = {
"sessionId": session_id,
"startTime": start_time or datetime.now().isoformat(),
"messages": messages
}
# Simple string replacement for injection
# The template has <script id="chat-data" type="application/json"></script>
placeholder = '<script id="chat-data" type="application/json">'
json_str = json.dumps(chat_data, ensure_ascii=False, indent=2)
# Escape </script> to prevent breaking the HTML script tag
json_str = json_str.replace('</script>', '<\\/script>')
injection = f'{placeholder}\n{json_str}\n'
rendered_content = template_content.replace(placeholder, injection)
async with aiofiles.open(rendered_html_path, 'w') as f:
await f.write(rendered_content)
self.console.print(f"[dim]Rendered chat HTML saved: {rendered_html_path.name}[/dim]")
else:
self.console.print(f"[yellow]Warning: Chat template not found at {template_path}[/yellow]")
except Exception as e:
self.console.print(f"[yellow]Warning: Failed to render chat HTML: {e}[/yellow]")
return output_log, session_id, rendered_html_path
async def _run_command(
self,
@@ -372,19 +412,165 @@ class StatusTracker:
await self._persist()
async def _persist(self) -> None:
"""Persist current state to JSON file."""
"""Persist current state to JSON file and generate HTML report."""
data = {
"updated_at": datetime.now().isoformat(),
"runs": [run.to_dict() for run in self._runs.values()],
}
# Write atomically
# Write JSON atomically
temp_file = self.results_file.with_suffix('.tmp')
async with aiofiles.open(temp_file, 'w') as f:
await f.write(json.dumps(data, indent=2))
temp_file.replace(self.results_file)
# Generate HTML report
await self._generate_html(data)
async def _generate_html(self, data: Dict[str, Any]) -> None:
"""Generate a beautiful HTML report."""
html_file = self.results_file.with_name("index.html")
# Calculate summary
total = len(data["runs"])
succeeded = sum(1 for r in data["runs"] if r["status"] == "succeeded")
failed = sum(1 for r in data["runs"] if r["status"] == "failed")
running = sum(1 for r in data["runs"] if r["status"] in ["preparing", "running"])
# Build rows
rows = []
for run in sorted(data["runs"], key=lambda x: x.get("started_at") or "", reverse=True):
status = run["status"]
status_class = f"status-{status}"
# Links
links = []
# Output Directory
if run.get("output_dir"):
# Make path absolute for local viewing
abs_output_dir = os.path.abspath(run["output_dir"])
links.append(f'<a href="file://{abs_output_dir}">Outputs</a>')
# Diff File
if run.get("diff_file"):
abs_diff_file = os.path.abspath(run["diff_file"])
links.append(f'<a href="file://{abs_diff_file}">Diff</a>')
# Session Log
if run.get("session_html_file"):
abs_session_html = os.path.abspath(run["session_html_file"])
links.append(f'<a href="file://{abs_session_html}">Chat</a>')
elif run.get("session_log_file"):
abs_session_log = os.path.abspath(run["session_log_file"])
links.append(f'<a href="file://{abs_session_log}">Chat (Raw)</a>')
# Worktree
if run.get("worktree_path"):
abs_worktree = os.path.abspath(run["worktree_path"])
links.append(f'<a href="file://{abs_worktree}">Worktree</a>')
# Prompt results (stdout/stderr)
prompt_links = []
for i, p in enumerate(run.get("prompt_results", []), 1):
p_links = []
if p.get("stdout_file"):
p_links.append(f'<a href="file://{os.path.abspath(p["stdout_file"])}">out</a>')
if p.get("stderr_file"):
p_links.append(f'<a href="file://{os.path.abspath(p["stderr_file"])}">err</a>')
if p_links:
prompt_links.append(f'P{i}: {"|".join(p_links)}')
links_html = " | ".join(links)
prompts_html = "<br>".join(prompt_links)
duration = "N/A"
if run.get("started_at") and run.get("ended_at"):
try:
start = datetime.fromisoformat(run["started_at"])
end = datetime.fromisoformat(run["ended_at"])
duration = f"{(end - start).total_seconds():.1f}s"
except: pass
error_msg = f'<div class="error-msg">{html.escape(run["error_message"])}</div>' if run.get("error_message") else ""
rows.append(f"""
<tr>
<td><code>{run["run_id"]}</code></td>
<td>{html.escape(run["task_name"])}</td>
<td>{html.escape(run["model"])}</td>
<td><span class="status-pill {status_class}">{status}</span></td>
<td>{duration}</td>
<td class="links">{links_html}</td>
<td class="links">{prompts_html}</td>
<td>{error_msg}</td>
</tr>
""")
html_content = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Qwen Runner Report</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; max-width: 1200px; margin: 0 auto; padding: 20px; background: #f5f7f9; }}
h1 {{ color: #1a202c; }}
.summary {{ display: flex; gap: 20px; margin-bottom: 30px; }}
.summary-card {{ background: white; padding: 15px 25px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); flex: 1; }}
.summary-card h3 {{ margin: 0; font-size: 14px; text-transform: uppercase; color: #718096; }}
.summary-card .value {{ font-size: 24px; font-weight: bold; margin-top: 5px; }}
table {{ width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }}
th, td {{ padding: 12px 15px; text-align: left; border-bottom: 1px solid #e2e8f0; }}
th {{ background: #edf2f7; font-weight: 600; color: #4a5568; }}
tr:hover {{ background: #f7fafc; }}
.status-pill {{ padding: 4px 8px; border-radius: 9999px; font-size: 12px; font-weight: 500; text-transform: uppercase; }}
.status-succeeded {{ background: #c6f6d5; color: #22543d; }}
.status-failed {{ background: #fed7d7; color: #822727; }}
.status-running {{ background: #bee3f8; color: #2a4365; }}
.status-preparing {{ background: #e9d8fd; color: #44337a; }}
.status-queued {{ background: #edf2f7; color: #4a5568; }}
.links a {{ color: #3182ce; text-decoration: none; font-size: 13px; }}
.links a:hover {{ text-decoration: underline; }}
.error-msg {{ color: #e53e3e; font-size: 12px; margin-top: 4px; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
code {{ background: #f1f5f9; padding: 2px 4px; border-radius: 4px; font-family: monospace; font-size: 12px; }}
</style>
</head>
<body>
<h1>Qwen Runner Execution Report</h1>
<div class="summary">
<div class="summary-card"><h3>Total</h3><div class="value">{total}</div></div>
<div class="summary-card"><h3 style="color: #38a169;">Succeeded</h3><div class="value" style="color: #38a169;">{succeeded}</div></div>
<div class="summary-card"><h3 style="color: #e53e3e;">Failed</h3><div class="value" style="color: #e53e3e;">{failed}</div></div>
<div class="summary-card"><h3 style="color: #3182ce;">Running</h3><div class="value" style="color: #3182ce;">{running}</div></div>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>Task</th>
<th>Model</th>
<th>Status</th>
<th>Duration</th>
<th>Logs & Artifacts</th>
<th>Prompts</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{"".join(rows)}
</tbody>
</table>
<div style="margin-top: 20px; color: #718096; font-size: 12px; text-align: right;">
Updated at: {data["updated_at"]}
</div>
</body>
</html>"""
async with aiofiles.open(html_file, 'w') as f:
await f.write(html_content)
def get_state(self) -> ExecutionState:
"""Get current execution state."""
runs = list(self._runs.values())
@@ -752,8 +938,9 @@ async def execute_single_run(
try:
result = await worktree_manager.collect_session_log(worktree_dir, output_dir)
if result:
session_log, session_id = result
session_log, session_id, session_html = result
run.session_log_file = str(session_log)
run.session_html_file = str(session_html)
run.session_id = session_id
console.print(f"[dim]Session log saved: {session_log.name} (ID: {session_id})[/dim]")
except Exception as e:
@@ -765,6 +952,7 @@ async def execute_single_run(
run.status,
diff_file=run.diff_file,
session_log_file=run.session_log_file,
session_html_file=run.session_html_file,
session_id=run.session_id,
)