chih.yikuan
πŸš€ ExamInsight: AI-powered exam analysis for teachers
054d73a
import { useState, useEffect } from "react";
interface StepData {
id: string;
status: "pending" | "active" | "completed" | "error";
detail?: string;
timestamp?: string;
}
interface StatusResponse {
current_step: string | null;
steps: StepData[];
started_at: string | null;
completed_at: string | null;
}
interface WorkflowStep {
id: string;
label: string;
icon: JSX.Element;
status: "pending" | "active" | "completed" | "error";
detail?: string;
}
interface WorkflowStatusProps {
sessionId?: string;
}
const STEP_CONFIG: { id: string; label: string; icon: JSX.Element; defaultDetail: string }[] = [
{
id: "fetch",
label: "Fetch Responses",
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
),
defaultDetail: "Getting data from Google Sheets",
},
{
id: "normalize",
label: "Parse Data",
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
),
defaultDetail: "Organizing questions and answers",
},
{
id: "grade",
label: "Grade Answers",
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
defaultDetail: "Comparing with correct answers",
},
{
id: "explain",
label: "Generate Explanations",
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
),
defaultDetail: "Explaining wrong answers",
},
{
id: "group",
label: "Create Peer Groups",
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
defaultDetail: "Matching helpers with learners",
},
{
id: "report",
label: "Generate Report",
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
defaultDetail: "Creating comprehensive report",
},
{
id: "email",
label: "Send Email",
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
),
defaultDetail: "Delivering report to inbox",
},
];
export function WorkflowStatus({ sessionId = "default" }: WorkflowStatusProps) {
const [steps, setSteps] = useState<WorkflowStep[]>(
STEP_CONFIG.map(s => ({ ...s, status: "pending" as const, detail: s.defaultDetail }))
);
const [isConnected, setIsConnected] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
useEffect(() => {
let eventSource: EventSource | null = null;
let retryCount = 0;
const maxRetries = 3;
const connect = () => {
eventSource = new EventSource(`/api/status/${sessionId}/stream`);
eventSource.onopen = () => {
setIsConnected(true);
retryCount = 0;
};
eventSource.onmessage = (event) => {
try {
const data: StatusResponse = JSON.parse(event.data);
// Check if there's any activity
const hasActivity = data.steps && data.steps.length > 0;
setIsAnalyzing(hasActivity && !data.completed_at);
// Update steps based on server data
setSteps(prevSteps => {
return prevSteps.map(step => {
const serverStep = data.steps?.find(s => s.id === step.id);
if (serverStep) {
return {
...step,
status: serverStep.status as WorkflowStep["status"],
detail: serverStep.detail || step.detail,
};
}
return step;
});
});
} catch (e) {
console.error("Error parsing status:", e);
}
};
eventSource.onerror = () => {
setIsConnected(false);
eventSource?.close();
// Retry connection
if (retryCount < maxRetries) {
retryCount++;
setTimeout(connect, 2000 * retryCount);
}
};
};
connect();
return () => {
eventSource?.close();
};
}, [sessionId]);
const getStatusColor = (status: WorkflowStep["status"]) => {
switch (status) {
case "completed": return "bg-[var(--color-success)] text-white";
case "active": return "bg-[var(--color-accent)] text-white animate-pulse";
case "error": return "bg-red-500 text-white";
default: return "bg-[var(--color-border)] text-[var(--color-text-muted)]";
}
};
const getLineColor = (status: WorkflowStep["status"]) => {
switch (status) {
case "completed": return "bg-[var(--color-success)]";
case "active": return "bg-[var(--color-accent)]";
default: return "bg-[var(--color-border)]";
}
};
const activeStep = steps.find(s => s.status === "active");
const completedCount = steps.filter(s => s.status === "completed").length;
return (
<div className="card p-4">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${isAnalyzing ? 'bg-[var(--color-accent)] animate-pulse' : isConnected ? 'bg-[var(--color-success)]' : 'bg-[var(--color-border)]'}`} />
<h3 className="font-display text-sm font-semibold text-[var(--color-text)]">
{isAnalyzing ? "AI Agent Working..." : "Agent Status"}
</h3>
</div>
{completedCount > 0 && (
<span className="text-xs text-[var(--color-text-muted)]">
{completedCount}/{steps.length}
</span>
)}
</div>
{/* Active step highlight */}
{activeStep && (
<div className="mb-4 p-3 rounded-lg bg-[var(--color-accent)]/10 border border-[var(--color-accent)]/20">
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded-full bg-[var(--color-accent)] flex items-center justify-center animate-pulse">
<div className="w-2 h-2 bg-white rounded-full" />
</div>
<div>
<div className="text-sm font-medium text-[var(--color-accent)]">
{activeStep.label}
</div>
<div className="text-xs text-[var(--color-text-muted)]">
{activeStep.detail}
</div>
</div>
</div>
</div>
)}
{/* Steps list */}
<div className="space-y-1">
{steps.map((step, index) => (
<div key={step.id} className="relative">
{/* Connector line */}
{index < steps.length - 1 && (
<div
className={`absolute left-[11px] top-[24px] w-0.5 h-4 transition-colors duration-300 ${getLineColor(step.status)}`}
/>
)}
<div className="flex items-center gap-3 py-1">
{/* Icon circle */}
<div
className={`flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center transition-all duration-300 ${getStatusColor(step.status)}`}
>
{step.status === "completed" ? (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
) : step.status === "active" ? (
<div className="w-2 h-2 bg-white rounded-full" />
) : step.status === "error" ? (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<span className="text-[10px]">{index + 1}</span>
)}
</div>
{/* Label */}
<div className={`text-sm transition-colors ${
step.status === "active"
? "font-medium text-[var(--color-accent)]"
: step.status === "completed"
? "text-[var(--color-success)]"
: step.status === "error"
? "text-red-500"
: "text-[var(--color-text-muted)]"
}`}>
{step.label}
</div>
</div>
</div>
))}
</div>
{/* Connection status */}
<div className="mt-4 pt-3 border-t border-[var(--color-border)]">
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<div className={`w-1.5 h-1.5 rounded-full ${isConnected ? 'bg-green-500' : 'bg-gray-400'}`} />
{isConnected ? "Live updates" : "Connecting..."}
</div>
</div>
</div>
);
}