File Upload Architecture
How users upload files from the chat interface and how those files reach the AI agent sandbox.
Architecture
┌──────────────┐ POST /api/chat/upload ┌──────────────┐
│ Chat UI │ ────────────────────────────▶│ Backend │
│ (Vue 3) │ ◀───────────────────────────│ (FastAPI) │
│ │ file metadata + key └──────┬───────┘
└──────────────┘ │
┌──────────▼──────────┐
│ Shared PVC │
│ zhiheng-user- │
│ data-pvc │
│ /uploads/ │
│ deer-flow/ │
│ users/{uid}/ │
│ threads/{tid}/│
│ user-data/ │
└──────────┬──────────┘
│ same PVC mount
┌──────▼───────┐
│ Sandbox │
│ $USER_DATA_DIR│
│ /user-data/ │
└───────────────┘
Key design: shared PVC — Both backend and sandbox pods mount the same PersistentVolumeClaim (zhiheng-user-data-pvc). Files written by the backend are immediately visible to the sandbox. No copy step, no S3, no MinIO.
Storage Layout
/uploads/
└── deer-flow/
└── users/
└── {user_id}/
└── threads/
└── {thread_id}/
└── user-data/
├── report.pdf
└── screenshot.png
This follows DeerFlow's internal convention. Each user gets an isolated directory tree — no cross-user access possible.
Upload Flow
1. Frontend upload
File: frontend/src/pages/Chat.vue
- User clicks paperclip icon or drags/drops files onto chat input
- Immediate XHR upload to
POST /api/chat/upload?thread_id={tid}withFormData(notfetch— XHR givesonprogressevents) - Upload progress shown as animated chips (0-100%)
- On success, file chip shows: filename + size + remove button
- On failure, chip shows error with dismiss button
- Max 50MB per file, max 10 files per message
2. Backend validation and storage
File: backend/routers/chat.py → _do_upload()
POST /api/chat/upload?thread_id={tid}
Authorization: Bearer <jwt>
# Validates:
# - JWT authentication (user context)
# - Content-Type and extension against allowlist
# - File size ≤ 50MB
# - Filename sanitized (path traversal removed)
# Writes directly to:
# /uploads/deer-flow/users/{user_id}/threads/{thread_id}/user-data/{uuid}_{filename}
# Returns:
{
"filename": "report.pdf",
"key": "deer-flow/users/{uid}/threads/{tid}/user-data/{uuid}_report.pdf",
"size": 1234567,
"content_type": "application/pdf",
"upload_url": "/api/storage/download/{key}"
}
Allowed file types:
| Category | Extensions |
|---|---|
| Documents | .pdf, .docx |
| Spreadsheets | .xlsx |
| Images | .png, .jpg, .jpeg, .gif, .webp, .svg |
| Data | .csv, .json, .xml |
| Text | .txt, .md, .html |
3. Redis upload tracking
Uploads are tracked in Redis per thread:
chat:uploads:{user_id}:{thread_id} → [
{ "filename": "report.pdf", "key": "...", "size": 1234567, "content_type": "..." },
...
]
TTL: 24 hours (86400s)
This tracks which files are "pending" for the next chat message. Once consumed, the key is deleted.
4. Agent message injection
When the user sends a chat message with pending uploads:
# In POST /api/chat/message handler
uploads = _get_uploads(user_id, thread_id)
if uploads:
file_refs = "\n\n用户上传的文件(已在沙箱中可用):\n"
for u in uploads:
file_refs += f"- {u['filename']} ({u['size']} bytes): $USER_DATA_DIR/{u['filename']}\n"
message = message + file_refs
# Consume uploads (delete from Redis)
REDIS.delete(_uploads_key(user_id, thread_id))
The agent sees file references like:
用户上传的文件(已在沙箱中可用):
- report.pdf (1234567 bytes): $USER_DATA_DIR/report.pdf
- screenshot.png (340123 bytes): $USER_DATA_DIR/screenshot.png
5. Sandbox access
The sandbox pod has the same PVC mounted:
# infra/k8s/base/09-sandbox-provisioner.yaml
volumeMounts:
- name: user-data
mountPath: /user-data/ # $USER_DATA_DIR env var
volumes:
- name: user-data
persistentVolumeClaim:
claimName: zhiheng-user-data-pvc
The agent can read files directly via tools (read_file, ls, etc.) — no copy step needed.
6. Sandbox upload (agent → storage)
Endpoint: POST /api/chat/upload/sandbox
Used by the agent to save generated files (PPT, documents) back to persistent storage:
POST /api/chat/upload/sandbox?user_id={uid}&thread_id={tid}
# No JWT — internal cluster-only (sandbox→backend)
# Validates:
# - user_id matches thread_id prefix (zhiheng-chat-{uid}-)
# - Same file type/size checks as user upload
Storage Management API
File: backend/routers/storage.py
A separate router provides full CRUD for user files:
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET | /api/storage/list?flat=&type=&page= | JWT | List files (folder or flat mode) |
GET | /api/storage/download/{key} | JWT | Download file (Content-Disposition) |
GET | /api/storage/url/{key} | JWT | Get download URL for a file |
DELETE | /api/storage/{key} | JWT | Delete file + clean empty dirs |
GET | /api/storage/stats | JWT | Usage stats (total files, size) |
Ownership enforcement: Every endpoint validates that {key} starts with deer-flow/users/{user_id}/. Users can never access another user's files.
Infrastructure
Kubernetes
# Backend (03-backend.yaml)
volumeMounts:
- name: user-data
mountPath: /uploads
volumes:
- name: user-data
persistentVolumeClaim:
claimName: zhiheng-user-data-pvc
# Sandbox (09-sandbox-provisioner.yaml)
env:
- name: PVC_NAME
value: zhiheng-user-data-pvc
Nginx
client_max_body_size 50M — enforces the 50MB upload limit at the proxy level.
Redis
- Purpose: Temporary upload tracking per chat thread
- Key pattern:
chat:uploads:{user_id}:{thread_id} - TTL: 24 hours (uploads expire if user never sends the message)
- Consumed: Key deleted after uploads are injected into an agent message
Dependencies
python-multipart— FastAPI multipart form parsing (inrequirements.txt)redis— Upload tracking (inrequirements.txt)
Security
| Control | Implementation |
|---|---|
| Auth | JWT required for user uploads, internal prefix check for sandbox uploads |
| Isolation | User can only access files in deer-flow/users/{user_id}/ |
| Path traversal | Filename sanitized, resolved path must start under /uploads/ |
| Content validation | Content-Type + extension checked against allowlist |
| Size limit | 50MB enforced at Nginx (client_max_body_size) and Python level |
| Cleanup | Empty parent directories removed on delete |
Frontend Implementation
File: frontend/src/pages/Chat.vue
Key components:
<!-- Upload trigger -->
<input ref="fileInput" type="file" multiple hidden @change="handleFiles" />
<button @click="fileInput?.click()">📎</button>
<!-- Pending uploads (in progress) -->
<div v-for="p in pendingUploads" :key="p.filename">
<span v-if="p.status === 'uploading'">{{ p.progress }}%</span>
</div>
<!-- Uploaded files (ready to send) -->
<div v-for="(f, i) in uploadedFiles" :key="f.key">
{{ f.filename }} ({{ f.size | formatSize }})
<button @click="removeUpload(i)">✕</button>
</div>
Upload mechanics:
- Uses raw
XMLHttpRequest(notfetch) forupload.onprogressevents - Uploads immediately on file selection (not on message send)
- Files sent with chat message as metadata (file already on disk, no re-upload)
Upload Lifecycle
User selects file
→ XHR upload to /api/chat/upload
→ Backend writes to PVC + tracks in Redis
→ Frontend shows file chip
User sends message
→ Backend injects file refs into agent message
→ Redis upload key deleted (consumed)
→ Agent reads files from sandbox ($USER_DATA_DIR/)
Agent generates file (PPT, doc)
→ POST /api/chat/upload/sandbox
→ File saved to PVC for user to download later
User views storage
→ GET /api/storage/list (browse threads)
→ GET /api/storage/download/{key} (download)
→ DELETE /api/storage/{key} (delete)