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.
131 lines
4.3 KiB
TypeScript
131 lines
4.3 KiB
TypeScript
import BunSQLite from "@moduletrace/bun-sqlite";
|
|
import type {
|
|
BUN_SQLITE_COBBLE_MEDIA,
|
|
BUN_SQLITE_COBBLE_WORKSPACES,
|
|
BunSQLiteTables,
|
|
} from "@/db/types/db";
|
|
import AppError from "@/src/utils/app-error";
|
|
import assertPromptLength from "@/src/utils/assert-prompt-length";
|
|
import grabUserWorkspace from "./grab-user-workspace";
|
|
import _ from "lodash";
|
|
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
|
|
import copyMediaAsPicture from "@/src/functions/backend/db/profile-images/copy-media-as-picture";
|
|
import grabOwnPictureMedia from "@/src/functions/backend/db/profile-images/grab-own-picture-media";
|
|
import removeReplacedPicture from "@/src/functions/backend/db/profile-images/remove-replaced-picture";
|
|
import resolveWorkspacePicture from "@/src/utils/resolve-workspace-picture";
|
|
|
|
type Params = {
|
|
user_id: number | string;
|
|
workspace_id: number | string;
|
|
/**
|
|
* Only the fields given are changed.
|
|
*/
|
|
data: Pick<
|
|
BUN_SQLITE_COBBLE_WORKSPACES,
|
|
"name" | "description" | "system_prompt" | "archived"
|
|
> & {
|
|
/**
|
|
* A new picture (a `media` id of the user's own), null to remove
|
|
* it, or undefined to leave it alone.
|
|
*/
|
|
image_media_id?: number | null;
|
|
/**
|
|
* The image to copy for the picture, for a caller that already
|
|
* resolved one (a bot naming one of its files). Copied, so the
|
|
* source's row and bytes stay where they are.
|
|
*/
|
|
picture_source?: BUN_SQLITE_COBBLE_MEDIA | null;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Updates one of the user's workspaces. Its `system_prompt` is shared
|
|
* context for every bot in it, and `image_media_id` its picture: a media
|
|
* id replaces it (the old picture is deleted), null removes it, and
|
|
* `picture_source` copies an image into a new picture row first.
|
|
*/
|
|
export default async function updateWorkspace({
|
|
user_id,
|
|
workspace_id,
|
|
data,
|
|
}: Params) {
|
|
await assertCrewManager({ user_id });
|
|
|
|
assertPromptLength({ label: "System prompt", text: data.system_prompt });
|
|
|
|
const workspace = await grabUserWorkspace({ workspace_id, user_id });
|
|
|
|
if (!workspace?.id) {
|
|
throw new AppError(`Workspace not found.`);
|
|
}
|
|
|
|
const source = data.picture_source;
|
|
|
|
/**
|
|
* A source that is already the picture changes nothing: copying it
|
|
* again would only churn a row and delete the old one for no reason.
|
|
*/
|
|
const requested =
|
|
source?.id && Number(source.id) == Number(workspace.image_media_id)
|
|
? undefined
|
|
: source
|
|
? await copyMediaAsPicture({
|
|
media: source,
|
|
user_id,
|
|
target: "workspace",
|
|
title: `${workspace.name || "Workspace"} picture`,
|
|
})
|
|
: data.image_media_id;
|
|
|
|
const image_media_id = resolveWorkspacePicture({
|
|
requested,
|
|
current: workspace.image_media_id,
|
|
media:
|
|
requested == null
|
|
? null
|
|
: source
|
|
? { id: requested, media_type: "image" }
|
|
: await grabOwnPictureMedia({ media_id: requested, user_id }),
|
|
});
|
|
|
|
const res = await BunSQLite.update<
|
|
BUN_SQLITE_COBBLE_WORKSPACES,
|
|
(typeof BunSQLiteTables)[number]
|
|
>({
|
|
table: "workspaces",
|
|
targetId: workspace.id,
|
|
/**
|
|
* `BunSQLite.update` writes every key it gets, so a field left out
|
|
* here would be set to null.
|
|
*/
|
|
data: _.omitBy(
|
|
{
|
|
name: data.name,
|
|
description: data.description,
|
|
system_prompt: data.system_prompt,
|
|
archived: data.archived,
|
|
image_media_id,
|
|
},
|
|
_.isUndefined,
|
|
),
|
|
});
|
|
|
|
if (!res.success) {
|
|
throw new Error(`Couldn't update the workspace: ${res.msg}`);
|
|
}
|
|
|
|
/**
|
|
* The picture is written, so the one it replaced isn't shown anywhere
|
|
* any more. Only a picture is deleted (`removeReplacedPicture`): a row
|
|
* a chat message still attaches stays, so the message keeps loading.
|
|
*/
|
|
if (image_media_id !== undefined) {
|
|
await removeReplacedPicture({
|
|
previous: workspace.image_media_id,
|
|
next: image_media_id,
|
|
user_id,
|
|
target: "workspace",
|
|
});
|
|
}
|
|
}
|