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>
212 lines
6.0 KiB
TypeScript
212 lines
6.0 KiB
TypeScript
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 };
|
|
}
|