88a1dab810
---ci--- phase: 7 milestone: v0.2 status: complete requirements: covered: [REQ-2-001, REQ-2-002, REQ-2-003, REQ-2-004, REQ-2-005, REQ-2-006, REQ-2-007, REQ-2-008, REQ-2-009, REQ-2-010, REQ-2-011, REQ-2-012] partial: [] ---/ci--- Milestone v0.2 (ai-tutor-architecture) merged to main. Escalation record (audit remediation, durable): P1 executor delegation failed twice (empty subagent results, zero files created); auto-resolved at full autonomy to inline execution with identical plan fidelity (commit 3271373, reflog-only after phase branch squash-delete).
191 lines
6.3 KiB
TypeScript
191 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';
|
|
|
|
const AI_SERVICE_URL =
|
|
process.env.NEXT_PUBLIC_AI_SERVICE_URL ?? 'http://localhost:8420';
|
|
|
|
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>
|
|
);
|
|
} |