@anchrd/intel-ui 0.6.0 → 0.7.2
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 -15
- package/src/knowledge/knowledge.tsx +2 -3
- package/src/knowledge-graph/graph-notice.tsx +21 -0
- package/src/knowledge-graph/knowledge-graph.tsx +2 -10
- 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.2",
|
|
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
|
+
}
|