feat: report workflow — analyst(LLM) + renderer(template)
CI / test (push) Has been cancelled

- analyst: structured JSON output via tool_choice (score, highlights, issues, roleAnalysis with eventId refs)
- renderer: pure code HTML template — dark theme, gantt bar, role cards, verdict badges
- report-live.ts: e2e test script, reads coding workflow DB → generates HTML report
- renderer-llm.ts: kept as reference (LLM version, not used — timeout issues)
- 2 unit tests pass, live test: 17.9s end-to-end
This commit is contained in:
2026-04-17 08:46:40 +00:00
parent 9efb535ead
commit 5ad4145dfc
7 changed files with 865 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env bun
/**
* Report workflow — LIVE test.
* Reads timeline from a coding workflow DB and generates an HTML report.
*
* Usage:
* PULSE_LLM_BASE_URL=... PULSE_LLM_API_KEY=... \
* bun packages/pulse/src/e2e/report-live.ts --source-db=<path> --key=<workflow-key>
*
* 小橘 🍊 (NEKO Team)
*/
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createStore } from '../index.js';
import { createOpenAiLlmClient } from '../llm-client.js';
import { createReportWorkflow } from '../workflows/report-workflow.js';
import { createAnalystRole } from '../workflows/roles/analyst-llm.js';
import { createRendererRole } from '../workflows/roles/renderer-template.js';
import { createWorkflowRule } from '../workflows/workflow-rule-adapter.js';
// ── Args ───────────────────────────────────────────────────────
const sourceDb = process.argv
.find((a) => a.startsWith('--source-db='))
?.slice(12);
const workflowKey = process.argv.find((a) => a.startsWith('--key='))?.slice(6);
if (!sourceDb || !workflowKey) {
console.error(
'Usage: report-live.ts --source-db=<events.db> --key=<workflow-key>',
);
process.exit(1);
}
const baseUrl = process.env.PULSE_LLM_BASE_URL ?? process.env.OPENAI_BASE_URL;
const apiKey = process.env.PULSE_LLM_API_KEY ?? process.env.OPENAI_API_KEY;
const model = process.env.PULSE_LLM_MODEL ?? 'qwen-plus';
if (!baseUrl || !apiKey) {
console.error('❌ Requires PULSE_LLM_BASE_URL + PULSE_LLM_API_KEY');
process.exit(1);
}
const t0 = Date.now();
const ts = () => `[+${((Date.now() - t0) / 1000).toFixed(1)}s]`;
// ── Read source timeline ───────────────────────────────────────
const sourceObjDir = join(sourceDb.replace('/events.db', ''), 'objects');
const source = createStore({
eventsDbPath: sourceDb,
objectsDir: sourceObjDir,
});
const events = source
.getAfter(0)
.filter((e) => e.kind.startsWith('coding.') && e.key === workflowKey);
if (events.length === 0) {
console.error(`No events found for key: ${workflowKey}`);
source.close();
process.exit(1);
}
const tStart = events[0].occurredAt;
const timelineJson = JSON.stringify(
{
key: workflowKey,
totalMs: events[events.length - 1].occurredAt - tStart,
events: events.map((e, i) => {
const role = e.kind.replace('coding.', '');
const meta = e.meta ? JSON.parse(e.meta) : null;
let content: string | null = null;
if (e.hash) {
try {
const obj = source.getObject(e.hash);
content = typeof obj === 'string' ? obj : JSON.stringify(obj);
} catch {}
}
return {
id: e.id,
role,
offsetMs: e.occurredAt - tStart,
durationMs: i > 0 ? e.occurredAt - events[i - 1].occurredAt : 0,
meta,
content,
};
}),
},
null,
2,
);
source.close();
console.log(
ts(),
`Timeline loaded: ${events.length} events, ${(JSON.parse(timelineJson).totalMs / 1000).toFixed(1)}s`,
);
// ── Run report workflow ────────────────────────────────────────
const tmpDir = mkdtempSync(join(tmpdir(), 'report-live-'));
const reportStore = createStore({
eventsDbPath: join(tmpDir, 'events.db'),
objectsDir: join(tmpDir, 'objects'),
});
const llm = createOpenAiLlmClient({
baseUrl,
apiKey,
model,
timeoutMs: 120_000,
});
const reportWorkflow = createReportWorkflow({
analystFn: createAnalystRole(llm),
rendererFn: createRendererRole(),
});
const rule = createWorkflowRule(reportWorkflow, reportStore);
// Seed with timeline JSON
const hash = reportStore.putObject(timelineJson);
reportStore.appendEvent({
occurredAt: Date.now(),
kind: 'report.__start__',
key: `report-${workflowKey}`,
hash,
meta: JSON.stringify({ sourceKey: workflowKey }),
});
console.log(ts(), 'Report workflow started\n');
// Tick until done
let tickNum = 0;
while (tickNum < 5) {
tickNum++;
console.log(`⚡ Tick ${tickNum}`);
const result = await rule.tick();
if (result.executed.length === 0) {
console.log(' No actions — done!');
break;
}
for (const a of result.executed) {
console.log(`${a.role}`);
}
}
// Extract HTML report
const allEvents = reportStore.getAfter(0);
const rendererEvt = allEvents.find((e) => e.kind === 'report.renderer');
if (rendererEvt?.hash) {
const html = reportStore.getObject(rendererEvt.hash) as string;
const outPath = join(tmpDir, `report-${workflowKey}.html`);
writeFileSync(outPath, html, 'utf-8');
console.log(
`\n📄 Report: ${outPath} (${(html.length / 1024).toFixed(1)} KB)`,
);
}
// Print analyst findings
const analystEvt = allEvents.find((e) => e.kind === 'report.analyst');
if (analystEvt?.meta) {
const meta = JSON.parse(analystEvt.meta);
console.log(`\n📊 Score: ${meta.score}/10`);
console.log(`✅ Highlights: ${meta.highlights?.join(', ')}`);
console.log(
`⚠️ Issues: ${meta.issues?.length ? meta.issues.join(', ') : 'None'}`,
);
}
reportStore.close();
console.log(`\n✅ Done in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
+3
View File
@@ -5,6 +5,7 @@
*/
export { createCodingWorkflow } from './coding-workflow.js';
export { createReportWorkflow } from './report-workflow.js';
export {
type AgentExecutorConfig,
type AgentResult,
@@ -12,8 +13,10 @@ export {
createAgentExecutorRole,
createCursorRunner,
} from './roles/agent-executor.js';
export { createAnalystRole } from './roles/analyst-llm.js';
export { createArchitectRole } from './roles/architect-llm.js';
export { createCoderRole } from './roles/coder-cursor.js';
export { createRendererRole } from './roles/renderer-template.js';
export { createReviewerRole } from './roles/reviewer-cursor.js';
export {
createWorkflowRule,
@@ -0,0 +1,90 @@
/**
* report-workflow.test.ts — unit tests for report workflow.
*
* 小橘 🍊 (NEKO Team)
*/
import { describe, expect, test } from 'bun:test';
import { createStore, type Store } from '../store.js';
import { createReportWorkflow } from './report-workflow.js';
import { createWorkflowRule } from './workflow-rule-adapter.js';
function tmpStore(): { store: Store; cleanup: () => void } {
const dir = `${require('node:os').tmpdir()}/report-wf-test-${Date.now()}`;
require('node:fs').mkdirSync(dir, { recursive: true });
const store = createStore({
eventsDbPath: `${dir}/events.db`,
objectsDir: `${dir}/objects`,
});
return { store, cleanup: () => store.close() };
}
describe('Report Workflow', () => {
test('mock: START → analyst → renderer → END', async () => {
const { store, cleanup } = tmpStore();
const workflow = createReportWorkflow();
const rule = createWorkflowRule(workflow, store);
// Create start event with timeline JSON
const timeline = JSON.stringify({
key: 'test-task',
totalMs: 10000,
events: [
{ id: 1, role: '__start__', offsetMs: 0, durationMs: 0 },
{ id: 2, role: 'architect', offsetMs: 1000, durationMs: 1000 },
{ id: 3, role: 'coder', offsetMs: 5000, durationMs: 4000 },
{
id: 4,
role: 'reviewer',
offsetMs: 9000,
durationMs: 4000,
meta: { verdict: 'approved' },
},
{ id: 5, role: 'closer', offsetMs: 10000, durationMs: 1000 },
],
});
const hash = store.putObject(timeline);
store.appendEvent({
occurredAt: Date.now(),
kind: 'report.__start__',
key: 'test-report',
hash,
});
// Tick 1: analyst
const r1 = await rule.tick();
expect(r1.executed.length).toBe(1);
expect(r1.executed[0].role).toBe('analyst');
// Tick 2: renderer
const r2 = await rule.tick();
expect(r2.executed.length).toBe(1);
expect(r2.executed[0].role).toBe('renderer');
// Verify renderer output is HTML
const events = store.getAfter(0);
const rendererEvt = events.find((e) => e.kind === 'report.renderer');
expect(rendererEvt).toBeDefined();
const html = store.getObject(rendererEvt!.hash!);
expect(typeof html).toBe('string');
expect((html as string).includes('<html>')).toBe(true);
// Verify meta
const meta = JSON.parse(rendererEvt!.meta!);
expect(meta.format).toBe('html');
expect(meta.bytes).toBeGreaterThan(0);
// Tick 3: no more
const r3 = await rule.tick();
expect(r3.executed.length).toBe(0);
cleanup();
});
test('moderator routes: START→analyst→renderer→END', () => {
const workflow = createReportWorkflow();
// Just verify it's well-formed
expect(workflow.name).toBe('report');
expect(Object.keys(workflow.roles)).toEqual(['analyst', 'renderer']);
});
});
@@ -0,0 +1,91 @@
/**
* Report WorkflowType — analyst → renderer.
*
* Pure LLM workflow: analyst reads timeline JSON and produces structured analysis,
* renderer turns it into a beautiful single-file HTML report.
*
* Trigger: report.__start__ with timeline JSON as content
*
* 小橘 🍊 (NEKO Team)
*/
import {
END,
type ModeratorInput,
type Role,
START,
type WorkflowType,
} from './workflow-type.js';
// ── Role Meta types ────────────────────────────────────────────
export type AnalystMeta = {
score: number; // 1-10
highlights: string[];
issues: string[];
};
export type RendererMeta = {
format: 'html';
bytes: number;
};
// ── Roles record ───────────────────────────────────────────────
export type ReportRoles = {
analyst: Role<AnalystMeta>;
renderer: Role<RendererMeta>;
};
// ── Default mock implementations ───────────────────────────────
const defaultAnalyst: Role<AnalystMeta> = async (chain) => {
const startMsg = chain.find((m) => m.role === '__start__');
return {
content: `[mock] Analysis of workflow: ${startMsg?.content?.slice(0, 100) ?? 'unknown'}`,
meta: { score: 8, highlights: ['completed'], issues: [] },
};
};
const defaultRenderer: Role<RendererMeta> = async (chain) => {
const analystMsg = chain.find((m) => m.role === 'analyst');
const html = `<!DOCTYPE html><html><body><h1>Report</h1><p>${analystMsg?.content ?? ''}</p></body></html>`;
return {
content: html,
meta: { format: 'html', bytes: html.length },
};
};
// ── Moderator ──────────────────────────────────────────────────
type ReportInput = ModeratorInput<ReportRoles>;
function reportModerator(
output: ReportInput,
_topicId: string,
): keyof ReportRoles | typeof END {
if (output.role === START) return 'analyst';
switch (output.role) {
case 'analyst':
return 'renderer';
case 'renderer':
return END;
}
return END;
}
// ── Factory ────────────────────────────────────────────────────
export function createReportWorkflow(opts?: {
analystFn?: Role<AnalystMeta>;
rendererFn?: Role<RendererMeta>;
}): WorkflowType<ReportRoles> {
return {
name: 'report',
roles: {
analyst: opts?.analystFn ?? defaultAnalyst,
renderer: opts?.rendererFn ?? defaultRenderer,
},
moderator: reportModerator,
};
}
@@ -0,0 +1,168 @@
/**
* Analyst role — LLM reads workflow timeline JSON and produces structured analysis.
* Output is fully structured JSON (via tool_choice), no free text.
*
* 小橘 🍊 (NEKO Team)
*/
import type { LlmClient } from '../../llm-client.js';
import type { AnalystMeta } from '../report-workflow.js';
import type { Role, RoleResult, WorkflowMessage } from '../workflow-type.js';
const SYSTEM_PROMPT = `You are a workflow execution analyst. You receive a workflow timeline (JSON with events array) and produce a structured analysis.
Analyze:
1. Overall quality and efficiency
2. Each role's performance (duration, output quality)
3. Issues: retries, slow steps, failures, rejected reviews
4. Concrete improvement suggestions
Use the extract_analysis tool to output your findings. Reference specific eventId values from the timeline.`;
const ANALYSIS_TOOL = {
type: 'function' as const,
function: {
name: 'extract_analysis',
description: 'Extract structured workflow analysis',
parameters: {
type: 'object',
properties: {
score: {
type: 'number',
minimum: 1,
maximum: 10,
description: 'Overall score 1-10 (10=perfect)',
},
summary: {
type: 'string',
description: 'One-sentence summary of the workflow execution',
},
highlights: {
type: 'array',
items: {
type: 'object',
properties: {
text: { type: 'string', description: 'What went well' },
refEventId: {
type: 'number',
description: 'Related event id from timeline',
},
},
required: ['text'],
},
description: 'Things that went well',
},
issues: {
type: 'array',
items: {
type: 'object',
properties: {
text: { type: 'string', description: 'Problem detected' },
refEventId: {
type: 'number',
description: 'Related event id from timeline',
},
},
required: ['text'],
},
description: 'Problems detected',
},
suggestions: {
type: 'array',
items: { type: 'string' },
description: 'Concrete improvement suggestions',
},
roleAnalysis: {
type: 'array',
items: {
type: 'object',
properties: {
eventId: {
type: 'number',
description: 'Event id from timeline',
},
role: { type: 'string', description: 'Role name' },
durationMs: { type: 'number', description: 'Duration in ms' },
verdict: {
type: 'string',
enum: ['good', 'slow', 'problematic'],
description: 'Performance verdict',
},
comment: { type: 'string', description: 'Brief assessment' },
},
required: ['eventId', 'role', 'verdict', 'comment'],
},
description: 'Per-role analysis referencing timeline event ids',
},
},
required: [
'score',
'summary',
'highlights',
'issues',
'suggestions',
'roleAnalysis',
],
},
},
};
export interface AnalysisResult {
score: number;
summary: string;
highlights: Array<{ text: string; refEventId?: number }>;
issues: Array<{ text: string; refEventId?: number }>;
suggestions: string[];
roleAnalysis: Array<{
eventId: number;
role: string;
durationMs?: number;
verdict: 'good' | 'slow' | 'problematic';
comment: string;
}>;
}
const DEFAULT_ANALYSIS: AnalysisResult = {
score: 5,
summary: 'Analysis failed to parse.',
highlights: [],
issues: [{ text: 'Analyst output could not be parsed' }],
suggestions: [],
roleAnalysis: [],
};
export function createAnalystRole(llm: LlmClient): Role<AnalystMeta> {
return async (chain: WorkflowMessage[]): Promise<RoleResult<AnalystMeta>> => {
const startMsg = chain.find((m) => m.role === '__start__');
const timelineJson = startMsg?.content ?? '{}';
const resp = await llm.chat({
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: `Workflow timeline:\n\n${timelineJson}` },
],
tools: [ANALYSIS_TOOL],
tool_choice: 'required',
});
let analysis = DEFAULT_ANALYSIS;
const toolCall = resp.tool_calls?.find(
(tc) => tc.function.name === 'extract_analysis',
);
if (toolCall) {
try {
analysis = JSON.parse(toolCall.function.arguments) as AnalysisResult;
} catch {}
}
// Content is the full analysis JSON (for renderer to consume)
return {
content: JSON.stringify(analysis),
meta: {
score: analysis.score,
highlights: analysis.highlights.map((h) => h.text),
issues: analysis.issues.map((i) => i.text),
},
};
};
}
@@ -0,0 +1,69 @@
/**
* Renderer role — LLM turns analyst output into a beautiful single-file HTML report.
*
* 小橘 🍊 (NEKO Team)
*/
import type { LlmClient } from '../../llm-client.js';
import type { RendererMeta } from '../report-workflow.js';
import type { Role, RoleResult, WorkflowMessage } from '../workflow-type.js';
const SYSTEM_PROMPT = `You are a talented web designer who creates beautiful single-file HTML reports.
You receive:
1. A workflow analysis (from the analyst)
2. The raw timeline data (from __start__)
Generate a COMPLETE, self-contained HTML file with:
- Inline CSS (no external dependencies)
- A clean, modern design with a tasteful color palette
- A horizontal Gantt-style timeline bar showing each role's duration proportionally
- Role cards with: name, duration, status icon, meta highlights
- A summary header: task name, total time, score badge, pass/fail
- The analyst's highlights (green) and issues (red) as styled callouts
- Responsive layout (looks good on mobile too)
- Subtle animations (fade-in on scroll, progress bar fill)
Design guidelines:
- Background: #0f172a (dark slate), cards: #1e293b, accent: #38bdf8 (sky blue)
- Font: system-ui, clean sans-serif
- Score badge: colored by score (green ≥8, yellow ≥5, red <5)
- Role timeline: horizontal bar chart, each segment a different color
- Keep it under 500 lines of HTML
Output ONLY the complete HTML file, starting with <!DOCTYPE html> and ending with </html>.
Do NOT wrap it in markdown code blocks. Just raw HTML.`;
export function createRendererRole(llm: LlmClient): Role<RendererMeta> {
return async (
chain: WorkflowMessage[],
): Promise<RoleResult<RendererMeta>> => {
const startMsg = chain.find((m) => m.role === '__start__');
const analystMsg = chain.find((m) => m.role === 'analyst');
const timelineJson = startMsg?.content ?? '{}';
const analysis = analystMsg?.content ?? 'No analysis available.';
const analysisMeta = analystMsg?.meta
? JSON.stringify(analystMsg.meta)
: '{}';
const resp = await llm.chat({
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{
role: 'user',
content: `## Timeline Data\n\`\`\`json\n${timelineJson}\n\`\`\`\n\n## Analyst Report\n${analysis}\n\n## Analyst Meta\n${analysisMeta}`,
},
],
});
let html =
resp.content ?? '<html><body>Report generation failed</body></html>';
// Strip markdown code fences if LLM wrapped it
html = html.replace(/^```html?\n?/i, '').replace(/\n?```$/i, '');
return {
content: html,
meta: { format: 'html', bytes: html.length },
};
};
}
@@ -0,0 +1,272 @@
/**
* Renderer role — pure code, no LLM. Generates a single-file HTML report
* from timeline JSON + analyst JSON.
*
* 小橘 🍊 (NEKO Team)
*/
import type { RendererMeta } from '../report-workflow.js';
import type { Role, RoleResult, WorkflowMessage } from '../workflow-type.js';
import type { AnalysisResult } from './analyst-llm.js';
interface TimelineEvent {
id: number;
role: string;
offsetMs: number;
durationMs: number;
meta?: Record<string, unknown> | null;
content?: string | null;
}
interface TimelineData {
key: string;
totalMs: number;
events: TimelineEvent[];
}
const ROLE_COLORS: Record<string, string> = {
__start__: '#94a3b8',
architect: '#a78bfa',
coder: '#38bdf8',
reviewer: '#34d399',
closer: '#f472b6',
analyst: '#fbbf24',
renderer: '#fb923c',
};
const VERDICT_COLORS: Record<string, string> = {
good: '#34d399',
slow: '#fbbf24',
problematic: '#f87171',
};
function esc(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function fmtDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function renderHtml(timeline: TimelineData, analysis: AnalysisResult): string {
const roleMap = new Map<number, (typeof analysis.roleAnalysis)[0]>();
for (const ra of analysis.roleAnalysis) roleMap.set(ra.eventId, ra);
// Build gantt bars
const ganttBars = timeline.events
.filter((e) => e.role !== '__start__')
.map((e) => {
const pct =
timeline.totalMs > 0 ? (e.durationMs / timeline.totalMs) * 100 : 0;
const color = ROLE_COLORS[e.role] ?? '#64748b';
const ra = roleMap.get(e.id);
const border = ra
? `border: 2px solid ${VERDICT_COLORS[ra.verdict] ?? '#64748b'}`
: '';
return `<div class="bar" style="width:${Math.max(pct, 2)}%;background:${color};${border}" title="${esc(e.role)}: ${fmtDuration(e.durationMs)}">${pct > 8 ? esc(e.role) : ''}</div>`;
})
.join('\n');
// Build event cards
const eventCards = timeline.events
.map((e) => {
const color = ROLE_COLORS[e.role] ?? '#64748b';
const ra = roleMap.get(e.id);
const verdictBadge = ra
? `<span class="badge" style="background:${VERDICT_COLORS[ra.verdict]}">${ra.verdict}</span>`
: '';
const comment = ra ? `<p class="comment">${esc(ra.comment)}</p>` : '';
const metaStr = e.meta
? `<pre class="meta">${esc(JSON.stringify(e.meta, null, 1))}</pre>`
: '';
const contentPreview = e.content
? `<details><summary>Content preview</summary><pre class="content">${esc(e.content.slice(0, 500))}${e.content.length > 500 ? '...' : ''}</pre></details>`
: '';
return `
<div class="card" id="event-${e.id}">
<div class="card-header">
<span class="role-dot" style="background:${color}"></span>
<span class="role-name">${esc(e.role)}</span>
<span class="event-id">#${e.id}</span>
<span class="duration">${fmtDuration(e.durationMs)}</span>
${verdictBadge}
</div>
${comment}
${metaStr}
${contentPreview}
</div>`;
})
.join('\n');
// Highlights & Issues
const highlightItems = analysis.highlights
.map((h) => {
const ref = h.refEventId
? ` <a href="#event-${h.refEventId}">#${h.refEventId}</a>`
: '';
return `<li class="highlight-item">✅ ${esc(h.text)}${ref}</li>`;
})
.join('\n');
const issueItems = analysis.issues
.map((i) => {
const ref = i.refEventId
? ` <a href="#event-${i.refEventId}">#${i.refEventId}</a>`
: '';
return `<li class="issue-item">⚠️ ${esc(i.text)}${ref}</li>`;
})
.join('\n');
const suggestionItems = analysis.suggestions
.map((s) => `<li>💡 ${esc(s)}</li>`)
.join('\n');
const scoreBg =
analysis.score >= 8
? '#34d399'
: analysis.score >= 5
? '#fbbf24'
: '#f87171';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Workflow Report: ${esc(timeline.key)}</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0f172a;color:#e2e8f0;font-family:system-ui,-apple-system,sans-serif;padding:24px;max-width:960px;margin:0 auto}
h1{font-size:1.5rem;margin-bottom:4px}
.subtitle{color:#94a3b8;font-size:.875rem;margin-bottom:24px}
.summary{display:flex;gap:16px;flex-wrap:wrap;margin-bottom:24px}
.stat{background:#1e293b;border-radius:8px;padding:16px 20px;flex:1;min-width:120px}
.stat-label{color:#94a3b8;font-size:.75rem;text-transform:uppercase;letter-spacing:.05em}
.stat-value{font-size:1.5rem;font-weight:700;margin-top:4px}
.score-badge{display:inline-block;width:48px;height:48px;border-radius:50%;line-height:48px;text-align:center;font-size:1.25rem;font-weight:700;color:#0f172a}
.gantt{background:#1e293b;border-radius:8px;padding:16px;margin-bottom:24px}
.gantt h2{font-size:1rem;margin-bottom:12px;color:#94a3b8}
.gantt-track{display:flex;height:36px;border-radius:6px;overflow:hidden;gap:2px}
.bar{display:flex;align-items:center;justify-content:center;font-size:.75rem;font-weight:600;color:#0f172a;border-radius:4px;transition:opacity .2s}
.bar:hover{opacity:.8}
.section{margin-bottom:24px}
.section h2{font-size:1.125rem;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid #334155}
.card{background:#1e293b;border-radius:8px;padding:16px;margin-bottom:8px}
.card-header{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.role-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.role-name{font-weight:600}
.event-id{color:#64748b;font-size:.75rem}
.duration{color:#94a3b8;font-size:.875rem;margin-left:auto}
.badge{font-size:.7rem;padding:2px 8px;border-radius:10px;color:#0f172a;font-weight:600;text-transform:uppercase}
.comment{color:#cbd5e1;margin-top:8px;font-size:.875rem}
.meta{background:#0f172a;border-radius:4px;padding:8px;margin-top:8px;font-size:.75rem;color:#94a3b8;overflow-x:auto}
.content{background:#0f172a;border-radius:4px;padding:8px;font-size:.75rem;color:#94a3b8;max-height:200px;overflow:auto;white-space:pre-wrap}
details{margin-top:8px}
summary{cursor:pointer;color:#64748b;font-size:.75rem}
ul{list-style:none;padding:0}
li{padding:6px 0;font-size:.875rem}
.highlight-item{color:#34d399}
.issue-item{color:#fbbf24}
a{color:#38bdf8;text-decoration:none}
a:hover{text-decoration:underline}
@media(max-width:640px){.summary{flex-direction:column}.gantt-track{height:24px}}
</style>
</head>
<body>
<h1>📊 ${esc(timeline.key)}</h1>
<p class="subtitle">Workflow Execution Report · Generated by Pulse Council v2</p>
<div class="summary">
<div class="stat">
<div class="stat-label">Score</div>
<div class="stat-value"><span class="score-badge" style="background:${scoreBg}">${analysis.score}</span></div>
</div>
<div class="stat">
<div class="stat-label">Total Time</div>
<div class="stat-value">${fmtDuration(timeline.totalMs)}</div>
</div>
<div class="stat">
<div class="stat-label">Steps</div>
<div class="stat-value">${timeline.events.length}</div>
</div>
<div class="stat">
<div class="stat-label">Summary</div>
<div style="margin-top:4px;font-size:.875rem">${esc(analysis.summary)}</div>
</div>
</div>
<div class="gantt">
<h2>Timeline</h2>
<div class="gantt-track">
${ganttBars}
</div>
</div>
<div class="section">
<h2>Events</h2>
${eventCards}
</div>
<div class="section">
<h2>Highlights</h2>
<ul>${highlightItems || '<li style="color:#64748b">None</li>'}</ul>
</div>
<div class="section">
<h2>Issues</h2>
<ul>${issueItems || '<li style="color:#64748b">None</li>'}</ul>
</div>
<div class="section">
<h2>Suggestions</h2>
<ul>${suggestionItems || '<li style="color:#64748b">None</li>'}</ul>
</div>
<footer style="margin-top:32px;padding-top:16px;border-top:1px solid #334155;color:#475569;font-size:.75rem">
小橘 🍊 · Pulse Council v2 · ${new Date().toISOString().slice(0, 19)}Z
</footer>
</body>
</html>`;
}
export function createRendererRole(): Role<RendererMeta> {
return async (
chain: WorkflowMessage[],
): Promise<RoleResult<RendererMeta>> => {
const startMsg = chain.find((m) => m.role === '__start__');
const analystMsg = chain.find((m) => m.role === 'analyst');
let timeline: TimelineData;
try {
timeline = JSON.parse(startMsg?.content ?? '{}');
} catch {
timeline = { key: 'unknown', totalMs: 0, events: [] };
}
let analysis: AnalysisResult;
try {
analysis = JSON.parse(analystMsg?.content ?? '{}');
} catch {
analysis = {
score: 0,
summary: 'Failed to parse analysis',
highlights: [],
issues: [],
suggestions: [],
roleAnalysis: [],
};
}
const html = renderHtml(timeline, analysis);
return {
content: html,
meta: { format: 'html', bytes: html.length },
};
};
}