Initial commit — AI-powered coding tutor (Professor)

Next.js 16, React 19, Monaco editor, Anthropic SDK, multi-provider AI,
Wandbox Python execution, iframe HTML preview, SQLite auth + session persistence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aodhan Collins
2026-03-04 21:48:34 +00:00
commit f644937604
56 changed files with 14012 additions and 0 deletions

211
hooks/useAI.ts Normal file
View File

@@ -0,0 +1,211 @@
import type { Dispatch } from 'react';
import type { AppAction, AppState, Topic, Task, ProviderConfig } from '@/types';
// Parse the task generation response into a structured Task object
function parseTask(raw: string, topic: Topic): Task {
const titleMatch = raw.match(/TITLE:\s*(.+)/);
const descMatch = raw.match(/DESCRIPTION:\s*([\s\S]*?)(?=HINTS:|STARTER_CODE:|$)/);
const hintsMatch = raw.match(/HINTS:\s*([\s\S]*?)(?=STARTER_CODE:|$)/);
const codeMatch = raw.match(/STARTER_CODE:\s*```[\w]*\n([\s\S]*?)```/);
const hints = hintsMatch
? hintsMatch[1]
.trim()
.split('\n')
.map((h) => h.replace(/^[-*]\s*/, '').trim())
.filter(Boolean)
: [];
return {
title: titleMatch ? titleMatch[1].trim() : topic.label,
description: descMatch ? descMatch[1].trim() : raw,
hints,
starterCode: codeMatch ? codeMatch[1] : topic.starterCode,
};
}
async function streamFromAPI(
body: Record<string, unknown> & { providerConfig: ProviderConfig },
onChunk: (chunk: string) => void,
signal?: AbortSignal
): Promise<string> {
const res = await fetch('/api/ai', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal,
});
if (!res.ok || !res.body) {
throw new Error(`AI request failed: ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let full = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
full += chunk;
onChunk(chunk);
}
return full;
}
export function useAI(dispatch: Dispatch<AppAction>, providerConfig: ProviderConfig) {
async function generateTask(topic: Topic): Promise<void> {
dispatch({ type: 'TASK_STREAM_START' });
try {
const raw = await streamFromAPI(
{ mode: 'generate_task', topic, code: topic.starterCode, messages: [], providerConfig },
(chunk) => dispatch({ type: 'TASK_STREAM_CHUNK', payload: chunk })
);
const task = parseTask(raw, topic);
dispatch({ type: 'TASK_STREAM_DONE', payload: task });
} catch (err) {
dispatch({
type: 'SET_ERROR',
payload: err instanceof Error ? err.message : 'Failed to generate task',
});
}
}
async function reviewCode(state: AppState): Promise<void> {
if (!state.topic) return;
dispatch({ type: 'REVIEW_START' });
try {
const raw = await streamFromAPI(
{
mode: 'review_code',
topic: state.topic,
code: state.code,
executionResult: state.executionResult ?? undefined,
messages: state.messages.map((m) => ({ role: m.role, content: m.content })),
providerConfig,
responseMode: state.responseMode,
},
(chunk) => dispatch({ type: 'STREAM_CHUNK', payload: chunk })
);
dispatch({
type: 'STREAM_DONE',
payload: {
id: crypto.randomUUID(),
role: 'assistant',
content: raw,
timestamp: Date.now(),
type: 'review',
},
});
} catch (err) {
dispatch({
type: 'SET_ERROR',
payload: err instanceof Error ? err.message : 'Failed to review code',
});
}
}
async function sendMessage(state: AppState, userMessage: string): Promise<void> {
if (!state.topic) return;
dispatch({ type: 'SEND_USER_MESSAGE', payload: userMessage });
const history = [
...state.messages.map((m) => ({ role: m.role, content: m.content })),
{ role: 'user' as const, content: userMessage },
];
try {
const raw = await streamFromAPI(
{
mode: 'chat',
topic: state.topic,
code: state.code,
messages: history,
providerConfig,
responseMode: state.responseMode,
},
(chunk) => dispatch({ type: 'STREAM_CHUNK', payload: chunk })
);
dispatch({
type: 'STREAM_DONE',
payload: {
id: crypto.randomUUID(),
role: 'assistant',
content: raw,
timestamp: Date.now(),
type: 'chat',
},
});
} catch (err) {
dispatch({
type: 'SET_ERROR',
payload: err instanceof Error ? err.message : 'Failed to send message',
});
}
}
async function generateLesson(topic: Topic): Promise<void> {
dispatch({ type: 'LESSON_STREAM_START' });
try {
const raw = await streamFromAPI(
{ mode: 'generate_lesson', topic, code: '', messages: [], providerConfig },
(chunk) => dispatch({ type: 'STREAM_CHUNK', payload: chunk })
);
dispatch({ type: 'LESSON_STREAM_DONE', payload: raw });
} catch (err) {
dispatch({
type: 'SET_ERROR',
payload: err instanceof Error ? err.message : 'Failed to generate lesson',
});
}
}
async function sendClassroomMessage(state: AppState, userMessage: string): Promise<void> {
if (!state.topic) return;
dispatch({ type: 'SEND_CLASSROOM_MESSAGE', payload: userMessage });
const history = [
...state.classroomMessages.map((m) => ({ role: m.role, content: m.content })),
{ role: 'user' as const, content: userMessage },
];
try {
const raw = await streamFromAPI(
{
mode: 'classroom_chat',
topic: state.topic,
code: '',
messages: history,
providerConfig,
},
(chunk) => dispatch({ type: 'STREAM_CHUNK', payload: chunk })
);
dispatch({
type: 'CLASSROOM_MESSAGE_DONE',
payload: {
id: crypto.randomUUID(),
role: 'assistant',
content: raw,
timestamp: Date.now(),
type: 'chat',
},
});
} catch (err) {
dispatch({
type: 'SET_ERROR',
payload: err instanceof Error ? err.message : 'Failed to send message',
});
}
}
return { generateTask, reviewCode, sendMessage, generateLesson, sendClassroomMessage };
}

172
hooks/useAppState.ts Normal file
View File

@@ -0,0 +1,172 @@
import { useReducer } from 'react';
import type { AppState, AppAction } from '@/types';
const initialState: AppState = {
phase: 'selecting',
appMode: 'homework',
topic: null,
task: null,
code: '',
messages: [],
classroomMessages: [],
lessonContent: null,
executionResult: null,
streamingContent: '',
isStreaming: false,
error: null,
responseMode: { hintMode: true, strict: false },
};
function reducer(state: AppState, action: AppAction): AppState {
switch (action.type) {
case 'SELECT_TOPIC':
return {
...initialState,
phase: 'loading_task',
appMode: state.appMode,
topic: action.payload,
code: action.payload.starterCode,
isStreaming: true,
responseMode: state.responseMode,
};
case 'TASK_STREAM_START':
return { ...state, phase: 'loading_task', isStreaming: true, streamingContent: '' };
case 'TASK_STREAM_CHUNK':
return { ...state, streamingContent: state.streamingContent + action.payload };
case 'TASK_STREAM_DONE':
return {
...state,
phase: 'ready',
task: action.payload,
code: action.payload.starterCode || state.code,
isStreaming: false,
streamingContent: '',
messages: [],
};
case 'CODE_CHANGE':
return { ...state, code: action.payload };
case 'EXECUTE_START':
return { ...state, phase: 'executing', executionResult: null, error: null };
case 'EXECUTE_DONE':
return { ...state, phase: 'ready', executionResult: action.payload };
case 'REVIEW_START':
return {
...state,
phase: 'reviewing',
isStreaming: true,
streamingContent: '',
error: null,
};
case 'STREAM_CHUNK':
return { ...state, streamingContent: state.streamingContent + action.payload };
case 'STREAM_DONE':
return {
...state,
phase: 'ready',
isStreaming: false,
streamingContent: '',
messages: [...state.messages, action.payload],
};
case 'SEND_USER_MESSAGE':
return {
...state,
isStreaming: true,
streamingContent: '',
messages: [
...state.messages,
{
id: crypto.randomUUID(),
role: 'user',
content: action.payload,
timestamp: Date.now(),
type: 'chat',
},
],
};
case 'SET_APP_MODE':
return { ...state, appMode: action.payload };
case 'LESSON_STREAM_START':
return { ...state, isStreaming: true, streamingContent: '', error: null };
case 'LESSON_STREAM_DONE':
return {
...state,
isStreaming: false,
streamingContent: '',
lessonContent: action.payload,
};
case 'SEND_CLASSROOM_MESSAGE':
return {
...state,
isStreaming: true,
streamingContent: '',
classroomMessages: [
...state.classroomMessages,
{
id: crypto.randomUUID(),
role: 'user',
content: action.payload,
timestamp: Date.now(),
type: 'chat',
},
],
};
case 'CLASSROOM_MESSAGE_DONE':
return {
...state,
isStreaming: false,
streamingContent: '',
classroomMessages: [...state.classroomMessages, action.payload],
};
case 'SET_ERROR':
return { ...state, phase: 'ready', isStreaming: false, streamingContent: '', error: action.payload };
case 'CLEAR_ERROR':
return { ...state, error: null };
case 'SET_RESPONSE_MODE':
return { ...state, responseMode: { ...state.responseMode, ...action.payload } };
case 'RESET':
return {
...initialState,
appMode: state.appMode,
responseMode: state.responseMode,
};
case 'RESTORE_SESSION':
return {
...initialState,
phase: 'ready',
appMode: state.appMode,
topic: action.payload.topic,
task: action.payload.task,
code: action.payload.code,
messages: action.payload.messages,
executionResult: action.payload.executionResult,
responseMode: state.responseMode,
};
default:
return state;
}
}
export function useAppState() {
return useReducer(reducer, initialState);
}

51
hooks/useCodeExecution.ts Normal file
View File

@@ -0,0 +1,51 @@
import type { Dispatch } from 'react';
import type { AppAction, SupportedLanguage } from '@/types';
const MAX_CODE_LENGTH = 10_000;
export function useCodeExecution(dispatch: Dispatch<AppAction>) {
async function execute(language: SupportedLanguage, code: string): Promise<void> {
if (code.trim().length === 0) return;
if (code.length > MAX_CODE_LENGTH) {
dispatch({ type: 'SET_ERROR', payload: 'Code exceeds the 10,000 character limit.' });
return;
}
// HTML is handled by HtmlPreview client-side
if (language === 'html') {
return;
}
dispatch({ type: 'EXECUTE_START' });
try {
const res = await fetch('/api/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language, code }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? `Server error ${res.status}`);
}
const result = await res.json();
dispatch({ type: 'EXECUTE_DONE', payload: result });
} catch (err) {
dispatch({
type: 'EXECUTE_DONE',
payload: {
stdout: '',
stderr: '',
exitCode: -1,
timedOut: false,
error: err instanceof Error ? err.message : 'Execution failed',
},
});
}
}
return { execute };
}