feat(ai): P0 client scaffolding — capability flags, settings pane, policy fetch

Per docs/AI-ASSISTANT-CONCEPT.md §12, P0 is deliberately generation-free:
prove platform gating and the policy round trip before any model exists
behind it. No provider is called anywhere in this change.

- lib/platform-capabilities.ts: supportsLocalLlm/localLlmNeedsCorsSetup,
  mirroring the same-named module in vncmail-native so the capability
  contract (§3, §11) reads identically on both clients. Web+Electron only
  here — mobile is a separate codebase.
- lib/ai/types.ts: AiPolicy/AiEntitlement schema, locked in now per decision
  #4 (entitlement from day one — cheap now, a live-tenant migration later).
- app/api/ai/policy/route.ts: GET, unauthenticated (users read this, like
  /api/admin/policy). Composes the real FeatureGates.aiAssistantEnabled
  toggle with a hardcoded unlicensed entitlement — there's no seats/billing
  backend yet (P2), so nothing here can honestly claim otherwise.
- components/settings/ai-assistant-settings.tsx: fetches that policy, shows
  a real (not fake) locked/unlicensed state. No model config UI yet — there
  is nothing real to configure until P1/P2/P5 land.
- New admin FeatureGates.aiAssistantEnabled (default false, like
  pluginsEnabled): the tab is entirely hidden until an admin opts in, so no
  existing install suddenly sees a tab that does nothing.

Verified: typecheck clean, lint clean, translations test passes (48/48),
full production build succeeds with /api/ai/policy compiled in.
This commit is contained in:
Bernd Rodler
2026-08-05 22:02:40 +02:00
parent cfe8ca96e1
commit 2a35019b21
7 changed files with 196 additions and 0 deletions
+8
View File
@@ -34,6 +34,7 @@ import {
Bug,
SwatchBook,
Download,
Sparkles,
X,
type LucideIcon,
} from 'lucide-react';
@@ -66,6 +67,7 @@ import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings
import { NotificationSettings } from '@/components/settings/notification-settings';
import { ThemesSettings } from '@/components/settings/themes-settings';
import { PluginsSettings } from '@/components/settings/plugins-settings';
import { AiAssistantSettings } from '@/components/settings/ai-assistant-settings';
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
@@ -111,6 +113,7 @@ type Tab =
| 'about_data'
| 'themes'
| 'plugins'
| 'ai_assistant'
| 'debug';
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
@@ -153,6 +156,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
about_data: Info,
themes: SwatchBook,
plugins: Puzzle,
ai_assistant: Sparkles,
debug: Bug,
};
@@ -234,6 +238,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
about_data: ['settings.advanced'],
themes: [],
plugins: [],
ai_assistant: [],
debug: ['settings.advanced'],
};
@@ -264,6 +269,7 @@ const tabKeywords: Record<Tab, string> = {
about_data: 'export import storage quota privacy backup',
themes: 'custom theme css skin appearance',
plugins: 'extensions addons',
ai_assistant: 'assistant ask model llm ollama chatbot',
debug: 'logs developer console diagnostic',
};
@@ -652,6 +658,7 @@ export default function SettingsPage() {
// Advanced
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup }] : []),
...(isFeatureEnabled('aiAssistantEnabled') ? [{ id: 'ai_assistant' as Tab, label: 'AI Assistant', icon: tabIcons.ai_assistant, group: 'advanced' as TabGroup }] : []),
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
];
@@ -777,6 +784,7 @@ export default function SettingsPage() {
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
{effectiveActiveTab === 'themes' && <ThemesSettings />}
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
{effectiveActiveTab === 'ai_assistant' && <AiAssistantSettings />}
{effectiveActiveTab === 'debug' && <DebugSettings />}
{effectiveActiveTab.startsWith('plugin:') && (
<PluginIframeSlot
+1
View File
@@ -28,6 +28,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
aiAssistantEnabled: { label: 'AI Assistant (preview)', description: 'Show the AI Assistant settings tab. No provider is wired up yet (see docs/AI-ASSISTANT-CONCEPT.md) — enabling this only proves the settings pane and policy fetch, it does not grant access to any model.' },
};
const RESTRICTABLE_SETTINGS = [
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger';
import { DEFAULT_AI_ENTITLEMENT, type AiPolicy } from '@/lib/ai/types';
/**
* GET /api/ai/policy - AI Assistant policy (NOT admin-protected - users read this)
*
* P0 stub (docs/AI-ASSISTANT-CONCEPT.md §12): proves the client<->server
* policy-fetch plumbing end-to-end with no provider ever called. `enabled`
* mirrors the admin FeatureGates toggle; entitlement is hardcoded unlicensed
* until P2 wires a real seats/billing backend (§9) - there is no provider
* class to grant yet regardless of what an entitlement record might say.
*/
export async function GET() {
try {
await configManager.ensureLoaded();
const policy = configManager.getPolicy();
const aiPolicy: AiPolicy = {
enabled: policy.features.aiAssistantEnabled,
entitlement: { ...DEFAULT_AI_ENTITLEMENT },
publicConsentVersion: null,
};
return NextResponse.json(aiPolicy, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('AI policy read error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -0,0 +1,76 @@
'use client';
import { useEffect, useState } from 'react';
import { Sparkles, Loader2 } from 'lucide-react';
import { SettingsSection, SettingItem } from './settings-section';
import { apiFetch } from '@/lib/browser-navigation';
import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types';
import { supportsLocalLlm, localLlmNeedsCorsSetup } from '@/lib/platform-capabilities';
/**
* P0 scope only (docs/AI-ASSISTANT-CONCEPT.md §12): proves capability
* gating and the policy-fetch round trip. No provider is called from here —
* that's P1 (server class) onward. Once entitlement is real (P2), this pane
* grows the Model/Scope/Index sections from §4.
*/
export function AiAssistantSettings() {
const [policy, setPolicy] = useState<AiPolicy>(DEFAULT_AI_POLICY);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await apiFetch('/api/ai/policy');
if (res.ok && !cancelled) {
setPolicy(await res.json());
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<div className="space-y-6">
<SettingsSection
title="AI Assistant"
description="Ask questions about your mail, answered by a model you or your admin choose. In preview — see below."
>
<SettingItem label="Status">
{loading ? (
<span className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Checking availability
</span>
) : policy.entitlement.licensed ? (
<span className="text-sm text-muted-foreground">
Licensed ({policy.entitlement.tier}) no model provider is configured yet.
</span>
) : (
<span className="text-sm text-muted-foreground">Not yet licensed for this account.</span>
)}
</SettingItem>
</SettingsSection>
<SettingsSection
title="What's coming"
description="This tab exists ahead of the feature so the settings surface and platform gating are proven before any model is wired in."
>
<div className="flex items-start gap-3 rounded-lg border border-border p-4">
<Sparkles className="w-4 h-4 mt-0.5 text-muted-foreground shrink-0" />
<p className="text-sm text-muted-foreground">
Local, VNC-hosted, and bring-your-own-key providers are planned (see the AI Assistant
concept doc). {supportsLocalLlm()
? localLlmNeedsCorsSetup()
? 'A local runtime will need its CORS setting adjusted to allow this browser origin.'
: 'This desktop app can reach a local runtime with no extra setup.'
: null}
</p>
</div>
</SettingsSection>
</div>
);
}
+9
View File
@@ -66,6 +66,14 @@ export interface FeatureGates {
crossStarredViewEnabled: boolean;
crossAllViewEnabled: boolean;
unifiedCrossAccountEnabled: boolean;
/**
* Master admin switch for the AI Assistant tab (docs/AI-ASSISTANT-CONCEPT.md).
* Defaults false, like pluginsEnabled — unlike every other gate, this one
* fronts a feature with no licensed provider class behind it yet (P1/P2 of
* that doc's phased rollout), so an admin opts in explicitly rather than
* every existing install suddenly showing a tab that does nothing.
*/
aiAssistantEnabled: boolean;
}
export const DEFAULT_FEATURE_GATES: FeatureGates = {
@@ -92,6 +100,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
crossStarredViewEnabled: false,
crossAllViewEnabled: false,
unifiedCrossAccountEnabled: false,
aiAssistantEnabled: false,
};
export interface ThemePolicy {
+40
View File
@@ -0,0 +1,40 @@
// Shared client/server contract for the AI Assistant feature.
// docs/AI-ASSISTANT-CONCEPT.md §9 (entitlement), §11 (client shape), §12 (P0).
//
// P0 scope only: this file defines the schema so it never needs a breaking
// migration later (decision #4 — entitlement from day one, cheap now). No
// provider class is implemented behind it yet; see the doc's phase table.
export type AiClass = 'local' | 'server' | 'public';
export interface AiEntitlement {
licensed: boolean;
subject: 'user' | 'tenant';
tier: 'base' | 'standard' | 'pro';
classes: AiClass[];
expiresAt: string | null;
graceUntil: string | null;
}
export interface AiPolicy {
/** Admin FeatureGates.aiAssistantEnabled — the tab is hidden entirely below this. */
enabled: boolean;
entitlement: AiEntitlement;
/** Public-model consent text version currently in force (§7.3). Unset until P2. */
publicConsentVersion: string | null;
}
export const DEFAULT_AI_ENTITLEMENT: AiEntitlement = {
licensed: false,
subject: 'tenant',
tier: 'base',
classes: [],
expiresAt: null,
graceUntil: null,
};
export const DEFAULT_AI_POLICY: AiPolicy = {
enabled: false,
entitlement: { ...DEFAULT_AI_ENTITLEMENT },
publicConsentVersion: null,
};
+29
View File
@@ -0,0 +1,29 @@
// Single source of truth for "this feature only exists on some platforms" —
// mirrors the same-named module in vncmail-native (mobile), so the AI
// Assistant capability contract (docs/AI-ASSISTANT-CONCEPT.md §3, §11)
// reads identically across both clients.
import { isElectronShell } from '@/lib/electron-bridge';
/**
* The `local` provider class (a loopback Ollama-compatible runtime) needs a
* host process reachable on 127.0.0.1. Electron's main process can fetch
* loopback directly, no CORS constraint. A browser page can too — localhost
* is a trustworthy origin so mixed-content doesn't block it — but only if
* the runtime's own CORS allowlist permits this origin (see
* localLlmNeedsCorsSetup below). Mobile has neither the runtime nor the RAM
* and is a separate codebase (vncmail-native), not reachable from here.
*/
export function supportsLocalLlm(): boolean {
return true; // web or Electron — this codebase is never mobile
}
/**
* True only for the plain-browser case: Electron reaches loopback from its
* main process with no CORS involved at all, so this is specifically the
* "advise the user to set OLLAMA_ORIGINS" case (docs/AI-ASSISTANT-CONCEPT.md
* §3's note under the platform matrix), not a general capability check.
*/
export function localLlmNeedsCorsSetup(): boolean {
return !isElectronShell();
}