Let bots set a picture from a file they can see
A coordinator asked to set an uploaded logo as a workspace picture could not: the picture tools take a media id, and nothing ever gave the bot one. Attachments reached the model as "[Attached image \"logo.png\"]", every file tool answers with a [file:ID] ref, and an SVG is deliberately stored as a document rather than an image. - copyMediaAsPicture copies an approved image into a new picture row (bot-image/workspace-image). The source keeps its row and bytes, so a message that attached it still loads and changing a picture can never take a file away from a conversation. - grabPictureSource resolves a picture by [file:ID] (the same scope gate every file tool uses) or by the user's own upload; parseFileRef reads the [file:12] form. - The new set_picture tool ships on every run: a bot sets its own face, the coordinator can also dress a worker. update_workspace gained picture_file_id, and setBotPicture/setProfileImage share the copy and the delete. - removeReplacedPicture deletes a replaced picture only when it really is one, never a chat upload. - uploadAndRecordMedia writes images in their own format: PNG stays PNG with its transparency, WebP stays WebP, GIF becomes PNG, everything else JPEG, and the row records the real mime type (it used to say jpeg for every image). - Pictures change live: BotUpdatedData carries image_media_id and a new server:workspace-updated pushes workspace pictures, relayed between processes. - Attachments are labelled with their media id, and BotFilesNote tells a bot that a [file:ID] can become a picture. - grabRunScopes is now the single source of a run's reachable folders, shared by the file tools, imports and the picture tools.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import imageOutputFormat from "./image-output-format";
|
||||
|
||||
describe("imageOutputFormat", () => {
|
||||
test("keeps a PNG a PNG, so its transparency survives", () => {
|
||||
expect(imageOutputFormat({ ext: "png" })).toEqual({
|
||||
format: "png",
|
||||
ext: "png",
|
||||
mime_type: "image/png",
|
||||
});
|
||||
expect(imageOutputFormat({ ext: "logo.PNG" }).format).toBe("png");
|
||||
});
|
||||
|
||||
test("keeps WebP a WebP", () => {
|
||||
expect(imageOutputFormat({ ext: "webp" })).toEqual({
|
||||
format: "webp",
|
||||
ext: "webp",
|
||||
mime_type: "image/webp",
|
||||
});
|
||||
});
|
||||
|
||||
test("stores a GIF as a PNG, which keeps transparency", () => {
|
||||
expect(imageOutputFormat({ ext: "gif" })).toEqual({
|
||||
format: "png",
|
||||
ext: "png",
|
||||
mime_type: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
test("stores anything else as JPEG, as it always has", () => {
|
||||
for (const ext of ["jpeg", "jpg", "bmp", "tiff", "", null, undefined, "weird"]) {
|
||||
expect(imageOutputFormat({ ext })).toEqual({
|
||||
format: "jpeg",
|
||||
ext: "jpeg",
|
||||
mime_type: "image/jpeg",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("reads the extension out of a name", () => {
|
||||
expect(imageOutputFormat({ ext: "summit-logo.png" }).format).toBe("png");
|
||||
expect(imageOutputFormat({ ext: "media/2/thing.webp" }).format).toBe("webp");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
type Params = {
|
||||
/**
|
||||
* The source's extension or name, e.g. `png` or `logo.png`.
|
||||
*/
|
||||
ext?: string | null;
|
||||
};
|
||||
|
||||
export type ImageOutputFormat = {
|
||||
/**
|
||||
* What sharp writes with: `png`, `webp` or `jpeg`.
|
||||
*/
|
||||
format: "png" | "webp" | "jpeg";
|
||||
/**
|
||||
* The extension the stored file gets.
|
||||
*/
|
||||
ext: string;
|
||||
/**
|
||||
* The MIME type recorded on the `media` row.
|
||||
*/
|
||||
mime_type: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* What an uploaded image is stored as.
|
||||
*
|
||||
* Pictures (a bot's or a workspace's) keep the format they came in, so a
|
||||
* transparent PNG stays a PNG with its transparency: forcing every image
|
||||
* through JPEG flattened a logo's clear corners onto black. A GIF is stored
|
||||
* as a PNG, which keeps its transparency.
|
||||
*
|
||||
* Everything else (a photo, a screenshot, an attachment) stays JPEG, which
|
||||
* is what it has always been.
|
||||
*/
|
||||
export default function imageOutputFormat({ ext }: Params = {}): ImageOutputFormat {
|
||||
const source = (ext || "")
|
||||
.split(".")
|
||||
.pop()
|
||||
?.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, "") || "";
|
||||
|
||||
if (source == "png") return { format: "png", ext: "png", mime_type: "image/png" };
|
||||
if (source == "webp") return { format: "webp", ext: "webp", mime_type: "image/webp" };
|
||||
if (source == "gif") return { format: "png", ext: "png", mime_type: "image/png" };
|
||||
|
||||
return { format: "jpeg", ext: "jpeg", mime_type: "image/jpeg" };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import parseFileRef from "./parse-file-ref";
|
||||
|
||||
describe("parseFileRef", () => {
|
||||
test("reads the ref a bot copies out of a tool result", () => {
|
||||
expect(parseFileRef({ ref: "[file:12]" })).toBe(12);
|
||||
expect(parseFileRef({ ref: " [file: 7] " })).toBe(7);
|
||||
expect(parseFileRef({ ref: "[FILE:3]" })).toBe(3);
|
||||
});
|
||||
|
||||
test("takes a bare id as text or as a number", () => {
|
||||
expect(parseFileRef({ ref: "42" })).toBe(42);
|
||||
expect(parseFileRef({ ref: 42 })).toBe(42);
|
||||
});
|
||||
|
||||
test("refuses anything that isn't a positive whole number", () => {
|
||||
for (const ref of ["", " ", "abc", "[file:abc]", "0", "-2", 0, -2, 1.5, "[file:1.5]", null, undefined]) {
|
||||
expect(parseFileRef({ ref })).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("refuses a ref that got pasted with other text around it", () => {
|
||||
expect(parseFileRef({ ref: "logo.png [file:12]" })).toBeNull();
|
||||
expect(parseFileRef({ ref: "[file:12][file:13]" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
type Params = {
|
||||
/**
|
||||
* A file ref as a bot writes it (`[file:12]`), an id as text (`"12"`),
|
||||
* or a number.
|
||||
*/
|
||||
ref?: string | number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The file id a `[file:ID]` ref names, so a tool can take the ref a bot
|
||||
* copied out of `list_files` instead of asking it for a bare number.
|
||||
* Null when it names nothing usable.
|
||||
*/
|
||||
export default function parseFileRef({ ref }: Params): number | null {
|
||||
if (typeof ref == "number") return Number.isInteger(ref) && ref > 0 ? ref : null;
|
||||
|
||||
if (typeof ref != "string") return null;
|
||||
|
||||
const text = ref.trim();
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
const marked = text.match(/^\[file:\s*(\d+)\s*\]$/i)?.[1];
|
||||
const bare = marked || (/^\d+$/.test(text) ? text : "");
|
||||
|
||||
if (!bare) return null;
|
||||
|
||||
const id = Number(bare);
|
||||
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
@@ -40,11 +40,15 @@ export default function resolveWorkspacePicture({ requested, current, media }: P
|
||||
if (current != null && Number(current) == id) return undefined;
|
||||
|
||||
if (!media || Number(media.id) != id) {
|
||||
throw new AppError(`That picture isn't one of yours. Upload it again.`);
|
||||
throw new AppError(
|
||||
`That picture isn't one you can use. Name a PNG, JPG, WebP or GIF in your files by its [file:ID] ref, or upload it in chat.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (media.media_type != "image") {
|
||||
throw new AppError(`A workspace picture has to be an image.`);
|
||||
throw new AppError(
|
||||
`A workspace picture has to be an image. This one is a ${media.media_type || "file"}: an SVG or a document is turned into a PNG first, then that PNG is set.`,
|
||||
);
|
||||
}
|
||||
|
||||
return id;
|
||||
|
||||
Reference in New Issue
Block a user