diff --git a/CHANGELOG.md b/CHANGELOG.md
index d590d3bf..4b38e626 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **AI service discovery** (`@object-ui/react`): Added `ai` service type to `DiscoveryInfo.services` interface with `enabled`, `status`, and `route` fields. Added `isAiEnabled` convenience property to `useDiscovery()` hook return value — returns `true` only when `services.ai.enabled === true` and `services.ai.status === 'available'`, defaults to `false` otherwise.
+
+- **Conditional chatbot rendering** (`@object-ui/console`): Console floating chatbot (FAB) now only renders when the AI service is detected as available via `useDiscovery().isAiEnabled`. Previously the chatbot was always visible; now it is hidden when the server has no AI plugin installed.
+
- **Home page user menu** (`@object-ui/console`): Added complete user menu dropdown (Profile, Settings, Sign Out) to the Home Dashboard via new `HomeLayout` shell component. Users can now access account actions directly from the `/home` page without navigating elsewhere.
- **"Return to Home" navigation** (`@object-ui/console`): Added a "Home" entry in the AppSidebar app switcher dropdown, allowing users to navigate back to `/home` from any application context. Previously, the only way to return to the Home Dashboard was to manually edit the URL.
diff --git a/apps/console/src/components/ConsoleLayout.tsx b/apps/console/src/components/ConsoleLayout.tsx
index 53cf3f9f..8e5f2391 100644
--- a/apps/console/src/components/ConsoleLayout.tsx
+++ b/apps/console/src/components/ConsoleLayout.tsx
@@ -10,6 +10,7 @@
import React from 'react';
import { AppShell } from '@object-ui/layout';
import { FloatingChatbot, useObjectChat, type ChatMessage } from '@object-ui/plugin-chatbot';
+import { useDiscovery } from '@object-ui/react';
import { AppSidebar } from './AppSidebar';
import { AppHeader } from './AppHeader';
import { useResponsiveSidebar } from '../hooks/useResponsiveSidebar';
@@ -96,6 +97,7 @@ export function ConsoleLayout({
connectionState
}: ConsoleLayoutProps) {
const appLabel = resolveI18nLabel(activeApp?.label) || activeAppName;
+ const { isAiEnabled } = useDiscovery();
return (
- {/* Global floating chatbot — available on every page */}
-
+ {/* Global floating chatbot — rendered only when AI service is available */}
+ {isAiEnabled && }
);
}
diff --git a/packages/react/README.md b/packages/react/README.md
index 34616d3d..3c651cea 100644
--- a/packages/react/README.md
+++ b/packages/react/README.md
@@ -120,13 +120,18 @@ Access server discovery information including preview mode detection:
import { useDiscovery } from '@object-ui/react'
function MyComponent() {
- const { discovery, isLoading, isAuthEnabled } = useDiscovery()
+ const { discovery, isLoading, isAuthEnabled, isAiEnabled } = useDiscovery()
// Check if the server is in preview mode
if (discovery?.mode === 'preview') {
console.log('Preview mode active:', discovery.previewMode)
}
+ // Check if AI service is available
+ if (isAiEnabled) {
+ console.log('AI service route:', discovery?.services?.ai?.route)
+ }
+
return
Server: {discovery?.name}
}
```
@@ -139,7 +144,7 @@ function MyComponent() {
| `version` | `string` | Server version |
| `mode` | `string` | Runtime mode (e.g. `'development'`, `'production'`, `'preview'`) |
| `previewMode` | `object` | Preview mode configuration (present when mode is `'preview'`) |
-| `services` | `object` | Service availability status (auth, data, metadata) |
+| `services` | `object` | Service availability status (auth, data, metadata, ai) |
| `capabilities` | `string[]` | API capabilities |
The `previewMode` object contains:
diff --git a/packages/react/src/hooks/__tests__/useDiscovery.test.tsx b/packages/react/src/hooks/__tests__/useDiscovery.test.tsx
index 1c85ab36..95ea32ae 100644
--- a/packages/react/src/hooks/__tests__/useDiscovery.test.tsx
+++ b/packages/react/src/hooks/__tests__/useDiscovery.test.tsx
@@ -122,6 +122,104 @@ describe('useDiscovery', () => {
expect(result.current.isAuthEnabled).toBe(false);
});
+ it('isAiEnabled defaults to false when no discovery', async () => {
+ const { result } = renderHook(() => useDiscovery());
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.isAiEnabled).toBe(false);
+ });
+
+ it('isAiEnabled is true when ai service is enabled and available', async () => {
+ const discoveryData = {
+ services: {
+ ai: { enabled: true, status: 'available' as const, route: '/api/v1/ai' },
+ },
+ };
+
+ const dataSource = {
+ getDiscovery: vi.fn().mockResolvedValue(discoveryData),
+ };
+
+ const { result } = renderHook(() => useDiscovery(), {
+ wrapper: createWrapper(dataSource),
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.isAiEnabled).toBe(true);
+ });
+
+ it('isAiEnabled is false when ai service is enabled but unavailable', async () => {
+ const discoveryData = {
+ services: {
+ ai: { enabled: true, status: 'unavailable' as const },
+ },
+ };
+
+ const dataSource = {
+ getDiscovery: vi.fn().mockResolvedValue(discoveryData),
+ };
+
+ const { result } = renderHook(() => useDiscovery(), {
+ wrapper: createWrapper(dataSource),
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.isAiEnabled).toBe(false);
+ });
+
+ it('isAiEnabled is false when ai service is disabled', async () => {
+ const discoveryData = {
+ services: {
+ ai: { enabled: false, status: 'available' as const },
+ },
+ };
+
+ const dataSource = {
+ getDiscovery: vi.fn().mockResolvedValue(discoveryData),
+ };
+
+ const { result } = renderHook(() => useDiscovery(), {
+ wrapper: createWrapper(dataSource),
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.isAiEnabled).toBe(false);
+ });
+
+ it('isAiEnabled is false when ai service has no status', async () => {
+ const discoveryData = {
+ services: {
+ ai: { enabled: true },
+ },
+ };
+
+ const dataSource = {
+ getDiscovery: vi.fn().mockResolvedValue(discoveryData),
+ };
+
+ const { result } = renderHook(() => useDiscovery(), {
+ wrapper: createWrapper(dataSource),
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.isAiEnabled).toBe(false);
+ });
+
it('cleans up on unmount (cancelled flag)', async () => {
let resolveDiscovery: (value: any) => void;
const discoveryPromise = new Promise((resolve) => {
diff --git a/packages/react/src/hooks/useDiscovery.ts b/packages/react/src/hooks/useDiscovery.ts
index 14855e65..3468e622 100644
--- a/packages/react/src/hooks/useDiscovery.ts
+++ b/packages/react/src/hooks/useDiscovery.ts
@@ -49,6 +49,13 @@ export interface DiscoveryInfo {
enabled: boolean;
status?: 'available' | 'unavailable';
};
+ /** AI service configuration */
+ ai?: {
+ enabled: boolean;
+ status?: 'available' | 'unavailable';
+ /** AI service endpoint route (e.g. '/api/v1/ai') */
+ route?: string;
+ };
[key: string]: any;
};
@@ -149,6 +156,13 @@ export function useDiscovery() {
* Defaults to true if discovery data is not available.
*/
isAuthEnabled: discovery?.services?.auth?.enabled ?? true,
+ /**
+ * Check if AI service is enabled and available on the server.
+ * Defaults to false if discovery data is not available.
+ */
+ isAiEnabled:
+ discovery?.services?.ai?.enabled === true &&
+ discovery?.services?.ai?.status === 'available',
};
}