feat(agent): /sdk/agent auf Compliance-Check + Snapshot-Historie reduzieren

- Tabs Website-Scan (nie funktioniert), Banner-Check, Agent-Test entfernt;
  Tab-Leiste weg, da nur noch Compliance-Check übrig.
- Unter dem Compliance-Check jetzt die Snapshot-Historie (neuer
  SnapshotHistoryList): neuester oben + farblich markiert, Klick → Detail-
  Seite mit den Ergebnissen. Macht /sdk/agent/snapshots erreichbar.
- ComplianceCheckTab zeigt nach dem Lauf keine Inline-Ergebnisse mehr,
  sondern einen Hinweis auf die Historie (onComplete refresht sie).
- Tote Komponenten gelöscht (ScanResult/BannerCheckTab/AgentTestTab).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-12 09:31:35 +02:00
parent 755ea44343
commit 1b2b030367
7 changed files with 109 additions and 1310 deletions
@@ -1,337 +0,0 @@
'use client'
/**
* AgentTestTab — Top-Level für den 5-URL-Test eines Specialist-Agents.
* Sections:
* 1. Agent-Wähler + 5 URL-Slots + Start-Button
* 2. Methodik-Erklärung (was wir tun, warum)
* 3. Live-Event-Log
* 4. Pro Slot: SlotCard (siehe AgentSlotCard.tsx)
*/
import React, { useEffect, useMemo, useRef, useState } from 'react'
import type { AgentInfo, RunResult, SlotOutput, StreamEvent } from './_agentTypes'
import { AgentSlotCard } from './AgentSlotCard'
const STORAGE_KEY = 'agent-test-state-v1'
const MAX_SLOTS = 5
export function AgentTestTab() {
const [agents, setAgents] = useState<AgentInfo[]>([])
const [agentId, setAgentId] = useState<string>('')
const [urls, setUrls] = useState<string[]>(['', '', '', '', ''])
const [running, setRunning] = useState(false)
const [runId, setRunId] = useState<string>('')
const [events, setEvents] = useState<StreamEvent[]>([])
const [result, setResult] = useState<RunResult | null>(null)
const [error, setError] = useState<string>('')
const eventSrcRef = useRef<EventSource | null>(null)
// Restore state from localStorage
useEffect(() => {
try {
const s = localStorage.getItem(STORAGE_KEY)
if (s) {
const parsed = JSON.parse(s)
if (parsed.agentId) setAgentId(parsed.agentId)
if (Array.isArray(parsed.urls)) {
const padded = [...parsed.urls.slice(0, MAX_SLOTS),
...new Array(MAX_SLOTS).fill('')].slice(0, MAX_SLOTS)
setUrls(padded)
}
}
} catch { /* noop */ }
}, [])
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY,
JSON.stringify({ agentId, urls }))
} catch { /* quota */ }
}, [agentId, urls])
// Load agents
useEffect(() => {
fetch('/api/sdk/v1/specialist-agent/agents')
.then(r => r.json())
.then(d => {
const list: AgentInfo[] = d.agents || []
setAgents(list)
if (list.length && !agentId) setAgentId(list[0].agent_id)
})
.catch(e => setError(`Agent-Liste fehlgeschlagen: ${e}`))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const startTest = async () => {
setError('')
setResult(null)
setEvents([])
const cleanUrls = urls.map(u => u.trim()).filter(Boolean)
if (!agentId) { setError('Kein Agent ausgewählt.'); return }
if (cleanUrls.length === 0) { setError('Mind. eine URL angeben.'); return }
setRunning(true)
try {
const r = await fetch('/api/sdk/v1/specialist-agent/test/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent_id: agentId, urls: cleanUrls }),
})
if (!r.ok) {
const j = await r.json().catch(() => ({}))
throw new Error(j.error || `HTTP ${r.status}`)
}
const data = await r.json()
setRunId(data.run_id)
openStream(data.run_id)
pollResult(data.run_id)
} catch (e: any) {
setError(e.message || String(e))
setRunning(false)
}
}
const openStream = (rid: string) => {
try { eventSrcRef.current?.close() } catch { /* noop */ }
const es = new EventSource(
`/api/sdk/v1/specialist-agent/test/stream/${rid}`,
)
eventSrcRef.current = es
es.onmessage = (ev) => {
try {
const data: StreamEvent = JSON.parse(ev.data)
setEvents(prev => [...prev, data])
if (data.type === 'stream_close' || data.type === 'run_complete') {
try { es.close() } catch { /* noop */ }
}
} catch { /* noop */ }
}
es.onerror = () => { try { es.close() } catch { /* noop */ } }
}
const pollResult = async (rid: string) => {
for (let i = 0; i < 360; i++) {
try {
const r = await fetch(
`/api/sdk/v1/specialist-agent/run/${rid}/result`,
)
if (r.ok) {
const d: RunResult = await r.json()
if (d.finished) {
setResult(d); setRunning(false); return
}
}
} catch { /* noop */ }
await new Promise(s => setTimeout(s, 2000))
}
setRunning(false)
}
const slotOutputs = useMemo(() => {
if (!result) return []
const items: { slot: string; output: SlotOutput }[] = []
for (const slot of Object.keys(result.results)) {
items.push({ slot, output: result.results[slot] })
}
return items.sort((a, b) => a.slot.localeCompare(b.slot))
}, [result])
const selectedAgent = agents.find(a => a.agent_id === agentId)
return (
<div className="space-y-4">
<InputCard
agents={agents}
agentId={agentId}
setAgentId={setAgentId}
selectedAgent={selectedAgent}
urls={urls}
setUrls={setUrls}
running={running}
runId={runId}
startTest={startTest}
error={error}
/>
<MethodikInfo />
{running && events.length > 0 && <EventLog events={events} />}
{slotOutputs.length > 0 && (
<div className="space-y-3">
{slotOutputs.map(({ slot, output }) => (
<AgentSlotCard
key={slot} slot={slot} output={output} runId={runId}
/>
))}
</div>
)}
</div>
)
}
function InputCard({
agents, agentId, setAgentId, selectedAgent, urls, setUrls,
running, runId, startTest, error,
}: {
agents: AgentInfo[]
agentId: string
setAgentId: (s: string) => void
selectedAgent?: AgentInfo
urls: string[]
setUrls: (urls: string[]) => void
running: boolean
runId: string
startTest: () => void
error: string
}) {
return (
<div className="rounded-lg border bg-white p-4 space-y-3 shadow-sm">
<h2 className="text-lg font-semibold">Agent-Test (max. {MAX_SLOTS} URLs)</h2>
<div className="flex flex-wrap gap-3 items-end">
<div>
<label className="block text-xs font-medium text-gray-600">Agent</label>
<select
value={agentId}
onChange={e => setAgentId(e.target.value)}
className="border rounded px-2 py-1 text-sm"
>
{agents.map(a => (
<option key={a.agent_id} value={a.agent_id}>
{a.agent_id} v{a.agent_version} ({a.mc_count} MCs)
</option>
))}
</select>
</div>
{selectedAgent && (
<div className="text-xs text-gray-500">
Doc-Type: <code>{selectedAgent.doc_type}</code>
</div>
)}
</div>
<div className="space-y-1">
{urls.map((u, i) => (
<div key={i} className="flex gap-2">
<span className="text-xs font-mono text-gray-500 w-8 pt-1.5">
URL{i + 1}
</span>
<input
value={u}
onChange={e => {
const next = [...urls]; next[i] = e.target.value
setUrls(next)
}}
placeholder="https://example.com/impressum"
className="flex-1 border rounded px-2 py-1 text-sm font-mono"
/>
</div>
))}
</div>
<div className="flex gap-2">
<button
onClick={startTest}
disabled={running}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white text-sm px-4 py-2 rounded"
>
{running ? 'Laufend...' : 'Test starten'}
</button>
{runId && (
<span className="text-xs text-gray-500 self-center">
Run-ID: <code>{runId}</code>
</span>
)}
</div>
{error && (
<div className="bg-red-50 border-l-4 border-red-400 p-2 text-sm text-red-700">
{error}
</div>
)}
</div>
)
}
function MethodikInfo() {
return (
<details className="rounded border bg-slate-50 px-3 py-2 text-xs text-gray-700">
<summary className="cursor-pointer font-semibold">
Methodik wie geprüft wird
</summary>
<ol className="list-decimal ml-5 mt-2 space-y-1">
<li>
<strong>Pattern-Checks</strong> deterministische Regex-Tests
gegen Pflichtangaben-Schema (z.B. § 5 TMG/DDG). Schnell,
reproduzierbar. <em>Hinweis:</em> diese Pattern-IDs (z.B.
<code>IMP-MC-001</code>) sind <strong>interne Test-IDs</strong>,
nicht die Master-Control-IDs aus der Datenbank. BreakPilot hat
313k Atomic-Controls 13.588 dedup. Master-Controls; davon
~1.778 für dieses Compliance-Agent-Tool ausgewählt. Die formale
Verknüpfung Pattern-Check Master-Control folgt in einem
späteren Schritt (Sprint 1.12).
</li>
<li>
<strong>Knowledge-Base</strong> kuratierte Patterns aus
anonymisierten Mandanten-FAQs.
</li>
<li>
<strong>Auto-Learning-Pattern-Library</strong> Labels die
der LLM-Validator gefunden hat (z.B. Telefonnr." statt
„Telefon") werden persistiert. Beim nächsten Run sind sie
deterministisch erkennbar der LLM wird seltener gerufen.
</li>
<li>
<strong>Semantic-Validator (LLM)</strong> nur bei
missing-Pflichtangabe: ein Aufruf des Self-Hosted-LLM
(<code>qwen3.5:35b-a3b</code> auf macmini) prüft ob die
Angabe doch da ist, nur unter abweichendem Label. Bei
Treffer wird HIGHLOW demoted und Umbenennen zu Standard"
empfohlen.
</li>
<li>
<strong>LLM-Eskalation (Fallback)</strong> — wenn der
Validator unsicher bleibt: OVH 120b, dann anonymisierter
Claude-Cloud-Call. Aktuell deaktiviert (OVH-Key leer).
</li>
<li>
<strong>Cross-Placement-Agent</strong> — erkennt deplatzierten
Content (Copyright, Disclaimer, WEEE im Impressum) +
empfiehlt Footer-Reiter „Legal".
</li>
</ol>
<p className="mt-2 italic text-gray-500">
Disclaimer: keine Aussagen wie rechtssicher" oder „konform"
nur Findings + Empfehlungen + Herleitung. Verbotene Begriffe
werden vom Linter aus Agent-Outputs entfernt.
</p>
</details>
)
}
function EventLog({ events }: { events: StreamEvent[] }) {
return (
<div className="rounded border bg-gray-50 p-3 max-h-48 overflow-y-auto">
<div className="text-xs font-mono space-y-0.5">
{events.slice(-30).map((ev, i) => (
<div key={i}>
<span className="text-gray-400">[{ev.type}]</span>{' '}
{ev.slot && <span className="text-blue-600">{ev.slot}</span>}{' '}
{ev.severity && (
<span className={severityColor(ev.severity)}>{ev.severity}</span>
)}{' '}
{ev.title || ev.error || ev.label || ev.model || ev.url || ''}
{ev.word_count !== undefined && (
<span className="text-gray-500">
{' '}({ev.word_count} Wörter)
</span>
)}
</div>
))}
</div>
</div>
)
}
function severityColor(sev: string) {
return sev === 'HIGH' ? 'text-red-600 font-semibold' :
sev === 'MEDIUM' ? 'text-amber-600 font-semibold' :
sev === 'LOW' ? 'text-blue-600' : 'text-gray-600'
}
@@ -1,374 +0,0 @@
'use client'
import React, { useState } from 'react'
import { ChecklistView } from './ChecklistView'
interface CheckItem {
id: string
label: string
passed: boolean
severity: string
matched_text: string
level?: number
parent?: string | null
skipped?: boolean
hint?: string
}
interface BannerResult {
banner_detected: boolean
banner_provider: string
banner_checks?: {
violations: { code: string; text: string; severity: string }[]
has_impressum_link?: boolean
has_dse_link?: boolean
}
structured_checks?: CheckItem[]
completeness_pct?: number
correctness_pct?: number
phases?: {
before_consent: { cookies: string[]; scripts: string[]; tracking_services: string[]; violations: any[] }
after_reject: { cookies: string[]; scripts: string[]; new_tracking: string[]; violations: any[] }
after_accept: { cookies: string[]; scripts: string[]; new_tracking: string[]; undocumented: string[] }
}
email_status?: string
}
const CATEGORIES = [
{ id: 'all', label: 'Alle Kategorien' },
{ id: 'necessary', label: 'Notwendig' },
{ id: 'statistics', label: 'Statistik' },
{ id: 'marketing', label: 'Marketing' },
{ id: 'functional', label: 'Funktional' },
{ id: 'preferences', label: 'Praeferenzen' },
]
export function BannerCheckTab() {
const [url, setUrl] = useState(() =>
typeof window !== 'undefined' ? localStorage.getItem('banner-check-url') || '' : ''
)
const [loading, setLoading] = useState(false)
const [progress, setProgress] = useState('')
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<BannerResult | null>(() => {
if (typeof window === 'undefined') return null
try { const s = localStorage.getItem('banner-check-result'); return s ? JSON.parse(s) : null } catch { return null }
})
const [categories, setCategories] = useState<string[]>(['all'])
const [useAgent, setUseAgent] = useState(false)
const [mcResults, setMcResults] = useState<any>(null)
const [history, setHistory] = useState<{ url: string; date: string; provider: string; violations: number; pct: number; resultKey: string }[]>(() => {
if (typeof window === 'undefined') return []
try { return JSON.parse(localStorage.getItem('banner-check-history') || '[]') } catch { return [] }
})
// Persist URL
React.useEffect(() => { localStorage.setItem('banner-check-url', url) }, [url])
const toggleCategory = (id: string) => {
if (id === 'all') {
setCategories(['all'])
return
}
setCategories(prev => {
const without = prev.filter(c => c !== 'all' && c !== id)
const next = prev.includes(id) ? without : [...without, id]
return next.length === 0 ? ['all'] : next
})
}
const handleScan = async (e: React.FormEvent) => {
e.preventDefault()
if (!url.trim()) return
setLoading(true)
setError(null)
setResult(null)
setProgress('Cookie-Banner wird analysiert...')
const selectedCategories = categories.includes('all') ? [] : categories
try {
const res = await fetch('/api/sdk/v1/agent/banner-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url.trim(), categories: selectedCategories }),
})
if (!res.ok) throw new Error(`Fehler: ${res.status}`)
const data = await res.json()
setResult(data)
localStorage.setItem('banner-check-result', JSON.stringify(data))
// If agent mode: also run cookie doc-check with 381 MCs
if (useAgent) {
setProgress('KI-Agent prueft Cookie-Richtlinie (381 MCs)...')
try {
const mcRes = await fetch('/api/sdk/v1/agent/doc-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entries: [{ doc_type: 'cookie', label: 'Cookie-Richtlinie', url: url.trim() }],
recipient: 'dsb@breakpilot.local',
use_agent: true,
}),
})
if (mcRes.ok) {
const { check_id } = await mcRes.json()
if (check_id) {
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 3000))
const poll = await fetch(`/api/sdk/v1/agent/doc-check?check_id=${check_id}`)
if (!poll.ok) continue
const pd = await poll.json()
if (pd.progress) setProgress(`KI-Agent: ${pd.progress}`)
if (pd.status === 'completed' && pd.result) { setMcResults(pd.result); break }
if (pd.status === 'failed') break
}
}
}
} catch { /* agent check is optional */ }
}
// Add to history with persistent result
const violations = data.structured_checks?.filter((c: CheckItem) => !c.passed && !c.skipped).length || 0
const resultKey = `banner-check-result-${Date.now()}`
try { localStorage.setItem(resultKey, JSON.stringify(data)) } catch { /* quota */ }
const entry = {
url: url.trim(),
date: new Date().toISOString(),
provider: data.banner_provider || 'Unbekannt',
violations,
pct: data.completeness_pct ?? 0,
resultKey,
}
const updated = [entry, ...history].slice(0, 30)
setHistory(updated)
localStorage.setItem('banner-check-history', JSON.stringify(updated))
} catch (e) {
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
setProgress('')
}
}
const loadFromHistory = (entry: { url: string; resultKey?: string }) => {
setUrl(entry.url)
if (entry.resultKey) {
try {
const saved = localStorage.getItem(entry.resultKey)
if (saved) { setResult(JSON.parse(saved)); return }
} catch {}
}
// Fallback: load last result
try {
const last = localStorage.getItem('banner-check-result')
if (last) setResult(JSON.parse(last))
} catch {}
}
const structuredChecks = result?.structured_checks || []
const hasStructured = structuredChecks.length > 0
const compPct = result?.completeness_pct ?? 0
const corrPct = result?.correctness_pct ?? 0
const checklistResults = hasStructured ? [{
label: `Cookie-Banner: ${result?.banner_provider || 'Unbekannt'}`,
url: url,
doc_type: 'banner',
word_count: 0,
completeness_pct: compPct,
correctness_pct: corrPct,
checks: structuredChecks,
findings_count: structuredChecks.filter(c => !c.passed && !c.skipped).length,
error: '',
}] : []
return (
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="text-sm font-semibold text-blue-900">Cookie-Banner Compliance Check</h3>
<p className="text-xs text-blue-700 mt-1">
Playwright-basierter 3-Phasen-Test: Vor Interaktion, nach Ablehnen, nach Akzeptieren.
Prueft Dark Patterns, Pre-Consent-Cookies, Farbkontrast, Klick-Paritaet und 36 weitere Kriterien.
</p>
</div>
<div className="flex items-center gap-3">
<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 (381 Cookie-MCs)' : 'KI-Agent aus'}
</button>
</div>
<form onSubmit={handleScan} className="space-y-3">
<div className="flex gap-3">
<input
type="url" value={url} onChange={e => setUrl(e.target.value)}
placeholder="https://www.example.com/"
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
disabled={loading} required
/>
<button type="submit" disabled={loading || !url.trim()}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center gap-2 text-sm font-medium whitespace-nowrap">
{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...</>
) : 'Banner pruefen'}
</button>
</div>
<div className="flex flex-wrap gap-2">
{CATEGORIES.map(cat => (
<label key={cat.id}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium cursor-pointer border transition-colors ${
categories.includes(cat.id)
? 'bg-purple-100 border-purple-300 text-purple-800'
: 'bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100'
}`}
>
<input type="checkbox" checked={categories.includes(cat.id)}
onChange={() => toggleCategory(cat.id)} className="sr-only" />
<span className={`w-3 h-3 rounded-sm border flex items-center justify-center ${
categories.includes(cat.id) ? 'bg-purple-600 border-purple-600' : 'border-gray-400'
}`}>
{categories.includes(cat.id) && (
<svg className="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 12 12">
<path d="M10 3L4.5 8.5 2 6" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
{cat.label}
</label>
))}
</div>
</form>
{progress && (
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-sm text-purple-700 flex items-center gap-3">
<svg className="animate-spin w-5 h-5 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>
{progress}
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{error}</div>
)}
{result && (
<div className="space-y-4">
{result.phases && (
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
<div className="flex items-center gap-3">
<span className="text-2xl">{result.banner_detected ? '🛡️' : '⚠️'}</span>
<div>
<h3 className="text-sm font-semibold text-gray-900">
{result.banner_detected
? `Banner erkannt: ${result.banner_provider || 'Unbekannter Anbieter'}`
: 'Kein Cookie-Banner erkannt'}
</h3>
<p className="text-xs text-gray-500 mt-0.5">3-Phasen-Analyse: Cookies und Scripts vor/nach Interaktion</p>
</div>
</div>
</div>
<div className="px-6 py-3 grid grid-cols-3 gap-4">
<PhaseBox label="Vor Consent" icon="🔒"
cookies={result.phases.before_consent.cookies?.length ?? 0}
scripts={result.phases.before_consent.scripts?.length ?? 0}
violations={result.phases.before_consent.violations?.length ?? 0} />
<PhaseBox label="Nach Ablehnen" icon="🚫"
cookies={result.phases.after_reject.cookies?.length ?? 0}
scripts={result.phases.after_reject.scripts?.length ?? 0}
violations={result.phases.after_reject.violations?.length ?? 0} />
<PhaseBox label="Nach Akzeptieren" icon="&#x2705;"
cookies={result.phases.after_accept.cookies?.length ?? 0}
scripts={result.phases.after_accept.scripts?.length ?? 0}
violations={0} />
</div>
</div>
)}
{hasStructured && (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<ChecklistView results={checklistResults} />
</div>
)}
{result.email_status && (
<div className="text-xs text-gray-500 flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${result.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
E-Mail: {result.email_status === 'sent' ? 'Gesendet' : result.email_status}
</div>
)}
{/* MC Agent Results (Cookie-Richtlinie) */}
{mcResults?.results && (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<h4 className="text-sm font-semibold text-gray-800 mb-3">KI-Agent: Cookie-Richtlinie (381 MCs)</h4>
<ChecklistView results={mcResults.results} />
</div>
)}
{!result.banner_detected && !hasStructured && (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<p className="text-sm text-gray-500">
Kein Cookie-Banner auf dieser Seite gefunden. Falls Cookies gesetzt werden, ist ein Banner nach §25 TDDDG Pflicht.
</p>
</div>
)}
</div>
)}
{/* 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 Banner-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 p-2.5 rounded-lg border border-gray-100 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900 truncate">{h.url}</div>
<div className="text-xs text-gray-500">
{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
{' · '}{h.provider}
</div>
</div>
<div className="flex items-center gap-3 shrink-0 ml-3">
<span className={`text-xs font-medium ${h.violations > 0 ? 'text-red-600' : 'text-green-600'}`}>
{h.violations} Findings
</span>
<span className={`text-xs font-medium ${h.pct === 100 ? 'text-green-700' : h.pct >= 50 ? 'text-yellow-700' : 'text-red-700'}`}>
{h.pct}%
</span>
</div>
</button>
))}
</div>
</div>
)}
</div>
)
}
function PhaseBox({ label, icon, cookies, scripts, violations }: {
label: string; icon: string; cookies: number; scripts: number; violations: number
}) {
return (
<div className="text-center">
<div className="text-lg">{icon}</div>
<div className="text-xs font-medium text-gray-700">{label}</div>
<div className="text-xs text-gray-500 mt-1">{cookies} Cookies, {scripts} Scripts</div>
{violations > 0 && <div className="text-xs text-red-600 font-medium">{violations} Verstoesse</div>}
</div>
)
}
@@ -1,7 +1,6 @@
'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'
@@ -13,7 +12,7 @@ import {
import { useCompanyOrigin } from './_useCompanyOrigin'
export function ComplianceCheckTab() {
export function ComplianceCheckTab({ onComplete }: { onComplete?: () => void } = {}) {
const [docs, setDocs] = useState<DocsState>(initState)
const { companyName, setCompanyName, originDomain, setOriginDomain } = useCompanyOrigin()
const [scanContext, setScanContext] = useScanContext()
@@ -243,21 +242,16 @@ export function ComplianceCheckTab() {
}
}
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)
// Nach Abschluss eines Checks (loading true→false mit Ergebnis) die
// Snapshot-Historie unten neu laden — der frische Snapshot erscheint oben.
const prevLoading = useRef(false)
React.useEffect(() => {
if (prevLoading.current && !loading && results) onComplete?.()
prevLoading.current = loading
}, [loading, results, onComplete])
return (
<div className="space-y-4">
{/* Info box */}
@@ -390,37 +384,12 @@ export function ComplianceCheckTab() {
<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>
{/* Nach Abschluss: Hinweis auf die Historie unten. Die eigentlichen
Ergebnisse leben in der Snapshot-Detail-Seite (oberster Eintrag). */}
{results && results.results && !loading && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3 text-sm text-green-800">
Check abgeschlossen das Ergebnis steht unten in der Historie (oberster, farblich
markierter Eintrag). Klick ihn an, um die Auswertung zu öffnen.
</div>
)}
</div>
@@ -1,325 +0,0 @@
'use client'
import React, { useState } from 'react'
import { TextReference } from './TextReference'
interface ServiceInfo {
name: string
category: string
provider: string
country: string
eu_adequate: boolean
requires_consent: boolean
legal_ref: string
in_dse: boolean
status: string
}
interface TextRef {
found: boolean
source_url: string
document_type: string
section_heading: string
section_number: string
parent_section: string
paragraph_index: number
original_text: string
issue: string
correction_type: string
correction_text: string
insert_after: string
}
interface ScanFinding {
code: string
doc_title?: string
severity: string
text: string
correction: string
text_reference: TextRef | null
}
interface DiscoveredDocument {
title: string
completeness_pct: number
word_count?: number
url?: string
}
interface ScanData {
pages_scanned: number
pages_list: string[]
services: ServiceInfo[]
findings: ScanFinding[]
discovered_documents?: DiscoveredDocument[]
ai_detected: boolean
chatbot_detected: boolean
chatbot_provider: string
missing_pages: Record<string, number>
email_status: string
}
const STATUS_ICON: Record<string, { icon: string; color: string }> = {
ok: { icon: '\u2713', color: 'text-green-600' },
undocumented: { icon: '\u2717', color: 'text-red-600' },
outdated: { icon: '~', color: 'text-yellow-600' },
}
const SEV_STYLE: Record<string, { bg: string; text: string; dot: string }> = {
HIGH: { bg: 'bg-red-50 border-red-200', text: 'text-red-800', dot: 'bg-red-500' },
MEDIUM: { bg: 'bg-yellow-50 border-yellow-200', text: 'text-yellow-800', dot: 'bg-yellow-500' },
LOW: { bg: 'bg-blue-50 border-blue-200', text: 'text-blue-800', dot: 'bg-blue-500' },
CRITICAL: { bg: 'bg-red-100 border-red-300', text: 'text-red-900', dot: 'bg-red-700' },
}
export function ScanResult({ data }: { data: ScanData }) {
const [expandedCorrection, setExpandedCorrection] = useState<string | null>(null)
const [expandedDoc, setExpandedDoc] = useState<string | null>(null)
const undocCount = data.services.filter(s => s.status === 'undocumented').length
const okCount = data.services.filter(s => s.status === 'ok').length
const highCount = data.findings.filter(f => f.severity === 'HIGH' || f.severity === 'CRITICAL').length
const docs = data.discovered_documents || []
// Group findings by doc_title
const docFindings: Record<string, ScanFinding[]> = {}
const generalFindings: ScanFinding[] = []
for (const f of data.findings) {
if (f.doc_title) {
if (!docFindings[f.doc_title]) docFindings[f.doc_title] = []
docFindings[f.doc_title].push(f)
} else {
generalFindings.push(f)
}
}
return (
<div className="space-y-5">
{/* Summary Bar */}
<div className="grid grid-cols-4 gap-3">
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-gray-900">{data.pages_scanned}</p>
<p className="text-xs text-gray-500">Seiten</p>
</div>
<div className="bg-green-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-green-700">{okCount}</p>
<p className="text-xs text-gray-500">Dokumentiert</p>
</div>
<div className="bg-red-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-red-700">{undocCount}</p>
<p className="text-xs text-gray-500">Nicht in DSE</p>
</div>
<div className="bg-purple-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-purple-700">{docs.length}</p>
<p className="text-xs text-gray-500">Dokumente</p>
</div>
</div>
{/* Scanned Pages */}
{data.pages_list?.length > 0 && (
<details className="text-sm">
<summary className="text-gray-600 cursor-pointer hover:text-gray-800">
{data.pages_scanned} Seiten gescannt
</summary>
<ul className="mt-2 space-y-1 ml-4">
{data.pages_list.map((p, i) => {
const isMissing = data.missing_pages[p]
return (
<li key={i} className={`text-xs ${isMissing ? 'text-red-600' : 'text-gray-500'}`}>
{isMissing ? '\u2717' : '\u2713'} {p}
</li>
)
})}
</ul>
</details>
)}
{/* Services Table */}
{data.services.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Dienstleister (SOLL/IST)</h4>
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Status</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Dienst</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Land</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">In DSE</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{data.services.map((s, i) => {
const st = STATUS_ICON[s.status] || STATUS_ICON.ok
return (
<tr key={i} className={s.status === 'undocumented' ? 'bg-red-50' : ''}>
<td className={`px-3 py-2 font-bold ${st.color}`}>{st.icon}</td>
<td className="px-3 py-2">
<span className="font-medium text-gray-900">{s.name}</span>
<span className="text-gray-400 text-xs ml-2">{s.provider}</span>
</td>
<td className="px-3 py-2 text-gray-600">{s.country}</td>
<td className="px-3 py-2">{s.in_dse ? '\u2713' : <span className="text-red-600 font-medium">Nein</span>}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
{/* === Document-Centric View === */}
{docs.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">
Rechtliche Dokumente ({docs.length})
</h4>
<div className="space-y-2">
{docs.map((doc, i) => {
const isExpanded = expandedDoc === doc.title
const findings = docFindings[doc.title] || []
const pct = doc.completeness_pct
const barColor = pct >= 80 ? 'bg-green-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500'
const statusLabel = pct >= 80 ? 'OK' : pct >= 50 ? 'Lueckenhaft' : 'Mangelhaft'
const statusColor = pct >= 80 ? 'text-green-700 bg-green-50' : pct >= 50 ? 'text-yellow-700 bg-yellow-50' : 'text-red-700 bg-red-50'
return (
<div key={i} className="border border-gray-200 rounded-lg overflow-hidden">
<button
onClick={() => setExpandedDoc(isExpanded ? null : doc.title)}
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50/50 hover:bg-gray-50 text-left"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<svg className={`w-4 h-4 text-gray-400 transition-transform shrink-0 ${isExpanded ? 'rotate-90' : ''}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900 truncate">{doc.title}</div>
<div className="text-xs text-gray-500">
{doc.word_count} Woerter
{findings.length > 0 && <span className="text-red-600 ml-2">{findings.length} Maengel</span>}
</div>
</div>
</div>
<div className="flex items-center gap-3 shrink-0 ml-3">
{/* Completeness bar */}
<div className="w-20 h-2 bg-gray-200 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
</div>
<span className={`text-xs font-medium px-2 py-0.5 rounded ${statusColor}`}>
{pct}%
</span>
</div>
</button>
{isExpanded && (
<div className="px-4 py-3 border-t border-gray-100 space-y-2">
{findings.length > 0 ? (
findings.map((f, fi) => {
const sev = SEV_STYLE[f.severity] || SEV_STYLE.MEDIUM
return (
<div key={fi} className="flex items-start gap-2 text-sm">
<span className={`w-2 h-2 rounded-full mt-1.5 shrink-0 ${sev.dot}`} />
<span className="text-gray-700">{f.text}</span>
</div>
)
})
) : (
<p className="text-sm text-green-600">Alle Pflichtangaben vorhanden.</p>
)}
{doc.url && (
<a href={doc.url} target="_blank" rel="noopener noreferrer"
className="text-xs text-purple-600 hover:underline mt-2 inline-block">
Dokument oeffnen
</a>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)}
{/* General Findings (not associated with a specific document) */}
{generalFindings.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">
Allgemeine Findings ({generalFindings.length})
</h4>
<div className="space-y-2">
{generalFindings.map((f, i) => {
const sev = SEV_STYLE[f.severity] || SEV_STYLE.MEDIUM
const corrKey = `gen-${i}`
const isExp = expandedCorrection === corrKey
return (
<div key={i} className={`border rounded-lg p-3 ${sev.bg}`}>
<div className="flex items-start gap-2">
<span className={`text-xs font-bold px-2 py-0.5 rounded ${sev.text} bg-white`}>
{f.severity}
</span>
<p className="text-sm text-gray-800 flex-1">{f.text}</p>
</div>
{/* Text Reference (original text + position + correction) */}
{f.text_reference && (
<TextReference ref={f.text_reference} correction={f.correction} />
)}
{/* Fallback: correction without text reference */}
{!f.text_reference && f.correction && (
<div className="mt-2">
<button onClick={() => setExpandedCorrection(isExp ? null : corrKey)}
className="text-xs text-purple-600 hover:text-purple-800 font-medium">
{isExp ? 'Korrektur ausblenden' : 'Korrekturvorschlag'}
</button>
{isExp && (
<div className="mt-2 bg-white border border-gray-200 rounded-lg p-3 relative">
<pre className="text-xs text-gray-700 whitespace-pre-wrap font-sans">{f.correction}</pre>
<button onClick={() => navigator.clipboard.writeText(f.correction)}
className="absolute top-2 right-2 text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">
Kopieren
</button>
</div>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)}
{/* PDF Export Button */}
<div className="pt-4 border-t flex gap-3">
<button
onClick={async () => {
try {
const res = await fetch('/api/sdk/v1/agent/scans/pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: '', scan_type: 'scan', analysis_mode: 'post_launch', result: data }),
})
if (res.ok) {
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'compliance-report.pdf'
a.click()
URL.revokeObjectURL(url)
}
} catch (e) { console.error('PDF export failed:', e) }
}}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
PDF herunterladen
</button>
</div>
</div>
)
}
@@ -0,0 +1,83 @@
'use client'
/**
* SnapshotHistoryList — Check-Historie aus gespeicherten Snapshots.
*
* Neuester Snapshot oben + farblich abgesetzt. Klick → Detail-Seite mit den
* Ergebnissen (/sdk/agent/snapshots/{id}). `refreshKey` neu setzen, um nach
* einem frisch gelaufenen Compliance-Check neu zu laden.
*/
import React, { useEffect, useState } from 'react'
import Link from 'next/link'
interface SnapMeta {
id: string
check_id?: string
site_domain?: string
site_label?: string
created_at?: string
}
export function SnapshotHistoryList(
{ refreshKey = 0, limit = 50 }: { refreshKey?: number; limit?: number },
) {
const [snaps, setSnaps] = useState<SnapMeta[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
setLoading(true)
fetch(`/api/sdk/v1/agent/snapshots?limit=${limit}`)
.then(r => r.json())
.then(d => { if (!cancelled) setSnaps(d.snapshots || []) })
.catch(() => { if (!cancelled) setSnaps([]) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [refreshKey, limit])
return (
<div className="space-y-2">
<div className="flex items-baseline justify-between">
<h2 className="text-sm font-semibold text-gray-900">Historie</h2>
{!loading && snaps.length > 0 && (
<span className="text-xs text-gray-400">{snaps.length} Checks</span>
)}
</div>
{loading ? (
<div className="text-sm text-gray-500">Lade Historie</div>
) : snaps.length === 0 ? (
<div className="text-sm text-gray-400 border border-dashed border-gray-200 rounded-lg px-4 py-6 text-center">
Noch keine Checks starte oben einen Compliance-Check.
</div>
) : (
<div className="border rounded-lg divide-y divide-gray-100 overflow-hidden">
{snaps.map((s, i) => (
<Link
key={s.id}
href={`/sdk/agent/snapshots/${s.id}`}
className={`flex items-center gap-3 px-4 py-3 text-sm transition-colors ${
i === 0 ? 'bg-purple-50 hover:bg-purple-100' : 'hover:bg-gray-50'
}`}
>
{i === 0 && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-600 text-white shrink-0">
aktuellster
</span>
)}
<span className={`font-medium w-44 truncate ${i === 0 ? 'text-purple-900' : 'text-gray-800'}`}>
{s.site_label || s.site_domain || 'unbekannt'}
</span>
<span className="text-gray-500 flex-1 min-w-0 truncate">{s.site_domain}</span>
<span className="text-xs text-gray-400 whitespace-nowrap">
{(s.created_at || '').slice(0, 16).replace('T', ' ')}
</span>
<span className="text-gray-300"></span>
</Link>
))}
</div>
)}
</div>
)
}
+6 -176
View File
@@ -1,194 +1,24 @@
'use client'
import React, { useState } from 'react'
import { ScanResult } from './_components/ScanResult'
import { ComplianceCheckTab } from './_components/ComplianceCheckTab'
import { BannerCheckTab } from './_components/BannerCheckTab'
import { ComplianceFAQ } from './_components/ComplianceFAQ'
import { AgentTestTab } from './_components/AgentTestTab'
type AnalysisTab = 'scan' | 'compliance-check' | 'banner-check' | 'agent-test' | 'impressum-check' | 'doc-check'
const TABS: { id: AnalysisTab; label: string; desc: string }[] = [
{ id: 'scan', label: 'Website-Scan', desc: 'Rechtliche Dokumente finden + Dienstleister erkennen' },
{ id: 'compliance-check', label: 'Compliance-Check', desc: 'Alle rechtlichen Dokumente zusammen pruefen' },
{ id: 'banner-check', label: 'Banner-Check', desc: 'Cookie-Banner auf DSGVO-Konformitaet testen' },
{ id: 'agent-test', label: 'Agent-Test', desc: 'Specialist-Agent gegen 5 URLs isoliert testen' },
]
import { SnapshotHistoryList } from './_components/SnapshotHistoryList'
export default function AgentPage() {
const [url, setUrl] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('agent-scan-url') || '' : '')
const [tab, setTab] = useState<AnalysisTab>(() => (typeof window !== 'undefined' ? localStorage.getItem('agent-scan-tab') as AnalysisTab : null) || 'compliance-check')
const [scanLoading, setScanLoading] = useState(false)
const [scanError, setScanError] = useState<string | null>(null)
const [scanData, setScanData] = useState<any>(() => {
if (typeof window === 'undefined') return null
try { const s = localStorage.getItem('agent-scan-result'); return s ? JSON.parse(s) : null } catch { return null }
})
const [scanProgress, setScanProgress] = useState<string>('')
const [activeScanId, setActiveScanId] = useState<string>(() => typeof window !== 'undefined' ? localStorage.getItem('agent-scan-id') || '' : '')
const [scanHistory, setScanHistory] = useState<{ url: string; date: string; findings: number; docs: number; resultKey: string }[]>(() => {
if (typeof window === 'undefined') return []
try { return JSON.parse(localStorage.getItem('agent-scan-history') || '[]') } catch { return [] }
})
React.useEffect(() => { localStorage.setItem('agent-scan-url', url) }, [url])
React.useEffect(() => { localStorage.setItem('agent-scan-tab', tab) }, [tab])
// Resume polling if scan was in progress
React.useEffect(() => {
if (!activeScanId || scanData?.services) return
let cancelled = false
setScanLoading(true)
setScanProgress('Scan laeuft noch...')
const poll = async () => {
while (!cancelled) {
await new Promise(r => setTimeout(r, 5000))
try {
const res = await fetch(`/api/sdk/v1/agent/scan?scan_id=${activeScanId}`)
if (!res.ok) continue
const data = await res.json()
if (data.progress) setScanProgress(data.progress)
if (data.status === 'completed' && data.result) {
setScanData(data.result); setScanProgress(''); setScanLoading(false)
localStorage.setItem('agent-scan-result', JSON.stringify(data.result))
localStorage.removeItem('agent-scan-id'); setActiveScanId('')
_addToHistory(data.result); return
}
if (data.status === 'failed' || data.status === 'not_found') {
if (data.status === 'failed') setScanError(data.error || 'Scan fehlgeschlagen')
setScanProgress(''); setScanLoading(false)
localStorage.removeItem('agent-scan-id'); setActiveScanId(''); return
}
} catch {}
}
}
poll()
return () => { cancelled = true }
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const _addToHistory = (result: any) => {
const resultKey = `scan-result-${Date.now()}`
try { localStorage.setItem(resultKey, JSON.stringify(result)) } catch {}
const entry = { url: url || result.url || '', date: new Date().toISOString(), findings: result.findings?.length || 0, docs: result.discovered_documents?.length || 0, resultKey }
const updated = [entry, ...scanHistory].slice(0, 30)
setScanHistory(updated); localStorage.setItem('agent-scan-history', JSON.stringify(updated))
}
const handleScan = async (e: React.FormEvent) => {
e.preventDefault()
if (!url.trim()) return
setScanLoading(true); setScanError(null); setScanData(null); setScanProgress('Scan wird gestartet...')
try {
const startRes = await fetch('/api/sdk/v1/agent/scan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: url.trim(), mode: 'post_launch' }) })
if (!startRes.ok) throw new Error(`Scan konnte nicht gestartet werden: ${startRes.status}`)
const { scan_id } = await startRes.json()
if (!scan_id) throw new Error('Keine Scan-ID erhalten')
setActiveScanId(scan_id); localStorage.setItem('agent-scan-id', scan_id)
let attempts = 0
while (attempts < 120) {
await new Promise(r => setTimeout(r, 5000))
const pollRes = await fetch(`/api/sdk/v1/agent/scan?scan_id=${scan_id}`)
if (!pollRes.ok) { attempts++; continue }
const pollData = await pollRes.json()
if (pollData.progress) setScanProgress(pollData.progress)
if (pollData.status === 'completed' && pollData.result) {
setScanData(pollData.result); setScanProgress('')
localStorage.setItem('agent-scan-result', JSON.stringify(pollData.result))
localStorage.removeItem('agent-scan-id'); setActiveScanId(''); _addToHistory(pollData.result); break
}
if (pollData.status === 'failed') throw new Error(pollData.error || 'Scan fehlgeschlagen')
attempts++
}
if (attempts >= 120) throw new Error('Scan-Timeout (10 Minuten)')
} catch (e) { setScanError(e instanceof Error ? e.message : 'Unbekannter Fehler'); setScanProgress('') }
finally { setScanLoading(false) }
}
const navigateToCheck = (targetTab: AnalysisTab, checkUrl: string) => {
const keyMap: Record<string, string> = { 'doc-check': 'doc-check-prefill-url', 'banner-check': 'banner-check-url', 'impressum-check': 'impressum-check-url' }
if (keyMap[targetTab]) localStorage.setItem(keyMap[targetTab], checkUrl)
setTab(targetTab)
}
const discoveredDocs = scanData?.discovered_documents || []
const scannedUrl = scanData?.url || url
// Nach einem abgeschlossenen Check die Historie unten neu laden.
const [historyKey, setHistoryKey] = useState(0)
return (
<div className="space-y-6 max-w-4xl">
<div>
<h1 className="text-2xl font-bold text-gray-900">Compliance Agent</h1>
<p className="text-gray-500 mt-1">Analysiere Webseiten und Dokumente auf DSGVO-Konformitaet.</p>
<p className="text-gray-500 mt-1">Webseiten + Dokumente auf DSGVO-Konformität prüfen.</p>
</div>
<div className="flex border-b border-gray-200 overflow-x-auto">
{TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
tab === t.id ? 'border-purple-500 text-purple-700' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
{t.label}
</button>
))}
</div>
<ComplianceCheckTab onComplete={() => setHistoryKey(k => k + 1)} />
{tab === 'scan' && (
<div className="space-y-4">
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
<h3 className="text-sm font-semibold text-indigo-900">Website-Scan (Discovery)</h3>
<p className="text-xs text-indigo-700 mt-1">Findet alle rechtlichen Dokumente (DSI, AGB, Impressum, Cookie, Widerruf), erkennt eingesetzte Drittdienste und prueft ob sie in der DSE dokumentiert sind.</p>
</div>
<form onSubmit={handleScan} className="flex gap-3">
<input type="url" value={url} onChange={e => setUrl(e.target.value)} placeholder="https://www.example.com/"
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm" disabled={scanLoading} required />
<button type="submit" disabled={scanLoading || !url.trim()}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center gap-2 text-sm font-medium whitespace-nowrap">
{scanLoading ? (<><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>Scanne...</>) : 'Website scannen'}
</button>
</form>
{scanProgress && <div className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-sm text-purple-700 flex items-center gap-3"><svg className="animate-spin w-5 h-5 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>{scanProgress}</div>}
{scanError && <div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{scanError}</div>}
{scanData && (
<div className="bg-white border border-gray-200 rounded-xl p-4 shadow-sm">
<h4 className="text-sm font-semibold text-gray-800 mb-3">Jetzt pruefen</h4>
<div className="grid grid-cols-2 gap-2">
<button onClick={() => navigateToCheck('banner-check', scannedUrl)} className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
<div className="text-sm font-medium text-gray-900">Cookie-Banner pruefen</div>
<div className="text-xs text-gray-500 mt-0.5">3-Phasen Dark-Pattern-Analyse</div>
</button>
<button onClick={() => navigateToCheck('impressum-check', scannedUrl + '/impressum')} className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
<div className="text-sm font-medium text-gray-900">Impressum pruefen</div>
<div className="text-xs text-gray-500 mt-0.5">§5 TMG Pflichtangaben</div>
</button>
{discoveredDocs.map((doc: any, i: number) => (
<button key={i} onClick={() => navigateToCheck('doc-check', doc.url)} className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
<div className="text-sm font-medium text-gray-900 truncate">{doc.title || doc.url}</div>
<div className="text-xs text-gray-500 mt-0.5">{doc.doc_type?.toUpperCase()} · {doc.word_count || '?'} Woerter{doc.completeness_pct != null && ` · ${doc.completeness_pct}%`}</div>
</button>
))}
</div>
</div>
)}
{scanData?.services && <div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm"><ScanResult data={scanData} /></div>}
{scanHistory.length > 0 && (
<div className="border border-gray-200 rounded-xl p-4">
<h4 className="text-sm font-medium text-gray-700 mb-3">Letzte Scans</h4>
<div className="space-y-2">
{scanHistory.map((h, i) => (
<button key={i} onClick={() => { setUrl(h.url); if (h.resultKey) { try { const s = localStorage.getItem(h.resultKey); if (s) { setScanData(JSON.parse(s)); return } } catch {} } }}
className="w-full flex items-center justify-between p-3 rounded-lg border border-gray-100 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left">
<div className="min-w-0 flex-1"><div className="text-sm font-medium text-gray-900 truncate">{h.url}</div><div className="text-xs text-gray-500">{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}</div></div>
<div className="flex items-center gap-3 shrink-0 ml-3">{h.docs > 0 && <span className="text-xs text-purple-600">{h.docs} Dok.</span>}<span className={`text-xs font-medium ${h.findings > 0 ? 'text-red-600' : 'text-green-600'}`}>{h.findings} Findings</span></div>
</button>
))}
</div>
</div>
)}
</div>
)}
{tab === 'compliance-check' && <ComplianceCheckTab />}
{tab === 'banner-check' && <BannerCheckTab />}
{tab === 'agent-test' && <AgentTestTab />}
<SnapshotHistoryList refreshKey={historyKey} />
<ComplianceFAQ />
</div>
@@ -1,37 +1,14 @@
'use client'
/**
* Check-Historie — listet gespeicherte Snapshots (alle Sites/Module).
* Ein DSB/Mitarbeiter kann jeden früheren Check öffnen, ohne neuen Check
* zu starten. Daten kommen aus den Snapshot-Rohdaten.
* Check-Historie (eigene Route) — listet gespeicherte Snapshots.
* Identische Liste wie unter /sdk/agent, nur als Vollseite.
*/
import React, { useEffect, useState } from 'react'
import Link from 'next/link'
interface SnapMeta {
id: string
check_id?: string
site_domain?: string
site_label?: string
created_at?: string
replay_count?: number
}
import React from 'react'
import { SnapshotHistoryList } from '../_components/SnapshotHistoryList'
export default function SnapshotHistory() {
const [snaps, setSnaps] = useState<SnapMeta[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
fetch('/api/sdk/v1/agent/snapshots?limit=50')
.then(r => r.json())
.then(d => { if (!cancelled) setSnaps(d.snapshots || []) })
.catch(() => { if (!cancelled) setSnaps([]) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [])
return (
<div className="p-6 max-w-4xl space-y-4">
<div>
@@ -41,31 +18,7 @@ export default function SnapshotHistory() {
ansehbar, ohne neuen Check zu starten.
</p>
</div>
{loading ? (
<div className="text-sm text-gray-500">Lade Historie</div>
) : snaps.length === 0 ? (
<div className="text-sm text-gray-400">Keine gespeicherten Checks gefunden.</div>
) : (
<div className="border rounded-lg divide-y divide-gray-100">
{snaps.map(s => (
<Link
key={s.id}
href={`/sdk/agent/snapshots/${s.id}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 text-sm"
>
<span className="font-medium text-gray-800 w-44 truncate">
{s.site_label || s.site_domain || 'unbekannt'}
</span>
<span className="text-gray-500 flex-1 min-w-0 truncate">{s.site_domain}</span>
<span className="text-xs text-gray-400 whitespace-nowrap">
{(s.created_at || '').slice(0, 16).replace('T', ' ')}
</span>
<span className="text-gray-300"></span>
</Link>
))}
</div>
)}
<SnapshotHistoryList />
</div>
)
}