@anchrd/intel-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,92 @@
1
+ import {
2
+ BlockNoteDocument,
3
+ BlockNoteMediaType,
4
+ type KnowledgeDocument,
5
+ } from "@anchrd/intel-contract";
6
+ import type { PartialBlock } from "@blocknote/core";
7
+ import { useCreateBlockNote } from "@blocknote/react";
8
+ import { useMutation } from "@tanstack/react-query";
9
+ import { Save } from "lucide-react";
10
+ import { useEffect, useMemo, useState } from "react";
11
+ import { BlockNoteView } from "@/blocknote-view/blocknote-view.tsx";
12
+ import type { IntelDataProvider } from "@/data/intel-data-provider/intel-data-provider.types.ts";
13
+ import type { I18n } from "@/i18n/i18n.types.ts";
14
+
15
+ function storedBlocks(content: string | null): PartialBlock[] | undefined {
16
+ if (!content) return undefined;
17
+ try {
18
+ const parsed = BlockNoteDocument.safeParse(JSON.parse(content));
19
+ return parsed.success ? (parsed.data.blocks as PartialBlock[]) : undefined;
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ }
24
+
25
+ export function KnowledgeEditor({
26
+ data,
27
+ document,
28
+ i18n,
29
+ onSaved,
30
+ }: {
31
+ data: IntelDataProvider;
32
+ document: KnowledgeDocument;
33
+ i18n: I18n;
34
+ onSaved(document: KnowledgeDocument): void;
35
+ }) {
36
+ const initialBlocks = useMemo(() => storedBlocks(document.content), [document.content]);
37
+ const editor = useCreateBlockNote({
38
+ ...(initialBlocks?.length ? { initialContent: initialBlocks } : {}),
39
+ });
40
+ const [dirty, setDirty] = useState(false);
41
+ const save = useMutation({
42
+ mutationFn: async () => {
43
+ const payload = BlockNoteDocument.parse({
44
+ format: "blocknote",
45
+ schemaVersion: 1,
46
+ blocks: editor.document,
47
+ markdown: await editor.blocksToMarkdownLossy(editor.document),
48
+ });
49
+ return await data.saveKnowledge({
50
+ nodeId: document.node.id,
51
+ baseVersionId: document.version?.id ?? null,
52
+ content: JSON.stringify(payload),
53
+ mediaType: BlockNoteMediaType,
54
+ idempotencyKey: crypto.randomUUID(),
55
+ });
56
+ },
57
+ onSuccess: (saved) => {
58
+ setDirty(false);
59
+ onSaved(saved);
60
+ },
61
+ });
62
+
63
+ useEffect(() => {
64
+ if (!document.content || initialBlocks) return;
65
+ const blocks = editor.tryParseMarkdownToBlocks(document.content);
66
+ editor.replaceBlocks(editor.document, blocks);
67
+ }, [document.content, editor, initialBlocks]);
68
+
69
+ return (
70
+ <div className="flex min-h-0 flex-1 flex-col">
71
+ <div className="flex items-center justify-end border-b px-4 py-2">
72
+ <button
73
+ type="button"
74
+ disabled={!dirty || save.isPending}
75
+ onClick={() => save.mutate()}
76
+ className="inline-flex items-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
77
+ >
78
+ <Save aria-hidden="true" className="size-4" />
79
+ {save.isPending ? i18n.t("common.saving") : i18n.t("common.save")}
80
+ </button>
81
+ </div>
82
+ {save.isError && (
83
+ <p role="alert" className="mx-6 mt-4 text-sm text-destructive">
84
+ {i18n.t("knowledge.saveError")}
85
+ </p>
86
+ )}
87
+ <div className="min-h-0 flex-1 overflow-y-auto py-6">
88
+ <BlockNoteView editor={editor} onChange={() => setDirty(true)} />
89
+ </div>
90
+ </div>
91
+ );
92
+ }
@@ -0,0 +1,58 @@
1
+ import type { KnowledgeGraph as KnowledgeGraphData } from "@anchrd/intel-contract";
2
+ import Color from "colorjs.io";
3
+ import { MultiDirectedGraph } from "graphology";
4
+
5
+ // Sigma paints to a canvas and cannot read CSS variables, so semantic tokens are resolved to
6
+ // concrete sRGB here. Tokens are tried in order; when none resolves — no DOM in tests and SSR, or a
7
+ // customer theme without the token — the result is undefined and Sigma applies its own default. No
8
+ // palette value is hard-coded, so a customer theme is never overridden by an Anchrd colour.
9
+ export function resolveGraphColor(...tokens: string[]): string | undefined {
10
+ if (typeof document === "undefined") return undefined;
11
+ const styles = getComputedStyle(document.documentElement);
12
+ for (const token of tokens) {
13
+ const value = styles.getPropertyValue(token).trim();
14
+ if (!value) continue;
15
+ try {
16
+ return new Color(value).to("srgb").toString({ format: "hex", inGamut: true });
17
+ } catch {}
18
+ }
19
+ return undefined;
20
+ }
21
+
22
+ export function createKnowledgeGraph(data: KnowledgeGraphData): MultiDirectedGraph {
23
+ const graph = new MultiDirectedGraph();
24
+ const primary = resolveGraphColor("--primary", "--foreground");
25
+ const secondary = resolveGraphColor("--secondary-foreground", "--foreground");
26
+ const muted = resolveGraphColor("--muted-foreground", "--foreground");
27
+ const edge = resolveGraphColor("--border", "--muted-foreground");
28
+ const count = Math.max(1, data.nodes.length);
29
+ for (const [index, node] of data.nodes.entries()) {
30
+ const angle = index * Math.PI * (3 - Math.sqrt(5));
31
+ const radius = Math.sqrt(index / count) * 10;
32
+ graph.addNode(node.id, {
33
+ label: node.title,
34
+ x: Math.cos(angle) * radius,
35
+ y: Math.sin(angle) * radius,
36
+ size: node.kind === "folder" ? 11 : node.kind === "attachment" ? 6 : 8,
37
+ color: node.kind === "folder" ? secondary : node.kind === "attachment" ? muted : primary,
38
+ });
39
+ }
40
+ for (const node of data.nodes) {
41
+ if (node.parentId && graph.hasNode(node.parentId)) {
42
+ graph.addDirectedEdgeWithKey(`tree:${node.id}`, node.parentId, node.id, {
43
+ size: 0.6,
44
+ color: edge,
45
+ });
46
+ }
47
+ }
48
+ for (const link of data.links) {
49
+ if (graph.hasNode(link.sourceNodeId) && graph.hasNode(link.targetNodeId)) {
50
+ graph.addDirectedEdgeWithKey(link.id, link.sourceNodeId, link.targetNodeId, {
51
+ label: link.label ?? link.relation.replaceAll("_", " "),
52
+ size: 2,
53
+ color: primary,
54
+ });
55
+ }
56
+ }
57
+ return graph;
58
+ }
@@ -0,0 +1,111 @@
1
+ import type { KnowledgeGraph as KnowledgeGraphData } from "@anchrd/intel-contract";
2
+ import { ControlsContainer, SigmaContainer, useCamera, useRegisterEvents } from "@react-sigma/core";
3
+ import { LocateFixed, Minus, Plus } from "lucide-react";
4
+ import { useEffect, useMemo } from "react";
5
+ import type { I18n } from "@/i18n/i18n.types.ts";
6
+ import { Modal } from "@/modal/modal.tsx";
7
+ import { createKnowledgeGraph, resolveGraphColor } from "./knowledge-graph.ts";
8
+
9
+ function labelColors() {
10
+ const label = resolveGraphColor("--foreground");
11
+ const edgeLabel = resolveGraphColor("--muted-foreground", "--foreground");
12
+ return {
13
+ ...(label ? { labelColor: { color: label } } : {}),
14
+ ...(edgeLabel ? { edgeLabelColor: { color: edgeLabel } } : {}),
15
+ };
16
+ }
17
+
18
+ function GraphEvents({ select }: { select(nodeId: string): void }) {
19
+ const registerEvents = useRegisterEvents();
20
+ useEffect(() => {
21
+ registerEvents({ clickNode: ({ node }) => select(node) });
22
+ }, [registerEvents, select]);
23
+ return null;
24
+ }
25
+
26
+ function GraphControls({ i18n }: { i18n: I18n }) {
27
+ const camera = useCamera({ duration: 180, factor: 1.5 });
28
+ const controls = [
29
+ {
30
+ label: i18n.t("knowledge.graphZoomIn"),
31
+ action: camera.zoomIn,
32
+ icon: Plus,
33
+ },
34
+ {
35
+ label: i18n.t("knowledge.graphZoomOut"),
36
+ action: camera.zoomOut,
37
+ icon: Minus,
38
+ },
39
+ {
40
+ label: i18n.t("knowledge.graphReset"),
41
+ action: camera.reset,
42
+ icon: LocateFixed,
43
+ },
44
+ ];
45
+ return (
46
+ <ControlsContainer className="!m-4 flex overflow-hidden rounded-lg border bg-card shadow-lg">
47
+ {controls.map(({ label, action, icon: Icon }) => (
48
+ <button
49
+ key={label}
50
+ type="button"
51
+ aria-label={label}
52
+ onClick={() => action()}
53
+ className="grid size-9 place-items-center border-r text-card-foreground outline-none last:border-r-0 hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
54
+ >
55
+ <Icon aria-hidden="true" className="size-4" />
56
+ </button>
57
+ ))}
58
+ </ControlsContainer>
59
+ );
60
+ }
61
+
62
+ export function KnowledgeGraphView({
63
+ data,
64
+ i18n,
65
+ close,
66
+ select,
67
+ }: {
68
+ data: KnowledgeGraphData;
69
+ i18n: I18n;
70
+ close(): void;
71
+ select(nodeId: string): void;
72
+ }) {
73
+ const graph = useMemo(() => createKnowledgeGraph(data), [data]);
74
+ return (
75
+ <Modal
76
+ title={i18n.t("knowledge.graph")}
77
+ close={close}
78
+ className="h-[calc(100vh-2rem)] max-w-none"
79
+ >
80
+ <p className="mb-4 text-sm text-muted-foreground">
81
+ {i18n.t("knowledge.graphDescription", {
82
+ nodes: data.nodes.length,
83
+ links: data.links.length,
84
+ })}
85
+ </p>
86
+ <div className="min-h-0 flex-1 overflow-hidden rounded-lg border bg-muted/20">
87
+ {data.nodes.length === 0 ? (
88
+ <div className="grid h-full place-items-center text-sm text-muted-foreground">
89
+ {i18n.t("knowledge.empty")}
90
+ </div>
91
+ ) : (
92
+ <SigmaContainer
93
+ graph={graph}
94
+ className="h-full w-full"
95
+ settings={{
96
+ labelRenderedSizeThreshold: 5,
97
+ renderEdgeLabels: true,
98
+ edgeLabelSize: 10,
99
+ // Omitted rather than defaulted when a theme lacks the token, so Sigma keeps its own
100
+ // label colour instead of receiving a hard-coded one.
101
+ ...labelColors(),
102
+ }}
103
+ >
104
+ <GraphEvents select={select} />
105
+ <GraphControls i18n={i18n} />
106
+ </SigmaContainer>
107
+ )}
108
+ </div>
109
+ </Modal>
110
+ );
111
+ }
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,26 @@
1
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2
+ import { RouterProvider } from "@tanstack/react-router";
3
+ import { StrictMode } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { createIntelDataProvider } from "@/data/intel-data-provider/intel-data-provider.ts";
6
+ import { createI18n } from "@/i18n/i18n.ts";
7
+ import { createIntelRouter } from "@/router/router.tsx";
8
+ import { watchSystemTheme } from "@/theme/theme.ts";
9
+ import "./styles.css";
10
+ import "./theme/custom.css";
11
+
12
+ watchSystemTheme(document.documentElement, window.matchMedia("(prefers-color-scheme: dark)"));
13
+
14
+ const root = document.getElementById("root");
15
+ if (!root) throw new Error("Missing #root element");
16
+ const data = createIntelDataProvider();
17
+ const i18n = createI18n();
18
+ const router = createIntelRouter({ data, i18n });
19
+
20
+ createRoot(root).render(
21
+ <StrictMode>
22
+ <QueryClientProvider client={new QueryClient()}>
23
+ <RouterProvider router={router} />
24
+ </QueryClientProvider>
25
+ </StrictMode>,
26
+ );
@@ -0,0 +1,53 @@
1
+ import { useId } from "react";
2
+ import { Modal as AriaModal, Dialog, ModalOverlay } from "react-aria-components";
3
+ import { cn } from "@/lib/utils.ts";
4
+ import { useIntelRouterContext } from "@/router/router-context.ts";
5
+
6
+ export function Modal({
7
+ title,
8
+ close,
9
+ children,
10
+ className,
11
+ }: {
12
+ title: string;
13
+ close(): void;
14
+ children: React.ReactNode;
15
+ className?: string;
16
+ }) {
17
+ const { i18n } = useIntelRouterContext();
18
+ const titleId = useId();
19
+ return (
20
+ <ModalOverlay
21
+ isOpen
22
+ isDismissable
23
+ onOpenChange={(open) => {
24
+ if (!open) close();
25
+ }}
26
+ className="fixed inset-0 z-50 grid place-items-center bg-background/80 p-4 backdrop-blur-sm"
27
+ >
28
+ <AriaModal
29
+ className={cn(
30
+ "w-full max-w-md rounded-xl border bg-card p-6 text-card-foreground shadow-xl outline-none",
31
+ className,
32
+ )}
33
+ >
34
+ <Dialog aria-labelledby={titleId} className="flex h-full flex-col outline-none">
35
+ <div className="mb-5 flex items-center justify-between gap-4">
36
+ <h2 id={titleId} className="text-lg font-semibold">
37
+ {title}
38
+ </h2>
39
+ <button
40
+ type="button"
41
+ onClick={close}
42
+ aria-label={i18n.t("common.close")}
43
+ className="rounded-md px-2 py-1 text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
44
+ >
45
+ ×
46
+ </button>
47
+ </div>
48
+ {children}
49
+ </Dialog>
50
+ </AriaModal>
51
+ </ModalOverlay>
52
+ );
53
+ }
@@ -0,0 +1,6 @@
1
+ import { useRouter } from "@tanstack/react-router";
2
+ import type { RouterContext } from "./router.types.ts";
3
+
4
+ export function useIntelRouterContext(): RouterContext {
5
+ return useRouter().options.context as RouterContext;
6
+ }
@@ -0,0 +1,42 @@
1
+ import {
2
+ createRootRouteWithContext,
3
+ createRoute,
4
+ createRouter,
5
+ lazyRouteComponent,
6
+ Navigate,
7
+ } from "@tanstack/react-router";
8
+ import { App } from "@/app/app.tsx";
9
+ import type { RouterContext } from "./router.types.ts";
10
+
11
+ const rootRoute = createRootRouteWithContext<RouterContext>()({ component: App });
12
+ const redirectRoute = createRoute({
13
+ getParentRoute: () => rootRoute,
14
+ path: "/",
15
+ component: () => <Navigate to="/knowledge" replace />,
16
+ });
17
+ const knowledgeRoute = createRoute({
18
+ getParentRoute: () => rootRoute,
19
+ path: "/knowledge",
20
+ component: lazyRouteComponent(() => import("@/knowledge/knowledge.tsx"), "Knowledge"),
21
+ });
22
+ const flowsRoute = createRoute({
23
+ getParentRoute: () => rootRoute,
24
+ path: "/flows",
25
+ component: lazyRouteComponent(() => import("@/flows/flows.tsx"), "Flows"),
26
+ });
27
+ const toolsRoute = createRoute({
28
+ getParentRoute: () => rootRoute,
29
+ path: "/tools",
30
+ component: lazyRouteComponent(() => import("@/tools/tools.tsx"), "Tools"),
31
+ });
32
+ const routeTree = rootRoute.addChildren([redirectRoute, knowledgeRoute, flowsRoute, toolsRoute]);
33
+
34
+ export function createIntelRouter(context: RouterContext) {
35
+ return createRouter({ routeTree, context });
36
+ }
37
+
38
+ declare module "@tanstack/react-router" {
39
+ interface Register {
40
+ router: ReturnType<typeof createIntelRouter>;
41
+ }
42
+ }
@@ -0,0 +1,7 @@
1
+ import type { IntelDataProvider } from "@/data/intel-data-provider/intel-data-provider.types.ts";
2
+ import type { I18n } from "@/i18n/i18n.types.ts";
3
+
4
+ export interface RouterContext {
5
+ data: IntelDataProvider;
6
+ i18n: I18n;
7
+ }
package/src/styles.css ADDED
@@ -0,0 +1,120 @@
1
+ @import "tailwindcss";
2
+ @import "@blocknote/core/fonts/inter.css";
3
+ @import "@blocknote/shadcn/style.css";
4
+ @import "@xyflow/react/dist/style.css";
5
+ @import "@react-sigma/core/lib/style.css";
6
+ @source "../node_modules/@blocknote/shadcn";
7
+
8
+ @custom-variant dark (&:is(.dark *));
9
+
10
+ :root {
11
+ --radius: 0.625rem;
12
+ --background: oklch(0.99 0.005 265);
13
+ --foreground: oklch(0.18 0.02 265);
14
+ --card: oklch(1 0 0);
15
+ --card-foreground: oklch(0.18 0.02 265);
16
+ --popover: oklch(1 0 0);
17
+ --popover-foreground: oklch(0.18 0.02 265);
18
+ --primary: oklch(0.45 0.17 265);
19
+ --primary-foreground: oklch(0.98 0.01 265);
20
+ --secondary: oklch(0.95 0.02 265);
21
+ --secondary-foreground: oklch(0.25 0.04 265);
22
+ --muted: oklch(0.96 0.01 265);
23
+ --muted-foreground: oklch(0.5 0.03 265);
24
+ --accent: oklch(0.94 0.03 265);
25
+ --accent-foreground: oklch(0.25 0.05 265);
26
+ --destructive: oklch(0.58 0.23 27);
27
+ --destructive-foreground: oklch(0.98 0.01 27);
28
+ --border: oklch(0.9 0.02 265);
29
+ --input: oklch(0.9 0.02 265);
30
+ --ring: oklch(0.58 0.15 265);
31
+ --sidebar: oklch(0.97 0.015 265);
32
+ --sidebar-foreground: oklch(0.2 0.03 265);
33
+ --sidebar-primary: oklch(0.45 0.17 265);
34
+ --sidebar-primary-foreground: oklch(0.98 0.01 265);
35
+ --sidebar-accent: oklch(0.92 0.035 265);
36
+ --sidebar-accent-foreground: oklch(0.24 0.06 265);
37
+ --sidebar-border: oklch(0.88 0.025 265);
38
+ --sidebar-ring: oklch(0.58 0.15 265);
39
+ }
40
+
41
+ .dark {
42
+ --background: oklch(0.15 0.025 265);
43
+ --foreground: oklch(0.96 0.01 265);
44
+ --card: oklch(0.19 0.03 265);
45
+ --card-foreground: oklch(0.96 0.01 265);
46
+ --popover: oklch(0.19 0.03 265);
47
+ --popover-foreground: oklch(0.96 0.01 265);
48
+ --primary: oklch(0.7 0.16 265);
49
+ --primary-foreground: oklch(0.16 0.03 265);
50
+ --secondary: oklch(0.25 0.035 265);
51
+ --secondary-foreground: oklch(0.95 0.01 265);
52
+ --muted: oklch(0.24 0.025 265);
53
+ --muted-foreground: oklch(0.7 0.025 265);
54
+ --accent: oklch(0.27 0.05 265);
55
+ --accent-foreground: oklch(0.96 0.01 265);
56
+ --destructive: oklch(0.7 0.19 25);
57
+ --destructive-foreground: oklch(0.98 0.01 25);
58
+ --border: oklch(1 0 0 / 12%);
59
+ --input: oklch(1 0 0 / 16%);
60
+ --ring: oklch(0.66 0.14 265);
61
+ --sidebar: oklch(0.18 0.035 265);
62
+ --sidebar-foreground: oklch(0.95 0.01 265);
63
+ --sidebar-primary: oklch(0.7 0.16 265);
64
+ --sidebar-primary-foreground: oklch(0.16 0.03 265);
65
+ --sidebar-accent: oklch(0.26 0.05 265);
66
+ --sidebar-accent-foreground: oklch(0.96 0.01 265);
67
+ --sidebar-border: oklch(1 0 0 / 10%);
68
+ --sidebar-ring: oklch(0.66 0.14 265);
69
+ }
70
+
71
+ @theme inline {
72
+ --radius-sm: calc(var(--radius) - 4px);
73
+ --radius-md: calc(var(--radius) - 2px);
74
+ --radius-lg: var(--radius);
75
+ --radius-xl: calc(var(--radius) + 4px);
76
+ --color-background: var(--background);
77
+ --color-foreground: var(--foreground);
78
+ --color-card: var(--card);
79
+ --color-card-foreground: var(--card-foreground);
80
+ --color-popover: var(--popover);
81
+ --color-popover-foreground: var(--popover-foreground);
82
+ --color-primary: var(--primary);
83
+ --color-primary-foreground: var(--primary-foreground);
84
+ --color-secondary: var(--secondary);
85
+ --color-secondary-foreground: var(--secondary-foreground);
86
+ --color-muted: var(--muted);
87
+ --color-muted-foreground: var(--muted-foreground);
88
+ --color-accent: var(--accent);
89
+ --color-accent-foreground: var(--accent-foreground);
90
+ --color-destructive: var(--destructive);
91
+ --color-destructive-foreground: var(--destructive-foreground);
92
+ --color-border: var(--border);
93
+ --color-input: var(--input);
94
+ --color-ring: var(--ring);
95
+ --color-sidebar: var(--sidebar);
96
+ --color-sidebar-foreground: var(--sidebar-foreground);
97
+ --color-sidebar-primary: var(--sidebar-primary);
98
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
99
+ --color-sidebar-accent: var(--sidebar-accent);
100
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
101
+ --color-sidebar-border: var(--sidebar-border);
102
+ --color-sidebar-ring: var(--sidebar-ring);
103
+ }
104
+
105
+ @layer base {
106
+ * {
107
+ @apply border-border outline-ring/50;
108
+ }
109
+ body {
110
+ @apply bg-background text-foreground;
111
+ }
112
+ }
113
+
114
+ .react-sigma {
115
+ --sigma-background-color: var(--background);
116
+ --sigma-controls-background-color: var(--card);
117
+ --sigma-controls-background-color-hover: var(--muted);
118
+ --sigma-controls-border-color: var(--border);
119
+ --sigma-controls-color: var(--card-foreground);
120
+ }
@@ -0,0 +1 @@
1
+ /* Generated by `intel build`. Intel defaults are active. */
@@ -0,0 +1,16 @@
1
+ export type Theme = "dark" | "light";
2
+
3
+ export function resolveTheme(prefersDark: boolean): Theme {
4
+ return prefersDark ? "dark" : "light";
5
+ }
6
+
7
+ export function applyTheme(root: HTMLElement, theme: Theme): void {
8
+ root.classList.toggle("dark", theme === "dark");
9
+ }
10
+
11
+ export function watchSystemTheme(root: HTMLElement, media: MediaQueryList): () => void {
12
+ applyTheme(root, resolveTheme(media.matches));
13
+ const onChange = (event: MediaQueryListEvent) => applyTheme(root, resolveTheme(event.matches));
14
+ media.addEventListener("change", onChange);
15
+ return () => media.removeEventListener("change", onChange);
16
+ }