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() { <Route path="/certificates" element={<RouteCertificates />} /> <Route path="/workspace/:name" element={<RouteWorkspace />} /> <Route path="/workspaces" element={<RouteWorkspaces />} /> + <Route path="/muxes" element={<RouteMuxes />} /> <Route path="/workspace/create" element={<RouteWorkspaceCreation />} /> <Route path="/certificates/security" 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<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + {...props} + > + <path d="M17 3v10" /> + <path d="m12.67 5.5 8.66 5" /> + <path d="m12.67 10.5 8.66-5" /> + <path d="M9 17a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-2z" /> + </svg> +) diff --git a/src/features/muxing/components/mux-node-base.tsx b/src/features/muxing/components/mux-node-base.tsx new file mode 100644 index 00000000..74b3fed2 --- /dev/null +++ b/src/features/muxing/components/mux-node-base.tsx @@ -0,0 +1,55 @@ +import { Handle, Position } from '@xyflow/react' +import * as config from '../constants/mux-node-config' +import { twMerge } from 'tailwind-merge' +import { tv } from 'tailwind-variants' +import { CSSProperties, ReactNode } from 'react' + +const nodeStyles = tv({ + base: 'w-full overflow-hidden rounded border border-gray-200 bg-base shadow-sm', +}) + +export const MuxNodeBase = ({ + hasSourceRight, + hasTargetLeft, + children, + className, + icon: Icon, + style, +}: { + icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element + className?: string + children: ReactNode + hasTargetLeft?: boolean + hasSourceRight?: boolean + style?: CSSProperties +}) => { + return ( + <> + {hasTargetLeft ? <Handle type="target" position={Position.Left} /> : null} + + <div + style={{ + height: config.HEIGHT_NODE, + ...style, + }} + className={twMerge(nodeStyles(), 'flex items-center')} + > + <div + className="flex shrink-0 items-center justify-center bg-gray-50" + style={{ + height: config.HEIGHT_NODE, + width: config.HEIGHT_NODE, + }} + > + <Icon className="size-5" /> + </div> + + <div className={twMerge('w-full px-4 py-3', className)}>{children}</div> + </div> + + {hasSourceRight ? ( + <Handle type="source" position={Position.Right} /> + ) : null} + </> + ) +} diff --git a/src/features/muxing/components/mux-node-matcher.tsx b/src/features/muxing/components/mux-node-matcher.tsx new file mode 100644 index 00000000..823c1baa --- /dev/null +++ b/src/features/muxing/components/mux-node-matcher.tsx @@ -0,0 +1,69 @@ +import { MuxNodeBase } from './mux-node-base' +import { Lock01 } from '@untitled-ui/icons-react' +import * as config from '../constants/mux-node-config' +import SvgDrag from '@/components/icons/Drag' +import { + FieldGroup, + Input, + Select, + SelectButton, + TextField, +} from '@stacklok/ui-kit' +import { IconRegex } from '@/components/icons/icon-regex' +import { Node } from '@xyflow/react' + +export const MuxNodeMatcher = ({ + id, + data, +}: Partial<Node> & { + data: { + isDisabled?: boolean + onChange: (id: string | undefined, v: string) => void + } +}) => { + return ( + <MuxNodeBase + style={{ + width: config.WIDTH_NODE, + }} + hasSourceRight + hasTargetLeft + icon={IconRegex} + className="" + > + <FieldGroup className="grid w-full grid-cols-[1fr_2fr] items-center"> + <Select + defaultSelectedKey={'all'} + items={[ + { + textValue: 'All', + id: 'all', + }, + { + textValue: 'FIM', + id: 'fim', + }, + { + textValue: 'Chat', + id: 'chat', + }, + ]} + > + <SelectButton isBorderless className="border-r border-r-gray-200" /> + </Select> + <TextField + isDisabled={data.isDisabled} + type="text" + aria-label="Matcher" + onChange={(v) => data.onChange(id, v)} + > + <Input + isBorderless + icon={data.isDisabled ? <Lock01 /> : null} + placeholder="e.g. *.ts" + /> + </TextField> + </FieldGroup> + </MuxNodeBase> + ) +} diff --git a/src/features/muxing/components/mux-node-prompt.tsx b/src/features/muxing/components/mux-node-prompt.tsx new file mode 100644 index 00000000..f7be4239 --- /dev/null +++ b/src/features/muxing/components/mux-node-prompt.tsx @@ -0,0 +1,10 @@ +import { MuxNodeBase } from './mux-node-base' +import { MessageDotsCircle } from '@untitled-ui/icons-react' + +export const MuxNodePrompt = () => { + return ( + <MuxNodeBase hasSourceRight icon={MessageDotsCircle}> + <span className="font-semibold">Prompt</span> + </MuxNodeBase> + ) +} diff --git a/src/features/muxing/constants/mux-node-config.tsx b/src/features/muxing/constants/mux-node-config.tsx new file mode 100644 index 00000000..74296c85 --- /dev/null +++ b/src/features/muxing/constants/mux-node-config.tsx @@ -0,0 +1,7 @@ +export const GRID_SIZE = 90 +export const PADDING_GROUP = 12 +export const HEIGHT_GROUP_HEADER = 40 +export const HEIGHT_NODE = 64 +export const HEIGHT_CONTAINER = 512 +export const WIDTH_GROUP = 500 +export const WIDTH_NODE = WIDTH_GROUP - PADDING_GROUP * 2 diff --git a/src/routes/route-mux-config.tsx b/src/routes/route-mux-config.tsx new file mode 100644 index 00000000..b847f2a6 --- /dev/null +++ b/src/routes/route-mux-config.tsx @@ -0,0 +1,550 @@ +import { PageContainer } from '@/components/page-container' +import { + Node, + ReactFlow, + Controls, + Background, + applyNodeChanges, + applyEdgeChanges, + addEdge, + Position, + Handle, + ConnectionLineType, + OnNodeDrag, +} from '@xyflow/react' +import { useCallback, useState } from 'react' +import * as config from '../features/muxing/constants/mux-node-config' +import '@xyflow/react/dist/style.css' +import { + Button, + ComboBox, + ComboBoxInput, + FieldGroup, + Heading, + Input, + Select, + SelectButton, + TextField, + Tooltip, + TooltipInfoButton, + TooltipTrigger, +} from '@stacklok/ui-kit' +import { tv } from 'tailwind-variants' +import { PageHeading } from '@/components/heading' +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' +import { MuxNodePrompt } from '@/features/muxing/components/mux-node-prompt' +import { MuxNodeMatcher } from '@/features/muxing/components/mux-node-matcher' + +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-[calc(100%-48px)] rounded-lg !border + !border-gray-200 stroke-gray-200 backdrop-blur-sm`, +}) + +enum NodeType { + MATCHER_GROUP = 'matcherGroup', + MODEL_GROUP = 'modelGroup', + PROMPT = 'prompt', + MATCHER = 'matcher', + MODEL = 'model', +} + +function computeGroupNodeY(index: number) { + return ( + config.PADDING_GROUP * 4 + + config.HEIGHT_GROUP_HEADER + + config.HEIGHT_NODE * index + + config.PADDING_GROUP * index + ) +} + +const initialNodes: Node[] = [ + { + id: 'prompt', + type: NodeType.PROMPT, + data: { label: 'Prompt' }, + position: { x: 50, y: config.HEIGHT_CONTAINER / 2 }, + origin: [0.5, 0.5], + sourcePosition: Position.Right, + draggable: false, + }, + { + id: 'matcher-group', + type: NodeType.MATCHER_GROUP, + data: { + title: 'Matchers', + description: + 'Matchers use regex patterns to route requests to specific models.', + }, + position: { + x: 200, + y: config.HEIGHT_CONTAINER / 2 - config.HEIGHT_GROUP_HEADER / 2, + }, + origin: [0, 0.5], + style: { + width: config.WIDTH_GROUP, + // height: '100%', + }, + draggable: false, + }, + { + id: 'model-group', + type: NodeType.MODEL_GROUP, + data: { + title: 'Models', + description: 'Add model nodes here', + }, + position: { + x: 720, + y: config.HEIGHT_CONTAINER / 2 - config.HEIGHT_GROUP_HEADER / 2, + }, + origin: [0, 0.5], + style: { + width: config.WIDTH_GROUP, + // height: '100%', + }, + draggable: false, + }, + + { + id: 'matcher-0', + type: NodeType.MATCHER, + data: { label: 'catch-all', isDisabled: true }, + position: { + x: 250, + y: computeGroupNodeY(0), + }, + parentId: 'matcher-group', + origin: [0.5, 0.5], + extent: 'parent', + targetPosition: Position.Left, + sourcePosition: Position.Right, + }, +] + +const EDGE = { + type: ConnectionLineType.Bezier, + animated: true, +} + +const initialEdges = [ + { + id: 'edge-0', + source: 'prompt', + target: 'matcher-0', + ...EDGE, + }, + { + id: 'edge-1', + source: 'matcher-0', + target: 'model-0', + ...EDGE, + }, +] + +/** + * 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 + ) +} + +function isOverlapping(node1: Node, node2: Node) { + const node1Bottom = node1.position.y + config.HEIGHT_NODE + const node2Bottom = node2.position.y + config.HEIGHT_NODE + + return ( + node1.position.y < node2Bottom && + node1Bottom > node2.position.y && + node1.position.x < node2.position.x + config.WIDTH_NODE && + node1.position.x + config.WIDTH_NODE > node2.position.x + ) +} + +export function RouteMuxes() { + const [nodes, setNodes] = useState(initialNodes) + const [edges, setEdges] = useState(initialEdges) + + const onNodesChange = (changes) => + setNodes((nds) => applyNodeChanges(changes, nds)) + const onEdgesChange = (changes) => + setEdges((eds) => + applyEdgeChanges( + { + ...changes, + ...EDGE, + }, + eds + ) + ) + + const onConnect = useCallback( + (params) => + setEdges((eds) => + addEdge( + { + ...params, + ...EDGE, + }, + eds + ) + ), + [] + ) + + const addMatcherNode = () => { + const matcherNodes = nodes.filter( + (node) => node.type === 'matcher' && node.id !== 'matcher-0' + ) + const newNode: Node = { + id: `matcher-${matcherNodes.length + 1}`, + type: 'matcher', + data: { label: '', onChange: handleNodeChange }, + position: { + x: config.WIDTH_GROUP / 2, + y: computeGroupNodeY(0), + }, + parentId: 'matcher-group', + origin: [0.5, 0.5], + extent: 'parent', + targetPosition: Position.Left, + sourcePosition: Position.Right, + } + setNodes((nds) => { + const updatedNodes = [...nds, newNode] + return alignMatcherNodes(updatedNodes) + }) + + const newEdge = { + id: `edge-${nodes.length}`, + source: 'prompt', + target: newNode.id, + type: ConnectionLineType.Bezier, + animated: true, + } + setEdges((eds) => [...eds, newEdge]) + } + + const addModelNode = useCallback(() => { + const newNode: Node = { + id: `model-${nodes.length}`, + type: NodeType.MODEL, + data: { label: 'Qwen', onChange: handleNodeChange }, + position: { + x: 250, + y: + nodes.filter((node) => node.id.startsWith('model')).length * + GRID_SIZE, + }, + origin: [0.5, 0.5], + parentId: 'model-group', + extent: 'parent', + targetPosition: Position.Right, + } + setNodes((nds) => [...nds, newNode]) + }, [nodes]) + + const handleNodeChange = (id, value) => { + setNodes((nds) => + nds.map((node) => + node.id === id + ? { ...node, data: { ...node.data, label: value } } + : node + ) + ) + } + + const onNodeDragStop = useCallback<OnNodeDrag<Node>>((event, node) => { + setNodes((nds) => { + const updatedNodes = nds.map((n) => + n.id === node.id ? { ...n, position: node.position } : n + ) + + const overlappingNode = updatedNodes.find( + (n) => n.id !== node.id && isOverlapping(n, node) + ) + + if (overlappingNode) { + const nodeIndex = updatedNodes.findIndex((n) => n.id === node.id) + const overlappingNodeIndex = updatedNodes.findIndex( + (n) => n.id === overlappingNode.id + ) + + if (nodeIndex !== -1 && overlappingNodeIndex !== -1) { + const temp = updatedNodes[nodeIndex] + if ( + updatedNodes[overlappingNodeIndex] && + updatedNodes[nodeIndex] && + temp + ) { + updatedNodes[nodeIndex] = updatedNodes[overlappingNodeIndex] + updatedNodes[overlappingNodeIndex] = temp + + updatedNodes[nodeIndex].position.y = computeGroupNodeY(nodeIndex) + updatedNodes[overlappingNodeIndex].position.y = + computeGroupNodeY(overlappingNodeIndex) + } + } + } + + return alignMatcherNodes(updatedNodes) + }) + }, []) + + return ( + <PageContainer className="flex min-h-dvh flex-col"> + <PageHeading level={1} title="Muxing" /> + <p className="mb-2 max-w-6xl text-balance text-secondary"> + 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. + </p> + <p className="mb-8 max-w-6xl text-balance text-secondary"> + Configure your IDE integration to send OpenAI-compatible requests to{' '} + <code className="rounded-sm border border-gray-200 bg-gray-50 px-1 py-0.5"> + http://localhost:8989/v1/mux + </code>{' '} + and configure the routing from here. + </p> + <div + style={{ + height: config.HEIGHT_CONTAINER, + }} + className="border border-gray-200" + > + <ReactFlow + nodes={nodes} + edges={edges} + onNodesChange={onNodesChange} + onEdgesChange={onEdgesChange} + onConnect={onConnect} + onNodeDragStop={(...args) => console.log('onNodeDragStop', args)} + onNodeDragStart={onNodeDragStop} + nodeTypes={{ + prompt: MuxNodePrompt, + matcher: MuxNodeMatcher, + model: ModelNode, + matcherGroup: (props) => ( + <GroupNode + {...props} + data={{ + ...props.data, + onAddNode: addMatcherNode, + numNodes: nodes.filter((n) => n.type === NodeType.MATCHER) + .length, + }} + /> + ), + modelGroup: (props) => ( + <GroupNode + {...props} + data={{ + ...props.data, + onAddNode: addModelNode, + numNodes: nodes.filter((n) => n.type === NodeType.MODEL) + .length, + }} + /> + ), + }} + > + <Controls /> + <Background className="bg-gray-100" /> + </ReactFlow> + </div> + </PageContainer> + ) +} + +const GroupNode = ({ + id, + data, + ...rest +}: Partial<Node> & { + data: { + title: string + description: string + onAddNode: (id: string | undefined) => void + numNodes: number + } +}) => { + console.debug('👉 data:', rest) + return ( + <div + style={{ + // padding: config.PADDING_GROUP, + height: + config.HEIGHT_GROUP_HEADER + + config.PADDING_GROUP * 2 + // space around all nodes + (data.numNodes - 1) * config.PADDING_GROUP + // space between nodes + data.numNodes * config.HEIGHT_NODE, + }} + className={`-z-10 flex h-auto min-h-[calc(100%-48px)] flex-col rounded-lg !border-2 + border-dashed !border-gray-200`} + > + <div + style={{ + height: config.HEIGHT_GROUP_HEADER, + }} + className="flex items-center gap-1 rounded-t-lg px-3" + > + <Heading level={3} className="mb-0 text-lg"> + {data.title} ({data.numNodes}) + </Heading> + <TooltipTrigger delay={0}> + <TooltipInfoButton /> + <Tooltip placement="right">{data.description}</Tooltip> + </TooltipTrigger> + + <Button + className="ml-auto h-7 px-2" + variant="secondary" + onPress={() => data.onAddNode(id)} + > + <Plus /> + Add Node + </Button> + </div> + </div> + ) +} + +const MatcherNode = ({ + id, + data, +}: Partial<Node> & { + data: { + isDisabled?: boolean + onChange: (id: string | undefined, v: string) => void + } +}) => { + return ( + <> + <Handle type="target" position={Position.Left} /> + + <div + style={{ + height: config.HEIGHT_NODE, + width: config.WIDTH_NODE, + }} + className={twMerge( + nodeStyles(), + 'grid grid-cols-[32px_1fr_2fr] items-center gap-4' + )} + > + <SvgDrag className="size-8" /> + <Select + defaultSelectedKey={'all'} + items={[ + { + textValue: 'All', + id: 'all', + }, + { + textValue: 'FIM', + id: 'fim', + }, + { + textValue: 'Chat', + id: 'chat', + }, + ]} + > + <SelectButton /> + </Select> + <TextField + isDisabled={data.isDisabled} + type="text" + aria-label="Matcher" + value={data.label} + onChange={(v) => data.onChange(id, v)} + > + <Input + icon={data.isDisabled ? <Lock01 /> : <IconRegex />} + placeholder="e.g. *.ts" + /> + </TextField> + </div> + <Handle type="source" position={Position.Right} /> + </> + ) +} + +const ModelNode = ({ id, data }) => { + return ( + <> + <Handle type="target" position={Position.Left} /> + + <div + style={{ + height: config.HEIGHT_NODE, + width: config.WIDTH_NODE, + }} + className={nodeStyles()} + > + <ComboBox + aria-label="Model" + items={[ + { + textValue: 'anthropic/claude-3.7-sonnet', + id: 'anthropic/claude-3.7-sonnet', + }, + { + textValue: 'deepseek-r1', + id: 'deepseek-r1', + }, + { + textValue: 'mistral:7b-instruct', + id: 'mistral:7b-instruct', + }, + ]} + > + <FieldGroup> + <ComboBoxInput + icon={<SearchMd />} + isBorderless + placeholder="Search for a model..." + /> + </FieldGroup> + </ComboBox> + </div> + </> + ) +}