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:
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user