diff --git a/app/(main)/configurations/prompt-editor/page.tsx b/app/(main)/configurations/prompt-editor/page.tsx index 0967ade5..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"; @@ -14,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"; 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"; @@ -43,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, @@ -52,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.", ); @@ -82,6 +76,11 @@ function PromptEditorContent() { ); const [compareWith, setCompareWith] = useState(null); + const { isSaving, saveConfig, renameConfig } = useConfigPersistence({ + allConfigMeta, + refetchConfigs, + }); + useEffect(() => { if (Object.keys(hookVersionItemsMap).length > 0) { setStableVersionItemsMap((prev) => ({ ...prev, ...hookVersionItemsMap })); @@ -120,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; } @@ -178,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); @@ -202,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: { @@ -238,7 +226,6 @@ function PromptEditorContent() { }, }, }); - setHasUnsavedChanges(promptChanged || configChanged); }, [ selectedConfigId, @@ -250,176 +237,24 @@ 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 handleRenameConfig = async ( - configId: string, - newName: string, - ): Promise => { - const apiKey = activeKey?.key ?? ""; - if (!isAuthenticated) { - toast.error("Please log in to rename configurations."); - return false; - } - const trimmed = newName.trim(); - if (!trimmed) { - toast.error("Name cannot be empty."); - return false; - } - if (trimmed === currentConfigName) return true; - - const conflict = allConfigMeta.find( - (m) => m.name === trimmed && m.id !== configId, - ); - if (conflict) { - toast.error(`A configuration named "${trimmed}" already exists.`); - return false; - } - - try { - const data = await apiFetch<{ success: boolean; error?: string }>( - `/api/configs/${configId}`, - apiKey, - { method: "PATCH", body: JSON.stringify({ name: trimmed }) }, - ); - if (!data.success) { - toast.error(`Failed to rename: ${data.error || "Unknown error"}`); - return false; - } - setCurrentConfigName(trimmed); - invalidateConfigCache(); - await refetchConfigs(true); - toast.success(`Renamed to "${trimmed}"`); - return true; - } catch (e) { - console.error("Failed to rename config:", e); - toast.error("Failed to rename configuration."); - return false; - } + const handleRename = async (configId: string, newName: string) => { + const ok = await renameConfig(configId, newName, currentConfigName); + if (ok) setCurrentConfigName(newName.trim()); + return ok; }; return ( @@ -458,9 +293,7 @@ function PromptEditorContent() { setSelectedVersion(version); setCompareWith(null); }} - onLoadVersion={(version) => { - handleLoadConfig(version); - }} + onLoadVersion={handleLoadConfig} onBackToEditor={() => { setSelectedVersion(null); setCompareWith(null); @@ -501,13 +334,7 @@ function PromptEditorContent() { ) : (
-
+
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/page.tsx b/app/(main)/evaluations/page.tsx index 0fff1a05..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"; @@ -248,10 +247,7 @@ function SimplifiedEvalContent() { }; return ( -
+
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/chat/ChatEmptyState.tsx b/app/components/chat/ChatEmptyState.tsx index 1c33662d..32ecb257 100644 --- a/app/components/chat/ChatEmptyState.tsx +++ b/app/components/chat/ChatEmptyState.tsx @@ -48,7 +48,7 @@ export default function ChatEmptyState({ key={s} type="button" onClick={() => onSuggestion(s)} - className="text-left text-sm text-text-primary px-4 py-3 rounded-xl border border-border bg-bg-primary hover:bg-neutral-50 transition-colors cursor-pointer" + className="text-left text-sm text-text-primary px-4 py-3 rounded-xl bg-bg-primary shadow-[0_2px_6px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] hover:shadow-[0_4px_12px_rgba(0,0,0,0.08),0_1px_2px_rgba(0,0,0,0.04)] transition-shadow cursor-pointer" > {s} diff --git a/app/components/datasets/DatasetCard.tsx b/app/components/datasets/DatasetCard.tsx new file mode 100644 index 00000000..bab34ebd --- /dev/null +++ b/app/components/datasets/DatasetCard.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { DocumentFileIcon, TrashIcon } from "@/app/components/icons"; +import { Dataset } from "@/app/lib/types/dataset"; + +interface DatasetCardProps { + dataset: Dataset; + onDelete: (datasetId: number) => void; +} + +export default function DatasetCard({ dataset, onDelete }: DatasetCardProps) { + return ( +
+
+
+
+ +

+ {dataset.dataset_name} +

+
+
+ + + + +
+
+ +
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} diff --git a/app/components/datasets/DatasetListing.tsx b/app/components/datasets/DatasetListing.tsx new file mode 100644 index 00000000..43b95e9f --- /dev/null +++ b/app/components/datasets/DatasetListing.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { Button } from "@/app/components"; +import { DocumentFileIcon, PlusIcon, InfoIcon } from "@/app/components/icons"; +import { Dataset } from "@/app/lib/types/dataset"; +import DatasetCard from "./DatasetCard"; +import DatasetListingSkeleton from "./DatasetListingSkeleton"; + +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; +} + +export default function DatasetListing({ + datasets, + onDelete, + onUploadNew, + isLoading, + error, + isAuthenticated, + totalPages, + currentPage, + onPageChange, +}: DatasetListingProps) { + return ( + <> +
+
+

+ Your Datasets +

+ +
+ + {isLoading && datasets.length === 0 ? ( + + ) : !isAuthenticated ? ( +
+

Login required

+

Please log in to manage datasets

+
+ ) : error ? ( +
+

+ Error: {error} +

+
+ ) : datasets.length === 0 ? ( + + ) : ( +
+ {datasets.map((dataset) => ( + + ))} +
+ )} + + {!isLoading && + !error && + isAuthenticated && + datasets.length > 0 && + totalPages > 1 && ( + + )} +
+ +
+
+ +
+

+ Storage Note +

+

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

+
+
+
+ + ); +} + +function EmptyState({ onUploadNew }: { onUploadNew: () => void }) { + return ( +
+
+ +
+

+ No datasets found +

+

+ Upload your first CSV dataset to get started with evaluations. +

+ +
+ ); +} + +function Pagination({ + currentPage, + totalPages, + onPageChange, +}: { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +}) { + return ( +
+

+ Page {currentPage} of {totalPages} +

+
+ + +
+
+ ); +} diff --git a/app/components/datasets/DatasetListingSkeleton.tsx b/app/components/datasets/DatasetListingSkeleton.tsx new file mode 100644 index 00000000..d7dc7d84 --- /dev/null +++ b/app/components/datasets/DatasetListingSkeleton.tsx @@ -0,0 +1,36 @@ +interface DatasetListingSkeletonProps { + count?: number; +} + +export default function DatasetListingSkeleton({ + count = 4, +}: DatasetListingSkeletonProps) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+
+
+
+
+
+
+
+ {[0, 1, 2, 3].map((j) => ( +
+
+
+
+ ))} +
+
+
+
+
+ ))} +
+ ); +} diff --git a/app/components/datasets/DeleteDatasetModal.tsx b/app/components/datasets/DeleteDatasetModal.tsx new file mode 100644 index 00000000..823ee78b --- /dev/null +++ b/app/components/datasets/DeleteDatasetModal.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { Button, Modal } from "@/app/components"; + +interface DeleteDatasetModalProps { + open: boolean; + datasetName?: string; + isDeleting?: boolean; + onClose: () => void; + onConfirm: () => void; +} + +export default function DeleteDatasetModal({ + open, + datasetName, + isDeleting = false, + onClose, + onConfirm, +}: DeleteDatasetModalProps) { + return ( + {} : onClose} + title="Delete Dataset" + maxWidth="max-w-md" + > +
+

+ Are you sure you want to delete{" "} + {datasetName ? ( + {datasetName} + ) : ( + "this dataset" + )} + ? This action cannot be undone. +

+
+ + +
+
+
+ ); +} diff --git a/app/components/datasets/UploadDatasetModal.tsx b/app/components/datasets/UploadDatasetModal.tsx new file mode 100644 index 00000000..cd172232 --- /dev/null +++ b/app/components/datasets/UploadDatasetModal.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useRef } from "react"; +import { Button, Field, Modal } from "@/app/components"; +import { CloudUploadIcon, InfoIcon } from "@/app/components/icons"; + +export interface UploadDatasetModalProps { + open: boolean; + 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 default function UploadDatasetModal({ + open, + selectedFile, + datasetName, + duplicationFactor, + isUploading, + onFileSelect, + onDatasetNameChange, + onDuplicationFactorChange, + onUpload, + onClose, +}: UploadDatasetModalProps) { + const fileInputRef = useRef(null); + const isDisabled = !selectedFile || !datasetName.trim() || isUploading; + + return ( + +
+

+ Upload a CSV file containing your QnA dataset. +

+ +
+
+
+ +
+ + + {selectedFile && ( +
+ Selected:{" "} + + {selectedFile.name} + + + ({Math.round(selectedFile.size / 1024)} KB) + +
+ )} +
+
+ + {selectedFile && ( + <> +
+ +
+ +
+ +

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

+
+ + )} + +
+
+ +
+

+ Expected CSV Format: +

+
+                {`question,answer
+"What is X?","Answer Y"`}
+              
+
+
+
+
+ +
+ + +
+
+ ); +} diff --git a/app/components/evaluations/EvalDatasetDescription.tsx b/app/components/evaluations/EvalDatasetDescription.tsx index b0b99a04..af44c506 100644 --- a/app/components/evaluations/EvalDatasetDescription.tsx +++ b/app/components/evaluations/EvalDatasetDescription.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import { colors } from "@/app/lib/colors"; const EVAL_DESCRIPTION_CHAR_LIMIT = 100; @@ -14,10 +13,7 @@ export default function EvalDatasetDescription({ const isLong = description.length > EVAL_DESCRIPTION_CHAR_LIMIT; return ( -
+
{isLong && !expanded ? description.slice(0, EVAL_DESCRIPTION_CHAR_LIMIT).trimEnd() + "..." @@ -29,8 +25,7 @@ export default function EvalDatasetDescription({ e.stopPropagation(); setExpanded(!expanded); }} - className="mt-1 block text-xs font-medium" - style={{ color: colors.text.primary }} + className="mt-1 block text-xs font-medium text-text-primary cursor-pointer" > {expanded ? "Show less" : "Read more"} diff --git a/app/components/prompt-editor/ConfigDiffPane.tsx b/app/components/prompt-editor/ConfigDiffPane.tsx index 09018ef9..3903a533 100644 --- a/app/components/prompt-editor/ConfigDiffPane.tsx +++ b/app/components/prompt-editor/ConfigDiffPane.tsx @@ -1,4 +1,4 @@ -import { colors } from "@/app/lib/colors"; +import { VersionPill } from "@/app/components"; import { SavedConfig } from "@/app/lib/types/configs"; interface ConfigDiffPaneProps { @@ -17,10 +17,8 @@ export default function ConfigDiffPane({ selectedCommit, compareWith, }: ConfigDiffPaneProps) { - // Build config diffs const configDiffs: ConfigDiff[] = []; - // Compare provider if (compareWith.provider !== selectedCommit.provider) { configDiffs.push({ field: "Provider", @@ -30,7 +28,6 @@ export default function ConfigDiffPane({ }); } - // Compare model if (compareWith.modelName !== selectedCommit.modelName) { configDiffs.push({ field: "Model", @@ -40,7 +37,6 @@ export default function ConfigDiffPane({ }); } - // Compare temperature if (compareWith.temperature !== selectedCommit.temperature) { configDiffs.push({ field: "Temperature", @@ -50,7 +46,6 @@ export default function ConfigDiffPane({ }); } - // Compare tools const oldTools = compareWith.tools || []; const newTools = selectedCommit.tools || []; if (JSON.stringify(oldTools) !== JSON.stringify(newTools)) { @@ -67,18 +62,14 @@ export default function ConfigDiffPane({ const renderValue = (value: unknown): string => { if (Array.isArray(value)) { if (value.length === 0) return "[]"; - return ( - value - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .map((_idx: any) => { - const tool = _idx; - if (tool.type === "file_search") { - return `File Search (${tool.knowledge_base_ids?.[0] || "no store"})`; - } - return JSON.stringify(tool); - }) - .join(", ") - ); + return value + .map((tool) => { + if (tool && typeof tool === "object" && tool.type === "file_search") { + return `File Search (${tool.knowledge_base_ids?.[0] || "no store"})`; + } + return JSON.stringify(tool); + }) + .join(", "); } if (typeof value === "object") { return JSON.stringify(value, null, 2); @@ -88,38 +79,23 @@ export default function ConfigDiffPane({ return (
-
-

+
+

Configuration Changes

-
- Comparing v{compareWith.version} → v{selectedCommit.version} +
+ Comparing → +
-
+
{!hasChanges ? ( -
-

+

+

No configuration changes

@@ -128,50 +104,25 @@ export default function ConfigDiffPane({ {configDiffs.map((diff, idx) => (
-
+
{diff.field}
-
+
Before (v{compareWith.version})
-
+
{renderValue(diff.oldValue)}
-
+
After (v{selectedCommit.version})
-
+
{renderValue(diff.newValue)}
diff --git a/app/components/prompt-editor/ConfigEditorPane.tsx b/app/components/prompt-editor/ConfigEditorPane.tsx index c48496b9..66c0808b 100644 --- a/app/components/prompt-editor/ConfigEditorPane.tsx +++ b/app/components/prompt-editor/ConfigEditorPane.tsx @@ -7,19 +7,14 @@ import { ConfigVersionItems, CompletionConfig, } from "@/app/lib/types/configs"; -import { formatRelativeTime } from "@/app/lib/utils"; import { MODEL_OPTIONS, isGpt5Model } from "@/app/lib/models"; -import { - ChevronDownIcon, - CheckIcon, - PlusIcon, - SpinnerIcon, - InfoIcon, -} from "@/app/components/icons"; import { PROVIDER_TYPES, PROVIDES_OPTIONS } from "@/app/lib/constants"; import GuardrailsSection from "./GuardrailsSection"; import SaveConfigModal from "./SaveConfigModal"; -import { Button, Field, VersionPill } from "@/app/components"; +import LoadConfigDropdown from "./LoadConfigDropdown"; +import ConfigNameSection from "./ConfigNameSection"; +import ToolsSection from "./ToolsSection"; +import { Button } from "@/app/components"; const inputClass = "w-full px-3 py-2 rounded-md text-sm focus:outline-none border border-border bg-bg-primary text-text-primary"; @@ -71,6 +66,8 @@ export default function ConfigEditorPane({ const [guardrailsQueryString, setGuardrailsQueryString] = useState< string | null >(null); + const [showSaveModal, setShowSaveModal] = useState(false); + const wasSavingRef = useRef(false); useEffect(() => { guardrailsFetch<{ data?: { organization_id: number; project_id: number } }>( @@ -89,17 +86,6 @@ export default function ConfigEditorPane({ .catch(() => {}); }, [apiKey]); - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [expandedConfigId, setExpandedConfigId] = useState(null); - const [loadingVersionsFor, setLoadingVersionsFor] = useState>( - new Set(), - ); - const [isRenaming, setIsRenaming] = useState(false); - const [renameDraft, setRenameDraft] = useState(""); - const [isApplyingRename, setIsApplyingRename] = useState(false); - const [showSaveModal, setShowSaveModal] = useState(false); - const wasSavingRef = useRef(false); - // Close the save modal when a save just completed successfully. useEffect(() => { if (wasSavingRef.current && !isSaving && commitMessage === "") { @@ -108,88 +94,13 @@ export default function ConfigEditorPane({ wasSavingRef.current = isSaving; }, [isSaving, commitMessage]); - const isBoundToSavedConfig = !!boundConfigId; - - const handleStartRename = () => { - setRenameDraft(configName); - setIsRenaming(true); - }; - - const handleCancelRename = () => { - setIsRenaming(false); - setRenameDraft(""); - }; - - const handleApplyRename = async () => { - if (!boundConfigId || !onRenameConfig) return; - const trimmed = renameDraft.trim(); - if (!trimmed || trimmed === configName) { - handleCancelRename(); - return; - } - setIsApplyingRename(true); - const ok = await onRenameConfig(boundConfigId, trimmed); - setIsApplyingRename(false); - if (ok) { - setIsRenaming(false); - setRenameDraft(""); - } - }; - - const handleOpenLoadDropdown = () => { - if (!isDropdownOpen) { - if (selectedConfigId) { - const selected = savedConfigs.find((c) => c.id === selectedConfigId); - if (selected) { - setExpandedConfigId(selected.config_id); - if (!versionItemsMap[selected.config_id] && loadVersionsForConfig) { - setLoadingVersionsFor((prev) => - new Set(prev).add(selected.config_id), - ); - loadVersionsForConfig(selected.config_id).finally(() => { - setLoadingVersionsFor((prev) => { - const s = new Set(prev); - s.delete(selected.config_id); - return s; - }); - }); - } - } - } - } - setIsDropdownOpen((prev) => !prev); - }; - - const handleToggleGroup = (config_id: string) => { - if (expandedConfigId === config_id) { - setExpandedConfigId(null); - return; - } - setExpandedConfigId(config_id); - if ( - !versionItemsMap[config_id] && - !loadingVersionsFor.has(config_id) && - loadVersionsForConfig - ) { - setLoadingVersionsFor((prev) => new Set(prev).add(config_id)); - loadVersionsForConfig(config_id).finally(() => { - setLoadingVersionsFor((prev) => { - const s = new Set(prev); - s.delete(config_id); - return s; - }); - }); - } - }; - const [showTooltip, setShowTooltip] = useState(null); - const provider = configBlob.completion.provider; const params = configBlob.completion.params; const isGpt5 = isGpt5Model(params.model); const tools = (params.tools || []) as Tool[]; const selectedConfig = savedConfigs.find((c) => c.id === selectedConfigId); - + const isBoundToSavedConfig = !!boundConfigId; const existingConfigForHint = configName.trim() ? allConfigMeta.find((m) => m.name === configName.trim()) : undefined; @@ -212,10 +123,7 @@ export default function ConfigEditorPane({ const handleTypeChange = (newType: "text" | "stt" | "tts") => { onConfigChange({ ...configBlob, - completion: { - ...configBlob.completion, - type: newType, - }, + completion: { ...configBlob.completion, type: newType }, }); }; @@ -295,459 +203,150 @@ export default function ConfigEditorPane({

- { -
-
-
- - - - {isDropdownOpen && ( -
- - - {allConfigMeta.map((meta) => { - const isExpanded = expandedConfigId === meta.id; - const isLoadingGroup = loadingVersionsFor.has(meta.id); - const items = versionItemsMap[meta.id] ?? []; - return ( -
- - {isExpanded && - !isLoadingGroup && - items.map((item) => { - const isSelected = - selectedConfig?.config_id === meta.id && - selectedConfig?.version === item.version; - return ( - - ); - })} - {isExpanded && isLoadingGroup && ( -
- Loading versions… -
- )} -
- ); - })} -
- )} - - {isDropdownOpen && ( -
setIsDropdownOpen(false)} - /> - )} -
- - {isBoundToSavedConfig && !isRenaming && ( -
- -
-
- {configName || "Untitled"} -
- -
-
- )} - - {isBoundToSavedConfig && isRenaming && ( -
- -
- - -
-

- ✏️ Renames the configuration metadata only — no new version is - created. -

-
- )} +
+
+ + + + +
+ + +
- {!isBoundToSavedConfig && ( -
- - {configName.trim() && ( -

- {existingConfigForHint - ? `💡 Will create a new version for "${configName}"` - : `✨ Will create a new config "${configName}"`} -

- )} -
- )} +
+ + +

+ Standard text-based LLM completion +

+
-
- - -
+
+ + +
+ {!isGpt5 && (
- -

- Standard text-based LLM completion -

-
- -
- - -
- - {!isGpt5 && ( -
- - - handleTemperatureChange(parseFloat(e.target.value)) - } - className="w-full accent-accent-primary" - /> -
- 0 - 2 -
+ className="w-full accent-accent-primary" + /> +
+ 0 + 2
- )} - -
-
- - -
- {tools.map((tool, index) => ( -
-
- - File Search - - -
-
- - handleUpdateTool(index, "knowledge_base_ids", [v]) - } - placeholder="vs_abc123" - /> -
- - {!isGpt5 && ( -
-
- -
setShowTooltip(index)} - onMouseLeave={() => setShowTooltip(null)} - > - - {showTooltip === index && ( -
- Controls how many matching results are returned -
- from the search -
-
- )} -
-
- - handleUpdateTool( - index, - "max_num_results", - parseInt(e.target.value) || 20, - ) - } - className="w-full px-3 py-2 rounded-lg border border-border bg-white text-text-primary text-sm placeholder:text-neutral-400 focus:outline-none focus:ring-accent-primary/20 focus:border-accent-primary transition-colors" - /> -
- )} -
- ))}
- - - onConfigChange({ ...configBlob, input_guardrails: refs }) - } - apiKey={apiKey} - queryString={guardrailsQueryString} - stage="input" - /> - - - onConfigChange({ ...configBlob, output_guardrails: refs }) - } - apiKey={apiKey} - queryString={guardrailsQueryString} - stage="output" - /> - - -
+ )} + + + + + onConfigChange({ ...configBlob, input_guardrails: refs }) + } + apiKey={apiKey} + queryString={guardrailsQueryString} + stage="input" + /> + + + onConfigChange({ ...configBlob, output_guardrails: refs }) + } + apiKey={apiKey} + queryString={guardrailsQueryString} + stage="output" + /> + +
- } +
void; + /** + * The parent config_id this version belongs to. When set, the name is + * locked behind an explicit Rename action that doesn't create a version. + */ + boundConfigId?: string; + /** Renames the config metadata only — does not create a new version. */ + onRenameConfig?: (configId: string, newName: string) => Promise; + allConfigMeta: ConfigPublic[]; +} + +export default function ConfigNameSection({ + configName, + onConfigNameChange, + boundConfigId, + onRenameConfig, + allConfigMeta, +}: ConfigNameSectionProps) { + const [isRenaming, setIsRenaming] = useState(false); + const [renameDraft, setRenameDraft] = useState(""); + const [isApplyingRename, setIsApplyingRename] = useState(false); + + const isBound = !!boundConfigId; + + const existingConfigForHint = configName.trim() + ? allConfigMeta.find((m) => m.name === configName.trim()) + : undefined; + + const handleStartRename = () => { + setRenameDraft(configName); + setIsRenaming(true); + }; + + const handleCancelRename = () => { + setIsRenaming(false); + setRenameDraft(""); + }; + + const handleApplyRename = async () => { + if (!boundConfigId || !onRenameConfig) return; + const trimmed = renameDraft.trim(); + if (!trimmed || trimmed === configName) { + handleCancelRename(); + return; + } + setIsApplyingRename(true); + const ok = await onRenameConfig(boundConfigId, trimmed); + setIsApplyingRename(false); + if (ok) { + setIsRenaming(false); + setRenameDraft(""); + } + }; + + if (isBound && !isRenaming) { + return ( +
+ +
+
+ {configName || "Untitled"} +
+ +
+
+ ); + } + + if (isBound && isRenaming) { + return ( +
+ +
+ + +
+

+ ✏️ Renames the configuration metadata only — no new version is + created. +

+
+ ); + } + + return ( +
+ + {configName.trim() && ( +

+ {existingConfigForHint + ? `💡 Will create a new version for "${configName}"` + : `✨ Will create a new config "${configName}"`} +

+ )} +
+ ); +} diff --git a/app/components/prompt-editor/LoadConfigDropdown.tsx b/app/components/prompt-editor/LoadConfigDropdown.tsx new file mode 100644 index 00000000..a157022e --- /dev/null +++ b/app/components/prompt-editor/LoadConfigDropdown.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useState } from "react"; +import { VersionPill } from "@/app/components"; +import { + ChevronDownIcon, + CheckIcon, + PlusIcon, + SpinnerIcon, +} from "@/app/components/icons"; +import { + ConfigPublic, + SavedConfig, + ConfigVersionItems, +} from "@/app/lib/types/configs"; +import { formatRelativeTime } from "@/app/lib/utils"; + +interface LoadConfigDropdownProps { + selectedConfig?: SavedConfig; + selectedConfigId: string; + savedConfigs: SavedConfig[]; + allConfigMeta: ConfigPublic[]; + versionItemsMap: Record; + loadVersionsForConfig?: (config_id: string) => Promise; + loadSingleVersion?: ( + config_id: string, + version: number, + ) => Promise; + onLoadConfig: (config: SavedConfig | null) => void; +} + +export default function LoadConfigDropdown({ + selectedConfig, + selectedConfigId, + savedConfigs, + allConfigMeta, + versionItemsMap, + loadVersionsForConfig, + loadSingleVersion, + onLoadConfig, +}: LoadConfigDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const [expandedConfigId, setExpandedConfigId] = useState(null); + const [loadingVersionsFor, setLoadingVersionsFor] = useState>( + new Set(), + ); + + const ensureVersionsLoaded = (configId: string) => { + if ( + !versionItemsMap[configId] && + !loadingVersionsFor.has(configId) && + loadVersionsForConfig + ) { + setLoadingVersionsFor((prev) => new Set(prev).add(configId)); + loadVersionsForConfig(configId).finally(() => { + setLoadingVersionsFor((prev) => { + const s = new Set(prev); + s.delete(configId); + return s; + }); + }); + } + }; + + const handleOpen = () => { + if (!isOpen && selectedConfig) { + setExpandedConfigId(selectedConfig.config_id); + ensureVersionsLoaded(selectedConfig.config_id); + } + setIsOpen((prev) => !prev); + }; + + const handleToggleGroup = (config_id: string) => { + if (expandedConfigId === config_id) { + setExpandedConfigId(null); + return; + } + setExpandedConfigId(config_id); + ensureVersionsLoaded(config_id); + }; + + const handleSelectVersion = async (item: ConfigVersionItems) => { + const full = savedConfigs.find( + (c) => c.config_id === item.config_id && c.version === item.version, + ); + const config = + full ?? + (loadSingleVersion + ? await loadSingleVersion(item.config_id, item.version) + : null); + if (config) { + onLoadConfig(config); + setIsOpen(false); + } + }; + + return ( +
+ + + + {isOpen && ( +
+ + + {allConfigMeta.map((meta) => { + const isExpanded = expandedConfigId === meta.id; + const isLoadingGroup = loadingVersionsFor.has(meta.id); + const items = versionItemsMap[meta.id] ?? []; + return ( +
+ + {isExpanded && + !isLoadingGroup && + items.map((item) => { + const isSelected = + selectedConfig?.config_id === meta.id && + selectedConfig?.version === item.version; + return ( + + ); + })} + {isExpanded && isLoadingGroup && ( +
+ Loading versions… +
+ )} +
+ ); + })} +
+ )} + + {isOpen && ( +
setIsOpen(false)} /> + )} +
+ ); +} diff --git a/app/components/prompt-editor/PromptDiffPane.tsx b/app/components/prompt-editor/PromptDiffPane.tsx index a89f1b83..cdbce7b9 100644 --- a/app/components/prompt-editor/PromptDiffPane.tsx +++ b/app/components/prompt-editor/PromptDiffPane.tsx @@ -1,4 +1,4 @@ -import { colors } from "@/app/lib/colors"; +import { VersionPill } from "@/app/components"; import { SavedConfig } from "@/app/lib/types/configs"; interface PromptDiffPaneProps { @@ -12,7 +12,6 @@ interface DiffLine { lineNumber: number; } -// Simple diff utility - matches SimplifiedConfigEditor implementation function generateDiff( text1: string, text2: string, @@ -28,19 +27,15 @@ function generateDiff( const line2 = lines2[i] !== undefined ? lines2[i] : null; if (line1 === null && line2 !== null) { - // Line only exists in text2 (added) left.push({ type: "same", content: "", lineNumber: i + 1 }); right.push({ type: "added", content: line2, lineNumber: i + 1 }); } else if (line1 !== null && line2 === null) { - // Line only exists in text1 (removed) left.push({ type: "removed", content: line1, lineNumber: i + 1 }); right.push({ type: "same", content: "", lineNumber: i + 1 }); } else if (line1 !== line2) { - // Lines are different left.push({ type: "removed", content: line1 || "", lineNumber: i + 1 }); right.push({ type: "added", content: line2 || "", lineNumber: i + 1 }); } else { - // Lines are the same left.push({ type: "same", content: line1 || "", lineNumber: i + 1 }); right.push({ type: "same", content: line2 || "", lineNumber: i + 1 }); } @@ -63,124 +58,47 @@ export default function PromptDiffPane({ return (
-
-

+
+

Prompt Changes

-
- Side-by-side comparison: v{compareWith.version} ↔ v - {selectedCommit.version} +
+ Side-by-side comparison:{" "} + ↔ +
{!hasChanges ? (
-

- No prompt changes -

+

No prompt changes

) : (
- {/* Column Headers */} -
-
+
+
v{compareWith.version} (Before)
-
+
v{selectedCommit.version} (After)
- {/* Side-by-Side Diff */}
-
- {/* Left Panel */} -
+
+
{left.map((line, idx) => ( -
- {line.type === "removed" && "- "} - {line.content || "\u00A0"} -
+ ))}
- {/* Right Panel */} -
+
{right.map((line, idx) => ( -
- {line.type === "added" && "+ "} - {line.content || "\u00A0"} -
+ ))}
@@ -190,3 +108,36 @@ export default function PromptDiffPane({
); } + +function DiffLineRow({ + line, + side, +}: { + line: DiffLine; + side: "left" | "right"; +}) { + const tone = + side === "left" + ? line.type === "removed" + ? "bg-status-error-bg text-status-error-text" + : "text-text-primary" + : line.type === "added" + ? "bg-status-success-bg text-status-success-text" + : "text-text-primary"; + + const prefix = + side === "left" && line.type === "removed" + ? "- " + : side === "right" && line.type === "added" + ? "+ " + : ""; + + return ( +
+ {prefix} + {line.content || " "} +
+ ); +} diff --git a/app/components/prompt-editor/ToolsSection.tsx b/app/components/prompt-editor/ToolsSection.tsx new file mode 100644 index 00000000..b98233b4 --- /dev/null +++ b/app/components/prompt-editor/ToolsSection.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useState } from "react"; +import { Field } from "@/app/components"; +import { InfoIcon } from "@/app/components/icons"; +import { Tool } from "@/app/lib/types/promptEditor"; + +interface ToolsSectionProps { + tools: Tool[]; + isGpt5: boolean; + onAddTool: () => void; + onRemoveTool: (index: number) => void; + onUpdateTool: ( + index: number, + field: K, + value: Tool[K], + ) => void; +} + +export default function ToolsSection({ + tools, + isGpt5, + onAddTool, + onRemoveTool, + onUpdateTool, +}: ToolsSectionProps) { + const [showTooltip, setShowTooltip] = useState(null); + + return ( +
+
+ + +
+ {tools.map((tool, index) => ( +
+
+ + File Search + + +
+
+ onUpdateTool(index, "knowledge_base_ids", [v])} + placeholder="vs_abc123" + /> +
+ + {!isGpt5 && ( +
+
+ +
setShowTooltip(index)} + onMouseLeave={() => setShowTooltip(null)} + > + + {showTooltip === index && ( +
+ Controls how many matching results are returned +
+ from the search +
+
+ )} +
+
+ + onUpdateTool( + index, + "max_num_results", + parseInt(e.target.value) || 20, + ) + } + className="w-full px-3 py-2 rounded-lg border border-border bg-bg-primary text-text-primary text-sm placeholder:text-neutral-400 focus:outline-none focus:ring-accent-primary/20 focus:border-accent-primary transition-colors" + /> +
+ )} +
+ ))} +
+ ); +} diff --git a/app/components/speech-to-text/AudioPlayer.tsx b/app/components/speech-to-text/AudioPlayer.tsx index 4a5347d8..f902490b 100644 --- a/app/components/speech-to-text/AudioPlayer.tsx +++ b/app/components/speech-to-text/AudioPlayer.tsx @@ -1,7 +1,6 @@ "use client"; import { useState, useEffect, useRef } from "react"; -import { colors } from "@/app/lib/colors"; import WaveformVisualizer from "./WaveformVisualizer"; interface AudioPlayerProps { @@ -57,6 +56,8 @@ export default function AudioPlayer({ return `${mins}:${secs.toString().padStart(2, "0")}`; }; + const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0; + return (