@anchrd/intel-ui 0.6.0 → 0.7.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/package.json +2 -2
- package/src/app/app-tree/app-tree.tsx +15 -3
- package/src/components/ui/table.tsx +82 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +4 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +3 -0
- package/src/entry-picker/entry-picker.tsx +166 -0
- package/src/flows/flows.tsx +154 -149
- package/src/flows/node-icon/node-icon.ts +13 -7
- package/src/flows/node-palette/node-palette.tsx +33 -19
- package/src/folder-contents/folder-contents.tsx +106 -0
- package/src/i18n/en.json +33 -14
- package/src/knowledge/knowledge.tsx +2 -3
- package/src/resource-menu/resource-menu.tsx +55 -45
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"typecheck": "tsc --noEmit"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@anchrd/intel-contract": "^0.
|
|
31
|
+
"@anchrd/intel-contract": "^0.3.0",
|
|
32
32
|
"@blocknote/core": "^0.52.1",
|
|
33
33
|
"@blocknote/react": "^0.52.1",
|
|
34
34
|
"@blocknote/shadcn": "^0.52.1",
|
|
@@ -82,6 +82,11 @@ function levelKey(level: Level): readonly unknown[] {
|
|
|
82
82
|
return level.type === "flow" ? ["flow-calls", level.id] : treeLevelKey(level.id);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// What a row puts on the clipboard for anybody outside the tree. Its own media type rather than
|
|
86
|
+
// `text/plain`, so a drop target that means "move this row" and one that means "make a node for
|
|
87
|
+
// this" cannot be confused by the same payload.
|
|
88
|
+
export const TreeEntryMediaType = "application/x-intel-tree-entry";
|
|
89
|
+
|
|
85
90
|
export function AppTree() {
|
|
86
91
|
const { data, i18n } = useIntelRouterContext();
|
|
87
92
|
const queryClient = useQueryClient();
|
|
@@ -176,7 +181,6 @@ export function AppTree() {
|
|
|
176
181
|
kind,
|
|
177
182
|
title,
|
|
178
183
|
description: null,
|
|
179
|
-
contextPolicy: "relevant",
|
|
180
184
|
idempotencyKey: crypto.randomUUID(),
|
|
181
185
|
});
|
|
182
186
|
// ⚠️ Two calls, because they are two things: the node is a row in the tree, the header is the
|
|
@@ -212,7 +216,6 @@ export function AppTree() {
|
|
|
212
216
|
kind: "attachment",
|
|
213
217
|
title: file.name,
|
|
214
218
|
description: null,
|
|
215
|
-
contextPolicy: "relevant",
|
|
216
219
|
idempotencyKey: crypto.randomUUID(),
|
|
217
220
|
});
|
|
218
221
|
try {
|
|
@@ -445,8 +448,17 @@ export function AppTree() {
|
|
|
445
448
|
isActive={isActive}
|
|
446
449
|
draggable={!derived}
|
|
447
450
|
onDragStart={(event) => {
|
|
448
|
-
|
|
451
|
+
// ⚠️ Two formats, one gesture. `text/plain` is what a drop inside the tree reads —
|
|
452
|
+
// that is a MOVE, and it only ever needed the id. The flow canvas needs the kind as
|
|
453
|
+
// well, to know which node to make, and resolving an id there would mean a second
|
|
454
|
+
// read of something the drag already knows. `effectAllowed` says both are allowed;
|
|
455
|
+
// each drop target picks the one it means (#75).
|
|
456
|
+
event.dataTransfer.effectAllowed = "copyMove";
|
|
449
457
|
event.dataTransfer.setData("text/plain", entry.id);
|
|
458
|
+
event.dataTransfer.setData(
|
|
459
|
+
TreeEntryMediaType,
|
|
460
|
+
JSON.stringify({ id: entry.id, kind: entry.kind, title: entry.title }),
|
|
461
|
+
);
|
|
450
462
|
draggedRef.current = entry;
|
|
451
463
|
setDragged(entry);
|
|
452
464
|
}}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils.ts";
|
|
3
|
+
|
|
4
|
+
// The shadcn table primitive. A real `<table>` and not a grid of divs: the row and column
|
|
5
|
+
// relationships are what a screen reader reads out, and no `role` patch on a div reproduces them
|
|
6
|
+
// as reliably as the element that means it.
|
|
7
|
+
//
|
|
8
|
+
// ⚠️ The horizontal scroll lives on the wrapper, not on the page. A wide table has to be able to
|
|
9
|
+
// scroll inside its own box, or the whole layout scrolls sideways with it.
|
|
10
|
+
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
|
11
|
+
return (
|
|
12
|
+
<div data-slot="table-container" className="relative w-full overflow-x-auto">
|
|
13
|
+
<table
|
|
14
|
+
data-slot="table"
|
|
15
|
+
className={cn("w-full caption-bottom text-sm", className)}
|
|
16
|
+
{...props}
|
|
17
|
+
/>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
|
23
|
+
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
|
27
|
+
return (
|
|
28
|
+
<tbody
|
|
29
|
+
data-slot="table-body"
|
|
30
|
+
className={cn("[&_tr:last-child]:border-0", className)}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
|
37
|
+
return (
|
|
38
|
+
<tr
|
|
39
|
+
data-slot="table-row"
|
|
40
|
+
className={cn(
|
|
41
|
+
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
|
42
|
+
className,
|
|
43
|
+
)}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
|
50
|
+
return (
|
|
51
|
+
<th
|
|
52
|
+
data-slot="table-head"
|
|
53
|
+
className={cn(
|
|
54
|
+
"h-10 px-2 text-left align-middle font-medium text-muted-foreground whitespace-nowrap",
|
|
55
|
+
className,
|
|
56
|
+
)}
|
|
57
|
+
{...props}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
|
63
|
+
return (
|
|
64
|
+
<td
|
|
65
|
+
data-slot="table-cell"
|
|
66
|
+
className={cn("p-2 align-middle whitespace-nowrap", className)}
|
|
67
|
+
{...props}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
|
|
73
|
+
return (
|
|
74
|
+
<caption
|
|
75
|
+
data-slot="table-caption"
|
|
76
|
+
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
|
77
|
+
{...props}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow };
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
FlowRunHistory,
|
|
15
15
|
FlowRunList,
|
|
16
16
|
FlowRunStep,
|
|
17
|
+
FlowValidation,
|
|
17
18
|
KnowledgeDocument,
|
|
18
19
|
KnowledgeGraph,
|
|
19
20
|
KnowledgeLinkList,
|
|
@@ -316,6 +317,9 @@ export function createIntelDataProvider(
|
|
|
316
317
|
async getFlow(flowId) {
|
|
317
318
|
return await request(`/flows/${encodeURIComponent(flowId)}`, FlowDocument);
|
|
318
319
|
},
|
|
320
|
+
async validateFlow(flowId) {
|
|
321
|
+
return await request(`/flows/${encodeURIComponent(flowId)}/validation`, FlowValidation);
|
|
322
|
+
},
|
|
319
323
|
async listFlowCalls(flowId) {
|
|
320
324
|
return await request(`/flows/${encodeURIComponent(flowId)}/calls`, FlowList);
|
|
321
325
|
},
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
FlowRunHistory,
|
|
15
15
|
FlowRunList,
|
|
16
16
|
FlowRunStep,
|
|
17
|
+
FlowValidation,
|
|
17
18
|
KnowledgeDocument,
|
|
18
19
|
KnowledgeGraph,
|
|
19
20
|
KnowledgeLinkList,
|
|
@@ -95,6 +96,8 @@ export interface IntelDataProvider {
|
|
|
95
96
|
// What a flow calls, read out of its graph. It answers a different question from `listTreeChildren`
|
|
96
97
|
// and deliberately gives a different answer: a shared sub-flow is listed under every caller.
|
|
97
98
|
listFlowCalls(flowId: string): Promise<FlowList>;
|
|
99
|
+
// Would this flow start for me, right now. A question, not an action: nothing is created (#72).
|
|
100
|
+
validateFlow(flowId: string): Promise<FlowValidation>;
|
|
98
101
|
// What accesses what, for one level of the shared tree: a folder for its contents, a flow for
|
|
99
102
|
// itself. ⚠️ Only nodes the signed-in user may see come back, and one they may not is absent
|
|
100
103
|
// altogether — never a placeholder, because the edge into one would already say that it exists.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { KnowledgeNode, KnowledgeNodeKind } from "@anchrd/intel-contract";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { ChevronRight, Folder, Search } from "lucide-react";
|
|
4
|
+
import { useMemo, useState } from "react";
|
|
5
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One picker for everything a node can name: search by name, or click down through the tree from
|
|
9
|
+
* the top-level folders.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ Both halves read ONE source — the authorized flat graph the editor already loads. Not because
|
|
12
|
+
* a search endpoint would be hard, but because `searchKnowledge` answers a different question: it
|
|
13
|
+
* searches PASSAGES and hands back citations. Someone looking for a document by name would get
|
|
14
|
+
* results ranked by what is written inside it, which is the wrong list in a picker. Filtering names
|
|
15
|
+
* here is also instant, and it cannot disagree with the tree about what exists.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ What the caller may not see is absent from the graph, so it is absent here — and an empty
|
|
18
|
+
* result never says which of the two it is (#17, #19).
|
|
19
|
+
*/
|
|
20
|
+
export function EntryPicker({
|
|
21
|
+
kind,
|
|
22
|
+
value,
|
|
23
|
+
onSelect,
|
|
24
|
+
label,
|
|
25
|
+
}: {
|
|
26
|
+
// Which kind of node may be chosen. `null` offers every kind — what the Flow step uses.
|
|
27
|
+
kind: KnowledgeNodeKind | null;
|
|
28
|
+
value: string;
|
|
29
|
+
onSelect(nodeId: string): void;
|
|
30
|
+
label: string;
|
|
31
|
+
}) {
|
|
32
|
+
const { data, i18n } = useIntelRouterContext();
|
|
33
|
+
const [query, setQuery] = useState("");
|
|
34
|
+
const [openFolder, setOpenFolder] = useState<string | null>(null);
|
|
35
|
+
const graph = useQuery({
|
|
36
|
+
queryKey: ["knowledge-graph"],
|
|
37
|
+
queryFn: () => data.getKnowledgeGraph(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const nodes = useMemo(() => graph.data?.nodes ?? [], [graph.data]);
|
|
41
|
+
const eligible = useMemo(
|
|
42
|
+
() => nodes.filter((node) => kind === null || node.kind === kind),
|
|
43
|
+
[nodes, kind],
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Searching looks at every eligible node wherever it sits; browsing looks at one level. The two
|
|
47
|
+
// are the same list seen two ways, which is why a result can be picked from either.
|
|
48
|
+
const searching = query.trim().length > 0;
|
|
49
|
+
const shown = useMemo(() => {
|
|
50
|
+
if (searching) {
|
|
51
|
+
const needle = query.trim().toLowerCase();
|
|
52
|
+
return eligible.filter((node) => node.title.toLowerCase().includes(needle)).slice(0, 50);
|
|
53
|
+
}
|
|
54
|
+
return nodes.filter((node) => node.parentId === openFolder);
|
|
55
|
+
}, [searching, query, eligible, nodes, openFolder]);
|
|
56
|
+
|
|
57
|
+
// The way back up, as the chain of folders that leads to the open one.
|
|
58
|
+
const trail = useMemo(() => {
|
|
59
|
+
const chain: KnowledgeNode[] = [];
|
|
60
|
+
let current = openFolder;
|
|
61
|
+
while (current !== null) {
|
|
62
|
+
const folder = nodes.find((node) => node.id === current);
|
|
63
|
+
if (!folder) break;
|
|
64
|
+
chain.unshift(folder);
|
|
65
|
+
current = folder.parentId;
|
|
66
|
+
}
|
|
67
|
+
return chain;
|
|
68
|
+
}, [openFolder, nodes]);
|
|
69
|
+
|
|
70
|
+
const chosen = nodes.find((node) => node.id === value) ?? null;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="mt-4">
|
|
74
|
+
<span className="block text-sm font-medium">{label}</span>
|
|
75
|
+
{chosen ? (
|
|
76
|
+
<p className="mt-1 truncate text-sm text-muted-foreground">{chosen.title}</p>
|
|
77
|
+
) : (
|
|
78
|
+
<p className="mt-1 text-sm text-muted-foreground">{i18n.t("flows.linkEmpty")}</p>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
<div className="mt-2 rounded-lg border">
|
|
82
|
+
<label className="flex items-center gap-2 border-b px-2 py-1.5">
|
|
83
|
+
<Search aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
|
|
84
|
+
<span className="sr-only">{i18n.t("picker.search")}</span>
|
|
85
|
+
<input
|
|
86
|
+
type="search"
|
|
87
|
+
value={query}
|
|
88
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
89
|
+
placeholder={i18n.t("picker.search")}
|
|
90
|
+
className="w-full bg-transparent text-sm outline-none"
|
|
91
|
+
/>
|
|
92
|
+
</label>
|
|
93
|
+
|
|
94
|
+
{/* The trail is hidden while searching: a result can come from anywhere, so "where you are"
|
|
95
|
+
would be a claim about a place the list is no longer showing. */}
|
|
96
|
+
{!searching && trail.length > 0 ? (
|
|
97
|
+
<div className="flex flex-wrap items-center gap-1 border-b px-2 py-1 text-xs">
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={() => setOpenFolder(null)}
|
|
101
|
+
className="rounded px-1 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
|
102
|
+
>
|
|
103
|
+
{i18n.t("picker.top")}
|
|
104
|
+
</button>
|
|
105
|
+
{trail.map((folder) => (
|
|
106
|
+
<span key={folder.id} className="flex items-center gap-1">
|
|
107
|
+
<ChevronRight aria-hidden="true" className="size-3 text-muted-foreground" />
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
onClick={() => setOpenFolder(folder.id)}
|
|
111
|
+
className="rounded px-1 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
|
112
|
+
>
|
|
113
|
+
{folder.title}
|
|
114
|
+
</button>
|
|
115
|
+
</span>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
) : null}
|
|
119
|
+
|
|
120
|
+
<ul className="max-h-56 overflow-y-auto py-1">
|
|
121
|
+
{shown.map((node) => {
|
|
122
|
+
// A folder is two things while browsing: somewhere to go, and — when folders are what
|
|
123
|
+
// is being picked — something to choose. It gets both, side by side, rather than one
|
|
124
|
+
// control that has to guess which was meant.
|
|
125
|
+
const canOpen = node.kind === "folder" && !searching;
|
|
126
|
+
const canPick = kind === null || node.kind === kind;
|
|
127
|
+
return (
|
|
128
|
+
<li key={node.id} className="flex items-center gap-1 px-1">
|
|
129
|
+
{canPick ? (
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
onClick={() => onSelect(node.id)}
|
|
133
|
+
aria-current={node.id === value}
|
|
134
|
+
className={`min-w-0 flex-1 truncate rounded px-2 py-1.5 text-left text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring ${
|
|
135
|
+
node.id === value ? "bg-muted font-medium" : ""
|
|
136
|
+
}`}
|
|
137
|
+
>
|
|
138
|
+
{node.title}
|
|
139
|
+
<span className="sr-only"> — {i18n.t(`knowledge.kind.${node.kind}`)}</span>
|
|
140
|
+
</button>
|
|
141
|
+
) : (
|
|
142
|
+
<span className="min-w-0 flex-1 truncate px-2 py-1.5 text-sm text-muted-foreground">
|
|
143
|
+
{node.title}
|
|
144
|
+
</span>
|
|
145
|
+
)}
|
|
146
|
+
{canOpen ? (
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
onClick={() => setOpenFolder(node.id)}
|
|
150
|
+
aria-label={i18n.t("picker.open", { title: node.title })}
|
|
151
|
+
className="inline-flex size-7 shrink-0 items-center justify-center rounded outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
152
|
+
>
|
|
153
|
+
<Folder aria-hidden="true" className="size-4" />
|
|
154
|
+
</button>
|
|
155
|
+
) : null}
|
|
156
|
+
</li>
|
|
157
|
+
);
|
|
158
|
+
})}
|
|
159
|
+
{shown.length === 0 ? (
|
|
160
|
+
<li className="px-3 py-2 text-sm text-muted-foreground">{i18n.t("picker.nothing")}</li>
|
|
161
|
+
) : null}
|
|
162
|
+
</ul>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
package/src/flows/flows.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Flow, FlowGraph, FlowNode, KnowledgeNode } from "@anchrd/intel-contract";
|
|
2
|
+
import { flowNodeLayer } from "@anchrd/intel-contract";
|
|
2
3
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
4
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
4
5
|
import {
|
|
@@ -17,12 +18,16 @@ import {
|
|
|
17
18
|
type NodeProps,
|
|
18
19
|
Position,
|
|
19
20
|
ReactFlow,
|
|
21
|
+
ReactFlowProvider,
|
|
22
|
+
useReactFlow,
|
|
20
23
|
} from "@xyflow/react";
|
|
21
24
|
import { FileSearch, Info, Send, Wrench } from "lucide-react";
|
|
22
25
|
import { useEffect, useId, useMemo, useState } from "react";
|
|
23
26
|
import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
|
|
27
|
+
import { TreeEntryMediaType } from "@/app/app-tree/app-tree.tsx";
|
|
24
28
|
import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
|
|
25
29
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
30
|
+
import { EntryPicker } from "@/entry-picker/entry-picker.tsx";
|
|
26
31
|
import { FlowRuns } from "@/flow-runs/flow-runs.tsx";
|
|
27
32
|
import { nodeIcon } from "@/flows/node-icon/node-icon.ts";
|
|
28
33
|
import { NodePalette, usePaletteOpen } from "@/flows/node-palette/node-palette.tsx";
|
|
@@ -48,19 +53,46 @@ const ContextHandle = "context";
|
|
|
48
53
|
const paletteKinds = [
|
|
49
54
|
"trigger",
|
|
50
55
|
"instruction",
|
|
51
|
-
"knowledge",
|
|
52
|
-
"tool",
|
|
53
56
|
"condition",
|
|
54
|
-
"approval",
|
|
55
57
|
"subflow",
|
|
58
|
+
"folder",
|
|
59
|
+
"document",
|
|
60
|
+
"upload",
|
|
61
|
+
"table",
|
|
62
|
+
"tool",
|
|
56
63
|
"output",
|
|
57
64
|
] as const;
|
|
58
65
|
|
|
66
|
+
// What the tree hands over, read back defensively: the payload crosses a browser API, so it is
|
|
67
|
+
// parsed rather than trusted. A drop that does not carry what we put there is simply ignored.
|
|
68
|
+
function droppedEntry(payload: string): { id: string; kind: string; title: string } | null {
|
|
69
|
+
try {
|
|
70
|
+
const parsed: unknown = JSON.parse(payload);
|
|
71
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
72
|
+
const { id, kind, title } = parsed as Record<string, unknown>;
|
|
73
|
+
if (typeof id !== "string" || typeof kind !== "string" || typeof title !== "string")
|
|
74
|
+
return null;
|
|
75
|
+
return { id, kind, title };
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A row's kind decides what dropping it makes, so nothing has to be asked. A folder becomes a
|
|
82
|
+
// Folder link, a flow becomes the Flow step that calls it (D25).
|
|
83
|
+
const flowKindOfEntry: Record<string, FlowNode["kind"] | undefined> = {
|
|
84
|
+
folder: "folder",
|
|
85
|
+
document: "document",
|
|
86
|
+
attachment: "upload",
|
|
87
|
+
table: "table",
|
|
88
|
+
flow: "subflow",
|
|
89
|
+
};
|
|
90
|
+
|
|
59
91
|
function FlowCard({ data, selected }: NodeProps<CanvasNode>) {
|
|
60
92
|
const { i18n } = useIntelRouterContext();
|
|
61
93
|
const contract = data.node;
|
|
62
94
|
const Icon = nodeIcon[contract.kind];
|
|
63
|
-
const branching = contract.kind === "condition"
|
|
95
|
+
const branching = contract.kind === "condition";
|
|
64
96
|
// ⚠️ The start is told apart by silhouette first (#39): a pill among rectangles, in the primary
|
|
65
97
|
// token. Shape survives zooming out past the point where the label is legible, and it is the only
|
|
66
98
|
// one of the three that also carries into the overview map, where nothing is written at all.
|
|
@@ -99,18 +131,8 @@ function FlowCard({ data, selected }: NodeProps<CanvasNode>) {
|
|
|
99
131
|
)}
|
|
100
132
|
{branching && (
|
|
101
133
|
<>
|
|
102
|
-
<Handle
|
|
103
|
-
|
|
104
|
-
type="source"
|
|
105
|
-
position={Position.Right}
|
|
106
|
-
style={{ top: "35%" }}
|
|
107
|
-
/>
|
|
108
|
-
<Handle
|
|
109
|
-
id={contract.kind === "approval" ? "rejected" : "no"}
|
|
110
|
-
type="source"
|
|
111
|
-
position={Position.Right}
|
|
112
|
-
style={{ top: "70%" }}
|
|
113
|
-
/>
|
|
134
|
+
<Handle id="yes" type="source" position={Position.Right} style={{ top: "35%" }} />
|
|
135
|
+
<Handle id="no" type="source" position={Position.Right} style={{ top: "70%" }} />
|
|
114
136
|
</>
|
|
115
137
|
)}
|
|
116
138
|
{/* The context point (#37). Sideways is the order of work, downwards is what a step works
|
|
@@ -143,7 +165,7 @@ function defaultGraph(): FlowGraph {
|
|
|
143
165
|
kind: "output",
|
|
144
166
|
label: "Result",
|
|
145
167
|
position: { x: 520, y: 180 },
|
|
146
|
-
configuration: {
|
|
168
|
+
configuration: {},
|
|
147
169
|
},
|
|
148
170
|
],
|
|
149
171
|
edges: [
|
|
@@ -236,6 +258,27 @@ function editsEdges(changes: EdgeChange<CanvasEdge>[]): boolean {
|
|
|
236
258
|
);
|
|
237
259
|
}
|
|
238
260
|
|
|
261
|
+
// ⚠️ `upload` is what the flow calls an attachment. The two names are deliberately not unified:
|
|
262
|
+
// in the tree it is a file somebody uploaded, in a flow it is material a step reads, and renaming
|
|
263
|
+
// either one to match the other would make the other read wrong.
|
|
264
|
+
//
|
|
265
|
+
// The reverse map is DERIVED rather than written a second time — two hand-kept tables of the same
|
|
266
|
+
// correspondence are two tables that eventually disagree, and the disagreement would show up as a
|
|
267
|
+
// picker offering the wrong kind.
|
|
268
|
+
const nodeKindOfLink = Object.fromEntries(
|
|
269
|
+
Object.entries(flowKindOfEntry)
|
|
270
|
+
.filter(([, kind]) => kind !== undefined && kind !== "subflow")
|
|
271
|
+
.map(([nodeKind, kind]) => [kind, nodeKind]),
|
|
272
|
+
) as Partial<Record<FlowNode["kind"], KnowledgeNode["kind"]>>;
|
|
273
|
+
|
|
274
|
+
type LinkNode = Extract<FlowNode, { configuration: { resourceId: string } }>;
|
|
275
|
+
|
|
276
|
+
// A predicate rather than a boolean, so the update below can build the one configuration a link
|
|
277
|
+
// has without widening every other node's to match.
|
|
278
|
+
function isLinkNode(node: FlowNode): node is LinkNode {
|
|
279
|
+
return flowNodeLayer[node.kind] === "link" && "resourceId" in node.configuration;
|
|
280
|
+
}
|
|
281
|
+
|
|
239
282
|
function newNode(
|
|
240
283
|
kind: FlowNode["kind"],
|
|
241
284
|
index: number,
|
|
@@ -259,16 +302,14 @@ function newNode(
|
|
|
259
302
|
kind,
|
|
260
303
|
configuration: { prompt: "Describe what the AI should do." },
|
|
261
304
|
};
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
},
|
|
271
|
-
};
|
|
305
|
+
// The four link kinds differ only in what the picker offers, so they are one branch. A link
|
|
306
|
+
// starts out naming nothing: it is filled in from the inspector, and `compileFlow` refuses to
|
|
307
|
+
// publish a graph whose reference is empty.
|
|
308
|
+
case "folder":
|
|
309
|
+
case "document":
|
|
310
|
+
case "upload":
|
|
311
|
+
case "table":
|
|
312
|
+
return { ...common, kind, configuration: { resourceId: firstKnowledgeId ?? "" } };
|
|
272
313
|
case "tool":
|
|
273
314
|
return {
|
|
274
315
|
...common,
|
|
@@ -288,15 +329,6 @@ function newNode(
|
|
|
288
329
|
instruction: "Describe how to choose yes or no.",
|
|
289
330
|
},
|
|
290
331
|
};
|
|
291
|
-
case "approval":
|
|
292
|
-
return {
|
|
293
|
-
...common,
|
|
294
|
-
kind,
|
|
295
|
-
configuration: {
|
|
296
|
-
prompt: "Describe what must be approved.",
|
|
297
|
-
timeout: "7 days",
|
|
298
|
-
},
|
|
299
|
-
};
|
|
300
332
|
case "subflow":
|
|
301
333
|
// `latest` while drafting: nobody should have to version things while building, and
|
|
302
334
|
// publishing turns it into the concrete version (ADR-0004 §5).
|
|
@@ -306,12 +338,24 @@ function newNode(
|
|
|
306
338
|
configuration: { flowId: firstFlowId ?? "", version: { mode: "latest" } },
|
|
307
339
|
};
|
|
308
340
|
case "output":
|
|
309
|
-
return { ...common, kind, configuration: {
|
|
341
|
+
return { ...common, kind, configuration: {} };
|
|
310
342
|
}
|
|
311
343
|
}
|
|
312
344
|
|
|
345
|
+
// ⚠️ The provider wraps the editor rather than sitting inside it: `useReactFlow` — which is how the
|
|
346
|
+
// drop turns a screen point into a graph point — has to be called from a component UNDER it, and
|
|
347
|
+
// the hook lives in the same component that renders the canvas.
|
|
313
348
|
export function Flows() {
|
|
349
|
+
return (
|
|
350
|
+
<ReactFlowProvider>
|
|
351
|
+
<FlowsEditor />
|
|
352
|
+
</ReactFlowProvider>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function FlowsEditor() {
|
|
314
357
|
const { data, i18n } = useIntelRouterContext();
|
|
358
|
+
const flow = useReactFlow();
|
|
315
359
|
const theme = useSystemTheme();
|
|
316
360
|
const queryClient = useQueryClient();
|
|
317
361
|
const navigate = useNavigate();
|
|
@@ -398,15 +442,6 @@ export function Flows() {
|
|
|
398
442
|
]);
|
|
399
443
|
},
|
|
400
444
|
});
|
|
401
|
-
const run = useMutation({
|
|
402
|
-
mutationFn: () =>
|
|
403
|
-
data.startFlow({
|
|
404
|
-
flowId: selectedFlowId ?? "",
|
|
405
|
-
input: {},
|
|
406
|
-
parent: null,
|
|
407
|
-
idempotencyKey: crypto.randomUUID(),
|
|
408
|
-
}),
|
|
409
|
-
});
|
|
410
445
|
|
|
411
446
|
function updateNode(update: (node: FlowNode) => FlowNode) {
|
|
412
447
|
setDirty(true);
|
|
@@ -435,10 +470,8 @@ export function Flows() {
|
|
|
435
470
|
dirty={dirty}
|
|
436
471
|
saving={save.isPending}
|
|
437
472
|
canMutate={canMutate}
|
|
438
|
-
running={run.isPending}
|
|
439
473
|
onSave={() => save.mutate()}
|
|
440
474
|
onPublish={() => setPublishing(true)}
|
|
441
|
-
onRun={() => run.mutate()}
|
|
442
475
|
/>
|
|
443
476
|
) : null}
|
|
444
477
|
<UnsavedChangesGuard dirty={dirty} />
|
|
@@ -551,6 +584,50 @@ export function Flows() {
|
|
|
551
584
|
setSelectedNodeId(null);
|
|
552
585
|
setPaletteOpen(false);
|
|
553
586
|
}}
|
|
587
|
+
// ⚠️ `copy`, not `move` (#75). The tree's own drop targets say `move`, and the two
|
|
588
|
+
// have to feel different while the pointer is still travelling: dropping here makes
|
|
589
|
+
// a node that POINTS at the row, it does not take the row out of its folder.
|
|
590
|
+
onDragOver={(event) => {
|
|
591
|
+
if (!event.dataTransfer.types.includes(TreeEntryMediaType)) return;
|
|
592
|
+
event.preventDefault();
|
|
593
|
+
event.dataTransfer.dropEffect = "copy";
|
|
594
|
+
}}
|
|
595
|
+
onDrop={(event) => {
|
|
596
|
+
const payload = event.dataTransfer.getData(TreeEntryMediaType);
|
|
597
|
+
if (!payload) return;
|
|
598
|
+
event.preventDefault();
|
|
599
|
+
const dropped = droppedEntry(payload);
|
|
600
|
+
if (!dropped) return;
|
|
601
|
+
const kind = flowKindOfEntry[dropped.kind];
|
|
602
|
+
if (!kind) return;
|
|
603
|
+
// Where the pointer let go, in the graph's own coordinates — not the screen's, or
|
|
604
|
+
// the node would land somewhere else at every zoom level.
|
|
605
|
+
const position = flow.screenToFlowPosition({
|
|
606
|
+
x: event.clientX,
|
|
607
|
+
y: event.clientY,
|
|
608
|
+
});
|
|
609
|
+
setDirty(true);
|
|
610
|
+
setNodes((current) => [
|
|
611
|
+
...current,
|
|
612
|
+
{
|
|
613
|
+
id: `${kind}-${crypto.randomUUID()}`,
|
|
614
|
+
type: "intel" as const,
|
|
615
|
+
position,
|
|
616
|
+
data: {
|
|
617
|
+
node: {
|
|
618
|
+
id: `${kind}-${crypto.randomUUID()}`,
|
|
619
|
+
kind,
|
|
620
|
+
label: dropped.title,
|
|
621
|
+
position,
|
|
622
|
+
configuration:
|
|
623
|
+
kind === "subflow"
|
|
624
|
+
? { flowId: dropped.id, version: { mode: "latest" as const } }
|
|
625
|
+
: { resourceId: dropped.id },
|
|
626
|
+
} as FlowNode,
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
]);
|
|
630
|
+
}}
|
|
554
631
|
>
|
|
555
632
|
<Background />
|
|
556
633
|
{/* The overview map has no room for a label, so the start is marked there the only
|
|
@@ -570,7 +647,6 @@ export function Flows() {
|
|
|
570
647
|
node={selectedNode}
|
|
571
648
|
update={updateNode}
|
|
572
649
|
tools={tools.data?.items ?? []}
|
|
573
|
-
knowledge={knowledge.data?.nodes ?? []}
|
|
574
650
|
flows={(callable.data?.items ?? []).filter((entry) => entry.id !== selectedFlowId)}
|
|
575
651
|
/>
|
|
576
652
|
<FlowNeeds flowId={selectedFlowId} />
|
|
@@ -578,15 +654,7 @@ export function Flows() {
|
|
|
578
654
|
</>
|
|
579
655
|
)}
|
|
580
656
|
</div>
|
|
581
|
-
{
|
|
582
|
-
<div className="fixed bottom-5 right-5 z-20 max-w-sm rounded-xl border bg-card p-4 text-sm shadow-xl">
|
|
583
|
-
<p className="font-medium">{i18n.t("flows.runStarted")}</p>
|
|
584
|
-
<p className="mt-1 text-xs text-muted-foreground">
|
|
585
|
-
{run.data.node?.label ?? run.data.run.status} · {run.data.run.id}
|
|
586
|
-
</p>
|
|
587
|
-
</div>
|
|
588
|
-
)}
|
|
589
|
-
{(save.isError || run.isError) && (
|
|
657
|
+
{save.isError && (
|
|
590
658
|
<p
|
|
591
659
|
role="alert"
|
|
592
660
|
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"
|
|
@@ -636,19 +704,15 @@ function FlowTitle({
|
|
|
636
704
|
dirty,
|
|
637
705
|
saving,
|
|
638
706
|
canMutate,
|
|
639
|
-
running,
|
|
640
707
|
onSave,
|
|
641
708
|
onPublish,
|
|
642
|
-
onRun,
|
|
643
709
|
}: {
|
|
644
710
|
flow: Flow;
|
|
645
711
|
dirty: boolean;
|
|
646
712
|
saving: boolean;
|
|
647
713
|
canMutate: boolean;
|
|
648
|
-
running: boolean;
|
|
649
714
|
onSave(): void;
|
|
650
715
|
onPublish(): void;
|
|
651
|
-
onRun(): void;
|
|
652
716
|
}) {
|
|
653
717
|
const { i18n } = useIntelRouterContext();
|
|
654
718
|
// A draft that is already the published one freezes nothing, so publishing it would be a no-op
|
|
@@ -693,14 +757,6 @@ function FlowTitle({
|
|
|
693
757
|
</Tooltip>
|
|
694
758
|
</TooltipProvider>
|
|
695
759
|
<SaveButton dirty={dirty && canMutate} saving={saving} onSave={onSave} />
|
|
696
|
-
<button
|
|
697
|
-
type="button"
|
|
698
|
-
onClick={onRun}
|
|
699
|
-
disabled={!canMutate || unpublished || running}
|
|
700
|
-
className="inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
701
|
-
>
|
|
702
|
-
{i18n.t("flows.run")}
|
|
703
|
-
</button>
|
|
704
760
|
</TitleRow>
|
|
705
761
|
);
|
|
706
762
|
}
|
|
@@ -795,13 +851,11 @@ function NodeInspector({
|
|
|
795
851
|
node,
|
|
796
852
|
update,
|
|
797
853
|
tools,
|
|
798
|
-
knowledge,
|
|
799
854
|
flows,
|
|
800
855
|
}: {
|
|
801
856
|
node: CanvasNode | null;
|
|
802
857
|
update(fn: (node: FlowNode) => FlowNode): void;
|
|
803
858
|
tools: Array<{ name: string; title: string | null; fingerprint: string }>;
|
|
804
|
-
knowledge: KnowledgeNode[];
|
|
805
859
|
flows: Flow[];
|
|
806
860
|
}) {
|
|
807
861
|
const { i18n } = useIntelRouterContext();
|
|
@@ -810,23 +864,24 @@ function NodeInspector({
|
|
|
810
864
|
return (
|
|
811
865
|
<div className="p-5">
|
|
812
866
|
<h2 className="font-semibold">{i18n.t(`flows.node.${contract.kind}`)}</h2>
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
867
|
+
{/* ⚠️ A marker has no name to give (D25). Start and End are the same two points in every
|
|
868
|
+
flow, and a start renamed "Rechnung holen" reads on the canvas as a step that does
|
|
869
|
+
something — which is exactly the confusion the layer split is meant to end. */}
|
|
870
|
+
{flowNodeLayer[contract.kind] !== "marker" && (
|
|
871
|
+
<Field
|
|
872
|
+
label={i18n.t("common.title")}
|
|
873
|
+
value={contract.label}
|
|
874
|
+
setValue={(label) => update((value) => ({ ...value, label }))}
|
|
875
|
+
/>
|
|
876
|
+
)}
|
|
877
|
+
{(contract.kind === "instruction" || contract.kind === "condition") && (
|
|
821
878
|
<TextArea
|
|
822
879
|
label={i18n.t("flows.instruction")}
|
|
823
880
|
hint={i18n.t("flows.instructionHint")}
|
|
824
881
|
value={
|
|
825
882
|
contract.kind === "instruction"
|
|
826
883
|
? contract.configuration.prompt
|
|
827
|
-
: contract.
|
|
828
|
-
? contract.configuration.instruction
|
|
829
|
-
: contract.configuration.prompt
|
|
884
|
+
: contract.configuration.instruction
|
|
830
885
|
}
|
|
831
886
|
setValue={(text) =>
|
|
832
887
|
update((value) =>
|
|
@@ -837,76 +892,26 @@ function NodeInspector({
|
|
|
837
892
|
...value,
|
|
838
893
|
configuration: { mode: "semantic", instruction: text },
|
|
839
894
|
}
|
|
840
|
-
: value
|
|
841
|
-
? {
|
|
842
|
-
...value,
|
|
843
|
-
configuration: { ...value.configuration, prompt: text },
|
|
844
|
-
}
|
|
845
|
-
: value,
|
|
895
|
+
: value,
|
|
846
896
|
)
|
|
847
897
|
}
|
|
848
898
|
/>
|
|
849
899
|
)}
|
|
850
|
-
{
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
}
|
|
866
|
-
onChange={(event) =>
|
|
867
|
-
update((value) => {
|
|
868
|
-
if (value.kind !== "knowledge") return value;
|
|
869
|
-
const selected = new Set(value.configuration.resourceIds);
|
|
870
|
-
if (event.target.checked) selected.add(item.id);
|
|
871
|
-
else selected.delete(item.id);
|
|
872
|
-
return {
|
|
873
|
-
...value,
|
|
874
|
-
configuration: {
|
|
875
|
-
...value.configuration,
|
|
876
|
-
resourceIds: [...selected],
|
|
877
|
-
},
|
|
878
|
-
};
|
|
879
|
-
})
|
|
880
|
-
}
|
|
881
|
-
className="mt-0.5 size-4 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
|
|
882
|
-
/>
|
|
883
|
-
<span className="min-w-0 truncate">{item.title}</span>
|
|
884
|
-
</label>
|
|
885
|
-
))}
|
|
886
|
-
{knowledge.length === 0 && (
|
|
887
|
-
<p className="px-2 py-1 text-sm text-muted-foreground">
|
|
888
|
-
{i18n.t("flows.knowledgeEmpty")}
|
|
889
|
-
</p>
|
|
890
|
-
)}
|
|
891
|
-
</fieldset>
|
|
892
|
-
<TextArea
|
|
893
|
-
label={i18n.t("flows.knowledgeQuery")}
|
|
894
|
-
value={contract.configuration.query ?? ""}
|
|
895
|
-
setValue={(query) =>
|
|
896
|
-
update((value) =>
|
|
897
|
-
value.kind === "knowledge"
|
|
898
|
-
? {
|
|
899
|
-
...value,
|
|
900
|
-
configuration: {
|
|
901
|
-
...value.configuration,
|
|
902
|
-
query: query || null,
|
|
903
|
-
},
|
|
904
|
-
}
|
|
905
|
-
: value,
|
|
906
|
-
)
|
|
907
|
-
}
|
|
908
|
-
/>
|
|
909
|
-
</>
|
|
900
|
+
{/* ⚠️ One reference, chosen — not a list, ticked. What replaces this properly is the picker
|
|
901
|
+
in #75 (search, the tree, dropping from the sidebar); until then a select does the one
|
|
902
|
+
thing the layer rule needs it to do, which is make "exactly one" the only sayable state.
|
|
903
|
+
The list is filtered to the kind of the node, so a Table node cannot name a folder. */}
|
|
904
|
+
{isLinkNode(contract) && (
|
|
905
|
+
<EntryPicker
|
|
906
|
+
kind={nodeKindOfLink[contract.kind] ?? null}
|
|
907
|
+
value={contract.configuration.resourceId}
|
|
908
|
+
label={i18n.t(`flows.node.${contract.kind}`)}
|
|
909
|
+
onSelect={(resourceId) =>
|
|
910
|
+
update((value) =>
|
|
911
|
+
isLinkNode(value) ? { ...value, configuration: { resourceId } } : value,
|
|
912
|
+
)
|
|
913
|
+
}
|
|
914
|
+
/>
|
|
910
915
|
)}
|
|
911
916
|
{contract.kind === "subflow" && (
|
|
912
917
|
<div className="mt-4">
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { FlowNode } from "@anchrd/intel-contract";
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
CircleDot,
|
|
4
4
|
CirclePlay,
|
|
5
|
-
|
|
5
|
+
FileText,
|
|
6
|
+
Folder,
|
|
6
7
|
GitBranch,
|
|
7
|
-
|
|
8
|
+
Paperclip,
|
|
8
9
|
Sparkles,
|
|
10
|
+
Table,
|
|
9
11
|
Workflow,
|
|
10
12
|
Wrench,
|
|
11
13
|
} from "lucide-react";
|
|
@@ -19,10 +21,14 @@ export type NodeIcon = ComponentType<SVGProps<SVGSVGElement>>;
|
|
|
19
21
|
export const nodeIcon: Record<FlowNode["kind"], NodeIcon> = {
|
|
20
22
|
trigger: CirclePlay,
|
|
21
23
|
instruction: Sparkles,
|
|
22
|
-
knowledge: FileSearch,
|
|
23
|
-
tool: Wrench,
|
|
24
24
|
condition: GitBranch,
|
|
25
|
-
approval: ShieldCheck,
|
|
26
25
|
subflow: Workflow,
|
|
27
|
-
|
|
26
|
+
// The four links wear the symbol their kind wears in the tree, so a document is the same shape
|
|
27
|
+
// wherever it is seen — the sidebar, the picker and the canvas do not each teach their own.
|
|
28
|
+
folder: Folder,
|
|
29
|
+
document: FileText,
|
|
30
|
+
upload: Paperclip,
|
|
31
|
+
table: Table,
|
|
32
|
+
tool: Wrench,
|
|
33
|
+
output: CircleDot,
|
|
28
34
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { flowNodeLayer } from "@anchrd/intel-contract";
|
|
1
2
|
import { Plus, X } from "lucide-react";
|
|
2
|
-
import { useId, useRef, useState } from "react";
|
|
3
|
+
import { Fragment, useId, useRef, useState } from "react";
|
|
3
4
|
import { type NodeIcon, nodeIcon } from "@/flows/node-icon/node-icon.ts";
|
|
4
5
|
import { cn } from "@/lib/utils.ts";
|
|
5
6
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
@@ -172,25 +173,38 @@ export function NodePalette<K extends PaletteKind>({
|
|
|
172
173
|
{kinds.map((kind, index) => {
|
|
173
174
|
const Icon: NodeIcon = nodeIcon[kind];
|
|
174
175
|
const reason = disabledReason?.(kind) ?? null;
|
|
176
|
+
// ⚠️ A separator before the first entry of each layer after the first (D25). It is
|
|
177
|
+
// decoration, not structure: the toolbar's keyboard walks `[data-palette-item]`, so a
|
|
178
|
+
// divider in between must not be one — otherwise the arrows would stop on a line.
|
|
179
|
+
const opensLayer =
|
|
180
|
+
index > 0 && flowNodeLayer[kind] !== flowNodeLayer[kinds[index - 1] ?? kind];
|
|
175
181
|
return (
|
|
176
|
-
<
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
182
|
+
<Fragment key={kind}>
|
|
183
|
+
{opensLayer ? (
|
|
184
|
+
<span
|
|
185
|
+
aria-hidden="true"
|
|
186
|
+
className="mx-1 my-1.5 w-px self-stretch bg-border"
|
|
187
|
+
title={i18n.t(`flows.layer.${flowNodeLayer[kind]}`)}
|
|
188
|
+
/>
|
|
189
|
+
) : null}
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
data-palette-item=""
|
|
193
|
+
disabled={reason !== null}
|
|
194
|
+
title={reason ?? undefined}
|
|
195
|
+
tabIndex={index === active ? 0 : -1}
|
|
196
|
+
onClick={() => {
|
|
197
|
+
setActive(index);
|
|
198
|
+
add(kind);
|
|
199
|
+
}}
|
|
200
|
+
// `h-9` is the trigger's height: sharing one row only reads as one row if the entries
|
|
201
|
+
// start on the trigger's top edge instead of floating in the middle of it.
|
|
202
|
+
className="inline-flex h-9 items-center gap-1.5 rounded-md px-2 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
|
|
203
|
+
>
|
|
204
|
+
<Icon aria-hidden="true" className="size-3.5" />
|
|
205
|
+
{i18n.t(`flows.node.${kind}`)}
|
|
206
|
+
</button>
|
|
207
|
+
</Fragment>
|
|
194
208
|
);
|
|
195
209
|
})}
|
|
196
210
|
</div>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { useNavigate } from "@tanstack/react-router";
|
|
3
|
+
import { treeLevelKey } from "@/app/tree-move/tree-move.tsx";
|
|
4
|
+
import {
|
|
5
|
+
Table,
|
|
6
|
+
TableBody,
|
|
7
|
+
TableCell,
|
|
8
|
+
TableHead,
|
|
9
|
+
TableHeader,
|
|
10
|
+
TableRow,
|
|
11
|
+
} from "@/components/ui/table.tsx";
|
|
12
|
+
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
13
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
14
|
+
|
|
15
|
+
const icons: Record<TreeEntry["kind"], string> = {
|
|
16
|
+
folder: "knowledge.kind.folder",
|
|
17
|
+
document: "knowledge.kind.document",
|
|
18
|
+
attachment: "knowledge.kind.attachment",
|
|
19
|
+
table: "knowledge.kind.table",
|
|
20
|
+
flow: "knowledge.kind.flow",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A folder's own screen: what lies directly in it, as a table.
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ It reads the SAME query the sidebar reads for the same folder — `treeLevelKey`, filled by
|
|
27
|
+
* `listTreeChildren`. Not a second call and not a second cache entry: two readers of one folder that
|
|
28
|
+
* fetch separately show it differently the moment one of them is stale, and that is the kind of
|
|
29
|
+
* difference a customer finds before anybody here does. The other half of the bargain is free —
|
|
30
|
+
* every invalidation the tree already does after a create, a move or an archive lands here too.
|
|
31
|
+
*/
|
|
32
|
+
export function FolderContents({ folderId }: { folderId: string }) {
|
|
33
|
+
const { data, i18n } = useIntelRouterContext();
|
|
34
|
+
const navigate = useNavigate();
|
|
35
|
+
const level = useQuery({
|
|
36
|
+
queryKey: treeLevelKey(folderId),
|
|
37
|
+
queryFn: () => data.listTreeChildren(folderId),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (level.isPending) {
|
|
41
|
+
return <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
|
|
42
|
+
}
|
|
43
|
+
if (level.isError) {
|
|
44
|
+
return (
|
|
45
|
+
<p role="alert" className="p-6 text-sm text-destructive">
|
|
46
|
+
{i18n.t("knowledge.folderFailed")}
|
|
47
|
+
</p>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const entries = level.data ?? [];
|
|
52
|
+
// ⚠️ An empty folder keeps a sentence, unlike an empty row in the tree (#59). A folder IS this
|
|
53
|
+
// screen: a blank rectangle leaves someone who just created it with nowhere to go, which is the
|
|
54
|
+
// same reason `tree.empty` survives at the root.
|
|
55
|
+
if (entries.length === 0) {
|
|
56
|
+
return (
|
|
57
|
+
<div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
|
|
58
|
+
{i18n.t("knowledge.folderEmpty")}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
|
65
|
+
<Table>
|
|
66
|
+
<TableHeader>
|
|
67
|
+
<TableRow>
|
|
68
|
+
<TableHead>{i18n.t("common.title")}</TableHead>
|
|
69
|
+
<TableHead>{i18n.t("knowledge.kind")}</TableHead>
|
|
70
|
+
<TableHead>{i18n.t("knowledge.changed")}</TableHead>
|
|
71
|
+
</TableRow>
|
|
72
|
+
</TableHeader>
|
|
73
|
+
<TableBody>
|
|
74
|
+
{entries.map((entry) => {
|
|
75
|
+
const changed = entry.type === "flow" ? entry.flow.updatedAt : entry.node.updatedAt;
|
|
76
|
+
return (
|
|
77
|
+
<TableRow key={entry.id}>
|
|
78
|
+
{/* ⚠️ The button carries the whole cell rather than the cell carrying an onClick.
|
|
79
|
+
A row that only reacts to a mouse is a row nobody can reach with a keyboard, and
|
|
80
|
+
a `<tr onClick>` has no name to read out either. */}
|
|
81
|
+
<TableCell className="p-0">
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
onClick={() =>
|
|
85
|
+
void navigate({
|
|
86
|
+
to: entry.type === "flow" ? "/flows" : "/knowledge",
|
|
87
|
+
search: { select: entry.id },
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
className="w-full px-2 py-2 text-left outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
|
91
|
+
>
|
|
92
|
+
{entry.title}
|
|
93
|
+
</button>
|
|
94
|
+
</TableCell>
|
|
95
|
+
<TableCell className="text-muted-foreground">{i18n.t(icons[entry.kind])}</TableCell>
|
|
96
|
+
<TableCell className="text-muted-foreground">
|
|
97
|
+
{new Date(changed).toLocaleDateString()}
|
|
98
|
+
</TableCell>
|
|
99
|
+
</TableRow>
|
|
100
|
+
);
|
|
101
|
+
})}
|
|
102
|
+
</TableBody>
|
|
103
|
+
</Table>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
package/src/i18n/en.json
CHANGED
|
@@ -84,11 +84,15 @@
|
|
|
84
84
|
"knowledge.table.empty": "No rows yet. Rows are appended by flows and agents.",
|
|
85
85
|
"knowledge.table.undefined": "This table has no columns yet.",
|
|
86
86
|
"knowledge.select": "Select a document or folder to work with it.",
|
|
87
|
-
"knowledge.
|
|
88
|
-
"knowledge.
|
|
89
|
-
"knowledge.
|
|
90
|
-
"knowledge.
|
|
91
|
-
"knowledge.
|
|
87
|
+
"knowledge.folderEmpty": "Nothing in this folder yet. Create something with the plus in the sidebar.",
|
|
88
|
+
"knowledge.folderFailed": "This folder could not be read.",
|
|
89
|
+
"knowledge.kind": "Kind",
|
|
90
|
+
"knowledge.changed": "Changed",
|
|
91
|
+
"knowledge.kind.folder": "Folder",
|
|
92
|
+
"knowledge.kind.document": "Document",
|
|
93
|
+
"knowledge.kind.attachment": "Upload",
|
|
94
|
+
"knowledge.kind.table": "Table",
|
|
95
|
+
"knowledge.kind.flow": "Flow",
|
|
92
96
|
"knowledge.share": "Share",
|
|
93
97
|
"knowledge.shareAction": "Grant access",
|
|
94
98
|
"knowledge.email": "Email address",
|
|
@@ -158,25 +162,40 @@
|
|
|
158
162
|
"flows.publish": "Publish",
|
|
159
163
|
"flows.publishNothing": "Nothing new to publish",
|
|
160
164
|
"flows.publishNeeded": "Publish — no run can start until you do",
|
|
161
|
-
"flows.
|
|
162
|
-
"flows.
|
|
165
|
+
"flows.validate": "Check whether it would run",
|
|
166
|
+
"flows.validateReady": "This flow would start now.",
|
|
167
|
+
"flows.validateWhen": "Checked {when}. Tool access is asked with your own token each time, so this answer is a snapshot.",
|
|
168
|
+
"flows.problem.flow_execute_forbidden": "You may not run this flow",
|
|
169
|
+
"flows.problem.flow_not_published": "The flow is not published",
|
|
170
|
+
"flows.problem.flow_graph_invalid": "The graph cannot be run",
|
|
171
|
+
"flows.problem.flow_tools_unavailable": "A tool is out of reach",
|
|
172
|
+
"flows.problem.flow_knowledge_forbidden": "Material a step reads is out of reach",
|
|
173
|
+
"flows.problem.flow_subflow_unavailable": "A called flow is unavailable",
|
|
174
|
+
"flows.problem.flow_subflow_not_published": "A called flow is not published",
|
|
163
175
|
"flows.addNode": "Add a step",
|
|
164
176
|
"flows.closePalette": "Close the step bar",
|
|
165
177
|
"flows.nodePalette": "Step types",
|
|
166
178
|
"flows.node.trigger": "Start",
|
|
167
179
|
"flows.startExists": "A flow has exactly one start, and this one already has it.",
|
|
168
180
|
"flows.node.instruction": "Instruction",
|
|
169
|
-
"flows.node.
|
|
181
|
+
"flows.node.folder": "Folder",
|
|
182
|
+
"flows.node.document": "Document",
|
|
183
|
+
"flows.node.upload": "Upload",
|
|
184
|
+
"flows.node.table": "Table",
|
|
170
185
|
"flows.node.tool": "Tool",
|
|
171
186
|
"flows.node.condition": "Condition",
|
|
172
|
-
"flows.node.
|
|
173
|
-
"flows.node.
|
|
174
|
-
"flows.node.output": "Output",
|
|
187
|
+
"flows.node.subflow": "Flow",
|
|
188
|
+
"flows.node.output": "End",
|
|
175
189
|
"flows.instruction": "Instruction for the AI or person",
|
|
176
190
|
"flows.instructionHint": "Conditions, checks and branches can be described here in words. You do not need a separate node for every small decision.",
|
|
177
|
-
"flows.
|
|
178
|
-
"
|
|
179
|
-
"
|
|
191
|
+
"flows.linkEmpty": "Nothing chosen yet",
|
|
192
|
+
"picker.search": "Search by name",
|
|
193
|
+
"picker.top": "All folders",
|
|
194
|
+
"picker.open": "Open {title}",
|
|
195
|
+
"picker.nothing": "Nothing here",
|
|
196
|
+
"flows.layer.marker": "Markers",
|
|
197
|
+
"flows.layer.step": "Steps",
|
|
198
|
+
"flows.layer.link": "Links",
|
|
180
199
|
"flows.subflowTarget": "Flow to call",
|
|
181
200
|
"flows.selectSubflow": "Select a flow to call",
|
|
182
201
|
"flows.subflowRule": "A flow may call a flow in its own folder or below it, or one in a folder every user may run. Publishing names the reason if it may not.",
|
|
@@ -6,6 +6,7 @@ import { lazy, Suspense, useState } from "react";
|
|
|
6
6
|
import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
|
|
7
7
|
import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
|
|
8
8
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
9
|
+
import { FolderContents } from "@/folder-contents/folder-contents.tsx";
|
|
9
10
|
import { GraphPane } from "@/graph-pane/graph-pane.tsx";
|
|
10
11
|
import { KnowledgeTablePanel } from "@/knowledge-table/knowledge-table.tsx";
|
|
11
12
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
@@ -135,9 +136,7 @@ export function Knowledge() {
|
|
|
135
136
|
}
|
|
136
137
|
/>
|
|
137
138
|
) : selected.kind === "folder" ? (
|
|
138
|
-
<
|
|
139
|
-
{i18n.t("knowledge.folderHelp")}
|
|
140
|
-
</div>
|
|
139
|
+
<FolderContents folderId={selected.id} />
|
|
141
140
|
) : selected.kind === "attachment" ? (
|
|
142
141
|
<AttachmentPanel node={selected} />
|
|
143
142
|
) : selected.kind === "table" ? (
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
ContextPolicy,
|
|
3
2
|
Flow,
|
|
3
|
+
FlowValidation,
|
|
4
4
|
KnowledgeNode,
|
|
5
5
|
ResourceVerb,
|
|
6
6
|
UnreadableKnowledge,
|
|
7
7
|
} from "@anchrd/intel-contract";
|
|
8
8
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
9
9
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
Archive,
|
|
12
|
+
CornerLeftUp,
|
|
13
|
+
Ellipsis,
|
|
14
|
+
Link2,
|
|
15
|
+
Pencil,
|
|
16
|
+
Share2,
|
|
17
|
+
ShieldCheck,
|
|
18
|
+
Trash2,
|
|
19
|
+
} from "lucide-react";
|
|
11
20
|
import type * as React from "react";
|
|
12
21
|
import { useState } from "react";
|
|
13
22
|
import { moveErrorKey, useTreeMove } from "@/app/tree-move/tree-move.tsx";
|
|
@@ -15,12 +24,7 @@ import {
|
|
|
15
24
|
DropdownMenu,
|
|
16
25
|
DropdownMenuContent,
|
|
17
26
|
DropdownMenuItem,
|
|
18
|
-
DropdownMenuRadioGroup,
|
|
19
|
-
DropdownMenuRadioItem,
|
|
20
27
|
DropdownMenuSeparator,
|
|
21
|
-
DropdownMenuSub,
|
|
22
|
-
DropdownMenuSubContent,
|
|
23
|
-
DropdownMenuSubTrigger,
|
|
24
28
|
DropdownMenuTrigger,
|
|
25
29
|
} from "@/components/ui/dropdown-menu";
|
|
26
30
|
import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
@@ -93,8 +97,6 @@ export function resourceErrorKey(error: unknown): string {
|
|
|
93
97
|
}
|
|
94
98
|
}
|
|
95
99
|
|
|
96
|
-
const contextPolicies: readonly ContextPolicy[] = ["pinned", "relevant", "explicit"];
|
|
97
|
-
|
|
98
100
|
/**
|
|
99
101
|
* The three-dot menu.
|
|
100
102
|
*
|
|
@@ -122,6 +124,14 @@ export function ResourceMenu({
|
|
|
122
124
|
// the name. #27 gave moving a second, equal route through a folder picker, and that route lived in
|
|
123
125
|
// the tree row's menu. With the row menu gone (#58) it lives here, or it does not exist.
|
|
124
126
|
const move = useTreeMove();
|
|
127
|
+
// ⚠️ Not a query. A validation is a snapshot taken when somebody asks (#72, ADR-0003): cached
|
|
128
|
+
// under a key it would be re-served later as if it still held, and the one thing it must never
|
|
129
|
+
// claim is that a moment which has passed is still true.
|
|
130
|
+
const [validation, setValidation] = useState<FlowValidation | null>(null);
|
|
131
|
+
const validate = useMutation({
|
|
132
|
+
mutationFn: () => data.validateFlow(id),
|
|
133
|
+
onSuccess: setValidation,
|
|
134
|
+
});
|
|
125
135
|
|
|
126
136
|
const id = idOf(target);
|
|
127
137
|
const title = titleOf(target);
|
|
@@ -192,26 +202,11 @@ export function ResourceMenu({
|
|
|
192
202
|
},
|
|
193
203
|
});
|
|
194
204
|
|
|
195
|
-
const setContextPolicy = useMutation({
|
|
196
|
-
mutationFn: async (contextPolicy: ContextPolicy) => {
|
|
197
|
-
if (target.type === "flow") throw new Error("A flow has no retrieval mode");
|
|
198
|
-
await data.updateKnowledge({
|
|
199
|
-
nodeId: target.node.id,
|
|
200
|
-
baseUpdatedAt: target.node.updatedAt,
|
|
201
|
-
contextPolicy,
|
|
202
|
-
idempotencyKey: crypto.randomUUID(),
|
|
203
|
-
});
|
|
204
|
-
},
|
|
205
|
-
onSuccess: refresh,
|
|
206
|
-
});
|
|
207
|
-
|
|
208
205
|
const failure = rename.isError
|
|
209
206
|
? null // the rename dialog words its own refusal, beside the field that caused it
|
|
210
207
|
: archive.isError
|
|
211
208
|
? archive.error
|
|
212
|
-
:
|
|
213
|
-
? setContextPolicy.error
|
|
214
|
-
: undefined;
|
|
209
|
+
: undefined;
|
|
215
210
|
|
|
216
211
|
return (
|
|
217
212
|
<>
|
|
@@ -236,27 +231,15 @@ export function ResourceMenu({
|
|
|
236
231
|
<CornerLeftUp aria-hidden="true" />
|
|
237
232
|
{i18n.t("tree.move.action")}
|
|
238
233
|
</DropdownMenuItem>
|
|
239
|
-
{
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
<DropdownMenuSubContent>
|
|
247
|
-
<DropdownMenuRadioGroup
|
|
248
|
-
value={node.contextPolicy}
|
|
249
|
-
onValueChange={(value) => setContextPolicy.mutate(value as ContextPolicy)}
|
|
250
|
-
>
|
|
251
|
-
{contextPolicies.map((policy) => (
|
|
252
|
-
<DropdownMenuRadioItem key={policy} value={policy}>
|
|
253
|
-
{i18n.t(`knowledge.context.${policy}`)}
|
|
254
|
-
</DropdownMenuRadioItem>
|
|
255
|
-
))}
|
|
256
|
-
</DropdownMenuRadioGroup>
|
|
257
|
-
</DropdownMenuSubContent>
|
|
258
|
-
</DropdownMenuSub>
|
|
234
|
+
{/* Only a flow can be run, so only a flow can be asked whether it would. Rarely needed —
|
|
235
|
+
which is why it is in the menu and not in the title line (#72). */}
|
|
236
|
+
{node === null ? (
|
|
237
|
+
<DropdownMenuItem onSelect={() => validate.mutate()}>
|
|
238
|
+
<ShieldCheck aria-hidden="true" />
|
|
239
|
+
{i18n.t("flows.validate")}
|
|
240
|
+
</DropdownMenuItem>
|
|
259
241
|
) : null}
|
|
242
|
+
{retrievable || linkable || node ? <DropdownMenuSeparator /> : null}
|
|
260
243
|
{linkable ? (
|
|
261
244
|
<DropdownMenuItem onSelect={() => setLinksOpen(true)}>
|
|
262
245
|
<Link2 aria-hidden="true" />
|
|
@@ -295,6 +278,33 @@ export function ResourceMenu({
|
|
|
295
278
|
`resourceErrorKey`: "that is not a folder" and "that would be a loop" have no equivalent
|
|
296
279
|
among the changes above, and one shared sentence would leave the reader guessing. */}
|
|
297
280
|
{move.error ? <MenuFailure>{i18n.t(moveErrorKey(move.error))}</MenuFailure> : null}
|
|
281
|
+
{/* ⚠️ Every reason at once, not the first one. Somebody with two missing tools should not have
|
|
282
|
+
to ask twice — and a success says so out loud, because an empty menu after a click reads
|
|
283
|
+
as "nothing happened" rather than "nothing is in the way". */}
|
|
284
|
+
{validation ? (
|
|
285
|
+
<Modal title={i18n.t("flows.validate")} close={() => setValidation(null)}>
|
|
286
|
+
{validation.problems.length === 0 ? (
|
|
287
|
+
<p className="text-sm">{i18n.t("flows.validateReady")}</p>
|
|
288
|
+
) : (
|
|
289
|
+
<ul className="space-y-2">
|
|
290
|
+
{validation.problems.map((problem) => (
|
|
291
|
+
<li key={problem.code} className="rounded-md border p-3 text-sm">
|
|
292
|
+
<span className="block font-medium">
|
|
293
|
+
{i18n.t(`flows.problem.${problem.code}`)}
|
|
294
|
+
</span>
|
|
295
|
+
<span className="mt-1 block text-xs text-muted-foreground">{problem.detail}</span>
|
|
296
|
+
</li>
|
|
297
|
+
))}
|
|
298
|
+
</ul>
|
|
299
|
+
)}
|
|
300
|
+
{/* A snapshot says when it was taken, or it will be read as a standing verdict. */}
|
|
301
|
+
<p className="mt-4 text-xs text-muted-foreground">
|
|
302
|
+
{i18n.t("flows.validateWhen", {
|
|
303
|
+
when: new Date(validation.checkedAt).toLocaleString(),
|
|
304
|
+
})}
|
|
305
|
+
</p>
|
|
306
|
+
</Modal>
|
|
307
|
+
) : null}
|
|
298
308
|
{move.dialog}
|
|
299
309
|
{renaming ? (
|
|
300
310
|
<RenameDialog
|