From e1be762949fee824d88c4ef5d649e310e844636d Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Thu, 27 Feb 2025 23:55:39 +0100 Subject: [PATCH 01/11] wip on mux node UI --- package-lock.json | 219 ++++++++++++++++++++++++++++++++ package.json | 1 + src/Page.tsx | 2 + src/routes/route-mux-config.tsx | 144 +++++++++++++++++++++ 4 files changed, 366 insertions(+) create mode 100644 src/routes/route-mux-config.tsx diff --git a/package-lock.json b/package-lock.json index e9aa8b42..51653db3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@types/prismjs": "^1.26.5", "@types/react-syntax-highlighter": "^15.5.13", "@untitled-ui/icons-react": "^0.1.4", + "@xyflow/react": "^12.4.4", "clsx": "^2.1.1", "date-fns": "^4.1.0", "fuse.js": "^7.0.0", @@ -4536,6 +4537,55 @@ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -5230,6 +5280,36 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xyflow/react": { + "version": "12.4.4", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.4.4.tgz", + "integrity": "sha512-9RZ9dgKZNJOlbrXXST5HPb5TcXPOIDGondjwcjDro44OQRPl1E0ZRPTeWPGaQtVjbg4WpR4BUYwOeshNI2TuVg==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.52", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.52", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.52.tgz", + "integrity": "sha512-pJBMaoh/GEebIABWEIxAai0yf57dm+kH7J/Br+LnLFPuJL87Fhcmm4KFWd/bCUy/kCWUg+2/yFAGY0AUHRPOnQ==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -6029,6 +6109,12 @@ "consola": "^3.2.3" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -6345,6 +6431,111 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -15101,6 +15292,34 @@ "zod": "^3.18.0" } }, + "node_modules/zustand": { + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.6.tgz", + "integrity": "sha512-ibr/n1hBzLLj5Y+yUcU7dYw8p6WnIVzdJbnX+1YpaScvZVF2ziugqHs+LAmHw4lWO9c/zRj+K1ncgWDQuthEdQ==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 5fe1bab1..8cca6ce3 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@types/prismjs": "^1.26.5", "@types/react-syntax-highlighter": "^15.5.13", "@untitled-ui/icons-react": "^0.1.4", + "@xyflow/react": "^12.4.4", "clsx": "^2.1.1", "date-fns": "^4.1.0", "fuse.js": "^7.0.0", diff --git a/src/Page.tsx b/src/Page.tsx index e2d4c30d..6968face 100644 --- a/src/Page.tsx +++ b/src/Page.tsx @@ -11,6 +11,7 @@ import { RouteNotFound } from './routes/route-not-found' import { RouteProvider } from './routes/route-providers' import { RouteProviderCreate } from './routes/route-provider-create' import { RouteProviderUpdate } from './routes/route-provider-update' +import { RouteMuxes } from './routes/route-mux-config' export default function Page() { return ( @@ -20,6 +21,7 @@ export default function Page() { } /> } /> } /> + } /> } /> + setNodes((nds) => applyNodeChanges(changes, nds)) + const onEdgesChange = (changes) => + setEdges((eds) => applyEdgeChanges(changes, eds)) + const onConnect = (connection) => setEdges((eds) => addEdge(connection, eds)) + + const addMatcherNode = () => { + const newNode = { + id: `matcher-${nodes.length}`, + type: 'matcher', + data: { label: '', onChange: handleNodeChange }, + position: { x: 200, y: nodes.length * 100 }, + targetPosition: 'left', + sourcePosition: 'right', + } + setNodes((nds) => [...nds, newNode]) + } + + const addModelNode = () => { + const newNode = { + id: `model-${nodes.length}`, + type: 'model', + data: { label: 'Qwen', onChange: handleNodeChange }, + position: { x: 400, y: nodes.length * 100 }, + targetPosition: 'left', + } + setNodes((nds) => [...nds, newNode]) + } + + const handleNodeChange = (id, value) => { + setNodes((nds) => + nds.map((node) => + node.id === id + ? { ...node, data: { ...node.data, label: value } } + : node + ) + ) + } + + return ( + +
+
+ + +
+ + + + + +
+
+ ) +} + +const MatcherNode = ({ id, data }) => { + return ( + <> + + +
+ data.onChange(id, v)} + > + + +
+ + + ) +} + +const ModelNode = ({ id, data }) => { + return ( + <> + + +
+ +
+ + ) +} From 6795924b9e8e16a744d02e55bed31c2226049e70 Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Fri, 28 Feb 2025 00:13:51 +0100 Subject: [PATCH 02/11] wip constrain matchers and models to groups --- src/routes/route-mux-config.tsx | 50 +++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/src/routes/route-mux-config.tsx b/src/routes/route-mux-config.tsx index 532560c1..af745025 100644 --- a/src/routes/route-mux-config.tsx +++ b/src/routes/route-mux-config.tsx @@ -20,14 +20,36 @@ import { tv } from 'tailwind-variants' const nodeStyles = tv({ base: 'rounded border border-gray-200 bg-base p-4 shadow-sm', }) +const groupStyles = tv({ + base: 'h-auto min-h-64 rounded-lg !border !border-gray-400 bg-gray-50 stroke-gray-200', +}) -const initialNodes = [ +const initialNodes: Node[] = [ { id: 'prompt', type: 'input', data: { label: 'Prompt' }, position: { x: 0, y: 80 }, - sourcePosition: 'right', + sourcePosition: Position.Right, + draggable: false, + }, + { + id: 'matcher-group', + type: 'group', + data: { label: 'Matcher Group' }, + position: { x: 200, y: 0 }, + style: { width: 400 }, + draggable: false, + className: groupStyles(), + }, + { + id: 'model-group', + type: 'group', + data: { label: 'Model Group' }, + position: { x: 620, y: 0 }, + draggable: false, + style: { width: 400 }, + className: groupStyles(), }, ] @@ -44,24 +66,34 @@ export function RouteMuxes() { const onConnect = (connection) => setEdges((eds) => addEdge(connection, eds)) const addMatcherNode = () => { - const newNode = { + const newNode: Node = { id: `matcher-${nodes.length}`, type: 'matcher', data: { label: '', onChange: handleNodeChange }, - position: { x: 200, y: nodes.length * 100 }, - targetPosition: 'left', - sourcePosition: 'right', + position: { + x: 0, + y: nodes.filter((node) => node.id.startsWith('matcher')).length * 10, + }, + parentId: 'matcher-group', + extent: 'parent', + targetPosition: Position.Left, + sourcePosition: Position.Right, } setNodes((nds) => [...nds, newNode]) } const addModelNode = () => { - const newNode = { + const newNode: Node = { id: `model-${nodes.length}`, type: 'model', data: { label: 'Qwen', onChange: handleNodeChange }, - position: { x: 400, y: nodes.length * 100 }, - targetPosition: 'left', + position: { + x: 400, + y: nodes.filter((node) => node.id.startsWith('model')).length * 100, + }, + parentId: 'model-group', + extent: 'parent', + targetPosition: Position.Right, } setNodes((nds) => [...nds, newNode]) } From 2843ed59739fcaa4cdf5d78e80131fc7c1fd3d5b Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Fri, 28 Feb 2025 00:53:30 +0100 Subject: [PATCH 03/11] wip begin styling ndoes and fixing layout bugs --- src/routes/route-mux-config.tsx | 123 ++++++++++++++++++++++++++------ 1 file changed, 102 insertions(+), 21 deletions(-) diff --git a/src/routes/route-mux-config.tsx b/src/routes/route-mux-config.tsx index af745025..b5a9d1b7 100644 --- a/src/routes/route-mux-config.tsx +++ b/src/routes/route-mux-config.tsx @@ -14,14 +14,27 @@ import { import { useState } from 'react' import '@xyflow/react/dist/style.css' -import { Input, Select, SelectButton, TextField } from '@stacklok/ui-kit' +import { + Button, + ButtonDarkMode, + Heading, + Input, + Select, + SelectButton, + TextField, + Tooltip, + TooltipInfoButton, + TooltipTrigger, +} from '@stacklok/ui-kit' import { tv } from 'tailwind-variants' +import { PageHeading } from '@/components/heading' const nodeStyles = tv({ - base: 'rounded border border-gray-200 bg-base p-4 shadow-sm', + base: 'w-full rounded border border-gray-200 bg-base p-4 shadow-sm', }) const groupStyles = tv({ - base: 'h-auto min-h-64 rounded-lg !border !border-gray-400 bg-gray-50 stroke-gray-200', + base: `bg-gray-50/50 -z-10 h-auto min-h-64 rounded-lg !border !border-gray-200 + stroke-gray-200 backdrop-blur-sm`, }) const initialNodes: Node[] = [ @@ -29,27 +42,35 @@ const initialNodes: Node[] = [ id: 'prompt', type: 'input', data: { label: 'Prompt' }, - position: { x: 0, y: 80 }, + position: { x: 50, y: 50 }, + origin: [0.5, 0.5], sourcePosition: Position.Right, draggable: false, }, { id: 'matcher-group', - type: 'group', - data: { label: 'Matcher Group' }, + type: 'matcherGroup', + data: { + title: 'Matchers', + description: + 'Matchers use regex patterns to route requests to specific models.', + // onAddNode: addMatcherNode, + }, position: { x: 200, y: 0 }, style: { width: 400 }, draggable: false, - className: groupStyles(), }, { id: 'model-group', - type: 'group', - data: { label: 'Model Group' }, + type: 'modelGroup', + data: { + title: 'Model Group', + description: 'Add model nodes here', + // onAddNode: addModelNode, + }, position: { x: 620, y: 0 }, - draggable: false, style: { width: 400 }, - className: groupStyles(), + draggable: false, }, ] @@ -72,9 +93,10 @@ export function RouteMuxes() { data: { label: '', onChange: handleNodeChange }, position: { x: 0, - y: nodes.filter((node) => node.id.startsWith('matcher')).length * 10, + y: nodes.filter((node) => node.id.startsWith('matcher')).length * 80, }, parentId: 'matcher-group', + origin: [0.5, 0.5], extent: 'parent', targetPosition: Position.Left, sourcePosition: Position.Right, @@ -109,12 +131,21 @@ export function RouteMuxes() { } return ( - -
-
- - -
+ + +

+ Model muxing (or multiplexing), allows you to configure your AI + assistant once and use CodeGate workspaces to switch between LLM + providers and models without reconfiguring your development environment. +

+

+ Configure your IDE integration to send OpenAI-compatible requests to{' '} + + http://localhost:8989/v1/mux + {' '} + and configure the routing from here. +

+
( + + ), + modelGroup: (props) => ( + + ), }} > - - +
) } +const GroupNode = ({ + id, + data, +}: Partial & { + data: { + title: string + description: string + onAddNode: (id: string | undefined) => void + } +}) => { + return ( +
+
+ + {data.title} + + + + {data.description} + +
+
+ +
+
+ ) +} + +export default GroupNode + const MatcherNode = ({ id, data }) => { return ( <> @@ -146,7 +227,7 @@ const MatcherNode = ({ id, data }) => { value={data.label} onChange={(v) => data.onChange(id, v)} > - +
From 4d359a66c9ba83e91b11ebad293dac04b0744b46 Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Fri, 28 Feb 2025 00:58:19 +0100 Subject: [PATCH 04/11] wip more styling tweaks --- src/routes/route-mux-config.tsx | 39 +++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/src/routes/route-mux-config.tsx b/src/routes/route-mux-config.tsx index b5a9d1b7..5c1cf4c3 100644 --- a/src/routes/route-mux-config.tsx +++ b/src/routes/route-mux-config.tsx @@ -10,8 +10,9 @@ import { addEdge, Position, Handle, + ConnectionLineType, } from '@xyflow/react' -import { useState } from 'react' +import { useCallback, useState } from 'react' import '@xyflow/react/dist/style.css' import { @@ -28,6 +29,7 @@ import { } from '@stacklok/ui-kit' import { tv } from 'tailwind-variants' import { PageHeading } from '@/components/heading' +import { Plus } from '@untitled-ui/icons-react' const nodeStyles = tv({ base: 'w-full rounded border border-gray-200 bg-base p-4 shadow-sm', @@ -84,8 +86,17 @@ export function RouteMuxes() { setNodes((nds) => applyNodeChanges(changes, nds)) const onEdgesChange = (changes) => setEdges((eds) => applyEdgeChanges(changes, eds)) - const onConnect = (connection) => setEdges((eds) => addEdge(connection, eds)) + const onConnect = useCallback( + (params) => + setEdges((eds) => + addEdge( + { ...params, type: ConnectionLineType.SmoothStep, animated: true }, + eds + ) + ), + [] + ) const addMatcherNode = () => { const newNode: Node = { id: `matcher-${nodes.length}`, @@ -189,10 +200,10 @@ const GroupNode = ({ }) => { return (
-
+
{data.title} @@ -201,15 +212,15 @@ const GroupNode = ({ {data.description}
-
- -
+ +
) } From 3b2b9692088fb9de0ca780c072b5fab114c90048 Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Fri, 28 Feb 2025 01:01:28 +0100 Subject: [PATCH 05/11] wip *more* styling tweaks --- src/routes/route-mux-config.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/routes/route-mux-config.tsx b/src/routes/route-mux-config.tsx index 5c1cf4c3..c9d813fa 100644 --- a/src/routes/route-mux-config.tsx +++ b/src/routes/route-mux-config.tsx @@ -42,7 +42,7 @@ const groupStyles = tv({ const initialNodes: Node[] = [ { id: 'prompt', - type: 'input', + type: 'prompt', data: { label: 'Prompt' }, position: { x: 50, y: 50 }, origin: [0.5, 0.5], @@ -164,6 +164,7 @@ export function RouteMuxes() { onEdgesChange={onEdgesChange} onConnect={onConnect} nodeTypes={{ + prompt: PromptNode, matcher: MatcherNode, model: ModelNode, matcherGroup: (props) => ( @@ -227,6 +228,16 @@ const GroupNode = ({ export default GroupNode +const PromptNode = ({ id, data }) => { + return ( + <> +
+ Prompt +
+ + + ) +} const MatcherNode = ({ id, data }) => { return ( <> From c14f5874181fc2fb2bfe608dea27dfddc7faf6ec Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Fri, 28 Feb 2025 01:03:11 +0100 Subject: [PATCH 06/11] auto connect matchers to prompt --- src/routes/route-mux-config.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/routes/route-mux-config.tsx b/src/routes/route-mux-config.tsx index c9d813fa..02ab424a 100644 --- a/src/routes/route-mux-config.tsx +++ b/src/routes/route-mux-config.tsx @@ -17,7 +17,6 @@ import { useCallback, useState } from 'react' import '@xyflow/react/dist/style.css' import { Button, - ButtonDarkMode, Heading, Input, Select, @@ -56,7 +55,6 @@ const initialNodes: Node[] = [ title: 'Matchers', description: 'Matchers use regex patterns to route requests to specific models.', - // onAddNode: addMatcherNode, }, position: { x: 200, y: 0 }, style: { width: 400 }, @@ -68,7 +66,6 @@ const initialNodes: Node[] = [ data: { title: 'Model Group', description: 'Add model nodes here', - // onAddNode: addModelNode, }, position: { x: 620, y: 0 }, style: { width: 400 }, @@ -97,6 +94,7 @@ export function RouteMuxes() { ), [] ) + const addMatcherNode = () => { const newNode: Node = { id: `matcher-${nodes.length}`, @@ -113,6 +111,15 @@ export function RouteMuxes() { sourcePosition: Position.Right, } setNodes((nds) => [...nds, newNode]) + + const newEdge = { + id: `edge-${nodes.length}`, + source: 'prompt', + target: newNode.id, + type: ConnectionLineType.SmoothStep, + animated: true, + } + setEdges((eds) => [...eds, newEdge]) } const addModelNode = () => { @@ -226,8 +233,6 @@ const GroupNode = ({ ) } -export default GroupNode - const PromptNode = ({ id, data }) => { return ( <> @@ -238,6 +243,7 @@ const PromptNode = ({ id, data }) => { ) } + const MatcherNode = ({ id, data }) => { return ( <> From ac28d08949c53648a64a9de405d2324d8dd7089a Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Fri, 28 Feb 2025 01:11:18 +0100 Subject: [PATCH 07/11] add catch-all matcher node --- src/routes/route-mux-config.tsx | 65 ++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/src/routes/route-mux-config.tsx b/src/routes/route-mux-config.tsx index 02ab424a..8386ca8e 100644 --- a/src/routes/route-mux-config.tsx +++ b/src/routes/route-mux-config.tsx @@ -28,7 +28,7 @@ import { } from '@stacklok/ui-kit' import { tv } from 'tailwind-variants' import { PageHeading } from '@/components/heading' -import { Plus } from '@untitled-ui/icons-react' +import { Lock01, Plus } from '@untitled-ui/icons-react' const nodeStyles = tv({ base: 'w-full rounded border border-gray-200 bg-base p-4 shadow-sm', @@ -71,9 +71,28 @@ const initialNodes: Node[] = [ style: { width: 400 }, draggable: false, }, + { + id: 'matcher-0', + type: 'matcher', + data: { label: 'catch-all', isDisabled: true }, + position: { x: 0, y: 0 }, + parentId: 'matcher-group', + origin: [0.5, 0.5], + extent: 'parent', + targetPosition: Position.Left, + sourcePosition: Position.Right, + }, ] -const initialEdges = [] +const initialEdges = [ + { + id: 'edge-0', + source: 'prompt', + target: 'matcher-0', + type: ConnectionLineType.SmoothStep, + animated: true, + }, +] export function RouteMuxes() { const [nodes, setNodes] = useState(initialNodes) @@ -88,7 +107,12 @@ export function RouteMuxes() { (params) => setEdges((eds) => addEdge( - { ...params, type: ConnectionLineType.SmoothStep, animated: true }, + { + ...params, + type: ConnectionLineType.SmoothStep, + animated: true, + className: 'z-10', + }, eds ) ), @@ -219,16 +243,16 @@ const GroupNode = ({ {data.description} -
- + + ) } @@ -244,18 +268,31 @@ const PromptNode = ({ id, data }) => { ) } -const MatcherNode = ({ id, data }) => { +const MatcherNode = ({ + id, + data, +}: Partial & { + data: { + label: string + isDisabled?: boolean + onChange: (id: string | undefined, v: string) => void + } +}) => { return ( <>
data.onChange(id, v)} > - + : undefined} + placeholder="e.g. *.ts" + />
From 6020654efd1c858dae9f10c03bd102e57a21a43c Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Fri, 28 Feb 2025 01:42:50 +0100 Subject: [PATCH 08/11] tidy up initial POC --- src/routes/route-mux-config.tsx | 101 +++++++++++++++++++++++--------- 1 file changed, 72 insertions(+), 29 deletions(-) diff --git a/src/routes/route-mux-config.tsx b/src/routes/route-mux-config.tsx index 8386ca8e..06f88fff 100644 --- a/src/routes/route-mux-config.tsx +++ b/src/routes/route-mux-config.tsx @@ -17,6 +17,9 @@ import { useCallback, useState } from 'react' import '@xyflow/react/dist/style.css' import { Button, + ComboBox, + ComboBoxInput, + FieldGroup, Heading, Input, Select, @@ -28,14 +31,14 @@ import { } from '@stacklok/ui-kit' import { tv } from 'tailwind-variants' import { PageHeading } from '@/components/heading' -import { Lock01, Plus } from '@untitled-ui/icons-react' +import { Lock01, Plus, SearchMd } from '@untitled-ui/icons-react' const nodeStyles = tv({ base: 'w-full rounded border border-gray-200 bg-base p-4 shadow-sm', }) const groupStyles = tv({ - base: `bg-gray-50/50 -z-10 h-auto min-h-64 rounded-lg !border !border-gray-200 - stroke-gray-200 backdrop-blur-sm`, + base: `bg-gray-50/50 -z-10 h-auto min-h-[calc(100%-48px)] rounded-lg !border + !border-gray-200 stroke-gray-200 backdrop-blur-sm`, }) const initialNodes: Node[] = [ @@ -43,7 +46,7 @@ const initialNodes: Node[] = [ id: 'prompt', type: 'prompt', data: { label: 'Prompt' }, - position: { x: 50, y: 50 }, + position: { x: 50, y: 114 }, origin: [0.5, 0.5], sourcePosition: Position.Right, draggable: false, @@ -56,8 +59,11 @@ const initialNodes: Node[] = [ description: 'Matchers use regex patterns to route requests to specific models.', }, - position: { x: 200, y: 0 }, - style: { width: 400 }, + position: { x: 200, y: 24 }, + style: { + width: 500, + height: '100%', + }, draggable: false, }, { @@ -67,15 +73,21 @@ const initialNodes: Node[] = [ title: 'Model Group', description: 'Add model nodes here', }, - position: { x: 620, y: 0 }, - style: { width: 400 }, + position: { x: 720, y: 24 }, + style: { + width: 500, + height: '100%', + }, draggable: false, }, { id: 'matcher-0', type: 'matcher', data: { label: 'catch-all', isDisabled: true }, - position: { x: 0, y: 0 }, + position: { + x: 250, + y: 90, + }, parentId: 'matcher-group', origin: [0.5, 0.5], extent: 'parent', @@ -84,13 +96,23 @@ const initialNodes: Node[] = [ }, ] +const EDGE = { + type: ConnectionLineType.Bezier, + animated: true, +} + const initialEdges = [ { id: 'edge-0', source: 'prompt', target: 'matcher-0', - type: ConnectionLineType.SmoothStep, - animated: true, + ...EDGE, + }, + { + id: 'edge-1', + source: 'matcher-0', + target: 'model-0', + ...EDGE, }, ] @@ -101,7 +123,15 @@ export function RouteMuxes() { const onNodesChange = (changes) => setNodes((nds) => applyNodeChanges(changes, nds)) const onEdgesChange = (changes) => - setEdges((eds) => applyEdgeChanges(changes, eds)) + setEdges((eds) => + applyEdgeChanges( + { + ...changes, + ...EDGE, + }, + eds + ) + ) const onConnect = useCallback( (params) => @@ -109,9 +139,7 @@ export function RouteMuxes() { addEdge( { ...params, - type: ConnectionLineType.SmoothStep, - animated: true, - className: 'z-10', + ...EDGE, }, eds ) @@ -125,8 +153,8 @@ export function RouteMuxes() { type: 'matcher', data: { label: '', onChange: handleNodeChange }, position: { - x: 0, - y: nodes.filter((node) => node.id.startsWith('matcher')).length * 80, + x: 250, + y: nodes.filter((node) => node.id.startsWith('matcher')).length * 90, }, parentId: 'matcher-group', origin: [0.5, 0.5], @@ -140,7 +168,7 @@ export function RouteMuxes() { id: `edge-${nodes.length}`, source: 'prompt', target: newNode.id, - type: ConnectionLineType.SmoothStep, + type: ConnectionLineType.Bezier, animated: true, } setEdges((eds) => [...eds, newEdge]) @@ -152,9 +180,10 @@ export function RouteMuxes() { type: 'model', data: { label: 'Qwen', onChange: handleNodeChange }, position: { - x: 400, - y: nodes.filter((node) => node.id.startsWith('model')).length * 100, + x: 250, + y: nodes.filter((node) => node.id.startsWith('model')).length * 90, }, + origin: [0.5, 0.5], parentId: 'model-group', extent: 'parent', targetPosition: Position.Right, @@ -232,10 +261,10 @@ const GroupNode = ({ }) => { return (
-
+
{data.title} @@ -250,7 +279,7 @@ const GroupNode = ({ onPress={() => data.onAddNode(id)} > - Add + Add Node
@@ -306,16 +335,30 @@ const ModelNode = ({ id, data }) => {
- + + } + isBorderless + placeholder="Search for a model..." + /> + +
) From 519eb769d7f941d3e1afe421e126a39990b60b43 Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Fri, 28 Feb 2025 16:38:41 +0100 Subject: [PATCH 09/11] basic notion of ordering nodes --- src/routes/route-mux-config.tsx | 82 +++++++++++++++++++++++++++------ 1 file changed, 69 insertions(+), 13 deletions(-) diff --git a/src/routes/route-mux-config.tsx b/src/routes/route-mux-config.tsx index 06f88fff..48e5de0a 100644 --- a/src/routes/route-mux-config.tsx +++ b/src/routes/route-mux-config.tsx @@ -41,16 +41,9 @@ const groupStyles = tv({ !border-gray-200 stroke-gray-200 backdrop-blur-sm`, }) +const GRID_SIZE = 90 + const initialNodes: Node[] = [ - { - id: 'prompt', - type: 'prompt', - data: { label: 'Prompt' }, - position: { x: 50, y: 114 }, - origin: [0.5, 0.5], - sourcePosition: Position.Right, - draggable: false, - }, { id: 'matcher-group', type: 'matcherGroup', @@ -80,6 +73,15 @@ const initialNodes: Node[] = [ }, draggable: false, }, + { + id: 'prompt', + type: 'prompt', + data: { label: 'Prompt' }, + position: { x: 50, y: 114 }, + origin: [0.5, 0.5], + sourcePosition: Position.Right, + draggable: false, + }, { id: 'matcher-0', type: 'matcher', @@ -148,13 +150,16 @@ export function RouteMuxes() { ) const addMatcherNode = () => { + const matcherNodes = nodes.filter( + (node) => node.type === 'matcher' && node.id !== 'matcher-0' + ) const newNode: Node = { - id: `matcher-${nodes.length}`, + id: `matcher-${matcherNodes.length + 1}`, type: 'matcher', data: { label: '', onChange: handleNodeChange }, position: { x: 250, - y: nodes.filter((node) => node.id.startsWith('matcher')).length * 90, + y: matcherNodes.length * GRID_SIZE, }, parentId: 'matcher-group', origin: [0.5, 0.5], @@ -162,7 +167,10 @@ export function RouteMuxes() { targetPosition: Position.Left, sourcePosition: Position.Right, } - setNodes((nds) => [...nds, newNode]) + setNodes((nds) => { + const updatedNodes = [...nds, newNode] + return alignNodesToGrid(updatedNodes) + }) const newEdge = { id: `edge-${nodes.length}`, @@ -181,7 +189,9 @@ export function RouteMuxes() { data: { label: 'Qwen', onChange: handleNodeChange }, position: { x: 250, - y: nodes.filter((node) => node.id.startsWith('model')).length * 90, + y: + nodes.filter((node) => node.id.startsWith('model')).length * + GRID_SIZE, }, origin: [0.5, 0.5], parentId: 'model-group', @@ -201,6 +211,51 @@ export function RouteMuxes() { ) } + const alignNodesToGrid = (nodes) => { + const matcherNodes = nodes.filter( + (node) => node.type === 'matcher' && node.id !== 'matcher-0' + ) + const catchAllNode = nodes.find((node) => node.id === 'matcher-0') + + matcherNodes.sort((a, b) => a.position.y - b.position.y) + + matcherNodes.forEach((node, index) => { + node.position.y = index * GRID_SIZE + }) + + if (catchAllNode) { + catchAllNode.position.y = matcherNodes.length * GRID_SIZE + } + + return [ + ...matcherNodes, + catchAllNode, + ...nodes.filter((node) => node.type !== 'matcher'), + ] + } + + const onDragStop = useCallback( + (event, node) => { + const { project } = useReactFlow() + if (node.type === 'matcher' && node.id !== 'matcher-0') { + const updatedNodes = nodes.map((n) => { + if (n.id === node.id) { + return { + ...n, + position: project({ + x: node.position.x, + y: Math.round(node.position.y / GRID_SIZE) * GRID_SIZE, + }), + } + } + return n + }) + setNodes(alignNodesToGrid(updatedNodes)) + } + }, + [nodes] + ) + return ( @@ -223,6 +278,7 @@ export function RouteMuxes() { onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} + onNodeDragStop={onDragStop} nodeTypes={{ prompt: PromptNode, matcher: MatcherNode, From a7104a7ecc65cf0e147dc3d92dde8bba2f0140e3 Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Fri, 7 Mar 2025 16:14:01 +0000 Subject: [PATCH 10/11] fix aspects of ordering --- src/components/icons/icon-regex.tsx | 21 ++ src/routes/route-mux-config.tsx | 289 ++++++++++++++++++++-------- 2 files changed, 226 insertions(+), 84 deletions(-) create mode 100644 src/components/icons/icon-regex.tsx diff --git a/src/components/icons/icon-regex.tsx b/src/components/icons/icon-regex.tsx new file mode 100644 index 00000000..fd6b16d6 --- /dev/null +++ b/src/components/icons/icon-regex.tsx @@ -0,0 +1,21 @@ +import type { SVGProps } from 'react' + +export const IconRegex = (props: SVGProps) => ( + + + + + + +) diff --git a/src/routes/route-mux-config.tsx b/src/routes/route-mux-config.tsx index 48e5de0a..3e8026bc 100644 --- a/src/routes/route-mux-config.tsx +++ b/src/routes/route-mux-config.tsx @@ -11,8 +11,9 @@ import { Position, Handle, ConnectionLineType, + useReactFlow, } from '@xyflow/react' -import { useCallback, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import '@xyflow/react/dist/style.css' import { @@ -31,7 +32,15 @@ import { } from '@stacklok/ui-kit' import { tv } from 'tailwind-variants' import { PageHeading } from '@/components/heading' -import { Lock01, Plus, SearchMd } from '@untitled-ui/icons-react' +import { + ChartBreakoutCircle, + Lock01, + Plus, + SearchMd, +} from '@untitled-ui/icons-react' +import { twMerge } from 'tailwind-merge' +import SvgDrag from '@/components/icons/Drag' +import { IconRegex } from '@/components/icons/icon-regex' const nodeStyles = tv({ base: 'w-full rounded border border-gray-200 bg-base p-4 shadow-sm', @@ -42,53 +51,79 @@ const groupStyles = tv({ }) const GRID_SIZE = 90 +const PADDING_GROUP = 12 +const HEIGHT_GROUP_HEADER = 40 +const HEIGHT_NODE = 74 +const HEIGHT_CONTAINER = 512 +const WIDTH_GROUP = 500 +const WIDTH_NODE = WIDTH_GROUP - PADDING_GROUP * 2 + +enum NodeType { + MATCHER_GROUP = 'matcherGroup', + MODEL_GROUP = 'modelGroup', + PROMPT = 'prompt', + MATCHER = 'matcher', + MODEL = 'model', +} + +function computeGroupNodeY(index: number) { + return ( + PADDING_GROUP * 4 + + HEIGHT_GROUP_HEADER + + HEIGHT_NODE * index + + PADDING_GROUP * index + ) +} const initialNodes: Node[] = [ + { + id: 'prompt', + type: NodeType.PROMPT, + data: { label: 'Prompt' }, + position: { x: 50, y: HEIGHT_CONTAINER / 2 }, + origin: [0.5, 0.5], + sourcePosition: Position.Right, + draggable: false, + }, { id: 'matcher-group', - type: 'matcherGroup', + type: NodeType.MATCHER_GROUP, data: { title: 'Matchers', description: 'Matchers use regex patterns to route requests to specific models.', }, - position: { x: 200, y: 24 }, + position: { x: 200, y: HEIGHT_CONTAINER / 2 - HEIGHT_GROUP_HEADER / 2 }, + origin: [0, 0.5], style: { - width: 500, - height: '100%', + width: WIDTH_GROUP, + // height: '100%', }, draggable: false, }, { id: 'model-group', - type: 'modelGroup', + type: NodeType.MODEL_GROUP, data: { - title: 'Model Group', + title: 'Models', description: 'Add model nodes here', }, - position: { x: 720, y: 24 }, + position: { x: 720, y: HEIGHT_CONTAINER / 2 - HEIGHT_GROUP_HEADER / 2 }, + origin: [0, 0.5], style: { - width: 500, - height: '100%', + width: WIDTH_GROUP, + // height: '100%', }, draggable: false, }, - { - id: 'prompt', - type: 'prompt', - data: { label: 'Prompt' }, - position: { x: 50, y: 114 }, - origin: [0.5, 0.5], - sourcePosition: Position.Right, - draggable: false, - }, + { id: 'matcher-0', - type: 'matcher', + type: NodeType.MATCHER, data: { label: 'catch-all', isDisabled: true }, position: { x: 250, - y: 90, + y: computeGroupNodeY(0), }, parentId: 'matcher-group', origin: [0.5, 0.5], @@ -118,6 +153,40 @@ const initialEdges = [ }, ] +/** + * Ensures correct ordering of "matcher" nodes, + * both visually, and in the list of nodes. + */ +function alignMatcherNodes(nodes: Node[]) { + const matcherNodes = nodes.filter((n) => n.type === NodeType.MATCHER) + + // Ensure that the last matcher node is always + // `matcher-0` (the catch-all matcher) + if ( + matcherNodes.length > 0 && + matcherNodes[matcherNodes.length - 1]?.id !== 'matcher-0' + ) { + const catchallNodeIndex = matcherNodes.findIndex( + (n) => n.id === 'matcher-0' + ) + if (catchallNodeIndex !== -1) { + const fallbackNode = matcherNodes.splice(catchallNodeIndex, 1)[0] + if (fallbackNode) matcherNodes.push(fallbackNode) + } + } + + // Update Y position of matcher nodes, so that their + // visual position reflects their position in the list + matcherNodes.forEach((n, i) => { + n.position.y = computeGroupNodeY(i) + }) + + // Re-integrate the matcher nodes into the node list + return nodes.map((n) => + n.type === NodeType.MATCHER ? matcherNodes.shift() : n + ) +} + export function RouteMuxes() { const [nodes, setNodes] = useState(initialNodes) const [edges, setEdges] = useState(initialEdges) @@ -158,8 +227,8 @@ export function RouteMuxes() { type: 'matcher', data: { label: '', onChange: handleNodeChange }, position: { - x: 250, - y: matcherNodes.length * GRID_SIZE, + x: WIDTH_GROUP / 2, + y: computeGroupNodeY(0), }, parentId: 'matcher-group', origin: [0.5, 0.5], @@ -169,7 +238,7 @@ export function RouteMuxes() { } setNodes((nds) => { const updatedNodes = [...nds, newNode] - return alignNodesToGrid(updatedNodes) + return alignMatcherNodes(updatedNodes) }) const newEdge = { @@ -182,10 +251,10 @@ export function RouteMuxes() { setEdges((eds) => [...eds, newEdge]) } - const addModelNode = () => { + const addModelNode = useCallback(() => { const newNode: Node = { id: `model-${nodes.length}`, - type: 'model', + type: NodeType.MODEL, data: { label: 'Qwen', onChange: handleNodeChange }, position: { x: 250, @@ -199,7 +268,7 @@ export function RouteMuxes() { targetPosition: Position.Right, } setNodes((nds) => [...nds, newNode]) - } + }, [nodes]) const handleNodeChange = (id, value) => { setNodes((nds) => @@ -211,50 +280,27 @@ export function RouteMuxes() { ) } - const alignNodesToGrid = (nodes) => { - const matcherNodes = nodes.filter( - (node) => node.type === 'matcher' && node.id !== 'matcher-0' - ) - const catchAllNode = nodes.find((node) => node.id === 'matcher-0') - - matcherNodes.sort((a, b) => a.position.y - b.position.y) - - matcherNodes.forEach((node, index) => { - node.position.y = index * GRID_SIZE - }) - - if (catchAllNode) { - catchAllNode.position.y = matcherNodes.length * GRID_SIZE - } - - return [ - ...matcherNodes, - catchAllNode, - ...nodes.filter((node) => node.type !== 'matcher'), - ] - } - - const onDragStop = useCallback( - (event, node) => { - const { project } = useReactFlow() - if (node.type === 'matcher' && node.id !== 'matcher-0') { - const updatedNodes = nodes.map((n) => { - if (n.id === node.id) { - return { - ...n, - position: project({ - x: node.position.x, - y: Math.round(node.position.y / GRID_SIZE) * GRID_SIZE, - }), - } - } - return n - }) - setNodes(alignNodesToGrid(updatedNodes)) - } - }, - [nodes] - ) + const onNodeDragStop = useCallback((event, node) => { + console.debug('👉 node:', node) + console.debug('👉 event:', event) + // const { project } = useReactFlow() + // console.debug('👉 project:', project) + // if (node.type === NodeType.MATCHER && node.id !== 'matcher-0') { + // const updatedNodes = nodes.map((n) => { + // if (n.id === node.id) { + // return { + // ...n, + // position: project({ + // x: node.position.x, + // y: Math.round(node.position.y / GRID_SIZE) * GRID_SIZE, + // }), + // } + // } + // return n + // }) + // setNodes(alignMatcherNodes(updatedNodes)) + // } + }, []) return ( @@ -271,14 +317,20 @@ export function RouteMuxes() { {' '} and configure the routing from here.

-
+
console.log('onNodeDragStop', args)} + onNodeDragStart={(...args) => console.log('onNodeDragStart', args)} nodeTypes={{ prompt: PromptNode, matcher: MatcherNode, @@ -286,13 +338,23 @@ export function RouteMuxes() { matcherGroup: (props) => ( n.type === NodeType.MATCHER) + .length, + }} /> ), modelGroup: (props) => ( n.type === NodeType.MODEL) + .length, + }} /> ), }} @@ -308,21 +370,37 @@ export function RouteMuxes() { const GroupNode = ({ id, data, + ...rest }: Partial & { data: { title: string description: string onAddNode: (id: string | undefined) => void + numNodes: number } }) => { + console.debug('👉 data:', rest) return (
-
+
- {data.title} + {data.title} ({data.numNodes}) @@ -330,7 +408,7 @@ const GroupNode = ({