Skip to content
77 changes: 50 additions & 27 deletions app/(main)/document/page.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,27 @@
"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";
import { DEFAULT_PAGE_LIMIT } from "@/app/lib/constants";

export interface Document {
id: string;
fname: string;
object_store_url: string;
signed_url?: string;
file_size?: number;
inserted_at?: string;
updated_at?: string;
}
import {
DEFAULT_PAGE_LIMIT,
MAX_DOCUMENT_SIZE_BYTES,
MAX_DOCUMENT_SIZE_MB,
} from "@/app/lib/constants";
import { Document } from "@/app/lib/types/document";

export default function DocumentPage() {
const toast = useToast();
Expand All @@ -34,6 +33,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 All @@ -59,23 +61,39 @@ export default function DocumentPage() {
const file = event.target.files?.[0];
if (!file) return;

if (file.size > MAX_DOCUMENT_SIZE_BYTES) {
toast.error(
`File size exceeds ${MAX_DOCUMENT_SIZE_MB} MB limit. Please select a smaller file within ${MAX_DOCUMENT_SIZE_MB} MB.`,
);
event.target.value = "";
return;
}

setSelectedFile(file);
};

const handleUpload = async () => {
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 +117,7 @@ export default function DocumentPage() {
);
} finally {
setIsUploading(false);
abortUploadRef.current = null;
}
};

Expand Down Expand Up @@ -198,18 +217,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",
},
});
}
5 changes: 2 additions & 3 deletions app/components/document/DocumentListing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { APIKey } from "@/app/lib/types/credentials";
import { formatDate } from "@/app/components/utils";
import { Document } from "@/app/(main)/document/page";
import { Document } from "@/app/lib/types/document";
import {
RefreshIcon,
KeyIcon,
Expand Down Expand Up @@ -44,7 +44,7 @@ export function DocumentListing({
</h2>
<button
onClick={onUploadNew}
className="px-3 py-1.5 rounded-md text-sm font-medium transition-colors bg-[#171717] text-white hover:bg-accent-hover"
className="px-3 py-1.5 rounded-md text-sm font-medium transition-colors bg-[#171717] text-white hover:bg-accent-hover cursor-pointer"
>
+ Upload
</button>
Expand All @@ -55,7 +55,6 @@ export function DocumentListing({
ref={scrollRef}
className="flex-1 overflow-y-auto p-4 bg-[hsl(0,0%,98%)]"
>
{/* Loading State */}
{isLoading && documents.length === 0 ? (
<div className="text-center py-12 text-[hsl(330,3%,49%)]">
<RefreshIcon className="w-12 h-12 mx-auto mb-4 animate-spin" />
Expand Down
6 changes: 1 addition & 5 deletions app/components/document/DocumentPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useState, useEffect } from "react";
import { formatDate } from "@/app/components/utils";
import { Document } from "@/app/(main)/document/page";
import { Document } from "@/app/lib/types/document";
import {
RefreshIcon,
DocumentFileIcon,
Expand All @@ -18,7 +18,6 @@
export function DocumentPreview({ document, isLoading }: DocumentPreviewProps) {
const [imageLoadError, setImageLoadError] = useState(false);

// Reset error state when document changes
useEffect(() => {
setImageLoadError(false);
}, [document?.id]);
Expand Down Expand Up @@ -109,13 +108,11 @@
</div>
</div>

{/* Preview Area */}
<div className="border rounded-lg p-6 min-h-[600px] bg-[hsl(0,0%,100%)] border-[hsl(0,0%,85%)]">
<h3 className="text-lg font-semibold mb-4 text-[hsl(330,3%,19%)]">
Preview
</h3>

{/* Info message if signed_url is not available */}
{!document.signed_url && document.object_store_url && (
<div className="mb-4 p-3 rounded-lg border bg-[hsl(48,100%,95%)] border-[hsl(48,100%,80%)]">
<p className="text-xs text-[hsl(48,100%,30%)]">
Expand All @@ -135,7 +132,7 @@
</p>
</div>
) : (
<img

Check warning on line 135 in app/components/document/DocumentPreview.tsx

View workflow job for this annotation

GitHub Actions / lint-and-build

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
src={document.signed_url}
alt={document.fname}
className="max-w-full h-auto rounded"
Expand Down Expand Up @@ -190,7 +187,6 @@
</div>
)}

{/* Download button */}
<div className="mt-6 text-center">
{(document.signed_url || document.object_store_url) && (
<a
Expand Down
Loading
Loading