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 7746bb89..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,13 +278,8 @@ function PromptEditorContent() {
{!editorReady ? ( -
-
- -
+
+
) : ( <> @@ -416,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); @@ -447,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)/evaluations/[id]/page.tsx b/app/(main)/evaluations/[id]/page.tsx index ce318c1c..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, diff --git a/app/(main)/evaluations/page.tsx b/app/(main)/evaluations/page.tsx index 69f098d0..c76d5573 100644 --- a/app/(main)/evaluations/page.tsx +++ b/app/(main)/evaluations/page.tsx @@ -9,7 +9,6 @@ import { useState, useEffect, useCallback, Suspense } from "react"; import { apiFetch } from "@/app/lib/apiClient"; -import { colors } from "@/app/lib/colors"; import { useSearchParams } from "next/navigation"; import { Dataset } from "@/app/lib/types/dataset"; import Sidebar from "@/app/components/Sidebar"; @@ -19,7 +18,7 @@ import { useToast } from "@/app/components/Toast"; import { useAuth } from "@/app/lib/context/AuthContext"; import { useApp } from "@/app/lib/context/AppContext"; import { FeatureGateModal, LoginModal } from "@/app/components/auth"; -import Loader from "@/app/components/Loader"; +import { Loader } from "@/app/components"; import DatasetsTab from "@/app/components/evaluations/DatasetsTab"; import EvaluationsTab from "@/app/components/evaluations/EvaluationsTab"; import { Tab } from "@/app/lib/types/evaluation"; @@ -248,10 +247,7 @@ function SimplifiedEvalContent() { }; return ( -
+
diff --git a/app/(main)/keystore/page.tsx b/app/(main)/keystore/page.tsx index fcd20e93..b0985acb 100644 --- a/app/(main)/keystore/page.tsx +++ b/app/(main)/keystore/page.tsx @@ -1,52 +1,58 @@ /** - * KaapiKeystore.tsx - API Key Management Interface - * - * Allows users to securely store and manage API keys for various LLM providers + * Keystore: API Key Management Interface + * Allows users to securely store and manage API keys for various LLM providers. */ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import Sidebar from "@/app/components/Sidebar"; -import PageHeader from "@/app/components/PageHeader"; +import { PageHeader } from "@/app/components"; +import KeysCard from "@/app/components/keystore/KeysCard"; +import AddKeyModal from "@/app/components/keystore/AddKeyModal"; import { useAuth } from "@/app/lib/context/AuthContext"; import { useApp } from "@/app/lib/context/AppContext"; +import { useToast } from "@/app/components/Toast"; import { APIKey } from "@/app/lib/types/credentials"; export const STORAGE_KEY = "kaapi_api_keys"; export default function KaapiKeystore() { const { sidebarCollapsed } = useApp(); - const [isModalOpen, setIsModalOpen] = useState(false); const { apiKeys, addKey, removeKey: removeApiKey } = useAuth(); + const toast = useToast(); + + const [isModalOpen, setIsModalOpen] = useState(false); const [newKeyLabel, setNewKeyLabel] = useState(""); const [newKeyValue, setNewKeyValue] = useState(""); const [newKeyProvider, setNewKeyProvider] = useState("Kaapi"); const [visibleKeys, setVisibleKeys] = useState>(new Set()); + const [copiedKeyId, setCopiedKeyId] = useState(null); - const providers = ["Kaapi"]; + const resetForm = () => { + setNewKeyLabel(""); + setNewKeyValue(""); + setNewKeyProvider("Kaapi"); + }; const handleAddKey = () => { if (!newKeyLabel.trim() || !newKeyValue.trim()) { - alert("Please provide both label and API key"); + toast.error("Please provide both a label and an API key"); return; } const newKey: APIKey = { id: Date.now().toString(), - label: newKeyLabel, - key: newKeyValue, + label: newKeyLabel.trim(), + key: newKeyValue.trim(), provider: newKeyProvider, createdAt: new Date().toISOString(), }; addKey(newKey); - setNewKeyLabel(""); - setNewKeyValue(""); - setNewKeyProvider("Kaapi"); - - // Close modal after adding + resetForm(); setIsModalOpen(false); + toast.success("API key added successfully"); }; const handleDeleteKey = (id: string) => { @@ -56,44 +62,51 @@ export default function KaapiKeystore() { next.delete(id); return next; }); + toast.success("API key removed"); }; const toggleKeyVisibility = (id: string) => { setVisibleKeys((prev) => { const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } + if (next.has(id)) next.delete(id); + else next.add(id); return next; }); }; - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text); - alert("API key copied to clipboard!"); + const copyToClipboard = async (text: string, id: string) => { + try { + await navigator.clipboard.writeText(text); + setCopiedKeyId(id); + toast.success("API key copied to clipboard"); + setTimeout(() => setCopiedKeyId(null), 1500); + } catch { + toast.error("Failed to copy API key"); + } + }; + + const closeAddModal = () => { + setIsModalOpen(false); + resetForm(); }; return ( -
+
- {/* Sidebar */} - {/* Main Content */}
- {/* Content Area */} -
-
- +
+
- {/* Add Key Modal */} - {isModalOpen && ( - setIsModalOpen(false)} - /> - )} -
- ); -} - -// ============ STORED KEYS TAB ============ -interface StoredKeysTabProps { - apiKeys: APIKey[]; - visibleKeys: Set; - onToggleVisibility: (id: string) => void; - onCopy: (text: string) => void; - onDelete: (id: string) => void; - onAddNew: () => void; -} - -function StoredKeysTab({ - apiKeys, - visibleKeys, - onToggleVisibility, - onCopy, - onDelete, - onAddNew, -}: StoredKeysTabProps) { - return ( - <> - {/* Stored Keys List */} -
-
-

- Your API Key -

- {apiKeys.length === 0 && ( - - )} -
- - {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)/settings/onboarding/page.tsx b/app/(main)/settings/onboarding/page.tsx index d1d4eb5f..1ad90123 100644 --- a/app/(main)/settings/onboarding/page.tsx +++ b/app/(main)/settings/onboarding/page.tsx @@ -40,22 +40,19 @@ function OrganizationListSkeleton() {
-
+
-
+
{[1, 2, 3].map((i) => (
-
-
+
+
-
-
-
-
+
))}
diff --git a/app/(main)/speech-to-text/page.tsx b/app/(main)/speech-to-text/page.tsx index bb2a50d1..23cde3ac 100644 --- a/app/(main)/speech-to-text/page.tsx +++ b/app/(main)/speech-to-text/page.tsx @@ -7,8 +7,7 @@ "use client"; -import { useState, useEffect } from "react"; -import { colors } from "@/app/lib/colors"; +import { useEffect, useState } from "react"; import Sidebar from "@/app/components/Sidebar"; import PageHeader from "@/app/components/PageHeader"; import TabNavigation from "@/app/components/TabNavigation"; @@ -19,19 +18,9 @@ import { apiFetch } from "@/app/lib/apiClient"; import ErrorModal from "@/app/components/ErrorModal"; import DatasetsTab from "@/app/components/speech-to-text/DatasetsTab"; import EvaluationsTab from "@/app/components/speech-to-text/EvaluationsTab"; +import { useSttData } from "@/app/hooks/useSttData"; import { AudioFile, - Dataset, - STTRun, - STTResult, - STTResultRaw, - Language, - DEFAULT_LANGUAGES, - RawLanguage, - LanguagesResponse, - DatasetsResponse, - RunsResponse, - RunDetailResponse, CreateDatasetResponse, CreateRunResponse, } from "@/app/lib/types/speechToText"; @@ -44,141 +33,43 @@ export default function SpeechToTextPage() { const { sidebarCollapsed } = useApp(); const [leftPanelWidth] = useState(450); const { apiKeys, isAuthenticated } = useAuth(); - const [languages, setLanguages] = useState([]); + + const { + languages, + datasets, + runs, + results, + selectedRunId, + setSelectedRunId, + setResults, + isLoadingDatasets, + isLoadingRuns, + isLoadingResults, + loadDatasets, + loadRuns, + loadResults, + } = useSttData(activeTab); + const [datasetName, setDatasetName] = useState(""); const [datasetDescription, setDatasetDescription] = useState(""); const [datasetLanguageId, setDatasetLanguageId] = useState(1); const [audioFiles, setAudioFiles] = useState([]); const [playingFileId, setPlayingFileId] = useState(null); const [isCreating, setIsCreating] = useState(false); - const [datasets, setDatasets] = useState([]); - const [isLoadingDatasets, setIsLoadingDatasets] = useState(true); const [evaluationName, setEvaluationName] = useState(""); const [selectedDatasetId, setSelectedDatasetId] = useState( null, ); const [selectedModel, setSelectedModel] = useState("gemini-2.5-pro"); const [isRunning, setIsRunning] = useState(false); - const [runs, setRuns] = useState([]); - const [isLoadingRuns, setIsLoadingRuns] = useState(true); - const [selectedRunId, setSelectedRunId] = useState(null); - const [results, setResults] = useState([]); - const [isLoadingResults, setIsLoadingResults] = useState(false); const [errorModalOpen, setErrorModalOpen] = useState(false); const [errorModalMessage, setErrorModalMessage] = useState(""); - const loadLanguages = async () => { - if (!isAuthenticated) return; - - try { - const data = await apiFetch( - "/api/languages", - apiKeys[0]?.key ?? "", - ); - - let rawList: RawLanguage[] = []; - if (Array.isArray(data)) { - rawList = data; - } else if ( - data.data && - !Array.isArray(data.data) && - Array.isArray(data.data.data) - ) { - rawList = data.data.data; - } else if (data.data && Array.isArray(data.data)) { - rawList = data.data; - } else if (data.languages && Array.isArray(data.languages)) { - rawList = data.languages; - } - - const languagesList: Language[] = rawList - .filter((l) => l.is_active !== false) - .map((l) => ({ - id: l.id, - code: l.locale || l.code || "", - name: l.label || l.name || "", - })); - - if (languagesList.length > 0) { - setLanguages(languagesList); - // Default dataset language to first available if not already set - if (languagesList[0]?.id) { - setDatasetLanguageId(languagesList[0].id); - } - } else { - setLanguages(DEFAULT_LANGUAGES); - } - } catch (error) { - console.error("Failed to load languages:", error); - setLanguages(DEFAULT_LANGUAGES); - } - }; - - const loadDatasets = async () => { - if (!isAuthenticated) return; - - setIsLoadingDatasets(true); - try { - const data = await apiFetch( - "/api/evaluations/stt/datasets", - apiKeys[0]?.key ?? "", - ); - - let datasetsList: Dataset[] = []; - if (Array.isArray(data)) { - datasetsList = data; - } else if (data.datasets && Array.isArray(data.datasets)) { - datasetsList = data.datasets; - } else if (data.data && Array.isArray(data.data)) { - datasetsList = data.data; - } - - setDatasets(datasetsList); - } catch (error) { - console.error("Failed to load datasets:", error); - toast.error("Failed to load datasets"); - setDatasets([]); - } finally { - setIsLoadingDatasets(false); - } - }; - - const loadRuns = async () => { - if (!isAuthenticated) return; - - setIsLoadingRuns(true); - try { - const data = await apiFetch( - "/api/evaluations/stt/runs", - apiKeys[0]?.key ?? "", - ); - - let runsList: STTRun[] = []; - if (Array.isArray(data)) { - runsList = data; - } else if (data.runs && Array.isArray(data.runs)) { - runsList = data.runs; - } else if (data.data && Array.isArray(data.data)) { - runsList = data.data; - } - - setRuns(runsList); - } catch (error) { - console.error("Failed to load runs:", error); - toast.error("Failed to load evaluation runs"); - setRuns([]); - } finally { - setIsLoadingRuns(false); - } - }; - useEffect(() => { - loadLanguages(); - loadDatasets(); - if (activeTab === "evaluations") { - loadRuns(); + if (languages.length > 0 && languages[0]?.id) { + setDatasetLanguageId(languages[0].id); } - }, [apiKeys, activeTab]); + }, [languages]); const handleAudioFileSelect = async ( event: React.ChangeEvent, @@ -347,7 +238,7 @@ export default function SpeechToTextPage() { setDatasetName(""); setDatasetDescription(""); - setDatasetLanguageId(1); + setDatasetLanguageId(languages[0]?.id ?? 1); setAudioFiles([]); await loadDatasets(); @@ -394,7 +285,6 @@ export default function SpeechToTextPage() { ); setSelectedModel("gemini-2.5-pro"); - setEvaluationName(""); setSelectedDatasetId(null); @@ -418,70 +308,10 @@ export default function SpeechToTextPage() { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; - const loadResults = async (runId: number) => { - if (!isAuthenticated) return; - - setIsLoadingResults(true); - try { - const runData = await apiFetch( - `/api/evaluations/stt/runs/${runId}?include_results=true&include_signed_url=true`, - apiKeys[0]?.key ?? "", - ); - - let rawResults: STTResultRaw[] = []; - if (Array.isArray(runData)) { - rawResults = runData; - } else if (runData.results && Array.isArray(runData.results)) { - rawResults = runData.results; - } else if (runData.data && Array.isArray(runData.data)) { - rawResults = runData.data as STTResultRaw[]; - } else if ( - runData.data && - !Array.isArray(runData.data) && - runData.data.results && - Array.isArray(runData.data.results) - ) { - rawResults = runData.data.results; - } - - const resultsList: STTResult[] = rawResults.map((result) => { - const sample = result.sample; - - const sampleName = - sample?.sample_metadata?.original_filename || - `Sample ${result.stt_sample_id}`; - - const groundTruth = sample?.ground_truth || ""; - const signedUrl = sample?.signed_url || ""; - const fileId = sample?.file_id; - - return { - ...result, - sampleName, - groundTruth, - fileId, - signedUrl, - }; - }); - - setResults(resultsList); - setSelectedRunId(runId); - } catch (error) { - console.error("Failed to load results:", error); - toast.error("Failed to load evaluation results"); - setResults([]); - } finally { - setIsLoadingResults(false); - } - }; - const selectedDataset = datasets.find((d) => d.id === selectedDatasetId); return ( -
+
@@ -501,21 +331,12 @@ export default function SpeechToTextPage() { /> {!isAuthenticated ? ( -
+
-

+

Authentication required

-

+

Please sign in to start creating datasets and running evaluations

diff --git a/app/(main)/text-to-speech/page.tsx b/app/(main)/text-to-speech/page.tsx index d3645b8b..4b4c384e 100644 --- a/app/(main)/text-to-speech/page.tsx +++ b/app/(main)/text-to-speech/page.tsx @@ -8,7 +8,6 @@ "use client"; import { useState, useEffect } from "react"; -import { colors } from "@/app/lib/colors"; import Sidebar from "@/app/components/Sidebar"; import PageHeader from "@/app/components/PageHeader"; import TabNavigation from "@/app/components/TabNavigation"; @@ -70,7 +69,6 @@ export default function TextToSpeechPage() { const [errorModalOpen, setErrorModalOpen] = useState(false); const [errorModalMessage, setErrorModalMessage] = useState(""); - // Load languages const loadLanguages = async () => { if (!isAuthenticated) return; @@ -358,10 +356,7 @@ export default function TextToSpeechPage() { const selectedDataset = datasets.find((d) => d.id === selectedDatasetId); return ( -
+
@@ -381,11 +376,8 @@ export default function TextToSpeechPage() { /> {!isAuthenticated ? ( -
-

+

+

Please sign in to start creating datasets and running evaluations.

diff --git a/app/components/ComingSoon.tsx b/app/components/ComingSoon.tsx index f309174d..7cc95cb8 100644 --- a/app/components/ComingSoon.tsx +++ b/app/components/ComingSoon.tsx @@ -1,12 +1,11 @@ /** * ComingSoon - Reusable component for features under construction - * Features a coffee brewing theme to match Kaapi branding */ "use client"; import { useRouter } from "next/navigation"; -import { colors } from "@/app/lib/colors"; +import { Button } from "@/app/components"; interface ComingSoonProps { featureName: string; @@ -20,96 +19,47 @@ export default function ComingSoon({ const router = useRouter(); return ( -
+
- {/* Coffee Cup Animation */}
-
+
- {/* Steam animation */} -
-
+
+
- {/* Main Message */} -

+

{featureName}

-
-

+

+

🚧 Being Brewed

-

+

{description || "This feature is currently being crafted with care. Check back soon for something amazing!"}

- {/* Fun fact */} -
-

+

+

☕ Kaapi Fact

-

+

Great features, like great coffee, take time to brew.

- {/* Back button */} - +
diff --git a/app/components/ConfigCard.tsx b/app/components/ConfigCard.tsx index 6b3ff9fb..3553d286 100644 --- a/app/components/ConfigCard.tsx +++ b/app/components/ConfigCard.tsx @@ -7,10 +7,16 @@ import { useState, useCallback } from "react"; import { useRouter } from "next/navigation"; -import { colors } from "@/app/lib/colors"; import { SavedConfig, ConfigPublic } from "@/app/lib/types/configs"; import { formatRelativeTime } from "@/app/lib/utils"; -import { CheckIcon, CopyIcon } from "@/app/components/icons"; +import { + CheckIcon, + CopyIcon, + ChevronDownIcon, + EditIcon, + PlayIcon, +} from "@/app/components/icons"; +import { Button, VersionPill, Loader } from "@/app/components"; import { useToast } from "@/app/hooks/useToast"; interface ConfigCardProps { @@ -38,6 +44,22 @@ export default function ConfigCard({ const [showTools, setShowTools] = useState(false); const [showVectorStores, setShowVectorStores] = useState(false); const [copiedId, setCopiedId] = useState(false); + const [copiedKbId, setCopiedKbId] = useState(null); + + const handleCopyKbId = useCallback( + async (e: React.MouseEvent, kbId: string) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(kbId); + setCopiedKbId(kbId); + toast.success("Knowledge Base ID copied to clipboard"); + setTimeout(() => setCopiedKbId(null), 1500); + } catch { + toast.error("Failed to copy"); + } + }, + [toast], + ); const handleCopyId = useCallback( async (e: React.MouseEvent) => { @@ -60,7 +82,6 @@ export default function ConfigCard({ return; } - // If we already loaded the details, just expand if (latestVersion) { setExpanded(true); return; @@ -70,11 +91,8 @@ export default function ConfigCard({ setExpanded(true); try { - // 1. Fetch the version list for this config await onLoadVersions(config.id); - // 2. Read the cached version items to find the latest version number - // (onLoadVersions populates configState.versionItemsCache) const { configState } = await import("@/app/lib/store/configStore"); const versionItems = configState.versionItemsCache[config.id]; if (!versionItems || versionItems.length === 0) { @@ -88,7 +106,6 @@ export default function ConfigCard({ b.version > a.version ? b : a, ); - // 3. Fetch the full details for the latest version const detail = await onLoadSingleVersion(config.id, latestItem.version); if (detail) { setLatestVersion(detail); @@ -100,7 +117,8 @@ export default function ConfigCard({ } }, [expanded, latestVersion, config.id, onLoadVersions, onLoadSingleVersion]); - const handleEdit = () => { + const handleEdit = (e?: React.MouseEvent) => { + e?.stopPropagation(); router.push( latestVersion ? `/configurations/prompt-editor?config=${config.id}&version=${latestVersion.version}` @@ -109,88 +127,75 @@ export default function ConfigCard({ }; const handleUseInEvaluation = () => { - router.push( - latestVersion - ? `/evaluations?config=${config.id}&version=${latestVersion.version}` - : `/evaluations?config=${config.id}`, - ); + const params = new URLSearchParams({ + tab: "evaluations", + config: config.id, + }); + if (latestVersion) { + params.set("version", String(latestVersion.version)); + } + router.push(`/evaluations?${params.toString()}`); }; return (
-
-
- {latestVersion && ( -
- v{latestVersion.version} -
- )} - +
+ {latestVersion && } + +
- {/* Collapsed meta row */} -
Updated {formatRelativeTime(config.updated_at)} {totalVersions > 0 && ( <> - | + | {totalVersions} version{totalVersions !== 1 ? "s" : ""} @@ -198,43 +203,20 @@ export default function ConfigCard({ )} {evaluationCount > 0 && ( <> - | + | {evaluationCount} evaluation{evaluationCount !== 1 ? "s" : ""} )} -
- + +
- {/* Expanded Details */} {expanded && ( -
+
{isLoadingDetails ? ( -
- - - - - Loading details... - +
+
) : latestVersion ? (
@@ -253,6 +235,7 @@ export default function ConfigCard({ onClick={handleCopyId} className="ml-auto p-1 rounded-md transition-colors shrink-0 cursor-pointer hover:bg-neutral-200" title="Copy Config ID" + aria-label="Copy Config ID" > {copiedId ? ( @@ -262,117 +245,64 @@ export default function ConfigCard({
- {/* Config Details */} -
-
- - Provider:{" "} - - - {latestVersion.provider} - -
-
- Type: - - {latestVersion.type === "text" && "Text"} - {latestVersion.type === "stt" && "STT"} - {latestVersion.type === "tts" && "TTS"} - -
-
- Model: - - {latestVersion.modelName} - -
+
+ + + {latestVersion.temperature != null && ( -
- Temp: - - {latestVersion.temperature.toFixed(2)} - -
+ )}
- {/* Tools Dropdown */} {latestVersion.tools && latestVersion.tools.length > 0 && (
{showTools && ( -
+
{latestVersion.tools.map((tool, idx) => (
-
+
{tool.type}
{tool.knowledge_base_ids && tool.knowledge_base_ids.length > 0 && ( -
+
Knowledge Bases:{" "} {tool.knowledge_base_ids.length}
)} {tool.max_num_results && ( -
+
Max Results: {tool.max_num_results}
)}
))} - {/* Vector Stores Section inside Tools */} {(() => { const allVectorStoreIds = latestVersion .tools!.flatMap( @@ -382,63 +312,52 @@ export default function ConfigCard({ return ( allVectorStoreIds.length > 0 && ( -
+
{showVectorStores && ( -
+
{allVectorStoreIds.map((id, idx) => (
- {id} + + {id} + +
))}
@@ -452,98 +371,36 @@ export default function ConfigCard({
)} - {/* Prompt */} -
-

+

+

{latestVersion.instructions || "No instructions set"}

- {/* Actions */}
- - +
) : (
-

+

No version details available

@@ -553,3 +410,12 @@ export default function ConfigCard({
); } + +function MetaPill({ label, value }: { label: string; value: string }) { + return ( +
+ {label}: + {value} +
+ ); +} diff --git a/app/components/ConfigDrawer.tsx b/app/components/ConfigDrawer.tsx deleted file mode 100644 index 23a99525..00000000 --- a/app/components/ConfigDrawer.tsx +++ /dev/null @@ -1,1240 +0,0 @@ -/** - * ConfigDrawer - Enhanced configuration management drawer - * Following the pattern from prompt-editor/ConfigDrawer.tsx - * Updated to work with backend API config structure - */ - -"use client"; - -import { useEffect, useState } from "react"; -import { MODEL_OPTIONS, isGpt5Model } from "@/app/lib/models"; -import { colors } from "@/app/lib/colors"; -import { SavedConfig } from "./SimplifiedConfigEditor"; -import { Tool } from "@/app/lib/types/configs"; - -interface DiffLine { - type: "same" | "added" | "removed"; - content: string; - lineNumber: number; -} - -interface ConfigDrawerProps { - isOpen: boolean; - onClose: () => void; - savedConfigs: SavedConfig[]; - currentConfig: { - name: string; - instructions: string; - modelName: string; - provider: string; - temperature: number; - vectorStoreIds: string; - tools?: Tool[]; - commitMessage?: string; - }; - selectedConfigId: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onConfigChange: (field: string, value: any) => void; - onSaveConfig: () => void; - onLoadConfig: (config: SavedConfig) => void; - onApplyConfig: (configId: string) => void; -} - -function generateDiff( - text1: string, - text2: string, -): { left: DiffLine[]; right: DiffLine[] } { - const lines1 = text1.split("\n"); - const lines2 = text2.split("\n"); - const left: DiffLine[] = []; - const right: DiffLine[] = []; - const maxLen = Math.max(lines1.length, lines2.length); - - for (let i = 0; i < maxLen; i++) { - const line1 = lines1[i] !== undefined ? lines1[i] : null; - const line2 = lines2[i] !== undefined ? lines2[i] : null; - - if (line1 === null && line2 !== null) { - left.push({ type: "same", content: "", lineNumber: i + 1 }); - right.push({ type: "added", content: line2, lineNumber: i + 1 }); - } else if (line1 !== null && line2 === null) { - left.push({ type: "removed", content: line1, lineNumber: i + 1 }); - right.push({ type: "same", content: "", lineNumber: i + 1 }); - } else if (line1 !== line2) { - left.push({ type: "removed", content: line1 || "", lineNumber: i + 1 }); - right.push({ type: "added", content: line2 || "", lineNumber: i + 1 }); - } else { - left.push({ type: "same", content: line1 || "", lineNumber: i + 1 }); - right.push({ type: "same", content: line2 || "", lineNumber: i + 1 }); - } - } - - return { left, right }; -} - -export default function ConfigDrawer({ - isOpen, - onClose, - savedConfigs, - currentConfig, - selectedConfigId, - onConfigChange, - onSaveConfig, - onLoadConfig, - onApplyConfig, -}: ConfigDrawerProps) { - const [activeTab, setActiveTab] = useState<"current" | "saved" | "compare">( - "current", - ); - const [leftConfigId, setLeftConfigId] = useState("current"); - const [rightConfigId, setRightConfigId] = useState(""); - const [expandedConfigs, setExpandedConfigs] = useState>( - new Set(), - ); - const [isCreatingNew, setIsCreatingNew] = useState(false); - - const isGpt5 = isGpt5Model(currentConfig.modelName); - - // Sync isCreatingNew with selectedConfigId - useEffect(() => { - if (selectedConfigId) { - setIsCreatingNew(false); - } - }, [selectedConfigId]); - - // Toggle expand/collapse - const toggleExpand = (configId: string) => { - const newExpanded = new Set(expandedConfigs); - if (newExpanded.has(configId)) { - newExpanded.delete(configId); - } else { - newExpanded.add(configId); - } - setExpandedConfigs(newExpanded); - }; - - // Group saved configs by name - const groupedConfigs = savedConfigs.reduce( - (acc, config) => { - if (!acc[config.name]) { - acc[config.name] = []; - } - acc[config.name].push(config); - return acc; - }, - {} as Record, - ); - - // Sort versions within each group (newest first) - Object.keys(groupedConfigs).forEach((name) => { - groupedConfigs[name].sort((a, b) => b.version - a.version); - }); - - // Format timestamp - calculate relative time from UTC timestamps - const formatTimestamp = (timestamp: number | string) => { - // eslint-disable-next-line react-hooks/purity - const now = Date.now(); // Current time in UTC milliseconds - const date = - typeof timestamp === "string" - ? new Date(timestamp).getTime() // Parse UTC timestamp to milliseconds - : timestamp; - - // Calculate difference (works the same in any timezone) - const diff = now - date; - const minutes = Math.floor(diff / 60000); - const hours = Math.floor(diff / 3600000); - const days = Math.floor(diff / 86400000); - - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes} min ago`; - if (hours < 24) return `${hours} hr ago`; - return `${days} day${days > 1 ? "s" : ""} ago`; - }; - - // Get content for comparison - const getContentForComparison = (configId: string) => { - if (configId === "current") { - return { ...currentConfig, label: "Current (unsaved)" }; - } - const config = savedConfigs.find((c) => c.id === configId); - if (config) { - return { ...config, label: `${config.name} v${config.version}` }; - } - return null; - }; - - // Build compare options - const buildCompareOptions = () => [ - { value: "current", label: "📝 Current (unsaved)" }, - ...savedConfigs.map((c) => ({ - value: c.id, - label: `${c.name} v${c.version} · ${formatTimestamp(c.timestamp)}`, - })), - ]; - - // Swap comparison - const handleSwap = () => { - const temp = leftConfigId; - setLeftConfigId(rightConfigId); - setRightConfigId(temp); - }; - - // Handle load config - const handleLoadConfigLocal = (configId: string) => { - const config = savedConfigs.find((c) => c.id === configId); - if (config) { - onLoadConfig(config); - setIsCreatingNew(false); - } - }; - - // Tools management - const tools = currentConfig.tools || []; - - const addTool = () => { - const newTools = [ - ...tools, - { - type: "file_search" as const, - knowledge_base_ids: [""], - max_num_results: 20, - }, - ]; - onConfigChange("tools", newTools); - }; - - const removeTool = (index: number) => { - const newTools = tools.filter((_, i) => i !== index); - onConfigChange("tools", newTools); - }; - - const updateTool = (index: number, field: keyof Tool, value: unknown) => { - const newTools = [...tools]; - if (field === "knowledge_base_ids") { - newTools[index][field] = [value as string]; - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (newTools[index] as any)[field] = value; - } - onConfigChange("tools", newTools); - }; - - if (!isOpen) return null; - - return ( - <> - {/* Backdrop */} -
- - {/* Drawer */} -
- {/* Header with Tabs */} -
-
- - - -
- -
- - {/* Tab Content */} -
- {/* CURRENT TAB */} - {activeTab === "current" && ( -
- {/* Config Selector */} -
- -
- {!isCreatingNew ? ( - <> - - - - ) : ( - <> - onConfigChange("name", e.target.value)} - placeholder="Enter config name..." - style={{ - flex: 1, - padding: "8px", - border: `1px solid ${colors.border}`, - borderRadius: "4px", - fontSize: "13px", - }} - /> - - - )} -
-
- - {/* Provider */} -
- - -
- - {/* Model */} -
- - -
- - {/* Instructions */} -
- -