| spec | SPEC-0029 | |||||||
|---|---|---|---|---|---|---|---|---|
| title | Project chat document attachments (AI Elements inline + upload-before-send) | |||||||
| version | 0.1.0 | |||||||
| date | 2026-02-10 | |||||||
| owners |
|
|||||||
| status | Implemented | |||||||
| related_requirements |
|
|||||||
| related_adrs |
|
|||||||
| related_specs |
|
|||||||
| notes | Adds first-class document attachments to Project Chat using vendored AI Elements components and a Vercel Blob client upload-before-send pipeline. |
Project Chat supports document attachments (PDF/DOCX/PPTX/XLSX/TXT/MD) end-to-end:
- Composer UI uses AI Elements
PromptInput+Attachments(inline variant).1 - Attachments are uploaded before send using Vercel Blob client uploads (
@vercel/blob/client upload()), then registered/ingested viaPOST /api/upload/register(sync ingest by default). - The durable multi-turn follow-up endpoint
POST /api/chat/:runIdaccepts attachments in addition to text. - Attachments persist in UI message history (DB) and are included in stream markers so resumes/replays can reconstruct user messages correctly.
- Document attachments in
/projects/[projectId]/chat. - Upload-before-send using Vercel Blob client uploads (supports files larger than Vercel server request limits).
- Multi-turn follow-ups with attachments via
POST /api/chat/:runId. - Resume-safe rendering: attachments appear for persisted messages and for user-message markers emitted in the assistant stream.
- Image/video/audio attachments (future: vision, previews, transcoding).
- Per-attachment “processing/ready” UI state (sync ingest is assumed fast enough for this iteration).
- Signed URL proxying or private blob ACLs (Blob URLs are currently public).
Requirement IDs are defined in docs/specs/requirements.md.
- FR-003: Upload supported file types to a project.
- FR-008: Project-scoped multi-turn chat that can resume after disconnects.
- FR-019: Chat grounded in the project knowledge base built from uploads.
- PR-001: Streaming starts quickly (p95).
- NFR-008: Accessibility (keyboard + SR).
- NFR-010: CI quality gates (format/lint/typecheck/test/build).
- IR-006: File storage via Vercel Blob.
| Criterion | Weight | Score | Weighted |
|---|---|---|---|
| Solution leverage | 0.35 | 9.4 | 3.29 |
| Application value | 0.30 | 9.2 | 2.76 |
| Maintenance & cognitive load | 0.25 | 9.0 | 2.25 |
| Architectural adaptability | 0.10 | 9.1 | 0.91 |
Total: 9.21 / 10.0
- Composer is built on
PromptInputand keeps attachment state asFileUIPart[](plus localidfor UI list keys).2 - Composer also keeps the original
Fileobjects for upload-before-send (rawFiles) so we never re-readblob:URLs viafetch(blob:). - Composer renders
Attachmentswithvariant="inline"for a compact row above the textarea.1 - Composer enforces basic client-side limits:
maxFileSize = budgets.maxUploadBytesandmaxFiles = 5per message (defense-in-depth UX guardrail). - Transcript rendering groups
part.type === "file"and renders them as inline chips above the message text.
- PromptInput creates attachment parts with
url: blob:<object-url>. - Before sending the chat message, the client:
- uploads the original
Fileobjects to Vercel Blob using@vercel/blob/client upload()withhandleUploadUrl: "/api/upload" - registers the uploaded blobs with
POST /api/upload/register(which persists metadata and ingests)
- uploads the original
- The register response is mapped back to
FileUIPart[]where:urlis the hosted Blob URL (no base64/data URL payloads).filenameandmediaTypeare preserved
Rationale:
FileUIPart.urlexplicitly supports hosted URLs or data URLs.2- Hosted URLs keep the chat payload small and avoid base64 memory spikes.
Vendored PromptInput normally converts blob: URLs to data: URLs before submit.
This repo adds:
fileUrlMode?: "preserve" | "data-url"(default:"preserve")
"preserve" keeps blob URLs and enables the upload-before-send pipeline.
"data-url" is retained for demos/testing where sending data URLs directly is acceptable.
When a durable chat session is active (runId):
- the client sends follow-ups via
POST /api/chat/:runId - request body supports
message?: string,files?: FileUIPart[], and always includesmessageId - validation requires at least one of
messageorfiles
The follow-up endpoint:
- persists a user message with parts
[...,files, ...(message? text : [])] - resumes the workflow hook with
{messageId, message?, files?}
We persist file parts in UI history for user-visible context, but we do not pass file parts to the model for document types.
Instead, when projecting UI messages to model input:
- file parts are stripped
- a text hint is appended:
"[Attached files: ...]"(filenames only)
Grounding happens via ingestion + retrieval (SPEC-0004); attachments are primarily an ingestion entrypoint, not a direct multimodal prompt channel.
The workflow writes data-workflow markers for user messages (including follow-ups).
Markers may include optional files so the client can reconstruct user messages even if the user message did not arrive as a standalone UI message before a refresh.
Marker shape:
type UserMessageMarker = {
type: "user-message";
id: string; // messageId
content: string;
files?: FileUIPart[];
timestamp: number;
};Client reconstruction:
- scans assistant message parts for
data-workflowmarkers - inserts a synthetic user message
{id, role:"user", parts:[...files, ...text]}if thatidis not already present in the raw UI messages
Used by Vercel Blob client uploads to exchange for a scoped client token. Inputs:
- JSON event (from
@vercel/blob/client upload()):type: "blob.generate-client-token"payload.pathnamepayload.clientPayload(JSON string containing{projectId})
Outputs:
200 { clientToken: string }
Used after Vercel Blob client uploads complete. Inputs:
- JSON body
{ projectId: string, async?: boolean, blobs: [{ url, originalName, contentType, size }] }
Outputs:
200 { files: ProjectFileDto[] }where each includes:name,mimeType,storageKey(hosted Blob URL)
Chat start already accepts UI messages that may include FileUIPart parts.
The client uploads blobs first and sends hosted URLs as FileUIPart.url.
Request JSON body:
type ResumeChatBody = {
messageId: string;
message?: string;
files?: FileUIPart[];
};Validation rule: at least one of message or files must be provided.
- UI composer + transcript:
src/app/(app)/projects/[projectId]/chat/chat-client.tsx - Upload helper:
src/lib/uploads/upload-files.client.ts - Prompt input attachment URL behavior:
src/components/ai-elements/prompt-input.tsx - Resume endpoint:
src/app/api/chat/[runId]/route.ts - Workflow hook schema:
src/workflows/chat/hooks/chat-message.ts - Durable workflow orchestration + markers:
src/workflows/chat/project-chat.workflow.ts - Marker writer:
src/workflows/chat/steps/writer.step.ts
- Upload fails: surface composer error; do not clear text/attachments.
- Ingest slow: composer shows “uploading” state; if this becomes a UX issue, migrate to a hybrid or async ingest path (future).
- Run ended mid-follow-up: follow-up endpoint returns
404/409; client clearsrunIdand starts a new session on next send. - Duplicate file uploads:
/api/upload/registerdedupes by content hash (sha256) and returns the existing DB record (SPEC-0003) while best-effort deleting the newly uploaded blob.
- Route contract:
POST /api/chat/:runIdacceptsfilesand persists the UI message parts.
- Workflow contract:
- hook schema accepts
{messageId, files}withoutmessage. - user-message markers include files when present.
- hook schema accepts
- UI:
- transcript renders attachment chips for
FileUIPartparts.
- transcript renders attachment chips for