Skip to content

Commit e301475

Browse files
os-zhuangclaude
andauthored
feat(console): hide the AI surface at runtime when the server serves no AI (Community Edition) (#1987)
* feat(console): hide the AI surface at runtime when the server serves no AI agent (Community Edition) A self-host Community Edition runtime (framework + this MIT console, WITHOUT the closed `@objectstack/service-ai-studio` package) serves no `ask`/`build` agent. The console must hide its AI UI via runtime, server-pushed gating — no build-time edition flag, no tree-shake, no relicensing; the AI code stays MIT. Several entry points rendered unconditionally and `/ai` could dead-end on an agent-less echo chat. This completes the gating. Key correctness point — gate on the AGENT CATALOG, not discovery: the `ask`/`build` agent personas moved to the cloud-only service-ai-studio, but the open-source framework keeps a HEADLESS `@objectstack/service-ai` that still `registerService('ai')`s. So a CE runtime reports `services.ai` as available in `/api/v1/discovery` (verified live: enabled=true, status=available) while the agent catalog is empty. Gating on `isAiEnabled` would leave the FAB / "Ask AI" visible with nothing to talk to. `GET /api/v1/ai/agents` (non-empty) is the real "is there an agent to answer?" signal — and what the Home CTAs already use. - Add `useAiSurfaceEnabled()` — one source of truth, driven by the agent catalog (`useAgents`). Handles the "fetch not started yet" frame so route guards don't flash a redirect. Replaces the duplicated FAB checks. - Add `RequireAiSurface` route guard; wrap the `/ai*` routes in App.tsx. When no agent is served it redirects to /home (waits for the catalog first). Cloud installs pass straight through. - Gate the top-bar AI link (AppHeader) and the two designer "Ask AI" buttons (ObjectFormCanvas) on the same signal. - AiChatPage: render a graceful "AI unavailable" state (Back-to-home + Retry on error) when the catalog resolves empty, instead of the autoResponse echo chat. - ConsoleLayout / HomeLayout FAB now use the shared signal; HomePage reuses the shared `resolveAiApiBase`. Verified live against a real framework runtime: with services.ai reporting AVAILABLE but an empty agent catalog, the FAB, top-bar link and Home CTAs are all hidden and /ai redirects to home; with an agent served, the FAB shows and /ai loads the real chat. Plus unit tests for the catalog signal (incl. the not-yet-fetched latch), the guard redirect, and the designer-button hiding. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: add changeset for CE AI runtime gating Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e575da0 commit e301475

14 files changed

Lines changed: 475 additions & 90 deletions

File tree

.changeset/ce-ai-runtime-gating.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@object-ui/app-shell": minor
3+
---
4+
5+
feat(console): hide the AI surface at runtime when the server serves no AI agent (Community Edition)
6+
7+
A self-host Community Edition runtime (framework + this MIT console, without the
8+
cloud `@objectstack/service-ai-studio` package) serves no `ask`/`build` agent.
9+
The console now hides every AI entry point via runtime, server-pushed gating —
10+
no build-time edition flag, no tree-shake.
11+
12+
Crucially, gating is driven off the **agent catalog** (`GET /api/v1/ai/agents`),
13+
not the discovery `services.ai` flag: the open-source framework keeps a headless
14+
`@objectstack/service-ai` that still reports `services.ai` as available, so a CE
15+
runtime can report AI "available" while serving zero agents. The catalog is the
16+
real "is there an agent to answer?" signal.
17+
18+
- New `useAiSurfaceEnabled()` hook + `RequireAiSurface` route guard (exported).
19+
- `/ai*` routes redirect to home when no agent is served; the FAB, top-bar AI
20+
link and the metadata designers' "Ask AI" buttons hide; `AiChatPage` shows a
21+
graceful "AI unavailable" state instead of an agent-less echo chat.
22+
- Fully additive for cloud installs — when an agent is served, every AI surface
23+
renders and works as before.

apps/console/src/App.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
ConsoleShell,
2424
ConnectedShell,
2525
RequireOrganization,
26+
RequireAiSurface,
2627
SystemRedirect,
2728
LoadingFallback,
2829
ConsoleToaster,
@@ -263,20 +264,27 @@ export function App() {
263264
back-compat redirects: bare `/ai` → the default agent surface, and
264265
a single-segment `/ai/:agent` that is actually a legacy bare
265266
conversation id → `/ai/:agent/:conversationId`.
267+
268+
`RequireAiSurface` keeps these from dead-ending on a runtime that
269+
serves no AI (Community Edition): with AI unavailable a stale
270+
bookmark / external link redirects to home instead of mounting a
271+
chat with no agent to talk to. Cloud installs pass straight
272+
through. Purely additive runtime gating — same server signal the
273+
floating-chat FAB has always used.
266274
*/}
267275
<Route path="/ai" element={
268276
<ProtectedRoute>
269-
<DefaultAiChatPage />
277+
<RequireAiSurface><DefaultAiChatPage /></RequireAiSurface>
270278
</ProtectedRoute>
271279
} />
272280
<Route path="/ai/:agent" element={
273281
<ProtectedRoute>
274-
<DefaultAiChatPage />
282+
<RequireAiSurface><DefaultAiChatPage /></RequireAiSurface>
275283
</ProtectedRoute>
276284
} />
277285
<Route path="/ai/:agent/:conversationId" element={
278286
<ProtectedRoute>
279-
<DefaultAiChatPage />
287+
<RequireAiSurface><DefaultAiChatPage /></RequireAiSurface>
280288
</ProtectedRoute>
281289
} />
282290
<Route path="/apps/:appName/*" element={

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { SchemaRendererProvider } from '@object-ui/react';
1818
import { createObjectStackUserStateAdapter } from '@object-ui/data-objectstack';
1919
import { AdapterProvider, useAdapter } from '../providers/AdapterProvider';
2020
import { MetadataProvider, useMetadata } from '../providers/MetadataProvider';
21+
import { useAiSurfaceEnabled } from '../hooks/useAiSurface';
2122
import { PreviewModeProvider } from '../preview/PreviewModeContext';
2223
import { NavigationProvider } from '../context/NavigationContext';
2324
import { FavoritesProvider } from '../context/FavoritesProvider';
@@ -182,6 +183,31 @@ export function RequireOrganization({ children }: { children: ReactNode }) {
182183
return <>{children}</>;
183184
}
184185

186+
/**
187+
* RequireAiSurface — gate for the `/ai*` routes. The console is edition-agnostic
188+
* (MIT, runtime-gated): a Community Edition runtime serves no AI agent, so a
189+
* stale `/ai` bookmark or external link must not land on a broken/empty chat.
190+
* When the server serves no agent it redirects to `redirectTo` (home) instead.
191+
*
192+
* Availability is the live agent catalog (see {@link useAiSurfaceEnabled}) — the
193+
* same signal every other AI entry point now gates on, so the route is reachable
194+
* from exactly the entry points that are visible (no shown CTA that bounces back
195+
* to home, no hidden FAB to a working route). Waits for the catalog to resolve
196+
* before deciding so the redirect never flashes on first paint.
197+
*/
198+
export function RequireAiSurface({
199+
children,
200+
redirectTo = '/home',
201+
}: {
202+
children: ReactNode;
203+
redirectTo?: string;
204+
}) {
205+
const { enabled, isLoading } = useAiSurfaceEnabled();
206+
if (isLoading) return <LoadingFallback />;
207+
if (!enabled) return <Navigate to={redirectTo} replace />;
208+
return <>{children}</>;
209+
}
210+
185211
/**
186212
* AuthenticatedRoute — convenience wrapper composing AuthGuard + ConnectedShell
187213
* (+ optional RequireOrganization). Covers the common case for protected
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
4+
import { RequireAiSurface } from '../ConsoleShell';
5+
import { useAiSurfaceEnabled } from '../../hooks/useAiSurface';
6+
7+
// The guard gates purely on the AI-surface signal (the agent catalog); that
8+
// hook's own plumbing is covered by useAiSurface.test.ts.
9+
vi.mock('../../hooks/useAiSurface', () => ({
10+
useAiSurfaceEnabled: vi.fn(() => ({ enabled: true, isLoading: false })),
11+
}));
12+
const mockSurface = vi.mocked(useAiSurfaceEnabled);
13+
14+
function renderGuardedAi() {
15+
return render(
16+
<MemoryRouter initialEntries={['/ai']}>
17+
<Routes>
18+
<Route
19+
path="/ai"
20+
element={
21+
<RequireAiSurface>
22+
<div>AI CHAT</div>
23+
</RequireAiSurface>
24+
}
25+
/>
26+
<Route path="/home" element={<div>HOME</div>} />
27+
</Routes>
28+
</MemoryRouter>,
29+
);
30+
}
31+
32+
describe('RequireAiSurface', () => {
33+
beforeEach(() => {
34+
mockSurface.mockReturnValue({ enabled: true, isLoading: false });
35+
});
36+
37+
it('renders the AI surface when the server serves agents (cloud install)', () => {
38+
renderGuardedAi();
39+
expect(screen.getByText('AI CHAT')).toBeInTheDocument();
40+
expect(screen.queryByText('HOME')).not.toBeInTheDocument();
41+
});
42+
43+
it('redirects to home when no agents are served (Community Edition) — no dead-end chat', () => {
44+
mockSurface.mockReturnValue({ enabled: false, isLoading: false });
45+
renderGuardedAi();
46+
expect(screen.queryByText('AI CHAT')).not.toBeInTheDocument();
47+
expect(screen.getByText('HOME')).toBeInTheDocument();
48+
});
49+
50+
it('waits (neither chat nor redirect) while the agent catalog is still resolving', () => {
51+
mockSurface.mockReturnValue({ enabled: false, isLoading: true });
52+
renderGuardedAi();
53+
expect(screen.queryByText('AI CHAT')).not.toBeInTheDocument();
54+
expect(screen.queryByText('HOME')).not.toBeInTheDocument();
55+
});
56+
});

packages/app-shell/src/console/ai/AiChatPage.tsx

Lines changed: 109 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ import {
3131
SheetDescription,
3232
SheetHeader,
3333
SheetTitle,
34+
Empty,
35+
EmptyTitle,
36+
EmptyDescription,
3437
cn,
3538
} from '@object-ui/components';
3639
import { PanelLeft, PanelLeftClose, PanelLeftOpen, Share2 } from 'lucide-react';
@@ -493,8 +496,16 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
493496
const env = (import.meta as any).env ?? {};
494497
const envDefaultAgent = env.VITE_AI_DEFAULT_AGENT as string | undefined;
495498

496-
const { agents, isLoading: agentsLoading, error: agentsError } = useAgents({ apiBase });
499+
const { agents, isLoading: agentsLoading, error: agentsError, refetch: refetchAgents } =
500+
useAgents({ apiBase });
497501
const catalogNames = useMemo(() => agents.map((a) => a.name), [agents]);
502+
// Catalog resolved with no agent to talk to. The `/ai` route guard already
503+
// redirects when discovery reports AI unavailable (Community Edition), so this
504+
// is the secondary safety net: a deployment that reports AI enabled but serves
505+
// no agent (misconfig), a transient `/agents` failure, or a `VITE_AI_BASE_URL`
506+
// server that returns an empty list. Either way, degrade to a graceful state
507+
// instead of the agent-less echo chat (autoResponse) that ChatPane falls into.
508+
const noAgents = !agentsLoading && agents.length === 0;
498509

499510
// Is the first path segment an agent? It is when it resolves to one (friendly
500511
// alias / new id / legacy id). When it doesn't, it's a legacy bare
@@ -739,40 +750,55 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
739750
return (
740751
<div className="flex h-svh w-full flex-col bg-background" data-testid="ai-chat-page">
741752
<header className="sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background/95 px-2 backdrop-blur sm:px-4">
742-
<Button
743-
variant="ghost"
744-
size="icon"
745-
className="h-8 w-8 shrink-0 md:hidden"
746-
onClick={() => setMobileChatsOpen(true)}
747-
aria-label={t('console.ai.openChats')}
748-
data-testid="ai-chat-mobile-sidebar-trigger"
749-
>
750-
<PanelLeft className="h-4 w-4" />
751-
</Button>
752-
<Button
753-
variant="ghost"
754-
size="icon"
755-
className="hidden h-8 w-8 shrink-0 md:inline-flex"
756-
onClick={toggleChatsCollapsed}
757-
aria-label={
758-
chatsCollapsed
759-
? t('console.ai.showChats', { defaultValue: 'Show chats' })
760-
: t('console.ai.hideChats', { defaultValue: 'Hide chats' })
761-
}
762-
title={
763-
chatsCollapsed
764-
? t('console.ai.showChats', { defaultValue: 'Show chats' })
765-
: t('console.ai.hideChats', { defaultValue: 'Hide chats' })
766-
}
767-
data-testid="ai-chat-collapse-sidebar-trigger"
768-
aria-pressed={chatsCollapsed}
769-
>
770-
{chatsCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
771-
</Button>
753+
{/* Chat-list toggles are meaningless with no agent/conversations, so
754+
hide them in the graceful no-agents state. */}
755+
{!noAgents && (
756+
<>
757+
<Button
758+
variant="ghost"
759+
size="icon"
760+
className="h-8 w-8 shrink-0 md:hidden"
761+
onClick={() => setMobileChatsOpen(true)}
762+
aria-label={t('console.ai.openChats')}
763+
data-testid="ai-chat-mobile-sidebar-trigger"
764+
>
765+
<PanelLeft className="h-4 w-4" />
766+
</Button>
767+
<Button
768+
variant="ghost"
769+
size="icon"
770+
className="hidden h-8 w-8 shrink-0 md:inline-flex"
771+
onClick={toggleChatsCollapsed}
772+
aria-label={
773+
chatsCollapsed
774+
? t('console.ai.showChats', { defaultValue: 'Show chats' })
775+
: t('console.ai.hideChats', { defaultValue: 'Hide chats' })
776+
}
777+
title={
778+
chatsCollapsed
779+
? t('console.ai.showChats', { defaultValue: 'Show chats' })
780+
: t('console.ai.hideChats', { defaultValue: 'Hide chats' })
781+
}
782+
data-testid="ai-chat-collapse-sidebar-trigger"
783+
aria-pressed={chatsCollapsed}
784+
>
785+
{chatsCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
786+
</Button>
787+
</>
788+
)}
772789
<div className="min-w-0 flex-1">
773790
<AppHeader variant="home" />
774791
</div>
775792
</header>
793+
{noAgents ? (
794+
<AiUnavailable
795+
hasError={Boolean(agentsError)}
796+
onRetry={refetchAgents}
797+
onHome={() => navigate('/home')}
798+
t={t}
799+
/>
800+
) : (
801+
<>
776802
<Sheet open={mobileChatsOpen} onOpenChange={setMobileChatsOpen}>
777803
<SheetContent side="left" className="w-[320px] p-0 sm:max-w-[360px]" data-testid="ai-chat-mobile-sidebar">
778804
<SheetHeader className="sr-only">
@@ -829,6 +855,58 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
829855
/>
830856
</main>
831857
</div>
858+
</>
859+
)}
860+
</div>
861+
);
862+
}
863+
864+
/**
865+
* Graceful state for `/ai` when the agent catalog resolved empty — shown
866+
* instead of an agent-less echo chat. `hasError` distinguishes "AI not enabled
867+
* on this deployment" (Community Edition) from "couldn't reach the AI service"
868+
* (offline/misconfig), which also offers a retry. Either way there's a way out
869+
* (back to home), so the route never dead-ends.
870+
*/
871+
function AiUnavailable({
872+
hasError,
873+
onRetry,
874+
onHome,
875+
t,
876+
}: {
877+
hasError: boolean;
878+
onRetry: () => void;
879+
onHome: () => void;
880+
t: (key: string, options?: Record<string, unknown>) => string;
881+
}) {
882+
return (
883+
<div className="flex flex-1 items-center justify-center p-6" data-testid="ai-unavailable">
884+
<Empty>
885+
<EmptyTitle>
886+
{t('console.ai.unavailableTitle', { defaultValue: 'AI assistant unavailable' })}
887+
</EmptyTitle>
888+
<EmptyDescription>
889+
{hasError
890+
? t('console.ai.unavailableError', {
891+
defaultValue:
892+
"Couldn't reach the AI service. It may be temporarily offline — try again, or head back home.",
893+
})
894+
: t('console.ai.unavailableDescription', {
895+
defaultValue:
896+
"This deployment doesn't have an AI assistant enabled. Everything else works as usual.",
897+
})}
898+
</EmptyDescription>
899+
<div className="mt-6 flex flex-col items-center gap-3 sm:flex-row">
900+
{hasError && (
901+
<Button variant="outline" onClick={onRetry} data-testid="ai-unavailable-retry">
902+
{t('console.ai.unavailableRetry', { defaultValue: 'Try again' })}
903+
</Button>
904+
)}
905+
<Button onClick={onHome} data-testid="ai-unavailable-home">
906+
{t('console.ai.unavailableHome', { defaultValue: 'Back to home' })}
907+
</Button>
908+
</div>
909+
</Empty>
832910
</div>
833911
);
834912
}

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

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import React, { useEffect } from 'react';
1212
import { useNavigationContext } from '../../context/NavigationContext';
1313
import { AppHeader } from '../../layout/AppHeader';
14-
import { useDiscovery } from '@object-ui/react';
14+
import { useAiSurfaceEnabled } from '../../hooks/useAiSurface';
1515
import { useObjectTranslation } from '@object-ui/i18n';
1616

1717
// Lightweight FAB stub — the heavy chat chunk graph only downloads on
@@ -29,13 +29,11 @@ interface HomeLayoutProps {
2929

3030
export function HomeLayout({ children, userId }: HomeLayoutProps) {
3131
const { setContext } = useNavigationContext();
32-
const { isAiEnabled } = useDiscovery();
3332
const { t } = useObjectTranslation();
34-
// Render the chatbot whenever AI is reachable. If the developer has explicitly
35-
// configured `VITE_AI_BASE_URL`, trust that opt-in even when discovery
36-
// reports AI as disabled (e.g. framework started without `--preset full`).
37-
const aiBaseUrlConfigured = Boolean(import.meta.env?.VITE_AI_BASE_URL);
38-
const showChatbot = isAiEnabled || aiBaseUrlConfigured;
33+
// Render the chatbot only when the server serves AI (or an explicit
34+
// `VITE_AI_BASE_URL` opt-in is set) — same runtime signal as the rest of the
35+
// console's AI surface. See useAiSurfaceEnabled.
36+
const { enabled: showChatbot } = useAiSurfaceEnabled();
3937

4038
useEffect(() => {
4139
setContext('home');

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

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,7 @@ import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/componen
3131
import { Sparkles, ShieldAlert, X, UploadCloud, MessageSquareText } from 'lucide-react';
3232
import { useMetadataClient } from '../../views/metadata-admin/useMetadata';
3333
import { usePublishAllDrafts } from '../../preview/usePublishAllDrafts';
34-
35-
/** Resolve the AI service base, mirroring AiChatPage/ConsoleFloatingChatbot. */
36-
function resolveAiApiBase(): string {
37-
const env = (import.meta as any).env ?? {};
38-
const fromEnv = env.VITE_AI_BASE_URL as string | undefined;
39-
if (fromEnv) return fromEnv.replace(/\/$/, '');
40-
const serverUrl = (env.VITE_SERVER_URL as string | undefined) ?? '';
41-
return `${serverUrl.replace(/\/$/, '')}/api/v1/ai`;
42-
}
34+
import { resolveAiApiBase } from '../../hooks/useAiSurface';
4335

4436
/**
4537
* Which AI home CTAs to surface, driven by the live agent catalog (the single

0 commit comments

Comments
 (0)