Skip to content
52 changes: 36 additions & 16 deletions app/(main)/document/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
"use client";

import { useState } from "react";
import { useRef, useState } from "react";
import { useAuth } from "@/app/lib/context/AuthContext";
import { useApp } from "@/app/lib/context/AppContext";
import Sidebar from "@/app/components/Sidebar";
import PageHeader from "@/app/components/PageHeader";
import { useToast } from "@/app/components/Toast";
import { usePaginatedList } from "@/app/hooks/usePaginatedList";
import { useInfiniteScroll } from "@/app/hooks/useInfiniteScroll";
import { apiFetch } from "@/app/lib/apiClient";
import {
apiFetch,
uploadWithProgress,
type UploadPhase,
} from "@/app/lib/apiClient";
import { DocumentListing } from "@/app/components/document/DocumentListing";
import { DocumentPreview } from "@/app/components/document/DocumentPreview";
import { UploadDocumentModal } from "@/app/components/document/UploadDocumentModal";
Expand All @@ -34,6 +38,9 @@ export default function DocumentPage() {
const [isLoadingDocument, setIsLoadingDocument] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadPhase, setUploadPhase] = useState<UploadPhase>("uploading");
const abortUploadRef = useRef<(() => void) | null>(null);
const { activeKey: apiKey } = useAuth();

const {
Expand Down Expand Up @@ -66,16 +73,24 @@ export default function DocumentPage() {
if (!apiKey || !selectedFile) return;

setIsUploading(true);
setUploadProgress(0);
setUploadPhase("uploading");

try {
const formData = new FormData();
formData.append("src", selectedFile);

const data = await apiFetch<{ data?: { id: string } }>(
const { promise, abort } = uploadWithProgress<{ data?: { id: string } }>(
"/api/document",
apiKey.key,
{ method: "POST", body: formData },
formData,
(percent, phase) => {
setUploadProgress(percent);
setUploadPhase(phase);
},
);
abortUploadRef.current = abort;
const data = await promise;
if (selectedFile && data.data?.id) {
const fileSizeMap = JSON.parse(
localStorage.getItem("document_file_sizes") || "{}",
Expand All @@ -99,6 +114,7 @@ export default function DocumentPage() {
);
} finally {
setIsUploading(false);
abortUploadRef.current = null;
}
};

Expand Down Expand Up @@ -198,18 +214,22 @@ export default function DocumentPage() {
</div>
</div>

{isModalOpen && (
<UploadDocumentModal
selectedFile={selectedFile}
isUploading={isUploading}
onFileSelect={handleFileSelect}
onUpload={handleUpload}
onClose={() => {
setIsModalOpen(false);
setSelectedFile(null);
}}
/>
)}
<UploadDocumentModal
open={isModalOpen}
selectedFile={selectedFile}
isUploading={isUploading}
uploadProgress={uploadProgress}
uploadPhase={uploadPhase}
onFileSelect={handleFileSelect}
onUpload={handleUpload}
onClose={() => {
abortUploadRef.current?.();
setIsModalOpen(false);
setSelectedFile(null);
setUploadProgress(0);
setUploadPhase("uploading");
}}
/>
</div>
);
}
4 changes: 1 addition & 3 deletions app/(main)/knowledge-base/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1687,9 +1687,7 @@ export default function KnowledgeBasePage() {
maxWidth="max-w-5xl"
maxHeight="h-[80vh]"
>
{/* Two-pane body */}
<div className="flex flex-1 overflow-hidden">
{/* Left pane — doc list */}
<div className="flex flex-1 overflow-hidden h-full">
<div
className="w-1/5 flex flex-col overflow-y-auto shrink-0"
style={{ borderRight: `1px solid ${colors.border}` }}
Expand Down
101 changes: 85 additions & 16 deletions app/api/document/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,93 @@ export async function GET(request: Request) {
}
}

/**
* Proxies the uploaded file to the backend while sending real-time progress
* events back to the client via a newline-delimited JSON stream.
* Uses a pull-based ReadableStream as the backend request body so that
* them — giving accurate progress regardless of internal buffer sizes.
*/
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const contentType = request.headers.get("Content-Type") || "";

const { status, data } = await apiClient(request, "/api/v1/documents/", {
method: "POST",
body: formData,
});
const fileBuffer = new Uint8Array(await request.arrayBuffer());
const totalSize = fileBuffer.byteLength;

return NextResponse.json(data, { status });
} catch (error: unknown) {
console.error("Proxy error:", error);
return NextResponse.json(
{
error: "Failed to forward request to backend",
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
if (totalSize === 0) {
return NextResponse.json({ error: "No body provided" }, { status: 400 });
}

const encoder = new TextEncoder();
const responseStream = new TransformStream();
const writer = responseStream.writable.getWriter();

const CHUNK_SIZE = 64 * 1024; // 64 KB

(async () => {
try {
let offset = 0;
let lastProgress = 0;

const uploadBody = new ReadableStream({
pull(controller) {
if (offset >= totalSize) {
controller.close();
return;
}
const end = Math.min(offset + CHUNK_SIZE, totalSize);
controller.enqueue(fileBuffer.subarray(offset, end));
offset = end;

const progress = Math.round((offset / totalSize) * 100);
if (progress > lastProgress) {
lastProgress = progress;
writer
.write(
encoder.encode(
JSON.stringify({ phase: "uploading", progress }) + "\n",
),
)
.catch(() => {});
}
},
});

const { status, data } = await apiClient(request, "/api/v1/documents/", {
method: "POST",
body: uploadBody,
headers: { "Content-Type": contentType },
signal: request.signal,
// @ts-expect-error -- Node fetch supports duplex for streaming request bodies
duplex: "half",
});

await writer.write(
encoder.encode(JSON.stringify({ phase: "processing" }) + "\n"),
);

await writer.write(
encoder.encode(JSON.stringify({ done: true, status, data }) + "\n"),
);
} catch (err) {
try {
await writer.write(
encoder.encode(
JSON.stringify({
error: err instanceof Error ? err.message : String(err),
}) + "\n",
),
);
} catch {}
} finally {
await writer.close();
}
})();

return new Response(responseStream.readable, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
Loading
Loading