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:
parent
5393ba0947
commit
0fe948d1d0
2 changed files with 115 additions and 0 deletions
|
|
@ -7,6 +7,7 @@ import type {
|
||||||
ChatMessageSearchResponse,
|
ChatMessageSearchResponse,
|
||||||
ChatBookmarkToggleResponse,
|
ChatBookmarkToggleResponse,
|
||||||
ChatBookmarkListResponse,
|
ChatBookmarkListResponse,
|
||||||
|
ChatFileUploadResponse,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
|
|
||||||
export const chatApi = {
|
export const chatApi = {
|
||||||
|
|
@ -211,4 +212,40 @@ export const chatApi = {
|
||||||
// Returns a download URL — use window.location.href to trigger
|
// Returns a download URL — use window.location.href to trigger
|
||||||
return `/api/conversations/${conversationId}/export?format=${format}`;
|
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);
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
78
ui/src/hooks/useChatFileUpload.ts
Normal file
78
ui/src/hooks/useChatFileUpload.ts
Normal 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 };
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue