squidclaw 3.1.0 → 3.2.0

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 };
@@ -382,10 +382,12 @@ export class ToolRouter {
382
382
  case 'pptx_slides':
383
383
  case 'pptx_pro':
384
384
  case 'powerpoint':
385
+ case 'slides':
385
386
  case 'presentation': {
386
387
  try {
387
- const { generatePresentation } = await import('./pptx-pro.js');
388
- const parts = toolArg.split('|');
388
+ const { generateSlides } = await import('./slides-engine.js');
389
+ const unescaped = toolArg.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
390
+ const parts = unescaped.split('|');
389
391
  let title, theme, content;
390
392
  if (parts.length >= 3) {
391
393
  title = parts[0].trim();
@@ -396,22 +398,23 @@ export class ToolRouter {
396
398
  content = parts[1];
397
399
  theme = 'executive';
398
400
  } else {
399
- const firstH = toolArg.match(/^#\s+(.+)/m);
401
+ const firstH = unescaped.match(/^#\s+(.+)/m);
400
402
  title = firstH ? firstH[1] : 'Presentation';
401
- content = toolArg;
403
+ content = unescaped;
402
404
  theme = 'executive';
403
405
  }
404
- const result = await generatePresentation({ title, theme, content });
406
+ const result = await generateSlides({ title, theme, content });
405
407
  return {
406
408
  toolUsed: true,
407
409
  toolName: 'pptx',
408
- toolResult: 'PowerPoint created: ' + result.filename + ' (' + result.slides + ' slides, types: ' + result.types.join(', ') + ')',
410
+ toolResult: 'Presentation created: ' + result.filename + ' (' + result.slides + ' slides, ' + result.images + ' photos, types: ' + result.types.join(', ') + ')',
409
411
  filePath: result.filepath,
410
412
  fileName: result.filename,
411
413
  cleanResponse
412
414
  };
413
415
  } catch (err) {
414
- toolResult = 'PowerPoint failed: ' + err.message;
416
+ toolResult = 'Slides failed: ' + err.message;
417
+ logger.error('tools', 'Slides error: ' + err.stack);
415
418
  }
416
419
  break;
417
420
  }
@@ -709,7 +712,7 @@ export class ToolRouter {
709
712
  case 'dashboard': {
710
713
  try {
711
714
  const { renderDashboard } = await import('./dashboard-pro.js');
712
- const result = await renderDashboard(toolArg);
715
+ const result = await renderDashboard(toolArg.replace(/\\n/g, '\n').replace(/\\t/g, '\t'));
713
716
  if (result.buffer) {
714
717
  return { toolUsed: true, toolName: 'dashboard', toolResult: 'Dashboard rendered', imageBase64: result.buffer.toString('base64'), mimeType: 'image/png', cleanResponse };
715
718
  } else {
@@ -0,0 +1,660 @@
1
+ /**
2
+ * 🦑 Slides Engine v2 — Genspark-style
3
+ *
4
+ * Flow:
5
+ * 1. AI generates structured slide data (JSON or markdown)
6
+ * 2. Engine fetches real images from Unsplash/Pexels
7
+ * 3. Renders as HTML slides (Reveal.js-style)
8
+ * 4. Screenshots each slide via Puppeteer
9
+ * 5. Assembles into PPTX (image-per-slide) or PDF
10
+ * 6. Optionally serves as web presentation via API
11
+ *
12
+ * Result: Professional slides with real photos, proper typography, smooth gradients
13
+ */
14
+
15
+ import PptxGenJS from 'pptxgenjs';
16
+ import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
17
+ import { join } from 'path';
18
+ import { logger } from '../core/logger.js';
19
+
20
+ const OUTPUT_DIR = '/tmp/squidclaw-slides';
21
+ const CACHE_DIR = '/tmp/squidclaw-slides/cache';
22
+
23
+ // ── Image fetcher (Unsplash free API) ──
24
+
25
+ async function fetchImage(query, width = 1200, height = 800) {
26
+ try {
27
+ // Unsplash Source (no API key needed, direct redirect)
28
+ const url = `https://source.unsplash.com/${width}x${height}/?${encodeURIComponent(query)}`;
29
+ const resp = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(8000) });
30
+ if (resp.ok) {
31
+ const buffer = Buffer.from(await resp.arrayBuffer());
32
+ if (buffer.length > 5000) { // Valid image
33
+ const cached = join(CACHE_DIR, query.replace(/[^a-z0-9]/gi, '_').slice(0, 30) + '.jpg');
34
+ writeFileSync(cached, buffer);
35
+ return { buffer, url: resp.url, cached };
36
+ }
37
+ }
38
+ } catch {}
39
+
40
+ // Fallback: Picsum (always works, random quality images)
41
+ try {
42
+ const url = `https://picsum.photos/${width}/${height}`;
43
+ const resp = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(5000) });
44
+ if (resp.ok) {
45
+ const buffer = Buffer.from(await resp.arrayBuffer());
46
+ return { buffer, url: resp.url };
47
+ }
48
+ } catch {}
49
+
50
+ return null;
51
+ }
52
+
53
+ // ── Slide Templates (HTML) ──
54
+
55
+ const CSS_BASE = `
56
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Playfair+Display:wght@700;800;900&display=swap');
57
+
58
+ * { margin: 0; padding: 0; box-sizing: border-box; }
59
+
60
+ .slide {
61
+ width: 1280px; height: 720px;
62
+ position: relative; overflow: hidden;
63
+ font-family: 'Inter', -apple-system, sans-serif;
64
+ }
65
+
66
+ /* Dark gradient overlay for text readability on images */
67
+ .overlay {
68
+ position: absolute; top: 0; left: 0; right: 0; bottom: 0;
69
+ z-index: 1;
70
+ }
71
+ .overlay-dark { background: linear-gradient(135deg, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 100%); }
72
+ .overlay-left { background: linear-gradient(90deg, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.7) 55%, transparent 100%); }
73
+ .overlay-bottom { background: linear-gradient(0deg, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.3) 50%, transparent 100%); }
74
+ .overlay-gradient { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
75
+ .overlay-saudi { background: linear-gradient(135deg, rgba(0,108,53,0.92) 0%, rgba(0,77,37,0.85) 100%); }
76
+ .overlay-ocean { background: linear-gradient(135deg, rgba(10,25,47,0.95) 0%, rgba(17,34,64,0.9) 100%); }
77
+ .overlay-fire { background: linear-gradient(135deg, rgba(139,0,0,0.9) 0%, rgba(26,0,0,0.85) 100%); }
78
+
79
+ .bg-image {
80
+ position: absolute; top: 0; left: 0; width: 100%; height: 100%;
81
+ object-fit: cover; z-index: 0;
82
+ }
83
+
84
+ .content { position: relative; z-index: 2; padding: 60px; height: 100%; display: flex; flex-direction: column; }
85
+ .content-split { display: flex; height: 100%; }
86
+ .content-left { width: 55%; padding: 60px; display: flex; flex-direction: column; justify-content: center; position: relative; z-index: 2; }
87
+ .content-right { width: 45%; position: relative; }
88
+ .content-right img { width: 100%; height: 100%; object-fit: cover; }
89
+
90
+ /* Typography */
91
+ .title-xl { font-family: 'Playfair Display', serif; font-size: 56px; font-weight: 800; color: #fff; line-height: 1.1; letter-spacing: -1px; }
92
+ .title-lg { font-family: 'Playfair Display', serif; font-size: 42px; font-weight: 700; color: #fff; line-height: 1.15; }
93
+ .title-md { font-size: 28px; font-weight: 700; color: #fff; }
94
+ .subtitle { font-size: 18px; color: rgba(255,255,255,0.7); margin-top: 16px; line-height: 1.6; font-weight: 300; }
95
+ .label { font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.5); text-transform: uppercase; letter-spacing: 3px; margin-bottom: 20px; }
96
+ .accent-line { width: 60px; height: 4px; background: linear-gradient(90deg, #58a6ff, #3fb950); border-radius: 2px; margin: 20px 0; }
97
+ .accent-line-gold { background: linear-gradient(90deg, #c8a951, #daa520); }
98
+
99
+ /* Stats Grid */
100
+ .stats-grid { display: grid; gap: 20px; margin-top: 30px; }
101
+ .stats-grid-2 { grid-template-columns: 1fr 1fr; }
102
+ .stats-grid-3 { grid-template-columns: 1fr 1fr 1fr; }
103
+ .stats-grid-4 { grid-template-columns: 1fr 1fr 1fr 1fr; }
104
+ .stat-card {
105
+ background: rgba(255,255,255,0.08); backdrop-filter: blur(20px);
106
+ border: 1px solid rgba(255,255,255,0.12); border-radius: 16px;
107
+ padding: 24px; text-align: center;
108
+ }
109
+ .stat-icon { font-size: 28px; margin-bottom: 8px; }
110
+ .stat-value { font-size: 36px; font-weight: 800; background: linear-gradient(135deg, #58a6ff, #3fb950); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
111
+ .stat-label { font-size: 12px; color: rgba(255,255,255,0.6); margin-top: 6px; font-weight: 500; }
112
+ .stat-change { font-size: 11px; margin-top: 4px; }
113
+ .stat-up { color: #3fb950; }
114
+ .stat-down { color: #f85149; }
115
+
116
+ /* Bullets */
117
+ .bullets { list-style: none; margin-top: 24px; }
118
+ .bullets li { display: flex; align-items: flex-start; gap: 14px; margin: 14px 0; font-size: 16px; color: rgba(255,255,255,0.85); line-height: 1.5; }
119
+ .bullet-dot { width: 8px; height: 8px; border-radius: 50%; background: #58a6ff; margin-top: 7px; flex-shrink: 0; }
120
+ .bullet-num { width: 28px; height: 28px; border-radius: 50%; background: rgba(88,166,255,0.2); color: #58a6ff; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; flex-shrink: 0; }
121
+
122
+ /* Table */
123
+ .slide-table { width: 100%; border-collapse: separate; border-spacing: 0; margin-top: 20px; border-radius: 12px; overflow: hidden; }
124
+ .slide-table th { background: rgba(88,166,255,0.15); color: #58a6ff; padding: 14px 18px; text-align: left; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; }
125
+ .slide-table td { padding: 12px 18px; font-size: 14px; color: rgba(255,255,255,0.8); border-bottom: 1px solid rgba(255,255,255,0.06); }
126
+ .slide-table tr:nth-child(even) td { background: rgba(255,255,255,0.03); }
127
+
128
+ /* Bar chart */
129
+ .bar-chart { margin-top: 20px; }
130
+ .bar-row { display: flex; align-items: center; margin: 10px 0; }
131
+ .bar-label { width: 110px; font-size: 13px; color: rgba(255,255,255,0.7); flex-shrink: 0; }
132
+ .bar-track { flex: 1; height: 32px; background: rgba(255,255,255,0.06); border-radius: 8px; overflow: hidden; }
133
+ .bar-fill { height: 100%; border-radius: 8px; display: flex; align-items: center; padding: 0 12px; }
134
+ .bar-value { font-size: 12px; color: #fff; font-weight: 700; }
135
+
136
+ /* Donut */
137
+ .donut-wrap { display: flex; align-items: center; gap: 32px; margin-top: 20px; }
138
+ .donut-circle { width: 180px; height: 180px; border-radius: 50%; position: relative; flex-shrink: 0; }
139
+ .donut-hole { position: absolute; top: 18%; left: 18%; width: 64%; height: 64%; border-radius: 50%; background: rgba(13,17,23,0.95); display: flex; align-items: center; justify-content: center; flex-direction: column; }
140
+ .donut-total { font-size: 24px; font-weight: 800; color: #fff; }
141
+ .donut-sub { font-size: 10px; color: rgba(255,255,255,0.5); }
142
+ .legend { display: flex; flex-direction: column; gap: 10px; }
143
+ .legend-item { display: flex; align-items: center; gap: 10px; font-size: 13px; color: rgba(255,255,255,0.8); }
144
+ .legend-dot { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; }
145
+
146
+ /* Timeline */
147
+ .timeline { margin-top: 30px; position: relative; padding-left: 30px; }
148
+ .timeline::before { content: ''; position: absolute; left: 8px; top: 0; bottom: 0; width: 2px; background: linear-gradient(180deg, #58a6ff, #3fb950); }
149
+ .timeline-item { position: relative; margin: 24px 0; }
150
+ .timeline-dot { position: absolute; left: -26px; top: 4px; width: 14px; height: 14px; border-radius: 50%; background: #58a6ff; border: 3px solid #0d1117; }
151
+ .timeline-year { font-size: 13px; font-weight: 700; color: #58a6ff; }
152
+ .timeline-title { font-size: 16px; font-weight: 600; color: #fff; margin-top: 2px; }
153
+ .timeline-desc { font-size: 13px; color: rgba(255,255,255,0.6); margin-top: 4px; line-height: 1.4; }
154
+
155
+ /* Quote */
156
+ .quote-mark { font-family: 'Playfair Display', serif; font-size: 120px; color: rgba(88,166,255,0.2); line-height: 0.8; position: absolute; top: 40px; left: 50px; }
157
+ .quote-text { font-family: 'Playfair Display', serif; font-size: 28px; color: #fff; line-height: 1.5; font-style: italic; max-width: 800px; }
158
+ .quote-author { font-size: 14px; color: rgba(255,255,255,0.5); margin-top: 24px; }
159
+
160
+ /* Progress */
161
+ .progress-list { margin-top: 20px; }
162
+ .progress-row { display: flex; align-items: center; gap: 16px; margin: 14px 0; }
163
+ .progress-label { width: 140px; font-size: 13px; color: rgba(255,255,255,0.7); }
164
+ .progress-track { flex: 1; height: 12px; background: rgba(255,255,255,0.08); border-radius: 6px; overflow: hidden; }
165
+ .progress-fill { height: 100%; border-radius: 6px; }
166
+ .progress-pct { font-size: 13px; font-weight: 700; width: 45px; text-align: right; }
167
+
168
+ /* Footer */
169
+ .slide-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: 14px 60px; display: flex; justify-content: space-between; align-items: center; z-index: 3; background: linear-gradient(0deg, rgba(0,0,0,0.5), transparent); }
170
+ .slide-footer span { font-size: 10px; color: rgba(255,255,255,0.35); }
171
+
172
+ /* Comparison */
173
+ .compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 20px; }
174
+ .compare-card { background: rgba(255,255,255,0.06); border-radius: 16px; padding: 28px; border: 1px solid rgba(255,255,255,0.08); }
175
+ .compare-title { font-size: 18px; font-weight: 700; margin-bottom: 16px; }
176
+ `;
177
+
178
+ // ── Chart Colors ──
179
+ const COLORS = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#f778ba', '#a5d6ff', '#7ee787'];
180
+ const GRADIENTS = [
181
+ 'linear-gradient(135deg, #58a6ff, #3fb950)',
182
+ 'linear-gradient(135deg, #f85149, #d29922)',
183
+ 'linear-gradient(135deg, #bc8cff, #f778ba)',
184
+ 'linear-gradient(135deg, #3fb950, #58a6ff)',
185
+ 'linear-gradient(135deg, #d29922, #f85149)',
186
+ 'linear-gradient(135deg, #a5d6ff, #58a6ff)',
187
+ ];
188
+
189
+ // ── Overlay picker by theme ──
190
+ function getOverlay(theme) {
191
+ const map = { saudi: 'overlay-saudi', ocean: 'overlay-ocean', fire: 'overlay-fire', gradient: 'overlay-gradient' };
192
+ return map[theme] || 'overlay-dark';
193
+ }
194
+ function getAccentLine(theme) {
195
+ return theme === 'saudi' ? 'accent-line accent-line-gold' : 'accent-line';
196
+ }
197
+
198
+ // ── Parse markdown to structured slide data ──
199
+
200
+ function parseToSlides(content) {
201
+ const slides = [];
202
+ const lines = content.split('\n');
203
+ let current = null;
204
+
205
+ for (const rawLine of lines) {
206
+ const line = rawLine.trim();
207
+ if (!line) continue;
208
+
209
+ if (line.startsWith('## ') || line.startsWith('# ')) {
210
+ if (current) slides.push(current);
211
+ current = {
212
+ title: line.replace(/^#+\s*/, '').replace(/\*\*/g, ''),
213
+ type: 'content', imageQuery: '', stats: [], table: [], chart: [],
214
+ bullets: [], timeline: [], quote: null, quoteAuthor: null,
215
+ progress: [], comparison: [], body: [],
216
+ };
217
+ // Auto image query from title
218
+ current.imageQuery = current.title.toLowerCase().replace(/[^a-z\s]/g, '').trim();
219
+ continue;
220
+ }
221
+
222
+ if (!current) {
223
+ current = { title: '', type: 'content', imageQuery: 'abstract technology', stats: [], table: [], chart: [], bullets: [], timeline: [], quote: null, quoteAuthor: null, progress: [], comparison: [], body: [] };
224
+ }
225
+
226
+ // Section markers
227
+ if (line.match(/^\[stats?\]/i)) { current.type = 'stats'; continue; }
228
+ if (line.match(/^\[chart\]/i) || line.match(/^\[bar\]/i)) { current.type = 'chart'; continue; }
229
+ if (line.match(/^\[donut\]/i) || line.match(/^\[pie\]/i)) { current.type = 'donut'; continue; }
230
+ if (line.match(/^\[table\]/i)) { current.type = 'table'; continue; }
231
+ if (line.match(/^\[timeline\]/i)) { current.type = 'timeline'; continue; }
232
+ if (line.match(/^\[progress\]/i)) { current.type = 'progress'; continue; }
233
+ if (line.match(/^\[compare\]/i) || line.match(/^\[comparison\]/i)) { current.type = 'comparison'; continue; }
234
+ if (line.match(/^\[quote\]/i)) { current.type = 'quote'; continue; }
235
+ if (line.match(/^\[image:\s*(.+?)\]/i)) { current.imageQuery = line.match(/\[image:\s*(.+?)\]/i)[1]; continue; }
236
+
237
+ // Stats: - icon value — label (change)
238
+ if (current.type === 'stats' && line.startsWith('-')) {
239
+ const m = line.match(/^-\s*(.+?)\s+(.+?)\s*[—–-]\s*(.+?)(?:\s*\((.+?)\))?$/);
240
+ if (m) { current.stats.push({ icon: m[1], value: m[2], label: m[3].trim(), change: m[4] || null }); continue; }
241
+ }
242
+
243
+ // Table
244
+ if (line.startsWith('|') && line.endsWith('|')) {
245
+ if (line.match(/^\|[\s-:|]+\|$/)) continue;
246
+ current.table.push(line.split('|').filter(c => c.trim()).map(c => c.trim()));
247
+ if (current.type === 'content') current.type = 'table';
248
+ continue;
249
+ }
250
+
251
+ // Chart: - label: value
252
+ if ((current.type === 'chart' || current.type === 'donut') && line.startsWith('-')) {
253
+ const m = line.match(/^-\s*(.+?):\s*(\d+)/);
254
+ if (m) { current.chart.push({ label: m[1].trim(), value: parseInt(m[2]) }); continue; }
255
+ }
256
+
257
+ // Progress: - label: value%
258
+ if (current.type === 'progress' && line.startsWith('-')) {
259
+ const m = line.match(/^-\s*(.+?):\s*(\d+)%?/);
260
+ if (m) { current.progress.push({ label: m[1].trim(), value: parseInt(m[2]) }); continue; }
261
+ }
262
+
263
+ // Timeline: N. Title — desc
264
+ if (current.type === 'timeline' && line.match(/^\d+\./)) {
265
+ const m = line.match(/^(\d+)\.\s*\*?\*?(.+?)\*?\*?\s*[—–-]\s*(.+)/);
266
+ if (m) { current.timeline.push({ year: m[1], title: m[2].trim(), desc: m[3].trim() }); continue; }
267
+ }
268
+
269
+ // Quote
270
+ if (line.startsWith('>') || current.type === 'quote') {
271
+ const q = line.replace(/^>\s*/, '').replace(/\*\*/g, '');
272
+ if (q.startsWith('—') || q.startsWith('- ')) {
273
+ current.quoteAuthor = q.replace(/^[—-]\s*/, '');
274
+ } else {
275
+ current.quote = (current.quote || '') + q + ' ';
276
+ }
277
+ current.type = 'quote';
278
+ continue;
279
+ }
280
+
281
+ // Bullets
282
+ if (line.startsWith('-') || line.startsWith('•') || line.startsWith('*')) {
283
+ const b = line.replace(/^[-•*]\s*/, '').replace(/\*\*/g, '');
284
+ current.bullets.push(b);
285
+ continue;
286
+ }
287
+
288
+ current.body.push(line.replace(/\*\*/g, ''));
289
+ }
290
+ if (current) slides.push(current);
291
+
292
+ // Auto-type detection
293
+ for (const s of slides) {
294
+ if (s.stats.length >= 2 && s.type === 'content') s.type = 'stats';
295
+ if (s.table.length >= 2 && s.type === 'content') s.type = 'table';
296
+ if (s.chart.length >= 2 && s.type === 'content') s.type = 'chart';
297
+ if (s.timeline.length >= 2 && s.type === 'content') s.type = 'timeline';
298
+ if (s.progress.length >= 2 && s.type === 'content') s.type = 'progress';
299
+ if (s.quote && s.type === 'content') s.type = 'quote';
300
+ if (s.bullets.length >= 2 && s.type === 'content') s.type = 'bullets';
301
+ }
302
+
303
+ return slides;
304
+ }
305
+
306
+ // ── HTML Renderers ──
307
+
308
+ function renderTitleSlide(title, subtitle, theme, imgB64) {
309
+ const overlay = getOverlay(theme);
310
+ return `<div class="slide">
311
+ ${imgB64 ? `<img class="bg-image" src="data:image/jpeg;base64,${imgB64}" />` : ''}
312
+ <div class="overlay overlay-left"></div>
313
+ <div class="content" style="justify-content:center">
314
+ <div class="label">PRESENTATION</div>
315
+ <div class="title-xl">${title}</div>
316
+ <div class="${getAccentLine(theme)}"></div>
317
+ ${subtitle ? `<div class="subtitle">${subtitle}</div>` : ''}
318
+ </div>
319
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span></span></div>
320
+ </div>`;
321
+ }
322
+
323
+ function renderStatsSlide(slide, theme, num, total, imgB64) {
324
+ const cols = Math.min(slide.stats.length, 4);
325
+ const statsHtml = slide.stats.map(s => {
326
+ const changeHtml = s.change ? `<div class="stat-change ${s.change.includes('+') || s.change.includes('↑') ? 'stat-up' : 'stat-down'}">${s.change}</div>` : '';
327
+ return `<div class="stat-card"><div class="stat-icon">${s.icon}</div><div class="stat-value">${s.value}</div><div class="stat-label">${s.label}</div>${changeHtml}</div>`;
328
+ }).join('');
329
+
330
+ return `<div class="slide">
331
+ ${imgB64 ? `<img class="bg-image" src="data:image/jpeg;base64,${imgB64}" />` : ''}
332
+ <div class="overlay ${getOverlay(theme)}"></div>
333
+ <div class="content">
334
+ <div class="label">KEY METRICS</div>
335
+ <div class="title-lg">${slide.title}</div>
336
+ <div class="${getAccentLine(theme)}"></div>
337
+ <div class="stats-grid stats-grid-${cols}" style="flex:1;align-content:center">${statsHtml}</div>
338
+ </div>
339
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
340
+ </div>`;
341
+ }
342
+
343
+ function renderBulletsSlide(slide, theme, num, total, imgB64) {
344
+ // Split layout: text left, image right
345
+ if (imgB64) {
346
+ const bulletsHtml = slide.bullets.map((b, i) => `<li><div class="bullet-num">${i + 1}</div><span>${b}</span></li>`).join('');
347
+ return `<div class="slide">
348
+ <div class="content-split">
349
+ <div class="content-left">
350
+ <div class="label">OVERVIEW</div>
351
+ <div class="title-lg">${slide.title}</div>
352
+ <div class="${getAccentLine(theme)}"></div>
353
+ <ul class="bullets">${bulletsHtml}</ul>
354
+ </div>
355
+ <div class="content-right"><img src="data:image/jpeg;base64,${imgB64}" /></div>
356
+ </div>
357
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
358
+ </div>`;
359
+ }
360
+
361
+ const bulletsHtml = slide.bullets.map(b => `<li><div class="bullet-dot"></div><span>${b}</span></li>`).join('');
362
+ return `<div class="slide">
363
+ <div class="overlay ${getOverlay(theme)}"></div>
364
+ <div class="content">
365
+ <div class="label">OVERVIEW</div>
366
+ <div class="title-lg">${slide.title}</div>
367
+ <div class="${getAccentLine(theme)}"></div>
368
+ <ul class="bullets">${bulletsHtml}</ul>
369
+ </div>
370
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
371
+ </div>`;
372
+ }
373
+
374
+ function renderTableSlide(slide, theme, num, total) {
375
+ if (slide.table.length < 2) return '';
376
+ const headers = slide.table[0];
377
+ const rows = slide.table.slice(1);
378
+ const headerHtml = headers.map(h => `<th>${h}</th>`).join('');
379
+ const rowsHtml = rows.map(r => `<tr>${r.map(c => `<td>${c}</td>`).join('')}</tr>`).join('');
380
+
381
+ return `<div class="slide">
382
+ <div class="overlay ${getOverlay(theme)}"></div>
383
+ <div class="content">
384
+ <div class="label">DATA</div>
385
+ <div class="title-lg">${slide.title}</div>
386
+ <div class="${getAccentLine(theme)}"></div>
387
+ <table class="slide-table"><thead><tr>${headerHtml}</tr></thead><tbody>${rowsHtml}</tbody></table>
388
+ </div>
389
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
390
+ </div>`;
391
+ }
392
+
393
+ function renderChartSlide(slide, theme, num, total) {
394
+ const max = Math.max(...slide.chart.map(i => i.value));
395
+ const barsHtml = slide.chart.map((item, idx) => {
396
+ const pct = (item.value / max * 100).toFixed(0);
397
+ const gradient = GRADIENTS[idx % GRADIENTS.length];
398
+ return `<div class="bar-row"><div class="bar-label">${item.label}</div><div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:${gradient}"><span class="bar-value">${item.value}</span></div></div></div>`;
399
+ }).join('');
400
+
401
+ return `<div class="slide">
402
+ <div class="overlay ${getOverlay(theme)}"></div>
403
+ <div class="content">
404
+ <div class="label">ANALYSIS</div>
405
+ <div class="title-lg">${slide.title}</div>
406
+ <div class="${getAccentLine(theme)}"></div>
407
+ <div class="bar-chart">${barsHtml}</div>
408
+ </div>
409
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
410
+ </div>`;
411
+ }
412
+
413
+ function renderDonutSlide(slide, theme, num, total) {
414
+ const items = slide.chart;
415
+ const chartTotal = items.reduce((s, i) => s + i.value, 0);
416
+ let rotation = 0;
417
+ const gradParts = [];
418
+ items.forEach((item, idx) => {
419
+ const deg = item.value / chartTotal * 360;
420
+ gradParts.push(`${COLORS[idx % COLORS.length]} ${rotation}deg ${rotation + deg}deg`);
421
+ rotation += deg;
422
+ });
423
+ const legendHtml = items.map((item, idx) =>
424
+ `<div class="legend-item"><div class="legend-dot" style="background:${COLORS[idx % COLORS.length]}"></div>${item.label}: <strong>${item.value}</strong> (${(item.value / chartTotal * 100).toFixed(0)}%)</div>`
425
+ ).join('');
426
+
427
+ return `<div class="slide">
428
+ <div class="overlay ${getOverlay(theme)}"></div>
429
+ <div class="content">
430
+ <div class="label">DISTRIBUTION</div>
431
+ <div class="title-lg">${slide.title}</div>
432
+ <div class="${getAccentLine(theme)}"></div>
433
+ <div class="donut-wrap">
434
+ <div class="donut-circle" style="background:conic-gradient(${gradParts.join(',')})">
435
+ <div class="donut-hole"><div class="donut-total">${chartTotal}</div><div class="donut-sub">Total</div></div>
436
+ </div>
437
+ <div class="legend">${legendHtml}</div>
438
+ </div>
439
+ </div>
440
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
441
+ </div>`;
442
+ }
443
+
444
+ function renderTimelineSlide(slide, theme, num, total) {
445
+ const itemsHtml = slide.timeline.map(t =>
446
+ `<div class="timeline-item"><div class="timeline-dot"></div><div class="timeline-year">${t.year}</div><div class="timeline-title">${t.title}</div><div class="timeline-desc">${t.desc}</div></div>`
447
+ ).join('');
448
+
449
+ return `<div class="slide">
450
+ <div class="overlay ${getOverlay(theme)}"></div>
451
+ <div class="content">
452
+ <div class="label">TIMELINE</div>
453
+ <div class="title-lg">${slide.title}</div>
454
+ <div class="${getAccentLine(theme)}"></div>
455
+ <div class="timeline">${itemsHtml}</div>
456
+ </div>
457
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
458
+ </div>`;
459
+ }
460
+
461
+ function renderQuoteSlide(slide, theme, num, total, imgB64) {
462
+ return `<div class="slide">
463
+ ${imgB64 ? `<img class="bg-image" src="data:image/jpeg;base64,${imgB64}" />` : ''}
464
+ <div class="overlay ${getOverlay(theme)}"></div>
465
+ <div class="content" style="justify-content:center;align-items:center">
466
+ <div class="quote-mark">"</div>
467
+ <div class="quote-text" style="margin-top:40px">${(slide.quote || '').trim()}</div>
468
+ ${slide.quoteAuthor ? `<div class="quote-author">— ${slide.quoteAuthor}</div>` : ''}
469
+ <div class="${getAccentLine(theme)}" style="margin-top:30px"></div>
470
+ </div>
471
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
472
+ </div>`;
473
+ }
474
+
475
+ function renderProgressSlide(slide, theme, num, total) {
476
+ const itemsHtml = slide.progress.map((item, idx) => {
477
+ const color = COLORS[idx % COLORS.length];
478
+ return `<div class="progress-row"><div class="progress-label">${item.label}</div><div class="progress-track"><div class="progress-fill" style="width:${item.value}%;background:${GRADIENTS[idx % GRADIENTS.length]}"></div></div><div class="progress-pct" style="color:${color}">${item.value}%</div></div>`;
479
+ }).join('');
480
+
481
+ return `<div class="slide">
482
+ <div class="overlay ${getOverlay(theme)}"></div>
483
+ <div class="content">
484
+ <div class="label">PROGRESS</div>
485
+ <div class="title-lg">${slide.title}</div>
486
+ <div class="${getAccentLine(theme)}"></div>
487
+ <div class="progress-list">${itemsHtml}</div>
488
+ </div>
489
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
490
+ </div>`;
491
+ }
492
+
493
+ function renderContentSlide(slide, theme, num, total, imgB64) {
494
+ const bodyHtml = slide.body.map(t => `<p style="font-size:16px;color:rgba(255,255,255,0.8);line-height:1.7;margin:8px 0">${t}</p>`).join('');
495
+
496
+ if (imgB64) {
497
+ return `<div class="slide">
498
+ <div class="content-split">
499
+ <div class="content-left">
500
+ <div class="title-lg">${slide.title}</div>
501
+ <div class="${getAccentLine(theme)}"></div>
502
+ ${bodyHtml}
503
+ ${slide.bullets.length ? `<ul class="bullets">${slide.bullets.map(b => `<li><div class="bullet-dot"></div><span>${b}</span></li>`).join('')}</ul>` : ''}
504
+ </div>
505
+ <div class="content-right"><img src="data:image/jpeg;base64,${imgB64}" /></div>
506
+ </div>
507
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
508
+ </div>`;
509
+ }
510
+
511
+ return `<div class="slide">
512
+ <div class="overlay ${getOverlay(theme)}"></div>
513
+ <div class="content">
514
+ <div class="title-lg">${slide.title}</div>
515
+ <div class="${getAccentLine(theme)}"></div>
516
+ ${bodyHtml}
517
+ ${slide.bullets.length ? `<ul class="bullets">${slide.bullets.map(b => `<li><div class="bullet-dot"></div><span>${b}</span></li>`).join('')}</ul>` : ''}
518
+ </div>
519
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
520
+ </div>`;
521
+ }
522
+
523
+ function renderThankYouSlide(theme, brand, imgB64) {
524
+ return `<div class="slide">
525
+ ${imgB64 ? `<img class="bg-image" src="data:image/jpeg;base64,${imgB64}" />` : ''}
526
+ <div class="overlay ${getOverlay(theme)}"></div>
527
+ <div class="content" style="justify-content:center;align-items:center">
528
+ <div class="title-xl">Thank You</div>
529
+ <div class="${getAccentLine(theme)}" style="width:80px;margin:30px auto"></div>
530
+ <div class="subtitle" style="text-align:center">Created with ${brand || 'Squidclaw AI 🦑'}</div>
531
+ </div>
532
+ </div>`;
533
+ }
534
+
535
+ // ── Main Export ──
536
+
537
+ export async function generateSlides(input) {
538
+ mkdirSync(OUTPUT_DIR, { recursive: true });
539
+ mkdirSync(CACHE_DIR, { recursive: true });
540
+
541
+ const { title = 'Presentation', subtitle = '', content = '', theme = 'executive', brand = 'Squidclaw AI 🦑' } =
542
+ typeof input === 'string' ? { content: input, title: 'Presentation' } : input;
543
+
544
+ const slides = parseToSlides(content);
545
+ const totalSlides = slides.length + 2; // + title + thank you
546
+
547
+ logger.info('slides-engine', `Generating ${totalSlides} slides, theme: ${theme}`);
548
+
549
+ // Fetch images in parallel (title + content slides that benefit from images)
550
+ const imageSlideTypes = ['content', 'bullets', 'quote'];
551
+ const imageQueries = [
552
+ title.toLowerCase().replace(/[^a-z\s]/g, '').trim() || 'presentation business',
553
+ ...slides.filter(s => imageSlideTypes.includes(s.type)).map(s => s.imageQuery || s.title.toLowerCase()),
554
+ 'thank you success',
555
+ ];
556
+
557
+ // Fetch up to 6 images (title + 4 content + thank you)
558
+ const imagePromises = imageQueries.slice(0, 6).map(q => fetchImage(q));
559
+ const images = await Promise.allSettled(imagePromises);
560
+ const imageBuffers = images.map(r => r.status === 'fulfilled' && r.value ? r.value.buffer.toString('base64') : null);
561
+
562
+ // Build HTML for each slide
563
+ const slideHtmls = [];
564
+ let imgIdx = 1; // 0 = title
565
+
566
+ // Title slide
567
+ slideHtmls.push(renderTitleSlide(title, subtitle || new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }), theme, imageBuffers[0]));
568
+
569
+ // Content slides
570
+ for (let i = 0; i < slides.length; i++) {
571
+ const s = slides[i];
572
+ const num = i + 2;
573
+ const needsImage = imageSlideTypes.includes(s.type);
574
+ const img = needsImage && imgIdx < imageBuffers.length ? imageBuffers[imgIdx++] : null;
575
+
576
+ switch (s.type) {
577
+ case 'stats': slideHtmls.push(renderStatsSlide(s, theme, num, totalSlides, img)); break;
578
+ case 'bullets': slideHtmls.push(renderBulletsSlide(s, theme, num, totalSlides, img)); break;
579
+ case 'table': slideHtmls.push(renderTableSlide(s, theme, num, totalSlides)); break;
580
+ case 'chart': slideHtmls.push(renderChartSlide(s, theme, num, totalSlides)); break;
581
+ case 'donut': slideHtmls.push(renderDonutSlide(s, theme, num, totalSlides)); break;
582
+ case 'timeline': slideHtmls.push(renderTimelineSlide(s, theme, num, totalSlides)); break;
583
+ case 'quote': slideHtmls.push(renderQuoteSlide(s, theme, num, totalSlides, img)); break;
584
+ case 'progress': slideHtmls.push(renderProgressSlide(s, theme, num, totalSlides)); break;
585
+ default: slideHtmls.push(renderContentSlide(s, theme, num, totalSlides, img)); break;
586
+ }
587
+ }
588
+
589
+ // Thank you slide
590
+ slideHtmls.push(renderThankYouSlide(theme, brand, imageBuffers[imageBuffers.length - 1]));
591
+
592
+ // Render each slide via Puppeteer → screenshot → assemble PPTX
593
+ const puppeteer = await import('puppeteer-core');
594
+ const paths = ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium-browser', '/usr/bin/chromium', '/snap/bin/chromium'];
595
+ let execPath = null;
596
+ for (const p of paths) { if (existsSync(p)) { execPath = p; break; } }
597
+ if (!execPath) throw new Error('No Chrome/Chromium found');
598
+
599
+ const browser = await puppeteer.default.launch({
600
+ executablePath: execPath, headless: 'new',
601
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--font-render-hinting=none'],
602
+ });
603
+
604
+ const pptx = new PptxGenJS();
605
+ pptx.layout = 'LAYOUT_WIDE';
606
+ pptx.author = brand;
607
+ pptx.title = title;
608
+
609
+ const page = await browser.newPage();
610
+ await page.setViewport({ width: 1280, height: 720 });
611
+
612
+ for (let i = 0; i < slideHtmls.length; i++) {
613
+ const html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><style>${CSS_BASE}</style></head><body style="margin:0;padding:0;background:#000">${slideHtmls[i]}</body></html>`;
614
+
615
+ await page.setContent(html, { waitUntil: 'networkidle0', timeout: 15000 });
616
+ // Wait for fonts to load
617
+ await page.evaluate(() => document.fonts.ready);
618
+ await new Promise(r => setTimeout(r, 300));
619
+
620
+ const screenshot = await page.screenshot({ type: 'jpeg', quality: 95 });
621
+
622
+ // Add as full-bleed image slide
623
+ const slide = pptx.addSlide();
624
+ const imgB64 = screenshot.toString('base64');
625
+ slide.addImage({
626
+ data: `image/jpeg;base64,${imgB64}`,
627
+ x: 0, y: 0, w: '100%', h: '100%',
628
+ });
629
+
630
+ logger.debug('slides-engine', `Slide ${i + 1}/${slideHtmls.length} rendered`);
631
+ }
632
+
633
+ await browser.close();
634
+
635
+ // Save PPTX
636
+ const filename = title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '_').slice(0, 50) + '.pptx';
637
+ const filepath = join(OUTPUT_DIR, filename);
638
+ await pptx.writeFile({ fileName: filepath });
639
+
640
+ // Also save HTML version for web viewing
641
+ const webHtml = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${title}</title>
642
+ <style>${CSS_BASE}
643
+ body { background: #000; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 20px; }
644
+ .slide { flex-shrink: 0; border-radius: 4px; box-shadow: 0 4px 20px rgba(0,0,0,0.5); }
645
+ </style></head><body>${slideHtmls.join('\n')}</body></html>`;
646
+ const htmlPath = filepath.replace('.pptx', '.html');
647
+ writeFileSync(htmlPath, webHtml);
648
+
649
+ logger.info('slides-engine', `Done: ${filepath} (${slideHtmls.length} slides)`);
650
+
651
+ return {
652
+ filepath, filename,
653
+ htmlPath,
654
+ slides: slideHtmls.length,
655
+ types: [...new Set(slides.map(s => s.type))],
656
+ images: imageBuffers.filter(Boolean).length,
657
+ };
658
+ }
659
+
660
+ export { parseToSlides, fetchImage };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squidclaw",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "🦑 AI agent platform — human-like agents for WhatsApp, Telegram & more",
5
5
  "main": "lib/engine.js",
6
6
  "bin": {