/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * `PrivacyPanel` — local privacy controls. * * Surfaces the no-content rule from RFC §06 §7 in prose, plus three * actions the user can take any time: * * - Export the action log as a JSON file (data they can audit). * - Clear the action log. * - Edit the prompt overlay (their personal notes the assistant * sees alongside the system prompt). * * Everything here is local. Nothing here triggers a network call. * * Spec: docs/architecture/ai-customization/06-self-improvement.md §7. */ import { useEffect, useRef, useState } from 'react'; import { Brain, Download, Eraser, ScrollText, Save, Shield, X } from 'lucide-react'; import { clampOverlay, extractMemoryProposals, mergeIntoOverlay, type Flavor, type MemoryProposal, type TranscriptTurn, } from '@ifc-lite/extensions'; import { useViewerStore } from '@/store'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { useExtensionHost } from '@/sdk/ExtensionHostProvider'; import { toast } from '@/components/ui/toast'; import { HelpHint } from './HelpHint'; interface PrivacyPanelProps { onClose?: () => void; } export function PrivacyPanel({ onClose }: PrivacyPanelProps) { const host = useExtensionHost(); const [logSize, setLogSize] = useState({ events: 0, bytes: 0 }); const [activeFlavor, setActiveFlavor] = useState(); const [overlayDraft, setOverlayDraft] = useState(''); const [dirty, setDirty] = useState(false); const [busy, setBusy] = useState(false); const [proposals, setProposals] = useState([]); const chatMessages = useViewerStore((s) => s.chatMessages); // `refresh` is captured once by the long-lived `flavors.onChange` // listener, so it must read `dirty` through a ref — a closed-over // `dirty` would freeze at `false` and clobber the user's edits when // a later flavor change fires. const dirtyRef = useRef(dirty); dirtyRef.current = dirty; const refresh = async () => { try { setLogSize({ events: host.actionLog.size(), bytes: host.actionLog.byteSize() }); const flavor = await host.flavors.getActive(); setActiveFlavor(flavor); if (flavor && !dirtyRef.current) { setOverlayDraft(flavor.promptOverlay?.content ?? ''); } } catch (err) { console.warn('[PrivacyPanel] refresh failed:', err); } }; useEffect(() => { void refresh(); const offFlavor = host.flavors.onChange(() => void refresh()); const offLog = host.actionLog.subscribe(() => { setLogSize({ events: host.actionLog.size(), bytes: host.actionLog.byteSize() }); }); return () => { offFlavor(); offLog(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [host]); const handleExportLog = () => { const json = host.actionLog.exportJson(); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `ifclite-action-log-${new Date().toISOString().slice(0, 10)}.json`; a.click(); URL.revokeObjectURL(url); toast.success('Action log exported.'); }; const handleClearLog = () => { if (!confirm('Clear the local action log? Suggestions reset until you build up new patterns.')) return; host.actionLog.clear(); // Wipe the IDB mirror too — otherwise reload would resurrect the // events the user just asked to forget. void host.clearPersistedActionLog().catch((err) => { console.warn('[PrivacyPanel] clear persisted action log failed:', err); }); setLogSize({ events: 0, bytes: 0 }); toast.success('Action log cleared.'); }; const handleExtractMemory = () => { const transcript: TranscriptTurn[] = chatMessages.map((m) => ({ role: m.role === 'system' ? 'system' : (m.role as 'user' | 'assistant'), content: m.content, })); const next = extractMemoryProposals(transcript); setProposals(next); if (next.length === 0) { toast.info('No stable preferences detected in this session yet.'); } else { toast.success(`Found ${next.length} candidate preference${next.length === 1 ? '' : 's'}.`); } }; const handleAcceptProposals = () => { const next = mergeIntoOverlay(overlayDraft, proposals); setOverlayDraft(next); setDirty(true); setProposals([]); toast.success(`Added ${proposals.length} preference${proposals.length === 1 ? '' : 's'} to the overlay. Save to keep them.`); }; const handleSaveOverlay = async () => { if (!activeFlavor) { toast.error('No active flavor — switch to one before editing its overlay.'); return; } setBusy(true); try { const clamped = clampOverlay(overlayDraft, { maxTokens: 4000 }); await host.flavors.put( { ...activeFlavor, promptOverlay: clamped.overlay }, 'overlay edit', ); setOverlayDraft(clamped.overlay.content); setDirty(false); if (clamped.truncated) { toast.info(`Overlay clamped to ~${clamped.estimatedTokens} tokens.`); } else { toast.success(`Overlay saved (${clamped.estimatedTokens} tokens).`); } } catch (err) { toast.error(`Save failed: ${err instanceof Error ? err.message : String(err)}`); } finally { setBusy(false); } }; return (

Privacy

IFClite keeps a content-free action log{' '} of intents you perform (model loads, lens applies, exports) — used by the pattern miner to suggest one-click tools. The log never records model content, chat content, file names, or API keys.

The prompt overlay on the active flavor is appended to every chat system prompt — use it for stable preferences. Extract from chat{' '} scans the current session for explicit preferences and proposes them.

{onClose && ( )}

What we store locally

ifc-lite keeps a content-free action log of the high-level intents you perform (model loads, lens applies, exports). We use it to mine recurring patterns and surface one-click tool suggestions. The log never records model content, chat content, file names, or API keys.

Suggestions, the audit log, the prompt overlay, and your flavor library are all stored in your browser's IndexedDB — nothing here is sent off device unless you explicitly export.

Action log

{logSize.events} events · {(logSize.bytes / 1024).toFixed(1)} KiB

Prompt overlay

Notes appended to the AI assistant's system prompt for the active flavor. Use it for stable preferences ("write CSV exports with semicolons", "default to red color for IfcWall"). Capped at ~4000 tokens.

{!activeFlavor ? (
No active flavor. Activate or import one to attach overlay notes to it.
) : ( <>
Editing overlay for{' '} {activeFlavor.name}