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:
49
.gitignore
vendored
Normal file
49
.gitignore
vendored
Normal file
@@ -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
|
||||||
198
CLAUDE.md
Normal file
198
CLAUDE.md
Normal file
@@ -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 <AppShell />
|
||||||
|
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
|
||||||
|
```
|
||||||
36
README.md
Normal file
36
README.md
Normal file
@@ -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.
|
||||||
151
ROADMAP.md
Normal file
151
ROADMAP.md
Normal file
@@ -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.
|
||||||
184
app/api/ai/route.ts
Normal file
184
app/api/ai/route.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
'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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
23
app/api/auth/login/route.ts
Normal file
23
app/api/auth/login/route.ts
Normal file
@@ -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 } });
|
||||||
|
}
|
||||||
7
app/api/auth/logout/route.ts
Normal file
7
app/api/auth/logout/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
8
app/api/auth/me/route.ts
Normal file
8
app/api/auth/me/route.ts
Normal file
@@ -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 } });
|
||||||
|
}
|
||||||
30
app/api/auth/register/route.ts
Normal file
30
app/api/auth/register/route.ts
Normal file
@@ -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 } });
|
||||||
|
}
|
||||||
38
app/api/execute/route.ts
Normal file
38
app/api/execute/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
84
app/api/models/route.ts
Normal file
84
app/api/models/route.ts
Normal file
@@ -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<string, string> = {};
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/api/session/route.ts
Normal file
62
app/api/session/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
38
app/globals.css
Normal file
38
app/globals.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
28
app/layout.tsx
Normal file
28
app/layout.tsx
Normal file
@@ -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 (
|
||||||
|
<html lang="en" className="dark h-full" suppressHydrationWarning>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased h-full overflow-hidden`} suppressHydrationWarning>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/page.tsx
Normal file
5
app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AppShell from '@/components/AppShell';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return <AppShell />;
|
||||||
|
}
|
||||||
310
components/AppShell.tsx
Normal file
310
components/AppShell.tsx
Normal file
@@ -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<ProviderConfig>(loadProviderConfig);
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
|
||||||
|
const [authUser, setAuthUser] = useState<AuthUser | null>(null);
|
||||||
|
const [showAuth, setShowAuth] = useState(false);
|
||||||
|
const [savedIndicator, setSavedIndicator] = useState(false);
|
||||||
|
const [splitPercent, setSplitPercent] = useState(50);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dbSaveRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||||
|
<>
|
||||||
|
<TopicSelector onSelect={handleTopicSelect} onOpenSettings={() => setShowSettings(true)} />
|
||||||
|
{showSettings && (
|
||||||
|
<ProviderSettings
|
||||||
|
config={providerConfig}
|
||||||
|
onSave={handleSaveProvider}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showAuth && (
|
||||||
|
<AuthModal onSuccess={handleAuthSuccess} onClose={() => setShowAuth(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div ref={containerRef} className="flex h-screen w-full overflow-hidden bg-zinc-950 flex-col lg:flex-row">
|
||||||
|
{/* Left pane — editor (Homework) or lesson viewer (Classroom) */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col min-h-0 min-w-0 overflow-hidden"
|
||||||
|
style={{ flex: `0 0 ${splitPercent}%` }}
|
||||||
|
>
|
||||||
|
{state.appMode === 'classroom' ? (
|
||||||
|
<LessonPane
|
||||||
|
topic={state.topic!}
|
||||||
|
lessonContent={state.lessonContent}
|
||||||
|
isGenerating={state.isStreaming && state.lessonContent === null}
|
||||||
|
streamingContent={state.streamingContent}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EditorPane
|
||||||
|
state={state}
|
||||||
|
dispatch={dispatch}
|
||||||
|
providerLabel={`${providerDef.label} · ${providerConfig.model}`}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onReset={handleReset}
|
||||||
|
onOpenSettings={() => setShowSettings(true)}
|
||||||
|
savedIndicator={savedIndicator}
|
||||||
|
authUser={authUser}
|
||||||
|
onShowAuth={() => setShowAuth(true)}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drag handle */}
|
||||||
|
<div
|
||||||
|
onMouseDown={handlePaneDragStart}
|
||||||
|
className="group relative flex-shrink-0 select-none
|
||||||
|
h-1 w-full cursor-row-resize
|
||||||
|
lg:h-full lg:w-1 lg:cursor-col-resize
|
||||||
|
bg-zinc-700 hover:bg-blue-500 active:bg-blue-400 transition-colors duration-100"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right pane — chat */}
|
||||||
|
<div className="flex flex-col flex-1 min-h-0 min-w-0 overflow-hidden">
|
||||||
|
<ChatPane
|
||||||
|
state={state}
|
||||||
|
dispatch={dispatch}
|
||||||
|
onSendMessage={handleSendMessage}
|
||||||
|
onSendClassroomMessage={handleSendClassroomMessage}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onSetAppMode={handleSetAppMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSettings && (
|
||||||
|
<ProviderSettings
|
||||||
|
config={providerConfig}
|
||||||
|
onSave={handleSaveProvider}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAuth && (
|
||||||
|
<AuthModal onSuccess={handleAuthSuccess} onClose={() => setShowAuth(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
components/AuthModal.tsx
Normal file
154
components/AuthModal.tsx
Normal file
@@ -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<Mode>('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 (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||||
|
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-sm rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-zinc-700 px-6 py-4">
|
||||||
|
<h2 className="text-sm font-semibold text-zinc-100">Save your progress</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M18 6 6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode tabs */}
|
||||||
|
<div className="flex border-b border-zinc-700">
|
||||||
|
{(['login', 'register'] as const).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => { setMode(m); setError(''); }}
|
||||||
|
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||||
|
mode === m
|
||||||
|
? 'border-b-2 border-blue-500 text-blue-400'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m === 'login' ? 'Sign In' : 'Create Account'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-5">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-zinc-400">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-zinc-400">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-red-800 bg-red-900/20 px-3 py-2 text-xs text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded-lg bg-blue-600 py-2.5 text-xs font-semibold text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-60 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<span className="h-3 w-3 animate-spin rounded-full border border-blue-300 border-t-white" />
|
||||||
|
{mode === 'login' ? 'Signing in…' : 'Creating account…'}
|
||||||
|
</span>
|
||||||
|
) : mode === 'login' ? (
|
||||||
|
'Sign In'
|
||||||
|
) : (
|
||||||
|
'Create Account'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center text-[11px] text-zinc-600">
|
||||||
|
{mode === 'login' ? (
|
||||||
|
<>No account?{' '}
|
||||||
|
<button type="button" onClick={() => { setMode('register'); setError(''); }} className="text-zinc-400 hover:text-zinc-200 underline">
|
||||||
|
Create one
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Already have an account?{' '}
|
||||||
|
<button type="button" onClick={() => { setMode('login'); setError(''); }} className="text-zinc-400 hover:text-zinc-200 underline">
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
348
components/ProviderSettings.tsx
Normal file
348
components/ProviderSettings.tsx
Normal file
@@ -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<ProviderId>(config.provider);
|
||||||
|
const [model, setModel] = useState(config.model);
|
||||||
|
const [apiKey, setApiKey] = useState(config.apiKey ?? '');
|
||||||
|
const [baseUrl, setBaseUrl] = useState(config.baseUrl ?? '');
|
||||||
|
|
||||||
|
const [connectStatus, setConnectStatus] = useState<ConnectStatus>('idle');
|
||||||
|
const [connectError, setConnectError] = useState('');
|
||||||
|
const [fetchedModels, setFetchedModels] = useState<string[]>([]);
|
||||||
|
const [modelFilter, setModelFilter] = useState('');
|
||||||
|
const filterRef = useRef<HTMLInputElement>(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 (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||||
|
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-zinc-700 px-6 py-4">
|
||||||
|
<h2 className="text-sm font-semibold text-zinc-100">AI Provider Settings</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M18 6 6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="space-y-5 px-6 py-5">
|
||||||
|
|
||||||
|
{/* Provider selector */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-xs font-medium text-zinc-400">Provider</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{PROVIDERS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => setProvider(p.id)}
|
||||||
|
className={`rounded-lg border px-3 py-2.5 text-left text-xs transition-all ${
|
||||||
|
provider === p.id
|
||||||
|
? 'border-blue-500 bg-blue-600/20 text-blue-300'
|
||||||
|
: 'border-zinc-700 bg-zinc-800 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="block font-semibold">{p.label}</span>
|
||||||
|
<span className="block text-[10px] opacity-70 mt-0.5">
|
||||||
|
{p.requiresApiKey ? 'Requires API key' : 'Local — no key needed'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Key — shown before model so user fills it first */}
|
||||||
|
{def.requiresApiKey && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-xs font-medium text-zinc-400">
|
||||||
|
API Key
|
||||||
|
{provider === 'anthropic' && (
|
||||||
|
<span className="ml-1 text-zinc-600">(leave blank to use ANTHROPIC_API_KEY env var)</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Base URL */}
|
||||||
|
{def.hasCustomBaseUrl && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-xs font-medium text-zinc-400">Base URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[11px] text-zinc-600">
|
||||||
|
{provider === 'ollama'
|
||||||
|
? 'Ollama must be running with OLLAMA_ORIGINS=* for CORS.'
|
||||||
|
: 'LM Studio server must be running on this address.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Model */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<label className="text-xs font-medium text-zinc-400">Model</label>
|
||||||
|
<button
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={connectStatus === 'loading'}
|
||||||
|
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed ${
|
||||||
|
connectStatus === 'success'
|
||||||
|
? 'bg-green-900/40 text-green-400 hover:bg-green-900/60'
|
||||||
|
: 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600 disabled:opacity-60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{connectStatus === 'loading' ? (
|
||||||
|
<>
|
||||||
|
<div className="h-3 w-3 animate-spin rounded-full border border-zinc-500 border-t-zinc-200" />
|
||||||
|
Connecting…
|
||||||
|
</>
|
||||||
|
) : connectStatus === 'success' ? (
|
||||||
|
<>
|
||||||
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
{fetchedModels.length} model{fetchedModels.length !== 1 ? 's' : ''} · Reconnect
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
Connect
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{connectStatus === 'error' && (
|
||||||
|
<div className="mb-2 rounded-lg border border-red-800 bg-red-900/20 px-3 py-2 text-xs text-red-400">
|
||||||
|
{connectError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live model list */}
|
||||||
|
{connectStatus === 'success' && fetchedModels.length > 0 ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{fetchedModels.length > 8 && (
|
||||||
|
<input
|
||||||
|
ref={filterRef}
|
||||||
|
type="text"
|
||||||
|
value={modelFilter}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="max-h-52 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-800">
|
||||||
|
{filteredModels.length === 0 ? (
|
||||||
|
<p className="px-3 py-3 text-xs italic text-zinc-500">No models match “{modelFilter}”</p>
|
||||||
|
) : (
|
||||||
|
filteredModels.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => setModel(m)}
|
||||||
|
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors ${
|
||||||
|
m === model
|
||||||
|
? 'bg-blue-600/20 text-blue-300'
|
||||||
|
: 'text-zinc-300 hover:bg-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m === model ? (
|
||||||
|
<svg className="h-3 w-3 flex-shrink-0 text-blue-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<span className="h-3 w-3 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="font-mono">{m}</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{modelFilter && filteredModels.length < fetchedModels.length && (
|
||||||
|
<p className="text-right text-[11px] text-zinc-600">
|
||||||
|
{filteredModels.length} of {fetchedModels.length} shown
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Static fallback — shown before connecting or on error */
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{def.modelSuggestions.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
value={def.modelSuggestions.includes(model) ? model : '__custom__'}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value !== '__custom__') setModel(e.target.value);
|
||||||
|
}}
|
||||||
|
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 focus:border-blue-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
{def.modelSuggestions.map((m) => (
|
||||||
|
<option key={m} value={m}>{m}</option>
|
||||||
|
))}
|
||||||
|
<option value="__custom__">Custom…</option>
|
||||||
|
</select>
|
||||||
|
{!def.modelSuggestions.includes(model) && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-[11px] text-zinc-600">
|
||||||
|
Hit Connect to load available models from {def.label}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{connectStatus === 'success' && (
|
||||||
|
<p className="mt-1.5 text-[11px] text-zinc-500">
|
||||||
|
Selected: <span className="font-mono text-zinc-400">{model}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-end gap-2 border-t border-zinc-700 px-6 py-4">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg px-4 py-2 text-xs text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="rounded-lg bg-blue-600 px-4 py-2 text-xs font-semibold text-white hover:bg-blue-500 transition-colors"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
components/TopicSelector.tsx
Normal file
157
components/TopicSelector.tsx
Normal file
@@ -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<SupportedLanguage, string> = {
|
||||||
|
python: '# Your code here\n',
|
||||||
|
html: '<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8">\n <title>Page</title>\n</head>\n<body>\n <!-- Your code here -->\n</body>\n</html>\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<SupportedLanguage>('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 (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center bg-zinc-950 p-8">
|
||||||
|
<div className="w-full max-w-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<h1 className="mb-2 text-3xl font-bold tracking-tight text-zinc-100">Professor</h1>
|
||||||
|
<p className="text-sm text-zinc-400">Choose a topic to start learning with your AI tutor</p>
|
||||||
|
<button
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
className="mt-3 inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} suppressHydrationWarning>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14" />
|
||||||
|
<path d="M12 2v2M12 20v2M2 12h2M20 12h2" />
|
||||||
|
</svg>
|
||||||
|
AI Provider Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Python section */}
|
||||||
|
<Section icon={PYTHON_ICON} label="Python" topics={pythonTopics} onSelect={onSelect} />
|
||||||
|
|
||||||
|
<div className="my-6 border-t border-zinc-800" />
|
||||||
|
|
||||||
|
{/* HTML / CSS section */}
|
||||||
|
<Section icon={HTML_ICON} label="HTML & CSS" topics={htmlTopics} onSelect={onSelect} />
|
||||||
|
|
||||||
|
<div className="my-6 border-t border-zinc-800" />
|
||||||
|
|
||||||
|
{/* Custom topic */}
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-zinc-500">
|
||||||
|
<span>✦</span>
|
||||||
|
Custom
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={handleCustomSubmit} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customLabel}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<div className="flex overflow-hidden rounded-lg border border-zinc-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCustomLang('python')}
|
||||||
|
className={`px-3 py-2 text-xs transition-colors ${
|
||||||
|
customLang === 'python'
|
||||||
|
? 'bg-zinc-600 text-zinc-100'
|
||||||
|
: 'text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{PYTHON_ICON} Python
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCustomLang('html')}
|
||||||
|
className={`border-l border-zinc-700 px-3 py-2 text-xs transition-colors ${
|
||||||
|
customLang === 'html'
|
||||||
|
? 'bg-zinc-600 text-zinc-100'
|
||||||
|
: 'text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{HTML_ICON} HTML
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!customLabel.trim()}
|
||||||
|
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
topics,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
topics: Topic[];
|
||||||
|
onSelect: (topic: Topic) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-zinc-500">
|
||||||
|
<span>{icon}</span>
|
||||||
|
{label}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
{topics.map((topic) => (
|
||||||
|
<button
|
||||||
|
key={topic.id}
|
||||||
|
onClick={() => onSelect(topic)}
|
||||||
|
className="group flex flex-col gap-1 rounded-xl border border-zinc-800 bg-zinc-900 p-4 text-left transition-all hover:border-zinc-600 hover:bg-zinc-800 hover:shadow-lg hover:shadow-black/20 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold text-zinc-200 group-hover:text-white transition-colors">
|
||||||
|
{topic.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs leading-relaxed text-zinc-500 group-hover:text-zinc-400 transition-colors">
|
||||||
|
{topic.description}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
components/chat/ChatInput.tsx
Normal file
69
components/chat/ChatInput.tsx
Normal file
@@ -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<HTMLTextAreaElement>(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<HTMLTextAreaElement>) {
|
||||||
|
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 (
|
||||||
|
<div className="border-t border-zinc-700 bg-zinc-900 p-3">
|
||||||
|
<div className="flex items-end gap-2 rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 focus-within:border-zinc-500 transition-colors">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onInput={handleInput}
|
||||||
|
disabled={isDisabled}
|
||||||
|
placeholder="Ask a question or chat with Professor…"
|
||||||
|
rows={1}
|
||||||
|
className="flex-1 resize-none bg-transparent text-sm text-zinc-200 placeholder-zinc-500 outline-none disabled:opacity-50"
|
||||||
|
style={{ maxHeight: '160px' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={isDisabled || !text.trim()}
|
||||||
|
className="flex-shrink-0 rounded-lg bg-blue-600 p-1.5 text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
||||||
|
aria-label="Send message"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M22 2L11 13" />
|
||||||
|
<path d="M22 2L15 22 11 13 2 9l20-7z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-center text-xs text-zinc-600">Enter to send · Shift+Enter for newline</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
components/chat/ChatPane.tsx
Normal file
102
components/chat/ChatPane.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { Dispatch } from 'react';
|
||||||
|
import type { AppState, AppAction, AppMode } from '@/types';
|
||||||
|
import TaskCard from './TaskCard';
|
||||||
|
import MessageList from './MessageList';
|
||||||
|
import ChatInput from './ChatInput';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
state: AppState;
|
||||||
|
dispatch: Dispatch<AppAction>;
|
||||||
|
onSendMessage: (text: string) => void;
|
||||||
|
onSendClassroomMessage: (text: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onSetAppMode: (mode: AppMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatPane({ state, onSendMessage, onSendClassroomMessage, onSetAppMode }: Props) {
|
||||||
|
const isClassroom = state.appMode === 'classroom';
|
||||||
|
const isTaskLoading = state.phase === 'loading_task';
|
||||||
|
|
||||||
|
// In classroom mode "busy" only covers classroom chat replies, not lesson generation
|
||||||
|
const isBusy = isClassroom
|
||||||
|
? state.isStreaming && state.lessonContent !== null
|
||||||
|
: state.isStreaming || state.phase === 'executing';
|
||||||
|
|
||||||
|
const messages = isClassroom ? state.classroomMessages : state.messages;
|
||||||
|
const handleSend = isClassroom ? onSendClassroomMessage : onSendMessage;
|
||||||
|
|
||||||
|
const showChatStreaming = isClassroom
|
||||||
|
? state.isStreaming && state.lessonContent !== null
|
||||||
|
: state.isStreaming && state.phase !== 'loading_task';
|
||||||
|
const chatStreamingContent = showChatStreaming ? state.streamingContent : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-zinc-900">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 border-b border-zinc-700 px-4 py-2">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-blue-400" />
|
||||||
|
<span className="text-sm font-semibold text-zinc-200">Professor</span>
|
||||||
|
|
||||||
|
{isBusy && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
|
||||||
|
<div className="h-2 w-2 animate-pulse rounded-full bg-blue-400" />
|
||||||
|
Thinking…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mode toggle */}
|
||||||
|
<div className="ml-auto flex overflow-hidden rounded-lg border border-zinc-700">
|
||||||
|
<button
|
||||||
|
onClick={() => onSetAppMode('classroom')}
|
||||||
|
className={`px-3 py-1 text-xs transition-colors ${
|
||||||
|
isClassroom ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Classroom
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSetAppMode('homework')}
|
||||||
|
className={`border-l border-zinc-700 px-3 py-1 text-xs transition-colors ${
|
||||||
|
!isClassroom ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Homework
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task card — homework only */}
|
||||||
|
{!isClassroom && (
|
||||||
|
<TaskCard
|
||||||
|
task={state.task}
|
||||||
|
isLoading={isTaskLoading}
|
||||||
|
streamingContent={isTaskLoading ? state.streamingContent : ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{state.error && (
|
||||||
|
<div className="mx-3 mt-3 rounded-lg border border-red-800 bg-red-900/30 px-4 py-2.5 text-sm text-red-300">
|
||||||
|
{state.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<MessageList
|
||||||
|
messages={messages}
|
||||||
|
isStreaming={showChatStreaming}
|
||||||
|
streamingContent={chatStreamingContent}
|
||||||
|
emptyText={
|
||||||
|
isClassroom
|
||||||
|
? 'Ask me anything about this topic.'
|
||||||
|
: 'Run your code or submit it for review — or ask me anything about the task.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<ChatInput isDisabled={state.isStreaming} onSend={handleSend} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
components/chat/MessageBubble.tsx
Normal file
68
components/chat/MessageBubble.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
|
import type { Message } from '@/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
message: Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageBubble({ message }: Props) {
|
||||||
|
const isUser = message.role === 'user';
|
||||||
|
|
||||||
|
if (isUser) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="max-w-[85%] rounded-2xl rounded-tr-sm bg-blue-600 px-4 py-3 text-sm text-white">
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="max-w-[92%] rounded-2xl rounded-tl-sm bg-zinc-800 px-4 py-3 text-sm text-zinc-200">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
code({ className, children, ...rest }) {
|
||||||
|
const match = /language-(\w+)/.exec(className ?? '');
|
||||||
|
const isBlock = Boolean(match);
|
||||||
|
return isBlock ? (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={vscDarkPlus as Record<string, React.CSSProperties>}
|
||||||
|
language={match![1]}
|
||||||
|
PreTag="div"
|
||||||
|
customStyle={{ margin: '0.5rem 0', borderRadius: '0.375rem', fontSize: '0.8rem' }}
|
||||||
|
>
|
||||||
|
{String(children).replace(/\n$/, '')}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
) : (
|
||||||
|
<code className="rounded bg-zinc-700 px-1 py-0.5 text-xs font-mono text-zinc-200" {...rest}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
p: ({ children }) => <p className="mb-2 last:mb-0 leading-relaxed">{children}</p>,
|
||||||
|
ul: ({ children }) => <ul className="mb-2 list-disc pl-5 space-y-1">{children}</ul>,
|
||||||
|
ol: ({ children }) => <ol className="mb-2 list-decimal pl-5 space-y-1">{children}</ol>,
|
||||||
|
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
||||||
|
h1: ({ children }) => <h1 className="mb-2 text-base font-bold text-zinc-100">{children}</h1>,
|
||||||
|
h2: ({ children }) => <h2 className="mb-2 text-sm font-bold text-zinc-100">{children}</h2>,
|
||||||
|
h3: ({ children }) => <h3 className="mb-1 text-sm font-semibold text-zinc-200">{children}</h3>,
|
||||||
|
strong: ({ children }) => <strong className="font-semibold text-zinc-100">{children}</strong>,
|
||||||
|
blockquote: ({ children }) => (
|
||||||
|
<blockquote className="my-2 border-l-2 border-blue-500 pl-3 text-zinc-400 italic">{children}</blockquote>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
components/chat/MessageList.tsx
Normal file
90
components/chat/MessageList.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
|
import type { Message } from '@/types';
|
||||||
|
import MessageBubble from './MessageBubble';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
messages: Message[];
|
||||||
|
isStreaming: boolean;
|
||||||
|
streamingContent: string;
|
||||||
|
emptyText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageList({ messages, isStreaming, streamingContent, emptyText }: Props) {
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages, streamingContent]);
|
||||||
|
|
||||||
|
if (messages.length === 0 && !isStreaming) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-6 text-center">
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
{emptyText ?? 'Run your code or submit it for review — or ask me anything about the task.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<MessageBubble key={msg.id} message={msg} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Live streaming bubble */}
|
||||||
|
{isStreaming && streamingContent && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="max-w-[92%] rounded-2xl rounded-tl-sm bg-zinc-800 px-4 py-3 text-sm text-zinc-200">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
code({ className, children, ...rest }) {
|
||||||
|
const match = /language-(\w+)/.exec(className ?? '');
|
||||||
|
return match ? (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={vscDarkPlus as Record<string, React.CSSProperties>}
|
||||||
|
language={match[1]}
|
||||||
|
PreTag="div"
|
||||||
|
customStyle={{ margin: '0.5rem 0', borderRadius: '0.375rem', fontSize: '0.8rem' }}
|
||||||
|
>
|
||||||
|
{String(children).replace(/\n$/, '')}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
) : (
|
||||||
|
<code className="rounded bg-zinc-700 px-1 py-0.5 text-xs font-mono text-zinc-200" {...rest}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
p: ({ children }) => <p className="mb-2 last:mb-0 leading-relaxed">{children}</p>,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{streamingContent}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Typing indicator when streaming but no content yet */}
|
||||||
|
{isStreaming && !streamingContent && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="rounded-2xl rounded-tl-sm bg-zinc-800 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="h-2 w-2 animate-bounce rounded-full bg-zinc-500 [animation-delay:-0.3s]" />
|
||||||
|
<div className="h-2 w-2 animate-bounce rounded-full bg-zinc-500 [animation-delay:-0.15s]" />
|
||||||
|
<div className="h-2 w-2 animate-bounce rounded-full bg-zinc-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
components/chat/TaskCard.tsx
Normal file
81
components/chat/TaskCard.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import type { Task } from '@/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
task: Task | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
streamingContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskCard({ task, isLoading, streamingContent }: Props) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
if (!isLoading && !task) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-zinc-700 bg-zinc-800/50">
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-3 text-left hover:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-zinc-400">{collapsed ? '▶' : '▼'}</span>
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-blue-400">
|
||||||
|
{isLoading ? 'Generating task…' : task?.title ?? 'Task'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="px-4 pb-4 text-sm text-zinc-300">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Skeleton shimmer while task streams in */}
|
||||||
|
{streamingContent ? (
|
||||||
|
<p className="whitespace-pre-wrap text-xs text-zinc-400 font-mono">{streamingContent}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="h-3 w-3/4 animate-pulse rounded bg-zinc-700" />
|
||||||
|
<div className="h-3 w-full animate-pulse rounded bg-zinc-700" />
|
||||||
|
<div className="h-3 w-2/3 animate-pulse rounded bg-zinc-700" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : task ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
p: ({ children }) => <p className="leading-relaxed text-zinc-300">{children}</p>,
|
||||||
|
ul: ({ children }) => <ul className="list-disc pl-5 space-y-1">{children}</ul>,
|
||||||
|
li: ({ children }) => <li className="text-zinc-400">{children}</li>,
|
||||||
|
strong: ({ children }) => <strong className="font-semibold text-zinc-200">{children}</strong>,
|
||||||
|
code: ({ children }) => (
|
||||||
|
<code className="rounded bg-zinc-700 px-1 py-0.5 text-xs font-mono text-zinc-300">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{task.description}
|
||||||
|
</ReactMarkdown>
|
||||||
|
|
||||||
|
{task.hints.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-semibold text-zinc-500 uppercase tracking-wider">Hints</p>
|
||||||
|
<ul className="list-disc pl-5 space-y-1">
|
||||||
|
{task.hints.map((hint, i) => (
|
||||||
|
<li key={i} className="text-xs text-zinc-500">{hint}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
components/classroom/LessonPane.tsx
Normal file
96
components/classroom/LessonPane.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
|
import type { Topic } from '@/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
topic: Topic;
|
||||||
|
lessonContent: string | null;
|
||||||
|
isGenerating: boolean;
|
||||||
|
streamingContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LessonPane({ topic, lessonContent, isGenerating, streamingContent }: Props) {
|
||||||
|
const content = lessonContent ?? (isGenerating ? streamingContent : null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-zinc-950">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 border-b border-zinc-700 bg-zinc-900 px-4 py-2 flex-shrink-0">
|
||||||
|
<svg className="h-3.5 w-3.5 text-zinc-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
||||||
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-semibold text-zinc-200">{topic.label}</span>
|
||||||
|
{isGenerating && (
|
||||||
|
<div className="ml-auto flex items-center gap-1.5 text-xs text-zinc-500">
|
||||||
|
<div className="h-2 w-2 animate-pulse rounded-full bg-blue-400" />
|
||||||
|
Writing lesson…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{!content ? (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
||||||
|
<div className="h-2 w-2 animate-bounce rounded-full bg-zinc-600 [animation-delay:-0.3s]" />
|
||||||
|
<div className="h-2 w-2 animate-bounce rounded-full bg-zinc-600 [animation-delay:-0.15s]" />
|
||||||
|
<div className="h-2 w-2 animate-bounce rounded-full bg-zinc-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-6 py-5 text-sm">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
code({ className, children, ...rest }) {
|
||||||
|
const match = /language-(\w+)/.exec(className ?? '');
|
||||||
|
return match ? (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={vscDarkPlus as Record<string, React.CSSProperties>}
|
||||||
|
language={match[1]}
|
||||||
|
PreTag="div"
|
||||||
|
customStyle={{ margin: '0.75rem 0', borderRadius: '0.375rem', fontSize: '0.8rem' }}
|
||||||
|
>
|
||||||
|
{String(children).replace(/\n$/, '')}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
) : (
|
||||||
|
<code className="rounded bg-zinc-800 px-1 py-0.5 text-xs font-mono text-zinc-300" {...rest}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
h1: ({ children }) => <h1 className="mb-4 mt-6 text-xl font-bold text-zinc-100 first:mt-0">{children}</h1>,
|
||||||
|
h2: ({ children }) => <h2 className="mb-3 mt-6 text-base font-bold text-zinc-100 border-b border-zinc-800 pb-1">{children}</h2>,
|
||||||
|
h3: ({ children }) => <h3 className="mb-2 mt-4 text-sm font-semibold text-zinc-200">{children}</h3>,
|
||||||
|
p: ({ children }) => <p className="mb-3 leading-relaxed text-zinc-300">{children}</p>,
|
||||||
|
ul: ({ children }) => <ul className="mb-3 list-disc pl-5 space-y-1.5 text-zinc-300">{children}</ul>,
|
||||||
|
ol: ({ children }) => <ol className="mb-3 list-decimal pl-5 space-y-1.5 text-zinc-300">{children}</ol>,
|
||||||
|
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
||||||
|
strong: ({ children }) => <strong className="font-semibold text-zinc-100">{children}</strong>,
|
||||||
|
blockquote: ({ children }) => (
|
||||||
|
<blockquote className="my-3 border-l-2 border-blue-500 pl-4 italic text-zinc-400">{children}</blockquote>
|
||||||
|
),
|
||||||
|
hr: () => <hr className="my-5 border-zinc-800" />,
|
||||||
|
table: ({ children }) => (
|
||||||
|
<div className="mb-3 overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse text-xs">{children}</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
th: ({ children }) => <th className="border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-left font-semibold text-zinc-200">{children}</th>,
|
||||||
|
td: ({ children }) => <td className="border border-zinc-700 px-3 py-1.5 text-zinc-300">{children}</td>,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
components/editor/CodeEditor.tsx
Normal file
46
components/editor/CodeEditor.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import type { SupportedLanguage } from '@/types';
|
||||||
|
|
||||||
|
const MonacoEditor = dynamic(() => import('@monaco-editor/react'), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-[#1e1e1e]">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-zinc-600 border-t-blue-400" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
language: SupportedLanguage;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
height?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CodeEditor({ language, value, onChange, height = '100%' }: Props) {
|
||||||
|
const monacoLanguage = language === 'html' ? 'html' : 'python';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MonacoEditor
|
||||||
|
height={height}
|
||||||
|
language={monacoLanguage}
|
||||||
|
value={value}
|
||||||
|
theme="vs-dark"
|
||||||
|
onChange={(val) => onChange(val ?? '')}
|
||||||
|
options={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace",
|
||||||
|
minimap: { enabled: false },
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
padding: { top: 16, bottom: 16 },
|
||||||
|
lineNumbersMinChars: 3,
|
||||||
|
tabSize: 4,
|
||||||
|
wordWrap: 'on',
|
||||||
|
smoothScrolling: true,
|
||||||
|
cursorSmoothCaretAnimation: 'on',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
components/editor/EditorPane.tsx
Normal file
73
components/editor/EditorPane.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { Dispatch } from 'react';
|
||||||
|
import type { AppState, AppAction } from '@/types';
|
||||||
|
import { useCodeExecution } from '@/hooks/useCodeExecution';
|
||||||
|
import CodeEditor from './CodeEditor';
|
||||||
|
import EditorToolbar from './EditorToolbar';
|
||||||
|
import OutputPanel from './OutputPanel';
|
||||||
|
import HtmlPreview from './HtmlPreview';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
state: AppState;
|
||||||
|
dispatch: Dispatch<AppAction>;
|
||||||
|
providerLabel: string;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onReset: () => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
savedIndicator: boolean;
|
||||||
|
authUser: { id: string; email: string } | null;
|
||||||
|
onShowAuth: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditorPane({ state, dispatch, providerLabel, onSubmit, onReset, onOpenSettings, savedIndicator, authUser, onShowAuth, onLogout }: Props) {
|
||||||
|
const { execute } = useCodeExecution(dispatch);
|
||||||
|
const language = state.topic!.language;
|
||||||
|
|
||||||
|
function handleRun() {
|
||||||
|
execute(language, state.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-[#1e1e1e]">
|
||||||
|
<EditorToolbar
|
||||||
|
language={language}
|
||||||
|
providerLabel={providerLabel}
|
||||||
|
isExecuting={state.phase === 'executing'}
|
||||||
|
isStreaming={state.isStreaming}
|
||||||
|
onRun={handleRun}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onReset={onReset}
|
||||||
|
onOpenSettings={onOpenSettings}
|
||||||
|
savedIndicator={savedIndicator}
|
||||||
|
authUser={authUser}
|
||||||
|
onShowAuth={onShowAuth}
|
||||||
|
onLogout={onLogout}
|
||||||
|
responseMode={state.responseMode}
|
||||||
|
onToggleHintMode={() =>
|
||||||
|
dispatch({ type: 'SET_RESPONSE_MODE', payload: { hintMode: !state.responseMode.hintMode } })
|
||||||
|
}
|
||||||
|
onToggleStrict={() =>
|
||||||
|
dispatch({ type: 'SET_RESPONSE_MODE', payload: { strict: !state.responseMode.strict } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<CodeEditor
|
||||||
|
language={language}
|
||||||
|
value={state.code}
|
||||||
|
onChange={(val) => dispatch({ type: 'CODE_CHANGE', payload: val })}
|
||||||
|
height="100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{language === 'html' && <HtmlPreview code={state.code} />}
|
||||||
|
|
||||||
|
<OutputPanel
|
||||||
|
result={state.executionResult}
|
||||||
|
isLoading={state.phase === 'executing'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
components/editor/EditorToolbar.tsx
Normal file
163
components/editor/EditorToolbar.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ResponseMode } from '@/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
language: string;
|
||||||
|
providerLabel: string;
|
||||||
|
isExecuting: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
onRun: () => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onReset: () => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
savedIndicator: boolean;
|
||||||
|
authUser: { id: string; email: string } | null;
|
||||||
|
onShowAuth: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
responseMode: ResponseMode;
|
||||||
|
onToggleHintMode: () => void;
|
||||||
|
onToggleStrict: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditorToolbar({
|
||||||
|
language,
|
||||||
|
providerLabel,
|
||||||
|
isExecuting,
|
||||||
|
isStreaming,
|
||||||
|
onRun,
|
||||||
|
onSubmit,
|
||||||
|
onReset,
|
||||||
|
onOpenSettings,
|
||||||
|
savedIndicator,
|
||||||
|
authUser,
|
||||||
|
onShowAuth,
|
||||||
|
onLogout,
|
||||||
|
responseMode,
|
||||||
|
onToggleHintMode,
|
||||||
|
onToggleStrict,
|
||||||
|
}: Props) {
|
||||||
|
const busy = isExecuting || isStreaming;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 border-b border-zinc-700 bg-zinc-900 px-4 py-2">
|
||||||
|
{/* Language badge */}
|
||||||
|
<span className="rounded bg-zinc-700 px-2 py-0.5 text-xs font-mono text-zinc-300">
|
||||||
|
{language}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Hint mode toggle */}
|
||||||
|
<button
|
||||||
|
onClick={onToggleHintMode}
|
||||||
|
title={responseMode.hintMode ? 'Hints on — click to disable' : 'Hints off — click to enable'}
|
||||||
|
className={`flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors ${
|
||||||
|
responseMode.hintMode
|
||||||
|
? 'bg-yellow-500/20 text-yellow-300 hover:bg-yellow-500/30'
|
||||||
|
: 'text-zinc-500 hover:bg-zinc-700 hover:text-zinc-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2a7 7 0 0 1 7 7c0 2.73-1.56 5.1-3.85 6.33L15 17H9l-.15-1.67C6.56 14.1 5 11.73 5 9a7 7 0 0 1 7-7zm-1 16h2v1a1 1 0 0 1-2 0v-1zm0 3h2v.5a1 1 0 0 1-2 0V21z" />
|
||||||
|
</svg>
|
||||||
|
Hints
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Strict / Lenient pill */}
|
||||||
|
<button
|
||||||
|
onClick={onToggleStrict}
|
||||||
|
title={responseMode.strict ? 'Strict — exact approach required. Click for Lenient.' : 'Lenient — equivalent solutions accepted. Click for Strict.'}
|
||||||
|
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||||
|
responseMode.strict
|
||||||
|
? 'bg-red-500/20 text-red-300 hover:bg-red-500/30'
|
||||||
|
: 'bg-zinc-700 text-zinc-400 hover:bg-zinc-600 hover:text-zinc-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{responseMode.strict ? 'Strict' : 'Lenient'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{/* Saved indicator */}
|
||||||
|
<span
|
||||||
|
className={`text-xs text-zinc-500 transition-opacity duration-300 ${savedIndicator ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
Saved
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Auth area */}
|
||||||
|
{authUser ? (
|
||||||
|
<>
|
||||||
|
<span className="max-w-[120px] truncate text-xs text-zinc-500" title={authUser.email}>
|
||||||
|
{authUser.email}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="rounded px-2 py-1 text-xs text-zinc-500 hover:bg-zinc-700 hover:text-zinc-300 transition-colors"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onShowAuth}
|
||||||
|
className="rounded px-2 py-1 text-xs text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200 transition-colors"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Provider settings */}
|
||||||
|
<button
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
title="AI provider settings"
|
||||||
|
className="flex items-center gap-1.5 rounded px-2 py-1.5 text-xs text-zinc-500 hover:bg-zinc-700 hover:text-zinc-300 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14" />
|
||||||
|
<path d="M12 2v2M12 20v2M2 12h2M20 12h2" />
|
||||||
|
</svg>
|
||||||
|
<span className="max-w-[80px] truncate">{providerLabel}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onReset}
|
||||||
|
title="Pick a new topic"
|
||||||
|
className="rounded px-3 py-1.5 text-xs text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200 transition-colors"
|
||||||
|
>
|
||||||
|
Change topic
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onRun}
|
||||||
|
disabled={busy}
|
||||||
|
className="flex items-center gap-1.5 rounded bg-zinc-700 px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-600 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{isExecuting ? (
|
||||||
|
<>
|
||||||
|
<div className="h-3 w-3 animate-spin rounded-full border border-zinc-500 border-t-blue-400" />
|
||||||
|
Running…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>▶ Run</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={busy}
|
||||||
|
className="flex items-center gap-1.5 rounded bg-blue-600 px-3 py-1.5 text-xs text-white hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{isStreaming ? (
|
||||||
|
<>
|
||||||
|
<div className="h-3 w-3 animate-spin rounded-full border border-blue-300 border-t-white" />
|
||||||
|
Reviewing…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Submit for Review'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
components/editor/HtmlPreview.tsx
Normal file
36
components/editor/HtmlPreview.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HtmlPreview({ code }: Props) {
|
||||||
|
const [previewCode, setPreviewCode] = useState(code);
|
||||||
|
|
||||||
|
// Debounce the preview update to avoid iframe thrashing
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setPreviewCode(code), 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col border-t border-zinc-700">
|
||||||
|
<div className="flex items-center gap-2 border-b border-zinc-700 bg-zinc-900 px-4 py-2">
|
||||||
|
<span className="text-xs font-sans font-medium uppercase tracking-wider text-zinc-400">Preview</span>
|
||||||
|
<div className="ml-auto flex gap-1">
|
||||||
|
<div className="h-2.5 w-2.5 rounded-full bg-red-500/60" />
|
||||||
|
<div className="h-2.5 w-2.5 rounded-full bg-yellow-500/60" />
|
||||||
|
<div className="h-2.5 w-2.5 rounded-full bg-green-500/60" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
srcDoc={previewCode}
|
||||||
|
sandbox="allow-scripts"
|
||||||
|
className="h-48 w-full bg-white"
|
||||||
|
title="HTML Preview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
components/editor/OutputPanel.tsx
Normal file
78
components/editor/OutputPanel.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { ExecutionResult } from '@/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
result: ExecutionResult | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OutputPanel({ result, isLoading }: Props) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
if (!isLoading && !result) return null;
|
||||||
|
|
||||||
|
const hasError = result?.error || (result?.stderr && result.stderr.trim());
|
||||||
|
const hasOutput = result?.stdout && result.stdout.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-zinc-700 bg-zinc-900 text-sm font-mono">
|
||||||
|
{/* Header bar */}
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-2 text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||||
|
>
|
||||||
|
<span>{collapsed ? '▶' : '▼'}</span>
|
||||||
|
<span className="font-sans font-medium uppercase tracking-wider">Output</span>
|
||||||
|
{result && !isLoading && (
|
||||||
|
<span
|
||||||
|
className={`ml-auto rounded px-1.5 py-0.5 text-xs font-sans ${
|
||||||
|
result.exitCode === 0 && !result.error
|
||||||
|
? 'bg-green-900/50 text-green-400'
|
||||||
|
: 'bg-red-900/50 text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{result.timedOut
|
||||||
|
? 'timed out'
|
||||||
|
: result.error
|
||||||
|
? 'error'
|
||||||
|
: `exit ${result.exitCode}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="max-h-48 overflow-y-auto px-4 pb-4 space-y-2">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center gap-2 text-zinc-500">
|
||||||
|
<div className="h-3 w-3 animate-spin rounded-full border border-zinc-600 border-t-blue-400" />
|
||||||
|
<span>Running…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result?.error && (
|
||||||
|
<pre className="whitespace-pre-wrap text-red-400">{result.error}</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result?.timedOut && !result.error && (
|
||||||
|
<p className="text-yellow-400">Execution timed out after 15 seconds.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasOutput && (
|
||||||
|
<pre className="whitespace-pre-wrap text-zinc-200">{result!.stdout}</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasError && !result?.error && (
|
||||||
|
<pre className="whitespace-pre-wrap text-red-400">{result!.stderr}</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && !isLoading && !result.error && !hasOutput && !hasError && (
|
||||||
|
<span className="text-zinc-500 italic">No output</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
db/index.ts
Normal file
18
db/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
type DrizzleDb = ReturnType<typeof drizzle<typeof schema>>;
|
||||||
|
const globalForDb = globalThis as unknown as { _professorDb?: DrizzleDb };
|
||||||
|
|
||||||
|
function createDb(): DrizzleDb {
|
||||||
|
const sqlite = new Database('professor.db');
|
||||||
|
sqlite.pragma('journal_mode = WAL');
|
||||||
|
return drizzle(sqlite, { schema });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db: DrizzleDb = globalForDb._professorDb ?? createDb();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
globalForDb._professorDb = db;
|
||||||
|
}
|
||||||
21
db/schema.ts
Normal file
21
db/schema.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { text, sqliteTable } from 'drizzle-orm/sqlite-core';
|
||||||
|
|
||||||
|
export const users = sqliteTable('users', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
passwordHash: text('password_hash').notNull(),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const savedSessions = sqliteTable('saved_sessions', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
topicId: text('topic_id'),
|
||||||
|
taskJson: text('task_json'),
|
||||||
|
code: text('code'),
|
||||||
|
messagesJson: text('messages_json'),
|
||||||
|
executionResultJson: text('execution_result_json'),
|
||||||
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
});
|
||||||
10
drizzle.config.ts
Normal file
10
drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Config } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
schema: './db/schema.ts',
|
||||||
|
out: './db/migrations',
|
||||||
|
dialect: 'sqlite',
|
||||||
|
dbCredentials: {
|
||||||
|
url: 'professor.db',
|
||||||
|
},
|
||||||
|
} satisfies Config;
|
||||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
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 };
|
||||||
|
}
|
||||||
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]));
|
||||||
5
next.config.ts
Normal file
5
next.config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
9914
package-lock.json
generated
Normal file
9914
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
package.json
Normal file
40
package.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "professor",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint",
|
||||||
|
"db:push": "drizzle-kit push"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.78.0",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"better-sqlite3": "^12.6.2",
|
||||||
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"jose": "^6.1.3",
|
||||||
|
"next": "16.1.6",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
|
"remark-gfm": "^4.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"drizzle-kit": "^0.31.9",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.1.6",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
69
start.sh
Normal file
69
start.sh
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# ─── Colours ──────────────────────────────────────────────────────────────────
|
||||||
|
BOLD='\033[1m'
|
||||||
|
DIM='\033[2m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
RESET='\033[0m'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD} Professor — AI Coding Tutor${RESET}"
|
||||||
|
echo -e "${DIM} ─────────────────────────────${RESET}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ─── Check Node ───────────────────────────────────────────────────────────────
|
||||||
|
if ! command -v node &>/dev/null; then
|
||||||
|
echo -e "${RED} ✗ Node.js is not installed. Please install Node.js 18+ and try again.${RESET}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
NODE_VERSION=$(node -e "process.stdout.write(process.version.slice(1).split('.')[0])")
|
||||||
|
if [ "$NODE_VERSION" -lt 18 ]; then
|
||||||
|
echo -e "${RED} ✗ Node.js 18+ required (found v${NODE_VERSION}).${RESET}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Check npm deps ───────────────────────────────────────────────────────────
|
||||||
|
if [ ! -d "node_modules" ]; then
|
||||||
|
echo -e "${YELLOW} ⚙ node_modules not found — installing dependencies…${RESET}"
|
||||||
|
npm install --silent
|
||||||
|
echo -e "${GREEN} ✓ Dependencies installed${RESET}"
|
||||||
|
else
|
||||||
|
echo -e "${GREEN} ✓ Dependencies ready${RESET}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Check .env.local ─────────────────────────────────────────────────────────
|
||||||
|
if [ ! -f ".env.local" ]; then
|
||||||
|
echo -e "${YELLOW} ⚠ .env.local not found — creating it now${RESET}"
|
||||||
|
echo "ANTHROPIC_API_KEY=your_api_key_here" > .env.local
|
||||||
|
fi
|
||||||
|
|
||||||
|
ANTHROPIC_KEY=$(grep -E "^ANTHROPIC_API_KEY=" .env.local | cut -d= -f2- | tr -d '[:space:]')
|
||||||
|
|
||||||
|
if [ -z "$ANTHROPIC_KEY" ] || [ "$ANTHROPIC_KEY" = "your_api_key_here" ]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW} ⚠ No Anthropic API key found in .env.local${RESET}"
|
||||||
|
echo -e "${DIM} You can still use OpenRouter, LM Studio, or Ollama via the${RESET}"
|
||||||
|
echo -e "${DIM} provider settings in the app. To use Claude, add your key:${RESET}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${DIM} ANTHROPIC_API_KEY=sk-ant-… → .env.local${RESET}"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo -e "${GREEN} ✓ Anthropic API key found${RESET}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Launch ───────────────────────────────────────────────────────────────────
|
||||||
|
PORT=${PORT:-3000}
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN} ➜ Starting dev server on http://localhost:${PORT}${RESET}"
|
||||||
|
echo -e "${DIM} Press Ctrl+C to stop${RESET}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
exec npm run dev -- --port "$PORT"
|
||||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
152
types/index.ts
Normal file
152
types/index.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
// ─── App Mode ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type AppMode = 'classroom' | 'homework';
|
||||||
|
|
||||||
|
// ─── Response Mode ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ResponseMode {
|
||||||
|
hintMode: boolean; // true = AI proactively suggests hints
|
||||||
|
strict: boolean; // true = only the exact required approach is accepted
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Languages & Topics ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type SupportedLanguage = 'python' | 'html';
|
||||||
|
|
||||||
|
export interface Topic {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
language: SupportedLanguage;
|
||||||
|
description: string;
|
||||||
|
starterCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tasks (AI generated) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
hints: string[];
|
||||||
|
starterCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Messages ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type MessageRole = 'user' | 'assistant';
|
||||||
|
export type MessageType = 'task' | 'review' | 'chat';
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
role: MessageRole;
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
type: MessageType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Provider Config ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ProviderId = 'anthropic' | 'openrouter' | 'lmstudio' | 'ollama';
|
||||||
|
|
||||||
|
export interface ProviderConfig {
|
||||||
|
provider: ProviderId;
|
||||||
|
model: string;
|
||||||
|
apiKey?: string; // Anthropic key override or OpenRouter key
|
||||||
|
baseUrl?: string; // LM Studio / Ollama custom host:port
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AI API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type AIMode = 'generate_task' | 'review_code' | 'chat' | 'generate_lesson' | 'classroom_chat';
|
||||||
|
|
||||||
|
export interface AIRequestBody {
|
||||||
|
mode: AIMode;
|
||||||
|
topic: Topic;
|
||||||
|
code: string;
|
||||||
|
executionResult?: ExecutionResult;
|
||||||
|
messages: Pick<Message, 'role' | 'content'>[];
|
||||||
|
userMessage?: string;
|
||||||
|
providerConfig: ProviderConfig;
|
||||||
|
responseMode?: ResponseMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Code Execution ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ExecutionResult {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
exitCode: number;
|
||||||
|
timedOut: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecuteRequestBody {
|
||||||
|
language: SupportedLanguage;
|
||||||
|
code: string;
|
||||||
|
stdin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── App State (useReducer) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type AppPhase =
|
||||||
|
| 'selecting'
|
||||||
|
| 'loading_task'
|
||||||
|
| 'ready'
|
||||||
|
| 'executing'
|
||||||
|
| 'reviewing';
|
||||||
|
|
||||||
|
export interface AppState {
|
||||||
|
phase: AppPhase;
|
||||||
|
appMode: AppMode;
|
||||||
|
topic: Topic | null;
|
||||||
|
task: Task | null;
|
||||||
|
code: string;
|
||||||
|
messages: Message[];
|
||||||
|
classroomMessages: Message[];
|
||||||
|
lessonContent: string | null;
|
||||||
|
executionResult: ExecutionResult | null;
|
||||||
|
streamingContent: string;
|
||||||
|
isStreaming: boolean;
|
||||||
|
error: string | null;
|
||||||
|
responseMode: ResponseMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Auth ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── App Actions ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type AppAction =
|
||||||
|
| { type: 'SELECT_TOPIC'; payload: Topic }
|
||||||
|
| { type: 'TASK_STREAM_START' }
|
||||||
|
| { type: 'TASK_STREAM_CHUNK'; payload: string }
|
||||||
|
| { type: 'TASK_STREAM_DONE'; payload: Task }
|
||||||
|
| { type: 'CODE_CHANGE'; payload: string }
|
||||||
|
| { type: 'EXECUTE_START' }
|
||||||
|
| { type: 'EXECUTE_DONE'; payload: ExecutionResult }
|
||||||
|
| { type: 'REVIEW_START' }
|
||||||
|
| { type: 'STREAM_CHUNK'; payload: string }
|
||||||
|
| { type: 'STREAM_DONE'; payload: Message }
|
||||||
|
| { type: 'SEND_USER_MESSAGE'; payload: string }
|
||||||
|
| { type: 'SET_APP_MODE'; payload: AppMode }
|
||||||
|
| { type: 'LESSON_STREAM_START' }
|
||||||
|
| { type: 'LESSON_STREAM_DONE'; payload: string }
|
||||||
|
| { type: 'SEND_CLASSROOM_MESSAGE'; payload: string }
|
||||||
|
| { type: 'CLASSROOM_MESSAGE_DONE'; payload: Message }
|
||||||
|
| { type: 'SET_ERROR'; payload: string }
|
||||||
|
| { type: 'CLEAR_ERROR' }
|
||||||
|
| { type: 'SET_RESPONSE_MODE'; payload: Partial<ResponseMode> }
|
||||||
|
| { type: 'RESET' }
|
||||||
|
| {
|
||||||
|
type: 'RESTORE_SESSION';
|
||||||
|
payload: {
|
||||||
|
topic: Topic;
|
||||||
|
task: Task;
|
||||||
|
code: string;
|
||||||
|
messages: Message[];
|
||||||
|
executionResult: ExecutionResult | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user