How long in hours an uploaded-but-unsent attachment is retained before being deleted by the orphan sweep. Set to `0` to disable the sweep.
|
| `NODE_USE_ENV_PROXY` | `0` |
Enables Node.js to automatically use `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variables for network requests. Set to `1` to enable or `0` to disable. See [this doc](https://nodejs.org/en/learn/http/enterprise-network-configuration) for more info.
|
| `HTTP_PROXY` | - |
HTTP proxy URL for routing non-SSL requests through a proxy server (e.g., `http://proxy.company.com:8080`). Requires `NODE_USE_ENV_PROXY=1`.
|
| `HTTPS_PROXY` | - |
HTTPS proxy URL for routing SSL requests through a proxy server (e.g., `http://proxy.company.com:8080`). Requires `NODE_USE_ENV_PROXY=1`.
|
diff --git a/packages/backend/src/attachmentPruner.ts b/packages/backend/src/attachmentPruner.ts
new file mode 100644
index 000000000..4be8a0562
--- /dev/null
+++ b/packages/backend/src/attachmentPruner.ts
@@ -0,0 +1,92 @@
+import { AttachmentStatus, PrismaClient } from "@sourcebot/db";
+import { createLogger, env } from "@sourcebot/shared";
+import { unlink } from "fs/promises";
+import path from "path";
+import { setIntervalAsync } from "./utils.js";
+
+const BATCH_SIZE = 1_000;
+const ONE_HOUR_MS = 60 * 60 * 1000;
+
+const logger = createLogger('attachment-pruner');
+
+/**
+ * Periodically deletes PENDING (uploaded-but-never-linked) attachment blobs
+ * older than the configured TTL, along with their stored bytes. These are the
+ * orphans produced when a user selects a file in the chat box but never sends
+ * the message. COMMITTED attachments are never touched here; their byte
+ * lifecycle is handled by the chat-delete sweep in the web app.
+ *
+ * @note Mirrors the local-FS layout used by `LocalFsStorageBackend` in the web
+ * package (`DATA_CACHE_DIR/attachments/`). When an S3 driver is
+ * added (Followup B), this deletion path must be generalized accordingly.
+ */
+export class AttachmentPruner {
+ private interval?: NodeJS.Timeout;
+ private readonly attachmentsDir = path.join(env.DATA_CACHE_DIR, 'attachments');
+
+ constructor(private db: PrismaClient) {}
+
+ startScheduler() {
+ const ttlHours = env.SOURCEBOT_CHAT_ATTACHMENT_ORPHAN_TTL_HOURS;
+ if (ttlHours <= 0) {
+ logger.info('SOURCEBOT_CHAT_ATTACHMENT_ORPHAN_TTL_HOURS is 0, attachment orphan pruning is disabled.');
+ return;
+ }
+
+ logger.debug(`Attachment pruner started. Pruning PENDING attachments older than ${ttlHours} hours.`);
+
+ // Run immediately on startup, then every hour.
+ this.pruneOrphanedAttachments();
+ this.interval = setIntervalAsync(() => this.pruneOrphanedAttachments(), ONE_HOUR_MS);
+ }
+
+ async dispose() {
+ if (this.interval) {
+ clearInterval(this.interval);
+ this.interval = undefined;
+ }
+ }
+
+ private async pruneOrphanedAttachments() {
+ const cutoff = new Date(Date.now() - env.SOURCEBOT_CHAT_ATTACHMENT_ORPHAN_TTL_HOURS * ONE_HOUR_MS);
+ let totalDeleted = 0;
+
+ while (true) {
+ const batch = await this.db.attachment.findMany({
+ where: {
+ status: AttachmentStatus.PENDING,
+ createdAt: { lt: cutoff },
+ },
+ select: { id: true, storageKey: true },
+ take: BATCH_SIZE,
+ });
+
+ if (batch.length === 0) {
+ break;
+ }
+
+ await Promise.all(batch.map(async (attachment) => {
+ try {
+ await unlink(path.join(this.attachmentsDir, attachment.storageKey));
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
+ logger.warn(`Failed to delete bytes for orphaned attachment ${attachment.id}: ${error}`);
+ }
+ }
+ }));
+
+ const result = await this.db.attachment.deleteMany({
+ where: { id: { in: batch.map((attachment) => attachment.id) } },
+ });
+ totalDeleted += result.count;
+
+ if (batch.length < BATCH_SIZE) {
+ break;
+ }
+ }
+
+ if (totalDeleted > 0) {
+ logger.debug(`Pruned ${totalDeleted} orphaned PENDING attachment(s).`);
+ }
+ }
+}
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
index 4b668a996..b97fe248f 100644
--- a/packages/backend/src/index.ts
+++ b/packages/backend/src/index.ts
@@ -8,6 +8,7 @@ import 'express-async-errors';
import { existsSync } from 'fs';
import { mkdir } from 'fs/promises';
import { Api } from "./api.js";
+import { AttachmentPruner } from "./attachmentPruner.js";
import { ConfigManager } from "./configManager.js";
import { ConnectionManager } from './connectionManager.js';
import { INDEX_CACHE_DIR, REPOS_CACHE_DIR, SHUTDOWN_SIGNALS } from './constants.js';
@@ -55,10 +56,12 @@ const accountPermissionSyncer = new AccountPermissionSyncer(prisma, settings, re
const repoIndexManager = new RepoIndexManager(prisma, settings, redis, promClient);
const configManager = new ConfigManager(prisma, connectionManager, env.CONFIG_PATH);
const auditLogPruner = new AuditLogPruner(prisma);
+const attachmentPruner = new AttachmentPruner(prisma);
connectionManager.startScheduler();
await repoIndexManager.startScheduler();
auditLogPruner.startScheduler();
+attachmentPruner.startScheduler();
if (env.PERMISSION_SYNC_ENABLED === 'true' && !await hasEntitlement('permission-syncing')) {
logger.warn('Permission syncing is not supported in current plan. Please contact team@sourcebot.dev for assistance.');
@@ -99,6 +102,7 @@ const listenToShutdownSignals = () => {
await repoPermissionSyncer.dispose()
await accountPermissionSyncer.dispose()
await auditLogPruner.dispose()
+ await attachmentPruner.dispose()
await configManager.dispose()
await prisma.$disconnect();
diff --git a/packages/db/prisma/migrations/20260627000032_add_chat_attachments/migration.sql b/packages/db/prisma/migrations/20260627000032_add_chat_attachments/migration.sql
new file mode 100644
index 000000000..739993375
--- /dev/null
+++ b/packages/db/prisma/migrations/20260627000032_add_chat_attachments/migration.sql
@@ -0,0 +1,49 @@
+-- CreateEnum
+CREATE TYPE "AttachmentStatus" AS ENUM ('PENDING', 'COMMITTED');
+
+-- CreateTable
+CREATE TABLE "Attachment" (
+ "id" TEXT NOT NULL,
+ "orgId" INTEGER NOT NULL,
+ "storageKey" TEXT NOT NULL,
+ "filename" TEXT NOT NULL,
+ "mediaType" TEXT NOT NULL,
+ "sizeBytes" INTEGER NOT NULL,
+ "checksum" TEXT NOT NULL,
+ "uploadedById" TEXT NOT NULL,
+ "status" "AttachmentStatus" NOT NULL DEFAULT 'PENDING',
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "Attachment_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "ChatAttachment" (
+ "id" TEXT NOT NULL,
+ "chatId" TEXT NOT NULL,
+ "attachmentId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "ChatAttachment_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "Attachment_status_createdAt_idx" ON "Attachment"("status", "createdAt");
+
+-- CreateIndex
+CREATE INDEX "ChatAttachment_attachmentId_idx" ON "ChatAttachment"("attachmentId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "ChatAttachment_chatId_attachmentId_key" ON "ChatAttachment"("chatId", "attachmentId");
+
+-- AddForeignKey
+ALTER TABLE "Attachment" ADD CONSTRAINT "Attachment_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Attachment" ADD CONSTRAINT "Attachment_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ChatAttachment" ADD CONSTRAINT "ChatAttachment_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ChatAttachment" ADD CONSTRAINT "ChatAttachment_attachmentId_fkey" FOREIGN KEY ("attachmentId") REFERENCES "Attachment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 54444bbe2..345a6ae63 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -24,6 +24,14 @@ enum ChatVisibility {
PUBLIC
}
+/// Lifecycle status of an uploaded attachment blob.
+/// PENDING: uploaded but not yet linked to a chat (orphan until a message
+/// referencing it is sent). COMMITTED: linked to at least one chat.
+enum AttachmentStatus {
+ PENDING
+ COMMITTED
+}
+
/// @note: The @map annotation is required to maintain backwards compatibility
/// with the existing database.
/// @note: In the generated client, these mapped values will be in pascalCase.
@@ -272,6 +280,7 @@ model Org {
connections Connection[]
repos Repo[]
apiKeys ApiKey[]
+ attachments Attachment[]
isOnboarded Boolean @default(false)
imageUrl String?
@@ -456,6 +465,7 @@ model User {
chats Chat[]
sharedChats ChatAccess[]
repoVisits RepoVisit[]
+ uploadedAttachments Attachment[]
oauthTokens OAuthToken[]
oauthAuthCodes OAuthAuthorizationCode[]
@@ -608,6 +618,67 @@ model Chat {
messages Json // This is a JSON array of `Message` types from @ai-sdk/ui-utils.
sharedWith ChatAccess[]
+
+ attachments ChatAttachment[]
+}
+
+/// A user-uploaded binary attachment blob (e.g. an image). The bytes live in
+/// the configured StorageBackend (keyed by `storageKey`), never in the DB.
+/// Attachments are NOT chat-bound: they are uploaded before any chat
+/// association exists, and linked to chats via `ChatAttachment`. Permissions
+/// are derived entirely from the linked chat(s); there are no independent ACLs.
+model Attachment {
+ id String @id @default(cuid())
+
+ org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
+ orgId Int
+
+ /// Opaque key the StorageBackend uses to locate the bytes.
+ storageKey String
+
+ /// Original (sanitized) filename supplied by the uploader.
+ filename String
+
+ /// Final media type of the stored bytes (validated by magic bytes at upload).
+ mediaType String
+
+ /// Size of the stored bytes.
+ sizeBytes Int
+
+ /// Hex SHA-256 of the stored bytes (integrity / debugging; not used for dedup).
+ checksum String
+
+ /// The user who uploaded this blob. Uploads require authentication, so this
+ /// is always set (anonymous users cannot upload binary attachments).
+ uploadedBy User @relation(fields: [uploadedById], references: [id], onDelete: Cascade)
+ uploadedById String
+
+ status AttachmentStatus @default(PENDING)
+
+ createdAt DateTime @default(now())
+
+ chats ChatAttachment[]
+
+ @@index([status, createdAt])
+}
+
+/// Join table linking an `Attachment` blob to a `Chat`. This is the linker
+/// that makes chat duplication metadata-only (no byte copy) and keeps
+/// attachment access purely chat-derived. Deleting a chat cascades these rows;
+/// a separate sweep deletes `Attachment`s left with zero links (and their bytes).
+model ChatAttachment {
+ id String @id @default(cuid())
+
+ chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
+ chatId String
+
+ attachment Attachment @relation(fields: [attachmentId], references: [id], onDelete: Cascade)
+ attachmentId String
+
+ createdAt DateTime @default(now())
+
+ @@unique([chatId, attachmentId])
+ @@index([attachmentId])
}
/// Represents a user's access to a chat that has been shared with them.
diff --git a/packages/shared/src/env.server.ts b/packages/shared/src/env.server.ts
index 57c83f9f0..b832bce4f 100644
--- a/packages/shared/src/env.server.ts
+++ b/packages/shared/src/env.server.ts
@@ -321,6 +321,23 @@ const options = {
SOURCEBOT_CHAT_PROMPT_CACHE_BREAK_DETECTION_ENABLED: booleanSchema.default('false'),
SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: numberSchema.int().positive().max(maxTimerDelayMs).default(60000),
+ /**
+ * Maximum size (in bytes) of a single image attachment uploaded to the
+ * Ask chat. Enforced server-side at upload time. Distinct from the
+ * inline-text cap (which lives as a web-package constant).
+ * @default 10 MiB
+ */
+ SOURCEBOT_CHAT_ATTACHMENT_MAX_IMAGE_BYTES: numberSchema.int().positive().default(10 * 1024 * 1024),
+
+ /**
+ * How long (in hours) an uploaded-but-unlinked (PENDING) attachment
+ * blob is retained before the orphan sweep deletes it and its bytes.
+ * Covers "select a file then never send" abandonment. Set to 0 to
+ * disable the orphan sweep entirely.
+ * @default 24 hours
+ */
+ SOURCEBOT_CHAT_ATTACHMENT_ORPHAN_TTL_HOURS: numberSchema.int().nonnegative().default(24),
+
DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'),
DEBUG_ENABLE_REACT_SCAN: booleanSchema.default('false'),
DEBUG_ENABLE_REACT_GRAB: booleanSchema.default('false'),
diff --git a/packages/web/src/app/api/(server)/ee/chat/[chatId]/attachments/[attachmentId]/route.ts b/packages/web/src/app/api/(server)/ee/chat/[chatId]/attachments/[attachmentId]/route.ts
new file mode 100644
index 000000000..b2d291fa2
--- /dev/null
+++ b/packages/web/src/app/api/(server)/ee/chat/[chatId]/attachments/[attachmentId]/route.ts
@@ -0,0 +1,104 @@
+import { sew } from "@/middleware/sew";
+import { apiHandler } from "@/lib/apiHandler";
+import { notFound, serviceErrorResponse } from "@/lib/serviceError";
+import { isServiceError } from "@/lib/utils";
+import { withOptionalAuth } from "@/middleware/withAuth";
+import { checkAskEntitlement, resolveChatAccess } from "@/features/chat/utils.server";
+import { getStorageBackend } from "@/features/chat/attachments/storage";
+import { NextRequest } from "next/server";
+import { Readable } from "stream";
+
+/**
+ * Serves the bytes of a committed binary attachment. Access is purely
+ * chat-derived: the caller must be able to view the chat (owner / shared /
+ * public) AND a `ChatAttachment(chatId, attachmentId)` link must exist. The
+ * link requirement is what makes chat duplication safe (the same blob can be
+ * served only through chats it is actually linked to). This endpoint is
+ * post-send only; pre-send previews are rendered client-side from the local
+ * file.
+ */
+export const GET = apiHandler(async (
+ _req: NextRequest,
+ { params }: { params: Promise<{ chatId: string; attachmentId: string }> },
+) => {
+ const { chatId, attachmentId } = await params;
+
+ const response = await sew(() =>
+ withOptionalAuth(async ({ org, user, prisma }) => {
+ const askError = await checkAskEntitlement();
+ if (askError) {
+ return askError;
+ }
+
+ const chat = await prisma.chat.findUnique({
+ where: {
+ id: chatId,
+ orgId: org.id,
+ },
+ });
+
+ if (!chat) {
+ return notFound();
+ }
+
+ const { canView } = await resolveChatAccess({ prisma, chat, user });
+ if (!canView) {
+ return notFound();
+ }
+
+ // The link must exist for THIS chat (chat-derived access). We load
+ // the attachment through the link so a blob can never be served via
+ // a chat it isn't linked to.
+ const link = await prisma.chatAttachment.findUnique({
+ where: {
+ chatId_attachmentId: { chatId, attachmentId },
+ },
+ include: { attachment: true },
+ });
+
+ if (!link || link.attachment.orgId !== org.id) {
+ return notFound();
+ }
+
+ return { attachment: link.attachment };
+ })
+ );
+
+ if (isServiceError(response)) {
+ return serviceErrorResponse(response);
+ }
+
+ const { attachment } = response;
+ const storage = getStorageBackend();
+
+ // Confirm the bytes exist before committing headers; a missing object would
+ // otherwise surface as a stream error after a 200 is already sent.
+ const stat = await storage.stat(attachment.storageKey);
+ if (!stat) {
+ return serviceErrorResponse(notFound());
+ }
+
+ const nodeStream = storage.createReadStream(attachment.storageKey);
+ const webStream = Readable.toWeb(nodeStream) as ReadableStream;
+
+ // Build a header-safe Content-Disposition: an ASCII fallback plus an
+ // RFC 5987 UTF-8 form for the real filename.
+ const asciiName = attachment.filename
+ .replace(/[^\x20-\x7e]/g, '_')
+ .replace(/["\\]/g, '_');
+ const contentDisposition =
+ `inline; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(attachment.filename)}`;
+
+ return new Response(webStream, {
+ headers: {
+ 'Content-Type': attachment.mediaType,
+ // On-disk size, so the header always matches the streamed bytes.
+ 'Content-Length': stat.sizeBytes.toString(),
+ 'Content-Disposition': contentDisposition,
+ // Never let the browser sniff a different (potentially executable)
+ // content type from the bytes.
+ 'X-Content-Type-Options': 'nosniff',
+ 'Cache-Control': 'private, max-age=3600',
+ },
+ });
+}, { track: false });
diff --git a/packages/web/src/app/api/(server)/ee/chat/attachments/route.ts b/packages/web/src/app/api/(server)/ee/chat/attachments/route.ts
new file mode 100644
index 000000000..59f4fdc87
--- /dev/null
+++ b/packages/web/src/app/api/(server)/ee/chat/attachments/route.ts
@@ -0,0 +1,113 @@
+import { sew } from "@/middleware/sew";
+import { apiHandler } from "@/lib/apiHandler";
+import { ErrorCode } from "@/lib/errorCodes";
+import { captureEvent } from "@/lib/posthog";
+import { ServiceError, serviceErrorResponse } from "@/lib/serviceError";
+import { isServiceError } from "@/lib/utils";
+import { withAuth } from "@/middleware/withAuth";
+import { checkAskEntitlement } from "@/features/chat/utils.server";
+import { getStorageBackend } from "@/features/chat/attachments/storage";
+import { validateImageAttachment } from "@/features/chat/attachments/validation";
+import { sanitizeFilename } from "@/features/chat/attachments/filename";
+import { env } from "@sourcebot/shared";
+import { createHash, randomUUID } from "crypto";
+import { StatusCodes } from "http-status-codes";
+import { NextRequest } from "next/server";
+
+export const POST = apiHandler(async (req: NextRequest) => {
+ // Reject obviously-oversized bodies before reading them into memory. The
+ // multipart envelope adds some overhead beyond the raw bytes, so allow a
+ // 1 MiB slack on top of the image cap; the exact byte cap is re-checked
+ // against the decoded buffer below.
+ const maxImageBytes = env.SOURCEBOT_CHAT_ATTACHMENT_MAX_IMAGE_BYTES;
+ const contentLength = Number(req.headers.get('content-length') ?? 0);
+ if (contentLength > maxImageBytes + 1024 * 1024) {
+ return serviceErrorResponse({
+ statusCode: StatusCodes.REQUEST_TOO_LONG,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: `Attachment exceeds the ${Math.round(maxImageBytes / (1024 * 1024))}MB limit.`,
+ } satisfies ServiceError);
+ }
+
+ const response = await sew(() =>
+ // `withAuth` (not `withOptionalAuth`) so anonymous users cannot upload
+ // binary attachments (a decision of the attachments design).
+ withAuth(async ({ org, user, prisma }) => {
+ const askError = await checkAskEntitlement();
+ if (askError) {
+ return askError;
+ }
+
+ const formData = await req.formData();
+ const file = formData.get('file');
+ if (!(file instanceof File)) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: 'Expected a `file` field in the multipart body.',
+ } satisfies ServiceError;
+ }
+
+ const buffer = Buffer.from(await file.arrayBuffer());
+
+ // Authoritative content-type + size check by magic bytes (never the
+ // client-supplied MIME type or extension).
+ const validation = validateImageAttachment(buffer, maxImageBytes);
+ if (!validation.ok) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: validation.reason,
+ } satisfies ServiceError;
+ }
+
+ const { mediaType } = validation;
+ const filename = sanitizeFilename(file.name || 'attachment');
+ const sizeBytes = buffer.length;
+ const checksum = createHash('sha256').update(buffer).digest('hex');
+ const storageKey = `${org.id}/${randomUUID()}`;
+
+ const storage = getStorageBackend();
+ await storage.put(storageKey, buffer);
+
+ let attachment;
+ try {
+ attachment = await prisma.attachment.create({
+ data: {
+ orgId: org.id,
+ storageKey,
+ filename,
+ mediaType,
+ sizeBytes,
+ checksum,
+ uploadedById: user.id,
+ status: 'PENDING',
+ },
+ });
+ } catch (error) {
+ // Roll back the orphaned bytes if the DB row couldn't be written.
+ await storage.delete(storageKey).catch(() => { /* best effort */ });
+ throw error;
+ }
+
+ await captureEvent('chat_attachment_uploaded', {
+ source: req.headers.get('X-Sourcebot-Client-Source') ?? undefined,
+ mediaType,
+ sizeBytes,
+ });
+
+ return {
+ attachmentId: attachment.id,
+ filename,
+ mediaType,
+ sizeBytes,
+ };
+ })
+ );
+
+ if (isServiceError(response)) {
+ return serviceErrorResponse(response);
+ }
+
+ return Response.json(response);
+}, { track: false });
diff --git a/packages/web/src/app/api/(server)/ee/chat/route.ts b/packages/web/src/app/api/(server)/ee/chat/route.ts
index cbb11f06e..7c49968bd 100644
--- a/packages/web/src/app/api/(server)/ee/chat/route.ts
+++ b/packages/web/src/app/api/(server)/ee/chat/route.ts
@@ -3,8 +3,10 @@ import { getAskMcpAvailabilityAnalytics, getAskMcpTurnCompletedAnalytics } from
import { createMessageStream } from "@/ee/features/chat/agent";
import { getPromptCacheStrategy } from "@/ee/features/chat/promptCaching";
import { additionalChatRequestParamsSchema } from "@/features/chat/types";
-import { getLanguageModelKey } from "@/features/chat/utils";
-import { checkAskEntitlement, getConfiguredLanguageModels, isOwnerOfChat, updateChatMessages } from "@/features/chat/utils.server";
+import { getLanguageModelKey, getMessageTextBytes, getUserMessageAttachments } from "@/features/chat/utils";
+import { ATTACHMENT_MAX_TURN_TEXT_BYTES } from "@/features/chat/constants";
+import { resolveModelCapabilities } from "@/features/chat/modelCapabilities.server";
+import { checkAskEntitlement, commitMessageAttachments, getConfiguredLanguageModels, isOwnerOfChat, updateChatMessages } from "@/features/chat/utils.server";
import { getAISDKLanguageModelAndOptions } from "@/features/chat/llm.server";
import { resolveContextWindow } from "@/features/chat/modelContextWindow.server";
import { apiHandler } from "@/lib/apiHandler";
@@ -74,6 +76,36 @@ export const POST = apiHandler(async (req: NextRequest) => {
} satisfies ServiceError;
}
+ const latestMessage = messages[messages.length - 1];
+
+ // Authoritatively enforce the per-turn inline-text budget (the client
+ // gate can't be trusted), keeping oversized text out of the prompt and
+ // the persisted messages. Only the user turn carries submitted text.
+ if (
+ latestMessage?.role === 'user' &&
+ getMessageTextBytes(latestMessage) > ATTACHMENT_MAX_TURN_TEXT_BYTES
+ ) {
+ return {
+ statusCode: StatusCodes.REQUEST_TOO_LONG,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: `Message and attachments exceed the ${Math.round(ATTACHMENT_MAX_TURN_TEXT_BYTES / 1024)}KB per-message limit.`,
+ } satisfies ServiceError;
+ }
+
+ // Verify and commit any binary attachments referenced by the latest
+ // message (links them to this chat, flips PENDING -> COMMITTED).
+ // Rejects forged/unauthorized attachment ids before the agent runs.
+ const attachmentError = await commitMessageAttachments({
+ prisma,
+ chatId: id,
+ orgId: org.id,
+ userId: user?.id,
+ message: latestMessage,
+ });
+ if (attachmentError) {
+ return attachmentError;
+ }
+
// From the language model ID, attempt to find the
// corresponding config in `config.json`.
const languageModelConfig =
@@ -90,6 +122,28 @@ export const POST = apiHandler(async (req: NextRequest) => {
const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(languageModelConfig);
+ // Authoritative, server-side resolution of image capability. The
+ // agent's multimodal content builder and degrade logic rely on this
+ // value, never the client.
+ const supportsImages = (await resolveModelCapabilities(languageModelConfig)).inputModalities.includes('image');
+
+ // If the latest message carries image attachments the selected model
+ // cannot accept, the agent will degrade (omit the bytes). Record it.
+ const latestImageAttachmentCount = latestMessage
+ ? getUserMessageAttachments(latestMessage).filter(
+ (attachment) => attachment.kind === 'blob' && attachment.mediaType.startsWith('image/'),
+ ).length
+ : 0;
+ if (!supportsImages && latestImageAttachmentCount > 0) {
+ await captureEvent('chat_attachment_degraded', {
+ chatId: id,
+ source: req.headers.get('X-Sourcebot-Client-Source') ?? undefined,
+ droppedImageCount: latestImageAttachmentCount,
+ modelProvider: languageModelConfig.provider,
+ model: languageModelConfig.model,
+ });
+ }
+
// Total context window for the selected model, used as the
// denominator for the UI's context-usage gauge. Undefined when
// unknown (e.g. self-hosted models).
@@ -151,6 +205,7 @@ export const POST = apiHandler(async (req: NextRequest) => {
modelTemperature: temperature,
userId: user?.id,
orgId: org.id,
+ supportsImages,
onFinish: async ({ messages }) => {
await updateChatMessages({ chatId: id, messages, prisma });
const askMcpTurnCompleted = getAskMcpTurnCompletedAnalytics({
diff --git a/packages/web/src/ee/features/chat/agent.ts b/packages/web/src/ee/features/chat/agent.ts
index e6cc49ca3..95c0d41b8 100644
--- a/packages/web/src/ee/features/chat/agent.ts
+++ b/packages/web/src/ee/features/chat/agent.ts
@@ -1,4 +1,5 @@
-import { SBChatMessage, SBChatMessageMetadata, StepTokenUsageEntry, ToolTokenUsageEntry } from "@/features/chat/types";
+import { BlobAttachment, SBChatMessage, SBChatMessageMetadata, StepTokenUsageEntry, ToolTokenUsageEntry } from "@/features/chat/types";
+import { getStorageBackend } from "@/features/chat/attachments/storage";
import { estimateModelToolOutputTokens } from "@/ee/features/chat/tokenEstimation";
import { getFileSource } from '@/features/git';
import { isServiceError } from "@/lib/utils";
@@ -34,6 +35,120 @@ const dedent = _dedent.withOptions({ alignValues: true });
const logger = createLogger('chat-agent');
+// Resolved image bytes for a single user turn, keyed by attachment id, plus
+// the number of image attachments that turn requested (so the content builder
+// can note how many were dropped).
+type ResolvedTurnImages = {
+ byId: Map;
+ requestedCount: number;
+};
+
+// Reads the image-attachment bytes for one user turn from the StorageBackend.
+// Fail-closed: returns no bytes (only a requested count) when the model cannot
+// accept images, so the builder can leave a degrade marker instead. Blobs are
+// loaded only when linked to this chat, mirroring the serving route's
+// chat-derived access.
+const resolveLatestTurnImages = async ({
+ message,
+ supportsImages,
+ prisma,
+ orgId,
+ chatId,
+}: {
+ message: SBChatMessage | undefined;
+ supportsImages: boolean;
+ prisma: PrismaClient;
+ orgId?: number;
+ chatId: string;
+}): Promise => {
+ const result: ResolvedTurnImages = { byId: new Map(), requestedCount: 0 };
+ if (!message) {
+ return result;
+ }
+
+ const imageBlobs = getUserMessageAttachments(message)
+ .filter((attachment): attachment is BlobAttachment =>
+ attachment.kind === 'blob' && attachment.mediaType.startsWith('image/'));
+ result.requestedCount = imageBlobs.length;
+
+ if (imageBlobs.length === 0 || !supportsImages || orgId === undefined) {
+ return result;
+ }
+
+ const ids = imageBlobs.map((blob) => blob.attachmentId);
+ const records = await prisma.attachment.findMany({
+ where: { id: { in: ids }, orgId, chats: { some: { chatId } } },
+ });
+
+ const storage = getStorageBackend();
+ await Promise.all(records.map(async (record) => {
+ try {
+ const bytes = await storage.get(record.storageKey);
+ result.byId.set(record.id, { bytes, mediaType: record.mediaType });
+ } catch (error) {
+ logger.error(`Failed to read attachment ${record.id} from storage:`, error);
+ }
+ }));
+
+ return result;
+};
+
+// Builds the `ModelMessage` for a user turn: the text part (with any
+// inline-text attachments folded in) plus, for the latest turn only, native
+// image content parts. When images are present but omitted (older turn, or a
+// text-only model), a short marker is appended so the model knows context was
+// dropped.
+const buildUserModelMessage = ({
+ message,
+ isLatestUserTurn,
+ supportsImages,
+ resolvedImages,
+}: {
+ message: SBChatMessage;
+ isLatestUserTurn: boolean;
+ supportsImages: boolean;
+ resolvedImages?: ResolvedTurnImages;
+}): ModelMessage => {
+ const text = getUserMessageText(message);
+ const attachmentsBlock = formatAttachmentsForPrompt(
+ getUserMessageAttachments(message),
+ );
+ let baseText = attachmentsBlock ? `${text}\n\n${attachmentsBlock}` : text;
+
+ const imageBlobs = getUserMessageAttachments(message)
+ .filter((attachment): attachment is BlobAttachment =>
+ attachment.kind === 'blob' && attachment.mediaType.startsWith('image/'));
+
+ if (isLatestUserTurn && resolvedImages && resolvedImages.byId.size > 0) {
+ const imageParts = imageBlobs
+ .map((blob) => resolvedImages.byId.get(blob.attachmentId))
+ .filter((resolved): resolved is { bytes: Buffer; mediaType: string } => resolved !== undefined)
+ .map((resolved) => ({ type: 'image' as const, image: resolved.bytes, mediaType: resolved.mediaType }));
+
+ const droppedCount = resolvedImages.requestedCount - imageParts.length;
+ if (droppedCount > 0) {
+ baseText += `\n\n[Note: ${droppedCount} image attachment(s) could not be loaded and were omitted.]`;
+ }
+
+ return {
+ role: 'user',
+ content: [{ type: 'text', text: baseText }, ...imageParts],
+ };
+ }
+
+ if (imageBlobs.length > 0) {
+ const reason = isLatestUserTurn && !supportsImages
+ ? 'the selected model does not support image input'
+ : 'image attachments are only sent on the turn they were added';
+ baseText += `\n\n[Note: ${imageBlobs.length} image attachment(s) omitted (${reason}).]`;
+ }
+
+ return {
+ role: 'user',
+ content: baseText,
+ };
+};
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mergeStreamAsync = async (stream: StreamTextResult, writer: UIMessageStreamWriter, options: UIMessageStreamOptions = {}) => {
await new Promise((resolve) => writer.merge(stream.toUIMessageStream({
@@ -63,6 +178,11 @@ interface CreateMessageStreamResponseProps {
metadata?: Partial;
userId?: string;
orgId?: number;
+ // Authoritative, server-resolved signal of whether the selected model can
+ // accept image input. Fail-closed (defaults to false): when false, image
+ // attachments are omitted from the model content and a marker is left in
+ // its place.
+ supportsImages?: boolean;
}
export const createMessageStream = async ({
@@ -82,6 +202,7 @@ export const createMessageStream = async ({
onError,
userId,
orgId,
+ supportsImages = false,
}: CreateMessageStreamResponseProps) => {
// Defense-in-depth: Ask Sourcebot is a paid feature. Every caller is
// expected to gate on the `ask` entitlement before reaching here (see
@@ -102,20 +223,28 @@ export const createMessageStream = async ({
// We will use this as the context we carry between messages.
// Server requests always receive persisted messages between client streams, so evaluate them in the ready state.
const incomingTurnProgress = getTurnProgressState({ messages, status: 'ready' });
+
+ // Image attachment bytes are included only on the turn that introduced
+ // them (decision: do not re-send image bytes on later turns). Resolve the
+ // bytes for the latest user turn up-front, reading from the StorageBackend.
+ const lastUserIndex = messages.map((message) => message.role).lastIndexOf('user');
+ const resolvedLatestTurnImages = await resolveLatestTurnImages({
+ message: lastUserIndex >= 0 ? messages[lastUserIndex] : undefined,
+ supportsImages,
+ prisma,
+ orgId,
+ chatId,
+ });
+
let messageHistory: ModelMessage[] =
- messages.map((message, index): ModelMessage | undefined => {
+ (await Promise.all(messages.map(async (message, index): Promise => {
if (message.role === 'user') {
- // Fold inline-text attachments into this turn's content (not the
- // system prompt) so they stay bound to their turn, re-emitted from
- // the persisted parts.
- const text = getUserMessageText(message);
- const attachmentsBlock = formatAttachmentsForPrompt(
- getUserMessageAttachments(message),
- );
- return {
- role: 'user',
- content: attachmentsBlock ? `${text}\n\n${attachmentsBlock}` : text,
- };
+ return buildUserModelMessage({
+ message,
+ isLatestUserTurn: index === lastUserIndex,
+ supportsImages,
+ resolvedImages: index === lastUserIndex ? resolvedLatestTurnImages : undefined,
+ });
}
if (message.role === 'assistant') {
@@ -130,7 +259,9 @@ export const createMessageStream = async ({
}
}
}
- }).filter(message => message !== undefined);
+
+ return undefined;
+ }))).filter((message) => message !== undefined);
// When the last assistant turn has approval responses (from the tool approval flow),
// the turn is incomplete — it has no answer text, only a pending tool call that was
diff --git a/packages/web/src/ee/features/chat/components/chatThread/chatThreadListItem.tsx b/packages/web/src/ee/features/chat/components/chatThread/chatThreadListItem.tsx
index 8d6e42827..d6388443c 100644
--- a/packages/web/src/ee/features/chat/components/chatThread/chatThreadListItem.tsx
+++ b/packages/web/src/ee/features/chat/components/chatThread/chatThreadListItem.tsx
@@ -425,7 +425,7 @@ const ChatThreadListItemComponent = forwardRef
{userAttachments.length > 0 && (
-
+
)}
diff --git a/packages/web/src/ee/features/chat/components/chatThread/messageAttachments.tsx b/packages/web/src/ee/features/chat/components/chatThread/messageAttachments.tsx
index 7d2b5040e..bdbf3b2e7 100644
--- a/packages/web/src/ee/features/chat/components/chatThread/messageAttachments.tsx
+++ b/packages/web/src/ee/features/chat/components/chatThread/messageAttachments.tsx
@@ -1,46 +1,161 @@
'use client';
import { VscodeFileIcon } from "@/app/components/vscodeFileIcon";
+import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { AttachmentViewerDialog } from "@/features/chat/components/chatBox/attachmentViewerDialog";
+import { getAttachmentPreviewUrl, releaseAttachmentPreviewUrl } from "@/features/chat/attachments/attachmentPreviewCache";
import { AttachmentData } from "@/features/chat/types";
import { cn } from "@/lib/utils";
-import { useState } from "react";
+import { useEffect, useState } from "react";
+
+// Stop probing the serving route after this many attempts rather than forever.
+const SERVING_PROBE_MAX_ATTEMPTS = 15;
+const SERVING_PROBE_INTERVAL_MS = 1000;
interface MessageAttachmentsProps {
attachments: AttachmentData[];
+ chatId: string;
className?: string;
}
-export const MessageAttachments = ({ attachments, className }: MessageAttachmentsProps) => {
+// Builds the access-controlled serving URL for a committed blob attachment.
+const getAttachmentServingUrl = (chatId: string, attachmentId: string): string => {
+ return `/api/ee/chat/${chatId}/attachments/${attachmentId}`;
+}
+
+// Prefer the local preview stashed at submit time (instant, avoids the
+// pre-commit 404), falling back to the serving URL for reloaded messages.
+const getBlobImageSrc = (chatId: string, attachmentId: string): string => {
+ return getAttachmentPreviewUrl(attachmentId) ?? getAttachmentServingUrl(chatId, attachmentId);
+}
+
+export const MessageAttachments = ({ attachments, chatId, className }: MessageAttachmentsProps) => {
const [activeAttachment, setActiveAttachment] = useState(null);
+ // Bumped when a preview is released so blob `src`s recompute to the served URL.
+ const [, setPreviewReleaseTick] = useState(0);
+
+ // For any just-sent blob still backed by a local preview, probe the serving
+ // route in the background; once it loads (i.e. the attachment is committed),
+ // release the preview and re-render so every consumer switches to the served
+ // URL atomically. Avoids revoking an object URL that's still on screen.
+ useEffect(() => {
+ const pendingIds = attachments
+ .filter((attachment) =>
+ attachment.kind === 'blob' &&
+ attachment.mediaType.startsWith('image/') &&
+ getAttachmentPreviewUrl(attachment.attachmentId) !== undefined)
+ .map((attachment) => (attachment as Extract).attachmentId);
+
+ if (pendingIds.length === 0) {
+ return;
+ }
+
+ let cancelled = false;
+ const timers: ReturnType[] = [];
+
+ pendingIds.forEach((attachmentId) => {
+ let attempts = 0;
+ const probe = () => {
+ if (cancelled) {
+ return;
+ }
+ const img = new Image();
+ img.onload = () => {
+ if (cancelled) {
+ return;
+ }
+ releaseAttachmentPreviewUrl(attachmentId);
+ setPreviewReleaseTick((tick) => tick + 1);
+ };
+ img.onerror = () => {
+ if (cancelled || attempts >= SERVING_PROBE_MAX_ATTEMPTS) {
+ return;
+ }
+ attempts++;
+ timers.push(setTimeout(probe, SERVING_PROBE_INTERVAL_MS));
+ };
+ img.src = getAttachmentServingUrl(chatId, attachmentId);
+ };
+ probe();
+ });
+
+ return () => {
+ cancelled = true;
+ timers.forEach(clearTimeout);
+ };
+ }, [attachments, chatId]);
if (attachments.length === 0) {
return null;
}
+ const activeImageSrc =
+ activeAttachment?.kind === 'blob' && activeAttachment.mediaType.startsWith('image/')
+ ? getBlobImageSrc(chatId, activeAttachment.attachmentId)
+ : undefined;
+
return (
<>
!open && setActiveAttachment(null)}
filename={activeAttachment?.filename}
text={activeAttachment?.kind === 'text' ? activeAttachment.text : undefined}
+ imageSrc={activeImageSrc}
/>
>
)
diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts
index fccc6df8a..5b74d25e9 100644
--- a/packages/web/src/features/chat/actions.ts
+++ b/packages/web/src/features/chat/actions.ts
@@ -8,7 +8,7 @@ import { notFound } from "@/lib/serviceError";
import { withAuth, withOptionalAuth } from "@/middleware/withAuth";
import { ChatVisibility, Prisma } from "@sourcebot/db";
import { SBChatMessage } from "./types";
-import { checkAskEntitlement, isChatSharedWithUser, isOwnerOfChat } from "./utils.server";
+import { checkAskEntitlement, deleteOrphanedAttachments, isChatSharedWithUser, isOwnerOfChat, resolveChatAccess } from "./utils.server";
export const createChat = async ({ source }: { source?: string } = {}) => sew(() =>
withOptionalAuth(async ({ org, user, prisma }) => {
@@ -75,11 +75,10 @@ export const getChatInfo = async ({ chatId }: { chatId: string }) => sew(() =>
return notFound();
}
- const isOwner = await isOwnerOfChat(chat, user);
- const isSharedWithUser = await isChatSharedWithUser({ prisma, chatId, userId: user?.id });
+ const { isOwner, isSharedWithUser, canView } = await resolveChatAccess({ prisma, chat, user });
// Private chats can only be viewed by the owner or users it's been shared with
- if (chat.visibility === ChatVisibility.PRIVATE && !isOwner && !isSharedWithUser) {
+ if (!canView) {
return notFound();
}
@@ -194,6 +193,13 @@ export const deleteChat = async ({ chatId }: { chatId: string }) => sew(() =>
return notFound();
}
+ // Capture the linked attachment ids before the delete cascades the
+ // link rows, so we can sweep any blobs left with zero links afterwards.
+ const linkedAttachments = await prisma.chatAttachment.findMany({
+ where: { chatId },
+ select: { attachmentId: true },
+ });
+
await prisma.chat.delete({
where: {
id: chatId,
@@ -201,6 +207,11 @@ export const deleteChat = async ({ chatId }: { chatId: string }) => sew(() =>
},
});
+ await deleteOrphanedAttachments({
+ prisma,
+ attachmentIds: linkedAttachments.map((link) => link.attachmentId),
+ });
+
await createAudit({
action: "chat.deleted",
actor: { id: user.id, type: "user" },
@@ -292,6 +303,23 @@ export const duplicateChat = async ({ chatId, newName }: { chatId: string, newNa
},
});
+ // Copy the attachment links (metadata-only; no byte copy). The
+ // duplicated messages reference the same blobs, and access stays
+ // chat-derived through the new chat's links.
+ const originalLinks = await prisma.chatAttachment.findMany({
+ where: { chatId: originalChat.id },
+ select: { attachmentId: true },
+ });
+ if (originalLinks.length > 0) {
+ await prisma.chatAttachment.createMany({
+ data: originalLinks.map((link) => ({
+ chatId: newChat.id,
+ attachmentId: link.attachmentId,
+ })),
+ skipDuplicates: true,
+ });
+ }
+
return {
id: newChat.id,
};
diff --git a/packages/web/src/features/chat/attachmentUtils.ts b/packages/web/src/features/chat/attachmentUtils.ts
index 4ab042532..df3c5d083 100644
--- a/packages/web/src/features/chat/attachmentUtils.ts
+++ b/packages/web/src/features/chat/attachmentUtils.ts
@@ -1,47 +1,72 @@
'use client';
import {
+ ATTACHMENT_ALLOWED_IMAGE_MIME_TYPES,
ATTACHMENT_ALLOWED_TEXT_EXTENSIONS,
ATTACHMENT_ALLOWED_TEXT_MIME_TYPES,
+ ATTACHMENT_MAX_IMAGE_BYTES,
+ ATTACHMENT_MAX_IMAGE_COUNT,
ATTACHMENT_MAX_TURN_TEXT_BYTES,
ATTACHMENT_PASTE_AUTO_CONVERT_MIN_CHARS,
ATTACHMENT_PASTE_AUTO_CONVERT_MIN_LINES,
} from "./constants";
-import { AttachmentData, TextAttachment } from "./types";
+import { AttachmentData } from "./types";
+import { sanitizeFilename } from "./attachments/filename";
import { v4 as uuidv4 } from "uuid";
-// Normalizes an untrusted filename: basename only, strips control chars (which
-// could break the `` tag or UI), collapses whitespace.
-export const sanitizeFilename = (name: string): string => {
- const basename = name.split(/[\\/]/).pop() ?? name;
- return Array.from(basename)
- .filter((char) => {
- const code = char.charCodeAt(0);
- return code >= 32 && code !== 127;
- })
- .join('')
- .replace(/\s+/g, ' ')
- .trim() || 'attachment';
-}
+export { sanitizeFilename };
+
+// A text attachment selected in the chat box but not yet submitted. The
+// extracted text travels inline in the message (no upload).
+export type PendingTextAttachment = {
+ kind: 'text';
+ id: string;
+ filename: string;
+ mediaType: string;
+ sizeBytes: number;
+ text: string;
+};
-// A text attachment selected in the chat box but not yet submitted. The `id`
-// is a client-only key for list rendering and removal; it is stripped before
-// the attachment becomes part of a message.
-export type PendingAttachment = TextAttachment & { id: string };
+// An image attachment selected in the chat box. Unlike text, the bytes are
+// uploaded to blob storage on select; `status`/`attachmentId` track that
+// upload. `previewUrl` is a local object URL used for the pre-send thumbnail,
+// and `file` is retained so the upload can be (re)issued.
+export type PendingImageAttachment = {
+ kind: 'image';
+ id: string;
+ filename: string;
+ mediaType: string;
+ sizeBytes: number;
+ previewUrl: string;
+ file: File;
+ status: 'uploading' | 'uploaded' | 'error';
+ attachmentId?: string;
+ error?: string;
+};
+
+// An attachment selected in the chat box but not yet submitted. The `id` is a
+// client-only key for list rendering and removal; it is stripped before the
+// attachment becomes part of a message.
+export type PendingAttachment = PendingTextAttachment | PendingImageAttachment;
// Builds the comma-separated `accept` attribute for a native ``
-// so the OS picker only surfaces supported text file types.
-export const getAttachmentAcceptAttribute = (): string => {
+// so the OS picker only surfaces supported file types. Image types are included
+// only when the selected model can accept image input.
+export const getAttachmentAcceptAttribute = (includeImages: boolean): string => {
return [
'text/*',
...ATTACHMENT_ALLOWED_TEXT_MIME_TYPES,
...ATTACHMENT_ALLOWED_TEXT_EXTENSIONS.map((extension) => `.${extension}`),
+ ...(includeImages ? ATTACHMENT_ALLOWED_IMAGE_MIME_TYPES : []),
].join(',');
}
-// Builds react-dropzone's `accept` map. Extensions are attached to `text/plain`
-// so code files that report an empty/unusual MIME type are still selectable.
-export const getAttachmentDropzoneAccept = (): Record => {
+// Builds the `accept` map for react-dropzone (and the native file picker) so
+// the OS dialog and drag overlay only surface supported file types. The
+// extension list is attached to `text/plain` so code files that report an empty
+// or unusual MIME type are still selectable by extension. Image types are
+// included only when the selected model can accept image input.
+export const getAttachmentDropzoneAccept = (includeImages: boolean): Record => {
const accept: Record = {
'text/*': [],
'text/plain': ATTACHMENT_ALLOWED_TEXT_EXTENSIONS.map((extension) => `.${extension}`),
@@ -49,25 +74,51 @@ export const getAttachmentDropzoneAccept = (): Record => {
for (const mimeType of ATTACHMENT_ALLOWED_TEXT_MIME_TYPES) {
accept[mimeType] = [];
}
+ if (includeImages) {
+ for (const mimeType of ATTACHMENT_ALLOWED_IMAGE_MIME_TYPES) {
+ accept[mimeType] = [];
+ }
+ }
return accept;
}
-// Total UTF-8 byte size of a turn's submitted text (prompt + attachment bodies),
-// checked against ATTACHMENT_MAX_TURN_TEXT_BYTES at submit time.
+// Total UTF-8 byte size of a turn's submitted text (prompt + text attachment
+// bodies), checked against ATTACHMENT_MAX_TURN_TEXT_BYTES at submit time. Image
+// attachments are excluded: their bytes are uploaded as blobs, not inlined into
+// the message text, so they don't count against the inline-text budget.
export const getSubmittedTextBytes = (text: string, attachments: PendingAttachment[]): number => {
const textBytes = new TextEncoder().encode(text).length;
- const attachmentBytes = attachments.reduce((sum, attachment) => sum + attachment.sizeBytes, 0);
+ const attachmentBytes = attachments
+ .filter((attachment) => attachment.kind === 'text')
+ .reduce((sum, attachment) => sum + attachment.sizeBytes, 0);
return textBytes + attachmentBytes;
}
-export const toAttachmentData = (attachment: PendingAttachment): AttachmentData => {
- return {
- kind: attachment.kind,
- filename: attachment.filename,
- mediaType: attachment.mediaType,
- sizeBytes: attachment.sizeBytes,
- text: attachment.text,
- };
+// Converts a pending attachment into the message `AttachmentData` part. Returns
+// `undefined` for an image whose upload has not completed (it must not be
+// referenced before the blob exists); callers filter these out.
+export const toAttachmentData = (attachment: PendingAttachment): AttachmentData | undefined => {
+ if (attachment.kind === 'text') {
+ return {
+ kind: 'text',
+ filename: attachment.filename,
+ mediaType: attachment.mediaType,
+ sizeBytes: attachment.sizeBytes,
+ text: attachment.text,
+ };
+ }
+
+ if (attachment.status === 'uploaded' && attachment.attachmentId) {
+ return {
+ kind: 'blob',
+ attachmentId: attachment.attachmentId,
+ filename: attachment.filename,
+ mediaType: attachment.mediaType,
+ sizeBytes: attachment.sizeBytes,
+ };
+ }
+
+ return undefined;
}
const getExtension = (filename: string): string => {
@@ -98,6 +149,10 @@ export const isAllowedTextFile = (file: File): boolean => {
return false;
}
+export const isAllowedImageFile = (file: File): boolean => {
+ return (ATTACHMENT_ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(file.type);
+}
+
const readAsText = (file: File): Promise => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -159,40 +214,99 @@ export type ReadFilesResult = {
errors: string[];
};
-// Reads files into pending text attachments, rejecting non-text files and any
-// file larger than the per-turn budget (skipped before reading to avoid loading
-// a huge file into memory). The aggregate budget is enforced at submit time.
+// Reads and validates files into pending attachments, enforcing the per-file
+// size, allowed-type, and per-message image-count caps (the per-turn text budget
+// is enforced at submit time). Text is read inline; images (when `allowImages`)
+// become pending uploads the caller then kicks off. The image-count cap mirrors
+// the server's for early feedback. Rejected files yield an error, not a throw.
export const readFilesAsAttachments = async (
files: File[],
+ { allowImages, existingImageCount = 0 }: { allowImages: boolean; existingImageCount?: number },
): Promise => {
const attachments: PendingAttachment[] = [];
const errors: string[] = [];
+ let imageCount = existingImageCount;
for (const file of files) {
- if (!isAllowedTextFile(file)) {
- errors.push(`${file.name}: unsupported file type (text files only).`);
- continue;
- }
-
- if (file.size > ATTACHMENT_MAX_TURN_TEXT_BYTES) {
- errors.push(`${file.name}: exceeds the ${Math.round(ATTACHMENT_MAX_TURN_TEXT_BYTES / 1024)}KB per-message limit.`);
+ if (isAllowedTextFile(file)) {
+ // Skip before reading to avoid loading a huge file into memory.
+ if (file.size > ATTACHMENT_MAX_TURN_TEXT_BYTES) {
+ errors.push(`${file.name}: exceeds the ${Math.round(ATTACHMENT_MAX_TURN_TEXT_BYTES / 1024)}KB per-message limit.`);
+ continue;
+ }
+ try {
+ const text = await readAsText(file);
+ attachments.push({
+ id: uuidv4(),
+ kind: 'text',
+ filename: sanitizeFilename(file.name),
+ mediaType: file.type || 'text/plain',
+ sizeBytes: file.size,
+ text,
+ });
+ } catch {
+ errors.push(`${file.name}: failed to read file.`);
+ }
continue;
}
- try {
- const text = await readAsText(file);
+ if (isAllowedImageFile(file)) {
+ if (!allowImages) {
+ errors.push(`${file.name}: the selected model does not support image input.`);
+ continue;
+ }
+ if (file.size > ATTACHMENT_MAX_IMAGE_BYTES) {
+ errors.push(`${file.name}: exceeds the ${Math.round(ATTACHMENT_MAX_IMAGE_BYTES / (1024 * 1024))}MB image limit.`);
+ continue;
+ }
+ if (imageCount >= ATTACHMENT_MAX_IMAGE_COUNT) {
+ errors.push(`You can attach at most ${ATTACHMENT_MAX_IMAGE_COUNT} images per message.`);
+ continue;
+ }
attachments.push({
id: uuidv4(),
- kind: 'text',
+ kind: 'image',
filename: sanitizeFilename(file.name),
- mediaType: file.type || 'text/plain',
+ mediaType: file.type,
sizeBytes: file.size,
- text,
+ previewUrl: URL.createObjectURL(file),
+ file,
+ status: 'uploading',
});
- } catch {
- errors.push(`${file.name}: failed to read file.`);
+ imageCount++;
+ continue;
}
+
+ errors.push(`${file.name}: unsupported file type.`);
}
return { attachments, errors };
}
+
+// Uploads an image attachment's bytes to blob storage, returning the committed
+// attachment metadata (including the server-assigned `attachmentId`). Throws
+// with a human-readable message on failure.
+export const uploadImageAttachment = async (file: File): Promise<{
+ attachmentId: string;
+ filename: string;
+ mediaType: string;
+ sizeBytes: number;
+}> => {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const response = await fetch('/api/ee/chat/attachments', {
+ method: 'POST',
+ body: formData,
+ headers: {
+ 'X-Sourcebot-Client-Source': 'sourcebot-web-client',
+ },
+ });
+
+ if (!response.ok) {
+ const body = await response.json().catch(() => undefined);
+ throw new Error(body?.message ?? 'Failed to upload image.');
+ }
+
+ return response.json();
+}
diff --git a/packages/web/src/features/chat/attachments/attachmentPreviewCache.ts b/packages/web/src/features/chat/attachments/attachmentPreviewCache.ts
new file mode 100644
index 000000000..0045975fc
--- /dev/null
+++ b/packages/web/src/features/chat/attachments/attachmentPreviewCache.ts
@@ -0,0 +1,24 @@
+'use client';
+
+// Client-only cache mapping an attachment id to its local object URL, so a
+// just-sent image renders instantly instead of 404ing before it's committed.
+// Entries are released once the served image loads (see releaseAttachmentPreviewUrl).
+const previewUrlByAttachmentId = new Map();
+
+export const setAttachmentPreviewUrl = (attachmentId: string, objectUrl: string): void => {
+ previewUrlByAttachmentId.set(attachmentId, objectUrl);
+}
+
+export const getAttachmentPreviewUrl = (attachmentId: string): string | undefined => {
+ return previewUrlByAttachmentId.get(attachmentId);
+}
+
+// Revokes and drops the cached preview (no-op if absent). Call once the served
+// image has loaded so the object URL and its bytes can be reclaimed.
+export const releaseAttachmentPreviewUrl = (attachmentId: string): void => {
+ const objectUrl = previewUrlByAttachmentId.get(attachmentId);
+ if (objectUrl) {
+ URL.revokeObjectURL(objectUrl);
+ previewUrlByAttachmentId.delete(attachmentId);
+ }
+}
diff --git a/packages/web/src/features/chat/attachments/filename.ts b/packages/web/src/features/chat/attachments/filename.ts
new file mode 100644
index 000000000..fc5a23fdb
--- /dev/null
+++ b/packages/web/src/features/chat/attachments/filename.ts
@@ -0,0 +1,15 @@
+// Normalizes an untrusted filename: keeps only the basename, drops control
+// characters (which could break the prompt's `` tag
+// or the UI), and collapses whitespace. Lives in a non-client module so both
+// the client picker and the server upload route can share one implementation.
+export const sanitizeFilename = (name: string): string => {
+ const basename = name.split(/[\\/]/).pop() ?? name;
+ return Array.from(basename)
+ .filter((char) => {
+ const code = char.charCodeAt(0);
+ return code >= 32 && code !== 127;
+ })
+ .join('')
+ .replace(/\s+/g, ' ')
+ .trim() || 'attachment';
+};
diff --git a/packages/web/src/features/chat/attachments/storage.ts b/packages/web/src/features/chat/attachments/storage.ts
new file mode 100644
index 000000000..f481e454a
--- /dev/null
+++ b/packages/web/src/features/chat/attachments/storage.ts
@@ -0,0 +1,101 @@
+import 'server-only';
+
+import { env } from '@sourcebot/shared';
+import { createReadStream as fsCreateReadStream } from 'fs';
+import fs from 'fs/promises';
+import path from 'path';
+import { Readable } from 'stream';
+
+/**
+ * App-mediated storage for binary chat attachments. The application always
+ * brokers access (no public URLs); callers resolve permissions via the chat
+ * linker before reading. A `LocalFsStorageBackend` is provided first; an
+ * S3-compatible driver implementing the same contract is planned (Followup B).
+ */
+export interface StorageBackend {
+ /** Writes the bytes for `key`, overwriting any existing object. */
+ put(key: string, data: Buffer): Promise;
+ /** Reads the full bytes for `key`. Throws if the object is missing. */
+ get(key: string): Promise;
+ /**
+ * Returns the object's byte size, or `undefined` if it is missing. Lets the
+ * serving route detect a missing object before committing response headers.
+ */
+ stat(key: string): Promise<{ sizeBytes: number } | undefined>;
+ /** Opens a Node read stream for `key` (used by the serving route). */
+ createReadStream(key: string): Readable;
+ /** Deletes the object for `key`. A missing object is not an error. */
+ delete(key: string): Promise;
+}
+
+/**
+ * Stores attachment bytes on the local filesystem under
+ * `DATA_CACHE_DIR/attachments`. Keys are opaque, server-generated strings; the
+ * backend defensively rejects any key that would resolve outside the base
+ * directory (path-traversal guard), even though keys are not client-controlled.
+ */
+export class LocalFsStorageBackend implements StorageBackend {
+ private readonly baseDir: string;
+
+ constructor(baseDir: string = path.join(env.DATA_CACHE_DIR, 'attachments')) {
+ this.baseDir = path.resolve(baseDir);
+ }
+
+ private resolveKey(key: string): string {
+ const resolved = path.resolve(this.baseDir, key);
+ // Defense-in-depth: never let a key escape the base directory.
+ if (resolved !== this.baseDir && !resolved.startsWith(this.baseDir + path.sep)) {
+ throw new Error(`Invalid storage key: ${key}`);
+ }
+ return resolved;
+ }
+
+ async put(key: string, data: Buffer): Promise {
+ const filePath = this.resolveKey(key);
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
+ await fs.writeFile(filePath, data);
+ }
+
+ async get(key: string): Promise {
+ return fs.readFile(this.resolveKey(key));
+ }
+
+ async stat(key: string): Promise<{ sizeBytes: number } | undefined> {
+ try {
+ const { size } = await fs.stat(this.resolveKey(key));
+ return { sizeBytes: size };
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
+ return undefined;
+ }
+ throw error;
+ }
+ }
+
+ createReadStream(key: string): Readable {
+ return fsCreateReadStream(this.resolveKey(key));
+ }
+
+ async delete(key: string): Promise {
+ try {
+ await fs.unlink(this.resolveKey(key));
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
+ throw error;
+ }
+ }
+ }
+}
+
+let storageBackend: StorageBackend | undefined;
+
+/**
+ * Returns the process-wide StorageBackend singleton. Local-FS for now; the
+ * driver selection (e.g. S3) will be config-driven in Followup B.
+ */
+export const getStorageBackend = (): StorageBackend => {
+ if (!storageBackend) {
+ storageBackend = new LocalFsStorageBackend();
+ }
+ return storageBackend;
+};
diff --git a/packages/web/src/features/chat/attachments/validation.ts b/packages/web/src/features/chat/attachments/validation.ts
new file mode 100644
index 000000000..d8ec30ecb
--- /dev/null
+++ b/packages/web/src/features/chat/attachments/validation.ts
@@ -0,0 +1,75 @@
+import { ATTACHMENT_ALLOWED_IMAGE_MIME_TYPES } from '../constants';
+
+export type AllowedImageMediaType = typeof ATTACHMENT_ALLOWED_IMAGE_MIME_TYPES[number];
+
+const isAllowedImageMediaType = (mediaType: string): mediaType is AllowedImageMediaType => {
+ return (ATTACHMENT_ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(mediaType);
+};
+
+const startsWith = (buffer: Buffer, bytes: number[], offset = 0): boolean => {
+ if (buffer.length < offset + bytes.length) {
+ return false;
+ }
+ for (let i = 0; i < bytes.length; i++) {
+ if (buffer[offset + i] !== bytes[i]) {
+ return false;
+ }
+ }
+ return true;
+};
+
+/**
+ * Determines an image's media type from its leading bytes (magic numbers),
+ * ignoring any client-supplied MIME type or filename extension. Returns
+ * `undefined` for anything that is not an allowlisted image format. This is the
+ * authoritative content-type check for binary attachment uploads.
+ */
+export const detectImageMediaType = (buffer: Buffer): AllowedImageMediaType | undefined => {
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
+ if (startsWith(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
+ return 'image/png';
+ }
+ // JPEG: FF D8 FF
+ if (startsWith(buffer, [0xff, 0xd8, 0xff])) {
+ return 'image/jpeg';
+ }
+ // GIF: "GIF87a" or "GIF89a"
+ if (startsWith(buffer, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) ||
+ startsWith(buffer, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])) {
+ return 'image/gif';
+ }
+ // WEBP: "RIFF" .... "WEBP"
+ if (startsWith(buffer, [0x52, 0x49, 0x46, 0x46]) && startsWith(buffer, [0x57, 0x45, 0x42, 0x50], 8)) {
+ return 'image/webp';
+ }
+ return undefined;
+};
+
+export type AttachmentValidationResult =
+ | { ok: true; mediaType: AllowedImageMediaType }
+ | { ok: false; reason: string };
+
+/**
+ * Validates uploaded attachment bytes: confirms they are an allowlisted image
+ * (by magic bytes) and that the bytes don't exceed `maxBytes`. The returned
+ * `mediaType` is the magic-byte-derived type, which callers should persist
+ * instead of any client-supplied value.
+ */
+export const validateImageAttachment = (
+ buffer: Buffer,
+ maxBytes: number,
+): AttachmentValidationResult => {
+ if (buffer.length === 0) {
+ return { ok: false, reason: 'Empty file.' };
+ }
+ if (buffer.length > maxBytes) {
+ return { ok: false, reason: `Image exceeds the ${Math.round(maxBytes / (1024 * 1024))}MB limit.` };
+ }
+
+ const mediaType = detectImageMediaType(buffer);
+ if (!mediaType || !isAllowedImageMediaType(mediaType)) {
+ return { ok: false, reason: 'Unsupported image type. Allowed: PNG, JPEG, WebP, GIF.' };
+ }
+
+ return { ok: true, mediaType };
+};
diff --git a/packages/web/src/features/chat/components/chatBox/attachmentButton.tsx b/packages/web/src/features/chat/components/chatBox/attachmentButton.tsx
index fef235c06..33d88b6de 100644
--- a/packages/web/src/features/chat/components/chatBox/attachmentButton.tsx
+++ b/packages/web/src/features/chat/components/chatBox/attachmentButton.tsx
@@ -8,10 +8,11 @@ import { useRef } from "react";
interface AttachmentButtonProps {
onAddFiles: (files: File[]) => void;
+ acceptImages?: boolean;
disabled?: boolean;
}
-export const AttachmentButton = ({ onAddFiles, disabled }: AttachmentButtonProps) => {
+export const AttachmentButton = ({ onAddFiles, acceptImages = false, disabled }: AttachmentButtonProps) => {
const inputRef = useRef(null);
return (
@@ -20,7 +21,7 @@ export const AttachmentButton = ({ onAddFiles, disabled }: AttachmentButtonProps
ref={inputRef}
type="file"
multiple
- accept={getAttachmentAcceptAttribute()}
+ accept={getAttachmentAcceptAttribute(acceptImages)}
className="hidden"
onChange={(e) => {
const files = e.target.files ? Array.from(e.target.files) : [];
@@ -46,7 +47,7 @@ export const AttachmentButton = ({ onAddFiles, disabled }: AttachmentButtonProps
- Attach text files
+ {acceptImages ? "Attach text files or images" : "Attach text files"}
>
diff --git a/packages/web/src/features/chat/components/chatBox/attachmentTray.tsx b/packages/web/src/features/chat/components/chatBox/attachmentTray.tsx
index 2646fa93b..3547ddf1b 100644
--- a/packages/web/src/features/chat/components/chatBox/attachmentTray.tsx
+++ b/packages/web/src/features/chat/components/chatBox/attachmentTray.tsx
@@ -1,8 +1,9 @@
'use client';
import { VscodeFileIcon } from "@/app/components/vscodeFileIcon";
+import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
-import { X } from "lucide-react";
+import { AlertCircle, Loader2, X } from "lucide-react";
import { useState } from "react";
import { PendingAttachment } from "../../attachmentUtils";
import { AttachmentViewerDialog } from "./attachmentViewerDialog";
@@ -26,39 +27,95 @@ export const AttachmentTray = ({ attachments, onRemove, className }: AttachmentT
<>
{attachments.map((attachment) => (
-
-
+ )
))}
!open && setActiveAttachment(null)}
filename={activeAttachment?.filename}
- text={activeAttachment?.text}
+ text={activeAttachment?.kind === 'text' ? activeAttachment.text : undefined}
+ imageSrc={activeAttachment?.kind === 'image' ? activeAttachment.previewUrl : undefined}
/>
>
)
diff --git a/packages/web/src/features/chat/components/chatBox/attachmentViewerDialog.tsx b/packages/web/src/features/chat/components/chatBox/attachmentViewerDialog.tsx
index 46dc236dc..d57eb68ee 100644
--- a/packages/web/src/features/chat/components/chatBox/attachmentViewerDialog.tsx
+++ b/packages/web/src/features/chat/components/chatBox/attachmentViewerDialog.tsx
@@ -6,13 +6,17 @@ import { useEffect } from "react";
interface AttachmentViewerDialogProps {
filename?: string;
text?: string;
+ // When set, the dialog shows the image at this URL instead of text. Used
+ // for image attachments (a local object URL pre-send, the serving route
+ // post-send).
+ imageSrc?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
-// Shared viewer for inspecting an inline-text attachment's contents. Used for
-// both staged (not-yet-sent) and sent attachments.
-export const AttachmentViewerDialog = ({ filename, text, open, onOpenChange }: AttachmentViewerDialogProps) => {
+// Shared viewer for inspecting an attachment's contents. Used for both staged
+// (not-yet-sent) and sent attachments, and for both text and image kinds.
+export const AttachmentViewerDialog = ({ filename, text, imageSrc, open, onOpenChange }: AttachmentViewerDialogProps) => {
// The staged viewer is rendered inside the Slate `Editable` subtree, where
// Radix's built-in Escape-to-close can get swallowed by the editor's
// focus/key handling. A capture-phase listener guarantees Escape closes the
@@ -45,9 +49,18 @@ export const AttachmentViewerDialog = ({ filename, text, open, onOpenChange }: A
Preview of the attached file{filename ? ` ${filename}` : ''}.
-