fix(evals-ui): align criterion name+weight inputs with flex-end; add /evals route

This commit is contained in:
Ishaan Jaffer 2026-04-22 20:34:09 -07:00
parent 35a5b4dd80
commit 4fa4cd8a08
No known key found for this signature in database
2 changed files with 416 additions and 88 deletions

View file

@ -0,0 +1,12 @@
"use client";
import EvalsCatalog from "@/components/evals/EvalsCatalog";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const EvalsPage = () => {
const { accessToken } = useAuthorized();
return <EvalsCatalog accessToken={accessToken} />;
};
export default EvalsPage;

View file

@ -1,11 +1,7 @@
"use client";
import React, { useEffect, useState } from "react";
import {
Button,
Table,
Tag,
Typography,
Space,
Modal,
Drawer,
Form,
Input,
@ -13,11 +9,30 @@ import {
Select,
Popconfirm,
message,
Tag,
Tooltip,
Typography,
} from "antd";
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
import { createLiteLLMEval, listLiteLLMEvals, deleteLiteLLMEval, modelAvailableCall } from "../networking";
const { Title, Text } = Typography;
import { Button } from "@tremor/react";
import { PlusOutlined, DeleteOutlined, QuestionCircleOutlined, RobotOutlined } from "@ant-design/icons";
import { TrashIcon, SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
SortingState,
useReactTable,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
} from "@tremor/react";
import { createLiteLLMEval, listLiteLLMEvals, deleteLiteLLMEval, updateLiteLLMEval, modelAvailableCall } from "../networking";
interface EvalCriterion {
name: string;
@ -47,10 +62,14 @@ interface Props {
export default function EvalsCatalog({ accessToken, userRole, availableModels: availableModelsProp = [] }: Props) {
const [evals, setEvals] = useState<LiteLLMEval[]>([]);
const [loading, setLoading] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [editEval, setEditEval] = useState<LiteLLMEval | null>(null);
const [editSaving, setEditSaving] = useState(false);
const [availableModels, setAvailableModels] = useState<string[]>(availableModelsProp);
const [sorting, setSorting] = useState<SortingState>([]);
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const fetchEvals = async () => {
if (!accessToken) return;
@ -68,11 +87,11 @@ export default function EvalsCatalog({ accessToken, userRole, availableModels: a
const fetchModels = async () => {
if (!accessToken || availableModelsProp.length > 0) return;
try {
const data = await modelAvailableCall(accessToken, null, null);
const data = await modelAvailableCall(accessToken, "", "");
const names: string[] = data?.data?.map((m: any) => m.id) ?? [];
setAvailableModels(names);
} catch {
// best-effort; leave empty
// best-effort
}
};
@ -94,7 +113,7 @@ export default function EvalsCatalog({ accessToken, userRole, availableModels: a
max_iterations: values.max_iterations ?? 1,
});
message.success("Eval created");
setDrawerOpen(false);
setModalOpen(false);
form.resetFields();
fetchEvals();
} catch (e: any) {
@ -104,6 +123,45 @@ export default function EvalsCatalog({ accessToken, userRole, availableModels: a
}
};
const handleEdit = (ev: LiteLLMEval) => {
setEditEval(ev);
editForm.setFieldsValue({
eval_name: ev.eval_name,
description: ev.description,
judge_model: ev.judge_model,
overall_threshold: ev.overall_threshold ?? 80,
max_iterations: ev.max_iterations ?? 1,
criteria: (ev.criteria || []).map((c: any) => ({
name: c.name,
weight: c.weight,
description: c.description,
})),
});
};
const handleUpdate = async (values: any) => {
if (!accessToken || !editEval) return;
setEditSaving(true);
try {
await updateLiteLLMEval(accessToken, editEval.eval_id, {
eval_name: values.eval_name,
criteria: values.criteria || [],
judge_model: values.judge_model,
description: values.description,
overall_threshold: values.overall_threshold ?? 80,
max_iterations: values.max_iterations ?? 1,
});
message.success("Eval updated");
setEditEval(null);
editForm.resetFields();
fetchEvals();
} catch (e: any) {
message.error(`Failed to update eval: ${e.message}`);
} finally {
setEditSaving(false);
}
};
const handleDelete = async (evalId: string) => {
if (!accessToken) return;
try {
@ -115,107 +173,222 @@ export default function EvalsCatalog({ accessToken, userRole, availableModels: a
}
};
const columns = [
const columns: ColumnDef<LiteLLMEval>[] = [
{
title: "Name",
dataIndex: "eval_name",
key: "eval_name",
render: (v: string) => <Text strong>{v}</Text>,
header: "Name",
accessorKey: "eval_name",
cell: ({ row }) => (
<span
className="text-xs font-semibold text-blue-600 cursor-pointer hover:underline"
onClick={() => handleEdit(row.original)}
>
{row.original.eval_name}
</span>
),
},
{
title: "Judge Model",
dataIndex: "judge_model",
key: "judge_model",
header: "Judge Model",
accessorKey: "judge_model",
cell: ({ row }) => (
<span className="text-xs font-mono">{row.original.judge_model}</span>
),
},
{
title: "Minimum Score to Pass",
dataIndex: "overall_threshold",
key: "overall_threshold",
render: (v?: number) =>
v != null ? <Tag color="blue"> {v} / 100</Tag> : <Text type="secondary"></Text>,
header: "Minimum Score to Pass",
accessorKey: "overall_threshold",
cell: ({ row }) => {
const v = row.original.overall_threshold;
return v != null ? (
<Tag color="blue" className="text-xs"> {v} / 100</Tag>
) : (
<span className="text-gray-400 text-xs"></span>
);
},
},
{
title: "Criteria",
dataIndex: "criteria",
key: "criteria",
render: (v: EvalCriterion[]) => <Tag>{v?.length ?? 0} criteria</Tag>,
header: "Criteria",
accessorKey: "criteria",
cell: ({ row }) => (
<Tag className="text-xs">{row.original.criteria?.length ?? 0} criteria</Tag>
),
},
{
title: "Version",
dataIndex: "version",
key: "version",
render: (v: number) => <Text type="secondary">v{v}</Text>,
header: "Version",
accessorKey: "version",
cell: ({ row }) => (
<span className="text-xs text-gray-500">v{row.original.version}</span>
),
},
{
title: "Actions",
key: "actions",
render: (_: any, record: LiteLLMEval) => (
id: "actions",
header: "Actions",
cell: ({ row }) => (
<Popconfirm
title="Delete this eval?"
onConfirm={() => handleDelete(record.eval_id)}
onConfirm={() => handleDelete(row.original.eval_id)}
okText="Delete"
okButtonProps={{ danger: true }}
>
<Button danger icon={<DeleteOutlined />} size="small" />
<Button
variant="light"
size="xs"
className="text-red-500 hover:text-red-700 hover:bg-red-50"
>
<TrashIcon className="h-4 w-4" />
</Button>
</Popconfirm>
),
},
];
const table = useReactTable({
data: evals,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
enableSorting: true,
});
return (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
}}
>
<Space>
<Title level={4} style={{ margin: 0 }}>
Evals
</Title>
<Tag color="blue">Beta</Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setDrawerOpen(true)}>
Create Eval
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
<div className="flex items-center gap-4 mb-4">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold">Evals</h1>
<Tag color="blue" className="text-xs">Beta</Tag>
</div>
<Button
className="flex-shrink-0"
onClick={() => setModalOpen(true)}
>
+ Create Eval
</Button>
</div>
<Table
dataSource={evals}
columns={columns}
rowKey="eval_id"
loading={loading}
locale={{ emptyText: "No evals yet. Create one to get started." }}
/>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHeaderCell
key={header.id}
className={`py-1 ${header.column.getCanSort() ? "cursor-pointer select-none" : ""}`}
onClick={header.column.getToggleSortingHandler()}
>
<div className="flex items-center gap-1 text-xs font-medium">
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getCanSort() && (
<span>
{header.column.getIsSorted() === "asc" ? (
<ChevronUpIcon className="h-4 w-4" />
) : header.column.getIsSorted() === "desc" ? (
<ChevronDownIcon className="h-4 w-4" />
) : (
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
)}
</span>
)}
</div>
</TableHeaderCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={columns.length} className="text-center py-4 text-gray-500 text-xs">
Loading...
</TableCell>
</TableRow>
) : evals.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="h-8">
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap text-xs ${
cell.column.id === "actions"
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
: ""
}`}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="text-center py-8 text-gray-500 text-xs">
No evals yet. Create one to get started.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<Drawer
<Modal
title={
<Space>
<div className="flex items-center gap-2">
<span>Create Eval</span>
<Tag color="blue">Beta</Tag>
</Space>
<Tag color="blue" className="text-xs">Beta</Tag>
</div>
}
open={drawerOpen}
onClose={() => {
setDrawerOpen(false);
open={modalOpen}
width={700}
onCancel={() => {
setModalOpen(false);
form.resetFields();
}}
width={560}
footer={
<div style={{ textAlign: "right" }}>
<Button style={{ marginRight: 8 }} onClick={() => setDrawerOpen(false)}>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
onClick={() => {
setModalOpen(false);
form.resetFields();
}}
>
Cancel
</Button>
<Button type="primary" loading={saving} onClick={() => form.submit()}>
Create Eval
<Button
onClick={() => form.submit()}
disabled={saving}
>
{saving ? "Creating..." : "Create Eval"}
</Button>
</div>
}
className="top-8"
styles={{
body: { padding: "24px" },
header: { padding: "24px 24px 0 24px", border: "none" },
}}
>
<Form form={form} layout="vertical" onFinish={handleCreate}>
{/* How it works banner */}
<div
style={{
background: "#f0f5ff",
border: "1px solid #adc6ff",
borderRadius: 6,
padding: "10px 14px",
marginBottom: 20,
display: "flex",
gap: 10,
alignItems: "flex-start",
}}
>
<RobotOutlined style={{ color: "#2f54eb", marginTop: 2, flexShrink: 0 }} />
<Typography.Text style={{ fontSize: 13, color: "#1d1d1d" }}>
After each agent response, the <strong>Judge Model</strong> (an LLM you choose) reads the response and scores it 0100 against each criterion. The weighted average of those scores is the <strong>final score</strong>. If it falls below the threshold, the response is blocked.
</Typography.Text>
</div>
<Form.Item name="eval_name" label="Eval Name" rules={[{ required: true }]}>
<Input placeholder="e.g. Insurance Claims QA" />
</Form.Item>
@ -224,7 +397,18 @@ export default function EvalsCatalog({ accessToken, userRole, availableModels: a
<Input.TextArea rows={2} placeholder="What does this eval measure?" />
</Form.Item>
<Form.Item name="judge_model" label="Judge Model" rules={[{ required: true }]}>
<Form.Item
name="judge_model"
label={
<span>
Judge Model&nbsp;
<Tooltip title="The LLM that reads each agent response and grades it against your criteria. It never sees end-user data beyond what the agent returned.">
<QuestionCircleOutlined style={{ color: "#8c8c8c" }} />
</Tooltip>
</span>
}
rules={[{ required: true }]}
>
<Select
showSearch
placeholder="Select a model"
@ -234,13 +418,29 @@ export default function EvalsCatalog({ accessToken, userRole, availableModels: a
<Form.Item
name="overall_threshold"
label="Minimum Score to Pass"
label={
<span>
Minimum Score to Pass&nbsp;
<Tooltip title="The judge scores the response 0100. If the weighted average falls below this number the eval fails. 80 is a good starting point.">
<QuestionCircleOutlined style={{ color: "#8c8c8c" }} />
</Tooltip>
</span>
}
initialValue={80}
>
<InputNumber min={0} max={100} addonAfter="/ 100" style={{ width: "100%" }} />
</Form.Item>
<Form.Item label="Evaluation Criteria">
<Form.Item
label={
<span>
Evaluation Criteria&nbsp;
<Tooltip title="Each criterion is a thing the judge checks. Give it a name, a weight (how much it counts toward the final score), and a description telling the judge what to look for. Weights should add up to 100%.">
<QuestionCircleOutlined style={{ color: "#8c8c8c" }} />
</Tooltip>
</span>
}
>
<Form.List name="criteria">
{(fields, { add, remove }) => (
<>
@ -254,7 +454,7 @@ export default function EvalsCatalog({ accessToken, userRole, availableModels: a
marginBottom: 8,
}}
>
<div style={{ display: "flex", gap: 8 }}>
<div style={{ display: "flex", gap: 8, alignItems: "flex-end" }}>
<Form.Item
{...restField}
name={[name, "name"]}
@ -266,6 +466,13 @@ export default function EvalsCatalog({ accessToken, userRole, availableModels: a
<Form.Item
{...restField}
name={[name, "weight"]}
label={
<Tooltip title="How much this criterion counts toward the final score. All weights should add up to 100%.">
<span style={{ fontSize: 12, color: "#595959" }}>
Weight <QuestionCircleOutlined style={{ color: "#bfbfbf" }} />
</span>
</Tooltip>
}
rules={[{ required: true, message: "Enter weight" }]}
style={{ flex: 1, marginBottom: 8 }}
>
@ -274,12 +481,19 @@ export default function EvalsCatalog({ accessToken, userRole, availableModels: a
max={100}
addonAfter="%"
style={{ width: "100%" }}
placeholder="Weight"
placeholder="e.g. 25"
/>
</Form.Item>
<Button danger onClick={() => remove(name)} style={{ marginTop: 0 }}>
×
</Button>
<div style={{ marginBottom: 8 }}>
<Button
variant="light"
size="xs"
className="text-red-500 hover:text-red-700"
onClick={() => remove(name)}
>
×
</Button>
</div>
</div>
<Form.Item
{...restField}
@ -292,13 +506,30 @@ export default function EvalsCatalog({ accessToken, userRole, availableModels: a
</div>
))}
<Button
type="dashed"
variant="secondary"
size="sm"
className="w-full mt-1"
onClick={() => add({ weight: 0 })}
block
icon={<PlusOutlined />}
>
Add Criterion
<PlusOutlined className="mr-1" /> Add Criterion
</Button>
{fields.length > 0 && (
<Form.Item shouldUpdate noStyle>
{() => {
const allCriteria: any[] = form.getFieldValue("criteria") || [];
const weightTotal = allCriteria.reduce(
(sum: number, c: any) => sum + (Number(c?.weight) || 0),
0
);
const weightOk = weightTotal === 100;
return (
<div style={{ marginTop: 6, fontSize: 12, color: weightOk ? "#52c41a" : "#faad14" }}>
Weights total: {weightTotal}%{weightOk ? " ✓" : " — should add up to 100%"}
</div>
);
}}
</Form.Item>
)}
</>
)}
</Form.List>
@ -306,12 +537,97 @@ export default function EvalsCatalog({ accessToken, userRole, availableModels: a
<Form.Item
name="max_iterations"
label="Retry on fail (attempts)"
label={
<span>
Retry on fail (attempts)&nbsp;
<Tooltip title="If the response fails the eval, the agent is called again with the judge's feedback injected. Set to 1 to disable retries.">
<QuestionCircleOutlined style={{ color: "#8c8c8c" }} />
</Tooltip>
</span>
}
initialValue={1}
>
<InputNumber min={1} max={5} style={{ width: "100%" }} />
</Form.Item>
</Form>
</Modal>
{/* Edit Drawer */}
<Drawer
title={editEval ? `Edit: ${editEval.eval_name}` : "Edit Eval"}
width={600}
open={!!editEval}
onClose={() => { setEditEval(null); editForm.resetFields(); }}
footer={
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => { setEditEval(null); editForm.resetFields(); }}>
Cancel
</Button>
<Button onClick={() => editForm.submit()} disabled={editSaving}>
{editSaving ? "Saving..." : "Save Changes"}
</Button>
</div>
}
>
<Form form={editForm} layout="vertical" onFinish={handleUpdate}>
<Form.Item name="eval_name" label="Eval Name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="judge_model" label="Judge Model" rules={[{ required: true }]}>
<Select showSearch options={availableModels.map((m) => ({ label: m, value: m }))} />
</Form.Item>
<Form.Item name="overall_threshold" label="Minimum Score to Pass">
<InputNumber min={0} max={100} addonAfter="/ 100" style={{ width: "100%" }} />
</Form.Item>
<Form.Item label="Evaluation Criteria">
<Form.List name="criteria">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<div key={key} style={{ border: "1px solid #f0f0f0", borderRadius: 6, padding: "12px 12px 0", marginBottom: 8 }}>
<div style={{ display: "flex", gap: 8, alignItems: "flex-end" }}>
<Form.Item {...restField} name={[name, "name"]} rules={[{ required: true, message: "Enter name" }]} style={{ flex: 2, marginBottom: 8 }}>
<Input placeholder="Criterion name" />
</Form.Item>
<Form.Item {...restField} name={[name, "weight"]} rules={[{ required: true, message: "Enter weight" }]} style={{ flex: 1, marginBottom: 8 }}>
<InputNumber min={0} max={100} addonAfter="%" style={{ width: "100%" }} />
</Form.Item>
<div style={{ marginBottom: 8 }}>
<Button variant="light" size="xs" className="text-red-500 hover:text-red-700" onClick={() => remove(name)}>×</Button>
</div>
</div>
<Form.Item {...restField} name={[name, "description"]} rules={[{ required: true, message: "Describe what to check" }]} style={{ marginBottom: 8 }}>
<Input placeholder="What should the judge check for this criterion?" />
</Form.Item>
</div>
))}
<Button variant="secondary" size="sm" className="w-full mt-1" onClick={() => add({ weight: 0 })}>
<PlusOutlined className="mr-1" /> Add Criterion
</Button>
{fields.length > 0 && (
<Form.Item shouldUpdate noStyle>
{() => {
const c: any[] = editForm.getFieldValue("criteria") || [];
const total = c.reduce((s: number, x: any) => s + (Number(x?.weight) || 0), 0);
return (
<div style={{ marginTop: 6, fontSize: 12, color: total === 100 ? "#52c41a" : "#faad14" }}>
Weights total: {total}%{total === 100 ? " ✓" : " — should add up to 100%"}
</div>
);
}}
</Form.Item>
)}
</>
)}
</Form.List>
</Form.Item>
<Form.Item name="max_iterations" label="Retry on fail (attempts)">
<InputNumber min={1} max={5} style={{ width: "100%" }} />
</Form.Item>
</Form>
</Drawer>
</div>
);