Quizzes, flashcards and learning roadmaps from any OpenAI-compatible LLM, in English and Bahasa Melayu
AI-generated quizzes, flashcards and learning roadmaps, with spaced repetition and a simple progress view. In English and Bahasa Melayu.
Live demo → (bring your own API key: open Settings, pick a provider and paste a key; it stays in your browser)
![]() |
![]() |
![]() |
![]() |
Every AI feature goes through one provider layer (src/lib/llm/) that speaks the OpenAI /chat/completions protocol. Choose a built-in provider in the browser, or configure the server once with environment variables when you host it yourself.
| Feature | What it does |
|---|---|
| Quiz | Generates multiple-choice questions for any topic at the difficulty you choose (easy / medium / hard), with an explanation after each answer |
| Flashcards | Generates cards for a topic and schedules reviews with the SM-2 spaced-repetition algorithm |
| Learning Roadmap | Builds a step-by-step plan for a topic at beginner / intermediate / advanced level |
| Progress Tracker | Shows the sessions, scores and token usage from your current visit (kept in memory, not saved) |
| Concept Map | A static sample map of connected concepts (not generated by the LLM yet) |
| Bahasa Melayu + English | Every screen in BM or English (switch in the top bar). The AI answers in the language you are reading |
| Light and dark themes | Follows your device setting; the moon/sun button overrides it |
- All UI text lives in
src/i18n/messages/en.tsandms.ts. A test fails if a key exists in one language but not the other, if placeholders differ, or if the BM file contains common Indonesian words (karena,harus,kirim, ...). - The first visit uses your browser language (
ms/ms-MY→ BM, anything else → English). Your choice is saved inlocalStorageand mirrored to a cookie so the server renders the page in the same language. - Every API call sends
language: "en" | "ms". The server adds an instruction to the prompt to answer in that language; for BM it asks for standard Malaysian spelling (Dewan Bahasa dan Pustaka) and names Indonesian words to avoid. How well the model follows it depends on the model you choose.
There are also two API-only routes with no page yet: POST /api/chat (single message, optional streaming) and POST /api/analyze (study-session analysis). Both accept the same optional language field.
The public demo has no server-side key. Each visitor uses their own:
- Click Settings (Tetapan in BM, the key icon on phones) in the top bar.
- Pick a provider, keep the suggested model or type another, and paste your API key.
- Press Save. Forget key removes it again.
What happens to the key:
- It is stored only in your browser (
localStorage). - With each request it is sent in an
x-llm-keyheader (withx-llm-providerandx-llm-model) to this app's own API route, which forwards it to the provider you chose. - The server does not store or log it, and error messages never echo the provider's response body.
- The server only forwards to the fixed base URLs of the built-in providers below. Visitors cannot point it at another address.
max_tokensis capped on the server (4096 by default).
Use a key with a spending limit, and press Forget key on shared devices.
| Provider | Id | Base URL | Default model | Auth header |
|---|---|---|---|---|
| OpenAI | openai |
https://api.openai.com/v1 |
gpt-4o-mini |
Authorization: Bearer |
| OpenRouter | openrouter |
https://openrouter.ai/api/v1 |
openai/gpt-4o-mini |
Authorization: Bearer |
| Groq | groq |
https://api.groq.com/openai/v1 |
llama-3.3-70b-versatile |
Authorization: Bearer |
| DeepSeek | deepseek |
https://api.deepseek.com/v1 |
deepseek-chat |
Authorization: Bearer |
| Together AI | together |
https://api.together.xyz/v1 |
meta-llama/Llama-3.3-70B-Instruct-Turbo |
Authorization: Bearer |
| Mistral | mistral |
https://api.mistral.ai/v1 |
mistral-small-latest |
Authorization: Bearer |
| Xiaomi MiMo | mimo |
https://api.xiaomimimo.com/v1 |
mimo-v2.5-pro |
api-key |
| Xiaomi MiMo (Token Plan, Singapore) | mimo-token-plan |
https://token-plan-sgp.xiaomimimo.com/v1 |
mimo-v2.5-pro |
api-key |
| Ollama (self-host) | ollama |
http://localhost:11434/v1 |
llama3.1 |
none / Bearer |
| llama.cpp server (self-host) | llamacpp |
http://localhost:8080/v1 |
local-model |
none / Bearer |
| Custom endpoint (self-host) | custom |
set by you | set by you | Bearer if a key is given |
The model field is free text, so any model your provider offers works. Self-host entries can only be chosen in the browser when the server sets LLM_ALLOW_CUSTOM_BASE_URL=1; they are always available through LLM_PROVIDER.
git clone https://github.com/aimanmalib/learnpath.git
cd learnpath
npm install
npm run dev # http://localhost:3000Without any environment variables the app runs in bring-your-own-key mode, like the demo. To give every visitor of your copy a shared server key, create .env.local:
LLM_PROVIDER=openai
LLM_API_KEY=your-key-here| Variable | Default | Meaning |
|---|---|---|
LLM_PROVIDER |
openai |
Provider id from the table above |
LLM_API_KEY |
(none) | Server key. When set, the server uses it and ignores keys sent by browsers |
LLM_MODEL |
preset default | Model name |
LLM_BASE_URL |
preset URL | Override the provider URL (required for custom) |
LLM_ALLOW_CUSTOM_BASE_URL |
off | 1 lets the browser choose Ollama, llama.cpp or a custom base URL. Only enable this on a private deployment: it lets visitors make your server send requests to any address |
LLM_MAX_TOKENS |
4096 |
Upper limit for max_tokens on every request |
Server mode is active when LLM_API_KEY is set, or when LLM_PROVIDER is a keyless local server (ollama, llamacpp, custom). Example for a local Ollama:
LLM_PROVIDER=ollama
LLM_MODEL=llama3.1
# LLM_BASE_URL=http://my-ollama-host:11434/v1GET /api/llm-config tells the UI which mode the server is in. It never returns the key.
Upgrading from 1.0: the old MIMO_API_KEY, OPENAI_API_KEY, *_BASE_URL and MIMO_MODEL variables are no longer read. Use LLM_API_KEY, LLM_BASE_URL and LLM_MODEL.
Requires Node.js 22.12 or newer.
npm ci # install
npm run dev # http://localhost:3000, then add your key in Settings
npm test # vitest
npm run typecheck # tsc --noEmit
npm run lint # eslint
npm run build # production buildBrowser (Next.js pages, UI text from src/i18n/messages/{en,ms}.ts)
│ Settings panel → localStorage (provider, model, key)
│ fetch /api/* with x-llm-provider / x-llm-model / x-llm-key, body includes language: "en" | "ms"
▼
API routes /api/quiz · /api/flashcards · /api/roadmap · /api/chat · /api/analyze
│ src/lib/llm/server.ts resolve: server env key → else request headers → else 400
│ allowlist of provider base URLs, max_tokens cap
│ src/lib/llm/language.ts "answer in English / Bahasa Melayu (Malaysia)" prompt instruction
│ src/lib/llm/client.ts POST {baseUrl}/chat/completions, friendly upstream errors
▼
OpenAI-compatible provider (OpenAI, OpenRouter, Groq, DeepSeek, Together, Mistral, MiMo, Ollama, …)
- Next.js 16 (App Router), React 19, TypeScript
- Tailwind CSS 4 with colour tokens (light + dark) in
src/app/globals.css; a test checks WCAG AA contrast for every text/background pair - lucide-react icons, Inter + Fraunces (self-hosted by
next/font) - Zustand (state)
- SM-2 spaced repetition
- Vitest + Testing Library
See SECURITY.md to report a vulnerability privately.
MIT © 2026 Muhammad Aiman
Built by Sasa Teguh · Malaysia



