diff --git a/app/(main)/[locale]/settings/page.tsx b/app/(main)/[locale]/settings/page.tsx index 7f94dc6b..3735c161 100644 --- a/app/(main)/[locale]/settings/page.tsx +++ b/app/(main)/[locale]/settings/page.tsx @@ -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 = { about_data: Info, themes: SwatchBook, plugins: Puzzle, + ai_assistant: Sparkles, debug: Bug, }; @@ -234,6 +238,7 @@ const tabSearchPaths: Record = { about_data: ['settings.advanced'], themes: [], plugins: [], + ai_assistant: [], debug: ['settings.advanced'], }; @@ -264,6 +269,7 @@ const tabKeywords: Record = { 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' && } {effectiveActiveTab === 'themes' && } {effectiveActiveTab === 'plugins' && } + {effectiveActiveTab === 'ai_assistant' && } {effectiveActiveTab === 'debug' && } {effectiveActiveTab.startsWith('plugin:') && ( 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 }); + } +} diff --git a/components/settings/ai-assistant-settings.tsx b/components/settings/ai-assistant-settings.tsx new file mode 100644 index 00000000..324aa083 --- /dev/null +++ b/components/settings/ai-assistant-settings.tsx @@ -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(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 ( +
+ + + {loading ? ( + + Checking availability… + + ) : policy.entitlement.licensed ? ( + + Licensed ({policy.entitlement.tier}) — no model provider is configured yet. + + ) : ( + Not yet licensed for this account. + )} + + + + +
+ +

+ 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} +

+
+
+
+ ); +} diff --git a/lib/admin/types.ts b/lib/admin/types.ts index cbec3aab..741ab218 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -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 { diff --git a/lib/ai/types.ts b/lib/ai/types.ts new file mode 100644 index 00000000..eba9b2f4 --- /dev/null +++ b/lib/ai/types.ts @@ -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, +}; diff --git a/lib/platform-capabilities.ts b/lib/platform-capabilities.ts new file mode 100644 index 00000000..53da5b0d --- /dev/null +++ b/lib/platform-capabilities.ts @@ -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(); +}