diff --git a/src/islands/agent/AskAgent.tsx b/src/islands/agent/AskAgent.tsx
index 9fd05db..5176adb 100644
--- a/src/islands/agent/AskAgent.tsx
+++ b/src/islands/agent/AskAgent.tsx
@@ -140,7 +140,7 @@ export default function AskAgent({ lang = 'en' }: { lang?: 'en' | 'id' }) {
{t.text}
- {t.imgUrl &&
}
+ {t.imgUrl &&
}
{(t.blobUrl || t.imgUrl) && (
Download
)}
diff --git a/src/tools/agent/executors.test.ts b/src/tools/agent/executors.test.ts
index cb85045..c5bddbb 100644
--- a/src/tools/agent/executors.test.ts
+++ b/src/tools/agent/executors.test.ts
@@ -1,11 +1,14 @@
import { describe, it, expect } from 'vitest';
-import { AGENT_EXECUTORS, scopeExecutors, executorFor, unknownExecutorIds } from './executors';
+import { AGENT_EXECUTORS, scopeExecutors, executorFor, unknownExecutorIds, duplicateExecutorIds } from './executors';
describe('executor registry', () => {
it('every executor maps to a real tool and declares a match fn', () => {
expect(unknownExecutorIds()).toEqual([]);
for (const e of AGENT_EXECUTORS) expect(typeof e.match).toBe('function');
});
+ it('has unique function names (toolIds)', () => {
+ expect(duplicateExecutorIds()).toEqual([]);
+ });
it('scopes to the right tool for encode vs qr requests', () => {
expect(scopeExecutors('encode base64 of hi').map(e => e.toolId)).toContain('base64');
expect(scopeExecutors('make a qr for hello').map(e => e.toolId)).toContain('qr-gen');
@@ -56,6 +59,21 @@ describe('executor registry', () => {
expect(scopeExecutors('crop this video').map(e => e.toolId)).toContain('media-trim');
expect(scopeExecutors('cut this mp3').map(e => e.toolId)).toContain('media-trim');
});
+ it('scopes svg generation (svg-viewer) for draw/create requests', () => {
+ expect(scopeExecutors('make a download icon').map(e => e.toolId)).toContain('svg-viewer');
+ expect(scopeExecutors('draw a flowchart of a login process').map(e => e.toolId)).toContain('svg-viewer');
+ expect(scopeExecutors('create an svg logo').map(e => e.toolId)).toContain('svg-viewer');
+ });
+ it('scopes the office/productivity tools', () => {
+ expect(scopeExecutors('remove duplicate rows from this csv').map(e => e.toolId)).toContain('csv-dedupe');
+ expect(scopeExecutors('convert this csv to excel').map(e => e.toolId)).toContain('spreadsheet-convert');
+ expect(scopeExecutors('convert this xlsx to csv').map(e => e.toolId)).toContain('spreadsheet-convert');
+ expect(scopeExecutors('word count of this text').map(e => e.toolId)).toContain('word-count');
+ });
+ it('scopes canvas-draw for chart/plot/procedural draw requests', () => {
+ expect(scopeExecutors('draw a bar chart of my sales').map(e => e.toolId)).toContain('canvas-draw');
+ expect(scopeExecutors('plot these data points on a canvas').map(e => e.toolId)).toContain('canvas-draw');
+ });
it('does not scope any media compressor for small talk', () => {
expect(scopeExecutors('hello how are you today')).toEqual([]);
});
diff --git a/src/tools/agent/executors.ts b/src/tools/agent/executors.ts
index 04426f6..fbc813b 100644
--- a/src/tools/agent/executors.ts
+++ b/src/tools/agent/executors.ts
@@ -14,7 +14,12 @@ export interface FileSpec { key: string; accept: string; label: string }
export interface ParamSpec { key: string; type: 'number' | 'string'; label: string; default?: string | number }
export interface ExecResult { text?: string; blob?: Blob; filename?: string; dataUrl?: string }
export interface AgentExecutor {
+ /** Unique function name the model calls. Usually a registry tool id, but for a
+ * headless op with no dedicated page it's a standalone name + a `page` ref. */
toolId: string;
+ /** Registry tool this maps to (for the guard / an "open" link), when the
+ * function name isn't itself a real tool id. Defaults to toolId. */
+ page?: string;
description: string;
match: (q: string) => boolean;
files: FileSpec[];
@@ -356,6 +361,48 @@ export const AGENT_EXECUTORS: AgentExecutor[] = [
return { text: /begin:vcard/i.test(text) ? contactsToCsv(parseVcards(text)) : buildVcards(csvToContacts(text)) };
},
},
+ {
+ toolId: 'csv-dedupe', page: 'csv-json', description: 'Remove duplicate rows from a CSV file',
+ match: re(/(dedup|de-?dup|duplicate|redundant|unique).*(csv|rows?|records?|data)|(csv|rows?|records?).*(dedup|duplicate|redundant|unique)|remove.*(duplicate|redundant).*(row|csv|record|line)/i),
+ files: [{ key: 'file', accept: '.csv,text/csv', label: 'CSV file' }], params: [],
+ execute: async ({ files }) => {
+ const { dedupeCsvRows } = await import('@/tools/documents/office.lib');
+ const { csv, removed } = dedupeCsvRows(await files.file.text());
+ return { blob: new Blob([csv], { type: 'text/csv' }), filename: 'deduped.csv', text: `removed ${removed} duplicate row${removed === 1 ? '' : 's'}` };
+ },
+ },
+ {
+ toolId: 'spreadsheet-convert', page: 'spreadsheet-viewer', description: 'Convert between CSV and Excel (.xlsx) — auto-detects the direction from the file',
+ match: re(/csv.*(excel|xlsx|spread ?sheet|workbook)|(excel|xlsx|spread ?sheet|workbook).*(csv|convert)|convert.*(excel|xlsx|csv|spread ?sheet)|to ?(xlsx|excel)\b|xlsx ?(to|2) ?csv/i),
+ files: [{ key: 'file', accept: '.csv,.xlsx,.xls,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', label: 'CSV or Excel file' }], params: [],
+ execute: async ({ files }) => {
+ const XLSX = await import('xlsx');
+ const file = files.file;
+ const isSheet = /\.(xlsx|xls|ods)$/i.test(file.name) || /sheet|excel/i.test(file.type);
+ if (isSheet) {
+ const wb = XLSX.read(new Uint8Array(await file.arrayBuffer()), { type: 'array' });
+ const name = wb.SheetNames[0];
+ const csv = XLSX.utils.sheet_to_csv(wb.Sheets[name]);
+ return { blob: new Blob([csv], { type: 'text/csv' }), filename: 'sheet.csv', text: `converted "${name}" to CSV` };
+ }
+ const { parseCsv } = await import('@/tools/dev/csv.lib');
+ const ws = XLSX.utils.aoa_to_sheet(parseCsv(await file.text()));
+ const wb = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(wb, ws, 'Sheet1');
+ const out = new Uint8Array(XLSX.write(wb, { type: 'array', bookType: 'xlsx' }));
+ return { blob: new Blob([out], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), filename: 'workbook.xlsx', text: 'converted to Excel (.xlsx)' };
+ },
+ },
+ {
+ toolId: 'word-count', page: 'word-counter', description: 'Count words, characters, sentences and reading time of text',
+ match: re(/word ?count|count.*(words|characters)|how many words|character count|text stat|reading time/i),
+ files: [], params: [{ key: 'text', type: 'string', label: 'Text' }],
+ execute: async ({ params }) => {
+ const { countText } = await import('@/tools/dev/text.lib');
+ const s = countText(String(params.text ?? ''));
+ return { text: `Words: ${s.words}\nCharacters: ${s.characters} (${s.charactersNoSpaces} without spaces)\nSentences: ${s.sentences}\nParagraphs: ${s.paragraphs}\nLines: ${s.lines}\nReading time: ~${s.readingMinutes} min` };
+ },
+ },
{
toolId: 'hash-text', description: 'Hash text (SHA-256)', match: re(/\bhash\b|sha-?\d|md5|checksum|digest/i),
files: [], params: [{ key: 'text', type: 'string', label: 'Text' }],
@@ -374,6 +421,40 @@ export const AGENT_EXECUTORS: AgentExecutor[] = [
return { dataUrl: await QRCode.toDataURL(text), filename: 'qr.png', text: 'made a QR code' };
},
},
+ {
+ // Generative: the model itself "draws" the icon/diagram by writing SVG markup;
+ // we sanitize it and hand back a rendered, downloadable graphic. Mapped to the
+ // real svg-viewer tool. Pass the full in the `svg` arg.
+ toolId: 'svg-viewer', description: 'Draw/create an SVG icon, logo, illustration, badge or simple diagram — YOU write the full markup and pass it as "svg".',
+ match: re(/\b(make|create|draw|generate|design|build|render)\b.*\b(icon|logo|svg|illustration|graphic|badge|emblem|diagram|flow ?chart|sketch|shape|avatar)\b|\bsvg\b.*(icon|logo|graphic|diagram|shape)/i),
+ files: [], params: [{ key: 'svg', type: 'string', label: 'SVG markup ()' }],
+ execute: async ({ params }) => {
+ const { sanitizeSvg, svgToDataUrl } = await import('@/tools/image/svg-gen.lib');
+ const clean = sanitizeSvg(String(params.svg ?? ''));
+ if (!clean) throw new Error('that was not valid SVG — write complete markup');
+ return { blob: new Blob([clean], { type: 'image/svg+xml' }), dataUrl: svgToDataUrl(clean), filename: 'graphic.svg', text: 'drew an SVG' };
+ },
+ },
+ {
+ // v2 code-canvas: the model writes JS that draws on a 2D `ctx`; it runs in a
+ // locked-down Web Worker (no DOM, no network, hard timeout) → PNG. For charts,
+ // procedural graphics, and pixel work SVG can't easily express.
+ toolId: 'canvas-draw', page: 'whiteboard',
+ description: 'Render anything on a 2D canvas by writing JavaScript. YOU write JS that draws on `ctx` (a CanvasRenderingContext2D of a `canvas`); it runs in a sandbox (no network) and returns a PNG. Use for charts, plots, procedural/pixel graphics.',
+ match: re(/\b(draw|render|plot|paint|generate|make|create)\b.*\b(canvas|chart|graph|plot|pixel|procedural|pattern|fractal|bar ?chart|pie ?chart|line ?graph|histogram)\b|canvas.*(draw|render|code)|\bplot\b.*(data|points|function)/i),
+ files: [], params: [
+ { key: 'code', type: 'string', label: 'JavaScript that draws on `ctx`' },
+ { key: 'width', type: 'number', label: 'Width px', default: 512 },
+ { key: 'height', type: 'number', label: 'Height px', default: 512 },
+ ],
+ execute: async ({ params }) => {
+ const { runCanvasCode, extractCode, blobToDataUrl } = await import('@/tools/image/canvas-run.lib');
+ const code = extractCode(String(params.code ?? ''));
+ if (!code) throw new Error('no drawing code — write JS that draws on `ctx`');
+ const blob = await runCanvasCode(code, { width: Number(params.width) || 512, height: Number(params.height) || 512 });
+ return { blob, dataUrl: await blobToDataUrl(blob), filename: 'canvas.png', text: 'rendered a canvas drawing' };
+ },
+ },
{
toolId: 'image-compress', description: 'Compress an image to a target size in KB',
match: re(/(image|img|photo|pic(ture)?|jpe?g|png|webp).*(compress|small|reduce|shrink|kb|mb|size)|(compress|small|reduce|shrink).*(image|img|photo|pic(ture)?|jpe?g|png|webp)/i),
@@ -495,7 +576,16 @@ export function executorFor(toolId: string): AgentExecutor | undefined {
return AGENT_EXECUTORS.find(e => e.toolId === toolId);
}
-/** Guard used by tests: every executor must reference a real tool. */
+/** Guard used by tests: every executor must reference a real tool (via `page`,
+ * else its `toolId`). Also flags duplicate function names. */
export function unknownExecutorIds(): string[] {
- return AGENT_EXECUTORS.filter(e => !getToolById(e.toolId)).map(e => e.toolId);
+ return AGENT_EXECUTORS.filter(e => !getToolById(e.page ?? e.toolId)).map(e => e.toolId);
+}
+
+/** Function names must be unique (they're what the model calls). */
+export function duplicateExecutorIds(): string[] {
+ const seen = new Set();
+ const dupes: string[] = [];
+ for (const e of AGENT_EXECUTORS) { if (seen.has(e.toolId)) dupes.push(e.toolId); seen.add(e.toolId); }
+ return dupes;
}
diff --git a/src/tools/documents/office.lib.test.ts b/src/tools/documents/office.lib.test.ts
new file mode 100644
index 0000000..ddabe8d
--- /dev/null
+++ b/src/tools/documents/office.lib.test.ts
@@ -0,0 +1,21 @@
+import { describe, it, expect } from 'vitest';
+import { dedupeCsvRows, toCsv } from './office.lib';
+
+describe('dedupeCsvRows', () => {
+ it('drops duplicate rows and keeps the first (incl. header)', () => {
+ const csv = 'name,qty\nApple,3\nBanana,5\nApple,3\nBanana,5\nCherry,2';
+ const { csv: out, removed } = dedupeCsvRows(csv);
+ expect(removed).toBe(2);
+ expect(out.split('\n')).toEqual(['name,qty', 'Apple,3', 'Banana,5', 'Cherry,2']);
+ });
+ it('is a no-op when there are no duplicates', () => {
+ const { removed } = dedupeCsvRows('a,b\n1,2\n3,4');
+ expect(removed).toBe(0);
+ });
+});
+
+describe('toCsv', () => {
+ it('quotes cells containing commas, quotes or newlines', () => {
+ expect(toCsv([['a', 'x,y'], ['b', 'he said "hi"']])).toBe('a,"x,y"\nb,"he said ""hi"""');
+ });
+});
diff --git a/src/tools/documents/office.lib.ts b/src/tools/documents/office.lib.ts
new file mode 100644
index 0000000..26b4941
--- /dev/null
+++ b/src/tools/documents/office.lib.ts
@@ -0,0 +1,28 @@
+/**
+ * Small pure helpers for the agent's office/productivity executors. XLSX
+ * conversion lives in the executors (SheetJS is a heavy, dynamically-imported
+ * dep); the row-level CSV logic here is pure and unit-testable.
+ */
+import { parseCsv } from '@/tools/dev/csv.lib';
+
+/** Serialize a row matrix back to CSV, quoting cells that need it. */
+export function toCsv(rows: string[][], delimiter = ','): string {
+ const esc = (c: string) => (/[",\n\r]/.test(c) || c.includes(delimiter)) ? `"${c.replace(/"/g, '""')}"` : c;
+ return rows.map(r => r.map(esc).join(delimiter)).join('\n');
+}
+
+/**
+ * Remove duplicate rows from CSV text, keeping the first occurrence (so a header
+ * row survives). Returns the cleaned CSV and how many rows were dropped.
+ */
+export function dedupeCsvRows(csv: string, delimiter = ','): { csv: string; removed: number } {
+ const rows = parseCsv(csv, delimiter);
+ const seen = new Set();
+ const kept = rows.filter(r => {
+ const key = JSON.stringify(r);
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+ return { csv: toCsv(kept, delimiter), removed: rows.length - kept.length };
+}
diff --git a/src/tools/image/canvas-run.lib.test.ts b/src/tools/image/canvas-run.lib.test.ts
new file mode 100644
index 0000000..06ac0c1
--- /dev/null
+++ b/src/tools/image/canvas-run.lib.test.ts
@@ -0,0 +1,11 @@
+import { describe, it, expect } from 'vitest';
+import { extractCode } from './canvas-run.lib';
+
+describe('extractCode', () => {
+ it('pulls code out of a js fence', () => {
+ expect(extractCode('Sure:\n```js\nctx.fillRect(0,0,10,10)\n```\ndone')).toBe('ctx.fillRect(0,0,10,10)');
+ });
+ it('returns the raw text when there is no fence', () => {
+ expect(extractCode('ctx.fillStyle="red"')).toBe('ctx.fillStyle="red"');
+ });
+});
diff --git a/src/tools/image/canvas-run.lib.ts b/src/tools/image/canvas-run.lib.ts
new file mode 100644
index 0000000..4622eb5
--- /dev/null
+++ b/src/tools/image/canvas-run.lib.ts
@@ -0,0 +1,48 @@
+/**
+ * Host side of the "code canvas": pull the JS out of an LLM reply and run it in
+ * the sandbox worker with a hard timeout. extractCode is pure/testable; the
+ * worker run needs a real browser (OffscreenCanvas + Worker), so it's covered by
+ * build + manual smoke.
+ */
+
+/** Pull JS out of a reply, tolerating a ```js fence; returns the code trimmed. */
+export function extractCode(input: string): string {
+ const fenced = input.match(/```(?:js|javascript|ts)?\s*([\s\S]*?)```/i);
+ return (fenced ? fenced[1] : input).trim();
+}
+
+export interface CanvasRunOpts { width?: number; height?: number; timeoutMs?: number }
+
+/** Run drawing code in the sandbox worker; resolve with the rendered PNG blob. */
+export async function runCanvasCode(code: string, opts: CanvasRunOpts = {}): Promise {
+ if (typeof Worker === 'undefined' || typeof OffscreenCanvas === 'undefined') {
+ throw new Error('this browser has no OffscreenCanvas sandbox — try a cloud model’s SVG instead');
+ }
+ const timeoutMs = opts.timeoutMs ?? 6000;
+ const worker = new Worker(new URL('./canvas-sandbox.worker.ts', import.meta.url), { type: 'module' });
+ try {
+ return await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error('drawing timed out — possible infinite loop')), timeoutMs);
+ worker.onmessage = (e: MessageEvent) => {
+ clearTimeout(timer);
+ const d = e.data as { ok: boolean; buf?: ArrayBuffer; error?: string };
+ if (d.ok && d.buf) resolve(new Blob([d.buf], { type: 'image/png' }));
+ else reject(new Error(d.error || 'drawing failed'));
+ };
+ worker.onerror = ev => { clearTimeout(timer); reject(new Error(ev.message || 'sandbox error')); };
+ worker.postMessage({ code, width: opts.width ?? 512, height: opts.height ?? 512 });
+ });
+ } finally {
+ worker.terminate();
+ }
+}
+
+/** Blob → data: URL (for inline
preview). */
+export function blobToDataUrl(blob: Blob): Promise {
+ return new Promise((resolve, reject) => {
+ const r = new FileReader();
+ r.onload = () => resolve(String(r.result));
+ r.onerror = () => reject(new Error('could not read blob'));
+ r.readAsDataURL(blob);
+ });
+}
diff --git a/src/tools/image/canvas-sandbox.worker.ts b/src/tools/image/canvas-sandbox.worker.ts
new file mode 100644
index 0000000..8338b01
--- /dev/null
+++ b/src/tools/image/canvas-sandbox.worker.ts
@@ -0,0 +1,36 @@
+/**
+ * Sandbox for running LLM-written 2D drawing code (agent v2 "code canvas").
+ *
+ * Runs in a Web Worker — so no DOM, no window, no cookies/localStorage. Before
+ * executing any user code we also neuter the exfiltration + storage channels a
+ * Worker *does* have (fetch/XHR/WebSocket/importScripts/indexedDB/caches). The
+ * code only ever sees an OffscreenCanvas + its 2D context, and the main thread
+ * enforces a wall-clock timeout by terminating this worker. The result is a PNG.
+ */
+
+const blocked = () => { throw new Error('disabled in the drawing sandbox'); };
+for (const key of ['fetch', 'XMLHttpRequest', 'WebSocket', 'importScripts', 'indexedDB', 'caches', 'EventSource', 'SharedWorker', 'Worker', 'Notification']) {
+ try { Object.defineProperty(self, key, { value: blocked, writable: false, configurable: false }); } catch { /* some are non-configurable already */ }
+}
+
+interface Job { code: string; width: number; height: number }
+
+self.onmessage = async (e: MessageEvent) => {
+ const { code, width, height } = e.data;
+ const post = (msg: unknown, transfer?: Transferable[]) => (self as unknown as Worker).postMessage(msg, transfer ?? []);
+ try {
+ const w = Math.max(1, Math.min(4096, Math.floor(width) || 512));
+ const h = Math.max(1, Math.min(4096, Math.floor(height) || 512));
+ const canvas = new OffscreenCanvas(w, h);
+ const ctx = canvas.getContext('2d');
+ if (!ctx) throw new Error('could not get a 2D context');
+ // The drawing code sees only `canvas`, `ctx`, and safe math/date globals.
+ const draw = new Function('canvas', 'ctx', 'Math', 'Date', `"use strict";\n${code}`);
+ await draw(canvas, ctx, Math, Date);
+ const blob = await canvas.convertToBlob({ type: 'image/png' });
+ const buf = await blob.arrayBuffer();
+ post({ ok: true, buf }, [buf]);
+ } catch (err) {
+ post({ ok: false, error: err instanceof Error ? err.message : String(err) });
+ }
+};
diff --git a/src/tools/image/svg-gen.lib.test.ts b/src/tools/image/svg-gen.lib.test.ts
new file mode 100644
index 0000000..2b5357c
--- /dev/null
+++ b/src/tools/image/svg-gen.lib.test.ts
@@ -0,0 +1,39 @@
+import { describe, it, expect } from 'vitest';
+import { extractSvg, sanitizeSvg, svgToDataUrl } from './svg-gen.lib';
+
+describe('extractSvg', () => {
+ it('pulls the svg out of a markdown code fence', () => {
+ const out = extractSvg('Sure!\n```svg\n\n```\nEnjoy');
+ expect(out).toBe('');
+ });
+ it('returns empty when there is no svg', () => {
+ expect(extractSvg('just some text')).toBe('');
+ });
+});
+
+describe('sanitizeSvg', () => {
+ it('keeps drawing elements', () => {
+ const out = sanitizeSvg('');
+ expect(out).toContain('