Files
SRCmail/components/settings/ai-assistant-settings.tsx
T
Bernd Rodler 2a35019b21 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.
2026-08-05 22:02:40 +02:00

77 lines
2.9 KiB
TypeScript

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