commit f6449376047b14258b87c493205271b5cee665e2 Author: Aodhan Collins Date: Wed Mar 4 21:48:34 2026 +0000 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e385634 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# database +*.db +*.db-shm +*.db-wal + +# claude code +.claude/ + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..40e4db1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,198 @@ +# Professor — LLM Project Reference + +> This file is loaded automatically by Claude Code at session start. It is not end-user documentation. +> For planned features and build order see [ROADMAP.md](ROADMAP.md). + +## What the app does + +AI-powered coding tutor. User picks a topic → Claude generates a task → user writes code in Monaco editor → runs it → gets AI feedback via chat. + +--- + +## Tech Stack + +| Layer | Choice | +|---|---| +| Framework | Next.js 16 (App Router, server components minimally used) | +| UI | React 19, Tailwind CSS v4 | +| Editor | `@monaco-editor/react` (dynamic import, SSR disabled) | +| AI | Anthropic SDK (primary) + raw SSE fetch for OpenAI-compatible providers | +| Code execution | Wandbox API (Python); iframe srcdoc (HTML/CSS) | +| DB | SQLite via Drizzle ORM + better-sqlite3 | +| Auth | Custom JWT (jose) + bcryptjs, httpOnly cookie | + +--- + +## Directory Map + +``` +app/ + api/ + ai/route.ts # Streaming AI endpoint (task gen, review, chat) + execute/route.ts # Code execution proxy → Wandbox + models/route.ts # Fetch available models from provider + auth/ + register/route.ts # POST: create account, set JWT cookie + login/route.ts # POST: verify password, set JWT cookie + logout/route.ts # POST: clear cookie + me/route.ts # GET: verify cookie → return user + session/route.ts # GET/PUT: load/save session for logged-in user + layout.tsx # Root layout (metadata, fonts) + page.tsx # Renders + globals.css + +components/ + AppShell.tsx # ← START HERE. Top-level orchestrator. Owns all state, + # auth, session sync, and renders everything. + TopicSelector.tsx # Full-screen topic picker (shown during 'selecting' phase) + ProviderSettings.tsx # Modal: AI provider/model/key config + AuthModal.tsx # Modal: Sign In / Create Account + editor/ + EditorPane.tsx # Left pane: wraps toolbar + editor + output + EditorToolbar.tsx # Toolbar: run, submit, auth status, saved indicator + CodeEditor.tsx # Monaco editor wrapper + OutputPanel.tsx # Execution result display + HtmlPreview.tsx # iframe srcdoc preview for HTML topics + chat/ + ChatPane.tsx # Right pane: wraps task card + message list + input + MessageList.tsx # Renders all messages + MessageBubble.tsx # Single message (markdown rendered) + ChatInput.tsx # Text input for follow-up chat + TaskCard.tsx # Displays the AI-generated task description + +hooks/ + useAppState.ts # Central useReducer — AppState + AppAction + reducer + useAI.ts # Streaming fetch: generateTask / reviewCode / sendMessage + useCodeExecution.ts # Calls /api/execute, dispatches EXECUTE_START/DONE + +lib/ + topics.ts # 10 static topics (TOPICS array + TOPIC_BY_ID map) + providers.ts # PROVIDERS, PROVIDER_MAP, load/saveProviderConfig + prompts.ts # 3 system prompt builders (task / review / chat) + pistonClient.ts # executePython() → Wandbox API (15s timeout) + localSession.ts # localStorage save/load/clear for soft session + auth.ts # hashPassword, verifyPassword, signToken, verifyToken, + # setAuthCookie, clearAuthCookie, getAuthUser + +db/ + schema.ts # Drizzle table definitions: users, saved_sessions + index.ts # Drizzle singleton (globalThis pattern for hot reload) + +types/ + index.ts # ALL shared types — read this first when confused about types + +drizzle.config.ts # Drizzle migration config (SQLite, professor.db) +``` + +--- + +## State Machine + +``` +AppPhase: selecting → loading_task → ready ↔ executing + ↕ + reviewing +``` + +`AppState` (in `hooks/useAppState.ts`): +- `phase` — current app phase +- `topic` — selected `Topic | null` +- `task` — AI-generated `Task | null` +- `code` — current editor content +- `messages` — `Message[]` (task card, reviews, chat) +- `executionResult` — last run output +- `streamingContent` — partial AI response being streamed +- `isStreaming` — whether AI stream is active +- `error` — error string | null + +Key actions: `SELECT_TOPIC`, `RESTORE_SESSION`, `CODE_CHANGE`, `EXECUTE_DONE`, `STREAM_DONE`, `RESET` + +--- + +## Data Flow + +``` +AppShell + ├── useAppState() → [state, dispatch] + ├── useAI() → generateTask / reviewCode / sendMessage + │ └── POST /api/ai → streams text back → dispatches STREAM_CHUNK / STREAM_DONE + ├── EditorPane + │ ├── EditorToolbar (auth status, saved indicator, run/submit buttons) + │ ├── CodeEditor (Monaco, dispatches CODE_CHANGE) + │ └── useCodeExecution → POST /api/execute → dispatches EXECUTE_DONE + └── ChatPane + ├── TaskCard (renders state.task) + └── MessageList (renders state.messages) +``` + +--- + +## Session Persistence + +**Soft (always on):** localStorage key `professor_session` stores `{ topicId, task, code, messages, executionResult }`. Saved on every state change, restored on mount via `RESTORE_SESSION` action. + +**Hard (account required):** Same data synced to `saved_sessions` table in SQLite, debounced 1s. Auth via httpOnly JWT cookie (`professor_auth`). On login, DB session overrides localStorage. + +Restore sequence in `AppShell.tsx`: +1. Mount → restore from localStorage immediately +2. Mount → `GET /api/auth/me` → if logged in, `GET /api/session` → dispatch `RESTORE_SESSION` (overrides local) + +--- + +## AI Provider System + +Configured at runtime via `ProviderSettings` modal. Config stored in `localStorage` under `professor_provider_config`. + +Supported providers (`lib/providers.ts`): `anthropic`, `openrouter`, `lmstudio`, `ollama` + +`/api/ai/route.ts` branches on `providerConfig.provider`: +- Anthropic → uses `@anthropic-ai/sdk` with streaming +- Others → raw SSE fetch to OpenAI-compatible endpoint + +--- + +## Key Patterns & Conventions + +- **All types in one file:** `types/index.ts`. Check here before creating new types. +- **No external state library** — everything is `useReducer` in `AppShell` via `useAppState`. +- **Prop drilling** (intentional, no Context) — `AppShell → EditorPane → EditorToolbar` and `AppShell → ChatPane`. +- **Modal pattern:** Fixed overlay div, `onClick` on backdrop closes. See `ProviderSettings.tsx` or `AuthModal.tsx` as reference. +- **localStorage pattern:** Always guard with `if (typeof window === 'undefined') return`. See `lib/providers.ts`. +- **API routes are all public** except `/api/session` (requires JWT cookie). +- **Streaming:** AI responses stream as plain text (`text/plain`). Client accumulates via `STREAM_CHUNK` and finalises with `STREAM_DONE`. +- **Monaco:** Always `next/dynamic({ ssr: false })`. +- **HTML topics** skip `/api/execute` entirely — rendered client-side in an iframe. + +--- + +## Environment Variables + +``` +ANTHROPIC_API_KEY=... # Fallback key for Anthropic provider (required) +JWT_SECRET=... # 32+ char secret for JWT signing (required) +``` + +--- + +## Common Tasks — Where to Start + +| Task | File(s) to read first | +|---|---| +| Add a new topic | `lib/topics.ts` | +| Change AI prompt behaviour | `lib/prompts.ts` | +| Add a new app phase / state | `types/index.ts` → `hooks/useAppState.ts` | +| Add a new modal | Copy `components/AuthModal.tsx` or `components/ProviderSettings.tsx` | +| Add a new API route | Mirror existing routes in `app/api/` | +| Change execution behaviour | `hooks/useCodeExecution.ts` + `app/api/execute/route.ts` | +| Modify session data saved | `lib/localSession.ts` + `app/api/session/route.ts` + `db/schema.ts` | +| Add a new provider | `lib/providers.ts` + `types/index.ts` (`ProviderId`) + `app/api/ai/route.ts` | + +--- + +## Dev Commands + +```bash +npm run dev # Start dev server +npm run db:push # Apply schema changes to professor.db (Drizzle) +npx tsc --noEmit # Type check +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..a44d1f8 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,151 @@ +# Professor — Feature Roadmap + +> Internal planning document. For architecture and file layout see [CLAUDE.md](CLAUDE.md). + +--- + +## Status Key +`[ ]` Planned  |  `[~]` In Progress  |  `[x]` Done + +--- + +## Features + +### `[x]` Response Modes +**Hint Mode · Strict · Lenient** + +Three toggles that change how the AI reviews code and responds in chat. + +- **Hint Mode (on/off):** When ON the AI proactively suggests hints. When OFF it only confirms or rejects — the user must ask explicitly. +- **Strict:** AI only accepts code that uses the exact approach the task required. Alternatives are flagged. +- **Lenient (default):** Functionally equivalent code is accepted as long as it doesn't circumvent the lesson concept. + +Implemented via system-prompt injections. UI: lightbulb toggle + Strict/Lenient pill in `EditorToolbar`. Settings persisted to `localStorage` under `professor_response_mode` and preserved across topic resets. + +**Key files:** `types/index.ts`, `lib/prompts.ts`, `hooks/useAppState.ts`, `components/editor/EditorToolbar.tsx`, `components/AppShell.tsx` + +--- + +### `[x]` Resizable Panes +**Drag handle between the editor and chat panes** + +A thin divider between the left and right panes can be dragged to resize. Clamped to 20–80%. Works in both the horizontal (desktop) and vertical (mobile) flex directions — `cursor-col-resize` on `lg+`, `cursor-row-resize` below. Highlights blue on hover/drag. + +**Key files:** `components/AppShell.tsx` + +--- + +### `[x]` Custom Topic Input +**Free-text topic entry on the topic selector** + +A "Custom" section at the bottom of the topic selector lets users type any topic (e.g. "Python Classes", "CSS Grid") and pick Python or HTML. Creates a synthetic `Topic` object and feeds it into the normal task-generation flow. + +**Key files:** `components/TopicSelector.tsx` + +--- + +### `[x]` Classroom / Homework Modes +**Two distinct learning modes toggled per-session** + +A `Classroom | Homework` pill in the ChatPane header switches modes. Mode is preserved across topic resets. + +- **Homework (default):** Existing behaviour — Monaco editor on the left, task card + review chat on the right. +- **Classroom:** Left pane becomes a scrollable AI-generated markdown lesson for the selected topic. Right pane becomes a pure Q&A chat (separate message history, different system prompt, no code review). The lesson is generated lazily on first switch and cached for the session. + +`AppMode`, `lessonContent`, and `classroomMessages` added to `AppState`. Two new AI modes: `generate_lesson` and `classroom_chat`. + +**Key files:** `types/index.ts`, `hooks/useAppState.ts`, `lib/prompts.ts`, `app/api/ai/route.ts`, `hooks/useAI.ts`, `components/AppShell.tsx`, `components/chat/ChatPane.tsx`, `components/classroom/LessonPane.tsx` (new) + +--- + +### `[ ]` 1. LLM Personality Cards +**Teaching personas that change AI tone and style** + +Five built-in personas: + +| Persona | Avatar | Style | +|---------|--------|-------| +| Professor | 🎓 | Formal, structured, methodical | +| Mentor | 🌱 | Warm, Socratic — asks "what do you think happens if…?" | +| Drill Sergeant | ⚡ | Terse, demanding, minimal praise | +| Peer | 👾 | Casual, like a knowledgeable dev friend | +| Rubber Duck | 🦆 | Reflects questions back — makes you explain your own reasoning | + +Users can also define custom personas via a JSON template (`public/personality-template.json`). Custom cards are saved to localStorage. + +Each persona adds a `systemAddition` string to every AI system prompt. Persona selection UI is a card-grid modal accessible via the toolbar. + +**Key files:** `lib/personalities.ts` (new), `components/PersonalityPicker.tsx` (new), `public/personality-template.json` (new), `types/index.ts`, `lib/prompts.ts`, `components/AppShell.tsx`, `components/editor/EditorToolbar.tsx` + +--- + +### `[ ]` 2. TODO Tracker +**AI-generated checklist that tracks lesson progress** + +When a task is generated the AI also produces a concrete checklist (e.g. "Define the function with the correct signature", "Handle the empty-input case"). Items are: +- Ticked automatically: the review response contains a `COMPLETED: 0,2` tag (zero-based indices) that is parsed and stripped before display. +- Togglable manually by clicking the checkbox. + +A progress counter ("3 / 5 complete") is shown at the top of the checklist, rendered inside `TaskCard` below the hints section. + +**Key files:** `components/chat/TodoList.tsx` (new), `types/index.ts`, `lib/prompts.ts`, `hooks/useAI.ts`, `hooks/useAppState.ts`, `components/chat/TaskCard.tsx` + +--- + +### `[ ]` 3. Revision Journal +**AI-generated cheat sheets saved per topic** + +A notebook icon in the chat header lets the user generate a concise markdown "cheat sheet" at any point. The AI summarises: key concepts covered, syntax patterns used, common pitfalls encountered in the session. + +Entries are stored in the `revision_journal` DB table (for logged-in users) or localStorage (anonymous). A journal modal shows all past entries as expandable markdown cards, newest first. + +**Key files:** `components/JournalModal.tsx` (new), `app/api/journal/route.ts` (new), `db/schema.ts`, `types/index.ts`, `lib/prompts.ts`, `hooks/useAI.ts`, `components/chat/ChatPane.tsx` + +--- + +### `[ ]` 4. Curriculum Generator +**End-to-end lesson plans on any topic** + +User enters a free-text subject (e.g. "Python object-oriented programming"), picks a language and depth (beginner / intermediate / advanced), and the AI generates a structured 6–10 lesson syllabus. Lessons are unlocked and completed sequentially; each task is generated on-demand when the lesson starts. + +> ⚠️ **Heavy token usage.** Curriculum generation uses significantly more tokens than a single task. This is surfaced clearly in the UI before generation. + +Navigation: a collapsible sidebar shows all lessons with ✓ / ▶ / ○ status. A "Generate Curriculum" secondary CTA appears on the topic selector screen. + +**New tables:** `curricula` +**Key files:** `components/CurriculumGenerator.tsx` (new), `components/CurriculumOverview.tsx` (new), `components/CurriculumSidebar.tsx` (new), `app/api/curriculum/route.ts` (new), `types/index.ts`, `hooks/useAppState.ts`, `hooks/useAI.ts`, `lib/prompts.ts`, `db/schema.ts`, `components/AppShell.tsx`, `components/TopicSelector.tsx` + +--- + +### `[ ]` 5. Mermaid Diagrams in Classroom Mode +**Flowcharts, sequence diagrams, and class diagrams in the lesson pane** + +Extends the Classroom mode lesson pane to render Mermaid diagrams embedded in the AI-generated lesson markdown. The lesson prompt is updated to produce diagrams where appropriate (e.g. flowcharts for control flow topics, class diagrams for OOP). + +> **Note:** This was originally scoped as a standalone "Q&A Mode / Blackboard" feature. The `---BLACKBOARD---` split-response approach is dropped in favour of rendering diagrams inline in the existing `LessonPane` — simpler and consistent with how the lesson document already works. + +**New package:** `mermaid` +**Key files:** `components/classroom/LessonPane.tsx`, `lib/prompts.ts` + +--- + +## Build Order Rationale + +The original six features have been partially reordered based on what was built: + +- **Response Modes, Resizable Panes, Custom Topics, Classroom/Homework** — all shipped. These were zero-DB, low-risk changes that improved the core UX immediately. +- **Personality Cards** next — still prompt-only, no DB, straightforward UI addition. +- **TODO Tracker** — introduces checklist parsing reused by Curriculum. +- **Revision Journal** — first new DB table; small scope, validates storage pattern. +- **Curriculum Generator** — highest complexity; builds on TODO Tracker and needs its own DB table. +- **Mermaid in Classroom** — cosmetic enhancement to an already-working mode; low risk, deferred until core features are stable. + +--- + +## DB Migration Notes + +After Features 3 and 4 are implemented, run: +```bash +npm run db:push +``` +New tables added: `revision_journal`, `curricula`. Existing data is unaffected. diff --git a/app/api/ai/route.ts b/app/api/ai/route.ts new file mode 100644 index 0000000..b0e96c1 --- /dev/null +++ b/app/api/ai/route.ts @@ -0,0 +1,184 @@ +import { NextRequest, NextResponse } from 'next/server'; +import Anthropic from '@anthropic-ai/sdk'; +import { + buildTaskGenerationPrompt, + buildCodeReviewPrompt, + buildChatPrompt, + buildLessonPrompt, + buildClassroomChatPrompt, +} from '@/lib/prompts'; +import { PROVIDER_MAP } from '@/lib/providers'; +import type { AIRequestBody, ProviderConfig } from '@/types'; + +// ─── Anthropic streaming ──────────────────────────────────────────────────── + +async function streamAnthropic( + config: ProviderConfig, + systemPrompt: string, + messages: Anthropic.MessageParam[], + controller: ReadableStreamDefaultController +) { + const client = config.apiKey + ? new Anthropic({ apiKey: config.apiKey }) + : new Anthropic(); // falls back to ANTHROPIC_API_KEY env var + + const stream = await client.messages.stream({ + model: config.model, + max_tokens: 2048, + system: systemPrompt, + messages, + }); + + for await (const chunk of stream) { + if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') { + controller.enqueue(new TextEncoder().encode(chunk.delta.text)); + } + } +} + +// ─── OpenAI-compatible streaming (OpenRouter, LM Studio, Ollama) ──────────── + +async function streamOpenAICompatible( + config: ProviderConfig, + systemPrompt: string, + messages: Array<{ role: 'user' | 'assistant'; content: string }>, + controller: ReadableStreamDefaultController +) { + const providerDef = PROVIDER_MAP[config.provider]; + const baseUrl = config.baseUrl ?? providerDef.defaultBaseUrl; + const apiKey = config.apiKey || 'none'; // LM Studio / Ollama accept any value + + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }; + + // OpenRouter requires attribution headers + if (config.provider === 'openrouter') { + headers['HTTP-Referer'] = 'http://localhost:3000'; + headers['X-Title'] = 'Professor'; + } + + const res = await fetch(`${baseUrl}/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify({ + model: config.model, + messages: [{ role: 'system', content: systemPrompt }, ...messages], + stream: true, + }), + }); + + if (!res.ok || !res.body) { + const text = await res.text().catch(() => res.statusText); + throw new Error(`${providerDef.label} error ${res.status}: ${text}`); + } + + // Parse SSE stream + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('data: ')) continue; + const data = trimmed.slice(6); + if (data === '[DONE]') return; + try { + const json = JSON.parse(data); + const content = json.choices?.[0]?.delta?.content; + if (content) controller.enqueue(new TextEncoder().encode(content)); + } catch { + // ignore malformed SSE lines + } + } + } +} + +// ─── Route handler ────────────────────────────────────────────────────────── + +export async function POST(req: NextRequest) { + let body: AIRequestBody; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const { mode, topic, code, executionResult, messages, userMessage, providerConfig, responseMode } = body; + + if (!mode || !topic || !providerConfig) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + // Build system prompt + let systemPrompt: string; + switch (mode) { + case 'generate_task': + systemPrompt = buildTaskGenerationPrompt(topic); + break; + case 'review_code': + systemPrompt = buildCodeReviewPrompt(topic, code, executionResult, responseMode); + break; + case 'chat': + systemPrompt = buildChatPrompt(topic, code, responseMode); + break; + case 'generate_lesson': + systemPrompt = buildLessonPrompt(topic); + break; + case 'classroom_chat': + systemPrompt = buildClassroomChatPrompt(topic); + break; + default: + return NextResponse.json({ error: 'Invalid mode' }, { status: 400 }); + } + + // Build message list + const chatMessages: Array<{ role: 'user' | 'assistant'; content: string }> = + mode === 'generate_task' + ? [{ role: 'user', content: 'Generate a task for this topic.' }] + : mode === 'review_code' + ? [{ role: 'user', content: 'Please review my code and give me feedback.' }] + : mode === 'generate_lesson' + ? [{ role: 'user', content: 'Write the lesson.' }] + : [ + ...(messages ?? []).map((m) => ({ + role: m.role as 'user' | 'assistant', + content: m.content, + })), + ...(userMessage ? [{ role: 'user' as const, content: userMessage }] : []), + ]; + + const stream = new ReadableStream({ + async start(controller) { + try { + if (providerConfig.provider === 'anthropic') { + await streamAnthropic(providerConfig, systemPrompt, chatMessages, controller); + } else { + await streamOpenAICompatible(providerConfig, systemPrompt, chatMessages, controller); + } + controller.close(); + } catch (err) { + const message = err instanceof Error ? err.message : 'AI error'; + controller.enqueue(new TextEncoder().encode(`\n\n[Error: ${message}]`)); + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Transfer-Encoding': 'chunked', + 'Cache-Control': 'no-cache', + }, + }); +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..3e61f1e --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/db'; +import { users } from '@/db/schema'; +import { verifyPassword, signToken, setAuthCookie } from '@/lib/auth'; +import { eq } from 'drizzle-orm'; + +export async function POST(req: NextRequest) { + const { email, password } = await req.json(); + + if (!email || !password) { + return NextResponse.json({ error: 'Email and password are required' }, { status: 400 }); + } + + const user = await db.select().from(users).where(eq(users.email, email)).get(); + if (!user || !(await verifyPassword(password, user.passwordHash))) { + return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 }); + } + + const token = await signToken({ userId: user.id, email: user.email }); + await setAuthCookie(token); + + return NextResponse.json({ user: { id: user.id, email: user.email } }); +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..d57dbed --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server'; +import { clearAuthCookie } from '@/lib/auth'; + +export async function POST() { + await clearAuthCookie(); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts new file mode 100644 index 0000000..e8d0e4b --- /dev/null +++ b/app/api/auth/me/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from 'next/server'; +import { getAuthUser } from '@/lib/auth'; + +export async function GET() { + const user = await getAuthUser(); + if (!user) return NextResponse.json({ user: null }); + return NextResponse.json({ user: { id: user.userId, email: user.email } }); +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..6ed1521 --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/db'; +import { users } from '@/db/schema'; +import { hashPassword, signToken, setAuthCookie } from '@/lib/auth'; +import { eq } from 'drizzle-orm'; + +export async function POST(req: NextRequest) { + const { email, password } = await req.json(); + + if (!email || typeof email !== 'string' || !email.includes('@')) { + return NextResponse.json({ error: 'Valid email is required' }, { status: 400 }); + } + if (!password || typeof password !== 'string' || password.length < 8) { + return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 }); + } + + const existing = await db.select().from(users).where(eq(users.email, email)).get(); + if (existing) { + return NextResponse.json({ error: 'Email already registered' }, { status: 409 }); + } + + const id = crypto.randomUUID(); + const passwordHash = await hashPassword(password); + await db.insert(users).values({ id, email, passwordHash, createdAt: new Date().toISOString() }); + + const token = await signToken({ userId: id, email }); + await setAuthCookie(token); + + return NextResponse.json({ user: { id, email } }); +} diff --git a/app/api/execute/route.ts b/app/api/execute/route.ts new file mode 100644 index 0000000..0410ed6 --- /dev/null +++ b/app/api/execute/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { executePython } from '@/lib/pistonClient'; +import type { ExecuteRequestBody } from '@/types'; + +const MAX_CODE_LENGTH = 10_000; + +export async function POST(req: NextRequest) { + let body: ExecuteRequestBody; + + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const { language, code, stdin } = body; + + if (!language || !code) { + return NextResponse.json({ error: 'Missing language or code' }, { status: 400 }); + } + + if (code.length > MAX_CODE_LENGTH) { + return NextResponse.json({ error: 'Code exceeds maximum length of 10,000 characters' }, { status: 400 }); + } + + // HTML is rendered client-side via iframe — no backend execution needed + if (language === 'html') { + return NextResponse.json({ + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }); + } + + const result = await executePython(code, stdin ?? ''); + return NextResponse.json(result); +} diff --git a/app/api/models/route.ts b/app/api/models/route.ts new file mode 100644 index 0000000..13bbc57 --- /dev/null +++ b/app/api/models/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from 'next/server'; +import type { ProviderId } from '@/types'; +import { PROVIDER_MAP } from '@/lib/providers'; + +interface ModelsRequestBody { + provider: ProviderId; + apiKey?: string; + baseUrl?: string; +} + +export async function POST(req: NextRequest) { + let body: ModelsRequestBody; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const { provider, apiKey, baseUrl } = body; + const def = PROVIDER_MAP[provider]; + const base = baseUrl?.trim() || def.defaultBaseUrl; + + try { + let models: string[] = []; + + if (provider === 'anthropic') { + const key = apiKey?.trim() || process.env.ANTHROPIC_API_KEY; + if (!key) { + return NextResponse.json({ error: 'No API key — enter one above or set ANTHROPIC_API_KEY in .env.local' }, { status: 400 }); + } + const res = await fetch('https://api.anthropic.com/v1/models?limit=100', { + headers: { + 'x-api-key': key, + 'anthropic-version': '2023-06-01', + }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + return NextResponse.json({ error: `Anthropic: ${res.status} ${text}` }, { status: res.status }); + } + const data = await res.json(); + models = (data.data ?? []).map((m: { id: string }) => m.id); + } + + else if (provider === 'openrouter') { + const headers: Record = {}; + if (apiKey?.trim()) headers['Authorization'] = `Bearer ${apiKey.trim()}`; + + const res = await fetch('https://openrouter.ai/api/v1/models', { headers }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + return NextResponse.json({ error: `OpenRouter: ${res.status} ${text}` }, { status: res.status }); + } + const data = await res.json(); + // Sort by id for easier browsing + models = (data.data ?? []) + .map((m: { id: string }) => m.id) + .sort((a: string, b: string) => a.localeCompare(b)); + } + + else { + // LM Studio and Ollama — OpenAI-compatible /v1/models + const res = await fetch(`${base}/models`, { + headers: { Authorization: 'Bearer none' }, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + return NextResponse.json({ error: `${def.label}: ${res.status} ${text || 'Connection refused'}` }, { status: res.status }); + } + const data = await res.json(); + models = (data.data ?? []).map((m: { id: string }) => m.id); + } + + return NextResponse.json({ models }); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + const isTimeout = msg.includes('timeout') || msg.includes('abort'); + return NextResponse.json( + { error: isTimeout ? `Could not reach ${def.label} — is it running?` : msg }, + { status: 500 } + ); + } +} diff --git a/app/api/session/route.ts b/app/api/session/route.ts new file mode 100644 index 0000000..dc04a21 --- /dev/null +++ b/app/api/session/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/db'; +import { savedSessions } from '@/db/schema'; +import { getAuthUser } from '@/lib/auth'; +import { eq } from 'drizzle-orm'; + +export async function GET() { + const authUser = await getAuthUser(); + if (!authUser) return NextResponse.json({ session: null }, { status: 401 }); + + const row = await db + .select() + .from(savedSessions) + .where(eq(savedSessions.userId, authUser.userId)) + .get(); + + if (!row) return NextResponse.json({ session: null }); + + return NextResponse.json({ + session: { + topicId: row.topicId, + task: row.taskJson ? JSON.parse(row.taskJson) : null, + code: row.code, + messages: row.messagesJson ? JSON.parse(row.messagesJson) : [], + executionResult: row.executionResultJson ? JSON.parse(row.executionResultJson) : null, + }, + }); +} + +export async function PUT(req: NextRequest) { + const authUser = await getAuthUser(); + if (!authUser) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { topicId, task, code, messages, executionResult } = await req.json(); + + const existing = await db + .select() + .from(savedSessions) + .where(eq(savedSessions.userId, authUser.userId)) + .get(); + + const data = { + userId: authUser.userId, + topicId: topicId ?? null, + taskJson: task ? JSON.stringify(task) : null, + code: code ?? null, + messagesJson: messages ? JSON.stringify(messages) : null, + executionResultJson: executionResult ? JSON.stringify(executionResult) : null, + updatedAt: new Date().toISOString(), + }; + + if (existing) { + await db + .update(savedSessions) + .set(data) + .where(eq(savedSessions.userId, authUser.userId)); + } else { + await db.insert(savedSessions).values({ id: crypto.randomUUID(), ...data }); + } + + return NextResponse.json({ ok: true }); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..62b6236 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,38 @@ +@import "tailwindcss"; + +:root { + --background: #09090b; + --foreground: #fafafa; +} + +html, +body { + height: 100%; + overflow: hidden; + background: var(--background); + color: var(--foreground); +} + +/* Scrollbar styling */ +* { + scrollbar-width: thin; + scrollbar-color: #3f3f46 transparent; +} + +*::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background-color: #3f3f46; + border-radius: 3px; +} + +*::-webkit-scrollbar-thumb:hover { + background-color: #52525b; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..2e1e445 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from 'next'; +import { Geist, Geist_Mono } from 'next/font/google'; +import './globals.css'; + +const geistSans = Geist({ + variable: '--font-geist-sans', + subsets: ['latin'], +}); + +const geistMono = Geist_Mono({ + variable: '--font-geist-mono', + subsets: ['latin'], +}); + +export const metadata: Metadata = { + title: 'Professor — AI Coding Tutor', + description: 'Learn to code with an AI tutor. Get tasks, write code, and receive feedback.', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..c6bdf56 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,5 @@ +import AppShell from '@/components/AppShell'; + +export default function Home() { + return ; +} diff --git a/components/AppShell.tsx b/components/AppShell.tsx new file mode 100644 index 0000000..1c36452 --- /dev/null +++ b/components/AppShell.tsx @@ -0,0 +1,310 @@ +'use client'; + +import { useState, useCallback, useEffect, useRef } from 'react'; +import type { Topic, ProviderConfig, AuthUser, AppMode } from '@/types'; +import { useAppState } from '@/hooks/useAppState'; +import { useAI } from '@/hooks/useAI'; +import { loadProviderConfig, saveProviderConfig, PROVIDER_MAP } from '@/lib/providers'; +import { loadLocalSession, saveLocalSession, clearLocalSession } from '@/lib/localSession'; +import { TOPICS } from '@/lib/topics'; +import TopicSelector from './TopicSelector'; +import EditorPane from './editor/EditorPane'; +import LessonPane from './classroom/LessonPane'; +import ChatPane from './chat/ChatPane'; +import ProviderSettings from './ProviderSettings'; +import AuthModal from './AuthModal'; + +export default function AppShell() { + const [state, dispatch] = useAppState(); + const [providerConfig, setProviderConfig] = useState(loadProviderConfig); + const [showSettings, setShowSettings] = useState(false); + + const [authUser, setAuthUser] = useState(null); + const [showAuth, setShowAuth] = useState(false); + const [savedIndicator, setSavedIndicator] = useState(false); + const [splitPercent, setSplitPercent] = useState(50); + const containerRef = useRef(null); + const dbSaveRef = useRef | null>(null); + const savedTimerRef = useRef | null>(null); + + // Keep localStorage in sync whenever providerConfig changes + useEffect(() => { + saveProviderConfig(providerConfig); + }, [providerConfig]); + + // Load persisted responseMode on mount + useEffect(() => { + if (typeof window === 'undefined') return; + try { + const raw = localStorage.getItem('professor_response_mode'); + if (raw) dispatch({ type: 'SET_RESPONSE_MODE', payload: JSON.parse(raw) }); + } catch { /* ignore */ } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Persist responseMode whenever it changes + useEffect(() => { + if (typeof window === 'undefined') return; + localStorage.setItem('professor_response_mode', JSON.stringify(state.responseMode)); + }, [state.responseMode]); + + // Check auth status on mount + useEffect(() => { + fetch('/api/auth/me') + .then((r) => r.json()) + .then((data) => { + if (data.user) setAuthUser(data.user as AuthUser); + }) + .catch(() => {}); + }, []); + + // Restore session from localStorage on mount (immediate, works without an account) + useEffect(() => { + const local = loadLocalSession(); + if (!local) return; + const topic = TOPICS.find((t) => t.id === local.topicId); + if (topic && local.task) { + dispatch({ + type: 'RESTORE_SESSION', + payload: { + topic, + task: local.task, + code: local.code, + messages: local.messages, + executionResult: local.executionResult, + }, + }); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // When user logs in, fetch their DB session and prefer it over the local one + useEffect(() => { + if (!authUser) return; + fetch('/api/session') + .then((r) => r.json()) + .then((data) => { + if (!data.session) return; + const { topicId, task, code, messages, executionResult } = data.session; + const topic = TOPICS.find((t) => t.id === topicId); + if (topic && task) { + dispatch({ + type: 'RESTORE_SESSION', + payload: { topic, task, code: code ?? '', messages: messages ?? [], executionResult }, + }); + // Sync DB session down to local so they're consistent + saveLocalSession({ topicId, task, code: code ?? '', messages: messages ?? [], executionResult }); + } + }) + .catch(() => {}); + }, [authUser, dispatch]); + + // Auto-save session whenever relevant state changes + useEffect(() => { + if (state.phase === 'selecting' || !state.topic || !state.task) return; + + const sessionData = { + topicId: state.topic.id, + task: state.task, + code: state.code, + messages: state.messages, + executionResult: state.executionResult, + }; + + // Always save to localStorage immediately + saveLocalSession(sessionData); + + // Flash "Saved" indicator + if (savedTimerRef.current) clearTimeout(savedTimerRef.current); + setSavedIndicator(true); + savedTimerRef.current = setTimeout(() => setSavedIndicator(false), 1500); + + // Debounced DB sync when logged in + if (authUser) { + if (dbSaveRef.current) clearTimeout(dbSaveRef.current); + dbSaveRef.current = setTimeout(() => { + fetch('/api/session', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(sessionData), + }).catch(() => {}); + }, 1000); + } + }, [state.topic, state.task, state.code, state.messages, state.executionResult, authUser]); + + const { generateTask, reviewCode, sendMessage, generateLesson, sendClassroomMessage } = useAI(dispatch, providerConfig); + + const handleTopicSelect = useCallback( + (topic: Topic) => { + clearLocalSession(); + dispatch({ type: 'SELECT_TOPIC', payload: topic }); + generateTask(topic); + }, + [dispatch, generateTask] + ); + + const handleSubmit = useCallback(() => { + reviewCode(state); + }, [reviewCode, state]); + + const handleSendMessage = useCallback( + (text: string) => { + sendMessage(state, text); + }, + [sendMessage, state] + ); + + const handleSendClassroomMessage = useCallback( + (text: string) => { + sendClassroomMessage(state, text); + }, + [sendClassroomMessage, state] + ); + + const handleSetAppMode = useCallback( + (mode: AppMode) => { + dispatch({ type: 'SET_APP_MODE', payload: mode }); + // Lazily generate the lesson the first time the user enters Classroom mode + if (mode === 'classroom' && !state.lessonContent && !state.isStreaming && state.topic) { + generateLesson(state.topic); + } + }, + [dispatch, state.lessonContent, state.isStreaming, state.topic, generateLesson] + ); + + const handleReset = useCallback(() => { + dispatch({ type: 'RESET' }); + clearLocalSession(); + // Clear DB session if logged in + if (authUser) { + fetch('/api/session', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ topicId: null, task: null, code: null, messages: [], executionResult: null }), + }).catch(() => {}); + } + }, [dispatch, authUser]); + + const handleSaveProvider = useCallback((config: ProviderConfig) => { + setProviderConfig(config); + }, []); + + const handleAuthSuccess = useCallback((user: AuthUser) => { + setAuthUser(user); + setShowAuth(false); + }, []); + + const handleLogout = useCallback(async () => { + await fetch('/api/auth/logout', { method: 'POST' }); + setAuthUser(null); + // Local session stays intact — user can continue without an account + }, []); + + const handlePaneDragStart = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + const container = containerRef.current; + if (!container) return; + + const onMove = (e: MouseEvent) => { + const rect = container.getBoundingClientRect(); + const isHorizontal = rect.width >= 1024; // lg breakpoint + const pct = isHorizontal + ? ((e.clientX - rect.left) / rect.width) * 100 + : ((e.clientY - rect.top) / rect.height) * 100; + setSplitPercent(Math.min(Math.max(pct, 20), 80)); + }; + + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }, []); + + const providerDef = PROVIDER_MAP[providerConfig.provider]; + + if (state.phase === 'selecting') { + return ( + <> + setShowSettings(true)} /> + {showSettings && ( + setShowSettings(false)} + /> + )} + {showAuth && ( + setShowAuth(false)} /> + )} + + ); + } + + return ( + <> +
+ {/* Left pane — editor (Homework) or lesson viewer (Classroom) */} +
+ {state.appMode === 'classroom' ? ( + + ) : ( + setShowSettings(true)} + savedIndicator={savedIndicator} + authUser={authUser} + onShowAuth={() => setShowAuth(true)} + onLogout={handleLogout} + /> + )} +
+ + {/* Drag handle */} +
+ + {/* Right pane — chat */} +
+ +
+
+ + {showSettings && ( + setShowSettings(false)} + /> + )} + + {showAuth && ( + setShowAuth(false)} /> + )} + + ); +} diff --git a/components/AuthModal.tsx b/components/AuthModal.tsx new file mode 100644 index 0000000..ec1f962 --- /dev/null +++ b/components/AuthModal.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { useState } from 'react'; +import type { AuthUser } from '@/types'; + +type Mode = 'login' | 'register'; + +interface Props { + onSuccess: (user: AuthUser) => void; + onClose: () => void; +} + +export default function AuthModal({ onSuccess, onClose }: Props) { + const [mode, setMode] = useState('login'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(''); + + const endpoint = mode === 'login' ? '/api/auth/login' : '/api/auth/register'; + try { + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const data = await res.json(); + + if (!res.ok) { + setError(data.error ?? 'Something went wrong'); + return; + } + + onSuccess(data.user as AuthUser); + } catch { + setError('Network error. Please try again.'); + } finally { + setLoading(false); + } + } + + return ( +
e.target === e.currentTarget && onClose()} + > +
+ {/* Header */} +
+

Save your progress

+ +
+ + {/* Mode tabs */} +
+ {(['login', 'register'] as const).map((m) => ( + + ))} +
+ + {/* Form */} +
+
+ + setEmail(e.target.value)} + required + autoComplete="email" + placeholder="you@example.com" + className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:border-blue-500 focus:outline-none" + /> +
+ +
+ + setPassword(e.target.value)} + required + autoComplete={mode === 'login' ? 'current-password' : 'new-password'} + placeholder={mode === 'register' ? 'At least 8 characters' : '••••••••'} + className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:border-blue-500 focus:outline-none" + /> +
+ + {error && ( +
+ {error} +
+ )} + + + +

+ {mode === 'login' ? ( + <>No account?{' '} + + + ) : ( + <>Already have an account?{' '} + + + )} +

+
+
+
+ ); +} diff --git a/components/ProviderSettings.tsx b/components/ProviderSettings.tsx new file mode 100644 index 0000000..da7eefa --- /dev/null +++ b/components/ProviderSettings.tsx @@ -0,0 +1,348 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import type { ProviderConfig, ProviderId } from '@/types'; +import { PROVIDERS, PROVIDER_MAP } from '@/lib/providers'; + +type ConnectStatus = 'idle' | 'loading' | 'success' | 'error'; + +interface Props { + config: ProviderConfig; + onSave: (config: ProviderConfig) => void; + onClose: () => void; +} + +export default function ProviderSettings({ config, onSave, onClose }: Props) { + const [provider, setProvider] = useState(config.provider); + const [model, setModel] = useState(config.model); + const [apiKey, setApiKey] = useState(config.apiKey ?? ''); + const [baseUrl, setBaseUrl] = useState(config.baseUrl ?? ''); + + const [connectStatus, setConnectStatus] = useState('idle'); + const [connectError, setConnectError] = useState(''); + const [fetchedModels, setFetchedModels] = useState([]); + const [modelFilter, setModelFilter] = useState(''); + const filterRef = useRef(null); + + const def = PROVIDER_MAP[provider]; + + // Reset when provider changes + useEffect(() => { + const newDef = PROVIDER_MAP[provider]; + setModel(newDef.defaultModel); + setBaseUrl(newDef.hasCustomBaseUrl ? newDef.defaultBaseUrl : ''); + if (!newDef.requiresApiKey) setApiKey(''); + setConnectStatus('idle'); + setFetchedModels([]); + setModelFilter(''); + }, [provider]); + + // Focus filter when models load + useEffect(() => { + if (connectStatus === 'success') { + setTimeout(() => filterRef.current?.focus(), 50); + } + }, [connectStatus]); + + async function handleConnect() { + setConnectStatus('loading'); + setConnectError(''); + setFetchedModels([]); + + try { + const res = await fetch('/api/models', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + provider, + apiKey: apiKey.trim() || undefined, + baseUrl: baseUrl.trim() || undefined, + }), + }); + + const data = await res.json(); + + if (!res.ok || data.error) { + setConnectError(data.error ?? `Error ${res.status}`); + setConnectStatus('error'); + return; + } + + const models: string[] = data.models ?? []; + setFetchedModels(models); + setConnectStatus('success'); + // Auto-select first model if current isn't in the fetched list + if (models.length && !models.includes(model)) { + setModel(models[0]); + } + } catch (err) { + setConnectError(err instanceof Error ? err.message : 'Connection failed'); + setConnectStatus('error'); + } + } + + function handleSave() { + const saved: ProviderConfig = { + provider, + model: model.trim() || def.defaultModel, + ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}), + ...(baseUrl.trim() && def.hasCustomBaseUrl ? { baseUrl: baseUrl.trim() } : {}), + }; + onSave(saved); + onClose(); + } + + const filteredModels = fetchedModels.filter((m) => + m.toLowerCase().includes(modelFilter.toLowerCase()) + ); + + return ( +
e.target === e.currentTarget && onClose()} + > +
+ {/* Header */} +
+

AI Provider Settings

+ +
+ + {/* Body */} +
+ + {/* Provider selector */} +
+ +
+ {PROVIDERS.map((p) => ( + + ))} +
+
+ + {/* API Key — shown before model so user fills it first */} + {def.requiresApiKey && ( +
+ + { + setApiKey(e.target.value); + setConnectStatus('idle'); + setFetchedModels([]); + }} + placeholder={provider === 'anthropic' ? 'sk-ant-… (optional)' : 'sk-or-…'} + autoComplete="off" + className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:border-blue-500 focus:outline-none" + /> +
+ )} + + {/* Base URL */} + {def.hasCustomBaseUrl && ( +
+ + { + setBaseUrl(e.target.value); + setConnectStatus('idle'); + setFetchedModels([]); + }} + placeholder={def.defaultBaseUrl} + className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:border-blue-500 focus:outline-none" + /> +

+ {provider === 'ollama' + ? 'Ollama must be running with OLLAMA_ORIGINS=* for CORS.' + : 'LM Studio server must be running on this address.'} +

+
+ )} + + {/* Model */} +
+
+ + +
+ + {/* Error */} + {connectStatus === 'error' && ( +
+ {connectError} +
+ )} + + {/* Live model list */} + {connectStatus === 'success' && fetchedModels.length > 0 ? ( +
+ {fetchedModels.length > 8 && ( + setModelFilter(e.target.value)} + placeholder={`Filter ${fetchedModels.length} models…`} + className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:border-blue-500 focus:outline-none" + /> + )} +
+ {filteredModels.length === 0 ? ( +

No models match “{modelFilter}”

+ ) : ( + filteredModels.map((m) => ( + + )) + )} +
+ {modelFilter && filteredModels.length < fetchedModels.length && ( +

+ {filteredModels.length} of {fetchedModels.length} shown +

+ )} +
+ ) : ( + /* Static fallback — shown before connecting or on error */ +
+ {def.modelSuggestions.length > 0 ? ( + <> + + {!def.modelSuggestions.includes(model) && ( + setModel(e.target.value)} + placeholder={def.defaultModel} + className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:border-blue-500 focus:outline-none" + /> + )} + + ) : ( + setModel(e.target.value)} + placeholder={def.defaultModel} + className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:border-blue-500 focus:outline-none" + /> + )} +

+ Hit Connect to load available models from {def.label}. +

+
+ )} + + {connectStatus === 'success' && ( +

+ Selected: {model} +

+ )} +
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/components/TopicSelector.tsx b/components/TopicSelector.tsx new file mode 100644 index 0000000..7e543da --- /dev/null +++ b/components/TopicSelector.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { useState } from 'react'; +import type { Topic, SupportedLanguage } from '@/types'; +import { TOPICS } from '@/lib/topics'; + +const PYTHON_ICON = '🐍'; +const HTML_ICON = '🌐'; + +const CUSTOM_STARTER: Record = { + python: '# Your code here\n', + html: '\n\n\n \n Page\n\n\n \n\n\n', +}; + +interface Props { + onSelect: (topic: Topic) => void; + onOpenSettings: () => void; +} + +export default function TopicSelector({ onSelect, onOpenSettings }: Props) { + const pythonTopics = TOPICS.filter((t) => t.language === 'python'); + const htmlTopics = TOPICS.filter((t) => t.language === 'html'); + + const [customLabel, setCustomLabel] = useState(''); + const [customLang, setCustomLang] = useState('python'); + + function handleCustomSubmit(e: React.FormEvent) { + e.preventDefault(); + const label = customLabel.trim(); + if (!label) return; + onSelect({ + id: `custom-${Date.now()}`, + label, + language: customLang, + description: 'Custom topic', + starterCode: CUSTOM_STARTER[customLang], + }); + } + + return ( +
+
+ {/* Header */} +
+

Professor

+

Choose a topic to start learning with your AI tutor

+ +
+ + {/* Python section */} +
+ +
+ + {/* HTML / CSS section */} +
+ +
+ + {/* Custom topic */} +
+

+ + Custom +

+
+ setCustomLabel(e.target.value)} + placeholder="e.g. Python Classes, CSS Grid, JavaScript Promises…" + className="min-w-0 flex-1 rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2.5 text-sm text-zinc-200 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none transition-colors" + /> +
+ + +
+ +
+
+
+
+ ); +} + +function Section({ + icon, + label, + topics, + onSelect, +}: { + icon: string; + label: string; + topics: Topic[]; + onSelect: (topic: Topic) => void; +}) { + return ( +
+

+ {icon} + {label} +

+
+ {topics.map((topic) => ( + + ))} +
+
+ ); +} diff --git a/components/chat/ChatInput.tsx b/components/chat/ChatInput.tsx new file mode 100644 index 0000000..65e2220 --- /dev/null +++ b/components/chat/ChatInput.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { useState, useRef, type KeyboardEvent } from 'react'; + +interface Props { + isDisabled: boolean; + onSend: (text: string) => void; +} + +export default function ChatInput({ isDisabled, onSend }: Props) { + const [text, setText] = useState(''); + const textareaRef = useRef(null); + + function handleSend() { + const trimmed = text.trim(); + if (!trimmed || isDisabled) return; + onSend(trimmed); + setText(''); + // Reset height + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + } + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + } + + function handleInput() { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${Math.min(el.scrollHeight, 160)}px`; + } + + return ( +
+
+