feat(agent): SSE — progressive Themen-Tabs (Phase 2)

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>
This commit is contained in:
Benjamin Admin
2026-06-10 19:07:26 +02:00
parent e21984e0ad
commit 65de90114a
8 changed files with 246 additions and 5 deletions
@@ -1,6 +1,6 @@
'use client'
import React, { useState, useCallback } from 'react'
import React, { useState, useCallback, useRef } from 'react'
import { ComplianceResultTabs } from './ComplianceResultTabs'
import { DocumentRow } from './DocumentRow'
import { PreScanWizard, useScanContext, isContextComplete } from './PreScanWizard'
@@ -35,6 +35,9 @@ export function ComplianceCheckTab() {
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(() => {
@@ -117,6 +120,38 @@ export function ComplianceCheckTab() {
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 () => {
@@ -157,6 +192,7 @@ export function ComplianceCheckTab() {
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
@@ -201,6 +237,7 @@ export function ComplianceCheckTab() {
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
setProgress('')
setProgressPct(0)
try { esRef.current?.close() } catch { /* noop */ }
} finally {
setLoading(false)
}