@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.
- package/README.md +18 -0
- package/components.json +15 -0
- package/index.html +13 -0
- package/package.json +63 -0
- package/src/app/app.tsx +62 -0
- package/src/blocknote-view/blocknote-view.tsx +13 -0
- package/src/branding/branding.tsx +17 -0
- package/src/branding/custom-logo.ts +1 -0
- package/src/branding/favicon.default.svg +5 -0
- package/src/branding/favicon.svg +5 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +338 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +85 -0
- package/src/flows/flows.tsx +921 -0
- package/src/i18n/custom.json +4 -0
- package/src/i18n/en.json +124 -0
- package/src/i18n/i18n.ts +28 -0
- package/src/i18n/i18n.types.ts +6 -0
- package/src/knowledge/knowledge.tsx +825 -0
- package/src/knowledge-editor/knowledge-editor.tsx +92 -0
- package/src/knowledge-graph/knowledge-graph.ts +58 -0
- package/src/knowledge-graph/knowledge-graph.tsx +111 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +26 -0
- package/src/modal/modal.tsx +53 -0
- package/src/router/router-context.ts +6 -0
- package/src/router/router.tsx +42 -0
- package/src/router/router.types.ts +7 -0
- package/src/styles.css +120 -0
- package/src/theme/custom.css +1 -0
- package/src/theme/theme.ts +16 -0
- package/src/tools/tools.tsx +346 -0
- package/tsconfig.json +11 -0
- package/vite.config.ts +17 -0
|
@@ -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
|
+
}
|