feat(admin): build the AI Policy console (§6) — approved, spec now implemented
New admin tab "AI" (app/(main)/admin/_tabs/ai-policy.tsx): provider-class toggles, server model allow-list, BYOK provider allow-list, seats/usage (front-end for the already-real lib/ai/entitlement.ts), retrieval on/off, consent text + version bump. Real backend, not cosmetic: AiConsoleConfig persisted via config-manager (lib/ai/types.ts, ai-policy.json in the CONFIG dir). New GET/PUT /api/admin/ai/policy. Enforcement wired at every real chokepoint, not just the picker: /api/ai/server/chat checks classesEnabled.server and the model allow-list, /api/ai/retrieve checks retrievalEnabled, /api/ai/server/models filters by allow-list. GET /api/ai/policy folds classesEnabled into the classes list clients see. Resolved the spec's 3 open questions as recommended: BYOK allow-list stays client-side/advisory (wired into ai-assistant-settings.tsx's addProfile), tier picker stays cosmetic, master aiAssistantEnabled toggle stays in the existing Policy tab (this tab links to it instead of duplicating it). Defaults preserve today's behavior exactly (classesEnabled/allowlists all start empty/null) — turning this on changes nothing until an admin touches it.
This commit is contained in:
@@ -3,6 +3,7 @@ import { logger } from '@/lib/logger';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
|
||||
import { ensureConfigDir, getConfigPath, assertWritable } from './paths';
|
||||
import { DEFAULT_AI_CONSOLE_CONFIG, type AiConsoleConfig } from '@/lib/ai/types';
|
||||
|
||||
function parseEnvValue(value: string, type: string): unknown {
|
||||
switch (type) {
|
||||
@@ -26,6 +27,7 @@ function parseEnvValue(value: string, type: string): unknown {
|
||||
class ConfigManager {
|
||||
private adminConfig: Record<string, unknown> = {};
|
||||
private policyCache: SettingsPolicy = { ...DEFAULT_POLICY };
|
||||
private aiConsoleConfigCache: AiConsoleConfig = { ...DEFAULT_AI_CONSOLE_CONFIG };
|
||||
private loaded = false;
|
||||
|
||||
/** Load admin config and policy from disk. Called once at startup and on reload. */
|
||||
@@ -42,6 +44,12 @@ class ConfigManager {
|
||||
} else {
|
||||
this.policyCache = { ...DEFAULT_POLICY };
|
||||
}
|
||||
const aiConsoleConfig = await this.readJsonFile('ai-policy.json');
|
||||
this.aiConsoleConfigCache = {
|
||||
...DEFAULT_AI_CONSOLE_CONFIG,
|
||||
...aiConsoleConfig,
|
||||
classesEnabled: { ...DEFAULT_AI_CONSOLE_CONFIG.classesEnabled, ...(aiConsoleConfig?.classesEnabled as object | undefined) },
|
||||
} as AiConsoleConfig;
|
||||
this.loaded = true;
|
||||
logger.debug('ConfigManager loaded', { configKeys: Object.keys(this.adminConfig).length });
|
||||
}
|
||||
@@ -175,6 +183,27 @@ class ConfigManager {
|
||||
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current AI console config (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6).
|
||||
*/
|
||||
getAiConsoleConfig(): AiConsoleConfig {
|
||||
return this.aiConsoleConfigCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the AI console config. Writes to disk.
|
||||
*/
|
||||
async setAiConsoleConfig(config: Partial<AiConsoleConfig>): Promise<AiConsoleConfig> {
|
||||
assertWritable('update AI console config');
|
||||
this.aiConsoleConfigCache = {
|
||||
...this.aiConsoleConfigCache,
|
||||
...config,
|
||||
classesEnabled: { ...this.aiConsoleConfigCache.classesEnabled, ...(config.classesEnabled || {}) },
|
||||
};
|
||||
await this.writeJsonFile('ai-policy.json', this.aiConsoleConfigCache as unknown as Record<string, unknown>);
|
||||
return this.aiConsoleConfigCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates deprecated feature gates forward. The standalone "All Mail" view
|
||||
* (`allMailViewEnabled`) was folded into the unified "All mail" entry, so an
|
||||
|
||||
@@ -37,6 +37,14 @@ export interface AiPolicy {
|
||||
entitlement: AiEntitlement;
|
||||
/** Public-model consent text version currently in force (§7.3). Unset until P2. */
|
||||
publicConsentVersion: string | null;
|
||||
/** Mirrors AiConsoleConfig.retrievalEnabled (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6). */
|
||||
retrievalEnabled: boolean;
|
||||
/** Admin-authored consent shown once per user before first BYOK/public use.
|
||||
* Bumping the version re-prompts everyone (client tracks acceptance per version). */
|
||||
consent: { version: string; text: string } | null;
|
||||
/** Base-URL prefixes a BYOK profile's baseUrl must match. null = unrestricted
|
||||
* (today's behavior). Advisory/client-side only — see spec §6.1. */
|
||||
publicProviderAllowlist: string[] | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_AI_ENTITLEMENT: AiEntitlement = {
|
||||
@@ -52,4 +60,39 @@ export const DEFAULT_AI_POLICY: AiPolicy = {
|
||||
enabled: false,
|
||||
entitlement: { ...DEFAULT_AI_ENTITLEMENT },
|
||||
publicConsentVersion: null,
|
||||
retrievalEnabled: true,
|
||||
consent: null,
|
||||
publicProviderAllowlist: null,
|
||||
};
|
||||
|
||||
// Admin-authored console config (docs/AI-ASSISTANT-CONCEPT.md §6 /
|
||||
// docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md). Persisted via config-manager
|
||||
// (CONFIG dir - operator-authored, not runtime state like entitlement.ts's
|
||||
// seats/ledger). Read by /api/ai/policy (public) and written by
|
||||
// /api/admin/ai/policy (admin-protected).
|
||||
export interface AiConsoleConfig {
|
||||
/** Per-class admin override. A class must be BOTH infra-available
|
||||
* (server: AI_SERVER_BASE_URL set) AND not explicitly disabled here to
|
||||
* reach users. Missing entries default to true - turning this feature
|
||||
* on changes nothing until an admin touches it. */
|
||||
classesEnabled: Partial<Record<AiClass, boolean>>;
|
||||
/** null = every completion-capable model Ollama reports (today's
|
||||
* behavior, unchanged). Non-null = only these model names selectable
|
||||
* for the `server` class. */
|
||||
serverModelAllowlist: string[] | null;
|
||||
/** null = unrestricted BYOK base URLs (today's behavior, unchanged).
|
||||
* Non-null = base URL must start with one of these prefixes. */
|
||||
publicProviderAllowlist: string[] | null;
|
||||
/** Master switch for the retrieval leg (mail-content → embeddings).
|
||||
* Independent of classesEnabled.server. Defaults true. */
|
||||
retrievalEnabled: boolean;
|
||||
consent: { version: string; text: string } | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_AI_CONSOLE_CONFIG: AiConsoleConfig = {
|
||||
classesEnabled: {},
|
||||
serverModelAllowlist: null,
|
||||
publicProviderAllowlist: null,
|
||||
retrievalEnabled: true,
|
||||
consent: null,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user