voyageai-cli 1.30.0 → 1.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +6 -0
  3. package/src/commands/chat.js +32 -11
  4. package/src/commands/export.js +124 -0
  5. package/src/commands/import.js +195 -0
  6. package/src/commands/index-workspace.js +239 -0
  7. package/src/commands/mcp-server.js +113 -3
  8. package/src/commands/playground.js +111 -3
  9. package/src/lib/export/contexts/benchmark-export.js +27 -0
  10. package/src/lib/export/contexts/chat-export.js +41 -0
  11. package/src/lib/export/contexts/explore-export.js +22 -0
  12. package/src/lib/export/contexts/search-export.js +54 -0
  13. package/src/lib/export/contexts/workflow-export.js +80 -0
  14. package/src/lib/export/formats/clipboard-export.js +29 -0
  15. package/src/lib/export/formats/csv-export.js +45 -0
  16. package/src/lib/export/formats/json-export.js +50 -0
  17. package/src/lib/export/formats/markdown-export.js +189 -0
  18. package/src/lib/export/formats/mermaid-export.js +274 -0
  19. package/src/lib/export/formats/pdf-export.js +117 -0
  20. package/src/lib/export/formats/png-export.js +96 -0
  21. package/src/lib/export/formats/svg-export.js +116 -0
  22. package/src/lib/export/index.js +175 -0
  23. package/src/lib/workflow.js +206 -27
  24. package/src/mcp/install.js +280 -7
  25. package/src/mcp/schemas/index.js +40 -0
  26. package/src/mcp/server.js +2 -0
  27. package/src/mcp/tools/workspace.js +463 -0
  28. package/src/playground/announcements.md +52 -5
  29. package/src/playground/index.html +11125 -7796
  30. package/src/playground/vendor/mermaid.min.js +2811 -0
  31. package/src/workflows/rag-chat.json +165 -0
  32. package/src/workflows/tests/consistency-check.happy-path.test.json +28 -0
  33. package/src/workflows/tests/consistency-check.missing-source.test.json +26 -0
  34. package/src/workflows/tests/cost-analysis.happy-path.test.json +28 -0
  35. package/src/workflows/tests/enrich-and-ingest.happy-path.test.json +38 -0
  36. package/src/workflows/tests/enrich-and-ingest.notify-fails.test.json +38 -0
  37. package/src/workflows/tests/intelligent-ingest.all-filtered.test.json +26 -0
  38. package/src/workflows/tests/intelligent-ingest.happy-path.test.json +28 -0
  39. package/src/workflows/tests/kb-health-report.custom-queries.test.json +24 -0
  40. package/src/workflows/tests/kb-health-report.happy-path.test.json +26 -0
  41. package/src/workflows/tests/multi-collection-search.happy-path.test.json +40 -0
  42. package/src/workflows/tests/multi-collection-search.one-empty.test.json +28 -0
  43. package/src/workflows/tests/rag-chat.happy-path.test.json +26 -0
  44. package/src/workflows/tests/rag-chat.no-relevant-results.test.json +25 -0
  45. package/src/workflows/tests/research-and-summarize.happy-path.test.json +33 -0
  46. package/src/workflows/tests/research-and-summarize.no-results.test.json +29 -0
  47. package/src/workflows/tests/search-with-fallback.empty-both.test.json +24 -0
  48. package/src/workflows/tests/search-with-fallback.fallback-branch.test.json +24 -0
  49. package/src/workflows/tests/search-with-fallback.happy-path.test.json +27 -0
  50. package/src/workflows/tests/smart-ingest.duplicate-detected.test.json +34 -0
  51. package/src/workflows/tests/smart-ingest.happy-path.test.json +31 -0
  52. package/src/playground/assets/announcements/appstore.jpg +0 -0
  53. package/src/playground/assets/announcements/circuits.jpg +0 -0
  54. package/src/playground/assets/announcements/csvingest.jpg +0 -0
  55. package/src/playground/assets/announcements/green-wave.jpg +0 -0
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Render normalized data as Markdown.
5
+ * The shape of `normalized` varies by context — the context module sets a `_context` key.
6
+ * @param {object} normalized
7
+ * @param {object} options
8
+ * @returns {{ content: string, mimeType: string }}
9
+ */
10
+ function renderMarkdown(normalized, options = {}) {
11
+ const ctx = normalized._context;
12
+ let md;
13
+ switch (ctx) {
14
+ case 'workflow':
15
+ md = renderWorkflowMd(normalized, options);
16
+ break;
17
+ case 'search':
18
+ md = renderSearchMd(normalized, options);
19
+ break;
20
+ case 'chat':
21
+ md = renderChatMd(normalized, options);
22
+ break;
23
+ case 'benchmark':
24
+ md = renderBenchmarkMd(normalized, options);
25
+ break;
26
+ default:
27
+ md = '# Export\n\n```json\n' + JSON.stringify(normalized, null, 2) + '\n```\n';
28
+ }
29
+ return { content: md, mimeType: 'text/markdown' };
30
+ }
31
+
32
+ // ── Workflow ──
33
+
34
+ function renderWorkflowMd(n, opts) {
35
+ const lines = [];
36
+ lines.push(`# ${n.name || 'Workflow'}`);
37
+ lines.push('');
38
+ if (n.description) {
39
+ lines.push(n.description);
40
+ lines.push('');
41
+ }
42
+
43
+ // Metadata
44
+ if (n.version) lines.push(`**Version:** ${n.version}`);
45
+ const tools = (n.steps || []).map((s) => s.tool).filter(Boolean);
46
+ const unique = [...new Set(tools)];
47
+ if (unique.length) lines.push(`**Tools used:** ${unique.join(', ')}`);
48
+ const layers = n._executionLayers || null;
49
+ if (layers) lines.push(`**Execution layers:** ${layers} (sequential)`);
50
+ lines.push('');
51
+
52
+ // Inputs table
53
+ if (n.inputs && Object.keys(n.inputs).length > 0) {
54
+ lines.push('## Inputs');
55
+ lines.push('');
56
+ lines.push('| Parameter | Type | Required | Default | Description |');
57
+ lines.push('|-----------|------|----------|---------|-------------|');
58
+ for (const [key, schema] of Object.entries(n.inputs)) {
59
+ const type = schema.type || 'string';
60
+ const req = schema.required ? 'Yes' : 'No';
61
+ const def = schema.default !== undefined ? String(schema.default) : '—';
62
+ const desc = schema.description || '';
63
+ lines.push(`| ${key} | ${type} | ${req} | ${def} | ${desc} |`);
64
+ }
65
+ lines.push('');
66
+ }
67
+
68
+ // Steps
69
+ if (n.steps && n.steps.length > 0) {
70
+ lines.push('## Steps');
71
+ lines.push('');
72
+ const deps = n._dependencyMap || {};
73
+ n.steps.forEach((step, i) => {
74
+ const label = step.name || step.description || step.id;
75
+ lines.push(`### ${i + 1}. ${label}`);
76
+ if (step.tool) lines.push(`- **Tool:** ${step.tool}`);
77
+ const stepDeps = deps[step.id];
78
+ if (stepDeps && stepDeps.length > 0) {
79
+ lines.push(`- **Depends on:** ${stepDeps.join(', ')}`);
80
+ } else {
81
+ lines.push('- **Depends on:** (none — first step)');
82
+ }
83
+ if (step.description && step.description !== label) {
84
+ lines.push(`- **Configuration:** ${step.description}`);
85
+ }
86
+ lines.push('');
87
+ });
88
+ }
89
+
90
+ // Output
91
+ if (n.output) {
92
+ lines.push('## Output');
93
+ lines.push('');
94
+ lines.push(`Returns: \`${n.output}\``);
95
+ lines.push('');
96
+ }
97
+
98
+ return lines.join('\n');
99
+ }
100
+
101
+ // ── Search Results ──
102
+
103
+ function renderSearchMd(n, opts) {
104
+ const lines = [];
105
+ lines.push('# Search Results');
106
+ lines.push('');
107
+ if (n.query) lines.push(`**Query:** ${n.query}`);
108
+ if (n.collection) lines.push(`**Collection:** ${n.collection}`);
109
+ if (n.model) lines.push(`**Model:** ${n.model}`);
110
+ const total = (n.results || []).length;
111
+ lines.push(`**Results:** ${total}`);
112
+ lines.push('');
113
+
114
+ (n.results || []).forEach((r, i) => {
115
+ lines.push(`### ${i + 1}. ${r.source || 'Result'} (score: ${r.score ?? '—'})`);
116
+ if (r.rerankedScore !== undefined) lines.push(`- **Reranked score:** ${r.rerankedScore}`);
117
+ const text = opts.includeFullText ? r.text : (r.text || '').slice(0, 200);
118
+ if (text) {
119
+ lines.push('');
120
+ lines.push(`> ${text}`);
121
+ }
122
+ lines.push('');
123
+ });
124
+
125
+ return lines.join('\n');
126
+ }
127
+
128
+ // ── Chat Session ──
129
+
130
+ function renderChatMd(n, opts) {
131
+ const lines = [];
132
+ const sessionId = n.sessionId || n.id || 'unknown';
133
+ lines.push(`# Chat Session: ${sessionId}`);
134
+ lines.push('');
135
+ if (n.startedAt) lines.push(`**Date:** ${n.startedAt}`);
136
+ if (n.provider && n.model) lines.push(`**Provider:** ${n.provider} (${n.model})`);
137
+ else if (n.model) lines.push(`**Model:** ${n.model}`);
138
+ if (n.collection) lines.push(`**Knowledge Base:** ${n.collection}`);
139
+ const turns = (n.turns || []).length;
140
+ lines.push(`**Turns:** ${turns}`);
141
+ lines.push('');
142
+ lines.push('---');
143
+ lines.push('');
144
+
145
+ (n.turns || []).forEach((turn) => {
146
+ const role = turn.role === 'user' ? 'User' : 'vai';
147
+ lines.push(`**${role}:**`);
148
+ lines.push(turn.content || '');
149
+ if (opts.includeSources !== false && turn.context && turn.context.length > 0) {
150
+ lines.push('');
151
+ lines.push('> **Sources:**');
152
+ turn.context.forEach((src, i) => {
153
+ const rel = src.score !== undefined ? ` (relevance: ${src.score})` : '';
154
+ lines.push(`> ${i + 1}. ${src.source || 'unknown'}${rel}`);
155
+ });
156
+ }
157
+ lines.push('');
158
+ lines.push('---');
159
+ lines.push('');
160
+ });
161
+
162
+ return lines.join('\n');
163
+ }
164
+
165
+ // ── Benchmark ──
166
+
167
+ function renderBenchmarkMd(n, opts) {
168
+ const lines = [];
169
+ lines.push('# Benchmark Results');
170
+ lines.push('');
171
+ if (n.name) lines.push(`**Benchmark:** ${n.name}`);
172
+ if (n.date) lines.push(`**Date:** ${n.date}`);
173
+ lines.push('');
174
+
175
+ const results = n.results || n.rows || [];
176
+ if (results.length > 0) {
177
+ const cols = Object.keys(results[0]);
178
+ lines.push('| ' + cols.join(' | ') + ' |');
179
+ lines.push('| ' + cols.map(() => '---').join(' | ') + ' |');
180
+ results.forEach((row) => {
181
+ lines.push('| ' + cols.map((c) => String(row[c] ?? '')).join(' | ') + ' |');
182
+ });
183
+ lines.push('');
184
+ }
185
+
186
+ return lines.join('\n');
187
+ }
188
+
189
+ module.exports = { renderMarkdown };
@@ -0,0 +1,274 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ VAI_TOOLS,
5
+ CONTROL_FLOW_TOOLS,
6
+ PROCESSING_TOOLS,
7
+ INTEGRATION_TOOLS,
8
+ buildDependencyGraph,
9
+ } = require('../../workflow');
10
+
11
+ // ════════════════════════════════════════════════════════════════════
12
+ // Tool → Category mapping
13
+ // ════════════════════════════════════════════════════════════════════
14
+
15
+ function getToolCategory(tool) {
16
+ if (!tool) return 'utility';
17
+ if (tool === 'rerank') return 'retrieval';
18
+ if (['query', 'search'].includes(tool)) return 'retrieval';
19
+ if (['embed', 'similarity'].includes(tool)) return 'embedding';
20
+ if (tool === 'generate') return 'generation';
21
+ if (tool === 'ingest') return 'ingestion';
22
+ if (CONTROL_FLOW_TOOLS.has(tool)) return 'control';
23
+ if (PROCESSING_TOOLS.has(tool)) return 'utility';
24
+ if (INTEGRATION_TOOLS.has(tool)) return 'utility';
25
+ return 'utility';
26
+ }
27
+
28
+ // ════════════════════════════════════════════════════════════════════
29
+ // Emoji per category
30
+ // ════════════════════════════════════════════════════════════════════
31
+
32
+ const CATEGORY_EMOJI = {
33
+ retrieval: '🔍',
34
+ embedding: '🧬',
35
+ generation: '✨',
36
+ control: '⚙️',
37
+ utility: '🔧',
38
+ ingestion: '📥',
39
+ };
40
+
41
+ // ════════════════════════════════════════════════════════════════════
42
+ // Mermaid node shape per category (spec Section 7.1)
43
+ // ════════════════════════════════════════════════════════════════════
44
+
45
+ function wrapNodeShape(id, label, category, isConditional) {
46
+ if (isConditional) return `${id}{"${label}"}`;
47
+ switch (category) {
48
+ case 'retrieval': return `${id}(["${label}"])`; // stadium
49
+ case 'embedding': return `${id}("${label}")`; // rounded
50
+ case 'generation': return `${id}[/"${label}"/]`; // trapezoid
51
+ case 'control': return `${id}{"${label}"}`; // diamond
52
+ case 'ingestion': return `${id}[["${label}"]]`; // subroutine
53
+ case 'utility':
54
+ default: return `${id}("${label}")`; // rounded
55
+ }
56
+ }
57
+
58
+ // ════════════════════════════════════════════════════════════════════
59
+ // Style class definitions (spec Section 7.2)
60
+ // ════════════════════════════════════════════════════════════════════
61
+
62
+ const CLASS_DEFS = {
63
+ retrieval: 'classDef retrieval fill:#00838F,stroke:#00D4AA,color:#fff',
64
+ embedding: 'classDef embedding fill:#4A148C,stroke:#CE93D8,color:#fff',
65
+ generation: 'classDef generation fill:#6A1B9A,stroke:#CE93D8,color:#fff',
66
+ control: 'classDef control fill:#424242,stroke:#9E9E9E,color:#fff',
67
+ utility: 'classDef utility fill:#E65100,stroke:#FFB74D,color:#fff',
68
+ ingestion: 'classDef ingestion fill:#1B5E20,stroke:#66BB6A,color:#fff',
69
+ };
70
+
71
+ // ════════════════════════════════════════════════════════════════════
72
+ // Main conversion
73
+ // ════════════════════════════════════════════════════════════════════
74
+
75
+ /**
76
+ * Convert a workflow definition to Mermaid flowchart syntax.
77
+ *
78
+ * @param {object} workflow - Parsed workflow JSON
79
+ * @param {object} [options]
80
+ * @param {string} [options.theme='dark']
81
+ * @param {string} [options.direction='TD']
82
+ * @param {boolean} [options.includeStepIds=false]
83
+ * @param {boolean} [options.includeToolEmoji=true]
84
+ * @param {boolean} [options.colorCoded=true]
85
+ * @param {boolean} [options.includeParallelism=false]
86
+ * @returns {string} Mermaid diagram source
87
+ */
88
+ function workflowToMermaid(workflow, options = {}) {
89
+ const {
90
+ theme = 'dark',
91
+ direction = 'TD',
92
+ includeStepIds = false,
93
+ includeToolEmoji = true,
94
+ colorCoded = true,
95
+ includeParallelism = false,
96
+ } = options;
97
+
98
+ const steps = workflow.steps || [];
99
+ if (steps.length === 0) return '';
100
+
101
+ const lines = [];
102
+
103
+ // Init directive
104
+ lines.push(`%%{init: {'theme': '${theme}', 'themeVariables': { 'primaryColor': '#00D4AA' }}}%%`);
105
+ lines.push(`graph ${direction}`);
106
+
107
+ // Class definitions
108
+ if (colorCoded) {
109
+ const usedCategories = new Set(steps.map((s) => getToolCategory(s.tool)));
110
+ for (const cat of usedCategories) {
111
+ if (CLASS_DEFS[cat]) lines.push(` ${CLASS_DEFS[cat]}`);
112
+ }
113
+ lines.push('');
114
+ }
115
+
116
+ // Build dependency info — buildDependencyGraph returns Map<string, Set<string>>
117
+ const depGraphMap = buildDependencyGraph(steps);
118
+ // Convert to plain object with arrays for easier iteration
119
+ const depGraph = {};
120
+ for (const [id, deps] of depGraphMap) {
121
+ depGraph[id] = [...deps];
122
+ }
123
+
124
+ // Build execution layers if needed (for subgraph grouping)
125
+ let layers = null;
126
+ if (includeParallelism) {
127
+ layers = buildLayers(steps, depGraph);
128
+ }
129
+
130
+ // Node definitions
131
+ if (layers && includeParallelism) {
132
+ layers.forEach((layerStepIds, i) => {
133
+ if (layerStepIds.length > 1) {
134
+ lines.push(` subgraph "Layer ${i + 1} (parallel)"`);
135
+ for (const sid of layerStepIds) {
136
+ const step = steps.find((s) => s.id === sid);
137
+ lines.push(` ${buildNode(step, { includeStepIds, includeToolEmoji })}`);
138
+ }
139
+ lines.push(' end');
140
+ } else if (layerStepIds.length === 1) {
141
+ const step = steps.find((s) => s.id === layerStepIds[0]);
142
+ lines.push(` ${buildNode(step, { includeStepIds, includeToolEmoji })}`);
143
+ }
144
+ });
145
+ } else {
146
+ for (const step of steps) {
147
+ lines.push(` ${buildNode(step, { includeStepIds, includeToolEmoji })}`);
148
+ }
149
+ }
150
+
151
+ lines.push('');
152
+
153
+ // Edges
154
+ const stepIds = new Set(steps.map((s) => s.id));
155
+ for (const step of steps) {
156
+ const deps = depGraph[step.id] || [];
157
+
158
+ // Also detect implicit deps from condition/forEach that reference step IDs
159
+ const implicitDeps = new Set();
160
+ if (step.condition) {
161
+ for (const sid of stepIds) {
162
+ if (step.condition.includes(sid) && sid !== step.id && !deps.includes(sid)) {
163
+ implicitDeps.add(sid);
164
+ }
165
+ }
166
+ }
167
+ if (step.forEach) {
168
+ for (const sid of stepIds) {
169
+ if (step.forEach.includes(sid) && sid !== step.id && !deps.includes(sid)) {
170
+ implicitDeps.add(sid);
171
+ }
172
+ }
173
+ }
174
+
175
+ for (const dep of deps) {
176
+ const hasCondition = step.condition && step.condition.includes(dep);
177
+ const arrow = hasCondition ? '-.->' : '-->';
178
+ lines.push(` ${dep} ${arrow} ${step.id}`);
179
+ }
180
+ for (const dep of implicitDeps) {
181
+ lines.push(` ${dep} -.-> ${step.id}`);
182
+ }
183
+ }
184
+
185
+ // Apply class assignments
186
+ if (colorCoded) {
187
+ lines.push('');
188
+ for (const step of steps) {
189
+ const cat = getToolCategory(step.tool);
190
+ lines.push(` class ${step.id} ${cat}`);
191
+ }
192
+ }
193
+
194
+ return lines.join('\n');
195
+ }
196
+
197
+ /**
198
+ * Build a single node definition string.
199
+ */
200
+ function buildNode(step, opts) {
201
+ const category = getToolCategory(step.tool);
202
+ const isConditional = !!step.condition;
203
+ const isForEach = !!step.forEach;
204
+
205
+ let label = step.name || step.description || step.id;
206
+ if (opts.includeToolEmoji && CATEGORY_EMOJI[category]) {
207
+ label = `${CATEGORY_EMOJI[category]} ${label}`;
208
+ }
209
+ if (opts.includeStepIds) {
210
+ label = `${label} (${step.id})`;
211
+ }
212
+ if (isConditional) {
213
+ const condText = step.condition.length > 30
214
+ ? step.condition.slice(0, 27) + '...'
215
+ : step.condition;
216
+ label = `${label}<br/><small>condition: ${condText}</small>`;
217
+ }
218
+ if (isForEach) {
219
+ label = `${label}<br/><small>∀ item in ${step.forEach}</small>`;
220
+ }
221
+
222
+ return wrapNodeShape(step.id, label, category, isConditional);
223
+ }
224
+
225
+ /**
226
+ * Build execution layers via topological sort / breadth-first layering.
227
+ */
228
+ function buildLayers(steps, depGraph) {
229
+ const inDegree = {};
230
+ const stepIds = steps.map((s) => s.id);
231
+ for (const id of stepIds) inDegree[id] = 0;
232
+ for (const [id, deps] of Object.entries(depGraph)) {
233
+ inDegree[id] = (deps || []).length;
234
+ }
235
+
236
+ const layers = [];
237
+ const remaining = new Set(stepIds);
238
+
239
+ while (remaining.size > 0) {
240
+ const layer = [];
241
+ for (const id of remaining) {
242
+ if ((inDegree[id] || 0) === 0) layer.push(id);
243
+ }
244
+ if (layer.length === 0) break; // cycle guard
245
+ layers.push(layer);
246
+ for (const id of layer) {
247
+ remaining.delete(id);
248
+ // Decrement in-degree of dependents
249
+ for (const [depId, deps] of Object.entries(depGraph)) {
250
+ if (remaining.has(depId) && deps.includes(id)) {
251
+ inDegree[depId]--;
252
+ }
253
+ }
254
+ }
255
+ }
256
+
257
+ return layers;
258
+ }
259
+
260
+ /**
261
+ * Render as Mermaid format for the export system.
262
+ * @param {object} normalized - workflow normalized data
263
+ * @param {object} options
264
+ * @returns {{ content: string, mimeType: string }}
265
+ */
266
+ function renderMermaid(normalized, options = {}) {
267
+ const mermaid = workflowToMermaid(normalized, options);
268
+ return {
269
+ content: mermaid,
270
+ mimeType: 'text/x-mermaid',
271
+ };
272
+ }
273
+
274
+ module.exports = { workflowToMermaid, renderMermaid, getToolCategory, buildLayers };
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Render a chat session (or any markdown content) as PDF using Playwright.
5
+ *
6
+ * @param {object} normalized
7
+ * @param {object} options
8
+ * @param {string} [options.theme='dark'] - 'dark' | 'light'
9
+ * @returns {Promise<{ content: Buffer, mimeType: string }>}
10
+ */
11
+ async function renderPdf(normalized, options = {}) {
12
+ if (normalized._context !== 'chat') {
13
+ throw new Error(`PDF export currently supported for chat sessions only (got: ${normalized._context})`);
14
+ }
15
+
16
+ const html = buildChatHtml(normalized, options);
17
+ const pdf = await htmlToPdf(html, options);
18
+ return { content: pdf, mimeType: 'application/pdf' };
19
+ }
20
+
21
+ /**
22
+ * Build styled HTML from a chat session for PDF rendering.
23
+ */
24
+ function buildChatHtml(chat, options = {}) {
25
+ const theme = options.theme || 'dark';
26
+ const isDark = theme === 'dark';
27
+ const bg = isDark ? '#1e1e1e' : '#ffffff';
28
+ const fg = isDark ? '#e0e0e0' : '#1a1a1a';
29
+ const userBg = isDark ? '#2a2a2a' : '#f5f5f5';
30
+ const asstBg = isDark ? '#1a2a3a' : '#e8f4f8';
31
+ const accent = '#00D4AA';
32
+
33
+ const turns = (chat.turns || []).map((t) => {
34
+ const isUser = t.role === 'user';
35
+ const roleName = isUser ? 'User' : 'vai';
36
+ const msgBg = isUser ? userBg : asstBg;
37
+ const content = escapeHtml(t.content || '').replace(/\n/g, '<br/>');
38
+
39
+ let sources = '';
40
+ if (t.context && t.context.length > 0 && options.includeSources !== false) {
41
+ const srcItems = t.context.map((s, i) => {
42
+ const rel = s.score !== undefined ? ` (relevance: ${s.score})` : '';
43
+ return `<li style="font-size:12px;color:#888;">${escapeHtml(s.source || 'unknown')}${rel}</li>`;
44
+ }).join('');
45
+ sources = `<ul style="margin:4px 0 0 16px;padding:0;">${srcItems}</ul>`;
46
+ }
47
+
48
+ let meta = '';
49
+ if (options.includeMetadata && t.metadata) {
50
+ const parts = [];
51
+ if (t.metadata.tokensUsed) parts.push(`${t.metadata.tokensUsed} tokens`);
52
+ if (t.metadata.retrievalTimeMs) parts.push(`retrieval: ${t.metadata.retrievalTimeMs}ms`);
53
+ if (t.metadata.generationTimeMs) parts.push(`generation: ${t.metadata.generationTimeMs}ms`);
54
+ if (parts.length) {
55
+ meta = `<div style="font-size:11px;color:#666;margin-top:4px;">${parts.join(' · ')}</div>`;
56
+ }
57
+ }
58
+
59
+ return `<div style="background:${msgBg};padding:12px 16px;border-radius:8px;margin:8px 0;">
60
+ <div style="font-weight:bold;color:${accent};margin-bottom:6px;">${roleName}</div>
61
+ <div>${content}</div>
62
+ ${sources}${meta}
63
+ </div>`;
64
+ }).join('');
65
+
66
+ const sessionId = chat.sessionId || chat.id || '';
67
+ const headerParts = [];
68
+ if (chat.startedAt) headerParts.push(`Date: ${chat.startedAt}`);
69
+ if (chat.provider && chat.model) headerParts.push(`Provider: ${chat.provider} (${chat.model})`);
70
+ else if (chat.model) headerParts.push(`Model: ${chat.model}`);
71
+ if (chat.collection) headerParts.push(`Knowledge Base: ${chat.collection}`);
72
+ headerParts.push(`Turns: ${(chat.turns || []).length}`);
73
+
74
+ return `<!DOCTYPE html>
75
+ <html><head><meta charset="utf-8">
76
+ <style>
77
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: ${bg}; color: ${fg}; margin: 40px; line-height: 1.6; }
78
+ h1 { color: ${accent}; font-size: 24px; margin-bottom: 4px; }
79
+ .meta { font-size: 13px; color: #888; margin-bottom: 24px; }
80
+ .footer { font-size: 11px; color: #666; text-align: center; margin-top: 40px; border-top: 1px solid #333; padding-top: 12px; }
81
+ </style>
82
+ </head><body>
83
+ <h1>Chat Session: ${escapeHtml(sessionId)}</h1>
84
+ <div class="meta">${headerParts.join(' · ')}</div>
85
+ <hr style="border:none;border-top:1px solid #333;margin:16px 0;">
86
+ ${turns}
87
+ <div class="footer">Generated by vai · voyageai-cli</div>
88
+ </body></html>`;
89
+ }
90
+
91
+ /**
92
+ * Convert HTML to PDF using Playwright.
93
+ */
94
+ async function htmlToPdf(html, options = {}) {
95
+ const { chromium } = require('playwright');
96
+ const browser = await chromium.launch({ headless: true });
97
+ try {
98
+ const page = await browser.newPage();
99
+ await page.setContent(html, { waitUntil: 'networkidle' });
100
+
101
+ const pdf = await page.pdf({
102
+ format: 'A4',
103
+ margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
104
+ printBackground: true,
105
+ });
106
+
107
+ return pdf;
108
+ } finally {
109
+ await browser.close();
110
+ }
111
+ }
112
+
113
+ function escapeHtml(str) {
114
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
115
+ }
116
+
117
+ module.exports = { renderPdf, buildChatHtml, htmlToPdf };
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Render PNG from Mermaid diagram via Playwright.
5
+ *
6
+ * @param {object} normalized
7
+ * @param {object} options
8
+ * @param {string} [options.resolution='2x'] - '1x' | '2x' | '3x'
9
+ * @param {string} [options.background='transparent'] - 'transparent' | 'dark' | 'light'
10
+ * @param {boolean} [options.includeWatermark=true]
11
+ * @param {boolean} [options.fitToContent=true]
12
+ * @returns {Promise<{ content: Buffer, mimeType: string }>}
13
+ */
14
+ async function renderPng(normalized, options = {}) {
15
+ // If raw PNG buffer provided (from Electron canvas capture)
16
+ if (normalized._pngBuffer) {
17
+ return { content: normalized._pngBuffer, mimeType: 'image/png' };
18
+ }
19
+
20
+ const { workflowToMermaid } = require('./mermaid-export');
21
+
22
+ if (normalized._context !== 'workflow' && normalized._context !== 'benchmark') {
23
+ throw new Error(`PNG export not supported for context: ${normalized._context}`);
24
+ }
25
+
26
+ const mermaidSrc = normalized._context === 'workflow'
27
+ ? workflowToMermaid(normalized, options)
28
+ : null;
29
+
30
+ if (!mermaidSrc) {
31
+ throw new Error('No Mermaid source available for PNG rendering');
32
+ }
33
+
34
+ const png = await renderMermaidToPng(mermaidSrc, options);
35
+ return { content: png, mimeType: 'image/png' };
36
+ }
37
+
38
+ /**
39
+ * Render Mermaid syntax to PNG using Playwright.
40
+ */
41
+ async function renderMermaidToPng(mermaidSrc, options = {}) {
42
+ const { chromium } = require('playwright');
43
+ const resolution = options.resolution || '2x';
44
+ const scale = parseInt(resolution) || 2;
45
+ const background = options.background || 'transparent';
46
+ const bgColor = background === 'dark' ? '#1e1e1e' : background === 'light' ? '#ffffff' : 'transparent';
47
+
48
+ const browser = await chromium.launch({ headless: true });
49
+ try {
50
+ const page = await browser.newPage({
51
+ deviceScaleFactor: scale,
52
+ });
53
+
54
+ const html = `<!DOCTYPE html>
55
+ <html><head>
56
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
57
+ </head><body style="margin:0;padding:16px;background:${bgColor};">
58
+ <pre class="mermaid">${escapeHtml(mermaidSrc)}</pre>
59
+ <script>mermaid.initialize({ startOnLoad: true, theme: '${options.theme || 'dark'}' });</script>
60
+ </body></html>`;
61
+
62
+ await page.setContent(html, { waitUntil: 'networkidle' });
63
+ await page.waitForSelector('svg', { timeout: 10000 });
64
+
65
+ const fitToContent = options.fitToContent !== false;
66
+
67
+ let screenshotOpts = { type: 'png' };
68
+ if (background === 'transparent') {
69
+ screenshotOpts.omitBackground = true;
70
+ }
71
+
72
+ if (fitToContent) {
73
+ const svgEl = await page.$('svg');
74
+ const box = await svgEl.boundingBox();
75
+ if (box) {
76
+ screenshotOpts.clip = {
77
+ x: Math.max(0, box.x - 8),
78
+ y: Math.max(0, box.y - 8),
79
+ width: box.width + 16,
80
+ height: box.height + 16,
81
+ };
82
+ }
83
+ }
84
+
85
+ const buffer = await page.screenshot(screenshotOpts);
86
+ return buffer;
87
+ } finally {
88
+ await browser.close();
89
+ }
90
+ }
91
+
92
+ function escapeHtml(str) {
93
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
94
+ }
95
+
96
+ module.exports = { renderPng, renderMermaidToPng };