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
5 changes: 5 additions & 0 deletions src/tools/agent/executors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ 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('does not scope any media compressor for small talk', () => {
expect(scopeExecutors('hello how are you today')).toEqual([]);
});
Expand Down
14 changes: 14 additions & 0 deletions src/tools/agent/executors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,20 @@ 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' };
},
},
{
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
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,/);
});
});
34 changes: 34 additions & 0 deletions src/tools/image/svg-gen.lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Sanitize LLM-generated SVG before it's rendered/downloaded. The agent lets a
* capable model "draw" icons/diagrams by emitting SVG markup; that markup is
* untrusted (could carry <script>, event handlers, or foreignObject), so we
* extract the <svg> element and run it through DOMPurify's SVG profile. Pure and
* unit-testable (DOMPurify runs against jsdom in tests, the real DOM at runtime).
*/
import DOMPurify from 'dompurify';

/** Pull the first <svg>…</svg> out of a reply, tolerating markdown code fences. */
export function extractSvg(input: string): string {
const unfenced = input.replace(/```(?:svg|xml|html)?/gi, '');
const m = unfenced.match(/<svg[\s\S]*?<\/svg>/i);
return m ? m[0] : '';
}

/**
* Return a safe SVG string, or '' if the input isn't valid SVG. Strips scripts,
* event handlers and foreignObject via DOMPurify's SVG profile.
*/
export function sanitizeSvg(input: string): string {
const raw = extractSvg(input);
if (!raw) return '';
const clean = DOMPurify.sanitize(raw, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ['use'],
});
return /<svg[\s>]/i.test(clean) ? clean : '';
}

/** SVG string → a `data:` URL that renders in an <img>. */
export function svgToDataUrl(svg: string): string {
return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg);
}
Loading