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 +6 -0
- package/lib/features/canvas.js +228 -0
- package/lib/middleware/commands.js +47 -0
- package/lib/tools/dashboard-pro.js +340 -0
- package/lib/tools/pptx-pro.js +686 -0
- package/lib/tools/router.js +75 -26
- package/package.json +1 -1
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 };
|