diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b8e32cf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,144 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +npm run dev # Start dev server at http://localhost:3000 +npm run lint # Run ESLint +npm run build # Production build +npm run format # Format TS/tsx with Prettier +``` + +## Architecture Overview + +### Tech Stack + +- **Framework**: Next.js 16.0.7 (App Router) +- **React 19.2.0** + **TypeScript** (strict mode) +- **Routing**: Next.js App Router (react-router-dom is listed as a dependency but currently unused) +- **Styling**: Tailwind CSS 4.x + custom color system and styles defined in `/app/globals.css` for cases not supported by Tailwind +- **Data Fetching**: Native Fetch API + SWR 2.3.6 (used selectively where required) +- **Date/Time**: date-fns 4.1.0, date-fns-tz 3.2.0 +- **ESLint**: Used for maintaining code quality, enforcing consistent coding standards, and catching potential issues during development and build time + +### Directory Structure + +| Path | Purpose | +| ------------------------------ | --------------------------------------------------------------------------------------------- | +| `app/(auth)/` | Authentication-related routes (e.g., invite, verify flows) | +| `app/(main)/` | Main application routes (dashboard-level features like datasets, evaluations, settings, etc.) | +| `app/api/` | Backend API route handlers (Next.js route handlers acting as BFF layer) | +| `app/components/` | App-scoped components used within routes/Pages | +| `app/components/icons/` | Hand-authored React icon components | +| `app/hooks/` | Custom React hooks specific to app features | +| `app/lib/` | Core shared logic and utilities across the application | +| `app/lib/context/` | React context providers (global state handling) | +| `app/lib/store/` | State management logic (custom/global store) | +| `app/lib/types/` | TypeScript type definitions (shared across modules) | +| `app/lib/utils/` | Domain-specific utility modules (e.g., evaluation, guardrails) | +| `app/lib/data/` | Static data and validators (e.g., guardrails validators) | +| `app/lib/apiClient.ts` | Centralized API client for forwarding requests to the backend | +| `app/lib/authCookie.ts` | Authentication cookie utilities (get/set/remove tokens) | +| `app/lib/configFetchers.ts` | API fetchers related to configuration modules | +| `app/lib/constants.ts` | Global constants used across the app | +| `app/lib/guardrailsClient.ts` | Client-side API helpers for guardrails features | +| `app/lib/models.ts` | Data models/interfaces for structured data handling | +| `app/lib/navConfig.ts` | Navigation configuration (sidebar/menu structure) | +| `app/lib/promptEditorUtils.ts` | Utility functions for prompt editor logic | +| `app/lib/utils.ts` | General utility/helper functions | +| `public/favicon.ico` | Application favicon | + +## Import Aliases + +[tsconfig.json](./tsconfig.json) sets paths: `{ "@/*": ["./*"] }`, so imports are resolved from the project root using the `@/` prefix. Use: + +``` +import { apiClient } from '@/app/lib/apiClient'; +import { Providers } from '@/app/components/providers'; +import { APP_NAME } from '@/app/lib/constants'; +``` + +SVGs follow Next.js defaults (imported as static assets via next/image or referenced from /public). + +## Routing & Role-Based Access + +Routing uses the **Next.js App Router** exclusively. Routes are organized via route groups: + +- `app/(auth)/` - unauthenticated flows (`/invite`, `/verify`) +- `app/(main)/` — authenticated app surface (`/evaluations`, `/datasets`, `/configurations`, `/guardrails`, `/knowledge-base`, `/settings`, etc.) + +Role gating lives in middleware.ts and reads a kaapi_role cookie with two values: + +- `user` - standard authenticated user +- `superuser` - admin; required for `/settings/*` + +The cookie is issued server-side by [authCookie.ts](app/lib/authCookie.ts) after login/verify based on user.is_superuser. Middleware classifies each request into one of: + +- `PUBLIC_ROUTES` — open to everyone (`/evaluations`, `/invite`, `/verify`, `/coming-soon/*`) +- `GUEST_ONLY_ROUTES` — unauthenticated only (`/keystore`); authenticated users are redirected to `/evaluations` +- `/settings/*` — superuser only +- Everything else — any authenticated user + +There is no dynamic/custom role system; only the two static roles above. + +## Toast Notifications + +Toasts are managed via a React Context provider ([Toast.tsx](app/components/Toast.tsx)), mounted once in [Providers.tsx](app/components/providers/Providers.tsx). Consume them from any client component: + +``` +import { useToast } from '@/app/components/Toast'; +// or the re-export: import { useToast } from '@/app/hooks/useToast'; + +function MyComponent() { + const toast = useToast(); + + toast.success('Saved successfully'); // success toast + toast.error('Something went wrong'); // error toast + toast.warning('Heads up'); // warning toast + toast.info('FYI'); // info toast + + // Optional: override the default 5000ms auto-dismiss + toast.success('Saved', 3000); + + // Low-level API (type + duration) + toast.addToast('Custom message', 'success', 4000); +} +``` + +## Authentication [AuthContext.tsx](app/lib/context/AuthContext.tsx) + +There is no `AuthService` class. Auth state is owned by a React Context provider (`AuthProvider`) mounted in [Providers.tsx](app/components/providers/Providers.tsx), and consumed via the `useAuth()` hook: + +``` +import { useAuth } from '@/app/lib/context/AuthContext'; + +function MyComponent() { + const { + isAuthenticated, isHydrated, + session, currentUser, googleProfile, + apiKeys, activeKey, addKey, removeKey, setKeys, + loginWithToken, logout, + } = useAuth(); +} +``` + +## App Context [AppContext.tsx](app/lib/context/AppContext.tsx) + +Sidebar state is managed via `AppProvider`, consumed with `useApp()`: + +``` +import { useApp } from '@/app/lib/context/AppContext'; + +const { sidebarCollapsed, setSidebarCollapsed, toggleSidebar } = useApp(); +``` + +## API Client & Error Handling + +The BFF layer uses [apiClient.ts](app/lib/apiClient.ts) which forwards requests from Next.js route handlers to the backend at `BACKEND_URL` (defaults to `http://localhost:8000`). Key patterns: + +- **Server-side (route handlers)**: Use `apiClient(request, endpoint, options)` — it relays `X-API-KEY` and `Cookie` headers automatically and returns `{ status, data, headers }`. +- **Client-side**: Use `clientFetch(endpoint, options)` — handles token refresh on 401, dispatches `AUTH_EXPIRED_EVENT` when refresh fails, and throws with a message extracted from `error`, `message`, or `detail` fields in the response body. +- **Error extraction**: `extractErrorMessage(body, fallback)` reads `body.error || body.message || body.detail` — follow this pattern when adding new API routes. +- **Auth expiry**: On 401 with failed refresh, a `CustomEvent(AUTH_EXPIRED_EVENT)` is dispatched on `window`, which `AuthContext` listens to for automatic logout. diff --git a/app/(auth)/invite/page.tsx b/app/(auth)/invite/page.tsx new file mode 100644 index 0000000..762eaef --- /dev/null +++ b/app/(auth)/invite/page.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { Suspense } from "react"; +import { useSearchParams } from "next/navigation"; +import { SpinnerIcon } from "@/app/components/icons"; +import TokenVerifyPage from "@/app/components/auth/TokenVerifyPage"; + +function InviteContent() { + const searchParams = useSearchParams(); + + return ( + + ); +} + +export default function InvitePage() { + return ( + + + + } + > + + + ); +} diff --git a/app/(auth)/verify/page.tsx b/app/(auth)/verify/page.tsx new file mode 100644 index 0000000..20caf11 --- /dev/null +++ b/app/(auth)/verify/page.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { Suspense } from "react"; +import { useSearchParams } from "next/navigation"; +import { SpinnerIcon } from "@/app/components/icons"; +import TokenVerifyPage from "@/app/components/auth/TokenVerifyPage"; + +function VerifyContent() { + const searchParams = useSearchParams(); + + return ( + + ); +} + +export default function VerifyPage() { + return ( + + + + } + > + + + ); +} diff --git a/app/(main)/configurations/page.tsx b/app/(main)/configurations/page.tsx index d9243f0..a36ab48 100644 --- a/app/(main)/configurations/page.tsx +++ b/app/(main)/configurations/page.tsx @@ -13,7 +13,7 @@ import { colors } from "@/app/lib/colors"; import { usePaginatedList, useInfiniteScroll } from "@/app/hooks"; import ConfigCard from "@/app/components/ConfigCard"; import Loader, { LoaderBox } from "@/app/components/Loader"; -import { EvalJob } from "@/app/components/types"; +import { EvalJob } from "@/app/lib/types/evaluation"; import { ConfigPublic, ConfigVersionItems, @@ -271,7 +271,10 @@ export default function ConfigLibraryPage() { -
+
{isLoading ? ( ) : error ? ( @@ -341,11 +344,13 @@ export default function ConfigLibraryPage() { ) : ( <>
{columns.map((col, colIdx) => ( -
+
{col.map((config) => ( +
) : (
- {/* Split View: Prompt (left) + Config (right) */}
{ if (!job) return; try { @@ -391,9 +392,9 @@ export default function EvaluationReport() { > -
+

- {/* Actions */} -
+
diff --git a/app/(main)/evaluations/page.tsx b/app/(main)/evaluations/page.tsx index d7900f3..13ca97c 100644 --- a/app/(main)/evaluations/page.tsx +++ b/app/(main)/evaluations/page.tsx @@ -49,12 +49,8 @@ function SimplifiedEvalContent() { const [duplicationFactor, setDuplicationFactor] = useState("1"); const [uploadedFile, setUploadedFile] = useState(null); const [isUploading, setIsUploading] = useState(false); - - // Stored datasets const [storedDatasets, setStoredDatasets] = useState([]); const [isDatasetsLoading, setIsDatasetsLoading] = useState(false); - - // Evaluation config state const [selectedDatasetId, setSelectedDatasetId] = useState(() => { return searchParams.get("dataset") || ""; }); @@ -235,6 +231,10 @@ function SimplifiedEvalContent() { }); setIsEvaluating(false); + setExperimentName(""); + setSelectedDatasetId(""); + setSelectedConfigId(""); + setSelectedConfigVersion(0); toast.success(`Evaluation created!`); return true; } catch (error: unknown) { diff --git a/app/(main)/settings/credentials/page.tsx b/app/(main)/settings/credentials/page.tsx index c80364e..3ca22c1 100644 --- a/app/(main)/settings/credentials/page.tsx +++ b/app/(main)/settings/credentials/page.tsx @@ -1,7 +1,7 @@ /** * Credentials Settings Page — orchestrator * State management and API calls only. UI split into: - * ProviderList — left sidebar nav + * ProviderSidebar — left sidebar nav * CredentialForm — right form with fields and actions */ @@ -9,7 +9,7 @@ import { useState, useEffect } from "react"; import SettingsSidebar from "@/app/components/settings/SettingsSidebar"; -import { colors } from "@/app/lib/colors"; +import PageHeader from "@/app/components/PageHeader"; import { useToast } from "@/app/components/Toast"; import { useAuth } from "@/app/lib/context/AuthContext"; import { @@ -18,7 +18,7 @@ import { ProviderDef, } from "@/app/lib/types/credentials"; import { getExistingForProvider } from "@/app/lib/utils"; -import ProviderList from "@/app/components/settings/credentials/ProviderList"; +import ProviderSidebar from "@/app/components/settings/ProviderSidebar"; import CredentialForm from "@/app/components/settings/credentials/CredentialForm"; import { apiFetch } from "@/app/lib/apiClient"; @@ -34,15 +34,14 @@ export default function CredentialsPage() { const [isDeleting, setIsDeleting] = useState(false); const [formValues, setFormValues] = useState>({}); const [isActive, setIsActive] = useState(true); - const [visibleFields, setVisibleFields] = useState>(new Set()); const [existingCredential, setExistingCredential] = useState(null); - // Load credentials once we have an API key + // Load credentials once authenticated useEffect(() => { if (!isAuthenticated) return; loadCredentials(); - }, [apiKeys]); + }, [isAuthenticated, apiKeys]); // Re-populate form when provider or credentials change useEffect(() => { @@ -64,7 +63,6 @@ export default function CredentialsPage() { }); setFormValues(blank); } - setVisibleFields(new Set()); }, [selectedProvider, credentials]); const loadCredentials = async () => { @@ -151,7 +149,6 @@ export default function CredentialsPage() { setFormValues(blank); setIsActive(true); } - setVisibleFields(new Set()); }; const handleDelete = async () => { @@ -178,68 +175,29 @@ export default function CredentialsPage() { setFormValues((prev) => ({ ...prev, [key]: value })); }; - const handleToggleVisibility = (key: string) => { - setVisibleFields((prev) => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - return next; - }); - }; - return ( -
+
-
-
-

- Credentials -

-

- Manage provider credentials -

-
-
+
-
{!isAuthenticated ? ( -
+
Please log in to manage credentials.
) : ( @@ -251,10 +209,8 @@ export default function CredentialsPage() { isLoading={isLoading} isSaving={isSaving} isDeleting={isDeleting} - visibleFields={visibleFields} onChange={handleFieldChange} onActiveChange={setIsActive} - onToggleVisibility={handleToggleVisibility} onSave={handleSave} onCancel={handleCancel} onDelete={handleDelete} diff --git a/app/(main)/settings/onboarding/page.tsx b/app/(main)/settings/onboarding/page.tsx index f453e60..b7e2197 100644 --- a/app/(main)/settings/onboarding/page.tsx +++ b/app/(main)/settings/onboarding/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import { useRouter } from "next/navigation"; import SettingsSidebar from "@/app/components/settings/SettingsSidebar"; import PageHeader from "@/app/components/PageHeader"; import { useAuth } from "@/app/lib/context/AuthContext"; @@ -13,6 +12,7 @@ import { ProjectList, StepIndicator, UserList, + OnboardingCredentials, } from "@/app/components/settings/onboarding"; import { Organization, @@ -21,9 +21,14 @@ import { OnboardResponseData, } from "@/app/lib/types/onboarding"; import { apiFetch } from "@/app/lib/apiClient"; -import { colors } from "@/app/lib/colors"; import { ArrowLeftIcon } from "@/app/components/icons"; import { DEFAULT_PAGE_LIMIT } from "@/app/lib/constants"; +import TabNavigation from "@/app/components/TabNavigation"; + +const PROJECT_TABS = [ + { id: "users", label: "Users" }, + { id: "credentials", label: "Credentials" }, +]; type View = "loading" | "list" | "projects" | "users" | "form" | "success"; @@ -59,8 +64,7 @@ function OrganizationListSkeleton() { } export default function OnboardingPage() { - const router = useRouter(); - const { activeKey, currentUser, isHydrated, isAuthenticated } = useAuth(); + const { activeKey } = useAuth(); const [view, setView] = useState("loading"); const [selectedOrg, setSelectedOrg] = useState(null); const [selectedProject, setSelectedProject] = useState(null); @@ -69,6 +73,7 @@ export default function OnboardingPage() { const [onboardData, setOnboardData] = useState( null, ); + const [activeProjectTab, setActiveProjectTab] = useState("users"); const { items: organizations, @@ -97,18 +102,6 @@ export default function OnboardingPage() { } }, [isLoadingOrgs, organizations.length]); - // Redirect if no API key or not a superuser - useEffect(() => { - if (!isHydrated) return; - if (!isAuthenticated) { - router.replace("/"); - return; - } - if (currentUser && !currentUser.is_superuser) { - router.replace("/settings/credentials"); - } - }, [isHydrated, activeKey, currentUser, router]); - const fetchProjects = useCallback( async (org: Organization) => { setSelectedOrg(org); @@ -172,20 +165,17 @@ export default function OnboardingPage() { }; return ( -
+
-
+
{view === "loading" && } @@ -195,7 +185,6 @@ export default function OnboardingPage() { isLoadingMore={isLoadingMore} onNewOrg={() => setView("form")} onSelectOrg={fetchProjects} - scrollRef={scrollRef} /> )} @@ -211,11 +200,47 @@ export default function OnboardingPage() { )} {view === "users" && selectedOrg && selectedProject && ( - +
+ + +
+
+

+ {selectedProject.name} +

+

+ {selectedOrg.name} +

+
+
+ + + + {activeProjectTab === "users" && ( + + )} + + {activeProjectTab === "credentials" && ( + + )} +
)} {view === "form" && ( diff --git a/app/api/auth/google/route.ts b/app/api/auth/google/route.ts index 7dc39ad..b9d1bd0 100644 --- a/app/api/auth/google/route.ts +++ b/app/api/auth/google/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { apiClient } from "@/app/lib/apiClient"; +import { setRoleCookieFromBody } from "@/app/lib/authCookie"; /** Proxy Google login token to backend. Forwards Set-Cookie headers back to the browser. */ export async function POST(request: Request) { @@ -31,6 +32,10 @@ export async function POST(request: Request) { }); } + if (status >= 200 && status < 300) { + setRoleCookieFromBody(response, data); + } + return response; } catch { return NextResponse.json( diff --git a/app/api/auth/invite/route.ts b/app/api/auth/invite/route.ts new file mode 100644 index 0000000..e1fd42d --- /dev/null +++ b/app/api/auth/invite/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; +import { setRoleCookieFromBody } from "@/app/lib/authCookie"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const token = searchParams.get("token"); + + if (!token) { + return NextResponse.json( + { success: false, error: "Missing invitation token" }, + { status: 400 }, + ); + } + + const { status, data, headers } = await apiClient( + request, + `/api/v1/auth/invite/verify?token=${encodeURIComponent(token)}`, + ); + + const res = NextResponse.json(data, { status }); + + const setCookies = headers.getSetCookie?.() ?? []; + for (const cookie of setCookies) { + res.headers.append("Set-Cookie", cookie); + } + + if (status >= 200 && status < 300) { + setRoleCookieFromBody(res, data); + } + + return res; + } catch { + return NextResponse.json( + { success: false, error: "Failed to connect to backend" }, + { status: 500 }, + ); + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index 3b5994f..5111c38 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { apiClient } from "@/app/lib/apiClient"; +import { clearRoleCookie } from "@/app/lib/authCookie"; export async function POST(request: NextRequest) { const { status, data, headers } = await apiClient( @@ -15,5 +16,7 @@ export async function POST(request: NextRequest) { res.headers.append("Set-Cookie", cookie); } + clearRoleCookie(res); + return res; } diff --git a/app/api/auth/magic-link/route.ts b/app/api/auth/magic-link/route.ts new file mode 100644 index 0000000..b88f67b --- /dev/null +++ b/app/api/auth/magic-link/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { status, data } = await apiClient( + request, + "/api/v1/auth/magic-link", + { + method: "POST", + body: JSON.stringify(body), + }, + ); + return NextResponse.json(data, { status }); + } catch { + return NextResponse.json( + { success: false, error: "Failed to connect to backend" }, + { status: 500 }, + ); + } +} diff --git a/app/api/auth/magic-link/verify/route.ts b/app/api/auth/magic-link/verify/route.ts new file mode 100644 index 0000000..70475a4 --- /dev/null +++ b/app/api/auth/magic-link/verify/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; +import { setRoleCookieFromBody } from "@/app/lib/authCookie"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const token = searchParams.get("token"); + + if (!token) { + return NextResponse.json( + { success: false, error: "Missing login token" }, + { status: 400 }, + ); + } + + const { status, data, headers } = await apiClient( + request, + `/api/v1/auth/magic-link/verify?token=${encodeURIComponent(token)}`, + ); + + const res = NextResponse.json(data, { status }); + + const setCookies = headers.getSetCookie?.() ?? []; + for (const cookie of setCookies) { + res.headers.append("Set-Cookie", cookie); + } + + if (status >= 200 && status < 300) { + setRoleCookieFromBody(res, data); + } + + return res; + } catch { + return NextResponse.json( + { success: false, error: "Failed to connect to backend" }, + { status: 500 }, + ); + } +} diff --git a/app/api/credentials/org/[orgId]/[projectId]/provider/[provider]/route.ts b/app/api/credentials/org/[orgId]/[projectId]/provider/[provider]/route.ts new file mode 100644 index 0000000..583ed96 --- /dev/null +++ b/app/api/credentials/org/[orgId]/[projectId]/provider/[provider]/route.ts @@ -0,0 +1,40 @@ +import { apiClient } from "@/app/lib/apiClient"; +import { NextResponse, NextRequest } from "next/server"; + +type Params = { + params: Promise<{ orgId: string; projectId: string; provider: string }>; +}; + +export async function GET(request: NextRequest, { params }: Params) { + const { orgId, projectId, provider } = await params; + try { + const { status, data } = await apiClient( + request, + `/api/v1/credentials/${orgId}/${projectId}/provider/${provider}`, + ); + return NextResponse.json(data, { status }); + } catch (e: unknown) { + return NextResponse.json( + { error: e instanceof Error ? e.message : String(e) }, + { status: 500 }, + ); + } +} + +export async function DELETE(request: NextRequest, { params }: Params) { + const { orgId, projectId, provider } = await params; + try { + const { status, data } = await apiClient( + request, + `/api/v1/credentials/${orgId}/${projectId}/provider/${provider}`, + { method: "DELETE" }, + ); + if (status === 204) return new NextResponse(null, { status: 204 }); + return NextResponse.json(data, { status }); + } catch (e: unknown) { + return NextResponse.json( + { error: e instanceof Error ? e.message : String(e) }, + { status: 500 }, + ); + } +} diff --git a/app/api/credentials/org/[orgId]/[projectId]/route.ts b/app/api/credentials/org/[orgId]/[projectId]/route.ts new file mode 100644 index 0000000..d98638b --- /dev/null +++ b/app/api/credentials/org/[orgId]/[projectId]/route.ts @@ -0,0 +1,56 @@ +import { apiClient } from "@/app/lib/apiClient"; +import { NextResponse, NextRequest } from "next/server"; + +type Params = { params: Promise<{ orgId: string; projectId: string }> }; + +export async function GET(request: NextRequest, { params }: Params) { + const { orgId, projectId } = await params; + try { + const { status, data } = await apiClient( + request, + `/api/v1/credentials/${orgId}/${projectId}`, + ); + return NextResponse.json(data, { status }); + } catch (e: unknown) { + return NextResponse.json( + { error: e instanceof Error ? e.message : String(e) }, + { status: 500 }, + ); + } +} + +export async function PATCH(request: NextRequest, { params }: Params) { + const { orgId, projectId } = await params; + try { + const body = await request.json(); + const { status, data } = await apiClient( + request, + `/api/v1/credentials/${orgId}/${projectId}`, + { method: "PATCH", body: JSON.stringify(body) }, + ); + return NextResponse.json(data, { status }); + } catch (e: unknown) { + return NextResponse.json( + { error: e instanceof Error ? e.message : String(e) }, + { status: 500 }, + ); + } +} + +export async function DELETE(request: NextRequest, { params }: Params) { + const { orgId, projectId } = await params; + try { + const { status, data } = await apiClient( + request, + `/api/v1/credentials/${orgId}/${projectId}`, + { method: "DELETE" }, + ); + if (status === 204) return new NextResponse(null, { status: 204 }); + return NextResponse.json(data, { status }); + } catch (e: unknown) { + return NextResponse.json( + { error: e instanceof Error ? e.message : String(e) }, + { status: 500 }, + ); + } +} diff --git a/app/api/users/me/route.ts b/app/api/users/me/route.ts index f910e1c..6b9fe0f 100644 --- a/app/api/users/me/route.ts +++ b/app/api/users/me/route.ts @@ -1,10 +1,17 @@ import { NextRequest, NextResponse } from "next/server"; import { apiClient } from "@/app/lib/apiClient"; +import { setRoleCookieFromBody } from "@/app/lib/authCookie"; export async function GET(request: NextRequest) { try { const { status, data } = await apiClient(request, "/api/v1/users/me"); - return NextResponse.json(data, { status }); + const res = NextResponse.json(data, { status }); + + if (status >= 200 && status < 300) { + setRoleCookieFromBody(res, data); + } + + return res; } catch { return NextResponse.json( { error: "Failed to connect to backend" }, diff --git a/app/components/CodeBlock.tsx b/app/components/CodeBlock.tsx new file mode 100644 index 0000000..e76d9a3 --- /dev/null +++ b/app/components/CodeBlock.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; + +interface CodeBlockProps { + children: ReactNode; +} + +export default function CodeBlock({ children }: CodeBlockProps) { + return ( +
+ {children} +
+ ); +} diff --git a/app/components/ConfigCard.tsx b/app/components/ConfigCard.tsx index d490c1d..a3fb5ba 100644 --- a/app/components/ConfigCard.tsx +++ b/app/components/ConfigCard.tsx @@ -411,14 +411,14 @@ export default function ConfigCard({ {/* Prompt */}

{latestVersion.instructions || "No instructions set"} diff --git a/app/components/ConfigModal.tsx b/app/components/ConfigModal.tsx index 817b24f..0f3f412 100644 --- a/app/components/ConfigModal.tsx +++ b/app/components/ConfigModal.tsx @@ -7,7 +7,11 @@ import React, { useState, useEffect } from "react"; import { colors } from "@/app/lib/colors"; -import { EvalJob, AssistantConfig } from "./types"; +import CopyableCodeBlock from "@/app/components/CopyableCodeBlock"; +import CodeBlock from "@/app/components/CodeBlock"; +import Tag from "@/app/components/Tag"; +import { CloseIcon } from "@/app/components/icons"; +import { EvalJob, AssistantConfig } from "@/app/lib/types/evaluation"; import { useAuth } from "@/app/lib/context/AuthContext"; import { apiFetch } from "@/app/lib/apiClient"; import { @@ -35,6 +39,24 @@ interface ConfigVersionInfo { knowledge_base_ids?: string[]; } +const ConfigField = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( +

+
+ {label} +
+ {children} +
+); + export default function ConfigModal({ isOpen, onClose, @@ -80,15 +102,14 @@ export default function ConfigModal({ const params: CompletionParams = blob?.completion?.params || ({} as CompletionParams); - // Extract knowledge base IDs from multiple sources const knowledgeBaseIds: string[] = []; - // 1. Check direct params.knowledge_base_ids + // Check direct params.knowledge_base_ids if (Array.isArray(params.knowledge_base_ids)) { knowledgeBaseIds.push(...params.knowledge_base_ids); } - // 2. Check tools array for knowledge_base_ids + // Check tools array for knowledge_base_ids if (params.tools) { const toolKbIds = params.tools .filter( @@ -100,7 +121,6 @@ export default function ConfigModal({ knowledgeBaseIds.push(...toolKbIds); } - // Remove duplicates const uniqueKbIds = [...new Set(knowledgeBaseIds)]; setConfigVersionInfo({ @@ -128,51 +148,9 @@ export default function ConfigModal({ if (!isOpen) return null; - const ConfigField = ({ - label, - children, - }: { - label: string; - children: React.ReactNode; - }) => ( -
-
- {label} -
- {children} -
- ); - - const CodeBlock = ({ children }: { children: React.ReactNode }) => ( -
- {children} -
- ); - - const Tag = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - return (
e.stopPropagation()} > - {/* Header */}
- - - +
- {/* Content */}
{isLoadingConfig ? (
@@ -295,9 +259,11 @@ export default function ConfigModal({ {configVersionInfo?.knowledge_base_ids && configVersionInfo.knowledge_base_ids.length > 0 && ( - + {configVersionInfo.knowledge_base_ids.join("\n")} - + )} @@ -305,11 +271,17 @@ export default function ConfigModal({ assistantConfig?.instructions || job.config?.instructions) && ( - + {configVersionInfo?.instructions || assistantConfig?.instructions || job.config?.instructions} - + )} diff --git a/app/components/CopyableCodeBlock.tsx b/app/components/CopyableCodeBlock.tsx new file mode 100644 index 0000000..7578033 --- /dev/null +++ b/app/components/CopyableCodeBlock.tsx @@ -0,0 +1,49 @@ +"use client"; + +import React, { useState, useCallback } from "react"; +import { useToast } from "@/app/hooks/useToast"; +import { CheckIcon, CopyIcon } from "@/app/components/icons"; + +interface CopyableCodeBlockProps { + children: React.ReactNode; + copyText: string; +} + +export default function CopyableCodeBlock({ + children, + copyText, +}: CopyableCodeBlockProps) { + const toast = useToast(); + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(copyText); + setCopied(true); + toast.success("Copied to clipboard"); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error("Failed to copy"); + } + }, [copyText, toast]); + + return ( +
+
+ {children} +
+ +
+ ); +} diff --git a/app/components/DetailedResultsTable.tsx b/app/components/DetailedResultsTable.tsx deleted file mode 100644 index c4b9de0..0000000 --- a/app/components/DetailedResultsTable.tsx +++ /dev/null @@ -1,639 +0,0 @@ -/** - * DetailedResultsTable.tsx - Table view for evaluation results - * - * Displays Q&A pairs with scores in a tabular format - * Supports both row format (individual traces) and grouped format (multiple answers per question) - */ - -import React, { useState, useEffect } from "react"; -import { - TraceScore, - getScoreObject, - normalizeToIndividualScores, - hasSummaryScores, - isNewScoreObjectV2, - isGroupedFormat, - GroupedTraceItem, - EvalJob, -} from "@/app/components/types"; - -// Helper function to format score value with color -const formatScoreValue = (score: TraceScore | undefined) => { - if (!score) return { value: "N/A", color: "#737373", bg: "transparent" }; - - if (score.data_type === "CATEGORICAL") { - const catValue = String(score.value); - let color = "#171717"; - let bg = "#fafafa"; - - if (catValue === "CORRECT") { - color = "#15803d"; - bg = "#dcfce7"; - } else if (catValue === "PARTIAL") { - color = "#92400e"; - bg = "#fef3c7"; - } else if (catValue === "INCORRECT") { - color = "#dc2626"; - bg = "#fee2e2"; - } - - return { value: catValue, color, bg }; - } - - // NUMERIC - const numValue = Number(score.value); - const formattedValue = numValue.toFixed(2); - let color = "#171717"; - let bg = "transparent"; - - // Color based on value - if (numValue >= 0.7) { - color = "#15803d"; - bg = "#dcfce7"; - } else if (numValue >= 0.5) { - color = "#92400e"; - bg = "#fef3c7"; - } else { - color = "#dc2626"; - bg = "#fee2e2"; - } - - return { value: formattedValue, color, bg }; -}; - -interface DetailedResultsTableProps { - job: EvalJob; -} - -export default function DetailedResultsTable({ - job, -}: DetailedResultsTableProps) { - const [openCommentId, setOpenCommentId] = useState(null); - const [commentPos, setCommentPos] = useState({ top: 0, left: 0 }); - - useEffect(() => { - if (!openCommentId) return; - const handleScroll = () => setOpenCommentId(null); - window.addEventListener("scroll", handleScroll, true); - return () => { - window.removeEventListener("scroll", handleScroll, true); - }; - }, [openCommentId]); - - const scoreObject = getScoreObject(job); - - // 1. First check: Does it have summary_scores at all? - if (!scoreObject || !hasSummaryScores(scoreObject)) { - return ( -
-

- No detailed results available or using legacy format -

-
- ); - } - - // 2. Second check: Does it have traces? (NewScoreObjectV2) - if (isNewScoreObjectV2(scoreObject)) { - // Check if grouped format - if (isGroupedFormat(scoreObject.traces)) { - return ( - - ); - } - // Otherwise show row format - } - - // 3. Try to normalize to IndividualScore format - // This handles NewScoreObjectV2 (with traces) - const individual_scores = normalizeToIndividualScores(scoreObject); - - // 4. If no individual scores available (e.g., BasicScoreObject with only summary_scores) - if (!individual_scores || individual_scores.length === 0) { - return ( -
-

- No individual scores available. Only summary metrics are available for - this evaluation. -

-
- ); - } - - // Get all unique score names from the first item - const scoreNames = - individual_scores[0]?.trace_scores?.map((s) => s.name) || []; - - // Helper function to get score value by name - const getScoreByName = ( - scores: TraceScore[], - name: string, - ): TraceScore | undefined => { - if (!scores || !Array.isArray(scores)) return undefined; - return scores.find((s) => s?.name === name); - }; - - return ( -
- {/* Table Container */} -
- - {/* Table Header */} - - - - - - - {scoreNames.map((scoreName) => ( - - ))} - - - - {/* Table Body */} - - {individual_scores.map((item, index) => { - const question = item.input?.question || "N/A"; - const answer = item.output?.answer || "N/A"; - const groundTruth = item.metadata?.ground_truth || "N/A"; - - return ( - { - const row = e.currentTarget; - row.style.backgroundColor = "#fafafa"; - }} - onMouseLeave={(e) => { - const row = e.currentTarget; - row.style.backgroundColor = "#ffffff"; - }} - > - - - {/* Question */} - - - {/* Ground Truth */} - - - {/* Answer */} - - - {/* Score Columns */} - {scoreNames.map((scoreName) => { - const score = getScoreByName(item.trace_scores, scoreName); - const { value, color, bg } = formatScoreValue(score); - - return ( - - ); - })} - - ); - })} - -
- Question - - Ground Truth - - Answer - - {scoreName} -
- {index + 1} - -
- {question} -
-
-
- {groundTruth} -
-
-
- {answer} -
-
-
-
- {value} -
- {score?.comment && ( - <> -
{ - const rect = - e.currentTarget.getBoundingClientRect(); - const tooltipWidth = 300; - const centerX = rect.left + rect.width / 2; - const clampedLeft = Math.min( - Math.max(centerX - tooltipWidth / 2, 8), - window.innerWidth - tooltipWidth - 8, - ); - setCommentPos({ - top: rect.top - 8, - left: clampedLeft, - }); - setOpenCommentId(`${index}-${scoreName}`); - }} - onMouseLeave={() => setOpenCommentId(null)} - > - i -
- {openCommentId === `${index}-${scoreName}` && ( -
- {score.comment} -
- )} - - )} -
-
-
-
- ); -} - -function GroupedResultsTable({ traces }: { traces: GroupedTraceItem[] }) { - const [openCommentId, setOpenCommentId] = useState(null); - const [commentPos, setCommentPos] = useState({ top: 0, left: 0 }); - - useEffect(() => { - if (!openCommentId) return; - const handleScroll = () => setOpenCommentId(null); - window.addEventListener("scroll", handleScroll, true); - return () => { - window.removeEventListener("scroll", handleScroll, true); - }; - }, [openCommentId]); - - if (!traces || traces.length === 0) { - return ( -
-

- No grouped results available -

-
- ); - } - - // Get max answers count - const maxAnswers = Math.max(...traces.map((t) => t.llm_answers.length)); - - // Fixed column widths (in pixels) for predictable layout - const COLUMN_WIDTHS = { - qId: 60, - question: 200, - groundTruth: 200, - answer: 250, - }; - - // Calculate minimum table width based on number of answers - // This ensures horizontal scroll activates at the right point - const fixedColumnsWidth = - COLUMN_WIDTHS.qId + COLUMN_WIDTHS.question + COLUMN_WIDTHS.groundTruth; - const tableMinWidth = fixedColumnsWidth + maxAnswers * COLUMN_WIDTHS.answer; - - return ( -
- {/* Table Container - overflow-x-auto enables horizontal scroll when table exceeds viewport */} -
- - {/* Table Header - matching row format styling */} - - - - - - {Array.from({ length: maxAnswers }, (_, i) => ( - - ))} - - - - {/* Table Body */} - - {traces.map((group, index) => ( - - {/* Text row */} - - {/* Question ID */} - - - {/* Question */} - - - {/* Ground Truth */} - - - {/* Answer text only */} - {Array.from({ length: maxAnswers }, (_, answerIndex) => { - const answer = group.llm_answers[answerIndex]; - return ( - - ); - })} - - {/* Scores row */} - - {/* Empty cells for Q.ID, Question, Ground Truth */} - - ); - })} - - - ))} - -
- Q.ID - - Question - - Ground Truth - - Answer {i + 1} -
- {group.question_id} - -
- {group.question} -
-
-
- {group.ground_truth_answer} -
-
- {answer ? ( -
- {answer} -
- ) : ( - - - )} -
- - - - {/* Score cells */} - {Array.from({ length: maxAnswers }, (_, answerIndex) => { - const answerScores: TraceScore[] = - group.scores?.[answerIndex] || []; - const answer = group.llm_answers[answerIndex]; - - return ( - - {answer && answerScores.length > 0 ? ( -
- {answerScores.map( - (score: TraceScore, scoreIdx: number) => { - if (!score) return null; - const { value, color, bg } = - formatScoreValue(score); - return ( -
- - {score.name}: - -
-
- {value} -
- {score?.comment && - (() => { - const commentId = `g${index}-a${answerIndex}-s${scoreIdx}`; - return ( - <> -
{ - const rect = - e.currentTarget.getBoundingClientRect(); - const tooltipWidth = 300; - const centerX = - rect.left + rect.width / 2; - const clampedLeft = Math.min( - Math.max( - centerX - - tooltipWidth / 2, - 8, - ), - window.innerWidth - - tooltipWidth - - 8, - ); - setCommentPos({ - top: rect.top - 8, - left: clampedLeft, - }); - setOpenCommentId(commentId); - }} - onMouseLeave={() => - setOpenCommentId(null) - } - > - i -
- {openCommentId === commentId && ( -
- {score.comment} -
- )} - - ); - })()} -
-
- ); - }, - )} -
- ) : null} -
-
-
- ); -} diff --git a/app/components/Field.tsx b/app/components/Field.tsx index eb32b1a..7d90b0b 100644 --- a/app/components/Field.tsx +++ b/app/components/Field.tsx @@ -11,6 +11,7 @@ interface FieldProps { error?: string; type?: string; disabled?: boolean; + className?: string; } export default function Field({ @@ -21,6 +22,7 @@ export default function Field({ error, type = "text", disabled = false, + className = "", }: FieldProps) { const [showPassword, setShowPassword] = useState(false); const isPassword = type === "password"; @@ -40,13 +42,13 @@ export default function Field({ disabled={disabled} className={`w-full px-3 py-2 rounded-lg border text-sm text-text-primary bg-white placeholder:text-neutral-400 focus:outline-none focus:ring-accent-primary/20 focus:border-accent-primary transition-colors ${ isPassword ? "pr-10" : "" - } ${error ? "border-red-400" : "border-border"} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`} + } ${error ? "border-red-400" : "border-border"} ${disabled ? "opacity-50 cursor-not-allowed" : ""} ${className}`} /> {isPassword && (
{text} +
); diff --git a/app/components/PageHeader.tsx b/app/components/PageHeader.tsx index 1b56118..2d13227 100644 --- a/app/components/PageHeader.tsx +++ b/app/components/PageHeader.tsx @@ -1,7 +1,6 @@ "use client"; import { ReactNode, useState } from "react"; -import { colors } from "@/app/lib/colors"; import { useApp } from "@/app/lib/context/AppContext"; import { useAuth } from "@/app/lib/context/AuthContext"; import { MenuIcon } from "@/app/components/icons"; @@ -31,18 +30,11 @@ export default function PageHeader({ return ( <> -
+
+ +
+
+
); } - -function getToastStyles(type: ToastType) { - switch (type) { - case "success": - return { - bg: "#f0fdf4", - border: "#86efac", - text: "#15803d", - icon: "#16a34a", - }; - case "error": - return { - bg: "#fef2f2", - border: "#fca5a5", - text: "#b91c1c", - icon: "#dc2626", - }; - case "warning": - return { - bg: "#fffbeb", - border: "#fcd34d", - text: "#b45309", - icon: "#f59e0b", - }; - case "info": - default: - return { - bg: "#eff6ff", - border: "#93c5fd", - text: "#1e40af", - icon: "#3b82f6", - }; - } -} diff --git a/app/components/auth/LoginModal.tsx b/app/components/auth/LoginModal.tsx index 37c5b6d..e8e548c 100644 --- a/app/components/auth/LoginModal.tsx +++ b/app/components/auth/LoginModal.tsx @@ -4,10 +4,13 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { GoogleLogin, CredentialResponse } from "@react-oauth/google"; import Modal from "@/app/components/Modal"; +import { Button, Field } from "@/app/components"; +import { MailIcon } from "@/app/components/icons"; import { useAuth } from "@/app/lib/context/AuthContext"; import { GoogleAuthResponse } from "@/app/lib/types/auth"; import { useToast } from "@/app/components/Toast"; import { apiFetch } from "@/app/lib/apiClient"; +import { isValidEmail } from "@/app/lib/utils"; interface LoginModalProps { open: boolean; @@ -16,9 +19,13 @@ interface LoginModalProps { export default function LoginModal({ open, onClose }: LoginModalProps) { const router = useRouter(); - const { loginWithGoogle } = useAuth(); + const { loginWithToken } = useAuth(); const toast = useToast(); const [isLoggingIn, setIsLoggingIn] = useState(false); + const [email, setEmail] = useState(""); + const [emailError, setEmailError] = useState(""); + const [isSendingLink, setIsSendingLink] = useState(false); + const [linkSent, setLinkSent] = useState(false); const handleGoogleSuccess = async ( credentialResponse: CredentialResponse, @@ -38,7 +45,7 @@ export default function LoginModal({ open, onClose }: LoginModalProps) { }); const { access_token, user, google_profile } = res.data; - loginWithGoogle(access_token, user, google_profile); + loginWithToken(access_token, user, google_profile); onClose(); } catch (err) { toast.error( @@ -49,9 +56,51 @@ export default function LoginModal({ open, onClose }: LoginModalProps) { } }; + const handleSendMagicLink = async () => { + if (!email.trim()) { + setEmailError("Please enter your email address"); + return; + } + if (!isValidEmail(email.trim())) { + setEmailError("Please enter a valid email address"); + return; + } + + setEmailError(""); + setIsSendingLink(true); + + try { + const res = await fetch("/api/auth/magic-link", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: email.trim() }), + }); + + const data = await res.json(); + + if (!res.ok || !data.success) { + setEmailError(data.error || "Failed to send login link."); + return; + } + + setLinkSent(true); + } catch { + toast.error("Failed to connect to server. Please try again."); + } finally { + setIsSendingLink(false); + } + }; + + const handleClose = () => { + setEmail(""); + setEmailError(""); + setLinkSent(false); + onClose(); + }; + return ( - -
+ +

Log in or connect

@@ -81,18 +130,75 @@ export default function LoginModal({ open, onClose }: LoginModalProps) { )}
-

- Want to use an X-API key instead?{" "} - -

+
+
+ OR +
+
+ + {linkSent ? ( +
+
+ +
+

+ Check your email +

+

+ We sent a login link to{" "} + {email}. + Click the link in the email to sign in. +

+ +
+ ) : ( +
+ { + setEmail(val); + if (emailError) setEmailError(""); + }} + placeholder="Email address" + error={emailError} + className="rounded-full! px-5! py-3!" + /> + +
+ )} + + {!linkSent && ( +

+ Want to use an X-API key instead?{" "} + +

+ )}
); diff --git a/app/components/auth/TokenVerifyPage.tsx b/app/components/auth/TokenVerifyPage.tsx new file mode 100644 index 0000000..cb31452 --- /dev/null +++ b/app/components/auth/TokenVerifyPage.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/app/lib/context/AuthContext"; +import { AuthTokenResponse } from "@/app/lib/types/auth"; +import { + CheckCircleIcon, + WarningIcon, + SpinnerIcon, +} from "@/app/components/icons"; +import { Button } from "@/app/components"; +import { APP_NAME } from "@/app/lib/constants"; + +type Status = "verifying" | "success" | "error"; + +interface TokenVerifyPageProps { + token: string | null; + apiUrl: string; + title: { + verifying: string; + success: string; + error: string; + }; + description: { + verifying: string; + success: string; + }; + errorFallback: string; + helpText?: string; +} + +export default function TokenVerifyPage({ + token, + apiUrl, + title, + description, + errorFallback, + helpText, +}: TokenVerifyPageProps) { + const router = useRouter(); + const { loginWithToken } = useAuth(); + const [status, setStatus] = useState("verifying"); + const [error, setError] = useState(""); + const [progress, setProgress] = useState(0); + + useEffect(() => { + if (!token) { + setStatus("error"); + setError("Invalid link. No token found."); + return; + } + + let cancelled = false; + + (async () => { + try { + const res = await fetch( + `${apiUrl}?token=${encodeURIComponent(token)}`, + { credentials: "include" }, + ); + + const data: AuthTokenResponse & { + data?: { + user?: { + id: number; + email: string; + full_name: string; + is_active: boolean; + is_superuser: boolean; + }; + }; + } = await res.json(); + + if (cancelled) return; + + if (!res.ok || !data.success || !data.data) { + setStatus("error"); + setError(data.error || errorFallback); + return; + } + + loginWithToken(data.data.access_token, data.data.user); + setStatus("success"); + } catch { + if (!cancelled) { + setStatus("error"); + setError("Failed to verify. Please try again."); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [token, apiUrl, loginWithToken, errorFallback]); + + // Drive progress bar and redirect on success + useEffect(() => { + if (status !== "success") return; + + const duration = 2000; + const interval = 30; + let elapsed = 0; + + const timer = setInterval(() => { + elapsed += interval; + const pct = Math.min((elapsed / duration) * 100, 100); + setProgress(pct); + + if (elapsed >= duration) { + clearInterval(timer); + router.push("/evaluations"); + } + }, interval); + + return () => clearInterval(timer); + }, [status, router]); + + return ( +
+
+
+
+
+ +
+
+

+ {APP_NAME} +

+

by Tech4Dev

+
+ +
+
+ +
+
+
+ {status === "verifying" && ( + + )} + {status === "success" && ( + + )} + {status === "error" && ( + + )} +
+
+ +

+ {status === "verifying" && title.verifying} + {status === "success" && title.success} + {status === "error" && title.error} +

+ +

+ {status === "verifying" && description.verifying} + {status === "success" && description.success} + {status === "error" && error} +

+ + {status === "success" && ( +
+
+
+
+
+ )} + + {status === "verifying" && ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ )} + + {status === "error" && ( +
+ + +
+ )} +
+
+ + {status === "error" && helpText && ( +

+ {helpText} +

+ )} +
+
+ ); +} diff --git a/app/components/auth/index.ts b/app/components/auth/index.ts index 366a852..3cf3b46 100644 --- a/app/components/auth/index.ts +++ b/app/components/auth/index.ts @@ -1,3 +1,4 @@ export { default as LoginModal } from "./LoginModal"; export { default as FeatureGateModal } from "./FeatureGateModal"; export { default as ProtectedPage } from "./ProtectedPage"; +export { default as TokenVerifyPage } from "./TokenVerifyPage"; diff --git a/app/components/evaluations/DetailedResultsTable.tsx b/app/components/evaluations/DetailedResultsTable.tsx new file mode 100644 index 0000000..9d50ebd --- /dev/null +++ b/app/components/evaluations/DetailedResultsTable.tsx @@ -0,0 +1,235 @@ +/** + * DetailedResultsTable.tsx - Table view for evaluation results + * + * Displays Q&A pairs with scores in a tabular format + * Supports both row format (individual traces) and grouped format (multiple answers per question) + */ + +import { useState, useEffect } from "react"; +import type { GroupedTraceItem, EvalJob } from "@/app/lib/types/evaluation"; +import { + getScoreObject, + normalizeToIndividualScores, + hasSummaryScores, + isNewScoreObjectV2, + isGroupedFormat, +} from "@/app/lib/utils/evaluation"; +import { formatScoreValue, getScoreByName } from "@/app/lib/utils"; +import GroupedResultsTable from "@/app/components/evaluations/GroupedResultsTable"; + +interface DetailedResultsTableProps { + job: EvalJob; +} + +export default function DetailedResultsTable({ + job, +}: DetailedResultsTableProps) { + const [openCommentId, setOpenCommentId] = useState(null); + const [commentPos, setCommentPos] = useState({ top: 0, left: 0 }); + + useEffect(() => { + if (!openCommentId) return; + const handleScroll = () => setOpenCommentId(null); + window.addEventListener("scroll", handleScroll, true); + return () => { + window.removeEventListener("scroll", handleScroll, true); + }; + }, [openCommentId]); + + const scoreObject = getScoreObject(job); + + if (!scoreObject || !hasSummaryScores(scoreObject)) { + return ( +
+

+ No detailed results available or using legacy format +

+
+ ); + } + + if (isNewScoreObjectV2(scoreObject)) { + if (isGroupedFormat(scoreObject.traces)) { + return ( + + ); + } + } + + const individual_scores = normalizeToIndividualScores(scoreObject); + + if (!individual_scores || individual_scores.length === 0) { + return ( +
+

+ No individual scores available. Only summary metrics are available for + this evaluation. +

+
+ ); + } + + // Get all unique score names from the first item + const scoreNames = + individual_scores[0]?.trace_scores?.map((s) => s.name) || []; + + const COLUMN_WIDTHS = { + index: 50, + question: 250, + groundTruth: 250, + answer: 250, + score: 160, + }; + const tableMinWidth = + COLUMN_WIDTHS.index + + COLUMN_WIDTHS.question + + COLUMN_WIDTHS.groundTruth + + COLUMN_WIDTHS.answer + + scoreNames.length * COLUMN_WIDTHS.score; + + return ( +
+
+ + + + + + + + {scoreNames.map((scoreName) => ( + + ))} + + + + + {individual_scores.map((item, index) => { + const question = item.input?.question || "N/A"; + const answer = item.output?.answer || "N/A"; + const groundTruth = item.metadata?.ground_truth || "N/A"; + + return ( + + + + + + + + + + {scoreNames.map((scoreName) => { + const score = getScoreByName(item.trace_scores, scoreName); + const { value, color, bg } = formatScoreValue(score); + + return ( + + ); + })} + + ); + })} + +
+ Question + + Ground Truth + + Answer + + {scoreName} +
+ {index + 1} + +
+ {question} +
+
+
+ {groundTruth} +
+
+
+ {answer} +
+
+
+
+ {value} +
+ {score?.comment && ( + <> +
{ + const rect = + e.currentTarget.getBoundingClientRect(); + const tooltipWidth = 300; + const centerX = rect.left + rect.width / 2; + const clampedLeft = Math.min( + Math.max(centerX - tooltipWidth / 2, 8), + window.innerWidth - tooltipWidth - 8, + ); + setCommentPos({ + top: rect.top - 8, + left: clampedLeft, + }); + setOpenCommentId(`${index}-${scoreName}`); + }} + onMouseLeave={() => setOpenCommentId(null)} + > + i +
+ {openCommentId === `${index}-${scoreName}` && ( +
+ {score.comment} +
+ )} + + )} +
+
+
+
+ ); +} diff --git a/app/components/evaluations/EvalDatasetDescription.tsx b/app/components/evaluations/EvalDatasetDescription.tsx index 4579101..b0b99a0 100644 --- a/app/components/evaluations/EvalDatasetDescription.tsx +++ b/app/components/evaluations/EvalDatasetDescription.tsx @@ -15,7 +15,7 @@ export default function EvalDatasetDescription({ return (
diff --git a/app/components/evaluations/EvalRunCard.tsx b/app/components/evaluations/EvalRunCard.tsx index 65990d1..64dba5b 100644 --- a/app/components/evaluations/EvalRunCard.tsx +++ b/app/components/evaluations/EvalRunCard.tsx @@ -2,18 +2,14 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { colors } from "@/app/lib/colors"; -import { - EvalJob, - AssistantConfig, - getScoreObject, -} from "@/app/components/types"; -import { getStatusColor, formatCostUSD } from "@/app/components/utils"; -import { timeAgo } from "@/app/lib/utils"; -import ConfigModal from "@/app/components/ConfigModal"; -import ScoreDisplay from "@/app/components/ScoreDisplay"; +import type { EvalJob, AssistantConfig } from "@/app/lib/types/evaluation"; +import { getScoreObject } from "@/app/lib/utils/evaluation"; +import { getStatusColor } from "@/app/components/utils"; +import { timeAgo, formatCostUSD } from "@/app/lib/utils"; +import { ConfigModal, InfoTooltip } from "@/app/components"; +import ScoreDisplay from "@/app/components/evaluations/ScoreDisplay"; import CostIcon from "@/app/components/icons/evaluations/CostIcon"; -import InfoTooltip from "@/app/components/InfoTooltip"; +import DatabaseIcon from "@/app/components/icons/evaluations/DatabaseIcon"; export interface EvalRunCardProps { job: EvalJob; @@ -33,91 +29,55 @@ export default function EvalRunCard({ return (
- {/* Row 1: Run Name (left) | Status (right) */}
-
+
{job.run_name}
{job.inserted_at && ( -
+
{timeAgo(job.inserted_at)}
)} {/* Error message (if failed) */} {job.error_message && ( -
+
{job.error_message}
)}
{job.status}
- {/* Row 2: Scores */} {scoreObj && (
)} - {/* Row 3: Dataset + Config + Cost (left) | Actions (right) */}
-
+
{job.dataset_name && ( - - - + {job.dataset_name} )} {job.assistant_id && assistantConfig?.name && ( - + {assistantConfig.name} )} {job.cost?.total_cost_usd != null && ( - + {formatCostUSD(job.cost.total_cost_usd)} )}
-
+
diff --git a/app/components/evaluations/EvaluationsTab.tsx b/app/components/evaluations/EvaluationsTab.tsx index 3fce988..d57ea05 100644 --- a/app/components/evaluations/EvaluationsTab.tsx +++ b/app/components/evaluations/EvaluationsTab.tsx @@ -4,12 +4,13 @@ import { useState, useEffect, useCallback } from "react"; import { apiFetch } from "@/app/lib/apiClient"; import { colors } from "@/app/lib/colors"; import { Dataset } from "@/app/lib/types/dataset"; -import { EvalJob, AssistantConfig } from "@/app/components/types"; +import { EvalJob, AssistantConfig } from "@/app/lib/types/evaluation"; import ConfigSelector from "@/app/components/ConfigSelector"; import Loader from "@/app/components/Loader"; import EvalRunCard from "./EvalRunCard"; import EvalDatasetDescription from "./EvalDatasetDescription"; import { useAuth } from "@/app/lib/context/AuthContext"; +import { RefreshIcon } from "@/app/components/icons"; type Tab = "datasets" | "evaluations"; @@ -390,23 +391,12 @@ export default function EvaluationsTab({
@@ -418,14 +408,12 @@ export default function EvaluationsTab({ boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)", }} > - {/* Loading */} {isLoading && evalJobs.length === 0 && (
)} - {/* Error */} {error && (
)} - {/* Empty State */} {!isLoading && evalJobs.length === 0 && !error && (
)} - {/* Runs List */} {evalJobs.length > 0 && (() => { const filteredJobs = diff --git a/app/components/evaluations/GroupedResultsTable.tsx b/app/components/evaluations/GroupedResultsTable.tsx new file mode 100644 index 0000000..1943d22 --- /dev/null +++ b/app/components/evaluations/GroupedResultsTable.tsx @@ -0,0 +1,261 @@ +/** + * GroupedResultsTable.tsx - Grouped view for evaluation results + * + * Displays multiple LLM answers per question in a grouped table format + */ + +import { useState, useEffect, Fragment } from "react"; +import { TraceScore, GroupedTraceItem } from "@/app/lib/types/evaluation"; +import { formatScoreValue } from "@/app/lib/utils"; + +export default function GroupedResultsTable({ + traces, +}: { + traces: GroupedTraceItem[]; +}) { + const [openCommentId, setOpenCommentId] = useState(null); + const [commentPos, setCommentPos] = useState({ top: 0, left: 0 }); + + useEffect(() => { + if (!openCommentId) return; + const handleScroll = () => setOpenCommentId(null); + window.addEventListener("scroll", handleScroll, true); + return () => { + window.removeEventListener("scroll", handleScroll, true); + }; + }, [openCommentId]); + + if (!traces || traces.length === 0) { + return ( +
+

No grouped results available

+
+ ); + } + + // Get max answers count + const maxAnswers = Math.max(...traces.map((t) => t.llm_answers.length)); + + // Fixed column widths (in pixels) for predictable layout + const COLUMN_WIDTHS = { + qId: 60, + question: 200, + groundTruth: 200, + answer: 250, + }; + + // Calculate minimum table width based on number of answers + // This ensures horizontal scroll activates at the right point + const fixedColumnsWidth = + COLUMN_WIDTHS.qId + COLUMN_WIDTHS.question + COLUMN_WIDTHS.groundTruth; + const tableMinWidth = fixedColumnsWidth + maxAnswers * COLUMN_WIDTHS.answer; + + return ( +
+
+ + + + + + + {Array.from({ length: maxAnswers }, (_, i) => ( + + ))} + + + + + {traces.map((group, index) => ( + + + + + + + + + {/* Answer */} + {Array.from({ length: maxAnswers }, (_, answerIndex) => { + const answer = group.llm_answers[answerIndex]; + return ( + + ); + })} + + + + ); + })} + + + ))} + +
+ Q.ID + + Question + + Ground Truth + + Answer {i + 1} +
+ {group.question_id} + +
+ {group.question} +
+
+
+ {group.ground_truth_answer} +
+
+ {answer ? ( +
+ {answer} +
+ ) : ( + - + )} +
+ + + + {Array.from({ length: maxAnswers }, (_, answerIndex) => { + const answerScores: TraceScore[] = + group.scores?.[answerIndex] || []; + const answer = group.llm_answers[answerIndex]; + + return ( + + {answer && answerScores.length > 0 ? ( +
+ {answerScores.map( + (score: TraceScore, scoreIdx: number) => { + if (!score) return null; + const { value, color, bg } = + formatScoreValue(score); + return ( +
+ + {score.name}: + +
+
+ {value} +
+ {score?.comment && + (() => { + const commentId = `g${index}-a${answerIndex}-s${scoreIdx}`; + return ( + <> +
{ + const rect = + e.currentTarget.getBoundingClientRect(); + const tooltipWidth = 300; + const centerX = + rect.left + rect.width / 2; + const clampedLeft = Math.min( + Math.max( + centerX - + tooltipWidth / 2, + 8, + ), + window.innerWidth - + tooltipWidth - + 8, + ); + setCommentPos({ + top: rect.top - 8, + left: clampedLeft, + }); + setOpenCommentId(commentId); + }} + onMouseLeave={() => + setOpenCommentId(null) + } + > + i +
+ {openCommentId === commentId && ( +
+ {score.comment} +
+ )} + + ); + })()} +
+
+ ); + }, + )} +
+ ) : null} +
+
+
+ ); +} diff --git a/app/components/ScoreDisplay.tsx b/app/components/evaluations/ScoreDisplay.tsx similarity index 94% rename from app/components/ScoreDisplay.tsx rename to app/components/evaluations/ScoreDisplay.tsx index 2f8b1db..68efa33 100644 --- a/app/components/ScoreDisplay.tsx +++ b/app/components/evaluations/ScoreDisplay.tsx @@ -5,7 +5,8 @@ "use client"; -import { ScoreObject, hasSummaryScores } from "./types"; +import type { ScoreObject } from "@/app/lib/types/evaluation"; +import { hasSummaryScores } from "@/app/lib/utils/evaluation"; interface ScoreDisplayProps { score: ScoreObject | null; @@ -16,7 +17,6 @@ export default function ScoreDisplay({ score, errorMessage, }: ScoreDisplayProps) { - // No score available if (!score) { return (
@@ -42,7 +42,6 @@ export default function ScoreDisplay({ ); } - // Separate numeric and categorical scores const numericScores = summaryScores.filter( (s) => s.data_type === "NUMERIC", ); @@ -83,7 +82,6 @@ export default function ScoreDisplay({ ); } - // Fallback for unsupported format return (
Score: diff --git a/app/components/icons/common/CopyIcon.tsx b/app/components/icons/common/CopyIcon.tsx new file mode 100644 index 0000000..ac3b372 --- /dev/null +++ b/app/components/icons/common/CopyIcon.tsx @@ -0,0 +1,20 @@ +interface IconProps { + className?: string; + style?: React.CSSProperties; +} + +export default function CopyIcon({ className, style }: IconProps) { + return ( + + + + + ); +} diff --git a/app/components/icons/common/ErrorCircleIcon.tsx b/app/components/icons/common/ErrorCircleIcon.tsx new file mode 100644 index 0000000..a0f604e --- /dev/null +++ b/app/components/icons/common/ErrorCircleIcon.tsx @@ -0,0 +1,23 @@ +interface IconProps { + className?: string; + style?: React.CSSProperties; +} + +export default function ErrorCircleIcon({ className, style }: IconProps) { + return ( + + + + ); +} diff --git a/app/components/icons/common/MailIcon.tsx b/app/components/icons/common/MailIcon.tsx new file mode 100644 index 0000000..1b8a834 --- /dev/null +++ b/app/components/icons/common/MailIcon.tsx @@ -0,0 +1,21 @@ +export default function MailIcon({ + className = "w-5 h-5", +}: { + className?: string; +}) { + return ( + + + + ); +} diff --git a/app/components/icons/common/RefreshIcon.tsx b/app/components/icons/common/RefreshIcon.tsx index fedb9e2..e244959 100644 --- a/app/components/icons/common/RefreshIcon.tsx +++ b/app/components/icons/common/RefreshIcon.tsx @@ -13,11 +13,13 @@ export default function RefreshIcon({ className, style }: IconProps) { strokeWidth={2} style={style} > - + + + ); } diff --git a/app/components/icons/document/CloseIcon.tsx b/app/components/icons/document/CloseIcon.tsx index af939c2..59b151c 100644 --- a/app/components/icons/document/CloseIcon.tsx +++ b/app/components/icons/document/CloseIcon.tsx @@ -1,8 +1,9 @@ interface IconProps { className?: string; + style?: React.CSSProperties; } -export default function CloseIcon({ className }: IconProps) { +export default function CloseIcon({ className, style }: IconProps) { return ( { - const params = new URLSearchParams(); - if (currentConfigId && currentConfigVersion) { - params.set("config", currentConfigId); - params.set("version", currentConfigVersion.toString()); - } - if (datasetId) params.set("dataset", datasetId); - if (experimentName) params.set("experiment", experimentName); - - router.push(`/evaluations?${params.toString()}`); - }; - return ( - - {fromEvaluations ? : } - {fromEvaluations ? "Back to Evaluation" : "Run Evaluation"} - - } - > +
+
+ +
+
+ ); +} diff --git a/app/components/settings/SettingsSidebar.tsx b/app/components/settings/SettingsSidebar.tsx index e2cc889..e0598e5 100644 --- a/app/components/settings/SettingsSidebar.tsx +++ b/app/components/settings/SettingsSidebar.tsx @@ -6,6 +6,7 @@ import Image from "next/image"; import { ArrowLeftIcon, KeyIcon, SlidersIcon } from "@/app/components/icons"; import { SETTINGS_NAV } from "@/app/lib/navConfig"; import { useAuth } from "@/app/lib/context/AuthContext"; +import { useApp } from "@/app/lib/context/AppContext"; import { Branding, UserMenuPopover } from "@/app/components/user-menu"; const iconMap: Record = { @@ -17,6 +18,7 @@ export default function SettingsSidebar() { const router = useRouter(); const pathname = usePathname(); const { currentUser, googleProfile, isAuthenticated, logout } = useAuth(); + const { sidebarCollapsed } = useApp(); const [showUserMenu, setShowUserMenu] = useState(false); const userMenuRef = useRef(null); @@ -41,7 +43,9 @@ export default function SettingsSidebar() { .toUpperCase(); return ( -