Skip to content

Commit 70b25a7

Browse files
Merge pull request #3 from StewAlexander-com/wire-frontend-chat
Wire frontend chat panel to backend /api/chat
2 parents e2b1b9d + 99461d0 commit 70b25a7

7 files changed

Lines changed: 820 additions & 3 deletions

File tree

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,59 @@ the auto-generated OpenAPI explorer. Override `OLLAMA_URL` and `TUTOR_MODEL`
111111
to point at a different server or model. See [`backend/README.md`](backend/README.md)
112112
for the full env-var reference, request/response shapes, and test instructions.
113113

114+
## End-to-End: Frontend + Backend + Ollama
115+
116+
The static PWA in [`frontend/`](frontend/) ships with an "Ask tutor" floating
117+
chat panel ([`frontend/tutor-chat.js`](frontend/tutor-chat.js)) that talks to
118+
the backend's `POST /api/chat`. There are two supported run modes.
119+
120+
### Mode A — single process (recommended for first run)
121+
122+
The backend serves the frontend directly. No CORS, no second port.
123+
124+
```bash
125+
# 1. Ollama
126+
ollama serve &
127+
ollama pull gemma3:4b
128+
129+
# 2. Backend + frontend together
130+
cd backend
131+
python3 -m venv .venv
132+
.venv/bin/pip install -r requirements.txt
133+
TUTOR_SERVE_FRONTEND=1 .venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8001
134+
```
135+
136+
Open <http://localhost:8001/> — the chat FAB appears bottom-right.
137+
138+
### Mode B — split static server and backend (developer-friendly)
139+
140+
Useful when iterating on frontend assets without reloading uvicorn.
141+
142+
```bash
143+
# Terminal 1 — Ollama
144+
ollama serve & ollama pull gemma3:4b
145+
146+
# Terminal 2 — backend on :8001 (CORS allows :8000 by default)
147+
cd backend && .venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8001 --reload
148+
149+
# Terminal 3 — static frontend on :8000
150+
cd frontend && python3 -m http.server 8000
151+
```
152+
153+
Open <http://localhost:8000/>. The chat module auto-detects port `8001` when
154+
served from `localhost:8000`. To point at a different backend, either edit the
155+
`<meta name="tutor-backend">` tag in `frontend/index.html`, or run this once in
156+
the browser console:
157+
158+
```js
159+
localStorage.setItem('tutor-backend', 'http://my-host:8001');
160+
location.reload();
161+
```
162+
163+
The chat module reads (in order): `window.TUTOR_BACKEND_URL` → `<meta
164+
name="tutor-backend">``localStorage["tutor-backend"]` → port heuristic →
165+
same origin.
166+
114167
## Core Components
115168

116169
- **Tutor UI**: A local web app, terminal interface, or desktop shell where the student reads lessons, submits code, and receives feedback.
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Integration-shaped smoke tests that verify the frontend chat module is
2+
wired to the backend correctly.
3+
4+
These tests do not require a running Ollama — they assert on:
5+
* Static frontend mount when TUTOR_SERVE_FRONTEND=1.
6+
* The chat JS/CSS assets exist and are referenced from index.html.
7+
* The chat JS calls POST /api/chat with a JSON body matching the
8+
ChatRequest schema.
9+
* The service worker bypasses /api/* requests.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import re
15+
from pathlib import Path
16+
17+
import httpx
18+
import pytest
19+
import respx
20+
from fastapi.testclient import TestClient
21+
22+
from app.config import REPO_ROOT, Settings
23+
from app.main import create_app
24+
25+
26+
FRONTEND_DIR = REPO_ROOT / "frontend"
27+
28+
29+
def _settings(**overrides) -> Settings:
30+
base = dict(
31+
ollama_url="http://ollama.test",
32+
model="gemma3:4b",
33+
request_timeout=5.0,
34+
allow_origins=("http://localhost:8000",),
35+
serve_frontend=True,
36+
frontend_dir=FRONTEND_DIR,
37+
system_prompt="You are a Python tutor.",
38+
)
39+
base.update(overrides)
40+
return Settings(**base)
41+
42+
43+
@pytest.fixture
44+
def client_with_frontend() -> TestClient:
45+
return TestClient(create_app(_settings()))
46+
47+
48+
def test_chat_assets_exist() -> None:
49+
assert (FRONTEND_DIR / "tutor-chat.js").is_file()
50+
assert (FRONTEND_DIR / "tutor-chat.css").is_file()
51+
52+
53+
def test_index_html_references_chat_assets() -> None:
54+
index = (FRONTEND_DIR / "index.html").read_text(encoding="utf-8")
55+
assert 'href="tutor-chat.css' in index
56+
assert 'src="tutor-chat.js' in index
57+
assert 'meta name="tutor-backend"' in index
58+
59+
60+
def test_chat_js_posts_to_api_chat() -> None:
61+
js = (FRONTEND_DIR / "tutor-chat.js").read_text(encoding="utf-8")
62+
# The module must POST to /api/chat with a JSON body containing `messages`.
63+
assert "/api/chat" in js
64+
assert "method: 'POST'" in js or 'method: "POST"' in js
65+
assert "application/json" in js
66+
assert "messages" in js
67+
# And it should also probe /api/health.
68+
assert "/api/health" in js
69+
70+
71+
def test_service_worker_bypasses_api_calls() -> None:
72+
sw = (FRONTEND_DIR / "sw.js").read_text(encoding="utf-8")
73+
# The bypass must short-circuit before the cache logic.
74+
assert "/api/" in sw
75+
assert re.search(r"pathname\.startsWith\(['\"]/api/['\"]\)", sw)
76+
77+
78+
def test_frontend_index_served_when_serve_frontend(client_with_frontend: TestClient) -> None:
79+
resp = client_with_frontend.get("/")
80+
assert resp.status_code == 200
81+
assert "Offline Python Tutor" in resp.text
82+
assert "tutor-chat.js" in resp.text
83+
84+
85+
def test_frontend_static_assets_served(client_with_frontend: TestClient) -> None:
86+
for path in ("/tutor-chat.js", "/tutor-chat.css", "/app.js", "/sw.js"):
87+
resp = client_with_frontend.get(path)
88+
assert resp.status_code == 200, path
89+
assert resp.content, path
90+
91+
92+
def test_api_routes_still_work_alongside_frontend(client_with_frontend: TestClient) -> None:
93+
resp = client_with_frontend.get("/api/config")
94+
assert resp.status_code == 200
95+
assert resp.json()["default_model"] == "gemma3:4b"
96+
97+
98+
@respx.mock
99+
def test_chat_endpoint_accepts_frontend_payload_shape(client_with_frontend: TestClient) -> None:
100+
"""Round-trip the exact JSON shape tutor-chat.js sends."""
101+
respx.post("http://ollama.test/api/chat").mock(
102+
return_value=httpx.Response(
103+
200,
104+
json={
105+
"model": "gemma3:4b",
106+
"message": {"role": "assistant", "content": "Let's start by printing the list."},
107+
"done": True,
108+
},
109+
)
110+
)
111+
payload = {
112+
"messages": [
113+
{"role": "user", "content": 'Context: I am currently reading "Section 03 — Lists".\n\nWhy is my list empty?'},
114+
]
115+
}
116+
resp = client_with_frontend.post("/api/chat", json=payload)
117+
assert resp.status_code == 200
118+
body = resp.json()
119+
assert body["message"]["role"] == "assistant"
120+
assert "print" in body["message"]["content"]
121+
122+
123+
def test_cors_allows_configured_origin() -> None:
124+
settings = _settings(allow_origins=("http://localhost:8000",), serve_frontend=False)
125+
client = TestClient(create_app(settings))
126+
resp = client.options(
127+
"/api/chat",
128+
headers={
129+
"origin": "http://localhost:8000",
130+
"access-control-request-method": "POST",
131+
"access-control-request-headers": "content-type",
132+
},
133+
)
134+
assert resp.status_code in (200, 204)
135+
assert resp.headers.get("access-control-allow-origin") == "http://localhost:8000"

frontend/README.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ It runs entirely in the browser, loads `content/sections.json` over `fetch`, and
1010
frontend/
1111
├── index.html # SPA shell, hash routing (#/, #/beginner, #/power, #/s/<key>)
1212
├── app.js # Router, renderer, lightweight Python syntax highlighter
13+
├── tutor-chat.js # Floating chat panel that calls POST /api/chat
1314
├── base.css # Reset + base typography
1415
├── style.css # Theme and layout
16+
├── tutor-chat.css # Chat panel styles
1517
├── manifest.json # PWA manifest
1618
├── sw.js # Service worker (cache-first shell, SWR for the rest)
1719
├── 404.html # GitHub Pages SPA hash redirect helper
@@ -62,10 +64,33 @@ The app uses hash routing so it works from any path:
6264

6365
## Hooking it up to the tutor backend
6466

65-
The current `app.js` is read-only and self-contained. To turn it into the **Tutor UI** referred to in [`docs/architecture.md`](../docs/architecture.md), future work should add:
67+
The first piece of tutor interactivity is in [`tutor-chat.js`](tutor-chat.js):
68+
a floating "Ask tutor" panel that POSTs the running message history to the
69+
backend's [`/api/chat`](../backend/README.md#post-apichat) endpoint and renders
70+
the assistant reply (markdown-ish — fenced code blocks and inline backticks
71+
are recognised). When a section is open, its number and title are prepended to
72+
the user message as lightweight context.
73+
74+
### Backend URL resolution
75+
76+
The chat module looks up the backend URL in this order:
77+
78+
1. `window.TUTOR_BACKEND_URL` (set by an inline `<script>` before `tutor-chat.js`)
79+
2. `<meta name="tutor-backend" content="...">` in `index.html`
80+
3. `localStorage.getItem('tutor-backend')`
81+
4. Heuristic: if the page is on `localhost:<other port>`, assume the backend is on `:8001`
82+
5. Same origin (empty string) — used when the backend serves the frontend with `TUTOR_SERVE_FRONTEND=1`
83+
84+
### Service-worker bypass
85+
86+
`sw.js` explicitly bypasses any same-origin URL starting with `/api/`, so
87+
chat requests always hit the live FastAPI server even when the rest of the
88+
shell is served from cache.
89+
90+
### Still to come
6691

6792
1. A code-editor pane on the section view, posting student code to a local sandbox endpoint.
68-
2. A hint/feedback pane that streams responses from a local LLM adapter (Ollama / llama.cpp / LM Studio).
93+
2. Streaming responses (the backend already supports `stream: true` NDJSON).
6994
3. A learner-state read/write layer talking to a small local store.
7095

7196
Keeping the frontend static means it can be hosted next to the backend as a `file://`-equivalent app, embedded in a desktop shell, or served by the tutor process itself.

frontend/index.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@
7979

8080
<link rel="stylesheet" href="base.css?v=20260416c" />
8181
<link rel="stylesheet" href="style.css?v=20260416b" />
82+
<link rel="stylesheet" href="tutor-chat.css?v=20260516a" />
83+
84+
<!--
85+
Tutor backend base URL. Defaults to "" (same origin) which is what you want
86+
when the backend serves the frontend (TUTOR_SERVE_FRONTEND=1). For split
87+
static-server + backend dev, set content="http://localhost:8001" or override
88+
at runtime via localStorage.setItem('tutor-backend', 'http://...').
89+
-->
90+
<meta name="tutor-backend" content="" />
8291
</head>
8392
<body>
8493

@@ -258,6 +267,7 @@ <h1 class="section__title" id="secTitle">Title</h1>
258267
</noscript>
259268

260269
<script src="app.js?v=20260416" defer></script>
270+
<script src="tutor-chat.js?v=20260516a" defer></script>
261271

262272
<!-- Service Worker Registration + Cache Bust -->
263273
<script>

frontend/sw.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
Cache-first for shell, network-first for content
44
============================================================ */
55

6-
const CACHE_VERSION = 'pytutor-v2026-05-16a';
6+
const CACHE_VERSION = 'pytutor-v2026-05-16b';
77
const SHELL_ASSETS = [
88
'./',
99
'./index.html',
1010
'./base.css',
1111
'./style.css',
12+
'./tutor-chat.css',
1213
'./app.js',
14+
'./tutor-chat.js',
1315
'./manifest.json',
1416
'./assets/favicon.svg',
1517
'./content/sections.json'
@@ -47,6 +49,11 @@ self.addEventListener('fetch', (e) => {
4749
return;
4850
}
4951

52+
// Never cache tutor backend API calls — they must hit the live FastAPI server.
53+
if (url.pathname.startsWith('/api/')) {
54+
return;
55+
}
56+
5057
// For navigation requests, serve the shell (SPA)
5158
if (e.request.mode === 'navigate') {
5259
e.respondWith(

0 commit comments

Comments
 (0)