canvaslab v1
This commit is contained in:
parent
931891b207
commit
1d5d1b625b
13 changed files with 429 additions and 118 deletions
1
studio/frontend/.gitignore
vendored
1
studio/frontend/.gitignore
vendored
|
|
@ -10,6 +10,7 @@ lerna-debug.log*
|
|||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
test/
|
||||
*.local
|
||||
.env
|
||||
.env.*
|
||||
|
|
|
|||
|
|
@ -30,13 +30,17 @@ function SheetPortal({
|
|||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
position = "fixed",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay> & {
|
||||
position?: "fixed" | "absolute";
|
||||
}) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50",
|
||||
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs inset-0 z-50",
|
||||
position === "fixed" ? "fixed" : "absolute",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -49,19 +53,31 @@ function SheetContent({
|
|||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
container,
|
||||
position = "fixed",
|
||||
overlayClassName,
|
||||
overlayPosition,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
showCloseButton?: boolean;
|
||||
container?: HTMLElement | null;
|
||||
position?: "fixed" | "absolute";
|
||||
overlayClassName?: string;
|
||||
overlayPosition?: "fixed" | "absolute";
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPortal container={container ?? undefined}>
|
||||
<SheetOverlay
|
||||
className={overlayClassName}
|
||||
position={overlayPosition ?? position}
|
||||
/>
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"bg-background data-open:animate-in data-closed:animate-out data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 fixed z-50 flex flex-col bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||
"bg-background data-open:animate-in data-closed:animate-out data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 z-50 flex flex-col bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||
position === "fixed" ? "fixed" : "absolute",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export function CanvasLabPage(): ReactElement {
|
|||
onConnect,
|
||||
addSamplerNode,
|
||||
addLlmNode,
|
||||
addExpressionNode,
|
||||
openConfig,
|
||||
updateConfig,
|
||||
isValidConnection,
|
||||
|
|
@ -55,6 +56,7 @@ export function CanvasLabPage(): ReactElement {
|
|||
onConnect: state.onConnect,
|
||||
addSamplerNode: state.addSamplerNode,
|
||||
addLlmNode: state.addLlmNode,
|
||||
addExpressionNode: state.addExpressionNode,
|
||||
openConfig: state.openConfig,
|
||||
updateConfig: state.updateConfig,
|
||||
isValidConnection: state.isValidConnection,
|
||||
|
|
@ -183,6 +185,7 @@ export function CanvasLabPage(): ReactElement {
|
|||
onViewChange={setSheetView}
|
||||
onAddSampler={addSamplerNode}
|
||||
onAddLlm={addLlmNode}
|
||||
onAddExpression={addExpressionNode}
|
||||
/>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { HugeiconsIcon } from "@hugeicons/react";
|
|||
import type { ReactElement } from "react";
|
||||
import type { LlmType, SamplerType } from "../types";
|
||||
|
||||
type SheetView = "root" | "sampler" | "llm";
|
||||
type SheetView = "root" | "sampler" | "llm" | "expression";
|
||||
|
||||
type BlockSheetProps = {
|
||||
container: HTMLDivElement | null;
|
||||
|
|
@ -27,6 +27,7 @@ type BlockSheetProps = {
|
|||
onViewChange: (view: SheetView) => void;
|
||||
onAddSampler: (type: SamplerType) => void;
|
||||
onAddLlm: (type: LlmType) => void;
|
||||
onAddExpression: () => void;
|
||||
};
|
||||
|
||||
function getSheetTitle(view: SheetView): string {
|
||||
|
|
@ -36,6 +37,9 @@ function getSheetTitle(view: SheetView): string {
|
|||
if (view === "sampler") {
|
||||
return "Sampler blocks";
|
||||
}
|
||||
if (view === "expression") {
|
||||
return "Expression blocks";
|
||||
}
|
||||
return "LLM blocks";
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +56,12 @@ const MAIN_SHEET_ITEMS = [
|
|||
description: "Text + structured blocks.",
|
||||
icon: SparklesIcon,
|
||||
},
|
||||
{
|
||||
kind: "expression" as const,
|
||||
title: "Expression",
|
||||
description: "Derived columns with Jinja.",
|
||||
icon: CodeIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const SAMPLER_ITEMS = [
|
||||
|
|
@ -120,12 +130,31 @@ const LLM_ITEMS = [
|
|||
},
|
||||
];
|
||||
|
||||
const EXPRESSION_ITEMS = [
|
||||
{
|
||||
title: "Expression",
|
||||
description: "Transform columns with Jinja.",
|
||||
icon: CodeIcon,
|
||||
},
|
||||
];
|
||||
|
||||
function nextViewForKind(kind: "sampler" | "llm" | "expression"): SheetView {
|
||||
if (kind === "sampler") {
|
||||
return "sampler";
|
||||
}
|
||||
if (kind === "expression") {
|
||||
return "expression";
|
||||
}
|
||||
return "llm";
|
||||
}
|
||||
|
||||
export function BlockSheet({
|
||||
container,
|
||||
view,
|
||||
onViewChange,
|
||||
onAddSampler,
|
||||
onAddLlm,
|
||||
onAddExpression,
|
||||
}: BlockSheetProps): ReactElement {
|
||||
const title = getSheetTitle(view);
|
||||
return (
|
||||
|
|
@ -138,8 +167,10 @@ export function BlockSheet({
|
|||
<SheetContent
|
||||
side="right"
|
||||
container={container}
|
||||
position="absolute"
|
||||
overlayPosition="absolute"
|
||||
className="absolute gap-0 p-0 shadow-none"
|
||||
overlayClassName="absolute inset-0 bg-transparent backdrop-blur-0 supports-backdrop-filter:backdrop-blur-0 data-open:fade-in-0 data-closed:fade-out-0"
|
||||
overlayClassName="bg-transparent pointer-events-none"
|
||||
>
|
||||
<SheetHeader className="border-b border-border/60 px-6 py-5">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -163,9 +194,7 @@ export function BlockSheet({
|
|||
<button
|
||||
key={item.kind}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onViewChange(item.kind === "sampler" ? "sampler" : "llm")
|
||||
}
|
||||
onClick={() => onViewChange(nextViewForKind(item.kind))}
|
||||
className="flex w-full items-center gap-3 rounded-2xl border border-border/60 bg-white px-3 py-3 text-left transition hover:border-border hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex size-9 items-center justify-center rounded-xl border border-border bg-muted/30 text-muted-foreground">
|
||||
|
|
@ -235,6 +264,31 @@ export function BlockSheet({
|
|||
/>
|
||||
</button>
|
||||
))}
|
||||
{view === "expression" &&
|
||||
EXPRESSION_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.title}
|
||||
type="button"
|
||||
onClick={onAddExpression}
|
||||
className="flex w-full items-center gap-3 rounded-2xl border border-border/60 bg-white px-3 py-3 text-left transition hover:border-border hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex size-9 items-center justify-center rounded-xl border border-border bg-muted/30 text-muted-foreground">
|
||||
<HugeiconsIcon icon={item.icon} className="size-4" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowRight01Icon}
|
||||
className="size-3.5 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { Database02Icon, SparklesIcon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
CodeIcon,
|
||||
Database02Icon,
|
||||
SparklesIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
|
|
@ -15,6 +19,10 @@ const NODE_META = {
|
|||
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;
|
||||
|
||||
function CanvasNodeBase({
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { UniformDialog } from "./samplers/uniform-dialog";
|
|||
import { UuidDialog } from "./samplers/uuid-dialog";
|
||||
import { DialogShell } from "./shared/dialog-shell";
|
||||
import { ValidationBanner } from "./shared/validation-banner";
|
||||
import { ExpressionDialog } from "./expression/expression-dialog";
|
||||
|
||||
type ConfigDialogProps = {
|
||||
open: boolean;
|
||||
|
|
@ -93,6 +94,12 @@ export function ConfigDialog({
|
|||
onUpdate={(patch) => onUpdate(config.id, patch)}
|
||||
/>
|
||||
)}
|
||||
{config.kind === "expression" && (
|
||||
<ExpressionDialog
|
||||
config={config}
|
||||
onUpdate={(patch) => onUpdate(config.id, patch)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { ReactElement } from "react";
|
||||
import type { ExpressionConfig, ExpressionDtype } from "../../types";
|
||||
import { NameField } from "../shared/name-field";
|
||||
|
||||
const DTYPE_OPTIONS: ExpressionDtype[] = ["str", "int", "float", "bool"];
|
||||
|
||||
type ExpressionDialogProps = {
|
||||
config: ExpressionConfig;
|
||||
onUpdate: (patch: Partial<ExpressionConfig>) => void;
|
||||
};
|
||||
|
||||
export function ExpressionDialog({
|
||||
config,
|
||||
onUpdate,
|
||||
}: ExpressionDialogProps): ReactElement {
|
||||
const dtypeId = `${config.id}-dtype`;
|
||||
const exprId = `${config.id}-expr`;
|
||||
const updateField = <K extends keyof ExpressionConfig>(
|
||||
key: K,
|
||||
value: ExpressionConfig[K],
|
||||
) => {
|
||||
onUpdate({ [key]: value } as Partial<ExpressionConfig>);
|
||||
};
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<NameField
|
||||
value={config.name}
|
||||
onChange={(value) => onUpdate({ name: value })}
|
||||
/>
|
||||
<div className="grid gap-2">
|
||||
<label
|
||||
className="text-xs font-semibold uppercase text-muted-foreground"
|
||||
htmlFor={dtypeId}
|
||||
>
|
||||
Output type
|
||||
</label>
|
||||
<Select
|
||||
value={config.dtype}
|
||||
onValueChange={(value) =>
|
||||
updateField("dtype", value as ExpressionDtype)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="nodrag w-full" id={dtypeId}>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DTYPE_OPTIONS.map((dtype) => (
|
||||
<SelectItem key={dtype} value={dtype}>
|
||||
{dtype}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label
|
||||
className="text-xs font-semibold uppercase text-muted-foreground"
|
||||
htmlFor={exprId}
|
||||
>
|
||||
Expression (Jinja2)
|
||||
</label>
|
||||
<Textarea
|
||||
id={exprId}
|
||||
className="nodrag"
|
||||
placeholder="{{ category_1 }} - {{ subcategory_1 }}"
|
||||
value={config.expr}
|
||||
onChange={(event) => updateField("expr", event.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use Jinja2. Reference columns like {"{{ column_name }}"}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -122,6 +122,9 @@ export function LlmDialog({ config, onUpdate }: LlmDialogProps): ReactElement {
|
|||
updateField("output_format", event.target.value)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paste a JSON schema object or minimal shape.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
type EdgeChange,
|
||||
type IsValidConnection,
|
||||
type NodeChange,
|
||||
addEdge,
|
||||
applyEdgeChanges,
|
||||
applyNodeChanges,
|
||||
} from "@xyflow/react";
|
||||
|
|
@ -19,12 +18,14 @@ import type {
|
|||
import {
|
||||
isCategoryConfig,
|
||||
isSubcategoryConfig,
|
||||
makeExpressionConfig,
|
||||
makeLlmConfig,
|
||||
makeSamplerConfig,
|
||||
nodeDataFromConfig,
|
||||
} from "../utils";
|
||||
import { applyCanvasConnection, isValidCanvasConnection } from "../utils/graph";
|
||||
|
||||
type SheetView = "root" | "sampler" | "llm";
|
||||
type SheetView = "root" | "sampler" | "llm" | "expression";
|
||||
|
||||
type CanvasLabState = {
|
||||
nodes: CanvasNode[];
|
||||
|
|
@ -40,6 +41,7 @@ type CanvasLabState = {
|
|||
openConfig: (id: string) => void;
|
||||
addSamplerNode: (type: SamplerType) => void;
|
||||
addLlmNode: (type: LlmType) => void;
|
||||
addExpressionNode: () => void;
|
||||
updateConfig: (id: string, patch: Partial<NodeConfig>) => void;
|
||||
onNodesChange: (changes: NodeChange<CanvasNode>[]) => void;
|
||||
onEdgesChange: (changes: EdgeChange<Edge>[]) => void;
|
||||
|
|
@ -57,16 +59,6 @@ function updateNodeData(
|
|||
);
|
||||
}
|
||||
|
||||
function buildPromptWithRef(prompt: string, ref: string): string {
|
||||
if (prompt.includes(ref)) {
|
||||
return prompt;
|
||||
}
|
||||
if (prompt.trim()) {
|
||||
return `${prompt}\n${ref}`;
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
|
||||
function findNodeIdByName(
|
||||
configs: Record<string, NodeConfig>,
|
||||
name: string,
|
||||
|
|
@ -77,34 +69,6 @@ function findNodeIdByName(
|
|||
return entry ? entry[0] : null;
|
||||
}
|
||||
|
||||
function syncSubcategoryMapping(
|
||||
subcategory: SamplerConfig,
|
||||
parent: NodeConfig,
|
||||
): SamplerConfig {
|
||||
if (!isCategoryConfig(parent)) {
|
||||
return {
|
||||
...subcategory,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
subcategory_parent: parent.name,
|
||||
};
|
||||
}
|
||||
const nextMapping: Record<string, string[]> = {
|
||||
...(subcategory.subcategory_mapping ?? {}),
|
||||
};
|
||||
for (const value of parent.values ?? []) {
|
||||
if (!nextMapping[value]) {
|
||||
nextMapping[value] = [];
|
||||
}
|
||||
}
|
||||
return {
|
||||
...subcategory,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
subcategory_parent: parent.name,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
subcategory_mapping: nextMapping,
|
||||
};
|
||||
}
|
||||
|
||||
export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
|
|
@ -159,6 +123,27 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
|
|||
};
|
||||
});
|
||||
},
|
||||
addExpressionNode: () => {
|
||||
set((state) => {
|
||||
const id = `n${state.nextId}`;
|
||||
const existing = Object.values(state.configs);
|
||||
const config = makeExpressionConfig(id, existing);
|
||||
const node: CanvasNode = {
|
||||
id,
|
||||
type: "builder",
|
||||
position: { x: 0, y: state.nextY },
|
||||
data: nodeDataFromConfig(config),
|
||||
};
|
||||
return {
|
||||
configs: { ...state.configs, [id]: config },
|
||||
nodes: [...state.nodes, node],
|
||||
nextId: state.nextId + 1,
|
||||
nextY: state.nextY + 140,
|
||||
activeConfigId: id,
|
||||
dialogOpen: true,
|
||||
};
|
||||
});
|
||||
},
|
||||
updateConfig: (id, patch) => {
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: store update
|
||||
const applyUpdate = (state: CanvasLabState) => {
|
||||
|
|
@ -289,55 +274,17 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
|
|||
set((state) => ({ edges: applyEdgeChanges(changes, state.edges) }));
|
||||
},
|
||||
onConnect: (connection) => {
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: store update
|
||||
const applyConnect = (state: CanvasLabState) => {
|
||||
const source = connection.source
|
||||
? state.configs[connection.source]
|
||||
: undefined;
|
||||
const target = connection.target
|
||||
? state.configs[connection.target]
|
||||
: undefined;
|
||||
if (isSubcategoryConfig(target) && !isCategoryConfig(source)) {
|
||||
return state;
|
||||
}
|
||||
const edges = addEdge(connection, state.edges);
|
||||
if (!(connection.source && connection.target)) {
|
||||
return { edges };
|
||||
}
|
||||
if (!(source && target)) {
|
||||
return { edges };
|
||||
}
|
||||
const sourceName = source.name;
|
||||
let configs = state.configs;
|
||||
|
||||
if (target.kind === "llm") {
|
||||
const ref = `{{ ${sourceName} }}`;
|
||||
const nextPrompt = buildPromptWithRef(target.prompt ?? "", ref);
|
||||
const next = { ...target, prompt: nextPrompt };
|
||||
configs = { ...configs, [target.id]: next };
|
||||
return { edges, configs };
|
||||
}
|
||||
|
||||
if (isSubcategoryConfig(target)) {
|
||||
const next = syncSubcategoryMapping(target, source);
|
||||
configs = { ...configs, [target.id]: next };
|
||||
return { edges, configs };
|
||||
}
|
||||
|
||||
return { edges };
|
||||
};
|
||||
set(applyConnect);
|
||||
},
|
||||
isValidConnection: (connection) => {
|
||||
if (!(connection.source && connection.target)) {
|
||||
return false;
|
||||
}
|
||||
const configs = get().configs;
|
||||
const source = configs[connection.source];
|
||||
const target = configs[connection.target];
|
||||
if (isSubcategoryConfig(target)) {
|
||||
return isCategoryConfig(source);
|
||||
}
|
||||
return connection.source !== connection.target;
|
||||
set((state) => {
|
||||
const result = applyCanvasConnection(
|
||||
connection,
|
||||
state.configs,
|
||||
state.edges,
|
||||
);
|
||||
return result.configs
|
||||
? { edges: result.edges, configs: result.configs }
|
||||
: { edges: result.edges };
|
||||
});
|
||||
},
|
||||
isValidConnection: (connection) =>
|
||||
isValidCanvasConnection(connection, get().configs),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -11,10 +11,12 @@ export type SamplerType =
|
|||
|
||||
export type LlmType = "text" | "structured" | "code";
|
||||
|
||||
export type ExpressionDtype = "str" | "int" | "float" | "bool";
|
||||
|
||||
export type CanvasNodeData = {
|
||||
title: string;
|
||||
name: string;
|
||||
kind: "sampler" | "llm";
|
||||
kind: "sampler" | "llm" | "expression";
|
||||
subtype: string;
|
||||
};
|
||||
|
||||
|
|
@ -75,4 +77,12 @@ export type LlmConfig = {
|
|||
output_format?: string;
|
||||
};
|
||||
|
||||
export type NodeConfig = SamplerConfig | LlmConfig;
|
||||
export type ExpressionConfig = {
|
||||
id: string;
|
||||
kind: "expression";
|
||||
name: string;
|
||||
expr: string;
|
||||
dtype: ExpressionDtype;
|
||||
};
|
||||
|
||||
export type NodeConfig = SamplerConfig | LlmConfig | ExpressionConfig;
|
||||
|
|
|
|||
107
studio/frontend/src/features/canvas-lab/utils/graph.ts
Normal file
107
studio/frontend/src/features/canvas-lab/utils/graph.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { type Connection, type Edge, addEdge } from "@xyflow/react";
|
||||
import type { NodeConfig, SamplerConfig } from "../types";
|
||||
import {
|
||||
isCategoryConfig,
|
||||
isExpressionConfig,
|
||||
isLlmConfig,
|
||||
isSubcategoryConfig,
|
||||
} from "./index";
|
||||
|
||||
function buildTemplateWithRef(template: string, ref: string): string {
|
||||
if (template.includes(ref)) {
|
||||
return template;
|
||||
}
|
||||
if (template.trim()) {
|
||||
return `${template}\n${ref}`;
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
|
||||
function syncSubcategoryMapping(
|
||||
subcategory: SamplerConfig,
|
||||
parent: NodeConfig,
|
||||
): SamplerConfig {
|
||||
if (!isCategoryConfig(parent)) {
|
||||
return {
|
||||
...subcategory,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
subcategory_parent: parent.name,
|
||||
};
|
||||
}
|
||||
const nextMapping: Record<string, string[]> = {
|
||||
...(subcategory.subcategory_mapping ?? {}),
|
||||
};
|
||||
for (const value of parent.values ?? []) {
|
||||
if (!nextMapping[value]) {
|
||||
nextMapping[value] = [];
|
||||
}
|
||||
}
|
||||
return {
|
||||
...subcategory,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
subcategory_parent: parent.name,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
subcategory_mapping: nextMapping,
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidCanvasConnection(
|
||||
connection: Connection,
|
||||
configs: Record<string, NodeConfig>,
|
||||
): boolean {
|
||||
if (!(connection.source && connection.target)) {
|
||||
return false;
|
||||
}
|
||||
if (connection.source === connection.target) {
|
||||
return false;
|
||||
}
|
||||
const source = configs[connection.source];
|
||||
const target = configs[connection.target];
|
||||
if (!(source && target)) {
|
||||
return false;
|
||||
}
|
||||
if (isSubcategoryConfig(target)) {
|
||||
return isCategoryConfig(source);
|
||||
}
|
||||
if (isLlmConfig(target) || isExpressionConfig(target)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function applyCanvasConnection(
|
||||
connection: Connection,
|
||||
configs: Record<string, NodeConfig>,
|
||||
edges: Edge[],
|
||||
): { edges: Edge[]; configs?: Record<string, NodeConfig> } {
|
||||
if (!isValidCanvasConnection(connection, configs)) {
|
||||
return { edges };
|
||||
}
|
||||
const source = connection.source ? configs[connection.source] : null;
|
||||
const target = connection.target ? configs[connection.target] : null;
|
||||
if (!(source && target)) {
|
||||
return { edges };
|
||||
}
|
||||
const nextEdges = addEdge(connection, edges);
|
||||
if (isLlmConfig(target)) {
|
||||
const ref = `{{ ${source.name} }}`;
|
||||
const next = {
|
||||
...target,
|
||||
prompt: buildTemplateWithRef(target.prompt ?? "", ref),
|
||||
};
|
||||
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
|
||||
}
|
||||
if (isExpressionConfig(target)) {
|
||||
const ref = `{{ ${source.name} }}`;
|
||||
const next = {
|
||||
...target,
|
||||
expr: buildTemplateWithRef(target.expr ?? "", ref),
|
||||
};
|
||||
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
|
||||
}
|
||||
if (isSubcategoryConfig(target)) {
|
||||
const next = syncSubcategoryMapping(target, source);
|
||||
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
|
||||
}
|
||||
return { edges: nextEdges };
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import type {
|
||||
CanvasNodeData,
|
||||
ExpressionConfig,
|
||||
ExpressionDtype,
|
||||
LlmConfig,
|
||||
LlmType,
|
||||
NodeConfig,
|
||||
|
|
@ -23,6 +25,13 @@ const LLM_LABELS: Record<LlmType, string> = {
|
|||
code: "LLM Code",
|
||||
};
|
||||
|
||||
const EXPRESSION_LABELS: Record<ExpressionDtype, string> = {
|
||||
str: "Text",
|
||||
int: "Int",
|
||||
float: "Float",
|
||||
bool: "Bool",
|
||||
};
|
||||
|
||||
export function nextName(existing: NodeConfig[], prefix: string): string {
|
||||
const counts = existing
|
||||
.map((item) => item.name)
|
||||
|
|
@ -161,8 +170,8 @@ export function makeLlmConfig(
|
|||
llm_type: llmType,
|
||||
name,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
model_alias: "local-text",
|
||||
prompt: "Write a response about {{ sampler_1 }}.",
|
||||
model_alias: "stepfun/step-3.5-flash:free",
|
||||
prompt: "Write a response.",
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
system_prompt: "",
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
|
|
@ -173,6 +182,19 @@ export function makeLlmConfig(
|
|||
};
|
||||
}
|
||||
|
||||
export function makeExpressionConfig(
|
||||
id: string,
|
||||
existing: NodeConfig[],
|
||||
): ExpressionConfig {
|
||||
return {
|
||||
id,
|
||||
kind: "expression",
|
||||
name: nextName(existing, "expr"),
|
||||
expr: "",
|
||||
dtype: "str",
|
||||
};
|
||||
}
|
||||
|
||||
export function labelForSampler(type: SamplerType): string {
|
||||
return SAMPLER_LABELS[type] ?? "Sampler";
|
||||
}
|
||||
|
|
@ -181,6 +203,10 @@ export function labelForLlm(type: LlmType): string {
|
|||
return LLM_LABELS[type] ?? "LLM";
|
||||
}
|
||||
|
||||
export function labelForExpression(type: ExpressionDtype): string {
|
||||
return EXPRESSION_LABELS[type] ?? "Expression";
|
||||
}
|
||||
|
||||
export function nodeDataFromConfig(config: NodeConfig): CanvasNodeData {
|
||||
if (config.kind === "sampler") {
|
||||
return {
|
||||
|
|
@ -190,6 +216,14 @@ export function nodeDataFromConfig(config: NodeConfig): CanvasNodeData {
|
|||
name: config.name,
|
||||
};
|
||||
}
|
||||
if (config.kind === "expression") {
|
||||
return {
|
||||
title: "Expression",
|
||||
kind: "expression",
|
||||
subtype: labelForExpression(config.dtype),
|
||||
name: config.name,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "LLM",
|
||||
kind: "llm",
|
||||
|
|
@ -228,6 +262,12 @@ export function isLlmConfig(
|
|||
return Boolean(config && config.kind === "llm");
|
||||
}
|
||||
|
||||
export function isExpressionConfig(
|
||||
config: NodeConfig | null | undefined,
|
||||
): config is ExpressionConfig {
|
||||
return Boolean(config && config.kind === "expression");
|
||||
}
|
||||
|
||||
function parseNumber(value?: string): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
|
|
@ -296,16 +336,29 @@ export function getConfigErrors(config: NodeConfig | null): string[] {
|
|||
if (config.kind === "llm" && !config.prompt.trim()) {
|
||||
errors.push("Prompt is required.");
|
||||
}
|
||||
if (config.kind === "llm" && config.llm_type === "code") {
|
||||
if (!config.code_lang) {
|
||||
errors.push("Code language is required.");
|
||||
}
|
||||
}
|
||||
if (
|
||||
config.kind === "llm" &&
|
||||
config.llm_type === "structured" &&
|
||||
typeof config.output_format === "string" &&
|
||||
config.output_format.trim()
|
||||
typeof config.output_format === "string"
|
||||
) {
|
||||
try {
|
||||
JSON.parse(config.output_format);
|
||||
} catch {
|
||||
errors.push("Output format must be valid JSON.");
|
||||
if (!config.output_format.trim()) {
|
||||
errors.push("Output format is required.");
|
||||
} else {
|
||||
try {
|
||||
JSON.parse(config.output_format);
|
||||
} catch {
|
||||
errors.push("Output format must be valid JSON.");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (config.kind === "expression") {
|
||||
if (!config.expr.trim()) {
|
||||
errors.push("Expression is required.");
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Edge } from "@xyflow/react";
|
||||
import type {
|
||||
CanvasNode,
|
||||
ExpressionConfig,
|
||||
LlmConfig,
|
||||
NodeConfig,
|
||||
SamplerConfig,
|
||||
|
|
@ -182,6 +183,22 @@ function buildLlmColumn(
|
|||
};
|
||||
}
|
||||
|
||||
function buildExpressionColumn(
|
||||
config: ExpressionConfig,
|
||||
errors: string[],
|
||||
): Record<string, unknown> {
|
||||
if (!config.expr.trim()) {
|
||||
errors.push(`Expression ${config.name}: expr required.`);
|
||||
}
|
||||
return {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
column_type: "expression",
|
||||
name: config.name,
|
||||
expr: config.expr,
|
||||
dtype: config.dtype,
|
||||
};
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: payload build
|
||||
export function buildCanvasPayload(
|
||||
configs: Record<string, NodeConfig>,
|
||||
|
|
@ -217,14 +234,16 @@ export function buildCanvasPayload(
|
|||
sampler_type: config.sampler_type,
|
||||
params: buildSamplerParams(config, errors),
|
||||
});
|
||||
continue;
|
||||
} else if (config.kind === "llm") {
|
||||
columns.push(buildLlmColumn(config, errors));
|
||||
if (config.model_alias) {
|
||||
modelAliases.add(config.model_alias);
|
||||
}
|
||||
nameToConfig.set(config.name, config);
|
||||
} else {
|
||||
columns.push(buildExpressionColumn(config, errors));
|
||||
nameToConfig.set(config.name, config);
|
||||
}
|
||||
|
||||
columns.push(buildLlmColumn(config, errors));
|
||||
if (config.model_alias) {
|
||||
modelAliases.add(config.model_alias);
|
||||
}
|
||||
nameToConfig.set(config.name, config);
|
||||
}
|
||||
|
||||
for (const config of Object.values(configs)) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue