• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

stacklok / codegate-ui / 13724706155

07 Mar 2025 04:14PM UTC coverage: 66.516% (-3.1%) from 69.62%
13724706155

Pull #355

github

web-flow
Merge 401ebe83b into db80e1ec2
Pull Request #355: [POC] feat: node-based muxing editor

424 of 711 branches covered (59.63%)

Branch coverage included in aggregate %.

22 of 89 new or added lines in 6 files covered. (24.72%)

81 existing lines in 4 files now uncovered.

903 of 1284 relevant lines covered (70.33%)

67.21 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

10.81
/src/routes/route-mux-config.tsx
1
import { PageContainer } from '@/components/page-container'
2
import {
3
  Node,
4
  ReactFlow,
5
  Controls,
6
  Background,
7
  applyNodeChanges,
8
  applyEdgeChanges,
9
  addEdge,
10
  Position,
11
  Handle,
12
  ConnectionLineType,
13
  OnNodeDrag,
14
} from '@xyflow/react'
15
import { useCallback, useState } from 'react'
16
import * as config from '../features/muxing/constants/mux-node-config'
17
import '@xyflow/react/dist/style.css'
18
import {
19
  Button,
20
  ComboBox,
21
  ComboBoxInput,
22
  FieldGroup,
23
  Heading,
24
  Input,
25
  Select,
26
  SelectButton,
27
  TextField,
28
  Tooltip,
29
  TooltipInfoButton,
30
  TooltipTrigger,
31
} from '@stacklok/ui-kit'
32
import { tv } from 'tailwind-variants'
33
import { PageHeading } from '@/components/heading'
34
import {
35
  ChartBreakoutCircle,
36
  Lock01,
37
  Plus,
38
  SearchMd,
39
} from '@untitled-ui/icons-react'
40
import { twMerge } from 'tailwind-merge'
41
import SvgDrag from '@/components/icons/Drag'
42
import { IconRegex } from '@/components/icons/icon-regex'
43
import { MuxNodePrompt } from '@/features/muxing/components/mux-node-prompt'
44
import { MuxNodeMatcher } from '@/features/muxing/components/mux-node-matcher'
45

46
const nodeStyles = tv({
2✔
47
  base: 'w-full rounded border border-gray-200 bg-base p-4 shadow-sm',
48
})
49
const groupStyles = tv({
2✔
50
  base: `bg-gray-50/50 -z-10 h-auto min-h-[calc(100%-48px)] rounded-lg !border
51
  !border-gray-200 stroke-gray-200 backdrop-blur-sm`,
52
})
53

54
enum NodeType {
4✔
55
  MATCHER_GROUP = 'matcherGroup',
56
  MODEL_GROUP = 'modelGroup',
57
  PROMPT = 'prompt',
58
  MATCHER = 'matcher',
59
  MODEL = 'model',
60
}
61

62
function computeGroupNodeY(index: number) {
63
  return (
2✔
64
    config.PADDING_GROUP * 4 +
65
    config.HEIGHT_GROUP_HEADER +
66
    config.HEIGHT_NODE * index +
67
    config.PADDING_GROUP * index
68
  )
69
}
70

71
const initialNodes: Node[] = [
2✔
72
  {
73
    id: 'prompt',
74
    type: NodeType.PROMPT,
75
    data: { label: 'Prompt' },
76
    position: { x: 50, y: config.HEIGHT_CONTAINER / 2 },
77
    origin: [0.5, 0.5],
78
    sourcePosition: Position.Right,
79
    draggable: false,
80
  },
81
  {
82
    id: 'matcher-group',
83
    type: NodeType.MATCHER_GROUP,
84
    data: {
85
      title: 'Matchers',
86
      description:
87
        'Matchers use regex patterns to route requests to specific models.',
88
    },
89
    position: {
90
      x: 200,
91
      y: config.HEIGHT_CONTAINER / 2 - config.HEIGHT_GROUP_HEADER / 2,
92
    },
93
    origin: [0, 0.5],
94
    style: {
95
      width: config.WIDTH_GROUP,
96
      // height: '100%',
97
    },
98
    draggable: false,
99
  },
100
  {
101
    id: 'model-group',
102
    type: NodeType.MODEL_GROUP,
103
    data: {
104
      title: 'Models',
105
      description: 'Add model nodes here',
106
    },
107
    position: {
108
      x: 720,
109
      y: config.HEIGHT_CONTAINER / 2 - config.HEIGHT_GROUP_HEADER / 2,
110
    },
111
    origin: [0, 0.5],
112
    style: {
113
      width: config.WIDTH_GROUP,
114
      // height: '100%',
115
    },
116
    draggable: false,
117
  },
118

119
  {
120
    id: 'matcher-0',
121
    type: NodeType.MATCHER,
122
    data: { label: 'catch-all', isDisabled: true },
123
    position: {
124
      x: 250,
125
      y: computeGroupNodeY(0),
126
    },
127
    parentId: 'matcher-group',
128
    origin: [0.5, 0.5],
129
    extent: 'parent',
130
    targetPosition: Position.Left,
131
    sourcePosition: Position.Right,
132
  },
133
]
134

135
const EDGE = {
2✔
136
  type: ConnectionLineType.Bezier,
137
  animated: true,
138
}
139

140
const initialEdges = [
2✔
141
  {
142
    id: 'edge-0',
143
    source: 'prompt',
144
    target: 'matcher-0',
145
    ...EDGE,
146
  },
147
  {
148
    id: 'edge-1',
149
    source: 'matcher-0',
150
    target: 'model-0',
151
    ...EDGE,
152
  },
153
]
154

155
/**
156
 * Ensures correct ordering of "matcher" nodes,
157
 * both visually, and in the list of nodes.
158
 */
159
function alignMatcherNodes(nodes: Node[]) {
NEW
160
  const matcherNodes = nodes.filter((n) => n.type === NodeType.MATCHER)
×
161

162
  // Ensure that the last matcher node is always
163
  // `matcher-0` (the catch-all matcher)
NEW
164
  if (
×
165
    matcherNodes.length > 0 &&
×
166
    matcherNodes[matcherNodes.length - 1]?.id !== 'matcher-0'
167
  ) {
NEW
168
    const catchallNodeIndex = matcherNodes.findIndex(
×
NEW
169
      (n) => n.id === 'matcher-0'
×
170
    )
NEW
171
    if (catchallNodeIndex !== -1) {
×
NEW
172
      const fallbackNode = matcherNodes.splice(catchallNodeIndex, 1)[0]
×
NEW
173
      if (fallbackNode) matcherNodes.push(fallbackNode)
×
174
    }
175
  }
176

177
  // Update Y position of matcher nodes, so that their
178
  // visual position reflects their position in the list
NEW
179
  matcherNodes.forEach((n, i) => {
×
NEW
180
    n.position.y = computeGroupNodeY(i)
×
181
  })
182

183
  // Re-integrate the matcher nodes into the node list
NEW
184
  return nodes.map((n) =>
×
NEW
185
    n.type === NodeType.MATCHER ? matcherNodes.shift() : n
×
186
  )
187
}
188

189
function isOverlapping(node1: Node, node2: Node) {
NEW
190
  const node1Bottom = node1.position.y + config.HEIGHT_NODE
×
NEW
191
  const node2Bottom = node2.position.y + config.HEIGHT_NODE
×
192

NEW
193
  return (
×
194
    node1.position.y < node2Bottom &&
×
195
    node1Bottom > node2.position.y &&
196
    node1.position.x < node2.position.x + config.WIDTH_NODE &&
197
    node1.position.x + config.WIDTH_NODE > node2.position.x
198
  )
199
}
200

201
export function RouteMuxes() {
NEW
202
  const [nodes, setNodes] = useState(initialNodes)
×
NEW
203
  const [edges, setEdges] = useState(initialEdges)
×
204

NEW
205
  const onNodesChange = (changes) =>
×
NEW
206
    setNodes((nds) => applyNodeChanges(changes, nds))
×
NEW
207
  const onEdgesChange = (changes) =>
×
NEW
208
    setEdges((eds) =>
×
NEW
209
      applyEdgeChanges(
×
210
        {
211
          ...changes,
212
          ...EDGE,
213
        },
214
        eds
215
      )
216
    )
217

NEW
218
  const onConnect = useCallback(
×
219
    (params) =>
NEW
220
      setEdges((eds) =>
×
NEW
221
        addEdge(
×
222
          {
223
            ...params,
224
            ...EDGE,
225
          },
226
          eds
227
        )
228
      ),
229
    []
230
  )
231

NEW
232
  const addMatcherNode = () => {
×
NEW
233
    const matcherNodes = nodes.filter(
×
NEW
234
      (node) => node.type === 'matcher' && node.id !== 'matcher-0'
×
235
    )
NEW
236
    const newNode: Node = {
×
237
      id: `matcher-${matcherNodes.length + 1}`,
238
      type: 'matcher',
239
      data: { label: '', onChange: handleNodeChange },
240
      position: {
241
        x: config.WIDTH_GROUP / 2,
242
        y: computeGroupNodeY(0),
243
      },
244
      parentId: 'matcher-group',
245
      origin: [0.5, 0.5],
246
      extent: 'parent',
247
      targetPosition: Position.Left,
248
      sourcePosition: Position.Right,
249
    }
NEW
250
    setNodes((nds) => {
×
NEW
251
      const updatedNodes = [...nds, newNode]
×
NEW
252
      return alignMatcherNodes(updatedNodes)
×
253
    })
254

NEW
255
    const newEdge = {
×
256
      id: `edge-${nodes.length}`,
257
      source: 'prompt',
258
      target: newNode.id,
259
      type: ConnectionLineType.Bezier,
260
      animated: true,
261
    }
NEW
262
    setEdges((eds) => [...eds, newEdge])
×
263
  }
264

NEW
265
  const addModelNode = useCallback(() => {
×
NEW
266
    const newNode: Node = {
×
267
      id: `model-${nodes.length}`,
268
      type: NodeType.MODEL,
269
      data: { label: 'Qwen', onChange: handleNodeChange },
270
      position: {
271
        x: 250,
272
        y:
NEW
273
          nodes.filter((node) => node.id.startsWith('model')).length *
×
274
          GRID_SIZE,
275
      },
276
      origin: [0.5, 0.5],
277
      parentId: 'model-group',
278
      extent: 'parent',
279
      targetPosition: Position.Right,
280
    }
NEW
281
    setNodes((nds) => [...nds, newNode])
×
282
  }, [nodes])
283

NEW
284
  const handleNodeChange = (id, value) => {
×
NEW
285
    setNodes((nds) =>
×
NEW
286
      nds.map((node) =>
×
NEW
287
        node.id === id
×
288
          ? { ...node, data: { ...node.data, label: value } }
289
          : node
290
      )
291
    )
292
  }
293

NEW
294
  const onNodeDragStop = useCallback<OnNodeDrag<Node>>((event, node) => {
×
NEW
295
    setNodes((nds) => {
×
NEW
296
      const updatedNodes = nds.map((n) =>
×
NEW
297
        n.id === node.id ? { ...n, position: node.position } : n
×
298
      )
299

NEW
300
      const overlappingNode = updatedNodes.find(
×
NEW
301
        (n) => n.id !== node.id && isOverlapping(n, node)
×
302
      )
303

NEW
304
      if (overlappingNode) {
×
NEW
305
        const nodeIndex = updatedNodes.findIndex((n) => n.id === node.id)
×
NEW
306
        const overlappingNodeIndex = updatedNodes.findIndex(
×
NEW
307
          (n) => n.id === overlappingNode.id
×
308
        )
309

NEW
310
        if (nodeIndex !== -1 && overlappingNodeIndex !== -1) {
×
NEW
311
          const temp = updatedNodes[nodeIndex]
×
NEW
312
          if (
×
313
            updatedNodes[overlappingNodeIndex] &&
×
314
            updatedNodes[nodeIndex] &&
315
            temp
316
          ) {
NEW
317
            updatedNodes[nodeIndex] = updatedNodes[overlappingNodeIndex]
×
NEW
318
            updatedNodes[overlappingNodeIndex] = temp
×
319

NEW
320
            updatedNodes[nodeIndex].position.y = computeGroupNodeY(nodeIndex)
×
NEW
321
            updatedNodes[overlappingNodeIndex].position.y =
×
322
              computeGroupNodeY(overlappingNodeIndex)
323
          }
324
        }
325
      }
326

NEW
327
      return alignMatcherNodes(updatedNodes)
×
328
    })
329
  }, [])
330

331
  return (
332
    <PageContainer className="flex min-h-dvh flex-col">
333
      <PageHeading level={1} title="Muxing" />
334
      <p className="mb-2 max-w-6xl text-balance text-secondary">
335
        Model muxing (or multiplexing), allows you to configure your AI
336
        assistant once and use CodeGate workspaces to switch between LLM
337
        providers and models without reconfiguring your development environment.
338
      </p>
339
      <p className="mb-8 max-w-6xl text-balance text-secondary">
340
        Configure your IDE integration to send OpenAI-compatible requests to{' '}
341
        <code className="rounded-sm border border-gray-200 bg-gray-50 px-1 py-0.5">
342
          http://localhost:8989/v1/mux
343
        </code>{' '}
344
        and configure the routing from here.
345
      </p>
346
      <div
347
        style={{
348
          height: config.HEIGHT_CONTAINER,
349
        }}
350
        className="border border-gray-200"
351
      >
352
        <ReactFlow
353
          nodes={nodes}
354
          edges={edges}
355
          onNodesChange={onNodesChange}
356
          onEdgesChange={onEdgesChange}
357
          onConnect={onConnect}
NEW
358
          onNodeDragStop={(...args) => console.log('onNodeDragStop', args)}
×
359
          onNodeDragStart={onNodeDragStop}
360
          nodeTypes={{
361
            prompt: MuxNodePrompt,
362
            matcher: MuxNodeMatcher,
363
            model: ModelNode,
364
            matcherGroup: (props) => (
365
              <GroupNode
366
                {...props}
367
                data={{
368
                  ...props.data,
369
                  onAddNode: addMatcherNode,
NEW
370
                  numNodes: nodes.filter((n) => n.type === NodeType.MATCHER)
×
371
                    .length,
372
                }}
373
              />
374
            ),
375
            modelGroup: (props) => (
376
              <GroupNode
377
                {...props}
378
                data={{
379
                  ...props.data,
380
                  onAddNode: addModelNode,
NEW
381
                  numNodes: nodes.filter((n) => n.type === NodeType.MODEL)
×
382
                    .length,
383
                }}
384
              />
385
            ),
386
          }}
387
        >
388
          <Controls />
389
          <Background className="bg-gray-100" />
390
        </ReactFlow>
391
      </div>
392
    </PageContainer>
393
  )
394
}
395

396
const GroupNode = ({
2✔
397
  id,
398
  data,
399
  ...rest
400
}: Partial<Node> & {
401
  data: {
402
    title: string
403
    description: string
404
    onAddNode: (id: string | undefined) => void
405
    numNodes: number
406
  }
407
}) => {
NEW
408
  console.debug('👉 data:', rest)
×
409
  return (
410
    <div
411
      style={{
412
        // padding: config.PADDING_GROUP,
413
        height:
414
          config.HEIGHT_GROUP_HEADER +
415
          config.PADDING_GROUP * 2 + // space around all nodes
416
          (data.numNodes - 1) * config.PADDING_GROUP + // space between nodes
417
          data.numNodes * config.HEIGHT_NODE,
418
      }}
419
      className={`-z-10 flex h-auto min-h-[calc(100%-48px)] flex-col rounded-lg !border-2
420
        border-dashed !border-gray-200`}
421
    >
422
      <div
423
        style={{
424
          height: config.HEIGHT_GROUP_HEADER,
425
        }}
426
        className="flex items-center gap-1 rounded-t-lg px-3"
427
      >
428
        <Heading level={3} className="mb-0 text-lg">
429
          {data.title} ({data.numNodes})
430
        </Heading>
431
        <TooltipTrigger delay={0}>
432
          <TooltipInfoButton />
433
          <Tooltip placement="right">{data.description}</Tooltip>
434
        </TooltipTrigger>
435

436
        <Button
437
          className="ml-auto h-7 px-2"
438
          variant="secondary"
NEW
439
          onPress={() => data.onAddNode(id)}
×
440
        >
441
          <Plus />
442
          Add Node
443
        </Button>
444
      </div>
445
    </div>
446
  )
447
}
448

449
const MatcherNode = ({
2✔
450
  id,
451
  data,
452
}: Partial<Node> & {
453
  data: {
454
    isDisabled?: boolean
455
    onChange: (id: string | undefined, v: string) => void
456
  }
457
}) => {
458
  return (
459
    <>
460
      <Handle type="target" position={Position.Left} />
461

462
      <div
463
        style={{
464
          height: config.HEIGHT_NODE,
465
          width: config.WIDTH_NODE,
466
        }}
467
        className={twMerge(
468
          nodeStyles(),
469
          'grid grid-cols-[32px_1fr_2fr] items-center gap-4'
470
        )}
471
      >
472
        <SvgDrag className="size-8" />
473
        <Select
474
          defaultSelectedKey={'all'}
475
          items={[
476
            {
477
              textValue: 'All',
478
              id: 'all',
479
            },
480
            {
481
              textValue: 'FIM',
482
              id: 'fim',
483
            },
484
            {
485
              textValue: 'Chat',
486
              id: 'chat',
487
            },
488
          ]}
489
        >
490
          <SelectButton />
491
        </Select>
492
        <TextField
493
          isDisabled={data.isDisabled}
494
          type="text"
495
          aria-label="Matcher"
496
          value={data.label}
NEW
497
          onChange={(v) => data.onChange(id, v)}
×
498
        >
499
          <Input
500
            icon={data.isDisabled ? <Lock01 /> : <IconRegex />}
×
501
            placeholder="e.g. *.ts"
502
          />
503
        </TextField>
504
      </div>
505
      <Handle type="source" position={Position.Right} />
506
    </>
507
  )
508
}
509

510
const ModelNode = ({ id, data }) => {
2✔
511
  return (
512
    <>
513
      <Handle type="target" position={Position.Left} />
514

515
      <div
516
        style={{
517
          height: config.HEIGHT_NODE,
518
          width: config.WIDTH_NODE,
519
        }}
520
        className={nodeStyles()}
521
      >
522
        <ComboBox
523
          aria-label="Model"
524
          items={[
525
            {
526
              textValue: 'anthropic/claude-3.7-sonnet',
527
              id: 'anthropic/claude-3.7-sonnet',
528
            },
529
            {
530
              textValue: 'deepseek-r1',
531
              id: 'deepseek-r1',
532
            },
533
            {
534
              textValue: 'mistral:7b-instruct',
535
              id: 'mistral:7b-instruct',
536
            },
537
          ]}
538
        >
539
          <FieldGroup>
540
            <ComboBoxInput
541
              icon={<SearchMd />}
542
              isBorderless
543
              placeholder="Search for a model..."
544
            />
545
          </FieldGroup>
546
        </ComboBox>
547
      </div>
548
    </>
549
  )
550
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc