From e4bbe8578a7546599a3b2ba15985121659099cc6 Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Mon, 9 Feb 2026 21:02:34 +0800 Subject: [PATCH] fix: persist file attachments so images/documents survive page reload Previously uploaded files were only held in memory (optimistic message) and lost when navigating away. Now: - API route saves all uploaded files to .codepilot-uploads/ on disk - File metadata (with filePath, not base64) is embedded in message content - New /api/uploads endpoint serves persisted files - FileAttachmentDisplay handles both base64 (in-memory) and filePath (DB) Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 4 +- package.json | 2 +- src/app/api/chat/route.ts | 22 ++++++- src/app/api/uploads/route.ts | 62 +++++++++++++++++++ src/components/chat/FileAttachmentDisplay.tsx | 15 ++++- src/types/index.ts | 1 + 6 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 src/app/api/uploads/route.ts diff --git a/package-lock.json b/package-lock.json index d00936c..affdace 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codepilot", - "version": "0.6.3", + "version": "0.6.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codepilot", - "version": "0.6.3", + "version": "0.6.4", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.33", "@hugeicons/core-free-icons": "^3.1.1", diff --git a/package.json b/package.json index 7046845..a1b56cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.6.3", + "version": "0.6.4", "private": true, "main": "dist-electron/main.js", "scripts": { diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index abea496..d2eb8ea 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -2,6 +2,8 @@ import { NextRequest } from 'next/server'; import { streamClaude } from '@/lib/claude-client'; import { addMessage, getSession, updateSessionTitle, updateSdkSessionId, getSetting } from '@/lib/db'; import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment } from '@/types'; +import fs from 'fs'; +import path from 'path'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -26,8 +28,24 @@ export async function POST(request: NextRequest) { }); } - // Save user message - addMessage(session_id, 'user', content); + // Save user message — persist file metadata so attachments survive page reload + let savedContent = content; + if (files && files.length > 0) { + const workDir = session.working_directory || process.cwd(); + const uploadDir = path.join(workDir, '.codepilot-uploads'); + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + const fileMeta = files.map((f) => { + const safeName = path.basename(f.name).replace(/[^a-zA-Z0-9._-]/g, '_'); + const filePath = path.join(uploadDir, `${Date.now()}-${safeName}`); + const buffer = Buffer.from(f.data, 'base64'); + fs.writeFileSync(filePath, buffer); + return { id: f.id, name: f.name, type: f.type, size: buffer.length, filePath }; + }); + savedContent = `${content}`; + } + addMessage(session_id, 'user', savedContent); // Auto-generate title from first message if still default if (session.title === 'New Chat') { diff --git a/src/app/api/uploads/route.ts b/src/app/api/uploads/route.ts new file mode 100644 index 0000000..78119c3 --- /dev/null +++ b/src/app/api/uploads/route.ts @@ -0,0 +1,62 @@ +import { NextRequest } from 'next/server'; +import fs from 'fs'; +import path from 'path'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.pdf': 'application/pdf', + '.txt': 'text/plain', + '.md': 'text/markdown', + '.json': 'application/json', + '.csv': 'text/csv', +}; + +/** + * Serve files from .codepilot-uploads/ directories. + * Only allows reading from paths that contain '.codepilot-uploads/' to prevent directory traversal. + */ +export async function GET(request: NextRequest) { + const filePath = request.nextUrl.searchParams.get('path'); + + if (!filePath) { + return new Response(JSON.stringify({ error: 'path parameter is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Security: only allow files within .codepilot-uploads/ directories + const resolved = path.resolve(filePath); + if (!resolved.includes('.codepilot-uploads')) { + return new Response(JSON.stringify({ error: 'Access denied' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (!fs.existsSync(resolved)) { + return new Response(JSON.stringify({ error: 'File not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const buffer = fs.readFileSync(resolved); + const ext = path.extname(resolved).toLowerCase(); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + + return new Response(buffer, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }); +} diff --git a/src/components/chat/FileAttachmentDisplay.tsx b/src/components/chat/FileAttachmentDisplay.tsx index 86c8131..7ce13d2 100644 --- a/src/components/chat/FileAttachmentDisplay.tsx +++ b/src/components/chat/FileAttachmentDisplay.tsx @@ -11,6 +11,17 @@ interface FileAttachmentDisplayProps { files: FileAttachment[]; } +/** + * Build a display URL for a file attachment. + * - If base64 `data` is available (optimistic / in-memory): use data URI + * - If `filePath` is available (reloaded from DB): use the uploads API + */ +function fileUrl(f: FileAttachment): string { + if (f.data) return `data:${f.type};base64,${f.data}`; + if (f.filePath) return `/api/uploads?path=${encodeURIComponent(f.filePath)}`; + return ''; +} + export function FileAttachmentDisplay({ files }: FileAttachmentDisplayProps) { const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(0); @@ -19,7 +30,7 @@ export function FileAttachmentDisplay({ files }: FileAttachmentDisplayProps) { const otherFiles = files.filter((f) => !isImageFile(f.type)); const lightboxImages = imageFiles.map((f) => ({ - src: `data:${f.type};base64,${f.data}`, + src: fileUrl(f), alt: f.name, })); @@ -44,7 +55,7 @@ export function FileAttachmentDisplay({ files }: FileAttachmentDisplayProps) { {imageFiles.map((file, i) => ( handlePreview(i)} /> diff --git a/src/types/index.ts b/src/types/index.ts index eff9823..63f819f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -402,6 +402,7 @@ export interface FileAttachment { type: string; // MIME type size: number; data: string; // base64 encoded content + filePath?: string; // persisted disk path (for messages reloaded from DB) } // Check if a MIME type is an image