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', }; }