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:
@@ -34,6 +34,7 @@ import {
|
|||||||
Bug,
|
Bug,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
Download,
|
Download,
|
||||||
|
Sparkles,
|
||||||
X,
|
X,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -66,6 +67,7 @@ import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings
|
|||||||
import { NotificationSettings } from '@/components/settings/notification-settings';
|
import { NotificationSettings } from '@/components/settings/notification-settings';
|
||||||
import { ThemesSettings } from '@/components/settings/themes-settings';
|
import { ThemesSettings } from '@/components/settings/themes-settings';
|
||||||
import { PluginsSettings } from '@/components/settings/plugins-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 { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
|
||||||
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||||
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
||||||
@@ -111,6 +113,7 @@ type Tab =
|
|||||||
| 'about_data'
|
| 'about_data'
|
||||||
| 'themes'
|
| 'themes'
|
||||||
| 'plugins'
|
| 'plugins'
|
||||||
|
| 'ai_assistant'
|
||||||
| 'debug';
|
| 'debug';
|
||||||
|
|
||||||
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
||||||
@@ -153,6 +156,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
|||||||
about_data: Info,
|
about_data: Info,
|
||||||
themes: SwatchBook,
|
themes: SwatchBook,
|
||||||
plugins: Puzzle,
|
plugins: Puzzle,
|
||||||
|
ai_assistant: Sparkles,
|
||||||
debug: Bug,
|
debug: Bug,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -234,6 +238,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
|||||||
about_data: ['settings.advanced'],
|
about_data: ['settings.advanced'],
|
||||||
themes: [],
|
themes: [],
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
ai_assistant: [],
|
||||||
debug: ['settings.advanced'],
|
debug: ['settings.advanced'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -264,6 +269,7 @@ const tabKeywords: Record<Tab, string> = {
|
|||||||
about_data: 'export import storage quota privacy backup',
|
about_data: 'export import storage quota privacy backup',
|
||||||
themes: 'custom theme css skin appearance',
|
themes: 'custom theme css skin appearance',
|
||||||
plugins: 'extensions addons',
|
plugins: 'extensions addons',
|
||||||
|
ai_assistant: 'assistant ask model llm ollama chatbot',
|
||||||
debug: 'logs developer console diagnostic',
|
debug: 'logs developer console diagnostic',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -652,6 +658,7 @@ export default function SettingsPage() {
|
|||||||
// Advanced
|
// Advanced
|
||||||
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: '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('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 }] : []),
|
...(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 === 'about_data' && <AboutDataSettings />}
|
||||||
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
||||||
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
||||||
|
{effectiveActiveTab === 'ai_assistant' && <AiAssistantSettings />}
|
||||||
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
||||||
{effectiveActiveTab.startsWith('plugin:') && (
|
{effectiveActiveTab.startsWith('plugin:') && (
|
||||||
<PluginIframeSlot
|
<PluginIframeSlot
|
||||||
|
|||||||
@@ -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.' },
|
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.' },
|
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.' },
|
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 = [
|
const RESTRICTABLE_SETTINGS = [
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -66,6 +66,14 @@ export interface FeatureGates {
|
|||||||
crossStarredViewEnabled: boolean;
|
crossStarredViewEnabled: boolean;
|
||||||
crossAllViewEnabled: boolean;
|
crossAllViewEnabled: boolean;
|
||||||
unifiedCrossAccountEnabled: 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 = {
|
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||||
@@ -92,6 +100,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
|||||||
crossStarredViewEnabled: false,
|
crossStarredViewEnabled: false,
|
||||||
crossAllViewEnabled: false,
|
crossAllViewEnabled: false,
|
||||||
unifiedCrossAccountEnabled: false,
|
unifiedCrossAccountEnabled: false,
|
||||||
|
aiAssistantEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ThemePolicy {
|
export interface ThemePolicy {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user