From f893212c2d35e29fa0914c0c9fb255028e09cac5 Mon Sep 17 00:00:00 2001 From: whoisthey Date: Sat, 27 Jun 2026 11:11:33 -0700 Subject: [PATCH 1/4] binary attachments first pass --- .../configuration/environment-variables.mdx | 2 + packages/backend/src/attachmentPruner.ts | 92 ++++++++ packages/backend/src/index.ts | 4 + .../migration.sql | 49 ++++ packages/db/prisma/schema.prisma | 71 ++++++ packages/shared/src/env.server.ts | 16 ++ .../attachments/[attachmentId]/route.ts | 96 ++++++++ .../api/(server)/ee/chat/attachments/route.ts | 113 ++++++++++ .../web/src/app/api/(server)/ee/chat/route.ts | 43 +++- packages/web/src/ee/features/chat/agent.ts | 161 +++++++++++-- .../chatThread/chatThreadListItem.tsx | 2 +- .../chatThread/messageAttachments.tsx | 63 ++++-- packages/web/src/features/chat/actions.ts | 36 ++- .../web/src/features/chat/attachmentUtils.ts | 211 +++++++++++++----- .../src/features/chat/attachments/filename.ts | 29 +++ .../src/features/chat/attachments/storage.ts | 84 +++++++ .../features/chat/attachments/validation.ts | 75 +++++++ .../components/chatBox/attachmentButton.tsx | 7 +- .../components/chatBox/attachmentTray.tsx | 88 ++++++-- .../chatBox/attachmentViewerDialog.tsx | 25 ++- .../chat/components/chatBox/chatBox.tsx | 100 ++++++++- .../components/chatBox/chatPaneDropzone.tsx | 7 +- packages/web/src/features/chat/constants.ts | 16 ++ packages/web/src/features/chat/types.ts | 17 +- .../web/src/features/chat/utils.server.ts | 155 ++++++++++++- packages/web/src/lib/posthogEvents.ts | 12 + 26 files changed, 1429 insertions(+), 145 deletions(-) create mode 100644 packages/backend/src/attachmentPruner.ts create mode 100644 packages/db/prisma/migrations/20260627000032_add_chat_attachments/migration.sql create mode 100644 packages/web/src/app/api/(server)/ee/chat/[chatId]/attachments/[attachmentId]/route.ts create mode 100644 packages/web/src/app/api/(server)/ee/chat/attachments/route.ts create mode 100644 packages/web/src/features/chat/attachments/filename.ts create mode 100644 packages/web/src/features/chat/attachments/storage.ts create mode 100644 packages/web/src/features/chat/attachments/validation.ts diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx index 2d4c57acb..68e94c19b 100644 --- a/docs/docs/configuration/environment-variables.mdx +++ b/docs/docs/configuration/environment-variables.mdx @@ -40,6 +40,8 @@ The following environment variables allow you to configure your Sourcebot deploy | `SOURCEBOT_TELEMETRY_DISABLED` | `false` |

Enables/disables telemetry collection in Sourcebot. See [this doc](/docs/misc/telemetry) for more info.

| | `DEFAULT_MAX_MATCH_COUNT` | `10000` |

The default maximum number of search results to return when using search in the web app.

| | `ALWAYS_INDEX_FILE_PATTERNS` | - |

A comma separated list of glob patterns matching file paths that should always be indexed, regardless of size or number of trigrams.

| +| `SOURCEBOT_CHAT_ATTACHMENT_MAX_IMAGE_BYTES` | `10485760` (10 MiB) |

Maximum size in bytes of a single image attachment uploaded to Ask Sourcebot. Enforced server-side at upload time.

| +| `SOURCEBOT_CHAT_ATTACHMENT_ORPHAN_TTL_HOURS` | `24` |

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..b76ff430b 100644 --- a/packages/shared/src/env.server.ts +++ b/packages/shared/src/env.server.ts @@ -321,6 +321,22 @@ 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. + * @default 24 hours + */ + SOURCEBOT_CHAT_ATTACHMENT_ORPHAN_TTL_HOURS: numberSchema.int().positive().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..377a8b761 --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/chat/[chatId]/attachments/[attachmentId]/route.ts @@ -0,0 +1,96 @@ +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(); + const nodeStream = storage.createReadStream(attachment.storageKey); + // Surface a missing-bytes condition as a 404 rather than a hung stream. + 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, + 'Content-Length': attachment.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..6ee562981 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,9 @@ 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, getUserMessageAttachments } from "@/features/chat/utils"; +import { resolveModelInputModalities } from "@/features/chat/modelCapabilities"; +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 +75,20 @@ export const POST = apiHandler(async (req: NextRequest) => { } 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: messages[messages.length - 1], + }); + if (attachmentError) { + return attachmentError; + } + // From the language model ID, attempt to find the // corresponding config in `config.json`. const languageModelConfig = @@ -90,6 +105,29 @@ 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 = resolveModelInputModalities(languageModelConfig).includes('image'); + + // If the latest message carries image attachments the selected model + // cannot accept, the agent will degrade (omit the bytes). Record it. + const latestMessage = messages[messages.length - 1]; + 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 +189,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 df95c52b0..df30155ed 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"; @@ -35,6 +36,121 @@ 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), + ATTACHMENT_MAX_TEXT_BYTES, + ); + 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({ @@ -64,6 +180,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 ({ @@ -83,6 +204,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 @@ -103,21 +225,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 any inline-text attachments into this turn's content (not - // the system prompt) so they stay bound to the turn they were - // attached to and are re-emitted per turn from the persisted parts. - const text = getUserMessageText(message); - const attachmentsBlock = formatAttachmentsForPrompt( - getUserMessageAttachments(message), - ATTACHMENT_MAX_TEXT_BYTES, - ); - 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') { @@ -132,7 +261,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..1696a9afb 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/messageAttachments.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/messageAttachments.tsx @@ -8,39 +8,72 @@ import { useState } from "react"; 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}`; +} + +export const MessageAttachments = ({ attachments, chatId, className }: MessageAttachmentsProps) => { const [activeAttachment, setActiveAttachment] = useState(null); if (attachments.length === 0) { return null; } + const activeImageSrc = + activeAttachment?.kind === 'blob' && activeAttachment.mediaType.startsWith('image/') + ? getAttachmentServingUrl(chatId, activeAttachment.attachmentId) + : undefined; + return ( <>
- {attachments.map((attachment, index) => ( - - ))} + {attachments.map((attachment, index) => { + if (attachment.kind === 'blob' && attachment.mediaType.startsWith('image/')) { + return ( + + ); + } + + 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 75828cb32..13a568544 100644 --- a/packages/web/src/features/chat/attachmentUtils.ts +++ b/packages/web/src/features/chat/attachmentUtils.ts @@ -1,60 +1,69 @@ 'use client'; import { + ATTACHMENT_ALLOWED_IMAGE_MIME_TYPES, ATTACHMENT_ALLOWED_TEXT_EXTENSIONS, ATTACHMENT_ALLOWED_TEXT_MIME_TYPES, ATTACHMENT_MAX_COUNT, - ATTACHMENT_MAX_FILENAME_LENGTH, + ATTACHMENT_MAX_IMAGE_BYTES, ATTACHMENT_MAX_TEXT_BYTES, } from "./constants"; -import { AttachmentData, TextAttachment } from "./types"; - -// Normalizes an untrusted filename: keeps only the basename, drops control -// characters (which could break the prompt's `` tag -// or the UI), collapses whitespace, and caps the length while preserving the -// extension. Long/abusive names are truncated rather than rejected. -export const sanitizeFilename = (name: string): string => { - const basename = name.split(/[\\/]/).pop() ?? name; - const cleaned = Array.from(basename) - .filter((char) => { - const code = char.charCodeAt(0); - return code >= 32 && code !== 127; - }) - .join('') - .replace(/\s+/g, ' ') - .trim() || 'attachment'; - - if (cleaned.length <= ATTACHMENT_MAX_FILENAME_LENGTH) { - return cleaned; - } +import { AttachmentData } from "./types"; +import { sanitizeFilename } from "./attachments/filename"; + +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; +}; - const dotIndex = cleaned.lastIndexOf('.'); - const extension = dotIndex > 0 ? cleaned.slice(dotIndex) : ''; - const stem = dotIndex > 0 ? cleaned.slice(0, dotIndex) : cleaned; - const keep = Math.max(1, ATTACHMENT_MAX_FILENAME_LENGTH - extension.length - 1); - return `${stem.slice(0, keep)}…${extension}`; -} +// 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; +}; -// 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 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 the `accept` map for react-dropzone (and the native file picker) so -// the OS dialog and drag overlay only surface supported text file types. The +// 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. -export const getAttachmentDropzoneAccept = (): Record => { +// 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}`), @@ -62,17 +71,39 @@ 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; } -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 => { @@ -103,6 +134,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(); @@ -117,12 +152,16 @@ export type ReadFilesResult = { errors: string[]; }; -// Reads and validates a set of files into pending text attachments, enforcing -// the per-message count, per-file size, and allowed-type caps. Rejected files -// produce a human-readable error message instead of throwing. +// Reads and validates a set of files into pending attachments, enforcing the +// per-message count, per-file size, and allowed-type caps. Text files are read +// inline; image files (only when `allowImages`) are turned into pending image +// attachments with a local preview and an `uploading` status (the actual +// upload is kicked off by the caller). Rejected files produce a human-readable +// error message instead of throwing. export const readFilesAsAttachments = async ( files: File[], existingCount: number, + { allowImages }: { allowImages: boolean }, ): Promise => { const attachments: PendingAttachment[] = []; const errors: string[] = []; @@ -134,31 +173,81 @@ export const readFilesAsAttachments = async ( break; } - if (!isAllowedTextFile(file)) { - errors.push(`${file.name}: unsupported file type (text files only).`); - continue; - } - - if (file.size > ATTACHMENT_MAX_TEXT_BYTES) { - errors.push(`${file.name}: exceeds the ${Math.round(ATTACHMENT_MAX_TEXT_BYTES / 1024)}KB limit.`); + if (isAllowedTextFile(file)) { + if (file.size > ATTACHMENT_MAX_TEXT_BYTES) { + errors.push(`${file.name}: exceeds the ${Math.round(ATTACHMENT_MAX_TEXT_BYTES / 1024)}KB limit.`); + continue; + } + try { + const text = await readAsText(file); + attachments.push({ + id: crypto.randomUUID(), + kind: 'text', + filename: sanitizeFilename(file.name), + mediaType: file.type || 'text/plain', + sizeBytes: file.size, + text, + }); + count++; + } 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; + } attachments.push({ id: crypto.randomUUID(), - 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', }); count++; - } catch { - errors.push(`${file.name}: failed to read file.`); + 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/filename.ts b/packages/web/src/features/chat/attachments/filename.ts new file mode 100644 index 000000000..81ae4259f --- /dev/null +++ b/packages/web/src/features/chat/attachments/filename.ts @@ -0,0 +1,29 @@ +import { ATTACHMENT_MAX_FILENAME_LENGTH } from '../constants'; + +// Normalizes an untrusted filename: keeps only the basename, drops control +// characters (which could break the prompt's `` tag +// or the UI), collapses whitespace, and caps the length while preserving the +// extension. Long/abusive names are truncated rather than rejected. 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; + const cleaned = Array.from(basename) + .filter((char) => { + const code = char.charCodeAt(0); + return code >= 32 && code !== 127; + }) + .join('') + .replace(/\s+/g, ' ') + .trim() || 'attachment'; + + if (cleaned.length <= ATTACHMENT_MAX_FILENAME_LENGTH) { + return cleaned; + } + + const dotIndex = cleaned.lastIndexOf('.'); + const extension = dotIndex > 0 ? cleaned.slice(dotIndex) : ''; + const stem = dotIndex > 0 ? cleaned.slice(0, dotIndex) : cleaned; + const keep = Math.max(1, ATTACHMENT_MAX_FILENAME_LENGTH - extension.length - 1); + return `${stem.slice(0, keep)}…${extension}`; +}; 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..a695bf358 --- /dev/null +++ b/packages/web/src/features/chat/attachments/storage.ts @@ -0,0 +1,84 @@ +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; + /** 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)); + } + + 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 090320e92..d77371afa 100644 --- a/packages/web/src/features/chat/components/chatBox/attachmentTray.tsx +++ b/packages/web/src/features/chat/components/chatBox/attachmentTray.tsx @@ -2,7 +2,7 @@ import { VscodeFileIcon } from "@/app/components/vscodeFileIcon"; 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"; @@ -24,37 +24,77 @@ export const AttachmentTray = ({ attachments, onRemove, className }: AttachmentT <>
{attachments.map((attachment) => ( -
- - + {attachment.status === 'uploading' && ( +
+ +
+ )} + {attachment.status === 'error' && ( +
+ +
+ )} + +
+ ) : ( +
- - -
+ + +
+ ) ))}
!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}` : ''}. -
-                    {text}
-                
+ {imageSrc ? ( + // eslint-disable-next-line @next/next/no-img-element + {filename + ) : ( +
+                        {text}
+                    
+ )} ) diff --git a/packages/web/src/features/chat/components/chatBox/chatBox.tsx b/packages/web/src/features/chat/components/chatBox/chatBox.tsx index e8a624cb8..1d7ccfd3f 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBox.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBox.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { AttachmentData, CustomEditor, MentionElement, RenderElementPropsFor, SearchScope } from "@/features/chat/types"; import { insertMention, slateContentToString } from "@/features/chat/utils"; -import { PendingAttachment, readFilesAsAttachments, toAttachmentData } from "@/features/chat/attachmentUtils"; +import { PendingAttachment, PendingImageAttachment, readFilesAsAttachments, toAttachmentData, uploadImageAttachment } from "@/features/chat/attachmentUtils"; import { AttachmentButton } from "./attachmentButton"; import { AttachmentTray } from "./attachmentTray"; import { cn } from "@/lib/utils"; @@ -95,12 +95,51 @@ const ChatBoxComponent = ({ const [attachments, setAttachments] = useState([]); const pathname = usePathname(); + // Whether the selected model can accept image input (from #1372). Image + // attachments are gated on this; text attachments are always allowed. + const supportsImages = useMemo( + () => selectedLanguageModel?.inputModalities?.includes('image') ?? false, + [selectedLanguageModel], + ); + + // Uploads an image attachment's bytes and reflects the outcome back into the + // tray (status + server attachment id). + const uploadAndTrackImage = useCallback(async (item: PendingImageAttachment) => { + try { + const result = await uploadImageAttachment(item.file); + setAttachments((prev) => prev.map((attachment) => + attachment.id === item.id && attachment.kind === 'image' + ? { + ...attachment, + status: 'uploaded', + attachmentId: result.attachmentId, + mediaType: result.mediaType, + sizeBytes: result.sizeBytes, + } + : attachment)); + } catch (error) { + const message = error instanceof Error ? error.message : 'upload failed.'; + setAttachments((prev) => prev.map((attachment) => + attachment.id === item.id && attachment.kind === 'image' + ? { ...attachment, status: 'error', error: message } + : attachment)); + toast({ + description: `⚠️ ${item.filename}: ${message}`, + variant: "destructive", + }); + } + }, [toast]); + const onAddFiles = useCallback(async (files: File[]) => { if (files.length === 0) { return; } - const { attachments: added, errors } = await readFilesAsAttachments(files, attachments.length); + const { attachments: added, errors } = await readFilesAsAttachments( + files, + attachments.length, + { allowImages: supportsImages }, + ); if (added.length > 0) { setAttachments((prev) => [...prev, ...added]); } @@ -111,12 +150,26 @@ const ChatBoxComponent = ({ }); } + // Upload image attachments immediately (upload-on-select); their refs + // are included at submit once the upload completes. + for (const item of added) { + if (item.kind === 'image') { + void uploadAndTrackImage(item); + } + } + // Return focus to the prompt input so the user can keep typing. ReactEditor.focus(editor); - }, [attachments.length, toast, editor]); + }, [attachments.length, toast, editor, supportsImages, uploadAndTrackImage]); const removeAttachment = useCallback((id: string) => { - setAttachments((prev) => prev.filter((attachment) => attachment.id !== id)); + setAttachments((prev) => { + const target = prev.find((attachment) => attachment.id === id); + if (target?.kind === 'image') { + URL.revokeObjectURL(target.previewUrl); + } + return prev.filter((attachment) => attachment.id !== id); + }); }, []); // Allow an ancestor pane-level drop zone to forward dropped files into this @@ -158,7 +211,7 @@ const ChatBoxComponent = ({ const { isSubmitDisabled, isSubmitDisabledReason } = useMemo((): { isSubmitDisabled: true, - isSubmitDisabledReason: "empty" | "redirecting" | "generating" | "no-language-model-selected" + isSubmitDisabledReason: "empty" | "redirecting" | "generating" | "no-language-model-selected" | "uploading" } | { isSubmitDisabled: false, isSubmitDisabledReason: undefined, @@ -170,6 +223,15 @@ const ChatBoxComponent = ({ } } + // Block submission until in-flight image uploads finish so their refs + // are available when the message is built. + if (attachments.some((attachment) => attachment.kind === 'image' && attachment.status === 'uploading')) { + return { + isSubmitDisabled: true, + isSubmitDisabledReason: "uploading", + } + } + if (isRedirecting) { return { isSubmitDisabled: true, @@ -197,7 +259,7 @@ const ChatBoxComponent = ({ isSubmitDisabledReason: undefined, } - }, [editor.children, isRedirecting, isTurnInProgress, selectedLanguageModel, attachments.length]) + }, [editor.children, isRedirecting, isTurnInProgress, selectedLanguageModel, attachments]) const { requiresLogin, @@ -220,6 +282,13 @@ const ChatBoxComponent = ({ }); } + if (isSubmitDisabledReason === "uploading") { + toast({ + description: "⚠️ Please wait for image uploads to finish", + variant: "destructive", + }); + } + return; } @@ -242,7 +311,18 @@ const ChatBoxComponent = ({ return; } - _onSubmit(editor.children, editor, attachments.map(toAttachmentData)); + const attachmentData = attachments + .map(toAttachmentData) + .filter((attachment): attachment is AttachmentData => attachment !== undefined); + _onSubmit(editor.children, editor, attachmentData); + + // Release the pre-send image preview object URLs now that the message + // has been handed off. + attachments.forEach((attachment) => { + if (attachment.kind === 'image') { + URL.revokeObjectURL(attachment.previewUrl); + } + }); setAttachments([]); }, [ isSubmitDisabled, @@ -414,6 +494,11 @@ const ChatBoxComponent = ({ className="mb-1.5" /> )} + {attachments.some((attachment) => attachment.kind === 'image') && !supportsImages && ( +

+ Images won't be sent: the selected model doesn't support image input. +

+ )} {isRedirecting ? ( diff --git a/packages/web/src/features/chat/components/chatBox/chatPaneDropzone.tsx b/packages/web/src/features/chat/components/chatBox/chatPaneDropzone.tsx index f7b5d0f59..5251b6059 100644 --- a/packages/web/src/features/chat/components/chatBox/chatPaneDropzone.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatPaneDropzone.tsx @@ -27,7 +27,10 @@ export const ChatPaneDropzone = ({ onFilesDropped, disabled, className, children const [dragFileCount, setDragFileCount] = useState(0); const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({ - accept: getAttachmentDropzoneAccept(), + // Accept images at the dropzone layer regardless of model capability; + // the chat box's add handler applies the authoritative image gate (and + // surfaces a precise message when the selected model is text-only). + accept: getAttachmentDropzoneAccept(true), multiple: true, noClick: true, noKeyboard: true, @@ -39,7 +42,7 @@ export const ChatPaneDropzone = ({ onFilesDropped, disabled, className, children } if (fileRejections.length > 0) { toast({ - description: `⚠️ Unsupported file type: ${fileRejections.map((rejection) => rejection.file.name).join(', ')}. Text files only.`, + description: `⚠️ Unsupported file type: ${fileRejections.map((rejection) => rejection.file.name).join(', ')}.`, variant: "destructive", }); } diff --git a/packages/web/src/features/chat/constants.ts b/packages/web/src/features/chat/constants.ts index 4036e8c80..b1d30e04d 100644 --- a/packages/web/src/features/chat/constants.ts +++ b/packages/web/src/features/chat/constants.ts @@ -20,6 +20,11 @@ export const ATTACHMENT_MAX_TEXT_BYTES = 256 * 1024; // 256KB per file export const ATTACHMENT_MAX_COUNT = 5; // per message export const ATTACHMENT_MAX_FILENAME_LENGTH = 200; // characters +// Client-side image size cap, used for early rejection before upload. The +// server enforces the authoritative cap via SOURCEBOT_CHAT_ATTACHMENT_MAX_IMAGE_BYTES +// (this mirrors its default). +export const ATTACHMENT_MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10MB per image + // Allowlist for inline-text attachments. Files are accepted if their MIME type // starts with `text/`, exactly matches an entry here, or their extension is in // ATTACHMENT_ALLOWED_TEXT_EXTENSIONS. Many code files report an empty MIME type @@ -33,6 +38,17 @@ export const ATTACHMENT_ALLOWED_TEXT_MIME_TYPES = [ 'application/toml', ]; +// Allowlist for binary image attachments. Validated server-side by magic +// bytes (never by client MIME/extension). `image/svg+xml` is intentionally +// excluded (XML/script surface). Used client-side only to build the file +// picker's `accept` filter and to gate the image-attach affordance. +export const ATTACHMENT_ALLOWED_IMAGE_MIME_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', +] as const; + export const ATTACHMENT_ALLOWED_TEXT_EXTENSIONS = [ 'txt', 'md', 'markdown', 'log', 'csv', 'tsv', 'json', 'jsonl', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'env', 'xml', 'html', 'css', 'scss', diff --git a/packages/web/src/features/chat/types.ts b/packages/web/src/features/chat/types.ts index 5e05493c1..d43516c29 100644 --- a/packages/web/src/features/chat/types.ts +++ b/packages/web/src/features/chat/types.ts @@ -104,9 +104,10 @@ export type SBChatMessageToolTypes = { }; // A user-provided file attachment. The `text` variant carries the file's -// extracted text inline (used for text/code/structured files); binary -// attachments (images, PDFs) will later add a `blob` variant that references -// stored bytes by id instead of inlining them. +// extracted text inline (used for text/code/structured files). The `blob` +// variant references stored bytes by id (used for binary attachments like +// images that cannot be inlined as text); the bytes live in the StorageBackend +// and never travel in the `messages` JSON. export const textAttachmentSchema = z.object({ kind: z.literal('text'), filename: z.string(), @@ -116,8 +117,18 @@ export const textAttachmentSchema = z.object({ }); export type TextAttachment = z.infer; +export const blobAttachmentSchema = z.object({ + kind: z.literal('blob'), + attachmentId: z.string(), + filename: z.string(), + mediaType: z.string(), + sizeBytes: z.number(), +}); +export type BlobAttachment = z.infer; + export const attachmentDataSchema = z.discriminatedUnion('kind', [ textAttachmentSchema, + blobAttachmentSchema, ]); export type AttachmentData = z.infer; diff --git a/packages/web/src/features/chat/utils.server.ts b/packages/web/src/features/chat/utils.server.ts index 0b04226d8..44df6aaff 100644 --- a/packages/web/src/features/chat/utils.server.ts +++ b/packages/web/src/features/chat/utils.server.ts @@ -1,12 +1,14 @@ import 'server-only'; import { getAnonymousId } from '@/lib/anonymousId'; -import { Chat, Prisma, PrismaClient, User } from '@sourcebot/db'; +import { AttachmentStatus, Chat, ChatVisibility, Prisma, PrismaClient, User } from '@sourcebot/db'; import { LanguageModel } from '@sourcebot/schemas/v3/languageModel.type'; import { env, loadConfig } from '@sourcebot/shared'; import fs from 'fs'; import path from 'path'; -import { LanguageModelInfo, SBChatMessage } from './types'; +import { BlobAttachment, LanguageModelInfo, SBChatMessage } from './types'; +import { getUserMessageAttachments } from './utils'; +import { getStorageBackend } from './attachments/storage'; import { resolveModelInputModalities, resolveModelSupportedDocumentTypes } from './modelCapabilities'; import { hasEntitlement } from '@/lib/entitlements'; import { ServiceError } from '@/lib/serviceError'; @@ -78,6 +80,155 @@ export const isChatSharedWithUser = async ({ return share !== null; }; +/** + * Resolves a (possibly anonymous) user's access to a chat. This is the single + * source of truth for the "can view this chat" rule, shared by `getChatInfo` + * and the attachment serving route so the two cannot drift: a PUBLIC chat is + * viewable by anyone in the org; a PRIVATE chat only by its owner or users it + * has been explicitly shared with. + */ +export const resolveChatAccess = async ({ + prisma, chat, user, +}: { + prisma: PrismaClient; + chat: Chat; + user: User | undefined; +}): Promise<{ isOwner: boolean; isSharedWithUser: boolean; canView: boolean }> => { + const isOwner = await isOwnerOfChat(chat, user); + const isSharedWithUser = await isChatSharedWithUser({ prisma, chatId: chat.id, userId: user?.id }); + const canView = chat.visibility !== ChatVisibility.PRIVATE || isOwner || isSharedWithUser; + return { isOwner, isSharedWithUser, canView }; +}; + +/** + * Verifies and commits the binary (blob) attachments referenced by the latest + * user message, then links them to the chat. Each referenced `attachmentId` + * must exist in this org, have been uploaded by this user, and still be + * `PENDING` (never trust client ids). Already-linked ids are treated as a + * no-op so re-sends / approval continuations are idempotent. On success the + * blobs are linked via `ChatAttachment` and flipped to `COMMITTED`. + * + * Returns a `ServiceError` to reject the request, or `null` when there is + * nothing to commit / the commit succeeded. + */ +export const commitMessageAttachments = async ({ + prisma, chatId, orgId, userId, message, +}: { + prisma: PrismaClient; + chatId: string; + orgId: number; + userId: string | undefined; + message: Pick | undefined; +}): Promise => { + if (!message) { + return null; + } + + const blobRefs = getUserMessageAttachments(message) + .filter((attachment): attachment is BlobAttachment => attachment.kind === 'blob'); + + if (blobRefs.length === 0) { + return null; + } + + // Anonymous users cannot upload binary attachments, so a blob ref from an + // unauthenticated request can only be a forged/replayed id. + if (!userId) { + return { + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + message: 'Anonymous users cannot attach files.', + } satisfies ServiceError; + } + + const ids = [...new Set(blobRefs.map((ref) => ref.attachmentId))]; + + const [attachments, existingLinks] = await Promise.all([ + prisma.attachment.findMany({ where: { id: { in: ids }, orgId } }), + prisma.chatAttachment.findMany({ where: { chatId, attachmentId: { in: ids } } }), + ]); + + const attachmentById = new Map(attachments.map((attachment) => [attachment.id, attachment])); + const alreadyLinkedIds = new Set(existingLinks.map((link) => link.attachmentId)); + + const idsToCommit: string[] = []; + for (const id of ids) { + // Already linked to this chat (idempotent re-send): nothing to do. + if (alreadyLinkedIds.has(id)) { + continue; + } + + const attachment = attachmentById.get(id); + if ( + !attachment || + attachment.uploadedById !== userId || + attachment.status !== AttachmentStatus.PENDING + ) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: 'Invalid or unauthorized attachment reference.', + } satisfies ServiceError; + } + idsToCommit.push(id); + } + + if (idsToCommit.length > 0) { + await prisma.$transaction([ + prisma.chatAttachment.createMany({ + data: idsToCommit.map((attachmentId) => ({ chatId, attachmentId })), + skipDuplicates: true, + }), + prisma.attachment.updateMany({ + where: { id: { in: idsToCommit } }, + data: { status: AttachmentStatus.COMMITTED }, + }), + ]); + } + + return null; +}; + +/** + * Deletes any of the given attachments that no longer have a `ChatAttachment` + * link (and their stored bytes). Bytes are never removed by DB cascade, so this + * is the refcount-aware byte sweep invoked after a chat (and its links) is + * deleted. Best-effort on the storage layer: a missing/failed byte delete does + * not block removing the DB row. + */ +export const deleteOrphanedAttachments = async ({ + prisma, attachmentIds, +}: { + prisma: PrismaClient; + attachmentIds: string[]; +}): Promise => { + if (attachmentIds.length === 0) { + return; + } + + const remainingLinks = await prisma.chatAttachment.findMany({ + where: { attachmentId: { in: attachmentIds } }, + select: { attachmentId: true }, + }); + const stillLinked = new Set(remainingLinks.map((link) => link.attachmentId)); + const orphanedIds = attachmentIds.filter((id) => !stillLinked.has(id)); + + if (orphanedIds.length === 0) { + return; + } + + const orphans = await prisma.attachment.findMany({ + where: { id: { in: orphanedIds } }, + select: { id: true, storageKey: true }, + }); + + const storage = getStorageBackend(); + await Promise.all(orphans.map((orphan) => + storage.delete(orphan.storageKey).catch(() => { /* best effort */ }))); + + await prisma.attachment.deleteMany({ where: { id: { in: orphanedIds } } }); +}; + export const updateChatMessages = async ({ prisma, chatId, messages, }: { diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts index b6b84f592..fb53366b5 100644 --- a/packages/web/src/lib/posthogEvents.ts +++ b/packages/web/src/lib/posthogEvents.ts @@ -204,6 +204,18 @@ export type PosthogEventMap = { */ selectedRepos?: string[], }, + chat_attachment_uploaded: { + source?: string, + mediaType: string, + sizeBytes: number, + }, + chat_attachment_degraded: { + chatId: string, + source?: string, + droppedImageCount: number, + modelProvider: string, + model: string, + }, ask_mcp_turn_completed: { chatId: string, source?: SourcebotWebClientSource, From d31052a3c14e589eb31a1d2ef2a61b9611940653 Mon Sep 17 00:00:00 2001 From: whoisthey Date: Sat, 27 Jun 2026 12:10:50 -0700 Subject: [PATCH 2/4] make attachment pills consistent --- .../chatThread/messageAttachments.tsx | 46 +++++++---- .../components/chatBox/attachmentTray.tsx | 77 +++++++++++-------- 2 files changed, 78 insertions(+), 45 deletions(-) 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 1696a9afb..0f63405ea 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/messageAttachments.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/messageAttachments.tsx @@ -1,6 +1,7 @@ '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 { AttachmentData } from "@/features/chat/types"; import { cn } from "@/lib/utils"; @@ -34,21 +35,38 @@ export const MessageAttachments = ({ attachments, chatId, className }: MessageAt
{attachments.map((attachment, index) => { if (attachment.kind === 'blob' && attachment.mediaType.startsWith('image/')) { + const imageSrc = getAttachmentServingUrl(chatId, attachment.attachmentId); return ( - + + + + + + {/* eslint-disable-next-line @next/next/no-img-element */} + {attachment.filename} + + ); } diff --git a/packages/web/src/features/chat/components/chatBox/attachmentTray.tsx b/packages/web/src/features/chat/components/chatBox/attachmentTray.tsx index d77371afa..a08c9bf30 100644 --- a/packages/web/src/features/chat/components/chatBox/attachmentTray.tsx +++ b/packages/web/src/features/chat/components/chatBox/attachmentTray.tsx @@ -1,6 +1,7 @@ 'use client'; import { VscodeFileIcon } from "@/app/components/vscodeFileIcon"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; import { cn } from "@/lib/utils"; import { AlertCircle, Loader2, X } from "lucide-react"; import { useState } from "react"; @@ -25,42 +26,56 @@ export const AttachmentTray = ({ attachments, onRemove, className }: AttachmentT
{attachments.map((attachment) => ( attachment.kind === 'image' ? ( -
- + + +
+ {/* eslint-disable-next-line @next/next/no-img-element */} {attachment.filename} - - {attachment.status === 'uploading' && ( -
- -
- )} - {attachment.status === 'error' && ( -
- -
- )} - -
+ + ) : (
Date: Sat, 27 Jun 2026 16:27:46 -0700 Subject: [PATCH 3/4] add attachment preview cache to avoid racey behaviour with chat thread creation --- .../chatThread/messageAttachments.tsx | 11 +++++++++-- .../chat/attachments/attachmentPreviewCache.ts | 14 ++++++++++++++ .../chat/components/chatBox/chatBox.tsx | 17 ++++++++++++----- 3 files changed, 35 insertions(+), 7 deletions(-) create mode 100644 packages/web/src/features/chat/attachments/attachmentPreviewCache.ts 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 0f63405ea..9536218c9 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/messageAttachments.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/messageAttachments.tsx @@ -3,6 +3,7 @@ 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 } from "@/features/chat/attachments/attachmentPreviewCache"; import { AttachmentData } from "@/features/chat/types"; import { cn } from "@/lib/utils"; import { useState } from "react"; @@ -18,6 +19,12 @@ 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); @@ -27,7 +34,7 @@ export const MessageAttachments = ({ attachments, chatId, className }: MessageAt const activeImageSrc = activeAttachment?.kind === 'blob' && activeAttachment.mediaType.startsWith('image/') - ? getAttachmentServingUrl(chatId, activeAttachment.attachmentId) + ? getBlobImageSrc(chatId, activeAttachment.attachmentId) : undefined; return ( @@ -35,7 +42,7 @@ export const MessageAttachments = ({ attachments, chatId, className }: MessageAt
{attachments.map((attachment, index) => { if (attachment.kind === 'blob' && attachment.mediaType.startsWith('image/')) { - const imageSrc = getAttachmentServingUrl(chatId, attachment.attachmentId); + const imageSrc = getBlobImageSrc(chatId, attachment.attachmentId); return ( 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..5ecf29f63 --- /dev/null +++ b/packages/web/src/features/chat/attachments/attachmentPreviewCache.ts @@ -0,0 +1,14 @@ +'use client'; + +// Client-only cache mapping an attachment id to its local object URL, so a +// just-sent image renders instantly instead of 404ing on the serving route +// until the attachment is committed. Entries live for the page's lifetime. +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); +} diff --git a/packages/web/src/features/chat/components/chatBox/chatBox.tsx b/packages/web/src/features/chat/components/chatBox/chatBox.tsx index e3a3a0321..93f5721ea 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBox.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBox.tsx @@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { AttachmentData, CustomEditor, MentionElement, RenderElementPropsFor, SearchScope } from "@/features/chat/types"; import { insertMention, slateContentToString } from "@/features/chat/utils"; import { createPastedTextAttachment, getSubmittedTextBytes, PendingAttachment, PendingImageAttachment, readFilesAsAttachments, shouldAutoConvertPaste, toAttachmentData, uploadImageAttachment } from "@/features/chat/attachmentUtils"; +import { setAttachmentPreviewUrl } from "@/features/chat/attachments/attachmentPreviewCache"; import { AttachmentButton } from "./attachmentButton"; import { AttachmentTray } from "./attachmentTray"; import { cn } from "@/lib/utils"; @@ -370,16 +371,22 @@ const ChatBoxComponent = ({ const attachmentData = attachments .map(toAttachmentData) .filter((attachment): attachment is AttachmentData => attachment !== undefined); - _onSubmit(editor.children, editor, attachmentData); - setSubmittedAttachments(attachments); - // Release the pre-send image preview object URLs now that the message - // has been handed off. + // Stash uploaded previews so the persisted message renders them + // instantly; release the rest (not part of the message). attachments.forEach((attachment) => { - if (attachment.kind === 'image') { + if (attachment.kind !== 'image') { + return; + } + if (attachment.status === 'uploaded' && attachment.attachmentId) { + setAttachmentPreviewUrl(attachment.attachmentId, attachment.previewUrl); + } else { URL.revokeObjectURL(attachment.previewUrl); } }); + + _onSubmit(editor.children, editor, attachmentData); + setSubmittedAttachments(attachments); setAttachments([]); }, [ isSubmitDisabled, From ac42b7339198877d7370dcd6cb8fd2e030e78b3d Mon Sep 17 00:00:00 2001 From: whoisthey Date: Sat, 27 Jun 2026 17:17:19 -0700 Subject: [PATCH 4/4] harden attachment caps, cleanup, and cold-cache capability resolution - enforce per-message image count and per-turn text budget server-side (reject forged/oversized requests; mirror image-count cap client-side) - serving route: 404 on missing bytes via storage.stat() before headers, and set Content-Length from the on-disk size - allow SOURCEBOT_CHAT_ATTACHMENT_ORPHAN_TTL_HOURS=0 to disable the sweep (.positive -> .nonnegative, matching the pruner and docs) - block only on the first (cold) models.dev fetch when resolving model capabilities, bounded by a short budget, so images aren't silently dropped right after a process start; airgapped pays one short wait - release preview object URLs once the served image loads to bound memory - carry only text attachments across the login/upgrade redirect Co-authored-by: Cursor --- packages/shared/src/env.server.ts | 5 +- .../attachments/[attachmentId]/route.ts | 12 +++- .../web/src/app/api/(server)/ee/chat/route.ts | 22 ++++++- .../chatThread/messageAttachments.tsx | 61 ++++++++++++++++++- .../web/src/features/chat/attachmentUtils.ts | 20 +++--- .../attachments/attachmentPreviewCache.ts | 14 ++++- .../src/features/chat/attachments/storage.ts | 17 ++++++ .../chat/components/chatBox/chatBox.tsx | 18 +++++- packages/web/src/features/chat/constants.ts | 5 ++ .../chat/modelCapabilities.server.test.ts | 22 +++---- .../features/chat/modelCapabilities.server.ts | 4 +- .../features/chat/modelsDevCatalog.server.ts | 32 +++++++--- .../web/src/features/chat/utils.server.ts | 10 +++ packages/web/src/features/chat/utils.ts | 12 ++++ 14 files changed, 211 insertions(+), 43 deletions(-) diff --git a/packages/shared/src/env.server.ts b/packages/shared/src/env.server.ts index b76ff430b..b832bce4f 100644 --- a/packages/shared/src/env.server.ts +++ b/packages/shared/src/env.server.ts @@ -332,10 +332,11 @@ const options = { /** * 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. + * 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().positive().default(24), + 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'), 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 index 377a8b761..b2d291fa2 100644 --- 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 @@ -70,8 +70,15 @@ export const GET = apiHandler(async ( 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); - // Surface a missing-bytes condition as a 404 rather than a hung stream. const webStream = Readable.toWeb(nodeStream) as ReadableStream; // Build a header-safe Content-Disposition: an ASCII fallback plus an @@ -85,7 +92,8 @@ export const GET = apiHandler(async ( return new Response(webStream, { headers: { 'Content-Type': attachment.mediaType, - 'Content-Length': attachment.sizeBytes.toString(), + // 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. 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 7b3c69186..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,7 +3,8 @@ 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, getUserMessageAttachments } from "@/features/chat/utils"; +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"; @@ -75,6 +76,22 @@ 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. @@ -83,7 +100,7 @@ export const POST = apiHandler(async (req: NextRequest) => { chatId: id, orgId: org.id, userId: user?.id, - message: messages[messages.length - 1], + message: latestMessage, }); if (attachmentError) { return attachmentError; @@ -112,7 +129,6 @@ export const POST = apiHandler(async (req: NextRequest) => { // If the latest message carries image attachments the selected model // cannot accept, the agent will degrade (omit the bytes). Record it. - const latestMessage = messages[messages.length - 1]; const latestImageAttachmentCount = latestMessage ? getUserMessageAttachments(latestMessage).filter( (attachment) => attachment.kind === 'blob' && attachment.mediaType.startsWith('image/'), 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 9536218c9..bdbf3b2e7 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/messageAttachments.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/messageAttachments.tsx @@ -3,10 +3,14 @@ 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 } from "@/features/chat/attachments/attachmentPreviewCache"; +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[]; @@ -27,6 +31,59 @@ const getBlobImageSrc = (chatId: string, attachmentId: string): string => { 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; diff --git a/packages/web/src/features/chat/attachmentUtils.ts b/packages/web/src/features/chat/attachmentUtils.ts index 4ac21c0e9..df3c5d083 100644 --- a/packages/web/src/features/chat/attachmentUtils.ts +++ b/packages/web/src/features/chat/attachmentUtils.ts @@ -5,6 +5,7 @@ import { 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, @@ -213,18 +214,18 @@ export type ReadFilesResult = { errors: string[]; }; -// Reads and validates a set of files into pending attachments, enforcing the -// per-file size and allowed-type caps (the aggregate per-turn text budget is -// enforced at submit time). Text files are read inline; image files (only when -// `allowImages`) are turned into pending image attachments with a local preview -// and an `uploading` status (the actual upload is kicked off by the caller). -// Rejected files produce a human-readable error message instead of throwing. +// 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 }: { allowImages: boolean }, + { 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)) { @@ -258,6 +259,10 @@ export const readFilesAsAttachments = async ( 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: 'image', @@ -268,6 +273,7 @@ export const readFilesAsAttachments = async ( file, status: 'uploading', }); + imageCount++; continue; } diff --git a/packages/web/src/features/chat/attachments/attachmentPreviewCache.ts b/packages/web/src/features/chat/attachments/attachmentPreviewCache.ts index 5ecf29f63..0045975fc 100644 --- a/packages/web/src/features/chat/attachments/attachmentPreviewCache.ts +++ b/packages/web/src/features/chat/attachments/attachmentPreviewCache.ts @@ -1,8 +1,8 @@ 'use client'; // Client-only cache mapping an attachment id to its local object URL, so a -// just-sent image renders instantly instead of 404ing on the serving route -// until the attachment is committed. Entries live for the page's lifetime. +// 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 => { @@ -12,3 +12,13 @@ export const setAttachmentPreviewUrl = (attachmentId: string, objectUrl: string) 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/storage.ts b/packages/web/src/features/chat/attachments/storage.ts index a695bf358..f481e454a 100644 --- a/packages/web/src/features/chat/attachments/storage.ts +++ b/packages/web/src/features/chat/attachments/storage.ts @@ -17,6 +17,11 @@ export interface StorageBackend { 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. */ @@ -55,6 +60,18 @@ export class LocalFsStorageBackend implements StorageBackend { 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)); } diff --git a/packages/web/src/features/chat/components/chatBox/chatBox.tsx b/packages/web/src/features/chat/components/chatBox/chatBox.tsx index 93f5721ea..6720d3f0e 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBox.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBox.tsx @@ -36,6 +36,15 @@ export interface ChatBoxHandle { addFiles: (files: File[]) => void; } +// Only inline-text attachments survive the login/upgrade redirect: image blobs +// require an authenticated, entitled upload, so a redirected sender can't have +// one, and a stashed blob ref would only fail to commit on re-submit. +const getRedirectSafeAttachments = (attachments: PendingAttachment[]): AttachmentData[] => { + return attachments + .map(toAttachmentData) + .filter((attachment): attachment is AttachmentData => attachment?.kind === 'text'); +} + interface ChatBoxProps { onSubmit: (children: Descendant[], editor: CustomEditor, attachments: AttachmentData[]) => void; onStop?: () => void; @@ -177,7 +186,10 @@ const ChatBoxComponent = ({ const { attachments: added, errors } = await readFilesAsAttachments( files, - { allowImages: supportsImages }, + { + allowImages: supportsImages, + existingImageCount: attachments.filter((attachment) => attachment.kind === 'image').length, + }, ); if (added.length > 0) { setAttachments((prev) => [...prev, ...added]); @@ -352,7 +364,7 @@ const ChatBoxComponent = ({ if (requiresLogin) { sessionStorage.setItem( PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY, - JSON.stringify({ pathname, children: editor.children, attachments: attachments.map(toAttachmentData) }), + JSON.stringify({ pathname, children: editor.children, attachments: getRedirectSafeAttachments(attachments) }), ); captureEvent('wa_askgh_login_wall_prompted', {}); setIsLoginDialogOpen(true); @@ -362,7 +374,7 @@ const ChatBoxComponent = ({ if (requiresUpgrade) { sessionStorage.setItem( PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY, - JSON.stringify({ pathname, children: editor.children, attachments: attachments.map(toAttachmentData) }), + JSON.stringify({ pathname, children: editor.children, attachments: getRedirectSafeAttachments(attachments) }), ); setIsUpsellDialogOpen(true); return; diff --git a/packages/web/src/features/chat/constants.ts b/packages/web/src/features/chat/constants.ts index ab5654c64..f37b02138 100644 --- a/packages/web/src/features/chat/constants.ts +++ b/packages/web/src/features/chat/constants.ts @@ -23,6 +23,11 @@ export const ATTACHMENT_MAX_TURN_TEXT_BYTES = 256 * 1024; // 256KB per turn // (this mirrors its default). export const ATTACHMENT_MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10MB per image +// Max image (blob) attachments per message. Enforced server-side in +// `commitMessageAttachments` (mirrored client-side for early feedback) to bound +// per-request memory/cost: each image is loaded and sent to the model. +export const ATTACHMENT_MAX_IMAGE_COUNT = 10; + // A plain-text paste at or above either of these thresholds is automatically // converted into a text attachment instead of being inserted inline export const ATTACHMENT_PASTE_AUTO_CONVERT_MIN_CHARS = 1500; diff --git a/packages/web/src/features/chat/modelCapabilities.server.test.ts b/packages/web/src/features/chat/modelCapabilities.server.test.ts index 4cd4121bf..3c75cd011 100644 --- a/packages/web/src/features/chat/modelCapabilities.server.test.ts +++ b/packages/web/src/features/chat/modelCapabilities.server.test.ts @@ -104,29 +104,23 @@ describe('resolveModelCapabilities', () => { vi.unstubAllGlobals(); }); - test('fetches the catalog once in the background and resolves capabilities (incl. provider mapping)', async () => { + test('blocks on the first (cold) fetch and then serves capabilities from cache (incl. provider mapping)', async () => { const fetchMock = vi.fn(async () => ({ ok: true, json: async () => catalog, }) as unknown as Response); vi.stubGlobal('fetch', fetchMock); - // The request path never blocks on the fetch: the first lookup kicks off - // the background fetch and falls back to text-only while it's in flight. + // The genuinely-first resolution blocks on the cold fetch (bounded) so + // capabilities resolve correctly instead of silently degrading to + // text-only right after a process start. expect(await resolveModelCapabilities(model('anthropic', 'claude-sonnet-4-5'))).toEqual({ - inputModalities: ['text'], - supportedDocumentTypes: [], - }); - - // Once the background fetch settles, lookups resolve from the cached catalog. - await vi.waitFor(async () => { - expect(await resolveModelCapabilities(model('anthropic', 'claude-sonnet-4-5'))).toEqual({ - inputModalities: ['text', 'image'], - supportedDocumentTypes: ['pdf'], - }); + inputModalities: ['text', 'image'], + supportedDocumentTypes: ['pdf'], }); - // Subsequent lookups reuse the cached catalog rather than refetching. + // Subsequent lookups reuse the cached catalog (incl. provider mapping) + // rather than refetching or blocking again. expect(await resolveModelCapabilities(model('google-generative-ai', 'gemini-2.5-pro'))).toEqual({ inputModalities: ['text', 'image', 'audio', 'video'], supportedDocumentTypes: ['pdf'], diff --git a/packages/web/src/features/chat/modelCapabilities.server.ts b/packages/web/src/features/chat/modelCapabilities.server.ts index 87d2cb131..1dc9e19e7 100644 --- a/packages/web/src/features/chat/modelCapabilities.server.ts +++ b/packages/web/src/features/chat/modelCapabilities.server.ts @@ -59,6 +59,8 @@ export const lookupModelCapabilities = ( export const resolveModelCapabilities = async ( config: Pick, ): Promise => { - const catalog = await loadCatalog(); + // Block on the first (cold) fetch so capabilities resolve correctly instead + // of degrading to text-only right after start. Bounded/one-time (see loadCatalog). + const catalog = await loadCatalog({ awaitWhenEmpty: true }); return lookupModelCapabilities(catalog, config); }; diff --git a/packages/web/src/features/chat/modelsDevCatalog.server.ts b/packages/web/src/features/chat/modelsDevCatalog.server.ts index f2344b6f7..b025fb454 100644 --- a/packages/web/src/features/chat/modelsDevCatalog.server.ts +++ b/packages/web/src/features/chat/modelsDevCatalog.server.ts @@ -18,6 +18,10 @@ const CATALOG_TTL_MS = 6 * 60 * 60 * 1000; // refresh attempts to once per interval during a models.dev outage instead of // kicking one off on (nearly) every request. const NEGATIVE_CACHE_MS = 60 * 1000; +// Max time a single `awaitWhenEmpty` caller blocks on the first fetch (well +// under FETCH_TIMEOUT_MS); past it the caller falls back while the fetch +// continues warming the cache in the background. +const COLD_START_BLOCK_BUDGET_MS = 2500; // Sourcebot provider id -> models.dev top-level catalog key. Only providers // whose Sourcebot id differs from the models.dev id need an entry; everything @@ -81,14 +85,15 @@ const fetchCatalog = async (): Promise => { * catalog is returned immediately (even if stale), or null before the first * successful fetch lands, and any refresh settles in the background. * - * Consequences of never awaiting: - * - For the brief window after a cold start (before the first fetch resolves), - * capability resolution falls back to text-only; it self-heals on the next - * request once the background fetch populates the cache. - * - An unreachable catalog (e.g. an airgapped deployment) costs nothing on the - * request path instead of repeatedly paying the fetch timeout. + * By default the request path NEVER blocks on the network: a cold cache returns + * null (text-only fallback) and an unreachable catalog costs nothing. Callers + * needing a correct answer on a cold cache may pass `awaitWhenEmpty: true`, + * which blocks only on the first-ever fetch and only up to + * COLD_START_BLOCK_BUDGET_MS (see below). */ -export const loadCatalog = async (): Promise => { +export const loadCatalog = async ( + { awaitWhenEmpty = false }: { awaitWhenEmpty?: boolean } = {}, +): Promise => { const now = Date.now(); const isFresh = cachedCatalog !== null && now - catalogFetchedAt <= CATALOG_TTL_MS; const isBackingOff = now - lastFailedAt < NEGATIVE_CACHE_MS; @@ -111,6 +116,19 @@ export const loadCatalog = async (): Promise => { }); } + // Block on the first-ever fetch only (`!hasAttempted`), bounded by + // COLD_START_BLOCK_BUDGET_MS, so a cold cache resolves correctly instead of + // silently degrading. After any attempt we never block again (airgapped pays + // at most one short wait per process); the background refresh self-heals. + const hasAttempted = catalogFetchedAt > 0 || lastFailedAt > 0; + if (awaitWhenEmpty && cachedCatalog === null && inFlightFetch && !hasAttempted) { + return Promise.race([ + inFlightFetch, + new Promise((resolve) => + setTimeout(() => resolve(cachedCatalog), COLD_START_BLOCK_BUDGET_MS)), + ]); + } + // Serve whatever we currently have cached (possibly null on a cold start) // and let any in-flight refresh settle in the background. return cachedCatalog; diff --git a/packages/web/src/features/chat/utils.server.ts b/packages/web/src/features/chat/utils.server.ts index f91feaee1..91eb3760d 100644 --- a/packages/web/src/features/chat/utils.server.ts +++ b/packages/web/src/features/chat/utils.server.ts @@ -9,6 +9,7 @@ import path from 'path'; import { BlobAttachment, LanguageModelInfo, SBChatMessage } from './types'; import { getUserMessageAttachments } from './utils'; import { getStorageBackend } from './attachments/storage'; +import { ATTACHMENT_MAX_IMAGE_COUNT } from './constants'; import { resolveModelCapabilities } from './modelCapabilities.server'; import { loadCatalog } from './modelsDevCatalog.server'; import { hasEntitlement } from '@/lib/entitlements'; @@ -134,6 +135,15 @@ export const commitMessageAttachments = async ({ return null; } + // Authoritative per-message image cap (the client mirror can't be trusted). + if (blobRefs.length > ATTACHMENT_MAX_IMAGE_COUNT) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: `You can attach at most ${ATTACHMENT_MAX_IMAGE_COUNT} images per message.`, + } satisfies ServiceError; + } + // Anonymous users cannot upload binary attachments, so a blob ref from an // unauthenticated request can only be a forged/replayed id. if (!userId) { diff --git a/packages/web/src/features/chat/utils.ts b/packages/web/src/features/chat/utils.ts index 15a4907ff..8c1334e99 100644 --- a/packages/web/src/features/chat/utils.ts +++ b/packages/web/src/features/chat/utils.ts @@ -417,6 +417,18 @@ export const getUserMessageAttachments = (message: Pick) .map((part) => part.data); } +// UTF-8 byte size of a message's inlined text (prompt + text-attachment bodies; +// image blobs are referenced by id, not inlined). Server-side counterpart to +// the chat box's `getSubmittedTextBytes` for enforcing the per-turn text budget. +export const getMessageTextBytes = (message: Pick): number => { + const encoder = new TextEncoder(); + const promptBytes = encoder.encode(getUserMessageText(message)).length; + const attachmentBytes = getUserMessageAttachments(message) + .filter((attachment) => attachment.kind === 'text') + .reduce((sum, attachment) => sum + encoder.encode(attachment.text).length, 0); + return promptBytes + attachmentBytes; +} + // Neutralizes ``/`` sequences in a body so it can't // close its own wrapper early. Unrelated markup (e.g. `
`) is left intact. const escapeAttachmentBody = (text: string): string => {