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:
211
hooks/useAI.ts
Normal file
211
hooks/useAI.ts
Normal 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
172
hooks/useAppState.ts
Normal 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
51
hooks/useCodeExecution.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user