feat: implement file upload feature

This commit is contained in:
hetao
2026-01-23 18:47:39 +08:00
parent 0908127bd7
commit 1fe37fdb6c
16 changed files with 1879 additions and 10 deletions

View File

@@ -7,6 +7,7 @@ import { useCallback } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { getAPIClient } from "../api";
import { uploadFiles } from "../uploads";
import type {
AgentThread,
@@ -72,6 +73,44 @@ export function useSubmitThread({
const callback = useCallback(
async (message: PromptInputMessage) => {
const text = message.text.trim();
// Upload files first if any
if (message.files && message.files.length > 0) {
try {
// Convert FileUIPart to File objects by fetching blob URLs
const filePromises = message.files.map(async (fileUIPart) => {
if (fileUIPart.url && fileUIPart.filename) {
try {
// Fetch the blob URL to get the file data
const response = await fetch(fileUIPart.url);
const blob = await response.blob();
// Create a File object from the blob
return new File([blob], fileUIPart.filename, {
type: fileUIPart.mediaType || blob.type,
});
} catch (error) {
console.error(`Failed to fetch file ${fileUIPart.filename}:`, error);
return null;
}
}
return null;
});
const files = (await Promise.all(filePromises)).filter(
(file): file is File => file !== null,
);
if (files.length > 0 && threadId) {
await uploadFiles(threadId, files);
}
} catch (error) {
console.error("Failed to upload files:", error);
// Continue with message submission even if upload fails
// You might want to show an error toast here
}
}
await thread.submit(
{
messages: [

View File

@@ -0,0 +1,97 @@
/**
* API functions for file uploads
*/
import { getBackendBaseURL } from "../config";
export interface UploadedFileInfo {
filename: string;
size: number;
path: string;
virtual_path: string;
artifact_url: string;
extension?: string;
modified?: number;
markdown_file?: string;
markdown_path?: string;
markdown_virtual_path?: string;
markdown_artifact_url?: string;
}
export interface UploadResponse {
success: boolean;
files: UploadedFileInfo[];
message: string;
}
export interface ListFilesResponse {
files: UploadedFileInfo[];
count: number;
}
/**
* Upload files to a thread
*/
export async function uploadFiles(
threadId: string,
files: File[],
): Promise<UploadResponse> {
const formData = new FormData();
files.forEach((file) => {
formData.append("files", file);
});
const response = await fetch(
`${getBackendBaseURL()}/api/threads/${threadId}/uploads`,
{
method: "POST",
body: formData,
},
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Upload failed" }));
throw new Error(error.detail || "Upload failed");
}
return response.json();
}
/**
* List all uploaded files for a thread
*/
export async function listUploadedFiles(
threadId: string,
): Promise<ListFilesResponse> {
const response = await fetch(
`${getBackendBaseURL()}/api/threads/${threadId}/uploads/list`,
);
if (!response.ok) {
throw new Error("Failed to list uploaded files");
}
return response.json();
}
/**
* Delete an uploaded file
*/
export async function deleteUploadedFile(
threadId: string,
filename: string,
): Promise<{ success: boolean; message: string }> {
const response = await fetch(
`${getBackendBaseURL()}/api/threads/${threadId}/uploads/${filename}`,
{
method: "DELETE",
},
);
if (!response.ok) {
throw new Error("Failed to delete file");
}
return response.json();
}

View File

@@ -0,0 +1,79 @@
/**
* React hooks for file uploads
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import {
deleteUploadedFile,
listUploadedFiles,
uploadFiles,
type UploadedFileInfo,
type UploadResponse,
} from "./api";
/**
* Hook to upload files
*/
export function useUploadFiles(threadId: string) {
const queryClient = useQueryClient();
return useMutation<UploadResponse, Error, File[]>({
mutationFn: (files: File[]) => uploadFiles(threadId, files),
onSuccess: () => {
// Invalidate the uploaded files list
void queryClient.invalidateQueries({
queryKey: ["uploads", "list", threadId],
});
},
});
}
/**
* Hook to list uploaded files
*/
export function useUploadedFiles(threadId: string) {
return useQuery({
queryKey: ["uploads", "list", threadId],
queryFn: () => listUploadedFiles(threadId),
enabled: !!threadId,
});
}
/**
* Hook to delete an uploaded file
*/
export function useDeleteUploadedFile(threadId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (filename: string) => deleteUploadedFile(threadId, filename),
onSuccess: () => {
// Invalidate the uploaded files list
void queryClient.invalidateQueries({
queryKey: ["uploads", "list", threadId],
});
},
});
}
/**
* Hook to handle file uploads in submit flow
* Returns a function that uploads files and returns their info
*/
export function useUploadFilesOnSubmit(threadId: string) {
const uploadMutation = useUploadFiles(threadId);
return useCallback(
async (files: File[]): Promise<UploadedFileInfo[]> => {
if (files.length === 0) {
return [];
}
const result = await uploadMutation.mutateAsync(files);
return result.files;
},
[uploadMutation],
);
}

View File

@@ -0,0 +1,6 @@
/**
* File uploads module
*/
export * from "./api";
export * from "./hooks";