squidclaw 3.1.1 → 3.2.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 +4 -2
- package/lib/ai/prompt-builder.js +8 -4
- package/lib/channels/telegram/bot.js +7 -0
- package/lib/core/agent.js +7 -2
- package/lib/engine.js +1 -1
- package/lib/features/auto-memory.js +58 -41
- package/lib/features/config-manager.js +6 -3
- package/lib/middleware/response-sender.js +49 -7
- package/lib/tools/router.js +11 -8
- package/lib/tools/slides-engine.js +660 -0
- package/package.json +1 -1
package/lib/ai/gateway.js
CHANGED
|
@@ -31,8 +31,10 @@ export class AIGateway {
|
|
|
31
31
|
// Smart routing: use best model for the task
|
|
32
32
|
let routedModel = options.model;
|
|
33
33
|
if (!routedModel && options.taskHint) {
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
try {
|
|
35
|
+
const route = getRouteForTask(options.taskHint, options.toolName, this.config);
|
|
36
|
+
if (route) routedModel = route.model;
|
|
37
|
+
} catch {}
|
|
36
38
|
}
|
|
37
39
|
const model = routedModel || this.config.ai?.defaultModel;
|
|
38
40
|
const fallbackChain = options.fallbackChain || this.config.ai?.fallbackChain || [];
|
package/lib/ai/prompt-builder.js
CHANGED
|
@@ -74,11 +74,15 @@ export class PromptBuilder {
|
|
|
74
74
|
|
|
75
75
|
// 4. Contact context
|
|
76
76
|
const contact = await this.storage.getContact(agent.id, contactId);
|
|
77
|
+
parts.push('\n## About This Person');
|
|
77
78
|
if (contact) {
|
|
78
|
-
parts.push(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
if (contact.name) parts.push('- Name: ' + contact.name);
|
|
80
|
+
parts.push('- Messages exchanged: ' + (contact.message_count || 0));
|
|
81
|
+
}
|
|
82
|
+
// Also check memories for name (more reliable)
|
|
83
|
+
const memName = memories.find(m => m.key === 'name');
|
|
84
|
+
if (memName && memName.value) {
|
|
85
|
+
parts.push('- Confirmed name: ' + memName.value + ' (USE THIS NAME)');
|
|
82
86
|
}
|
|
83
87
|
|
|
84
88
|
// 5. Knowledge base (RAG)
|
|
@@ -157,6 +157,13 @@ export class TelegramManager {
|
|
|
157
157
|
/**
|
|
158
158
|
* Send a reaction
|
|
159
159
|
*/
|
|
160
|
+
async sendChatAction(agentId, contactId, action, metadata = {}) {
|
|
161
|
+
const botInfo = this.bots.get(agentId);
|
|
162
|
+
if (!botInfo?.bot) return;
|
|
163
|
+
const chatId = metadata.chatId || contactId.replace('tg_', '');
|
|
164
|
+
try { await botInfo.bot.api.sendChatAction(chatId, action); } catch {}
|
|
165
|
+
}
|
|
166
|
+
|
|
160
167
|
async sendReaction(agentId, contactId, messageId, emoji, metadata = {}) {
|
|
161
168
|
const botInfo = this.bots.get(agentId);
|
|
162
169
|
if (!botInfo?.bot) return;
|
package/lib/core/agent.js
CHANGED
|
@@ -66,12 +66,17 @@ export class Agent {
|
|
|
66
66
|
const systemPrompt = await this.promptBuilder.build(this, contactId, message);
|
|
67
67
|
|
|
68
68
|
// Get conversation history
|
|
69
|
-
const history = await this.storage.getConversation(this.id, contactId,
|
|
69
|
+
const history = await this.storage.getConversation(this.id, contactId, 20);
|
|
70
70
|
|
|
71
71
|
// Build messages array for AI
|
|
72
72
|
const messages = [
|
|
73
73
|
{ role: 'system', content: systemPrompt },
|
|
74
|
-
...history.map(h => ({
|
|
74
|
+
...history.map(h => ({
|
|
75
|
+
role: h.role,
|
|
76
|
+
content: h.role === 'assistant'
|
|
77
|
+
? h.content.replace(/---TOOL:\w+:.{0,200}---/gs, '[tool used]').slice(0, 500)
|
|
78
|
+
: h.content.slice(0, 500),
|
|
79
|
+
})),
|
|
75
80
|
];
|
|
76
81
|
|
|
77
82
|
// If the last message in history is already the current message, don't add again
|
package/lib/engine.js
CHANGED
|
@@ -117,7 +117,7 @@ export class SquidclawEngine {
|
|
|
117
117
|
initLogger(this.config.engine?.logLevel || 'info');
|
|
118
118
|
|
|
119
119
|
console.log(`
|
|
120
|
-
🦑 Squidclaw Engine
|
|
120
|
+
🦑 Squidclaw Engine v${require('./package.json').version}
|
|
121
121
|
──────────────────────────`);
|
|
122
122
|
|
|
123
123
|
// 1. Storage
|
|
@@ -1,47 +1,73 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 🦑 Auto Memory —
|
|
3
|
-
*
|
|
2
|
+
* 🦑 Auto Memory v2 — Smarter fact extraction
|
|
3
|
+
* Fixes: no more saving "ur" as a name, better filtering
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { logger } from '../core/logger.js';
|
|
7
7
|
|
|
8
|
+
// Words that should NEVER be saved as names
|
|
9
|
+
const NAME_BLACKLIST = new Set([
|
|
10
|
+
'ur', 'u', 'im', 'me', 'my', 'i', 'the', 'a', 'an', 'is', 'am', 'are',
|
|
11
|
+
'here', 'there', 'this', 'that', 'it', 'he', 'she', 'they', 'we', 'you',
|
|
12
|
+
'not', 'no', 'yes', 'ok', 'okay', 'hi', 'hey', 'hello', 'bye',
|
|
13
|
+
'just', 'very', 'really', 'actually', 'basically', 'literally',
|
|
14
|
+
'new', 'old', 'good', 'bad', 'great', 'nice', 'cool', 'fine',
|
|
15
|
+
'going', 'doing', 'working', 'looking', 'trying', 'getting',
|
|
16
|
+
'available', 'interested', 'confused', 'tired', 'busy', 'free',
|
|
17
|
+
'happy', 'sad', 'angry', 'bored', 'excited', 'worried',
|
|
18
|
+
'still', 'also', 'too', 'now', 'then', 'so', 'but',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
// Must look like a real name: 2+ chars, starts with letter, no pure numbers
|
|
22
|
+
function isValidName(name) {
|
|
23
|
+
if (!name || name.length < 2 || name.length > 30) return false;
|
|
24
|
+
if (NAME_BLACKLIST.has(name.toLowerCase())) return false;
|
|
25
|
+
if (/^\d+$/.test(name)) return false;
|
|
26
|
+
if (/^[a-z]/.test(name) && !/[\u0600-\u06FF]/.test(name)) return false; // English names must be capitalized
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Must look like a real location
|
|
31
|
+
function isValidLocation(loc) {
|
|
32
|
+
if (!loc || loc.length < 2 || loc.length > 40) return false;
|
|
33
|
+
if (/^(here|there|home|work|office|bed|room|outside)$/i.test(loc.trim())) return false;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
8
37
|
const FACT_PATTERNS = [
|
|
9
|
-
// Names
|
|
10
|
-
{ regex: /(?:my name is|call me
|
|
38
|
+
// Names — stricter matching
|
|
39
|
+
{ regex: /(?:my name is|call me|اسمي)\s+([A-Z\u0600-\u06FF][\w\u0600-\u06FF]{1,25})/i, key: 'name', extract: 1, validate: isValidName },
|
|
40
|
+
|
|
41
|
+
// "I'm Tamer" — but ONLY if the word after is a capitalized proper noun (not "I'm fine")
|
|
42
|
+
{ regex: /^(?:i'?m|ana)\s+([A-Z\u0600-\u06FF][\w\u0600-\u06FF]{1,25})$/im, key: 'name', extract: 1, validate: isValidName },
|
|
11
43
|
|
|
12
44
|
// Location
|
|
13
|
-
{ regex: /(?:i live in|i'?m from|
|
|
45
|
+
{ regex: /(?:i live in|i'?m from|based in|located in|ساكن في|من مدينة)\s+([A-Z\u0600-\u06FF][\w\u0600-\u06FF\s]{2,30})/i, key: 'location', extract: 1, validate: isValidLocation },
|
|
14
46
|
|
|
15
47
|
// Job
|
|
16
|
-
{ regex: /(?:i work (?:as|at|in|for)|my job is
|
|
48
|
+
{ regex: /(?:i work (?:as|at|in|for)|my job is|اشتغل|شغلي)\s+(.{3,40}?)(?:\.|,|!|\?|$)/i, key: 'job', extract: 1 },
|
|
17
49
|
|
|
18
|
-
// Age
|
|
19
|
-
{ regex: /(?:i'?m|i am|عمري)\s+(\d{1,3})\s*(?:years? old|سنة|سنه)
|
|
50
|
+
// Age — must be reasonable
|
|
51
|
+
{ regex: /(?:i'?m|i am|عمري)\s+(\d{1,3})\s*(?:years? old|سنة|سنه)/i, key: 'age', extract: 1, validate: v => { const n = parseInt(v); return n > 5 && n < 120; } },
|
|
20
52
|
|
|
21
53
|
// Family
|
|
22
|
-
{ regex: /(?:my (?:wife|husband|son|daughter|brother|sister|mom|dad|father|mother)(?:'s name)? is)\s+(\w+)/i, key: (m) => m[0].match(/wife|husband|son|daughter|brother|sister|mom|dad|father|mother/i)[0], extract: 1 },
|
|
54
|
+
{ regex: /(?:my (?:wife|husband|son|daughter|brother|sister|mom|dad|father|mother)(?:'s name)? is)\s+([A-Z\u0600-\u06FF]\w+)/i, key: (m) => m[0].match(/wife|husband|son|daughter|brother|sister|mom|dad|father|mother/i)[0], extract: 1, validate: isValidName },
|
|
23
55
|
|
|
24
56
|
// Favorites
|
|
25
|
-
{ regex: /(?:my fav(?:orite|ourite)?\s+(\w+)\s+is)\s+(
|
|
26
|
-
{ regex: /(?:i (?:love|like|prefer|enjoy))\s+(.{3,30}?)(?:\.|,|!|\?|$)/i, key: 'likes', extract: 1, append: true },
|
|
27
|
-
{ regex: /(?:i (?:hate|dislike|don'?t like))\s+(.{3,30}?)(?:\.|,|!|\?|$)/i, key: 'dislikes', extract: 1, append: true },
|
|
28
|
-
|
|
29
|
-
// Timezone
|
|
30
|
-
{ regex: /(?:my time(?:zone)? is|i'?m in)\s+(UTC[+-]\d+|GMT[+-]\d+|[A-Z]{2,4}\/[A-Za-z_]+)/i, key: 'timezone', extract: 1 },
|
|
57
|
+
{ regex: /(?:my fav(?:orite|ourite)?\s+(\w+)\s+is)\s+(.{2,30}?)(?:\.|,|!|$)/i, key: (m) => 'favorite_' + m[1], extract: 2 },
|
|
31
58
|
|
|
32
59
|
// Birthday
|
|
33
|
-
{ regex: /(?:my birthday is|born on|ميلادي)\s+(.{5,
|
|
60
|
+
{ regex: /(?:my birthday is|born on|ميلادي)\s+(.{5,25})/i, key: 'birthday', extract: 1 },
|
|
34
61
|
|
|
35
62
|
// Language
|
|
36
|
-
{ regex: /(?:i speak|my language is|لغتي)\s+(\w+)/i, key: 'language', extract: 1 },
|
|
63
|
+
{ regex: /(?:i speak|my language is|لغتي)\s+([A-Z\u0600-\u06FF]\w+)/i, key: 'language', extract: 1 },
|
|
37
64
|
|
|
38
65
|
// Pet
|
|
39
|
-
{ regex: /(?:my (?:cat|dog|pet)(?:'s name)? is)\s+(\w+)/i, key: (m) => m[0].match(/cat|dog|pet/i)[0] + '_name', extract: 1 },
|
|
66
|
+
{ regex: /(?:my (?:cat|dog|pet)(?:'s name)? is)\s+([A-Z\u0600-\u06FF]\w+)/i, key: (m) => m[0].match(/cat|dog|pet/i)[0] + '_name', extract: 1, validate: isValidName },
|
|
40
67
|
];
|
|
41
68
|
|
|
42
|
-
// Extract per-contact facts
|
|
43
69
|
const CONTACT_PATTERNS = [
|
|
44
|
-
{ regex: /(?:remember|don'?t forget|note|تذكر|لا تنسى)\s+(?:that\s+)?(.{5,100})/i, key: 'noted', extract: 1 },
|
|
70
|
+
{ regex: /(?:remember|don'?t forget|note that|تذكر|لا تنسى)\s+(?:that\s+)?(.{5,100})/i, key: 'noted', extract: 1 },
|
|
45
71
|
];
|
|
46
72
|
|
|
47
73
|
export class AutoMemory {
|
|
@@ -49,10 +75,10 @@ export class AutoMemory {
|
|
|
49
75
|
this.storage = storage;
|
|
50
76
|
}
|
|
51
77
|
|
|
52
|
-
/**
|
|
53
|
-
* Scan a user message for facts and auto-save them
|
|
54
|
-
*/
|
|
55
78
|
async extract(agentId, contactId, message) {
|
|
79
|
+
// Skip very short messages or commands
|
|
80
|
+
if (!message || message.length < 4 || message.startsWith('/')) return [];
|
|
81
|
+
|
|
56
82
|
const extracted = [];
|
|
57
83
|
|
|
58
84
|
for (const pattern of FACT_PATTERNS) {
|
|
@@ -62,23 +88,20 @@ export class AutoMemory {
|
|
|
62
88
|
const key = typeof pattern.key === 'function' ? pattern.key(match) : pattern.key;
|
|
63
89
|
const value = match[pattern.extract].trim();
|
|
64
90
|
|
|
91
|
+
// Validate
|
|
65
92
|
if (value.length < 2 || value.length > 100) continue;
|
|
93
|
+
if (pattern.validate && !pattern.validate(value)) continue;
|
|
66
94
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (existing && existing.includes(value)) continue;
|
|
71
|
-
const newValue = existing ? existing + ', ' + value : value;
|
|
72
|
-
await this.storage.saveMemory(agentId, key, newValue, 'auto');
|
|
73
|
-
} else {
|
|
74
|
-
await this.storage.saveMemory(agentId, key, value, 'auto');
|
|
75
|
-
}
|
|
95
|
+
// Don't overwrite with worse data
|
|
96
|
+
const existing = await this._getMemory(agentId, key);
|
|
97
|
+
if (existing && key === 'name' && isValidName(existing) && existing.length > value.length) continue;
|
|
76
98
|
|
|
99
|
+
await this.storage.saveMemory(agentId, key, value, 'auto');
|
|
77
100
|
extracted.push({ key, value });
|
|
78
101
|
logger.info('auto-memory', `Extracted: ${key} = ${value}`);
|
|
79
102
|
}
|
|
80
103
|
|
|
81
|
-
//
|
|
104
|
+
// "Remember this" patterns
|
|
82
105
|
for (const pattern of CONTACT_PATTERNS) {
|
|
83
106
|
const match = message.match(pattern.regex);
|
|
84
107
|
if (!match) continue;
|
|
@@ -88,7 +111,6 @@ export class AutoMemory {
|
|
|
88
111
|
const key = 'user_note_' + Date.now().toString(36);
|
|
89
112
|
await this.storage.saveMemory(agentId, key, value, 'noted');
|
|
90
113
|
extracted.push({ key, value });
|
|
91
|
-
logger.info('auto-memory', `User note: ${value}`);
|
|
92
114
|
}
|
|
93
115
|
|
|
94
116
|
return extracted;
|
|
@@ -96,12 +118,7 @@ export class AutoMemory {
|
|
|
96
118
|
|
|
97
119
|
async _getMemory(agentId, key) {
|
|
98
120
|
try {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
).get(agentId, key);
|
|
102
|
-
return row?.value;
|
|
103
|
-
} catch {
|
|
104
|
-
return null;
|
|
105
|
-
}
|
|
121
|
+
return this.storage.db.prepare('SELECT value FROM memories WHERE agent_id = ? AND key = ?').get(agentId, key)?.value;
|
|
122
|
+
} catch { return null; }
|
|
106
123
|
}
|
|
107
124
|
}
|
|
@@ -212,11 +212,12 @@ export class ConfigManager {
|
|
|
212
212
|
static parseConfigCommand(message) {
|
|
213
213
|
const lower = message.toLowerCase().trim();
|
|
214
214
|
|
|
215
|
-
// "switch to gpt-4o" / "use gemini"
|
|
216
|
-
const modelMatch = lower.match(
|
|
215
|
+
// "switch to gpt-4o" / "use gemini" — must be the MAIN intent (start of message)
|
|
216
|
+
const modelMatch = lower.match(/^(?:switch|change|use)\s+(?:to\s+)?(?:model\s+)?(\S+(?:\s+\S+){0,3})$/);
|
|
217
217
|
if (modelMatch) {
|
|
218
218
|
const model = modelMatch[1].trim();
|
|
219
|
-
//
|
|
219
|
+
// Only match if it looks like a model name (known alias or contains provider-like patterns)
|
|
220
|
+
const knownPatterns = /^(gpt|claude|sonnet|opus|haiku|gemini|flash|llama|deepseek|mistral|groq|ollama|o[1-4]|\S+-\d)/i;
|
|
220
221
|
const aliases = {
|
|
221
222
|
'gpt4': 'gpt-4o', 'gpt-4': 'gpt-4o', 'gpt4o': 'gpt-4o',
|
|
222
223
|
'claude': 'claude-sonnet-4-20250514', 'sonnet': 'claude-sonnet-4-20250514',
|
|
@@ -224,6 +225,8 @@ export class ConfigManager {
|
|
|
224
225
|
'gemini': 'gemini-2.5-flash', 'flash': 'gemini-2.5-flash',
|
|
225
226
|
'llama': 'llama-3.3-70b-versatile', 'deepseek': 'deepseek-chat',
|
|
226
227
|
};
|
|
228
|
+
if (!knownPatterns.test(model) && !aliases[model]) return null; // Not a model name
|
|
229
|
+
// Map common names to actual model IDs
|
|
227
230
|
return { action: 'set_model', model: aliases[model] || model };
|
|
228
231
|
}
|
|
229
232
|
|
|
@@ -1,8 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Send the response back to the user via the appropriate channel
|
|
3
|
+
* With human-like timing — small delays to feel natural
|
|
3
4
|
*/
|
|
4
5
|
import { logger } from '../core/logger.js';
|
|
5
6
|
|
|
7
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
8
|
+
|
|
9
|
+
// Calculate human-like "typing" delay based on message length
|
|
10
|
+
function typingDelay(text) {
|
|
11
|
+
const len = (text || '').length;
|
|
12
|
+
if (len < 20) return 400 + Math.random() * 300; // Short: 0.4-0.7s
|
|
13
|
+
if (len < 100) return 700 + Math.random() * 500; // Medium: 0.7-1.2s
|
|
14
|
+
if (len < 300) return 1000 + Math.random() * 700; // Long: 1.0-1.7s
|
|
15
|
+
return 1500 + Math.random() * 800; // Very long: 1.5-2.3s
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Delay between split messages
|
|
19
|
+
function splitDelay(text) {
|
|
20
|
+
const len = (text || '').length;
|
|
21
|
+
if (len < 50) return 300 + Math.random() * 400; // 0.3-0.7s
|
|
22
|
+
return 500 + Math.random() * 700; // 0.5-1.2s
|
|
23
|
+
}
|
|
24
|
+
|
|
6
25
|
export async function responseSenderMiddleware(ctx, next) {
|
|
7
26
|
if (!ctx.response) return;
|
|
8
27
|
|
|
@@ -15,9 +34,10 @@ export async function responseSenderMiddleware(ctx, next) {
|
|
|
15
34
|
ctx.engine.reminders.add(agentId, contactId, response._reminder.message, response._reminder.time, ctx.platform, metadata);
|
|
16
35
|
}
|
|
17
36
|
|
|
18
|
-
// Send reaction
|
|
37
|
+
// Send reaction first (with tiny natural delay)
|
|
19
38
|
if (response.reaction && metadata.messageId) {
|
|
20
39
|
try {
|
|
40
|
+
await sleep(200 + Math.random() * 400); // 0.2-0.6s to "see" the message
|
|
21
41
|
if (ctx.platform === 'telegram' && tm) {
|
|
22
42
|
await tm.sendReaction(agentId, contactId, metadata.messageId, response.reaction, metadata);
|
|
23
43
|
}
|
|
@@ -27,9 +47,18 @@ export async function responseSenderMiddleware(ctx, next) {
|
|
|
27
47
|
// No messages to send
|
|
28
48
|
if (!response.messages?.length && !response.image) return;
|
|
29
49
|
|
|
50
|
+
// Human-like delay before first reply (thinking time)
|
|
51
|
+
const firstMsg = response.messages?.[0] || '';
|
|
52
|
+
await sleep(typingDelay(firstMsg));
|
|
53
|
+
|
|
54
|
+
// Send typing indicator if available
|
|
55
|
+
if (ctx.platform === 'telegram' && tm) {
|
|
56
|
+
try { await tm.sendChatAction(agentId, contactId, 'typing', metadata); } catch {}
|
|
57
|
+
}
|
|
58
|
+
|
|
30
59
|
// Send via appropriate channel
|
|
31
60
|
if (ctx.platform === 'telegram' && tm) {
|
|
32
|
-
// Send file attachment
|
|
61
|
+
// Send file attachment
|
|
33
62
|
if (response.filePath) {
|
|
34
63
|
logger.info('sender', 'SENDING FILE: ' + response.filePath);
|
|
35
64
|
try {
|
|
@@ -38,7 +67,6 @@ export async function responseSenderMiddleware(ctx, next) {
|
|
|
38
67
|
return;
|
|
39
68
|
} catch (err) {
|
|
40
69
|
logger.error('sender', 'File send failed: ' + err.message);
|
|
41
|
-
// Fall through to text
|
|
42
70
|
}
|
|
43
71
|
}
|
|
44
72
|
|
|
@@ -53,14 +81,28 @@ export async function responseSenderMiddleware(ctx, next) {
|
|
|
53
81
|
const audio = await vr.generate(response.messages[0], { language: lang });
|
|
54
82
|
await tm.sendVoice(agentId, contactId, audio, metadata);
|
|
55
83
|
} catch {
|
|
56
|
-
|
|
84
|
+
// Fallback: send one by one with timing
|
|
85
|
+
for (let i = 0; i < response.messages.length; i++) {
|
|
86
|
+
if (i > 0) { try { await tm.sendChatAction(agentId, contactId, "typing", metadata); } catch {} await sleep(splitDelay(response.messages[i])); }
|
|
87
|
+
await tm.sendMessage(agentId, contactId, response.messages[i], metadata);
|
|
88
|
+
}
|
|
57
89
|
}
|
|
58
90
|
} else {
|
|
59
|
-
|
|
91
|
+
// Send messages with human-like gaps between splits
|
|
92
|
+
for (let i = 0; i < response.messages.length; i++) {
|
|
93
|
+
if (i > 0) {
|
|
94
|
+
// Typing indicator + delay between messages
|
|
95
|
+
try { await tm.sendChatAction(agentId, contactId, 'typing', metadata); } catch {}
|
|
96
|
+
await sleep(splitDelay(response.messages[i]));
|
|
97
|
+
}
|
|
98
|
+
await tm.sendMessage(agentId, contactId, response.messages[i], metadata);
|
|
99
|
+
}
|
|
60
100
|
}
|
|
61
101
|
} else if (wm) {
|
|
62
|
-
|
|
63
|
-
|
|
102
|
+
// WhatsApp — same human timing
|
|
103
|
+
for (let i = 0; i < response.messages.length; i++) {
|
|
104
|
+
if (i > 0) await sleep(splitDelay(response.messages[i]));
|
|
105
|
+
await wm.sendMessage(agentId, contactId, response.messages[i]);
|
|
64
106
|
}
|
|
65
107
|
}
|
|
66
108
|
|
package/lib/tools/router.js
CHANGED
|
@@ -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 {
|
|
388
|
-
const
|
|
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 =
|
|
401
|
+
const firstH = unescaped.match(/^#\s+(.+)/m);
|
|
400
402
|
title = firstH ? firstH[1] : 'Presentation';
|
|
401
|
-
content =
|
|
403
|
+
content = unescaped;
|
|
402
404
|
theme = 'executive';
|
|
403
405
|
}
|
|
404
|
-
const result = await
|
|
406
|
+
const result = await generateSlides({ title, theme, content });
|
|
405
407
|
return {
|
|
406
408
|
toolUsed: true,
|
|
407
409
|
toolName: 'pptx',
|
|
408
|
-
toolResult: '
|
|
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 = '
|
|
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
|
+
/* Fonts: system stack (no external loads) */
|
|
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: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, 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: Georgia, 'Times New Roman', serif; font-size: 56px; font-weight: 800; color: #fff; line-height: 1.1; letter-spacing: -1px; }
|
|
92
|
+
.title-lg { font-family: Georgia, 'Times New Roman', 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: Georgia, 'Times New Roman', 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: Georgia, 'Times New Roman', 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: 'domcontentloaded', timeout: 10000 });
|
|
616
|
+
// Wait for fonts to load
|
|
617
|
+
// fonts are system, no wait needed
|
|
618
|
+
await new Promise(r => setTimeout(r, 100));
|
|
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 };
|