feat(25-02): add chatApi.uploadFile and useChatFileUpload hook

- Add uploadFile method to chatApi using XHR for progress tracking
- Add ChatFileUploadResponse to shared type imports
- Create useChatFileUpload hook with PendingFile lifecycle management
- Hook manages uploading/done/error states with progress callbacks
This commit is contained in:
Nexus Dev 2026-04-01 23:04:56 +00:00
parent 5393ba0947
commit 0fe948d1d0
2 changed files with 115 additions and 0 deletions

View file

@ -7,6 +7,7 @@ import type {
ChatMessageSearchResponse,
ChatBookmarkToggleResponse,
ChatBookmarkListResponse,
ChatFileUploadResponse,
} from "@paperclipai/shared";
export const chatApi = {
@ -211,4 +212,40 @@ export const chatApi = {
// Returns a download URL — use window.location.href to trigger
return `/api/conversations/${conversationId}/export?format=${format}`;
},
async uploadFile(
conversationId: string,
file: File,
opts?: { source?: string; projectId?: string },
onProgress?: (percent: number) => void,
): Promise<ChatFileUploadResponse> {
const formData = new FormData();
formData.append("file", file);
if (opts?.source) formData.append("source", opts.source);
if (opts?.projectId) formData.append("projectId", opts.projectId);
// Use XMLHttpRequest for progress tracking
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `/api/conversations/${conversationId}/files`);
xhr.withCredentials = true;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText) as ChatFileUploadResponse);
} else {
reject(new Error(`Upload failed: ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error("Upload failed"));
xhr.send(formData);
});
},
};

View file

@ -0,0 +1,78 @@
import { useState, useCallback } from "react";
import { chatApi } from "../api/chat";
import type { ChatFile } from "@paperclipai/shared";
export interface PendingFile {
id: string; // temp client-side id before upload completes
file: File; // raw File object
name: string;
mimeType: string;
sizeBytes: number;
progress: number; // 0-100
status: "uploading" | "done" | "error";
uploadedFile?: ChatFile; // set when upload completes
contentPath?: string;
error?: string;
}
export function useChatFileUpload(conversationId: string | null) {
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
const addFile = useCallback(async (file: File) => {
if (!conversationId) return;
const tempId = crypto.randomUUID();
const pending: PendingFile = {
id: tempId,
file,
name: file.name,
mimeType: file.type || "application/octet-stream",
sizeBytes: file.size,
progress: 0,
status: "uploading",
};
setPendingFiles((prev) => [...prev, pending]);
try {
const result = await chatApi.uploadFile(
conversationId,
file,
{ source: "user_upload" },
(percent) => {
setPendingFiles((prev) =>
prev.map((p) => (p.id === tempId ? { ...p, progress: percent } : p))
);
},
);
setPendingFiles((prev) =>
prev.map((p) =>
p.id === tempId
? { ...p, status: "done", progress: 100, uploadedFile: result.file, contentPath: result.contentPath }
: p
)
);
} catch (err) {
setPendingFiles((prev) =>
prev.map((p) =>
p.id === tempId ? { ...p, status: "error", error: (err as Error).message } : p
)
);
}
}, [conversationId]);
const removeFile = useCallback((tempId: string) => {
setPendingFiles((prev) => prev.filter((p) => p.id !== tempId));
}, []);
const clearCompleted = useCallback(() => {
setPendingFiles((prev) => prev.filter((p) => p.status !== "done"));
}, []);
const completedFileIds = pendingFiles
.filter((p) => p.status === "done" && p.uploadedFile)
.map((p) => p.uploadedFile!.id);
return { pendingFiles, addFile, removeFile, clearCompleted, completedFileIds };
}