diff --git a/studio/frontend/AGENTS.md b/studio/frontend/AGENTS.md new file mode 100644 index 0000000000..b1de874979 --- /dev/null +++ b/studio/frontend/AGENTS.md @@ -0,0 +1,37 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `src/` is app code; entry is `src/main.tsx`, global styles in `src/index.css`. +- `src/app/` holds app shell and routing; `src/features/` is feature slices w/ public `index.ts` exports. +- Shared UI lives in `src/components/` (shadcn in `src/components/ui/`). +- Shared logic in `src/hooks/`, `src/stores/`, `src/utils/`, `src/lib/`, and types in `src/types/`. +- Static assets: `src/assets/` and `public/`. +- `test/` is a Python harness for payload validation and preview; not a JS test suite. + +## Build, Test, and Development Commands +- `bun run dev`: start Vite dev server. +- `bun run build`: typecheck + build to `dist/`. +- `bun run preview`: serve the production build locally. +- `bun run lint`: ESLint checks for TS/React. +- `bun run typecheck`: `tsc` no-emit verification. +- `bun run biome:check` / `bun run biome:fix`: format + lint w/ Biome. +- Optional harness: `python test/scripts/validate_payload.py test/data/ui_payload.json`. + +## Coding Style & Naming Conventions +- TypeScript + React, 2-space indent (Biome). +- Prefer explicit, compact code; avoid heavy abstraction. +- Use path alias `@/` for app imports. +- Feature boundaries enforced: import from `@/features/` only, not deep paths. +- Components in `PascalCase`, hooks in `useCamelCase`, files in `kebab-case` or `camelCase` per local convention. + +## Testing Guidelines +- No frontend test runner configured yet; add one if needed. +- `test/` is for API payload validation and preview flows; add samples as `test/data/ui_payload_*.json`. + +## Commit & Pull Request Guidelines +- Commit history shows short, imperative messages; optional prefix like `refactor:`; keep it terse. +- PRs should include: clear summary, linked issue (if any), and UI screenshots/gifs for visual changes. +- Call out new deps, config, or required env changes in the PR body. + +## Agent Notes +- Keep changes minimal, focused, and easy to review. diff --git a/studio/frontend/src/features/canvas-lab/blocks/registry.tsx b/studio/frontend/src/features/canvas-lab/blocks/registry.tsx index e47d5d0812..ac1749dab7 100644 --- a/studio/frontend/src/features/canvas-lab/blocks/registry.tsx +++ b/studio/frontend/src/features/canvas-lab/blocks/registry.tsx @@ -1,8 +1,17 @@ import { + BalanceScaleIcon, + Clock01Icon, CodeIcon, - Database02Icon, - Flowchart01Icon, - SparklesIcon, + CodeSimpleIcon, + DiceFaces03Icon, + EqualSignIcon, + FingerPrintIcon, + FunctionIcon, + Parabola02Icon, + PencilEdit02Icon, + Tag01Icon, + TagsIcon, + UserAccountIcon, } from "@hugeicons/core-free-icons"; import type { ReactElement } from "react"; import type { LlmType, NodeConfig, SamplerConfig, SamplerType } from "../types"; @@ -24,7 +33,7 @@ import { UuidDialog } from "../dialogs/samplers/uuid-dialog"; export type BlockKind = "sampler" | "llm" | "expression"; export type BlockType = SamplerType | LlmType | "expression"; -type IconType = typeof Database02Icon; +type IconType = typeof CodeIcon; type BlockGroup = { kind: BlockKind; @@ -54,19 +63,19 @@ export const BLOCK_GROUPS: BlockGroup[] = [ kind: "sampler", title: "Sampler", description: "Numeric + categorical blocks.", - icon: Database02Icon, + icon: DiceFaces03Icon, }, { kind: "llm", title: "LLM", description: "Text + structured blocks.", - icon: SparklesIcon, + icon: PencilEdit02Icon, }, { kind: "expression", title: "Expression", description: "Derived columns with Jinja.", - icon: CodeIcon, + icon: FunctionIcon, }, ]; @@ -76,7 +85,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ type: "category", title: "Category", description: "Pick from a list of values.", - icon: Database02Icon, + icon: Tag01Icon, createConfig: (id, existing) => makeSamplerConfig(id, "category", existing), renderDialog: ({ config, onUpdate }) => config.kind === "sampler" && config.sampler_type === "category" ? ( @@ -91,7 +100,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ type: "subcategory", title: "Subcategory", description: "Map sub-values to a category.", - icon: Database02Icon, + icon: TagsIcon, createConfig: (id, existing) => makeSamplerConfig(id, "subcategory", existing), renderDialog: ({ config, categoryOptions, onUpdate }) => @@ -108,7 +117,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ type: "uniform", title: "Uniform", description: "Random number between low/high.", - icon: Database02Icon, + icon: EqualSignIcon, createConfig: (id, existing) => makeSamplerConfig(id, "uniform", existing), renderDialog: ({ config, onUpdate }) => config.kind === "sampler" && config.sampler_type === "uniform" ? ( @@ -123,7 +132,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ type: "gaussian", title: "Gaussian", description: "Normal distribution sampler.", - icon: Database02Icon, + icon: Parabola02Icon, createConfig: (id, existing) => makeSamplerConfig(id, "gaussian", existing), renderDialog: ({ config, onUpdate }) => config.kind === "sampler" && config.sampler_type === "gaussian" ? ( @@ -138,7 +147,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ type: "datetime", title: "Datetime", description: "Date/time range sampler.", - icon: Database02Icon, + icon: Clock01Icon, createConfig: (id, existing) => makeSamplerConfig(id, "datetime", existing), renderDialog: ({ config, onUpdate }) => config.kind === "sampler" && config.sampler_type === "datetime" ? ( @@ -153,7 +162,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ type: "uuid", title: "UUID", description: "UUID string sampler.", - icon: Database02Icon, + icon: FingerPrintIcon, createConfig: (id, existing) => makeSamplerConfig(id, "uuid", existing), renderDialog: ({ config, onUpdate }) => config.kind === "sampler" && config.sampler_type === "uuid" ? ( @@ -168,7 +177,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ type: "person", title: "Person", description: "Synthetic person sampler.", - icon: Database02Icon, + icon: UserAccountIcon, createConfig: (id, existing) => makeSamplerConfig(id, "person", existing), renderDialog: ({ config, onUpdate }) => config.kind === "sampler" && @@ -185,7 +194,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ type: "text", title: "LLM Text", description: "Free-form prompt generation.", - icon: SparklesIcon, + icon: PencilEdit02Icon, createConfig: (id, existing) => makeLlmConfig(id, "text", existing), renderDialog: ({ config, onUpdate }) => config.kind === "llm" && config.llm_type === "text" ? ( @@ -200,7 +209,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ type: "structured", title: "LLM Structured", description: "JSON output via schema.", - icon: Flowchart01Icon, + icon: CodeIcon, createConfig: (id, existing) => makeLlmConfig(id, "structured", existing), renderDialog: ({ config, onUpdate }) => config.kind === "llm" && config.llm_type === "structured" ? ( @@ -215,7 +224,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ type: "code", title: "LLM Code", description: "Generate code or SQL.", - icon: CodeIcon, + icon: CodeSimpleIcon, createConfig: (id, existing) => makeLlmConfig(id, "code", existing), renderDialog: ({ config, onUpdate }) => config.kind === "llm" && config.llm_type === "code" ? ( @@ -225,12 +234,27 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ /> ) : null, }, + { + kind: "llm", + type: "judge", + title: "LLM Judge", + description: "Score outputs with criteria.", + icon: BalanceScaleIcon, + createConfig: (id, existing) => makeLlmConfig(id, "judge", existing), + renderDialog: ({ config, onUpdate }) => + config.kind === "llm" && config.llm_type === "judge" ? ( + onUpdate(config.id, patch)} + /> + ) : null, + }, { kind: "expression", type: "expression", title: "Expression", description: "Transform columns with Jinja.", - icon: CodeIcon, + icon: FunctionIcon, createConfig: (id, existing) => makeExpressionConfig(id, existing), renderDialog: ({ config, onUpdate }) => config.kind === "expression" ? ( diff --git a/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx b/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx index 79ac2e531b..a726e027d7 100644 --- a/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx +++ b/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx @@ -1,6 +1,7 @@ import { Background, BackgroundVariant, + Controls, type EdgeTypes, type Node, type NodeTypes, @@ -52,10 +53,10 @@ function LayoutControls({ return ( - - @@ -267,7 +268,7 @@ export function CanvasLabPage(): ReactElement {
+
diff --git a/studio/frontend/src/features/canvas-lab/components/block-sheet.tsx b/studio/frontend/src/features/canvas-lab/components/block-sheet.tsx index 46a990722f..4e19b6e28e 100644 --- a/studio/frontend/src/features/canvas-lab/components/block-sheet.tsx +++ b/studio/frontend/src/features/canvas-lab/components/block-sheet.tsx @@ -53,20 +53,26 @@ function BlockSheetButton({ title, description, onClick, + isActive = false, }: { icon: typeof Database02Icon; title: string; description: string; onClick: () => void; + isActive?: boolean; }): ReactElement { return ( @@ -102,7 +114,7 @@ export function BlockSheet({ position="absolute" overlayPosition="absolute" className="absolute gap-0 p-0 shadow-none" - overlayClassName="bg-transparent pointer-events-none" + overlayClassName="bg-transparent pointer-events-none backdrop-blur-none supports-backdrop-filter:backdrop-blur-none" >
@@ -119,25 +131,28 @@ export function BlockSheet({ {title}
-
+
{view === "root" && - BLOCK_GROUPS.map((item) => ( + BLOCK_GROUPS.map((item, index) => ( onViewChange(item.kind)} /> ))} {view !== "root" && - getBlocksForKind(VIEW_KIND[view] ?? "sampler").map((item) => ( + getBlocksForKind(VIEW_KIND[view] ?? "sampler").map( + (item, index) => ( { if (item.kind === "sampler") { onAddSampler(item.type as SamplerType); @@ -148,7 +163,8 @@ export function BlockSheet({ } }} /> - ))} + ), + )}
diff --git a/studio/frontend/src/features/canvas-lab/components/canvas-node.tsx b/studio/frontend/src/features/canvas-lab/components/canvas-node.tsx index 282c2700ba..60283a6bea 100644 --- a/studio/frontend/src/features/canvas-lab/components/canvas-node.tsx +++ b/studio/frontend/src/features/canvas-lab/components/canvas-node.tsx @@ -1,36 +1,84 @@ import { cn } from "@/lib/utils"; import { + BalanceScaleIcon, + Clock01Icon, CodeIcon, - Database02Icon, - SparklesIcon, + CodeSimpleIcon, + DiceFaces03Icon, + EqualSignIcon, + FingerPrintIcon, + FunctionIcon, + Parabola02Icon, + PencilEdit02Icon, + Tag01Icon, + TagsIcon, + UserAccountIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import type { NodeProps } from "@xyflow/react"; import { Handle, Position, useUpdateNodeInternals } from "@xyflow/react"; import { type ReactElement, memo, useEffect } from "react"; -import type { CanvasNode as CanvasNodeType } from "../types"; +import type { + CanvasNode as CanvasNodeType, + LlmType, + SamplerType, +} from "../types"; + +type IconType = typeof CodeIcon; const NODE_META = { sampler: { - icon: Database02Icon, tone: "bg-emerald-50 text-emerald-600 border-emerald-100", }, llm: { - icon: SparklesIcon, tone: "bg-purple-50 text-purple-600 border-purple-100", }, expression: { - icon: CodeIcon, tone: "bg-sky-50 text-sky-600 border-sky-100", }, } as const; +const SAMPLER_ICONS: Record = { + category: Tag01Icon, + subcategory: TagsIcon, + uniform: EqualSignIcon, + gaussian: Parabola02Icon, + datetime: Clock01Icon, + uuid: FingerPrintIcon, + person: UserAccountIcon, + person_from_faker: UserAccountIcon, +}; + +const LLM_ICONS: Record = { + text: PencilEdit02Icon, + structured: CodeIcon, + code: CodeSimpleIcon, + judge: BalanceScaleIcon, +}; + +function resolveNodeIcon( + kind: CanvasNodeType["data"]["kind"], + blockType: CanvasNodeType["data"]["blockType"], +): IconType { + if (kind === "sampler" && blockType in SAMPLER_ICONS) { + return SAMPLER_ICONS[blockType as SamplerType]; + } + if (kind === "llm" && blockType in LLM_ICONS) { + return LLM_ICONS[blockType as LlmType]; + } + if (kind === "expression") { + return FunctionIcon; + } + return DiceFaces03Icon; +} + function CanvasNodeBase({ id, data, selected, }: NodeProps): ReactElement { const meta = NODE_META[data.kind]; + const icon = resolveNodeIcon(data.kind, data.blockType); const layoutDirection = data.layoutDirection ?? "LR"; const isHorizontal = layoutDirection === "LR"; const updateNodeInternals = useUpdateNodeInternals(); @@ -55,7 +103,7 @@ function CanvasNodeBase({ meta.tone, )} > - +

{data.title}

diff --git a/studio/frontend/src/features/canvas-lab/dialogs/llm/llm-dialog.tsx b/studio/frontend/src/features/canvas-lab/dialogs/llm/llm-dialog.tsx index e67289cb86..9d0a96a8ce 100644 --- a/studio/frontend/src/features/canvas-lab/dialogs/llm/llm-dialog.tsx +++ b/studio/frontend/src/features/canvas-lab/dialogs/llm/llm-dialog.tsx @@ -1,3 +1,4 @@ +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, @@ -8,7 +9,7 @@ import { } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import type { ReactElement } from "react"; -import type { LlmConfig } from "../../types"; +import type { LlmConfig, Score, ScoreOption } from "../../types"; import { NameField } from "../shared/name-field"; const CODE_LANG_OPTIONS = [ @@ -41,12 +42,69 @@ export function LlmDialog({ config, onUpdate }: LlmDialogProps): ReactElement { const promptId = `${config.id}-prompt`; const outputFormatId = `${config.id}-output-format`; const systemPromptId = `${config.id}-system-prompt`; + const scores = config.scores ?? []; const updateField = ( key: K, value: LlmConfig[K], ) => { onUpdate({ [key]: value } as Partial); }; + const updateScores = (next: Score[]) => updateField("scores", next); + const updateScore = (index: number, patch: Partial) => { + updateScores( + scores.map((score, i) => + i === index ? { ...score, ...patch } : score, + ), + ); + }; + const removeScore = (index: number) => { + updateScores(scores.filter((_, i) => i !== index)); + }; + const addScore = () => { + updateScores([ + ...scores, + { + name: "", + description: "", + options: [ + { value: "1", description: "" }, + { value: "5", description: "" }, + ], + }, + ]); + }; + const updateOption = ( + scoreIndex: number, + optionIndex: number, + patch: Partial, + ) => { + const score = scores[scoreIndex]; + if (!score) { + return; + } + const nextOptions = score.options.map((option, i) => + i === optionIndex ? { ...option, ...patch } : option, + ); + updateScore(scoreIndex, { options: nextOptions }); + }; + const addOption = (scoreIndex: number) => { + const score = scores[scoreIndex]; + if (!score) { + return; + } + updateScore(scoreIndex, { + options: [...score.options, { value: "", description: "" }], + }); + }; + const removeOption = (scoreIndex: number, optionIndex: number) => { + const score = scores[scoreIndex]; + if (!score) { + return; + } + updateScore(scoreIndex, { + options: score.options.filter((_, i) => i !== optionIndex), + }); + }; return (
updateField("prompt", event.target.value)} />
+ {config.llm_type === "judge" && ( +
+
+

+ Scores +

+ +
+ {scores.length === 0 && ( +

+ Add at least one score to define evaluation criteria. +

+ )} + {scores.map((score, index) => ( +
+
+
+ + updateScore(index, { name: event.target.value }) + } + /> +