From e977165dd2e2d9b5b06a42794f7c5a848e3754f0 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:21:22 +0700 Subject: [PATCH 01/11] =?UTF-8?q?feat(agent=20v3):=20sandboxed=20data=20in?= =?UTF-8?q?terpreter=20=E2=80=94=20transform=20files=20with=20LLM-written?= =?UTF-8?q?=20JS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit peek-data previews a data file's schema (dimensions + first rows) so the model writes a correct transform; run-on-data runs the model's JS on the full file in a locked-down Web Worker (no DOM/network, 8s timeout) with parseCSV/toCSV/JSON helpers, returning CSV/JSON that chains to spreadsheet-convert for Excel. Covers the long tail of office data ops (clean/filter/pivot/reshape/extract) as 'describe it, the model codes it'. data-sandbox.worker + data-run.lib (peekData/runDataCode). --- src/tools/agent/executors.test.ts | 5 +++ src/tools/agent/executors.ts | 28 +++++++++++++ src/tools/documents/data-run.lib.test.ts | 18 ++++++++ src/tools/documents/data-run.lib.ts | 39 ++++++++++++++++++ src/tools/documents/data-sandbox.worker.ts | 48 ++++++++++++++++++++++ 5 files changed, 138 insertions(+) create mode 100644 src/tools/documents/data-run.lib.test.ts create mode 100644 src/tools/documents/data-run.lib.ts create mode 100644 src/tools/documents/data-sandbox.worker.ts diff --git a/src/tools/agent/executors.test.ts b/src/tools/agent/executors.test.ts index c5bddbb..1b4923a 100644 --- a/src/tools/agent/executors.test.ts +++ b/src/tools/agent/executors.test.ts @@ -74,6 +74,11 @@ describe('executor registry', () => { 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('scopes the data interpreter (peek-data / run-on-data)', () => { + expect(scopeExecutors('preview this csv file').map(e => e.toolId)).toContain('peek-data'); + expect(scopeExecutors('filter rows where amount is over 100 in this csv').map(e => e.toolId)).toContain('run-on-data'); + expect(scopeExecutors('pivot this data by region').map(e => e.toolId)).toContain('run-on-data'); + }); 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 fbc813b..51eb513 100644 --- a/src/tools/agent/executors.ts +++ b/src/tools/agent/executors.ts @@ -403,6 +403,34 @@ export const AGENT_EXECUTORS: AgentExecutor[] = [ 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` }; }, }, + { + // v3 data interpreter: peek the schema so the model writes a correct transform. + toolId: 'peek-data', page: 'csv-json', + description: "Preview a data file's shape (dimensions + first rows). Call this FIRST when the user wants to clean/filter/reshape a data file, so you can see the columns before writing a transform.", + match: re(/(peek|preview|inspect|look at|show me|what.?s? in).*(data|csv|file|rows?|columns?)|what columns/i), + files: [{ key: 'file', accept: '.csv,.tsv,.txt,.json,text/*', label: 'Data file' }], params: [], + execute: async ({ files }) => { + const { peekData } = await import('@/tools/documents/data-run.lib'); + return { text: peekData(await files.file.text()) }; + }, + }, + { + toolId: 'run-on-data', page: 'code-scratchpad', + description: "Transform a data file by writing JavaScript. YOU write JS in `code` that reads the file's text as `input` and returns the result (assign to `output` or return it). Helpers: parseCSV(text)->rows[][], toCSV(rows)->text, JSON. Runs in a no-network sandbox. Call peek-data first to see the columns. CSV output chains to spreadsheet-convert for Excel.", + match: re(/(clean|filter|dedup|deduplicate|sort|pivot|group|reshape|transform|process|extract|merge|remove|summari[sz]e|aggregate).*(csv|data|rows?|records?|file|columns?|json)|(csv|data|rows?|spreadsheet|json).*(clean|filter|dedup|sort|pivot|group|reshape|transform|process|extract|remove|aggregate)/i), + files: [{ key: 'file', accept: '.csv,.tsv,.txt,.json,text/*', label: 'Data file' }], + params: [{ key: 'code', type: 'string', label: 'JavaScript transform (reads `input`, returns the output text)' }], + execute: async ({ files, params }, onProgress) => { + const { runDataCode } = await import('@/tools/documents/data-run.lib'); + const { extractCode } = await import('@/tools/image/canvas-run.lib'); + const code = extractCode(String(params.code ?? '')); + if (!code) throw new Error('no transform code — write JS that reads `input` and returns the result'); + onProgress?.(0.3); + const out = await runDataCode(code, await files.file.text()); + const isJson = out.trim().startsWith('{') || out.trim().startsWith('['); + return { blob: new Blob([out], { type: isJson ? 'application/json' : 'text/csv' }), filename: isJson ? 'output.json' : 'output.csv', text: `transformed the data (${out.length} chars)` }; + }, + }, { 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' }], diff --git a/src/tools/documents/data-run.lib.test.ts b/src/tools/documents/data-run.lib.test.ts new file mode 100644 index 0000000..40ea527 --- /dev/null +++ b/src/tools/documents/data-run.lib.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import { peekData } from './data-run.lib'; + +describe('peekData', () => { + it('reports CSV dimensions and shows the first rows', () => { + const out = peekData('name,qty\nApple,3\nBanana,5\nCherry,2'); + expect(out).toMatch(/4 lines, ~2 columns \(CSV\)/); + expect(out).toContain('name,qty'); + expect(out).toContain('Apple,3'); + }); + it('truncates long inputs with an ellipsis', () => { + const many = Array.from({ length: 40 }, (_, i) => `row${i}`).join('\n'); + const out = peekData(many, 5); + expect(out).toContain('row0'); + expect(out).toContain('…'); + expect(out).not.toContain('row30'); + }); +}); diff --git a/src/tools/documents/data-run.lib.ts b/src/tools/documents/data-run.lib.ts new file mode 100644 index 0000000..746755d --- /dev/null +++ b/src/tools/documents/data-run.lib.ts @@ -0,0 +1,39 @@ +/** + * Host side of the data interpreter. `peekData` builds a small schema sample the + * model reads before writing a transform (pure/testable); `runDataCode` runs the + * code in the sandbox worker with a timeout (needs a real browser). + */ + +/** A compact preview of a file's shape: dimensions + the first rows. */ +export function peekData(text: string, maxLines = 15): string { + const lines = text.replace(/\r\n/g, '\n').split('\n').filter((l, i, a) => i < a.length - 1 || l.length > 0); + const head = lines.slice(0, maxLines); + const looksCsv = /,/.test(head[0] ?? ''); + const cols = looksCsv ? (head[0]?.split(',').length ?? 0) : 0; + const meta = `${lines.length} line${lines.length === 1 ? '' : 's'}` + (looksCsv ? `, ~${cols} columns (CSV)` : ''); + return `${meta}\n\nFirst ${head.length} line${head.length === 1 ? '' : 's'}:\n${head.join('\n')}${lines.length > maxLines ? '\n…' : ''}`; +} + +export interface DataRunOpts { timeoutMs?: number } + +/** Run a data-transform in the sandbox worker; resolve with the output text. */ +export async function runDataCode(code: string, input: string, opts: DataRunOpts = {}): Promise { + if (typeof Worker === 'undefined') throw new Error('this browser cannot run the data sandbox'); + const timeoutMs = opts.timeoutMs ?? 8000; + const worker = new Worker(new URL('./data-sandbox.worker.ts', import.meta.url), { type: 'module' }); + try { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('the transform timed out — possible infinite loop')), timeoutMs); + worker.onmessage = (e: MessageEvent) => { + clearTimeout(timer); + const d = e.data as { ok: boolean; output?: string; error?: string }; + if (d.ok && typeof d.output === 'string') resolve(d.output); + else reject(new Error(d.error || 'transform failed')); + }; + worker.onerror = ev => { clearTimeout(timer); reject(new Error(ev.message || 'sandbox error')); }; + worker.postMessage({ code, input }); + }); + } finally { + worker.terminate(); + } +} diff --git a/src/tools/documents/data-sandbox.worker.ts b/src/tools/documents/data-sandbox.worker.ts new file mode 100644 index 0000000..d340fa9 --- /dev/null +++ b/src/tools/documents/data-sandbox.worker.ts @@ -0,0 +1,48 @@ +/** + * Sandbox for running LLM-written DATA-transform code (agent v3 "data interpreter"). + * + * Same lock-down as the canvas sandbox: a Web Worker (no DOM), with the + * exfiltration/storage channels neutered, and a wall-clock timeout enforced by + * the main thread. The code gets the file's text as `input` plus small CSV/JSON + * helpers, and returns the transformed text. + */ + +const blocked = () => { throw new Error('disabled in the data 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 { /* non-configurable */ } +} + +/** Parse a CSV line into fields, honoring double-quoted cells. */ +function splitCsvLine(line: string): string[] { + const out: string[] = []; + let cur = '', inQ = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (inQ) { + if (c === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else inQ = false; } + else cur += c; + } else if (c === '"') inQ = true; + else if (c === ',') { out.push(cur); cur = ''; } + else cur += c; + } + out.push(cur); + return out; +} +const parseCSV = (text: string): string[][] => text.replace(/\r\n/g, '\n').replace(/\n+$/, '').split('\n').map(splitCsvLine); +const toCSV = (rows: unknown[][]): string => + rows.map(r => r.map(c => { const s = String(c ?? ''); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }).join(',')).join('\n'); + +self.onmessage = async (e: MessageEvent<{ code: string; input: string }>) => { + const { code, input } = e.data; + const post = (msg: unknown) => (self as unknown as Worker).postMessage(msg); + try { + // The code sees `input` (file text) + helpers, and returns the output string. + const fn = new Function('input', 'parseCSV', 'toCSV', 'JSON', 'Math', 'Date', + `"use strict";\nlet output;\n${code}\n;return (typeof output !== 'undefined') ? output : undefined;`); + let out = await fn(input, parseCSV, toCSV, JSON, Math, Date); + if (out === undefined || out === null) throw new Error('the code produced no output — set `output` or return a value'); + post({ ok: true, output: typeof out === 'string' ? out : JSON.stringify(out, null, 2) }); + } catch (err) { + post({ ok: false, error: err instanceof Error ? err.message : String(err) }); + } +}; From a20bc70ea6ef78a5abcc7bf42deb2541fa2c290b Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:32:33 +0700 Subject: [PATCH 02/11] =?UTF-8?q?feat(agent):=20PDF=20executors=20?= =?UTF-8?q?=E2=80=94=20compress,=20rotate,=20split=20(via=20mupdf)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap mupdf.client's headless PDF ops as executors: pdf-compress (shrink), pdf-rotate (90/180/270), pdf-split (extract pages like '1-3,5' via a new pagerange.lib). 'compress this pdf' scopes only pdf-compress (no image/video collision). Rounds out office/document productivity; all chain in the harness. --- src/tools/agent/executors.test.ts | 5 +++++ src/tools/agent/executors.ts | 33 +++++++++++++++++++++++++++++ src/tools/pdf/pagerange.lib.test.ts | 15 +++++++++++++ src/tools/pdf/pagerange.lib.ts | 19 +++++++++++++++++ 4 files changed, 72 insertions(+) create mode 100644 src/tools/pdf/pagerange.lib.test.ts create mode 100644 src/tools/pdf/pagerange.lib.ts diff --git a/src/tools/agent/executors.test.ts b/src/tools/agent/executors.test.ts index 1b4923a..e633563 100644 --- a/src/tools/agent/executors.test.ts +++ b/src/tools/agent/executors.test.ts @@ -79,6 +79,11 @@ describe('executor registry', () => { expect(scopeExecutors('filter rows where amount is over 100 in this csv').map(e => e.toolId)).toContain('run-on-data'); expect(scopeExecutors('pivot this data by region').map(e => e.toolId)).toContain('run-on-data'); }); + it('scopes the PDF tools (compress/rotate/split), not image/video compress', () => { + expect(scopeExecutors('compress this pdf').map(e => e.toolId)).toEqual(['pdf-compress']); + expect(scopeExecutors('rotate my pdf 90 degrees').map(e => e.toolId)).toContain('pdf-rotate'); + expect(scopeExecutors('extract pages 1-3 from this pdf').map(e => e.toolId)).toContain('pdf-split'); + }); 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 51eb513..5cd13a5 100644 --- a/src/tools/agent/executors.ts +++ b/src/tools/agent/executors.ts @@ -431,6 +431,39 @@ export const AGENT_EXECUTORS: AgentExecutor[] = [ return { blob: new Blob([out], { type: isJson ? 'application/json' : 'text/csv' }), filename: isJson ? 'output.json' : 'output.csv', text: `transformed the data (${out.length} chars)` }; }, }, + { + toolId: 'pdf-compress', description: 'Compress / shrink a PDF file', match: re(/(compress|shrink|reduce|smaller|optimi[sz]e).*pdf|pdf.*(compress|shrink|reduce|smaller|size)/i), + files: [{ key: 'file', accept: '.pdf,application/pdf', label: 'PDF' }], params: [], + execute: async ({ files }) => { + const { compressPdf } = await import('@/tools/pdf/mupdf.client'); + const blob = await compressPdf(files.file); + return { blob, filename: 'compressed.pdf', text: `compressed to ${Math.round(blob.size / 1024)} KB` }; + }, + }, + { + toolId: 'pdf-rotate', description: 'Rotate every page of a PDF (90, 180 or 270 degrees)', match: re(/rotate.*pdf|pdf.*rotate|turn.*pdf/i), + files: [{ key: 'file', accept: '.pdf,application/pdf', label: 'PDF' }], + params: [{ key: 'degrees', type: 'number', label: 'Degrees (90/180/270)', default: 90 }], + execute: async ({ files, params }) => { + const { rotatePdf } = await import('@/tools/pdf/mupdf.client'); + const deg = Number(params.degrees) || 90; + const blob = await rotatePdf(files.file, deg); + return { blob, filename: 'rotated.pdf', text: `rotated by ${deg}°` }; + }, + }, + { + toolId: 'pdf-split', description: 'Extract specific pages from a PDF (e.g. "1-3,5") into a new PDF', match: re(/(extract|split|get|keep|pull|take).*pages?.*pdf|pdf.*pages?.*(extract|split|keep)|split.*pdf|pdf.*split/i), + files: [{ key: 'file', accept: '.pdf,application/pdf', label: 'PDF' }], + params: [{ key: 'pages', type: 'string', label: 'Pages (e.g. 1-3,5)' }], + execute: async ({ files, params }) => { + const { extractPageList } = await import('@/tools/pdf/mupdf.client'); + const { parsePageRange } = await import('@/tools/pdf/pagerange.lib'); + const pages = parsePageRange(String(params.pages ?? '')); + if (!pages.length) throw new Error('tell me which pages, e.g. "1-3,5"'); + const blob = await extractPageList(files.file, pages); + return { blob, filename: 'pages.pdf', text: `extracted ${pages.length} page${pages.length === 1 ? '' : 's'}` }; + }, + }, { 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' }], diff --git a/src/tools/pdf/pagerange.lib.test.ts b/src/tools/pdf/pagerange.lib.test.ts new file mode 100644 index 0000000..3b51436 --- /dev/null +++ b/src/tools/pdf/pagerange.lib.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest'; +import { parsePageRange } from './pagerange.lib'; + +describe('parsePageRange', () => { + it('expands ranges and singles, sorted and de-duped', () => { + expect(parsePageRange('1-3,5,7-9')).toEqual([1, 2, 3, 5, 7, 8, 9]); + }); + it('handles reversed ranges and whitespace', () => { + expect(parsePageRange(' 9 - 7 , 2 ')).toEqual([2, 7, 8, 9]); + }); + it('ignores junk and drops non-positive pages', () => { + expect(parsePageRange('0, abc, 3, -1')).toEqual([3]); + expect(parsePageRange('')).toEqual([]); + }); +}); diff --git a/src/tools/pdf/pagerange.lib.ts b/src/tools/pdf/pagerange.lib.ts new file mode 100644 index 0000000..1058ba2 --- /dev/null +++ b/src/tools/pdf/pagerange.lib.ts @@ -0,0 +1,19 @@ +/** + * Parse a human page range like "1-3,5,7-9" into a sorted, de-duplicated list of + * 1-based page numbers. Pure/testable; used by the agent's PDF executors. + */ +export function parsePageRange(input: string): number[] { + const pages = new Set(); + for (const part of String(input).split(',')) { + const t = part.trim(); + if (!t) continue; + const range = t.match(/^(\d+)\s*-\s*(\d+)$/); + if (range) { + const a = Number(range[1]), b = Number(range[2]); + for (let i = Math.min(a, b); i <= Math.max(a, b); i++) pages.add(i); + } else if (/^\d+$/.test(t)) { + pages.add(Number(t)); + } + } + return [...pages].filter(n => n > 0).sort((a, b) => a - b); +} From facc9c8307c8c1c7787aa02dd6bae34b77028489 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:05:38 +0700 Subject: [PATCH 03/11] feat(agent): multi-file input + pdf-merge Add an optional variable-count file slot (AgentExecutor.multiFile) so tools like pdf-merge can take several files. useAgentChat requestFiles/provideFiles/cancelFiles + pendingFiles UI (multi-select dropzone); runExecutor collects the list and passes fileList to execute; native-FC schema includes the slot. pdf-merge wraps mupdf mergePdfs. Committed to develop (batched for the Sep 1 deploy). --- src/hooks/useAgentChat.ts | 24 +++++++++++++++++++++--- src/islands/agent/AskAgent.tsx | 9 ++++++++- src/tools/agent/executors.test.ts | 4 ++++ src/tools/agent/executors.ts | 14 +++++++++++++- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/hooks/useAgentChat.ts b/src/hooks/useAgentChat.ts index 0fb2b62..6649476 100644 --- a/src/hooks/useAgentChat.ts +++ b/src/hooks/useAgentChat.ts @@ -33,10 +33,13 @@ export function useAgentChat(provider: AgentProvider | null) { const [turns, setTurns] = useState([]); const [busy, setBusy] = useState(false); const [pendingFile, setPendingFile] = useState<{ label: string } | null>(null); + const [pendingFiles, setPendingFiles] = useState<{ label: string } | null>(null); const [pendingInput, setPendingInput] = useState<{ label: string } | null>(null); const sessionRef = useRef(emptySession()); const fileResolver = useRef<((f: File) => void) | null>(null); const fileRejecter = useRef<((e: Error) => void) | null>(null); + const filesResolver = useRef<((f: File[]) => void) | null>(null); + const filesRejecter = useRef<((e: Error) => void) | null>(null); const inputResolver = useRef<((v: string) => void) | null>(null); const inputRejecter = useRef<((e: Error) => void) | null>(null); // Files the user already uploaded this session, keyed by file-slot. Reused on a @@ -62,6 +65,15 @@ export function useAgentChat(provider: AgentProvider | null) { /** Abandon a pending file request (user chose not to upload). Unwinds the loop. */ const cancelFile = () => { const r = fileRejecter.current; clearFileWaiters(); r?.(new Error('__cancelled__')); }; + // Variable-count file request (e.g. "merge these PDFs"). + const requestFiles = (label: string): Promise => { + setPendingFiles({ label }); + return new Promise((res, rej) => { filesResolver.current = res; filesRejecter.current = rej; }); + }; + const clearFilesWaiters = () => { setPendingFiles(null); filesResolver.current = null; filesRejecter.current = null; }; + const provideFiles = (fs: File[]) => { const r = filesResolver.current; clearFilesWaiters(); r?.(fs); }; + const cancelFiles = () => { const r = filesRejecter.current; clearFilesWaiters(); r?.(new Error('__cancelled__')); }; + // Ask the user for a required text value the model couldn't fill (e.g. what a // QR should encode) — the text equivalent of requestFile. const requestInput = (label: string): Promise => { @@ -144,6 +156,11 @@ export function useAgentChat(provider: AgentProvider | null) { files[fs.key] = f; if (!piped) { loopFiles[fs.key] = f; lastFilesRef.current[fs.key] = f; } // remember only real uploads } + let fileList: File[] | undefined; + if (exec.multiFile) { + try { fileList = await requestFiles(exec.multiFile.label); } + catch { updateLastText(`✗ ${exec.toolId} — cancelled`); return { ok: false, resultText: 'cancelled', cancelled: true }; } + } const params: Record = { ...argsIn }; for (const ps of exec.params) { if (ps.default !== undefined) continue; @@ -155,7 +172,7 @@ export function useAgentChat(provider: AgentProvider | null) { } } try { - const result = await exec.execute({ files, params }, p => updateLastText(`→ ${exec.toolId} — ${Math.round(p * 100)}%`)); + const result = await exec.execute({ files, params, fileList }, p => updateLastText(`→ ${exec.toolId} — ${Math.round(p * 100)}%`)); const blobUrl = result.blob ? URL.createObjectURL(result.blob) : undefined; push({ role: 'assistant', text: `✓ ${exec.toolId}: ${result.text ?? 'produced a file'}`, blobUrl, imgUrl: result.dataUrl, filename: result.filename }); if (result.blob) chainFile = new File([result.blob], result.filename ?? 'output', { type: result.blob.type }); @@ -178,9 +195,10 @@ export function useAgentChat(provider: AgentProvider | null) { type: 'object', properties: { ...Object.fromEntries(e.files.map(f => [f.key, { type: 'string', description: `${f.label} — pass the string "UPLOAD" and the app will ask the user for the file` }])), + ...(e.multiFile ? { [e.multiFile.key]: { type: 'string', description: `${e.multiFile.label} — pass "UPLOAD"; the app will let the user pick several files` } } : {}), ...Object.fromEntries(e.params.map(p => [p.key, { type: p.type === 'number' ? 'number' : 'string', description: p.label }])), }, - required: [...e.files.map(f => f.key), ...e.params.filter(p => p.default === undefined).map(p => p.key)], + required: [...e.files.map(f => f.key), ...(e.multiFile ? [e.multiFile.key] : []), ...e.params.filter(p => p.default === undefined).map(p => p.key)], }, })); const sys = "You are GoodWebTools' agent. Use the tools to fulfil the user's request. You can call several tools in sequence — each tool's output file automatically becomes the next tool's input, so you can chain them. For a file argument pass the string \"UPLOAD\". When the task is done, reply with a short final message and no tool call."; @@ -250,5 +268,5 @@ export function useAgentChat(provider: AgentProvider | null) { } }; - return { turns, busy, pendingFile, pendingInput, send, provideFile, cancelFile, provideInput, cancelInput }; + return { turns, busy, pendingFile, pendingFiles, pendingInput, send, provideFile, cancelFile, provideFiles, cancelFiles, provideInput, cancelInput }; } diff --git a/src/islands/agent/AskAgent.tsx b/src/islands/agent/AskAgent.tsx index 5176adb..2e7779b 100644 --- a/src/islands/agent/AskAgent.tsx +++ b/src/islands/agent/AskAgent.tsx @@ -34,7 +34,7 @@ export default function AskAgent({ lang = 'en' }: { lang?: 'en' | 'id' }) { const [input, setInput] = useState(''); const ondeviceRef = useRef(null); - const { turns, busy, pendingFile, pendingInput, send, provideFile, cancelFile, provideInput, cancelInput } = useAgentChat(provider); + const { turns, busy, pendingFile, pendingFiles, pendingInput, send, provideFile, cancelFile, provideFiles, cancelFiles, provideInput, cancelInput } = useAgentChat(provider); const [inputValue, setInputValue] = useState(''); const loadOndevice = async () => { @@ -156,6 +156,13 @@ export default function AskAgent({ lang = 'en' }: { lang?: 'en' | 'id' }) { )} + {pendingFiles && ( +
+

The agent needs files: {pendingFiles.label}

+ { const fs = Array.from(e.target.files ?? []); if (fs.length) provideFiles(fs); }} className="text-sm" /> + +
+ )} {pendingInput && (

{pendingInput.label}

diff --git a/src/tools/agent/executors.test.ts b/src/tools/agent/executors.test.ts index e633563..e1f36fb 100644 --- a/src/tools/agent/executors.test.ts +++ b/src/tools/agent/executors.test.ts @@ -83,6 +83,10 @@ describe('executor registry', () => { expect(scopeExecutors('compress this pdf').map(e => e.toolId)).toEqual(['pdf-compress']); expect(scopeExecutors('rotate my pdf 90 degrees').map(e => e.toolId)).toContain('pdf-rotate'); expect(scopeExecutors('extract pages 1-3 from this pdf').map(e => e.toolId)).toContain('pdf-split'); + expect(scopeExecutors('merge these pdfs into one').map(e => e.toolId)).toContain('pdf-merge'); + }); + it('the pdf-merge executor declares a multiFile slot', () => { + expect(executorFor('pdf-merge')?.multiFile?.key).toBe('files'); }); 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 5cd13a5..59b52c9 100644 --- a/src/tools/agent/executors.ts +++ b/src/tools/agent/executors.ts @@ -23,9 +23,11 @@ export interface AgentExecutor { description: string; match: (q: string) => boolean; files: FileSpec[]; + /** An optional variable-count file input (e.g. "merge these PDFs"). */ + multiFile?: FileSpec; params: ParamSpec[]; execute: ( - inputs: { files: Record; params: Record }, + inputs: { files: Record; params: Record; fileList?: File[] }, onProgress?: (p: number, note?: string) => void, ) => Promise; } @@ -464,6 +466,16 @@ export const AGENT_EXECUTORS: AgentExecutor[] = [ return { blob, filename: 'pages.pdf', text: `extracted ${pages.length} page${pages.length === 1 ? '' : 's'}` }; }, }, + { + toolId: 'pdf-merge', description: 'Merge several PDF files into one', match: re(/merge.*pdf|combine.*pdf|pdf.*(merge|combine)|join.*pdfs?/i), + files: [], multiFile: { key: 'files', accept: '.pdf,application/pdf', label: 'PDFs to merge (pick 2 or more)' }, params: [], + execute: async ({ fileList }) => { + if (!fileList || fileList.length < 2) throw new Error('pick at least two PDFs to merge'); + const { mergePdfs } = await import('@/tools/pdf/mupdf.client'); + const blob = await mergePdfs(fileList); + return { blob, filename: 'merged.pdf', text: `merged ${fileList.length} PDFs` }; + }, + }, { 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' }], From efd49c4f9e373ef9f2e39fa591a340e6817addcd Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:24:36 +0700 Subject: [PATCH 04/11] fix(agent): base-convert scope/validate + arg recovery + attach-file button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - base-convert no longer matches 'base64' (polluted scope on weak models); validate bases are 2-36 (was crashing with toString() radix error). - Recover a single tool's content arg from the message ('format json: {a:1}' → '{a:1}') before re-asking, so weak models that omit the arg don't re-prompt. - Attach-file button (📎) next to Send: attached files are used by the next tool that needs one, so you can provide the file up front instead of a dropzone. --- src/hooks/useAgentChat.ts | 47 ++++++++++++++++++++++++++----- src/islands/agent/AskAgent.tsx | 21 ++++++++++++-- src/tools/agent/executors.test.ts | 4 +++ src/tools/agent/executors.ts | 10 +++++-- 4 files changed, 70 insertions(+), 12 deletions(-) diff --git a/src/hooks/useAgentChat.ts b/src/hooks/useAgentChat.ts index 6649476..e6e0d05 100644 --- a/src/hooks/useAgentChat.ts +++ b/src/hooks/useAgentChat.ts @@ -3,7 +3,7 @@ import { classifyIntent } from '@/tools/agent/intent'; import { executorFor, AGENT_EXECUTORS } from '@/tools/agent/executors'; import { buildSystemPrompt, parseAction, recoverContentAction, type LoopTool } from '@/tools/agent/loop.lib'; import { emptySession, recordUser, applyResolution, historyForPrompt } from '@/tools/agent/session.lib'; -import { prefillUrl } from '@/tools/agent/router.lib'; +import { prefillUrl, extractParams } from '@/tools/agent/router.lib'; import { buildToolChoicePrompt, parseToolChoice } from '@/tools/agent/select.lib'; import { getToolById } from '@/registry/tools'; import type { AgentProvider, ChatMessage, ToolSpec, ToolMsg } from '@/services/agent/provider'; @@ -24,6 +24,23 @@ const CHAT_SYSTEM = [ 'If you are unsure whether a tool exists, say you can look for one rather than sending them elsewhere.', ].join(' '); +/** + * Guess a content value from the user's message for a tool arg a weak model left + * empty — e.g. "format this json: {ac:1}" → "{ac:1}", or a quoted string. Falls + * back to the router's residual-text extraction. So a small model that calls the + * right tool but forgets the arg doesn't re-ask for content already in the message. + */ +function guessContent(q: string): string { + // Content after the FIRST ':' (a colon inside the value, e.g. JSON, must not + // split it) — "format json: {a:1}" → "{a:1}". + const colon = q.indexOf(':'); + if (colon >= 0) { const after = q.slice(colon + 1).trim(); if (after) return after; } + const quoted = q.match(/["'`]([^"'`]{2,})["'`]/); + if (quoted) return quoted[1]; + const residual = extractParams(q).text; + return residual ? residual.trim() : ''; +} + /** * Orchestrates one agent conversation over any `AgentProvider`: * intent gate (chat / open / task) → runtime-scoped agentic loop → session. @@ -45,6 +62,9 @@ export function useAgentChat(provider: AgentProvider | null) { // Files the user already uploaded this session, keyed by file-slot. Reused on a // follow-up ("make it 50kb") so the agent doesn't re-ask for the same image. const lastFilesRef = useRef>({}); + // Files attached to the current message (via the paperclip) — consumed by tools + // that need a file before falling back to a dropzone prompt. + const attachedRef = useRef([]); const push = (t: ChatUiTurn) => setTurns(x => [...x, t]); // Rewrite the most recent turn's text — used to animate a running executor's @@ -84,10 +104,11 @@ export function useAgentChat(provider: AgentProvider | null) { const provideInput = (v: string) => { const r = inputResolver.current; clearInputWaiters(); r?.(v); }; const cancelInput = () => { const r = inputRejecter.current; clearInputWaiters(); r?.(new Error('__cancelled__')); }; - const send = async (text: string) => { + const send = async (text: string, attached: File[] = []) => { const q = text.trim(); if (!q || !provider || busy) return; - push({ role: 'user', text: q }); + attachedRef.current = [...attached]; + push({ role: 'user', text: q + (attached.length ? ` 📎 ${attached.map(f => f.name).join(', ')}` : '') }); sessionRef.current = recordUser(sessionRef.current, q); setBusy(true); try { @@ -149,6 +170,7 @@ export function useAgentChat(provider: AgentProvider | null) { const cached = piped ?? loopFiles[fs.key]; let f: File; if (cached) { f = cached; } + else if (attachedRef.current.length) { f = attachedRef.current.shift()!; } // use an attached file else { try { f = await requestFile(fs.label); } catch { updateLastText(`✗ ${exec.toolId} — cancelled`); return { ok: false, resultText: 'cancelled', cancelled: true }; } @@ -158,17 +180,28 @@ export function useAgentChat(provider: AgentProvider | null) { } let fileList: File[] | undefined; if (exec.multiFile) { - try { fileList = await requestFiles(exec.multiFile.label); } - catch { updateLastText(`✗ ${exec.toolId} — cancelled`); return { ok: false, resultText: 'cancelled', cancelled: true }; } + if (attachedRef.current.length) { fileList = attachedRef.current.splice(0); } // use attached files + else { + try { fileList = await requestFiles(exec.multiFile.label); } + catch { updateLastText(`✗ ${exec.toolId} — cancelled`); return { ok: false, resultText: 'cancelled', cancelled: true }; } + } } const params: Record = { ...argsIn }; + const singleContentParam = exec.params.filter(p => p.default === undefined).length === 1; for (const ps of exec.params) { if (ps.default !== undefined) continue; const raw = params[ps.key] == null ? '' : String(params[ps.key]).trim(); const echoed = raw !== '' && (raw.toLowerCase() === q.trim().toLowerCase() || (wordCount(raw) <= 3 && exec.match(raw))); if (raw === '' || raw.toUpperCase() === 'UPLOAD' || echoed) { - try { params[ps.key] = await requestInput(ps.label); } - catch { updateLastText(`✗ ${exec.toolId} — cancelled`); return { ok: false, resultText: 'cancelled', cancelled: true }; } + // For a single-content tool, recover the value from the message before + // asking — a weak model often calls the right tool but omits the arg. + const guess = singleContentParam ? guessContent(q) : ''; + if (guess && guess.toLowerCase() !== q.trim().toLowerCase() && !(wordCount(guess) <= 3 && exec.match(guess))) { + params[ps.key] = guess; + } else { + try { params[ps.key] = await requestInput(ps.label); } + catch { updateLastText(`✗ ${exec.toolId} — cancelled`); return { ok: false, resultText: 'cancelled', cancelled: true }; } + } } } try { diff --git a/src/islands/agent/AskAgent.tsx b/src/islands/agent/AskAgent.tsx index 2e7779b..eae65c9 100644 --- a/src/islands/agent/AskAgent.tsx +++ b/src/islands/agent/AskAgent.tsx @@ -36,6 +36,9 @@ export default function AskAgent({ lang = 'en' }: { lang?: 'en' | 'id' }) { const { turns, busy, pendingFile, pendingFiles, pendingInput, send, provideFile, cancelFile, provideFiles, cancelFiles, provideInput, cancelInput } = useAgentChat(provider); const [inputValue, setInputValue] = useState(''); + const [attached, setAttached] = useState([]); + const attachInputRef = useRef(null); + const submit = () => { if (input.trim()) { send(input, attached); setInput(''); setAttached([]); } }; const loadOndevice = async () => { setLoading(true); setProgressText(''); @@ -177,10 +180,24 @@ export default function AskAgent({ lang = 'en' }: { lang?: 'en' | 'id' }) {
)} + {attached.length > 0 && ( +
+ {attached.map((f, i) => ( + + 📎 {f.name} + + + ))} +
+ )}
- setInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { send(input); setInput(''); } }} + { const fs = Array.from(e.target.files ?? []); if (fs.length) setAttached(a => [...a, ...fs]); e.target.value = ''; }} /> + + setInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') submit(); }} placeholder="Tell the agent what you want…" className="flex-1 border-2 border-border bg-muted p-3 outline-none focus:shadow-brutal-sm" /> - +
)} diff --git a/src/tools/agent/executors.test.ts b/src/tools/agent/executors.test.ts index e1f36fb..9e74b75 100644 --- a/src/tools/agent/executors.test.ts +++ b/src/tools/agent/executors.test.ts @@ -103,6 +103,10 @@ describe('executor registry', () => { expect(scopeExecutors('convert this unix timestamp').map(e => e.toolId)).toContain('timestamp'); expect(scopeExecutors('csv to json').map(e => e.toolId)).toContain('csv-json'); expect(scopeExecutors('convert 255 base 10 to base 16').map(e => e.toolId)).toContain('base-convert'); + // base-convert must NOT be pulled in by "base64" (that's the base64 tool). + const b64 = scopeExecutors('encode base64 ABCDEF').map(e => e.toolId); + expect(b64).toContain('base64'); + expect(b64).not.toContain('base-convert'); expect(scopeExecutors('clean the tracking params from this url').map(e => e.toolId)).toContain('url-cleaner'); expect(scopeExecutors('generate a strong password').map(e => e.toolId)).toContain('password-gen'); expect(scopeExecutors('convert this html to markdown').map(e => e.toolId)).toContain('html-markdown'); diff --git a/src/tools/agent/executors.ts b/src/tools/agent/executors.ts index 59b52c9..7d787fe 100644 --- a/src/tools/agent/executors.ts +++ b/src/tools/agent/executors.ts @@ -141,16 +141,20 @@ export const AGENT_EXECUTORS: AgentExecutor[] = [ }, }, { - toolId: 'base-convert', description: 'Convert a number between bases (pass from and to, e.g. 2, 10, 16)', match: re(/base ?\d+|binary|hex(adecimal)?|octal|radix|convert.*(base|binary|hex|octal)/i), + // Match only a real base CONVERSION — NOT "base64" (which is the base64 tool); + // "base ?\d+" alone caught "base64" and polluted the scope. + toolId: 'base-convert', description: 'Convert a number between numeric bases 2–36 (pass from and to, e.g. 2, 10, 16)', + match: re(/\bto\s+base[- ]?\d+\b|\bbase[- ]?\d+\s+to\b|\bin\s+base[- ]?\d+\b|\bradix\b|\bnumber base\b|convert.*\b(binary|octal|hexadecimal)\b|\b(binary|octal|hexadecimal)\b.*\bconvert\b/i), files: [], params: [ { key: 'value', type: 'string', label: 'Number' }, - { key: 'from', type: 'number', label: 'From base', default: 10 }, - { key: 'to', type: 'number', label: 'To base', default: 16 }, + { key: 'from', type: 'number', label: 'From base (2–36)', default: 10 }, + { key: 'to', type: 'number', label: 'To base (2–36)', default: 16 }, ], execute: async ({ params }) => { const { parseInBase } = await import('@/tools/dev/base-convert.lib'); const from = Number(params.from) || 10; const to = Number(params.to) || 16; + if (from < 2 || from > 36 || to < 2 || to > 36) throw new Error('bases must be between 2 and 36'); const n = parseInBase(String(params.value ?? '').trim(), from); if (n === null) throw new Error(`"${params.value}" is not a valid base-${from} number`); return { text: n.toString(to) }; From d40283c467f51d10a472416d1d9794b74ddd6347 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:43:04 +0700 Subject: [PATCH 05/11] fix(agent): Bahasa Indonesia keyword support + stop weak-model tool re-runs - expandIndonesian() bridges ID keywords to English (gambar->image, kompres, gabungkan->merge, buat->create, etc.) in scopeExecutors + routeQuery, so ID queries like 'compress gambar ini ke 100kb' hit the executor, not open-mode. - On-device prompt loop caps each tool to ONE run (ranTools): a 0.5B was re-encoding its own base64 output ~10 times; the tool+args dedup missed it because each output differed. --- src/hooks/useAgentChat.ts | 3 +++ src/tools/agent/executors.test.ts | 5 +++++ src/tools/agent/executors.ts | 4 +++- src/tools/agent/router.lib.ts | 24 +++++++++++++++++++++++- 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/hooks/useAgentChat.ts b/src/hooks/useAgentChat.ts index e6e0d05..58607f9 100644 --- a/src/hooks/useAgentChat.ts +++ b/src/hooks/useAgentChat.ts @@ -274,6 +274,7 @@ export function useAgentChat(provider: AgentProvider | null) { })); const convo: ChatMessage[] = [{ role: 'system', content: buildSystemPrompt(loopTools) }, { role: 'user', content: q }]; const doneKeys = new Set(); + const ranTools = new Set(); // weak models re-run a tool on its own output (base64→base64→…) — cap at one run per tool for (let iter = 0; iter < 8; iter++) { const raw = await provider.chat(convo); const act = parseAction(raw) ?? recoverContentAction(raw, offeredIds); @@ -288,8 +289,10 @@ export function useAgentChat(provider: AgentProvider | null) { if (doneKeys.has(key)) { push({ role: 'assistant', text: 'Done — anything else?' }); break; } const exec = executorFor(act.tool); if (!exec) { convo.push({ role: 'assistant', content: raw }, { role: 'user', content: `TOOL_ERROR: unknown tool ${act.tool}` }); continue; } + if (ranTools.has(exec.toolId)) { push({ role: 'assistant', text: 'Done — anything else?' }); break; } const res = await runExecutor(exec, act.args); if (res.cancelled) break; + if (res.ok) ranTools.add(exec.toolId); doneKeys.add(key); convo.push({ role: 'assistant', content: raw }, { role: 'user', content: `TOOL_RESULT ${exec.toolId}: ${res.resultText}. Respond with a "final" action now unless the user asked for more.` }); if (iter === 7) push({ role: 'assistant', text: produced ? 'Done — anything else?' : "I couldn't finish that — try rephrasing, or a bigger model." }); diff --git a/src/tools/agent/executors.test.ts b/src/tools/agent/executors.test.ts index 9e74b75..2a2ac53 100644 --- a/src/tools/agent/executors.test.ts +++ b/src/tools/agent/executors.test.ts @@ -88,6 +88,11 @@ describe('executor registry', () => { it('the pdf-merge executor declares a multiFile slot', () => { expect(executorFor('pdf-merge')?.multiFile?.key).toBe('files'); }); + it('recognizes Bahasa Indonesia keywords', () => { + expect(scopeExecutors('compress gambar ini ke 100kb').map(e => e.toolId)).toContain('image-compress'); + expect(scopeExecutors('kompres video ini jadi 5mb').map(e => e.toolId)).toContain('video-compress'); + expect(scopeExecutors('gabungkan pdf ini').map(e => e.toolId)).toContain('pdf-merge'); + }); 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 7d787fe..c1b0127 100644 --- a/src/tools/agent/executors.ts +++ b/src/tools/agent/executors.ts @@ -9,6 +9,7 @@ * compress-to-size and audio/video trim) backed by src/tools/media/encode.lib. */ import { getToolById } from '@/registry/tools'; +import { expandIndonesian } from './router.lib'; export interface FileSpec { key: string; accept: string; label: string } export interface ParamSpec { key: string; type: 'number' | 'string'; label: string; default?: string | number } @@ -646,7 +647,8 @@ export const AGENT_EXECUTORS: AgentExecutor[] = [ ]; export function scopeExecutors(query: string): AgentExecutor[] { - return AGENT_EXECUTORS.filter(e => e.match(query)); + const q = expandIndonesian(query); // recognize Bahasa Indonesia keywords too + return AGENT_EXECUTORS.filter(e => e.match(q)); } export function executorFor(toolId: string): AgentExecutor | undefined { diff --git a/src/tools/agent/router.lib.ts b/src/tools/agent/router.lib.ts index e08786f..323fdeb 100644 --- a/src/tools/agent/router.lib.ts +++ b/src/tools/agent/router.lib.ts @@ -119,9 +119,31 @@ function scoreTool(tool: (typeof tools)[number], queryStems: string[]): number { return score; } +// Bahasa Indonesia → English keyword bridge, so the agent works on the /id/ site. +// Appended (not replaced) to the query before matching, so mixed EN/ID also works. +const ID_EN: Record = { + gambar: 'image', foto: 'photo', citra: 'image', gbr: 'image', + kompres: 'compress', mampatkan: 'compress', perkecil: 'shrink smaller', kecilkan: 'shrink smaller', kurangi: 'reduce', + suara: 'audio', video: 'video', musik: 'audio', + ubah: 'convert', konversi: 'convert', konversikan: 'convert', jadikan: 'convert', mengubah: 'convert', + potong: 'trim cut', pangkas: 'trim crop', gabung: 'merge combine', gabungkan: 'merge combine', satukan: 'merge', + pisah: 'split', pisahkan: 'split', putar: 'rotate', rotasi: 'rotate', + hapus: 'remove delete', duplikat: 'duplicate', ganda: 'duplicate', + kata: 'word', huruf: 'text case', teks: 'text', tabel: 'table', + sandi: 'password', kata_sandi: 'password', enkripsi: 'encrypt', dekripsi: 'decrypt', + terjemah: 'translate', terjemahkan: 'translate', ringkas: 'summarize', + buat: 'make create', bikin: 'make create', buatkan: 'make create', gambarkan: 'draw', + unduh: 'download', warna: 'color', diagram: 'diagram', ikon: 'icon', +}; +export function expandIndonesian(query: string): string { + const extra: string[] = []; + for (const w of query.toLowerCase().split(/[^a-z0-9]+/)) { const e = ID_EN[w]; if (e) extra.push(e); } + return extra.length ? `${query} ${extra.join(' ')}` : query; +} + /** Route a query to the most relevant tools plus any extracted parameters. */ export function routeQuery(query: string, limit = 5): RouteResult { - const queryStems = tokenize(query).filter(t => !STOPWORDS.has(t)).map(stem); + const queryStems = tokenize(expandIndonesian(query)).filter(t => !STOPWORDS.has(t)).map(stem); const rank = (id: string) => { const i = POPULARITY.indexOf(id); return i === -1 ? 999 : i; }; const scored = tools .filter(t => !t.desktopOnly) From bccab1f7cfcb2928c0178c47a41082e402bf4b80 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:05:12 +0700 Subject: [PATCH 06/11] fix(agent): add colloquial ID kecilin/kurangin to the keyword bridge --- src/tools/agent/router.lib.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/agent/router.lib.ts b/src/tools/agent/router.lib.ts index 323fdeb..60d47f4 100644 --- a/src/tools/agent/router.lib.ts +++ b/src/tools/agent/router.lib.ts @@ -123,7 +123,7 @@ function scoreTool(tool: (typeof tools)[number], queryStems: string[]): number { // Appended (not replaced) to the query before matching, so mixed EN/ID also works. const ID_EN: Record = { gambar: 'image', foto: 'photo', citra: 'image', gbr: 'image', - kompres: 'compress', mampatkan: 'compress', perkecil: 'shrink smaller', kecilkan: 'shrink smaller', kurangi: 'reduce', + kompres: 'compress', mampatkan: 'compress', perkecil: 'shrink smaller', kecilkan: 'shrink smaller', kecilin: 'shrink smaller', kurangi: 'reduce', kurangin: 'reduce', suara: 'audio', video: 'video', musik: 'audio', ubah: 'convert', konversi: 'convert', konversikan: 'convert', jadikan: 'convert', mengubah: 'convert', potong: 'trim cut', pangkas: 'trim crop', gabung: 'merge combine', gabungkan: 'merge combine', satukan: 'merge', From 4d65e0682bcc9f5264c35402c2dea3553e22acd1 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:06:24 +0700 Subject: [PATCH 07/11] fix(agent): broaden informal/gaul Bahasa keyword bridge (-in verbs, slang, ganti/change, misspellings) --- src/tools/agent/router.lib.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/tools/agent/router.lib.ts b/src/tools/agent/router.lib.ts index 60d47f4..01cf9f2 100644 --- a/src/tools/agent/router.lib.ts +++ b/src/tools/agent/router.lib.ts @@ -134,6 +134,21 @@ const ID_EN: Record = { terjemah: 'translate', terjemahkan: 'translate', ringkas: 'summarize', buat: 'make create', bikin: 'make create', buatkan: 'make create', gambarkan: 'draw', unduh: 'download', warna: 'color', diagram: 'diagram', ikon: 'icon', + // Informal / gaul: the -in suffix (kecilin, gabungin…), slang, and misspellings. + gedein: 'enlarge upscale bigger', gede: 'enlarge bigger', gedegin: 'enlarge', + ubahin: 'convert', jadiin: 'convert make', rubah: 'convert', ganti: 'convert change', + potongin: 'trim cut', pangkas: 'trim crop', pangkasin: 'trim crop', + gabungin: 'merge combine', satuin: 'merge', pisahin: 'split', pecah: 'split', + puterin: 'rotate', rotasiin: 'rotate', balik: 'rotate flip', + hapusin: 'remove delete', ilangin: 'remove delete', buang: 'remove delete', + buatin: 'make create', bikinin: 'make create', gambarin: 'draw', + kompresin: 'compress', mampatin: 'compress', kecilkin: 'shrink smaller', + ringkasin: 'summarize', ringkesin: 'summarize', terjemahin: 'translate', + amanin: 'encrypt protect', kunciin: 'password protect', enkripin: 'encrypt', + rapiin: 'format tidy', rapihin: 'format tidy', benerin: 'repair fix', perbaiki: 'repair fix', + vidio: 'video', vidionya: 'video', dokumen: 'document', dok: 'document', + angka: 'number', bilangan: 'number', tulisan: 'text', kalimat: 'sentence', paragraf: 'paragraph', + qr: 'qr code', barkode: 'barcode', sandiin: 'password', }; export function expandIndonesian(query: string): string { const extra: string[] = []; From 793ace77f5f6ccb0801d0c3973447f61b029572c Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:07:33 +0700 Subject: [PATCH 08/11] fix(agent): remove duplicate 'pangkas' key in the ID keyword bridge (lint) --- src/tools/agent/router.lib.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/agent/router.lib.ts b/src/tools/agent/router.lib.ts index 01cf9f2..5523043 100644 --- a/src/tools/agent/router.lib.ts +++ b/src/tools/agent/router.lib.ts @@ -137,7 +137,7 @@ const ID_EN: Record = { // Informal / gaul: the -in suffix (kecilin, gabungin…), slang, and misspellings. gedein: 'enlarge upscale bigger', gede: 'enlarge bigger', gedegin: 'enlarge', ubahin: 'convert', jadiin: 'convert make', rubah: 'convert', ganti: 'convert change', - potongin: 'trim cut', pangkas: 'trim crop', pangkasin: 'trim crop', + potongin: 'trim cut', pangkasin: 'trim crop', gabungin: 'merge combine', satuin: 'merge', pisahin: 'split', pecah: 'split', puterin: 'rotate', rotasiin: 'rotate', balik: 'rotate flip', hapusin: 'remove delete', ilangin: 'remove delete', buang: 'remove delete', From 1d5a5ac0affa2eae1c26cb94d9fae8c8c6a5911c Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:08:54 +0700 Subject: [PATCH 09/11] feat(agent): add ID prepositions ke/dari/jadi to the keyword bridge --- src/tools/agent/router.lib.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tools/agent/router.lib.ts b/src/tools/agent/router.lib.ts index 5523043..53efd5f 100644 --- a/src/tools/agent/router.lib.ts +++ b/src/tools/agent/router.lib.ts @@ -149,6 +149,7 @@ const ID_EN: Record = { vidio: 'video', vidionya: 'video', dokumen: 'document', dok: 'document', angka: 'number', bilangan: 'number', tulisan: 'text', kalimat: 'sentence', paragraf: 'paragraph', qr: 'qr code', barkode: 'barcode', sandiin: 'password', + ke: 'to', dari: 'from', jadi: 'to become', }; export function expandIndonesian(query: string): string { const extra: string[] = []; From ac0acf13251fdc0101d01e834909c6bdaae7d96e Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:18:26 +0700 Subject: [PATCH 10/11] feat(agent): add image-format-convert executor + fix ID phrase routing - New image-convert executor (png/jpg/webp/avif) backed by convertImage() in canvas.lib, mapped to the existing image-convert registry tool. - expandIndonesian now replaces ID keywords in place (not append) so format phrases like 'ganti gambar ke webp' stay adjacent for matchers. --- src/tools/agent/executors.test.ts | 5 +++++ src/tools/agent/executors.ts | 13 +++++++++++++ src/tools/agent/router.lib.ts | 8 ++++---- src/tools/image/canvas.lib.ts | 22 ++++++++++++++++++++++ 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/tools/agent/executors.test.ts b/src/tools/agent/executors.test.ts index 2a2ac53..4c37def 100644 --- a/src/tools/agent/executors.test.ts +++ b/src/tools/agent/executors.test.ts @@ -19,6 +19,11 @@ describe('executor registry', () => { it('scopes image-compress for an image request', () => { expect(scopeExecutors('compress this image to 100kb').map(e => e.toolId)).toContain('image-compress'); }); + it('scopes image-convert for a format-change request (not compress)', () => { + expect(scopeExecutors('convert this image to webp').map(e => e.toolId)).toContain('image-convert'); + expect(scopeExecutors('change this photo to png').map(e => e.toolId)).toContain('image-convert'); + expect(scopeExecutors('ganti gambar ini ke jpg').map(e => e.toolId)).toContain('image-convert'); + }); it('scopes audio-convert (not video/image) for "compress my mp3 to 3mb"', () => { const ids = scopeExecutors('compress my mp3 to 3mb').map(e => e.toolId); expect(ids).toContain('audio-convert'); diff --git a/src/tools/agent/executors.ts b/src/tools/agent/executors.ts index c1b0127..c895a76 100644 --- a/src/tools/agent/executors.ts +++ b/src/tools/agent/executors.ts @@ -544,6 +544,19 @@ export const AGENT_EXECUTORS: AgentExecutor[] = [ return { blob: r.blob, filename: 'compressed.jpg', text: `compressed to ${Math.round(r.blob.size / 1024)} KB` }; }, }, + { + toolId: 'image-convert', description: 'Convert an image to another format — png, jpg, or webp', + match: re(/(convert|change|turn|export|save|transcode).*(image|img|photo|picture).*\b(png|jpe?g|webp|avif)\b|(image|img|photo|picture).*\b(to|into|as)\b.*\b(png|jpe?g|webp|avif)\b|\b(to|into|as) ?(png|jpe?g|webp|avif)\b/i), + files: [{ key: 'file', accept: 'image/*', label: 'Image' }], + params: [{ key: 'format', type: 'string', label: 'Format (png/jpg/webp)', default: 'webp' }], + execute: async ({ files, params }) => { + const { convertImage } = await import('@/tools/image/canvas.lib'); + const fmt = String(params.format ?? 'webp').toLowerCase().replace('jpeg', 'jpg'); + const mime = ({ png: 'image/png', jpg: 'image/jpeg', webp: 'image/webp', avif: 'image/avif' } as Record)[fmt] || 'image/webp'; + const blob = await convertImage(files.file, mime); + return { blob, filename: `converted.${fmt}`, text: `converted to ${fmt.toUpperCase()} — ${Math.round(blob.size / 1024)} KB` }; + }, + }, { toolId: 'video-compress', description: 'Compress a video file to a target size in megabytes', match: re(/(video|vid|mp4|mov|mkv|movie|clip|footage|webm).*(compress|smaller|reduce|shrink|size|\bmb\b|\bkb\b)|(compress|smaller|reduce|shrink).*(video|vid|mp4|mov|mkv|movie|clip|footage|webm)/i), diff --git a/src/tools/agent/router.lib.ts b/src/tools/agent/router.lib.ts index 53efd5f..9c2d276 100644 --- a/src/tools/agent/router.lib.ts +++ b/src/tools/agent/router.lib.ts @@ -120,7 +120,7 @@ function scoreTool(tool: (typeof tools)[number], queryStems: string[]): number { } // Bahasa Indonesia → English keyword bridge, so the agent works on the /id/ site. -// Appended (not replaced) to the query before matching, so mixed EN/ID also works. +// Replaced in place (see expandIndonesian) so phrases stay adjacent for the matchers. const ID_EN: Record = { gambar: 'image', foto: 'photo', citra: 'image', gbr: 'image', kompres: 'compress', mampatkan: 'compress', perkecil: 'shrink smaller', kecilkan: 'shrink smaller', kecilin: 'shrink smaller', kurangi: 'reduce', kurangin: 'reduce', @@ -152,9 +152,9 @@ const ID_EN: Record = { ke: 'to', dari: 'from', jadi: 'to become', }; export function expandIndonesian(query: string): string { - const extra: string[] = []; - for (const w of query.toLowerCase().split(/[^a-z0-9]+/)) { const e = ID_EN[w]; if (e) extra.push(e); } - return extra.length ? `${query} ${extra.join(' ')}` : query; + // Replace ID words in place (not append) so phrases stay adjacent — "ganti + // video ke mp4" → "convert change video to mp4", which the format matchers need. + return query.split(/\b/).map(tok => ID_EN[tok.toLowerCase()] ?? tok).join(''); } /** Route a query to the most relevant tools plus any extracted parameters. */ diff --git a/src/tools/image/canvas.lib.ts b/src/tools/image/canvas.lib.ts index 4bb7b53..9f89b90 100644 --- a/src/tools/image/canvas.lib.ts +++ b/src/tools/image/canvas.lib.ts @@ -61,6 +61,28 @@ export async function encodeCanvas( ); } +/** Convert an image File to another raster format via a canvas. Browser-only. */ +export async function convertImage(file: File, mime: string, quality = 0.92): Promise { + const url = URL.createObjectURL(file); + try { + const img = await new Promise((resolve, reject) => { + const i = new Image(); + i.onload = () => resolve(i); + i.onerror = () => reject(new Error('could not load the image')); + i.src = url; + }); + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth || img.width; + canvas.height = img.naturalHeight || img.height; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('no 2d context'); + ctx.drawImage(img, 0, 0); + return await encodeCanvas(canvas, mime, mime === 'image/png' ? undefined : quality); + } finally { + URL.revokeObjectURL(url); + } +} + /** * Map a user-facing Scale percent (1–100) to a `fontScale` fraction of the * image's shorter side. Larger percents yield a bigger font and — because the From f900ab6aa6e9d469124d8908603b718f659f3f14 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:21:17 +0700 Subject: [PATCH 11/11] feat(agent): weak-model single-candidate shortcut, size display, cloud settings persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - On-device: when the keyword scope collapses to one tool, run it directly (seedArgs maps '1mb'/'100kb' onto targetMb/targetKb) instead of asking a 0.5B to emit JSON it often can't — fixes 'I didn't quite catch that' on clear tasks. - humanSize() reports sub-MB outputs in KB (no more '0 MB'); empty-blob guard on video-compress. - AskAgent persists the cloud preset/model/proxy + last-used tab to localStorage so reopening returns to the chosen provider instead of the OpenAI default. - Dev-only: pre-bundle xlsx/turndown/prettier/csso/terser in optimizeDeps so those tools stop 404-ing on first dynamic import locally (no prod effect). --- astro.config.mjs | 5 +++- src/hooks/useAgentChat.seed.test.ts | 23 +++++++++++++++++ src/hooks/useAgentChat.ts | 38 +++++++++++++++++++++++++++++ src/islands/agent/AskAgent.tsx | 23 +++++++++++------ src/tools/agent/executors.test.ts | 13 +++++++++- src/tools/agent/executors.ts | 9 ++++++- 6 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 src/hooks/useAgentChat.seed.test.ts diff --git a/astro.config.mjs b/astro.config.mjs index 5b46e98..ae3c67a 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -134,7 +134,10 @@ export default defineConfig({ // Vite doesn't discover them mid-request and force a reload that makes // in-flight dynamic imports fail ("Failed to fetch dynamically imported // module"). pdfjs worker is excluded — it's loaded via ?url. - include: ['pdf-lib', 'pdfjs-dist', 'marked', 'dompurify', 'qrcode', 'jsqr', 'comlink', 'fflate', 'gifenc', 'yaml', 'fast-xml-parser', 'smol-toml', 'hash-wasm', 'highlight.js/lib/core', 'highlight.js/lib/languages/json', 'highlight.js/lib/languages/yaml', 'highlight.js/lib/languages/xml', 'highlight.js/lib/languages/ini', '@imgly/background-removal', '@mediapipe/tasks-vision', 'upscaler', '@tensorflow/tfjs'], + include: ['pdf-lib', 'pdfjs-dist', 'marked', 'dompurify', 'qrcode', 'jsqr', 'comlink', 'fflate', 'gifenc', 'yaml', 'fast-xml-parser', 'smol-toml', 'hash-wasm', 'xlsx', 'turndown', 'highlight.js/lib/core', 'highlight.js/lib/languages/json', 'highlight.js/lib/languages/yaml', 'highlight.js/lib/languages/xml', 'highlight.js/lib/languages/ini', '@imgly/background-removal', '@mediapipe/tasks-vision', 'upscaler', '@tensorflow/tfjs', + // Pure-JS deps only reached via dynamic import (Code Beautifier / CSS tools) — + // pre-bundle so they don't 404 on first use in dev. No effect on prod bundling. + 'csso', 'terser', 'prettier/standalone', 'prettier/plugins/babel', 'prettier/plugins/estree', 'prettier/plugins/html', 'prettier/plugins/markdown', 'prettier/plugins/postcss', 'prettier/plugins/typescript', 'prettier/plugins/yaml'], // mupdf is a large wasm module used only inside a worker — don't pre-bundle it. // @tauri-apps/api must be excluded - it's only available in Tauri runtime exclude: ['pdfjs-dist/build/pdf.worker.min.mjs', 'mupdf', 'libarchive.js', 'onnxruntime-web', '@ffmpeg/ffmpeg', '@ffmpeg/util', '@sqlite.org/sqlite-wasm', '@tauri-apps/api'], diff --git a/src/hooks/useAgentChat.seed.test.ts b/src/hooks/useAgentChat.seed.test.ts new file mode 100644 index 0000000..f3a13b7 --- /dev/null +++ b/src/hooks/useAgentChat.seed.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { seedArgs } from './useAgentChat'; +import { executorFor } from '@/tools/agent/executors'; + +describe('seedArgs — deterministic single-candidate arg seeding', () => { + it('maps "1mb" onto a targetMb param (video-compress)', () => { + const exec = executorFor('video-compress')!; + expect(seedArgs(exec, 'compress this video to 1mb').targetMb).toBe(1); + expect(seedArgs(exec, 'compress this video to 25mb').targetMb).toBe(25); + }); + it('maps "100kb" onto a targetKb param (image-compress)', () => { + const exec = executorFor('image-compress')!; + expect(seedArgs(exec, 'compress this image to 100kb').targetKb).toBe(100); + }); + it('converts MB→KB when the param is targetKb', () => { + const exec = executorFor('image-compress')!; + expect(seedArgs(exec, 'compress this image to 1mb').targetKb).toBe(1024); + }); + it('returns no numeric arg when the message has no size (falls back to default)', () => { + const exec = executorFor('video-compress')!; + expect(seedArgs(exec, 'compress this video').targetMb).toBeUndefined(); + }); +}); diff --git a/src/hooks/useAgentChat.ts b/src/hooks/useAgentChat.ts index 58607f9..caf30c9 100644 --- a/src/hooks/useAgentChat.ts +++ b/src/hooks/useAgentChat.ts @@ -41,6 +41,34 @@ function guessContent(q: string): string { return residual ? residual.trim() : ''; } +/** + * Deterministically seed an executor's args from the query when we bypass the + * model. A tiny on-device model often can't emit valid JSON, but a single scoped + * candidate means the tool is already certain — so map a parsed size/number onto + * the matching numeric param (targetKb / targetMb) and any text onto a content + * param, and let runExecutor fill the rest (files, defaults, prompts). + */ +export function seedArgs(exec: (typeof AGENT_EXECUTORS)[number], q: string): Record { + const p = extractParams(q); + const out: Record = {}; + for (const ps of exec.params) { + const k = ps.key.toLowerCase(); + if (ps.type === 'number') { + if (p.size) { + const kb = p.size.unit === 'MB' ? p.size.value * 1024 : p.size.unit === 'GB' ? p.size.value * 1024 * 1024 : p.size.value; + if (k.includes('kb')) out[ps.key] = Math.round(kb); + else if (k.includes('mb')) out[ps.key] = Math.round((kb / 1024) * 100) / 100; + else out[ps.key] = p.size.value; + } else if (p.number !== undefined) { + out[ps.key] = p.number; + } + } else if (p.text) { + out[ps.key] = p.text; + } + } + return out; +} + /** * Orchestrates one agent conversation over any `AgentProvider`: * intent gate (chat / open / task) → runtime-scoped agentic loop → session. @@ -218,6 +246,16 @@ export function useAgentChat(provider: AgentProvider | null) { } }; + // Deterministic shortcut for tiny models: when the keyword scope collapses + // to a SINGLE tool, the route is already certain — run it directly instead + // of asking a 0.5B to emit JSON (which it frequently can't, producing an + // "I didn't quite catch that"). Args are seeded from the message; files come + // from the attachment/upload path. Capable cloud models still plan + chain. + if (!capable && offered.length === 1) { + await runExecutor(offered[0], seedArgs(offered[0], q)); + return; + } + // --- Native function-calling loop (cloud): the provider's real tools API --- const chatTools = provider.chatTools; if (chatTools) { diff --git a/src/islands/agent/AskAgent.tsx b/src/islands/agent/AskAgent.tsx index eae65c9..754a3e1 100644 --- a/src/islands/agent/AskAgent.tsx +++ b/src/islands/agent/AskAgent.tsx @@ -6,6 +6,12 @@ import { type AgentProvider, type OnDeviceProvider, } from '@/services/agent/provider'; +// Persist the last-used cloud settings so reopening the panel returns to the +// provider/model/proxy the user picked (the API key already persists) instead of +// snapping back to the OpenAI default — faster to get back to chatting. +const ls = (k: string): string | null => (typeof localStorage !== 'undefined' ? localStorage.getItem(k) : null); +const save = (k: string, v: string) => { if (typeof localStorage !== 'undefined') localStorage.setItem(k, v); }; + const TR = { en: { h1: 'Ask Agent', @@ -21,11 +27,12 @@ const TR = { export default function AskAgent({ lang = 'en' }: { lang?: 'en' | 'id' }) { const tr = TR[lang] ?? TR.en; - const [source, setSource] = useState<'ondevice' | 'cloud'>('ondevice'); + const [source, setSource] = useState<'ondevice' | 'cloud'>(() => (ls('gwt-agent-source') === 'cloud' ? 'cloud' : 'ondevice')); const [ondeviceModel, setOndeviceModel] = useState(ONDEVICE_MODELS[0].id); - const [cloudPreset, setCloudPreset] = useState('openai'); - const [cloudModel, setCloudModel] = useState(CLOUD_PRESETS.openai.model); - const [useProxy, setUseProxy] = useState(!!CLOUD_PRESETS.openai.proxied); + const initPreset = (() => { const s = ls('gwt-agent-cloud-preset'); return s && CLOUD_PRESETS[s] ? s : 'openai'; })(); + const [cloudPreset, setCloudPreset] = useState(initPreset); + const [cloudModel, setCloudModel] = useState(() => ls('gwt-agent-cloud-model') || CLOUD_PRESETS[initPreset].model); + const [useProxy, setUseProxy] = useState(() => { const v = ls('gwt-agent-cloud-proxy'); return v == null ? !!CLOUD_PRESETS[initPreset].proxied : v === '1'; }); const [apiKey, setApiKey] = useState(() => (typeof localStorage !== 'undefined' ? localStorage.getItem('gwt-agent-key') || '' : '')); const [provider, setProvider] = useState(null); const [loading, setLoading] = useState(false); @@ -90,7 +97,7 @@ export default function AskAgent({ lang = 'en' }: { lang?: 'en' | 'id' }) {
{(['ondevice', 'cloud'] as const).map(s => ( - @@ -119,15 +126,15 @@ export default function AskAgent({ lang = 'en' }: { lang?: 'en' | 'id' }) { : '⚠ Cloud mode sends your conversation to the provider using your key (direct from your browser, no GoodWebTools server). On-device keeps everything private.'}

- { const k = e.target.value; setCloudPreset(k); setCloudModel(CLOUD_PRESETS[k].model); setUseProxy(!!CLOUD_PRESETS[k].proxied); save('gwt-agent-cloud-preset', k); save('gwt-agent-cloud-model', CLOUD_PRESETS[k].model); save('gwt-agent-cloud-proxy', CLOUD_PRESETS[k].proxied ? '1' : '0'); setProvider(null); }} className={inputCls}> {Object.entries(CLOUD_PRESETS).map(([k, v]) => )} - setCloudModel(e.target.value)} placeholder="model" className={`${inputCls} w-48`} /> + { setCloudModel(e.target.value); save('gwt-agent-cloud-model', e.target.value); }} placeholder="model" className={`${inputCls} w-48`} /> setApiKey(e.target.value)} type="password" placeholder="API key" className={`${inputCls} w-56`} />
{progressText && {progressText}} diff --git a/src/tools/agent/executors.test.ts b/src/tools/agent/executors.test.ts index 4c37def..da273e0 100644 --- a/src/tools/agent/executors.test.ts +++ b/src/tools/agent/executors.test.ts @@ -1,5 +1,16 @@ import { describe, it, expect } from 'vitest'; -import { AGENT_EXECUTORS, scopeExecutors, executorFor, unknownExecutorIds, duplicateExecutorIds } from './executors'; +import { AGENT_EXECUTORS, scopeExecutors, executorFor, unknownExecutorIds, duplicateExecutorIds, humanSize } from './executors'; + +describe('humanSize', () => { + it('shows KB under 1 MB (never "0 MB")', () => { + expect(humanSize(40 * 1024)).toBe('40 KB'); + expect(humanSize(500)).toBe('1 KB'); // sub-KB clamps to 1, not 0 + }); + it('shows MB at/above 1 MB', () => { + expect(humanSize(1024 * 1024)).toBe('1 MB'); + expect(humanSize(Math.round(2.5 * 1024 * 1024))).toBe('2.5 MB'); + }); +}); describe('executor registry', () => { it('every executor maps to a real tool and declares a match fn', () => { diff --git a/src/tools/agent/executors.ts b/src/tools/agent/executors.ts index c895a76..a24ea5c 100644 --- a/src/tools/agent/executors.ts +++ b/src/tools/agent/executors.ts @@ -35,6 +35,12 @@ export interface AgentExecutor { const re = (r: RegExp) => (q: string) => r.test(q); +/** Human-friendly byte size: KB under 1 MB (so a small output never reads "0 MB"), MB above. */ +export function humanSize(bytes: number): string { + const kb = bytes / 1024; + return kb >= 1024 ? `${Math.round((kb / 1024) * 10) / 10} MB` : `${Math.max(1, Math.round(kb))} KB`; +} + export const AGENT_EXECUTORS: AgentExecutor[] = [ { toolId: 'base64', description: 'Encode or decode Base64 text', match: re(/base64|b64/i), @@ -577,7 +583,8 @@ export const AGENT_EXECUTORS: AgentExecutor[] = [ maxWidth: Number(params.maxWidth) || 0, audioKbps: keepAudio ? 128 : 0, }, p => onProgress?.(p)); - return { blob, filename: 'compressed.mp4', text: `compressed to ${Math.round(blob.size / 1024 / 1024 * 10) / 10} MB` }; + if (blob.size === 0) throw new Error('compression produced an empty file — try a larger target size'); + return { blob, filename: 'compressed.mp4', text: `compressed to ${humanSize(blob.size)}` }; }, }, {