65de90114a
Der Compliance-Check streamt jetzt progressive Events; der Impressum-Tab
erscheint, sobald das Thema fertig ist, statt am Ende alles auf einmal.
Additiv — das Polling fürs finale Ergebnis bleibt.
- backend: _sse.py (Queue/emit/event_generator) + Endpoint
/compliance-check/{id}/stream; _update emittiert progress,
run_agent_outputs emittiert topic (laeuft jetzt frueh nach Phase B),
Orchestrator emittiert complete/error.
- frontend: SSE-Proxy-Route + EventSource in ComplianceCheckTab merged
topic-Events in agent_outputs -> Tab erscheint progressiv.
- Tests: backend 5 passed (SSE + agent_outputs); tsc 0 neue Fehler,
vitest 2 passed, check-loc 0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
429 lines
19 KiB
TypeScript
429 lines
19 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useCallback, useRef } from 'react'
|
|
import { ComplianceResultTabs } from './ComplianceResultTabs'
|
|
import { DocumentRow } from './DocumentRow'
|
|
import { PreScanWizard, useScanContext, isContextComplete } from './PreScanWizard'
|
|
import { DOCUMENT_TYPES, type DocTypeId } from './_document_types'
|
|
import {
|
|
STORAGE_KEY_STATE, STORAGE_KEY_RESULTS, STORAGE_KEY_HISTORY,
|
|
STORAGE_KEY_CHECK_ID, countWords, initState,
|
|
type DocState, type DocsState, type HistoryEntry,
|
|
} from './_compliance_storage'
|
|
import { useCompanyOrigin } from './_useCompanyOrigin'
|
|
|
|
|
|
export function ComplianceCheckTab() {
|
|
const [docs, setDocs] = useState<DocsState>(initState)
|
|
const { companyName, setCompanyName, originDomain, setOriginDomain } = useCompanyOrigin()
|
|
const [scanContext, setScanContext] = useScanContext()
|
|
const [useAgent, setUseAgent] = useState(false)
|
|
const [tdmOverride, setTdmOverride] = useState(false)
|
|
const [tdmOverrideReason, setTdmOverrideReason] = useState('')
|
|
const [loading, setLoading] = useState(false)
|
|
const [progress, setProgress] = useState('')
|
|
const [progressPct, setProgressPct] = useState(0)
|
|
const [results, setResults] = useState<any>(() => {
|
|
if (typeof window === 'undefined') return null
|
|
try { const s = localStorage.getItem(STORAGE_KEY_RESULTS); return s ? JSON.parse(s) : null } catch { return null }
|
|
})
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [activeCheckId, setActiveCheckId] = useState<string>(() =>
|
|
typeof window !== 'undefined' ? localStorage.getItem(STORAGE_KEY_CHECK_ID) || '' : ''
|
|
)
|
|
const [history, setHistory] = useState<HistoryEntry[]>(() => {
|
|
if (typeof window === 'undefined') return []
|
|
try { return JSON.parse(localStorage.getItem(STORAGE_KEY_HISTORY) || '[]') } catch { return [] }
|
|
})
|
|
// SSE: progressive Themen-Tabs (additiv zum Polling).
|
|
const esRef = useRef<EventSource | null>(null)
|
|
React.useEffect(() => () => { try { esRef.current?.close() } catch { /* noop */ } }, [])
|
|
|
|
// Persist URLs and texts (not loading/error state)
|
|
React.useEffect(() => {
|
|
const toSave: Record<string, { url: string; text: string }> = {}
|
|
for (const [key, val] of Object.entries(docs)) {
|
|
toSave[key] = { url: val.url, text: val.text }
|
|
}
|
|
try { localStorage.setItem(STORAGE_KEY_STATE, JSON.stringify(toSave)) } catch { /* quota */ }
|
|
}, [docs])
|
|
|
|
// Resume polling if check was in progress when navigating away
|
|
React.useEffect(() => {
|
|
if (!activeCheckId || results) return
|
|
let cancelled = false
|
|
setLoading(true)
|
|
setProgress('Pruefung laeuft noch...')
|
|
const poll = async () => {
|
|
while (!cancelled) {
|
|
await new Promise(r => setTimeout(r, 3000))
|
|
try {
|
|
const res = await fetch(`/api/sdk/v1/agent/compliance-check?check_id=${activeCheckId}`)
|
|
if (!res.ok) continue
|
|
const data = await res.json()
|
|
if (data.progress) setProgress(data.progress)
|
|
if (typeof data.progress_pct === 'number') setProgressPct(data.progress_pct)
|
|
if (data.status === 'completed' && data.result) {
|
|
setResults(data.result); setProgress(''); setProgressPct(0); setLoading(false)
|
|
localStorage.setItem(STORAGE_KEY_RESULTS, JSON.stringify(data.result))
|
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
|
return
|
|
}
|
|
if (['failed', 'not_found', 'skipped_tdm'].includes(data.status)) {
|
|
if (data.status !== 'not_found') setError(data.error || (data.status === 'skipped_tdm' ? 'TDM-Vorbehalt erkannt — Crawl uebersprungen' : 'Pruefung fehlgeschlagen'))
|
|
setProgress(''); setProgressPct(0); setLoading(false); localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId(''); return
|
|
}
|
|
} catch { /* retry */ }
|
|
}
|
|
}
|
|
poll()
|
|
return () => { cancelled = true }
|
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const updateDoc = useCallback((docType: DocTypeId, patch: Partial<DocState>) => {
|
|
setDocs(prev => ({ ...prev, [docType]: { ...prev[docType], ...patch } }))
|
|
}, [])
|
|
|
|
const handleFetchText = useCallback(async (docType: DocTypeId) => {
|
|
const url = docs[docType].url.trim()
|
|
if (!url) return
|
|
|
|
updateDoc(docType, { loading: true, error: null })
|
|
try {
|
|
const res = await fetch('/api/sdk/v1/agent/extract-text', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url }),
|
|
})
|
|
if (!res.ok) {
|
|
const msg = res.status === 404
|
|
? 'Seite nicht erreichbar'
|
|
: `Fehler beim Laden (${res.status})`
|
|
throw new Error(msg)
|
|
}
|
|
const data = await res.json()
|
|
updateDoc(docType, { text: data.text || '', loading: false })
|
|
} catch (e) {
|
|
updateDoc(docType, {
|
|
loading: false,
|
|
error: e instanceof Error ? e.message : 'Text konnte nicht geladen werden',
|
|
})
|
|
}
|
|
}, [docs, updateDoc])
|
|
|
|
const handleFileUpload = useCallback(async (docType: DocTypeId, file: File) => {
|
|
// For now, read as text. PDF/DOCX parsing can be added server-side later.
|
|
const reader = new FileReader()
|
|
reader.onload = () => {
|
|
updateDoc(docType, { text: reader.result as string })
|
|
}
|
|
reader.readAsText(file)
|
|
}, [updateDoc])
|
|
|
|
// SSE: füllt agent_outputs progressiv, sobald ein Thema fertig ist.
|
|
// Das Polling unten liefert weiterhin das finale Gesamtergebnis.
|
|
const openTopicStream = useCallback((checkId: string) => {
|
|
try { esRef.current?.close() } catch { /* noop */ }
|
|
const partial: any = { results: [], agent_outputs: {} }
|
|
const es = new EventSource(
|
|
`/api/sdk/v1/agent/compliance-check/${checkId}/stream`,
|
|
)
|
|
esRef.current = es
|
|
es.onmessage = (ev) => {
|
|
try {
|
|
const data = JSON.parse(ev.data)
|
|
if (data.type === 'topic' && data.topic && data.output) {
|
|
partial.agent_outputs = {
|
|
...partial.agent_outputs, [data.topic]: data.output,
|
|
}
|
|
setResults((prev: any) =>
|
|
(prev && Array.isArray(prev.results) && prev.results.length > 0)
|
|
? prev // finales Ergebnis schon da → behalten
|
|
: { ...partial },
|
|
)
|
|
} else if (data.type === 'progress') {
|
|
if (data.msg) setProgress(data.msg)
|
|
if (typeof data.pct === 'number') setProgressPct(data.pct)
|
|
} else if (data.type === 'complete' || data.type === 'stream_close') {
|
|
try { es.close() } catch { /* noop */ }
|
|
}
|
|
} catch { /* noop */ }
|
|
}
|
|
es.onerror = () => { try { es.close() } catch { /* noop */ } }
|
|
}, [])
|
|
|
|
const filledCount = Object.values(docs).filter(d => d.url.trim() || d.text.trim()).length
|
|
|
|
const handleSubmit = async () => {
|
|
if (filledCount === 0) return
|
|
|
|
setLoading(true)
|
|
setError(null)
|
|
setResults(null)
|
|
setProgress('Compliance-Check wird gestartet...')
|
|
setProgressPct(0)
|
|
|
|
try {
|
|
const entries = DOCUMENT_TYPES
|
|
.filter(dt => docs[dt.id].url.trim() || docs[dt.id].text.trim())
|
|
.map(dt => ({
|
|
doc_type: dt.id,
|
|
label: dt.label,
|
|
url: docs[dt.id].url.trim(),
|
|
text: docs[dt.id].text.trim() || undefined,
|
|
}))
|
|
|
|
const startRes = await fetch('/api/sdk/v1/agent/compliance-check', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
documents: entries,
|
|
use_agent: useAgent,
|
|
tdm_override: tdmOverride && tdmOverrideReason.trim().length >= 10,
|
|
tdm_override_reason: tdmOverrideReason.trim(),
|
|
company_name: companyName.trim() || undefined,
|
|
origin_domain: originDomain.trim() || undefined,
|
|
// P79 — Pre-Scan-Wizard 8 Pflichtfelder; treibt MC-Scope-Filter (P72)
|
|
scan_context: scanContext,
|
|
}),
|
|
})
|
|
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
|
|
const { check_id } = await startRes.json()
|
|
if (!check_id) throw new Error('Keine Check-ID erhalten')
|
|
setActiveCheckId(check_id)
|
|
localStorage.setItem(STORAGE_KEY_CHECK_ID, check_id)
|
|
openTopicStream(check_id)
|
|
|
|
// Poll for results (max 25 min = 500 polls x 3s)
|
|
let attempts = 0
|
|
while (attempts < 500) {
|
|
await new Promise(r => setTimeout(r, 3000))
|
|
const pollRes = await fetch(`/api/sdk/v1/agent/compliance-check?check_id=${check_id}`)
|
|
if (!pollRes.ok) { attempts++; continue }
|
|
const pollData = await pollRes.json()
|
|
if (pollData.progress) setProgress(pollData.progress)
|
|
if (typeof pollData.progress_pct === 'number') setProgressPct(pollData.progress_pct)
|
|
if (pollData.status === 'completed' && pollData.result) {
|
|
setResults(pollData.result)
|
|
setProgress('')
|
|
setProgressPct(0)
|
|
localStorage.setItem(STORAGE_KEY_RESULTS, JSON.stringify(pollData.result))
|
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
|
|
|
const resultKey = `compliance-check-result-${Date.now()}`
|
|
try { localStorage.setItem(resultKey, JSON.stringify(pollData.result)) } catch { /* quota */ }
|
|
const entry: HistoryEntry = {
|
|
date: new Date().toISOString(),
|
|
docCount: entries.length,
|
|
findings: pollData.result.total_findings || 0,
|
|
resultKey,
|
|
}
|
|
const updated = [entry, ...history].slice(0, 30)
|
|
setHistory(updated)
|
|
localStorage.setItem(STORAGE_KEY_HISTORY, JSON.stringify(updated))
|
|
break
|
|
}
|
|
if (['failed', 'skipped_tdm'].includes(pollData.status)) {
|
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
|
throw new Error(pollData.error || (pollData.status === 'skipped_tdm' ? 'TDM-Vorbehalt' : 'Pruefung fehlgeschlagen'))
|
|
}
|
|
attempts++
|
|
}
|
|
if (attempts >= 500) {
|
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
|
throw new Error('Zeitlimit ueberschritten (15 Min)')
|
|
}
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
|
setProgress('')
|
|
setProgressPct(0)
|
|
try { esRef.current?.close() } catch { /* noop */ }
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const loadFromHistory = (entry: HistoryEntry) => {
|
|
if (entry.resultKey) {
|
|
try {
|
|
const saved = localStorage.getItem(entry.resultKey)
|
|
if (saved) { setResults(JSON.parse(saved)); return }
|
|
} catch { /* ignore */ }
|
|
}
|
|
try {
|
|
const last = localStorage.getItem(STORAGE_KEY_RESULTS)
|
|
if (last) setResults(JSON.parse(last))
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
const contextReady = isContextComplete(scanContext)
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Info box */}
|
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
|
<h3 className="text-sm font-semibold text-purple-900">Compliance-Check (Alle Dokumente)</h3>
|
|
<p className="text-xs text-purple-700 mt-1">
|
|
Geben Sie die URLs Ihrer Rechtstexte ein oder laden Sie die Dokumente hoch.
|
|
Das System prueft alle Pflichtangaben nach DSGVO, TDDDG, TMG und UWG.
|
|
Pflichtdokumente sind mit * markiert.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Firma + Domain (priorisiert vor extracted_profile-LLM-Inferenz) */}
|
|
<div className="bg-white border border-slate-200 rounded-lg p-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<label className="block">
|
|
<span className="block text-xs font-medium text-slate-700 mb-1">Firma</span>
|
|
<input
|
|
type="text"
|
|
value={companyName}
|
|
onChange={e => setCompanyName(e.target.value)}
|
|
placeholder="z.B. Tesla Germany GmbH"
|
|
className="w-full text-sm border border-slate-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="block text-xs font-medium text-slate-700 mb-1">Domain (Site-Origin)</span>
|
|
<input
|
|
type="url"
|
|
value={originDomain}
|
|
onChange={e => setOriginDomain(e.target.value)}
|
|
placeholder="z.B. https://www.tesla.com/de_de"
|
|
className="w-full text-sm border border-slate-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
{/* P79 Pre-Scan-Wizard — 8 Pflichtfelder zum MC-Scope-Filter (P72) */}
|
|
<PreScanWizard value={scanContext} onChange={setScanContext} />
|
|
|
|
{/* Document rows */}
|
|
<div className="space-y-2">
|
|
{DOCUMENT_TYPES.map(dt => (
|
|
<DocumentRow
|
|
key={dt.id}
|
|
label={dt.label}
|
|
docType={dt.id}
|
|
required={dt.required}
|
|
url={docs[dt.id].url}
|
|
text={docs[dt.id].text}
|
|
loading={docs[dt.id].loading}
|
|
error={docs[dt.id].error}
|
|
wordCount={countWords(docs[dt.id].text)}
|
|
onUrlChange={url => updateDoc(dt.id, { url })}
|
|
onFetchText={() => handleFetchText(dt.id)}
|
|
onTextChange={text => updateDoc(dt.id, { text })}
|
|
onFileUpload={file => handleFileUpload(dt.id, file)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Agent toggle + submit */}
|
|
<div className="flex items-center justify-between">
|
|
<button
|
|
type="button"
|
|
onClick={() => setUseAgent(!useAgent)}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
|
|
useAgent
|
|
? 'bg-emerald-100 border-emerald-300 text-emerald-800'
|
|
: 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100'
|
|
}`}
|
|
>
|
|
<span className={`w-2 h-2 rounded-full ${useAgent ? 'bg-emerald-500' : 'bg-gray-300'}`} />
|
|
{useAgent ? 'KI-Agent aktiv (alle MCs)' : 'KI-Agent aus'}
|
|
</button>
|
|
|
|
<span className="text-xs text-gray-500">
|
|
{filledCount} von {DOCUMENT_TYPES.length} Dokumenten ausgefuellt
|
|
</span>
|
|
</div>
|
|
|
|
<div className="bg-amber-50/60 border border-amber-200 rounded-lg p-3 space-y-2">
|
|
<label className="flex items-start gap-2 cursor-pointer"><input type="checkbox" checked={tdmOverride} onChange={e => setTdmOverride(e.target.checked)} className="mt-0.5 accent-amber-600" /><span className="text-xs text-amber-900"><strong>Schriftliche Crawl-Erlaubnis vorhanden</strong> — uebergeht TDM-Vorbehalte (robots.txt / ai.txt)</span></label>
|
|
{tdmOverride && <input type="text" value={tdmOverrideReason} onChange={e => setTdmOverrideReason(e.target.value)} placeholder="z.B. Auftragsbeziehung Safetykon GmbH, Email Hr. X vom 18.05.2026" className="w-full px-3 py-2 text-xs border border-amber-300 rounded bg-white" />}
|
|
{tdmOverride && tdmOverrideReason.trim().length < 10 && <p className="text-[10px] text-amber-700">Pflicht: Reason mit min. 10 Zeichen (Audit-Spur).</p>}
|
|
</div>
|
|
{/* Submit button — Wizard muss vollstaendig sein (P79) */}
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={loading || filledCount === 0 || !contextReady || (tdmOverride && tdmOverrideReason.trim().length < 10)}
|
|
title={!contextReady ? 'Pre-Scan-Wizard zuerst vollstaendig ausfuellen' : ''}
|
|
className="w-full px-4 py-3 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors text-sm flex items-center justify-center gap-2"
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
Pruefe...
|
|
</>
|
|
) : !contextReady ? (
|
|
'Pre-Scan-Wizard vollstaendig ausfuellen (oben)'
|
|
) : (
|
|
`Compliance-Check starten (${filledCount} Dokument${filledCount !== 1 ? 'e' : ''})`
|
|
)}
|
|
</button>
|
|
|
|
{/* Progress */}
|
|
{progress && (
|
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm text-purple-700 space-y-2">
|
|
<div className="flex items-center gap-3">
|
|
<svg className="animate-spin w-4 h-4 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
<span className="flex-1">{progress}</span>
|
|
<span className="text-xs font-mono text-purple-600 tabular-nums">{progressPct}%</span>
|
|
</div>
|
|
<div className="h-1.5 bg-purple-100 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-purple-500 rounded-full transition-all duration-500 ease-out"
|
|
style={{ width: `${Math.max(2, progressPct)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
|
|
)}
|
|
|
|
{/* Results — strukturierte Themen-Tabs (Impressum, …) + Roh-Checkliste */}
|
|
{results && results.results && (
|
|
<ComplianceResultTabs results={results} />
|
|
)}
|
|
|
|
{/* History */}
|
|
{history.length > 0 && (
|
|
<div className="border border-gray-200 rounded-xl p-4">
|
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Compliance-Checks</h4>
|
|
<div className="space-y-1">
|
|
{history.map((h, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => loadFromHistory(h)}
|
|
className="w-full flex items-center justify-between text-sm py-2 px-2 rounded-lg border border-gray-50 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left"
|
|
>
|
|
<span className="text-gray-600">
|
|
{new Date(h.date).toLocaleDateString('de-DE', {
|
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
|
hour: '2-digit', minute: '2-digit',
|
|
})}
|
|
</span>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xs text-gray-500">{h.docCount} Dok.</span>
|
|
<span className={`text-xs font-medium ${h.findings > 0 ? 'text-amber-600' : 'text-green-600'}`}>
|
|
{h.findings} Findings
|
|
</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|