diff --git a/app/(main)/datasets/page.tsx b/app/(main)/datasets/page.tsx index 1234bc1..05d9316 100644 --- a/app/(main)/datasets/page.tsx +++ b/app/(main)/datasets/page.tsx @@ -10,11 +10,11 @@ import { useState, useEffect } from "react"; import { useAuth } from "@/app/lib/context/AuthContext"; import { useApp } from "@/app/lib/context/AppContext"; +import { Dataset } from "@/app/lib/types/datasets"; import { apiFetch } from "@/app/lib/apiClient"; import Sidebar from "@/app/components/Sidebar"; import PageHeader from "@/app/components/PageHeader"; import { useToast } from "@/app/components/Toast"; -import { Dataset } from "@/app/lib/types/dataset"; export const DATASETS_STORAGE_KEY = "kaapi_datasets"; diff --git a/app/(main)/keystore/page.tsx b/app/(main)/keystore/page.tsx index fcd20e9..c66a8ba 100644 --- a/app/(main)/keystore/page.tsx +++ b/app/(main)/keystore/page.tsx @@ -13,8 +13,6 @@ import { useAuth } from "@/app/lib/context/AuthContext"; import { useApp } from "@/app/lib/context/AppContext"; 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); diff --git a/app/api/assessment/assessments/[assessment_id]/results/route.ts b/app/api/assessment/assessments/[assessment_id]/results/route.ts new file mode 100644 index 0000000..8cc5d3b --- /dev/null +++ b/app/api/assessment/assessments/[assessment_id]/results/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + assessmentApiFetch, + safeParseJson, + toDownloadResponse, +} from "@/app/api/assessment/utils"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ assessment_id: string }> }, +) { + try { + const { assessment_id } = await params; + const queryParams = new URLSearchParams(request.nextUrl.searchParams); + queryParams.set("get_trace_info", "true"); + + const response = await assessmentApiFetch( + request, + `/api/v1/assessment/assessments/${assessment_id}/results?${queryParams.toString()}`, + { method: "GET" }, + ); + + const downloadResponse = await toDownloadResponse(response); + if (downloadResponse) { + return downloadResponse; + } + + const data = await safeParseJson(response); + return NextResponse.json(data, { status: response.status }); + } catch (error: unknown) { + console.error("Assessment results proxy error:", error); + return NextResponse.json( + { + error: "Failed to forward request", + }, + { status: 500 }, + ); + } +} diff --git a/app/api/assessment/assessments/[assessment_id]/retry/route.ts b/app/api/assessment/assessments/[assessment_id]/retry/route.ts new file mode 100644 index 0000000..2ed01c9 --- /dev/null +++ b/app/api/assessment/assessments/[assessment_id]/retry/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +interface RouteContext { + params: Promise<{ assessment_id: string }>; +} + +export async function POST(request: NextRequest, context: RouteContext) { + try { + const { assessment_id } = await context.params; + const { status, data } = await apiClient( + request, + `/api/v1/assessment/assessments/${assessment_id}/retry`, + { method: "POST" }, + ); + + return NextResponse.json(data, { status }); + } catch (error: unknown) { + console.error("Assessment retry proxy error:", error); + return NextResponse.json( + { + error: "Failed to forward assessment retry request", + }, + { status: 500 }, + ); + } +} diff --git a/app/api/assessment/assessments/route.ts b/app/api/assessment/assessments/route.ts new file mode 100644 index 0000000..4dfdda5 --- /dev/null +++ b/app/api/assessment/assessments/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function GET(request: NextRequest) { + try { + const queryString = request.nextUrl.searchParams.toString(); + const endpoint = `/api/v1/assessment/assessments${ + queryString ? `?${queryString}` : "" + }`; + + const { status, data } = await apiClient(request, endpoint, { + method: "GET", + }); + + return NextResponse.json(data, { status }); + } catch (error: unknown) { + console.error("Assessment list proxy error:", error); + return NextResponse.json( + { + error: "Failed to forward request to backend", + }, + { status: 500 }, + ); + } +} diff --git a/app/api/assessment/datasets/[dataset_id]/route.ts b/app/api/assessment/datasets/[dataset_id]/route.ts new file mode 100644 index 0000000..0132b04 --- /dev/null +++ b/app/api/assessment/datasets/[dataset_id]/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ dataset_id: string }> }, +) { + try { + const { dataset_id } = await params; + const fetchContent = + request.nextUrl.searchParams.get("fetch_content") === "true"; + + // Always request signed URL when fetch_content is needed + const backendParams = new URLSearchParams(request.nextUrl.searchParams); + if (fetchContent) { + backendParams.set("include_signed_url", "true"); + } + const endpoint = `/api/v1/assessment/datasets/${dataset_id}${ + backendParams.toString() ? `?${backendParams.toString()}` : "" + }`; + + const { status, data } = await apiClient(request, endpoint, { + method: "GET", + }); + + if (status >= 400) { + return NextResponse.json(data, { status }); + } + + // Download file from S3 server-side and return as base64 + if (fetchContent) { + const signedUrl = + (data as { data?: { signed_url?: string }; signed_url?: string })?.data + ?.signed_url || + (data as { data?: { signed_url?: string }; signed_url?: string }) + ?.signed_url; + + if (!signedUrl) { + return NextResponse.json( + { error: "No signed URL available" }, + { status: 404 }, + ); + } + + const fileResponse = await fetch(signedUrl); + if (!fileResponse.ok) { + return NextResponse.json( + { error: "Failed to fetch file from storage" }, + { status: 502 }, + ); + } + + const arrayBuffer = await fileResponse.arrayBuffer(); + const base64 = Buffer.from(arrayBuffer).toString("base64"); + return NextResponse.json( + { ...(data as Record), file_content: base64 }, + { status: 200 }, + ); + } + + return NextResponse.json(data, { status }); + } catch (error: unknown) { + console.error("Assessment dataset details proxy error:", error); + return NextResponse.json( + { + error: "Failed to forward request to backend", + }, + { status: 500 }, + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ dataset_id: string }> }, +) { + try { + const { dataset_id } = await params; + const { status, data } = await apiClient( + request, + `/api/v1/assessment/datasets/${dataset_id}`, + { method: "DELETE" }, + ); + + return NextResponse.json(data, { status }); + } catch (error: unknown) { + console.error("Assessment dataset delete proxy error:", error); + return NextResponse.json( + { + error: "Failed to forward request to backend", + }, + { status: 500 }, + ); + } +} diff --git a/app/api/assessment/datasets/route.ts b/app/api/assessment/datasets/route.ts new file mode 100644 index 0000000..f262d35 --- /dev/null +++ b/app/api/assessment/datasets/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function GET(request: NextRequest) { + try { + const { status, data } = await apiClient( + request, + "/api/v1/assessment/datasets", + { method: "GET" }, + ); + + return NextResponse.json(data, { status }); + } catch (error: unknown) { + console.error("Assessment datasets list proxy error:", error); + return NextResponse.json( + { + error: "Failed to forward request to backend", + }, + { status: 500 }, + ); + } +} + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + + const { status, data } = await apiClient( + request, + "/api/v1/assessment/datasets", + { + method: "POST", + body: formData, + }, + ); + + return NextResponse.json(data, { status }); + } catch (error: unknown) { + console.error("Assessment datasets create proxy error:", error); + return NextResponse.json( + { + error: "Failed to forward request to backend", + }, + { status: 500 }, + ); + } +} diff --git a/app/api/assessment/evaluations/[evaluation_id]/results/route.ts b/app/api/assessment/evaluations/[evaluation_id]/results/route.ts new file mode 100644 index 0000000..a9b64c7 --- /dev/null +++ b/app/api/assessment/evaluations/[evaluation_id]/results/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + assessmentApiFetch, + safeParseJson, + toDownloadResponse, +} from "@/app/api/assessment/utils"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ evaluation_id: string }> }, +) { + try { + const { evaluation_id } = await params; + const queryString = request.nextUrl.searchParams.toString(); + const endpoint = `/api/v1/assessment/evaluations/${evaluation_id}/results${ + queryString ? `?${queryString}` : "" + }`; + + const response = await assessmentApiFetch(request, endpoint, { + method: "GET", + }); + + const downloadResponse = await toDownloadResponse(response); + if (downloadResponse) { + return downloadResponse; + } + + const data = await safeParseJson(response); + return NextResponse.json(data, { status: response.status }); + } catch (error: unknown) { + console.error("Assessment evaluation results proxy error:", error); + return NextResponse.json( + { + error: "Failed to forward request", + }, + { status: 500 }, + ); + } +} diff --git a/app/api/assessment/evaluations/[evaluation_id]/retry/route.ts b/app/api/assessment/evaluations/[evaluation_id]/retry/route.ts new file mode 100644 index 0000000..c260689 --- /dev/null +++ b/app/api/assessment/evaluations/[evaluation_id]/retry/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +interface RouteContext { + params: Promise<{ evaluation_id: string }>; +} + +export async function POST(request: NextRequest, context: RouteContext) { + try { + const { evaluation_id } = await context.params; + const { status, data } = await apiClient( + request, + `/api/v1/assessment/evaluations/${evaluation_id}/retry`, + { method: "POST" }, + ); + + return NextResponse.json(data, { status }); + } catch (error: unknown) { + console.error("Assessment evaluation retry proxy error:", error); + return NextResponse.json( + { + error: "Failed to forward evaluation retry request", + }, + { status: 500 }, + ); + } +} diff --git a/app/api/assessment/evaluations/route.ts b/app/api/assessment/evaluations/route.ts new file mode 100644 index 0000000..48c34a6 --- /dev/null +++ b/app/api/assessment/evaluations/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function GET(request: NextRequest) { + try { + const queryString = request.nextUrl.searchParams.toString(); + const endpoint = `/api/v1/assessment/evaluations${ + queryString ? `?${queryString}` : "" + }`; + + const { status, data } = await apiClient(request, endpoint, { + method: "GET", + }); + + return NextResponse.json(data, { status }); + } catch (error: unknown) { + console.error("Assessment evaluations list proxy error:", error); + return NextResponse.json( + { + error: "Failed to forward request to backend", + }, + { status: 500 }, + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + const { status, data } = await apiClient( + request, + "/api/v1/assessment/evaluations", + { + method: "POST", + body: JSON.stringify(body), + }, + ); + + return NextResponse.json(data, { status }); + } catch (error: unknown) { + console.error("Assessment evaluations create proxy error:", error); + return NextResponse.json( + { + error: "Failed to forward request to backend", + }, + { status: 500 }, + ); + } +} diff --git a/app/api/assessment/events/route.ts b/app/api/assessment/events/route.ts new file mode 100644 index 0000000..52993bb --- /dev/null +++ b/app/api/assessment/events/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assessmentApiFetch } from "@/app/api/assessment/utils"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + try { + const response = await assessmentApiFetch( + request, + "/api/v1/assessment/events", + { + method: "GET", + headers: { Accept: "text/event-stream" }, + cache: "no-store", + }, + ); + + if (!response.ok || !response.body) { + const text = await response.text(); + return NextResponse.json( + { error: "Failed to connect assessment event stream", details: text }, + { status: response.status || 500 }, + ); + } + + return new Response(response.body, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); + } catch (error: unknown) { + console.error("Assessment events proxy error:", error); + return NextResponse.json( + { + error: "Failed to connect assessment event stream", + }, + { status: 500 }, + ); + } +} diff --git a/app/api/assessment/utils.ts b/app/api/assessment/utils.ts new file mode 100644 index 0000000..8cfee62 --- /dev/null +++ b/app/api/assessment/utils.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from "next/server"; + +const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:8000"; + +const DOWNLOAD_CONTENT_TYPE_HINTS = [ + "text/csv", + "spreadsheetml", + "octet-stream", + "application/zip", +]; + +export function isDownloadContentType(contentType: string): boolean { + return DOWNLOAD_CONTENT_TYPE_HINTS.some((hint) => contentType.includes(hint)); +} + +function buildAssessmentAuthHeaders( + request: NextRequest | Request, + headers: Headers = new Headers(), +): Headers { + const apiKey = request.headers.get("X-API-KEY") || ""; + const cookie = request.headers.get("Cookie") || ""; + + if (apiKey) { + headers.set("X-API-KEY", apiKey); + } + if (cookie) { + headers.set("Cookie", cookie); + } + + return headers; +} + +export async function assessmentApiFetch( + request: NextRequest | Request, + endpoint: string, + options: RequestInit = {}, +): Promise { + const headers = buildAssessmentAuthHeaders( + request, + new Headers(options.headers), + ); + if ( + options.body !== undefined && + !(options.body instanceof FormData) && + !headers.has("Content-Type") + ) { + headers.set("Content-Type", "application/json"); + } + + return fetch(`${BACKEND_URL}${endpoint}`, { + ...options, + headers, + credentials: "include", + }); +} + +export async function safeParseJson( + response: Response, +): Promise | unknown[] | null> { + const text = response.status === 204 ? "" : await response.text(); + if (!text) return null; + try { + return JSON.parse(text) as Record | unknown[]; + } catch { + return null; + } +} + +export async function toDownloadResponse( + response: Response, +): Promise { + const contentType = response.headers.get("content-type") || ""; + if (!isDownloadContentType(contentType)) { + return null; + } + + const blob = await response.blob(); + const headers = new Headers(); + headers.set("Content-Type", contentType); + + const disposition = response.headers.get("content-disposition"); + if (disposition) { + headers.set("Content-Disposition", disposition); + } + + return new NextResponse(blob, { status: response.status, headers }); +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index 5111c38..8461a4f 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { apiClient } from "@/app/lib/apiClient"; -import { clearRoleCookie } from "@/app/lib/authCookie"; +import { clearFeaturesCookie, clearRoleCookie } from "@/app/lib/authCookie"; export async function POST(request: NextRequest) { const { status, data, headers } = await apiClient( @@ -17,6 +17,7 @@ export async function POST(request: NextRequest) { } clearRoleCookie(res); + clearFeaturesCookie(res); return res; } diff --git a/app/api/configs/[config_id]/versions/route.ts b/app/api/configs/[config_id]/versions/route.ts index 9ac697d..44e1247 100644 --- a/app/api/configs/[config_id]/versions/route.ts +++ b/app/api/configs/[config_id]/versions/route.ts @@ -8,10 +8,11 @@ export async function GET( const { config_id } = await params; try { - const { status, data } = await apiClient( - request, - `/api/v1/configs/${config_id}/versions`, - ); + const { searchParams } = new URL(request.url); + const queryString = searchParams.toString(); + const endpoint = `/api/v1/configs/${config_id}/versions${queryString ? `?${queryString}` : ""}`; + const { status, data } = await apiClient(request, endpoint); + return NextResponse.json(data, { status }); } catch (_error) { return NextResponse.json( @@ -29,7 +30,6 @@ export async function POST( try { const body = await request.json(); - const { status, data } = await apiClient( request, `/api/v1/configs/${config_id}/versions`, diff --git a/app/api/configs/route.ts b/app/api/configs/route.ts index d0f6016..f4dd6b8 100644 --- a/app/api/configs/route.ts +++ b/app/api/configs/route.ts @@ -7,6 +7,7 @@ export async function GET(request: Request) { const queryString = searchParams.toString(); const endpoint = `/api/v1/configs/${queryString ? `?${queryString}` : ""}`; const { status, data } = await apiClient(request, endpoint); + return NextResponse.json(data, { status }); } catch (error) { return NextResponse.json( @@ -23,14 +24,15 @@ export async function GET(request: Request) { export async function POST(request: Request) { try { const body = await request.json(); - const { status, data } = await apiClient(request, "/api/v1/configs/", { method: "POST", body: JSON.stringify(body), }); + return NextResponse.json(data, { status }); } catch (error) { console.error("Proxy error:", error); + return NextResponse.json( { error: "Failed to forward request", diff --git a/app/api/users/me/route.ts b/app/api/users/me/route.ts index 6b9fe0f..32686d7 100644 --- a/app/api/users/me/route.ts +++ b/app/api/users/me/route.ts @@ -1,6 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { apiClient } from "@/app/lib/apiClient"; -import { setRoleCookieFromBody } from "@/app/lib/authCookie"; +import { + setFeaturesCookieFromBody, + setRoleCookieFromBody, +} from "@/app/lib/authCookie"; export async function GET(request: NextRequest) { try { @@ -9,6 +12,7 @@ export async function GET(request: NextRequest) { if (status >= 200 && status < 300) { setRoleCookieFromBody(res, data); + setFeaturesCookieFromBody(res, data); } return res; diff --git a/app/assessment/AssessmentPageClient.tsx b/app/assessment/AssessmentPageClient.tsx new file mode 100644 index 0000000..49c5ecd --- /dev/null +++ b/app/assessment/AssessmentPageClient.tsx @@ -0,0 +1,721 @@ +"use client"; + +import { + useState, + useEffect, + useCallback, + useRef, + Suspense, + useMemo, +} from "react"; +import { useRouter } from "next/navigation"; +import { colors } from "@/app/lib/colors"; +import { apiFetch } from "@/app/lib/apiClient"; +import { STORAGE_KEY } from "@/app/lib/constants/keystore"; +import { FeatureFlag } from "@/app/lib/constants/featureFlags"; +import { removeFeatureFromClient } from "@/app/lib/featureState"; +import { APIKey } from "@/app/lib/types/credentials"; +import Sidebar from "@/app/components/Sidebar"; +import Loader from "@/app/components/Loader"; +import { MenuIcon, KeyIcon, DatabaseIcon } from "@/app/components/icons"; +import { useToast } from "@/app/components/Toast"; +import Stepper, { Step } from "./components/Stepper"; +import DatasetStep from "./components/DatasetStep"; +import ColumnMapperStep from "./components/ColumnMapperStep"; +import PromptAndConfigStep from "./components/PromptAndConfigStep"; +import ReviewStep from "./components/ReviewStep"; +import EvaluationsTab from "./components/EvaluationsTab"; +import { useAssessmentEvents } from "./useAssessmentEvents"; +import { ConfigSelection, SchemaProperty, AssessmentFormState } from "./types"; +import { useAssessmentDatasetStore } from "./store"; +import { schemaToJsonSchema } from "./schemaUtils"; +import { handleForbiddenApiError } from "./errorUtils"; + +type TabId = "datasets" | "config" | "results"; + +const TABS: { id: TabId; label: string }[] = [ + { id: "datasets", label: "Datasets" }, + { id: "config", label: "Config" }, + { id: "results", label: "Result" }, +]; + +const CONFIG_STEPS: Step[] = [ + { id: 1, label: "Mapper" }, + { id: 2, label: "Prompt & Config" }, + { id: 3, label: "Review" }, +]; + +declare global { + interface Window { + __assessmentForbiddenNavLock?: boolean; + } +} + +function ShimmerDot({ color }: { color: string }) { + return ( + + + + + ); +} + +type IndicatorState = "none" | "processing" | "failed" | "success"; +type DatasetSummary = { dataset_id: number; dataset_name?: string }; +type EvaluationStatusRun = { status: string; updated_at: string }; + +function AssessmentContent() { + const router = useRouter(); + const toast = useToast(); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [activeTab, setActiveTab] = useState("datasets"); + const [configStep, setConfigStep] = useState(1); + const [completedConfigSteps, setCompletedConfigSteps] = useState>( + new Set(), + ); + const [isSubmitting, setIsSubmitting] = useState(false); + const [apiKeys, setApiKeys] = useState([]); + const [selectedKeyId, setSelectedKeyId] = useState(""); + const [evalIndicator, setEvalIndicator] = useState("none"); + const dismissedRef = useRef(false); + const featureRedirectingRef = useRef(false); + const [assessmentRefreshToken, setAssessmentRefreshToken] = useState(0); + const [experimentName, setExperimentName] = useState(""); + const { + datasetId, + datasetName, + columns, + sampleRow, + columnMapping, + setDatasetId, + setDatasetName, + setDataset, + setColumnMapping, + clearDataset, + } = useAssessmentDatasetStore(); + const [promptTemplate, setPromptTemplate] = useState(""); + const [outputSchema, setOutputSchema] = useState([]); + const [configs, setConfigs] = useState([]); + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const keys = JSON.parse(stored); + setApiKeys(keys); + if (keys.length > 0) setSelectedKeyId(keys[0].id); + } catch (e) { + console.error("Failed to load API keys:", e); + } + } + }, []); + + const selectedKey = apiKeys.find((k) => k.id === selectedKeyId); + + const handleAssessmentForbidden = useCallback( + (options?: { notify?: boolean }) => { + if ( + typeof window !== "undefined" && + window.__assessmentForbiddenNavLock + ) { + return; + } + if (featureRedirectingRef.current) { + return; + } + + if (typeof window !== "undefined") { + window.__assessmentForbiddenNavLock = true; + } + featureRedirectingRef.current = true; + + if (options?.notify) { + toast.error( + "Assessment feature is disabled for this organization/project.", + ); + } + removeFeatureFromClient(FeatureFlag.ASSESSMENT); + if ( + typeof window !== "undefined" && + window.location.pathname !== "/evaluations" + ) { + router.replace("/"); + } + }, + [router, toast], + ); + + const handleAssessmentForbiddenWithNotify = useCallback(() => { + handleAssessmentForbidden({ notify: true }); + }, [handleAssessmentForbidden]); + + // Backfill dataset name when old persisted state has only datasetId. + useEffect(() => { + if (!selectedKey?.key || !datasetId || datasetName) return; + + let cancelled = false; + + (async () => { + try { + const data = await apiFetch< + { data?: DatasetSummary[] } | DatasetSummary[] + >("/api/assessment/datasets", selectedKey.key); + if (cancelled) return; + + const datasets: DatasetSummary[] = Array.isArray(data) + ? data + : data.data || []; + const selected = datasets.find( + (dataset: { dataset_id: number; dataset_name?: string }) => + dataset.dataset_id.toString() === datasetId, + ); + if (!cancelled && selected?.dataset_name) { + setDatasetName(selected.dataset_name); + } + } catch (error) { + if (handleForbiddenApiError(error, handleAssessmentForbiddenWithNotify)) + return; + // ignore non-forbidden backfill failures; review will fallback gracefully + } + })(); + + return () => { + cancelled = true; + }; + }, [ + datasetId, + datasetName, + selectedKey?.key, + setDatasetName, + handleAssessmentForbiddenWithNotify, + ]); + + useEffect(() => { + if (!selectedKey?.key) return; + + let cancelled = false; + + (async () => { + try { + if (cancelled) return; + await apiFetch("/api/assessment/evaluations?limit=1", selectedKey.key); + } catch (error) { + if (handleForbiddenApiError(error, handleAssessmentForbiddenWithNotify)) + return; + console.error("Assessment feature check failed:", error); + // silently ignore + } + })(); + + return () => { + cancelled = true; + }; + }, [handleAssessmentForbiddenWithNotify, selectedKey?.key]); + + const pollEvalStatus = useCallback(async () => { + if (!selectedKey) return; + try { + const data = await apiFetch< + { data?: EvaluationStatusRun[] } | EvaluationStatusRun[] + >("/api/assessment/evaluations?limit=10", selectedKey.key); + const runs: EvaluationStatusRun[] = Array.isArray(data) + ? data + : data.data || []; + + const hasProcessing = runs.some( + (r: { status: string }) => + r.status === "processing" || r.status === "pending", + ); + + if (hasProcessing) { + setEvalIndicator("processing"); + dismissedRef.current = false; + return; + } + + if (dismissedRef.current) { + setEvalIndicator("none"); + return; + } + + if (runs.length > 0) { + const recent = runs[0]; + const updatedAt = new Date(recent.updated_at).getTime(); + const fiveMinAgo = Date.now() - 5 * 60 * 1000; + + if (updatedAt > fiveMinAgo) { + if ( + recent.status === "failed" || + recent.status === "completed_with_errors" + ) { + setEvalIndicator("failed"); + return; + } + if (recent.status === "completed") { + setEvalIndicator("success"); + return; + } + } + } + + setEvalIndicator("none"); + } catch (error) { + if (handleForbiddenApiError(error, handleAssessmentForbiddenWithNotify)) + return; + // silently fail + } + }, [handleAssessmentForbiddenWithNotify, selectedKey]); + + useEffect(() => { + if (!selectedKey) return; + pollEvalStatus(); + }, [pollEvalStatus, selectedKey]); + + useAssessmentEvents( + selectedKey?.key || "", + () => { + setAssessmentRefreshToken((prev) => prev + 1); + void pollEvalStatus(); + }, + activeTab === "results", + handleAssessmentForbiddenWithNotify, + ); + + const handleTabSwitch = (tab: TabId) => { + if ( + tab === "results" && + (evalIndicator === "failed" || evalIndicator === "success") + ) { + dismissedRef.current = true; + setEvalIndicator("none"); + } + setActiveTab(tab); + }; + + const hasDraftData = + !!datasetId || + columnMapping.textColumns.length > 0 || + columnMapping.attachments.length > 0 || + columnMapping.groundTruthColumns.length > 0 || + !!promptTemplate.trim() || + configs.length > 0 || + outputSchema.length > 0 || + !!experimentName.trim(); + + const resetDraftState = () => { + dismissedRef.current = false; + setEvalIndicator("none"); + clearDataset(); + setPromptTemplate(""); + setOutputSchema([]); + setConfigs([]); + setExperimentName(""); + setConfigStep(1); + setCompletedConfigSteps(new Set()); + setActiveTab("datasets"); + }; + + const markConfigCompleted = (step: number) => { + setCompletedConfigSteps((prev) => new Set([...prev, step])); + }; + + const handleConfigNext = (fromStep: number) => { + markConfigCompleted(fromStep); + setConfigStep(fromStep + 1); + }; + + const handleColumnsLoaded = useCallback( + (cols: string[], firstRow: Record = {}) => { + const currentId = useAssessmentDatasetStore.getState().datasetId; + setDataset(currentId, cols, firstRow); + setPromptTemplate(""); + }, + [setDataset], + ); + + const handleSubmit = useCallback(async () => { + if (!selectedKey) { + toast.error("No API key selected"); + return; + } + if (!experimentName.trim()) { + toast.error("Experiment name is required"); + return; + } + + setIsSubmitting(true); + try { + const payload = { + experiment_name: experimentName.trim(), + dataset_id: parseInt(datasetId, 10), + prompt_template: promptTemplate || null, + text_columns: columnMapping.textColumns, + attachments: columnMapping.attachments.map( + ({ column, type, format }) => ({ column, type, format }), + ), + output_schema: schemaToJsonSchema(outputSchema) || null, + configs: configs.map(({ config_id, config_version }) => ({ + config_id, + config_version, + })), + }; + + await apiFetch("/api/assessment/evaluations", selectedKey.key, { + method: "POST", + body: JSON.stringify(payload), + }); + + toast.success("Assessment evaluation submitted!"); + setConfigStep(1); + setCompletedConfigSteps(new Set()); + setExperimentName(""); + clearDataset(); + setPromptTemplate(""); + setOutputSchema([]); + setConfigs([]); + dismissedRef.current = false; + setActiveTab("results"); + pollEvalStatus(); + } catch (error) { + if (handleForbiddenApiError(error, handleAssessmentForbiddenWithNotify)) + return; + toast.error( + `Failed to submit: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } finally { + setIsSubmitting(false); + } + }, [ + clearDataset, + columnMapping, + configs, + datasetId, + experimentName, + handleAssessmentForbiddenWithNotify, + outputSchema, + pollEvalStatus, + promptTemplate, + selectedKey, + toast, + ]); + + const formState: AssessmentFormState = { + experimentName, + datasetId, + datasetName, + columns, + columnMapping, + promptTemplate, + outputSchema, + configs, + }; + + const hasDataset = !!datasetId && columns.length > 0; + const hasMapperSelection = columnMapping.textColumns.length > 0; + const hasConfiguredResponseFormat = outputSchema.some((field) => + field.name.trim(), + ); + const canReachReview = configs.length > 0 && hasConfiguredResponseFormat; + const effectiveCompletedConfigSteps = useMemo(() => { + const merged = new Set(completedConfigSteps); + if (hasMapperSelection) merged.add(1); + if (canReachReview) merged.add(2); + return merged; + }, [canReachReview, completedConfigSteps, hasMapperSelection]); + + const indicatorStyles: Record< + IndicatorState, + { dot: string; underline: string } + > = { + none: { + dot: "transparent", + underline: colors.text.primary, + }, + processing: { + dot: "#f59e0b", + underline: "#f59e0b", + }, + failed: { + dot: colors.status.error, + underline: colors.status.error, + }, + success: { + dot: colors.status.success, + underline: colors.status.success, + }, + }; + + const indicatorColor: Record = { + none: "transparent", + processing: indicatorStyles.processing.dot, + failed: indicatorStyles.failed.dot, + success: indicatorStyles.success.dot, + }; + + return ( +
+
+ + +
+
+ +
+

+ Assessment +

+

+ Multi-modal batch evaluation with prompt templates, attachments, + and config comparison +

+
+
+ + {apiKeys.length === 0 ? ( +
+
+ + + +

+ API key required +

+

+ Add an API key in the Keystore first +

+ + Go to Keystore + +
+
+ ) : ( + <> +
+
+ {TABS.map((tab) => { + const isActive = activeTab === tab.id; + const showIndicator = + tab.id === "results" && evalIndicator !== "none"; + const tabColor = isActive + ? colors.text.primary + : colors.text.secondary; + + return ( + + ); + })} +
+ +
+ + {activeTab === "datasets" && ( +
+ { + setActiveTab("config"); + setConfigStep(1); + }} + /> +
+ )} + +
+ {!hasDataset ? ( +
+
+ +

+ No dataset selected +

+

+ Select a dataset first from the Datasets tab +

+ +
+
+ ) : ( + <> + +
+
+ handleConfigNext(1)} + onBack={() => setActiveTab("datasets")} + /> +
+
+ handleConfigNext(2)} + onBack={() => setConfigStep(1)} + /> +
+
+ setConfigStep(2)} + onEditStep={setConfigStep} + /> +
+
+ + )} +
+ + {activeTab === "results" && ( +
+ +
+ )} + + )} +
+
+
+ ); +} + +export default function AssessmentPageClient() { + return ( + }> + + + ); +} diff --git a/app/assessment/components/ColumnMapperStep.tsx b/app/assessment/components/ColumnMapperStep.tsx new file mode 100644 index 0000000..87a8334 --- /dev/null +++ b/app/assessment/components/ColumnMapperStep.tsx @@ -0,0 +1,445 @@ +"use client"; + +import { useState } from "react"; +import { colors } from "@/app/lib/colors"; +import { Attachment, ColumnMapping, ATTACHMENT_FORMATS } from "../types"; + +interface ColumnMapperStepProps { + columns: string[]; + columnMapping: ColumnMapping; + setColumnMapping: (mapping: ColumnMapping) => void; + onNext: () => void; + onBack: () => void; +} + +type ColumnRole = "unmapped" | "text" | "attachment" | "ground_truth"; + +interface ColumnConfig { + role: ColumnRole; + attachmentType?: "image" | "pdf"; + attachmentFormat?: string; +} + +interface RoleOption { + value: ColumnRole; + label: string; + accent: string; + activeBg: string; + activeBorder: string; + activeText: string; +} + +const ROLE_OPTIONS: RoleOption[] = [ + { + value: "text", + label: "Text", + accent: "#166534", + activeBg: "rgba(22, 101, 52, 0.08)", + activeBorder: "rgba(22, 101, 52, 0.2)", + activeText: "#166534", + }, + { + value: "attachment", + label: "Attachment", + accent: "#7c2d12", + activeBg: "rgba(124, 45, 18, 0.08)", + activeBorder: "rgba(124, 45, 18, 0.24)", + activeText: "#7c2d12", + }, + { + value: "ground_truth", + label: "Ground Truth", + accent: "#1d4ed8", + activeBg: "rgba(29, 78, 216, 0.08)", + activeBorder: "rgba(29, 78, 216, 0.24)", + activeText: "#1d4ed8", + }, + { + value: "unmapped", + label: "Skip", + accent: colors.text.secondary, + activeBg: colors.bg.secondary, + activeBorder: colors.border, + activeText: colors.text.primary, + }, +]; + +export default function ColumnMapperStep({ + columns, + columnMapping, + setColumnMapping, + onNext, + onBack, +}: ColumnMapperStepProps) { + const [columnConfigs, setColumnConfigs] = useState< + Record + >(() => { + const configs: Record = {}; + + columns.forEach((column) => { + if (columnMapping.textColumns.includes(column)) { + configs[column] = { role: "text" }; + return; + } + + if (columnMapping.groundTruthColumns.includes(column)) { + configs[column] = { role: "ground_truth" }; + return; + } + + const attachment = columnMapping.attachments.find( + (item) => item.column === column, + ); + configs[column] = attachment + ? { + role: "attachment", + attachmentType: attachment.type, + attachmentFormat: attachment.format, + } + : { role: "unmapped" }; + }); + + return configs; + }); + + const updateRole = (column: string, role: ColumnRole) => { + setColumnConfigs((prev) => { + const current = prev[column]; + + if (role !== "attachment") { + return { + ...prev, + [column]: { role }, + }; + } + + return { + ...prev, + [column]: { + role, + attachmentType: current?.attachmentType || "image", + attachmentFormat: current?.attachmentFormat || "url", + }, + }; + }); + }; + + const updateAttachmentType = (column: string, type: "image" | "pdf") => { + setColumnConfigs((prev) => ({ + ...prev, + [column]: { + ...prev[column], + attachmentType: type, + attachmentFormat: "url", + }, + })); + }; + + const updateAttachmentFormat = (column: string, format: string) => { + setColumnConfigs((prev) => ({ + ...prev, + [column]: { + ...prev[column], + attachmentFormat: format, + }, + })); + }; + + const handleNext = () => { + const textColumns: string[] = []; + const attachments: Attachment[] = []; + const groundTruthColumns: string[] = []; + + Object.entries(columnConfigs).forEach(([column, config]) => { + if (config.role === "text") { + textColumns.push(column); + } else if (config.role === "ground_truth") { + groundTruthColumns.push(column); + } else if ( + config.role === "attachment" && + config.attachmentType && + config.attachmentFormat + ) { + attachments.push({ + column, + type: config.attachmentType, + format: config.attachmentFormat as Attachment["format"], + }); + } + }); + + setColumnMapping({ textColumns, attachments, groundTruthColumns }); + onNext(); + }; + + const mappedCount = Object.values(columnConfigs).filter( + (config) => config.role !== "unmapped", + ).length; + const hasText = Object.values(columnConfigs).some( + (config) => config.role === "text", + ); + + return ( +
+
+
+
+

+ Map Columns +

+

+ Choose a role for each column. +

+
+
+ {mappedCount}/{columns.length} mapped +
+
+ + {columns.length === 0 ? ( +
+

+ No columns found. +

+

+ Go back and select a dataset first. +

+
+ ) : ( +
+ {columns.map((column, index) => { + const config = columnConfigs[column] || { + role: "unmapped" as ColumnRole, + }; + const activeOption = + ROLE_OPTIONS.find((option) => option.value === config.role) || + ROLE_OPTIONS[3]; + + return ( +
+
+
+
+
+ + + {column} + +
+
+ +
+ {ROLE_OPTIONS.map((option) => { + const isActive = config.role === option.value; + return ( + + ); + })} +
+
+ + {config.role === "attachment" && ( +
+ + + +
+ )} +
+
+ ); + })} +
+ )} +
+ +
+ + +
+ + {hasText + ? "Ready to continue." + : "Select at least one Text column."} + + +
+
+
+ ); +} diff --git a/app/assessment/components/DataViewModal.tsx b/app/assessment/components/DataViewModal.tsx new file mode 100644 index 0000000..5ccce9d --- /dev/null +++ b/app/assessment/components/DataViewModal.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { colors } from "@/app/lib/colors"; +import CloseIcon from "@/app/components/icons/document/CloseIcon"; + +export interface DataViewModalProps { + title: string; + subtitle?: string; + headers: string[]; + rows: string[][]; + onClose: () => void; +} + +/** + * Reusable modal for viewing tabular data (dataset preview, result preview). + */ +export default function DataViewModal({ + title, + subtitle, + headers, + rows, + onClose, +}: DataViewModalProps) { + return ( +
+
e.stopPropagation()} + > +
+
+

+ {title} +

+

+ {subtitle ?? `${rows.length} rows · ${headers.length} columns`} +

+
+ +
+
+ + + + + {headers.map((header, i) => ( + + ))} + + + + {rows.map((row, rowIdx) => ( + + + {row.map((cell, cellIdx) => ( + + ))} + + ))} + +
+ {header} +
+ {rowIdx + 1} + +
+ {cell || ( + + — + + )} +
+
+
+
+
+ ); +} + +/** + * Convert JSON result rows (array of objects) into headers + rows for DataViewModal. + * Filters out metadata-heavy fields and drops columns where every value is empty. + */ +export function jsonResultsToTableData( + results: Record[], + opts?: { skipFields?: Set }, +): { headers: string[]; rows: string[][] } { + if (results.length === 0) return { headers: [], rows: [] }; + + const skipFields = + opts?.skipFields ?? + new Set([ + "assessment_id", + "dataset_id", + "dataset_name", + "run_id", + "run_name", + "run_status", + "config_id", + "config_version", + "response_id", + "input_tokens", + "output_tokens", + "total_tokens", + "updated_at", + "result_status", + "error", + "row_id", + "experiment_name", + ]); + + const allKeys = Array.from(new Set(results.flatMap((r) => Object.keys(r)))); + const displayKeys = allKeys.filter((k) => !skipFields.has(k)); + + // Drop columns where every row is null / empty + const nonEmptyKeys = displayKeys.filter((key) => + results.some((r) => { + const v = r[key]; + return v != null && String(v).trim() !== ""; + }), + ); + + const rows = results.map((r) => + nonEmptyKeys.map((key) => { + const v = r[key]; + if (v == null) return ""; + if (typeof v === "object") return JSON.stringify(v); + return String(v); + }), + ); + + return { headers: nonEmptyKeys, rows }; +} diff --git a/app/assessment/components/DatasetStep.tsx b/app/assessment/components/DatasetStep.tsx new file mode 100644 index 0000000..ee90b18 --- /dev/null +++ b/app/assessment/components/DatasetStep.tsx @@ -0,0 +1,844 @@ +"use client"; + +import { useState, useCallback, useEffect, useRef } from "react"; +import * as XLSX from "xlsx"; +import { apiFetch } from "@/app/lib/apiClient"; +import { colors } from "@/app/lib/colors"; +import { Dataset } from "@/app/lib/types/datasets"; +import { useToast } from "@/app/components/Toast"; +import { + CloseIcon, + CloudUploadIcon, + DatabaseIcon, + WarningIcon, +} from "@/app/components/icons"; +import EvalDatasetDescription from "@/app/components/evaluations/EvalDatasetDescription"; +import DataViewModal from "./DataViewModal"; +import { handleForbiddenApiError } from "../errorUtils"; + +interface DatasetStepProps { + apiKey: string; + onForbidden?: () => void; + datasetId: string; + setDatasetId: (id: string) => void; + setSelectedDatasetName: (name: string) => void; + onColumnsLoaded: ( + columns: string[], + sampleRow?: Record, + ) => void; + onNext: () => void; +} + +type DatasetResponse = Dataset[] | { data?: Dataset[] }; +type CreateDatasetResponse = + | { dataset_id?: number; dataset_name?: string } + | { data?: { dataset_id?: number; dataset_name?: string } }; +type DatasetFileResponse = { file_content?: string }; + +const LEFT_PANEL_CLASSES = "w-[40%] min-w-[360px] max-w-[500px]"; + +export default function DatasetStep({ + apiKey, + onForbidden, + datasetId, + setDatasetId, + setSelectedDatasetName, + onColumnsLoaded, + onNext, +}: DatasetStepProps) { + const toast = useToast(); + const fileInputRef = useRef(null); + + // Dataset list + const [datasets, setDatasets] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingColumns, setIsLoadingColumns] = useState(false); + + // Create dataset form + const [datasetName, setDatasetName] = useState(""); + const [datasetDescription, setDatasetDescription] = useState(""); + const [uploadedFile, setUploadedFile] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [isDragging, setIsDragging] = useState(false); + + // View dataset modal + const [viewingId, setViewingId] = useState(null); + const [viewModalData, setViewModalData] = useState<{ + name: string; + headers: string[]; + rows: string[][]; + } | null>(null); + + // Delete confirmation + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [deletingId, setDeletingId] = useState(null); + + // Fetch file via proxy (server-side S3 download) and parse with XLSX + const fetchAndParseFile = async ( + id: string | number, + ): Promise<{ headers: string[]; rows: string[][] } | null> => { + const json = await apiFetch( + `/api/assessment/datasets/${id}?fetch_content=true`, + apiKey, + ); + const base64 = json?.file_content; + if (!base64) return null; + + // Decode base64 to binary and parse with XLSX + const binary = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + const workbook = XLSX.read(binary, { type: "array" }); + const sheet = workbook.Sheets[workbook.SheetNames[0]]; + if (!sheet) return null; + + const rawData: string[][] = XLSX.utils.sheet_to_json(sheet, { + header: 1, + defval: "", + }); + if (rawData.length === 0) return null; + + const headers = rawData[0].map(String); + const rows = rawData + .slice(1) + .filter((row) => row.some((cell) => String(cell).trim() !== "")); + + return { headers, rows: rows.map((row) => row.map(String)) }; + }; + + // Fetch datasets + const loadDatasets = useCallback(async () => { + if (!apiKey) return; + setIsLoading(true); + try { + const data = await apiFetch( + "/api/assessment/datasets", + apiKey, + ); + setDatasets(Array.isArray(data) ? data : data.data || []); + } catch (e) { + if (handleForbiddenApiError(e, onForbidden)) return; + console.error("Failed to load datasets:", e); + } finally { + setIsLoading(false); + } + }, [apiKey, onForbidden]); + + useEffect(() => { + loadDatasets(); + }, [loadDatasets]); + + // Hydrate selected dataset name from the loaded dataset list. + useEffect(() => { + if (!datasetId || datasets.length === 0) return; + const selected = datasets.find( + (dataset) => dataset.dataset_id.toString() === datasetId, + ); + if (selected?.dataset_name) { + setSelectedDatasetName(selected.dataset_name); + } + }, [datasetId, datasets, setSelectedDatasetName]); + + // File selection handler + const handleFileSelect = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + const allowedExts = [".csv", ".xlsx", ".xls"]; + const hasValidExt = allowedExts.some((ext) => + file.name.toLowerCase().endsWith(ext), + ); + if (!hasValidExt) { + toast.error("Please select a CSV or Excel (.xlsx, .xls) file"); + event.target.value = ""; + return; + } + setUploadedFile(file); + if (!datasetName) { + setDatasetName(file.name.replace(/\.(csv|xlsx|xls)$/i, "")); + } + }; + + // Create dataset + const handleCreateDataset = async () => { + if (!uploadedFile || !datasetName.trim() || !apiKey) return; + + setIsUploading(true); + try { + const formData = new FormData(); + formData.append("file", uploadedFile); + formData.append("dataset_name", datasetName.trim()); + if (datasetDescription.trim()) { + formData.append("description", datasetDescription.trim()); + } + + const data = await apiFetch( + "/api/assessment/datasets", + apiKey, + { + method: "POST", + body: formData, + }, + ); + await loadDatasets(); + + // Auto-select the created dataset + const created = + (data as { data?: { dataset_id?: number; dataset_name?: string } }) + .data ?? (data as { dataset_id?: number; dataset_name?: string }); + if (created?.dataset_id) { + void handleDatasetSelect( + created.dataset_id.toString(), + created.dataset_name ?? datasetName.trim(), + ); + } + + // Reset form + setUploadedFile(null); + setDatasetName(""); + setDatasetDescription(""); + if (fileInputRef.current) fileInputRef.current.value = ""; + + toast.success("Dataset created successfully!"); + } catch (error) { + if (handleForbiddenApiError(error, onForbidden)) return; + toast.error( + `Failed to create dataset: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } finally { + setIsUploading(false); + } + }; + + // Select dataset and fetch columns + const handleDatasetSelect = async (id: string, name?: string) => { + setDatasetId(id); + if (!id) { + setSelectedDatasetName(""); + return; + } + const resolvedName = + name ?? + datasets.find((dataset) => dataset.dataset_id.toString() === id) + ?.dataset_name ?? + ""; + setSelectedDatasetName(resolvedName); + + setIsLoadingColumns(true); + try { + const parsed = await fetchAndParseFile(id); + if (parsed?.headers) { + const firstRow = parsed.rows[0] || []; + const sampleRow = Object.fromEntries( + parsed.headers.map((header, index) => [ + header, + String(firstRow[index] ?? ""), + ]), + ); + onColumnsLoaded(parsed.headers, sampleRow); + } + } catch (e) { + if (handleForbiddenApiError(e, onForbidden)) return; + console.error("Failed to fetch dataset columns:", e); + } finally { + setIsLoadingColumns(false); + } + }; + + // View dataset + const handleViewDataset = async (datasetId: number, name: string) => { + setViewingId(datasetId); + try { + const parsed = await fetchAndParseFile(datasetId); + if (!parsed || !parsed.headers) { + toast.error("No data available"); + return; + } + + setViewModalData({ + name, + headers: parsed.headers, + rows: parsed.rows, + }); + } catch (err) { + if (handleForbiddenApiError(err, onForbidden)) return; + toast.error( + err instanceof Error ? err.message : "Failed to view dataset", + ); + } finally { + setViewingId(null); + } + }; + + // Delete dataset + const handleDeleteDataset = async (id: number) => { + setDeletingId(id); + try { + await apiFetch(`/api/assessment/datasets/${id}`, apiKey, { + method: "DELETE", + }); + toast.success("Dataset deleted"); + if (datasetId === id.toString()) { + setDatasetId(""); + setSelectedDatasetName(""); + } + void loadDatasets(); + } catch (err) { + if (handleForbiddenApiError(err, onForbidden)) return; + toast.error( + err instanceof Error ? err.message : "Failed to delete dataset", + ); + } finally { + setDeletingId(null); + } + }; + + // Drag and drop + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files?.[0]; + const allowedExts = [".csv", ".xlsx", ".xls"]; + if ( + file && + allowedExts.some((ext) => file.name.toLowerCase().endsWith(ext)) + ) { + const dt = new DataTransfer(); + dt.items.add(file); + if (fileInputRef.current) { + fileInputRef.current.files = dt.files; + fileInputRef.current.dispatchEvent( + new Event("change", { bubbles: true }), + ); + } + } + }; + + const resetForm = () => { + setDatasetName(""); + setDatasetDescription(""); + setUploadedFile(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const canProceed = datasetId && !isLoadingColumns; + + return ( +
+ {/* Left Panel - Create Dataset + Experiment Name */} +
+
+ {/* Page Title */} +
+

+ Create New Dataset +

+

+ Upload a CSV file for evaluation +

+
+ + {/* Name */} +
+ + setDatasetName(e.target.value)} + placeholder="e.g., Hindi QnA Dataset" + className="w-full px-3 py-2 border rounded-md text-sm" + style={{ + backgroundColor: colors.bg.primary, + borderColor: colors.border, + color: colors.text.primary, + }} + /> +
+ + {/* Description */} +
+ + setDatasetDescription(e.target.value)} + placeholder="Optional description" + className="w-full px-3 py-2 border rounded-md text-sm" + style={{ + backgroundColor: colors.bg.primary, + borderColor: colors.border, + color: colors.text.primary, + }} + /> +
+ + {/* CSV Upload */} +
+ + + + + {uploadedFile ? ( +
+
+
+ + + +
+

+ {uploadedFile.name} +

+

+ {(uploadedFile.size / 1024).toFixed(1)} KB +

+
+
+ +
+
+ ) : ( +
fileInputRef.current?.click()} + onDragOver={(e) => { + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + > + + + +

+ Drop file here, or click to browse +

+

+ CSV or Excel (.xlsx, .xls) +

+
+ )} +
+
+ + {/* Bottom Action Bar */} +
+ + +
+
+ + {/* Right Panel - Dataset List + Selection */} +
+
+
+

+ Datasets +

+ {isLoadingColumns && ( + + Loading columns... + + )} +
+ + {isLoading ? ( +
+
+

+ Loading datasets... +

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

+ No datasets yet +

+

+ Create your first dataset using the form on the left +

+
+ ) : ( +
+ {datasets.map((dataset) => { + const isSelected = datasetId === dataset.dataset_id.toString(); + return ( +
+ handleDatasetSelect( + dataset.dataset_id.toString(), + dataset.dataset_name, + ) + } + > +
+
+
+
+ {isSelected && ( + + + + )} +
+ {dataset.dataset_name} +
+
+ {dataset.description && ( + + )} +
+ {dataset.total_items} items + {dataset.original_items > 0 && + dataset.original_items !== + dataset.total_items && ( + <> + + · + + {dataset.original_items} original + + )} +
+
+
+ + +
+
+
+
+ ); + })} +
+ )} +
+ + {/* Next Button */} +
+ +
+
+ + {/* View Dataset Modal */} + {viewModalData && ( + setViewModalData(null)} + /> + )} + + {/* Delete Confirmation Modal */} + {confirmDeleteId !== null && + (() => { + const dataset = datasets.find( + (d) => d.dataset_id === confirmDeleteId, + ); + return ( +
setConfirmDeleteId(null)} + > +
e.stopPropagation()} + > +
+
+
+ + + +
+
+

+ Delete dataset +

+

+ Are you sure you want to delete{" "} + + {dataset?.dataset_name} + + ? This action cannot be undone. +

+
+
+
+
+ + +
+
+
+ ); + })()} +
+ ); +} diff --git a/app/assessment/components/EvaluationsTab.tsx b/app/assessment/components/EvaluationsTab.tsx new file mode 100644 index 0000000..e37bebd --- /dev/null +++ b/app/assessment/components/EvaluationsTab.tsx @@ -0,0 +1,1105 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { apiFetch } from "@/app/lib/apiClient"; +import { colors } from "@/app/lib/colors"; +import { useToast } from "@/app/components/Toast"; +import { + RefreshIcon, + DatabaseIcon, + ClipboardIcon, + ChevronDownIcon, + EyeIcon, +} from "@/app/components/icons"; +import DataViewModal, { jsonResultsToTableData } from "./DataViewModal"; +import { ConfigResponse, ConfigVersionResponse } from "@/app/lib/configTypes"; +import { handleForbiddenApiError } from "../errorUtils"; + +interface EvaluationsTabProps { + apiKey: string; + refreshToken: number; + onForbidden?: () => void; +} + +interface AssessmentRun { + id: number; + experiment_name: string; + dataset_name: string | null; + dataset_id: number | null; + status: string; + total_runs: number; + pending_runs: number; + processing_runs: number; + completed_runs: number; + failed_runs: number; + run_stats: { + run_id: number; + config_id: string | null; + config_version: number | null; + status: string; + total_items: number; + error_message: string | null; + updated_at: string | null; + }[]; + error_message: string | null; + inserted_at: string; + updated_at: string; +} + +interface EvaluationRun { + id: number; + assessment_id: number | null; + run_name: string; + dataset_name: string | null; + dataset_id: number | null; + config_id: string | null; + config_version: number | null; + status: string; + total_items: number; + error_message: string | null; + organization_id: number; + project_id: number; + assessment_config: Record | null; + inserted_at: string; + updated_at: string; +} + +interface ConfigRunDetail { + configId: string; + version: number; + name: string; + description: string | null; + commitMessage: string | null; + provider: string | null; + model: string | null; +} + +type StatusFilter = "all" | "processing" | "completed" | "failed"; +type ExportFormat = "csv" | "xlsx"; +type AssessmentListResponse = AssessmentRun[] | { data?: AssessmentRun[] }; +type EvaluationListResponse = EvaluationRun[] | { data?: EvaluationRun[] }; + +const STATUS_COLORS: Record = { + pending: { bg: "rgba(202, 138, 4, 0.1)", text: "#92400e" }, + processing: { bg: "rgba(202, 138, 4, 0.1)", text: "#92400e" }, + in_progress: { bg: "rgba(202, 138, 4, 0.1)", text: "#92400e" }, + completed: { bg: "rgba(22, 163, 74, 0.1)", text: "#166534" }, + completed_with_errors: { bg: "rgba(245, 158, 11, 0.12)", text: "#9a3412" }, + failed: { bg: "rgba(220, 38, 38, 0.1)", text: "#991b1b" }, + cancelled: { bg: "rgba(107, 114, 128, 0.1)", text: "#374151" }, +}; + +function formatRelativeTime(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return "just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 30) return `${diffDays} day${diffDays !== 1 ? "s" : ""} ago`; + const diffMonths = Math.floor(diffDays / 30); + return `about ${diffMonths} month${diffMonths !== 1 ? "s" : ""} ago`; +} + +function DownloadDropdown({ + onDownload, + disabled, + loading, +}: { + onDownload: (format: ExportFormat) => void; + disabled?: boolean; + loading?: boolean; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) + setOpen(false); + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + return ( +
+ + {open && ( +
+ {( + [ + ["csv", "CSV File"], + ["xlsx", "Excel Sheet"], + ] as const + ).map(([fmt, label]) => ( + + ))} +
+ )} +
+ ); +} + +export default function EvaluationsTab({ + apiKey, + refreshToken, + onForbidden, +}: EvaluationsTabProps) { + const toast = useToast(); + const [assessments, setAssessments] = useState([]); + const [childRunsByAssessment, setChildRunsByAssessment] = useState< + Record + >({}); + const [configDetailsByKey, setConfigDetailsByKey] = useState< + Record + >({}); + const [configLoadingKeys, setConfigLoadingKeys] = useState< + Record + >({}); + const [configErrorKeys, setConfigErrorKeys] = useState< + Record + >({}); + const [isLoading, setIsLoading] = useState(false); + const [statusFilter, setStatusFilter] = useState("all"); + const [rerunningId, setRerunningId] = useState(null); + const [retryingAssessmentId, setRetryingAssessmentId] = useState< + number | null + >(null); + const [expandedId, setExpandedId] = useState(null); + const [downloadingId, setDownloadingId] = useState(null); + const [previewLoading, setPreviewLoading] = useState(null); + const [previewModal, setPreviewModal] = useState<{ + title: string; + headers: string[]; + rows: string[][]; + } | null>(null); + + const buildAuthHeaders = useCallback(() => { + const headers = new Headers(); + if (apiKey) headers.set("X-API-KEY", apiKey); + return headers; + }, [apiKey]); + + const loadAssessments = useCallback(async () => { + if (!apiKey) return; + setIsLoading(true); + try { + const data = await apiFetch( + "/api/assessment/assessments", + apiKey, + ); + const list = Array.isArray(data) ? data : data.data || []; + setAssessments(list); + } catch (e) { + if (handleForbiddenApiError(e, onForbidden)) return; + console.error("Failed to load assessments:", e); + } finally { + setIsLoading(false); + } + }, [apiKey, onForbidden]); + + const loadChildRuns = useCallback( + async (assessmentId: number) => { + if (!apiKey) return; + try { + const data = await apiFetch( + `/api/assessment/evaluations?assessment_id=${assessmentId}`, + apiKey, + ); + const list = Array.isArray(data) ? data : data.data || []; + setChildRunsByAssessment((prev) => ({ ...prev, [assessmentId]: list })); + } catch (e) { + if (handleForbiddenApiError(e, onForbidden)) return; + console.error("Failed to load child runs:", e); + } + }, + [apiKey, onForbidden], + ); + + const loadConfigDetail = useCallback( + async (configId: string, version: number) => { + if (!apiKey) return; + + const key = `${configId}:${version}`; + if (configDetailsByKey[key] || configLoadingKeys[key]) return; + + setConfigLoadingKeys((prev) => ({ ...prev, [key]: true })); + setConfigErrorKeys((prev) => { + const next = { ...prev }; + delete next[key]; + return next; + }); + + try { + const [configResponse, versionResponse] = await Promise.all([ + apiFetch(`/api/configs/${configId}`, apiKey), + apiFetch( + `/api/configs/${configId}/versions/${version}`, + apiKey, + ), + ]); + const configJson = configResponse; + const versionJson = versionResponse; + + if ( + !configJson.success || + !configJson.data || + !versionJson.success || + !versionJson.data + ) { + throw new Error( + configJson.error || + versionJson.error || + "Configuration details unavailable", + ); + } + + const detail: ConfigRunDetail = { + configId, + version, + name: configJson.data.name, + description: configJson.data.description, + commitMessage: versionJson.data.commit_message, + provider: versionJson.data.config_blob?.completion?.provider || null, + model: + versionJson.data.config_blob?.completion?.params?.model || null, + }; + + setConfigDetailsByKey((prev) => ({ ...prev, [key]: detail })); + } catch (error) { + setConfigErrorKeys((prev) => ({ + ...prev, + [key]: + error instanceof Error + ? error.message + : "Failed to load configuration details", + })); + } finally { + setConfigLoadingKeys((prev) => { + const next = { ...prev }; + delete next[key]; + return next; + }); + } + }, + [apiKey, configDetailsByKey, configLoadingKeys], + ); + + useEffect(() => { + loadAssessments(); + }, [loadAssessments]); + + useEffect(() => { + if (!apiKey || refreshToken === 0) return; + void loadAssessments(); + if (expandedId !== null) { + void loadChildRuns(expandedId); + } + }, [apiKey, expandedId, loadAssessments, loadChildRuns, refreshToken]); + + useEffect(() => { + if (expandedId === null) return; + const runs = childRunsByAssessment[expandedId] || []; + runs.forEach((run) => { + if (run.config_id && run.config_version) { + void loadConfigDetail(run.config_id, run.config_version); + } + }); + }, [childRunsByAssessment, expandedId, loadConfigDetail]); + + const counts = { + total: assessments.length, + processing: assessments.filter( + (r) => r.status === "processing" || r.status === "pending", + ).length, + completed: assessments.filter((r) => r.status === "completed").length, + failed: assessments.filter( + (r) => r.status === "failed" || r.status === "completed_with_errors", + ).length, + }; + + const filteredRuns = + statusFilter === "all" + ? assessments + : assessments.filter((r) => { + if (statusFilter === "processing") + return r.status === "processing" || r.status === "pending"; + if (statusFilter === "failed") + return ( + r.status === "failed" || r.status === "completed_with_errors" + ); + return r.status === statusFilter; + }); + + const triggerDownload = useCallback( + async (url: string, format: ExportFormat, key: string) => { + if (!apiKey) return; + setDownloadingId(key); + try { + const response = await fetch(`${url}?export_format=${format}`, { + headers: buildAuthHeaders(), + credentials: "include", + }); + if (response.status === 403) { + onForbidden?.(); + return; + } + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error( + err.error || + err.message || + err.detail || + `Export failed (${response.status})`, + ); + } + const blob = await response.blob(); + const disposition = response.headers.get("content-disposition") || ""; + const filenameMatch = disposition.match(/filename="?([^"]+)"?/); + const filename = filenameMatch?.[1] || `export.${format}`; + + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(a.href); + toast.success("Download started"); + } catch (error) { + toast.error( + `Export failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } finally { + setDownloadingId(null); + } + }, + [apiKey, buildAuthHeaders, onForbidden, toast], + ); + + const handleRerun = useCallback( + async (run: EvaluationRun) => { + if (!apiKey) { + toast.error("Cannot retry without an API key"); + return; + } + + setRerunningId(run.id); + try { + await apiFetch(`/api/assessment/evaluations/${run.id}/retry`, apiKey, { + method: "POST", + }); + + toast.success("Evaluation re-submitted successfully!"); + loadAssessments(); + if (run.assessment_id) { + loadChildRuns(run.assessment_id); + } + } catch (error) { + if (handleForbiddenApiError(error, onForbidden)) return; + toast.error( + `Re-run failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } finally { + setRerunningId(null); + } + }, + [apiKey, loadAssessments, loadChildRuns, onForbidden, toast], + ); + + const handleRetryAssessment = useCallback( + async (assessmentId: number) => { + if (!apiKey) { + toast.error("Cannot retry without an API key"); + return; + } + + setRetryingAssessmentId(assessmentId); + try { + await apiFetch( + `/api/assessment/assessments/${assessmentId}/retry`, + apiKey, + { method: "POST" }, + ); + + toast.success("Assessment re-submitted successfully!"); + void loadAssessments(); + if (expandedId !== null) { + void loadChildRuns(expandedId); + } + } catch (error) { + if (handleForbiddenApiError(error, onForbidden)) return; + toast.error( + `Retry failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } finally { + setRetryingAssessmentId(null); + } + }, + [apiKey, expandedId, loadAssessments, loadChildRuns, onForbidden, toast], + ); + + const handleExpand = useCallback( + (assessmentId: number) => { + const next = expandedId === assessmentId ? null : assessmentId; + setExpandedId(next); + if (next !== null && !childRunsByAssessment[next]) { + loadChildRuns(next); + } + }, + [childRunsByAssessment, expandedId, loadChildRuns], + ); + + const handlePreview = useCallback( + async (runId: number, label: string) => { + if (!apiKey) return; + setPreviewLoading(runId); + try { + const json = await apiFetch< + { data?: Record[] } | Record[] + >( + `/api/assessment/evaluations/${runId}/results?export_format=json`, + apiKey, + ); + const results: Record[] = Array.isArray(json) + ? json + : json.data || []; + const { headers, rows } = jsonResultsToTableData(results); + setPreviewModal({ title: label, headers, rows }); + } catch (error) { + if (handleForbiddenApiError(error, onForbidden)) return; + toast.error( + `Preview failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } finally { + setPreviewLoading(null); + } + }, + [apiKey, onForbidden, toast], + ); + + const formatStatusLabel = (status: string) => status.replace(/_/g, " "); + + return ( +
+
+
+

+ Assessments +

+
+ {[ + { + label: "Total", + value: counts.total, + color: colors.text.primary, + }, + { + label: "Processing", + value: counts.processing, + color: "#92400e", + }, + { label: "Completed", value: counts.completed, color: "#166534" }, + { label: "Failed", value: counts.failed, color: "#991b1b" }, + ].map((item) => ( + + {item.value} + + {item.label.toLowerCase()} + + + ))} +
+
+ +
+ + + +
+
+ +
+ {isLoading && assessments.length === 0 ? ( +
+
+

+ Loading assessments... +

+
+ ) : filteredRuns.length === 0 ? ( +
+ + + +

+ {statusFilter === "all" + ? "No assessments yet" + : `No ${statusFilter} assessments`} +

+

+ {statusFilter === "all" + ? "Submit an assessment from the Config tab to get started" + : "Try changing the status filter"} +

+
+ ) : ( +
+ {filteredRuns.map((run) => { + const statusStyle = + STATUS_COLORS[run.status] || STATUS_COLORS.processing; + const isExpanded = expandedId === run.id; + const childRuns = childRunsByAssessment[run.id] || []; + const canRetryAssessment = + run.status === "failed" || + run.status === "completed_with_errors"; + const isRetryingAssessment = retryingAssessmentId === run.id; + const hasCompletedRuns = run.completed_runs > 0; + + return ( +
+
+
+
+
+ + {run.experiment_name} + + + {run.total_runs} configs + +
+ +
+ {formatRelativeTime(run.inserted_at)} +
+ +
+ {run.dataset_name && ( + + + {run.dataset_name} + + )} + + + {run.completed_runs} completed + + + {run.processing_runs + run.pending_runs} active + + {run.failed_runs > 0 && ( + + {run.failed_runs} failed + + )} +
+ + {(run.status === "failed" || + run.status === "completed_with_errors") && + run.error_message && ( +
+ {run.error_message} +
+ )} +
+ +
+ + {run.status.replace("_", " ")} + + +
+ {hasCompletedRuns && ( + + triggerDownload( + `/api/assessment/assessments/${run.id}/results`, + fmt, + `assessment-${run.id}`, + ) + } + disabled={!hasCompletedRuns} + loading={downloadingId === `assessment-${run.id}`} + /> + )} + {canRetryAssessment && ( + + )} + +
+
+
+ + {isExpanded && ( +
+
+
+
+ Configurations in this assessment +
+
+ Each configuration keeps its own status, preview, + and export actions. +
+
+
+ {childRuns.length} run + {childRuns.length !== 1 ? "s" : ""} +
+
+ + {childRuns.length === 0 ? ( +
+ Loading child evaluation runs... +
+ ) : ( + childRuns.map((childRun) => { + const childStatusStyle = + STATUS_COLORS[childRun.status] || + STATUS_COLORS.processing; + const isFailedChild = childRun.status === "failed"; + const isCompletedChild = + childRun.status === "completed"; + const isRerunning = rerunningId === childRun.id; + const configKey = + childRun.config_id && childRun.config_version + ? `${childRun.config_id}:${childRun.config_version}` + : null; + const configDetail = configKey + ? configDetailsByKey[configKey] + : null; + const isConfigLoading = configKey + ? Boolean(configLoadingKeys[configKey]) + : false; + const configError = configKey + ? configErrorKeys[configKey] + : null; + const fallbackName = childRun.config_id + ? `Config ${childRun.config_id.slice(0, 8)}` + : "Configuration"; + const configName = + configDetail?.name || fallbackName; + const previewLabel = `${configName}${childRun.config_version ? ` v${childRun.config_version}` : ""}`; + + return ( +
+
+
+
+ + {configName} + + {childRun.config_version !== null && ( + + v{childRun.config_version} + + )} + {configDetail?.provider && + configDetail?.model && ( + + {configDetail.provider}/ + {configDetail.model} + + )} +
+ +
+ {isConfigLoading + ? "Loading configuration details..." + : configDetail?.description || + configDetail?.commitMessage || + "No description available for this configuration."} +
+ +
+ {childRun.total_items} items + {childRun.updated_at && ( + + {formatRelativeTime( + childRun.updated_at, + )} + + )} + {childRun.config_id && ( + + ID {childRun.config_id.slice(0, 8)} + + )} +
+ + {configError && ( +
+ {configError} +
+ )} + {isFailedChild && + childRun.error_message && ( +
+ {childRun.error_message} +
+ )} +
+ +
+ + {formatStatusLabel(childRun.status)} + + {isCompletedChild && ( + + )} + {isCompletedChild && ( + + triggerDownload( + `/api/assessment/evaluations/${childRun.id}/results`, + fmt, + `run-${childRun.id}`, + ) + } + loading={ + downloadingId === `run-${childRun.id}` + } + /> + )} + {isFailedChild && ( + + )} +
+
+
+ ); + }) + )} +
+ )} +
+
+ ); + })} +
+ )} +
+ + {previewModal && ( + setPreviewModal(null)} + /> + )} +
+ ); +} diff --git a/app/assessment/components/JsonEditor.tsx b/app/assessment/components/JsonEditor.tsx new file mode 100644 index 0000000..f7b413f --- /dev/null +++ b/app/assessment/components/JsonEditor.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { useRef, useCallback, useId } from "react"; +import { colors } from "@/app/lib/colors"; + +interface JsonEditorProps { + value: string; + onChange: (value: string) => void; + error?: string | null; + isValid?: boolean; + placeholder?: string; + minHeight?: number; +} + +/* JSON token colors — light background */ +const C = { + key: "#0550ae", // blue — property keys + string: "#116329", // green — string values + number: "#953800", // orange — numbers + boolean: "#8250df", // purple — true/false + null: "#8250df", // purple — null + punct: "#6e7781", // gray — brackets, commas, colons +}; + +function highlight(code: string): string { + if (!code) return ""; + + const escHtml = (s: string) => + s.replace(/&/g, "&").replace(//g, ">"); + + const re = + /("(?:\\.|[^"\\])*")(\s*:)?|(\btrue\b|\bfalse\b|\bnull\b)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g; + let result = ""; + let cursor = 0; + let m: RegExpExecArray | null; + + while ((m = re.exec(code)) !== null) { + // punctuation / whitespace between tokens + if (cursor < m.index) { + result += `${escHtml(code.slice(cursor, m.index))}`; + } + + if (m[1] !== undefined) { + const isKey = !!m[2]; + result += `${escHtml(m[1])}`; + if (m[2]) + result += `${escHtml(m[2])}`; + cursor = m.index + m[0].length; + } else if (m[3] !== undefined) { + result += `${escHtml(m[3])}`; + cursor = m.index + m[3].length; + } else if (m[4] !== undefined) { + result += `${escHtml(m[4])}`; + cursor = m.index + m[4].length; + } + } + + if (cursor < code.length) { + result += `${escHtml(code.slice(cursor))}`; + } + + return result; +} + +const FONT: React.CSSProperties = { + fontFamily: + "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + fontSize: "13px", + lineHeight: "1.7", + tabSize: 2, +}; + +export default function JsonEditor({ + value, + onChange, + error, + isValid, + placeholder, + minHeight = 400, +}: JsonEditorProps) { + const textareaRef = useRef(null); + const preRef = useRef(null); + const textareaId = useId(); + const errorId = `${textareaId}-error`; + + const syncScroll = useCallback(() => { + if (textareaRef.current && preRef.current) { + preRef.current.scrollTop = textareaRef.current.scrollTop; + preRef.current.scrollLeft = textareaRef.current.scrollLeft; + } + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Tab") { + e.preventDefault(); + const el = e.currentTarget; + const s = el.selectionStart; + const newVal = + value.substring(0, s) + " " + value.substring(el.selectionEnd); + onChange(newVal); + requestAnimationFrame(() => { + el.selectionStart = el.selectionEnd = s + 2; + }); + return; + } + const pairs: Record = { "{": "}", "[": "]" }; + if (pairs[e.key]) { + const el = e.currentTarget; + const s = el.selectionStart; + if (s === el.selectionEnd) { + e.preventDefault(); + const newVal = + value.substring(0, s) + e.key + pairs[e.key] + value.substring(s); + onChange(newVal); + requestAnimationFrame(() => { + el.selectionStart = el.selectionEnd = s + 1; + }); + } + } + }; + + const borderColor = error + ? "rgba(239,68,68,0.4)" + : isValid && value.trim() + ? "rgba(34,197,94,0.35)" + : colors.border; + + return ( +
+ {/* Minimal top bar */} +
+
+ + JSON + + {value.trim() && ( + + {error ? "Invalid" : isValid ? "Valid" : ""} + + )} +
+
+ {error && ( + + {error} + + )} + {value.trim() && ( + + )} +
+
+ + {/* Editor */} +
+ {/* Placeholder */} + {!value && placeholder && ( +
+            {placeholder}
+          
+ )} + + {/* Highlighted layer */} +
+
+        {/* Editable layer */}
+