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
7 changes: 6 additions & 1 deletion packages/contracts/src/attachments.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { Schema } from "effect";
import { uuidSchema } from "./uuid.ts";

const BYTES_PER_MEGABYTE = 1024 * 1024;

/** The largest file a person can attach to a message, in bytes. */
export const MAX_ATTACHMENT_BYTES = 30 * 1024 * 1024;
export const MAX_ATTACHMENT_BYTES = 30 * BYTES_PER_MEGABYTE;

/** `MAX_ATTACHMENT_BYTES` in megabytes, as people are told it. */
export const MAX_ATTACHMENT_MEGABYTES = MAX_ATTACHMENT_BYTES / BYTES_PER_MEGABYTE;

/** The most files one message can carry, and so the most a person can have uploaded and not yet sent. */
export const MAX_ATTACHMENTS_PER_MESSAGE = 20;
Expand Down
9 changes: 9 additions & 0 deletions packages/contracts/src/http/groups/chats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ export class ChatsApi extends HttpApiGroup.make("chats")
success: uploadedAttachmentSchema.pipe(HttpApiSchema.status(201)),
error: [BadRequest, PayloadTooLarge, Conflict, ...refused],
}),
/**
* Removes one of the caller's uploads before it is sent. Removing one
* already gone, sent, or someone else's changes nothing.
*/
HttpApiEndpoint.delete("removeFile", "/chats/:chatId/files/:fileId", {
params: { chatId: Schema.String, fileId: Schema.String },
success: HttpApiSchema.Empty(204),
error: refused,
}),
/** A file attached to a message in the chat, with its own media type. */
HttpApiEndpoint.get("file", "/chats/:chatId/files/:fileId", {
params: { chatId: Schema.String, fileId: Schema.String },
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/conversations/chats/chat-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ export interface Interface {
| BlobStore.Failure,
CurrentActor.Service
>;
/**
* Removes one of the actor's uploads to the chat before it is sent, such as
* one taken off the message they are writing. Anything else is left as it is.
*/
readonly removeUpload: (input: {
chatId: string;
fileId: string;
}) => Effect.Effect<void, ResourceHidden, CurrentActor.Service>;
/** A file attached to a message in the chat. An upload not yet sent is not found. */
readonly read: (input: {
chatId: string;
Expand Down Expand Up @@ -71,6 +79,15 @@ export const make = Effect.gen(function* () {
});
}),
),
removeUpload: ({ chatId, fileId }) =>
operation(
"removeUpload",
Effect.gen(function* () {
const { mainThreadId } = yield* visibility.chat(chatId);
const { userId } = yield* CurrentActor.Service;
yield* files.removeUnsentUpload({ threadId: mainThreadId, uploaderId: userId, fileId });
}),
),
read: ({ chatId, fileId }) =>
operation(
"read",
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/conversations/thread-files/thread-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,36 @@ describe.skipIf(!process.env.DATABASE_URL)("thread files, against Postgres", ()
await expect(upload(userId, "three.txt")).resolves.toMatchObject({ name: "three.txt" });
});

it("removes an unsent upload for its uploader only, and never a sent one", async () => {
const { threadId, userId } = await aThread();
const other = await aRoutineOwner();
const upload = (name: string) =>
files.upload({
threadId,
uploaderId: userId,
name,
maxSize: 1024,
maxUnsent: 20,
content: text(name),
});
const removed = await upload("removed.txt");
const kept = await upload("kept.txt");
const sent = await upload("sent.txt");
await send(threadId, userId, [sent.id]);
const rows = () =>
onDatabase((db) =>
db.select({ id: threadFile.id }).from(threadFile).where(eq(threadFile.threadId, threadId)),
);

await files.removeUnsentUpload({ threadId, uploaderId: other.userId, fileId: kept.id });
await files.removeUnsentUpload({ threadId, uploaderId: userId, fileId: sent.id });
await files.removeUnsentUpload({ threadId, uploaderId: userId, fileId: removed.id });
// Again, once it is gone.
await files.removeUnsentUpload({ threadId, uploaderId: userId, fileId: removed.id });

expect((await rows()).map(({ id }) => id).sort()).toEqual([kept.id, sent.id].sort());
});

it("removes uploads left unsent, with their blobs, and nothing else", async () => {
const { threadId, userId } = await aThread();
const upload = (name: string) =>
Expand Down
88 changes: 73 additions & 15 deletions packages/core/src/conversations/thread-files/thread-files.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
export * as ThreadFiles from "./thread-files.ts";

import { userText } from "@sugabots/errors";
import { type DomainError, userText } from "@sugabots/errors";
import { and, count, eq, inArray, isNotNull, lt, not, sql } from "drizzle-orm";
import { Config, Context, Data, Duration, Effect, Layer, Option, Schema, Stream } from "effect";
import {
Config,
Context,
Data,
Duration,
Effect,
ErrorReporter,
Layer,
Option,
Schema,
Stream,
} from "effect";
import { BlobStore } from "../../blob-store/blob-store.ts";
import { query, serviceOperations, transaction } from "../../database/database.ts";
import { threadFile } from "../../database/schema.ts";
Expand Down Expand Up @@ -72,6 +83,16 @@ export interface Interface {
}) => Effect.Effect<readonly AttachedFileRow[], UploadsNotFound>;
/** The files in the thread kept as tool calls' results: each call's file id, by the call's id. */
readonly resultFiles: (threadId: string) => Effect.Effect<ReadonlyMap<string, string>>;
/**
* Removes `fileId` when it is one of `uploaderId`'s unsent uploads in the
* thread, such as one taken off a message before it was sent. Anything
* else, a file already gone included, is left as it is.
*/
readonly removeUnsentUpload: (file: {
threadId: string;
uploaderId: string;
fileId: string;
}) => Effect.Effect<void>;
/**
* Removes every upload still unsent `unsentFor` after it was uploaded, and
* returns how many. A blob that cannot be removed is left to go with its
Expand Down Expand Up @@ -124,6 +145,16 @@ export const make = Effect.gen(function* () {
return { id, contentType, size };
});

/** Removes an unsent upload's blob, once its row is gone; one that cannot be removed goes with the thread's folder. */
const removeUploadBlob = (threadId: string, fileId: string) =>
blobs
.delete(fileKey(threadId, fileId))
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("Removing an unsent upload's blob failed", cause),
),
);

return Service.of({
write: ({ threadId, toolCallId, content }) =>
operation("write", keep(threadId, content, { kind: "tool_result", toolCallId })),
Expand Down Expand Up @@ -237,6 +268,29 @@ export const make = Effect.gen(function* () {
);
}),
),
removeUnsentUpload: ({ threadId, uploaderId, fileId }) =>
operation(
"removeUnsentUpload",
Effect.gen(function* () {
// The id comes from the uploader's client, and Postgres rejects a
// uuid comparison against anything that is not one.
if (!isUuid(fileId)) return;
const removed = yield* query((db) =>
db
.delete(threadFile)
.where(
and(
eq(threadFile.id, fileId),
eq(threadFile.threadId, threadId),
eq(threadFile.uploaderId, uploaderId),
unsentUpload,
),
)
.returning({ id: threadFile.id }),
);
if (removed.length > 0) yield* removeUploadBlob(threadId, fileId);
}),
),
removeUnsentUploads: (unsentFor) =>
operation(
"removeUnsentUploads",
Expand All @@ -256,18 +310,9 @@ export const make = Effect.gen(function* () {
)
.returning({ id: threadFile.id, threadId: threadFile.threadId }),
);
yield* Effect.forEach(
removed,
({ id, threadId }) =>
blobs
.delete(fileKey(threadId, id))
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("Removing an unsent upload's blob failed", cause),
),
),
{ discard: true },
);
yield* Effect.forEach(removed, ({ id, threadId }) => removeUploadBlob(threadId, id), {
discard: true,
});
return removed.length;
}),
),
Expand Down Expand Up @@ -323,7 +368,20 @@ export class FileNotFound
}
}

export class UploadsNotFound extends Data.TaggedError("UploadsNotFound") implements UserFacing {
/**
* A file to send is not one of the sender's unsent uploads, such as one cleared
* for waiting too long. A domain error, so a client can tell it apart by its
* tag rather than its wording.
*/
export class UploadsNotFound
extends Schema.TaggedError<UploadsNotFound>()("UploadsNotFound", {})
implements DomainError
{
readonly isRetryable = false;
override readonly [ErrorReporter.severity] = "Info" as const;
override get message() {
return "A file to send is not one of the sender's unsent uploads";
}
get userMessage() {
return userText`A file to send is no longer there. Remove it and attach it again`;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { defaultTokenStore, type TokenStore } from "./tokens.ts";

export type { AuthApi } from "./auth.ts";
export { isEmailUnverified, isResetLinkInvalid } from "./auth.ts";
export { failureForStatus, isApiFailure } from "./errors.ts";
export { failureForStatus, failureFromResponse, isApiFailure } from "./errors.ts";
export type { EventStream, EventStreamOptions, EventsApi } from "./events.ts";
export {
defaultTokenStore,
Expand Down
15 changes: 15 additions & 0 deletions packages/server/src/routes/chats/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,21 @@ describe("uploading a chat file", () => {
});
});

describe("removing an unsent upload", () => {
it("removes it for the person asking, answering with no content", async () => {
let removed: unknown;
const response = await app({
removeUpload: (input) => {
removed = input;
return Effect.void;
},
}).request(`/chats/${CHAT}/files/${FILE}`, { method: "DELETE", headers: signedIn });

expect(response.status).toBe(204);
expect(removed).toEqual({ chatId: CHAT, fileId: FILE });
});
});

describe("serving a chat file", () => {
it("shows an image in place, as its own type and never sniffed for another", async () => {
const response = await serving({
Expand Down
5 changes: 5 additions & 0 deletions packages/server/src/routes/chats/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ export const chatRoutes = HttpApiBuilder.group(ServerApi, "chats", (handlers) =>
});
}).pipe(Effect.catchTags(storeOutagesAsDefects), asSessionUser, asHttpError(fileErrors)),
)
.handle("removeFile", ({ params }) =>
files
.removeUpload({ chatId: params.chatId, fileId: params.fileId })
.pipe(asSessionUser, asHttpError(refusals)),
)
.handle("file", ({ params }) =>
files
.read({ chatId: params.chatId, fileId: params.fileId })
Expand Down
20 changes: 20 additions & 0 deletions packages/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,19 @@
/* Green text for something that worked. Both pass AA on a panel. */
--success-text: light-dark(#1a7f37, #4ade80);

/*
* A file attached in the composer: the page drawn for a document and the badge
* naming its type. One that failed uses the destructive colours.
*/
--file-page: light-dark(#ffffff, #e7e8ec);
--file-page-line: light-dark(#d4d6dc, #b9bcc4);
--file-page-heading: light-dark(#9a9ea8, #8b8f99);
--file-badge-sheet: #1a7a43;
--file-badge-other: light-dark(#5f646e, #4b5060);
/* Over the chat while files are dragged onto it, and the wash inside its dashed edge. */
--drop-veil: light-dark(rgba(255, 255, 255, 0.8), rgba(11, 12, 15, 0.8));
--drop-target: light-dark(oklch(0.96 0.03 268 / 0.7), oklch(0.2 0.03 268 / 0.55));

/* A person with no photo: initials on a grey disc. */
--person-avatar: light-dark(#dcdee3, #3a3d45);
--person-avatar-foreground: light-dark(#3d4048, #dfe1e6);
Expand Down Expand Up @@ -183,6 +196,13 @@
--color-success-text: var(--success-text);
--color-success: var(--success);
--color-destructive: var(--destructive);
--color-file-page: var(--file-page);
--color-file-page-line: var(--file-page-line);
--color-file-page-heading: var(--file-page-heading);
--color-file-badge-sheet: var(--file-badge-sheet);
--color-file-badge-other: var(--file-badge-other);
--color-drop-veil: var(--drop-veil);
--color-drop-target: var(--drop-target);
--color-person-avatar: var(--person-avatar);
--color-person-avatar-foreground: var(--person-avatar-foreground);
/* Set on an element by `botColorVariables()` from `@sugabots/avatars`. */
Expand Down
74 changes: 74 additions & 0 deletions packages/web/src/lib/chat-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { type UploadedAttachment, uploadedAttachmentSchema } from "@sugabots/contracts";
import { failureFromResponse } from "@sugabots/sdk";
import { Effect, Schema } from "effect";
import { client } from "@/api.ts";

const decodeUploadedAttachment = Schema.decodeUnknownSync(uploadedAttachmentSchema);

/** An upload that never got an answer from the API: the connection dropped, or it was stopped. */
export class UploadInterrupted extends Error {
constructor() {
super("The upload did not finish");
this.name = "UploadInterrupted";
}
}

/**
* Uploads `file` to the chat, ready to attach to a message, reporting how much
* of it has gone as a fraction from 0 to 1. Sent with XMLHttpRequest, because
* fetch cannot report an upload's progress. Fails with the API's failure when
* it refuses the file, or `UploadInterrupted` when there is no answer,
* including when `signal` aborts it.
*/
export function uploadChatFile({
chatId,
file,
onProgress,
signal,
}: {
chatId: string;
file: File;
onProgress: (fraction: number) => void;
signal: AbortSignal;
}): Promise<UploadedAttachment> {
return new Promise((resolve, reject) => {
const query = new URLSearchParams({ name: file.name });
// Left out when the browser has no type for it, so the API takes one from the name.
if (file.type) query.set("type", file.type);
const request = new XMLHttpRequest();
request.open("POST", `${client.baseUrl}/chats/${encodeURIComponent(chatId)}/files?${query}`);
request.withCredentials = true;
request.responseType = "json";
request.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && event.total > 0) onProgress(event.loaded / event.total);
});
request.addEventListener("load", () => {
if (request.status !== 201) {
reject(failureFromResponse(request.response, request.status));
return;
}
try {
resolve(decodeUploadedAttachment(request.response));
} catch (error) {
reject(error);
}
});
request.addEventListener("error", () => reject(new UploadInterrupted()));
request.addEventListener("abort", () => reject(new UploadInterrupted()));
if (signal.aborted) {
reject(new UploadInterrupted());
return;
}
signal.addEventListener("abort", () => request.abort(), { once: true });
request.send(file);
});
}

/**
* Removes an upload that will not be sent, so it stops counting toward the
* files a person can have waiting. Nothing waits on it, and a failure is left
* alone: the API clears unsent uploads by itself after a day.
*/
export function removeChatFile(chatId: string, fileId: string): void {
Effect.runPromise(client.api.chats.removeFile({ params: { chatId, fileId } })).catch(() => {});
}
Loading
Loading