Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
},
});
}
2 changes: 1 addition & 1 deletion app/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export default function Modal({
{showClose && (
<button
onClick={onClose}
className="p-1 rounded-md text-text-secondary transition-colors hover:bg-neutral-100 hover:text-text-primary"
className="p-1 rounded-md text-text-secondary transition-colors hover:bg-neutral-100 hover:text-text-primary cursor-pointer"
>
<CloseIcon className="w-5 h-5" />
</button>
Expand Down
11 changes: 5 additions & 6 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 Expand Up @@ -107,11 +106,11 @@ export function DocumentListing({
onClick={() => onSelect(doc)}
className={`border rounded-lg p-3 cursor-pointer transition-colors ${
selectedDocument?.id === doc.id
? "ring-2 ring-offset-1 bg-[hsl(202,100%,95%)] border-[hsl(202,100%,50%)]"
? "bg-[hsl(202,100%,95%)] border-[hsl(202,100%,50%)]"
: "bg-[hsl(0,0%,100%)] border-[hsl(0,0%,85%)]"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<DocumentFileIcon className="w-5 h-5 shrink-0 text-[#171717]" />
Expand All @@ -128,7 +127,7 @@ export function DocumentListing({
e.stopPropagation();
onDelete(doc.id);
}}
className="p-1.5 rounded-md transition-colors shrink-0 border border-[hsl(8,86%,80%)] bg-[hsl(0,0%,100%)] text-[hsl(8,86%,40%)] hover:bg-[hsl(8,86%,95%)]"
className="p-1.5 rounded-md transition-colors shrink-0 border border-[hsl(8,86%,80%)] bg-[hsl(0,0%,100%)] text-[hsl(8,86%,40%)] hover:bg-[hsl(8,86%,95%)] cursor-pointer"
title="Delete Document"
>
<TrashIcon />
Expand Down
Loading
Loading