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:
1 parent
183860587f
commit
e4bbe8578a
6 files changed
+99
-7
No files matched your search
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codepilot",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.4",
|
||||
"private": true,
|
||||
"main": "dist-electron/main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user