Skip to content

Commit 64eabd5

Browse files
committed
Add AI agent discovery and in-header picker to floating chatbot
1 parent a897070 commit 64eabd5

10 files changed

Lines changed: 493 additions & 76 deletions

File tree

packages/app-shell/src/console/AppContent.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { useState, useEffect, useCallback, lazy, Suspense, useMemo, type ReactNo
1313
import { ModalForm } from '@object-ui/plugin-form';
1414
import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
1515
import { toast } from 'sonner';
16-
import { SchemaRendererProvider, useActionRunner, useGlobalUndo } from '@object-ui/react';
16+
import { useActionRunner, useGlobalUndo } from '@object-ui/react';
1717
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
1818
import type { ConnectionState } from '@object-ui/data-objectstack';
1919
import { useAuth } from '@object-ui/auth';
@@ -292,7 +292,6 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
292292
/>
293293
<KeyboardShortcutsDialog />
294294
<OnboardingWalkthrough />
295-
<SchemaRendererProvider dataSource={dataSource || {}}>
296295
<ErrorBoundary>
297296
<Suspense fallback={<LoadingScreen />}>
298297
<Routes>
@@ -358,7 +357,6 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
358357
dataSource={dataSource}
359358
/>
360359
)}
361-
</SchemaRendererProvider>
362360
</ConsoleLayout>
363361
</ExpressionProvider>
364362
);

packages/app-shell/src/console/ConsoleShell.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import { Suspense, type ReactNode } from 'react';
1414
import { Navigate, useLocation } from 'react-router-dom';
1515
import { AuthGuard, useAuth } from '@object-ui/auth';
16+
import { SchemaRendererProvider } from '@object-ui/react';
1617
import { AdapterProvider, useAdapter } from '../providers/AdapterProvider';
1718
import { MetadataProvider, useMetadata } from '../providers/MetadataProvider';
1819
import { NavigationProvider } from '../context/NavigationContext';
@@ -69,7 +70,13 @@ export function ConnectedShell({ children }: { children: ReactNode }) {
6970
function ConnectedShellInner({ children }: { children: ReactNode }) {
7071
const adapter = useAdapter();
7172
if (!adapter) return <LoadingFallback />;
72-
return <MetadataProvider adapter={adapter}>{children}</MetadataProvider>;
73+
// Expose the adapter via SchemaRendererContext so descendant hooks like
74+
// useDiscovery() (used to gate the global AI chatbot) can resolve it.
75+
return (
76+
<SchemaRendererProvider dataSource={adapter}>
77+
<MetadataProvider adapter={adapter}>{children}</MetadataProvider>
78+
</SchemaRendererProvider>
79+
);
7380
}
7481

7582
/**

packages/app-shell/src/console/home/HomeLayout.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,22 @@
88
* @module
99
*/
1010

11-
import React, { useEffect } from 'react';
11+
import React, { useEffect, Suspense, lazy } from 'react';
1212
import { useNavigationContext } from '../../context/NavigationContext';
1313
import { AppHeader } from '../../layout/AppHeader';
14+
import { useDiscovery } from '@object-ui/react';
15+
16+
// Lazy-load the chatbot so its heavy markdown deps stay out of the initial
17+
// paint until the AI assistant is actually enabled.
18+
const ConsoleFloatingChatbot = lazy(() => import('../../layout/ConsoleFloatingChatbot'));
1419

1520
interface HomeLayoutProps {
1621
children: React.ReactNode;
1722
}
1823

1924
export function HomeLayout({ children }: HomeLayoutProps) {
2025
const { setContext } = useNavigationContext();
26+
const { isAiEnabled } = useDiscovery();
2127

2228
useEffect(() => {
2329
setContext('home');
@@ -31,6 +37,13 @@ export function HomeLayout({ children }: HomeLayoutProps) {
3137
<main className="flex-1 min-w-0 overflow-auto">
3238
{children}
3339
</main>
40+
41+
{/* Global floating chatbot — also available on the home/workspace screen */}
42+
{isAiEnabled && (
43+
<Suspense fallback={null}>
44+
<ConsoleFloatingChatbot appLabel="Workspace" objects={[]} />
45+
</Suspense>
46+
)}
3447
</div>
3548
);
3649
}
Lines changed: 170 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,30 @@
11
/**
22
* ConsoleFloatingChatbot
33
*
4-
* Extracted from ConsoleLayout so it can be code-split via React.lazy.
4+
* Wires the global FAB chatbot to the framework's `@objectstack/service-ai`
5+
* backend (the `/api/v1/ai/agents/:agentName/chat` Vercel Data Stream
6+
* endpoint) and exposes an in-header agent picker.
7+
*
58
* The chatbot pulls in `react-markdown` + `micromark` (~150 KB) which is
69
* unused on every page until the AI assistant is enabled, so deferring it
710
* keeps those bytes off the initial paint.
811
* @module
912
*/
1013
import React from 'react';
11-
import { FloatingChatbot, useObjectChat, type ChatMessage } from '@object-ui/plugin-chatbot';
14+
import {
15+
FloatingChatbot,
16+
useObjectChat,
17+
useAgents,
18+
type ChatMessage,
19+
type AgentDescriptor,
20+
} from '@object-ui/plugin-chatbot';
21+
import {
22+
Select,
23+
SelectContent,
24+
SelectItem,
25+
SelectTrigger,
26+
SelectValue,
27+
} from '@object-ui/components';
1228

1329
interface ConsoleObject {
1430
name: string;
@@ -18,11 +34,65 @@ interface ConsoleObject {
1834
export interface ConsoleFloatingChatbotProps {
1935
appLabel: string;
2036
objects: ConsoleObject[];
37+
/**
38+
* Base URL of the AI service. Defaults to `${VITE_SERVER_URL}/api/v1/ai`
39+
* (or the relative `/api/v1/ai` when no server URL is configured).
40+
*/
41+
apiBase?: string;
42+
/** Default agent name to select on first render. */
43+
defaultAgent?: string;
44+
}
45+
46+
const DEFAULT_AI_PATH = '/api/v1/ai';
47+
48+
function resolveApiBase(explicit?: string): string {
49+
if (explicit) return explicit.replace(/\/$/, '');
50+
const env = (import.meta as any).env ?? {};
51+
const fromEnv = env.VITE_AI_BASE_URL as string | undefined;
52+
if (fromEnv) return fromEnv.replace(/\/$/, '');
53+
const serverUrl = (env.VITE_SERVER_URL as string | undefined) ?? '';
54+
return `${serverUrl.replace(/\/$/, '')}${DEFAULT_AI_PATH}`;
55+
}
56+
57+
/**
58+
* Inner component that owns the chat hook. Re-mounted (via `key`) whenever
59+
* the active agent changes so `useObjectChat`'s first-render mode lock
60+
* (api vs local) picks up the new endpoint.
61+
*/
62+
interface ChatbotInnerProps {
63+
appLabel: string;
64+
objects: ConsoleObject[];
65+
agents: AgentDescriptor[];
66+
agentsLoading: boolean;
67+
agentsError: Error | undefined;
68+
activeAgent: string | undefined;
69+
onAgentChange: (name: string) => void;
70+
chatApi: string | undefined;
2171
}
2272

23-
export default function ConsoleFloatingChatbot({ appLabel, objects }: ConsoleFloatingChatbotProps) {
73+
function ChatbotInner({
74+
appLabel,
75+
objects,
76+
agents,
77+
agentsLoading,
78+
agentsError,
79+
activeAgent,
80+
onAgentChange,
81+
chatApi,
82+
}: ChatbotInnerProps) {
2483
const objectNames = objects.map((o) => o.label || o.name).join(', ');
2584

85+
const activeAgentLabel = React.useMemo<string>(() => {
86+
const found = agents.find((a) => a.name === activeAgent);
87+
return found?.label ?? activeAgent ?? appLabel;
88+
}, [agents, activeAgent, appLabel]);
89+
90+
const welcomeContent = activeAgent
91+
? `Hello! I'm **${activeAgentLabel}**, your ${appLabel} assistant. How can I help you today?`
92+
: `Hello! I'm your **${appLabel}** assistant. ${
93+
agentsError ? '(Backend unreachable — running in offline demo mode.)' : ''
94+
}`;
95+
2696
const {
2797
messages,
2898
isLoading,
@@ -32,32 +102,78 @@ export default function ConsoleFloatingChatbot({ appLabel, objects }: ConsoleFlo
32102
reload,
33103
clear,
34104
} = useObjectChat({
105+
api: chatApi,
106+
conversationId: activeAgent ? `${appLabel}:${activeAgent}` : undefined,
107+
body: {
108+
context: {
109+
activeApp: appLabel,
110+
objects: objects.map((o) => ({ name: o.name, label: o.label })),
111+
agentName: activeAgent,
112+
},
113+
},
35114
initialMessages: [
36115
{
37116
id: 'welcome',
38117
role: 'assistant' as const,
39-
content: `Hello! I'm your **${appLabel}** assistant. How can I help you today?`,
118+
content: welcomeContent,
40119
},
41120
],
42-
autoResponse: true,
121+
// Local-mode fallback: only used when `chatApi` is undefined (no agent
122+
// resolved yet, or no backend available). Keeps the UI usable.
123+
autoResponse: !chatApi,
43124
autoResponseText: objectNames
44125
? `I can help you work with ${objectNames}. What would you like to do?`
45-
: 'Thanks for your message! I\'m here to help you navigate and manage your data.',
46-
autoResponseDelay: 800,
126+
: "Thanks for your message! I'm here to help you navigate and manage your data.",
127+
autoResponseDelay: 600,
47128
});
48129

130+
const headerExtra =
131+
agents.length > 0 ? (
132+
<Select
133+
value={activeAgent}
134+
onValueChange={onAgentChange}
135+
disabled={agentsLoading}
136+
>
137+
<SelectTrigger
138+
className="h-7 w-[180px] text-xs"
139+
data-testid="floating-chatbot-agent-picker"
140+
>
141+
<SelectValue placeholder="Choose agent..." />
142+
</SelectTrigger>
143+
<SelectContent align="end">
144+
{agents.map((agent: AgentDescriptor) => (
145+
<SelectItem key={agent.name} value={agent.name} className="text-xs">
146+
<span className="font-medium">{agent.label}</span>
147+
{agent.description ? (
148+
<span className="block text-muted-foreground text-[10px] truncate max-w-[220px]">
149+
{agent.description}
150+
</span>
151+
) : null}
152+
</SelectItem>
153+
))}
154+
</SelectContent>
155+
</Select>
156+
) : null;
157+
49158
return (
50159
<FloatingChatbot
51160
floatingConfig={{
52161
position: 'bottom-right',
53162
defaultOpen: false,
54-
panelWidth: 400,
55-
panelHeight: 520,
163+
panelWidth: 420,
164+
panelHeight: 560,
56165
title: `${appLabel} Assistant`,
57166
triggerSize: 56,
58167
}}
168+
headerExtra={headerExtra}
59169
messages={messages as ChatMessage[]}
60-
placeholder="Ask anything..."
170+
placeholder={
171+
activeAgent
172+
? `Ask ${activeAgentLabel}...`
173+
: agentsLoading
174+
? 'Loading agents...'
175+
: 'Ask anything...'
176+
}
61177
onSendMessage={(content: string) => sendMessage(content)}
62178
onClear={clear}
63179
onStop={isLoading ? stop : undefined}
@@ -68,3 +184,47 @@ export default function ConsoleFloatingChatbot({ appLabel, objects }: ConsoleFlo
68184
/>
69185
);
70186
}
187+
188+
export default function ConsoleFloatingChatbot({
189+
appLabel,
190+
objects,
191+
apiBase: apiBaseProp,
192+
defaultAgent: defaultAgentProp,
193+
}: ConsoleFloatingChatbotProps) {
194+
const apiBase = React.useMemo(() => resolveApiBase(apiBaseProp), [apiBaseProp]);
195+
const env = (import.meta as any).env ?? {};
196+
const envDefaultAgent = env.VITE_AI_DEFAULT_AGENT as string | undefined;
197+
198+
const { agents, isLoading: agentsLoading, error: agentsError } = useAgents({ apiBase });
199+
200+
const [activeAgent, setActiveAgent] = React.useState<string | undefined>(undefined);
201+
React.useEffect(() => {
202+
if (!activeAgent && agents.length > 0) {
203+
const preferred = defaultAgentProp ?? envDefaultAgent;
204+
const match = preferred && agents.find((a) => a.name === preferred);
205+
setActiveAgent((match ?? agents[0]).name);
206+
}
207+
}, [agents, activeAgent, defaultAgentProp, envDefaultAgent]);
208+
209+
const chatApi = activeAgent
210+
? `${apiBase}/agents/${encodeURIComponent(activeAgent)}/chat`
211+
: undefined;
212+
213+
// `key` forces a clean remount whenever the active agent (and therefore the
214+
// chat API URL) changes — required because `useObjectChat` locks its mode
215+
// (api vs local) on first render.
216+
return (
217+
<ChatbotInner
218+
key={chatApi ?? 'local'}
219+
appLabel={appLabel}
220+
objects={objects}
221+
agents={agents}
222+
agentsLoading={agentsLoading}
223+
agentsError={agentsError}
224+
activeAgent={activeAgent}
225+
onAgentChange={setActiveAgent}
226+
chatApi={chatApi}
227+
/>
228+
);
229+
}
230+

packages/plugin-chatbot/README.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ function App() {
4646
### AI Streaming Mode (service-ai)
4747

4848
When `api` is set in the schema, the chatbot connects to a backend SSE endpoint
49-
using `@ai-sdk/react` for streaming, tool-calling, and production-grade chat:
49+
using `@ai-sdk/react` v3 (Vercel UI Message Stream protocol) for streaming,
50+
tool-calling, and production-grade chat:
5051

5152
```tsx
5253
import '@object-ui/plugin-chatbot';
@@ -102,6 +103,55 @@ function MyChat() {
102103

103104
## Schema-Driven Usage
104105

106+
### Discovering Backend Agents
107+
108+
Use `useAgents` to fetch the list of agents exposed by `@objectstack/service-ai`
109+
at `GET {apiBase}/agents`. This is what the global console FAB uses to populate
110+
its in-header agent picker:
111+
112+
```tsx
113+
import { useAgents } from '@object-ui/plugin-chatbot';
114+
115+
function AgentPicker() {
116+
const { agents, isLoading, error } = useAgents({
117+
apiBase: 'http://localhost:3000/api/v1/ai',
118+
// Optional fallback list shown when the backend is unreachable
119+
fallback: [{ name: 'data_chat', label: 'Data Chat' }],
120+
});
121+
122+
if (isLoading) return <span>Loading agents…</span>;
123+
if (error) return <span>Backend unreachable</span>;
124+
125+
return (
126+
<select>
127+
{agents.map(a => (
128+
<option key={a.name} value={a.name}>{a.label}</option>
129+
))}
130+
</select>
131+
);
132+
}
133+
```
134+
135+
Each agent's chat endpoint is `POST {apiBase}/agents/{name}/chat` — pass that
136+
URL as the `api` option to `useObjectChat` to talk to it.
137+
138+
### Console Integration
139+
140+
The console (`@object-ui/app-shell`) auto-mounts a global floating chatbot
141+
when `useDiscovery().isAiEnabled` is true. Configure the backend in your
142+
console `.env`:
143+
144+
```bash
145+
# AI service endpoint (defaults to ${VITE_SERVER_URL}/api/v1/ai when unset)
146+
VITE_AI_BASE_URL=http://localhost:3000/api/v1/ai
147+
# Default agent to select on first open (must match an agent name returned
148+
# by GET ${VITE_AI_BASE_URL}/agents)
149+
VITE_AI_DEFAULT_AGENT=sales_copilot
150+
```
151+
152+
The picker lets the user switch agents at runtime; switching transparently
153+
remounts the chat hook against the new agent's `/chat` endpoint.
154+
105155
This plugin automatically registers with ObjectUI's component registry when imported:
106156

107157
```tsx

0 commit comments

Comments
 (0)