Add Docker support with .dockerignore and Dockerfile updates. Integrate Next.js frontend build process, including package.json and related configurations. Enhance API to serve static frontend files and update README for project clarity. Add course and document metadata structures for improved content management.
Browse files- .dockerignore +41 -0
- .github/workflows/sync-to-hf.yml +1 -1
- .gitignore +11 -2
- Dockerfile +22 -4
- README.md +4 -4
- data/source_versions.json +32 -0
- frontend/app/apple-icon.png +0 -0
- frontend/app/icon.png +0 -0
- frontend/lib/api.ts +74 -0
- frontend/lib/chat-ui.ts +304 -0
- frontend/lib/course-metadata.ts +34 -0
- frontend/lib/doc-metadata.ts +37 -0
- frontend/next-env.d.ts +1 -1
- frontend/next.config.mjs +3 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +34 -0
- frontend/public/towardsai-logo.png +0 -0
- frontend/tsconfig.json +41 -0
- scripts/api.py +16 -0
.dockerignore
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Version control
|
| 2 |
+
.git
|
| 3 |
+
.github
|
| 4 |
+
|
| 5 |
+
# Python build artifacts
|
| 6 |
+
__pycache__
|
| 7 |
+
*.pyc
|
| 8 |
+
.venv
|
| 9 |
+
venv
|
| 10 |
+
.pytest_cache
|
| 11 |
+
.mypy_cache
|
| 12 |
+
|
| 13 |
+
# Node build artifacts
|
| 14 |
+
frontend/node_modules
|
| 15 |
+
frontend/.next
|
| 16 |
+
frontend/out
|
| 17 |
+
|
| 18 |
+
# Local env files (secrets / per-developer config)
|
| 19 |
+
.env
|
| 20 |
+
.env.local
|
| 21 |
+
.env.*.local
|
| 22 |
+
frontend/.env
|
| 23 |
+
frontend/.env.local
|
| 24 |
+
frontend/.env.*.local
|
| 25 |
+
|
| 26 |
+
# Editor/OS
|
| 27 |
+
.vscode
|
| 28 |
+
.idea
|
| 29 |
+
.DS_Store
|
| 30 |
+
|
| 31 |
+
# Docs and tests (optional – shrink build context)
|
| 32 |
+
docs
|
| 33 |
+
tests
|
| 34 |
+
*.md
|
| 35 |
+
!README.md
|
| 36 |
+
|
| 37 |
+
# Heavy data that will be downloaded at runtime from HF Hub
|
| 38 |
+
data/chroma-db-all_sources
|
| 39 |
+
data/*.jsonl
|
| 40 |
+
data/*.pkl
|
| 41 |
+
data/*_md_files
|
.github/workflows/sync-to-hf.yml
CHANGED
|
@@ -24,4 +24,4 @@ jobs:
|
|
| 24 |
env:
|
| 25 |
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 26 |
HF_USERNAME: ${{ secrets.HF_USERNAME }}
|
| 27 |
-
run: git push --force https://$HF_USERNAME:$HF_TOKEN@huggingface.co/spaces/towardsai-tutors/ai-tutor
|
|
|
|
| 24 |
env:
|
| 25 |
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 26 |
HF_USERNAME: ${{ secrets.HF_USERNAME }}
|
| 27 |
+
run: git push --force https://$HF_USERNAME:$HF_TOKEN@huggingface.co/spaces/towardsai-tutors/ai-tutor main:main
|
.gitignore
CHANGED
|
@@ -14,8 +14,8 @@ dist/
|
|
| 14 |
downloads/
|
| 15 |
eggs/
|
| 16 |
.eggs/
|
| 17 |
-
lib/
|
| 18 |
-
lib64/
|
| 19 |
parts/
|
| 20 |
sdist/
|
| 21 |
var/
|
|
@@ -182,3 +182,12 @@ frontend/out/
|
|
| 182 |
*.pkl
|
| 183 |
*.png
|
| 184 |
*.mov
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
downloads/
|
| 15 |
eggs/
|
| 16 |
.eggs/
|
| 17 |
+
/lib/
|
| 18 |
+
/lib64/
|
| 19 |
parts/
|
| 20 |
sdist/
|
| 21 |
var/
|
|
|
|
| 182 |
*.pkl
|
| 183 |
*.png
|
| 184 |
*.mov
|
| 185 |
+
|
| 186 |
+
# Keep assets + configs needed for deployment
|
| 187 |
+
!frontend/package.json
|
| 188 |
+
!frontend/package-lock.json
|
| 189 |
+
!frontend/tsconfig.json
|
| 190 |
+
!frontend/public/towardsai-logo.png
|
| 191 |
+
!frontend/app/icon.png
|
| 192 |
+
!frontend/app/apple-icon.png
|
| 193 |
+
!data/source_versions.json
|
Dockerfile
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
FROM python:3.13
|
| 2 |
|
| 3 |
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
| 4 |
-
|
| 5 |
ENV PATH="/root/.local/bin:${PATH}"
|
| 6 |
|
| 7 |
RUN useradd -m -u 1000 user
|
|
@@ -11,11 +27,13 @@ COPY pyproject.toml uv.lock ./
|
|
| 11 |
RUN uv sync --locked
|
| 12 |
|
| 13 |
COPY . .
|
|
|
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
|
|
|
| 17 |
RUN chown -R user:user /app
|
| 18 |
USER user
|
| 19 |
|
| 20 |
EXPOSE 7860
|
| 21 |
-
CMD ["
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1.6
|
| 2 |
+
|
| 3 |
+
# Stage 1: build the Next.js frontend as a static export
|
| 4 |
+
FROM node:20-alpine AS frontend
|
| 5 |
+
WORKDIR /frontend
|
| 6 |
+
|
| 7 |
+
COPY frontend/package.json frontend/package-lock.json ./
|
| 8 |
+
RUN npm ci
|
| 9 |
+
|
| 10 |
+
COPY frontend/ ./
|
| 11 |
+
|
| 12 |
+
# Empty base URL => API calls are same-origin (served by FastAPI)
|
| 13 |
+
ENV NEXT_PUBLIC_AI_TUTOR_API_BASE_URL=""
|
| 14 |
+
RUN npm run build
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# Stage 2: Python runtime + FastAPI + bundled static frontend
|
| 18 |
FROM python:3.13
|
| 19 |
|
| 20 |
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
|
|
| 21 |
ENV PATH="/root/.local/bin:${PATH}"
|
| 22 |
|
| 23 |
RUN useradd -m -u 1000 user
|
|
|
|
| 27 |
RUN uv sync --locked
|
| 28 |
|
| 29 |
COPY . .
|
| 30 |
+
COPY --from=frontend /frontend/out ./frontend/out
|
| 31 |
|
| 32 |
+
ENV HOME=/home/user \
|
| 33 |
+
PATH="/root/.local/bin:/home/user/.local/bin:${PATH}" \
|
| 34 |
+
PORT=7860
|
| 35 |
RUN chown -R user:user /app
|
| 36 |
USER user
|
| 37 |
|
| 38 |
EXPOSE 7860
|
| 39 |
+
CMD ["sh", "-c", "uv run uvicorn scripts.api:app --host 0.0.0.0 --port ${PORT:-7860}"]
|
README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
---
|
| 2 |
-
title: AI Tutor
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
|
|
|
| 1 |
---
|
| 2 |
+
title: AI Tutor
|
| 3 |
+
emoji: 🎓
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
data/source_versions.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"langchain": {
|
| 3 |
+
"indexedAt": "2026-04-17",
|
| 4 |
+
"sha": "b921b07",
|
| 5 |
+
"version": "langchain-core==1.3.0"
|
| 6 |
+
},
|
| 7 |
+
"llama_index": {
|
| 8 |
+
"indexedAt": "2026-04-17",
|
| 9 |
+
"sha": "33f7ba4",
|
| 10 |
+
"version": "v0.14.20"
|
| 11 |
+
},
|
| 12 |
+
"openai_cookbooks": {
|
| 13 |
+
"indexedAt": "2026-04-17",
|
| 14 |
+
"sha": "9e0a00d",
|
| 15 |
+
"version": null
|
| 16 |
+
},
|
| 17 |
+
"peft": {
|
| 18 |
+
"indexedAt": "2026-04-17",
|
| 19 |
+
"sha": "2859358",
|
| 20 |
+
"version": "v0.19.1"
|
| 21 |
+
},
|
| 22 |
+
"transformers": {
|
| 23 |
+
"indexedAt": "2026-04-17",
|
| 24 |
+
"sha": "a29df2d",
|
| 25 |
+
"version": "v5.5.4"
|
| 26 |
+
},
|
| 27 |
+
"trl": {
|
| 28 |
+
"indexedAt": "2026-04-17",
|
| 29 |
+
"sha": "573ea22",
|
| 30 |
+
"version": "v1.2.0"
|
| 31 |
+
}
|
| 32 |
+
}
|
frontend/app/apple-icon.png
ADDED
|
|
frontend/app/icon.png
ADDED
|
|
frontend/lib/api.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type TutorSource = {
|
| 2 |
+
key: string;
|
| 3 |
+
label: string;
|
| 4 |
+
group: "courses" | "docs";
|
| 5 |
+
selectedByDefault: boolean;
|
| 6 |
+
version?: string | null;
|
| 7 |
+
indexedAt?: string | null;
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
export type TutorConfigurableTool = {
|
| 11 |
+
kind: "configurable";
|
| 12 |
+
key: "retrieval";
|
| 13 |
+
label: string;
|
| 14 |
+
active: boolean;
|
| 15 |
+
sources: TutorSource[];
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export type TutorToggleTool = {
|
| 19 |
+
kind: "toggle";
|
| 20 |
+
key: string;
|
| 21 |
+
label: string;
|
| 22 |
+
active: boolean;
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
export type TutorTool = TutorConfigurableTool | TutorToggleTool;
|
| 26 |
+
|
| 27 |
+
export type AvailableModel = {
|
| 28 |
+
id: string;
|
| 29 |
+
label: string;
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
export type ToolsResponse = {
|
| 33 |
+
model: string;
|
| 34 |
+
availableModels: AvailableModel[];
|
| 35 |
+
tools: TutorTool[];
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
export type SourcePartData = {
|
| 39 |
+
docId: string;
|
| 40 |
+
title: string;
|
| 41 |
+
url: string;
|
| 42 |
+
sourceKey: string;
|
| 43 |
+
sourceLabel: string;
|
| 44 |
+
score: number;
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
const FALLBACK_API_BASE_URL = "http://127.0.0.1:8000";
|
| 48 |
+
|
| 49 |
+
export function getApiBaseUrl() {
|
| 50 |
+
return (
|
| 51 |
+
process.env.NEXT_PUBLIC_AI_TUTOR_API_BASE_URL?.replace(/\/+$/, "") ??
|
| 52 |
+
FALLBACK_API_BASE_URL
|
| 53 |
+
);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export async function fetchTools(
|
| 57 |
+
signal?: AbortSignal,
|
| 58 |
+
model?: string,
|
| 59 |
+
): Promise<ToolsResponse> {
|
| 60 |
+
const url = new URL(`${getApiBaseUrl()}/api/tools`);
|
| 61 |
+
if (model) {
|
| 62 |
+
url.searchParams.set("model", model);
|
| 63 |
+
}
|
| 64 |
+
const response = await fetch(url.toString(), {
|
| 65 |
+
method: "GET",
|
| 66 |
+
signal,
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
if (!response.ok) {
|
| 70 |
+
throw new Error(`Unable to load tools (${response.status})`);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
return (await response.json()) as ToolsResponse;
|
| 74 |
+
}
|
frontend/lib/chat-ui.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { UIMessage } from "ai";
|
| 2 |
+
import type { SourcePartData } from "./api";
|
| 3 |
+
|
| 4 |
+
export type TutorMessage = UIMessage;
|
| 5 |
+
|
| 6 |
+
export type TutorMessagePart = {
|
| 7 |
+
type: string;
|
| 8 |
+
text?: string;
|
| 9 |
+
state?: string;
|
| 10 |
+
data?: unknown;
|
| 11 |
+
toolCallId?: string;
|
| 12 |
+
input?: unknown;
|
| 13 |
+
output?: unknown;
|
| 14 |
+
errorText?: string;
|
| 15 |
+
sourceId?: string;
|
| 16 |
+
url?: string;
|
| 17 |
+
title?: string;
|
| 18 |
+
mediaType?: string;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
export type TutorMessageBlock = {
|
| 22 |
+
key: string;
|
| 23 |
+
kind: "text" | "reasoning" | "tool";
|
| 24 |
+
parts: TutorMessagePart[];
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
export type CitationKind = "web" | "course" | "doc";
|
| 28 |
+
|
| 29 |
+
export type MessageCitation = {
|
| 30 |
+
label: string;
|
| 31 |
+
url: string;
|
| 32 |
+
kind: CitationKind;
|
| 33 |
+
sublabel?: string;
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
export function getTextParts(message: TutorMessage) {
|
| 37 |
+
return message.parts
|
| 38 |
+
.filter((part) => "type" in part && part.type === "text")
|
| 39 |
+
.map((part) => part as TutorMessagePart);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export function getMessageTextContent(message: TutorMessage) {
|
| 43 |
+
return getTextParts(message)
|
| 44 |
+
.map((part) => (part.text ?? "").trim())
|
| 45 |
+
.filter(Boolean)
|
| 46 |
+
.join("\n\n");
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
export function getReasoningParts(message: TutorMessage) {
|
| 50 |
+
return message.parts
|
| 51 |
+
.filter((part) => "type" in part && part.type === "reasoning")
|
| 52 |
+
.map((part) => part as TutorMessagePart);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export function getToolParts(message: TutorMessage) {
|
| 56 |
+
return message.parts
|
| 57 |
+
.filter((part) => "type" in part && String(part.type).startsWith("tool-"))
|
| 58 |
+
.map((part) => part as TutorMessagePart);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export function getOrderedMessageBlocks(message: TutorMessage): TutorMessageBlock[] {
|
| 62 |
+
const blocks: TutorMessageBlock[] = [];
|
| 63 |
+
let currentKind: TutorMessageBlock["kind"] | null = null;
|
| 64 |
+
let currentParts: TutorMessagePart[] = [];
|
| 65 |
+
|
| 66 |
+
for (const part of message.parts) {
|
| 67 |
+
if (!("type" in part)) {
|
| 68 |
+
continue;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
const typedPart = part as TutorMessagePart;
|
| 72 |
+
const nextKind = classifyMessagePart(typedPart);
|
| 73 |
+
|
| 74 |
+
if (!nextKind) {
|
| 75 |
+
continue;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
if (currentKind && currentKind !== nextKind) {
|
| 79 |
+
blocks.push({
|
| 80 |
+
key: `${message.id}-${currentKind}-${blocks.length}`,
|
| 81 |
+
kind: currentKind,
|
| 82 |
+
parts: currentParts,
|
| 83 |
+
});
|
| 84 |
+
currentParts = [];
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
currentKind = nextKind;
|
| 88 |
+
currentParts.push(typedPart);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
if (currentKind && currentParts.length > 0) {
|
| 92 |
+
blocks.push({
|
| 93 |
+
key: `${message.id}-${currentKind}-${blocks.length}`,
|
| 94 |
+
kind: currentKind,
|
| 95 |
+
parts: currentParts,
|
| 96 |
+
});
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
return blocks;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
export function getMessageSources(message: TutorMessage): SourcePartData[] {
|
| 103 |
+
const seen = new Set<string>();
|
| 104 |
+
const sources: SourcePartData[] = [];
|
| 105 |
+
|
| 106 |
+
for (const part of message.parts) {
|
| 107 |
+
if (!("type" in part) || part.type !== "data-source") {
|
| 108 |
+
continue;
|
| 109 |
+
}
|
| 110 |
+
const data = (part as TutorMessagePart).data as SourcePartData | undefined;
|
| 111 |
+
if (!data) {
|
| 112 |
+
continue;
|
| 113 |
+
}
|
| 114 |
+
const key = data.docId || data.url;
|
| 115 |
+
if (seen.has(key)) {
|
| 116 |
+
continue;
|
| 117 |
+
}
|
| 118 |
+
seen.add(key);
|
| 119 |
+
sources.push(data);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
return sources.sort((left, right) => right.score - left.score);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
export function hasRenderableContent(message: TutorMessage): boolean {
|
| 126 |
+
for (const part of message.parts) {
|
| 127 |
+
if (!("type" in part)) {
|
| 128 |
+
continue;
|
| 129 |
+
}
|
| 130 |
+
const type = String(part.type);
|
| 131 |
+
if (type === "text" || type === "reasoning" || type.startsWith("tool-")) {
|
| 132 |
+
return true;
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
return false;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
export function getLastAssistantMessage(messages: TutorMessage[]) {
|
| 139 |
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
| 140 |
+
if (messages[index]?.role === "assistant") {
|
| 141 |
+
return messages[index];
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
return undefined;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
const WEB_SOURCE_KEYS = new Set(["google_search", "url_context"]);
|
| 149 |
+
const COURSE_SOURCE_KEYS = new Set([
|
| 150 |
+
"8-hour_primer",
|
| 151 |
+
"llm_developer",
|
| 152 |
+
"python_primer",
|
| 153 |
+
"master_ai_for_work",
|
| 154 |
+
"agentic_ai_engineering",
|
| 155 |
+
]);
|
| 156 |
+
|
| 157 |
+
function classifySource(sourceKey: string): CitationKind {
|
| 158 |
+
if (WEB_SOURCE_KEYS.has(sourceKey)) {
|
| 159 |
+
return "web";
|
| 160 |
+
}
|
| 161 |
+
if (COURSE_SOURCE_KEYS.has(sourceKey)) {
|
| 162 |
+
return "course";
|
| 163 |
+
}
|
| 164 |
+
return "doc";
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
export function getMessageCitations(message: TutorMessage): MessageCitation[] {
|
| 168 |
+
const seen = new Set<string>();
|
| 169 |
+
const citations: MessageCitation[] = [];
|
| 170 |
+
|
| 171 |
+
for (const source of getMessageSources(message)) {
|
| 172 |
+
const url = source.url?.trim();
|
| 173 |
+
if (!url) {
|
| 174 |
+
continue;
|
| 175 |
+
}
|
| 176 |
+
const kind = classifySource(source.sourceKey);
|
| 177 |
+
const title = cleanTitle(source.title);
|
| 178 |
+
const label = (
|
| 179 |
+
kind === "web"
|
| 180 |
+
? title || hostnameFromUrl(url) || "Source"
|
| 181 |
+
: title || source.sourceLabel || "Source"
|
| 182 |
+
).slice(0, 80);
|
| 183 |
+
const sublabel =
|
| 184 |
+
kind === "web"
|
| 185 |
+
? hostnameFromUrl(url)
|
| 186 |
+
: source.sourceLabel?.trim() || undefined;
|
| 187 |
+
const key = `${kind}::${url}`;
|
| 188 |
+
if (seen.has(key)) {
|
| 189 |
+
continue;
|
| 190 |
+
}
|
| 191 |
+
seen.add(key);
|
| 192 |
+
citations.push({ label, url, kind, sublabel });
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
for (const part of getTextParts(message)) {
|
| 196 |
+
const text = part.text ?? "";
|
| 197 |
+
for (const citation of extractMarkdownLinkCitations(text)) {
|
| 198 |
+
const key = `web::${citation.url}`;
|
| 199 |
+
if (seen.has(key)) {
|
| 200 |
+
continue;
|
| 201 |
+
}
|
| 202 |
+
seen.add(key);
|
| 203 |
+
citations.push({
|
| 204 |
+
label: citation.label,
|
| 205 |
+
url: citation.url,
|
| 206 |
+
kind: "web",
|
| 207 |
+
sublabel: hostnameFromUrl(citation.url) || undefined,
|
| 208 |
+
});
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
return citations;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
function cleanTitle(raw: string | undefined) {
|
| 216 |
+
const trimmed = raw?.trim();
|
| 217 |
+
if (!trimmed) {
|
| 218 |
+
return "";
|
| 219 |
+
}
|
| 220 |
+
if (/^[-–—\s]+$/.test(trimmed)) {
|
| 221 |
+
return "";
|
| 222 |
+
}
|
| 223 |
+
return trimmed;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
function hostnameFromUrl(url: string) {
|
| 227 |
+
try {
|
| 228 |
+
return new URL(url).hostname.replace(/^www\./, "");
|
| 229 |
+
} catch {
|
| 230 |
+
return "";
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
export function prettifyToolName(type: string) {
|
| 235 |
+
return type
|
| 236 |
+
.replace(/^tool-/, "")
|
| 237 |
+
.replaceAll("_", " ")
|
| 238 |
+
.replace(/\b\w/g, (match) => match.toUpperCase());
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
export function toolInputSummary(input: unknown) {
|
| 242 |
+
if (!input || typeof input !== "object") {
|
| 243 |
+
return "";
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
const query = "query" in input ? String(input.query ?? "").trim() : "";
|
| 247 |
+
if (query) {
|
| 248 |
+
return query;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
const text = "text" in input ? String(input.text ?? "").trim() : "";
|
| 252 |
+
if (text) {
|
| 253 |
+
return text;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
return JSON.stringify(input);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
function classifyMessagePart(
|
| 260 |
+
part: TutorMessagePart,
|
| 261 |
+
): TutorMessageBlock["kind"] | null {
|
| 262 |
+
if (part.type === "text") {
|
| 263 |
+
return "text";
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
if (part.type === "reasoning") {
|
| 267 |
+
return "reasoning";
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
if (String(part.type).startsWith("tool-")) {
|
| 271 |
+
return "tool";
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
return null;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
type RawLinkCitation = { label: string; url: string };
|
| 278 |
+
|
| 279 |
+
function extractMarkdownLinkCitations(text: string): RawLinkCitation[] {
|
| 280 |
+
const citations: RawLinkCitation[] = [];
|
| 281 |
+
const markdownLinkPattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+(?:\([^\s)]+\)[^\s)]*)*)\)/g;
|
| 282 |
+
const bareUrlPattern = /(?<!\()https?:\/\/[^\s<>()]+/g;
|
| 283 |
+
const urlsInMarkdown = new Set<string>();
|
| 284 |
+
|
| 285 |
+
for (const match of text.matchAll(markdownLinkPattern)) {
|
| 286 |
+
const label = (match[1] ?? "").trim();
|
| 287 |
+
const url = (match[2] ?? "").trim();
|
| 288 |
+
if (!label || !url) {
|
| 289 |
+
continue;
|
| 290 |
+
}
|
| 291 |
+
urlsInMarkdown.add(url);
|
| 292 |
+
citations.push({ label, url });
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
for (const match of text.matchAll(bareUrlPattern)) {
|
| 296 |
+
const url = (match[0] ?? "").trim();
|
| 297 |
+
if (!url || urlsInMarkdown.has(url)) {
|
| 298 |
+
continue;
|
| 299 |
+
}
|
| 300 |
+
citations.push({ label: "Source", url });
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
return citations;
|
| 304 |
+
}
|
frontend/lib/course-metadata.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type CourseMetadata = {
|
| 2 |
+
description: string;
|
| 3 |
+
academyUrl: string;
|
| 4 |
+
};
|
| 5 |
+
|
| 6 |
+
export const COURSE_METADATA: Record<string, CourseMetadata> = {
|
| 7 |
+
"8-hour_primer": {
|
| 8 |
+
description:
|
| 9 |
+
"Crash course on LLM fundamentals: prompting, APIs, and shipping simple production apps. Good starting point for beginners deciding if LLM dev is for them.",
|
| 10 |
+
academyUrl: "https://academy.towardsai.net/courses/8-hour-genai-primer",
|
| 11 |
+
},
|
| 12 |
+
full_stack_ai_engineering: {
|
| 13 |
+
description:
|
| 14 |
+
"Full-stack LLM engineering, covering RAG, fine-tuning, evaluation, and deploying production systems end-to-end. The deepest technical course.",
|
| 15 |
+
academyUrl:
|
| 16 |
+
"https://academy.towardsai.net/courses/beginner-to-advanced-llm-dev",
|
| 17 |
+
},
|
| 18 |
+
beginner_python_for_ai_engineering: {
|
| 19 |
+
description:
|
| 20 |
+
"Python for the LLM era: API integration, using open-source models, and core training/testing workflows. Assumes no prior Python.",
|
| 21 |
+
academyUrl: "https://academy.towardsai.net/courses/python-for-genai",
|
| 22 |
+
},
|
| 23 |
+
master_ai_for_work: {
|
| 24 |
+
description:
|
| 25 |
+
"Non-engineer course on using AI tools (ChatGPT, Claude, etc.) for workplace productivity and rolling them out across a team.",
|
| 26 |
+
academyUrl:
|
| 27 |
+
"https://academy.towardsai.net/courses/ai-business-professionals",
|
| 28 |
+
},
|
| 29 |
+
agentic_ai_engineering: {
|
| 30 |
+
description:
|
| 31 |
+
"Designing, building, evaluating, and deploying production-grade AI agents end-to-end.",
|
| 32 |
+
academyUrl: "https://academy.towardsai.net/courses/agent-engineering",
|
| 33 |
+
},
|
| 34 |
+
};
|
frontend/lib/doc-metadata.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type DocMetadata = {
|
| 2 |
+
description: string;
|
| 3 |
+
docsUrl: string;
|
| 4 |
+
};
|
| 5 |
+
|
| 6 |
+
export const DOC_METADATA: Record<string, DocMetadata> = {
|
| 7 |
+
transformers: {
|
| 8 |
+
description:
|
| 9 |
+
"Hugging Face library for state-of-the-art NLP and multimodal models. Load, run, and train pretrained transformers.",
|
| 10 |
+
docsUrl: "https://huggingface.co/docs/transformers",
|
| 11 |
+
},
|
| 12 |
+
peft: {
|
| 13 |
+
description:
|
| 14 |
+
"Parameter-Efficient Fine-Tuning: LoRA, prefix tuning, and other methods for adapting large models with minimal compute.",
|
| 15 |
+
docsUrl: "https://huggingface.co/docs/peft",
|
| 16 |
+
},
|
| 17 |
+
trl: {
|
| 18 |
+
description:
|
| 19 |
+
"Train language models with reinforcement learning. Covers SFT, DPO, PPO, and other alignment techniques.",
|
| 20 |
+
docsUrl: "https://huggingface.co/docs/trl",
|
| 21 |
+
},
|
| 22 |
+
llama_index: {
|
| 23 |
+
description:
|
| 24 |
+
"Framework for building RAG apps: ingestion, indexing, retrievers, and query engines over your own data.",
|
| 25 |
+
docsUrl: "https://docs.llamaindex.ai",
|
| 26 |
+
},
|
| 27 |
+
langchain: {
|
| 28 |
+
description:
|
| 29 |
+
"Framework for building LLM apps: chains, agents, tool-calling, and production observability.",
|
| 30 |
+
docsUrl: "https://docs.langchain.com/oss/python/langchain/overview",
|
| 31 |
+
},
|
| 32 |
+
openai_cookbooks: {
|
| 33 |
+
description:
|
| 34 |
+
"Example notebooks and recipes from OpenAI covering practical patterns for using their APIs.",
|
| 35 |
+
docsUrl: "https://cookbook.openai.com",
|
| 36 |
+
},
|
| 37 |
+
};
|
frontend/next-env.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
/// <reference types="next" />
|
| 2 |
/// <reference types="next/image-types/global" />
|
| 3 |
-
import "./.next/
|
| 4 |
|
| 5 |
// NOTE: This file should not be edited
|
| 6 |
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
|
|
|
| 1 |
/// <reference types="next" />
|
| 2 |
/// <reference types="next/image-types/global" />
|
| 3 |
+
import "./.next/types/routes.d.ts";
|
| 4 |
|
| 5 |
// NOTE: This file should not be edited
|
| 6 |
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
frontend/next.config.mjs
CHANGED
|
@@ -6,6 +6,9 @@ const __dirname = dirname(__filename);
|
|
| 6 |
|
| 7 |
/** @type {import('next').NextConfig} */
|
| 8 |
const nextConfig = {
|
|
|
|
|
|
|
|
|
|
| 9 |
turbopack: {
|
| 10 |
root: __dirname,
|
| 11 |
},
|
|
|
|
| 6 |
|
| 7 |
/** @type {import('next').NextConfig} */
|
| 8 |
const nextConfig = {
|
| 9 |
+
output: "export",
|
| 10 |
+
images: { unoptimized: true },
|
| 11 |
+
trailingSlash: true,
|
| 12 |
turbopack: {
|
| 13 |
root: __dirname,
|
| 14 |
},
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "ai-tutor-frontend",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"packageManager": "npm@11.12.1",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "next dev",
|
| 8 |
+
"build": "next build",
|
| 9 |
+
"start": "next start",
|
| 10 |
+
"lint": "eslint ."
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@ai-sdk/react": "^3.0.164",
|
| 14 |
+
"ai": "^6.0.162",
|
| 15 |
+
"clsx": "^2.1.1",
|
| 16 |
+
"lucide-react": "^0.511.0",
|
| 17 |
+
"next": "^16.2.3",
|
| 18 |
+
"react": "^19.2.0",
|
| 19 |
+
"react-dom": "^19.2.0",
|
| 20 |
+
"react-markdown": "^10.1.0",
|
| 21 |
+
"remark-gfm": "^4.0.1"
|
| 22 |
+
},
|
| 23 |
+
"devDependencies": {
|
| 24 |
+
"@eslint/eslintrc": "^3.3.1",
|
| 25 |
+
"@tailwindcss/postcss": "^4.1.17",
|
| 26 |
+
"@types/node": "^24.10.1",
|
| 27 |
+
"@types/react": "^19.2.2",
|
| 28 |
+
"@types/react-dom": "^19.2.2",
|
| 29 |
+
"eslint": "^9.39.1",
|
| 30 |
+
"eslint-config-next": "^16.2.3",
|
| 31 |
+
"tailwindcss": "^4.2.2",
|
| 32 |
+
"typescript": "^5.9.3"
|
| 33 |
+
}
|
| 34 |
+
}
|
frontend/public/towardsai-logo.png
ADDED
|
frontend/tsconfig.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2017",
|
| 4 |
+
"lib": [
|
| 5 |
+
"dom",
|
| 6 |
+
"dom.iterable",
|
| 7 |
+
"esnext"
|
| 8 |
+
],
|
| 9 |
+
"allowJs": false,
|
| 10 |
+
"skipLibCheck": true,
|
| 11 |
+
"strict": true,
|
| 12 |
+
"noEmit": true,
|
| 13 |
+
"esModuleInterop": true,
|
| 14 |
+
"module": "esnext",
|
| 15 |
+
"moduleResolution": "bundler",
|
| 16 |
+
"resolveJsonModule": true,
|
| 17 |
+
"isolatedModules": true,
|
| 18 |
+
"jsx": "react-jsx",
|
| 19 |
+
"incremental": true,
|
| 20 |
+
"plugins": [
|
| 21 |
+
{
|
| 22 |
+
"name": "next"
|
| 23 |
+
}
|
| 24 |
+
],
|
| 25 |
+
"paths": {
|
| 26 |
+
"@/*": [
|
| 27 |
+
"./*"
|
| 28 |
+
]
|
| 29 |
+
}
|
| 30 |
+
},
|
| 31 |
+
"include": [
|
| 32 |
+
"next-env.d.ts",
|
| 33 |
+
"**/*.ts",
|
| 34 |
+
"**/*.tsx",
|
| 35 |
+
".next/types/**/*.ts",
|
| 36 |
+
".next/dev/types/**/*.ts"
|
| 37 |
+
],
|
| 38 |
+
"exclude": [
|
| 39 |
+
"node_modules"
|
| 40 |
+
]
|
| 41 |
+
}
|
scripts/api.py
CHANGED
|
@@ -12,6 +12,7 @@ import uvicorn
|
|
| 12 |
from fastapi import FastAPI, HTTPException
|
| 13 |
from fastapi.middleware.cors import CORSMiddleware
|
| 14 |
from fastapi.responses import StreamingResponse
|
|
|
|
| 15 |
from pydantic import BaseModel, Field
|
| 16 |
|
| 17 |
from .chat_service import (
|
|
@@ -462,6 +463,21 @@ async def chat(payload: ApiChatRequest) -> StreamingResponse:
|
|
| 462 |
)
|
| 463 |
|
| 464 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
if __name__ == "__main__":
|
| 466 |
uvicorn.run(
|
| 467 |
"scripts.api:app",
|
|
|
|
| 12 |
from fastapi import FastAPI, HTTPException
|
| 13 |
from fastapi.middleware.cors import CORSMiddleware
|
| 14 |
from fastapi.responses import StreamingResponse
|
| 15 |
+
from fastapi.staticfiles import StaticFiles
|
| 16 |
from pydantic import BaseModel, Field
|
| 17 |
|
| 18 |
from .chat_service import (
|
|
|
|
| 463 |
)
|
| 464 |
|
| 465 |
|
| 466 |
+
FRONTEND_OUT_DIR = Path(__file__).resolve().parent.parent / "frontend" / "out"
|
| 467 |
+
|
| 468 |
+
if FRONTEND_OUT_DIR.is_dir():
|
| 469 |
+
app.mount(
|
| 470 |
+
"/",
|
| 471 |
+
StaticFiles(directory=str(FRONTEND_OUT_DIR), html=True),
|
| 472 |
+
name="frontend",
|
| 473 |
+
)
|
| 474 |
+
else:
|
| 475 |
+
logfire.info(
|
| 476 |
+
"Frontend static export not found; skipping static mount.",
|
| 477 |
+
path=str(FRONTEND_OUT_DIR),
|
| 478 |
+
)
|
| 479 |
+
|
| 480 |
+
|
| 481 |
if __name__ == "__main__":
|
| 482 |
uvicorn.run(
|
| 483 |
"scripts.api:app",
|