llm_log_frontend/generate.js
2026-04-26 02:16:36 +08:00

407 lines
10 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const { marked } = require('marked');
const LOG_DIR = path.join(__dirname, 'llm_log');
const OUTPUT_FILE = path.join(__dirname, 'index.html');
function readDirSafe(dir) {
if (!fs.existsSync(dir)) return [];
const items = fs.readdirSync(dir);
return items
.filter(f => f.endsWith('.md'))
.map(f => path.join(dir, f))
.filter(f => fs.statSync(f).isFile());
}
function readFileSafe(filePath) {
try {
const stat = fs.statSync(filePath);
if (stat.size === 0) return null;
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function parseMarkdown(content) {
if (!content) return '';
marked.setOptions({
gfm: true,
breaks: false,
});
return marked.parse(content);
}
const htmlTemplate = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LLM Log Dashboard</title>
<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=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--color-primary: #6366f1;
--color-primary-hover: #4f46e5;
--color-primary-light: #eef2ff;
--color-success: #10b981;
--color-warning: #f59e0b;
--color-danger: #ef4444;
--color-surface: #ffffff;
--color-surface-elevated: #f8fafc;
--color-background: #f1f5f9;
--color-text-primary: #0f172a;
--color-text-secondary: #475569;
--color-text-tertiary: #94a3b8;
--color-border: #e2e8f0;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
--transition-fast: 150ms ease-out;
--transition-normal: 250ms ease-out;
--spacing-unit: 4px;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font-sans);
background-color: var(--color-background);
color: var(--color-text-primary);
line-height: 1.6;
min-height: 100vh;
}
.navbar {
position: sticky;
top: 0;
z-index: 100;
background-color: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: 0 24px;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: var(--shadow-sm);
}
.navbar-brand {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 600;
color: var(--color-text-primary);
text-decoration: none;
letter-spacing: -0.01em;
}
.navbar-brand svg {
color: var(--color-primary);
}
.navbar-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--color-text-tertiary);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 32px 24px;
}
.card {
background-color: var(--color-surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
border: 1px solid var(--color-border);
overflow: hidden;
transition: box-shadow var(--transition-normal), transform var(--transition-normal);
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.card-header {
padding: 20px 24px;
border-bottom: 1px solid var(--color-border);
background-color: var(--color-surface-elevated);
}
.card-title {
font-size: 15px;
font-weight: 600;
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.card-title svg {
color: var(--color-primary);
}
.card-body {
padding: 24px;
}
.log-entry {
margin-bottom: 32px;
}
.log-entry:last-child {
margin-bottom: 0;
}
.log-date {
font-size: 13px;
font-weight: 500;
color: var(--color-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.log-date::before {
content: '';
display: inline-block;
width: 8px;
height: 8px;
background-color: var(--color-primary);
border-radius: 50%;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
thead {
background-color: var(--color-surface-elevated);
}
th {
text-align: left;
padding: 12px 16px;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
border-bottom: 2px solid var(--color-border);
}
td {
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
color: var(--color-text-primary);
transition: background-color var(--transition-fast);
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background-color: var(--color-primary-light);
}
td:first-child, th:first-child {
border-left: none;
}
td:last-child, th:last-child {
border-right: none;
}
.table-highlight {
background-color: var(--color-primary-light) !important;
font-weight: 600;
}
.table-highlight td {
color: var(--color-primary);
}
code {
font-family: var(--font-mono);
font-size: 13px;
background-color: var(--color-surface-elevated);
padding: 2px 6px;
border-radius: var(--radius-sm);
color: var(--color-primary);
}
blockquote {
border-left: 4px solid var(--color-primary);
padding-left: 16px;
margin: 16px 0;
color: var(--color-text-secondary);
font-style: italic;
}
a {
color: var(--color-primary);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
color: var(--color-primary-hover);
text-decoration: underline;
}
@media (max-width: 768px) {
.container {
padding: 16px;
}
.card-body {
padding: 16px;
overflow-x: auto;
}
table {
font-size: 12px;
}
th, td {
padding: 8px 10px;
}
.navbar {
padding: 0 16px;
}
.navbar-meta {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>
</head>
<body>
<nav class="navbar" role="navigation" aria-label="Main navigation">
<a href="#" class="navbar-brand">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
LLM Log Dashboard
</a>
<div class="navbar-meta">
<span id="last-updated"></span>
</div>
</nav>
<main class="container" id="main-content">
<div class="card">
<div class="card-header">
<h1 class="card-title">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<line x1="3" y1="9" x2="21" y2="9"/>
<line x1="9" y1="21" x2="9" y2="9"/>
</svg>
日志记录
</h1>
</div>
<div class="card-body" id="log-content">
</div>
</div>
</main>
<script>
document.getElementById('last-updated').textContent = new Date().toLocaleString('zh-CN');
</script>
</body>
</html>`;
function buildHtml(logs) {
let content = '';
if (logs.length === 0) {
content = '<p style="color: var(--color-text-tertiary);">暂无日志记录</p>';
} else {
content = logs.map(({ date, html }) => `
<section class="log-entry" aria-label="${date}">
<div class="log-date">${date}</div>
${html}
</section>
`).join('\n');
}
return htmlTemplate.replace('<div class="card-body" id="log-content">', `<div class="card-body" id="log-content">\n ${content}\n `);
}
function main() {
console.log('Reading llm_log directory...');
const files = readDirSafe(LOG_DIR);
if (files.length === 0) {
console.log('No markdown files found in llm_log directory.');
const emptyHtml = htmlTemplate.replace('<p style="color: var(--color-text-tertiary);">加载中...</p>', '<p style="color: var(--color-text-tertiary);">暂无日志记录</p>');
fs.writeFileSync(OUTPUT_FILE, emptyHtml);
console.log(`Generated: ${OUTPUT_FILE}`);
return;
}
const logs = files
.map(filePath => {
const content = readFileSafe(filePath);
if (!content) {
console.warn(`Skipping empty or unreadable file: ${filePath}`);
return null;
}
const date = path.basename(filePath, '.md');
const html = parseMarkdown(content);
return { date, html };
})
.filter(Boolean);
logs.sort((a, b) => a.date.localeCompare(b.date));
const html = buildHtml(logs);
fs.writeFileSync(OUTPUT_FILE, html);
console.log(`Generated: ${OUTPUT_FILE}`);
console.log(`Processed ${logs.length} log files.`);
}
main();