Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/islands/agent/AskAgent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export default function AskAgent({ lang = 'en' }: { lang?: 'en' | 'id' }) {
<div key={i} className={t.role === 'user' ? 'text-right' : ''}>
<span className={`inline-block max-w-[90%] border-2 border-border px-3 py-2 text-left text-sm ${t.role === 'user' ? 'bg-background' : 'bg-accent/20'}`}>
<span className="whitespace-pre-wrap break-words font-mono">{t.text}</span>
{t.imgUrl && <img src={t.imgUrl} alt="" className="mt-2 h-32 w-32 border-2 border-border" />}
{t.imgUrl && <img src={t.imgUrl} alt="" className="mt-2 max-h-64 max-w-full border-2 border-border bg-white object-contain" />}
{(t.blobUrl || t.imgUrl) && (
<a href={t.blobUrl || t.imgUrl} download={t.filename || 'download'} className="ml-2 inline-block border-2 border-border bg-accent px-2 py-0.5 text-xs font-bold uppercase text-accent-foreground">Download</a>
)}
Expand Down
20 changes: 19 additions & 1 deletion src/tools/agent/executors.test.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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([]);
});
Expand Down
94 changes: 92 additions & 2 deletions src/tools/agent/executors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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' }],
Expand All @@ -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 <svg>…</svg> in the `svg` arg.
toolId: 'svg-viewer', description: 'Draw/create an SVG icon, logo, illustration, badge or simple diagram — YOU write the full <svg>…</svg> 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 (<svg>…</svg>)' }],
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 <svg>…</svg> 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),
Expand Down Expand Up @@ -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<string>();
const dupes: string[] = [];
for (const e of AGENT_EXECUTORS) { if (seen.has(e.toolId)) dupes.push(e.toolId); seen.add(e.toolId); }
return dupes;
}
21 changes: 21 additions & 0 deletions src/tools/documents/office.lib.test.ts
Original file line number Diff line number Diff line change
@@ -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"""');
});
});
28 changes: 28 additions & 0 deletions src/tools/documents/office.lib.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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 };
}
11 changes: 11 additions & 0 deletions src/tools/image/canvas-run.lib.test.ts
Original file line number Diff line number Diff line change
@@ -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"');
});
});
48 changes: 48 additions & 0 deletions src/tools/image/canvas-run.lib.ts
Original file line number Diff line number Diff line change
@@ -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<Blob> {
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<Blob>((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 <img> preview). */
export function blobToDataUrl(blob: Blob): Promise<string> {
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);
});
}
36 changes: 36 additions & 0 deletions src/tools/image/canvas-sandbox.worker.ts
Original file line number Diff line number Diff line change
@@ -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<Job>) => {
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) });
}
};
39 changes: 39 additions & 0 deletions src/tools/image/svg-gen.lib.test.ts
Original file line number Diff line number Diff line change
@@ -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<svg viewBox="0 0 10 10"><rect/></svg>\n```\nEnjoy');
expect(out).toBe('<svg viewBox="0 0 10 10"><rect/></svg>');
});
it('returns empty when there is no svg', () => {
expect(extractSvg('just some text')).toBe('');
});
});

describe('sanitizeSvg', () => {
it('keeps drawing elements', () => {
const out = sanitizeSvg('<svg><circle cx="5" cy="5" r="4"/></svg>');
expect(out).toContain('<svg');
expect(out).toContain('circle');
});
it('strips <script> from the svg', () => {
const out = sanitizeSvg('<svg><script>alert(1)</script><rect width="10" height="10"/></svg>');
expect(out).not.toMatch(/<script/i);
expect(out).toContain('rect');
});
it('strips inline event handlers', () => {
const out = sanitizeSvg('<svg><rect onclick="steal()" width="10" height="10"/></svg>');
expect(out).not.toMatch(/onclick/i);
});
it('returns empty for non-svg input', () => {
expect(sanitizeSvg('<div>not svg</div>')).toBe('');
expect(sanitizeSvg('hello')).toBe('');
});
});

describe('svgToDataUrl', () => {
it('produces an image/svg+xml data url', () => {
expect(svgToDataUrl('<svg></svg>')).toMatch(/^data:image\/svg\+xml;utf8,/);
});
});
Loading
Loading