squidclaw 3.0.0 → 3.1.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.
package/lib/ai/gateway.js CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { logger } from '../core/logger.js';
7
+ import { getRouteForTask } from './smart-router.js';
7
8
  import { MODEL_MAP, MODEL_PRICING } from '../core/config.js';
8
9
 
9
10
  export class AIGateway {
@@ -27,7 +28,13 @@ export class AIGateway {
27
28
  * Send a chat completion request with auto-fallback
28
29
  */
29
30
  async chat(messages, options = {}) {
30
- const model = options.model || this.config.ai?.defaultModel;
31
+ // Smart routing: use best model for the task
32
+ let routedModel = options.model;
33
+ if (!routedModel && options.taskHint) {
34
+ const route = getRouteForTask(options.taskHint, options.toolName, this.config);
35
+ if (route) routedModel = route.model;
36
+ }
37
+ const model = routedModel || this.config.ai?.defaultModel;
31
38
  const fallbackChain = options.fallbackChain || this.config.ai?.fallbackChain || [];
32
39
  const modelsToTry = [model, ...fallbackChain].filter(Boolean);
33
40
 
@@ -0,0 +1,77 @@
1
+ /**
2
+ * 🦑 Smart AI Router
3
+ * Routes to the best model based on task type
4
+ *
5
+ * - Creative content (PPT, reports, stories) → Gemini (free + creative)
6
+ * - Conversation, reasoning → Claude (smart)
7
+ * - Quick tasks, translations → Flash models (fast + cheap)
8
+ * - Code, analysis → Claude or GPT-4o
9
+ */
10
+
11
+ import { logger } from '../core/logger.js';
12
+
13
+ const TASK_ROUTES = {
14
+ // Creative content → Gemini
15
+ presentation: { provider: 'google', model: 'gemini-2.5-flash', reason: 'creative content' },
16
+ pptx: { provider: 'google', model: 'gemini-2.5-flash', reason: 'presentation' },
17
+ dashboard: { provider: 'google', model: 'gemini-2.5-flash', reason: 'visual content' },
18
+ report: { provider: 'google', model: 'gemini-2.5-flash', reason: 'report generation' },
19
+ story: { provider: 'google', model: 'gemini-2.5-flash', reason: 'creative writing' },
20
+ article: { provider: 'google', model: 'gemini-2.5-flash', reason: 'article writing' },
21
+ email_draft: { provider: 'google', model: 'gemini-2.5-flash', reason: 'email draft' },
22
+ excel: { provider: 'google', model: 'gemini-2.5-flash', reason: 'data generation' },
23
+ pdf: { provider: 'google', model: 'gemini-2.5-flash', reason: 'document' },
24
+
25
+ // Quick tasks → Flash (fast)
26
+ translate: { provider: 'google', model: 'gemini-2.0-flash', reason: 'translation' },
27
+ summarize: { provider: 'google', model: 'gemini-2.0-flash', reason: 'summarization' },
28
+ weather: { provider: 'google', model: 'gemini-2.0-flash', reason: 'simple lookup' },
29
+
30
+ // Default stays on primary model
31
+ };
32
+
33
+ // Detect task type from message + tool context
34
+ function detectTask(message, toolName) {
35
+ const lower = (message || '').toLowerCase();
36
+
37
+ // Explicit tool-based routing
38
+ if (toolName) {
39
+ if (['pptx', 'pptx_slides', 'pptx_pro', 'presentation', 'powerpoint'].includes(toolName)) return 'presentation';
40
+ if (['dashboard', 'canvas_dashboard'].includes(toolName)) return 'dashboard';
41
+ if (['excel', 'xlsx'].includes(toolName)) return 'excel';
42
+ if (['pdf'].includes(toolName)) return 'pdf';
43
+ if (['translate'].includes(toolName)) return 'translate';
44
+ }
45
+
46
+ // Message-based detection
47
+ if (lower.match(/\b(presentation|ppt|powerpoint|slides|عرض تقديمي|شرائح)\b/)) return 'presentation';
48
+ if (lower.match(/\b(dashboard|لوحة)\b/)) return 'dashboard';
49
+ if (lower.match(/\b(report|تقرير)\b/)) return 'report';
50
+ if (lower.match(/\b(story|قصة|حكاية)\b/)) return 'story';
51
+ if (lower.match(/\b(article|مقال)\b/)) return 'article';
52
+ if (lower.match(/\b(translate|ترجم)\b/)) return 'translate';
53
+ if (lower.match(/\b(summarize|summary|لخص|ملخص)\b/)) return 'summarize';
54
+ if (lower.match(/\b(excel|spreadsheet|جدول)\b/)) return 'excel';
55
+
56
+ return null; // Use default model
57
+ }
58
+
59
+ export function getRouteForTask(message, toolName, config) {
60
+ const task = detectTask(message, toolName);
61
+ if (!task) return null;
62
+
63
+ const route = TASK_ROUTES[task];
64
+ if (!route) return null;
65
+
66
+ // Check if provider is available
67
+ const providers = config?.ai?.providers || {};
68
+ if (!providers[route.provider]?.key) {
69
+ logger.debug('smart-router', `${route.provider} not available for ${task}, using default`);
70
+ return null;
71
+ }
72
+
73
+ logger.info('smart-router', `Routing "${task}" → ${route.provider}/${route.model} (${route.reason})`);
74
+ return { provider: route.provider, model: route.model, task, reason: route.reason };
75
+ }
76
+
77
+ export { detectTask, TASK_ROUTES };
@@ -0,0 +1,340 @@
1
+ /**
2
+ * 🦑 Dashboard PRO — Professional visual dashboards
3
+ * Renders beautiful dashboards as images via Puppeteer
4
+ *
5
+ * Features:
6
+ * - Auto-layout from markdown/data
7
+ * - Multiple chart types (CSS-based, no Chart.js needed)
8
+ * - Stats cards with gradients
9
+ * - Tables with alternating rows
10
+ * - Progress bars
11
+ * - KPI indicators (up/down arrows)
12
+ * - Dark/light themes
13
+ * - Responsive grid
14
+ */
15
+
16
+ import { mkdirSync, writeFileSync, existsSync } from 'fs';
17
+ import { join } from 'path';
18
+ import { logger } from '../core/logger.js';
19
+
20
+ const OUTPUT_DIR = '/tmp/squidclaw-dashboard';
21
+
22
+ const DASH_THEMES = {
23
+ dark: {
24
+ bg: '#0d1117', card: '#161b22', border: '#30363d', text: '#c9d1d9', subtle: '#8b949e',
25
+ accent: '#58a6ff', accent2: '#3fb950', warning: '#d29922', danger: '#f85149',
26
+ gradient: 'linear-gradient(135deg, #58a6ff 0%, #3fb950 100%)',
27
+ },
28
+ light: {
29
+ bg: '#f6f8fa', card: '#ffffff', border: '#d0d7de', text: '#24292f', subtle: '#656d76',
30
+ accent: '#0969da', accent2: '#1a7f37', warning: '#9a6700', danger: '#cf222e',
31
+ gradient: 'linear-gradient(135deg, #0969da 0%, #1a7f37 100%)',
32
+ },
33
+ saudi: {
34
+ bg: '#fafdf7', card: '#ffffff', border: '#bbf7d0', text: '#14532d', subtle: '#6b7280',
35
+ accent: '#006c35', accent2: '#c8a951', warning: '#b8860b', danger: '#dc2626',
36
+ gradient: 'linear-gradient(135deg, #006c35 0%, #c8a951 100%)',
37
+ },
38
+ ocean: {
39
+ bg: '#0a192f', card: '#112240', border: '#1e3a5f', text: '#ccd6f6', subtle: '#8892b0',
40
+ accent: '#64ffda', accent2: '#5eead4', warning: '#fbbf24', danger: '#f87171',
41
+ gradient: 'linear-gradient(135deg, #64ffda 0%, #5eead4 100%)',
42
+ },
43
+ neon: {
44
+ bg: '#0a0a0a', card: '#1a1a1a', border: '#333', text: '#e0e0e0', subtle: '#888',
45
+ accent: '#00ff88', accent2: '#00ccff', warning: '#ffcc00', danger: '#ff3366',
46
+ gradient: 'linear-gradient(135deg, #00ff88 0%, #00ccff 100%)',
47
+ },
48
+ };
49
+
50
+ function buildDashboardHtml(data, theme = 'dark') {
51
+ const t = DASH_THEMES[theme] || DASH_THEMES.dark;
52
+ const chartColors = [t.accent, t.accent2, t.warning, t.danger, '#bc8cff', '#f778ba', '#a5d6ff', '#7ee787'];
53
+
54
+ let html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><style>
55
+ * { margin: 0; padding: 0; box-sizing: border-box; }
56
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: ${t.bg}; color: ${t.text}; padding: 28px; min-width: 900px; }
57
+ .header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; padding-bottom: 16px; border-bottom: 2px solid ${t.border}; }
58
+ .header h1 { font-size: 26px; font-weight: 700; background: ${t.gradient}; -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
59
+ .header .date { font-size: 12px; color: ${t.subtle}; }
60
+ .grid { display: grid; gap: 16px; margin-bottom: 16px; }
61
+ .grid-2 { grid-template-columns: 1fr 1fr; }
62
+ .grid-3 { grid-template-columns: 1fr 1fr 1fr; }
63
+ .grid-4 { grid-template-columns: 1fr 1fr 1fr 1fr; }
64
+ .card { background: ${t.card}; border: 1px solid ${t.border}; border-radius: 12px; padding: 20px; position: relative; overflow: hidden; }
65
+ .card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; background: ${t.gradient}; }
66
+ .card h3 { font-size: 13px; color: ${t.subtle}; font-weight: 500; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
67
+
68
+ /* Stats */
69
+ .stat-card { text-align: center; padding: 24px 16px; }
70
+ .stat-icon { font-size: 28px; margin-bottom: 8px; }
71
+ .stat-value { font-size: 32px; font-weight: 800; background: ${t.gradient}; -webkit-background-clip: text; -webkit-text-fill-color: transparent; line-height: 1.1; }
72
+ .stat-label { font-size: 12px; color: ${t.subtle}; margin-top: 6px; font-weight: 500; }
73
+ .stat-change { font-size: 11px; margin-top: 4px; }
74
+ .stat-change.up { color: ${t.accent2}; }
75
+ .stat-change.down { color: ${t.danger}; }
76
+
77
+ /* Bar chart */
78
+ .bar-row { display: flex; align-items: center; margin: 8px 0; }
79
+ .bar-label { width: 100px; font-size: 12px; color: ${t.subtle}; flex-shrink: 0; }
80
+ .bar-track { flex: 1; height: 28px; background: ${t.bg}; border-radius: 6px; overflow: hidden; position: relative; }
81
+ .bar-fill { height: 100%; border-radius: 6px; display: flex; align-items: center; padding-left: 10px; transition: width 0.5s ease; }
82
+ .bar-value { font-size: 11px; color: #fff; font-weight: 700; }
83
+
84
+ /* Donut chart (CSS) */
85
+ .donut-container { display: flex; align-items: center; gap: 24px; }
86
+ .donut { width: 160px; height: 160px; border-radius: 50%; position: relative; flex-shrink: 0; }
87
+ .donut-hole { position: absolute; top: 20%; left: 20%; width: 60%; height: 60%; border-radius: 50%; background: ${t.card}; display: flex; align-items: center; justify-content: center; flex-direction: column; }
88
+ .donut-total { font-size: 22px; font-weight: 800; color: ${t.text}; }
89
+ .donut-subtitle { font-size: 10px; color: ${t.subtle}; }
90
+ .legend { display: flex; flex-direction: column; gap: 8px; }
91
+ .legend-item { display: flex; align-items: center; gap: 8px; font-size: 12px; }
92
+ .legend-dot { width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
93
+
94
+ /* Table */
95
+ table { width: 100%; border-collapse: collapse; }
96
+ th { background: ${t.bg}; color: ${t.accent}; padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 2px solid ${t.border}; }
97
+ td { padding: 10px 12px; font-size: 12px; border-bottom: 1px solid ${t.border}; }
98
+ tr:nth-child(even) { background: ${t.bg}40; }
99
+ tr:hover { background: ${t.accent}10; }
100
+
101
+ /* Progress */
102
+ .progress-row { display: flex; align-items: center; gap: 12px; margin: 10px 0; }
103
+ .progress-label { width: 120px; font-size: 12px; }
104
+ .progress-track { flex: 1; height: 10px; background: ${t.bg}; border-radius: 5px; overflow: hidden; }
105
+ .progress-fill { height: 100%; border-radius: 5px; }
106
+ .progress-pct { font-size: 12px; font-weight: 700; width: 40px; text-align: right; }
107
+
108
+ /* Section */
109
+ .section-title { font-size: 15px; font-weight: 700; color: ${t.text}; margin: 20px 0 12px; padding-left: 12px; border-left: 3px solid ${t.accent}; }
110
+
111
+ /* Footer */
112
+ .footer { text-align: center; margin-top: 20px; padding-top: 12px; border-top: 1px solid ${t.border}; font-size: 10px; color: ${t.subtle}; }
113
+ </style></head><body>`;
114
+
115
+ // Header
116
+ html += `<div class="header"><h1>${data.title || 'Dashboard'}</h1><div class="date">${data.subtitle || new Date().toLocaleString()}</div></div>`;
117
+
118
+ // Render sections
119
+ for (const section of (data.sections || [])) {
120
+ if (section.heading) {
121
+ html += `<div class="section-title">${section.heading}</div>`;
122
+ }
123
+
124
+ // Stats
125
+ if (section.type === 'stats' && section.items) {
126
+ const cols = Math.min(section.items.length, 4);
127
+ html += `<div class="grid grid-${cols}">`;
128
+ for (const stat of section.items) {
129
+ const changeClass = stat.change ? (stat.change.startsWith('+') || stat.change.startsWith('↑') ? 'up' : 'down') : '';
130
+ html += `<div class="card stat-card">
131
+ <div class="stat-icon">${stat.icon || '📊'}</div>
132
+ <div class="stat-value">${stat.value}</div>
133
+ <div class="stat-label">${stat.label}</div>
134
+ ${stat.change ? `<div class="stat-change ${changeClass}">${stat.change}</div>` : ''}
135
+ </div>`;
136
+ }
137
+ html += `</div>`;
138
+ }
139
+
140
+ // Bar chart
141
+ if (section.type === 'bar' && section.items) {
142
+ const max = Math.max(...section.items.map(i => i.value));
143
+ html += `<div class="card"><h3>${section.title || 'Chart'}</h3>`;
144
+ section.items.forEach((item, idx) => {
145
+ const pct = (item.value / max * 100).toFixed(0);
146
+ const color = item.color || chartColors[idx % chartColors.length];
147
+ html += `<div class="bar-row">
148
+ <div class="bar-label">${item.label}</div>
149
+ <div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:${color}"><span class="bar-value">${item.value}</span></div></div>
150
+ </div>`;
151
+ });
152
+ html += `</div>`;
153
+ }
154
+
155
+ // Donut/Pie
156
+ if ((section.type === 'donut' || section.type === 'pie') && section.items) {
157
+ const total = section.items.reduce((s, i) => s + i.value, 0);
158
+ let rotation = 0;
159
+ const gradParts = [];
160
+ section.items.forEach((item, idx) => {
161
+ const pct = item.value / total * 360;
162
+ const color = item.color || chartColors[idx % chartColors.length];
163
+ gradParts.push(`${color} ${rotation}deg ${rotation + pct}deg`);
164
+ rotation += pct;
165
+ });
166
+
167
+ html += `<div class="card"><h3>${section.title || 'Distribution'}</h3>
168
+ <div class="donut-container">
169
+ <div class="donut" style="background:conic-gradient(${gradParts.join(',')})">
170
+ <div class="donut-hole"><div class="donut-total">${total}</div><div class="donut-subtitle">Total</div></div>
171
+ </div>
172
+ <div class="legend">`;
173
+ section.items.forEach((item, idx) => {
174
+ const color = item.color || chartColors[idx % chartColors.length];
175
+ html += `<div class="legend-item"><div class="legend-dot" style="background:${color}"></div>${item.label}: <strong>${item.value}</strong> (${(item.value / total * 100).toFixed(0)}%)</div>`;
176
+ });
177
+ html += `</div></div></div>`;
178
+ }
179
+
180
+ // Table
181
+ if (section.type === 'table' && section.rows) {
182
+ html += `<div class="card"><h3>${section.title || 'Data'}</h3><table>`;
183
+ if (section.headers) {
184
+ html += `<tr>${section.headers.map(h => `<th>${h}</th>`).join('')}</tr>`;
185
+ }
186
+ for (const row of section.rows) {
187
+ html += `<tr>${row.map(c => `<td>${c}</td>`).join('')}</tr>`;
188
+ }
189
+ html += `</table></div>`;
190
+ }
191
+
192
+ // Progress bars
193
+ if (section.type === 'progress' && section.items) {
194
+ html += `<div class="card"><h3>${section.title || 'Progress'}</h3>`;
195
+ section.items.forEach((item, idx) => {
196
+ const color = item.color || chartColors[idx % chartColors.length];
197
+ html += `<div class="progress-row">
198
+ <div class="progress-label">${item.label}</div>
199
+ <div class="progress-track"><div class="progress-fill" style="width:${item.value}%;background:${color}"></div></div>
200
+ <div class="progress-pct" style="color:${color}">${item.value}%</div>
201
+ </div>`;
202
+ });
203
+ html += `</div>`;
204
+ }
205
+
206
+ // Two cards side by side
207
+ if (section.type === 'grid-2' && section.left && section.right) {
208
+ html += `<div class="grid grid-2">`;
209
+ html += `<div class="card"><h3>${section.left.title || ''}</h3>${section.left.html || ''}</div>`;
210
+ html += `<div class="card"><h3>${section.right.title || ''}</h3>${section.right.html || ''}</div>`;
211
+ html += `</div>`;
212
+ }
213
+ }
214
+
215
+ // Footer
216
+ html += `<div class="footer">Squidclaw AI 🦑 · Generated ${new Date().toLocaleString()}</div>`;
217
+ html += `</body></html>`;
218
+
219
+ return html;
220
+ }
221
+
222
+ // Smart parser: markdown → dashboard data
223
+ function parseMarkdownToDashboard(content, title) {
224
+ const sections = [];
225
+ const lines = content.split('\n').map(l => l.trim()).filter(Boolean);
226
+ let current = null;
227
+
228
+ for (const line of lines) {
229
+ if (line.startsWith('## ') || line.startsWith('# ')) {
230
+ if (current) sections.push(current);
231
+ current = { heading: line.replace(/^#+\s*/, ''), type: null, items: [], rows: [], headers: null, title: '' };
232
+ continue;
233
+ }
234
+ if (!current) current = { type: null, items: [], rows: [], headers: null, title: '' };
235
+
236
+ if (line.match(/^\[stats?\]/i)) { current.type = 'stats'; continue; }
237
+ if (line.match(/^\[bar\s*chart?\]/i) || line.match(/^\[chart\]/i)) { current.type = 'bar'; continue; }
238
+ if (line.match(/^\[donut|pie\]/i)) { current.type = 'donut'; continue; }
239
+ if (line.match(/^\[table\]/i)) { current.type = 'table'; continue; }
240
+ if (line.match(/^\[progress\]/i)) { current.type = 'progress'; continue; }
241
+
242
+ // Stats line: - icon value — label (change)
243
+ if ((current.type === 'stats' || !current.type) && line.startsWith('-')) {
244
+ const m = line.match(/^-\s*(.+?)\s+(.+?)\s*[—–-]\s*(.+?)(?:\s*\((.+?)\))?$/);
245
+ if (m) {
246
+ current.type = 'stats';
247
+ current.items.push({ icon: m[1], value: m[2], label: m[3].trim(), change: m[4] || null });
248
+ continue;
249
+ }
250
+ }
251
+
252
+ // Chart/bar: - label: value
253
+ if ((current.type === 'bar' || current.type === 'donut') && line.startsWith('-')) {
254
+ const m = line.match(/^-\s*(.+?):\s*(\d+)/);
255
+ if (m) { current.items.push({ label: m[1], value: parseInt(m[2]) }); continue; }
256
+ }
257
+
258
+ // Progress: - label: value%
259
+ if (current.type === 'progress' && line.startsWith('-')) {
260
+ const m = line.match(/^-\s*(.+?):\s*(\d+)%?/);
261
+ if (m) { current.items.push({ label: m[1], value: parseInt(m[2]) }); continue; }
262
+ }
263
+
264
+ // Table
265
+ if (line.startsWith('|') && line.endsWith('|')) {
266
+ if (line.match(/^\|[\s-:|]+\|$/)) continue;
267
+ const cells = line.split('|').filter(c => c.trim()).map(c => c.trim());
268
+ if (!current.headers) { current.headers = cells; current.type = 'table'; }
269
+ else current.rows.push(cells);
270
+ continue;
271
+ }
272
+
273
+ // Auto-detect chart data from plain "- label: number"
274
+ if (line.startsWith('-') && !current.type) {
275
+ const m = line.match(/^-\s*(.+?):\s*(\d+)/);
276
+ if (m) { current.type = 'bar'; current.items.push({ label: m[1], value: parseInt(m[2]) }); continue; }
277
+ }
278
+ }
279
+ if (current) sections.push(current);
280
+
281
+ return { title, sections };
282
+ }
283
+
284
+ export async function renderDashboard(input) {
285
+ mkdirSync(OUTPUT_DIR, { recursive: true });
286
+
287
+ let data, theme;
288
+ if (typeof input === 'string') {
289
+ // Parse markdown
290
+ const parts = input.split('|');
291
+ const title = parts[0]?.trim() || 'Dashboard';
292
+ theme = parts[1]?.trim() || 'dark';
293
+ const content = parts.slice(2).join('|') || parts.slice(1).join('|');
294
+ data = parseMarkdownToDashboard(content, title);
295
+ } else {
296
+ data = input.data || input;
297
+ theme = input.theme || 'dark';
298
+ }
299
+
300
+ const dashHtml = buildDashboardHtml(data, theme);
301
+
302
+ // Try Puppeteer
303
+ try {
304
+ const puppeteer = await import('puppeteer-core');
305
+ const paths = ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium-browser', '/usr/bin/chromium', '/snap/bin/chromium'];
306
+ let execPath = null;
307
+ for (const p of paths) { if (existsSync(p)) { execPath = p; break; } }
308
+
309
+ if (execPath) {
310
+ const browser = await puppeteer.default.launch({
311
+ executablePath: execPath, headless: 'new',
312
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
313
+ });
314
+ const page = await browser.newPage();
315
+ await page.setViewport({ width: 1000, height: 800 });
316
+ await page.setContent(dashHtml, { waitUntil: 'networkidle0', timeout: 10000 });
317
+ const height = await page.evaluate(() => document.body.scrollHeight);
318
+ await page.setViewport({ width: 1000, height: Math.min(height + 40, 3000) });
319
+ const screenshot = await page.screenshot({ type: 'png', fullPage: true });
320
+ await browser.close();
321
+
322
+ const filename = 'dashboard_' + Date.now() + '.png';
323
+ const filepath = join(OUTPUT_DIR, filename);
324
+ writeFileSync(filepath, screenshot);
325
+
326
+ logger.info('dashboard', `Rendered: ${filepath}`);
327
+ return { filepath, filename, buffer: screenshot, html: dashHtml };
328
+ }
329
+ } catch (err) {
330
+ logger.warn('dashboard', 'Puppeteer failed: ' + err.message);
331
+ }
332
+
333
+ // Fallback: save HTML
334
+ const filename = 'dashboard_' + Date.now() + '.html';
335
+ const filepath = join(OUTPUT_DIR, filename);
336
+ writeFileSync(filepath, dashHtml);
337
+ return { filepath, filename, html: dashHtml };
338
+ }
339
+
340
+ export { buildDashboardHtml, parseMarkdownToDashboard, DASH_THEMES };
@@ -0,0 +1,686 @@
1
+ /**
2
+ * 🦑 PPTX PRO — Smart Presentation Engine
3
+ *
4
+ * Takes raw content and auto-generates professional multi-slide decks.
5
+ * AI just says what it wants → engine handles layout, design, charts, animations.
6
+ *
7
+ * Smart features:
8
+ * - Auto-detects content type (stats, table, chart, quote, timeline, comparison)
9
+ * - Markdown → proper slides conversion
10
+ * - Professional layouts with consistent branding
11
+ * - Master slide designs with gradients, shapes, accents
12
+ * - Auto icon assignment
13
+ * - Smart color palettes
14
+ */
15
+
16
+ import PptxGenJS from 'pptxgenjs';
17
+ import { mkdirSync, existsSync } from 'fs';
18
+ import { join } from 'path';
19
+ import { logger } from '../core/logger.js';
20
+
21
+ const OUTPUT_DIR = '/tmp/squidclaw-pptx';
22
+
23
+ // ── Professional Themes ──
24
+
25
+ const THEMES = {
26
+ executive: {
27
+ bg: '0D1117', accent: '58A6FF', accent2: '3FB950', text: 'FFFFFF', subtle: '8B949E',
28
+ gradStart: '0D1117', gradEnd: '161B22', cardBg: '161B22', border: '30363D',
29
+ },
30
+ corporate: {
31
+ bg: 'FFFFFF', accent: '1B4F72', accent2: '2E86C1', text: '2C3E50', subtle: '7F8C8D',
32
+ gradStart: '1B4F72', gradEnd: '2E86C1', cardBg: 'F8F9FA', border: 'DEE2E6',
33
+ },
34
+ saudi: {
35
+ bg: 'FFFFFF', accent: '006C35', accent2: 'C8A951', text: '1A1A2E', subtle: '6B7280',
36
+ gradStart: '006C35', gradEnd: '004D25', cardBg: 'F0FDF4', border: 'BBF7D0',
37
+ },
38
+ modern: {
39
+ bg: '1A1A2E', accent: 'E94560', accent2: '0F3460', text: 'EAEAEA', subtle: '9CA3AF',
40
+ gradStart: '16213E', gradEnd: '1A1A2E', cardBg: '16213E', border: '374151',
41
+ },
42
+ ocean: {
43
+ bg: '0A192F', accent: '64FFDA', accent2: '5EEAD4', text: 'CCD6F6', subtle: '8892B0',
44
+ gradStart: '0A192F', gradEnd: '112240', cardBg: '112240', border: '1E3A5F',
45
+ },
46
+ gradient: {
47
+ bg: '0F0C29', accent: 'F7971E', accent2: 'FFD200', text: 'FFFFFF', subtle: 'B0B0B0',
48
+ gradStart: '302B63', gradEnd: '24243E', cardBg: '1E1E3F', border: '4A4A7A',
49
+ },
50
+ minimal: {
51
+ bg: 'FAFAFA', accent: '111111', accent2: '555555', text: '111111', subtle: '999999',
52
+ gradStart: '111111', gradEnd: '333333', cardBg: 'FFFFFF', border: 'E5E5E5',
53
+ },
54
+ fire: {
55
+ bg: '1A0000', accent: 'FF4500', accent2: 'FF8C00', text: 'FFFFFF', subtle: 'CC9999',
56
+ gradStart: '8B0000', gradEnd: '1A0000', cardBg: '2D0000', border: '661111',
57
+ },
58
+ };
59
+
60
+ // ── Auto Icons ──
61
+ const TOPIC_ICONS = {
62
+ revenue: '💰', money: '💰', gdp: '💰', profit: '💰', income: '💰', cost: '💰', price: '💰', budget: '💰', finance: '💰',
63
+ growth: '📈', increase: '📈', rise: '📈', trend: '📈', up: '📈',
64
+ users: '👥', people: '👥', team: '👥', employees: '👥', population: '👥', workforce: '👥',
65
+ time: '⏱️', speed: '⚡', fast: '⚡', performance: '⚡',
66
+ global: '🌍', world: '🌍', international: '🌍', country: '🌍',
67
+ tech: '💻', software: '💻', digital: '💻', ai: '🤖', data: '📊',
68
+ security: '🔒', safe: '🔒', protect: '🛡️',
69
+ health: '🏥', medical: '🏥', hospital: '🏥',
70
+ education: '🎓', school: '🎓', university: '🎓', training: '🎓',
71
+ oil: '⛽', energy: '⚡', power: '⚡',
72
+ tourism: '✈️', travel: '✈️', visitors: '✈️',
73
+ construction: '🏗️', building: '🏗️', infrastructure: '🏗️',
74
+ investment: '📊', fdi: '📊', invest: '📊',
75
+ target: '🎯', goal: '🎯', objective: '🎯',
76
+ success: '✅', complete: '✅', done: '✅', achievement: '🏆',
77
+ };
78
+
79
+ function autoIcon(text) {
80
+ const lower = text.toLowerCase();
81
+ for (const [key, icon] of Object.entries(TOPIC_ICONS)) {
82
+ if (lower.includes(key)) return icon;
83
+ }
84
+ return '📌';
85
+ }
86
+
87
+ // ── Smart Content Parser ──
88
+
89
+ function parseMarkdownToSlides(content) {
90
+ const slides = [];
91
+ const lines = content.split('\n').map(l => l.trim()).filter(Boolean);
92
+
93
+ let currentSlide = null;
94
+ let currentSection = null;
95
+
96
+ for (let i = 0; i < lines.length; i++) {
97
+ const line = lines[i];
98
+
99
+ // H1/H2 = new slide
100
+ if (line.startsWith('# ') || line.startsWith('## ')) {
101
+ if (currentSlide) slides.push(currentSlide);
102
+ const title = line.replace(/^#+\s*/, '').replace(/\*\*/g, '');
103
+ currentSlide = { title, type: 'content', body: [], stats: [], table: [], chart: [], bullets: [], timeline: [], comparisons: [], quote: null };
104
+ currentSection = 'body';
105
+ continue;
106
+ }
107
+
108
+ if (!currentSlide) {
109
+ currentSlide = { title: 'Overview', type: 'content', body: [], stats: [], table: [], chart: [], bullets: [], timeline: [], comparisons: [], quote: null };
110
+ }
111
+
112
+ // [stats] marker
113
+ if (line.match(/^\[stats?\]/i)) { currentSection = 'stats'; continue; }
114
+ if (line.match(/^\[table\]/i)) { currentSection = 'table'; continue; }
115
+ if (line.match(/^\[chart\]/i)) { currentSection = 'chart'; continue; }
116
+ if (line.match(/^\[timeline\]/i)) { currentSection = 'timeline'; continue; }
117
+ if (line.match(/^\[compare|comparison\]/i)) { currentSection = 'comparison'; continue; }
118
+ if (line.match(/^\[quote\]/i)) { currentSection = 'quote'; continue; }
119
+
120
+ // Stats: - icon value — label OR - label: value
121
+ if (currentSection === 'stats' && line.startsWith('-')) {
122
+ const m = line.match(/^-\s*(.+?)\s+(.+?)\s*[—–-]\s*(.+)/) || line.match(/^-\s*(.+?):\s*(.+)/);
123
+ if (m) {
124
+ if (m[3]) {
125
+ currentSlide.stats.push({ icon: m[1].trim(), value: m[2].trim(), label: m[3].trim() });
126
+ } else {
127
+ currentSlide.stats.push({ icon: autoIcon(m[1]), value: m[2].trim(), label: m[1].trim() });
128
+ }
129
+ }
130
+ continue;
131
+ }
132
+
133
+ // Table: | col | col |
134
+ if (line.startsWith('|') && line.endsWith('|')) {
135
+ if (line.match(/^\|[\s-:|]+\|$/)) continue; // separator row
136
+ const cells = line.split('|').filter(c => c.trim()).map(c => c.trim());
137
+ currentSlide.table.push(cells);
138
+ currentSection = 'table';
139
+ continue;
140
+ }
141
+
142
+ // Chart: - label: value
143
+ if (currentSection === 'chart' && line.startsWith('-')) {
144
+ const m = line.match(/^-\s*(.+?):\s*(\d+)/);
145
+ if (m) currentSlide.chart.push({ label: m[1].trim(), value: parseInt(m[2]) });
146
+ continue;
147
+ }
148
+
149
+ // Timeline: N. Title — description
150
+ if (currentSection === 'timeline' && line.match(/^\d+\./)) {
151
+ const m = line.match(/^(\d+)\.\s*\*?\*?(.+?)\*?\*?\s*[—–-]\s*(.+)/);
152
+ if (m) currentSlide.timeline.push({ year: m[1], title: m[2].trim(), desc: m[3].trim() });
153
+ continue;
154
+ }
155
+
156
+ // Quote
157
+ if (line.startsWith('>') || currentSection === 'quote') {
158
+ const q = line.replace(/^>\s*/, '').replace(/\*\*/g, '');
159
+ if (q) currentSlide.quote = (currentSlide.quote || '') + q + ' ';
160
+ currentSection = 'quote';
161
+ continue;
162
+ }
163
+
164
+ // Bullets
165
+ if (line.startsWith('-') || line.startsWith('•') || line.startsWith('*')) {
166
+ const bullet = line.replace(/^[-•*]\s*/, '').replace(/\*\*/g, '');
167
+ currentSlide.bullets.push(bullet);
168
+ continue;
169
+ }
170
+
171
+ // H3 = subsection title, add as body
172
+ if (line.startsWith('### ')) {
173
+ currentSlide.body.push({ type: 'heading', text: line.replace(/^###\s*/, '') });
174
+ continue;
175
+ }
176
+
177
+ // Plain text
178
+ currentSlide.body.push({ type: 'text', text: line.replace(/\*\*/g, '') });
179
+ }
180
+
181
+ if (currentSlide) slides.push(currentSlide);
182
+
183
+ // Auto-detect best slide type
184
+ for (const slide of slides) {
185
+ if (slide.stats.length >= 3) slide.type = 'stats';
186
+ else if (slide.table.length >= 2) slide.type = 'table';
187
+ else if (slide.chart.length >= 2) slide.type = 'chart';
188
+ else if (slide.timeline.length >= 2) slide.type = 'timeline';
189
+ else if (slide.quote) slide.type = 'quote';
190
+ else if (slide.bullets.length >= 3 && slide.stats.length >= 2) slide.type = 'stats-bullets';
191
+ else if (slide.bullets.length >= 2) slide.type = 'bullets';
192
+ }
193
+
194
+ // Split oversized slides
195
+ const final = [];
196
+ for (const slide of slides) {
197
+ if (slide.stats.length > 6) {
198
+ // Split stats across slides
199
+ for (let j = 0; j < slide.stats.length; j += 4) {
200
+ final.push({ ...slide, stats: slide.stats.slice(j, j + 4), type: 'stats', title: j === 0 ? slide.title : slide.title + ' (cont.)' });
201
+ }
202
+ } else if (slide.type === 'content' && slide.body.length > 8) {
203
+ // Split long content
204
+ const mid = Math.ceil(slide.body.length / 2);
205
+ final.push({ ...slide, body: slide.body.slice(0, mid) });
206
+ final.push({ ...slide, body: slide.body.slice(mid), title: slide.title + ' (cont.)' });
207
+ } else {
208
+ final.push(slide);
209
+ }
210
+
211
+ // Also break out table/chart as separate slides if they exist alongside stats
212
+ if (slide.table.length >= 2 && slide.type !== 'table') {
213
+ final.push({ title: slide.title + ' — Data', type: 'table', table: slide.table, body: [], stats: [], chart: [], bullets: [], timeline: [], comparisons: [], quote: null });
214
+ }
215
+ if (slide.chart.length >= 2 && slide.type !== 'chart') {
216
+ final.push({ title: slide.title + ' — Chart', type: 'chart', chart: slide.chart, body: [], stats: [], table: [], bullets: [], timeline: [], comparisons: [], quote: null });
217
+ }
218
+ }
219
+
220
+ return final;
221
+ }
222
+
223
+ // ── Slide Renderers ──
224
+
225
+ function addTitleSlide(pptx, title, subtitle, theme) {
226
+ const slide = pptx.addSlide();
227
+ const t = THEMES[theme] || THEMES.executive;
228
+
229
+ slide.background = { fill: t.gradStart };
230
+
231
+ // Accent bar top
232
+ slide.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: '100%', h: 0.06, fill: { color: t.accent } });
233
+
234
+ // Decorative circle
235
+ slide.addShape(pptx.ShapeType.ellipse, {
236
+ x: 7.5, y: 2, w: 3.5, h: 3.5,
237
+ fill: { color: t.accent, transparency: 90 },
238
+ line: { color: t.accent, width: 2, transparency: 50 },
239
+ });
240
+ slide.addShape(pptx.ShapeType.ellipse, {
241
+ x: 8, y: 2.5, w: 2.5, h: 2.5,
242
+ fill: { color: t.accent2, transparency: 92 },
243
+ line: { color: t.accent2, width: 1, transparency: 60 },
244
+ });
245
+
246
+ slide.addText(title, {
247
+ x: 0.8, y: 1.5, w: 7, h: 2,
248
+ fontSize: 40, fontFace: 'Arial', color: t.text, bold: true,
249
+ lineSpacingMultiple: 1.1,
250
+ });
251
+
252
+ if (subtitle) {
253
+ slide.addText(subtitle, {
254
+ x: 0.8, y: 3.5, w: 6, h: 0.6,
255
+ fontSize: 16, fontFace: 'Arial', color: t.subtle,
256
+ });
257
+ }
258
+
259
+ // Bottom accent line
260
+ slide.addShape(pptx.ShapeType.rect, { x: 0.8, y: 3.2, w: 2, h: 0.05, fill: { color: t.accent } });
261
+
262
+ // Footer
263
+ addFooter(slide, pptx, t, null, null);
264
+ }
265
+
266
+ function addStatsSlide(pptx, slideData, theme, slideNum, total) {
267
+ const slide = pptx.addSlide();
268
+ const t = THEMES[theme] || THEMES.executive;
269
+
270
+ slide.background = { fill: t.bg };
271
+ addSlideHeader(slide, pptx, slideData.title, t);
272
+
273
+ const stats = slideData.stats.slice(0, 6);
274
+ const cols = Math.min(stats.length, 3);
275
+ const rows = Math.ceil(stats.length / cols);
276
+ const cardW = 2.6;
277
+ const cardH = 1.6;
278
+ const gap = 0.3;
279
+ const startX = (10 - (cols * cardW + (cols - 1) * gap)) / 2;
280
+ const startY = rows > 1 ? 1.8 : 2.2;
281
+
282
+ stats.forEach((stat, i) => {
283
+ const col = i % cols;
284
+ const row = Math.floor(i / cols);
285
+ const x = startX + col * (cardW + gap);
286
+ const y = startY + row * (cardH + gap);
287
+
288
+ // Card background
289
+ slide.addShape(pptx.ShapeType.roundRect, {
290
+ x, y, w: cardW, h: cardH, rectRadius: 0.15,
291
+ fill: { color: t.cardBg },
292
+ line: { color: t.border, width: 1 },
293
+ shadow: { type: 'outer', blur: 6, offset: 2, color: '000000', opacity: 0.15 },
294
+ });
295
+
296
+ // Icon
297
+ slide.addText(stat.icon || '📊', {
298
+ x, y: y + 0.15, w: cardW, h: 0.45,
299
+ fontSize: 24, align: 'center',
300
+ });
301
+
302
+ // Value
303
+ slide.addText(stat.value, {
304
+ x, y: y + 0.55, w: cardW, h: 0.5,
305
+ fontSize: 26, fontFace: 'Arial', color: t.accent, bold: true, align: 'center',
306
+ });
307
+
308
+ // Label
309
+ slide.addText(stat.label, {
310
+ x, y: y + 1.05, w: cardW, h: 0.35,
311
+ fontSize: 11, fontFace: 'Arial', color: t.subtle, align: 'center',
312
+ });
313
+ });
314
+
315
+ // Add bullets below if they exist
316
+ if (slideData.bullets.length > 0) {
317
+ const bulletY = startY + rows * (cardH + gap) + 0.2;
318
+ const bulletText = slideData.bullets.map(b => ({ text: ' • ' + b + '\n', options: { fontSize: 12, color: t.text } }));
319
+ slide.addText(bulletText, {
320
+ x: 0.8, y: bulletY, w: 8.4, h: 5 - bulletY,
321
+ fontFace: 'Arial', valign: 'top', lineSpacingMultiple: 1.4,
322
+ });
323
+ }
324
+
325
+ addFooter(slide, pptx, t, slideNum, total);
326
+ }
327
+
328
+ function addTableSlide(pptx, slideData, theme, slideNum, total) {
329
+ const slide = pptx.addSlide();
330
+ const t = THEMES[theme] || THEMES.executive;
331
+
332
+ slide.background = { fill: t.bg };
333
+ addSlideHeader(slide, pptx, slideData.title, t);
334
+
335
+ if (slideData.table.length < 2) return;
336
+
337
+ const headers = slideData.table[0];
338
+ const rows = slideData.table.slice(1);
339
+ const tableData = [headers, ...rows];
340
+
341
+ const colW = Math.min(8.4 / headers.length, 3);
342
+
343
+ slide.addTable(tableData.map((row, ri) =>
344
+ row.map(cell => ({
345
+ text: cell,
346
+ options: {
347
+ fontSize: ri === 0 ? 12 : 11,
348
+ fontFace: 'Arial',
349
+ color: ri === 0 ? 'FFFFFF' : t.text,
350
+ bold: ri === 0,
351
+ fill: { color: ri === 0 ? t.accent : (ri % 2 === 0 ? t.cardBg : t.bg) },
352
+ border: { type: 'solid', color: t.border, pt: 0.5 },
353
+ valign: 'middle',
354
+ align: 'center',
355
+ margin: [4, 6, 4, 6],
356
+ }
357
+ }))
358
+ ), {
359
+ x: 0.8, y: 1.8, w: 8.4,
360
+ autoPage: true,
361
+ autoPageRepeatHeader: true,
362
+ });
363
+
364
+ addFooter(slide, pptx, t, slideNum, total);
365
+ }
366
+
367
+ function addChartSlide(pptx, slideData, theme, slideNum, total) {
368
+ const slide = pptx.addSlide();
369
+ const t = THEMES[theme] || THEMES.executive;
370
+
371
+ slide.background = { fill: t.bg };
372
+ addSlideHeader(slide, pptx, slideData.title, t);
373
+
374
+ const items = slideData.chart;
375
+ if (items.length === 0) return;
376
+
377
+ const chartColors = [t.accent, t.accent2, 'D29922', 'F85149', 'BC8CFF', 'F778BA', 'A5D6FF', '7EE787'];
378
+
379
+ // Determine chart type
380
+ const useDonut = items.length <= 6;
381
+
382
+ if (useDonut) {
383
+ slide.addChart(pptx.charts.DOUGHNUT, [{
384
+ name: slideData.title,
385
+ labels: items.map(i => i.label),
386
+ values: items.map(i => i.value),
387
+ }], {
388
+ x: 0.8, y: 1.6, w: 5, h: 3.5,
389
+ showLegend: true, legendPos: 'r', legendFontSize: 11, legendColor: t.text,
390
+ chartColors: chartColors.slice(0, items.length),
391
+ dataLabelColor: 'FFFFFF', showPercent: true, dataLabelFontSize: 10,
392
+ holeSize: 55,
393
+ });
394
+ } else {
395
+ slide.addChart(pptx.charts.BAR, [{
396
+ name: slideData.title,
397
+ labels: items.map(i => i.label),
398
+ values: items.map(i => i.value),
399
+ }], {
400
+ x: 0.8, y: 1.6, w: 8.4, h: 3.5,
401
+ showLegend: false,
402
+ chartColors: [t.accent],
403
+ catAxisLabelColor: t.text, valAxisLabelColor: t.subtle,
404
+ catAxisLabelFontSize: 10, valAxisLabelFontSize: 9,
405
+ gridLineColor: t.border,
406
+ dataLabelColor: t.text, showValue: true, dataLabelFontSize: 9,
407
+ barDir: 'bar',
408
+ });
409
+ }
410
+
411
+ addFooter(slide, pptx, t, slideNum, total);
412
+ }
413
+
414
+ function addBulletsSlide(pptx, slideData, theme, slideNum, total) {
415
+ const slide = pptx.addSlide();
416
+ const t = THEMES[theme] || THEMES.executive;
417
+
418
+ slide.background = { fill: t.bg };
419
+ addSlideHeader(slide, pptx, slideData.title, t);
420
+
421
+ const bullets = slideData.bullets;
422
+
423
+ // Two-column layout if > 6 bullets
424
+ if (bullets.length > 6) {
425
+ const mid = Math.ceil(bullets.length / 2);
426
+ const left = bullets.slice(0, mid);
427
+ const right = bullets.slice(mid);
428
+
429
+ [left, right].forEach((col, ci) => {
430
+ const x = ci === 0 ? 0.8 : 5.2;
431
+ col.forEach((b, bi) => {
432
+ const y = 1.8 + bi * 0.55;
433
+ slide.addShape(pptx.ShapeType.roundRect, {
434
+ x, y, w: 0.3, h: 0.3, rectRadius: 0.05,
435
+ fill: { color: t.accent, transparency: 80 },
436
+ });
437
+ slide.addText('✦', { x, y, w: 0.3, h: 0.3, fontSize: 10, align: 'center', color: t.accent });
438
+ slide.addText(b, { x: x + 0.4, y, w: 3.8, h: 0.5, fontSize: 12, fontFace: 'Arial', color: t.text, valign: 'middle' });
439
+ });
440
+ });
441
+ } else {
442
+ bullets.forEach((b, i) => {
443
+ const y = 1.8 + i * 0.65;
444
+
445
+ // Accent dot
446
+ slide.addShape(pptx.ShapeType.ellipse, {
447
+ x: 1, y: y + 0.12, w: 0.16, h: 0.16,
448
+ fill: { color: t.accent },
449
+ });
450
+
451
+ slide.addText(b, {
452
+ x: 1.4, y, w: 7.5, h: 0.5,
453
+ fontSize: 14, fontFace: 'Arial', color: t.text, valign: 'middle',
454
+ });
455
+ });
456
+ }
457
+
458
+ addFooter(slide, pptx, t, slideNum, total);
459
+ }
460
+
461
+ function addQuoteSlide(pptx, slideData, theme, slideNum, total) {
462
+ const slide = pptx.addSlide();
463
+ const t = THEMES[theme] || THEMES.executive;
464
+
465
+ slide.background = { fill: t.gradStart };
466
+
467
+ // Big quote mark
468
+ slide.addText('"', {
469
+ x: 1, y: 1, w: 1.5, h: 1.5,
470
+ fontSize: 120, fontFace: 'Georgia', color: t.accent, transparency: 50,
471
+ });
472
+
473
+ slide.addText(slideData.quote.trim(), {
474
+ x: 1.5, y: 2, w: 7, h: 2.5,
475
+ fontSize: 22, fontFace: 'Georgia', color: t.text, italic: true,
476
+ lineSpacingMultiple: 1.5, valign: 'middle',
477
+ });
478
+
479
+ // Attribution line
480
+ slide.addShape(pptx.ShapeType.rect, { x: 1.5, y: 4.5, w: 1.5, h: 0.04, fill: { color: t.accent } });
481
+
482
+ addFooter(slide, pptx, t, slideNum, total);
483
+ }
484
+
485
+ function addContentSlide(pptx, slideData, theme, slideNum, total) {
486
+ const slide = pptx.addSlide();
487
+ const t = THEMES[theme] || THEMES.executive;
488
+
489
+ slide.background = { fill: t.bg };
490
+ addSlideHeader(slide, pptx, slideData.title, t);
491
+
492
+ let y = 1.8;
493
+ for (const item of slideData.body) {
494
+ if (item.type === 'heading') {
495
+ slide.addText(item.text, {
496
+ x: 0.8, y, w: 8.4, h: 0.5,
497
+ fontSize: 18, fontFace: 'Arial', color: t.accent, bold: true,
498
+ });
499
+ y += 0.6;
500
+ } else {
501
+ slide.addText(item.text, {
502
+ x: 0.8, y, w: 8.4, h: 0.45,
503
+ fontSize: 12, fontFace: 'Arial', color: t.text, lineSpacingMultiple: 1.4,
504
+ });
505
+ y += 0.5;
506
+ }
507
+ if (y > 4.8) break;
508
+ }
509
+
510
+ addFooter(slide, pptx, t, slideNum, total);
511
+ }
512
+
513
+ function addTimelineSlide(pptx, slideData, theme, slideNum, total) {
514
+ const slide = pptx.addSlide();
515
+ const t = THEMES[theme] || THEMES.executive;
516
+
517
+ slide.background = { fill: t.bg };
518
+ addSlideHeader(slide, pptx, slideData.title, t);
519
+
520
+ const items = slideData.timeline.slice(0, 5);
521
+ const stepW = 8 / items.length;
522
+
523
+ // Horizontal line
524
+ slide.addShape(pptx.ShapeType.rect, {
525
+ x: 1, y: 2.8, w: 8, h: 0.04, fill: { color: t.accent },
526
+ });
527
+
528
+ items.forEach((item, i) => {
529
+ const x = 1 + i * stepW + stepW / 2 - 0.5;
530
+
531
+ // Circle node
532
+ slide.addShape(pptx.ShapeType.ellipse, {
533
+ x: x + 0.25, y: 2.6, w: 0.5, h: 0.5,
534
+ fill: { color: t.accent },
535
+ shadow: { type: 'outer', blur: 4, offset: 1, color: t.accent, opacity: 0.3 },
536
+ });
537
+ slide.addText(item.year, {
538
+ x: x + 0.25, y: 2.6, w: 0.5, h: 0.5,
539
+ fontSize: 10, fontFace: 'Arial', color: 'FFFFFF', bold: true, align: 'center', valign: 'middle',
540
+ });
541
+
542
+ // Title + desc below
543
+ slide.addText(item.title, {
544
+ x: x - 0.2, y: 3.3, w: 1.4, h: 0.4,
545
+ fontSize: 11, fontFace: 'Arial', color: t.accent, bold: true, align: 'center',
546
+ });
547
+ slide.addText(item.desc, {
548
+ x: x - 0.3, y: 3.7, w: 1.6, h: 0.8,
549
+ fontSize: 9, fontFace: 'Arial', color: t.subtle, align: 'center', lineSpacingMultiple: 1.2,
550
+ });
551
+ });
552
+
553
+ addFooter(slide, pptx, t, slideNum, total);
554
+ }
555
+
556
+ function addThankYouSlide(pptx, theme, brandName) {
557
+ const slide = pptx.addSlide();
558
+ const t = THEMES[theme] || THEMES.executive;
559
+
560
+ slide.background = { fill: t.gradStart };
561
+
562
+ // Decorative circles
563
+ slide.addShape(pptx.ShapeType.ellipse, {
564
+ x: 6.5, y: 0.5, w: 4, h: 4,
565
+ fill: { color: t.accent, transparency: 92 },
566
+ line: { color: t.accent, width: 2, transparency: 60 },
567
+ });
568
+
569
+ slide.addText('Thank You', {
570
+ x: 0, y: 1.8, w: '100%', h: 1.2,
571
+ fontSize: 48, fontFace: 'Arial', color: t.text, bold: true, align: 'center',
572
+ });
573
+
574
+ slide.addShape(pptx.ShapeType.rect, { x: 4.2, y: 3.1, w: 1.6, h: 0.05, fill: { color: t.accent } });
575
+
576
+ slide.addText('Created with ' + (brandName || 'Squidclaw AI 🦑'), {
577
+ x: 0, y: 3.5, w: '100%', h: 0.5,
578
+ fontSize: 14, fontFace: 'Arial', color: t.subtle, align: 'center',
579
+ });
580
+ }
581
+
582
+ // ── Helpers ──
583
+
584
+ function addSlideHeader(slide, pptx, title, t) {
585
+ // Top accent bar
586
+ slide.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: '100%', h: 0.05, fill: { color: t.accent } });
587
+
588
+ // Title
589
+ slide.addText(title || '', {
590
+ x: 0.8, y: 0.3, w: 8.4, h: 0.8,
591
+ fontSize: 24, fontFace: 'Arial', color: t.text, bold: true,
592
+ });
593
+
594
+ // Title underline
595
+ slide.addShape(pptx.ShapeType.rect, { x: 0.8, y: 1.15, w: 1.5, h: 0.04, fill: { color: t.accent } });
596
+ }
597
+
598
+ function addFooter(slide, pptx, t, num, total) {
599
+ const footerY = 5.15;
600
+
601
+ // Footer line
602
+ slide.addShape(pptx.ShapeType.rect, { x: 0.5, y: footerY, w: 9, h: 0.01, fill: { color: t.border } });
603
+
604
+ // Brand
605
+ slide.addText('Squidclaw AI 🦑', {
606
+ x: 0.5, y: footerY + 0.05, w: 3, h: 0.35,
607
+ fontSize: 9, fontFace: 'Arial', color: t.subtle,
608
+ });
609
+
610
+ // Page number
611
+ if (num && total) {
612
+ slide.addText(num + ' / ' + total, {
613
+ x: 7.5, y: footerY + 0.05, w: 2, h: 0.35,
614
+ fontSize: 9, fontFace: 'Arial', color: t.subtle, align: 'right',
615
+ });
616
+ }
617
+ }
618
+
619
+ // ── Main Export ──
620
+
621
+ export async function generatePresentation(input) {
622
+ mkdirSync(OUTPUT_DIR, { recursive: true });
623
+
624
+ const {
625
+ title = 'Presentation',
626
+ subtitle = '',
627
+ content = '',
628
+ slides: rawSlides = null,
629
+ theme = 'executive',
630
+ brand = 'Squidclaw AI 🦑',
631
+ } = typeof input === 'string' ? { content: input, title: 'Presentation' } : input;
632
+
633
+ const pptx = new PptxGenJS();
634
+ pptx.layout = 'LAYOUT_WIDE'; // 16:9
635
+ pptx.author = brand;
636
+ pptx.title = title;
637
+
638
+ // Parse content into slides
639
+ const parsedSlides = rawSlides || parseMarkdownToSlides(content);
640
+ const totalSlides = parsedSlides.length + 2; // +title +thankyou
641
+
642
+ // Title slide
643
+ addTitleSlide(pptx, title, subtitle || new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }), theme);
644
+
645
+ // Content slides
646
+ parsedSlides.forEach((slideData, i) => {
647
+ const num = i + 2;
648
+ switch (slideData.type) {
649
+ case 'stats':
650
+ case 'stats-bullets':
651
+ addStatsSlide(pptx, slideData, theme, num, totalSlides); break;
652
+ case 'table':
653
+ addTableSlide(pptx, slideData, theme, num, totalSlides); break;
654
+ case 'chart':
655
+ addChartSlide(pptx, slideData, theme, num, totalSlides); break;
656
+ case 'bullets':
657
+ addBulletsSlide(pptx, slideData, theme, num, totalSlides); break;
658
+ case 'quote':
659
+ addQuoteSlide(pptx, slideData, theme, num, totalSlides); break;
660
+ case 'timeline':
661
+ addTimelineSlide(pptx, slideData, theme, num, totalSlides); break;
662
+ default:
663
+ addContentSlide(pptx, slideData, theme, num, totalSlides); break;
664
+ }
665
+ });
666
+
667
+ // Thank you slide
668
+ addThankYouSlide(pptx, theme, brand);
669
+
670
+ const filename = title.replace(/[^a-zA-Z0-9 ]/g, '').replace(/\s+/g, '_').slice(0, 50) + '.pptx';
671
+ const filepath = join(OUTPUT_DIR, filename);
672
+
673
+ await pptx.writeFile({ fileName: filepath });
674
+
675
+ logger.info('pptx-pro', `Generated: ${filepath} (${parsedSlides.length + 2} slides, theme: ${theme})`);
676
+
677
+ return {
678
+ filepath,
679
+ filename,
680
+ slides: parsedSlides.length + 2,
681
+ theme,
682
+ types: [...new Set(parsedSlides.map(s => s.type))],
683
+ };
684
+ }
685
+
686
+ export { parseMarkdownToSlides, THEMES };
@@ -77,7 +77,16 @@ export class ToolRouter {
77
77
  '- Section: ## New Section [section]',
78
78
  '',
79
79
  'Example:',
80
- '---TOOL:pptx_slides:AI Report|dark|## Introduction\n- AI is transforming industries\n- Revenue growing 40% YoY\n\n## Growth [chart:bar]\n- 2020: 50\n- 2021: 80\n- 2022: 120\n- 2023: 200\n\n## Key Stats [stats]\n- 🌍 195 — Countries using AI\n- 💰 $500B — Market size\n- 🚀 40% — Annual growth---');
80
+ 'Write content in markdown. Engine auto-detects slide types from content:',
81
+ '- ## Heading = new slide',
82
+ '- [stats] + lines like "- 💰 $500B — Market size" = stats cards slide',
83
+ '- | col | col | table rows = table slide',
84
+ '- [chart] + "- label: value" lines = chart slide (pie/bar/doughnut)',
85
+ '- [timeline] + "1. Title — desc" = timeline slide',
86
+ '- > quoted text = quote slide',
87
+ '- Bullet lists = bullets slide',
88
+ '',
89
+ 'EXAMPLE: ---TOOL:pptx_slides:AI Market Report|executive|## Key Metrics\n[stats]\n- 🌍 195 — Countries\n- 💰 $500B — Market Size\n- 🚀 40% — Annual Growth\n- 👥 2.5M — AI Engineers\n\n## Revenue by Sector\n[chart]\n- Healthcare: 85\n- Finance: 72\n- Retail: 45\n- Energy: 38\n\n## Growth Timeline\n[timeline]\n1. 2020 — Early adoption phase\n2. 2022 — Rapid enterprise rollout\n3. 2024 — AI-first companies emerge\n4. 2026 — Full integration era\n\n## Market Data\n| Region | Revenue | Growth |\n| North America | $180B | 35% |\n| Europe | $120B | 28% |\n| Asia | $150B | 42% |\n\n> The companies that embrace AI today will define the industries of tomorrow.---');
81
90
 
82
91
  tools.push('', '### Spawn Sub-Agent Session',
83
92
  '---TOOL:session_spawn:task description---',
@@ -169,10 +178,11 @@ export class ToolRouter {
169
178
  '---TOOL:handoff:reason---',
170
179
  'Transfer the conversation to a human agent. Use when you cannot help further.');
171
180
 
172
- tools.push('', '### Render Dashboard (sends as image!)',
173
- '---TOOL:canvas_dashboard:title|stat_json|chart_json---',
174
- 'Render a visual dashboard and send as image. Use JSON for data.',
175
- 'Simple format: ---TOOL:canvas_dashboard:Title|icon:value:label,icon:value:label|label:value,label:value---',
181
+ tools.push('', '### Render Dashboard PRO (sends as image!)',
182
+ '---TOOL:dashboard:Title|theme|markdown content---',
183
+ 'Render a beautiful dashboard as image. Themes: dark, light, saudi, ocean, neon.',
184
+ 'Use same markdown format as presentations. Engine auto-detects: [stats], [chart], [table], [progress], [donut].',
185
+ 'EXAMPLE: ---TOOL:dashboard:Sales Dashboard|dark|## KPIs\n[stats]\n- 💰 $2.4M — Revenue (+12%)\n- 👥 1,234 — Customers (+8%)\n- 📈 78% — Conversion\n\n## Revenue by Product\n[chart]\n- Enterprise: 850\n- Pro: 420\n- Starter: 180\n\n## Targets\n[progress]\n- Q1 Revenue: 85%\n- New Customers: 72%\n- NPS Score: 91%---',
176
186
  '', '### Render Chart (sends as image!)',
177
187
  '---TOOL:canvas_chart:title|type|label:value,label:value---',
178
188
  'Render a chart (bar, pie, doughnut) as image. Separate items with commas.',
@@ -370,45 +380,32 @@ export class ToolRouter {
370
380
  }
371
381
  case 'pptx':
372
382
  case 'pptx_slides':
383
+ case 'pptx_pro':
373
384
  case 'powerpoint':
374
385
  case 'presentation': {
375
386
  try {
376
- const { PptxGenerator } = await import('./pptx.js');
377
- const gen = new PptxGenerator();
378
-
379
- // Parse: title|theme|content or just content
387
+ const { generatePresentation } = await import('./pptx-pro.js');
380
388
  const parts = toolArg.split('|');
381
- let title, theme, slideContent;
382
-
389
+ let title, theme, content;
383
390
  if (parts.length >= 3) {
384
391
  title = parts[0].trim();
385
392
  theme = parts[1].trim();
386
- slideContent = parts.slice(2).join('|');
393
+ content = parts.slice(2).join('|');
387
394
  } else if (parts.length === 2) {
388
395
  title = parts[0].trim();
389
- slideContent = parts[1];
390
- theme = 'corporate';
396
+ content = parts[1];
397
+ theme = 'executive';
391
398
  } else {
392
- // Try to extract title from first ## heading
393
- const firstH2 = toolArg.match(/^##\s+(.+)/m);
394
- title = firstH2 ? firstH2[1] : 'Presentation';
395
- slideContent = toolArg;
396
- theme = 'corporate';
397
- }
398
-
399
- const slides = PptxGenerator.parseContent(slideContent);
400
- if (slides.length === 0) {
401
- toolResult = 'No slides found. Use ## headings and - bullet points.';
402
- break;
399
+ const firstH = toolArg.match(/^#\s+(.+)/m);
400
+ title = firstH ? firstH[1] : 'Presentation';
401
+ content = toolArg;
402
+ theme = 'executive';
403
403
  }
404
-
405
- const result = await gen.create(title, slides, { theme });
406
-
407
- // Return file path for sending
404
+ const result = await generatePresentation({ title, theme, content });
408
405
  return {
409
406
  toolUsed: true,
410
407
  toolName: 'pptx',
411
- toolResult: 'PowerPoint created: ' + result.filename + ' (' + result.slideCount + ' slides)',
408
+ toolResult: 'PowerPoint created: ' + result.filename + ' (' + result.slides + ' slides, types: ' + result.types.join(', ') + ')',
412
409
  filePath: result.filepath,
413
410
  fileName: result.filename,
414
411
  cleanResponse
@@ -708,33 +705,17 @@ export class ToolRouter {
708
705
  }
709
706
  break;
710
707
  }
711
- case 'canvas_dashboard': {
712
- if (!this._engine?.canvas) { toolResult = 'Canvas not available'; break; }
708
+ case 'canvas_dashboard':
709
+ case 'dashboard': {
713
710
  try {
714
- const parts = toolArg.split('|');
715
- const title = parts[0]?.trim() || 'Dashboard';
716
-
717
- // Parse stats: icon:value:label,icon:value:label
718
- const stats = [];
719
- if (parts[1]) {
720
- parts[1].split(',').forEach(s => {
721
- const [icon, value, label] = s.trim().split(':');
722
- if (value) stats.push({ icon: icon || '', value: value || '', label: label || '' });
723
- });
724
- }
725
-
726
- // Parse chart: label:value,label:value
727
- const chart = [];
728
- if (parts[2]) {
729
- parts[2].split(',').forEach(c => {
730
- const [label, value] = c.trim().split(':');
731
- if (value) chart.push({ label: label || '', value: parseInt(value) || 0 });
732
- });
711
+ const { renderDashboard } = await import('./dashboard-pro.js');
712
+ const result = await renderDashboard(toolArg);
713
+ if (result.buffer) {
714
+ return { toolUsed: true, toolName: 'dashboard', toolResult: 'Dashboard rendered', imageBase64: result.buffer.toString('base64'), mimeType: 'image/png', cleanResponse };
715
+ } else {
716
+ return { toolUsed: true, toolName: 'dashboard', toolResult: 'Dashboard created', filePath: result.filepath, fileName: result.filename, cleanResponse };
733
717
  }
734
-
735
- const result = await this._engine.canvas.renderDashboard({ title, stats, chart });
736
- return { toolUsed: true, toolName: 'canvas', toolResult: 'Dashboard rendered', imageBase64: result.buffer.toString('base64'), mimeType: 'image/png', cleanResponse };
737
- } catch (err) { toolResult = 'Canvas failed: ' + err.message; }
718
+ } catch (err) { toolResult = 'Dashboard failed: ' + err.message; }
738
719
  break;
739
720
  }
740
721
  case 'canvas_chart': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squidclaw",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
4
4
  "description": "🦑 AI agent platform — human-like agents for WhatsApp, Telegram & more",
5
5
  "main": "lib/engine.js",
6
6
  "bin": {