@anchrd/intel-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,921 @@
1
+ import type { FlowGraph, FlowNode, ResourceRole } from "@anchrd/intel-contract";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import {
4
+ addEdge,
5
+ applyEdgeChanges,
6
+ applyNodeChanges,
7
+ Background,
8
+ type Connection,
9
+ Controls,
10
+ type Edge,
11
+ type EdgeChange,
12
+ Handle,
13
+ MiniMap,
14
+ type Node,
15
+ type NodeChange,
16
+ type NodeProps,
17
+ Position,
18
+ ReactFlow,
19
+ } from "@xyflow/react";
20
+ import {
21
+ Bot,
22
+ CheckCircle2,
23
+ CirclePlay,
24
+ FileSearch,
25
+ GitBranch,
26
+ Plus,
27
+ Save,
28
+ Send,
29
+ Share2,
30
+ ShieldCheck,
31
+ Sparkles,
32
+ Trash2,
33
+ Wrench,
34
+ } from "lucide-react";
35
+ import { useEffect, useMemo, useState } from "react";
36
+ import type { KnowledgeTreeNode } from "@/data/intel-data-provider/intel-data-provider.types.ts";
37
+ import { Modal } from "@/modal/modal.tsx";
38
+ import { useIntelRouterContext } from "@/router/router-context.ts";
39
+
40
+ type CanvasNode = Node<{ node: FlowNode }, "intel">;
41
+ type CanvasEdge = Edge;
42
+
43
+ const nodeIcon = {
44
+ trigger: CirclePlay,
45
+ instruction: Sparkles,
46
+ knowledge: FileSearch,
47
+ tool: Wrench,
48
+ condition: GitBranch,
49
+ approval: ShieldCheck,
50
+ output: CheckCircle2,
51
+ } as const;
52
+
53
+ function FlowCard({ data, selected }: NodeProps<CanvasNode>) {
54
+ const contract = data.node;
55
+ const Icon = nodeIcon[contract.kind];
56
+ const branching = contract.kind === "condition" || contract.kind === "approval";
57
+ return (
58
+ <div
59
+ className={`min-w-48 rounded-xl border bg-card p-3 text-card-foreground shadow-sm ${selected ? "ring-2 ring-ring" : ""}`}
60
+ >
61
+ {contract.kind !== "trigger" && <Handle type="target" position={Position.Left} />}
62
+ <div className="flex items-center gap-2">
63
+ <span className="grid size-8 place-items-center rounded-lg bg-primary/10 text-primary">
64
+ <Icon aria-hidden="true" className="size-4" />
65
+ </span>
66
+ <span className="min-w-0">
67
+ <span className="block truncate text-sm font-medium">{contract.label}</span>
68
+ <span className="block text-xs capitalize text-muted-foreground">{contract.kind}</span>
69
+ </span>
70
+ </div>
71
+ {contract.description && (
72
+ <p className="mt-2 line-clamp-2 text-xs text-muted-foreground">{contract.description}</p>
73
+ )}
74
+ {contract.kind !== "output" && !branching && (
75
+ <Handle type="source" position={Position.Right} />
76
+ )}
77
+ {branching && (
78
+ <>
79
+ <Handle
80
+ id={contract.kind === "approval" ? "approved" : "yes"}
81
+ type="source"
82
+ position={Position.Right}
83
+ style={{ top: "35%" }}
84
+ />
85
+ <Handle
86
+ id={contract.kind === "approval" ? "rejected" : "no"}
87
+ type="source"
88
+ position={Position.Right}
89
+ style={{ top: "70%" }}
90
+ />
91
+ </>
92
+ )}
93
+ </div>
94
+ );
95
+ }
96
+
97
+ const nodeTypes = { intel: FlowCard };
98
+
99
+ function defaultGraph(): FlowGraph {
100
+ return {
101
+ nodes: [
102
+ {
103
+ id: "trigger",
104
+ kind: "trigger",
105
+ label: "Manual start",
106
+ description: null,
107
+ position: { x: 80, y: 180 },
108
+ configuration: { mode: "manual" },
109
+ },
110
+ {
111
+ id: "output",
112
+ kind: "output",
113
+ label: "Result",
114
+ description: null,
115
+ position: { x: 520, y: 180 },
116
+ configuration: { template: "" },
117
+ },
118
+ ],
119
+ edges: [
120
+ {
121
+ id: "trigger-output",
122
+ source: "trigger",
123
+ target: "output",
124
+ label: null,
125
+ sourceHandle: null,
126
+ },
127
+ ],
128
+ };
129
+ }
130
+
131
+ function canvas(graph: FlowGraph) {
132
+ return {
133
+ nodes: graph.nodes.map((node) => ({
134
+ id: node.id,
135
+ type: "intel" as const,
136
+ position: node.position,
137
+ data: { node },
138
+ })),
139
+ edges: graph.edges.map((edge) => ({
140
+ id: edge.id,
141
+ source: edge.source,
142
+ target: edge.target,
143
+ sourceHandle: edge.sourceHandle,
144
+ label: edge.label ?? undefined,
145
+ })),
146
+ };
147
+ }
148
+
149
+ function graph(nodes: CanvasNode[], edges: CanvasEdge[]): FlowGraph {
150
+ return {
151
+ nodes: nodes.map((node) => ({
152
+ ...node.data.node,
153
+ position: node.position,
154
+ })),
155
+ edges: edges.map((edge) => ({
156
+ id: edge.id,
157
+ source: edge.source,
158
+ target: edge.target,
159
+ label: typeof edge.label === "string" && edge.label ? edge.label : null,
160
+ sourceHandle: edge.sourceHandle ?? null,
161
+ })),
162
+ };
163
+ }
164
+
165
+ function newNode(
166
+ kind: Exclude<FlowNode["kind"], "trigger">,
167
+ index: number,
168
+ firstKnowledgeId?: string,
169
+ ): FlowNode {
170
+ const common = {
171
+ id: `${kind}-${crypto.randomUUID()}`,
172
+ label: kind[0]?.toUpperCase() + kind.slice(1),
173
+ description: null,
174
+ position: {
175
+ x: 260 + (index % 3) * 240,
176
+ y: 80 + Math.floor(index / 3) * 180,
177
+ },
178
+ };
179
+ switch (kind) {
180
+ case "instruction":
181
+ return {
182
+ ...common,
183
+ kind,
184
+ configuration: { prompt: "Describe what the AI should do." },
185
+ };
186
+ case "knowledge":
187
+ return {
188
+ ...common,
189
+ kind,
190
+ configuration: {
191
+ resourceIds: firstKnowledgeId ? [firstKnowledgeId] : [],
192
+ mode: "relevant",
193
+ query: null,
194
+ },
195
+ };
196
+ case "tool":
197
+ return {
198
+ ...common,
199
+ kind,
200
+ configuration: {
201
+ sourceId: "select-a-source",
202
+ toolName: "select-a-tool",
203
+ fingerprint: null,
204
+ arguments: {},
205
+ },
206
+ };
207
+ case "condition":
208
+ return {
209
+ ...common,
210
+ kind,
211
+ configuration: {
212
+ mode: "semantic",
213
+ instruction: "Describe how to choose yes or no.",
214
+ },
215
+ };
216
+ case "approval":
217
+ return {
218
+ ...common,
219
+ kind,
220
+ configuration: {
221
+ prompt: "Describe what must be approved.",
222
+ timeout: "7 days",
223
+ },
224
+ };
225
+ case "output":
226
+ return { ...common, kind, configuration: { template: "" } };
227
+ }
228
+ }
229
+
230
+ export function Flows() {
231
+ const { data, i18n } = useIntelRouterContext();
232
+ const queryClient = useQueryClient();
233
+ const flows = useQuery({
234
+ queryKey: ["flows"],
235
+ queryFn: () => data.listFlows(),
236
+ });
237
+ const tools = useQuery({
238
+ queryKey: ["tools"],
239
+ queryFn: () => data.listTools(),
240
+ });
241
+ const knowledge = useQuery({
242
+ queryKey: ["knowledge-tree"],
243
+ queryFn: () => data.loadKnowledgeTree(),
244
+ });
245
+ const [selectedFlowId, setSelectedFlowId] = useState<string | null>(null);
246
+ const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
247
+ const [creating, setCreating] = useState(false);
248
+ const [sharing, setSharing] = useState(false);
249
+ const document = useQuery({
250
+ queryKey: ["flow", selectedFlowId],
251
+ queryFn: () => data.getFlow(selectedFlowId ?? ""),
252
+ enabled: Boolean(selectedFlowId),
253
+ });
254
+ const initial = useMemo(() => canvas(defaultGraph()), []);
255
+ const [nodes, setNodes] = useState<CanvasNode[]>(initial.nodes);
256
+ const [edges, setEdges] = useState<CanvasEdge[]>(initial.edges);
257
+ // The canvas stays visible during a background refetch; only a mutation needs the stricter
258
+ // guarantee that the loaded document is both the selected flow and settled.
259
+ const documentReady = selectedFlowId !== null && document.data?.flow.id === selectedFlowId;
260
+ const canMutate = documentReady && !document.isFetching;
261
+ useEffect(() => {
262
+ if (!document.data || document.data.flow.id !== selectedFlowId) return;
263
+ const next = canvas(document.data.version?.graph ?? defaultGraph());
264
+ setNodes(next.nodes);
265
+ setEdges(next.edges);
266
+ setSelectedNodeId(null);
267
+ }, [document.data, selectedFlowId]);
268
+ const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null;
269
+
270
+ const save = useMutation({
271
+ mutationFn: () =>
272
+ data.saveFlow({
273
+ flowId: selectedFlowId ?? "",
274
+ baseVersionId: document.data?.flow.currentVersionId ?? null,
275
+ graph: graph(nodes, edges),
276
+ idempotencyKey: crypto.randomUUID(),
277
+ }),
278
+ onSuccess: async (saved) => {
279
+ queryClient.setQueryData(["flow", saved.flow.id], saved);
280
+ await queryClient.invalidateQueries({ queryKey: ["flows"] });
281
+ },
282
+ });
283
+ const publish = useMutation({
284
+ mutationFn: () =>
285
+ data.publishFlow({
286
+ flowId: selectedFlowId ?? "",
287
+ versionId: document.data?.flow.currentVersionId ?? "",
288
+ idempotencyKey: crypto.randomUUID(),
289
+ }),
290
+ onSuccess: async () => {
291
+ await Promise.all([
292
+ queryClient.invalidateQueries({ queryKey: ["flows"] }),
293
+ queryClient.invalidateQueries({ queryKey: ["flow", selectedFlowId] }),
294
+ ]);
295
+ },
296
+ });
297
+ const run = useMutation({
298
+ mutationFn: () =>
299
+ data.startFlow({
300
+ flowId: selectedFlowId ?? "",
301
+ input: {},
302
+ idempotencyKey: crypto.randomUUID(),
303
+ }),
304
+ });
305
+
306
+ function updateNode(update: (node: FlowNode) => FlowNode) {
307
+ setNodes((current) =>
308
+ current.map((node) =>
309
+ node.id === selectedNodeId ? { ...node, data: { node: update(node.data.node) } } : node,
310
+ ),
311
+ );
312
+ }
313
+
314
+ return (
315
+ <div className="flex h-screen min-h-0 flex-col">
316
+ <header className="flex items-center justify-between gap-6 border-b px-8 py-5">
317
+ <div>
318
+ <h1 className="text-xl font-semibold tracking-tight">{i18n.t("flows.title")}</h1>
319
+ <p className="mt-1 text-sm text-muted-foreground">{i18n.t("flows.description")}</p>
320
+ </div>
321
+ <div className="flex items-center gap-2">
322
+ {selectedFlowId && (
323
+ <>
324
+ <button
325
+ type="button"
326
+ onClick={() => setSharing(true)}
327
+ className="inline-flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
328
+ >
329
+ <Share2 aria-hidden="true" className="size-4" /> {i18n.t("flows.share")}
330
+ </button>
331
+ <button
332
+ type="button"
333
+ onClick={() => save.mutate()}
334
+ disabled={!canMutate || save.isPending}
335
+ className="inline-flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
336
+ >
337
+ <Save aria-hidden="true" className="size-4" /> {i18n.t("common.save")}
338
+ </button>
339
+ <button
340
+ type="button"
341
+ onClick={() => publish.mutate()}
342
+ disabled={!canMutate || !document.data?.flow.currentVersionId || publish.isPending}
343
+ className="inline-flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
344
+ >
345
+ <Send aria-hidden="true" className="size-4" /> {i18n.t("flows.publish")}
346
+ </button>
347
+ <button
348
+ type="button"
349
+ onClick={() => run.mutate()}
350
+ disabled={!canMutate || !document.data?.flow.publishedVersionId || run.isPending}
351
+ className="inline-flex items-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
352
+ >
353
+ <Bot aria-hidden="true" className="size-4" /> {i18n.t("flows.run")}
354
+ </button>
355
+ </>
356
+ )}
357
+ <button
358
+ type="button"
359
+ onClick={() => setCreating(true)}
360
+ className="inline-flex items-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring"
361
+ >
362
+ <Plus aria-hidden="true" className="size-4" /> {i18n.t("flows.new")}
363
+ </button>
364
+ </div>
365
+ </header>
366
+ <div className="flex min-h-0 flex-1">
367
+ <aside className="w-64 shrink-0 overflow-y-auto border-r bg-muted/20 p-3">
368
+ {flows.data?.items.map((flow) => (
369
+ <button
370
+ key={flow.id}
371
+ type="button"
372
+ onClick={() => setSelectedFlowId(flow.id)}
373
+ className={`mb-1 w-full rounded-md p-3 text-left outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring ${selectedFlowId === flow.id ? "bg-accent text-accent-foreground" : ""}`}
374
+ >
375
+ <span className="block truncate text-sm font-medium">{flow.title}</span>
376
+ <span className="mt-1 block text-xs text-muted-foreground">
377
+ {flow.publishedVersionId ? i18n.t("flows.published") : i18n.t("flows.draft")}
378
+ </span>
379
+ </button>
380
+ ))}
381
+ {flows.data?.items.length === 0 && (
382
+ <p className="p-3 text-sm text-muted-foreground">{i18n.t("flows.empty")}</p>
383
+ )}
384
+ </aside>
385
+ {!selectedFlowId ? (
386
+ <div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
387
+ {i18n.t("flows.select")}
388
+ </div>
389
+ ) : !documentReady ? (
390
+ <div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
391
+ {i18n.t("common.loading")}
392
+ </div>
393
+ ) : (
394
+ <>
395
+ <section className="relative min-w-0 flex-1">
396
+ <div className="absolute left-4 top-4 z-10 flex max-w-[calc(100%-2rem)] flex-wrap gap-1 rounded-lg border bg-card/95 p-2 shadow-sm backdrop-blur">
397
+ {(
398
+ ["instruction", "knowledge", "tool", "condition", "approval", "output"] as const
399
+ ).map((kind) => {
400
+ const Icon = nodeIcon[kind];
401
+ return (
402
+ <button
403
+ key={kind}
404
+ type="button"
405
+ onClick={() => {
406
+ const item = newNode(kind, nodes.length, knowledge.data?.[0]?.id);
407
+ setNodes((current) => [
408
+ ...current,
409
+ {
410
+ id: item.id,
411
+ type: "intel",
412
+ position: item.position,
413
+ data: { node: item },
414
+ },
415
+ ]);
416
+ }}
417
+ className="inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
418
+ >
419
+ <Icon aria-hidden="true" className="size-3.5" />
420
+ {i18n.t(`flows.node.${kind}`)}
421
+ </button>
422
+ );
423
+ })}
424
+ </div>
425
+ <ReactFlow<CanvasNode, CanvasEdge>
426
+ nodes={nodes}
427
+ edges={edges}
428
+ nodeTypes={nodeTypes}
429
+ fitView
430
+ onNodesChange={(changes: NodeChange<CanvasNode>[]) =>
431
+ setNodes((current) => applyNodeChanges(changes, current))
432
+ }
433
+ onEdgesChange={(changes: EdgeChange<CanvasEdge>[]) =>
434
+ setEdges((current) => applyEdgeChanges(changes, current))
435
+ }
436
+ onConnect={(connection: Connection) =>
437
+ setEdges((current) =>
438
+ addEdge({ ...connection, id: crypto.randomUUID() }, current),
439
+ )
440
+ }
441
+ onNodeClick={(_event, node) => setSelectedNodeId(node.id)}
442
+ onPaneClick={() => setSelectedNodeId(null)}
443
+ >
444
+ <Background />
445
+ <MiniMap pannable zoomable />
446
+ <Controls />
447
+ </ReactFlow>
448
+ </section>
449
+ <NodeInspector
450
+ node={selectedNode}
451
+ update={updateNode}
452
+ tools={tools.data?.items ?? []}
453
+ knowledge={knowledge.data ?? []}
454
+ />
455
+ </>
456
+ )}
457
+ </div>
458
+ {run.data && (
459
+ <div className="fixed bottom-5 right-5 z-20 max-w-sm rounded-xl border bg-card p-4 text-sm shadow-xl">
460
+ <p className="font-medium">{i18n.t("flows.runStarted")}</p>
461
+ <p className="mt-1 text-xs text-muted-foreground">
462
+ {run.data.node?.label ?? run.data.run.status} · {run.data.run.id}
463
+ </p>
464
+ </div>
465
+ )}
466
+ {(save.isError || publish.isError || run.isError) && (
467
+ <p
468
+ role="alert"
469
+ className="fixed bottom-5 left-[18rem] z-20 rounded-lg border border-destructive/30 bg-card px-4 py-3 text-sm text-destructive shadow-xl"
470
+ >
471
+ {i18n.t("flows.operationFailed")}
472
+ </p>
473
+ )}
474
+ {creating && <CreateFlow close={() => setCreating(false)} select={setSelectedFlowId} />}
475
+ {sharing && selectedFlowId && (
476
+ <ShareFlow flowId={selectedFlowId} close={() => setSharing(false)} />
477
+ )}
478
+ </div>
479
+ );
480
+ }
481
+
482
+ function ShareFlow({ flowId, close }: { flowId: string; close(): void }) {
483
+ const { data, i18n } = useIntelRouterContext();
484
+ const queryClient = useQueryClient();
485
+ const [email, setEmail] = useState("");
486
+ const [role, setRole] = useState<ResourceRole>("viewer");
487
+ const grants = useQuery({
488
+ queryKey: ["flow-grants", flowId],
489
+ queryFn: () => data.listFlowGrants(flowId),
490
+ });
491
+ const share = useMutation({
492
+ mutationFn: () =>
493
+ data.shareFlow({
494
+ resourceId: flowId,
495
+ principal: { type: "email", email },
496
+ role,
497
+ expiresAt: null,
498
+ idempotencyKey: crypto.randomUUID(),
499
+ }),
500
+ onSuccess: async () => {
501
+ setEmail("");
502
+ await queryClient.invalidateQueries({
503
+ queryKey: ["flow-grants", flowId],
504
+ });
505
+ },
506
+ });
507
+ const revoke = useMutation({
508
+ mutationFn: (grantId: string) =>
509
+ data.revokeFlowGrant({
510
+ resourceId: flowId,
511
+ grantId,
512
+ idempotencyKey: crypto.randomUUID(),
513
+ }),
514
+ onSuccess: async () => {
515
+ await queryClient.invalidateQueries({
516
+ queryKey: ["flow-grants", flowId],
517
+ });
518
+ },
519
+ });
520
+
521
+ return (
522
+ <Modal title={i18n.t("flows.share")} close={close}>
523
+ {(share.isError || revoke.isError) && (
524
+ <p role="alert" className="mb-4 text-sm text-destructive">
525
+ {i18n.t("flows.shareFailed")}
526
+ </p>
527
+ )}
528
+ <ul className="mb-5 max-h-44 space-y-2 overflow-y-auto">
529
+ {grants.data?.items.map((grant) => (
530
+ <li
531
+ key={grant.id}
532
+ className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm"
533
+ >
534
+ <span className="min-w-0 truncate">
535
+ {grant.principal.type === "email"
536
+ ? grant.principal.email
537
+ : grant.principal.type === "user"
538
+ ? grant.principal.id
539
+ : i18n.t("flows.organization")}
540
+ <span className="ml-2 text-xs text-muted-foreground">{grant.role}</span>
541
+ </span>
542
+ <button
543
+ type="button"
544
+ onClick={() => revoke.mutate(grant.id)}
545
+ aria-label={i18n.t("flows.revokeShare")}
546
+ className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
547
+ >
548
+ <Trash2 aria-hidden="true" className="size-4" />
549
+ </button>
550
+ </li>
551
+ ))}
552
+ </ul>
553
+ <form
554
+ className="space-y-4 border-t pt-5"
555
+ onSubmit={(event) => {
556
+ event.preventDefault();
557
+ share.mutate();
558
+ }}
559
+ >
560
+ <label className="block text-sm font-medium">
561
+ {i18n.t("knowledge.email")}
562
+ <input
563
+ type="email"
564
+ required
565
+ value={email}
566
+ onChange={(event) => setEmail(event.target.value)}
567
+ className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
568
+ />
569
+ </label>
570
+ <label className="block text-sm font-medium">
571
+ {i18n.t("knowledge.role")}
572
+ <select
573
+ value={role}
574
+ onChange={(event) => setRole(event.target.value as ResourceRole)}
575
+ className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
576
+ >
577
+ <option value="viewer">{i18n.t("knowledge.viewer")}</option>
578
+ <option value="editor">{i18n.t("knowledge.editor")}</option>
579
+ <option value="manager">{i18n.t("knowledge.manager")}</option>
580
+ </select>
581
+ </label>
582
+ <button
583
+ type="submit"
584
+ disabled={share.isPending}
585
+ className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
586
+ >
587
+ {i18n.t("knowledge.shareAction")}
588
+ </button>
589
+ </form>
590
+ </Modal>
591
+ );
592
+ }
593
+
594
+ function NodeInspector({
595
+ node,
596
+ update,
597
+ tools,
598
+ knowledge,
599
+ }: {
600
+ node: CanvasNode | null;
601
+ update(fn: (node: FlowNode) => FlowNode): void;
602
+ tools: Array<{
603
+ source: { id: string; name: string };
604
+ capability: { name: string; title: string | null; fingerprint: string };
605
+ }>;
606
+ knowledge: KnowledgeTreeNode[];
607
+ }) {
608
+ const { i18n } = useIntelRouterContext();
609
+ if (!node)
610
+ return (
611
+ <aside className="w-80 shrink-0 border-l bg-card p-5 text-sm text-muted-foreground">
612
+ {i18n.t("flows.inspect")}
613
+ </aside>
614
+ );
615
+ const contract = node.data.node;
616
+ return (
617
+ <aside className="w-80 shrink-0 overflow-y-auto border-l bg-card p-5">
618
+ <h2 className="font-semibold">{i18n.t(`flows.node.${contract.kind}`)}</h2>
619
+ <Field
620
+ label={i18n.t("common.title")}
621
+ value={contract.label}
622
+ setValue={(label) => update((value) => ({ ...value, label }))}
623
+ />
624
+ <Field
625
+ label={i18n.t("flows.nodeDescription")}
626
+ value={contract.description ?? ""}
627
+ setValue={(description) =>
628
+ update((value) => ({ ...value, description: description || null }))
629
+ }
630
+ />
631
+ {(contract.kind === "instruction" ||
632
+ contract.kind === "condition" ||
633
+ contract.kind === "approval") && (
634
+ <TextArea
635
+ label={i18n.t("flows.instruction")}
636
+ value={
637
+ contract.kind === "instruction"
638
+ ? contract.configuration.prompt
639
+ : contract.kind === "condition"
640
+ ? contract.configuration.instruction
641
+ : contract.configuration.prompt
642
+ }
643
+ setValue={(text) =>
644
+ update((value) =>
645
+ value.kind === "instruction"
646
+ ? { ...value, configuration: { prompt: text } }
647
+ : value.kind === "condition"
648
+ ? {
649
+ ...value,
650
+ configuration: { mode: "semantic", instruction: text },
651
+ }
652
+ : value.kind === "approval"
653
+ ? {
654
+ ...value,
655
+ configuration: { ...value.configuration, prompt: text },
656
+ }
657
+ : value,
658
+ )
659
+ }
660
+ />
661
+ )}
662
+ {contract.kind === "knowledge" && (
663
+ <>
664
+ <fieldset className="mt-4 rounded-lg border p-3">
665
+ <legend className="px-1 text-sm font-medium">{i18n.t("flows.knowledgeIds")}</legend>
666
+ {flattenKnowledge(knowledge).map((item) => (
667
+ <label
668
+ key={item.id}
669
+ className="flex cursor-pointer items-start gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted"
670
+ style={{ paddingLeft: `${item.depth * 12 + 8}px` }}
671
+ >
672
+ <input
673
+ type="checkbox"
674
+ checked={contract.configuration.resourceIds.includes(item.id)}
675
+ disabled={
676
+ contract.configuration.resourceIds.length === 1 &&
677
+ contract.configuration.resourceIds[0] === item.id
678
+ }
679
+ onChange={(event) =>
680
+ update((value) => {
681
+ if (value.kind !== "knowledge") return value;
682
+ const selected = new Set(value.configuration.resourceIds);
683
+ if (event.target.checked) selected.add(item.id);
684
+ else selected.delete(item.id);
685
+ return {
686
+ ...value,
687
+ configuration: {
688
+ ...value.configuration,
689
+ resourceIds: [...selected],
690
+ },
691
+ };
692
+ })
693
+ }
694
+ className="mt-0.5 size-4 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
695
+ />
696
+ <span className="min-w-0 truncate">{item.title}</span>
697
+ </label>
698
+ ))}
699
+ {knowledge.length === 0 && (
700
+ <p className="px-2 py-1 text-sm text-muted-foreground">
701
+ {i18n.t("flows.knowledgeEmpty")}
702
+ </p>
703
+ )}
704
+ </fieldset>
705
+ <TextArea
706
+ label={i18n.t("flows.knowledgeQuery")}
707
+ value={contract.configuration.query ?? ""}
708
+ setValue={(query) =>
709
+ update((value) =>
710
+ value.kind === "knowledge"
711
+ ? {
712
+ ...value,
713
+ configuration: {
714
+ ...value.configuration,
715
+ query: query || null,
716
+ },
717
+ }
718
+ : value,
719
+ )
720
+ }
721
+ />
722
+ </>
723
+ )}
724
+ {contract.kind === "tool" && (
725
+ <div className="mt-4">
726
+ <label className="block text-sm font-medium">
727
+ {i18n.t("flows.tool")}
728
+ <select
729
+ value={`${contract.configuration.sourceId}:${contract.configuration.toolName}`}
730
+ onChange={(event) => {
731
+ const [sourceId, ...name] = event.target.value.split(":");
732
+ const toolName = name.join(":");
733
+ update((value) =>
734
+ value.kind === "tool" && sourceId
735
+ ? {
736
+ ...value,
737
+ configuration: {
738
+ ...value.configuration,
739
+ sourceId,
740
+ toolName,
741
+ fingerprint:
742
+ tools.find(
743
+ (entry) =>
744
+ entry.source.id === sourceId && entry.capability.name === toolName,
745
+ )?.capability.fingerprint ?? null,
746
+ },
747
+ }
748
+ : value,
749
+ );
750
+ }}
751
+ className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
752
+ >
753
+ <option value="select-a-source:select-a-tool">{i18n.t("flows.selectTool")}</option>
754
+ {tools.map((entry) => (
755
+ <option
756
+ key={`${entry.source.id}:${entry.capability.name}`}
757
+ value={`${entry.source.id}:${entry.capability.name}`}
758
+ >
759
+ {entry.source.name} · {entry.capability.title ?? entry.capability.name}
760
+ </option>
761
+ ))}
762
+ </select>
763
+ </label>
764
+ <ToolArgumentsEditor
765
+ key={contract.id}
766
+ label={i18n.t("tools.arguments")}
767
+ invalidLabel={i18n.t("tools.invalidJson")}
768
+ initialValue={contract.configuration.arguments}
769
+ onValid={(argumentsValue) => {
770
+ update((value) =>
771
+ value.kind === "tool"
772
+ ? {
773
+ ...value,
774
+ configuration: { ...value.configuration, arguments: argumentsValue },
775
+ }
776
+ : value,
777
+ );
778
+ }}
779
+ />
780
+ </div>
781
+ )}
782
+ </aside>
783
+ );
784
+ }
785
+
786
+ function ToolArgumentsEditor({
787
+ label,
788
+ invalidLabel,
789
+ initialValue,
790
+ onValid,
791
+ }: {
792
+ label: string;
793
+ invalidLabel: string;
794
+ initialValue: Record<string, unknown>;
795
+ onValid(value: Record<string, unknown>): void;
796
+ }) {
797
+ const [text, setText] = useState(() => JSON.stringify(initialValue, null, 2));
798
+ const [invalid, setInvalid] = useState(false);
799
+ return (
800
+ <label className="mt-4 block text-sm font-medium">
801
+ {label}
802
+ <textarea
803
+ rows={6}
804
+ value={text}
805
+ onChange={(event) => {
806
+ const next = event.target.value;
807
+ setText(next);
808
+ try {
809
+ const parsed: unknown = JSON.parse(next);
810
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
811
+ setInvalid(true);
812
+ return;
813
+ }
814
+ setInvalid(false);
815
+ onValid(parsed as Record<string, unknown>);
816
+ } catch {
817
+ setInvalid(true);
818
+ }
819
+ }}
820
+ className="mt-2 w-full resize-y rounded-md border bg-background px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
821
+ />
822
+ {invalid && <span className="mt-1 block text-xs text-destructive">{invalidLabel}</span>}
823
+ </label>
824
+ );
825
+ }
826
+
827
+ function flattenKnowledge(
828
+ nodes: KnowledgeTreeNode[],
829
+ depth = 0,
830
+ ): Array<{ id: string; title: string; depth: number }> {
831
+ return nodes.flatMap((node) => [
832
+ { id: node.id, title: node.title, depth },
833
+ ...flattenKnowledge(node.children, depth + 1),
834
+ ]);
835
+ }
836
+
837
+ function Field({
838
+ label,
839
+ value,
840
+ setValue,
841
+ }: {
842
+ label: string;
843
+ value: string;
844
+ setValue(value: string): void;
845
+ }) {
846
+ return (
847
+ <label className="mt-4 block text-sm font-medium">
848
+ {label}
849
+ <input
850
+ value={value}
851
+ onChange={(event) => setValue(event.target.value)}
852
+ className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
853
+ />
854
+ </label>
855
+ );
856
+ }
857
+ function TextArea({
858
+ label,
859
+ value,
860
+ setValue,
861
+ }: {
862
+ label: string;
863
+ value: string;
864
+ setValue(value: string): void;
865
+ }) {
866
+ return (
867
+ <label className="mt-4 block text-sm font-medium">
868
+ {label}
869
+ <textarea
870
+ rows={6}
871
+ value={value}
872
+ onChange={(event) => setValue(event.target.value)}
873
+ className="mt-2 w-full resize-y rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
874
+ />
875
+ </label>
876
+ );
877
+ }
878
+
879
+ function CreateFlow({ close, select }: { close(): void; select(id: string): void }) {
880
+ const { data, i18n } = useIntelRouterContext();
881
+ const queryClient = useQueryClient();
882
+ const [title, setTitle] = useState("");
883
+ const create = useMutation({
884
+ mutationFn: () =>
885
+ data.createFlow({
886
+ title,
887
+ description: null,
888
+ idempotencyKey: crypto.randomUUID(),
889
+ }),
890
+ onSuccess: async (flow) => {
891
+ await queryClient.invalidateQueries({ queryKey: ["flows"] });
892
+ select(flow.id);
893
+ close();
894
+ },
895
+ });
896
+ return (
897
+ <Modal title={i18n.t("flows.new")} close={close}>
898
+ <form
899
+ onSubmit={(event) => {
900
+ event.preventDefault();
901
+ if (create.isPending) return;
902
+ create.mutate();
903
+ }}
904
+ >
905
+ <Field label={i18n.t("common.title")} value={title} setValue={setTitle} />
906
+ {create.isError ? (
907
+ <p role="alert" className="mt-3 text-sm text-destructive">
908
+ {i18n.t("flows.operationFailed")}
909
+ </p>
910
+ ) : null}
911
+ <button
912
+ type="submit"
913
+ disabled={create.isPending || title.trim().length === 0}
914
+ className="mt-5 w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
915
+ >
916
+ {create.isPending ? i18n.t("common.saving") : i18n.t("common.create")}
917
+ </button>
918
+ </form>
919
+ </Modal>
920
+ );
921
+ }