squidclaw 2.8.0 → 3.0.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/engine.js CHANGED
@@ -228,6 +228,12 @@ export class SquidclawEngine {
228
228
  if (pending.c > 0) console.log(` ⏰ Reminders: ${pending.c} pending`);
229
229
  } catch {}
230
230
 
231
+ // Canvas (Visual Rendering)
232
+ try {
233
+ const { Canvas } = await import('./features/canvas.js');
234
+ this.canvas = new Canvas(this);
235
+ } catch (err) { logger.error('engine', 'Canvas init failed: ' + err.message); }
236
+
231
237
  // Nodes (Paired Devices)
232
238
  try {
233
239
  const { NodeManager } = await import('./features/nodes.js');
@@ -0,0 +1,228 @@
1
+ /**
2
+ * 🦑 Canvas — Render HTML/charts as images in chat
3
+ * Uses Puppeteer to render HTML → screenshot → send as image
4
+ * Mini-apps, dashboards, visual reports
5
+ */
6
+
7
+ import { logger } from '../core/logger.js';
8
+ import { writeFileSync, mkdirSync } from 'fs';
9
+ import { join } from 'path';
10
+
11
+ export class Canvas {
12
+ constructor(engine) {
13
+ this.engine = engine;
14
+ this.outputDir = '/tmp/squidclaw-canvas';
15
+ mkdirSync(this.outputDir, { recursive: true });
16
+ }
17
+
18
+ // ── Render HTML to image ──
19
+
20
+ async renderHtml(html, options = {}) {
21
+ const puppeteer = await import('puppeteer-core');
22
+ const { existsSync } = await import('fs');
23
+
24
+ const paths = ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium-browser', '/usr/bin/chromium', '/snap/bin/chromium'];
25
+ let execPath = null;
26
+ for (const p of paths) { if (existsSync(p)) { execPath = p; break; } }
27
+ if (!execPath) throw new Error('No Chrome/Chromium found');
28
+
29
+ const browser = await puppeteer.default.launch({
30
+ executablePath: execPath,
31
+ headless: 'new',
32
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
33
+ });
34
+
35
+ try {
36
+ const page = await browser.newPage();
37
+ const width = options.width || 800;
38
+ const height = options.height || 600;
39
+ await page.setViewport({ width, height });
40
+
41
+ // Full HTML page
42
+ const fullHtml = html.includes('<html') ? html : `
43
+ <!DOCTYPE html>
44
+ <html>
45
+ <head>
46
+ <meta charset="UTF-8">
47
+ <style>
48
+ * { margin: 0; padding: 0; box-sizing: border-box; }
49
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0d1117; color: #c9d1d9; padding: 24px; }
50
+ h1 { color: #58a6ff; font-size: 24px; margin-bottom: 16px; }
51
+ h2 { color: #58a6ff; font-size: 18px; margin: 20px 0 8px; }
52
+ .card { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 20px; margin: 12px 0; }
53
+ .stat { display: inline-block; text-align: center; padding: 16px 24px; }
54
+ .stat-value { font-size: 36px; font-weight: bold; color: #58a6ff; }
55
+ .stat-label { font-size: 12px; color: #8b949e; margin-top: 4px; }
56
+ .grid { display: grid; gap: 12px; }
57
+ .grid-2 { grid-template-columns: 1fr 1fr; }
58
+ .grid-3 { grid-template-columns: 1fr 1fr 1fr; }
59
+ .grid-4 { grid-template-columns: 1fr 1fr 1fr 1fr; }
60
+ .bar { height: 24px; border-radius: 4px; margin: 4px 0; }
61
+ .bar-fill { height: 100%; border-radius: 4px; background: linear-gradient(90deg, #58a6ff, #3fb950); }
62
+ .progress { background: #21262d; border-radius: 8px; overflow: hidden; height: 12px; margin: 6px 0; }
63
+ .progress-fill { height: 100%; background: linear-gradient(90deg, #58a6ff, #3fb950); border-radius: 8px; }
64
+ table { width: 100%; border-collapse: collapse; }
65
+ th { background: #161b22; color: #58a6ff; padding: 10px; text-align: left; border-bottom: 2px solid #30363d; }
66
+ td { padding: 10px; border-bottom: 1px solid #21262d; }
67
+ tr:nth-child(even) { background: #161b22; }
68
+ .tag { display: inline-block; background: #1f6feb33; color: #58a6ff; padding: 2px 10px; border-radius: 12px; font-size: 12px; margin: 2px; }
69
+ .green { color: #3fb950; } .red { color: #f85149; } .yellow { color: #d29922; }
70
+ .accent { color: #58a6ff; }
71
+ </style>
72
+ </head>
73
+ <body>${html}</body>
74
+ </html>`;
75
+
76
+ await page.setContent(fullHtml, { waitUntil: 'networkidle0', timeout: 10000 });
77
+
78
+ // Auto-height if not specified
79
+ if (!options.height) {
80
+ const bodyHeight = await page.evaluate(() => document.body.scrollHeight);
81
+ await page.setViewport({ width, height: Math.min(bodyHeight + 48, 2000) });
82
+ }
83
+
84
+ const screenshot = await page.screenshot({
85
+ type: 'png',
86
+ fullPage: !options.height,
87
+ });
88
+
89
+ const filename = (options.name || 'canvas_' + Date.now()) + '.png';
90
+ const filepath = join(this.outputDir, filename);
91
+ writeFileSync(filepath, screenshot);
92
+
93
+ logger.info('canvas', `Rendered: ${filepath} (${width}x${options.height || 'auto'})`);
94
+ return { buffer: screenshot, filepath, filename };
95
+ } finally {
96
+ await browser.close();
97
+ }
98
+ }
99
+
100
+ // ── Pre-built templates ──
101
+
102
+ async renderDashboard(data) {
103
+ const { title, stats, chart, table } = data;
104
+
105
+ let html = `<h1>${title || 'Dashboard'}</h1>`;
106
+
107
+ // Stats cards
108
+ if (stats && stats.length > 0) {
109
+ html += `<div class="card"><div class="grid grid-${Math.min(stats.length, 4)}">`;
110
+ for (const s of stats) {
111
+ html += `<div class="stat">
112
+ ${s.icon ? `<div style="font-size:32px;margin-bottom:4px">${s.icon}</div>` : ''}
113
+ <div class="stat-value" style="color:${s.color || '#58a6ff'}">${s.value}</div>
114
+ <div class="stat-label">${s.label}</div>
115
+ </div>`;
116
+ }
117
+ html += `</div></div>`;
118
+ }
119
+
120
+ // Bar chart
121
+ if (chart && chart.length > 0) {
122
+ const max = Math.max(...chart.map(c => c.value));
123
+ html += `<div class="card"><h2>${data.chartTitle || 'Chart'}</h2>`;
124
+ for (const item of chart) {
125
+ const pct = (item.value / max * 100).toFixed(0);
126
+ html += `<div style="display:flex;align-items:center;margin:8px 0">
127
+ <div style="width:100px;font-size:13px">${item.label}</div>
128
+ <div style="flex:1;background:#21262d;border-radius:4px;height:24px;overflow:hidden">
129
+ <div style="width:${pct}%;height:100%;background:linear-gradient(90deg,#58a6ff,#3fb950);border-radius:4px;display:flex;align-items:center;padding-left:8px">
130
+ <span style="font-size:11px;color:#fff;font-weight:bold">${item.value}</span>
131
+ </div>
132
+ </div>
133
+ </div>`;
134
+ }
135
+ html += `</div>`;
136
+ }
137
+
138
+ // Table
139
+ if (table && table.length > 0) {
140
+ html += `<div class="card"><h2>${data.tableTitle || 'Data'}</h2><table>`;
141
+ html += `<tr>${table[0].map(h => `<th>${h}</th>`).join('')}</tr>`;
142
+ for (let i = 1; i < table.length; i++) {
143
+ html += `<tr>${table[i].map(c => `<td>${c}</td>`).join('')}</tr>`;
144
+ }
145
+ html += `</table></div>`;
146
+ }
147
+
148
+ html += `<div style="text-align:center;margin-top:16px;font-size:11px;color:#484f58">Squidclaw AI 🦑 · ${new Date().toLocaleString()}</div>`;
149
+
150
+ return this.renderHtml(html, { name: 'dashboard' });
151
+ }
152
+
153
+ async renderChart(data) {
154
+ const { title, type, items } = data;
155
+ const max = Math.max(...items.map(i => i.value));
156
+ const colors = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#f778ba', '#a5d6ff', '#7ee787'];
157
+
158
+ let html = `<h1>${title || 'Chart'}</h1><div class="card" style="padding:24px">`;
159
+
160
+ if (type === 'pie' || type === 'doughnut') {
161
+ const total = items.reduce((s, i) => s + i.value, 0);
162
+ let rotation = 0;
163
+ const gradientParts = [];
164
+
165
+ items.forEach((item, idx) => {
166
+ const pct = (item.value / total * 100);
167
+ gradientParts.push(`${colors[idx % colors.length]} ${rotation}deg ${rotation + pct * 3.6}deg`);
168
+ rotation += pct * 3.6;
169
+ });
170
+
171
+ const hole = type === 'doughnut' ? 'radial-gradient(circle, #0d1117 40%, transparent 40%),' : '';
172
+ html += `<div style="display:flex;align-items:center;gap:32px">
173
+ <div style="width:200px;height:200px;border-radius:50%;background:${hole}conic-gradient(${gradientParts.join(',')})"></div>
174
+ <div>`;
175
+ items.forEach((item, idx) => {
176
+ html += `<div style="display:flex;align-items:center;gap:8px;margin:6px 0">
177
+ <div style="width:12px;height:12px;border-radius:2px;background:${colors[idx % colors.length]}"></div>
178
+ <span style="font-size:13px">${item.label}: <strong>${item.value}</strong> (${(item.value / total * 100).toFixed(0)}%)</span>
179
+ </div>`;
180
+ });
181
+ html += `</div></div>`;
182
+ } else {
183
+ // Bar chart
184
+ for (const item of items) {
185
+ const pct = (item.value / max * 100).toFixed(0);
186
+ const color = item.color || colors[items.indexOf(item) % colors.length];
187
+ html += `<div style="display:flex;align-items:center;margin:10px 0">
188
+ <div style="width:120px;font-size:13px;color:#8b949e">${item.label}</div>
189
+ <div style="flex:1;background:#21262d;border-radius:6px;height:28px;overflow:hidden">
190
+ <div style="width:${pct}%;height:100%;background:${color};border-radius:6px;display:flex;align-items:center;padding-left:10px">
191
+ <span style="font-size:12px;color:#fff;font-weight:600">${item.value}</span>
192
+ </div>
193
+ </div>
194
+ </div>`;
195
+ }
196
+ }
197
+
198
+ html += `</div>`;
199
+ return this.renderHtml(html, { name: 'chart' });
200
+ }
201
+
202
+ async renderReport(data) {
203
+ const { title, sections } = data;
204
+ let html = `<h1>${title || 'Report'}</h1>`;
205
+
206
+ for (const section of (sections || [])) {
207
+ html += `<div class="card"><h2>${section.title || ''}</h2>`;
208
+ if (section.text) html += `<p style="line-height:1.7;color:#8b949e">${section.text}</p>`;
209
+ if (section.bullets) {
210
+ html += `<ul style="list-style:none;padding:0">`;
211
+ section.bullets.forEach(b => {
212
+ html += `<li style="padding:4px 0;color:#c9d1d9">● ${b}</li>`;
213
+ });
214
+ html += `</ul>`;
215
+ }
216
+ if (section.stats) {
217
+ html += `<div class="grid grid-${Math.min(section.stats.length, 3)}" style="margin-top:12px">`;
218
+ section.stats.forEach(s => {
219
+ html += `<div class="stat"><div class="stat-value" style="font-size:28px">${s.value}</div><div class="stat-label">${s.label}</div></div>`;
220
+ });
221
+ html += `</div>`;
222
+ }
223
+ html += `</div>`;
224
+ }
225
+
226
+ return this.renderHtml(html, { name: 'report' });
227
+ }
228
+ }
@@ -145,6 +145,53 @@ export async function commandsMiddleware(ctx, next) {
145
145
  return;
146
146
  }
147
147
 
148
+ if (cmd === '/canvas') {
149
+ if (!ctx.engine.canvas) { await ctx.reply('❌ Canvas not available'); return; }
150
+ const args = msg.slice(8).trim();
151
+
152
+ if (!args || args === 'help') {
153
+ await ctx.reply('🎨 *Canvas*\n\nRender visual content as images.\n\n/canvas demo — see a sample dashboard\n\nOr ask me naturally: "Show me a dashboard of our stats" or "Make a pie chart of market share"');
154
+ return;
155
+ }
156
+
157
+ if (args === 'demo') {
158
+ try {
159
+ const result = await ctx.engine.canvas.renderDashboard({
160
+ title: 'Squidclaw Dashboard',
161
+ stats: [
162
+ { icon: '🦑', value: 'v2.8', label: 'Version', color: '#58a6ff' },
163
+ { icon: '⚡', value: '40+', label: 'Skills', color: '#3fb950' },
164
+ { icon: '🔌', value: '3', label: 'Plugins', color: '#d29922' },
165
+ { icon: '💬', value: '15', label: 'Middleware', color: '#bc8cff' },
166
+ ],
167
+ chartTitle: 'Feature Growth by Version',
168
+ chart: [
169
+ { label: 'v1.0', value: 20 },
170
+ { label: 'v1.5', value: 28 },
171
+ { label: 'v2.0', value: 40 },
172
+ { label: 'v2.5', value: 48 },
173
+ { label: 'v2.8', value: 55 },
174
+ ],
175
+ tableTitle: 'System Status',
176
+ table: [
177
+ ['Component', 'Status', 'Uptime'],
178
+ ['Telegram', '🟢 Connected', '99.9%'],
179
+ ['WhatsApp', '🔴 Off', '—'],
180
+ ['Plugins', '🟢 3 Active', '100%'],
181
+ ['Sandbox', '🟢 Ready', '100%'],
182
+ ['Nodes', '🟢 Listening', '100%'],
183
+ ],
184
+ });
185
+
186
+ // Send as image
187
+ if (ctx.platform === 'telegram' && ctx.engine.telegramManager) {
188
+ await ctx.engine.telegramManager.sendPhoto(ctx.agentId, ctx.contactId, { base64: result.buffer.toString('base64') }, '🎨 Squidclaw Dashboard Demo', ctx.metadata);
189
+ }
190
+ } catch (err) { await ctx.reply('❌ ' + err.message); }
191
+ return;
192
+ }
193
+ }
194
+
148
195
  if (cmd === '/nodes' || cmd === '/devices') {
149
196
  if (!ctx.engine.nodeManager) { await ctx.reply('❌ Nodes not available'); return; }
150
197
  const args = msg.split(/\s+/).slice(1);
@@ -169,6 +169,17 @@ export class ToolRouter {
169
169
  '---TOOL:handoff:reason---',
170
170
  'Transfer the conversation to a human agent. Use when you cannot help further.');
171
171
 
172
+ tools.push('', '### Render Dashboard (sends as image!)',
173
+ '---TOOL:canvas_dashboard:title|stat_json|chart_json---',
174
+ 'Render a visual dashboard and send as image. Use JSON for data.',
175
+ 'Simple format: ---TOOL:canvas_dashboard:Title|icon:value:label,icon:value:label|label:value,label:value---',
176
+ '', '### Render Chart (sends as image!)',
177
+ '---TOOL:canvas_chart:title|type|label:value,label:value---',
178
+ 'Render a chart (bar, pie, doughnut) as image. Separate items with commas.',
179
+ '', '### Render Custom HTML (sends as image!)',
180
+ '---TOOL:canvas_html:html content---',
181
+ 'Render any HTML as an image. Built-in dark theme CSS with cards, grids, stats, tables.');
182
+
172
183
  tools.push('', '### Pair Device',
173
184
  '---TOOL:node_pair:device name---',
174
185
  'Generate a pairing code for a new device (phone, PC, server).',
@@ -697,6 +708,63 @@ export class ToolRouter {
697
708
  }
698
709
  break;
699
710
  }
711
+ case 'canvas_dashboard': {
712
+ if (!this._engine?.canvas) { toolResult = 'Canvas not available'; break; }
713
+ try {
714
+ const parts = toolArg.split('|');
715
+ const title = parts[0]?.trim() || 'Dashboard';
716
+
717
+ // Parse stats: icon:value:label,icon:value:label
718
+ const stats = [];
719
+ if (parts[1]) {
720
+ parts[1].split(',').forEach(s => {
721
+ const [icon, value, label] = s.trim().split(':');
722
+ if (value) stats.push({ icon: icon || '', value: value || '', label: label || '' });
723
+ });
724
+ }
725
+
726
+ // Parse chart: label:value,label:value
727
+ const chart = [];
728
+ if (parts[2]) {
729
+ parts[2].split(',').forEach(c => {
730
+ const [label, value] = c.trim().split(':');
731
+ if (value) chart.push({ label: label || '', value: parseInt(value) || 0 });
732
+ });
733
+ }
734
+
735
+ const result = await this._engine.canvas.renderDashboard({ title, stats, chart });
736
+ return { toolUsed: true, toolName: 'canvas', toolResult: 'Dashboard rendered', imageBase64: result.buffer.toString('base64'), mimeType: 'image/png', cleanResponse };
737
+ } catch (err) { toolResult = 'Canvas failed: ' + err.message; }
738
+ break;
739
+ }
740
+ case 'canvas_chart': {
741
+ if (!this._engine?.canvas) { toolResult = 'Canvas not available'; break; }
742
+ try {
743
+ const parts = toolArg.split('|');
744
+ const title = parts[0]?.trim() || 'Chart';
745
+ const type = parts[1]?.trim() || 'bar';
746
+ const items = [];
747
+ if (parts[2]) {
748
+ parts[2].split(',').forEach(c => {
749
+ const [label, value] = c.trim().split(':');
750
+ if (value) items.push({ label: label || '', value: parseInt(value) || 0 });
751
+ });
752
+ }
753
+ const result = await this._engine.canvas.renderChart({ title, type, items });
754
+ return { toolUsed: true, toolName: 'canvas', toolResult: 'Chart rendered', imageBase64: result.buffer.toString('base64'), mimeType: 'image/png', cleanResponse };
755
+ } catch (err) { toolResult = 'Canvas failed: ' + err.message; }
756
+ break;
757
+ }
758
+ case 'canvas_html':
759
+ case 'canvas_render':
760
+ case 'canvas': {
761
+ if (!this._engine?.canvas) { toolResult = 'Canvas not available'; break; }
762
+ try {
763
+ const result = await this._engine.canvas.renderHtml(toolArg);
764
+ return { toolUsed: true, toolName: 'canvas', toolResult: 'Rendered', imageBase64: result.buffer.toString('base64'), mimeType: 'image/png', cleanResponse };
765
+ } catch (err) { toolResult = 'Canvas failed: ' + err.message; }
766
+ break;
767
+ }
700
768
  case 'node_pair': {
701
769
  if (!this._engine?.nodeManager) { toolResult = 'Nodes not available'; break; }
702
770
  const token = this._engine.nodeManager.generatePairToken(agentId, toolArg || 'Device');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squidclaw",
3
- "version": "2.8.0",
3
+ "version": "3.0.0",
4
4
  "description": "🦑 AI agent platform — human-like agents for WhatsApp, Telegram & more",
5
5
  "main": "lib/engine.js",
6
6
  "bin": {