feat(adapters): rasterize SVG attachments to PNG and auto-attach written SVGs

This commit is contained in:
cynarAI 2026-10-22 13:33:55 +02:00
parent fe80edbf52
commit 78827e0a5b
6 changed files with 126 additions and 9 deletions

View File

@ -207,7 +207,7 @@ export const builtinAgentTools: ConnectorTool[] = [
{
name: "attach_file",
description:
"Attach a workspace file from this bot's home to the chat thread as an image, video, audio or common file. The file stays in place; users can open or play it from the message. Set present when the file is the result the user asked for, so it opens beside the conversation.",
"Attach a workspace file from this bot's home to the chat thread as an image (including .svg, .png, .jpg, .webp, .gif), video, audio or common file (.pdf, .md, .txt, .csv, .json, .zip). The file stays in place; users can open or play it from the message. Set present when the file is the result the user asked for, so it opens beside the conversation.",
inputSchema: {
type: "object",
properties: {

View File

@ -305,4 +305,14 @@ describe("formatWorkingPrinciples", () => {
expect(withImage).toContain("continue_after true");
expect(withImage).toContain("attachments/<artifactId>.<ext>");
});
it("instructs attaching visual and document deliverables when attach_file is available", () => {
const withoutAttach = formatWorkingPrinciples(new Set(["shell"]));
expect(withoutAttach).not.toContain("attach_file");
const withAttach = formatWorkingPrinciples(new Set(["attach_file"]));
expect(withAttach).toContain("attach_file");
expect(withAttach).toContain("chat gallery and preview pane");
expect(withAttach).toContain("`#C41E5C`");
});
});

View File

@ -59,6 +59,9 @@ export function formatWorkingPrinciples(toolNames: ReadonlySet<string>): string
toolNames.has("generate_image")
? "- When a task calls for photography, key visuals, moodboards, product shots, campaign imagery, textures, or raster brand assets, generate real images with generate_image (set continue_after true when the files feed into a website, brand book, or next step, where they land on your computer in attachments/<artifactId>.<ext>) instead of hand-coding placeholder or clipart SVGs. Reserve hand-crafted SVG for crisp typographic wordmarks, monograms, geometric emblems, and icons."
: undefined,
toolNames.has("attach_file")
? "- When you create or export visual assets, logos, templates, documents, or reports (such as .svg, .png, .jpg, .webp, .pdf, or .md files) for the user, attach the finished deliverables to the conversation with attach_file (setting present true on the primary deliverable) so they render in the chat gallery and preview pane — never just list workspace or server file paths as plain text. Write hex color codes in backticks (e.g. `#C41E5C`) so inline color swatches render."
: undefined,
"- Say something is done only after a tool confirmed it: read back what you wrote, run what you built, check what you changed. When something fails, find the cause instead of working around it, say what failed, and try another way.",
"- When a request has a real problem, say so and propose the better option, then follow the user's decision.",
toolNames.has("skill_read")
@ -157,7 +160,7 @@ export function formatMainAgentRole(
const projectName = project?.trim() ? escapePromptData(project.trim()) : undefined;
return [
projectName
? `You are the project coordinator for the project "${projectName}". You do not execute tasks yourself; you only dispatch, delegate, and coordinate tasks with the appropriate specialists. When a specialist delivers, tell the user what it means for them.`
? `You are the project coordinator for the project "${projectName}". You do not execute tasks yourself; you only dispatch, delegate, and coordinate tasks with the appropriate specialists. When a specialist delivers, tell the user what it means for them, preserving exact URLs, IP addresses, and backticked hex color codes (\`#RRGGBB\`) verbatim without retyping or altering them.`
: "You are the user's main agent: the one they talk to first and the one who knows them best.",
projectName
? "Keep the overview of the project and delegate work to specialist bots instead of doing tasks yourself."

View File

@ -1882,6 +1882,8 @@ export function createRunExecutor(deps: ExecutorDeps) {
const mutatingEffectOccurrences = new Map<string, number>();
const consumedEffectIds = new Set<string>();
const writtenSvgFiles = new Map<string, { filePath: string; content: Uint8Array }>();
const attachedPaths = new Set<string>();
const nextMutatingEffectOccurrence = (toolName: string, args: Record<string, unknown>) => {
const fingerprint = toolEffectIdempotencyKey(runId, toolName, args);
const occurrence = mutatingEffectOccurrences.get(fingerprint) ?? 0;
@ -2578,15 +2580,20 @@ export function createRunExecutor(deps: ExecutorDeps) {
if (name === "write_file") {
const filePath = String(args.path ?? "notes/result.txt");
const content = textContentArg(args.content, "");
const storedPath = resolveStoredWorkspacePath(filePath);
const encoded = new TextEncoder().encode(content);
workspaceCheckpoint.markDirty();
await deps.sandbox.writeFile(
computer,
{
path: resolveStoredWorkspacePath(filePath),
content: new TextEncoder().encode(content),
path: storedPath,
content: encoded,
},
context,
);
if (/\.svg$/i.test(filePath) && /<svg[\s>]/i.test(content)) {
writtenSvgFiles.set(storedPath, { filePath, content: encoded });
}
return finish({ ok: true, path: filePath });
}
if (name === "render_plot") {
@ -2717,7 +2724,8 @@ export function createRunExecutor(deps: ExecutorDeps) {
return finish({ error: "artifact storage unavailable", path: filePath });
}
const storedPath = resolveStoredWorkspacePath(filePath);
const mimeType = inferAttachmentMimeType(filePath);
const isSvg = /\.svg$/i.test(filePath);
const mimeType = isSvg ? "image/png" : inferAttachmentMimeType(filePath);
if (!mimeType) {
return finish({ error: "unsupported attachment type", path: filePath });
}
@ -2753,6 +2761,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
present: args.present === true,
},
);
attachedPaths.add(storedPath);
await publishMessage(deps, run, "bot", [attached.block]);
return finish({ ok: true, artifactId: attached.artifactId, path: filePath });
} catch (error) {
@ -4906,6 +4915,36 @@ export function createRunExecutor(deps: ExecutorDeps) {
}
}
if (exposedToolNames.has("attach_file") && deps.artifacts && writtenSvgFiles.size > 0) {
for (const [storedPath, svgFile] of writtenSvgFiles) {
const pngStoredPath = storedPath.replace(/\.svg$/i, ".png");
if (attachedPaths.has(storedPath) || attachedPaths.has(pngStoredPath)) continue;
try {
const attached = await attachWorkspaceFileToThread(
{ prisma: deps.prisma, artifacts: deps.artifacts },
{
spaceId: run.spaceId,
userId: run.userId,
botId: bot.id,
groupId: thread.groupId ?? undefined,
runId: run.id,
filePath: svgFile.filePath,
content: svgFile.content,
operationId: `auto-attach:${run.id}:${storedPath}`,
signal: context.signal,
},
);
attachedPaths.add(storedPath);
await publishMessage(deps, run, "bot", [attached.block]);
} catch (error) {
getLogger().warn("auto-attach svg failed", {
path: svgFile.filePath,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
await workspaceCheckpoint.flush();
terminalCheckpointComplete = true;

View File

@ -112,6 +112,58 @@ describe("current-turn thread files", () => {
expect(attached.block).not.toHaveProperty("present");
});
it("rasterizes SVG workspace deliverables to PNG image blocks so they render in the chat gallery", async () => {
const create = vi.fn(
async ({ data }: { data: { name: string; mimeType: string; size: number } }) => ({
id: "artifact-svg-1",
...data,
}),
);
const putStream = vi.fn(async (put: ArtifactStreamPut) => {
let total = 0;
for await (const chunk of byteChunks(put.stream)) {
total += chunk.byteLength;
}
return { key: "stored-svg-1", size: total, sha256: "hash-svg" };
});
const deps = {
prisma: { artifact: { create } } as unknown as PrismaClient,
artifacts: {
describe: () => ({ capabilities: { stream: true } }),
putStream,
remove: vi.fn(),
} as unknown as ArtifactStore,
};
const svgContent = new TextEncoder().encode(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120"><circle cx="60" cy="60" r="50" fill="#C41E5C"/></svg>',
);
const attached = await attachWorkspaceFileToThread(deps, {
spaceId: "workspace-1",
userId: "user-1",
botId: "bot-1",
runId: "run-1",
filePath: "konzept-final/logo_v1_typo.svg",
content: svgContent,
operationId: "attach-svg-1",
});
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
name: "logo_v1_typo.png",
mimeType: "image/png",
}),
}),
);
expect(attached.block).toMatchObject({
kind: "image",
artifactId: "artifact-svg-1",
name: "logo_v1_typo.png",
mimeType: "image/png",
});
});
it("copies non-image attachments into the bot workspace and describes their paths", async () => {
const findMany = vi.fn().mockResolvedValue([
{

View File

@ -15,7 +15,7 @@ import {
} from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
import { storeArtifactStream } from "./artifact-files.js";
import { discardByteStream } from "./byte-streams.js";
import { collectBytes, discardByteStream } from "./byte-streams.js";
import {
assertTeamWorkspacePathAllowed,
computerFileWriteMaxBytes,
@ -67,16 +67,29 @@ export async function attachWorkspaceFileToThread(
},
): Promise<{ artifactId: string; block: AttachmentBlock }> {
const content = input.content;
const stream =
const rawName = path.basename(input.filePath) || input.filePath;
const isSvg = /\.svg$/i.test(rawName);
let stream =
content instanceof Uint8Array
? (async function* () {
yield content;
})()
: content;
const name = path.basename(input.filePath) || input.filePath;
let name = rawName;
let row: Awaited<ReturnType<typeof storeArtifactStream>>;
try {
const mimeType = inferAttachmentMimeType(name);
let mimeType = inferAttachmentMimeType(name);
if (isSvg) {
const svgBytes = content instanceof Uint8Array ? content : await collectBytes(stream);
const { default: sharp } = await import("sharp");
const pngBuffer = await sharp(Buffer.from(svgBytes), { density: 144 }).png().toBuffer();
const pngBytes = new Uint8Array(pngBuffer.buffer, pngBuffer.byteOffset, pngBuffer.byteLength);
name = rawName.replace(/\.svg$/i, ".png");
mimeType = "image/png";
stream = (async function* () {
yield pngBytes;
})();
}
if (!mimeType) {
throw new Error(`Unsupported attachment type for ${name}`);
}