docs(P06): complete learner-surface-integration phase

---ci---
phase: 6
milestone: v0.2
status: complete
---/ci---

REQ-2-011/012 complete. All four learner surfaces stream from the
real ai-service: dashboard chat (Coach/Tutor switcher, agent-scoped
sessions), byte viewer (Tutor), sandbox (Lab), defend (Assessor
rubric bars + Proctor banner), dashboard Mentor panel. Error states
with retry everywhere; ping-frame immune SSE parser; 131/131 tests,
tsc + next build green, E2E smoke 7/7.
This commit is contained in:
CIAgent
2026-09-11 16:36:21 +00:00
parent 3ad788b579
commit 004adbf815
14 changed files with 835 additions and 88 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
{
"phase": 5,
"phase": 6,
"stage": "verify",
"milestone": "v0.2",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-09-11T21:10:00Z"
"updated_at": "2026-09-11T22:40:00Z"
}
+2
View File
@@ -0,0 +1,2 @@
# AI service (v0.2) — learner chat/panels stream from this FastAPI service
NEXT_PUBLIC_AI_SERVICE_URL=http://localhost:8420
@@ -11,7 +11,8 @@ import {
Activity,
} from 'lucide-react';
import { Button } from '@nextcraft/ui';
import { allCompetencies, competencyStacks } from '@nextcraft/mock-data';
import { allCompetencies, competencyStacks, aiLabScenarios } from '@nextcraft/mock-data';
import { LabFeedbackPanel } from '../../../../components/learner/lab-feedback-panel';
interface FileEntry {
label: string;
@@ -279,6 +280,10 @@ export default async function BuildSandboxPage({
))}
</ul>
</div>
{/* Lab in-flow feedback — mock telemetry scenario (real engine v0.3+) */}
<div className="border-t border-slate-200 pt-3 dark:border-slate-800">
<LabFeedbackPanel scenarioId={aiLabScenarios[0].id} />
</div>
</aside>
</div>
+17 -1
View File
@@ -19,6 +19,7 @@ import {
learnerMicrocredentials,
} from '@nextcraft/mock-data';
import { AiTutorChat } from '../../../components/learner/ai-tutor-chat';
import { MentorPanel } from '../../../components/learner/mentor-panel';
import { ProgressGraph } from '../../../components/learner/progress-graph';
const ACTIVE_COMPETENCY_IDS = [
@@ -265,7 +266,7 @@ export default function DashboardPage() {
AI Tutor
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Coach and Socratic tutor · mock responses
Coach and Socratic tutor · live streaming
</p>
</div>
</div>
@@ -274,6 +275,21 @@ export default function DashboardPage() {
<AiTutorChat />
</CardBody>
</Card>
{/* Mentor — long-horizon career narrative */}
<Card>
<CardHeader>
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">
Mentor
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Long-horizon career trajectory · live streaming
</p>
</CardHeader>
<CardBody>
<MentorPanel />
</CardBody>
</Card>
</div>
);
}
@@ -16,6 +16,9 @@ import {
import { Card, CardBody, CardHeader, Badge, Button } from '@nextcraft/ui';
import { allCompetencies, competencyStacks } from '@nextcraft/mock-data';
import { OralDefenseInterface } from '../../../../components/learner/oral-defense-interface';
import { AssessorResultsPanel } from '../../../../components/learner/assessor-results-panel';
import { ProctorBanner } from '../../../../components/learner/proctor-banner';
import { aiArtifactSubmissions } from '@nextcraft/mock-data';
const RUBRIC = [
{ name: 'Correctness of agent architecture', passed: true, weight: 25 },
@@ -254,10 +257,22 @@ export default async function DefensePage({
a structured-output schema and re-run the eval harness before your oral defense.
</p>
</div>
{/* Live Assessor — structured rubric from the real agent (mock inputs) */}
<div className="border-t border-slate-200 pt-3 dark:border-slate-800">
<AssessorResultsPanel artifactId={aiArtifactSubmissions[0].id} />
</div>
</CardBody>
</Card>
</div>
{/* Proctor integrity banner — coaching-shaped (mock telemetry) */}
<Card>
<CardBody>
<ProctorBanner scenarioId="proctor-scenario-distracted" />
</CardBody>
</Card>
{/* Process trace timeline */}
<Card>
<CardHeader>
@@ -4,6 +4,7 @@ import { ArrowLeft, ArrowRight, Clock } from 'lucide-react';
import { Button, Card, CardBody, Badge } from '@nextcraft/ui';
import { allCompetencies, competencyStacks } from '@nextcraft/mock-data';
import { WorkedExampleTabs } from '../../../../components/learner/worked-example-tabs';
import { ByteTutorPanel } from '../../../../components/learner/byte-tutor-panel';
export default async function ByteTutorialPage({
params,
@@ -39,6 +40,12 @@ export default async function ByteTutorialPage({
</header>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Tutor explanation panel — agent fixed to tutor (A-007) */}
<Card>
<CardBody>
<ByteTutorPanel competencyName={competency.name} />
</CardBody>
</Card>
{/* Concept panel */}
<Card className="flex flex-col">
<CardBody className="flex flex-col gap-4">
@@ -0,0 +1,208 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
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;
}
function parseSseEvents(buffer: string): { events: string[]; rest: string } {
const events: string[] = [];
let rest = buffer;
const separatorIndex = rest.lastIndexOf('\n\n');
if (separatorIndex === -1) return { events, rest };
const complete = rest.slice(0, separatorIndex);
rest = rest.slice(separatorIndex + 2);
for (const frame of complete.split('\n\n')) {
const dataLines = frame
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart());
if (dataLines.length === 0) continue; // ping frame — ignore (G-1)
events.push(dataLines.join('\n'));
}
return { events, rest };
}
/**
* 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>
);
}
+112 -84
View File
@@ -1,130 +1,110 @@
'use client';
import { useState, useRef, useEffect, type FormEvent } from 'react';
import { Bot, Send, User } from 'lucide-react';
import { aiTutorResponses, type TutorResponse } from '@nextcraft/mock-data';
import { Bot, Send, AlertTriangle, RotateCcw } from 'lucide-react';
import { primaryLearner } from '@nextcraft/mock-data';
import { Avatar } from '@nextcraft/ui';
import { useChatStream, type AgentName, type StreamMessage } from '../../hooks/use-chat-stream';
interface ChatMessage {
id: string;
role: 'learner' | 'tutor';
content: string;
suggestedActions?: string[];
}
const SEED_MESSAGES: ChatMessage[] = [
{
id: 'seed-1',
role: 'tutor',
content:
"Welcome back, Alex. You're 62% through the AI Orchestration stack. What would you like to work on today?",
suggestedActions: ['Review my pacing', 'Start Multi-Agent Communication', 'Prep for my defense'],
},
const AGENTS: { id: AgentName; label: string; blurb: string }[] = [
{ id: 'coach', label: 'Coach', blurb: 'Pacing, motivation, retrieval practice' },
{ id: 'tutor', label: 'Tutor', blurb: 'Concepts, worked examples, Socratic checks' },
];
const SEED_MESSAGE: StreamMessage = {
id: 'seed-1',
role: 'assistant',
content:
"Welcome back, Alex. You're 62% through the AI Orchestration stack. What would you like to work on today?",
suggestedActions: ['Review my pacing', 'Start Multi-Agent Communication', 'Prep for my defense'],
};
export function AiTutorChat() {
const [messages, setMessages] = useState<ChatMessage[]>(SEED_MESSAGES);
const [agent, setAgent] = useState<AgentName>('coach');
const [input, setInput] = useState('');
const [isTyping, setIsTyping] = useState(false);
const { messages, isStreaming, error, send, retry, abort } = useChatStream(agent);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, isTyping]);
}, [messages, isStreaming, error]);
function send(e: FormEvent) {
function handleSubmit(e: FormEvent) {
e.preventDefault();
const text = input.trim();
if (!text || isTyping) return;
const learnerMsg: ChatMessage = { id: `me-${Date.now()}`, role: 'learner', content: text };
setMessages((prev) => [...prev, learnerMsg]);
if (!text || isStreaming) return;
setInput('');
setIsTyping(true);
void send(text);
}
window.setTimeout(() => {
const pick: TutorResponse =
aiTutorResponses[Math.floor(Math.random() * aiTutorResponses.length)];
const tutorMsg: ChatMessage = {
id: `tutor-${Date.now()}`,
role: 'tutor',
content: pick.message,
suggestedActions: pick.suggestedActions,
};
setMessages((prev) => [...prev, tutorMsg]);
setIsTyping(false);
}, 1000);
function switchAgent(next: AgentName) {
if (isStreaming) abort();
setAgent(next);
}
return (
<div className="flex h-[28rem] flex-col">
{/* Agent switcher (A-007: explicit routing, no autonomy) */}
<div className="mb-3 flex items-center gap-2" role="tablist" aria-label="Choose tutor agent">
{AGENTS.map((a) => (
<button
key={a.id}
role="tab"
aria-selected={agent === a.id}
onClick={() => switchAgent(a.id)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
agent === a.id
? 'bg-primary-600 text-white'
: 'border border-slate-300 text-slate-600 hover:border-primary-400 hover:text-primary-700 dark:border-slate-600 dark:text-slate-300 dark:hover:text-primary-300'
}`}
>
{a.label}
</button>
))}
</div>
{/* Message list */}
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto pr-2">
<ChatMessage message={SEED_MESSAGE} />
{messages.map((m) => (
<div
key={m.id}
className={`flex gap-3 ${m.role === 'learner' ? 'flex-row-reverse' : 'flex-row'}`}
>
{m.role === 'tutor' ? (
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">
<Bot className="h-4 w-4" />
</span>
) : (
<Avatar name={primaryLearner.name} src={primaryLearner.avatar} size="sm" />
)}
<div
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
m.role === 'tutor'
? 'bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-100'
: 'bg-primary-600 text-white'
}`}
>
<p className="leading-relaxed">{m.content}</p>
{m.suggestedActions && m.suggestedActions.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{m.suggestedActions.map((action) => (
<button
key={action}
onClick={() => setInput(action)}
className="rounded-full border border-slate-300 bg-white px-2 py-0.5 text-xs text-slate-600 transition-colors hover:border-primary-400 hover:text-primary-700 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-primary-400 dark:hover:text-primary-300"
>
{action}
</button>
))}
</div>
)}
</div>
</div>
<ChatMessage key={m.id} message={m} onAction={(a) => setInput(a)} />
))}
{/* Typing indicator */}
{isTyping && (
<div className="flex flex-row gap-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">
<Bot className="h-4 w-4" />
{/* Error state with retry (A-010) */}
{error && (
<div
role="alert"
className="flex items-center gap-2 rounded-lg 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="h-4 w-4 shrink-0" />
<span className="flex-1">
The tutor service is unreachable. Your message can be retried.
</span>
<div className="flex items-center gap-1 rounded-lg bg-slate-100 px-3 py-3 dark:bg-slate-800">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" />
</div>
<button
onClick={retry}
className="inline-flex items-center gap-1 font-semibold underline"
>
<RotateCcw className="h-3 w-3" /> Retry
</button>
</div>
)}
</div>
{/* Input */}
<form onSubmit={send} className="mt-3 flex items-center gap-2 border-t border-slate-200 pt-3 dark:border-slate-800">
<form onSubmit={handleSubmit} className="mt-3 flex items-center gap-2 border-t border-slate-200 pt-3 dark:border-slate-800">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask your AI tutor anything…"
placeholder={`Ask your ${agent} anything…`}
aria-label="Message"
className="h-10 flex-1 rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-900 placeholder:text-slate-400 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
<button
type="submit"
disabled={!input.trim() || isTyping}
disabled={!input.trim() || isStreaming}
className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-primary-600 text-white transition-colors hover:bg-primary-700 disabled:opacity-50"
aria-label="Send message"
>
@@ -133,4 +113,52 @@ export function AiTutorChat() {
</form>
</div>
);
}
function ChatMessage({
message,
onAction,
}: {
message: StreamMessage;
onAction?: (text: string) => void;
}) {
const isAssistant = message.role === 'assistant';
return (
<div className={`flex gap-3 ${isAssistant ? 'flex-row' : 'flex-row-reverse'}`}>
{isAssistant ? (
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">
<Bot className="h-4 w-4" />
</span>
) : (
<Avatar name={primaryLearner.name} src={primaryLearner.avatar} size="sm" />
)}
<div
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
isAssistant
? 'bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-100'
: 'bg-primary-600 text-white'
}`}
>
<p className="leading-relaxed whitespace-pre-wrap">
{message.content}
{message.streaming && (
<span className="ml-0.5 inline-block h-4 w-1.5 animate-pulse rounded-sm bg-primary-500 align-middle" />
)}
</p>
{message.suggestedActions && message.suggestedActions.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{message.suggestedActions.map((action) => (
<button
key={action}
onClick={() => onAction?.(action)}
className="rounded-full border border-slate-300 bg-white px-2 py-0.5 text-xs text-slate-600 transition-colors hover:border-primary-400 hover:text-primary-700 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-primary-400 dark:hover:text-primary-300"
>
{action}
</button>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,101 @@
'use client';
import { AgentStreamPanel } from './agent-stream-panel';
import { AlertTriangle, CheckCircle2, CircleDashed } from 'lucide-react';
interface CriterionScore {
criterion_id: string;
name: string;
score: number;
evidence: string;
}
interface RubricScore {
rubric_id: string;
artifact_id: string;
competency_id: string;
scores: CriterionScore[];
strengths: string[];
gaps: string[];
verdict: string;
}
/**
* Assessment surface — Assessor rubric output (structured JSON) +
* Proctor integrity banner. Mock engine inputs; real engines v0.3+.
*/
export function AssessorResultsPanel({ artifactId }: { artifactId: string }) {
return (
<AgentStreamPanel
title="Assessor — rubric evaluation"
endpoint="/v1/assessment/evaluate"
body={{ artifact_id: artifactId }}
emptyHint="Run the Assessor to grade this artifact against its rubric."
renderJson={(data) => {
const score = data as unknown as RubricScore;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
{score.verdict === 'mastered' ? (
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
) : score.verdict === 'developing' ? (
<CircleDashed className="h-5 w-5 text-amber-600 dark:text-amber-400" />
) : (
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
)}
<span className="text-sm font-semibold capitalize text-slate-900 dark:text-slate-100">
{score.verdict}
</span>
</div>
<div className="flex flex-col gap-3">
{score.scores.map((c) => (
<div key={c.criterion_id}>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="font-medium text-slate-700 dark:text-slate-300">{c.name}</span>
<span className="text-slate-500 dark:text-slate-400">{c.score}/100</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
<div
className="h-full rounded-full bg-primary-500 transition-all"
style={{ width: `${c.score}%` }}
role="progressbar"
aria-valuenow={c.score}
aria-valuemin={0}
aria-valuemax={100}
aria-label={c.name}
/>
</div>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{c.evidence}</p>
</div>
))}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-emerald-700 dark:text-emerald-400">
Strengths
</h4>
<ul className="list-inside list-disc text-xs text-slate-600 dark:text-slate-300">
{score.strengths.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
</div>
<div>
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wide text-amber-700 dark:text-amber-400">
Gaps
</h4>
<ul className="list-inside list-disc text-xs text-slate-600 dark:text-slate-300">
{score.gaps.map((g) => (
<li key={g}>{g}</li>
))}
</ul>
</div>
</div>
</div>
);
}}
/>
);
}
@@ -0,0 +1,27 @@
'use client';
import { AgentStreamPanel } from './agent-stream-panel';
/**
* Byte viewer Tutor panel — "Explain this byte" streams a Socratic concept
* walkthrough for the current competency (agent fixed to tutor, A-007).
*/
export function ByteTutorPanel({ competencyName }: { competencyName: string }) {
return (
<AgentStreamPanel
title="Tutor — explain this byte"
endpoint="/v1/chat/stream"
body={{
agent: 'tutor',
session_id: `byte-${competencyName}`,
messages: [
{
role: 'user',
content: `Explain the byte "${competencyName}" — one concept, a worked example, then a question to check my understanding.`,
},
],
}}
emptyHint="Ask the Tutor to walk you through this byte concept step by step."
/>
);
}
@@ -0,0 +1,18 @@
'use client';
import { AgentStreamPanel } from './agent-stream-panel';
/**
* Sandbox Lab feedback panel — streams in-flow feedback for the selected
* mock telemetry scenario (real telemetry is v0.3+).
*/
export function LabFeedbackPanel({ scenarioId }: { scenarioId: string }) {
return (
<AgentStreamPanel
title="Lab — in-flow feedback"
endpoint="/v1/lab/feedback"
body={{ scenario_id: scenarioId }}
emptyHint="Run the Lab agent on this build session's telemetry."
/>
);
}
@@ -0,0 +1,18 @@
'use client';
import { AgentStreamPanel } from './agent-stream-panel';
/**
* Dashboard Mentor panel — streams a long-horizon career narrative
* tied to the learner's progress. Session-backed follow-ups in v0.3+ UI.
*/
export function MentorPanel() {
return (
<AgentStreamPanel
title="Mentor — your trajectory"
endpoint="/v1/mentor/narrative"
body={{ session_id: 'dashboard-mentor', prompt: 'Narrate my trajectory.' }}
emptyHint="Ask the Mentor where your competency progress is taking you."
/>
);
}
@@ -0,0 +1,60 @@
'use client';
import { AgentStreamPanel } from './agent-stream-panel';
import { ShieldCheck } from 'lucide-react';
interface IntegritySignal {
signal_type: string;
severity: 'low' | 'medium' | 'high';
note: string;
}
interface ProctorAssessment {
scenario_id: string;
signals: IntegritySignal[];
intervention: string;
summary: string;
}
const SEVERITY_STYLES: Record<string, string> = {
low: 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-300 dark:border-emerald-800',
medium: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-300 dark:border-amber-800',
high: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800',
};
/**
* Proctor integrity banner — supportive, coaching-shaped (never punitive).
*/
export function ProctorBanner({ scenarioId }: { scenarioId: string }) {
return (
<AgentStreamPanel
title="Proctor — integrity support"
endpoint="/v1/proctor/signals"
body={{ scenario_id: scenarioId }}
emptyHint="Run the Proctor to review this session's integrity signals."
renderJson={(data) => {
const assessment = data as unknown as ProctorAssessment;
return (
<div className="flex flex-col gap-3">
<p className="text-xs text-slate-600 dark:text-slate-300">{assessment.summary}</p>
<div className="flex flex-wrap gap-1.5">
{assessment.signals.map((s, i) => (
<span
key={i}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs ${SEVERITY_STYLES[s.severity] ?? SEVERITY_STYLES.low}`}
title={s.note}
>
<ShieldCheck className="h-3 w-3" />
{s.signal_type} · {s.severity}
</span>
))}
</div>
<p className="rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-800 dark:bg-primary-900/30 dark:text-primary-200">
<strong>Suggested next step:</strong> {assessment.intervention}
</p>
</div>
);
}}
/>
);
}
+242
View File
@@ -0,0 +1,242 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
const AI_SERVICE_URL =
process.env.NEXT_PUBLIC_AI_SERVICE_URL ?? 'http://localhost:8420';
export type AgentName = 'coach' | 'tutor' | 'lab' | 'assessor' | 'proctor' | 'mentor';
export interface StreamMessage {
id: string;
role: 'user' | 'assistant';
content: string;
agent?: AgentName;
streaming?: boolean;
suggestedActions?: string[];
}
interface StreamState {
messages: StreamMessage[];
isStreaming: boolean;
error: string | null;
model: string | null;
}
interface ChatStreamEvent {
type: 'meta' | 'delta' | 'done' | 'error';
[key: string]: unknown;
}
function parseSseEvents(buffer: string): { events: string[]; rest: string } {
// SSE frames are separated by a blank line (\n\n). Multiple `data:` lines
// within one frame are joined with \n per the SSE spec. Frames with no
// data lines (sse-starlette `: ping` keep-alive comments) yield no events.
const events: string[] = [];
const frames: string[] = [];
let rest = buffer;
const separatorIndex = rest.lastIndexOf('\n\n');
if (separatorIndex === -1) return { events, rest };
const complete = rest.slice(0, separatorIndex);
rest = rest.slice(separatorIndex + 2);
for (const frame of complete.split('\n\n')) {
const dataLines = frame
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart());
if (dataLines.length === 0) continue; // ping/comment frame — ignore (G-1)
events.push(dataLines.join('\n'));
}
return { events, rest };
}
function decodeEvent(raw: string): ChatStreamEvent | '[DONE]' | null {
if (raw === '[DONE]') return '[DONE]';
try {
const parsed = JSON.parse(raw);
if (typeof parsed?.type === 'string') return parsed as ChatStreamEvent;
// OpenAI-shaped chunks (id/choices) are not used by our envelope;
// ignore anything without a type.
return null;
} catch {
return null;
}
}
export function useChatStream(agent: AgentName) {
const [state, setState] = useState<StreamState>({
messages: [],
isStreaming: false,
error: null,
model: null,
});
const abortRef = useRef<AbortController | null>(null);
// Agent-scoped sessions (A-007/D-019): switching agents starts a NEW
// session per agent — no persona bleed across switcher flips.
const sessionsRef = useRef<Partial<Record<AgentName, string>>>({});
if (!sessionsRef.current[agent]) {
const uuid =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: String(Date.now());
sessionsRef.current[agent] = `${agent}-${uuid}`;
}
// Idempotent abort + cleanup on unmount or agent switch (Strict Mode safe)
const abort = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
}, []);
useEffect(() => {
return () => {
abortRef.current?.abort();
abortRef.current = null;
};
}, []);
const send = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed || abortRef.current) return;
const userMessage: StreamMessage = {
id: `user-${Date.now()}`,
role: 'user',
content: trimmed,
};
const assistantId = `assistant-${Date.now()}`;
setState((prev) => ({
...prev,
messages: [...prev.messages, userMessage],
isStreaming: true,
error: null,
}));
const controller = new AbortController();
abortRef.current = controller;
try {
const response = await fetch(`${AI_SERVICE_URL}/v1/chat/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agent,
session_id: sessionsRef.current[agent],
messages: [{ role: 'user', content: trimmed }],
}),
signal: controller.signal,
});
if (!response.ok || !response.body) {
throw new Error(`AI service unavailable (${response.status})`);
}
setState((prev) => ({
...prev,
messages: [
...prev.messages,
{ id: assistantId, role: 'assistant', content: '', agent, streaming: true },
],
}));
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
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) {
const event = decodeEvent(raw);
if (event === null) continue;
if (event === '[DONE]') {
done = true;
setState((prev) => ({
...prev,
isStreaming: false,
messages: prev.messages.map((m) =>
m.id === assistantId ? { ...m, streaming: false } : m,
),
}));
break;
}
if (event.type === 'meta') {
setState((prev) => ({ ...prev, model: (event.model as string) ?? null }));
} else if (event.type === 'delta') {
const content = event.content as string;
setState((prev) => ({
...prev,
messages: prev.messages.map((m) =>
m.id === assistantId ? { ...m, content: m.content + content } : m,
),
}));
} else if (event.type === 'error') {
setState((prev) => ({
...prev,
error: (event.message as string) ?? 'stream error',
}));
}
}
}
setState((prev) => ({
...prev,
isStreaming: false,
messages: prev.messages.map((m) =>
m.id === assistantId ? { ...m, streaming: false } : m,
),
}));
} catch (err) {
const aborted = err instanceof DOMException && err.name === 'AbortError';
if (!aborted) {
setState((prev) => ({
...prev,
isStreaming: false,
error: err instanceof Error ? err.message : 'connection failed',
messages: prev.messages.map((m) =>
m.id === assistantId ? { ...m, streaming: false } : m,
),
}));
} else {
setState((prev) => ({
...prev,
isStreaming: false,
messages: prev.messages.map((m) =>
m.id === assistantId ? { ...m, streaming: false } : m,
),
}));
}
} finally {
abortRef.current = null;
}
},
[agent],
);
const retry = useCallback(() => {
setState((prev) => ({ ...prev, error: null }));
const lastUser = [...state.messages].reverse().find((m) => m.role === 'user');
if (lastUser) void send(lastUser.content);
}, [send, state.messages]);
return {
messages: state.messages,
isStreaming: state.isStreaming,
error: state.error,
model: state.model,
send,
retry,
abort,
};
}