@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,825 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ContextPolicy,
|
|
3
|
+
KnowledgeLinkRelation,
|
|
4
|
+
KnowledgeNode,
|
|
5
|
+
ResourceRole,
|
|
6
|
+
} from "@anchrd/intel-contract";
|
|
7
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
8
|
+
import {
|
|
9
|
+
Archive,
|
|
10
|
+
ChevronDown,
|
|
11
|
+
ChevronRight,
|
|
12
|
+
Download,
|
|
13
|
+
FileText,
|
|
14
|
+
Folder,
|
|
15
|
+
FolderPlus,
|
|
16
|
+
History,
|
|
17
|
+
Link2,
|
|
18
|
+
Network,
|
|
19
|
+
Paperclip,
|
|
20
|
+
Plus,
|
|
21
|
+
Search,
|
|
22
|
+
Share2,
|
|
23
|
+
Trash2,
|
|
24
|
+
} from "lucide-react";
|
|
25
|
+
import { lazy, Suspense, useMemo, useState } from "react";
|
|
26
|
+
import { Button, Collection, Tree, TreeItem, TreeItemContent } from "react-aria-components";
|
|
27
|
+
import type { KnowledgeTreeNode } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
28
|
+
import { Modal } from "@/modal/modal.tsx";
|
|
29
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
30
|
+
|
|
31
|
+
const KnowledgeEditor = lazy(async () => ({
|
|
32
|
+
default: (await import("@/knowledge-editor/knowledge-editor.tsx")).KnowledgeEditor,
|
|
33
|
+
}));
|
|
34
|
+
const KnowledgeGraphView = lazy(async () => ({
|
|
35
|
+
default: (await import("@/knowledge-graph/knowledge-graph.tsx")).KnowledgeGraphView,
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
function findNode(nodes: KnowledgeTreeNode[], id: string | null): KnowledgeTreeNode | null {
|
|
39
|
+
if (!id) return null;
|
|
40
|
+
for (const node of nodes) {
|
|
41
|
+
if (node.id === id) return node;
|
|
42
|
+
const child = findNode(node.children, id);
|
|
43
|
+
if (child) return child;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function flattenTree(nodes: KnowledgeTreeNode[]): KnowledgeTreeNode[] {
|
|
49
|
+
return nodes.flatMap((node) => [node, ...flattenTree(node.children)]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function fileBase64(file: File): Promise<string> {
|
|
53
|
+
if (file.size > 15_000_000) throw new Error("Attachment exceeds the 15 MB upload limit");
|
|
54
|
+
return await new Promise((resolve, reject) => {
|
|
55
|
+
const reader = new FileReader();
|
|
56
|
+
reader.onerror = () => reject(reader.error ?? new Error("Attachment could not be read"));
|
|
57
|
+
reader.onload = () => {
|
|
58
|
+
const result = reader.result;
|
|
59
|
+
if (typeof result !== "string" || !result.includes(",")) {
|
|
60
|
+
reject(new Error("Attachment could not be encoded"));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
resolve(result.slice(result.indexOf(",") + 1));
|
|
64
|
+
};
|
|
65
|
+
reader.readAsDataURL(file);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function Knowledge() {
|
|
70
|
+
const { data, i18n } = useIntelRouterContext();
|
|
71
|
+
const queryClient = useQueryClient();
|
|
72
|
+
const tree = useQuery({ queryKey: ["knowledge-tree"], queryFn: () => data.loadKnowledgeTree() });
|
|
73
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
74
|
+
const [creating, setCreating] = useState<"document" | "folder" | null>(null);
|
|
75
|
+
const [sharing, setSharing] = useState(false);
|
|
76
|
+
const [linksOpen, setLinksOpen] = useState(false);
|
|
77
|
+
const [graphOpen, setGraphOpen] = useState(false);
|
|
78
|
+
const [versionsOpen, setVersionsOpen] = useState(false);
|
|
79
|
+
const [searchText, setSearchText] = useState("");
|
|
80
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
81
|
+
const selected = useMemo(() => findNode(tree.data ?? [], selectedId), [selectedId, tree.data]);
|
|
82
|
+
const graph = useQuery({
|
|
83
|
+
queryKey: ["knowledge-graph"],
|
|
84
|
+
queryFn: () => data.getKnowledgeGraph(),
|
|
85
|
+
enabled: graphOpen,
|
|
86
|
+
});
|
|
87
|
+
const document = useQuery({
|
|
88
|
+
queryKey: ["knowledge", selectedId],
|
|
89
|
+
queryFn: () => data.getKnowledge(selectedId ?? ""),
|
|
90
|
+
enabled: Boolean(selectedId && selected?.kind !== "folder"),
|
|
91
|
+
});
|
|
92
|
+
const search = useQuery({
|
|
93
|
+
queryKey: ["knowledge-search", searchQuery],
|
|
94
|
+
queryFn: () => data.searchKnowledge({ query: searchQuery, limit: 12 }),
|
|
95
|
+
enabled: searchQuery.length > 0,
|
|
96
|
+
});
|
|
97
|
+
const archive = useMutation({
|
|
98
|
+
mutationFn: (node: KnowledgeNode) =>
|
|
99
|
+
data.archiveKnowledge({
|
|
100
|
+
nodeId: node.id,
|
|
101
|
+
baseUpdatedAt: node.updatedAt,
|
|
102
|
+
archived: true,
|
|
103
|
+
idempotencyKey: crypto.randomUUID(),
|
|
104
|
+
}),
|
|
105
|
+
onSuccess: async () => {
|
|
106
|
+
setSelectedId(null);
|
|
107
|
+
await queryClient.invalidateQueries({ queryKey: ["knowledge-tree"] });
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
const setContextPolicy = useMutation({
|
|
111
|
+
mutationFn: ({ node, contextPolicy }: { node: KnowledgeNode; contextPolicy: ContextPolicy }) =>
|
|
112
|
+
data.updateKnowledge({
|
|
113
|
+
nodeId: node.id,
|
|
114
|
+
baseUpdatedAt: node.updatedAt,
|
|
115
|
+
contextPolicy,
|
|
116
|
+
idempotencyKey: crypto.randomUUID(),
|
|
117
|
+
}),
|
|
118
|
+
onSuccess: async () => {
|
|
119
|
+
await queryClient.invalidateQueries({ queryKey: ["knowledge-tree"] });
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
const upload = useMutation({
|
|
123
|
+
mutationFn: async (file: File) => {
|
|
124
|
+
const contentBase64 = await fileBase64(file);
|
|
125
|
+
const node = await data.createKnowledge({
|
|
126
|
+
parentId: selected?.kind === "folder" ? selected.id : (selected?.parentId ?? null),
|
|
127
|
+
kind: "attachment",
|
|
128
|
+
title: file.name,
|
|
129
|
+
description: null,
|
|
130
|
+
contextPolicy: "relevant",
|
|
131
|
+
idempotencyKey: crypto.randomUUID(),
|
|
132
|
+
});
|
|
133
|
+
try {
|
|
134
|
+
const saved = await data.saveKnowledgeAttachment({
|
|
135
|
+
nodeId: node.id,
|
|
136
|
+
baseVersionId: null,
|
|
137
|
+
contentBase64,
|
|
138
|
+
mediaType: file.type || "application/octet-stream",
|
|
139
|
+
idempotencyKey: crypto.randomUUID(),
|
|
140
|
+
});
|
|
141
|
+
return saved.node;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
const current = await data.getKnowledge(node.id).catch(() => null);
|
|
144
|
+
if (current?.version === null && current.node.updatedAt === node.updatedAt) {
|
|
145
|
+
await data
|
|
146
|
+
.archiveKnowledge({
|
|
147
|
+
nodeId: node.id,
|
|
148
|
+
baseUpdatedAt: current.node.updatedAt,
|
|
149
|
+
archived: true,
|
|
150
|
+
idempotencyKey: crypto.randomUUID(),
|
|
151
|
+
})
|
|
152
|
+
.catch(() => undefined);
|
|
153
|
+
}
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
onSuccess: async (node) => {
|
|
158
|
+
setSelectedId(node.id);
|
|
159
|
+
await queryClient.invalidateQueries({ queryKey: ["knowledge-tree"] });
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const renderTreeItem = (node: KnowledgeTreeNode) => (
|
|
164
|
+
<TreeItem
|
|
165
|
+
id={node.id}
|
|
166
|
+
textValue={node.title}
|
|
167
|
+
className="rounded-md outline-none data-[focused]:ring-2 data-[focused]:ring-ring data-[selected]:bg-accent data-[selected]:text-accent-foreground"
|
|
168
|
+
>
|
|
169
|
+
<TreeItemContent>
|
|
170
|
+
{({ hasChildItems, isExpanded }) => (
|
|
171
|
+
<div className="flex min-w-0 items-center gap-2 px-2 py-1.5 text-sm">
|
|
172
|
+
{hasChildItems ? (
|
|
173
|
+
<Button
|
|
174
|
+
slot="chevron"
|
|
175
|
+
className="rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
176
|
+
>
|
|
177
|
+
{isExpanded ? (
|
|
178
|
+
<ChevronDown aria-hidden="true" className="size-3.5" />
|
|
179
|
+
) : (
|
|
180
|
+
<ChevronRight aria-hidden="true" className="size-3.5" />
|
|
181
|
+
)}
|
|
182
|
+
</Button>
|
|
183
|
+
) : (
|
|
184
|
+
<span className="size-3.5" />
|
|
185
|
+
)}
|
|
186
|
+
{node.kind === "folder" ? (
|
|
187
|
+
<Folder aria-hidden="true" className="size-4 shrink-0" />
|
|
188
|
+
) : (
|
|
189
|
+
<FileText aria-hidden="true" className="size-4 shrink-0" />
|
|
190
|
+
)}
|
|
191
|
+
<span className="truncate">{node.title}</span>
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
</TreeItemContent>
|
|
195
|
+
<Collection items={node.children}>{renderTreeItem}</Collection>
|
|
196
|
+
</TreeItem>
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<div className="flex h-screen min-h-0 flex-col">
|
|
201
|
+
<header className="flex items-center justify-between gap-6 border-b px-8 py-5">
|
|
202
|
+
<div>
|
|
203
|
+
<h1 className="text-xl font-semibold tracking-tight">{i18n.t("knowledge.title")}</h1>
|
|
204
|
+
<p className="mt-1 text-sm text-muted-foreground">{i18n.t("knowledge.description")}</p>
|
|
205
|
+
</div>
|
|
206
|
+
<div className="flex items-center gap-2">
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
onClick={() => setGraphOpen(true)}
|
|
210
|
+
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"
|
|
211
|
+
>
|
|
212
|
+
<Network aria-hidden="true" className="size-4" />
|
|
213
|
+
{i18n.t("knowledge.graph")}
|
|
214
|
+
</button>
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
onClick={() => setCreating("folder")}
|
|
218
|
+
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"
|
|
219
|
+
>
|
|
220
|
+
<FolderPlus aria-hidden="true" className="size-4" />
|
|
221
|
+
{i18n.t("knowledge.newFolder")}
|
|
222
|
+
</button>
|
|
223
|
+
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm outline-none hover:bg-muted focus-within:ring-2 focus-within:ring-ring">
|
|
224
|
+
<Paperclip aria-hidden="true" className="size-4" />
|
|
225
|
+
{upload.isPending ? i18n.t("knowledge.uploading") : i18n.t("knowledge.upload")}
|
|
226
|
+
<input
|
|
227
|
+
type="file"
|
|
228
|
+
className="sr-only"
|
|
229
|
+
disabled={upload.isPending}
|
|
230
|
+
onChange={(event) => {
|
|
231
|
+
const file = event.target.files?.[0];
|
|
232
|
+
if (file) upload.mutate(file);
|
|
233
|
+
event.currentTarget.value = "";
|
|
234
|
+
}}
|
|
235
|
+
/>
|
|
236
|
+
</label>
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
onClick={() => setCreating("document")}
|
|
240
|
+
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"
|
|
241
|
+
>
|
|
242
|
+
<Plus aria-hidden="true" className="size-4" />
|
|
243
|
+
{i18n.t("knowledge.new")}
|
|
244
|
+
</button>
|
|
245
|
+
</div>
|
|
246
|
+
</header>
|
|
247
|
+
{upload.isError && (
|
|
248
|
+
<p role="alert" className="border-b px-8 py-3 text-sm text-destructive">
|
|
249
|
+
{i18n.t("knowledge.uploadFailed")}
|
|
250
|
+
</p>
|
|
251
|
+
)}
|
|
252
|
+
{archive.isError || setContextPolicy.isError ? (
|
|
253
|
+
<p role="alert" className="border-b px-8 py-3 text-sm text-destructive">
|
|
254
|
+
{i18n.t("knowledge.operationFailed")}
|
|
255
|
+
</p>
|
|
256
|
+
) : null}
|
|
257
|
+
<div className="flex min-h-0 flex-1">
|
|
258
|
+
<aside className="flex w-80 min-w-72 flex-col border-r bg-muted/20">
|
|
259
|
+
<form
|
|
260
|
+
className="border-b p-3"
|
|
261
|
+
onSubmit={(event) => {
|
|
262
|
+
event.preventDefault();
|
|
263
|
+
setSearchQuery(searchText.trim());
|
|
264
|
+
}}
|
|
265
|
+
>
|
|
266
|
+
<label className="relative block">
|
|
267
|
+
<span className="sr-only">{i18n.t("knowledge.search")}</span>
|
|
268
|
+
<Search
|
|
269
|
+
aria-hidden="true"
|
|
270
|
+
className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
|
271
|
+
/>
|
|
272
|
+
<input
|
|
273
|
+
type="search"
|
|
274
|
+
value={searchText}
|
|
275
|
+
onChange={(event) => setSearchText(event.target.value)}
|
|
276
|
+
placeholder={i18n.t("knowledge.search")}
|
|
277
|
+
className="w-full rounded-md border bg-background py-2 pl-9 pr-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
278
|
+
/>
|
|
279
|
+
</label>
|
|
280
|
+
</form>
|
|
281
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
|
282
|
+
{searchQuery ? (
|
|
283
|
+
<div className="space-y-1">
|
|
284
|
+
{search.data?.items.map((citation) => (
|
|
285
|
+
<button
|
|
286
|
+
key={`${citation.nodeId}:${citation.versionId}`}
|
|
287
|
+
type="button"
|
|
288
|
+
onClick={() => setSelectedId(citation.nodeId)}
|
|
289
|
+
className="w-full rounded-md p-2 text-left outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
|
|
290
|
+
>
|
|
291
|
+
<span className="block truncate text-sm font-medium">{citation.title}</span>
|
|
292
|
+
<span className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
|
293
|
+
{citation.passage}
|
|
294
|
+
</span>
|
|
295
|
+
</button>
|
|
296
|
+
))}
|
|
297
|
+
{search.data?.items.length === 0 && (
|
|
298
|
+
<p className="p-2 text-sm text-muted-foreground">
|
|
299
|
+
{i18n.t("knowledge.noResults")}
|
|
300
|
+
</p>
|
|
301
|
+
)}
|
|
302
|
+
<button
|
|
303
|
+
type="button"
|
|
304
|
+
onClick={() => {
|
|
305
|
+
setSearchQuery("");
|
|
306
|
+
setSearchText("");
|
|
307
|
+
}}
|
|
308
|
+
className="mt-3 text-xs text-muted-foreground underline outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
309
|
+
>
|
|
310
|
+
{i18n.t("knowledge.clearSearch")}
|
|
311
|
+
</button>
|
|
312
|
+
</div>
|
|
313
|
+
) : tree.isPending ? (
|
|
314
|
+
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
315
|
+
) : (
|
|
316
|
+
<Tree
|
|
317
|
+
aria-label={i18n.t("knowledge.tree")}
|
|
318
|
+
items={tree.data ?? []}
|
|
319
|
+
selectionMode="single"
|
|
320
|
+
selectedKeys={selectedId ? [selectedId] : []}
|
|
321
|
+
onSelectionChange={(keys) => {
|
|
322
|
+
if (keys === "all") return;
|
|
323
|
+
const key = [...keys][0];
|
|
324
|
+
setSelectedId(key === undefined ? null : String(key));
|
|
325
|
+
}}
|
|
326
|
+
className="outline-none"
|
|
327
|
+
renderEmptyState={() => (
|
|
328
|
+
<p className="p-2 text-sm text-muted-foreground">{i18n.t("knowledge.empty")}</p>
|
|
329
|
+
)}
|
|
330
|
+
>
|
|
331
|
+
{renderTreeItem}
|
|
332
|
+
</Tree>
|
|
333
|
+
)}
|
|
334
|
+
</div>
|
|
335
|
+
</aside>
|
|
336
|
+
<section className="relative flex min-w-0 flex-1 flex-col bg-card">
|
|
337
|
+
{!selected && (
|
|
338
|
+
<div className="grid flex-1 place-items-center p-8 text-center text-sm text-muted-foreground">
|
|
339
|
+
{i18n.t("knowledge.select")}
|
|
340
|
+
</div>
|
|
341
|
+
)}
|
|
342
|
+
{selected && (
|
|
343
|
+
<>
|
|
344
|
+
<div className="flex items-start justify-between gap-5 border-b px-6 py-4">
|
|
345
|
+
<div className="min-w-0">
|
|
346
|
+
<h2 className="truncate text-lg font-semibold">{selected.title}</h2>
|
|
347
|
+
{selected.description && (
|
|
348
|
+
<p className="mt-1 text-sm text-muted-foreground">{selected.description}</p>
|
|
349
|
+
)}
|
|
350
|
+
</div>
|
|
351
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
352
|
+
<select
|
|
353
|
+
value={selected.contextPolicy}
|
|
354
|
+
disabled={setContextPolicy.isPending}
|
|
355
|
+
onChange={(event) =>
|
|
356
|
+
setContextPolicy.mutate({
|
|
357
|
+
node: selected,
|
|
358
|
+
contextPolicy: event.target.value as ContextPolicy,
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
aria-label={i18n.t("knowledge.contextPolicy")}
|
|
362
|
+
className="mr-2 rounded-md border bg-background px-2 py-1.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
363
|
+
>
|
|
364
|
+
<option value="pinned">{i18n.t("knowledge.context.pinned")}</option>
|
|
365
|
+
<option value="relevant">{i18n.t("knowledge.context.relevant")}</option>
|
|
366
|
+
<option value="explicit">{i18n.t("knowledge.context.explicit")}</option>
|
|
367
|
+
</select>
|
|
368
|
+
<button
|
|
369
|
+
type="button"
|
|
370
|
+
onClick={() => setLinksOpen(true)}
|
|
371
|
+
aria-label={i18n.t("knowledge.links")}
|
|
372
|
+
className="rounded-md p-2 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
373
|
+
>
|
|
374
|
+
<Link2 aria-hidden="true" className="size-4" />
|
|
375
|
+
</button>
|
|
376
|
+
<button
|
|
377
|
+
type="button"
|
|
378
|
+
onClick={() => setSharing(true)}
|
|
379
|
+
aria-label={i18n.t("knowledge.share")}
|
|
380
|
+
className="rounded-md p-2 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
381
|
+
>
|
|
382
|
+
<Share2 aria-hidden="true" className="size-4" />
|
|
383
|
+
</button>
|
|
384
|
+
{selected.kind !== "folder" && (
|
|
385
|
+
<button
|
|
386
|
+
type="button"
|
|
387
|
+
onClick={() => setVersionsOpen((value) => !value)}
|
|
388
|
+
aria-label={i18n.t("knowledge.versions")}
|
|
389
|
+
className="rounded-md p-2 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
390
|
+
>
|
|
391
|
+
<History aria-hidden="true" className="size-4" />
|
|
392
|
+
</button>
|
|
393
|
+
)}
|
|
394
|
+
<button
|
|
395
|
+
type="button"
|
|
396
|
+
onClick={() => archive.mutate(selected)}
|
|
397
|
+
aria-label={i18n.t("knowledge.archive")}
|
|
398
|
+
className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
399
|
+
>
|
|
400
|
+
<Archive aria-hidden="true" className="size-4" />
|
|
401
|
+
</button>
|
|
402
|
+
</div>
|
|
403
|
+
</div>
|
|
404
|
+
{selected.kind === "folder" ? (
|
|
405
|
+
<div className="grid flex-1 place-items-center p-8 text-sm text-muted-foreground">
|
|
406
|
+
{i18n.t("knowledge.folderHelp")}
|
|
407
|
+
</div>
|
|
408
|
+
) : selected.kind === "attachment" && document.data ? (
|
|
409
|
+
<AttachmentPanel node={selected} />
|
|
410
|
+
) : document.isPending ? (
|
|
411
|
+
<p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
412
|
+
) : document.data ? (
|
|
413
|
+
<Suspense
|
|
414
|
+
fallback={
|
|
415
|
+
<p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
416
|
+
}
|
|
417
|
+
>
|
|
418
|
+
<KnowledgeEditor
|
|
419
|
+
key={document.data.node.id}
|
|
420
|
+
data={data}
|
|
421
|
+
document={document.data}
|
|
422
|
+
i18n={i18n}
|
|
423
|
+
onSaved={(saved) => {
|
|
424
|
+
queryClient.setQueryData(["knowledge", selected.id], saved);
|
|
425
|
+
void queryClient.invalidateQueries({ queryKey: ["knowledge-tree"] });
|
|
426
|
+
}}
|
|
427
|
+
/>
|
|
428
|
+
</Suspense>
|
|
429
|
+
) : null}
|
|
430
|
+
{versionsOpen && selected.kind !== "folder" && (
|
|
431
|
+
<VersionHistory nodeId={selected.id} close={() => setVersionsOpen(false)} />
|
|
432
|
+
)}
|
|
433
|
+
</>
|
|
434
|
+
)}
|
|
435
|
+
</section>
|
|
436
|
+
</div>
|
|
437
|
+
{creating && (
|
|
438
|
+
<CreateKnowledge
|
|
439
|
+
kind={creating}
|
|
440
|
+
parentId={selected?.kind === "folder" ? selected.id : (selected?.parentId ?? null)}
|
|
441
|
+
close={() => setCreating(null)}
|
|
442
|
+
/>
|
|
443
|
+
)}
|
|
444
|
+
{sharing && selected && <ShareKnowledge node={selected} close={() => setSharing(false)} />}
|
|
445
|
+
{linksOpen && selected && (
|
|
446
|
+
<KnowledgeLinks
|
|
447
|
+
node={selected}
|
|
448
|
+
nodes={flattenTree(tree.data ?? [])}
|
|
449
|
+
close={() => setLinksOpen(false)}
|
|
450
|
+
/>
|
|
451
|
+
)}
|
|
452
|
+
{graphOpen && !graph.data && (
|
|
453
|
+
<Modal title={i18n.t("knowledge.graph")} close={() => setGraphOpen(false)}>
|
|
454
|
+
<p role={graph.isError ? "alert" : undefined} className="text-sm text-muted-foreground">
|
|
455
|
+
{graph.isError ? i18n.t("knowledge.graphFailed") : i18n.t("common.loading")}
|
|
456
|
+
</p>
|
|
457
|
+
{graph.isError && (
|
|
458
|
+
<button
|
|
459
|
+
type="button"
|
|
460
|
+
onClick={() => void graph.refetch()}
|
|
461
|
+
className="mt-4 rounded-md border px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
462
|
+
>
|
|
463
|
+
{i18n.t("common.retry")}
|
|
464
|
+
</button>
|
|
465
|
+
)}
|
|
466
|
+
</Modal>
|
|
467
|
+
)}
|
|
468
|
+
{graphOpen && graph.data && (
|
|
469
|
+
<Suspense fallback={null}>
|
|
470
|
+
<KnowledgeGraphView
|
|
471
|
+
data={graph.data}
|
|
472
|
+
i18n={i18n}
|
|
473
|
+
close={() => setGraphOpen(false)}
|
|
474
|
+
select={(nodeId) => {
|
|
475
|
+
setSelectedId(nodeId);
|
|
476
|
+
setGraphOpen(false);
|
|
477
|
+
}}
|
|
478
|
+
/>
|
|
479
|
+
</Suspense>
|
|
480
|
+
)}
|
|
481
|
+
</div>
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function AttachmentPanel({ node }: { node: KnowledgeNode }) {
|
|
486
|
+
const { data, i18n } = useIntelRouterContext();
|
|
487
|
+
const download = useMutation({
|
|
488
|
+
mutationFn: () => data.getKnowledgeAttachment(node.id),
|
|
489
|
+
onSuccess: (blob) => {
|
|
490
|
+
const url = URL.createObjectURL(blob);
|
|
491
|
+
const anchor = document.createElement("a");
|
|
492
|
+
anchor.href = url;
|
|
493
|
+
anchor.download = node.title;
|
|
494
|
+
document.body.append(anchor);
|
|
495
|
+
anchor.click();
|
|
496
|
+
anchor.remove();
|
|
497
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
return (
|
|
501
|
+
<div className="grid flex-1 place-items-center p-8">
|
|
502
|
+
<div className="max-w-md rounded-xl border bg-background p-8 text-center shadow-sm">
|
|
503
|
+
<Paperclip aria-hidden="true" className="mx-auto size-10 text-primary" />
|
|
504
|
+
<h3 className="mt-4 font-semibold">{node.title}</h3>
|
|
505
|
+
<p className="mt-2 text-sm text-muted-foreground">{i18n.t("knowledge.attachmentHelp")}</p>
|
|
506
|
+
<button
|
|
507
|
+
type="button"
|
|
508
|
+
onClick={() => download.mutate()}
|
|
509
|
+
disabled={download.isPending}
|
|
510
|
+
className="mt-5 inline-flex items-center gap-2 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"
|
|
511
|
+
>
|
|
512
|
+
<Download aria-hidden="true" className="size-4" />
|
|
513
|
+
{download.isPending ? i18n.t("common.loading") : i18n.t("knowledge.download")}
|
|
514
|
+
</button>
|
|
515
|
+
{download.isError ? (
|
|
516
|
+
<p role="alert" className="mt-4 text-sm text-destructive">
|
|
517
|
+
{i18n.t("knowledge.downloadFailed")}
|
|
518
|
+
</p>
|
|
519
|
+
) : null}
|
|
520
|
+
</div>
|
|
521
|
+
</div>
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function KnowledgeLinks({
|
|
526
|
+
node,
|
|
527
|
+
nodes,
|
|
528
|
+
close,
|
|
529
|
+
}: {
|
|
530
|
+
node: KnowledgeNode;
|
|
531
|
+
nodes: KnowledgeNode[];
|
|
532
|
+
close(): void;
|
|
533
|
+
}) {
|
|
534
|
+
const { data, i18n } = useIntelRouterContext();
|
|
535
|
+
const queryClient = useQueryClient();
|
|
536
|
+
const [targetNodeId, setTargetNodeId] = useState("");
|
|
537
|
+
const [relation, setRelation] = useState<KnowledgeLinkRelation>("related");
|
|
538
|
+
const links = useQuery({
|
|
539
|
+
queryKey: ["knowledge-links", node.id],
|
|
540
|
+
queryFn: () => data.listKnowledgeLinks(node.id),
|
|
541
|
+
});
|
|
542
|
+
const create = useMutation({
|
|
543
|
+
mutationFn: () =>
|
|
544
|
+
data.createKnowledgeLink({
|
|
545
|
+
sourceNodeId: node.id,
|
|
546
|
+
targetNodeId,
|
|
547
|
+
relation,
|
|
548
|
+
label: null,
|
|
549
|
+
idempotencyKey: crypto.randomUUID(),
|
|
550
|
+
}),
|
|
551
|
+
onSuccess: async () => {
|
|
552
|
+
setTargetNodeId("");
|
|
553
|
+
await Promise.all([
|
|
554
|
+
queryClient.invalidateQueries({ queryKey: ["knowledge-links", node.id] }),
|
|
555
|
+
queryClient.invalidateQueries({ queryKey: ["knowledge-graph"] }),
|
|
556
|
+
]);
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
const remove = useMutation({
|
|
560
|
+
mutationFn: (linkId: string) =>
|
|
561
|
+
data.deleteKnowledgeLink({
|
|
562
|
+
sourceNodeId: node.id,
|
|
563
|
+
linkId,
|
|
564
|
+
idempotencyKey: crypto.randomUUID(),
|
|
565
|
+
}),
|
|
566
|
+
onSuccess: async () => {
|
|
567
|
+
await Promise.all([
|
|
568
|
+
queryClient.invalidateQueries({ queryKey: ["knowledge-links", node.id] }),
|
|
569
|
+
queryClient.invalidateQueries({ queryKey: ["knowledge-graph"] }),
|
|
570
|
+
]);
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
const titles = new Map(nodes.map((candidate) => [candidate.id, candidate.title]));
|
|
574
|
+
|
|
575
|
+
return (
|
|
576
|
+
<Modal title={i18n.t("knowledge.links")} close={close}>
|
|
577
|
+
<ul className="mb-5 max-h-48 space-y-2 overflow-y-auto">
|
|
578
|
+
{links.data?.items.map((link) => {
|
|
579
|
+
const outgoing = link.sourceNodeId === node.id;
|
|
580
|
+
const otherId = outgoing ? link.targetNodeId : link.sourceNodeId;
|
|
581
|
+
return (
|
|
582
|
+
<li
|
|
583
|
+
key={link.id}
|
|
584
|
+
className="flex items-center justify-between gap-3 rounded-md border p-3"
|
|
585
|
+
>
|
|
586
|
+
<div className="min-w-0 text-sm">
|
|
587
|
+
<span className="block truncate font-medium">{titles.get(otherId) ?? otherId}</span>
|
|
588
|
+
<span className="text-xs text-muted-foreground">
|
|
589
|
+
{outgoing ? "→" : "←"} {link.label ?? link.relation.replaceAll("_", " ")}
|
|
590
|
+
</span>
|
|
591
|
+
</div>
|
|
592
|
+
{outgoing && (
|
|
593
|
+
<button
|
|
594
|
+
type="button"
|
|
595
|
+
onClick={() => remove.mutate(link.id)}
|
|
596
|
+
aria-label={i18n.t("knowledge.deleteLink")}
|
|
597
|
+
className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
598
|
+
>
|
|
599
|
+
<Trash2 aria-hidden="true" className="size-4" />
|
|
600
|
+
</button>
|
|
601
|
+
)}
|
|
602
|
+
</li>
|
|
603
|
+
);
|
|
604
|
+
})}
|
|
605
|
+
{links.data?.items.length === 0 && (
|
|
606
|
+
<li className="text-sm text-muted-foreground">{i18n.t("knowledge.noLinks")}</li>
|
|
607
|
+
)}
|
|
608
|
+
</ul>
|
|
609
|
+
<form
|
|
610
|
+
className="space-y-4 border-t pt-5"
|
|
611
|
+
onSubmit={(event) => {
|
|
612
|
+
event.preventDefault();
|
|
613
|
+
create.mutate();
|
|
614
|
+
}}
|
|
615
|
+
>
|
|
616
|
+
<label className="block text-sm font-medium">
|
|
617
|
+
{i18n.t("knowledge.linkTarget")}
|
|
618
|
+
<select
|
|
619
|
+
required
|
|
620
|
+
value={targetNodeId}
|
|
621
|
+
onChange={(event) => setTargetNodeId(event.target.value)}
|
|
622
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
623
|
+
>
|
|
624
|
+
<option value="">{i18n.t("knowledge.selectLinkTarget")}</option>
|
|
625
|
+
{nodes
|
|
626
|
+
.filter((candidate) => candidate.id !== node.id)
|
|
627
|
+
.map((candidate) => (
|
|
628
|
+
<option key={candidate.id} value={candidate.id}>
|
|
629
|
+
{candidate.title}
|
|
630
|
+
</option>
|
|
631
|
+
))}
|
|
632
|
+
</select>
|
|
633
|
+
</label>
|
|
634
|
+
<label className="block text-sm font-medium">
|
|
635
|
+
{i18n.t("knowledge.relation")}
|
|
636
|
+
<select
|
|
637
|
+
value={relation}
|
|
638
|
+
onChange={(event) => setRelation(event.target.value as KnowledgeLinkRelation)}
|
|
639
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
640
|
+
>
|
|
641
|
+
{(["related", "references", "depends_on", "implements"] as const).map((value) => (
|
|
642
|
+
<option key={value} value={value}>
|
|
643
|
+
{i18n.t(`knowledge.relation.${value}`)}
|
|
644
|
+
</option>
|
|
645
|
+
))}
|
|
646
|
+
</select>
|
|
647
|
+
</label>
|
|
648
|
+
<button
|
|
649
|
+
type="submit"
|
|
650
|
+
disabled={!targetNodeId || create.isPending}
|
|
651
|
+
className="w-full rounded-md bg-primary px-4 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"
|
|
652
|
+
>
|
|
653
|
+
{i18n.t("knowledge.createLink")}
|
|
654
|
+
</button>
|
|
655
|
+
</form>
|
|
656
|
+
</Modal>
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function CreateKnowledge({
|
|
661
|
+
kind,
|
|
662
|
+
parentId,
|
|
663
|
+
close,
|
|
664
|
+
}: {
|
|
665
|
+
kind: "document" | "folder";
|
|
666
|
+
parentId: string | null;
|
|
667
|
+
close(): void;
|
|
668
|
+
}) {
|
|
669
|
+
const { data, i18n } = useIntelRouterContext();
|
|
670
|
+
const queryClient = useQueryClient();
|
|
671
|
+
const [title, setTitle] = useState("");
|
|
672
|
+
const mutation = useMutation({
|
|
673
|
+
mutationFn: () =>
|
|
674
|
+
data.createKnowledge({
|
|
675
|
+
parentId,
|
|
676
|
+
kind,
|
|
677
|
+
title,
|
|
678
|
+
description: null,
|
|
679
|
+
contextPolicy: "relevant",
|
|
680
|
+
idempotencyKey: crypto.randomUUID(),
|
|
681
|
+
}),
|
|
682
|
+
onSuccess: async () => {
|
|
683
|
+
await queryClient.invalidateQueries({ queryKey: ["knowledge-tree"] });
|
|
684
|
+
close();
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
return (
|
|
688
|
+
<Modal
|
|
689
|
+
title={kind === "folder" ? i18n.t("knowledge.newFolder") : i18n.t("knowledge.new")}
|
|
690
|
+
close={close}
|
|
691
|
+
>
|
|
692
|
+
<form
|
|
693
|
+
onSubmit={(event) => {
|
|
694
|
+
event.preventDefault();
|
|
695
|
+
mutation.mutate();
|
|
696
|
+
}}
|
|
697
|
+
className="space-y-4"
|
|
698
|
+
>
|
|
699
|
+
<label className="block text-sm font-medium">
|
|
700
|
+
{i18n.t("common.title")}
|
|
701
|
+
<input
|
|
702
|
+
required
|
|
703
|
+
value={title}
|
|
704
|
+
onChange={(event) => setTitle(event.target.value)}
|
|
705
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
706
|
+
/>
|
|
707
|
+
</label>
|
|
708
|
+
<button
|
|
709
|
+
type="submit"
|
|
710
|
+
disabled={mutation.isPending}
|
|
711
|
+
className="w-full rounded-md bg-primary px-4 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"
|
|
712
|
+
>
|
|
713
|
+
{i18n.t("common.create")}
|
|
714
|
+
</button>
|
|
715
|
+
</form>
|
|
716
|
+
</Modal>
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void }) {
|
|
721
|
+
const { data, i18n } = useIntelRouterContext();
|
|
722
|
+
const [email, setEmail] = useState("");
|
|
723
|
+
const [role, setRole] = useState<ResourceRole>("viewer");
|
|
724
|
+
const mutation = useMutation({
|
|
725
|
+
mutationFn: () =>
|
|
726
|
+
data.shareKnowledge({
|
|
727
|
+
resourceId: node.id,
|
|
728
|
+
principal: { type: "email", email },
|
|
729
|
+
role,
|
|
730
|
+
expiresAt: null,
|
|
731
|
+
idempotencyKey: crypto.randomUUID(),
|
|
732
|
+
}),
|
|
733
|
+
onSuccess: close,
|
|
734
|
+
});
|
|
735
|
+
return (
|
|
736
|
+
<Modal title={i18n.t("knowledge.share")} close={close}>
|
|
737
|
+
<form
|
|
738
|
+
onSubmit={(event) => {
|
|
739
|
+
event.preventDefault();
|
|
740
|
+
mutation.mutate();
|
|
741
|
+
}}
|
|
742
|
+
className="space-y-4"
|
|
743
|
+
>
|
|
744
|
+
<label className="block text-sm font-medium">
|
|
745
|
+
{i18n.t("knowledge.email")}
|
|
746
|
+
<input
|
|
747
|
+
type="email"
|
|
748
|
+
required
|
|
749
|
+
value={email}
|
|
750
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
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
|
+
</label>
|
|
754
|
+
<label className="block text-sm font-medium">
|
|
755
|
+
{i18n.t("knowledge.role")}
|
|
756
|
+
<select
|
|
757
|
+
value={role}
|
|
758
|
+
onChange={(event) => setRole(event.target.value as ResourceRole)}
|
|
759
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
760
|
+
>
|
|
761
|
+
<option value="viewer">{i18n.t("knowledge.viewer")}</option>
|
|
762
|
+
<option value="editor">{i18n.t("knowledge.editor")}</option>
|
|
763
|
+
<option value="manager">{i18n.t("knowledge.manager")}</option>
|
|
764
|
+
</select>
|
|
765
|
+
</label>
|
|
766
|
+
<button
|
|
767
|
+
type="submit"
|
|
768
|
+
disabled={mutation.isPending}
|
|
769
|
+
className="w-full rounded-md bg-primary px-4 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"
|
|
770
|
+
>
|
|
771
|
+
{i18n.t("knowledge.shareAction")}
|
|
772
|
+
</button>
|
|
773
|
+
</form>
|
|
774
|
+
</Modal>
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function VersionHistory({ nodeId, close }: { nodeId: string; close(): void }) {
|
|
779
|
+
const { data, i18n } = useIntelRouterContext();
|
|
780
|
+
const versions = useQuery({
|
|
781
|
+
queryKey: ["knowledge-versions", nodeId],
|
|
782
|
+
queryFn: () => data.listKnowledgeVersions(nodeId),
|
|
783
|
+
});
|
|
784
|
+
return (
|
|
785
|
+
<aside
|
|
786
|
+
aria-label={i18n.t("knowledge.versions")}
|
|
787
|
+
className="absolute bottom-0 right-0 top-0 z-10 w-80 overflow-y-auto border-l bg-card p-5 shadow-xl"
|
|
788
|
+
>
|
|
789
|
+
<div className="flex items-start justify-between gap-4">
|
|
790
|
+
<h3 className="font-semibold">{i18n.t("knowledge.versions")}</h3>
|
|
791
|
+
<button
|
|
792
|
+
type="button"
|
|
793
|
+
onClick={close}
|
|
794
|
+
aria-label={i18n.t("common.close")}
|
|
795
|
+
className="rounded-md px-2 py-1 text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
796
|
+
>
|
|
797
|
+
×
|
|
798
|
+
</button>
|
|
799
|
+
</div>
|
|
800
|
+
{versions.isPending ? (
|
|
801
|
+
<p className="mt-4 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
802
|
+
) : null}
|
|
803
|
+
{versions.isError ? (
|
|
804
|
+
<p role="alert" className="mt-4 text-sm text-destructive">
|
|
805
|
+
{i18n.t("knowledge.operationFailed")}
|
|
806
|
+
</p>
|
|
807
|
+
) : null}
|
|
808
|
+
<ol className="mt-4 space-y-3">
|
|
809
|
+
{versions.data?.items.map((version) => (
|
|
810
|
+
<li key={version.id} className="rounded-md border p-3 text-sm">
|
|
811
|
+
<span className="font-medium">
|
|
812
|
+
{i18n.t("knowledge.version", { sequence: version.sequence })}
|
|
813
|
+
</span>
|
|
814
|
+
<time className="mt-1 block text-xs text-muted-foreground" dateTime={version.createdAt}>
|
|
815
|
+
{new Intl.DateTimeFormat(i18n.locale, {
|
|
816
|
+
dateStyle: "medium",
|
|
817
|
+
timeStyle: "short",
|
|
818
|
+
}).format(new Date(version.createdAt))}
|
|
819
|
+
</time>
|
|
820
|
+
</li>
|
|
821
|
+
))}
|
|
822
|
+
</ol>
|
|
823
|
+
</aside>
|
|
824
|
+
);
|
|
825
|
+
}
|