Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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>
);
}
53 changes: 8 additions & 45 deletions app/(main)/knowledge-base/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,7 @@ import PageHeader from "@/app/components/PageHeader";
import Modal from "@/app/components/Modal";
import { useAuth } from "@/app/lib/context/AuthContext";
import { useApp } from "@/app/lib/context/AppContext";

export interface Document {
id: string;
fname: string;
object_store_url: string;
signed_url?: string;
file_size?: number;
inserted_at?: string;
updated_at?: string;
}

export interface Collection {
id: string;
name?: string;
description?: string;
knowledge_base_id?: string;
inserted_at: string;
updated_at: string;
status?: string;
job_id?: string;
documents?: Document[];
}
import { Document, Collection } from "@/app/lib/types/document";

export default function KnowledgeBasePage() {
const { sidebarCollapsed } = useApp();
Expand Down Expand Up @@ -919,37 +898,25 @@ export default function KnowledgeBasePage() {
style={{ borderColor: colors.border }}
>
{/* Create Button */}
<div className="p-6 flex justify-end">
<div className="px-6 py-4 flex justify-end">
<button
onClick={() => {
setShowCreateForm(true);
setSelectedCollection(null);
}}
className="px-4 py-2 rounded-md text-sm font-medium transition-colors"
style={{
backgroundColor: colors.bg.secondary,
color: colors.text.primary,
border: `1px solid ${colors.border}`,
}}
className="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-bg-secondary text-text-primary border border-border"
>
+ Create
</button>
</div>

{/* Collections List */}
<div className="flex-1 overflow-y-auto px-6 pb-6">
{isLoading && collections.length === 0 ? (
<div
className="text-center py-8"
style={{ color: colors.text.secondary }}
>
<div className="text-center py-8 text-text-secondary">
Loading knowledge bases...
</div>
) : collections.length === 0 ? (
<div
className="text-center py-8"
style={{ color: colors.text.secondary }}
>
<div className="text-center py-8 text-text-secondary">
No knowledge bases yet. Create your first one!
</div>
) : (
Expand All @@ -963,9 +930,7 @@ export default function KnowledgeBasePage() {
fetchCollectionDetails(collection.id);
}}
className={`border rounded-lg p-3 cursor-pointer transition-colors ${
selectedCollection?.id === collection.id
? "ring-2 ring-offset-1"
: ""
selectedCollection?.id === collection.id ? "" : ""
}`}
style={{
backgroundColor:
Expand All @@ -978,7 +943,7 @@ export default function KnowledgeBasePage() {
: colors.border,
}}
>
<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">
<svg
Expand Down Expand Up @@ -1687,9 +1652,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
Loading
Loading