Skip to content

Commit 376e838

Browse files
committed
Merge branch 'main' of https://github.com/ProjectTech4DevAI/kaapi-frontend into feat/admin-flow
2 parents 28513c8 + 25ab018 commit 376e838

File tree

18 files changed

+504
-742
lines changed

18 files changed

+504
-742
lines changed

app/(main)/document/page.tsx

Lines changed: 50 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,26 @@
11
"use client";
22

3-
import { useState } from "react";
3+
import { useRef, useState } from "react";
44
import { useAuth } from "@/app/lib/context/AuthContext";
55
import { useApp } from "@/app/lib/context/AppContext";
66
import Sidebar from "@/app/components/Sidebar";
77
import PageHeader from "@/app/components/PageHeader";
88
import { useToast } from "@/app/components/Toast";
99
import { usePaginatedList, useInfiniteScroll } from "@/app/hooks";
10-
import { apiFetch } from "@/app/lib/apiClient";
10+
import {
11+
apiFetch,
12+
uploadWithProgress,
13+
type UploadPhase,
14+
} from "@/app/lib/apiClient";
1115
import { DocumentListing } from "@/app/components/document/DocumentListing";
1216
import { DocumentPreview } from "@/app/components/document/DocumentPreview";
1317
import { UploadDocumentModal } from "@/app/components/document/UploadDocumentModal";
14-
import { DEFAULT_PAGE_LIMIT } from "@/app/lib/constants";
15-
16-
export interface Document {
17-
id: string;
18-
fname: string;
19-
object_store_url: string;
20-
signed_url?: string;
21-
file_size?: number;
22-
inserted_at?: string;
23-
updated_at?: string;
24-
}
18+
import {
19+
DEFAULT_PAGE_LIMIT,
20+
MAX_DOCUMENT_SIZE_BYTES,
21+
MAX_DOCUMENT_SIZE_MB,
22+
} from "@/app/lib/constants";
23+
import { Document } from "@/app/lib/types/document";
2524

2625
export default function DocumentPage() {
2726
const toast = useToast();
@@ -33,6 +32,9 @@ export default function DocumentPage() {
3332
const [isLoadingDocument, setIsLoadingDocument] = useState(false);
3433
const [selectedFile, setSelectedFile] = useState<File | null>(null);
3534
const [isUploading, setIsUploading] = useState(false);
35+
const [uploadProgress, setUploadProgress] = useState(0);
36+
const [uploadPhase, setUploadPhase] = useState<UploadPhase>("uploading");
37+
const abortUploadRef = useRef<(() => void) | null>(null);
3638
const { activeKey: apiKey } = useAuth();
3739

3840
const {
@@ -58,23 +60,39 @@ export default function DocumentPage() {
5860
const file = event.target.files?.[0];
5961
if (!file) return;
6062

63+
if (file.size > MAX_DOCUMENT_SIZE_BYTES) {
64+
toast.error(
65+
`File size exceeds ${MAX_DOCUMENT_SIZE_MB} MB limit. Please select a smaller file within ${MAX_DOCUMENT_SIZE_MB} MB.`,
66+
);
67+
event.target.value = "";
68+
return;
69+
}
70+
6171
setSelectedFile(file);
6272
};
6373

6474
const handleUpload = async () => {
6575
if (!apiKey || !selectedFile) return;
6676

6777
setIsUploading(true);
78+
setUploadProgress(0);
79+
setUploadPhase("uploading");
6880

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

73-
const data = await apiFetch<{ data?: { id: string } }>(
85+
const { promise, abort } = uploadWithProgress<{ data?: { id: string } }>(
7486
"/api/document",
7587
apiKey.key,
76-
{ method: "POST", body: formData },
88+
formData,
89+
(percent, phase) => {
90+
setUploadProgress(percent);
91+
setUploadPhase(phase);
92+
},
7793
);
94+
abortUploadRef.current = abort;
95+
const data = await promise;
7896
if (selectedFile && data.data?.id) {
7997
const fileSizeMap = JSON.parse(
8098
localStorage.getItem("document_file_sizes") || "{}",
@@ -98,6 +116,7 @@ export default function DocumentPage() {
98116
);
99117
} finally {
100118
setIsUploading(false);
119+
abortUploadRef.current = null;
101120
}
102121
};
103122

@@ -197,18 +216,22 @@ export default function DocumentPage() {
197216
</div>
198217
</div>
199218

200-
{isModalOpen && (
201-
<UploadDocumentModal
202-
selectedFile={selectedFile}
203-
isUploading={isUploading}
204-
onFileSelect={handleFileSelect}
205-
onUpload={handleUpload}
206-
onClose={() => {
207-
setIsModalOpen(false);
208-
setSelectedFile(null);
209-
}}
210-
/>
211-
)}
219+
<UploadDocumentModal
220+
open={isModalOpen}
221+
selectedFile={selectedFile}
222+
isUploading={isUploading}
223+
uploadProgress={uploadProgress}
224+
uploadPhase={uploadPhase}
225+
onFileSelect={handleFileSelect}
226+
onUpload={handleUpload}
227+
onClose={() => {
228+
abortUploadRef.current?.();
229+
setIsModalOpen(false);
230+
setSelectedFile(null);
231+
setUploadProgress(0);
232+
setUploadPhase("uploading");
233+
}}
234+
/>
212235
</div>
213236
);
214237
}

app/(main)/knowledge-base/page.tsx

Lines changed: 8 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,7 @@ import PageHeader from "@/app/components/PageHeader";
88
import Modal from "@/app/components/Modal";
99
import { useAuth } from "@/app/lib/context/AuthContext";
1010
import { useApp } from "@/app/lib/context/AppContext";
11-
12-
export interface Document {
13-
id: string;
14-
fname: string;
15-
object_store_url: string;
16-
signed_url?: string;
17-
file_size?: number;
18-
inserted_at?: string;
19-
updated_at?: string;
20-
}
21-
22-
export interface Collection {
23-
id: string;
24-
name?: string;
25-
description?: string;
26-
knowledge_base_id?: string;
27-
inserted_at: string;
28-
updated_at: string;
29-
status?: string;
30-
job_id?: string;
31-
documents?: Document[];
32-
}
11+
import { Document, Collection } from "@/app/lib/types/document";
3312

3413
export default function KnowledgeBasePage() {
3514
const { sidebarCollapsed } = useApp();
@@ -916,37 +895,25 @@ export default function KnowledgeBasePage() {
916895
style={{ borderColor: colors.border }}
917896
>
918897
{/* Create Button */}
919-
<div className="p-6 flex justify-end">
898+
<div className="px-6 py-4 flex justify-end">
920899
<button
921900
onClick={() => {
922901
setShowCreateForm(true);
923902
setSelectedCollection(null);
924903
}}
925-
className="px-4 py-2 rounded-md text-sm font-medium transition-colors"
926-
style={{
927-
backgroundColor: colors.bg.secondary,
928-
color: colors.text.primary,
929-
border: `1px solid ${colors.border}`,
930-
}}
904+
className="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-bg-secondary text-text-primary border border-border"
931905
>
932906
+ Create
933907
</button>
934908
</div>
935909

936-
{/* Collections List */}
937910
<div className="flex-1 overflow-y-auto px-6 pb-6">
938911
{isLoading && collections.length === 0 ? (
939-
<div
940-
className="text-center py-8"
941-
style={{ color: colors.text.secondary }}
942-
>
912+
<div className="text-center py-8 text-text-secondary">
943913
Loading knowledge bases...
944914
</div>
945915
) : collections.length === 0 ? (
946-
<div
947-
className="text-center py-8"
948-
style={{ color: colors.text.secondary }}
949-
>
916+
<div className="text-center py-8 text-text-secondary">
950917
No knowledge bases yet. Create your first one!
951918
</div>
952919
) : (
@@ -960,9 +927,7 @@ export default function KnowledgeBasePage() {
960927
fetchCollectionDetails(collection.id);
961928
}}
962929
className={`border rounded-lg p-3 cursor-pointer transition-colors ${
963-
selectedCollection?.id === collection.id
964-
? "ring-2 ring-offset-1"
965-
: ""
930+
selectedCollection?.id === collection.id ? "" : ""
966931
}`}
967932
style={{
968933
backgroundColor:
@@ -975,7 +940,7 @@ export default function KnowledgeBasePage() {
975940
: colors.border,
976941
}}
977942
>
978-
<div className="flex items-start justify-between gap-2">
943+
<div className="flex items-center justify-between gap-2">
979944
<div className="flex-1 min-w-0">
980945
<div className="flex items-center gap-2 mb-1">
981946
<svg
@@ -1684,9 +1649,7 @@ export default function KnowledgeBasePage() {
16841649
maxWidth="max-w-5xl"
16851650
maxHeight="h-[80vh]"
16861651
>
1687-
{/* Two-pane body */}
1688-
<div className="flex flex-1 overflow-hidden">
1689-
{/* Left pane — doc list */}
1652+
<div className="flex flex-1 overflow-hidden h-full">
16901653
<div
16911654
className="w-1/5 flex flex-col overflow-y-auto shrink-0"
16921655
style={{ borderRight: `1px solid ${colors.border}` }}

app/(main)/speech-to-text/page.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { APIKey } from "@/app/lib/types/credentials";
2222
import WaveformVisualizer from "@/app/components/speech-to-text/WaveformVisualizer";
2323
import { computeWordDiff } from "@/app/components/speech-to-text/TranscriptionDiffViewer";
2424
import ErrorModal from "@/app/components/ErrorModal";
25+
import { getStatusColor } from "@/app/components/utils";
2526

2627
type Tab = "datasets" | "evaluations";
2728

@@ -3168,14 +3169,13 @@ function EvaluationsTab({
31683169
{filteredRuns.map((run) => {
31693170
const isCompleted =
31703171
run.status.toLowerCase() === "completed";
3172+
const statusColor = getStatusColor(run.status);
31713173
return (
31723174
<div
31733175
key={run.id}
3174-
className="rounded-lg overflow-hidden"
3176+
className="rounded-lg overflow-hidden bg-bg-primary shadow-sm border-l-3"
31753177
style={{
3176-
backgroundColor: colors.bg.primary,
3177-
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.06)",
3178-
borderLeft: "3px solid #DCCFC3",
3178+
borderLeftColor: statusColor.border,
31793179
}}
31803180
>
31813181
<div className="px-5 py-4">

app/api/document/route.ts

Lines changed: 85 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,24 +20,93 @@ export async function GET(request: Request) {
2020
}
2121
}
2222

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

27-
const { status, data } = await apiClient(request, "/api/v1/documents/", {
28-
method: "POST",
29-
body: formData,
30-
});
32+
const fileBuffer = new Uint8Array(await request.arrayBuffer());
33+
const totalSize = fileBuffer.byteLength;
3134

32-
return NextResponse.json(data, { status });
33-
} catch (error: unknown) {
34-
console.error("Proxy error:", error);
35-
return NextResponse.json(
36-
{
37-
error: "Failed to forward request to backend",
38-
details: error instanceof Error ? error.message : String(error),
39-
},
40-
{ status: 500 },
41-
);
35+
if (totalSize === 0) {
36+
return NextResponse.json({ error: "No body provided" }, { status: 400 });
4237
}
38+
39+
const encoder = new TextEncoder();
40+
const responseStream = new TransformStream();
41+
const writer = responseStream.writable.getWriter();
42+
43+
const CHUNK_SIZE = 64 * 1024; // 64 KB
44+
45+
(async () => {
46+
try {
47+
let offset = 0;
48+
let lastProgress = 0;
49+
50+
const uploadBody = new ReadableStream({
51+
pull(controller) {
52+
if (offset >= totalSize) {
53+
controller.close();
54+
return;
55+
}
56+
const end = Math.min(offset + CHUNK_SIZE, totalSize);
57+
controller.enqueue(fileBuffer.subarray(offset, end));
58+
offset = end;
59+
60+
const progress = Math.round((offset / totalSize) * 100);
61+
if (progress > lastProgress) {
62+
lastProgress = progress;
63+
writer
64+
.write(
65+
encoder.encode(
66+
JSON.stringify({ phase: "uploading", progress }) + "\n",
67+
),
68+
)
69+
.catch(() => {});
70+
}
71+
},
72+
});
73+
74+
const { status, data } = await apiClient(request, "/api/v1/documents/", {
75+
method: "POST",
76+
body: uploadBody,
77+
headers: { "Content-Type": contentType },
78+
signal: request.signal,
79+
// @ts-expect-error -- Node fetch supports duplex for streaming request bodies
80+
duplex: "half",
81+
});
82+
83+
await writer.write(
84+
encoder.encode(JSON.stringify({ phase: "processing" }) + "\n"),
85+
);
86+
87+
await writer.write(
88+
encoder.encode(JSON.stringify({ done: true, status, data }) + "\n"),
89+
);
90+
} catch (err) {
91+
try {
92+
await writer.write(
93+
encoder.encode(
94+
JSON.stringify({
95+
error: err instanceof Error ? err.message : String(err),
96+
}) + "\n",
97+
),
98+
);
99+
} catch {}
100+
} finally {
101+
await writer.close();
102+
}
103+
})();
104+
105+
return new Response(responseStream.readable, {
106+
headers: {
107+
"Content-Type": "text/event-stream",
108+
"Cache-Control": "no-cache",
109+
Connection: "keep-alive",
110+
},
111+
});
43112
}

app/components/DetailedResultsTable.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,7 @@ function GroupedResultsTable({ traces }: { traces: GroupedTraceItem[] }) {
387387
{/* Table Container - overflow-x-auto enables horizontal scroll when table exceeds viewport */}
388388
<div className="overflow-x-auto">
389389
<table
390-
className="w-full border-collapse"
390+
className="w-full border-collapse table-fixed"
391391
style={{ minWidth: `${tableMinWidth}px` }}
392392
>
393393
{/* Table Header - matching row format styling */}

app/components/Modal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export default function Modal({
5858
{showClose && (
5959
<button
6060
onClick={onClose}
61-
className="p-1 rounded-md text-text-secondary transition-colors hover:bg-neutral-100 hover:text-text-primary"
61+
className="p-1 rounded-md text-text-secondary transition-colors hover:bg-neutral-100 hover:text-text-primary cursor-pointer"
6262
>
6363
<CloseIcon className="w-5 h-5" />
6464
</button>

0 commit comments

Comments
 (0)