Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Adaptive learning with any OpenAI-compatible LLM**

> AI-generated quizzes, flashcards and learning roadmaps, with spaced repetition and a simple progress view.
> AI-generated quizzes, flashcards and learning roadmaps, with spaced repetition and a simple progress view. In English and Bahasa Melayu.

[![CI](https://github.com/aimanmalib/learnpath/actions/workflows/ci.yml/badge.svg)](https://github.com/aimanmalib/learnpath/actions/workflows/ci.yml)
[![Next.js 16](https://img.shields.io/badge/Next.js-16-black.svg)](https://nextjs.org/)
Expand All @@ -24,14 +24,22 @@ Every AI feature goes through one provider layer (`src/lib/llm/`) that speaks th
| **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 |

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).
### Languages

- All UI text lives in `src/i18n/messages/en.ts` and `ms.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 in `localStorage` and 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.

## Bring your own key (how the demo works)

The public demo has no server-side key. Each visitor uses their own:

1. Click **Settings** in the top bar.
1. Click **Settings** (**Tetapan** in BM, the key icon on phones) in the top bar.
2. Pick a provider, keep the suggested model or type another, and paste your API key.
3. Press **Save**. **Forget key** removes it again.

Expand Down Expand Up @@ -114,22 +122,24 @@ npm run build # production build
## Architecture

```
Browser (Next.js pages)
Browser (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
│ 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/client.ts POST {baseUrl}/chat/completions, friendly upstream errors
│ 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, …)
```

## Tech stack

- Next.js 16 (App Router), React 19, TypeScript
- Tailwind CSS 4
- 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
Expand Down
16 changes: 16 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "1.52.0",
"next": "^16.4.2",
"react": "^19.3.1",
"react-dom": "^19.3.1",
Expand Down
7 changes: 4 additions & 3 deletions src/app/api/analyze/route.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
import { NextResponse } from "next/server";
import { chatCompletion } from "@/lib/llm/client";
import { handleLlmRoute, textField } from "@/lib/llm/server";
import { languageInstruction, languageReminder, outputLanguage } from "@/lib/llm/language";

export async function POST(request: Request) {
return handleLlmRoute(request, async ({ llm, body }) => {
const topic = textField(body.topic, 200, "general");
const sessions = Array.isArray(body.sessions) ? body.sessions.slice(-20) : [];
const sessionSummary = JSON.stringify(sessions).slice(0, 8000);
const language = outputLanguage(body.language);

const systemPrompt =
"You are a learning analytics expert. Analyze study session data and provide insights and recommendations for improvement. Be specific and actionable.";
const systemPrompt = `You are a learning analytics expert. Analyze study session data and provide insights and recommendations for improvement. Be specific and actionable. ${languageInstruction(language)}`;

const result = await chatCompletion(
llm,
[
{ role: "system", content: systemPrompt },
{
role: "user",
content: `Analyze these study sessions for topic ${JSON.stringify(topic)}: ${sessionSummary}. Provide: 1) Performance summary 2) Weak areas 3) Recommended next steps`,
content: `Analyze these study sessions for topic ${JSON.stringify(topic)}: ${sessionSummary}. Provide: 1) Performance summary 2) Weak areas 3) Recommended next steps. ${languageReminder(language)}`,
},
],
{ temperature: 0.5, maxTokens: 2048 },
Expand Down
11 changes: 8 additions & 3 deletions src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@ import { NextResponse } from "next/server";
import { chatCompletion, chatCompletionStream, type ChatMessage } from "@/lib/llm/client";
import { handleLlmRoute, textField } from "@/lib/llm/server";
import { LlmError } from "@/lib/llm/errors";
import { languageInstruction, outputLanguage } from "@/lib/llm/language";

export async function POST(request: Request) {
return handleLlmRoute(request, async ({ llm, body }) => {
const message = textField(body.message, 8000);
if (!message) throw new LlmError("invalid_request", "Message is empty.", 400);
const systemPrompt = textField(body.systemPrompt, 4000);
const language = outputLanguage(body.language);

const messages: ChatMessage[] = [];
if (systemPrompt) messages.push({ role: "system", content: systemPrompt });
messages.push({ role: "user", content: message });
// The language instruction always goes first; a caller's system prompt follows it.
const system = [languageInstruction(language), systemPrompt].filter(Boolean).join("\n\n");
const messages: ChatMessage[] = [
{ role: "system", content: system },
{ role: "user", content: message },
];

if (body.stream === true) {
const stream = await chatCompletionStream(llm, messages, { temperature: 0.7, maxTokens: 2048, timeoutMs: 120_000 });
Expand Down
6 changes: 4 additions & 2 deletions src/app/api/flashcards/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@ import { NextResponse } from "next/server";
import { chatCompletion } from "@/lib/llm/client";
import { extractJson, handleLlmRoute, intField, textField, unreadable } from "@/lib/llm/server";
import { LlmError } from "@/lib/llm/errors";
import { languageInstruction, languageReminder, outputLanguage } from "@/lib/llm/language";

export async function POST(request: Request) {
return handleLlmRoute(request, async ({ llm, body }) => {
const topic = textField(body.topic, 200);
if (!topic) throw new LlmError("invalid_request", "Enter a topic first.", 400);
const count = intField(body.count, 1, 20, 10);
const language = outputLanguage(body.language);

const systemPrompt = `Generate ${count} study flashcards about the user's topic. Return ONLY a valid JSON array, no prose.`;
const userPrompt = `Topic: ${JSON.stringify(topic)}. Format: [{"id":"fc1","front":"question or concept","back":"answer or definition"}]`;
const systemPrompt = `Generate ${count} study flashcards about the user's topic. Return ONLY a valid JSON array, no prose. ${languageInstruction(language)}`;
const userPrompt = `Topic: ${JSON.stringify(topic)}. Format: [{"id":"fc1","front":"question or concept","back":"answer or definition"}]. ${languageReminder(language)}`;

const result = await chatCompletion(
llm,
Expand Down
6 changes: 4 additions & 2 deletions src/app/api/quiz/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { chatCompletion } from "@/lib/llm/client";
import { extractJson, handleLlmRoute, intField, oneOf, textField, unreadable } from "@/lib/llm/server";
import { LlmError } from "@/lib/llm/errors";
import { languageInstruction, languageReminder, outputLanguage } from "@/lib/llm/language";

const DIFFICULTIES = ["easy", "medium", "hard"] as const;

Expand All @@ -21,9 +22,10 @@ export async function POST(request: Request) {
if (!topic) throw new LlmError("invalid_request", "Enter a topic first.", 400);
const count = intField(body.count, 1, 10, 5);
const difficulty = oneOf(body.difficulty, DIFFICULTIES, "medium");
const language = outputLanguage(body.language);

const systemPrompt = `You are LearnPath's quiz generator. Create ${count} multiple-choice questions about the user's topic at ${difficulty} difficulty. Each question must have exactly 4 options with one correct answer. Return ONLY a valid JSON array, no prose.`;
const userPrompt = `Topic: ${JSON.stringify(topic)}. Format: [{"id":"q1","question":"...","options":["A","B","C","D"],"correctIndex":0,"explanation":"..."}]`;
const systemPrompt = `You are LearnPath's quiz generator. Create ${count} multiple-choice questions about the user's topic at ${difficulty} difficulty. Each question must have exactly 4 options with one correct answer. Return ONLY a valid JSON array, no prose. ${languageInstruction(language)}`;
const userPrompt = `Topic: ${JSON.stringify(topic)}. Format: [{"id":"q1","question":"...","options":["A","B","C","D"],"correctIndex":0,"explanation":"..."}]. ${languageReminder(language)}`;

const result = await chatCompletion(
llm,
Expand Down
7 changes: 5 additions & 2 deletions src/app/api/roadmap/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { chatCompletion } from "@/lib/llm/client";
import { extractJson, handleLlmRoute, oneOf, textField, unreadable } from "@/lib/llm/server";
import { LlmError } from "@/lib/llm/errors";
import { languageInstruction, languageReminder, outputLanguage } from "@/lib/llm/language";

const LEVELS = ["beginner", "intermediate", "advanced"] as const;

Expand All @@ -10,9 +11,11 @@ export async function POST(request: Request) {
const topic = textField(body.topic, 200);
if (!topic) throw new LlmError("invalid_request", "Enter a topic first.", 400);
const level = oneOf(body.level, LEVELS, "beginner");
const language = outputLanguage(body.language);
const exampleTime = language === "ms" ? "2 jam" : "2 hours";

const systemPrompt = `You are a learning path designer. Create a structured ${level}-level roadmap for the user's topic. Break it into clear steps with estimated times. Return ONLY valid JSON, no prose.`;
const userPrompt = `Topic: ${JSON.stringify(topic)}. Format: {"title":"...","steps":[{"id":"s1","title":"...","description":"...","estimatedTime":"2 hours","topics":["a","b"]}]}`;
const systemPrompt = `You are a learning path designer. Create a structured ${level}-level roadmap for the user's topic. Break it into clear steps with estimated times. Return ONLY valid JSON, no prose. ${languageInstruction(language)}`;
const userPrompt = `Topic: ${JSON.stringify(topic)}. Format: {"title":"...","steps":[{"id":"s1","title":"...","description":"...","estimatedTime":"${exampleTime}","topics":["a","b"]}]}. ${languageReminder(language)}`;

const result = await chatCompletion(
llm,
Expand Down
34 changes: 21 additions & 13 deletions src/app/concepts/page.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
"use client";
import { Info, Network } from "lucide-react";
import { ConceptMap } from "@/components/ConceptMap";
import { PageHeader } from "@/components/ui/PageHeader";
import { PageShell } from "@/components/ui/PageShell";
import { useI18n } from "@/i18n/I18nProvider";
import type { MessageKey } from "@/i18n/messages";

const sampleConcepts = [
{ id: "1", label: "Variables", connections: ["2", "3"], mastery: 90 },
{ id: "2", label: "Functions", connections: ["1", "4"], mastery: 75 },
{ id: "3", label: "Data Types", connections: ["1", "5"], mastery: 85 },
{ id: "4", label: "Control Flow", connections: ["2", "6"], mastery: 60 },
{ id: "5", label: "Collections", connections: ["3", "6"], mastery: 45 },
{ id: "6", label: "OOP", connections: ["4", "5"], mastery: 30 },
/** Fixed sample data (labels come from the language files). */
const SAMPLE: { id: string; label: MessageKey; connections: string[]; mastery: number }[] = [
{ id: "1", label: "concepts.variables", connections: ["2", "3"], mastery: 90 },
{ id: "2", label: "concepts.functions", connections: ["1", "4"], mastery: 75 },
{ id: "3", label: "concepts.dataTypes", connections: ["1", "5"], mastery: 85 },
{ id: "4", label: "concepts.controlFlow", connections: ["2", "6"], mastery: 60 },
{ id: "5", label: "concepts.collections", connections: ["3", "6"], mastery: 45 },
{ id: "6", label: "concepts.oop", connections: ["4", "5"], mastery: 30 },
];

export default function ConceptsPage() {
const { t } = useI18n();
return (
<main className="min-h-[70vh] px-4 py-6 sm:p-8 max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-6">🧠 Concept Map</h1>
<ConceptMap concepts={sampleConcepts} title="Programming Fundamentals" />
<p className="text-sm text-gray-500 mt-4 text-center">
Sample data. The concept map is a static demo and is not generated from your sessions yet.
<PageShell>
<PageHeader icon={Network} title={t("concepts.title")} description={t("concepts.description")} />
<p className="mb-5 flex items-start gap-2 rounded-xl border border-line bg-surface-2 px-4 py-3 text-sm text-ink-muted">
<Info className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
{t("concepts.sampleNote")}
</p>
</main>
<ConceptMap concepts={SAMPLE.map((c) => ({ ...c, label: t(c.label) }))} title={t("concepts.mapTitle")} />
</PageShell>
);
}
Loading
Loading