- 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:
@@ -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`);
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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 },
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user