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 8f54f594f1..5a5722bdca 100644 --- a/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx +++ b/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx @@ -18,8 +18,9 @@ import { EyeIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { previewCanvas } from "./api"; import { BlockSheet } from "./components/block-sheet"; -import { CanvasEdge } from "./components/canvas-edge"; import { CanvasNode } from "./components/canvas-node"; +import { CanvasSemanticEdge } from "./components/canvas-semantic-edge"; +import { DataEdge } from "./components/rf-ui/data-edge"; import { ConfigDialog } from "./dialogs/config-dialog"; import { ImportDialog } from "./dialogs/import-dialog"; import { ProcessorsDialog } from "./dialogs/processors-dialog"; @@ -31,7 +32,7 @@ import { buildCanvasPayload } from "./utils/payload"; import { buildDefaultSchemaTransform } from "./utils/processors"; const NODE_TYPES: NodeTypes = { builder: CanvasNode }; -const EDGE_TYPES: EdgeTypes = { canvas: CanvasEdge, semantic: CanvasEdge }; +const EDGE_TYPES: EdgeTypes = { canvas: DataEdge, semantic: CanvasSemanticEdge }; type LayoutControlsProps = { direction: "LR" | "TB"; @@ -83,7 +84,7 @@ export function CanvasLabPage(): ReactElement { addModelProviderNode, addModelConfigNode, addExpressionNode, - openConfig, + selectConfig, updateConfig, isValidConnection, setSheetView, @@ -110,7 +111,7 @@ export function CanvasLabPage(): ReactElement { addModelProviderNode: state.addModelProviderNode, addModelConfigNode: state.addModelConfigNode, addExpressionNode: state.addExpressionNode, - openConfig: state.openConfig, + selectConfig: state.selectConfig, updateConfig: state.updateConfig, isValidConnection: state.isValidConnection, setSheetView: state.setSheetView, @@ -134,9 +135,9 @@ export function CanvasLabPage(): ReactElement { const handleNodeClick = useCallback( (_: unknown, node: Node) => { - openConfig(node.id); + selectConfig(node.id); }, - [openConfig], + [selectConfig], ); const config = activeConfigId ? configs[activeConfigId] : null; @@ -310,7 +311,8 @@ export function CanvasLabPage(): ReactElement { edgeTypes={EDGE_TYPES} defaultEdgeOptions={{ type: "canvas", - style: { strokeWidth: 1.5, stroke: "#cbd5f5" }, + data: { key: "name", path: "smoothstep" }, + style: { strokeWidth: 1.5, stroke: "var(--border)" }, }} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} 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 b98206b6e6..d5540da30a 100644 --- a/studio/frontend/src/features/canvas-lab/components/canvas-node.tsx +++ b/studio/frontend/src/features/canvas-lab/components/canvas-node.tsx @@ -1,3 +1,4 @@ +import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { BalanceScaleIcon, @@ -17,15 +18,28 @@ import { 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 { Position, useUpdateNodeInternals, type NodeProps } from "@xyflow/react"; +import { memo, type ReactElement, useEffect } from "react"; +import { useCanvasLabStore } from "../stores/canvas-lab"; import type { CanvasNode as CanvasNodeType, LlmType, + NodeConfig, SamplerType, } from "../types"; import { HANDLE_IDS } from "../utils/handles"; +import { InlineExpression } from "./inline/inline-expression"; +import { InlineLlm } from "./inline/inline-llm"; +import { InlineModel } from "./inline/inline-model"; +import { isInlineConfig } from "./inline/inline-policy"; +import { InlineSampler } from "./inline/inline-sampler"; +import { + BaseNode, + BaseNodeContent, + BaseNodeHeader, + BaseNodeHeaderTitle, +} from "./rf-ui/base-node"; +import { LabeledHandle } from "./rf-ui/labeled-handle"; type IconType = typeof CodeIcon; @@ -34,16 +48,16 @@ const NODE_META = { tone: "bg-emerald-50 text-emerald-600 border-emerald-100", }, llm: { - tone: "bg-purple-50 text-purple-600 border-purple-100", + tone: "bg-sky-50 text-sky-600 border-sky-100", }, expression: { - tone: "bg-sky-50 text-sky-600 border-sky-100", + tone: "bg-indigo-50 text-indigo-600 border-indigo-100", }, model_provider: { tone: "bg-amber-50 text-amber-600 border-amber-100", }, model_config: { - tone: "bg-indigo-50 text-indigo-600 border-indigo-100", + tone: "bg-orange-50 text-orange-600 border-orange-100", }, } as const; @@ -89,92 +103,244 @@ function resolveNodeIcon( return DiceFaces03Icon; } -function CanvasNodeBase({ - id, - data, - selected, -}: NodeProps): ReactElement { +function toSingleLine(value: string | undefined): string { + if (!value) { + return ""; + } + const normalized = value.replace(/\s+/g, " ").trim(); + if (!normalized) { + return ""; + } + if (normalized.length <= 96) { + return normalized; + } + return `${normalized.slice(0, 93)}...`; +} + +function getConfigSummary(config: NodeConfig | undefined): string { + if (!config) { + return "Open details for config"; + } + + if (config.kind === "sampler") { + if (config.sampler_type === "category") { + const count = config.values?.length ?? 0; + return `${count} values`; + } + if (config.sampler_type === "subcategory") { + if (config.subcategory_parent?.trim()) { + return `Parent: ${config.subcategory_parent}`; + } + return "Select parent category"; + } + if (config.sampler_type === "datetime") { + const start = config.datetime_start?.trim() || "?"; + const end = config.datetime_end?.trim() || "?"; + return `${start} -> ${end}`; + } + if (config.sampler_type === "timedelta") { + if (config.reference_column_name?.trim()) { + return `Ref: ${config.reference_column_name}`; + } + return "Pick datetime reference"; + } + if ( + config.sampler_type === "person" || + config.sampler_type === "person_from_faker" + ) { + const locale = config.person_locale?.trim() || "any locale"; + const city = config.person_city?.trim(); + if (city) { + return `${locale} · ${city}`; + } + return locale; + } + return "Open details for config"; + } + + if (config.kind === "llm") { + const prompt = toSingleLine(config.prompt); + if (config.llm_type === "structured") { + if (prompt) { + return `Prompt: ${prompt}`; + } + return "Structured output schema in details"; + } + if (config.llm_type === "judge") { + const scoreCount = config.scores?.length ?? 0; + if (prompt) { + return `${scoreCount} scores · ${prompt}`; + } + return `${scoreCount} scores`; + } + return "Open details for config"; + } + + return "Open details for config"; +} + +function renderInlineEditor( + config: NodeConfig | undefined, + updateConfig: (id: string, patch: Partial) => void, +): ReactElement | null { + if (!config || !isInlineConfig(config)) { + return null; + } + + if (config.kind === "sampler") { + return ( + updateConfig(config.id, patch)} + /> + ); + } + + if (config.kind === "model_provider" || config.kind === "model_config") { + return ( + updateConfig(config.id, patch)} + /> + ); + } + + if (config.kind === "llm") { + return ( + updateConfig(config.id, patch)} /> + ); + } + + if (config.kind === "expression") { + return ( + updateConfig(config.id, patch)} + /> + ); + } + + return null; +} + +function CanvasNodeBase({ id, data }: NodeProps): ReactElement { const meta = NODE_META[data.kind]; const icon = resolveNodeIcon(data.kind, data.blockType); const layoutDirection = data.layoutDirection ?? "LR"; + const config = useCanvasLabStore((state) => state.configs[id]); + const openConfig = useCanvasLabStore((state) => state.openConfig); + const updateConfig = useCanvasLabStore((state) => state.updateConfig); const updateNodeInternals = useUpdateNodeInternals(); useEffect(() => { updateNodeInternals(id); - }, [id, layoutDirection, updateNodeInternals]); + }, [id, layoutDirection, config, updateNodeInternals]); const showDataHandles = - data.kind === "llm" || - data.kind === "expression" || - (data.kind === "sampler" && - data.blockType !== "model_provider" && - data.blockType !== "model_config"); - const showSemanticIn = - data.kind === "llm" || - data.kind === "model_config"; - const showSemanticOut = - data.kind === "llm" || - data.kind === "model_config" || - data.kind === "model_provider"; + data.kind === "llm" || data.kind === "expression" || data.kind === "sampler"; + const showSemanticIn = data.kind === "llm" || data.kind === "model_config"; + const showSemanticOut = data.kind === "model_config" || data.kind === "model_provider"; + const isTopBottom = layoutDirection === "TB"; + + const dataInPosition = isTopBottom ? Position.Top : Position.Left; + const dataOutPosition = isTopBottom ? Position.Bottom : Position.Right; + const semanticInPosition = isTopBottom ? Position.Left : Position.Top; + const semanticOutPosition = isTopBottom ? Position.Right : Position.Bottom; + + const inlineEditor = renderInlineEditor(config, updateConfig); + const summary = getConfigSummary(config); return ( -
-
-
+ +
+
+ +
+
+ + {data.title} + +

+ {data.subtype} · {data.name} +

+
+
+
-
-

{data.title}

-

- {data.subtype} · {data.name} -

-
-
+ Details + + + + + {inlineEditor ? ( + inlineEditor + ) : ( +

{summary}

+ )} +
+ {showDataHandles && ( <> - - )} + {showSemanticIn && ( - )} + {showSemanticOut && ( - )} -
+ ); } diff --git a/studio/frontend/src/features/canvas-lab/components/canvas-edge.tsx b/studio/frontend/src/features/canvas-lab/components/canvas-semantic-edge.tsx similarity index 59% rename from studio/frontend/src/features/canvas-lab/components/canvas-edge.tsx rename to studio/frontend/src/features/canvas-lab/components/canvas-semantic-edge.tsx index ca47bd35b1..e7eb0a03fc 100644 --- a/studio/frontend/src/features/canvas-lab/components/canvas-edge.tsx +++ b/studio/frontend/src/features/canvas-lab/components/canvas-semantic-edge.tsx @@ -1,7 +1,7 @@ import { BaseEdge, type EdgeProps, getSmoothStepPath } from "@xyflow/react"; import { memo, type ReactElement } from "react"; -export const CanvasEdge = memo(function CanvasEdge({ +export const CanvasSemanticEdge = memo(function CanvasSemanticEdge({ id, sourceX, sourceY, @@ -10,7 +10,7 @@ export const CanvasEdge = memo(function CanvasEdge({ sourcePosition, targetPosition, style, - type, + markerEnd, }: EdgeProps): ReactElement { const [path] = getSmoothStepPath({ sourceX, @@ -23,10 +23,17 @@ export const CanvasEdge = memo(function CanvasEdge({ offset: 16, }); - const nextStyle = - type === "semantic" - ? { ...style, strokeDasharray: "4 4" } - : style; - - return ; + return ( + + ); }); diff --git a/studio/frontend/src/features/canvas-lab/components/inline/inline-expression.tsx b/studio/frontend/src/features/canvas-lab/components/inline/inline-expression.tsx new file mode 100644 index 0000000000..aa3273a8bb --- /dev/null +++ b/studio/frontend/src/features/canvas-lab/components/inline/inline-expression.tsx @@ -0,0 +1,50 @@ +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { ReactElement } from "react"; +import type { ExpressionConfig, ExpressionDtype } from "../../types"; + +type InlineExpressionProps = { + config: ExpressionConfig; + onUpdate: (patch: Partial) => void; +}; + +const DTYPE_OPTIONS: ExpressionDtype[] = ["str", "int", "float", "bool"]; + +export function InlineExpression({ + config, + onUpdate, +}: InlineExpressionProps): ReactElement { + return ( +
+ + onUpdate({ expr: event.target.value })} + /> +
+ ); +} diff --git a/studio/frontend/src/features/canvas-lab/components/inline/inline-llm.tsx b/studio/frontend/src/features/canvas-lab/components/inline/inline-llm.tsx new file mode 100644 index 0000000000..d9d06298ae --- /dev/null +++ b/studio/frontend/src/features/canvas-lab/components/inline/inline-llm.tsx @@ -0,0 +1,83 @@ +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import type { ReactElement } from "react"; +import type { LlmConfig } from "../../types"; + +type InlineLlmProps = { + config: LlmConfig; + onUpdate: (patch: Partial) => void; +}; + +const CODE_LANG_OPTIONS = [ + "python", + "javascript", + "typescript", + "java", + "kotlin", + "go", + "rust", + "ruby", + "scala", + "swift", + "sql:sqlite", + "sql:postgres", + "sql:mysql", + "sql:tsql", + "sql:bigquery", + "sql:ansi", +] as const; + +export function InlineLlm({ config, onUpdate }: InlineLlmProps): ReactElement { + const isCode = config.llm_type === "code"; + + return ( +
+ + onUpdate({ + // biome-ignore lint/style/useNamingConvention: api schema + model_alias: event.target.value, + }) + } + /> + {isCode && ( + + )} +