Files
nextcraft/apps/web/components/learner/agent-stream-panel.tsx
T
CIAgent 135ea21a61 merge: hotfix/fresh-box-experience → main (v0.3.5 fresh-box experience)
Fixes the v0.3.4 fresh-box failures: silent CLI under bare-word PATH
invocation (SEA argv detection), bootstrap dying on venv creation without an
actionable hint (poisoned-partial-venv recovery + apt hint + doctor
venv-capability probe + preflight), installer falsely 'verifying' a silent
binary, and localhost-only server binding (network mode: 0.0.0.0 + wildcard
CORS + hostname-derived API URL — remote browsing zero-config).

38 CLI tests, 3 web tests, 409 ai-service tests green; build/typecheck/lint
clean.

---ci---
phase: hotfix
milestone: v0.4
status: complete
type: hotfix
requirements:
  covered: [REQ-4-001, REQ-4-002, REQ-4-003, REQ-4-004, REQ-4-005]
  partial: []
---/ci---
2026-09-13 00:05:37 +00:00

192 lines
6.3 KiB
TypeScript

'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { parseSseEvents } from '../../lib/sse';
import { Bot, RefreshCw, AlertTriangle, Loader2 } from 'lucide-react';
import { engineBaseUrl } from '../../lib/engine-base-url';
const AI_SERVICE_URL = engineBaseUrl();
interface StreamPanelProps {
title: string;
endpoint: string; // e.g. "/v1/lab/feedback"
body: Record<string, unknown>;
autoLoad?: boolean;
emptyHint?: string;
/** Renders structured JSON results (assessor/proctor) as custom UI */
renderJson?: (data: Record<string, unknown>) => React.ReactNode;
}
interface SseResult {
text: string;
error: string | null;
streaming: boolean;
}
/**
* Generic SSE-consuming panel for the non-chat agent endpoints
* (lab feedback, mentor narrative, assessor/proctor JSON).
* Streams text panels; renders structured JSON via renderJson when set.
*/
export function AgentStreamPanel({
title,
endpoint,
body,
autoLoad = false,
emptyHint,
renderJson,
}: StreamPanelProps) {
const [result, setResult] = useState<SseResult>({ text: '', error: null, streaming: false });
const [json, setJson] = useState<Record<string, unknown> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const startedRef = useRef(false);
const load = useCallback(async () => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setResult({ text: '', error: null, streaming: true });
setJson(null);
try {
const response = await fetch(`${AI_SERVICE_URL}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
});
const contentType = response.headers.get('content-type') ?? '';
if (!response.ok) {
throw new Error(`AI service error (${response.status})`);
}
if (contentType.includes('application/json')) {
const data = (await response.json()) as Record<string, unknown>;
setJson(data);
setResult({ text: '', error: null, streaming: false });
return;
}
if (!response.body) {
throw new Error('AI service returned an empty stream');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let text = '';
let done = false;
while (!done) {
const { value, done: readerDone } = await reader.read();
if (readerDone) break;
buffer += decoder.decode(value, { stream: true });
const { events, rest } = parseSseEvents(buffer);
buffer = rest;
for (const raw of events) {
if (raw === '[DONE]') {
done = true;
break;
}
try {
const event = JSON.parse(raw);
if (event.type === 'delta') {
text += event.content as string;
setResult({ text, error: null, streaming: true });
} else if (event.type === 'error') {
setResult({ text, error: event.message as string, streaming: false });
done = true;
break;
}
} catch {
// ignore non-JSON frames
}
}
}
setResult((prev) => ({ ...prev, streaming: false }));
} catch (err) {
const aborted = err instanceof DOMException && err.name === 'AbortError';
if (!aborted) {
setResult({
text: '',
error: err instanceof Error ? err.message : 'connection failed',
streaming: false,
});
} else {
setResult((prev) => ({ ...prev, streaming: false }));
}
} finally {
abortRef.current = null;
}
}, [endpoint, body]);
useEffect(() => {
return () => {
abortRef.current?.abort();
abortRef.current = null;
};
}, []);
useEffect(() => {
if (autoLoad && !startedRef.current) {
startedRef.current = true;
void load();
}
}, [autoLoad, load]);
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h3 className="inline-flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-slate-100">
<Bot className="h-4 w-4 text-primary-600 dark:text-primary-400" />
{title}
</h3>
<button
onClick={() => void load()}
disabled={result.streaming}
className="inline-flex items-center gap-1 rounded-md border border-slate-300 px-2 py-1 text-xs text-slate-600 transition-colors hover:border-primary-400 hover:text-primary-700 disabled:opacity-50 dark:border-slate-600 dark:text-slate-300 dark:hover:border-primary-400 dark:hover:text-primary-300"
>
{result.streaming ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
{result.streaming ? 'Streaming…' : json || result.text ? 'Regenerate' : 'Generate'}
</button>
</div>
{result.error && (
<div
role="alert"
className="flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div>
<p className="font-medium">AI service unavailable</p>
<p className="opacity-80">{result.error}</p>
<button onClick={() => void load()} className="mt-1 font-semibold underline">
Retry
</button>
</div>
</div>
)}
{!result.error && !json && !result.text && !result.streaming && (
<p className="text-xs text-slate-500 dark:text-slate-400">
{emptyHint ?? 'Generate to see the agent in action.'}
</p>
)}
{json && renderJson ? (
renderJson(json)
) : result.text ? (
<div className="whitespace-pre-wrap rounded-md bg-slate-100 px-3 py-2 text-sm leading-relaxed text-slate-800 dark:bg-slate-800 dark:text-slate-100">
{result.text}
{result.streaming && (
<span className="ml-0.5 inline-block h-4 w-1.5 animate-pulse rounded-sm bg-primary-500 align-middle" />
)}
</div>
) : null}
</div>
);
}