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 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-09 21:02:34 +08:00
1 parent 183860587f
commit e4bbe8578a
6 files changed
+99 -7

No files matched your search

+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codepilot",
"version": "0.6.3",
"version": "0.6.4",
"private": true,
"main": "dist-electron/main.js",
"scripts": {
+20 -2
View File
@@ -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 = `<!--files:${JSON.stringify(fileMeta)}-->${content}`;
}
addMessage(session_id, 'user', savedContent);
// Auto-generate title from first message if still default
if (session.title === 'New Chat') {
+62
View File
@@ -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<string, string> = {
'.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',
},
});
}
+13 -2
View File
@@ -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) => (
<ImageThumbnail
key={file.id}
src={`data:${file.type};base64,${file.data}`}
src={fileUrl(file)}
alt={file.name}
onClick={() => handlePreview(i)}
/>
+1
View File
@@ -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