Spaces:
Sleeping
Sleeping
[NOTICKET] Update icon file
Browse files- src/app/components/KnowledgeManagement.tsx +91 -9
- src/app/components/Main.tsx +44 -14
- src/services/api.ts +7 -0
src/app/components/KnowledgeManagement.tsx
CHANGED
|
@@ -3,12 +3,15 @@ import {
|
|
| 3 |
Upload,
|
| 4 |
Trash2,
|
| 5 |
FileText,
|
|
|
|
|
|
|
| 6 |
Check,
|
| 7 |
Loader2,
|
| 8 |
Database,
|
| 9 |
X,
|
| 10 |
ChevronLeft,
|
| 11 |
Link,
|
|
|
|
| 12 |
} from "lucide-react";
|
| 13 |
import { toast } from "sonner";
|
| 14 |
import {
|
|
@@ -22,12 +25,14 @@ import {
|
|
| 22 |
getDatabaseClients,
|
| 23 |
deleteDatabaseClient,
|
| 24 |
ingestDatabaseClient,
|
|
|
|
| 25 |
type ApiDocument,
|
| 26 |
type DocumentStatus,
|
| 27 |
type DocTypeInfo,
|
| 28 |
type DbType,
|
| 29 |
type DbTypeInfo,
|
| 30 |
type DatabaseClient,
|
|
|
|
| 31 |
} from "../../services/api";
|
| 32 |
|
| 33 |
interface KnowledgeManagementProps {
|
|
@@ -35,7 +40,7 @@ interface KnowledgeManagementProps {
|
|
| 35 |
onClose: () => void;
|
| 36 |
}
|
| 37 |
|
| 38 |
-
type View = "main" | "db-select" | "db-credentials";
|
| 39 |
|
| 40 |
const LOGO_MAP: Record<string, string> = {
|
| 41 |
postgres: "https://cdn.simpleicons.org/postgresql/336791",
|
|
@@ -52,6 +57,17 @@ const getUserId = (): string | null => {
|
|
| 52 |
return (JSON.parse(stored).user_id as string) ?? null;
|
| 53 |
};
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
export default function KnowledgeManagement({
|
| 56 |
open,
|
| 57 |
onClose,
|
|
@@ -79,6 +95,10 @@ export default function KnowledgeManagement({
|
|
| 79 |
const [deletingClient, setDeletingClient] = useState<string | null>(null);
|
| 80 |
const pollingTimers = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
// ββ DB credentials form state βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 83 |
const [connectionName, setConnectionName] = useState("");
|
| 84 |
const [dbForm, setDbForm] = useState<Record<string, string | number | boolean>>({});
|
|
@@ -355,6 +375,21 @@ export default function KnowledgeManagement({
|
|
| 355 |
);
|
| 356 |
};
|
| 357 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
if (!open) return null;
|
| 359 |
|
| 360 |
// ββ Header title & back button logic ββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -366,6 +401,8 @@ export default function KnowledgeManagement({
|
|
| 366 |
? "Connect Database"
|
| 367 |
: view === "db-credentials"
|
| 368 |
? `Connect to ${selectedDbInfo?.display_name ?? selectedDbType}`
|
|
|
|
|
|
|
| 369 |
: "Knowledge Base";
|
| 370 |
|
| 371 |
const headerBack =
|
|
@@ -373,6 +410,8 @@ export default function KnowledgeManagement({
|
|
| 373 |
? () => setView("main")
|
| 374 |
: view === "db-credentials"
|
| 375 |
? () => setView("db-select")
|
|
|
|
|
|
|
| 376 |
: null;
|
| 377 |
|
| 378 |
// ββ Render βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -399,12 +438,23 @@ export default function KnowledgeManagement({
|
|
| 399 |
)}
|
| 400 |
<h2 className="text-sm font-semibold text-slate-900">{headerTitle}</h2>
|
| 401 |
</div>
|
| 402 |
-
<
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
</div>
|
| 409 |
|
| 410 |
{/* Content */}
|
|
@@ -570,8 +620,8 @@ export default function KnowledgeManagement({
|
|
| 570 |
key={doc.id}
|
| 571 |
className="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-slate-50 transition group"
|
| 572 |
>
|
| 573 |
-
<div className=
|
| 574 |
-
<
|
| 575 |
</div>
|
| 576 |
<div className="flex-1 min-w-0">
|
| 577 |
<p className="text-sm font-medium text-slate-800 truncate" title={doc.filename}>
|
|
@@ -768,6 +818,36 @@ export default function KnowledgeManagement({
|
|
| 768 |
</button>
|
| 769 |
</div>
|
| 770 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 771 |
</div>
|
| 772 |
|
| 773 |
{/* Footer */}
|
|
@@ -777,6 +857,8 @@ export default function KnowledgeManagement({
|
|
| 777 |
? `Supported formats: ${supportedFormatsText}`
|
| 778 |
: view === "db-select"
|
| 779 |
? "More integrations coming soon"
|
|
|
|
|
|
|
| 780 |
: "Credentials are encrypted at rest"}
|
| 781 |
</p>
|
| 782 |
</div>
|
|
|
|
| 3 |
Upload,
|
| 4 |
Trash2,
|
| 5 |
FileText,
|
| 6 |
+
FileSpreadsheet,
|
| 7 |
+
File,
|
| 8 |
Check,
|
| 9 |
Loader2,
|
| 10 |
Database,
|
| 11 |
X,
|
| 12 |
ChevronLeft,
|
| 13 |
Link,
|
| 14 |
+
Table,
|
| 15 |
} from "lucide-react";
|
| 16 |
import { toast } from "sonner";
|
| 17 |
import {
|
|
|
|
| 25 |
getDatabaseClients,
|
| 26 |
deleteDatabaseClient,
|
| 27 |
ingestDatabaseClient,
|
| 28 |
+
getDataCatalog,
|
| 29 |
type ApiDocument,
|
| 30 |
type DocumentStatus,
|
| 31 |
type DocTypeInfo,
|
| 32 |
type DbType,
|
| 33 |
type DbTypeInfo,
|
| 34 |
type DatabaseClient,
|
| 35 |
+
type DataCatalogSource,
|
| 36 |
} from "../../services/api";
|
| 37 |
|
| 38 |
interface KnowledgeManagementProps {
|
|
|
|
| 40 |
onClose: () => void;
|
| 41 |
}
|
| 42 |
|
| 43 |
+
type View = "main" | "db-select" | "db-credentials" | "catalog";
|
| 44 |
|
| 45 |
const LOGO_MAP: Record<string, string> = {
|
| 46 |
postgres: "https://cdn.simpleicons.org/postgresql/336791",
|
|
|
|
| 57 |
return (JSON.parse(stored).user_id as string) ?? null;
|
| 58 |
};
|
| 59 |
|
| 60 |
+
const FILE_ICON_MAP: Record<string, { icon: React.ElementType; bg: string; color: string }> = {
|
| 61 |
+
pdf: { icon: FileText, bg: "bg-red-50", color: "text-red-400" },
|
| 62 |
+
docx: { icon: FileText, bg: "bg-blue-50", color: "text-blue-400" },
|
| 63 |
+
txt: { icon: File, bg: "bg-slate-100", color: "text-slate-400" },
|
| 64 |
+
csv: { icon: FileSpreadsheet, bg: "bg-green-50", color: "text-green-500" },
|
| 65 |
+
xlsx: { icon: FileSpreadsheet, bg: "bg-emerald-50",color: "text-emerald-500"},
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
const getFileIcon = (fileType: string) =>
|
| 69 |
+
FILE_ICON_MAP[fileType.toLowerCase()] ?? { icon: File, bg: "bg-slate-100", color: "text-slate-400" };
|
| 70 |
+
|
| 71 |
export default function KnowledgeManagement({
|
| 72 |
open,
|
| 73 |
onClose,
|
|
|
|
| 95 |
const [deletingClient, setDeletingClient] = useState<string | null>(null);
|
| 96 |
const pollingTimers = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
|
| 97 |
|
| 98 |
+
// ββ Data Catalog state ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 99 |
+
const [catalogSources, setCatalogSources] = useState<DataCatalogSource[]>([]);
|
| 100 |
+
const [loadingCatalog, setLoadingCatalog] = useState(false);
|
| 101 |
+
|
| 102 |
// ββ DB credentials form state βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 103 |
const [connectionName, setConnectionName] = useState("");
|
| 104 |
const [dbForm, setDbForm] = useState<Record<string, string | number | boolean>>({});
|
|
|
|
| 375 |
);
|
| 376 |
};
|
| 377 |
|
| 378 |
+
const handleViewCatalog = async () => {
|
| 379 |
+
const uid = getUserId();
|
| 380 |
+
setLoadingCatalog(true);
|
| 381 |
+
setView("catalog");
|
| 382 |
+
try {
|
| 383 |
+
if (!uid) throw new Error("no user");
|
| 384 |
+
const catalog = await getDataCatalog(uid);
|
| 385 |
+
setCatalogSources(catalog.sources.filter((s) => s.source_type !== "unstructured"));
|
| 386 |
+
} catch {
|
| 387 |
+
setCatalogSources([]);
|
| 388 |
+
} finally {
|
| 389 |
+
setLoadingCatalog(false);
|
| 390 |
+
}
|
| 391 |
+
};
|
| 392 |
+
|
| 393 |
if (!open) return null;
|
| 394 |
|
| 395 |
// ββ Header title & back button logic ββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 401 |
? "Connect Database"
|
| 402 |
: view === "db-credentials"
|
| 403 |
? `Connect to ${selectedDbInfo?.display_name ?? selectedDbType}`
|
| 404 |
+
: view === "catalog"
|
| 405 |
+
? "Data Catalog"
|
| 406 |
: "Knowledge Base";
|
| 407 |
|
| 408 |
const headerBack =
|
|
|
|
| 410 |
? () => setView("main")
|
| 411 |
: view === "db-credentials"
|
| 412 |
? () => setView("db-select")
|
| 413 |
+
: view === "catalog"
|
| 414 |
+
? () => setView("main")
|
| 415 |
: null;
|
| 416 |
|
| 417 |
// ββ Render βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 438 |
)}
|
| 439 |
<h2 className="text-sm font-semibold text-slate-900">{headerTitle}</h2>
|
| 440 |
</div>
|
| 441 |
+
<div className="flex items-center gap-1">
|
| 442 |
+
{view === "main" && (
|
| 443 |
+
<button
|
| 444 |
+
onClick={handleViewCatalog}
|
| 445 |
+
title="View data catalog"
|
| 446 |
+
className="p-1.5 rounded-lg text-slate-400 hover:text-[#FF8F00] hover:bg-orange-50 transition"
|
| 447 |
+
>
|
| 448 |
+
<Table className="w-4 h-4" />
|
| 449 |
+
</button>
|
| 450 |
+
)}
|
| 451 |
+
<button
|
| 452 |
+
onClick={handleClose}
|
| 453 |
+
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition"
|
| 454 |
+
>
|
| 455 |
+
<X className="w-4 h-4" />
|
| 456 |
+
</button>
|
| 457 |
+
</div>
|
| 458 |
</div>
|
| 459 |
|
| 460 |
{/* Content */}
|
|
|
|
| 620 |
key={doc.id}
|
| 621 |
className="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-slate-50 transition group"
|
| 622 |
>
|
| 623 |
+
<div className={`w-8 h-8 rounded-lg ${getFileIcon(doc.file_type).bg} flex items-center justify-center flex-shrink-0`}>
|
| 624 |
+
{(() => { const { icon: Icon, color } = getFileIcon(doc.file_type); return <Icon className={`w-4 h-4 ${color}`} />; })()}
|
| 625 |
</div>
|
| 626 |
<div className="flex-1 min-w-0">
|
| 627 |
<p className="text-sm font-medium text-slate-800 truncate" title={doc.filename}>
|
|
|
|
| 818 |
</button>
|
| 819 |
</div>
|
| 820 |
)}
|
| 821 |
+
|
| 822 |
+
{/* ββ VIEW: catalog ββ */}
|
| 823 |
+
{view === "catalog" && (
|
| 824 |
+
<div className="space-y-2">
|
| 825 |
+
{loadingCatalog ? (
|
| 826 |
+
<div className="flex justify-center py-10">
|
| 827 |
+
<Loader2 className="w-5 h-5 animate-spin text-slate-300" />
|
| 828 |
+
</div>
|
| 829 |
+
) : catalogSources.length === 0 ? (
|
| 830 |
+
<p className="text-center text-xs text-slate-400 py-8">No structured data indexed yet.</p>
|
| 831 |
+
) : (
|
| 832 |
+
catalogSources.map((src) => (
|
| 833 |
+
<div key={src.source_id} className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-slate-100">
|
| 834 |
+
<div className="w-8 h-8 rounded-lg bg-slate-100 flex items-center justify-center flex-shrink-0">
|
| 835 |
+
{src.source_type === "schema"
|
| 836 |
+
? <Database className="w-4 h-4 text-slate-500" />
|
| 837 |
+
: <Table className="w-4 h-4 text-slate-500" />}
|
| 838 |
+
</div>
|
| 839 |
+
<div className="flex-1 min-w-0">
|
| 840 |
+
<p className="text-sm font-medium text-slate-800 truncate">{src.name}</p>
|
| 841 |
+
<p className="text-xs text-slate-400">
|
| 842 |
+
{src.source_type === "schema" ? "Database" : "Tabular"}
|
| 843 |
+
{src.table_count != null ? ` Β· ${src.table_count} table${src.table_count !== 1 ? "s" : ""}` : ""}
|
| 844 |
+
</p>
|
| 845 |
+
</div>
|
| 846 |
+
</div>
|
| 847 |
+
))
|
| 848 |
+
)}
|
| 849 |
+
</div>
|
| 850 |
+
)}
|
| 851 |
</div>
|
| 852 |
|
| 853 |
{/* Footer */}
|
|
|
|
| 857 |
? `Supported formats: ${supportedFormatsText}`
|
| 858 |
: view === "db-select"
|
| 859 |
? "More integrations coming soon"
|
| 860 |
+
: view === "catalog"
|
| 861 |
+
? "Structured data sources (database schemas & tabular files)"
|
| 862 |
: "Credentials are encrypted at rest"}
|
| 863 |
</p>
|
| 864 |
</div>
|
src/app/components/Main.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
| 12 |
Bot,
|
| 13 |
Loader2,
|
| 14 |
Database,
|
|
|
|
| 15 |
} from "lucide-react";
|
| 16 |
import ReactMarkdown from "react-markdown";
|
| 17 |
import remarkGfm from "remark-gfm";
|
|
@@ -30,6 +31,7 @@ import {
|
|
| 30 |
getRoom,
|
| 31 |
createRoom,
|
| 32 |
deleteRoom,
|
|
|
|
| 33 |
streamChat,
|
| 34 |
type ChatSource,
|
| 35 |
} from "../../services/api";
|
|
@@ -399,6 +401,18 @@ export default function Main() {
|
|
| 399 |
setCurrentChatId(null);
|
| 400 |
};
|
| 401 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
const handleLogout = () => {
|
| 403 |
localStorage.removeItem("chatbot_user");
|
| 404 |
navigate("/login");
|
|
@@ -485,10 +499,11 @@ export default function Main() {
|
|
| 485 |
const decoder = new TextDecoder();
|
| 486 |
let buffer = "";
|
| 487 |
let currentEvent = "";
|
|
|
|
| 488 |
|
| 489 |
while (true) {
|
| 490 |
const { done, value } = await reader.read();
|
| 491 |
-
if (done) break;
|
| 492 |
|
| 493 |
buffer += decoder.decode(value, { stream: true });
|
| 494 |
const lines = buffer.split(/\r?\n/);
|
|
@@ -501,19 +516,23 @@ export default function Main() {
|
|
| 501 |
const data = line.replace(/^data: ?/, "");
|
| 502 |
|
| 503 |
if (currentEvent === "sources" && data) {
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
} else if (currentEvent === "chunk" && data) {
|
| 518 |
setChats((prev) =>
|
| 519 |
prev.map((chat) =>
|
|
@@ -545,6 +564,7 @@ export default function Main() {
|
|
| 545 |
)
|
| 546 |
);
|
| 547 |
} else if (currentEvent === "done") {
|
|
|
|
| 548 |
break;
|
| 549 |
}
|
| 550 |
}
|
|
@@ -790,6 +810,16 @@ export default function Main() {
|
|
| 790 |
Hasil Interview
|
| 791 |
</button>
|
| 792 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 793 |
</div>
|
| 794 |
)}
|
| 795 |
|
|
|
|
| 12 |
Bot,
|
| 13 |
Loader2,
|
| 14 |
Database,
|
| 15 |
+
Eraser,
|
| 16 |
} from "lucide-react";
|
| 17 |
import ReactMarkdown from "react-markdown";
|
| 18 |
import remarkGfm from "remark-gfm";
|
|
|
|
| 31 |
getRoom,
|
| 32 |
createRoom,
|
| 33 |
deleteRoom,
|
| 34 |
+
clearRoomMessages,
|
| 35 |
streamChat,
|
| 36 |
type ChatSource,
|
| 37 |
} from "../../services/api";
|
|
|
|
| 401 |
setCurrentChatId(null);
|
| 402 |
};
|
| 403 |
|
| 404 |
+
const handleClearChat = async () => {
|
| 405 |
+
if (!currentChatId || !user || isStreaming) return;
|
| 406 |
+
try {
|
| 407 |
+
await clearRoomMessages(currentChatId, user.user_id);
|
| 408 |
+
setChats((prev) =>
|
| 409 |
+
prev.map((chat) =>
|
| 410 |
+
chat.id === currentChatId ? { ...chat, messages: [] } : chat
|
| 411 |
+
)
|
| 412 |
+
);
|
| 413 |
+
} catch { /* silent */ }
|
| 414 |
+
};
|
| 415 |
+
|
| 416 |
const handleLogout = () => {
|
| 417 |
localStorage.removeItem("chatbot_user");
|
| 418 |
navigate("/login");
|
|
|
|
| 499 |
const decoder = new TextDecoder();
|
| 500 |
let buffer = "";
|
| 501 |
let currentEvent = "";
|
| 502 |
+
let streamDone = false;
|
| 503 |
|
| 504 |
while (true) {
|
| 505 |
const { done, value } = await reader.read();
|
| 506 |
+
if (done || streamDone) break;
|
| 507 |
|
| 508 |
buffer += decoder.decode(value, { stream: true });
|
| 509 |
const lines = buffer.split(/\r?\n/);
|
|
|
|
| 516 |
const data = line.replace(/^data: ?/, "");
|
| 517 |
|
| 518 |
if (currentEvent === "sources" && data) {
|
| 519 |
+
try {
|
| 520 |
+
const sources: ChatSource[] = JSON.parse(data);
|
| 521 |
+
setChats((prev) =>
|
| 522 |
+
prev.map((chat) =>
|
| 523 |
+
chat.id === roomId
|
| 524 |
+
? {
|
| 525 |
+
...chat,
|
| 526 |
+
messages: chat.messages.map((m) =>
|
| 527 |
+
m.id === assistantMsgId ? { ...m, sources } : m
|
| 528 |
+
),
|
| 529 |
+
}
|
| 530 |
+
: chat
|
| 531 |
+
)
|
| 532 |
+
);
|
| 533 |
+
} catch {
|
| 534 |
+
// ignore malformed sources
|
| 535 |
+
}
|
| 536 |
} else if (currentEvent === "chunk" && data) {
|
| 537 |
setChats((prev) =>
|
| 538 |
prev.map((chat) =>
|
|
|
|
| 564 |
)
|
| 565 |
);
|
| 566 |
} else if (currentEvent === "done") {
|
| 567 |
+
streamDone = true;
|
| 568 |
break;
|
| 569 |
}
|
| 570 |
}
|
|
|
|
| 810 |
Hasil Interview
|
| 811 |
</button>
|
| 812 |
)}
|
| 813 |
+
{currentPhase === "analytics" && (currentChat?.messages.length ?? 0) > 0 && (
|
| 814 |
+
<button
|
| 815 |
+
onClick={handleClearChat}
|
| 816 |
+
disabled={isStreaming}
|
| 817 |
+
title="Clear chat history"
|
| 818 |
+
className="ml-auto p-1.5 rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 transition disabled:opacity-40"
|
| 819 |
+
>
|
| 820 |
+
<Eraser className="w-3.5 h-3.5" />
|
| 821 |
+
</button>
|
| 822 |
+
)}
|
| 823 |
</div>
|
| 824 |
)}
|
| 825 |
|
src/services/api.ts
CHANGED
|
@@ -158,6 +158,13 @@ export const createRoom = (userId: string, title?: string) =>
|
|
| 158 |
body: JSON.stringify({ user_id: userId, title }),
|
| 159 |
});
|
| 160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
// βββ Documents ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 162 |
|
| 163 |
export const getDocuments = (userId: string): Promise<ApiDocument[]> =>
|
|
|
|
| 158 |
body: JSON.stringify({ user_id: userId, title }),
|
| 159 |
});
|
| 160 |
|
| 161 |
+
export const clearRoomMessages = (roomId: string, userId: string) =>
|
| 162 |
+
request<{ status: string; message: string }>(
|
| 163 |
+
ORCHESTRATION_BASE_URL,
|
| 164 |
+
`/api/v1/chat-rooms/${roomId}/messages?user_id=${userId}`,
|
| 165 |
+
{ method: "DELETE" }
|
| 166 |
+
);
|
| 167 |
+
|
| 168 |
// βββ Documents ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 169 |
|
| 170 |
export const getDocuments = (userId: string): Promise<ApiDocument[]> =>
|