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(' from the svg', () => { + const out = sanitizeSvg(''); + expect(out).not.toMatch(/