@iloveagents/foundry-web-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/AGENTS.md +59 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/package.json +61 -0
- package/src/__tests__/no-spaces-imports.test.ts +59 -0
- package/src/__tests__/open-core-guards.test.ts +307 -0
- package/src/__tests__/theme-runtime.test.ts +81 -0
- package/src/__tests__/tool-fallback.test.tsx +109 -0
- package/src/components/ag-ui-runtime-provider.tsx +59 -0
- package/src/components/app-brand.tsx +38 -0
- package/src/components/assistant-chat.tsx +423 -0
- package/src/components/chat-attachments.tsx +68 -0
- package/src/components/chat-bubble.tsx +339 -0
- package/src/components/chat-header.tsx +44 -0
- package/src/components/client-tool-executor.tsx +230 -0
- package/src/components/collection-empty-state.tsx +37 -0
- package/src/components/collection-filter-bar.tsx +168 -0
- package/src/components/collection-search-input.tsx +41 -0
- package/src/components/collection-skeleton.tsx +55 -0
- package/src/components/collection-sort-menu.tsx +62 -0
- package/src/components/collection-surface.tsx +46 -0
- package/src/components/collection-toolbar.tsx +33 -0
- package/src/components/collection-view-toggle.tsx +61 -0
- package/src/components/confirm-dialog.tsx +49 -0
- package/src/components/confirmation-card.tsx +32 -0
- package/src/components/context-badges.tsx +124 -0
- package/src/components/context-bar.tsx +202 -0
- package/src/components/form-dialog.tsx +56 -0
- package/src/components/global-selection-popover.tsx +135 -0
- package/src/components/infinite-scroll-sentinel.tsx +48 -0
- package/src/components/json-viewer.tsx +232 -0
- package/src/components/loading-indicator.tsx +40 -0
- package/src/components/markdown-text.tsx +379 -0
- package/src/components/name-dialog.tsx +87 -0
- package/src/components/selection-popover.tsx +156 -0
- package/src/components/show-document-tool-ui.tsx +90 -0
- package/src/components/sidebar.test.tsx +182 -0
- package/src/components/sidebar.tsx +1174 -0
- package/src/components/surface-card.tsx +45 -0
- package/src/components/theme-runtime-provider.tsx +93 -0
- package/src/components/theme-toggle.tsx +27 -0
- package/src/components/tool-call-card.tsx +126 -0
- package/src/components/tool-fallback.tsx +169 -0
- package/src/components/tool-panel-layout.tsx +36 -0
- package/src/components/tool-panel.tsx +233 -0
- package/src/components/tooltip-icon-button.tsx +28 -0
- package/src/components/user-menu.tsx +60 -0
- package/src/index.ts +170 -0
- package/src/lib/__tests__/ag-ui-adapter.test.ts +182 -0
- package/src/lib/__tests__/app-store.test.ts +330 -0
- package/src/lib/__tests__/attachment-adapter.test.ts +48 -0
- package/src/lib/__tests__/chat-bubble-store.test.ts +67 -0
- package/src/lib/__tests__/client-tools.test.ts +124 -0
- package/src/lib/__tests__/dev-store.test.ts +78 -0
- package/src/lib/__tests__/nav-config.test.ts +135 -0
- package/src/lib/__tests__/nav-dnd.test.ts +24 -0
- package/src/lib/__tests__/nav-store.test.ts +59 -0
- package/src/lib/__tests__/sidebar-store.test.ts +144 -0
- package/src/lib/__tests__/theme-store.test.ts +83 -0
- package/src/lib/__tests__/tool-panel-store.test.ts +61 -0
- package/src/lib/__tests__/use-page-context.test.ts +58 -0
- package/src/lib/ag-ui-adapter.ts +362 -0
- package/src/lib/app-store.ts +294 -0
- package/src/lib/attachment-adapter.ts +92 -0
- package/src/lib/auth-provider.tsx +144 -0
- package/src/lib/chat-bubble-store.ts +46 -0
- package/src/lib/client-tools.ts +293 -0
- package/src/lib/dev-store.ts +69 -0
- package/src/lib/nav-config.ts +292 -0
- package/src/lib/nav-dnd.ts +14 -0
- package/src/lib/nav-store.ts +76 -0
- package/src/lib/sidebar-store.ts +113 -0
- package/src/lib/theme-runtime.ts +660 -0
- package/src/lib/theme-store.ts +67 -0
- package/src/lib/tool-panel-store.ts +121 -0
- package/src/lib/use-new-conversation.test.tsx +124 -0
- package/src/lib/use-new-conversation.ts +33 -0
- package/src/lib/use-page-tools.ts +40 -0
- package/src/ui/collapsible.tsx +7 -0
- package/src/ui/dialog.tsx +109 -0
- package/src/ui/dropdown-menu.tsx +71 -0
- package/src/ui/popover.tsx +34 -0
- package/src/ui/select.tsx +15 -0
- package/src/ui/tooltip.tsx +30 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File attachment adapter for assistant-ui.
|
|
3
|
+
*
|
|
4
|
+
* Reads files client-side as base64 and produces FileMessagePart content.
|
|
5
|
+
* The AG-UI adapter converts these to BinaryInputContent for the protocol,
|
|
6
|
+
* and the agent framework passes them to the Responses API as input_file
|
|
7
|
+
* or input_image -- no separate upload endpoint needed.
|
|
8
|
+
*
|
|
9
|
+
* Supported: images (PNG, JPEG, WEBP, GIF), PDFs, DOCX
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
AttachmentAdapter,
|
|
14
|
+
PendingAttachment,
|
|
15
|
+
CompleteAttachment,
|
|
16
|
+
Attachment,
|
|
17
|
+
} from "@assistant-ui/react";
|
|
18
|
+
|
|
19
|
+
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 MB
|
|
20
|
+
|
|
21
|
+
/** Infer MIME type from extension when browser reports empty file.type (common for .docx) */
|
|
22
|
+
const EXTENSION_MIME: Record<string, string> = {
|
|
23
|
+
".pdf": "application/pdf",
|
|
24
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
25
|
+
".png": "image/png",
|
|
26
|
+
".jpg": "image/jpeg",
|
|
27
|
+
".jpeg": "image/jpeg",
|
|
28
|
+
".gif": "image/gif",
|
|
29
|
+
".webp": "image/webp",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function resolveMimeType(file: File): string {
|
|
33
|
+
if (file.type) return file.type;
|
|
34
|
+
const ext = file.name.slice(file.name.lastIndexOf(".")).toLowerCase();
|
|
35
|
+
return EXTENSION_MIME[ext] ?? "application/octet-stream";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fileToBase64(file: File): Promise<string> {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const reader = new FileReader();
|
|
41
|
+
reader.onload = () => {
|
|
42
|
+
const result = reader.result as string;
|
|
43
|
+
// Strip the data URI prefix (e.g. "data:image/png;base64,"), keep raw base64
|
|
44
|
+
resolve(result.split(",")[1]);
|
|
45
|
+
};
|
|
46
|
+
reader.onerror = reject;
|
|
47
|
+
reader.readAsDataURL(file);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class FileAttachmentAdapter implements AttachmentAdapter {
|
|
52
|
+
accept =
|
|
53
|
+
"image/*,application/pdf,.pdf,.docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
54
|
+
|
|
55
|
+
async add({ file }: { file: File }): Promise<PendingAttachment> {
|
|
56
|
+
if (file.size > MAX_FILE_SIZE) {
|
|
57
|
+
throw new Error(`File too large (${Math.round(file.size / 1024 / 1024)} MB). Max 20 MB.`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const mimeType = resolveMimeType(file);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
id: crypto.randomUUID(),
|
|
64
|
+
type: mimeType.startsWith("image/") ? "image" : "document",
|
|
65
|
+
name: file.name,
|
|
66
|
+
contentType: mimeType,
|
|
67
|
+
file,
|
|
68
|
+
status: { type: "requires-action", reason: "composer-send" },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async send(attachment: PendingAttachment): Promise<CompleteAttachment> {
|
|
73
|
+
const base64 = await fileToBase64(attachment.file);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
...attachment,
|
|
77
|
+
status: { type: "complete" },
|
|
78
|
+
content: [
|
|
79
|
+
{
|
|
80
|
+
type: "file" as const,
|
|
81
|
+
data: base64,
|
|
82
|
+
mimeType: attachment.contentType ?? "application/octet-stream",
|
|
83
|
+
filename: attachment.name,
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async remove(_attachment: Attachment): Promise<void> {
|
|
90
|
+
// No-op -- files are read client-side, nothing to clean up
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuthProvider — wraps the app with MSAL authentication.
|
|
3
|
+
*
|
|
4
|
+
* Uses MsalProvider's built-in redirect handling (no manual handleRedirectPromise).
|
|
5
|
+
* AuthGuard uses useMsal() + inProgress to avoid redirect loops.
|
|
6
|
+
*
|
|
7
|
+
* The provider expects a fully resolved MSAL configuration.
|
|
8
|
+
* Missing configuration is treated as an application bootstrap error.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
type FC,
|
|
13
|
+
type PropsWithChildren,
|
|
14
|
+
type ReactNode,
|
|
15
|
+
useEffect,
|
|
16
|
+
useState,
|
|
17
|
+
} from "react";
|
|
18
|
+
import {
|
|
19
|
+
authStore,
|
|
20
|
+
initializeMsal,
|
|
21
|
+
getMsalInstance,
|
|
22
|
+
type MsalAuthConfig,
|
|
23
|
+
type MsalClientApplication,
|
|
24
|
+
} from "@iloveagents/foundry-agent/msal";
|
|
25
|
+
|
|
26
|
+
interface AuthProviderProps {
|
|
27
|
+
config: MsalAuthConfig;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type MsalProviderComponent = FC<{
|
|
31
|
+
instance: MsalClientApplication;
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
}>;
|
|
34
|
+
|
|
35
|
+
// Dynamic imports for MSAL React (stays optional)
|
|
36
|
+
type MsalHook = () => {
|
|
37
|
+
instance: MsalClientApplication;
|
|
38
|
+
accounts: { name?: string; username: string; localAccountId: string }[];
|
|
39
|
+
inProgress: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const AuthProvider: FC<PropsWithChildren<AuthProviderProps>> = ({
|
|
43
|
+
config,
|
|
44
|
+
children,
|
|
45
|
+
}) => {
|
|
46
|
+
const [ready, setReady] = useState(false);
|
|
47
|
+
const [initError, setInitError] = useState<string | null>(null);
|
|
48
|
+
const [MsalProvider, setMsalProvider] = useState<MsalProviderComponent | null>(null);
|
|
49
|
+
const [useMsalHook, setUseMsalHook] = useState<MsalHook | null>(null);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
let cancelled = false;
|
|
53
|
+
|
|
54
|
+
(async () => {
|
|
55
|
+
try {
|
|
56
|
+
await initializeMsal(config);
|
|
57
|
+
const msalReact = await import("@azure/msal-react");
|
|
58
|
+
|
|
59
|
+
if (cancelled) return;
|
|
60
|
+
|
|
61
|
+
setMsalProvider(() => msalReact.MsalProvider as unknown as MsalProviderComponent);
|
|
62
|
+
setUseMsalHook(() => msalReact.useMsal as unknown as MsalHook);
|
|
63
|
+
setReady(true);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error("[auth] Failed to initialize MSAL", error);
|
|
66
|
+
if (cancelled) return;
|
|
67
|
+
setInitError(
|
|
68
|
+
error instanceof Error ? error.message : "Failed to initialize authentication.",
|
|
69
|
+
);
|
|
70
|
+
setReady(true);
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
|
|
74
|
+
return () => { cancelled = true; };
|
|
75
|
+
}, [config]);
|
|
76
|
+
|
|
77
|
+
if (!ready) return null;
|
|
78
|
+
|
|
79
|
+
if (initError) {
|
|
80
|
+
return (
|
|
81
|
+
<div className="flex min-h-dvh items-center justify-center bg-background px-6 text-center">
|
|
82
|
+
<div className="max-w-md rounded-2xl border border-border bg-card px-6 py-5 shadow-sm">
|
|
83
|
+
<h1 className="text-lg font-semibold text-foreground">Authentication unavailable</h1>
|
|
84
|
+
<p className="mt-2 text-sm text-muted-foreground">{initError}</p>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!MsalProvider || !useMsalHook) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const instance = getMsalInstance()!;
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<MsalProvider instance={instance}>
|
|
98
|
+
<AuthGuard useMsalHook={useMsalHook}>{children}</AuthGuard>
|
|
99
|
+
</MsalProvider>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* AuthGuard — waits for MsalProvider to finish processing redirects,
|
|
105
|
+
* then either syncs the account or triggers loginRedirect.
|
|
106
|
+
*
|
|
107
|
+
* Uses inProgress === "none" to avoid redirect loops (best practice from
|
|
108
|
+
* Microsoft docs — never call loginRedirect while another interaction is active).
|
|
109
|
+
*/
|
|
110
|
+
const AuthGuard: FC<PropsWithChildren<{ useMsalHook: MsalHook }>> = ({
|
|
111
|
+
useMsalHook,
|
|
112
|
+
children,
|
|
113
|
+
}) => {
|
|
114
|
+
const { instance, accounts, inProgress } = useMsalHook();
|
|
115
|
+
const [authenticated, setAuthenticated] = useState(false);
|
|
116
|
+
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
// Wait until MsalProvider finishes processing any redirect or startup
|
|
119
|
+
if (inProgress !== "none") return;
|
|
120
|
+
|
|
121
|
+
if (accounts.length > 0) {
|
|
122
|
+
const account = accounts[0];
|
|
123
|
+
authStore.getState().signIn({
|
|
124
|
+
name: account.name ?? account.username,
|
|
125
|
+
email: account.username,
|
|
126
|
+
oid: account.localAccountId,
|
|
127
|
+
});
|
|
128
|
+
instance.setActiveAccount(account);
|
|
129
|
+
setAuthenticated(true);
|
|
130
|
+
} else {
|
|
131
|
+
// No account and no interaction in progress — redirect to login.
|
|
132
|
+
// Only request OIDC scopes here. API/SPACES tokens are acquired
|
|
133
|
+
// silently later via acquireTokenSilent (consent is per-resource
|
|
134
|
+
// in Entra ID — including them here would show N consent screens).
|
|
135
|
+
instance.loginRedirect({
|
|
136
|
+
scopes: ["openid", "profile", "email"],
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}, [instance, accounts, inProgress]);
|
|
140
|
+
|
|
141
|
+
if (!authenticated) return null;
|
|
142
|
+
|
|
143
|
+
return <>{children}</>;
|
|
144
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Bubble Store — manages chat display mode as a layout state.
|
|
3
|
+
*
|
|
4
|
+
* Four modes: CLOSED → BUBBLE → EXPANDED → FULL
|
|
5
|
+
* - CLOSED: only trigger button visible
|
|
6
|
+
* - BUBBLE (isOpen): floating 380x600 chat panel
|
|
7
|
+
* - EXPANDED (isExpanded): chat fills main area, page goes to panel
|
|
8
|
+
* - FULL: expanded with page panel closed
|
|
9
|
+
*
|
|
10
|
+
* No route changes. The URL stays the same in all modes.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { create } from "zustand";
|
|
14
|
+
|
|
15
|
+
interface ChatBubbleState {
|
|
16
|
+
/** Bubble floating panel is visible */
|
|
17
|
+
isOpen: boolean;
|
|
18
|
+
/** Chat is expanded into the main layout area */
|
|
19
|
+
isExpanded: boolean;
|
|
20
|
+
/** Page panel visible in expanded mode (false = FULL mode) */
|
|
21
|
+
showPagePanel: boolean;
|
|
22
|
+
|
|
23
|
+
open: () => void;
|
|
24
|
+
close: () => void;
|
|
25
|
+
toggle: () => void;
|
|
26
|
+
/** Expand: hide bubble, show chat in main layout */
|
|
27
|
+
expand: () => void;
|
|
28
|
+
/** Collapse: hide expanded chat, show bubble */
|
|
29
|
+
collapse: () => void;
|
|
30
|
+
/** Toggle page panel in expanded mode (EXPANDED ↔ FULL) */
|
|
31
|
+
togglePagePanel: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const useChatBubbleStore = create<ChatBubbleState>((set) => ({
|
|
35
|
+
isOpen: false,
|
|
36
|
+
isExpanded: false,
|
|
37
|
+
showPagePanel: true,
|
|
38
|
+
|
|
39
|
+
open: () => set({ isOpen: true }),
|
|
40
|
+
close: () => set({ isOpen: false, isExpanded: false }),
|
|
41
|
+
toggle: () => set((s) => ({ isOpen: !s.isOpen })),
|
|
42
|
+
|
|
43
|
+
expand: () => set({ isOpen: false, isExpanded: true, showPagePanel: true }),
|
|
44
|
+
collapse: () => set({ isOpen: true, isExpanded: false }),
|
|
45
|
+
togglePagePanel: () => set((s) => ({ showPagePanel: !s.showPagePanel })),
|
|
46
|
+
}));
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global Client Tools — registered into the dynamic tool registry at app init.
|
|
3
|
+
*
|
|
4
|
+
* Naming convention:
|
|
5
|
+
* ui_* — tools that affect the UI (navigate, theme, panel, content)
|
|
6
|
+
*
|
|
7
|
+
* Page-specific tools use their own prefix (e.g., page_search_documents).
|
|
8
|
+
* The adapter reads from the registry (not this file) on every request.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useThemeStore, type ThemeMode } from "./theme-store.ts";
|
|
12
|
+
import { useToolPanelStore } from "./tool-panel-store.ts";
|
|
13
|
+
import { useAppStore } from "./app-store.ts";
|
|
14
|
+
import { clientToolRegistry, type ClientToolEntry } from "@iloveagents/foundry-agent";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Navigate function injection (needs React Router context)
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
let _navigateFn: ((path: string) => void) | null = null;
|
|
21
|
+
|
|
22
|
+
export function setNavigateFunction(fn: (path: string) => void) {
|
|
23
|
+
_navigateFn = fn;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Global tool definitions with executors
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
const GLOBAL_TOOLS: ClientToolEntry[] = [
|
|
31
|
+
{
|
|
32
|
+
name: "ui_navigate",
|
|
33
|
+
description:
|
|
34
|
+
"Navigate the user to a different page in the application. " +
|
|
35
|
+
"Only use when the user explicitly asks to go to a page.",
|
|
36
|
+
parameters: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
path: {
|
|
40
|
+
type: "string",
|
|
41
|
+
description: "The path to navigate to (e.g. '/workspace')",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
required: ["path"],
|
|
45
|
+
},
|
|
46
|
+
execute: async (argsJson) => {
|
|
47
|
+
const { path } = JSON.parse(argsJson || "{}");
|
|
48
|
+
if (_navigateFn) _navigateFn(path);
|
|
49
|
+
return JSON.stringify({ status: "success", path });
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "ui_set_theme",
|
|
54
|
+
description: "Change the application theme. Modes: 'light', 'dark', or 'system'.",
|
|
55
|
+
parameters: {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: {
|
|
58
|
+
mode: {
|
|
59
|
+
type: "string",
|
|
60
|
+
enum: ["light", "dark", "system"],
|
|
61
|
+
description: "The theme mode to apply",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
required: ["mode"],
|
|
65
|
+
},
|
|
66
|
+
execute: async (argsJson) => {
|
|
67
|
+
const { mode } = JSON.parse(argsJson || "{}");
|
|
68
|
+
useThemeStore.getState().setMode(mode as ThemeMode);
|
|
69
|
+
return JSON.stringify({ status: "success", mode });
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "ui_open_panel",
|
|
74
|
+
description:
|
|
75
|
+
"Show content in the side panel. Prefer this over ui_navigate to keep " +
|
|
76
|
+
"chat visible. Use path for a page component (e.g. '/workspace'), " +
|
|
77
|
+
"or content for generated markdown/text.",
|
|
78
|
+
parameters: {
|
|
79
|
+
type: "object",
|
|
80
|
+
properties: {
|
|
81
|
+
title: { type: "string", description: "Panel title" },
|
|
82
|
+
path: {
|
|
83
|
+
type: "string",
|
|
84
|
+
description: "Page path to render in the panel (e.g. '/workspace')",
|
|
85
|
+
},
|
|
86
|
+
content: {
|
|
87
|
+
type: "string",
|
|
88
|
+
description: "Markdown or text content (used when path is not provided)",
|
|
89
|
+
},
|
|
90
|
+
type: {
|
|
91
|
+
type: "string",
|
|
92
|
+
enum: ["markdown", "text"],
|
|
93
|
+
description: "Content format (default: markdown)",
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
required: ["title"],
|
|
97
|
+
},
|
|
98
|
+
execute: async (argsJson) => {
|
|
99
|
+
const args = JSON.parse(argsJson || "{}");
|
|
100
|
+
if (args.path) {
|
|
101
|
+
useToolPanelStore.getState().openPanel({
|
|
102
|
+
title: args.title,
|
|
103
|
+
content: args.path,
|
|
104
|
+
type: "page",
|
|
105
|
+
});
|
|
106
|
+
} else {
|
|
107
|
+
useToolPanelStore.getState().openPanel({
|
|
108
|
+
title: args.title,
|
|
109
|
+
content: args.content || "",
|
|
110
|
+
type: args.type || "markdown",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return JSON.stringify({
|
|
114
|
+
status: "success",
|
|
115
|
+
title: args.title,
|
|
116
|
+
mode: args.path ? "page" : "content",
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "ui_select_item",
|
|
122
|
+
description: "Select an item in the current page by its ID. Highlights it in the UI.",
|
|
123
|
+
parameters: {
|
|
124
|
+
type: "object",
|
|
125
|
+
properties: {
|
|
126
|
+
itemId: {
|
|
127
|
+
type: "string",
|
|
128
|
+
description: "The ID of the item to select (or null to deselect)",
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
required: ["itemId"],
|
|
132
|
+
},
|
|
133
|
+
execute: async (argsJson) => {
|
|
134
|
+
const { itemId } = JSON.parse(argsJson || "{}");
|
|
135
|
+
useAppStore.getState().setSelectedItemId(itemId ?? null);
|
|
136
|
+
return JSON.stringify({ status: "success", itemId });
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: "ui_update_content",
|
|
141
|
+
description:
|
|
142
|
+
"Update fields in the shared draft/form. Pass all fields as key-value " +
|
|
143
|
+
"pairs in a single call. Works for one or many fields.",
|
|
144
|
+
parameters: {
|
|
145
|
+
type: "object",
|
|
146
|
+
properties: {
|
|
147
|
+
fields: {
|
|
148
|
+
type: "object",
|
|
149
|
+
description: "Key-value pairs of field names and their values",
|
|
150
|
+
additionalProperties: true,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
required: ["fields"],
|
|
154
|
+
},
|
|
155
|
+
execute: async (argsJson) => {
|
|
156
|
+
const { fields } = JSON.parse(argsJson || "{}");
|
|
157
|
+
const store = useAppStore.getState();
|
|
158
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
159
|
+
store.updateContentField(key, value);
|
|
160
|
+
}
|
|
161
|
+
return JSON.stringify({ status: "success", fields });
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "ui_highlight",
|
|
166
|
+
description: "Highlight an entity in the UI to draw the user's attention.",
|
|
167
|
+
parameters: {
|
|
168
|
+
type: "object",
|
|
169
|
+
properties: {
|
|
170
|
+
entityId: {
|
|
171
|
+
type: "string",
|
|
172
|
+
description: "The ID of the entity to highlight",
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
required: ["entityId"],
|
|
176
|
+
},
|
|
177
|
+
execute: async (argsJson) => {
|
|
178
|
+
const { entityId } = JSON.parse(argsJson || "{}");
|
|
179
|
+
useAppStore.getState().addHighlight(entityId);
|
|
180
|
+
return JSON.stringify({ status: "success", entityId });
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: "ui_confirm",
|
|
185
|
+
description:
|
|
186
|
+
"Ask the user to confirm or reject an action before proceeding. " +
|
|
187
|
+
"Use for destructive or important actions.",
|
|
188
|
+
parameters: {
|
|
189
|
+
type: "object",
|
|
190
|
+
properties: {
|
|
191
|
+
action: {
|
|
192
|
+
type: "string",
|
|
193
|
+
description: "Short description of the action requiring confirmation",
|
|
194
|
+
},
|
|
195
|
+
details: {
|
|
196
|
+
type: "string",
|
|
197
|
+
description: "Optional details about what will happen",
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
required: ["action"],
|
|
201
|
+
},
|
|
202
|
+
execute: async (argsJson) => {
|
|
203
|
+
const { action, details } = JSON.parse(argsJson || "{}");
|
|
204
|
+
return JSON.stringify({
|
|
205
|
+
status: "pending",
|
|
206
|
+
action,
|
|
207
|
+
details: details || "",
|
|
208
|
+
message: "Awaiting user confirmation via chat.",
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
name: "ui_get_context",
|
|
214
|
+
description:
|
|
215
|
+
"Get the full application context: navigation breadcrumb from root " +
|
|
216
|
+
"to current page (with accumulated metadata), selected items, draft " +
|
|
217
|
+
"values, highlights, and user-pinned context items.",
|
|
218
|
+
parameters: { type: "object", properties: {}, required: [] },
|
|
219
|
+
execute: async () => {
|
|
220
|
+
const state = useAppStore.getState().getAgentState();
|
|
221
|
+
const { useNavStore } = await import("./nav-store.ts");
|
|
222
|
+
const navConfig = useNavStore.getState().config;
|
|
223
|
+
|
|
224
|
+
// Build breadcrumb: root group → parent item → current leaf
|
|
225
|
+
const breadcrumb: Array<{
|
|
226
|
+
level: string;
|
|
227
|
+
label: string;
|
|
228
|
+
description?: string;
|
|
229
|
+
meta?: Record<string, unknown>;
|
|
230
|
+
}> = [];
|
|
231
|
+
|
|
232
|
+
for (const group of navConfig) {
|
|
233
|
+
for (const item of group.items) {
|
|
234
|
+
if (item.to === state.navigation.page) {
|
|
235
|
+
breadcrumb.push({ level: "group", label: group.label, meta: group.meta });
|
|
236
|
+
breadcrumb.push({
|
|
237
|
+
level: "page",
|
|
238
|
+
label: item.label,
|
|
239
|
+
description: item.description,
|
|
240
|
+
meta: item.meta,
|
|
241
|
+
});
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
if (item.children) {
|
|
245
|
+
for (const child of item.children) {
|
|
246
|
+
if (child.to === state.navigation.page) {
|
|
247
|
+
breadcrumb.push({ level: "group", label: group.label, meta: group.meta });
|
|
248
|
+
breadcrumb.push({
|
|
249
|
+
level: "parent",
|
|
250
|
+
label: item.label,
|
|
251
|
+
description: item.description,
|
|
252
|
+
meta: item.meta,
|
|
253
|
+
});
|
|
254
|
+
breadcrumb.push({
|
|
255
|
+
level: "page",
|
|
256
|
+
label: child.label,
|
|
257
|
+
description: child.description,
|
|
258
|
+
meta: child.meta,
|
|
259
|
+
});
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (breadcrumb.length > 0) break;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return JSON.stringify({
|
|
269
|
+
status: "success",
|
|
270
|
+
breadcrumb,
|
|
271
|
+
currentPage: state.navigation,
|
|
272
|
+
selectedItemId: state.selectedItemId,
|
|
273
|
+
content: state.content,
|
|
274
|
+
highlights: state.highlights,
|
|
275
|
+
contextItems: state.contextItems,
|
|
276
|
+
});
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
];
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// Registration — called once at module load
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
export function registerGlobalTools() {
|
|
286
|
+
const registry = clientToolRegistry.getState();
|
|
287
|
+
for (const tool of GLOBAL_TOOLS) {
|
|
288
|
+
registry.registerGlobal(tool);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Auto-register on import
|
|
293
|
+
registerGlobalTools();
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev Store — captures debug telemetry for the Dev Panel.
|
|
3
|
+
*
|
|
4
|
+
* Only populated in dev mode. Stores the last AG-UI request payload
|
|
5
|
+
* and tool call events so developers can inspect the full agent turn.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { create } from "zustand";
|
|
9
|
+
|
|
10
|
+
export interface DevToolCall {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
args: string;
|
|
14
|
+
result?: unknown;
|
|
15
|
+
isClientSide: boolean;
|
|
16
|
+
timestamp: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface DevState {
|
|
20
|
+
/** Last AG-UI request payload (runInput) */
|
|
21
|
+
lastRequest: Record<string, unknown> | null;
|
|
22
|
+
/** Timestamp of last request */
|
|
23
|
+
lastRequestAt: number | null;
|
|
24
|
+
/** Tool calls from the current/last turn */
|
|
25
|
+
toolCalls: DevToolCall[];
|
|
26
|
+
/** SSE events log (last N) */
|
|
27
|
+
events: Array<{ type: string; timestamp: number; data?: string }>;
|
|
28
|
+
|
|
29
|
+
/** Capture a request payload (resets tool calls for new turn) */
|
|
30
|
+
captureRequest: (payload: Record<string, unknown>) => void;
|
|
31
|
+
/** Record a tool call */
|
|
32
|
+
addToolCall: (call: DevToolCall) => void;
|
|
33
|
+
/** Update a tool call with args and/or result */
|
|
34
|
+
updateToolCall: (id: string, update: Partial<Pick<DevToolCall, "args" | "result">>) => void;
|
|
35
|
+
/** Log an SSE event */
|
|
36
|
+
logEvent: (type: string, data?: string) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const MAX_EVENTS = 50;
|
|
40
|
+
|
|
41
|
+
export const useDevStore = create<DevState>((set, get) => ({
|
|
42
|
+
lastRequest: null,
|
|
43
|
+
lastRequestAt: null,
|
|
44
|
+
toolCalls: [],
|
|
45
|
+
events: [],
|
|
46
|
+
|
|
47
|
+
captureRequest: (payload) => {
|
|
48
|
+
const isFollowUp = get().toolCalls.length > 0;
|
|
49
|
+
set({
|
|
50
|
+
lastRequest: payload,
|
|
51
|
+
lastRequestAt: Date.now(),
|
|
52
|
+
// Keep tool calls for follow-up requests (multi-turn), reset on fresh turn
|
|
53
|
+
...(isFollowUp ? {} : { toolCalls: [], events: [] }),
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
addToolCall: (call) => set({ toolCalls: [...get().toolCalls, call] }),
|
|
58
|
+
|
|
59
|
+
updateToolCall: (id, update) =>
|
|
60
|
+
set({
|
|
61
|
+
toolCalls: get().toolCalls.map((tc) => (tc.id === id ? { ...tc, ...update } : tc)),
|
|
62
|
+
}),
|
|
63
|
+
|
|
64
|
+
logEvent: (type, data) => {
|
|
65
|
+
const events = get().events;
|
|
66
|
+
const next = [...events, { type, timestamp: Date.now(), data }];
|
|
67
|
+
set({ events: next.length > MAX_EVENTS ? next.slice(-MAX_EVENTS) : next });
|
|
68
|
+
},
|
|
69
|
+
}));
|