diff --git a/.agents/memory/MEMORY.md b/.agents/memory/MEMORY.md index 5793f2b..5968210 100644 --- a/.agents/memory/MEMORY.md +++ b/.agents/memory/MEMORY.md @@ -1,2 +1,3 @@ - [lucide-react icon name collisions](lucide-icon-name-collisions.md) — `Badge`/`Activity` from lucide collide with shadcn/ui Badge and React 19 Activity; import Badge from ui, Activity from lucide. - [OpenAI gpt-5 temperature](openai-temperature-gpt5.md) — gpt-5* reject `temperature != 1`; omit temperature in OpenAI-compatible clients or AI analysis silently fails. +- [NDJSON streaming on Replit](ndjson-streaming-express-replit.md) — use `res.on("close")`+`writableFinished` (NOT `req.on("close")`); persist on disconnect; proxy doesn't buffer; gate fallback to avoid dup rows. diff --git a/.agents/memory/ndjson-streaming-express-replit.md b/.agents/memory/ndjson-streaming-express-replit.md new file mode 100644 index 0000000..aeb823f --- /dev/null +++ b/.agents/memory/ndjson-streaming-express-replit.md @@ -0,0 +1,36 @@ +--- +name: NDJSON streaming under Express behind the Replit proxy +description: Pitfalls for live/streaming HTTP responses (NDJSON/SSE) from an Express API on Replit — client-disconnect detection, persistence, and proxy buffering +--- + +# Streaming responses (NDJSON) from Express on Replit + +For a long-lived POST that streams progress (e.g. `application/x-ndjson` with +`res.flushHeaders()` then line-delimited JSON writes): + +**Detect a real client abort with `res.on("close")` + `res.writableFinished` — never +`req.on("close")`.** +`req.on("close")` fires as soon as the POST *request body* has been fully consumed, +which is normal and happens immediately. Gating writes on that wrongly suppresses the +entire stream. Use the *response* close event, and treat it as an abort only when +`!res.writableFinished`. + +**Persist the result regardless of disconnect.** Run the analysis and the DB write +inside the handler's `try` unconditionally; only guard the `res.write()` calls on +"connection still open". This way a client that disconnects mid-stream still gets its +result persisted (and the client must NOT re-submit on a mid-stream error, or it +creates a duplicate row — see below). + +**The Replit path-proxy does NOT buffer the stream.** Incremental flushes arrive line +by line through `$REPLIT_DEV_DOMAIN`, same as `localhost`. No special proxy header or +chunk-padding needed. + +**Client fetch must be root-relative (`/api/...`), not `import.meta.env.BASE_URL`.** +That matches the generated API client and is what routes correctly through the proxy. + +**Fallback gating to avoid duplicate persists:** a client that streams and also falls +back to a non-streaming POST on error must only fall back when the server never +processed the request (fetch rejected, or `!res.ok` with a 5xx). Once the response +body has started streaming, any read error means the server is already persisting — +falling back then duplicates the record. Distinguish 4xx (show message, no retry) +from transport failures. diff --git a/.agents/memory/openai-temperature-gpt5.md b/.agents/memory/openai-temperature-gpt5.md index 625c1b8..e5da2e5 100644 --- a/.agents/memory/openai-temperature-gpt5.md +++ b/.agents/memory/openai-temperature-gpt5.md @@ -16,6 +16,5 @@ rather than hardcoding a low value for determinism. **Why:** older models accepted `temperature: 0.1` but gpt-5* only accept the default. A hardcoded low temperature breaks every scan against modern models. -**How to apply:** in `artifacts/api-server/src/lib/aiAnalysis.ts` the OpenAI-compatible -path no longer sends `temperature`. If reintroducing sampling controls, gate them per -model or make them optional. +**How to apply:** in the OpenAI-compatible AI-analysis path, do not send `temperature`. +If reintroducing sampling controls, gate them per model or make them optional. diff --git a/artifacts/api-server/src/lib/scanEngine.ts b/artifacts/api-server/src/lib/scanEngine.ts index 377f1e7..92e23a7 100644 --- a/artifacts/api-server/src/lib/scanEngine.ts +++ b/artifacts/api-server/src/lib/scanEngine.ts @@ -15,9 +15,14 @@ import { type Severity, type Axis, } from "./ruleCatalog"; -import type { FindingCounts as DbFindingCounts } from "@workspace/db"; +import type { + FindingCounts as DbFindingCounts, + ScanCheckpoint, +} from "@workspace/db"; import { runAiAnalysis, type AiRuleConfig } from "./aiAnalysis"; +export type { ScanCheckpoint } from "@workspace/db"; + const SEVERITY_WEIGHT: Record = { critical: 50, high: 18, @@ -26,9 +31,18 @@ const SEVERITY_WEIGHT: Record = { info: 0, }; +export type ScanProgressEvent = + | { type: "ai-start" } + | { type: "checkpoint"; checkpoint: ScanCheckpoint }; + +export type ProgressFn = ( + event: ScanProgressEvent, +) => void | Promise; + export type EngineResult = { findings: RawFinding[]; counts: DbFindingCounts; + checkpoints: ScanCheckpoint[]; riskScore: number; verdict: "pass" | "review" | "block"; aiUsed: boolean; @@ -70,29 +84,75 @@ export function computeVerdict( return "pass"; } +function scoreOf(findings: RawFinding[]): number { + return findings.reduce((s, f) => s + SEVERITY_WEIGHT[f.severity], 0); +} + export async function analyzeSkill( files: ParsedFile[], useAi: boolean, + onProgress?: ProgressFn, ): Promise { const dbRules = await db.select().from(rulesTable); const ruleConfig = new Map( - dbRules.map((r) => [r.ruleId, { enabled: r.enabled, severity: r.severity as Severity }]), + dbRules.map((r) => [ + r.ruleId, + { enabled: r.enabled, severity: r.severity as Severity }, + ]), ); const findings: RawFinding[] = []; + const checkpoints: ScanCheckpoint[] = []; + for (const rule of STATIC_RULES) { const cfg = ruleConfig.get(rule.ruleId); - if (cfg && !cfg.enabled) continue; const severity = cfg?.severity ?? rule.defaultSeverity; - for (const file of files) { - findings.push(...runStaticRule(rule, file, severity)); + + if (cfg && !cfg.enabled) { + const checkpoint: ScanCheckpoint = { + id: rule.ruleId, + label: rule.title, + category: rule.category, + axis: rule.axis, + severity, + status: "skipped", + findingCount: 0, + scoreDelta: 0, + detectedBy: "static", + }; + checkpoints.push(checkpoint); + await onProgress?.({ type: "checkpoint", checkpoint }); + continue; } + + const ruleFindings: RawFinding[] = []; + for (const file of files) { + ruleFindings.push(...runStaticRule(rule, file, severity)); + } + findings.push(...ruleFindings); + + const checkpoint: ScanCheckpoint = { + id: rule.ruleId, + label: rule.title, + category: rule.category, + axis: rule.axis, + severity, + status: ruleFindings.length > 0 ? "flagged" : "pass", + findingCount: ruleFindings.length, + scoreDelta: scoreOf(ruleFindings), + detectedBy: "static", + }; + checkpoints.push(checkpoint); + await onProgress?.({ type: "checkpoint", checkpoint }); } let aiUsed = false; let aiError: string | null = null; + let aiFindings: RawFinding[] = []; if (useAi) { + await onProgress?.({ type: "ai-start" }); + const aiRuleIds = new Set(AI_RULES.map((r) => r.ruleId)); const enabledAiRules: AiRuleConfig[] = AI_RULES.filter((rule) => { const cfg = ruleConfig.get(rule.ruleId); @@ -131,14 +191,58 @@ export async function analyzeSkill( aiError = result.error; if (!result.error) { aiUsed = true; + aiFindings = result.findings; findings.push(...result.findings); } } + + for (const rule of AI_RULES) { + const cfg = ruleConfig.get(rule.ruleId); + const severity = cfg?.severity ?? rule.defaultSeverity; + const enabled = cfg ? cfg.enabled : true; + + let status: ScanCheckpoint["status"]; + let findingCount = 0; + let scoreDelta = 0; + + if (!enabled) { + status = "skipped"; + } else if (!aiUsed) { + status = "error"; + } else { + const ruleFindings = aiFindings.filter((f) => f.ruleId === rule.ruleId); + findingCount = ruleFindings.length; + scoreDelta = scoreOf(ruleFindings); + status = findingCount > 0 ? "flagged" : "pass"; + } + + const checkpoint: ScanCheckpoint = { + id: rule.ruleId, + label: rule.title, + category: rule.category, + axis: rule.axis, + severity, + status, + findingCount, + scoreDelta, + detectedBy: "ai", + }; + checkpoints.push(checkpoint); + await onProgress?.({ type: "checkpoint", checkpoint }); + } } const riskScore = computeScore(findings); const counts = computeCounts(findings); const verdict = computeVerdict(findings, riskScore); - return { findings, counts, riskScore, verdict, aiUsed, aiError }; + return { + findings, + counts, + checkpoints, + riskScore, + verdict, + aiUsed, + aiError, + }; } diff --git a/artifacts/api-server/src/routes/scans.ts b/artifacts/api-server/src/routes/scans.ts index 6627569..798a47d 100644 --- a/artifacts/api-server/src/routes/scans.ts +++ b/artifacts/api-server/src/routes/scans.ts @@ -22,12 +22,14 @@ import { parseText, deriveScanName, } from "../lib/skillParser"; -import { analyzeSkill } from "../lib/scanEngine"; -import type { ParsedFile } from "../lib/ruleCatalog"; +import { analyzeSkill, type EngineResult } from "../lib/scanEngine"; +import { STATIC_RULES, AI_RULES, type ParsedFile } from "../lib/ruleCatalog"; import { logger } from "../lib/logger"; const router: IRouter = Router(); +type CreateScanInput = ReturnType; + export function serializeScan(scan: Scan) { return { id: scan.id, @@ -69,32 +71,33 @@ function serializeFinding(f: Finding) { }; } -router.get("/scans", async (_req, res) => { - const rows = await db - .select() - .from(scansTable) - .orderBy(desc(scansTable.createdAt)); - res.json(ListScansResponse.parse(rows.map(serializeScan))); -}); +function serializeScanDetail( + scan: Scan, + files: ScanFile[], + findings: Finding[], +) { + return { + ...serializeScan(scan), + checkpoints: scan.checkpoints ?? [], + files: files.map(serializeFile), + findings: [...findings].sort((a, b) => a.id - b.id).map(serializeFinding), + }; +} -router.post("/scans", async (req, res) => { - const parsed = CreateScanBody.safeParse(req.body); - if (!parsed.success) { - return res - .status(400) - .json({ message: "Ungültige Eingabe", details: parsed.error.issues }); - } - const input = parsed.data; +type ParseResult = + | { ok: true; files: ParsedFile[] } + | { ok: false; status: number; message: string }; - let files: ParsedFile[] = []; +function parseScanInput(input: CreateScanInput): ParseResult { try { + let files: ParsedFile[]; if (input.source === "zip") { if (!input.contentBase64) - return res.status(400).json({ message: "ZIP-Inhalt fehlt." }); + return { ok: false, status: 400, message: "ZIP-Inhalt fehlt." }; files = parseZip(Buffer.from(input.contentBase64, "base64")); } else if (input.source === "file") { if (!input.contentBase64) - return res.status(400).json({ message: "Dateiinhalt fehlt." }); + return { ok: false, status: 400, message: "Dateiinhalt fehlt." }; files = [ parseSingleFile( input.filename ?? "datei", @@ -103,26 +106,33 @@ router.post("/scans", async (req, res) => { ]; } else { if (!input.text || !input.text.trim()) - return res.status(400).json({ message: "Text fehlt." }); + return { ok: false, status: 400, message: "Text fehlt." }; files = [parseText(input.text, input.name ?? "SKILL.md")]; } + if (files.length === 0) + return { + ok: false, + status: 400, + message: "Keine analysierbaren Dateien gefunden.", + }; + return { ok: true, files }; } catch (err) { logger.error({ err }, "Skill-Parsing fehlgeschlagen"); - return res.status(400).json({ + return { + ok: false, + status: 400, message: "Das Skill konnte nicht gelesen werden. Bitte prüfen Sie das Format (gültiges ZIP / Textdatei).", - }); + }; } +} - if (files.length === 0) { - return res - .status(400) - .json({ message: "Keine analysierbaren Dateien gefunden." }); - } - - const name = input.name?.trim() || deriveScanName(files, "Unbenanntes Skill"); - const result = await analyzeSkill(files, input.useAi); - +async function persistScan( + input: CreateScanInput, + name: string, + files: ParsedFile[], + result: EngineResult, +): Promise<{ scan: Scan; files: ScanFile[]; findings: Finding[] }> { const [scan] = await db .insert(scansTable) .values({ @@ -135,6 +145,7 @@ router.post("/scans", async (req, res) => { aiUsed: result.aiUsed, aiError: result.aiError, findingCounts: result.counts, + checkpoints: result.checkpoints, }) .returning(); @@ -176,14 +187,127 @@ router.post("/scans", async (req, res) => { .returning(); } - const payload = { - ...serializeScan(scan), - files: insertedFiles.map(serializeFile), - findings: insertedFindings - .sort((a, b) => a.id - b.id) - .map(serializeFinding), + return { scan, files: insertedFiles, findings: insertedFindings }; +} + +router.get("/scans", async (_req, res) => { + const rows = await db + .select() + .from(scansTable) + .orderBy(desc(scansTable.createdAt)); + res.json(ListScansResponse.parse(rows.map(serializeScan))); +}); + +router.post("/scans", async (req, res) => { + const parsed = CreateScanBody.safeParse(req.body); + if (!parsed.success) { + return res + .status(400) + .json({ message: "Ungültige Eingabe", details: parsed.error.issues }); + } + const input = parsed.data; + + const parseResult = parseScanInput(input); + if (!parseResult.ok) { + return res.status(parseResult.status).json({ message: parseResult.message }); + } + const files = parseResult.files; + + const name = input.name?.trim() || deriveScanName(files, "Unbenanntes Skill"); + const result = await analyzeSkill(files, input.useAi); + const { scan, files: insertedFiles, findings } = await persistScan( + input, + name, + files, + result, + ); + + return res + .status(201) + .json(GetScanResponse.parse(serializeScanDetail(scan, insertedFiles, findings))); +}); + +const STREAM_PACING_MS = 80; +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +router.post("/scans/stream", async (req, res) => { + const parsed = CreateScanBody.safeParse(req.body); + if (!parsed.success) { + res + .status(400) + .json({ message: "Ungültige Eingabe", details: parsed.error.issues }); + return; + } + const input = parsed.data; + + const parseResult = parseScanInput(input); + if (!parseResult.ok) { + res.status(parseResult.status).json({ message: parseResult.message }); + return; + } + const files = parseResult.files; + const name = input.name?.trim() || deriveScanName(files, "Unbenanntes Skill"); + + res.status(200); + res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8"); + res.setHeader("Cache-Control", "no-cache, no-transform"); + res.setHeader("X-Accel-Buffering", "no"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders(); + + // Detect a genuine client disconnect. NOTE: do NOT use req.on("close") here — + // for a POST it fires as soon as the request body is consumed, not on abort. + // res "close" before writableFinished means the client went away. + let aborted = false; + res.on("close", () => { + if (!res.writableFinished) aborted = true; + }); + + const write = (obj: unknown) => { + if (aborted || res.writableEnded) return; + res.write(JSON.stringify(obj) + "\n"); }; - return res.status(201).json(GetScanResponse.parse(payload)); + + write({ + type: "start", + name, + fileCount: files.length, + totalChecks: STATIC_RULES.length + (input.useAi ? AI_RULES.length : 0), + }); + + let cumulative = 0; + try { + const result = await analyzeSkill(files, input.useAi, async (event) => { + if (event.type === "ai-start") { + write({ type: "ai-start" }); + return; + } + cumulative += event.checkpoint.scoreDelta; + write({ + type: "checkpoint", + checkpoint: event.checkpoint, + runningScore: Math.min(100, cumulative), + }); + if (!aborted) await delay(STREAM_PACING_MS); + }); + + const { scan } = await persistScan(input, name, files, result); + + write({ + type: "done", + scanId: scan.id, + riskScore: result.riskScore, + verdict: result.verdict, + findingCounts: result.counts, + aiUsed: result.aiUsed, + aiError: result.aiError, + }); + if (!aborted && !res.writableEnded) res.end(); + } catch (err) { + logger.error({ err }, "Streaming-Scan fehlgeschlagen"); + write({ type: "error", message: "Die Analyse ist fehlgeschlagen." }); + if (!aborted && !res.writableEnded) res.end(); + } }); router.get("/scans/:id", async (req, res) => { @@ -207,12 +331,7 @@ router.get("/scans/:id", async (req, res) => { .where(eq(findingsTable.scanId, scan.id)) .orderBy(findingsTable.id); - const payload = { - ...serializeScan(scan), - files: files.map(serializeFile), - findings: findings.map(serializeFinding), - }; - return res.json(GetScanResponse.parse(payload)); + return res.json(GetScanResponse.parse(serializeScanDetail(scan, files, findings))); }); router.delete("/scans/:id", async (req, res) => { diff --git a/artifacts/skillguard/public/opengraph.jpg b/artifacts/skillguard/public/opengraph.jpg index 01f6431..38feab2 100644 Binary files a/artifacts/skillguard/public/opengraph.jpg and b/artifacts/skillguard/public/opengraph.jpg differ diff --git a/artifacts/skillguard/src/components/ui-helpers.tsx b/artifacts/skillguard/src/components/ui-helpers.tsx index 2e7b773..04b137c 100644 --- a/artifacts/skillguard/src/components/ui-helpers.tsx +++ b/artifacts/skillguard/src/components/ui-helpers.tsx @@ -1,5 +1,27 @@ import { Badge } from "@/components/ui/badge"; -import { ShieldCheck, ShieldAlert, Shield, AlertTriangle, Info, AlertCircle, AlertOctagon } from "lucide-react"; +import { ShieldCheck, ShieldAlert, Shield, AlertTriangle, Info, AlertCircle, AlertOctagon, CheckCircle2, MinusCircle, XCircle } from "lucide-react"; + +export const CHECKPOINT_STATUS_LABELS: Record = { + pass: "Unauffällig", + flagged: "Auffällig", + skipped: "Übersprungen", + error: "Fehler", +}; + +export function CheckpointStatusBadge({ status, className }: { status: string, className?: string }) { + switch (status) { + case "pass": + return Unauffällig; + case "flagged": + return Auffällig; + case "skipped": + return Übersprungen; + case "error": + return Fehler; + default: + return {status}; + } +} export function VerdictBadge({ verdict, className }: { verdict: string, className?: string }) { switch (verdict) { diff --git a/artifacts/skillguard/src/lib/streamScan.ts b/artifacts/skillguard/src/lib/streamScan.ts new file mode 100644 index 0000000..bb40031 --- /dev/null +++ b/artifacts/skillguard/src/lib/streamScan.ts @@ -0,0 +1,119 @@ +import type { + SkillScanInput, + ScanCheckpoint, + FindingCounts, +} from "@workspace/api-client-react"; + +export type ScanStreamEvent = + | { type: "start"; name: string; fileCount: number; totalChecks: number } + | { type: "ai-start" } + | { type: "checkpoint"; checkpoint: ScanCheckpoint; runningScore: number } + | { + type: "done"; + scanId: number; + riskScore: number; + verdict: string; + findingCounts: FindingCounts; + aiUsed: boolean; + aiError: string | null; + } + | { type: "error"; message: string }; + +/** + * Fehler beim Aufruf des Streaming-Endpunkts. `allowFallback` zeigt an, ob ein + * Rückfall auf den nicht-streamenden Endpunkt sinnvoll ist. Bei Client-Fehlern + * (4xx, z. B. ungültige Eingabe) ist das nicht der Fall – die Servermeldung + * soll direkt angezeigt werden, statt dieselbe Anfrage erneut zu senden. + */ +export class ScanStreamError extends Error { + allowFallback: boolean; + constructor(message: string, allowFallback: boolean) { + super(message); + this.name = "ScanStreamError"; + this.allowFallback = allowFallback; + } +} + +/** + * Streamt einen Skill-Scan über NDJSON. Ruft `onEvent` für jedes Ereignis auf, + * sobald es eintrifft (Live-Prüfschritte). Wirft bei Transportfehlern; ein vom + * Server gemeldeter Analysefehler kommt als `{ type: "error" }`-Ereignis an. + * + * Nutzt – wie der generierte API-Client – eine wurzelrelative `/api`-URL, damit + * der Replit-Pfad-Proxy die Anfrage korrekt an den API-Server weiterleitet. + */ +export async function streamScan( + input: SkillScanInput, + onEvent: (event: ScanStreamEvent) => void, + signal?: AbortSignal, +): Promise { + let res: Response; + try { + res = await fetch("/api/scans/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + signal, + }); + } catch (err) { + // Verbindung kam nicht zustande – der Server hat die Anfrage nicht erhalten. + // Rückfall auf den nicht-streamenden Endpunkt ist sicher und sinnvoll. + throw new ScanStreamError( + err instanceof Error ? err.message : "Verbindung zum Server fehlgeschlagen.", + true, + ); + } + + if (!res.ok || !res.body) { + let message = "Live-Analyse nicht verfügbar."; + try { + const body = await res.json(); + if (body && typeof body.message === "string") message = body.message; + } catch { + // ignore – Standardmeldung verwenden + } + // 4xx = Client-/Eingabefehler: Meldung direkt anzeigen, kein erneuter Versuch. + // 5xx oder fehlender Body: Rückfall auf den nicht-streamenden Endpunkt erlauben. + const isClientError = res.status >= 400 && res.status < 500; + throw new ScanStreamError(message, !isClientError); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + const emit = (line: string) => { + const trimmed = line.trim(); + if (!trimmed) return; + try { + onEvent(JSON.parse(trimmed) as ScanStreamEvent); + } catch { + // Unvollständige / nicht-JSON Zeile ignorieren + } + }; + + // Ab hier verarbeitet der Server die Anfrage und persistiert das Ergebnis auch + // bei Verbindungsabbruch. Ein Fehler beim Lesen darf daher NICHT zum Rückfall + // führen, sonst entsteht ein doppelter Scan-Eintrag. + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let newlineIndex: number; + while ((newlineIndex = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + emit(line); + } + } + emit(buffer); + } catch (err) { + throw new ScanStreamError( + err instanceof Error && err.name === "AbortError" + ? "Die Analyse wurde abgebrochen." + : "Die Live-Übertragung wurde unterbrochen. Der Scan läuft möglicherweise im Hintergrund weiter – bitte prüfen Sie den Verlauf.", + false, + ); + } +} diff --git a/artifacts/skillguard/src/pages/scan-form.tsx b/artifacts/skillguard/src/pages/scan-form.tsx index 807bd9b..e08686b 100644 --- a/artifacts/skillguard/src/pages/scan-form.tsx +++ b/artifacts/skillguard/src/pages/scan-form.tsx @@ -1,6 +1,11 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useLocation } from "wouter"; -import { useCreateScan, SkillScanInputSource } from "@workspace/api-client-react"; +import { + useCreateScan, + SkillScanInputSource, + type SkillScanInput, + type ScanCheckpoint, +} from "@workspace/api-client-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -8,26 +13,126 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Switch } from "@/components/ui/switch"; -import { FileUp, FileText, Type, Loader2, ShieldCheck } from "lucide-react"; +import { Progress } from "@/components/ui/progress"; +import { Badge } from "@/components/ui/badge"; +import { CheckpointStatusBadge, AxisBadge } from "@/components/ui-helpers"; +import { + FileUp, + FileText, + Type, + Loader2, + ShieldCheck, + Sparkles, + Activity, + CheckCircle2, +} from "lucide-react"; import { useToast } from "@/hooks/use-toast"; +import { streamScan, ScanStreamError, type ScanStreamEvent } from "@/lib/streamScan"; + +type Phase = "idle" | "scanning" | "done"; + +function scoreColor(score: number): string { + if (score < 30) return "text-emerald-500"; + if (score < 70) return "text-amber-500"; + return "text-rose-500"; +} + +function deltaLabel(checkpoint: ScanCheckpoint): string { + if (checkpoint.status === "skipped") return "übersprungen"; + if (checkpoint.scoreDelta > 0) return `+${checkpoint.scoreDelta} Punkte`; + return "0 Punkte"; +} export default function ScanForm() { const [, setLocation] = useLocation(); const { toast } = useToast(); const createScan = useCreateScan(); - + const [sourceType, setSourceType] = useState("file"); const [name, setName] = useState(""); const [useAi, setUseAi] = useState(false); const [file, setFile] = useState(null); const [text, setText] = useState(""); + const [phase, setPhase] = useState("idle"); + const [steps, setSteps] = useState([]); + const [runningScore, setRunningScore] = useState(0); + const [totalChecks, setTotalChecks] = useState(0); + const [aiActive, setAiActive] = useState(false); + const [finalVerdict, setFinalVerdict] = useState(null); + + const isBusy = phase === "scanning" || phase === "done" || createScan.isPending; + + const groupedSteps = useMemo(() => { + const order: string[] = []; + const groups = new Map(); + for (const step of steps) { + if (!groups.has(step.category)) { + groups.set(step.category, []); + order.push(step.category); + } + groups.get(step.category)!.push(step); + } + return order.map((category) => ({ category, steps: groups.get(category)! })); + }, [steps]); + const handleFileChange = (e: React.ChangeEvent) => { if (e.target.files && e.target.files[0]) { setFile(e.target.files[0]); } }; + const buildInput = async (): Promise => { + let contentBase64: string | undefined = undefined; + let filename: string | undefined = undefined; + + if (file && (sourceType === "file" || sourceType === "zip")) { + const reader = new FileReader(); + const base64Promise = new Promise((resolve, reject) => { + reader.onload = () => { + const result = reader.result as string; + const base64 = result.split(",")[1] || result; + resolve(base64); + }; + reader.onerror = reject; + }); + reader.readAsDataURL(file); + contentBase64 = await base64Promise; + filename = file.name; + } + + return { + name: name || undefined, + source: sourceType, + useAi, + contentBase64, + filename, + text: sourceType === "text" ? text : undefined, + }; + }; + + const finishWithScan = (scanId: number) => { + setPhase("done"); + window.setTimeout(() => { + toast({ title: "Scan abgeschlossen", description: "Der Bericht wird geöffnet." }); + setLocation(`/berichte/${scanId}`); + }, 900); + }; + + const runNonStreaming = async (input: SkillScanInput) => { + try { + const data = await createScan.mutateAsync({ data: input }); + finishWithScan(data.id); + } catch (err) { + setPhase("idle"); + toast({ + title: "Fehler", + description: "Der Scan konnte nicht durchgeführt werden.", + variant: "destructive", + }); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -40,49 +145,193 @@ export default function ScanForm() { return; } + let input: SkillScanInput; try { - let contentBase64: string | undefined = undefined; - let filename: string | undefined = undefined; + input = await buildInput(); + } catch { + toast({ title: "Fehler", description: "Beim Verarbeiten der Datei ist ein Fehler aufgetreten.", variant: "destructive" }); + return; + } - if (file && (sourceType === "file" || sourceType === "zip")) { - const reader = new FileReader(); - const base64Promise = new Promise((resolve, reject) => { - reader.onload = () => { - const result = reader.result as string; - // Strip data URL prefix - const base64 = result.split(',')[1] || result; - resolve(base64); - }; - reader.onerror = reject; - }); - reader.readAsDataURL(file); - contentBase64 = await base64Promise; - filename = file.name; - } + setPhase("scanning"); + setSteps([]); + setRunningScore(0); + setTotalChecks(0); + setAiActive(false); + setFinalVerdict(null); - createScan.mutate({ - data: { - name: name || undefined, - source: sourceType, - useAi, - contentBase64, - filename, - text: sourceType === "text" ? text : undefined - } - }, { - onSuccess: (data) => { - toast({ title: "Scan erfolgreich", description: "Der Skill wurde erfolgreich analysiert." }); - setLocation(`/berichte/${data.id}`); - }, - onError: (err) => { - toast({ title: "Fehler", description: "Der Scan konnte nicht durchgeführt werden.", variant: "destructive" }); + let outcome: "done" | "error" | null = null; + let doneScanId: number | null = null; + let errorMessage = "Die Analyse ist fehlgeschlagen."; + + try { + await streamScan(input, (event: ScanStreamEvent) => { + switch (event.type) { + case "start": + setTotalChecks(event.totalChecks); + break; + case "ai-start": + setAiActive(true); + break; + case "checkpoint": + setSteps((prev) => [...prev, event.checkpoint]); + setRunningScore(event.runningScore); + if (event.checkpoint.detectedBy === "ai") setAiActive(false); + break; + case "done": + outcome = "done"; + doneScanId = event.scanId; + setRunningScore(event.riskScore); + setFinalVerdict(event.verdict); + break; + case "error": + outcome = "error"; + errorMessage = event.message; + break; } }); } catch (err) { - toast({ title: "Fehler", description: "Beim Verarbeiten der Datei ist ein Fehler aufgetreten.", variant: "destructive" }); + // Rückfall nur, wenn der Server die Anfrage nicht verarbeitet hat + // (Verbindung kam nicht zustande oder 5xx). Bei Client-Fehlern (4xx) oder + // einem Abbruch während des Streamings persistiert der Server bereits – + // ein erneuter Versuch würde einen doppelten Eintrag erzeugen. + const canFallback = err instanceof ScanStreamError ? err.allowFallback : true; + if (canFallback) { + await runNonStreaming(input); + return; + } + setPhase("idle"); + toast({ + title: "Fehler", + description: err instanceof Error ? err.message : "Die Analyse ist fehlgeschlagen.", + variant: "destructive", + }); + return; + } + + if (outcome === "done" && doneScanId != null) { + finishWithScan(doneScanId); + } else if (outcome === "error") { + setPhase("idle"); + toast({ title: "Fehler", description: errorMessage, variant: "destructive" }); + } else { + // Stream endete ohne Abschluss-Ereignis: Fallback auf klassischen Scan. + await runNonStreaming(input); } }; + if (phase === "scanning" || phase === "done") { + const completed = steps.length; + const progressPct = totalChecks > 0 ? (completed / totalChecks) * 100 : 0; + return ( +
+
+

+ {phase === "done" ? "Analyse abgeschlossen" : "Analyse läuft"} +

+

+ {phase === "done" + ? "Alle Prüfschritte wurden ausgewertet. Der Bericht wird geöffnet." + : "Verfolgen Sie jeden Prüfschritt und seine Teilbewertung in Echtzeit."} +

+
+ + + +
+ + {phase === "done" ? ( + + ) : ( + + )} + Live-Risiko + +
+ + {runningScore} + + / 100 +
+
+
+ +
+
+ Prüfschritte + + {completed}{totalChecks > 0 ? ` / ${totalChecks}` : ""} + +
+ +
+ + {aiActive && ( +
+ + KI-Analyse läuft – semantische Prüfung der Instruktionen... +
+ )} + {finalVerdict && ( +
+ Vorläufiges Ergebnis:{" "} + + {finalVerdict === "pass" + ? "Freigabe" + : finalVerdict === "review" + ? "Manuelle Prüfung" + : "Blockieren"} + +
+ )} +
+
+ +
+ {groupedSteps.map((group) => ( + + + + {group.category} + + + + {group.steps.map((step) => ( +
+
+ + {step.label} + {step.axis && } + + {step.detectedBy === "ai" ? "KI" : "Statisch"} + +
+ 0 ? "text-rose-600 dark:text-rose-400" : "text-muted-foreground" + }`} + > + {deltaLabel(step)} + +
+ ))} +
+
+ ))} + {groupedSteps.length === 0 && ( +
+ + Initialisiere Prüfung... +
+ )} +
+
+ ); + } + return (
@@ -99,9 +348,9 @@ export default function ScanForm() {
- setName(e.target.value)} /> @@ -113,7 +362,7 @@ export default function ScanForm() { ZIP-Archiv Text - +
@@ -126,9 +375,9 @@ export default function ScanForm() { -