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:
68
lib/auth.ts
Normal file
68
lib/auth.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { SignJWT, jwtVerify } from 'jose';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode(
|
||||
process.env.JWT_SECRET ?? 'dev-secret-change-in-production-please'
|
||||
);
|
||||
const COOKIE_NAME = 'professor_auth';
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
|
||||
|
||||
export interface JWTPayload {
|
||||
userId: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// ─── Password helpers ────────────────────────────────────────────────────────
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 12);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
// ─── JWT helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function signToken(payload: JWTPayload): Promise<string> {
|
||||
return new SignJWT(payload as unknown as Record<string, unknown>)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('30d')
|
||||
.sign(JWT_SECRET);
|
||||
}
|
||||
|
||||
export async function verifyToken(token: string): Promise<JWTPayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_SECRET);
|
||||
return payload as unknown as JWTPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cookie helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function setAuthCookie(token: string): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearAuthCookie(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(COOKIE_NAME);
|
||||
}
|
||||
|
||||
export async function getAuthUser(): Promise<JWTPayload | null> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
||||
if (!token) return null;
|
||||
return verifyToken(token);
|
||||
}
|
||||
36
lib/localSession.ts
Normal file
36
lib/localSession.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Task, Message, ExecutionResult } from '@/types';
|
||||
|
||||
const STORAGE_KEY = 'professor_session';
|
||||
|
||||
export interface LocalSession {
|
||||
topicId: string;
|
||||
task: Task;
|
||||
code: string;
|
||||
messages: Message[];
|
||||
executionResult: ExecutionResult | null;
|
||||
}
|
||||
|
||||
export function saveLocalSession(session: LocalSession): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
||||
} catch {
|
||||
// Ignore quota errors
|
||||
}
|
||||
}
|
||||
|
||||
export function loadLocalSession(): LocalSession | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as LocalSession;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearLocalSession(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
57
lib/pistonClient.ts
Normal file
57
lib/pistonClient.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { ExecutionResult } from '@/types';
|
||||
|
||||
// Wandbox — free public code execution API, no auth required
|
||||
// https://github.com/melpon/wandbox
|
||||
const WANDBOX_API = 'https://wandbox.org/api/compile.json';
|
||||
const TIMEOUT_MS = 15_000;
|
||||
|
||||
export async function executePython(code: string, stdin = ''): Promise<ExecutionResult> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(WANDBOX_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
compiler: 'cpython-3.12.7',
|
||||
code,
|
||||
stdin,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Execution service error: ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const exitCode = parseInt(data.status ?? '0', 10);
|
||||
|
||||
// Wandbox separates compiler errors from runtime errors
|
||||
const stderr = [data.program_error, data.compiler_error]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
return {
|
||||
stdout: data.program_output ?? '',
|
||||
stderr,
|
||||
exitCode,
|
||||
timedOut: !!data.signal,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return { stdout: '', stderr: '', exitCode: -1, timedOut: true, error: 'Execution timed out after 15 seconds.' };
|
||||
}
|
||||
return {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: -1,
|
||||
timedOut: false,
|
||||
error: err instanceof Error ? err.message : 'Unknown execution error',
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
138
lib/prompts.ts
Normal file
138
lib/prompts.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { Topic, ExecutionResult, ResponseMode } from '@/types';
|
||||
|
||||
function buildContext(topic: Topic, code: string, result?: ExecutionResult): string {
|
||||
const execSection = result
|
||||
? `\nEXECUTION OUTPUT:\nstdout: ${result.stdout || '(empty)'}\nstderr: ${result.stderr || '(empty)'}\nexit code: ${result.exitCode}`
|
||||
: '';
|
||||
|
||||
return `CURRENT TOPIC: ${topic.label} (${topic.language})
|
||||
CURRENT CODE:
|
||||
\`\`\`${topic.language}
|
||||
${code}
|
||||
\`\`\`${execSection}`;
|
||||
}
|
||||
|
||||
// ─── Task Generation ────────────────────────────────────────────────────────
|
||||
|
||||
export function buildTaskGenerationPrompt(topic: Topic): string {
|
||||
return `You are Professor, an expert and encouraging coding tutor. Generate a hands-on coding task for a student learning "${topic.label}" in ${topic.language}.
|
||||
|
||||
Respond in EXACTLY this format — no preamble, no extra text:
|
||||
|
||||
TITLE: <short task title>
|
||||
|
||||
DESCRIPTION:
|
||||
<2-4 sentence description of what the student should build or accomplish. Be specific and concrete.>
|
||||
|
||||
HINTS:
|
||||
- <hint 1>
|
||||
- <hint 2>
|
||||
- <hint 3>
|
||||
|
||||
STARTER_CODE:
|
||||
\`\`\`${topic.language}
|
||||
<starter code with comments guiding the student. Leave key parts blank for them to fill in.>
|
||||
\`\`\`
|
||||
|
||||
Rules:
|
||||
- The task should be completable in 10-15 minutes
|
||||
- Keep it focused on the topic, not broader concepts
|
||||
- The starter code should be runnable but incomplete — scaffold the structure, leave the logic for the student
|
||||
- For Python tasks, use print() to show output so the student can verify their work
|
||||
- For HTML tasks, include visible elements so the student can see results immediately`;
|
||||
}
|
||||
|
||||
// ─── Code Review ────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildCodeReviewPrompt(topic: Topic, code: string, result?: ExecutionResult, responseMode?: ResponseMode): string {
|
||||
const context = buildContext(topic, code, result);
|
||||
|
||||
const hintInstruction = responseMode && !responseMode.hintMode
|
||||
? '3. Do NOT proactively suggest hints or next steps — only confirm correctness or point out specific errors. The student must ask explicitly to receive hints.'
|
||||
: '3. Give hints to fix issues rather than directly writing the correct code';
|
||||
|
||||
const strictInstruction = responseMode?.strict
|
||||
? `\n9. STRICT MODE: Only accept code that uses the exact approach and techniques the task requires. If the student\'s solution works but uses a different method (e.g. a built-in instead of a manual implementation, or a different algorithm), explicitly flag it as not meeting the task requirements and explain what approach is expected.`
|
||||
: '';
|
||||
|
||||
return `You are Professor, a patient and encouraging coding tutor. Review the student's code for the topic "${topic.label}".
|
||||
|
||||
${context}
|
||||
|
||||
Guidelines for your review:
|
||||
1. Start by acknowledging what the student got right — be specific
|
||||
2. Point out any issues clearly but kindly — explain WHY it's a problem, not just what
|
||||
${hintInstruction}
|
||||
4. If the code is correct, praise it and challenge them to extend it
|
||||
5. Reference the execution output if provided — point out what the output reveals about correctness
|
||||
6. Keep your response concise and actionable — avoid walls of text
|
||||
7. Use markdown with code blocks where helpful${strictInstruction}`;
|
||||
}
|
||||
|
||||
// ─── Classroom Lesson ───────────────────────────────────────────────────────
|
||||
|
||||
export function buildLessonPrompt(topic: Topic): string {
|
||||
return `You are Professor, an expert coding tutor. Write a comprehensive lesson on "${topic.label}" in ${topic.language}.
|
||||
|
||||
Structure the lesson as a well-organised markdown document with these sections:
|
||||
|
||||
## Introduction
|
||||
What this concept is and why it matters (2–3 sentences).
|
||||
|
||||
## Key Concepts
|
||||
The core ideas, clearly explained with simple language.
|
||||
|
||||
## Syntax & Usage
|
||||
Concrete ${topic.language} examples with labelled code blocks.
|
||||
|
||||
## Common Patterns
|
||||
2–3 real-world usage patterns the student will encounter.
|
||||
|
||||
## Common Mistakes
|
||||
Frequent pitfalls beginners make and how to avoid them.
|
||||
|
||||
## Quick Reference
|
||||
A concise bullet-list summary of the most important points.
|
||||
|
||||
Rules:
|
||||
- Use ${topic.language} fenced code blocks for all examples
|
||||
- Keep explanations beginner-friendly and concise
|
||||
- Include practical, runnable examples throughout
|
||||
- Do NOT include exercises or tasks — those belong in Homework mode`;
|
||||
}
|
||||
|
||||
// ─── Classroom Q&A Chat ─────────────────────────────────────────────────────
|
||||
|
||||
export function buildClassroomChatPrompt(topic: Topic): string {
|
||||
return `You are Professor, a knowledgeable and patient coding tutor. You are in a Classroom session helping a student learn "${topic.label}" in ${topic.language}.
|
||||
|
||||
The student has just read a lesson on this topic and may ask conceptual questions, request clarification, or ask for more examples.
|
||||
|
||||
Guidelines:
|
||||
- Answer questions clearly and concisely
|
||||
- Use ${topic.language} code examples where helpful, with fenced code blocks
|
||||
- Focus on explanation and understanding — this is not a code review
|
||||
- If the student asks for an exercise or task, suggest they switch to Homework mode
|
||||
- Keep responses conversational and reasonably short`;
|
||||
}
|
||||
|
||||
// ─── Free Chat ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildChatPrompt(topic: Topic, code: string, responseMode?: ResponseMode): string {
|
||||
const context = buildContext(topic, code);
|
||||
|
||||
const hintInstruction = responseMode && !responseMode.hintMode
|
||||
? '- Do NOT proactively suggest hints or next steps. Answer only what is explicitly asked — no unsolicited guidance.'
|
||||
: '- Never just give them the answer to their task — guide with hints and explanations';
|
||||
|
||||
return `You are Professor, a friendly and knowledgeable coding tutor. You are currently helping a student with the topic "${topic.label}".
|
||||
|
||||
${context}
|
||||
|
||||
Guidelines:
|
||||
- Answer the student's questions clearly and concisely
|
||||
- Stay focused on the current topic and their code, but help with related questions too
|
||||
${hintInstruction}
|
||||
- Use markdown with code blocks for code examples
|
||||
- Keep responses reasonably short — this is a conversation, not a lecture`;
|
||||
}
|
||||
95
lib/providers.ts
Normal file
95
lib/providers.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { ProviderId, ProviderConfig } from '@/types';
|
||||
|
||||
export interface ProviderDefinition {
|
||||
id: ProviderId;
|
||||
label: string;
|
||||
requiresApiKey: boolean;
|
||||
hasCustomBaseUrl: boolean;
|
||||
defaultBaseUrl: string;
|
||||
defaultModel: string;
|
||||
modelSuggestions: string[];
|
||||
}
|
||||
|
||||
export const PROVIDERS: ProviderDefinition[] = [
|
||||
{
|
||||
id: 'anthropic',
|
||||
label: 'Anthropic (Claude)',
|
||||
requiresApiKey: true,
|
||||
hasCustomBaseUrl: false,
|
||||
defaultBaseUrl: 'https://api.anthropic.com',
|
||||
defaultModel: 'claude-sonnet-4-6',
|
||||
modelSuggestions: [
|
||||
'claude-sonnet-4-6',
|
||||
'claude-haiku-4-5-20251001',
|
||||
'claude-opus-4-6',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'openrouter',
|
||||
label: 'OpenRouter',
|
||||
requiresApiKey: true,
|
||||
hasCustomBaseUrl: false,
|
||||
defaultBaseUrl: 'https://openrouter.ai/api/v1',
|
||||
defaultModel: 'meta-llama/llama-3.1-8b-instruct:free',
|
||||
modelSuggestions: [
|
||||
'meta-llama/llama-3.1-8b-instruct:free',
|
||||
'google/gemma-3-27b-it:free',
|
||||
'mistralai/mistral-7b-instruct:free',
|
||||
'deepseek/deepseek-chat-v3-0324:free',
|
||||
'qwen/qwen3-8b:free',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'lmstudio',
|
||||
label: 'LM Studio',
|
||||
requiresApiKey: false,
|
||||
hasCustomBaseUrl: true,
|
||||
defaultBaseUrl: 'http://localhost:1234/v1',
|
||||
defaultModel: 'local-model',
|
||||
modelSuggestions: [],
|
||||
},
|
||||
{
|
||||
id: 'ollama',
|
||||
label: 'Ollama',
|
||||
requiresApiKey: false,
|
||||
hasCustomBaseUrl: true,
|
||||
defaultBaseUrl: 'http://localhost:11434/v1',
|
||||
defaultModel: 'llama3.2',
|
||||
modelSuggestions: [
|
||||
'llama3.2',
|
||||
'llama3.1',
|
||||
'mistral',
|
||||
'qwen2.5-coder',
|
||||
'deepseek-coder-v2',
|
||||
'phi4',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const PROVIDER_MAP = Object.fromEntries(PROVIDERS.map((p) => [p.id, p])) as Record<
|
||||
ProviderId,
|
||||
ProviderDefinition
|
||||
>;
|
||||
|
||||
export const DEFAULT_PROVIDER_CONFIG: ProviderConfig = {
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-6',
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'professor_provider_config';
|
||||
|
||||
export function loadProviderConfig(): ProviderConfig {
|
||||
if (typeof window === 'undefined') return DEFAULT_PROVIDER_CONFIG;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return DEFAULT_PROVIDER_CONFIG;
|
||||
return JSON.parse(raw) as ProviderConfig;
|
||||
} catch {
|
||||
return DEFAULT_PROVIDER_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveProviderConfig(config: ProviderConfig): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
|
||||
}
|
||||
79
lib/topics.ts
Normal file
79
lib/topics.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { Topic } from '@/types';
|
||||
|
||||
export const TOPICS: Topic[] = [
|
||||
// ─── Python ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'python-variables',
|
||||
label: 'Python Variables',
|
||||
language: 'python',
|
||||
description: 'Learn how to store and work with different types of data',
|
||||
starterCode: '# Your code here\n',
|
||||
},
|
||||
{
|
||||
id: 'python-lists',
|
||||
label: 'Python Lists',
|
||||
language: 'python',
|
||||
description: 'Create, modify, and iterate over collections of items',
|
||||
starterCode: '# Your code here\n',
|
||||
},
|
||||
{
|
||||
id: 'python-loops',
|
||||
label: 'Python Loops',
|
||||
language: 'python',
|
||||
description: 'Repeat actions with for and while loops',
|
||||
starterCode: '# Your code here\n',
|
||||
},
|
||||
{
|
||||
id: 'python-functions',
|
||||
label: 'Python Functions',
|
||||
language: 'python',
|
||||
description: 'Define reusable blocks of code that accept inputs and return outputs',
|
||||
starterCode: '# Your code here\n',
|
||||
},
|
||||
{
|
||||
id: 'python-dicts',
|
||||
label: 'Python Dictionaries',
|
||||
language: 'python',
|
||||
description: 'Store and retrieve data using key-value pairs',
|
||||
starterCode: '# Your code here\n',
|
||||
},
|
||||
{
|
||||
id: 'python-strings',
|
||||
label: 'Python Strings',
|
||||
language: 'python',
|
||||
description: 'Manipulate text with string methods and formatting',
|
||||
starterCode: '# Your code here\n',
|
||||
},
|
||||
|
||||
// ─── HTML / CSS ───────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'html-structure',
|
||||
label: 'HTML Structure',
|
||||
language: 'html',
|
||||
description: 'Build the skeleton of a webpage with semantic HTML elements',
|
||||
starterCode: '<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8">\n <title>My Page</title>\n</head>\n<body>\n <!-- Your code here -->\n</body>\n</html>\n',
|
||||
},
|
||||
{
|
||||
id: 'css-box-model',
|
||||
label: 'CSS Box Model',
|
||||
language: 'html',
|
||||
description: 'Understand margin, border, padding, and content sizing',
|
||||
starterCode: '<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8">\n <title>Box Model</title>\n <style>\n /* Your styles here */\n </style>\n</head>\n<body>\n <!-- Your code here -->\n</body>\n</html>\n',
|
||||
},
|
||||
{
|
||||
id: 'css-flexbox',
|
||||
label: 'CSS Flexbox',
|
||||
language: 'html',
|
||||
description: 'Arrange elements in rows and columns with flexbox layout',
|
||||
starterCode: '<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8">\n <title>Flexbox</title>\n <style>\n /* Your styles here */\n </style>\n</head>\n<body>\n <!-- Your code here -->\n</body>\n</html>\n',
|
||||
},
|
||||
{
|
||||
id: 'html-forms',
|
||||
label: 'HTML Forms',
|
||||
language: 'html',
|
||||
description: 'Collect user input with form elements and validation attributes',
|
||||
starterCode: '<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8">\n <title>Forms</title>\n</head>\n<body>\n <!-- Your form here -->\n</body>\n</html>\n',
|
||||
},
|
||||
];
|
||||
|
||||
export const TOPIC_BY_ID = Object.fromEntries(TOPICS.map((t) => [t.id, t]));
|
||||
Reference in New Issue
Block a user