@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,346 @@
1
+ import type { ToolCatalogEntry } from "@anchrd/intel-contract";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import {
4
+ AlertTriangle,
5
+ CheckCircle2,
6
+ CircleOff,
7
+ KeyRound,
8
+ LogIn,
9
+ Play,
10
+ Plus,
11
+ RefreshCw,
12
+ ShieldAlert,
13
+ Wrench,
14
+ } from "lucide-react";
15
+ import { useMemo, useState } from "react";
16
+ import { Modal } from "@/modal/modal.tsx";
17
+ import { useIntelRouterContext } from "@/router/router-context.ts";
18
+
19
+ const statusIcon = {
20
+ connected: CheckCircle2,
21
+ missing: KeyRound,
22
+ forbidden: ShieldAlert,
23
+ invalid: CircleOff,
24
+ } as const;
25
+
26
+ export function Tools() {
27
+ const { data, i18n } = useIntelRouterContext();
28
+ const queryClient = useQueryClient();
29
+ const sources = useQuery({ queryKey: ["tool-sources"], queryFn: () => data.listToolSources() });
30
+ const catalog = useQuery({ queryKey: ["tools"], queryFn: () => data.listTools() });
31
+ const [registering, setRegistering] = useState(false);
32
+ const [selected, setSelected] = useState<ToolCatalogEntry | null>(null);
33
+ const connectError =
34
+ typeof window === "undefined"
35
+ ? null
36
+ : new URLSearchParams(window.location.search).get("connectError");
37
+ const grouped = useMemo(() => {
38
+ const entries = catalog.data?.items ?? [];
39
+ return (sources.data?.items ?? []).map((source) => ({
40
+ source,
41
+ entries: entries.filter((entry) => entry.source.id === source.id),
42
+ }));
43
+ }, [catalog.data, sources.data]);
44
+ const discover = useMutation({
45
+ mutationFn: (sourceId: string) =>
46
+ data.discoverToolSource({ sourceId, idempotencyKey: crypto.randomUUID() }),
47
+ onSuccess: async () => {
48
+ await Promise.all([
49
+ queryClient.invalidateQueries({ queryKey: ["tool-sources"] }),
50
+ queryClient.invalidateQueries({ queryKey: ["tools"] }),
51
+ ]);
52
+ },
53
+ });
54
+
55
+ return (
56
+ <div className="min-h-screen">
57
+ <header className="flex items-center justify-between gap-6 border-b px-8 py-5">
58
+ <div>
59
+ <h1 className="text-xl font-semibold tracking-tight">{i18n.t("tools.title")}</h1>
60
+ <p className="mt-1 text-sm text-muted-foreground">{i18n.t("tools.description")}</p>
61
+ </div>
62
+ <button
63
+ type="button"
64
+ onClick={() => setRegistering(true)}
65
+ 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"
66
+ >
67
+ <Plus aria-hidden="true" className="size-4" />
68
+ {i18n.t("tools.addSource")}
69
+ </button>
70
+ </header>
71
+
72
+ <div className="grid gap-5 p-8 xl:grid-cols-[minmax(0,1fr)_24rem]">
73
+ <div className="space-y-5">
74
+ {discover.isError && (
75
+ <p
76
+ role="alert"
77
+ className="rounded-md border border-destructive/30 p-3 text-sm text-destructive"
78
+ >
79
+ {i18n.t("tools.operationFailed")}
80
+ </p>
81
+ )}
82
+ {connectError && (
83
+ <p
84
+ role="alert"
85
+ className="rounded-md border border-destructive/30 p-3 text-sm text-destructive"
86
+ >
87
+ {i18n.t("tools.connectFailed")}
88
+ </p>
89
+ )}
90
+ {sources.isPending || catalog.isPending ? (
91
+ <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
92
+ ) : grouped.length === 0 ? (
93
+ <section className="rounded-xl border border-dashed bg-card p-12 text-center">
94
+ <Wrench aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />
95
+ <h2 className="mt-4 font-semibold">{i18n.t("tools.empty")}</h2>
96
+ <p className="mx-auto mt-2 max-w-lg text-sm text-muted-foreground">
97
+ {i18n.t("tools.emptyHelp")}
98
+ </p>
99
+ </section>
100
+ ) : (
101
+ grouped.map(({ source, entries }) => (
102
+ <section
103
+ key={source.id}
104
+ className="overflow-hidden rounded-xl border bg-card shadow-sm"
105
+ >
106
+ <div className="flex items-start justify-between gap-5 border-b bg-muted/20 px-5 py-4">
107
+ <div className="min-w-0">
108
+ <h2 className="font-semibold">{source.name}</h2>
109
+ <p className="mt-1 truncate text-xs text-muted-foreground">{source.url}</p>
110
+ <p className="mt-2 text-xs text-muted-foreground">
111
+ {i18n.t("tools.connectionHandle")}: {source.connectionHandle}
112
+ </p>
113
+ </div>
114
+ <div className="flex shrink-0 items-center gap-2">
115
+ <a
116
+ href={data.toolSourceConnectUrl(source.id, "/tools")}
117
+ className="inline-flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-xs font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
118
+ >
119
+ <LogIn aria-hidden="true" className="size-3.5" />
120
+ {i18n.t("tools.connect")}
121
+ </a>
122
+ <button
123
+ type="button"
124
+ disabled={discover.isPending && discover.variables === source.id}
125
+ onClick={() => discover.mutate(source.id)}
126
+ className="inline-flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-xs font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
127
+ >
128
+ <RefreshCw aria-hidden="true" className="size-3.5" />
129
+ {i18n.t("tools.discover")}
130
+ </button>
131
+ </div>
132
+ </div>
133
+ {entries.length === 0 ? (
134
+ <p className="p-5 text-sm text-muted-foreground">
135
+ {i18n.t("tools.notDiscovered")}
136
+ </p>
137
+ ) : (
138
+ <ul className="divide-y">
139
+ {entries.map((entry) => {
140
+ const Status = statusIcon[entry.connectionStatus];
141
+ return (
142
+ <li key={entry.capability.name}>
143
+ <button
144
+ type="button"
145
+ onClick={() => setSelected(entry)}
146
+ className="flex w-full items-start justify-between gap-5 px-5 py-4 text-left outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
147
+ >
148
+ <span className="min-w-0">
149
+ <span className="block text-sm font-medium">
150
+ {entry.capability.title ?? entry.capability.name}
151
+ </span>
152
+ <span className="mt-1 block truncate font-mono text-xs text-muted-foreground">
153
+ {entry.capability.name}
154
+ </span>
155
+ {entry.capability.description && (
156
+ <span className="mt-2 line-clamp-2 block text-xs text-muted-foreground">
157
+ {entry.capability.description}
158
+ </span>
159
+ )}
160
+ </span>
161
+ <span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground">
162
+ <Status aria-hidden="true" className="size-3.5" />
163
+ {i18n.t(`tools.status.${entry.connectionStatus}`)}
164
+ </span>
165
+ </button>
166
+ </li>
167
+ );
168
+ })}
169
+ </ul>
170
+ )}
171
+ </section>
172
+ ))
173
+ )}
174
+ </div>
175
+ <ToolTester entry={selected} />
176
+ </div>
177
+ {registering && <RegisterSource close={() => setRegistering(false)} />}
178
+ </div>
179
+ );
180
+ }
181
+
182
+ function ToolTester({ entry }: { entry: ToolCatalogEntry | null }) {
183
+ const { data, i18n } = useIntelRouterContext();
184
+ const [argumentsText, setArgumentsText] = useState("{}");
185
+ const [parseError, setParseError] = useState(false);
186
+ const safeToTest =
187
+ entry?.capability.annotations.readOnlyHint === true &&
188
+ entry.capability.annotations.destructiveHint !== true;
189
+ const test = useMutation({
190
+ mutationFn: async () => {
191
+ const parsed: unknown = JSON.parse(argumentsText);
192
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("object");
193
+ return await data.testTool({
194
+ sourceId: entry?.source.id ?? "",
195
+ name: entry?.capability.name ?? "",
196
+ arguments: parsed as Record<string, unknown>,
197
+ });
198
+ },
199
+ onMutate: () => setParseError(false),
200
+ onError: (error) => setParseError(error instanceof SyntaxError || error.message === "object"),
201
+ });
202
+
203
+ return (
204
+ <aside className="sticky top-5 h-fit rounded-xl border bg-card p-5 shadow-sm">
205
+ <h2 className="font-semibold">{i18n.t("tools.testTitle")}</h2>
206
+ {!entry ? (
207
+ <p className="mt-3 text-sm text-muted-foreground">{i18n.t("tools.select")}</p>
208
+ ) : (
209
+ <div className="mt-4 space-y-4">
210
+ <div>
211
+ <p className="text-sm font-medium">{entry.capability.title ?? entry.capability.name}</p>
212
+ <p className="mt-1 font-mono text-xs text-muted-foreground">{entry.capability.name}</p>
213
+ </div>
214
+ {entry.capability.annotations.destructiveHint && (
215
+ <p className="flex gap-2 rounded-md bg-destructive/10 p-3 text-xs text-destructive">
216
+ <AlertTriangle aria-hidden="true" className="size-4 shrink-0" />
217
+ {i18n.t("tools.destructive")}
218
+ </p>
219
+ )}
220
+ {!safeToTest && (
221
+ <p className="flex gap-2 rounded-md bg-muted p-3 text-xs text-muted-foreground">
222
+ <ShieldAlert aria-hidden="true" className="size-4 shrink-0" />
223
+ {i18n.t("tools.testUnsafe")}
224
+ </p>
225
+ )}
226
+ {entry.connectionStatus !== "connected" && (
227
+ <p className="rounded-md bg-muted p-3 text-xs text-muted-foreground">
228
+ {i18n.t("tools.connectionRequired")}
229
+ </p>
230
+ )}
231
+ <label className="block text-xs font-medium">
232
+ {i18n.t("tools.arguments")}
233
+ <textarea
234
+ value={argumentsText}
235
+ onChange={(event) => setArgumentsText(event.target.value)}
236
+ rows={9}
237
+ spellCheck={false}
238
+ className="mt-2 w-full resize-y rounded-md border bg-background p-3 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
239
+ />
240
+ </label>
241
+ {parseError && <p className="text-xs text-destructive">{i18n.t("tools.invalidJson")}</p>}
242
+ <button
243
+ type="button"
244
+ disabled={test.isPending || entry.connectionStatus !== "connected" || !safeToTest}
245
+ onClick={() => test.mutate()}
246
+ className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
247
+ >
248
+ <Play aria-hidden="true" className="size-4" />
249
+ {i18n.t("tools.runTest")}
250
+ </button>
251
+ {test.data && (
252
+ <pre className="max-h-80 overflow-auto rounded-md bg-muted p-3 text-xs">
253
+ {JSON.stringify(test.data, null, 2)}
254
+ </pre>
255
+ )}
256
+ {test.isError && !parseError && (
257
+ <p className="text-xs text-destructive">{i18n.t("common.unavailable")}</p>
258
+ )}
259
+ </div>
260
+ )}
261
+ </aside>
262
+ );
263
+ }
264
+
265
+ function RegisterSource({ close }: { close(): void }) {
266
+ const { data, i18n } = useIntelRouterContext();
267
+ const queryClient = useQueryClient();
268
+ const [name, setName] = useState("");
269
+ const [url, setUrl] = useState("");
270
+ const [connectionHandle, setConnectionHandle] = useState("intel-tools");
271
+ const create = useMutation({
272
+ mutationFn: () =>
273
+ data.createToolSource({
274
+ name,
275
+ url,
276
+ connectionHandle,
277
+ idempotencyKey: crypto.randomUUID(),
278
+ }),
279
+ onSuccess: async () => {
280
+ await queryClient.invalidateQueries({ queryKey: ["tool-sources"] });
281
+ close();
282
+ },
283
+ });
284
+ return (
285
+ <Modal title={i18n.t("tools.addSource")} close={close} className="max-w-lg">
286
+ <form
287
+ className="space-y-4"
288
+ onSubmit={(event) => {
289
+ event.preventDefault();
290
+ if (create.isPending) return;
291
+ create.mutate();
292
+ }}
293
+ >
294
+ <Field label={i18n.t("common.title")} value={name} setValue={setName} />
295
+ <Field label={i18n.t("tools.endpoint")} value={url} setValue={setUrl} type="url" />
296
+ <Field
297
+ label={i18n.t("tools.connectionHandle")}
298
+ value={connectionHandle}
299
+ setValue={setConnectionHandle}
300
+ pattern="[a-z0-9][a-z0-9-]{1,62}"
301
+ />
302
+ <p className="text-xs text-muted-foreground">{i18n.t("tools.sourceHelp")}</p>
303
+ {create.isError && (
304
+ <p role="alert" className="text-sm text-destructive">
305
+ {i18n.t("tools.operationFailed")}
306
+ </p>
307
+ )}
308
+ <button
309
+ type="submit"
310
+ disabled={create.isPending}
311
+ 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"
312
+ >
313
+ {create.isPending ? i18n.t("common.saving") : i18n.t("common.create")}
314
+ </button>
315
+ </form>
316
+ </Modal>
317
+ );
318
+ }
319
+
320
+ function Field({
321
+ label,
322
+ value,
323
+ setValue,
324
+ type = "text",
325
+ pattern,
326
+ }: {
327
+ label: string;
328
+ value: string;
329
+ setValue(value: string): void;
330
+ type?: string;
331
+ pattern?: string;
332
+ }) {
333
+ return (
334
+ <label className="block text-sm font-medium">
335
+ {label}
336
+ <input
337
+ required
338
+ type={type}
339
+ pattern={pattern}
340
+ value={value}
341
+ onChange={(event) => setValue(event.target.value)}
342
+ className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
343
+ />
344
+ </label>
345
+ );
346
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "baseUrl": ".",
5
+ "jsx": "react-jsx",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "paths": { "@/*": ["src/*"] },
8
+ "types": ["vite/client"]
9
+ },
10
+ "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
11
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import tailwindcss from "@tailwindcss/vite";
3
+ import react from "@vitejs/plugin-react";
4
+ import { defineConfig } from "vite";
5
+
6
+ export default defineConfig({
7
+ plugins: [react(), tailwindcss()],
8
+ resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
9
+ server: {
10
+ proxy: {
11
+ "/api": { target: "http://localhost:8788", changeOrigin: true },
12
+ "/auth": { target: "http://localhost:8788", changeOrigin: true },
13
+ "/mcp": { target: "http://localhost:8788", changeOrigin: true },
14
+ "/.well-known": { target: "http://localhost:8788", changeOrigin: true },
15
+ },
16
+ },
17
+ });