squidclaw 2.8.0 โ†’ 3.1.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);
@@ -0,0 +1,340 @@
1
+ /**
2
+ * ๐Ÿฆ‘ Dashboard PRO โ€” Professional visual dashboards
3
+ * Renders beautiful dashboards as images via Puppeteer
4
+ *
5
+ * Features:
6
+ * - Auto-layout from markdown/data
7
+ * - Multiple chart types (CSS-based, no Chart.js needed)
8
+ * - Stats cards with gradients
9
+ * - Tables with alternating rows
10
+ * - Progress bars
11
+ * - KPI indicators (up/down arrows)
12
+ * - Dark/light themes
13
+ * - Responsive grid
14
+ */
15
+
16
+ import { mkdirSync, writeFileSync, existsSync } from 'fs';
17
+ import { join } from 'path';
18
+ import { logger } from '../core/logger.js';
19
+
20
+ const OUTPUT_DIR = '/tmp/squidclaw-dashboard';
21
+
22
+ const DASH_THEMES = {
23
+ dark: {
24
+ bg: '#0d1117', card: '#161b22', border: '#30363d', text: '#c9d1d9', subtle: '#8b949e',
25
+ accent: '#58a6ff', accent2: '#3fb950', warning: '#d29922', danger: '#f85149',
26
+ gradient: 'linear-gradient(135deg, #58a6ff 0%, #3fb950 100%)',
27
+ },
28
+ light: {
29
+ bg: '#f6f8fa', card: '#ffffff', border: '#d0d7de', text: '#24292f', subtle: '#656d76',
30
+ accent: '#0969da', accent2: '#1a7f37', warning: '#9a6700', danger: '#cf222e',
31
+ gradient: 'linear-gradient(135deg, #0969da 0%, #1a7f37 100%)',
32
+ },
33
+ saudi: {
34
+ bg: '#fafdf7', card: '#ffffff', border: '#bbf7d0', text: '#14532d', subtle: '#6b7280',
35
+ accent: '#006c35', accent2: '#c8a951', warning: '#b8860b', danger: '#dc2626',
36
+ gradient: 'linear-gradient(135deg, #006c35 0%, #c8a951 100%)',
37
+ },
38
+ ocean: {
39
+ bg: '#0a192f', card: '#112240', border: '#1e3a5f', text: '#ccd6f6', subtle: '#8892b0',
40
+ accent: '#64ffda', accent2: '#5eead4', warning: '#fbbf24', danger: '#f87171',
41
+ gradient: 'linear-gradient(135deg, #64ffda 0%, #5eead4 100%)',
42
+ },
43
+ neon: {
44
+ bg: '#0a0a0a', card: '#1a1a1a', border: '#333', text: '#e0e0e0', subtle: '#888',
45
+ accent: '#00ff88', accent2: '#00ccff', warning: '#ffcc00', danger: '#ff3366',
46
+ gradient: 'linear-gradient(135deg, #00ff88 0%, #00ccff 100%)',
47
+ },
48
+ };
49
+
50
+ function buildDashboardHtml(data, theme = 'dark') {
51
+ const t = DASH_THEMES[theme] || DASH_THEMES.dark;
52
+ const chartColors = [t.accent, t.accent2, t.warning, t.danger, '#bc8cff', '#f778ba', '#a5d6ff', '#7ee787'];
53
+
54
+ let html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><style>
55
+ * { margin: 0; padding: 0; box-sizing: border-box; }
56
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: ${t.bg}; color: ${t.text}; padding: 28px; min-width: 900px; }
57
+ .header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; padding-bottom: 16px; border-bottom: 2px solid ${t.border}; }
58
+ .header h1 { font-size: 26px; font-weight: 700; background: ${t.gradient}; -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
59
+ .header .date { font-size: 12px; color: ${t.subtle}; }
60
+ .grid { display: grid; gap: 16px; margin-bottom: 16px; }
61
+ .grid-2 { grid-template-columns: 1fr 1fr; }
62
+ .grid-3 { grid-template-columns: 1fr 1fr 1fr; }
63
+ .grid-4 { grid-template-columns: 1fr 1fr 1fr 1fr; }
64
+ .card { background: ${t.card}; border: 1px solid ${t.border}; border-radius: 12px; padding: 20px; position: relative; overflow: hidden; }
65
+ .card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; background: ${t.gradient}; }
66
+ .card h3 { font-size: 13px; color: ${t.subtle}; font-weight: 500; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
67
+
68
+ /* Stats */
69
+ .stat-card { text-align: center; padding: 24px 16px; }
70
+ .stat-icon { font-size: 28px; margin-bottom: 8px; }
71
+ .stat-value { font-size: 32px; font-weight: 800; background: ${t.gradient}; -webkit-background-clip: text; -webkit-text-fill-color: transparent; line-height: 1.1; }
72
+ .stat-label { font-size: 12px; color: ${t.subtle}; margin-top: 6px; font-weight: 500; }
73
+ .stat-change { font-size: 11px; margin-top: 4px; }
74
+ .stat-change.up { color: ${t.accent2}; }
75
+ .stat-change.down { color: ${t.danger}; }
76
+
77
+ /* Bar chart */
78
+ .bar-row { display: flex; align-items: center; margin: 8px 0; }
79
+ .bar-label { width: 100px; font-size: 12px; color: ${t.subtle}; flex-shrink: 0; }
80
+ .bar-track { flex: 1; height: 28px; background: ${t.bg}; border-radius: 6px; overflow: hidden; position: relative; }
81
+ .bar-fill { height: 100%; border-radius: 6px; display: flex; align-items: center; padding-left: 10px; transition: width 0.5s ease; }
82
+ .bar-value { font-size: 11px; color: #fff; font-weight: 700; }
83
+
84
+ /* Donut chart (CSS) */
85
+ .donut-container { display: flex; align-items: center; gap: 24px; }
86
+ .donut { width: 160px; height: 160px; border-radius: 50%; position: relative; flex-shrink: 0; }
87
+ .donut-hole { position: absolute; top: 20%; left: 20%; width: 60%; height: 60%; border-radius: 50%; background: ${t.card}; display: flex; align-items: center; justify-content: center; flex-direction: column; }
88
+ .donut-total { font-size: 22px; font-weight: 800; color: ${t.text}; }
89
+ .donut-subtitle { font-size: 10px; color: ${t.subtle}; }
90
+ .legend { display: flex; flex-direction: column; gap: 8px; }
91
+ .legend-item { display: flex; align-items: center; gap: 8px; font-size: 12px; }
92
+ .legend-dot { width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
93
+
94
+ /* Table */
95
+ table { width: 100%; border-collapse: collapse; }
96
+ th { background: ${t.bg}; color: ${t.accent}; padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 2px solid ${t.border}; }
97
+ td { padding: 10px 12px; font-size: 12px; border-bottom: 1px solid ${t.border}; }
98
+ tr:nth-child(even) { background: ${t.bg}40; }
99
+ tr:hover { background: ${t.accent}10; }
100
+
101
+ /* Progress */
102
+ .progress-row { display: flex; align-items: center; gap: 12px; margin: 10px 0; }
103
+ .progress-label { width: 120px; font-size: 12px; }
104
+ .progress-track { flex: 1; height: 10px; background: ${t.bg}; border-radius: 5px; overflow: hidden; }
105
+ .progress-fill { height: 100%; border-radius: 5px; }
106
+ .progress-pct { font-size: 12px; font-weight: 700; width: 40px; text-align: right; }
107
+
108
+ /* Section */
109
+ .section-title { font-size: 15px; font-weight: 700; color: ${t.text}; margin: 20px 0 12px; padding-left: 12px; border-left: 3px solid ${t.accent}; }
110
+
111
+ /* Footer */
112
+ .footer { text-align: center; margin-top: 20px; padding-top: 12px; border-top: 1px solid ${t.border}; font-size: 10px; color: ${t.subtle}; }
113
+ </style></head><body>`;
114
+
115
+ // Header
116
+ html += `<div class="header"><h1>${data.title || 'Dashboard'}</h1><div class="date">${data.subtitle || new Date().toLocaleString()}</div></div>`;
117
+
118
+ // Render sections
119
+ for (const section of (data.sections || [])) {
120
+ if (section.heading) {
121
+ html += `<div class="section-title">${section.heading}</div>`;
122
+ }
123
+
124
+ // Stats
125
+ if (section.type === 'stats' && section.items) {
126
+ const cols = Math.min(section.items.length, 4);
127
+ html += `<div class="grid grid-${cols}">`;
128
+ for (const stat of section.items) {
129
+ const changeClass = stat.change ? (stat.change.startsWith('+') || stat.change.startsWith('โ†‘') ? 'up' : 'down') : '';
130
+ html += `<div class="card stat-card">
131
+ <div class="stat-icon">${stat.icon || '๐Ÿ“Š'}</div>
132
+ <div class="stat-value">${stat.value}</div>
133
+ <div class="stat-label">${stat.label}</div>
134
+ ${stat.change ? `<div class="stat-change ${changeClass}">${stat.change}</div>` : ''}
135
+ </div>`;
136
+ }
137
+ html += `</div>`;
138
+ }
139
+
140
+ // Bar chart
141
+ if (section.type === 'bar' && section.items) {
142
+ const max = Math.max(...section.items.map(i => i.value));
143
+ html += `<div class="card"><h3>${section.title || 'Chart'}</h3>`;
144
+ section.items.forEach((item, idx) => {
145
+ const pct = (item.value / max * 100).toFixed(0);
146
+ const color = item.color || chartColors[idx % chartColors.length];
147
+ html += `<div class="bar-row">
148
+ <div class="bar-label">${item.label}</div>
149
+ <div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:${color}"><span class="bar-value">${item.value}</span></div></div>
150
+ </div>`;
151
+ });
152
+ html += `</div>`;
153
+ }
154
+
155
+ // Donut/Pie
156
+ if ((section.type === 'donut' || section.type === 'pie') && section.items) {
157
+ const total = section.items.reduce((s, i) => s + i.value, 0);
158
+ let rotation = 0;
159
+ const gradParts = [];
160
+ section.items.forEach((item, idx) => {
161
+ const pct = item.value / total * 360;
162
+ const color = item.color || chartColors[idx % chartColors.length];
163
+ gradParts.push(`${color} ${rotation}deg ${rotation + pct}deg`);
164
+ rotation += pct;
165
+ });
166
+
167
+ html += `<div class="card"><h3>${section.title || 'Distribution'}</h3>
168
+ <div class="donut-container">
169
+ <div class="donut" style="background:conic-gradient(${gradParts.join(',')})">
170
+ <div class="donut-hole"><div class="donut-total">${total}</div><div class="donut-subtitle">Total</div></div>
171
+ </div>
172
+ <div class="legend">`;
173
+ section.items.forEach((item, idx) => {
174
+ const color = item.color || chartColors[idx % chartColors.length];
175
+ html += `<div class="legend-item"><div class="legend-dot" style="background:${color}"></div>${item.label}: <strong>${item.value}</strong> (${(item.value / total * 100).toFixed(0)}%)</div>`;
176
+ });
177
+ html += `</div></div></div>`;
178
+ }
179
+
180
+ // Table
181
+ if (section.type === 'table' && section.rows) {
182
+ html += `<div class="card"><h3>${section.title || 'Data'}</h3><table>`;
183
+ if (section.headers) {
184
+ html += `<tr>${section.headers.map(h => `<th>${h}</th>`).join('')}</tr>`;
185
+ }
186
+ for (const row of section.rows) {
187
+ html += `<tr>${row.map(c => `<td>${c}</td>`).join('')}</tr>`;
188
+ }
189
+ html += `</table></div>`;
190
+ }
191
+
192
+ // Progress bars
193
+ if (section.type === 'progress' && section.items) {
194
+ html += `<div class="card"><h3>${section.title || 'Progress'}</h3>`;
195
+ section.items.forEach((item, idx) => {
196
+ const color = item.color || chartColors[idx % chartColors.length];
197
+ html += `<div class="progress-row">
198
+ <div class="progress-label">${item.label}</div>
199
+ <div class="progress-track"><div class="progress-fill" style="width:${item.value}%;background:${color}"></div></div>
200
+ <div class="progress-pct" style="color:${color}">${item.value}%</div>
201
+ </div>`;
202
+ });
203
+ html += `</div>`;
204
+ }
205
+
206
+ // Two cards side by side
207
+ if (section.type === 'grid-2' && section.left && section.right) {
208
+ html += `<div class="grid grid-2">`;
209
+ html += `<div class="card"><h3>${section.left.title || ''}</h3>${section.left.html || ''}</div>`;
210
+ html += `<div class="card"><h3>${section.right.title || ''}</h3>${section.right.html || ''}</div>`;
211
+ html += `</div>`;
212
+ }
213
+ }
214
+
215
+ // Footer
216
+ html += `<div class="footer">Squidclaw AI ๐Ÿฆ‘ ยท Generated ${new Date().toLocaleString()}</div>`;
217
+ html += `</body></html>`;
218
+
219
+ return html;
220
+ }
221
+
222
+ // Smart parser: markdown โ†’ dashboard data
223
+ function parseMarkdownToDashboard(content, title) {
224
+ const sections = [];
225
+ const lines = content.split('\n').map(l => l.trim()).filter(Boolean);
226
+ let current = null;
227
+
228
+ for (const line of lines) {
229
+ if (line.startsWith('## ') || line.startsWith('# ')) {
230
+ if (current) sections.push(current);
231
+ current = { heading: line.replace(/^#+\s*/, ''), type: null, items: [], rows: [], headers: null, title: '' };
232
+ continue;
233
+ }
234
+ if (!current) current = { type: null, items: [], rows: [], headers: null, title: '' };
235
+
236
+ if (line.match(/^\[stats?\]/i)) { current.type = 'stats'; continue; }
237
+ if (line.match(/^\[bar\s*chart?\]/i) || line.match(/^\[chart\]/i)) { current.type = 'bar'; continue; }
238
+ if (line.match(/^\[donut|pie\]/i)) { current.type = 'donut'; continue; }
239
+ if (line.match(/^\[table\]/i)) { current.type = 'table'; continue; }
240
+ if (line.match(/^\[progress\]/i)) { current.type = 'progress'; continue; }
241
+
242
+ // Stats line: - icon value โ€” label (change)
243
+ if ((current.type === 'stats' || !current.type) && line.startsWith('-')) {
244
+ const m = line.match(/^-\s*(.+?)\s+(.+?)\s*[โ€”โ€“-]\s*(.+?)(?:\s*\((.+?)\))?$/);
245
+ if (m) {
246
+ current.type = 'stats';
247
+ current.items.push({ icon: m[1], value: m[2], label: m[3].trim(), change: m[4] || null });
248
+ continue;
249
+ }
250
+ }
251
+
252
+ // Chart/bar: - label: value
253
+ if ((current.type === 'bar' || current.type === 'donut') && line.startsWith('-')) {
254
+ const m = line.match(/^-\s*(.+?):\s*(\d+)/);
255
+ if (m) { current.items.push({ label: m[1], value: parseInt(m[2]) }); continue; }
256
+ }
257
+
258
+ // Progress: - label: value%
259
+ if (current.type === 'progress' && line.startsWith('-')) {
260
+ const m = line.match(/^-\s*(.+?):\s*(\d+)%?/);
261
+ if (m) { current.items.push({ label: m[1], value: parseInt(m[2]) }); continue; }
262
+ }
263
+
264
+ // Table
265
+ if (line.startsWith('|') && line.endsWith('|')) {
266
+ if (line.match(/^\|[\s-:|]+\|$/)) continue;
267
+ const cells = line.split('|').filter(c => c.trim()).map(c => c.trim());
268
+ if (!current.headers) { current.headers = cells; current.type = 'table'; }
269
+ else current.rows.push(cells);
270
+ continue;
271
+ }
272
+
273
+ // Auto-detect chart data from plain "- label: number"
274
+ if (line.startsWith('-') && !current.type) {
275
+ const m = line.match(/^-\s*(.+?):\s*(\d+)/);
276
+ if (m) { current.type = 'bar'; current.items.push({ label: m[1], value: parseInt(m[2]) }); continue; }
277
+ }
278
+ }
279
+ if (current) sections.push(current);
280
+
281
+ return { title, sections };
282
+ }
283
+
284
+ export async function renderDashboard(input) {
285
+ mkdirSync(OUTPUT_DIR, { recursive: true });
286
+
287
+ let data, theme;
288
+ if (typeof input === 'string') {
289
+ // Parse markdown
290
+ const parts = input.split('|');
291
+ const title = parts[0]?.trim() || 'Dashboard';
292
+ theme = parts[1]?.trim() || 'dark';
293
+ const content = parts.slice(2).join('|') || parts.slice(1).join('|');
294
+ data = parseMarkdownToDashboard(content, title);
295
+ } else {
296
+ data = input.data || input;
297
+ theme = input.theme || 'dark';
298
+ }
299
+
300
+ const dashHtml = buildDashboardHtml(data, theme);
301
+
302
+ // Try Puppeteer
303
+ try {
304
+ const puppeteer = await import('puppeteer-core');
305
+ const paths = ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium-browser', '/usr/bin/chromium', '/snap/bin/chromium'];
306
+ let execPath = null;
307
+ for (const p of paths) { if (existsSync(p)) { execPath = p; break; } }
308
+
309
+ if (execPath) {
310
+ const browser = await puppeteer.default.launch({
311
+ executablePath: execPath, headless: 'new',
312
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
313
+ });
314
+ const page = await browser.newPage();
315
+ await page.setViewport({ width: 1000, height: 800 });
316
+ await page.setContent(dashHtml, { waitUntil: 'networkidle0', timeout: 10000 });
317
+ const height = await page.evaluate(() => document.body.scrollHeight);
318
+ await page.setViewport({ width: 1000, height: Math.min(height + 40, 3000) });
319
+ const screenshot = await page.screenshot({ type: 'png', fullPage: true });
320
+ await browser.close();
321
+
322
+ const filename = 'dashboard_' + Date.now() + '.png';
323
+ const filepath = join(OUTPUT_DIR, filename);
324
+ writeFileSync(filepath, screenshot);
325
+
326
+ logger.info('dashboard', `Rendered: ${filepath}`);
327
+ return { filepath, filename, buffer: screenshot, html: dashHtml };
328
+ }
329
+ } catch (err) {
330
+ logger.warn('dashboard', 'Puppeteer failed: ' + err.message);
331
+ }
332
+
333
+ // Fallback: save HTML
334
+ const filename = 'dashboard_' + Date.now() + '.html';
335
+ const filepath = join(OUTPUT_DIR, filename);
336
+ writeFileSync(filepath, dashHtml);
337
+ return { filepath, filename, html: dashHtml };
338
+ }
339
+
340
+ export { buildDashboardHtml, parseMarkdownToDashboard, DASH_THEMES };