Assessor_2.0/src/compliance/ui/AiOverviewPanel.tsx

175 lines
12 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import { useEnginePreferences } from '../../app/engine-preferences';
import type { AiOverviewResponse, AiOverviewResult } from '../ai-overview';
import { statusPollApi } from './status-poll-client';
import { useBackendWorkActivity } from './use-backend-work-activity';
import './ai-overview.css';
import type { ReactNode } from 'react';
const assessorApi = new AssessorApiClient();
export interface AiOverviewController {
/** False while the Compliance Engine is switched off: no request, no tab. */
available: boolean;
response?: AiOverviewResponse;
loading: boolean;
generating: boolean;
error?: string;
generate(): Promise<void>;
}
export function useAiOverview(requestedProjectId: string | undefined, assessmentIds: string[] = [], scanRunIds: string[] = []): AiOverviewController {
const available = useEnginePreferences().compliance;
const projectId = available ? requestedProjectId : undefined;
const [storedResponse, setStoredResponse] = useState<{ scope: { key: string }; value: AiOverviewResponse }>();
const [loading, setLoading] = useState(false);
const [generating, setGenerating] = useState(false);
const [error, setError] = useState<string>();
const assessmentKey = [...new Set(assessmentIds)].sort().join('\u0000');
const scanRunKey = [...new Set(scanRunIds)].sort().join('\u0000');
const normalizedAssessmentIds = useMemo(() => assessmentKey ? assessmentKey.split('\u0000') : [], [assessmentKey]);
const normalizedScanRunIds = useMemo(() => scanRunKey ? scanRunKey.split('\u0000') : [], [scanRunKey]);
const scopeKey = `${projectId ?? ''}\u0001${assessmentKey}\u0001${scanRunKey}`;
const activeScope = useRef({ key: scopeKey });
if (activeScope.current.key !== scopeKey) activeScope.current = { key: scopeKey };
const requestScope = activeScope.current;
const response = storedResponse?.scope === requestScope ? storedResponse.value : undefined;
const refresh = useCallback(async (poll = false) => {
if (!projectId || (!normalizedAssessmentIds.length && !normalizedScanRunIds.length)) {
if (activeScope.current === requestScope) setStoredResponse(undefined);
return;
}
try {
const next = await (poll ? statusPollApi : assessorApi).getAiOverview(projectId, normalizedAssessmentIds, normalizedScanRunIds);
if (activeScope.current !== requestScope) return;
setStoredResponse({ scope: requestScope, value: next });
setError(undefined);
} catch (cause) {
if (activeScope.current !== requestScope) return;
setError(cause instanceof Error ? cause.message : 'AI overview could not be loaded.');
setStoredResponse(current => current?.scope === requestScope && ['queued', 'running'].includes(current.value.state)
? { ...current, value: { ...current.value } }
: current);
}
}, [normalizedAssessmentIds, normalizedScanRunIds, projectId, requestScope]);
useEffect(() => {
let active = true;
setStoredResponse(undefined);
setError(undefined);
setGenerating(false);
setLoading(true);
void refresh().finally(() => { if (active) setLoading(false); });
return () => { active = false; };
}, [refresh, scopeKey]);
useEffect(() => {
if (!response || !['queued', 'running'].includes(response.state)) return;
const timer = window.setTimeout(() => void refresh(true), response.pollAfterMs ?? 1_500);
return () => window.clearTimeout(timer);
}, [refresh, response]);
const generate = useCallback(async () => {
if (!projectId) return;
setGenerating(true);
setError(undefined);
try {
const next = await assessorApi.generateAiOverview(projectId, {
assessmentIds: normalizedAssessmentIds,
scanRunIds: normalizedScanRunIds,
idempotencyKey: createIdempotencyKey()
});
if (activeScope.current === requestScope) setStoredResponse({ scope: requestScope, value: next });
} catch (cause) {
if (activeScope.current === requestScope) {
setError(cause instanceof Error ? cause.message : 'AI overview generation could not be started.');
}
} finally {
if (activeScope.current === requestScope) setGenerating(false);
}
}, [normalizedAssessmentIds, normalizedScanRunIds, projectId, requestScope]);
// The model runs in the backend; keep one activity open across the status polls until the run ends.
useBackendWorkActivity('Generating AI overview', !response ? 'idle'
: response.state === 'queued' || response.state === 'running' ? response.state
: response.state === 'succeeded' ? 'finished' : response.state === 'failed' ? 'failed' : 'idle');
return { available, response, loading, generating, error, generate };
}
export function AiOverviewPanel({ controller, navigation }: { controller: AiOverviewController; navigation?: ReactNode }) {
const { response, loading, generating, error, generate } = controller;
const run = response?.run;
const displayedRun = run?.result ? run : response?.previousSuccessfulRun;
const result = displayedRun?.result;
const active = response && ['queued', 'running'].includes(response.state);
const staleState = response?.stale && run?.status === 'succeeded';
return <div className="dashboard-page client-command-center ai-overview-page">
{navigation}
<header className="ai-overview-header">
<div><span>Advisory analysis</span><h1>AI Overview</h1><p>Generated from the complete privacy-safe export for this assessment timeslot.</p></div>
<div className="ai-overview-actions">
{run && <span className={`ai-overview-state is-${staleState ? 'stale' : run.status}`}>{staleState ? 'Stale' : run.status}</span>}
{response?.generationEnabled && <button className="btn primary" type="button" disabled={Boolean(active) || generating}
onClick={() => void generate()}>{result || run ? 'Regenerate' : 'Generate'}</button>}
</div>
</header>
{response?.stale && result && <p className="ai-overview-notice">The assessment data, model, or analysis contract changed. This saved overview remains visible until you regenerate it.</p>}
{error && <p className="ai-overview-error" role="alert">{error}</p>}
{run?.status === 'failed' && result && <p className="ai-overview-error" role="alert">{run.errorMessage ?? 'Generation failed.'} Showing the previous saved overview.</p>}
{(loading || active || generating) && !result && <section className="ai-overview-empty"><h2>{active ? 'Generation in progress' : 'Loading AI Overview'}</h2><p>{active ? 'The server is analyzing the complete timeslot. You can leave this tab while it runs.' : 'Loading the latest saved analysis…'}</p></section>}
{!loading && !result && !active && <section className="ai-overview-empty">
<h2>{run?.status === 'failed' ? 'Generation failed' : 'No AI Overview yet'}</h2>
<p>{run?.errorMessage ?? (response?.generationEnabled
? 'Generate an advisory summary from the privacy-safe assessment export.'
: 'Work LiteLLM is not configured on this server.')}</p>
</section>}
{result && <AiOverviewResultView result={result} generatedAt={displayedRun?.completedAt} model={displayedRun?.model} />}
</div>;
}
export function AiOverviewResultView({ result, generatedAt, model }: { result: AiOverviewResult; generatedAt?: string; model?: string }) {
return <div className="ai-overview-content">
<section className="ai-overview-executive">
<div><span>Executive summary</span><p>{result.executiveSummary}</p></div>
<dl><div><dt>Risk</dt><dd>{result.assessment.riskLevel}</dd></div><div><dt>Confidence</dt><dd>{result.assessment.confidence}</dd></div></dl>
<p>{result.assessment.rationale}</p>
</section>
<AiSection title="Environment overview"><p>{result.environmentOverview.summary}</p>{result.environmentOverview.factsUsed.length > 0 && <TagList values={result.environmentOverview.factsUsed} />}</AiSection>
<AiSection title="Security themes"><div className="ai-overview-grid">{result.themes.map(theme => <article key={theme.id} className={`ai-overview-theme tone-${theme.riskLevel}`}><small>{theme.id} · {theme.riskLevel} · {theme.confidence} confidence</small><h3>{theme.title}</h3><p>{theme.summary}</p><ReferenceList values={theme.findingIds}/></article>)}</div></AiSection>
<AiSection title="Priorities"><ol className="ai-overview-priorities">{result.priorities.map(priority => <li key={priority.rank}><b>{priority.rank}</b><div><small>{priority.timeframe.replace('_', ' ')} · {priority.owner}</small><h3>{priority.title}</h3><p>{priority.rationale}</p><dl><div><dt>Expected reduction</dt><dd>{priority.expectedRiskReduction}</dd></div><div><dt>Validation</dt><dd>{priority.validation}</dd></div></dl><ReferenceList values={priority.findingIds}/></div></li>)}</ol></AiSection>
<AiSection title="Five-phase roadmap"><div className="ai-overview-roadmap">{result.roadmap.map(phase => <article key={phase.phase}><b>{phase.phase}</b><h3>{phase.title}</h3><h4>Tasks</h4><TextList values={phase.tasks}/><h4>Completion criteria</h4><TextList values={phase.completionCriteria}/></article>)}</div></AiSection>
<div className="ai-overview-split">
<AiSection title="Coverage gaps"><div className="ai-overview-stack">{result.coverageGaps.length ? result.coverageGaps.map(gap => <article key={`${gap.title}:${gap.evidenceIds.join('|')}`}><h3>{gap.title}</h3><p>{gap.impact}</p><strong>Next step</strong><p>{gap.nextStep}</p><ReferenceList values={gap.evidenceIds}/></article>) : <p className="ai-overview-none">No coverage gaps identified.</p>}</div></AiSection>
<AiSection title="Quick wins"><div className="ai-overview-stack">{result.quickWins.length ? result.quickWins.map(win => <article key={`${win.title}:${win.findingIds.join('|')}`}><h3>{win.title}</h3><ReferenceList values={win.findingIds}/></article>) : <p className="ai-overview-none">No quick wins identified.</p>}</div></AiSection>
</div>
<div className="ai-overview-split">
<AiSection title="Questions"><CollapsibleTextList values={result.questions} noun="questions"/></AiSection>
<AiSection title="Assumptions"><CollapsibleTextList values={result.assumptions} noun="assumptions"/></AiSection>
</div>
<footer className="ai-overview-footer">Advisory AI output · Schema {result.schemaVersion}{model ? ` · ${model}` : ''}{generatedAt ? ` · ${new Date(generatedAt).toLocaleString('en-US')}` : ''}</footer>
</div>;
}
function AiSection({ title, children }: { title: string; children: ReactNode }) {
return <section className="ai-overview-section"><h2>{title}</h2>{children}</section>;
}
function TextList({ values }: { values: string[] }) { return values.length ? <ul>{values.map((value, index) => <li key={`${index}:${value}`}>{value}</li>)}</ul> : <p className="ai-overview-none">No supported items.</p>; }
/** Questions and assumptions have no count limit; the first entries stay visible and the rest expand on request. */
const COLLAPSED_LIST_ENTRIES = 10;
function CollapsibleTextList({ values, noun }: { values: string[]; noun: string }) {
const [expanded, setExpanded] = useState(false);
const collapsible = values.length > COLLAPSED_LIST_ENTRIES;
return <>
<TextList values={collapsible && !expanded ? values.slice(0, COLLAPSED_LIST_ENTRIES) : values}/>
{collapsible && <button className="btn compact ai-overview-list-toggle" type="button" aria-expanded={expanded} onClick={() => setExpanded(current => !current)}>
{expanded ? `Show first ${COLLAPSED_LIST_ENTRIES}` : `Show all ${values.length} ${noun}`}
</button>}
</>;
}
function TagList({ values }: { values: string[] }) { return <ul className="ai-overview-tags">{values.map(value => <li key={value}>{value}</li>)}</ul>; }
function ReferenceList({ values }: { values: string[] }) { return values.length ? <p className="ai-overview-refs">Evidence: {values.join(', ')}</p> : null; }
function createIdempotencyKey(): string { return globalThis.crypto?.randomUUID?.() ?? `request-${Date.now()}-${Math.random().toString(36).slice(2)}`; }