diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..c9c0c1e8 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,7 @@ +reviews: + auto_review: + enabled: true + labels: + - "ready-for-review" + base_branches: + - ".*" # allows all branches diff --git a/CLAUDE.md b/CLAUDE.md index b8e32cfb..5990c8bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,6 +134,17 @@ import { useApp } from '@/app/lib/context/AppContext'; const { sidebarCollapsed, setSidebarCollapsed, toggleSidebar } = useApp(); ``` +## Code Quality Guidelines + +Follow these rules when writing or modifying code in this repository: + +- **File size limit**: Do not let any file exceed **500 LOC**. If a file is approaching or has crossed this limit, split it into smaller modules (extract sub-components, hooks, utilities, or types into their own files). +- **Single Responsibility Principle (SRP)**: Each component, hook, function, or module should do one thing and have one reason to change. If a component handles data fetching, business logic, and UI rendering all together, split it — extract data fetching into a hook, business logic into a utility, and keep the component focused on presentation. +- **Don't Repeat Yourself (DRY)**: Before writing new logic, search the codebase for existing implementations. Reuse and extend rather than duplicate. If you spot the same pattern emerging in 2+ places, extract it into a shared helper, hook, or component in `app/lib/` or `app/components/`. +- **Reuse existing components and icons**: Always check `app/components/` and `app/components/icons/` before creating a new component or icon. Prefer composing or extending existing primitives over authoring new ones. New icons go in `app/components/icons/` as hand-authored React components — do not inline SVGs in feature code. +- **Reuse existing utilities and hooks**: Check `app/lib/utils/`, `app/lib/utils.ts`, and `app/hooks/` before adding new helpers. Domain-specific utilities belong under `app/lib/utils//`. +- **Reuse existing types**: Shared types live in `app/lib/types/` and `app/lib/models.ts` — import from there instead of redefining shapes locally. + ## API Client & Error Handling The BFF layer uses [apiClient.ts](app/lib/apiClient.ts) which forwards requests from Next.js route handlers to the backend at `BACKEND_URL` (defaults to `http://localhost:8000`). Key patterns: diff --git a/app/(main)/configurations/page.tsx b/app/(main)/configurations/page.tsx index a36ab484..93b58c43 100644 --- a/app/(main)/configurations/page.tsx +++ b/app/(main)/configurations/page.tsx @@ -9,10 +9,10 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import { useRouter } from "next/navigation"; import Sidebar from "@/app/components/Sidebar"; import PageHeader from "@/app/components/PageHeader"; -import { colors } from "@/app/lib/colors"; -import { usePaginatedList, useInfiniteScroll } from "@/app/hooks"; +import { Button, Loader } from "@/app/components"; import ConfigCard from "@/app/components/ConfigCard"; -import Loader, { LoaderBox } from "@/app/components/Loader"; +import ConfigLibrarySkeleton from "@/app/components/ConfigLibrarySkeleton"; +import { usePaginatedList, useInfiniteScroll } from "@/app/hooks"; import { EvalJob } from "@/app/lib/types/evaluation"; import { ConfigPublic, @@ -68,7 +68,6 @@ export default function ConfigLibraryPage() { isLoading: isLoading || isLoadingMore, }); - // Responsive column count (matches Tailwind lg/xl breakpoints) useEffect(() => { const update = () => { if (window.innerWidth >= 1280) setColumnCount(3); @@ -80,7 +79,6 @@ export default function ConfigLibraryPage() { return () => window.removeEventListener("resize", update); }, []); - // Distribute configs into fixed columns so items never shift between columns const columns = useMemo(() => { const cols: ConfigPublic[][] = Array.from( { length: columnCount }, @@ -99,13 +97,16 @@ export default function ConfigLibraryPage() { }, [searchInput]); useEffect(() => { + if (!isAuthenticated || !apiKey) return; + + let cancelled = false; const fetchEvaluationCounts = async () => { - if (!isAuthenticated) return; try { const data = await apiFetch( "/api/evaluations", apiKey, ); + if (cancelled) return; const jobs: EvalJob[] = Array.isArray(data) ? data : data.data || []; const counts: Record = {}; jobs.forEach((job) => { @@ -115,11 +116,17 @@ export default function ConfigLibraryPage() { }); setEvaluationCounts(counts); } catch (e) { - console.error("Failed to fetch evaluation counts:", e); + if (!cancelled) { + console.warn("Could not fetch evaluation counts:", e); + } } }; fetchEvaluationCounts(); - }, [activeKey]); + + return () => { + cancelled = true; + }; + }, [apiKey, isAuthenticated]); const loadVersionsForConfig = useCallback( async (configId: string) => { @@ -187,88 +194,44 @@ export default function ConfigLibraryPage() { }; return ( -
+
- {/* Toolbar */} -
+
- + setSearchInput(e.target.value)} placeholder="Search configs..." - className="w-full pl-10 pr-4 py-2 rounded-md text-sm focus:outline-none transition-colors" - style={{ - backgroundColor: colors.bg.secondary, - border: `1px solid ${colors.border}`, - color: colors.text.primary, - }} + className="w-full pl-11 pr-4 py-3 rounded-full bg-bg-secondary text-text-primary text-sm placeholder:text-neutral focus:outline-none focus:ring-1 focus:ring-accent-primary focus:bg-bg-primary transition-colors" />
- +
{isLoading ? ( - + ) : error ? ( -
- -

{error}

+
+ +

+ {error} +

) : configs.length === 0 ? ( -
+
{debouncedQuery ? ( <> - -

- No configs match "{debouncedQuery}" +

+ +
+

+ No configs match “{debouncedQuery}”

) : ( <> - -

+

+ +
+

No configurations yet

-

- Create your first configuration to get started +

+ Create your first configuration to start building prompts + and model setups.

- + + Create Configuration + )}
diff --git a/app/(main)/configurations/prompt-editor/page.tsx b/app/(main)/configurations/prompt-editor/page.tsx index 19677150..e5bbaf28 100644 --- a/app/(main)/configurations/prompt-editor/page.tsx +++ b/app/(main)/configurations/prompt-editor/page.tsx @@ -1,5 +1,6 @@ /** - * Prompt WYSIWYG Editor: Manage prompts and configs with versioning, caching, and URL-based navigation support. + * Prompt WYSIWYG Editor: Manage prompts and configs with versioning, caching, + * and URL-based navigation support. */ "use client"; @@ -7,7 +8,6 @@ import React, { useState, useEffect, Suspense } from "react"; import { useSearchParams } from "next/navigation"; import Sidebar from "@/app/components/Sidebar"; -import { colors } from "@/app/lib/colors"; import { ConfigBlob, Tool } from "@/app/lib/types/promptEditor"; import { hasConfigChanges } from "@/app/lib/promptEditorUtils"; import Header from "@/app/components/prompt-editor/Header"; @@ -15,28 +15,19 @@ import HistorySidebar from "@/app/components/prompt-editor/HistorySidebar"; import PromptEditorPane from "@/app/components/prompt-editor/PromptEditorPane"; import ConfigEditorPane from "@/app/components/prompt-editor/ConfigEditorPane"; import DiffView from "@/app/components/prompt-editor/DiffView"; -import { useToast } from "@/app/components/Toast"; -import Loader from "@/app/components/Loader"; +import { Loader } from "@/app/components"; import { useApp } from "@/app/lib/context/AppContext"; import { useAuth } from "@/app/lib/context/AuthContext"; import { useConfigs } from "@/app/hooks"; -import { - SavedConfig, - ConfigCreate, - ConfigVersionCreate, - ConfigVersionItems, -} from "@/app/lib/types/configs"; -import { invalidateConfigCache } from "@/app/lib/utils"; +import { useConfigPersistence } from "@/app/hooks/useConfigPersistence"; +import { SavedConfig, ConfigVersionItems } from "@/app/lib/types/configs"; import { configState } from "@/app/lib/store/configStore"; -import { apiFetch } from "@/app/lib/apiClient"; -import { isGpt5Model } from "@/app/lib/models"; import { DEFAULT_CONFIG } from "@/app/lib/constants"; function PromptEditorContent() { - const toast = useToast(); const searchParams = useSearchParams(); const { sidebarCollapsed } = useApp(); - const { activeKey, isAuthenticated } = useAuth(); + const { activeKey } = useAuth(); const urlConfigId = searchParams.get("config"); const urlVersion = searchParams.get("version"); const showHistory = searchParams.get("history") === "true"; @@ -44,6 +35,7 @@ function PromptEditorContent() { const urlDatasetId = searchParams.get("dataset"); const urlExperimentName = searchParams.get("experiment"); const fromEvaluations = searchParams.get("from") === "evaluations"; + const { configs: savedConfigs, isLoading, @@ -53,13 +45,14 @@ function PromptEditorContent() { versionItemsMap: hookVersionItemsMap, allConfigMeta, } = useConfigs({ pageSize: 0 }); - const [isSaving, setIsSaving] = useState(false); + const initialLoadComplete = !isLoading; const editorInitialized = React.useRef(false); const [editorReady, setEditorReady] = useState(!urlConfigId); const [stableVersionItemsMap, setStableVersionItemsMap] = useState< Record >({}); + const [currentContent, setCurrentContent] = useState( "You are a helpful AI assistant.\nYou provide clear and concise answers.\nYou are polite and professional.", ); @@ -78,13 +71,16 @@ function PromptEditorContent() { ); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [commitMessage, setCommitMessage] = useState(""); - const [showHistorySidebar, setShowHistorySidebar] = useState(true); - const [showConfigPane, setShowConfigPane] = useState(true); const [selectedVersion, setSelectedVersion] = useState( null, ); const [compareWith, setCompareWith] = useState(null); + const { isSaving, saveConfig, renameConfig } = useConfigPersistence({ + allConfigMeta, + refetchConfigs, + }); + useEffect(() => { if (Object.keys(hookVersionItemsMap).length > 0) { setStableVersionItemsMap((prev) => ({ ...prev, ...hookVersionItemsMap })); @@ -123,53 +119,47 @@ function PromptEditorContent() { setCurrentConfigVersion(config.version); setTools(config.tools || []); setExpandedConfigs((prev) => - prev.has(currentConfigParentId) + prev.has(config.config_id) ? prev - : new Set([...prev, currentConfigParentId]), + : new Set([...prev, config.config_id]), ); if (selectInHistory) setSelectedVersion(config); }, [], ); + const resetEditor = React.useCallback(() => { + setCurrentContent(""); + setCurrentConfigBlob(DEFAULT_CONFIG); + setProvider("openai"); + setTemperature(0.7); + setSelectedConfigId(""); + setCurrentConfigName(""); + setCurrentConfigParentId(""); + setCurrentConfigVersion(0); + setTools([]); + }, []); + const handleLoadConfig = React.useCallback( (config: SavedConfig | null) => { if (!config) { - // Reset to new config - setCurrentContent(""); - setCurrentConfigBlob(DEFAULT_CONFIG); - setProvider("openai"); - setTemperature(0.7); - setSelectedConfigId(""); - setCurrentConfigName(""); - setCurrentConfigParentId(""); - setCurrentConfigVersion(0); - setTools([]); + resetEditor(); return; } loadVersionsForConfig(config.config_id); applyConfig(config); }, - [applyConfig, loadVersionsForConfig, DEFAULT_CONFIG], + [applyConfig, loadVersionsForConfig, resetEditor], ); - // Initialize editor from URL params — runs once, on first load completion + // Initialize editor from URL params — runs once useEffect(() => { if (!initialLoadComplete) return; if (editorInitialized.current) return; editorInitialized.current = true; - // If new config is requested, reset to defaults if (isNewConfig) { - setCurrentContent(""); - setCurrentConfigBlob(DEFAULT_CONFIG); - setProvider("openai"); - setTemperature(0.7); - setSelectedConfigId(""); - setCurrentConfigName(""); - setCurrentConfigParentId(""); - setCurrentConfigVersion(0); - setTools([]); + resetEditor(); setEditorReady(true); return; } @@ -181,17 +171,14 @@ function PromptEditorContent() { (async () => { await loadVersionsForConfig(urlConfigId); - const items = configState.versionItemsCache[urlConfigId] ?? []; if (items.length === 0) { setEditorReady(true); return; } - const versionNum = urlVersion ? parseInt(urlVersion) : items.reduce((a, b) => (b.version > a.version ? b : a)).version; - const config = await loadSingleVersion(urlConfigId, versionNum); if (config) applyConfig(config, showHistory); setEditorReady(true); @@ -205,29 +192,27 @@ function PromptEditorContent() { loadVersionsForConfig, loadSingleVersion, applyConfig, - DEFAULT_CONFIG, + resetEditor, ]); - // Re-populate version items when missing (e.g. after background cache revalidation wipes versionItemsCache) + // Re-populate version items when missing (e.g. after background cache wipe) useEffect(() => { if (currentConfigParentId && !versionItemsMap[currentConfigParentId]) { loadVersionsForConfig(currentConfigParentId); } }, [currentConfigParentId, versionItemsMap, loadVersionsForConfig]); + // Track unsaved changes by diffing against the loaded config useEffect(() => { if (!selectedConfigId) { setHasUnsavedChanges(true); return; } - const selectedConfig = savedConfigs.find((c) => c.id === selectedConfigId); if (!selectedConfig) { setHasUnsavedChanges(true); return; } - - // Compare current state with selected config const promptChanged = currentContent !== selectedConfig.promptContent; const configChanged = hasConfigChanges(currentConfigBlob, { completion: { @@ -241,7 +226,6 @@ function PromptEditorContent() { }, }, }); - setHasUnsavedChanges(promptChanged || configChanged); }, [ selectedConfigId, @@ -253,132 +237,26 @@ function PromptEditorContent() { savedConfigs, ]); - const handleSaveConfig = async () => { - if (!currentConfigName.trim()) { - toast.error("Please enter a configuration name"); - return; - } - - const apiKey = activeKey?.key ?? ""; - if (!isAuthenticated) { - toast.error("Please log in to save configurations."); - return; - } - - setIsSaving(true); - - try { - const tools = currentConfigBlob.completion.params.tools || []; - - const allKnowledgeBaseIds: string[] = []; - let maxNumResults = 20; - - tools.forEach((tool) => { - allKnowledgeBaseIds.push(...tool.knowledge_base_ids); - // Use max_num_results from first tool (could be made configurable) - if (allKnowledgeBaseIds.length === tool.knowledge_base_ids.length) { - maxNumResults = tool.max_num_results; - } - }); - - const model = currentConfigBlob.completion.params.model; - const gpt5 = isGpt5Model(model); - - const configBlob: ConfigBlob = { - completion: { - provider: currentConfigBlob.completion.provider, - type: currentConfigBlob.completion.type || "text", - params: { - model, - instructions: currentContent, - ...(!gpt5 && { - temperature: currentConfigBlob.completion.params.temperature, - }), - ...(allKnowledgeBaseIds.length > 0 && { - knowledge_base_ids: allKnowledgeBaseIds, - ...(!gpt5 && { max_num_results: maxNumResults }), - }), - }, - }, - ...(currentConfigBlob.input_guardrails?.length && { - input_guardrails: currentConfigBlob.input_guardrails, - }), - ...(currentConfigBlob.output_guardrails?.length && { - output_guardrails: currentConfigBlob.output_guardrails, - }), - }; - - const existingConfigMeta = allConfigMeta.find( - (m) => m.name === currentConfigName.trim(), - ); - - if (existingConfigMeta) { - const versionCreate: ConfigVersionCreate = { - config_blob: configBlob, - commit_message: commitMessage.trim() || `Updated prompt and config`, - }; - - const data = await apiFetch<{ success: boolean; error?: string }>( - `/api/configs/${existingConfigMeta.id}/versions`, - apiKey, - { - method: "POST", - body: JSON.stringify(versionCreate), - }, - ); - - if (!data.success) { - toast.error( - `Failed to create version: ${data.error || "Unknown error"}`, - ); - return; - } - - toast.success( - `Configuration "${currentConfigName}" updated! New version created.`, - ); - } else { - const configCreate: ConfigCreate = { - name: currentConfigName.trim(), - description: `${provider} configuration with prompt`, - config_blob: configBlob, - commit_message: commitMessage.trim() || "Initial version", - }; - - const data = await apiFetch<{ - success: boolean; - data?: unknown; - error?: string; - }>("/api/configs", apiKey, { - method: "POST", - body: JSON.stringify(configCreate), - }); - - if (!data.success || !data.data) { - toast.error( - `Failed to create config: ${data.error || "Unknown error"}`, - ); - return; - } - - toast.success( - `Configuration "${currentConfigName}" created successfully!`, - ); - } - - invalidateConfigCache(); - await refetchConfigs(true); - + const handleSave = async () => { + const ok = await saveConfig({ + currentConfigName, + currentConfigBlob, + currentContent, + commitMessage, + provider, + }); + if (ok) { setHasUnsavedChanges(false); setCommitMessage(""); - } catch (e) { - console.error("Failed to save config:", e); - toast.error("Failed to save configuration. Please try again."); - } finally { - setIsSaving(false); } }; + const handleRename = async (configId: string, newName: string) => { + const ok = await renameConfig(configId, newName, currentConfigName); + if (ok) setCurrentConfigName(newName.trim()); + return ok; + }; + return (
@@ -400,16 +278,8 @@ function PromptEditorContent() {
{!editorReady ? ( -
-
-
-

- Loading configuration... -

-
+
+
) : ( <> @@ -419,15 +289,11 @@ function PromptEditorContent() { currentConfigId={currentConfigParentId || undefined} expandedConfigs={expandedConfigs} setExpandedConfigs={setExpandedConfigs} - collapsed={!showHistorySidebar} - onToggle={() => setShowHistorySidebar(!showHistorySidebar)} onSelectVersion={(version) => { setSelectedVersion(version); setCompareWith(null); }} - onLoadVersion={(version) => { - handleLoadConfig(version); - }} + onLoadVersion={handleLoadConfig} onBackToEditor={() => { setSelectedVersion(null); setCompareWith(null); @@ -450,7 +316,7 @@ function PromptEditorContent() { loadSingleVersionForConfig={loadSingleVersion} /> - {showHistorySidebar && selectedVersion ? ( + {selectedVersion ? (
-
+
setShowConfigPane(!showConfigPane)} apiKey={activeKey?.key ?? ""} />
diff --git a/app/(main)/datasets/page.tsx b/app/(main)/datasets/page.tsx index 1234bc11..7da82d9c 100644 --- a/app/(main)/datasets/page.tsx +++ b/app/(main)/datasets/page.tsx @@ -1,45 +1,46 @@ /** - * Datasets.tsx - Dataset Management Interface - * - * Allows users to upload CSV datasets and manage them via backend API + * Datasets - Dataset Management Interface + * Allows users to upload CSV datasets and manage them via backend API. */ "use client"; -import { useState, useEffect } from "react"; - +import { useState, useEffect, useCallback } from "react"; import { useAuth } from "@/app/lib/context/AuthContext"; import { useApp } from "@/app/lib/context/AppContext"; import { apiFetch } from "@/app/lib/apiClient"; import Sidebar from "@/app/components/Sidebar"; -import PageHeader from "@/app/components/PageHeader"; +import { PageHeader } from "@/app/components"; import { useToast } from "@/app/components/Toast"; +import DatasetListing from "@/app/components/datasets/DatasetListing"; +import UploadDatasetModal from "@/app/components/datasets/UploadDatasetModal"; +import DeleteDatasetModal from "@/app/components/datasets/DeleteDatasetModal"; import { Dataset } from "@/app/lib/types/dataset"; export const DATASETS_STORAGE_KEY = "kaapi_datasets"; +const ITEMS_PER_PAGE = 10; + export default function Datasets() { const toast = useToast(); const { sidebarCollapsed } = useApp(); - const [isModalOpen, setIsModalOpen] = useState(false); + const { activeKey: apiKey, isAuthenticated } = useAuth(); + const [datasets, setDatasets] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + + const [isUploadOpen, setIsUploadOpen] = useState(false); const [selectedFile, setSelectedFile] = useState(null); const [datasetName, setDatasetName] = useState(""); const [duplicationFactor, setDuplicationFactor] = useState("1"); const [isUploading, setIsUploading] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const { activeKey: apiKey, isAuthenticated } = useAuth(); - const [currentPage, setCurrentPage] = useState(1); - const itemsPerPage = 10; - useEffect(() => { - if (isAuthenticated) { - fetchDatasets(); - } - }, [apiKey, isAuthenticated]); + const [datasetToDelete, setDatasetToDelete] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); - const fetchDatasets = async () => { + const fetchDatasets = useCallback(async () => { if (!isAuthenticated) { setError("Please log in to continue."); return; @@ -53,55 +54,50 @@ export default function Datasets() { "/api/evaluations/datasets", apiKey?.key ?? "", ); - const datasetList = Array.isArray(data) ? data : data.data || []; - setDatasets(datasetList); + const list = Array.isArray(data) ? data : data.data || []; + setDatasets(list); } catch (err: unknown) { console.error("Failed to fetch datasets:", err); setError(err instanceof Error ? err.message : "Failed to fetch datasets"); } finally { setIsLoading(false); } + }, [apiKey, isAuthenticated]); + + useEffect(() => { + if (isAuthenticated) { + fetchDatasets(); + } + }, [fetchDatasets, isAuthenticated]); + + const resetUploadForm = () => { + setSelectedFile(null); + setDatasetName(""); + setDuplicationFactor("1"); }; const handleFileSelect = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; - if (!file.name.endsWith(".csv")) { toast.error("Please select a CSV file"); event.target.value = ""; return; } - setSelectedFile(file); - // Auto-fill dataset name from filename (without extension) - const nameFromFile = file.name.replace(/\.csv$/i, ""); - setDatasetName(nameFromFile); + setDatasetName(file.name.replace(/\.csv$/i, "")); }; const handleUpload = async () => { - if (!selectedFile) { - toast.error("Please select a file first"); - return; - } - - if (!datasetName.trim()) { - toast.error("Please enter a dataset name"); - return; - } - - if (!isAuthenticated) { - toast.error("Please log in to continue."); - return; - } + if (!selectedFile) return toast.error("Please select a file first"); + if (!datasetName.trim()) return toast.error("Please enter a dataset name"); + if (!isAuthenticated) return toast.error("Please log in to continue."); setIsUploading(true); - try { const formData = new FormData(); formData.append("file", selectedFile); formData.append("dataset_name", datasetName.trim()); - formData.append("duplication_factor", duplicationFactor || "1"); await apiFetch("/api/evaluations/datasets", apiKey?.key ?? "", { @@ -110,58 +106,58 @@ export default function Datasets() { }); await fetchDatasets(); - setSelectedFile(null); - setDatasetName(""); - setDuplicationFactor("1"); - setIsModalOpen(false); - + resetUploadForm(); + setIsUploadOpen(false); toast.success("Dataset uploaded successfully!"); - } catch (error) { - console.error("Upload error:", error); + } catch (e) { + console.error("Upload error:", e); toast.error( - `Failed to upload dataset: ${error instanceof Error ? error.message : "Unknown error"}`, + `Failed to upload dataset: ${e instanceof Error ? e.message : "Unknown error"}`, ); } finally { setIsUploading(false); } }; - const handleDeleteDataset = async (datasetId: number) => { + const handleRequestDelete = (datasetId: number) => { if (!isAuthenticated) { toast.error("Please log in to continue"); return; } + const target = datasets.find((d) => d.dataset_id === datasetId); + if (target) setDatasetToDelete(target); + }; - // Using browser confirm for now - could be replaced with a custom modal later - if (!confirm("Are you sure you want to delete this dataset?")) { - return; - } - + const handleConfirmDelete = async () => { + if (!datasetToDelete) return; + setIsDeleting(true); try { await apiFetch( - `/api/evaluations/datasets/${datasetId}`, + `/api/evaluations/datasets/${datasetToDelete.dataset_id}`, apiKey?.key ?? "", - { - method: "DELETE", - }, + { method: "DELETE" }, ); - await fetchDatasets(); toast.success("Dataset deleted successfully"); - } catch (error) { - console.error("Delete error:", error); + setDatasetToDelete(null); + } catch (e) { + console.error("Delete error:", e); toast.error( - `Failed to delete dataset: ${error instanceof Error ? error.message : "Unknown error"}`, + `Failed to delete dataset: ${e instanceof Error ? e.message : "Unknown error"}`, ); + } finally { + setIsDeleting(false); } }; - const indexOfLastItem = currentPage * itemsPerPage; - const indexOfFirstItem = indexOfLastItem - itemsPerPage; - const currentDatasets = datasets.slice(indexOfFirstItem, indexOfLastItem); - const totalPages = Math.ceil(datasets.length / itemsPerPage); + const totalPages = Math.ceil(datasets.length / ITEMS_PER_PAGE); + const start = (currentPage - 1) * ITEMS_PER_PAGE; + const currentDatasets = datasets.slice(start, start + ITEMS_PER_PAGE); - const paginate = (pageNumber: number) => setCurrentPage(pageNumber); + const closeUpload = () => { + setIsUploadOpen(false); + resetUploadForm(); + }; return (
@@ -178,752 +174,40 @@ export default function Datasets() {
setIsModalOpen(true)} + onDelete={handleRequestDelete} + onUploadNew={() => setIsUploadOpen(true)} isLoading={isLoading} error={error} isAuthenticated={isAuthenticated} totalPages={totalPages} currentPage={currentPage} - onPageChange={paginate} - /> -
-
-
-
- - {isModalOpen && ( - { - setIsModalOpen(false); - setSelectedFile(null); - setDatasetName(""); - setDuplicationFactor("1"); - }} - /> - )} -
- ); -} - -// ============ DATASET LISTING COMPONENT ============ -interface DatasetListingProps { - datasets: Dataset[]; - onDelete: (datasetId: number) => void; - onUploadNew: () => void; - isLoading: boolean; - error: string | null; - isAuthenticated: boolean; - totalPages: number; - currentPage: number; - onPageChange: (page: number) => void; -} - -function DatasetListing({ - datasets, - onDelete, - onUploadNew, - isLoading, - error, - isAuthenticated, - totalPages, - currentPage, - onPageChange, -}: DatasetListingProps) { - return ( - <> - {/* Datasets List Card */} -
- {/* Header with Upload Button */} -
-

- Your Datasets -

- -
- - {/* Loading State */} - {isLoading && datasets.length === 0 ? ( -
- - - -

Loading datasets...

-
- ) : !isAuthenticated ? ( -
-

Login required

-

Please log in to manage datasets

-
- ) : error ? ( -
-

- Error: {error} -

-
- ) : datasets.length === 0 ? ( -
- - - -

- No datasets found -

-

- Upload your first CSV dataset to get started with evaluations -

- -
- ) : ( -
- {datasets.map((dataset) => ( -
-
-
-
- - - -

- {dataset.dataset_name} -

-
-
-
-
- Dataset ID -
-
- {dataset.dataset_id} -
-
-
-
- Total Items -
-
- {dataset.total_items} -
-
-
-
- Original Items -
-
- {dataset.original_items} -
-
-
-
- Duplication Factor -
-
- ×{dataset.duplication_factor} -
-
-
-
-
- -
-
-
- ))} -
- )} - - {/* Pagination */} - {!isLoading && - !error && - isAuthenticated && - datasets.length > 0 && - totalPages > 1 && ( -
-

- Page {currentPage} of {totalPages} -

-
- - -
- )} -
- - {/* Info Card */} -
-
- - - -
-

- Storage Note -

-

- Datasets are stored on the server and synced with Langfuse for - evaluation tracking. -

- - ); -} -// ============ UPLOAD DATASET MODAL ============ -export interface UploadDatasetModalProps { - selectedFile: File | null; - datasetName: string; - duplicationFactor: string; - isUploading: boolean; - onFileSelect: (event: React.ChangeEvent) => void; - onDatasetNameChange: (value: string) => void; - onDuplicationFactorChange: (value: string) => void; - onUpload: () => void; - onClose: () => void; -} - -export function UploadDatasetModal({ - selectedFile, - datasetName, - duplicationFactor, - isUploading, - onFileSelect, - onDatasetNameChange, - onDuplicationFactorChange, - onUpload, - onClose, -}: UploadDatasetModalProps) { - // Handle backdrop click - const handleBackdropClick = (e: React.MouseEvent) => { - if (e.target === e.currentTarget) { - onClose(); - } - }; - - // Handle escape key - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose(); - } - }; - window.addEventListener("keydown", handleEscape); - return () => window.removeEventListener("keydown", handleEscape); - }, [onClose]); - - return ( -
-
e.stopPropagation()} - > - {/* Modal Header */} -
-

- Upload New Dataset -

- -
- - {/* Modal Body */} -
-

- Upload a CSV file containing your QnA dataset. The file will be - stored in your browser's local storage. -

- - {/* File Selection Area */} -
-
-
- - - -
-
- - -
- {selectedFile && ( -
- Selected:{" "} - - {selectedFile.name} - - - ({Math.round(selectedFile.size / 1024)} KB) - -
- )} -
-
- - {/* Dataset Name Field */} - {selectedFile && ( - <> -
- - onDatasetNameChange(e.target.value)} - placeholder="Enter dataset name" - disabled={isUploading} - className="w-full px-4 py-2 rounded-md border text-sm focus:outline-none focus:ring-2" - style={{ - borderColor: datasetName ? "#171717" : "hsl(0, 0%, 85%)", - backgroundColor: isUploading - ? "hsl(0, 0%, 97%)" - : "hsl(0, 0%, 100%)", - color: "hsl(330, 3%, 19%)", - }} - /> -
- -
- - onDuplicationFactorChange(e.target.value)} - placeholder="1" - min="1" - disabled={isUploading} - className="w-full px-4 py-2 rounded-md border text-sm focus:outline-none focus:ring-2" - style={{ - borderColor: "hsl(0, 0%, 85%)", - backgroundColor: isUploading - ? "hsl(0, 0%, 97%)" - : "hsl(0, 0%, 100%)", - color: "hsl(330, 3%, 19%)", - }} - /> -

- Number of times to duplicate the dataset rows (leave empty or - 1 for no duplication) -

-
- - )} - - {/* Sample CSV Format */} -
-
- - - -
-

- Expected CSV Format: -

-
-                  {`question,answer
-"What is X?","Answer Y"`}
-                
-
-
-
-
- - {/* Modal Footer */} -
- - -
-
+ + + setDatasetToDelete(null)} + onConfirm={handleConfirmDelete} + />
); } diff --git a/app/(main)/document/page.tsx b/app/(main)/document/page.tsx index ac1b5a6b..fa885ad0 100644 --- a/app/(main)/document/page.tsx +++ b/app/(main)/document/page.tsx @@ -15,10 +15,13 @@ import { import { DocumentListing } from "@/app/components/document/DocumentListing"; import { DocumentPreview } from "@/app/components/document/DocumentPreview"; import { UploadDocumentModal } from "@/app/components/document/UploadDocumentModal"; +import DeleteDocumentModal from "@/app/components/document/DeleteDocumentModal"; +import Modal from "@/app/components/Modal"; import { DEFAULT_PAGE_LIMIT, MAX_DOCUMENT_SIZE_BYTES, MAX_DOCUMENT_SIZE_MB, + MAX_DOCUMENT_UPLOAD_BATCH, } from "@/app/lib/constants"; import { Document } from "@/app/lib/types/document"; @@ -30,10 +33,14 @@ export default function DocumentPage() { null, ); const [isLoadingDocument, setIsLoadingDocument] = useState(false); - const [selectedFile, setSelectedFile] = useState(null); + const [selectedFiles, setSelectedFiles] = useState([]); const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [uploadPhase, setUploadPhase] = useState("uploading"); + const [currentUploadIndex, setCurrentUploadIndex] = useState(0); + const [documentToDelete, setDocumentToDelete] = useState( + null, + ); const abortUploadRef = useRef<(() => void) | null>(null); const { activeKey: apiKey, isAuthenticated } = useAuth(); @@ -57,78 +64,126 @@ export default function DocumentPage() { }); const handleFileSelect = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; + const files = Array.from(event.target.files ?? []); + event.target.value = ""; + if (files.length === 0) return; + + const remaining = MAX_DOCUMENT_UPLOAD_BATCH - selectedFiles.length; + if (remaining <= 0) return; + + const accepted: File[] = []; + let oversizedCount = 0; + for (const file of files.slice(0, remaining)) { + if (file.size > MAX_DOCUMENT_SIZE_BYTES) { + oversizedCount += 1; + continue; + } + accepted.push(file); + } - if (file.size > MAX_DOCUMENT_SIZE_BYTES) { + if (oversizedCount > 0) { toast.error( - `File size exceeds ${MAX_DOCUMENT_SIZE_MB} MB limit. Please select a smaller file within ${MAX_DOCUMENT_SIZE_MB} MB.`, + `${oversizedCount} file${oversizedCount > 1 ? "s" : ""} exceed the ${MAX_DOCUMENT_SIZE_MB} MB limit and were skipped.`, + ); + } + if (files.length > remaining) { + toast.warning( + `You can upload up to ${MAX_DOCUMENT_UPLOAD_BATCH} documents at a time.`, ); - event.target.value = ""; - return; } - setSelectedFile(file); + if (accepted.length > 0) { + setSelectedFiles((prev) => [...prev, ...accepted]); + } }; - const handleUpload = async () => { - if (!isAuthenticated || !selectedFile) return; + const handleRemoveSelectedFile = (index: number) => { + setSelectedFiles((prev) => prev.filter((_, i) => i !== index)); + }; - setIsUploading(true); + const uploadOneFile = async (file: File): Promise => { setUploadProgress(0); setUploadPhase("uploading"); - try { - const formData = new FormData(); - formData.append("src", selectedFile); + const formData = new FormData(); + formData.append("src", file); - const { promise, abort } = uploadWithProgress<{ data?: { id: string } }>( - "/api/document", - apiKey?.key ?? "", - formData, - (percent, phase) => { - setUploadProgress(percent); - setUploadPhase(phase); - }, - ); - abortUploadRef.current = abort; + const { promise, abort } = uploadWithProgress<{ data?: { id: string } }>( + "/api/document", + apiKey?.key ?? "", + formData, + (percent, phase) => { + setUploadProgress(percent); + setUploadPhase(phase); + }, + ); + abortUploadRef.current = abort; + + try { const data = await promise; - if (selectedFile && data.data?.id) { + if (data.data?.id) { const fileSizeMap = JSON.parse( localStorage.getItem("document_file_sizes") || "{}", ); - fileSizeMap[data.data.id] = selectedFile.size; + fileSizeMap[data.data.id] = file.size; localStorage.setItem( "document_file_sizes", JSON.stringify(fileSizeMap), ); } - - refetch(); - setSelectedFile(null); - setIsModalOpen(false); - - toast.success("Document uploaded successfully!"); + return true; } catch (error) { console.error("Upload error:", error); toast.error( - `Failed to upload document: ${error instanceof Error ? error.message : "Unknown error"}`, + `Failed to upload "${file.name}": ${error instanceof Error ? error.message : "Unknown error"}`, ); + return false; } finally { - setIsUploading(false); abortUploadRef.current = null; } }; - const handleDeleteDocument = async (documentId: string) => { + const handleUpload = async () => { + if (!isAuthenticated || selectedFiles.length === 0) return; + + setIsUploading(true); + setCurrentUploadIndex(0); + + let successCount = 0; + for (let i = 0; i < selectedFiles.length; i += 1) { + setCurrentUploadIndex(i); + const ok = await uploadOneFile(selectedFiles[i]); + if (ok) successCount += 1; + } + + refetch(); + setSelectedFiles([]); + setIsModalOpen(false); + setIsUploading(false); + setCurrentUploadIndex(0); + + if (successCount > 0) { + toast.success( + successCount === 1 + ? "Document uploaded successfully!" + : `${successCount} documents uploaded successfully!`, + ); + } + }; + + const handleRequestDelete = (documentId: string) => { if (!isAuthenticated) { toast.error("Please log in to continue"); return; } + const doc = documents.find((d) => d.id === documentId); + if (doc) setDocumentToDelete(doc); + }; - if (!confirm("Are you sure you want to delete this document?")) { - return; - } + const handleConfirmDelete = async () => { + if (!documentToDelete) return; + const documentId = documentToDelete.id; + setDocumentToDelete(null); try { await apiFetch(`/api/document/${documentId}`, apiKey?.key ?? "", { @@ -180,7 +235,7 @@ export default function DocumentPage() { }; return ( -
+
@@ -190,13 +245,13 @@ export default function DocumentPage() { subtitle="Manage your uploaded documents" /> -
-
+
+
setIsModalOpen(true)} isLoading={isLoading} isLoadingMore={isLoadingMore} @@ -205,7 +260,7 @@ export default function DocumentPage() { />
-
+
+ {/* Mobile/tablet — preview rendered in a modal */} +
+ setSelectedDocument(null)} + title={selectedDocument?.fname ?? "Document Preview"} + maxWidth="max-w-3xl" + maxHeight="max-h-[90vh]" + > +
+ +
+
+
+ setSelectedFiles([])} onUpload={handleUpload} onClose={() => { abortUploadRef.current?.(); setIsModalOpen(false); - setSelectedFile(null); + setSelectedFiles([]); setUploadProgress(0); setUploadPhase("uploading"); + setCurrentUploadIndex(0); }} /> + + setDocumentToDelete(null)} + onConfirm={handleConfirmDelete} + />
); } diff --git a/app/(main)/evaluations/[id]/page.tsx b/app/(main)/evaluations/[id]/page.tsx index 11a933fc..744d6fc3 100644 --- a/app/(main)/evaluations/[id]/page.tsx +++ b/app/(main)/evaluations/[id]/page.tsx @@ -30,9 +30,8 @@ import ConfigModal from "@/app/components/ConfigModal"; import Sidebar from "@/app/components/Sidebar"; import DetailedResultsTable from "@/app/components/evaluations/DetailedResultsTable"; import MetricsOverview from "@/app/components/evaluations/MetricsOverview"; -import { Button, Modal, ResultsTableSkeleton } from "@/app/components"; +import { Button, Modal, ResultsTableSkeleton, Loader } from "@/app/components"; import { useToast } from "@/app/components/Toast"; -import Loader from "@/app/components/Loader"; import { MenuIcon, ChevronLeftIcon, @@ -252,7 +251,7 @@ export default function EvaluationReport() { job.status.toLowerCase() !== "failed"; const segmentedClass = - "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all cursor-pointer border border-transparent text-text-primary hover:bg-black/4 hover:shadow-[0_0_0_1px_rgba(0,0,0,0.06)] data-[selected=true]:bg-bg-primary data-[selected=true]:border-border data-[selected=true]:shadow-[0_1px_2px_rgba(0,0,0,0.08)] data-[selected=true]:hover:bg-bg-primary data-[selected=true]:hover:shadow-[0_1px_2px_rgba(0,0,0,0.08)]"; + "inline-flex items-center gap-1.5 px-4 py-1.5 text-xs font-semibold rounded-full transition-colors cursor-pointer text-accent-primary/70 hover:text-accent-primary data-[selected=true]:bg-accent-primary data-[selected=true]:text-white data-[selected=true]:shadow-[0_1px_2px_rgba(0,0,0,0.12)] data-[selected=true]:hover:bg-accent-hover"; return (
@@ -292,7 +291,7 @@ export default function EvaluationReport() {
-
+
- )} -
- - {apiKeys.length === 0 ? ( -
- - - -

- No API key stored yet -

-

- Add your API key to get started with evaluations -

- -
- ) : ( -
- {/* Info Message */} -
-
- - - -

- Only one API key can be stored at a time. Delete this key to - add a different one. -

-
-
- - {apiKeys.map((apiKey) => ( -
-
-
-
- - {apiKey.provider} - -

- {apiKey.label} -

-
-
- - {visibleKeys.has(apiKey.id) - ? apiKey.key - : "•".repeat(32)} - -
-

- Added {new Date(apiKey.createdAt).toLocaleDateString()} -

-
-
- - - -
-
-
- ))} -
- )} -
- - {/* Info Card */} -
-
- - - -
-

- Security Note -

-

- API keys are stored in your browser's local storage. For - production use, consider implementing secure server-side storage. -

-
-
-
- - ); -} - -// ============ ADD KEY MODAL ============ -interface AddKeyModalProps { - newKeyLabel: string; - newKeyValue: string; - newKeyProvider: string; - providers: string[]; - onLabelChange: (value: string) => void; - onValueChange: (value: string) => void; - onProviderChange: (value: string) => void; - onAddKey: () => void; - onClose: () => void; -} - -function AddKeyModal({ - newKeyLabel, - newKeyValue, - newKeyProvider, - providers, - onLabelChange, - onValueChange, - onProviderChange, - onAddKey, - onClose, -}: AddKeyModalProps) { - // Handle backdrop click - const handleBackdropClick = (e: React.MouseEvent) => { - if (e.target === e.currentTarget) { - onClose(); - } - }; - - // Handle escape key - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose(); - } - }; - window.addEventListener("keydown", handleEscape); - return () => window.removeEventListener("keydown", handleEscape); - }, [onClose]); - - return ( -
-
e.stopPropagation()} - > - {/* Modal Header */} -
-

- Add New API Key -

- -
- - {/* Modal Body */} -
-

- Add a new API key to use in your evaluation workflows. All keys are - stored securely in your browser. -

- -
-
- - -
- -
- - onLabelChange(e.target.value)} - placeholder="e.g., Production Key" - className="w-full px-4 py-2 rounded-md border focus:outline-none focus:ring-2 border-[hsl(0,0%,85%)] bg-[hsl(0,0%,100%)] text-[hsl(330,3%,19%)]" - /> -
- -
- - onValueChange(e.target.value)} - placeholder="Paste your API key here" - className="w-full px-4 py-2 rounded-md border focus:outline-none focus:ring-2 font-mono text-sm border-[hsl(0,0%,85%)] bg-[hsl(0,0%,100%)] text-[hsl(330,3%,19%)]" - /> -
-
- - {/* Info Card */} -
-
- - - -

- API keys are stored in your browser's local storage. -

-
-
-
- - {/* Modal Footer */} -
- - -
-
+
); } diff --git a/app/(main)/knowledge-base/page.tsx b/app/(main)/knowledge-base/page.tsx index 6be08ee9..a30a30da 100644 --- a/app/(main)/knowledge-base/page.tsx +++ b/app/(main)/knowledge-base/page.tsx @@ -1,38 +1,34 @@ "use client"; -import { useState, useEffect, useRef } from "react"; -import { formatDate } from "@/app/components/utils"; -import Sidebar from "@/app/components/Sidebar"; -import PageHeader from "@/app/components/PageHeader"; -import Modal from "@/app/components/Modal"; -import { - CloseIcon, - TrashIcon, - BookOpenIcon, - ChevronRightIcon, -} from "@/app/components/icons"; -import { Button, Field } from "@/app/components"; -import { useAuth } from "@/app/lib/context/AuthContext"; +import { useState } from "react"; +import CollectionsList from "@/app/components/knowledge-base/CollectionsList"; +import CreateCollectionForm from "@/app/components/knowledge-base/CreateCollectionForm"; +import CollectionDetail from "@/app/components/knowledge-base/CollectionDetail"; +import DocumentPickerModal from "@/app/components/knowledge-base/DocumentPickerModal"; +import DeleteCollectionModal from "@/app/components/knowledge-base/DeleteCollectionModal"; +import DocumentPreviewModal from "@/app/components/knowledge-base/DocumentPreviewModal"; +import { Modal, Loader, Sidebar, PageHeader } from "@/app/components"; +import { BookOpenIcon } from "@/app/components/icons"; import { useApp } from "@/app/lib/context/AppContext"; -import { apiFetch } from "@/app/lib/apiClient"; -import { - JobStatusData, - CollectionResponse, - DocumentResponse, - CreateCollectionResponse, - DeleteCollectionResponse, - DocumentDetailResponse, -} from "@/app/lib/types/knowledgeBase"; -import { Document, Collection } from "@/app/lib/types/document"; +import { useCollections } from "@/app/hooks/useCollections"; +import { Document } from "@/app/lib/types/document"; export default function KnowledgeBasePage() { const { sidebarCollapsed } = useApp(); - const [collections, setCollections] = useState([]); - const [availableDocuments, setAvailableDocuments] = useState([]); - const [selectedCollection, setSelectedCollection] = - useState(null); - const [isLoading, setIsLoading] = useState(false); - const [isCreating, setIsCreating] = useState(false); + const { + collections, + availableDocuments, + selectedCollection, + isLoading, + isLoadingDetail, + isCreating, + setSelectedCollection, + fetchCollectionDetails, + createCollection, + deleteCollection, + fetchAndPreviewDoc, + } = useCollections(); + const [showCreateForm, setShowCreateForm] = useState(false); const [showDocumentPicker, setShowDocumentPicker] = useState(false); const [showConfirmDelete, setShowConfirmDelete] = useState(false); @@ -41,1328 +37,228 @@ export default function KnowledgeBasePage() { ); const [showDocPreviewModal, setShowDocPreviewModal] = useState(false); const [previewDoc, setPreviewDoc] = useState(null); - const { activeKey: apiKey, isAuthenticated } = useAuth(); - const [showAllDocs, setShowAllDocs] = useState(false); - - // Polling refs — persist across renders, no stale closures - const apiKeyRef = useRef(null); - const activeJobsRef = useRef>(new Map()); // collectionId → jobId - const pollingRef = useRef | null>(null); - const fetchCollectionsRef = useRef<(() => Promise) | null>(null); - // Form state const [collectionName, setCollectionName] = useState(""); const [collectionDescription, setCollectionDescription] = useState(""); const [selectedDocuments, setSelectedDocuments] = useState>( new Set(), ); - // Helper functions for name cache - using job_id as key - const CACHE_KEY = "collection_job_cache"; - - const saveCollectionData = ( - jobId: string, - name: string, - description: string, - collectionId?: string, - ) => { - try { - const cache = JSON.parse(localStorage.getItem(CACHE_KEY) || "{}"); - cache[jobId] = { name, description, collection_id: collectionId }; - localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); - } catch (e) { - console.error("Failed to save collection data:", e); - } - }; - - const getCollectionDataByCollectionId = ( - collectionId: string, - ): { name?: string; description?: string; job_id?: string } => { - try { - const cache = JSON.parse(localStorage.getItem(CACHE_KEY) || "{}"); - // Find the entry where collection_id matches - for (const [jobId, data] of Object.entries(cache)) { - const cacheData = data as { - name: string; - description: string; - collection_id?: string; - }; - if (cacheData.collection_id === collectionId) { - return { - name: cacheData.name, - description: cacheData.description, - job_id: jobId, - }; - } - } - return {}; - } catch (e) { - console.error("Failed to get collection data:", e); - return {}; - } - }; - - const deleteCollectionFromCache = (collectionId: string) => { - try { - const cache = JSON.parse(localStorage.getItem(CACHE_KEY) || "{}"); - for (const [jobId, data] of Object.entries(cache)) { - const cacheData = data as { collection_id?: string }; - if (cacheData.collection_id === collectionId) { - delete cache[jobId]; - break; - } - } - localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); - } catch (e) { - console.error("Failed to delete collection from cache:", e); - } - }; - - const pruneStaleCache = (liveCollectionIds: Set) => { - try { - const cache = JSON.parse(localStorage.getItem(CACHE_KEY) || "{}"); - let changed = false; - for (const [jobId, data] of Object.entries(cache)) { - const cacheData = data as { collection_id?: string }; - // Only prune entries that have a collection_id but it's no longer in the live list - if ( - cacheData.collection_id && - !liveCollectionIds.has(cacheData.collection_id) - ) { - delete cache[jobId]; - changed = true; - } - } - if (changed) localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); - } catch (e) { - console.error("Failed to prune stale cache:", e); - } - }; - - const enrichCollectionWithCache = async ( - collection: Collection, - jobStatusMap: Map< - string, - { status: string | null; collectionId: string | null } - >, - ): Promise => { - // First try to look up cached data by collection_id - const cached = getCollectionDataByCollectionId(collection.id); - - let jobId = cached.job_id; - let collectionJobStatus = null; - let name = cached.name; - let description = cached.description; - - // If we don't have cached data by collection_id, we need to find it by checking all jobs - if (!jobId) { - const cache = JSON.parse(localStorage.getItem(CACHE_KEY) || "{}"); - - // Try each job_id in the cache to find which one matches this collection - for (const [cachedJobId, data] of Object.entries(cache)) { - const cacheData = data as { - name: string; - description: string; - collection_id?: string; - }; - - // If collection_id is not set yet, check the pre-fetched job status - if (!cacheData.collection_id) { - const jobInfo = jobStatusMap.get(cachedJobId); - if (jobInfo?.collectionId === collection.id) { - jobId = cachedJobId; - name = cacheData.name; - description = cacheData.description; - collectionJobStatus = jobInfo.status; - - // Update cache with collection_id for faster lookup next time - saveCollectionData( - cachedJobId, - cacheData.name, - cacheData.description, - collection.id, - ); - break; - } - } - } - } - - // If we have job_id but no status yet, get it from the map - if (jobId && !collectionJobStatus) { - const jobInfo = jobStatusMap.get(jobId); - if (jobInfo?.status) { - collectionJobStatus = jobInfo.status; - - // Update cache with collection_id if not already set - if (jobInfo.collectionId && !cached.job_id) { - saveCollectionData( - jobId, - name || "", - description || "", - jobInfo.collectionId, - ); - } - } - } - - return { - ...collection, - name: name || "Untitled Collection", - description: description || "", - status: collectionJobStatus || undefined, - job_id: jobId, - }; - }; - - // Pre-fetch job statuses only for entries that need collection_id resolution - const preFetchJobStatuses = async ( - collections: Collection[], - ): Promise< - Map - > => { - if (!isAuthenticated) return new Map(); - - const cache = JSON.parse(localStorage.getItem(CACHE_KEY) || "{}"); - - // Only fetch job statuses for entries without collection_id AND that might match our collections - const jobIdsToFetch = new Set(); - - collections.forEach((collection) => { - // First check if this collection already has cached data with collection_id - const cached = getCollectionDataByCollectionId(collection.id); - if (!cached.job_id) { - // No cached data found by collection_id, need to check all uncached jobs - for (const [jobId, data] of Object.entries(cache)) { - const cacheData = data as { - name: string; - description: string; - collection_id?: string; - }; - if (!cacheData.collection_id) { - jobIdsToFetch.add(jobId); - } - } - } - }); - - // If no jobs need fetching, return empty map - if (jobIdsToFetch.size === 0) { - return new Map(); - } - - // Fetch only the necessary job statuses in parallel - const results = await Promise.all( - Array.from(jobIdsToFetch).map(async (jobId) => { - try { - const result = await apiFetch< - { data?: JobStatusData } & JobStatusData - >(`/api/collections/jobs/${jobId}`, apiKey?.key ?? ""); - const jobData = result.data || result; - const collectionId = - jobData.collection?.id || jobData.collection_id || null; - - return { - jobId, - status: jobData.status || null, - collectionId: collectionId, - }; - } catch (error) { - console.error("Error fetching job status:", error); - } - return { jobId, status: null, collectionId: null }; - }), - ); - - // Convert to Map for O(1) lookup - const jobStatusMap = new Map(); - results.forEach(({ jobId, status, collectionId }) => { - jobStatusMap.set(jobId, { status, collectionId }); - }); - - return jobStatusMap; - }; - - // Fetch collections - - const fetchCollections = async () => { - if (!isAuthenticated) return; - - setIsLoading(true); - try { - const result = await apiFetch( - "/api/collections", - apiKey?.key ?? "", - ); - const collections = ( - Array.isArray(result.data) ? result.data : [] - ) as Collection[]; - - // Pre-fetch job statuses only for collections that need it - const jobStatusMap = await preFetchJobStatuses(collections); - - // Enrich collections with cached names and live status - const enrichedCollections = await Promise.all( - collections.map((collection: Collection) => - enrichCollectionWithCache(collection, jobStatusMap), - ), - ); - - // Remove cache entries whose collection no longer exists on the backend - const liveIds = new Set( - enrichedCollections.map((c: Collection) => c.id), - ); - pruneStaleCache(liveIds); - - // Preserve optimistic entries not yet replaced by a real collection - setCollections((prev) => { - const fetchedJobIds = new Set( - enrichedCollections.map((c: Collection) => c.job_id).filter(Boolean), - ); - const activeOptimistic = prev.filter( - (c) => - c.id.startsWith("optimistic-") && - (!c.job_id || !fetchedJobIds.has(c.job_id)), - ); - // Sort by inserted_at in descending order (latest first) - const combined = [...activeOptimistic, ...enrichedCollections]; - return combined.sort( - (a, b) => - new Date(b.inserted_at).getTime() - - new Date(a.inserted_at).getTime(), - ); - }); - - // If selectedCollection is optimistic and the real one just arrived, fetch full details - // Extract the logic outside the updater to avoid side effects - let replacementId: string | null = null; - setSelectedCollection((prev) => { - if (prev?.id.startsWith("optimistic-") && prev.job_id) { - const replacement = enrichedCollections.find( - (c: Collection) => c.job_id === prev.job_id, - ); - if (replacement) { - replacementId = replacement.id; - // Don't set the replacement yet - let fetchCollectionDetails do it with full data - } - } - return prev; - }); - - // Fetch full details (including documents) for the replacement - if (replacementId) { - fetchCollectionDetails(replacementId); - } - } catch (error) { - console.error("Error fetching collections:", error); - } finally { - setIsLoading(false); + const toggleDocumentSelection = (documentId: string) => { + const newSelection = new Set(selectedDocuments); + if (newSelection.has(documentId)) { + newSelection.delete(documentId); + } else { + newSelection.add(documentId); } + setSelectedDocuments(newSelection); }; - // Fetch available documents - const fetchDocuments = async () => { - if (!isAuthenticated) return; - - try { - const result = await apiFetch( - "/api/document", - apiKey?.key ?? "", - ); - - // Handle both direct array and wrapped response - const documentList = Array.isArray(result) - ? result - : (result as DocumentResponse).data || []; - - // Sort by inserted_at in descending order (latest first) - const sortedDocuments = documentList.sort( - (a: Document, b: Document) => - new Date(b.inserted_at || 0).getTime() - - new Date(a.inserted_at || 0).getTime(), - ); - - setAvailableDocuments(sortedDocuments); - } catch (error) { - console.error("Error fetching documents:", error); - } + const handleSelectCollection = (collectionId: string) => { + setShowCreateForm(false); + setShowDocumentPicker(false); + fetchCollectionDetails(collectionId); }; - // Fetch collection details with documents - const fetchCollectionDetails = async (collectionId: string) => { - if (!isAuthenticated) return; - - // Don't fetch optimistic collections from the server - if (collectionId.startsWith("optimistic-")) { - const optimisticCollection = collections.find( - (c) => c.id === collectionId, - ); - if (optimisticCollection) { - setSelectedCollection(optimisticCollection); - } - return; - } - - setIsLoading(true); - try { - const result = await apiFetch( - `/api/collections/${collectionId}`, - apiKey?.key ?? "", - ); - - // Handle different response formats - const collectionData = (result.data as Collection) || result; - - // Get cached data to find the job_id - const cached = getCollectionDataByCollectionId(collectionId); - - // If we have a job_id, fetch its status - let status = undefined; - if (cached.job_id) { - try { - const jobResult = await apiFetch< - { data?: JobStatusData } & JobStatusData - >(`/api/collections/jobs/${cached.job_id}`, apiKey?.key ?? ""); - const jobData = jobResult.data || jobResult; - status = jobData.status || undefined; - } catch (error) { - console.error( - "Error fetching job status for collection details:", - error, - ); - } - } - - // Enrich the collection with cached name/description and live status - const enrichedCollection = { - ...collectionData, - name: cached.name || collectionData.name || "Untitled Collection", - description: cached.description || collectionData.description || "", - status: status, - job_id: cached.job_id, - }; - - setSelectedCollection(enrichedCollection); - } catch (error) { - console.error("Error fetching collection details:", error); - } finally { - setIsLoading(false); - } + const handleCreateNew = () => { + setShowCreateForm(true); + setSelectedCollection(null); }; - // Start the 3-second polling loop (idempotent — safe to call multiple times) - const startPolling = () => { - if (pollingRef.current) return; - if (activeJobsRef.current.size === 0) return; - - pollingRef.current = setInterval(async () => { - const currentApiKey = apiKeyRef.current; - if (!currentApiKey && !isAuthenticated) return; - - const jobs = activeJobsRef.current; - if (jobs.size === 0) { - clearInterval(pollingRef.current!); - pollingRef.current = null; - return; - } - - let anyResolved = false; - - for (const [collectionId, jobId] of Array.from(jobs)) { - try { - const result = await apiFetch< - { data?: JobStatusData } & JobStatusData - >(`/api/collections/jobs/${jobId}`, currentApiKey?.key ?? ""); - const jobData = result.data || result; - const status = jobData.status || null; - const realCollectionId = - jobData.collection?.id || jobData.collection_id || null; - const knowledgeBaseId = jobData.collection?.knowledge_base_id || null; - - if (status) { - // Always update status in UI (including in_progress/pending states) - setCollections((prev) => - prev.map((c) => { - // Update by collectionId OR by job_id (handles optimistic->real transition) - if (c.id === collectionId || c.job_id === jobId) { - return { - ...c, - status, - knowledge_base_id: knowledgeBaseId || c.knowledge_base_id, - }; - } - return c; - }), - ); - setSelectedCollection((prev) => { - if (prev?.id === collectionId || prev?.job_id === jobId) { - return { - ...prev, - status, - knowledge_base_id: knowledgeBaseId || prev?.knowledge_base_id, - }; - } - return prev; - }); - - // If job is complete (not pending/in_progress/processing), remove from polling and trigger full refresh - const isComplete = !["pending", "processing"].includes( - status.toLowerCase(), - ); - if (isComplete) { - jobs.delete(collectionId); - anyResolved = true; - - // Persist real collectionId so enrichment finds it on next load - if (collectionId.startsWith("optimistic-") && realCollectionId) { - try { - const cache = JSON.parse( - localStorage.getItem(CACHE_KEY) || "{}", - ); - const existing = cache[jobId] || {}; - cache[jobId] = { - ...existing, - collection_id: realCollectionId, - }; - localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); - } catch (e) { - console.error("Failed to update cache:", e); - } - } - } - } - } catch (error) { - console.error("Polling error for job", jobId, error); - } - } - - // At least one job finished — refresh the full list to swap in real collections - if (anyResolved && fetchCollectionsRef.current) { - fetchCollectionsRef.current(); - } - }, 5000); + const handleCancelCreate = () => { + setShowCreateForm(false); + setShowDocumentPicker(false); + setCollectionName(""); + setCollectionDescription(""); + setSelectedDocuments(new Set()); }; - // Create knowledge base const handleCreateClick = async () => { - if (!isAuthenticated) { - alert("Please log in to continue"); - return; - } - - if (!collectionName.trim() || selectedDocuments.size === 0) { - alert("Please provide a name and select at least one document"); - return; - } - - setIsCreating(true); - - // Capture form values before clearing them - const nameAtCreation = collectionName; - const descriptionAtCreation = collectionDescription; - const docsAtCreation = Array.from(selectedDocuments); - - // Immediately clear the form and switch to preview + const params = { + name: collectionName, + description: collectionDescription, + documentIds: Array.from(selectedDocuments), + }; setShowCreateForm(false); setShowDocumentPicker(false); setCollectionName(""); setCollectionDescription(""); setSelectedDocuments(new Set()); - - // Build an optimistic collection and show the preview right away - const optimisticId = `optimistic-${Date.now()}`; - const now = new Date().toISOString(); - const optimisticDocuments: Document[] = docsAtCreation - .map((id) => availableDocuments.find((d) => d.id === id)) - .filter((d): d is Document => !!d); - - const optimisticCollection: Collection = { - id: optimisticId, - name: nameAtCreation, - description: descriptionAtCreation, - inserted_at: now, - updated_at: now, - status: "pending", - documents: optimisticDocuments, - }; - - setCollections((prev) => [optimisticCollection, ...prev]); - setSelectedCollection(optimisticCollection); - - try { - const result = await apiFetch( - "/api/collections", - apiKey?.key ?? "", - { - method: "POST", - body: JSON.stringify({ - name: nameAtCreation, - description: descriptionAtCreation, - documents: docsAtCreation, - provider: "openai", - }), - }, - ); - - const jobId = result.data?.job_id; - - if (jobId) { - saveCollectionData(jobId, nameAtCreation, descriptionAtCreation); - - // Attach job_id to the optimistic entry so polling picks it up - setCollections((prev) => - prev.map((c) => - c.id === optimisticId ? { ...c, job_id: jobId } : c, - ), - ); - setSelectedCollection((prev) => - prev?.id === optimisticId ? { ...prev, job_id: jobId } : prev, - ); - - // Register for polling immediately — don't wait for the next collections render - activeJobsRef.current.set(optimisticId, jobId); - startPolling(); - } else { - console.error( - "No job ID found in response - cannot save name to cache", - ); - } - - // Refresh the real list from the backend (replaces the optimistic entry once the backend knows about it) - await fetchCollections(); - } catch (error) { - console.error("Error creating knowledge base:", error); - alert( - `Failed to create knowledge base: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - setCollections((prev) => prev.filter((c) => c.id !== optimisticId)); - setSelectedCollection(null); - } finally { - setIsCreating(false); - } + await createCollection(params); }; - // Delete collection - show confirmation modal - const handleDeleteCollection = (collectionId: string) => { - if (!isAuthenticated) return; + const handleRequestDelete = (collectionId: string) => { setCollectionToDelete(collectionId); setShowConfirmDelete(true); }; - // Confirm and execute delete const handleConfirmDelete = async () => { - if (!collectionToDelete || !isAuthenticated) return; - + if (!collectionToDelete) return; setShowConfirmDelete(false); - const collectionId = collectionToDelete; + const id = collectionToDelete; setCollectionToDelete(null); - - // Store the original collection in case we need to restore it - const originalCollection = collections.find((c) => c.id === collectionId); - - // Update status to "deleting" instead of removing immediately - setCollections((prev) => - prev.map((c) => - c.id === collectionId ? { ...c, status: "deleting" } : c, - ), - ); - setSelectedCollection((prev) => - prev?.id === collectionId ? { ...prev, status: "deleting" } : prev, - ); - - try { - const result = await apiFetch( - `/api/collections/${collectionId}`, - apiKey?.key ?? "", - { method: "DELETE" }, - ); - - const jobId = result.data?.job_id; - - if (jobId) { - // Poll the delete job status - const pollDeleteStatus = async () => { - const currentApiKey = apiKeyRef.current; - if (!currentApiKey) return; - - try { - const jobResult = await apiFetch< - { data?: JobStatusData } & JobStatusData - >(`/api/collections/jobs/${jobId}`, currentApiKey?.key ?? ""); - const jobData = jobResult.data || jobResult; - const status = jobData.status; - const statusLower = status?.toLowerCase(); - - if (statusLower === "successful") { - // Job completed successfully - remove from UI and clean up cache - deleteCollectionFromCache(collectionId); - setCollections((prev) => - prev.filter((c) => c.id !== collectionId), - ); - setSelectedCollection(null); - } else if (statusLower === "failed") { - // Job failed - restore original collection - alert("Failed to delete collection"); - if (originalCollection) { - setCollections((prev) => - prev.map((c) => - c.id === collectionId ? originalCollection : c, - ), - ); - setSelectedCollection((prev) => - prev?.id === collectionId ? originalCollection : prev, - ); - } - } else { - // Still processing - keep status as "deleting" and poll again - setTimeout(pollDeleteStatus, 2000); // Poll every 2 seconds - } - } catch (error) { - console.error("Error polling delete status:", error); - alert("Failed to check delete status"); - if (originalCollection) { - setCollections((prev) => - prev.map((c) => - c.id === collectionId ? originalCollection : c, - ), - ); - setSelectedCollection((prev) => - prev?.id === collectionId ? originalCollection : prev, - ); - } - } - }; - - // Start polling - pollDeleteStatus(); - } else { - // No job_id returned, assume immediate success - deleteCollectionFromCache(collectionId); - setCollections((prev) => prev.filter((c) => c.id !== collectionId)); - setSelectedCollection(null); - } - } catch (error) { - console.error("Error deleting collection:", error); - alert("Failed to delete collection"); - // Restore the original collection on error - if (originalCollection) { - setCollections((prev) => - prev.map((c) => (c.id === collectionId ? originalCollection : c)), - ); - setSelectedCollection((prev) => - prev?.id === collectionId ? originalCollection : prev, - ); - } - } + await deleteCollection(id); }; - // Fetch document details and set preview - const fetchAndPreviewDoc = async (doc: Document) => { - setPreviewDoc(doc); - if (isAuthenticated) { - try { - const data = await apiFetch( - `/api/document/${doc.id}`, - apiKey?.key ?? "", - ); - const documentDetails = (data.data || data) as Document; - setPreviewDoc(documentDetails); - } catch (err) { - console.error("Failed to fetch document details:", err); - } - } + const handlePreviewDocument = async (firstDocument: Document) => { + setShowDocPreviewModal(true); + setPreviewDoc(firstDocument); + const enriched = await fetchAndPreviewDoc(firstDocument); + setPreviewDoc(enriched); }; - // Toggle document selection - const toggleDocumentSelection = (documentId: string) => { - const newSelection = new Set(selectedDocuments); - if (newSelection.has(documentId)) { - newSelection.delete(documentId); - } else { - newSelection.add(documentId); - } - setSelectedDocuments(newSelection); + const handleSelectPreviewDoc = async (doc: Document) => { + setPreviewDoc(doc); + const enriched = await fetchAndPreviewDoc(doc); + setPreviewDoc(enriched); }; - useEffect(() => { - if (isAuthenticated) { - fetchCollections(); - fetchDocuments(); - } - }, [apiKey]); - - // Keep apiKeyRef in sync so polling always has the current key - useEffect(() => { - apiKeyRef.current = apiKey; - }, [apiKey, isAuthenticated]); - - // Keep fetchCollectionsRef in sync so polling always has the current function - useEffect(() => { - fetchCollectionsRef.current = fetchCollections; - }, [fetchCollections]); - - // Sync activeJobsRef when collections change (picks up in-progress entries on initial load) - useEffect(() => { - // Remove tracked jobs whose collections no longer exist in the list - const currentIds = new Set(collections.map((c) => c.id)); - for (const [id] of Array.from(activeJobsRef.current)) { - if (!currentIds.has(id)) activeJobsRef.current.delete(id); - } - - // Add any new pending / processing collections - let newJobAdded = false; - collections.forEach((c) => { - const isProcessing = - c.status && ["pending", "processing"].includes(c.status.toLowerCase()); - - if (isProcessing && c.job_id && !activeJobsRef.current.has(c.id)) { - activeJobsRef.current.set(c.id, c.job_id); - newJobAdded = true; - } - }); - - if (newJobAdded && isAuthenticated) startPolling(); - }, [collections, isAuthenticated]); - - // Reset showAllDocs when selectedCollection changes - useEffect(() => { - setShowAllDocs(false); - }, [selectedCollection?.id]); - - // Cleanup polling interval on unmount - useEffect(() => { - return () => { - if (pollingRef.current) { - clearInterval(pollingRef.current); - pollingRef.current = null; - } - }; - }, []); - return (
- {/* Main Content */}
- {/* Content Area - Split View */}
- {/* Left Panel - Collections List */} -
- {/* Create Button */} -
- -
- -
- {isLoading && collections.length === 0 ? ( -
- Loading knowledge bases... -
- ) : collections.length === 0 ? ( -
- No knowledge bases yet. Create your first one! -
- ) : ( -
- {collections.map((collection) => { - const isSelected = selectedCollection?.id === collection.id; - return ( - - ); - })} -
- )} -
-
- -
+ + +
{showCreateForm ? ( -
-
-

- Create Knowledge Base -

- -
- - {/* Name Input */} -
- -
- - {/* Description Input */} -
- -