omarsol commited on
Commit
cf4a919
·
1 Parent(s): 2996371

Add Next.js frontend with chat functionality, including components for chat messages, source panels, and markdown rendering. Update README with setup instructions and enhance .gitignore for frontend build artifacts.

Browse files
.gitignore CHANGED
@@ -164,6 +164,11 @@ cython_debug/
164
  .vscode/
165
  data/chroma-db**/
166
  evaluation_data/chroma-db**/
 
 
 
 
 
167
 
168
  .huggingface
169
 
@@ -176,4 +181,4 @@ evaluation_data/chroma-db**/
176
  *.mdx
177
  *.pkl
178
  *.png
179
- *.mov
 
164
  .vscode/
165
  data/chroma-db**/
166
  evaluation_data/chroma-db**/
167
+ node_modules/
168
+ .next/
169
+ frontend/node_modules/
170
+ frontend/.next/
171
+ frontend/out/
172
 
173
  .huggingface
174
 
 
181
  *.mdx
182
  *.pkl
183
  *.png
184
+ *.mov
README.md CHANGED
@@ -40,6 +40,53 @@ The Gradio demo is deployed on Hugging Face Spaces at: [AI Tutor Chatbot on Hugg
40
 
41
  Starts the Gradio AI Tutor interface.
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  ### Gradio API
44
 
45
  The chat endpoint is exposed as `chat`, so the API flow is:
 
40
 
41
  Starts the Gradio AI Tutor interface.
42
 
43
+ ### Next.js Frontend — Quick Start
44
+
45
+ The repo now also includes a separate Next.js frontend in [frontend](./frontend) that talks to the FastAPI backend instead of the Gradio transport.
46
+
47
+ 1. Start the Python API:
48
+
49
+ ```bash
50
+ uv run -m scripts.api
51
+ ```
52
+
53
+ If `8000` is already taken, bind another port instead:
54
+
55
+ ```bash
56
+ AI_TUTOR_API_PORT=8001 uv run -m scripts.api
57
+ ```
58
+
59
+ 2. In a second terminal, install the frontend dependencies:
60
+
61
+ ```bash
62
+ cd frontend
63
+ npm install
64
+ ```
65
+
66
+ 3. Configure the frontend API target:
67
+
68
+ ```bash
69
+ cp .env.example .env.local
70
+ ```
71
+
72
+ The default points at `http://127.0.0.1:8000`, which matches the local FastAPI app.
73
+ If you override the backend port, update `NEXT_PUBLIC_AI_TUTOR_API_BASE_URL` to match.
74
+
75
+ 4. Run the frontend:
76
+
77
+ ```bash
78
+ npm run dev
79
+ ```
80
+
81
+ 5. Open [http://localhost:3000](http://localhost:3000).
82
+
83
+ This frontend consumes:
84
+
85
+ - `GET /api/sources`
86
+ - `POST /api/chat`
87
+
88
+ and renders sources, tool activity, and reasoning as separate UI elements rather than a single markdown block.
89
+
90
  ### Gradio API
91
 
92
  The chat endpoint is exposed as `chat`, so the API flow is:
frontend/.env.example ADDED
@@ -0,0 +1 @@
 
 
1
+ NEXT_PUBLIC_AI_TUTOR_API_BASE_URL=http://127.0.0.1:8000
frontend/.env.local ADDED
@@ -0,0 +1 @@
 
 
1
+ NEXT_PUBLIC_AI_TUTOR_API_BASE_URL=http://127.0.0.1:8000
frontend/app/globals.css ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+
3
+ :root {
4
+ --font-sans:
5
+ ui-sans-serif,
6
+ -apple-system,
7
+ BlinkMacSystemFont,
8
+ "SF Pro Text",
9
+ "SF Pro Display",
10
+ "Segoe UI",
11
+ system-ui,
12
+ sans-serif;
13
+ --font-display: var(--font-sans);
14
+ --paper: #f7fbfe;
15
+ --paper-strong: #ffffff;
16
+ --ink: #12213d;
17
+ --muted: #5f7393;
18
+ --panel: rgba(247, 251, 254, 0.8);
19
+ --panel-strong: rgba(255, 255, 255, 0.92);
20
+ --line: rgba(11, 136, 238, 0.08);
21
+ --line-strong: rgba(11, 136, 238, 0.18);
22
+ --accent: #0b88ee;
23
+ --accent-soft: rgba(11, 136, 238, 0.14);
24
+ --accent-faint: rgba(193, 235, 255, 0.42);
25
+ --success: #122acc;
26
+ --shadow: 0 24px 80px rgba(18, 42, 204, 0.12);
27
+ --surface: rgba(255, 255, 255, 0.78);
28
+ --surface-strong: rgba(255, 255, 255, 0.92);
29
+ --surface-soft: rgba(255, 255, 255, 0.55);
30
+ --surface-subtle: rgba(255, 255, 255, 0.35);
31
+ --surface-hover: rgba(255, 255, 255, 0.9);
32
+ --app-bg:
33
+ radial-gradient(circle at top left, rgba(193, 235, 255, 0.42), transparent 26%),
34
+ radial-gradient(circle at top right, rgba(11, 136, 238, 0.18), transparent 24%),
35
+ linear-gradient(180deg, #eef7ff 0%, #f7fbfe 46%, #ffffff 100%);
36
+ --grid-dot: rgba(15, 23, 32, 0.018);
37
+ --scroll-track: rgba(15, 23, 32, 0.18);
38
+ --grain-dot: rgba(15, 23, 32, 0.9);
39
+ --grain-blend: multiply;
40
+ --grain-opacity: 0.08;
41
+ --code-border: rgba(15, 23, 32, 0.08);
42
+ --code-bg: rgba(255, 255, 255, 0.72);
43
+ --pre-bg: rgba(15, 23, 32, 0.96);
44
+ --pre-color: #f7f2ea;
45
+ --hr-color: rgba(15, 23, 32, 0.1);
46
+ --table-header-bg: rgba(15, 23, 32, 0.04);
47
+ }
48
+
49
+ @media (prefers-color-scheme: dark) {
50
+ :root {
51
+ --paper: #0e1626;
52
+ --paper-strong: #13203a;
53
+ --ink: #e8edf5;
54
+ --muted: #94a5c2;
55
+ --panel: rgba(17, 28, 50, 0.74);
56
+ --panel-strong: rgba(25, 40, 72, 0.9);
57
+ --line: rgba(120, 180, 255, 0.1);
58
+ --line-strong: rgba(120, 180, 255, 0.24);
59
+ --accent: #4fa6f5;
60
+ --accent-soft: rgba(79, 166, 245, 0.2);
61
+ --accent-faint: rgba(79, 166, 245, 0.16);
62
+ --success: #6a8cff;
63
+ --shadow: 0 24px 80px rgba(0, 0, 0, 0.55);
64
+ --surface: rgba(30, 46, 78, 0.6);
65
+ --surface-strong: rgba(36, 54, 90, 0.88);
66
+ --surface-soft: rgba(30, 46, 78, 0.4);
67
+ --surface-subtle: rgba(30, 46, 78, 0.26);
68
+ --surface-hover: rgba(42, 62, 100, 0.72);
69
+ --app-bg:
70
+ radial-gradient(circle at top left, rgba(79, 166, 245, 0.18), transparent 28%),
71
+ radial-gradient(circle at top right, rgba(99, 102, 241, 0.22), transparent 26%),
72
+ linear-gradient(180deg, #070c18 0%, #0a1224 46%, #0d172e 100%);
73
+ --grid-dot: rgba(255, 255, 255, 0.028);
74
+ --scroll-track: rgba(255, 255, 255, 0.16);
75
+ --grain-dot: rgba(255, 255, 255, 0.5);
76
+ --grain-blend: screen;
77
+ --grain-opacity: 0.05;
78
+ --code-border: rgba(255, 255, 255, 0.12);
79
+ --code-bg: rgba(255, 255, 255, 0.06);
80
+ --pre-bg: rgba(0, 0, 0, 0.5);
81
+ --pre-color: #f7f2ea;
82
+ --hr-color: rgba(255, 255, 255, 0.12);
83
+ --table-header-bg: rgba(255, 255, 255, 0.05);
84
+ }
85
+ }
86
+
87
+ * {
88
+ box-sizing: border-box;
89
+ }
90
+
91
+ html {
92
+ height: 100%;
93
+ color: var(--ink);
94
+ background: var(--app-bg);
95
+ }
96
+
97
+ body {
98
+ min-height: 100vh;
99
+ margin: 0;
100
+ font-family: var(--font-sans), sans-serif;
101
+ line-height: 1.5;
102
+ letter-spacing: -0.012em;
103
+ text-rendering: optimizeLegibility;
104
+ -webkit-font-smoothing: antialiased;
105
+ -moz-osx-font-smoothing: grayscale;
106
+ font-feature-settings: "ss01" 1, "ss03" 1, "cv11" 1;
107
+ color: var(--ink);
108
+ background-image:
109
+ linear-gradient(var(--grid-dot) 1px, transparent 1px),
110
+ linear-gradient(90deg, var(--grid-dot) 1px, transparent 1px);
111
+ background-size: 32px 32px;
112
+ overflow-x: hidden;
113
+ }
114
+
115
+ @media (min-width: 1024px) {
116
+ body {
117
+ height: 100vh;
118
+ overflow: hidden;
119
+ }
120
+ }
121
+
122
+ a {
123
+ color: inherit;
124
+ text-decoration: none;
125
+ }
126
+
127
+ button,
128
+ input,
129
+ textarea {
130
+ font: inherit;
131
+ }
132
+
133
+ ::selection {
134
+ background: rgba(11, 136, 238, 0.18);
135
+ }
136
+
137
+ .glass-panel {
138
+ background: var(--panel);
139
+ border: 1px solid var(--line);
140
+ box-shadow: var(--shadow);
141
+ backdrop-filter: blur(24px);
142
+ }
143
+
144
+ .panel-strong {
145
+ background: var(--panel-strong);
146
+ border: 1px solid var(--line-strong);
147
+ }
148
+
149
+ .display-font {
150
+ font-family: var(--font-display), sans-serif;
151
+ font-weight: 620;
152
+ letter-spacing: -0.045em;
153
+ }
154
+
155
+ .grain-mask::before {
156
+ content: "";
157
+ position: absolute;
158
+ inset: 0;
159
+ pointer-events: none;
160
+ opacity: var(--grain-opacity);
161
+ background-image:
162
+ radial-gradient(circle at 1px 1px, var(--grain-dot) 1px, transparent 0);
163
+ background-size: 14px 14px;
164
+ mix-blend-mode: var(--grain-blend);
165
+ }
166
+
167
+ .scrollbar-thin {
168
+ scrollbar-width: thin;
169
+ scrollbar-color: var(--scroll-track) transparent;
170
+ }
171
+
172
+ .scrollbar-thin::-webkit-scrollbar {
173
+ width: 10px;
174
+ }
175
+
176
+ .scrollbar-thin::-webkit-scrollbar-track {
177
+ background: transparent;
178
+ }
179
+
180
+ .scrollbar-thin::-webkit-scrollbar-thumb {
181
+ background: var(--scroll-track);
182
+ border-radius: 999px;
183
+ border: 2px solid transparent;
184
+ background-clip: padding-box;
185
+ }
186
+
187
+ @keyframes rise-in {
188
+ from {
189
+ opacity: 0;
190
+ transform: translateY(14px);
191
+ }
192
+
193
+ to {
194
+ opacity: 1;
195
+ transform: translateY(0);
196
+ }
197
+ }
198
+
199
+ @keyframes pulse-border {
200
+ 0%,
201
+ 100% {
202
+ box-shadow: inset 0 0 0 1px rgba(11, 136, 238, 0.12);
203
+ }
204
+
205
+ 50% {
206
+ box-shadow: inset 0 0 0 1px rgba(11, 136, 238, 0.34);
207
+ }
208
+ }
209
+
210
+ @keyframes processing-sheen {
211
+ 0% {
212
+ transform: translateX(-120%);
213
+ }
214
+
215
+ 100% {
216
+ transform: translateX(160%);
217
+ }
218
+ }
219
+
220
+ @keyframes processing-orb {
221
+ 0%,
222
+ 100% {
223
+ transform: translate(0, -50%) scale(0.95);
224
+ opacity: 0.2;
225
+ }
226
+
227
+ 50% {
228
+ transform: translate(calc(100% + 5.75rem), -50%) scale(1.08);
229
+ opacity: 0.42;
230
+ }
231
+ }
232
+
233
+ @keyframes processing-pulse {
234
+ 0%,
235
+ 100% {
236
+ transform: scale(0.9);
237
+ opacity: 0.55;
238
+ }
239
+
240
+ 50% {
241
+ transform: scale(1.2);
242
+ opacity: 1;
243
+ }
244
+ }
245
+
246
+ .animate-rise-in {
247
+ animation: rise-in 240ms ease-out both;
248
+ }
249
+
250
+ .animate-pulse-border {
251
+ animation: pulse-border 1.8s ease-in-out infinite;
252
+ }
253
+
254
+ .processing-button__sheen {
255
+ background:
256
+ linear-gradient(
257
+ 115deg,
258
+ transparent 18%,
259
+ rgba(255, 255, 255, 0.14) 38%,
260
+ rgba(255, 255, 255, 0.78) 50%,
261
+ rgba(255, 255, 255, 0.16) 62%,
262
+ transparent 82%
263
+ );
264
+ animation: processing-sheen 1.65s linear infinite;
265
+ }
266
+
267
+ .processing-button__orb {
268
+ background:
269
+ radial-gradient(circle, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0) 72%);
270
+ filter: blur(2px);
271
+ animation: processing-orb 2.2s ease-in-out infinite;
272
+ }
273
+
274
+ .processing-button__pulse {
275
+ background: currentColor;
276
+ box-shadow: 0 0 0 0 rgba(11, 136, 238, 0.18);
277
+ animation: processing-pulse 1.2s ease-in-out infinite;
278
+ }
279
+
280
+ .activity-sheen {
281
+ background: linear-gradient(
282
+ 115deg,
283
+ transparent 18%,
284
+ rgba(11, 136, 238, 0.1) 40%,
285
+ rgba(11, 136, 238, 0.24) 50%,
286
+ rgba(11, 136, 238, 0.1) 60%,
287
+ transparent 82%
288
+ );
289
+ animation: processing-sheen 1.9s linear infinite;
290
+ }
291
+
292
+ .markdown-block {
293
+ color: inherit;
294
+ }
295
+
296
+ .markdown-block > :first-child {
297
+ margin-top: 0;
298
+ }
299
+
300
+ .markdown-block > :last-child {
301
+ margin-bottom: 0;
302
+ }
303
+
304
+ .markdown-block p,
305
+ .markdown-block ul,
306
+ .markdown-block ol,
307
+ .markdown-block pre,
308
+ .markdown-block blockquote,
309
+ .markdown-block table {
310
+ margin: 0.85rem 0;
311
+ }
312
+
313
+ .markdown-block h1,
314
+ .markdown-block h2,
315
+ .markdown-block h3,
316
+ .markdown-block h4 {
317
+ margin: 1.1rem 0 0.55rem;
318
+ font-family: var(--font-display), sans-serif;
319
+ font-weight: 620;
320
+ letter-spacing: -0.035em;
321
+ line-height: 1.04;
322
+ color: var(--ink);
323
+ }
324
+
325
+ .markdown-block h1 {
326
+ font-size: 1.62rem;
327
+ }
328
+
329
+ .markdown-block h2 {
330
+ font-size: 1.3rem;
331
+ }
332
+
333
+ .markdown-block h3 {
334
+ font-size: 1.06rem;
335
+ }
336
+
337
+ .markdown-block ul,
338
+ .markdown-block ol {
339
+ padding-left: 1.35rem;
340
+ }
341
+
342
+ .markdown-block li + li {
343
+ margin-top: 0.35rem;
344
+ }
345
+
346
+ .markdown-block code {
347
+ border: 1px solid var(--code-border);
348
+ border-radius: 0.5rem;
349
+ background: var(--code-bg);
350
+ padding: 0.08rem 0.4rem;
351
+ font-size: 0.92em;
352
+ }
353
+
354
+ .markdown-block pre {
355
+ overflow-x: auto;
356
+ border: 1px solid var(--code-border);
357
+ border-radius: 1rem;
358
+ background: var(--pre-bg);
359
+ padding: 0.9rem 1rem;
360
+ color: var(--pre-color);
361
+ }
362
+
363
+ .markdown-block pre code {
364
+ border: 0;
365
+ background: transparent;
366
+ padding: 0;
367
+ color: inherit;
368
+ }
369
+
370
+ .markdown-block blockquote {
371
+ border-left: 3px solid rgba(11, 136, 238, 0.32);
372
+ padding-left: 0.9rem;
373
+ color: var(--muted);
374
+ }
375
+
376
+ .markdown-block a {
377
+ color: var(--accent);
378
+ text-decoration: underline;
379
+ text-decoration-color: rgba(11, 136, 238, 0.28);
380
+ text-underline-offset: 0.14em;
381
+ }
382
+
383
+ .markdown-block hr {
384
+ border: 0;
385
+ border-top: 1px solid var(--hr-color);
386
+ margin: 1rem 0;
387
+ }
388
+
389
+ .markdown-block table {
390
+ width: 100%;
391
+ border-collapse: collapse;
392
+ overflow: hidden;
393
+ border-radius: 0.9rem;
394
+ border: 1px solid var(--code-border);
395
+ }
396
+
397
+ .markdown-block th,
398
+ .markdown-block td {
399
+ border-bottom: 1px solid var(--code-border);
400
+ padding: 0.65rem 0.75rem;
401
+ text-align: left;
402
+ }
403
+
404
+ .markdown-block th {
405
+ background: var(--table-header-bg);
406
+ }
407
+
408
+ .markdown-block-muted {
409
+ color: var(--muted);
410
+ }
411
+
412
+ .markdown-block-muted h1,
413
+ .markdown-block-muted h2,
414
+ .markdown-block-muted h3,
415
+ .markdown-block-muted h4 {
416
+ color: var(--ink);
417
+ }
frontend/app/layout.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode } from "react";
2
+ import type { Metadata } from "next";
3
+ import "./globals.css";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "AI Tutor Agent",
7
+ description:
8
+ "A modern chat workspace for the AI Tutor retrieval app, powered by FastAPI and LangGraph.",
9
+ };
10
+
11
+ export default function RootLayout({
12
+ children,
13
+ }: Readonly<{
14
+ children: ReactNode;
15
+ }>) {
16
+ return (
17
+ <html lang="en">
18
+ <body suppressHydrationWarning>{children}</body>
19
+ </html>
20
+ );
21
+ }
frontend/app/page.tsx ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import { ChatShell } from "@/components/chat-shell";
2
+
3
+ export default function Home() {
4
+ return <ChatShell />;
5
+ }
frontend/components/chat-message.tsx ADDED
@@ -0,0 +1,586 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import clsx from "clsx";
4
+ import {
5
+ BookOpen,
6
+ Check,
7
+ ChevronDown,
8
+ Copy,
9
+ ExternalLink,
10
+ Globe,
11
+ GraduationCap,
12
+ LibraryBig,
13
+ Pencil,
14
+ RefreshCw,
15
+ SearchCheck,
16
+ Sparkles,
17
+ Wrench,
18
+ } from "lucide-react";
19
+ import type { ComponentType, SVGProps } from "react";
20
+ import {
21
+ useEffect,
22
+ useRef,
23
+ useState,
24
+ type ReactNode,
25
+ type RefObject,
26
+ } from "react";
27
+ import { MarkdownBlock } from "@/components/markdown-block";
28
+ import type {
29
+ TutorMessage,
30
+ TutorMessageBlock,
31
+ MessageCitation,
32
+ TutorMessagePart,
33
+ } from "@/lib/chat-ui";
34
+ import {
35
+ getMessageCitations,
36
+ getOrderedMessageBlocks,
37
+ prettifyToolName,
38
+ toolInputSummary,
39
+ } from "@/lib/chat-ui";
40
+
41
+ type ChatMessageProps = {
42
+ message: TutorMessage;
43
+ editDraft?: string;
44
+ isEditing?: boolean;
45
+ isStreaming?: boolean;
46
+ onAssistantCopy?: (message: TutorMessage) => void;
47
+ onAssistantRedo?: (messageId: string) => void;
48
+ onEditCancel?: () => void;
49
+ onEditChange?: (value: string) => void;
50
+ onEditSave?: (messageId: string) => void;
51
+ onUserEdit?: (messageId: string) => void;
52
+ actionDisabled?: boolean;
53
+ copied?: boolean;
54
+ showAssistantActions?: boolean;
55
+ };
56
+
57
+ export function ChatMessage({
58
+ message,
59
+ editDraft = "",
60
+ isEditing = false,
61
+ isStreaming = false,
62
+ onAssistantCopy,
63
+ onAssistantRedo,
64
+ onEditCancel,
65
+ onEditChange,
66
+ onEditSave,
67
+ onUserEdit,
68
+ actionDisabled = false,
69
+ copied = false,
70
+ showAssistantActions = true,
71
+ }: ChatMessageProps) {
72
+ const isAssistant = message.role === "assistant";
73
+ const contentBlocks = getOrderedMessageBlocks(message);
74
+ const citations = isAssistant && !isStreaming ? getMessageCitations(message) : [];
75
+ const editTextareaRef = useRef<HTMLTextAreaElement>(null);
76
+
77
+ useEffect(() => {
78
+ if (!isEditing || isAssistant) {
79
+ return;
80
+ }
81
+
82
+ const textarea = editTextareaRef.current;
83
+ if (!textarea) {
84
+ return;
85
+ }
86
+
87
+ textarea.focus();
88
+ const length = textarea.value.length;
89
+ textarea.setSelectionRange(length, length);
90
+ }, [isAssistant, isEditing]);
91
+
92
+ useEffect(() => {
93
+ if (!isEditing || isAssistant) {
94
+ return;
95
+ }
96
+
97
+ const textarea = editTextareaRef.current;
98
+ if (!textarea) {
99
+ return;
100
+ }
101
+
102
+ textarea.style.height = "0px";
103
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 240)}px`;
104
+ }, [editDraft, isAssistant, isEditing]);
105
+
106
+ return (
107
+ <div
108
+ data-message-id={message.id}
109
+ data-role={message.role}
110
+ className={clsx(
111
+ "animate-rise-in flex flex-col",
112
+ isAssistant ? "w-full items-start" : "items-end",
113
+ )}
114
+ >
115
+ <article
116
+ className={clsx(
117
+ "rounded-[1.8rem] border px-4 py-3 text-left shadow-[0_20px_50px_rgba(18,42,204,0.08)] transition outline-none sm:px-5",
118
+ isAssistant
119
+ ? "w-full border-[var(--line)] bg-[var(--surface)]"
120
+ : "ml-auto border-[var(--accent)] bg-[linear-gradient(135deg,rgba(11,136,238,0.16),rgba(193,235,255,0.35))]",
121
+ )}
122
+ >
123
+ {isEditing && !isAssistant ? (
124
+ <InlineEditor
125
+ disabled={actionDisabled}
126
+ textareaRef={editTextareaRef}
127
+ value={editDraft}
128
+ onCancel={onEditCancel}
129
+ onChange={onEditChange}
130
+ onSave={() => onEditSave?.(message.id)}
131
+ />
132
+ ) : (
133
+ <div className="space-y-3">
134
+ {contentBlocks.map((block, index) => {
135
+ const isLastBlock = index === contentBlocks.length - 1;
136
+ const isActive =
137
+ isStreaming && isLastBlock && block.kind !== "text";
138
+ return (
139
+ <ContentBlock
140
+ key={block.key}
141
+ block={block}
142
+ isActive={isActive}
143
+ />
144
+ );
145
+ })}
146
+ {citations.length > 0 ? <CitationRow citations={citations} /> : null}
147
+ </div>
148
+ )}
149
+ </article>
150
+
151
+ {isAssistant && showAssistantActions ? (
152
+ <div className="mt-1.5 flex items-center gap-1.5 pl-2">
153
+ <MessageActionButton
154
+ label="Redo"
155
+ onClick={() => onAssistantRedo?.(message.id)}
156
+ disabled={actionDisabled}
157
+ >
158
+ <RefreshCw className="h-3.5 w-3.5" />
159
+ </MessageActionButton>
160
+ <MessageActionButton
161
+ label={copied ? "Copied" : "Copy"}
162
+ onClick={() => onAssistantCopy?.(message)}
163
+ >
164
+ {copied ? (
165
+ <Check className="h-3.5 w-3.5" />
166
+ ) : (
167
+ <Copy className="h-3.5 w-3.5" />
168
+ )}
169
+ </MessageActionButton>
170
+ </div>
171
+ ) : !isAssistant ? (
172
+ !isEditing ? (
173
+ <div className="mt-1.5 flex items-center gap-1 pr-3">
174
+ <MessageActionButton
175
+ label="Edit"
176
+ onClick={() => onUserEdit?.(message.id)}
177
+ disabled={actionDisabled}
178
+ >
179
+ <Pencil className="h-3.5 w-3.5" />
180
+ </MessageActionButton>
181
+ </div>
182
+ ) : null
183
+ ) : null}
184
+ </div>
185
+ );
186
+ }
187
+
188
+ function InlineEditor({
189
+ disabled,
190
+ onCancel,
191
+ onChange,
192
+ onSave,
193
+ textareaRef,
194
+ value,
195
+ }: {
196
+ disabled: boolean;
197
+ onCancel?: () => void;
198
+ onChange?: (value: string) => void;
199
+ onSave?: () => void;
200
+ textareaRef: RefObject<HTMLTextAreaElement | null>;
201
+ value: string;
202
+ }) {
203
+ const trimmed = value.trim();
204
+
205
+ return (
206
+ <div className="space-y-3">
207
+ <textarea
208
+ ref={textareaRef}
209
+ rows={1}
210
+ value={value}
211
+ onChange={(event) => onChange?.(event.target.value)}
212
+ onKeyDown={(event) => {
213
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
214
+ event.preventDefault();
215
+ if (!disabled && trimmed) {
216
+ onSave?.();
217
+ }
218
+ return;
219
+ }
220
+
221
+ if (event.key === "Escape") {
222
+ event.preventDefault();
223
+ onCancel?.();
224
+ }
225
+ }}
226
+ className="max-h-60 min-h-20 w-full resize-none overflow-y-auto rounded-[1.15rem] border border-[var(--accent)]/20 bg-[var(--surface-subtle)] px-3 py-3 text-[15px] leading-7 text-[var(--ink)] outline-none placeholder:text-[var(--muted)]"
227
+ />
228
+ <div className="flex items-center justify-end gap-2">
229
+ <button
230
+ type="button"
231
+ onClick={onCancel}
232
+ disabled={disabled}
233
+ className="rounded-full border border-[var(--accent)]/20 bg-[var(--surface-soft)] px-3 py-1.5 text-sm font-medium text-[var(--ink)] transition enabled:hover:border-[var(--accent)] enabled:hover:text-[var(--accent)] disabled:cursor-not-allowed disabled:opacity-40"
234
+ >
235
+ Cancel
236
+ </button>
237
+ <button
238
+ type="button"
239
+ onClick={onSave}
240
+ disabled={disabled || !trimmed}
241
+ className="rounded-full bg-[var(--accent)] px-3 py-1.5 text-sm font-semibold text-white transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40"
242
+ >
243
+ Save
244
+ </button>
245
+ </div>
246
+ </div>
247
+ );
248
+ }
249
+
250
+ function MessageActionButton({
251
+ children,
252
+ label,
253
+ onClick,
254
+ disabled = false,
255
+ }: {
256
+ children: ReactNode;
257
+ label: string;
258
+ onClick?: () => void;
259
+ disabled?: boolean;
260
+ }) {
261
+ return (
262
+ <button
263
+ type="button"
264
+ onClick={onClick}
265
+ disabled={disabled}
266
+ aria-label={label}
267
+ title={label}
268
+ className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-[var(--line)] bg-[var(--surface)] text-[var(--muted)] shadow-[0_8px_24px_rgba(18,42,204,0.06)] transition enabled:hover:border-[var(--accent)] enabled:hover:text-[var(--accent)] disabled:cursor-not-allowed disabled:opacity-40"
269
+ >
270
+ {children}
271
+ </button>
272
+ );
273
+ }
274
+
275
+ function ContentBlock({
276
+ block,
277
+ isActive = false,
278
+ }: {
279
+ block: TutorMessageBlock;
280
+ isActive?: boolean;
281
+ }) {
282
+ if (block.kind === "reasoning") {
283
+ return <ReasoningBlock parts={block.parts} isActive={isActive} />;
284
+ }
285
+
286
+ if (block.kind === "tool") {
287
+ return <ToolActivityBlock parts={block.parts} isActive={isActive} />;
288
+ }
289
+
290
+ return <TextBlock parts={block.parts} />;
291
+ }
292
+
293
+ function ReasoningBlock({
294
+ parts,
295
+ isActive = false,
296
+ }: {
297
+ parts: TutorMessagePart[];
298
+ isActive?: boolean;
299
+ }) {
300
+ const [isOpen, setIsOpen] = useState(false);
301
+
302
+ return (
303
+ <section className="relative overflow-hidden rounded-[1.35rem] border border-[var(--line)] bg-[var(--paper)]/80 px-4 py-3">
304
+ {isActive ? (
305
+ <span
306
+ aria-hidden="true"
307
+ className="activity-sheen pointer-events-none absolute inset-x-0 top-0 h-[2.75rem]"
308
+ />
309
+ ) : null}
310
+ <button
311
+ type="button"
312
+ onClick={(event) => {
313
+ event.stopPropagation();
314
+ setIsOpen((current) => !current);
315
+ }}
316
+ className="relative z-[1] flex w-full items-center justify-between gap-2 text-left text-[13px] font-semibold tracking-[-0.012em] text-[var(--ink)]"
317
+ aria-expanded={isOpen}
318
+ >
319
+ <span className="inline-flex items-center gap-2 text-[var(--accent)]">
320
+ <Sparkles
321
+ className={clsx(
322
+ "h-4 w-4 text-[var(--accent)]",
323
+ isActive && "animate-pulse",
324
+ )}
325
+ />
326
+ <span className="text-[var(--ink)]">
327
+ {isActive ? "Thinking" : "Reasoning trace"}
328
+ </span>
329
+ {isActive ? (
330
+ <span
331
+ aria-hidden="true"
332
+ className="processing-button__pulse ml-0.5 h-1.5 w-1.5 rounded-full"
333
+ />
334
+ ) : null}
335
+ </span>
336
+ <ChevronDown
337
+ className={clsx(
338
+ "h-4 w-4 text-[var(--muted)] transition-transform",
339
+ isOpen && "rotate-180",
340
+ )}
341
+ />
342
+ </button>
343
+ {isOpen ? (
344
+ <div className="relative z-[1] mt-3 space-y-3 text-[14px] leading-[1.65] text-[var(--muted)]">
345
+ {parts.map((part, index) => (
346
+ <MarkdownBlock
347
+ key={`reasoning-${index}`}
348
+ className="markdown-block-muted"
349
+ >
350
+ {part.text ?? ""}
351
+ </MarkdownBlock>
352
+ ))}
353
+ </div>
354
+ ) : null}
355
+ </section>
356
+ );
357
+ }
358
+
359
+ function ToolActivityBlock({
360
+ parts,
361
+ isActive = false,
362
+ }: {
363
+ parts: TutorMessagePart[];
364
+ isActive?: boolean;
365
+ }) {
366
+ const [isOpen, setIsOpen] = useState(false);
367
+
368
+ const sourceCount = getToolPartSourceCount(parts);
369
+ const sourceCountLabel =
370
+ sourceCount > 0
371
+ ? `${sourceCount} source${sourceCount === 1 ? "" : "s"}`
372
+ : "";
373
+
374
+ return (
375
+ <section className="relative overflow-hidden rounded-[1.35rem] border border-[var(--line)] bg-[var(--surface)] px-4 py-3">
376
+ {isActive ? (
377
+ <span
378
+ aria-hidden="true"
379
+ className="activity-sheen pointer-events-none absolute inset-x-0 top-0 h-[2.75rem]"
380
+ />
381
+ ) : null}
382
+ <button
383
+ type="button"
384
+ onClick={(event) => {
385
+ event.stopPropagation();
386
+ setIsOpen((current) => !current);
387
+ }}
388
+ className="relative z-[1] flex w-full items-center justify-between gap-3 text-left"
389
+ aria-expanded={isOpen}
390
+ >
391
+ <div className="inline-flex items-center gap-2 text-[13px] font-semibold tracking-[-0.012em] text-[var(--ink)]">
392
+ <Wrench
393
+ className={clsx(
394
+ "h-4 w-4 text-[var(--accent)]",
395
+ isActive && "animate-pulse",
396
+ )}
397
+ />
398
+ <span>{isActive ? "Running tools" : "Tool activity"}</span>
399
+ {isActive ? (
400
+ <span
401
+ aria-hidden="true"
402
+ className="processing-button__pulse ml-0.5 h-1.5 w-1.5 rounded-full text-[var(--accent)]"
403
+ />
404
+ ) : null}
405
+ </div>
406
+ <div className="flex items-center gap-3">
407
+ {sourceCountLabel ? (
408
+ <span className="rounded-full bg-[var(--surface)] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-[var(--muted)]">
409
+ {sourceCountLabel}
410
+ </span>
411
+ ) : null}
412
+ <ChevronDown
413
+ className={clsx(
414
+ "h-4 w-4 text-[var(--muted)] transition-transform",
415
+ isOpen && "rotate-180",
416
+ )}
417
+ />
418
+ </div>
419
+ </button>
420
+ {isOpen ? (
421
+ <div className="relative z-[1] mt-3 space-y-3">
422
+ {parts.map((part, index) => (
423
+ <ToolCard key={`tool-${index}`} part={part} />
424
+ ))}
425
+ </div>
426
+ ) : null}
427
+ </section>
428
+ );
429
+ }
430
+
431
+ function TextBlock({ parts }: { parts: TutorMessagePart[] }) {
432
+ return (
433
+ <div className="space-y-3 text-[15px] leading-[1.72] tracking-[-0.012em] text-[var(--ink)]">
434
+ {parts.map((part, index) => (
435
+ <MarkdownBlock
436
+ key={`text-${index}`}
437
+ className="text-[15px] leading-[1.72] tracking-[-0.012em] text-[var(--ink)]"
438
+ >
439
+ {part.text ?? ""}
440
+ </MarkdownBlock>
441
+ ))}
442
+ </div>
443
+ );
444
+ }
445
+
446
+ function ToolCard({ part }: { part: TutorMessagePart }) {
447
+ const summary = toolInputSummary(part.input);
448
+ const output =
449
+ part.output && typeof part.output === "object"
450
+ ? (part.output as { text?: string; matches?: unknown[] })
451
+ : undefined;
452
+ const matchCount = Array.isArray(output?.matches) ? output.matches.length : 0;
453
+ const resultSummary = describeToolResult(output?.text, matchCount);
454
+
455
+ return (
456
+ <div className="rounded-[1.25rem] border border-[var(--line)] bg-[var(--surface)] px-4 py-3 shadow-[0_10px_30px_rgba(18,42,204,0.05)]">
457
+ <div className="flex items-center justify-between gap-3">
458
+ <div>
459
+ <p className="text-[10px] font-semibold uppercase tracking-[0.11em] text-[var(--muted)]">
460
+ Tool call
461
+ </p>
462
+ <p className="mt-1 text-[13px] font-semibold tracking-[-0.012em] text-[var(--ink)]">
463
+ {prettifyToolName(part.type)}
464
+ </p>
465
+ </div>
466
+ <span className="rounded-full bg-[var(--surface)] px-2 py-1 text-[10px] font-medium uppercase tracking-[0.1em] text-[var(--muted)]">
467
+ {formatToolState(part.state)}
468
+ </span>
469
+ </div>
470
+
471
+ {summary ? (
472
+ <div className="mt-3 rounded-[1rem] border border-[var(--line)] bg-[var(--paper)]/65 px-3 py-3">
473
+ <div className="inline-flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.11em] text-[var(--muted)]">
474
+ <SearchCheck className="h-3.5 w-3.5 text-[var(--accent)]" />
475
+ Tool input
476
+ </div>
477
+ <p className="mt-2 text-[13px] leading-[1.65] text-[var(--ink)]">{summary}</p>
478
+ </div>
479
+ ) : null}
480
+
481
+ {resultSummary ? (
482
+ <div className="mt-3 rounded-[1rem] border border-[var(--line)] bg-[rgba(11,136,238,0.08)] px-3 py-3">
483
+ <p className="text-[10px] font-semibold uppercase tracking-[0.11em] text-[var(--muted)]">
484
+ Tool result
485
+ </p>
486
+ <p className="mt-2 text-[13px] leading-[1.65] text-[var(--ink)]">
487
+ {resultSummary}
488
+ </p>
489
+ </div>
490
+ ) : null}
491
+
492
+ {part.errorText ? (
493
+ <p className="mt-3 text-xs leading-5 text-red-700">{part.errorText}</p>
494
+ ) : null}
495
+ </div>
496
+ );
497
+ }
498
+
499
+ function formatToolState(state?: string) {
500
+ if (!state) {
501
+ return "running";
502
+ }
503
+
504
+ return state.replaceAll("-", " ");
505
+ }
506
+
507
+ function describeToolResult(outputText?: string, matchCount = 0) {
508
+ if (matchCount > 0) {
509
+ return `${matchCount} source match${matchCount === 1 ? "" : "es"} captured for the final answer.`;
510
+ }
511
+
512
+ if ((outputText ?? "").trim()) {
513
+ return "Tool completed.";
514
+ }
515
+
516
+ return "";
517
+ }
518
+
519
+ function getToolPartSourceCount(parts: TutorMessagePart[]) {
520
+ const seen = new Set<string>();
521
+
522
+ for (const part of parts) {
523
+ const output =
524
+ part.output && typeof part.output === "object"
525
+ ? (part.output as { matches?: unknown[] })
526
+ : undefined;
527
+
528
+ for (const match of Array.isArray(output?.matches) ? output.matches : []) {
529
+ if (!match || typeof match !== "object") {
530
+ continue;
531
+ }
532
+
533
+ const source = match as { docId?: string; url?: string };
534
+ const key = source.docId || source.url;
535
+ if (!key) {
536
+ continue;
537
+ }
538
+ seen.add(key);
539
+ }
540
+ }
541
+
542
+ return seen.size;
543
+ }
544
+
545
+ const CITATION_KIND_META: Record<
546
+ MessageCitation["kind"],
547
+ { icon: ComponentType<SVGProps<SVGSVGElement>>; label: string }
548
+ > = {
549
+ web: { icon: Globe, label: "Web" },
550
+ course: { icon: GraduationCap, label: "Course" },
551
+ doc: { icon: BookOpen, label: "Docs" },
552
+ };
553
+
554
+ function CitationRow({ citations }: { citations: MessageCitation[] }) {
555
+ return (
556
+ <section className="mt-2 space-y-2 border-t border-[var(--line)] pt-3">
557
+ <div className="inline-flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--muted)]">
558
+ <LibraryBig className="h-3.5 w-3.5 text-[var(--accent)]" />
559
+ <span>Sources</span>
560
+ <span className="rounded-full bg-[var(--accent-faint)] px-1.5 py-0.5 text-[10px] font-semibold text-[var(--accent)]">
561
+ {citations.length}
562
+ </span>
563
+ </div>
564
+ <div className="flex flex-wrap gap-2">
565
+ {citations.map((citation, index) => {
566
+ const meta = CITATION_KIND_META[citation.kind];
567
+ const Icon = meta.icon;
568
+ return (
569
+ <a
570
+ key={`${citation.kind}-${citation.url}-${index}`}
571
+ href={citation.url}
572
+ target="_blank"
573
+ rel="noreferrer"
574
+ title={`${meta.label}${citation.sublabel ? ` — ${citation.sublabel}` : ""}`}
575
+ className="group inline-flex max-w-full items-center gap-1.5 rounded-full border border-[var(--line-strong)] bg-[var(--accent-faint)] px-2.5 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:border-[var(--accent)] hover:bg-[var(--surface-strong)]"
576
+ >
577
+ <Icon className="h-3.5 w-3.5 shrink-0" />
578
+ <span className="truncate">{citation.label}</span>
579
+ <ExternalLink className="h-3 w-3 shrink-0 opacity-60 transition group-hover:opacity-100" />
580
+ </a>
581
+ );
582
+ })}
583
+ </div>
584
+ </section>
585
+ );
586
+ }
frontend/components/chat-shell.tsx ADDED
@@ -0,0 +1,515 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import clsx from "clsx";
4
+ import { useChat } from "@ai-sdk/react";
5
+ import { DefaultChatTransport } from "ai";
6
+ import { Send, Square, WandSparkles } from "lucide-react";
7
+ import {
8
+ useEffect,
9
+ useLayoutEffect,
10
+ useRef,
11
+ useState,
12
+ useTransition,
13
+ } from "react";
14
+ import { ChatMessage } from "@/components/chat-message";
15
+ import { SourceSidebar } from "@/components/source-sidebar";
16
+ import { fetchTools, getApiBaseUrl, type TutorTool } from "@/lib/api";
17
+ import {
18
+ getMessageTextContent,
19
+ hasRenderableContent,
20
+ type TutorMessage,
21
+ } from "@/lib/chat-ui";
22
+
23
+ type ThreadDataPart = {
24
+ type: "data-thread";
25
+ data: {
26
+ threadId: string;
27
+ };
28
+ };
29
+
30
+ export function ChatShell() {
31
+ const [transport] = useState(
32
+ () => new DefaultChatTransport({ api: `${getApiBaseUrl()}/api/chat` }),
33
+ );
34
+ const [tools, setTools] = useState<TutorTool[]>([]);
35
+ const [selectedSourceKeys, setSelectedSourceKeys] = useState<string[]>([]);
36
+ const [sourceError, setSourceError] = useState<string | null>(null);
37
+ const [input, setInput] = useState("");
38
+ const [threadId, setThreadId] = useState("");
39
+ const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
40
+ const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
41
+ const [editingText, setEditingText] = useState("");
42
+ const [, startTransition] = useTransition();
43
+ const threadViewportRef = useRef<HTMLDivElement>(null);
44
+ const composerInputRef = useRef<HTMLTextAreaElement>(null);
45
+ const pendingScrollRef = useRef(false);
46
+ const [spacerHeight, setSpacerHeight] = useState(0);
47
+
48
+ const {
49
+ messages,
50
+ sendMessage,
51
+ regenerate,
52
+ stop,
53
+ status,
54
+ error,
55
+ clearError,
56
+ } = useChat({
57
+ transport,
58
+ onData: (part) => {
59
+ if (part.type === "data-thread") {
60
+ const nextThreadId = (part as ThreadDataPart).data.threadId;
61
+ if (nextThreadId) {
62
+ startTransition(() => setThreadId(nextThreadId));
63
+ }
64
+ }
65
+ },
66
+ experimental_throttle: 50,
67
+ });
68
+
69
+ useEffect(() => {
70
+ const controller = new AbortController();
71
+
72
+ async function loadTools() {
73
+ try {
74
+ const { tools: loadedTools } = await fetchTools(controller.signal);
75
+ setTools(loadedTools);
76
+ const retrieval = loadedTools.find(
77
+ (tool) => tool.kind === "configurable",
78
+ );
79
+ if (retrieval) {
80
+ setSelectedSourceKeys(
81
+ retrieval.sources
82
+ .filter((source) => source.selectedByDefault)
83
+ .map((source) => source.key),
84
+ );
85
+ }
86
+ setSourceError(null);
87
+ } catch (loadError) {
88
+ if (controller.signal.aborted) {
89
+ return;
90
+ }
91
+ setSourceError(
92
+ loadError instanceof Error
93
+ ? loadError.message
94
+ : "Unable to load tool registry.",
95
+ );
96
+ }
97
+ }
98
+
99
+ void loadTools();
100
+ return () => controller.abort();
101
+ }, []);
102
+
103
+ const isStreaming = status === "submitted" || status === "streaming";
104
+ const isReady = status === "ready";
105
+ const typedMessages = messages as TutorMessage[];
106
+ const latestMessage = typedMessages[typedMessages.length - 1];
107
+ const streamingAssistantId =
108
+ isStreaming && latestMessage?.role === "assistant"
109
+ ? latestMessage.id
110
+ : null;
111
+ const visibleMessages = typedMessages.filter((message) => {
112
+ if (message.id !== streamingAssistantId) {
113
+ return true;
114
+ }
115
+ return hasRenderableContent(message);
116
+ });
117
+ const chatColumnClass =
118
+ "mx-auto w-full max-w-[1040px] px-3 sm:px-5 lg:px-8 xl:px-10";
119
+
120
+ useEffect(() => {
121
+ const viewport = threadViewportRef.current;
122
+ if (!viewport) {
123
+ return;
124
+ }
125
+
126
+ const userElements = viewport.querySelectorAll<HTMLElement>(
127
+ '[data-role="user"]',
128
+ );
129
+ const lastUserEl = userElements[userElements.length - 1];
130
+
131
+ let needed = 0;
132
+ if (lastUserEl && (isStreaming || pendingScrollRef.current)) {
133
+ const container = lastUserEl.parentElement;
134
+ const children = container
135
+ ? (Array.from(container.children) as HTMLElement[])
136
+ : [];
137
+ const lastNonSpacer = children
138
+ .filter((child) => child.dataset.spacer !== "true")
139
+ .pop();
140
+ if (lastNonSpacer) {
141
+ const spaceFromLastUserTop =
142
+ lastNonSpacer.getBoundingClientRect().bottom -
143
+ lastUserEl.getBoundingClientRect().top;
144
+ needed = Math.max(
145
+ 0,
146
+ viewport.clientHeight - 24 - spaceFromLastUserTop,
147
+ );
148
+ }
149
+ }
150
+
151
+ setSpacerHeight(needed);
152
+ }, [messages, isStreaming]);
153
+
154
+ useLayoutEffect(() => {
155
+ if (!pendingScrollRef.current) {
156
+ return;
157
+ }
158
+ const viewport = threadViewportRef.current;
159
+ if (!viewport) {
160
+ return;
161
+ }
162
+ pendingScrollRef.current = false;
163
+
164
+ const users = viewport.querySelectorAll<HTMLElement>(
165
+ '[data-role="user"]',
166
+ );
167
+ const lastUserEl = users[users.length - 1];
168
+ if (!lastUserEl) {
169
+ return;
170
+ }
171
+
172
+ const delta =
173
+ lastUserEl.getBoundingClientRect().top -
174
+ viewport.getBoundingClientRect().top;
175
+ viewport.scrollTo({
176
+ top: Math.max(0, viewport.scrollTop + delta - 24),
177
+ behavior: "instant",
178
+ });
179
+ }, [spacerHeight]);
180
+
181
+ useEffect(() => {
182
+ const textarea = composerInputRef.current;
183
+ if (!textarea) {
184
+ return;
185
+ }
186
+
187
+ textarea.style.height = "0px";
188
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 176)}px`;
189
+ }, [input]);
190
+
191
+ async function handleSubmit() {
192
+ const trimmed = input.trim();
193
+ if (!trimmed || isStreaming) {
194
+ return;
195
+ }
196
+
197
+ clearError();
198
+ setCopiedMessageId(null);
199
+ setEditingMessageId(null);
200
+ setEditingText("");
201
+ setInput("");
202
+ pendingScrollRef.current = true;
203
+ await sendMessage(
204
+ { text: trimmed },
205
+ {
206
+ body: {
207
+ sourceKeys: selectedSourceKeys,
208
+ includeReasoning: true,
209
+ threadId,
210
+ },
211
+ },
212
+ );
213
+ }
214
+
215
+ async function handleRedo(messageId?: string) {
216
+ clearError();
217
+ setCopiedMessageId(null);
218
+ await regenerate({
219
+ messageId,
220
+ body: {
221
+ sourceKeys: selectedSourceKeys,
222
+ includeReasoning: true,
223
+ threadId,
224
+ },
225
+ });
226
+ }
227
+
228
+ function handleEdit(messageId: string) {
229
+ const messageIndex = messages.findIndex((message) => message.id === messageId);
230
+ if (messageIndex === -1) {
231
+ return;
232
+ }
233
+
234
+ const message = messages[messageIndex] as TutorMessage;
235
+ const draft = getMessageTextContent(message);
236
+
237
+ clearError();
238
+ setCopiedMessageId(null);
239
+ setEditingMessageId(messageId);
240
+ setEditingText(draft);
241
+ }
242
+
243
+ async function handleCopy(message: TutorMessage) {
244
+ const text = getMessageTextContent(message);
245
+ if (!text) {
246
+ return;
247
+ }
248
+
249
+ try {
250
+ await navigator.clipboard.writeText(text);
251
+ setCopiedMessageId(message.id);
252
+ window.setTimeout(() => {
253
+ setCopiedMessageId((current) =>
254
+ current === message.id ? null : current,
255
+ );
256
+ }, 1400);
257
+ } catch {
258
+ setCopiedMessageId(null);
259
+ }
260
+ }
261
+
262
+ function handleEditCancel() {
263
+ setEditingMessageId(null);
264
+ setEditingText("");
265
+ }
266
+
267
+ async function handleEditSave(messageId: string) {
268
+ const trimmed = editingText.trim();
269
+ if (!trimmed || isStreaming) {
270
+ return;
271
+ }
272
+
273
+ clearError();
274
+ setCopiedMessageId(null);
275
+ setEditingMessageId(null);
276
+ setEditingText("");
277
+ pendingScrollRef.current = true;
278
+ await sendMessage(
279
+ { text: trimmed, messageId },
280
+ {
281
+ body: {
282
+ sourceKeys: selectedSourceKeys,
283
+ includeReasoning: true,
284
+ threadId,
285
+ },
286
+ },
287
+ );
288
+ }
289
+
290
+ function toggleSource(sourceKey: string) {
291
+ setSelectedSourceKeys((current) =>
292
+ current.includes(sourceKey)
293
+ ? current.filter((key) => key !== sourceKey)
294
+ : [...current, sourceKey],
295
+ );
296
+ }
297
+
298
+ return (
299
+ <main className="min-h-screen p-2 lg:h-screen lg:overflow-hidden">
300
+ <div className="flex min-h-[calc(100vh-1rem)] w-full flex-col gap-2 lg:h-[calc(100vh-1rem)] lg:min-h-0 lg:grid lg:grid-cols-[248px_minmax(0,1fr)]">
301
+ <SourceSidebar
302
+ selectedSourceKeys={selectedSourceKeys}
303
+ sourceError={sourceError}
304
+ tools={tools}
305
+ onToggleSource={toggleSource}
306
+ />
307
+
308
+ <section className="glass-panel flex min-h-0 flex-col overflow-hidden rounded-[1.5rem] p-2 sm:p-2.5 lg:max-h-[calc(100vh-1rem)] lg:min-h-[calc(100vh-1rem)]">
309
+ <div
310
+ ref={threadViewportRef}
311
+ className="scrollbar-thin min-h-0 flex-1 overflow-y-auto"
312
+ >
313
+ {typedMessages.length === 0 ? (
314
+ <div className={clsx(chatColumnClass, "flex min-h-full")}>
315
+ <EmptyConversation
316
+ onSelect={(prompt) => {
317
+ setInput(prompt);
318
+ composerInputRef.current?.focus();
319
+ }}
320
+ />
321
+ </div>
322
+ ) : (
323
+ <div className={clsx(chatColumnClass, "flex flex-col gap-3 pt-4 pb-3")}>
324
+ {visibleMessages.map((message) => (
325
+ <ChatMessage
326
+ key={message.id}
327
+ message={message}
328
+ actionDisabled={isStreaming}
329
+ copied={copiedMessageId === message.id}
330
+ editDraft={editingMessageId === message.id ? editingText : ""}
331
+ isEditing={editingMessageId === message.id}
332
+ isStreaming={message.id === streamingAssistantId}
333
+ onAssistantCopy={handleCopy}
334
+ onAssistantRedo={(messageId) => void handleRedo(messageId)}
335
+ onEditCancel={handleEditCancel}
336
+ onEditChange={setEditingText}
337
+ onEditSave={(messageId) => void handleEditSave(messageId)}
338
+ onUserEdit={handleEdit}
339
+ showAssistantActions={message.id !== streamingAssistantId}
340
+ />
341
+ ))}
342
+ <div
343
+ aria-hidden
344
+ data-spacer="true"
345
+ className="shrink-0"
346
+ style={{ height: spacerHeight }}
347
+ />
348
+ </div>
349
+ )}
350
+ </div>
351
+
352
+ <footer className="sticky bottom-0 z-10 mt-2">
353
+ <div className={chatColumnClass}>
354
+ <div className="rounded-[1.1rem] border border-[var(--line)] bg-[var(--panel-strong)] px-2.5 py-2 shadow-[0_10px_30px_rgba(18,42,204,0.08)] backdrop-blur-xl">
355
+ <textarea
356
+ ref={composerInputRef}
357
+ rows={1}
358
+ value={input}
359
+ onChange={(event) => setInput(event.target.value)}
360
+ onKeyDown={(event) => {
361
+ if (
362
+ event.key === "Enter" &&
363
+ !event.shiftKey &&
364
+ !event.altKey &&
365
+ !event.ctrlKey &&
366
+ !event.metaKey &&
367
+ !event.nativeEvent.isComposing
368
+ ) {
369
+ event.preventDefault();
370
+ void handleSubmit();
371
+ }
372
+ }}
373
+ placeholder="Ask about RAG, LangGraph, PEFT, or any of your selected sources…"
374
+ className="max-h-44 min-h-10 w-full resize-none overflow-y-auto bg-transparent px-0.5 py-2 text-[15px] leading-[1.6] tracking-[-0.012em] text-[var(--ink)] outline-none placeholder:text-[var(--muted)]"
375
+ />
376
+
377
+ <div className="mt-1.5 flex items-center justify-end gap-2">
378
+ <ComposerActionButton
379
+ disabled={!isStreaming && (!input.trim() || !isReady)}
380
+ isStreaming={isStreaming}
381
+ onClick={
382
+ isStreaming ? () => stop() : () => void handleSubmit()
383
+ }
384
+ />
385
+ </div>
386
+ </div>
387
+
388
+ {error ? (
389
+ <p className="mt-2 rounded-[0.9rem] border border-red-300/70 bg-red-50 px-3 py-2 text-sm text-red-800">
390
+ {error.message}
391
+ </p>
392
+ ) : null}
393
+ </div>
394
+ </footer>
395
+ </section>
396
+
397
+ </div>
398
+ </main>
399
+ );
400
+ }
401
+
402
+ function ComposerActionButton({
403
+ disabled,
404
+ isStreaming,
405
+ onClick,
406
+ }: {
407
+ disabled: boolean;
408
+ isStreaming: boolean;
409
+ onClick: () => void;
410
+ }) {
411
+ return (
412
+ <button
413
+ type="button"
414
+ onClick={onClick}
415
+ disabled={disabled}
416
+ aria-label={isStreaming ? "Stop generating" : "Send message"}
417
+ title={isStreaming ? "Stop generating" : "Send message"}
418
+ className={clsx(
419
+ "relative inline-flex h-10 min-w-[8.75rem] items-center justify-center overflow-hidden rounded-full px-4 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-50",
420
+ isStreaming
421
+ ? "border border-[var(--line-strong)] bg-[rgba(11,136,238,0.12)] text-[var(--accent)] shadow-[0_12px_30px_rgba(11,136,238,0.14)] hover:border-[var(--accent)] hover:bg-[rgba(11,136,238,0.15)]"
422
+ : "bg-[var(--accent)] text-white hover:brightness-110",
423
+ )}
424
+ >
425
+ {isStreaming ? (
426
+ <>
427
+ <span
428
+ aria-hidden="true"
429
+ className="processing-button__sheen absolute inset-0 rounded-full"
430
+ />
431
+ <span
432
+ aria-hidden="true"
433
+ className="processing-button__orb absolute left-2 top-1/2 h-6 w-6 -translate-y-1/2 rounded-full"
434
+ />
435
+ <span className="relative z-10 inline-flex items-center gap-2.5">
436
+ <span
437
+ aria-hidden="true"
438
+ className="processing-button__pulse h-2.5 w-2.5 rounded-full"
439
+ />
440
+ <span>Streaming</span>
441
+ <span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-white/92 text-[var(--accent)] shadow-[0_6px_16px_rgba(11,136,238,0.18)]">
442
+ <Square className="h-3 w-3 fill-current" />
443
+ </span>
444
+ </span>
445
+ </>
446
+ ) : (
447
+ <span className="relative z-10 inline-flex items-center gap-2">
448
+ <Send className="h-4 w-4" />
449
+ <span>Send</span>
450
+ </span>
451
+ )}
452
+ </button>
453
+ );
454
+ }
455
+
456
+ const SUGGESTIONS: ReadonlyArray<{ title: string; prompt: string }> = [
457
+ {
458
+ title: "RAG vs fine-tuning",
459
+ prompt:
460
+ "When should I use retrieval-augmented generation instead of fine-tuning a model?",
461
+ },
462
+ {
463
+ title: "LoRA with PEFT",
464
+ prompt:
465
+ "Walk me through fine-tuning a model with LoRA using the PEFT library.",
466
+ },
467
+ {
468
+ title: "Build a LangGraph agent",
469
+ prompt:
470
+ "How do I build a tool-calling agent with LangGraph, step by step?",
471
+ },
472
+ {
473
+ title: "Evaluate a RAG pipeline",
474
+ prompt:
475
+ "What are practical ways to evaluate the quality of a RAG pipeline?",
476
+ },
477
+ ];
478
+
479
+ function EmptyConversation({
480
+ onSelect,
481
+ }: {
482
+ onSelect: (prompt: string) => void;
483
+ }) {
484
+ return (
485
+ <div className="flex min-h-full flex-1 flex-col items-center justify-center px-4 py-10 text-center">
486
+ <div className="inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-[var(--accent-faint)] text-[var(--accent)]">
487
+ <WandSparkles className="h-5 w-5" />
488
+ </div>
489
+ <h2 className="display-font mt-4 text-[1.95rem] leading-[1] text-[var(--ink)] sm:text-[2.2rem]">
490
+ Ask your AI tutor
491
+ </h2>
492
+ <p className="mt-2 max-w-md text-[13.5px] leading-[1.6] text-[var(--muted)]">
493
+ Answers are grounded in the sources you selected on the left. Try one of
494
+ these to start:
495
+ </p>
496
+ <div className="mt-6 grid w-full max-w-[640px] grid-cols-1 gap-2 sm:grid-cols-2">
497
+ {SUGGESTIONS.map((suggestion) => (
498
+ <button
499
+ key={suggestion.title}
500
+ type="button"
501
+ onClick={() => onSelect(suggestion.prompt)}
502
+ className="group flex flex-col items-start gap-1 rounded-[0.95rem] border border-[var(--line)] bg-[var(--surface)] px-3.5 py-3 text-left transition hover:-translate-y-0.5 hover:border-[var(--accent)] hover:bg-[var(--surface-strong)] hover:shadow-[0_8px_20px_rgba(11,136,238,0.08)]"
503
+ >
504
+ <span className="text-[12.5px] font-semibold tracking-[-0.01em] text-[var(--ink)]">
505
+ {suggestion.title}
506
+ </span>
507
+ <span className="text-[12px] leading-[1.45] text-[var(--muted)]">
508
+ {suggestion.prompt}
509
+ </span>
510
+ </button>
511
+ ))}
512
+ </div>
513
+ </div>
514
+ );
515
+ }
frontend/components/markdown-block.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ReactMarkdown from "react-markdown";
2
+ import remarkGfm from "remark-gfm";
3
+
4
+ type MarkdownBlockProps = {
5
+ children: string;
6
+ className?: string;
7
+ };
8
+
9
+ export function MarkdownBlock({
10
+ children,
11
+ className = "",
12
+ }: MarkdownBlockProps) {
13
+ return (
14
+ <div className={`markdown-block ${className}`.trim()}>
15
+ <ReactMarkdown
16
+ remarkPlugins={[remarkGfm]}
17
+ components={{
18
+ a: ({ ...props }) => (
19
+ <a
20
+ {...props}
21
+ target="_blank"
22
+ rel="noreferrer"
23
+ />
24
+ ),
25
+ }}
26
+ >
27
+ {children}
28
+ </ReactMarkdown>
29
+ </div>
30
+ );
31
+ }
frontend/components/source-panel.tsx ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import clsx from "clsx";
4
+ import {
5
+ ChevronLeft,
6
+ ChevronRight,
7
+ ExternalLink,
8
+ LibraryBig,
9
+ SearchCode,
10
+ } from "lucide-react";
11
+ import type { SourcePartData } from "@/lib/api";
12
+ import type { TutorMessage } from "@/lib/chat-ui";
13
+ import { getMessageSources } from "@/lib/chat-ui";
14
+
15
+ type SourcePanelProps = {
16
+ isCollapsed: boolean;
17
+ message: TutorMessage | undefined;
18
+ onToggleCollapse: () => void;
19
+ };
20
+
21
+ export function SourcePanel({
22
+ isCollapsed,
23
+ message,
24
+ onToggleCollapse,
25
+ }: SourcePanelProps) {
26
+ const sources = message ? getMessageSources(message) : [];
27
+
28
+ return (
29
+ <div className="relative min-h-0">
30
+ <button
31
+ type="button"
32
+ onClick={onToggleCollapse}
33
+ aria-label={isCollapsed ? "Expand sources panel" : "Collapse sources panel"}
34
+ aria-pressed={isCollapsed}
35
+ className="absolute left-0 top-5 z-20 hidden h-10 w-10 -translate-x-1/2 items-center justify-center rounded-full border border-[var(--line-strong)] bg-[rgba(247,251,254,0.96)] text-[var(--accent)] shadow-[0_14px_34px_rgba(18,42,204,0.12)] backdrop-blur-xl transition hover:border-[var(--accent)] hover:text-[var(--ink)] lg:flex"
36
+ >
37
+ {isCollapsed ? (
38
+ <ChevronLeft className="h-4 w-4" />
39
+ ) : (
40
+ <ChevronRight className="h-4 w-4" />
41
+ )}
42
+ </button>
43
+
44
+ <aside
45
+ className={clsx(
46
+ "glass-panel overflow-hidden rounded-[1.5rem] p-3 transition-[padding] duration-300 lg:flex lg:min-h-0 lg:max-h-[calc(100vh-1rem)] lg:min-h-[calc(100vh-1rem)] lg:rounded-r-none lg:flex-col",
47
+ isCollapsed && "lg:items-center lg:px-2 lg:py-3",
48
+ )}
49
+ >
50
+ <div
51
+ className={clsx(
52
+ "hidden h-full flex-col items-center gap-3 lg:flex",
53
+ !isCollapsed && "lg:hidden",
54
+ )}
55
+ >
56
+ <div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[var(--accent-faint)] text-[var(--accent)]">
57
+ <LibraryBig className="h-[18px] w-[18px]" />
58
+ </div>
59
+ {message ? (
60
+ <span className="rounded-full bg-white/70 px-2 py-1 text-[10px] font-semibold tracking-[0.02em] text-[var(--accent)]">
61
+ {sources.length}
62
+ </span>
63
+ ) : null}
64
+ <span className="[writing-mode:vertical-rl] text-[10px] font-semibold uppercase tracking-[0.16em] text-[var(--muted)]">
65
+ Sources
66
+ </span>
67
+ </div>
68
+
69
+ <div className={clsx("lg:flex lg:min-h-0 lg:flex-1 lg:flex-col", isCollapsed && "lg:hidden")}>
70
+ <div className="flex items-center gap-2 text-[13px] font-semibold tracking-[-0.012em] text-[var(--ink)]">
71
+ <LibraryBig className="h-4 w-4 text-[var(--accent)]" />
72
+ Sources
73
+ </div>
74
+
75
+ {!message ? (
76
+ <EmptyState
77
+ title="No answer selected"
78
+ body="Send a question or click any assistant answer to inspect the sources and relevance scores behind it."
79
+ />
80
+ ) : sources.length === 0 ? (
81
+ <EmptyState
82
+ title="No sources attached yet"
83
+ body="This assistant turn has not emitted source data yet. During streaming, the cards will populate here automatically."
84
+ />
85
+ ) : (
86
+ <div className="mt-3 space-y-2 lg:flex lg:min-h-0 lg:flex-1 lg:flex-col">
87
+ <div className="rounded-[1.1rem] border border-[var(--line)] bg-white/60 p-3">
88
+ <p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--muted)]">
89
+ Current answer
90
+ </p>
91
+ <p className="mt-1.5 text-[13px] leading-[1.45] tracking-[-0.01em] text-[var(--ink)]">
92
+ {sources.length} supporting reference{sources.length === 1 ? "" : "s"}
93
+ </p>
94
+ </div>
95
+
96
+ <div className="scrollbar-thin min-w-0 space-y-2 pr-2 lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
97
+ {sources.map((source) => (
98
+ <SourceCard key={source.docId || source.url} source={source} />
99
+ ))}
100
+ </div>
101
+ </div>
102
+ )}
103
+ </div>
104
+ </aside>
105
+ </div>
106
+ );
107
+ }
108
+
109
+ function EmptyState({ title, body }: { title: string; body: string }) {
110
+ return (
111
+ <div className="mt-3 rounded-[1.1rem] border border-dashed border-[var(--line-strong)] bg-white/45 p-3">
112
+ <div className="flex items-center gap-2 text-[13px] font-semibold tracking-[-0.012em] text-[var(--ink)]">
113
+ <SearchCode className="h-4 w-4 text-[var(--accent)]" />
114
+ {title}
115
+ </div>
116
+ <p className="mt-1.5 text-[13px] leading-[1.55] text-[var(--muted)]">{body}</p>
117
+ </div>
118
+ );
119
+ }
120
+
121
+ function SourceCard({ source }: { source: SourcePartData }) {
122
+ const percentage = Math.max(0, Math.min(100, Math.round(source.score * 100)));
123
+
124
+ return (
125
+ <article className="animate-rise-in min-w-0 rounded-[1.1rem] border border-[var(--line)] bg-white/70 p-3 shadow-[0_18px_50px_rgba(18,42,204,0.08)]">
126
+ <div className="flex items-start justify-between gap-4">
127
+ <div className="min-w-0">
128
+ <p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--muted)]">
129
+ {source.sourceLabel}
130
+ </p>
131
+ <h3 className="mt-1.5 line-clamp-3 text-[15px] font-semibold leading-[1.35] tracking-[-0.02em] text-[var(--ink)]">
132
+ {source.title}
133
+ </h3>
134
+ </div>
135
+ <span className="rounded-full bg-[var(--accent-faint)] px-2.5 py-1 text-[11px] font-semibold tracking-[-0.01em] text-[var(--accent)]">
136
+ {percentage}%
137
+ </span>
138
+ </div>
139
+
140
+ <div className="mt-2 h-2 overflow-hidden rounded-full bg-[var(--accent-faint)]">
141
+ <div
142
+ className="h-full rounded-full bg-[var(--accent)] transition-[width] duration-500"
143
+ style={{ width: `${percentage}%` }}
144
+ />
145
+ </div>
146
+
147
+ <a
148
+ href={source.url}
149
+ target="_blank"
150
+ rel="noreferrer"
151
+ className="mt-3 inline-flex items-center gap-2 text-[13px] font-medium tracking-[-0.01em] text-[var(--accent)] transition hover:text-[var(--ink)]"
152
+ >
153
+ Open source
154
+ <ExternalLink className="h-4 w-4" />
155
+ </a>
156
+ </article>
157
+ );
158
+ }
frontend/components/source-sidebar.tsx ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import clsx from "clsx";
4
+ import {
5
+ BookOpen,
6
+ ChevronDown,
7
+ Globe,
8
+ GraduationCap,
9
+ Library,
10
+ Link as LinkIcon,
11
+ Wrench,
12
+ } from "lucide-react";
13
+ import { useState, type ComponentType, type SVGProps } from "react";
14
+ import type { TutorSource, TutorTool } from "@/lib/api";
15
+
16
+ type SourceSidebarProps = {
17
+ selectedSourceKeys: string[];
18
+ sourceError: string | null;
19
+ tools: TutorTool[];
20
+ onToggleSource: (sourceKey: string) => void;
21
+ };
22
+
23
+ type ToggleToolMeta = {
24
+ icon: ComponentType<SVGProps<SVGSVGElement>>;
25
+ };
26
+
27
+ const TOGGLE_TOOL_META: Record<string, ToggleToolMeta> = {
28
+ web_search: { icon: Globe },
29
+ url_context: { icon: LinkIcon },
30
+ };
31
+
32
+ export function SourceSidebar({
33
+ selectedSourceKeys,
34
+ sourceError,
35
+ tools,
36
+ onToggleSource,
37
+ }: SourceSidebarProps) {
38
+ const retrievalTool = tools.find(
39
+ (tool): tool is Extract<TutorTool, { kind: "configurable" }> =>
40
+ tool.kind === "configurable",
41
+ );
42
+ const toggleTools = tools.filter(
43
+ (tool): tool is Extract<TutorTool, { kind: "toggle" }> =>
44
+ tool.kind === "toggle",
45
+ );
46
+ const activeCount =
47
+ (retrievalTool && selectedSourceKeys.length > 0 ? 1 : 0) +
48
+ toggleTools.filter((tool) => tool.active).length;
49
+ const totalCount = (retrievalTool ? 1 : 0) + toggleTools.length;
50
+
51
+ return (
52
+ <aside className="glass-panel relative overflow-hidden rounded-[1.5rem] p-2.5 lg:flex lg:min-h-0 lg:max-h-[calc(100vh-1rem)] lg:min-h-[calc(100vh-1rem)] lg:flex-col">
53
+ <div className="grain-mask absolute inset-0" />
54
+ <div className="relative flex flex-col gap-2.5 lg:min-h-0 lg:flex-1">
55
+ <div className="space-y-0.5 px-1 pt-0.5">
56
+ <h1 className="text-[1.3rem] font-semibold leading-tight tracking-[-0.03em] text-[var(--ink)]">
57
+ AI Tutor
58
+ </h1>
59
+ <p className="text-[11.5px] leading-[1.45] text-[var(--muted)]">
60
+ Pick the tools that guide the agent.
61
+ </p>
62
+ </div>
63
+
64
+ <div className="flex items-center justify-between border-t border-[var(--line)] px-1 pt-2">
65
+ <div className="flex items-center gap-1.5">
66
+ <Wrench className="h-3.5 w-3.5 text-[var(--accent)]" />
67
+ <span className="text-[10.5px] font-semibold uppercase tracking-[0.12em] text-[var(--muted)]">
68
+ Tools
69
+ </span>
70
+ </div>
71
+ <span className="rounded-full bg-[var(--accent-faint)] px-2 py-0.5 text-[10.5px] font-semibold tracking-[-0.01em] text-[var(--accent)]">
72
+ {activeCount}/{totalCount}
73
+ </span>
74
+ </div>
75
+
76
+ <div className="scrollbar-thin space-y-2 pr-0.5 lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
77
+ {retrievalTool ? (
78
+ <RetrievalTool
79
+ tool={retrievalTool}
80
+ selectedSourceKeys={selectedSourceKeys}
81
+ onToggleSource={onToggleSource}
82
+ />
83
+ ) : null}
84
+ {toggleTools.map((tool) => (
85
+ <ToggleToolRow key={tool.key} tool={tool} />
86
+ ))}
87
+ </div>
88
+
89
+ {sourceError ? (
90
+ <p className="rounded-[0.75rem] border border-amber-300/60 bg-amber-50 px-2.5 py-1.5 text-[12px] text-amber-800">
91
+ {sourceError}
92
+ </p>
93
+ ) : null}
94
+ </div>
95
+ </aside>
96
+ );
97
+ }
98
+
99
+ function RetrievalTool({
100
+ tool,
101
+ selectedSourceKeys,
102
+ onToggleSource,
103
+ }: {
104
+ tool: Extract<TutorTool, { kind: "configurable" }>;
105
+ selectedSourceKeys: string[];
106
+ onToggleSource: (sourceKey: string) => void;
107
+ }) {
108
+ const [isOpen, setIsOpen] = useState(true);
109
+ const courseSources = tool.sources.filter((source) => source.group === "courses");
110
+ const docSources = tool.sources.filter((source) => source.group === "docs");
111
+
112
+ return (
113
+ <section className="space-y-1">
114
+ <button
115
+ type="button"
116
+ onClick={() => setIsOpen((current) => !current)}
117
+ aria-expanded={isOpen}
118
+ className="flex w-full items-center gap-2 rounded-[0.75rem] px-2 py-1.5 text-left transition hover:bg-[var(--surface-soft)]"
119
+ >
120
+ <Library className="h-3.5 w-3.5 shrink-0 text-[var(--accent)]" />
121
+ <span className="min-w-0 flex-1 truncate text-[12.5px] font-semibold tracking-[-0.01em] text-[var(--ink)]">
122
+ {tool.label}
123
+ </span>
124
+ <ChevronDown
125
+ className={clsx(
126
+ "h-3.5 w-3.5 shrink-0 text-[var(--muted)] transition-transform",
127
+ isOpen && "rotate-180",
128
+ )}
129
+ />
130
+ </button>
131
+
132
+ {isOpen ? (
133
+ <div className="ml-[11px] space-y-2.5 border-l border-[var(--line-strong)] pl-3">
134
+ {courseSources.length > 0 ? (
135
+ <SourceGroup
136
+ label="Courses"
137
+ icon={GraduationCap}
138
+ sources={courseSources}
139
+ selectedSourceKeys={selectedSourceKeys}
140
+ onToggleSource={onToggleSource}
141
+ />
142
+ ) : null}
143
+ {docSources.length > 0 ? (
144
+ <SourceGroup
145
+ label="Open-source docs"
146
+ icon={BookOpen}
147
+ sources={docSources}
148
+ selectedSourceKeys={selectedSourceKeys}
149
+ onToggleSource={onToggleSource}
150
+ />
151
+ ) : null}
152
+ </div>
153
+ ) : null}
154
+ </section>
155
+ );
156
+ }
157
+
158
+ function SourceGroup({
159
+ label,
160
+ icon: Icon,
161
+ sources,
162
+ selectedSourceKeys,
163
+ onToggleSource,
164
+ }: {
165
+ label: string;
166
+ icon: ComponentType<SVGProps<SVGSVGElement>>;
167
+ sources: TutorSource[];
168
+ selectedSourceKeys: string[];
169
+ onToggleSource: (sourceKey: string) => void;
170
+ }) {
171
+ return (
172
+ <div className="space-y-1">
173
+ <div className="flex items-center gap-1.5 px-1">
174
+ <Icon className="h-3 w-3 text-[var(--muted)]/70" />
175
+ <h2 className="text-[10px] font-medium tracking-[0.02em] text-[var(--muted)]/80">
176
+ {label}
177
+ </h2>
178
+ </div>
179
+
180
+ <div className="space-y-1">
181
+ {sources.map((source) => {
182
+ const selected = selectedSourceKeys.includes(source.key);
183
+ const isCourse = source.group === "courses";
184
+ return (
185
+ <button
186
+ key={source.key}
187
+ type="button"
188
+ onClick={() => onToggleSource(source.key)}
189
+ className={clsx(
190
+ "flex w-full items-center gap-2 rounded-[0.75rem] border px-2 py-1.5 text-left transition",
191
+ selected
192
+ ? isCourse
193
+ ? "border-[var(--accent)] bg-[var(--accent)] text-white shadow-[0_4px_12px_rgba(11,136,238,0.18)]"
194
+ : "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)]"
195
+ : "border-[var(--line)] bg-[var(--surface-soft)] text-[var(--ink)] hover:border-[var(--line-strong)] hover:bg-[var(--surface-hover)]",
196
+ )}
197
+ >
198
+ <span
199
+ className={clsx(
200
+ "flex h-4 w-4 shrink-0 items-center justify-center rounded-full border text-[9px] font-bold",
201
+ selected
202
+ ? isCourse
203
+ ? "border-white/70 bg-white text-[var(--accent)]"
204
+ : "border-[var(--accent)] bg-[var(--accent)] text-white"
205
+ : "border-[var(--line-strong)] text-transparent",
206
+ )}
207
+ >
208
+ ✓
209
+ </span>
210
+ <span className="min-w-0 truncate text-[12.5px] font-medium tracking-[-0.01em]">
211
+ {formatSourceLabel(source.label)}
212
+ </span>
213
+ </button>
214
+ );
215
+ })}
216
+ </div>
217
+ </div>
218
+ );
219
+ }
220
+
221
+ function ToggleToolRow({
222
+ tool,
223
+ }: {
224
+ tool: Extract<TutorTool, { kind: "toggle" }>;
225
+ }) {
226
+ const meta = TOGGLE_TOOL_META[tool.key];
227
+ const Icon = meta?.icon ?? Globe;
228
+ return (
229
+ <div
230
+ className="flex items-center gap-2 rounded-[0.9rem] border border-[var(--line)] bg-[var(--surface-subtle)] px-2 py-2"
231
+ title={tool.active ? "Always on for this model" : "Unavailable"}
232
+ >
233
+ <Icon className="h-3.5 w-3.5 shrink-0 text-[var(--accent)]" />
234
+ <span className="min-w-0 flex-1 truncate text-[12.5px] font-medium tracking-[-0.01em] text-[var(--ink)]">
235
+ {tool.label}
236
+ </span>
237
+ <span
238
+ aria-label={tool.active ? "on" : "off"}
239
+ className={clsx(
240
+ "inline-flex items-center rounded-full px-1.5 py-0.5 text-[9.5px] font-semibold uppercase tracking-[0.1em]",
241
+ tool.active
242
+ ? "bg-[var(--accent-faint)] text-[var(--accent)]"
243
+ : "bg-[var(--muted)]/15 text-[var(--muted)]",
244
+ )}
245
+ >
246
+ {tool.active ? "on" : "off"}
247
+ </span>
248
+ </div>
249
+ );
250
+ }
251
+
252
+ function formatSourceLabel(label: string) {
253
+ return label.replace(/\s+Docs$/, "");
254
+ }
frontend/eslint.config.mjs ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import nextVitals from "eslint-config-next/core-web-vitals";
2
+ import nextTypescript from "eslint-config-next/typescript";
3
+
4
+ const eslintConfig = [...nextVitals, ...nextTypescript];
5
+
6
+ export default eslintConfig;
frontend/next-env.d.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+ import "./.next/dev/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 ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { dirname } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ const __filename = fileURLToPath(import.meta.url);
5
+ const __dirname = dirname(__filename);
6
+
7
+ /** @type {import('next').NextConfig} */
8
+ const nextConfig = {
9
+ turbopack: {
10
+ root: __dirname,
11
+ },
12
+ };
13
+
14
+ export default nextConfig;
frontend/postcss.config.mjs ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
scripts/api.py CHANGED
@@ -2,19 +2,28 @@ from __future__ import annotations
2
 
3
  import json
4
  import os
5
- from typing import Any
 
6
  from uuid import uuid4
7
 
 
8
  import uvicorn
9
  from fastapi import FastAPI, HTTPException
10
  from fastapi.middleware.cors import CORSMiddleware
11
  from fastapi.responses import StreamingResponse
12
  from pydantic import BaseModel, Field
13
 
14
- from .chat_service import message_content_to_text, stream_chat
 
 
 
 
 
15
  from .chat_types import ChatEvent, ChatRequest, ChatTurn
16
  from .setup import (
 
17
  AVAILABLE_SOURCES_UI,
 
18
  DEFAULT_MODEL_NAME,
19
  DEFAULT_SELECTED_SOURCE_KEYS,
20
  DEFAULT_SELECTED_SOURCES_UI,
@@ -42,7 +51,40 @@ def parse_cors_origins() -> list[str]:
42
  return [origin.strip() for origin in raw.split(",") if origin.strip()] or ["*"]
43
 
44
 
45
- app = FastAPI(title="AI Tutor API")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  app.add_middleware(
47
  CORSMiddleware,
48
  allow_origins=parse_cors_origins(),
@@ -94,7 +136,13 @@ def build_chat_request(payload: ApiChatRequest) -> ChatRequest:
94
  if not query:
95
  raise HTTPException(status_code=422, detail="query is required")
96
 
97
- source_keys = tuple(payload.sourceKeys or DEFAULT_SELECTED_SOURCE_KEYS)
 
 
 
 
 
 
98
  return ChatRequest(
99
  query=query,
100
  history=history,
@@ -109,10 +157,18 @@ class UIMessageStreamEncoder:
109
  def __init__(self) -> None:
110
  self.message_id = ""
111
  self.text_block_id = ""
112
- self.reasoning_block_ids: dict[str, str] = {}
113
  self.source_matches_by_call_id: dict[str, list[dict[str, Any]]] = {}
114
  self.closed = False
115
 
 
 
 
 
 
 
 
 
116
  def encode(self, event: ChatEvent) -> list[dict[str, Any]]:
117
  parts: list[dict[str, Any]] = []
118
 
@@ -127,11 +183,13 @@ class UIMessageStreamEncoder:
127
 
128
  if event.type == "message_started":
129
  self.message_id = str(event.data.get("message_id", uuid4().hex))
 
130
  parts.append({"type": "start", "messageId": self.message_id})
131
  parts.append({"type": "start-step"})
132
  return parts
133
 
134
  if event.type == "text_delta":
 
135
  if not self.text_block_id:
136
  self.text_block_id = f"text_{self.message_id or uuid4().hex}"
137
  parts.append({"type": "text-start", "id": self.text_block_id})
@@ -145,22 +203,20 @@ class UIMessageStreamEncoder:
145
  return parts
146
 
147
  if event.type == "reasoning_delta":
148
- step = str(event.data.get("step", "")) or "default"
149
- reasoning_id = self.reasoning_block_ids.get(step)
150
- if reasoning_id is None:
151
- reasoning_id = f"reasoning_{step}_{uuid4().hex[:8]}"
152
- self.reasoning_block_ids[step] = reasoning_id
153
- parts.append({"type": "reasoning-start", "id": reasoning_id})
154
  parts.append(
155
  {
156
  "type": "reasoning-delta",
157
- "id": reasoning_id,
158
  "delta": str(event.data.get("text", "")),
159
  }
160
  )
161
  return parts
162
 
163
  if event.type == "tool_call_started":
 
164
  call_id = str(event.data.get("call_id", uuid4().hex))
165
  tool_name = str(event.data.get("tool_name", "tool"))
166
  parts.append(
@@ -223,6 +279,7 @@ class UIMessageStreamEncoder:
223
  return parts
224
 
225
  if event.type == "tool_call_completed":
 
226
  call_id = str(event.data.get("call_id", uuid4().hex))
227
  output = {
228
  "text": str(event.data.get("output_text", "")),
@@ -238,6 +295,7 @@ class UIMessageStreamEncoder:
238
  return parts
239
 
240
  if event.type == "message_completed":
 
241
  answer = str(event.data.get("answer", "")).strip()
242
  if answer and not self.text_block_id:
243
  self.text_block_id = f"text_{self.message_id or uuid4().hex}"
@@ -248,11 +306,9 @@ class UIMessageStreamEncoder:
248
  "id": self.text_block_id,
249
  "delta": answer,
250
  }
251
- )
252
  if self.text_block_id:
253
  parts.append({"type": "text-end", "id": self.text_block_id})
254
- for reasoning_id in self.reasoning_block_ids.values():
255
- parts.append({"type": "reasoning-end", "id": reasoning_id})
256
  parts.append({"type": "finish-step"})
257
  parts.append({"type": "finish"})
258
  self.closed = True
@@ -263,10 +319,9 @@ class UIMessageStreamEncoder:
263
  def finish_error(self, error_text: str) -> list[dict[str, Any]]:
264
  parts: list[dict[str, Any]] = [{"type": "error", "errorText": error_text}]
265
  if not self.closed:
 
266
  if self.text_block_id:
267
  parts.append({"type": "text-end", "id": self.text_block_id})
268
- for reasoning_id in self.reasoning_block_ids.values():
269
- parts.append({"type": "reasoning-end", "id": reasoning_id})
270
  if self.message_id:
271
  parts.append({"type": "finish-step"})
272
  parts.append({"type": "finish"})
@@ -279,19 +334,55 @@ def healthcheck() -> dict[str, str]:
279
  return {"status": "ok"}
280
 
281
 
282
- @app.get("/api/sources")
283
- def list_sources() -> dict[str, Any]:
284
  defaults = set(DEFAULT_SELECTED_SOURCES_UI)
285
- return {
286
- "sources": [
 
 
287
  {
288
  "label": label,
289
- "key": SOURCE_UI_TO_KEY[label],
 
290
  "selectedByDefault": label in defaults,
291
  }
292
- for label in AVAILABLE_SOURCES_UI
293
- ]
 
 
 
 
 
 
 
 
 
294
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
 
296
 
297
  @app.post("/api/chat")
@@ -327,4 +418,9 @@ async def chat(payload: ApiChatRequest) -> StreamingResponse:
327
 
328
 
329
  if __name__ == "__main__":
330
- uvicorn.run("scripts.api:app", host="0.0.0.0", port=8000, reload=False)
 
 
 
 
 
 
2
 
3
  import json
4
  import os
5
+ from contextlib import asynccontextmanager
6
+ from typing import Any, AsyncIterator
7
  from uuid import uuid4
8
 
9
+ import logfire
10
  import uvicorn
11
  from fastapi import FastAPI, HTTPException
12
  from fastapi.middleware.cors import CORSMiddleware
13
  from fastapi.responses import StreamingResponse
14
  from pydantic import BaseModel, Field
15
 
16
+ from .chat_service import (
17
+ get_retriever,
18
+ is_google_genai_model,
19
+ message_content_to_text,
20
+ stream_chat,
21
+ )
22
  from .chat_types import ChatEvent, ChatRequest, ChatTurn
23
  from .setup import (
24
+ AVAILABLE_SOURCES,
25
  AVAILABLE_SOURCES_UI,
26
+ COURSE_SOURCE_KEYS,
27
  DEFAULT_MODEL_NAME,
28
  DEFAULT_SELECTED_SOURCE_KEYS,
29
  DEFAULT_SELECTED_SOURCES_UI,
 
51
  return [origin.strip() for origin in raw.split(",") if origin.strip()] or ["*"]
52
 
53
 
54
+ def parse_bind_port() -> int:
55
+ raw = os.getenv("AI_TUTOR_API_PORT") or os.getenv("PORT") or "8000"
56
+ try:
57
+ return int(raw)
58
+ except ValueError as exc:
59
+ raise ValueError(f"Invalid API port: {raw!r}") from exc
60
+
61
+
62
+ def parse_bind_host() -> str:
63
+ return (os.getenv("AI_TUTOR_API_HOST") or os.getenv("HOST") or "0.0.0.0").strip()
64
+
65
+
66
+ @asynccontextmanager
67
+ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
68
+ """Warm up the Chroma retriever before serving any requests.
69
+
70
+ LangGraph's ToolNode runs sync tools in a threadpool, and parallel
71
+ `retrieve_tutor_context` calls can race into `PersistentClient(...)`
72
+ at the same time on the first turn. That race occasionally surfaces
73
+ `Could not connect to tenant default_tenant`. Touching the retriever
74
+ at startup forces single-threaded tenant init.
75
+ """
76
+ if os.environ.get("COHERE_API_KEY"):
77
+ try:
78
+ get_retriever()
79
+ except Exception as exc: # pragma: no cover - diagnostic logging only
80
+ logfire.warn(
81
+ "Retriever warm-up failed; first retrieval call may retry.",
82
+ error=str(exc),
83
+ )
84
+ yield
85
+
86
+
87
+ app = FastAPI(title="AI Tutor API", lifespan=lifespan)
88
  app.add_middleware(
89
  CORSMiddleware,
90
  allow_origins=parse_cors_origins(),
 
136
  if not query:
137
  raise HTTPException(status_code=422, detail="query is required")
138
 
139
+ allowed_source_keys = set(AVAILABLE_SOURCES)
140
+ requested_source_keys = payload.sourceKeys or list(DEFAULT_SELECTED_SOURCE_KEYS)
141
+ source_keys = tuple(
142
+ dict.fromkeys(
143
+ key for key in requested_source_keys if key in allowed_source_keys
144
+ )
145
+ ) or DEFAULT_SELECTED_SOURCE_KEYS
146
  return ChatRequest(
147
  query=query,
148
  history=history,
 
157
  def __init__(self) -> None:
158
  self.message_id = ""
159
  self.text_block_id = ""
160
+ self.active_reasoning_id = ""
161
  self.source_matches_by_call_id: dict[str, list[dict[str, Any]]] = {}
162
  self.closed = False
163
 
164
+ def close_reasoning_block(self) -> list[dict[str, Any]]:
165
+ if not self.active_reasoning_id:
166
+ return []
167
+
168
+ parts = [{"type": "reasoning-end", "id": self.active_reasoning_id}]
169
+ self.active_reasoning_id = ""
170
+ return parts
171
+
172
  def encode(self, event: ChatEvent) -> list[dict[str, Any]]:
173
  parts: list[dict[str, Any]] = []
174
 
 
183
 
184
  if event.type == "message_started":
185
  self.message_id = str(event.data.get("message_id", uuid4().hex))
186
+ self.active_reasoning_id = ""
187
  parts.append({"type": "start", "messageId": self.message_id})
188
  parts.append({"type": "start-step"})
189
  return parts
190
 
191
  if event.type == "text_delta":
192
+ parts.extend(self.close_reasoning_block())
193
  if not self.text_block_id:
194
  self.text_block_id = f"text_{self.message_id or uuid4().hex}"
195
  parts.append({"type": "text-start", "id": self.text_block_id})
 
203
  return parts
204
 
205
  if event.type == "reasoning_delta":
206
+ if not self.active_reasoning_id:
207
+ self.active_reasoning_id = f"reasoning_{uuid4().hex[:8]}"
208
+ parts.append({"type": "reasoning-start", "id": self.active_reasoning_id})
 
 
 
209
  parts.append(
210
  {
211
  "type": "reasoning-delta",
212
+ "id": self.active_reasoning_id,
213
  "delta": str(event.data.get("text", "")),
214
  }
215
  )
216
  return parts
217
 
218
  if event.type == "tool_call_started":
219
+ parts.extend(self.close_reasoning_block())
220
  call_id = str(event.data.get("call_id", uuid4().hex))
221
  tool_name = str(event.data.get("tool_name", "tool"))
222
  parts.append(
 
279
  return parts
280
 
281
  if event.type == "tool_call_completed":
282
+ parts.extend(self.close_reasoning_block())
283
  call_id = str(event.data.get("call_id", uuid4().hex))
284
  output = {
285
  "text": str(event.data.get("output_text", "")),
 
295
  return parts
296
 
297
  if event.type == "message_completed":
298
+ parts.extend(self.close_reasoning_block())
299
  answer = str(event.data.get("answer", "")).strip()
300
  if answer and not self.text_block_id:
301
  self.text_block_id = f"text_{self.message_id or uuid4().hex}"
 
306
  "id": self.text_block_id,
307
  "delta": answer,
308
  }
309
+ )
310
  if self.text_block_id:
311
  parts.append({"type": "text-end", "id": self.text_block_id})
 
 
312
  parts.append({"type": "finish-step"})
313
  parts.append({"type": "finish"})
314
  self.closed = True
 
319
  def finish_error(self, error_text: str) -> list[dict[str, Any]]:
320
  parts: list[dict[str, Any]] = [{"type": "error", "errorText": error_text}]
321
  if not self.closed:
322
+ parts.extend(self.close_reasoning_block())
323
  if self.text_block_id:
324
  parts.append({"type": "text-end", "id": self.text_block_id})
 
 
325
  if self.message_id:
326
  parts.append({"type": "finish-step"})
327
  parts.append({"type": "finish"})
 
334
  return {"status": "ok"}
335
 
336
 
337
+ def _source_entries() -> list[dict[str, Any]]:
 
338
  defaults = set(DEFAULT_SELECTED_SOURCES_UI)
339
+ entries: list[dict[str, Any]] = []
340
+ for label in AVAILABLE_SOURCES_UI:
341
+ key = SOURCE_UI_TO_KEY[label]
342
+ entries.append(
343
  {
344
  "label": label,
345
+ "key": key,
346
+ "group": "courses" if key in COURSE_SOURCE_KEYS else "docs",
347
  "selectedByDefault": label in defaults,
348
  }
349
+ )
350
+ return entries
351
+
352
+
353
+ def _tool_catalog(model_name: str) -> list[dict[str, Any]]:
354
+ retrieval_tool: dict[str, Any] = {
355
+ "key": "retrieval",
356
+ "label": "Retrieval",
357
+ "kind": "configurable",
358
+ "active": True,
359
+ "sources": _source_entries(),
360
  }
361
+ tools: list[dict[str, Any]] = [retrieval_tool]
362
+ if is_google_genai_model(model_name):
363
+ tools.append(
364
+ {
365
+ "key": "web_search",
366
+ "label": "Web search",
367
+ "kind": "toggle",
368
+ "active": True,
369
+ }
370
+ )
371
+ tools.append(
372
+ {
373
+ "key": "url_context",
374
+ "label": "URL reading",
375
+ "kind": "toggle",
376
+ "active": True,
377
+ }
378
+ )
379
+ return tools
380
+
381
+
382
+ @app.get("/api/tools")
383
+ def list_tools(model: str | None = None) -> dict[str, Any]:
384
+ model_name = (model or DEFAULT_MODEL_NAME).strip() or DEFAULT_MODEL_NAME
385
+ return {"model": model_name, "tools": _tool_catalog(model_name)}
386
 
387
 
388
  @app.post("/api/chat")
 
418
 
419
 
420
  if __name__ == "__main__":
421
+ uvicorn.run(
422
+ "scripts.api:app",
423
+ host=parse_bind_host(),
424
+ port=parse_bind_port(),
425
+ reload=False,
426
+ )
scripts/chat_service.py CHANGED
@@ -9,6 +9,7 @@ from uuid import uuid4
9
 
10
  import logfire
11
  from langchain.agents import create_agent
 
12
  from langchain.tools import ToolRuntime, tool
13
  from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, HumanMessage
14
  from langchain_openai import ChatOpenAI
@@ -53,7 +54,21 @@ def get_retriever() -> LocalChromaRetriever:
53
  )
54
 
55
 
56
- @tool
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  def retrieve_tutor_context(query: str, runtime: ToolRuntime[AppContext]) -> str:
58
  """Retrieve relevant course and documentation context for an AI tutor question."""
59
  results = get_retriever().search(
@@ -202,6 +217,9 @@ def sync_thread_with_history(
202
  state.values.get("messages", [])
203
  )
204
 
 
 
 
205
  if not checkpoint_history:
206
  restored_messages = history_to_langgraph_messages(history)
207
  if restored_messages:
@@ -210,12 +228,6 @@ def sync_thread_with_history(
210
  )
211
  return thread_id
212
 
213
- if not history:
214
- return thread_id
215
-
216
- if checkpoint_history == history:
217
- return thread_id
218
-
219
  branched_thread_id = new_thread_id()
220
  restored_messages = history_to_langgraph_messages(history)
221
  if restored_messages:
@@ -336,18 +348,113 @@ def build_chat_model(model_name: str, include_thoughts: bool = False):
336
  )
337
 
338
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  @lru_cache(maxsize=8)
340
  def build_agent(model_name: str, include_thoughts: bool = False):
341
  model = build_chat_model(model_name, include_thoughts=include_thoughts)
 
 
 
 
 
 
342
  return create_agent(
343
  model=model,
344
- tools=[retrieve_tutor_context],
345
  system_prompt=system_message_openai_agent,
346
  context_schema=AppContext,
347
  checkpointer=CHECKPOINTER,
 
348
  )
349
 
350
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
  async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
352
  normalized_history = normalize_history(request.history)
353
  matches_by_doc_id: dict[str, SourceMatch] = {}
@@ -358,6 +465,9 @@ async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
358
  include_reasoning = bool(request.include_reasoning) and is_google_genai_model(
359
  request.model_name
360
  )
 
 
 
361
 
362
  logfire.info("Running query", query=request.query)
363
  agent = build_agent(request.model_name, include_thoughts=include_reasoning)
@@ -427,6 +537,46 @@ async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
427
  "text": text_delta,
428
  },
429
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  continue
431
 
432
  if chunk["type"] != "updates":
@@ -478,10 +628,72 @@ async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
478
 
479
  if step != "model" or getattr(message, "type", None) != "ai":
480
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
  if getattr(message, "tool_calls", None):
482
  continue
483
  completed_answer = message_content_to_text(message.content)
484
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  answer = "".join(answer_chunks).strip() or completed_answer.strip()
486
  yield ChatEvent(
487
  "message_completed",
 
9
 
10
  import logfire
11
  from langchain.agents import create_agent
12
+ from langchain.agents.middleware import AgentMiddleware
13
  from langchain.tools import ToolRuntime, tool
14
  from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, HumanMessage
15
  from langchain_openai import ChatOpenAI
 
54
  )
55
 
56
 
57
+ RETRIEVE_TUTOR_CONTEXT_SCHEMA = {
58
+ "title": "retrieve_tutor_context",
59
+ "description": "Retrieve relevant course and documentation context for an AI tutor question.",
60
+ "type": "object",
61
+ "properties": {
62
+ "query": {
63
+ "type": "string",
64
+ "description": "The question or topic to search for in the course and documentation corpus.",
65
+ },
66
+ },
67
+ "required": ["query"],
68
+ }
69
+
70
+
71
+ @tool(args_schema=RETRIEVE_TUTOR_CONTEXT_SCHEMA)
72
  def retrieve_tutor_context(query: str, runtime: ToolRuntime[AppContext]) -> str:
73
  """Retrieve relevant course and documentation context for an AI tutor question."""
74
  results = get_retriever().search(
 
217
  state.values.get("messages", [])
218
  )
219
 
220
+ if checkpoint_history == history:
221
+ return thread_id
222
+
223
  if not checkpoint_history:
224
  restored_messages = history_to_langgraph_messages(history)
225
  if restored_messages:
 
228
  )
229
  return thread_id
230
 
 
 
 
 
 
 
231
  branched_thread_id = new_thread_id()
232
  restored_messages = history_to_langgraph_messages(history)
233
  if restored_messages:
 
348
  )
349
 
350
 
351
+ class GeminiServerSideToolsMiddleware(AgentMiddleware):
352
+ """Enable server-side tool invocation for Gemini 3 tool combinations.
353
+
354
+ Combining built-in tools (e.g. `google_search`, `url_context`) with
355
+ user-defined function declarations requires
356
+ `include_server_side_tool_invocations=True` on Gemini's `ToolConfig`.
357
+ We inject it via `model_settings` so it flows through LangChain's
358
+ `bind_tools(..., tool_config=...)` path.
359
+ """
360
+
361
+ def _inject(self, request):
362
+ existing = request.model_settings.get("tool_config") or {}
363
+ if isinstance(existing, dict):
364
+ next_tool_config: Any = {
365
+ **existing,
366
+ "include_server_side_tool_invocations": True,
367
+ }
368
+ else:
369
+ next_tool_config = existing
370
+ new_settings = {**request.model_settings, "tool_config": next_tool_config}
371
+ return request.override(model_settings=new_settings)
372
+
373
+ def wrap_model_call(self, request, handler):
374
+ return handler(self._inject(request))
375
+
376
+ async def awrap_model_call(self, request, handler):
377
+ return await handler(self._inject(request))
378
+
379
+
380
  @lru_cache(maxsize=8)
381
  def build_agent(model_name: str, include_thoughts: bool = False):
382
  model = build_chat_model(model_name, include_thoughts=include_thoughts)
383
+ tools: list[Any] = [retrieve_tutor_context]
384
+ middleware: list[AgentMiddleware] = []
385
+ if is_google_genai_model(model_name):
386
+ tools.append({"google_search": {}})
387
+ tools.append({"url_context": {}})
388
+ middleware.append(GeminiServerSideToolsMiddleware())
389
  return create_agent(
390
  model=model,
391
+ tools=tools,
392
  system_prompt=system_message_openai_agent,
393
  context_schema=AppContext,
394
  checkpointer=CHECKPOINTER,
395
+ middleware=middleware,
396
  )
397
 
398
 
399
+ def extract_web_search_queries(response_metadata: Any) -> list[str]:
400
+ """Pull the queries Gemini ran against google_search from grounding metadata."""
401
+ if not isinstance(response_metadata, dict):
402
+ return []
403
+ grounding = response_metadata.get("grounding_metadata") or {}
404
+ queries = grounding.get("web_search_queries") or []
405
+ return [str(q).strip() for q in queries if isinstance(q, str) and str(q).strip()]
406
+
407
+
408
+ def extract_grounding_source_matches(
409
+ response_metadata: Any,
410
+ matches_by_doc_id: dict[str, SourceMatch],
411
+ ) -> list[SourceMatch]:
412
+ """Turn Gemini grounding metadata into source matches (deduped by URI)."""
413
+ if not isinstance(response_metadata, dict):
414
+ return []
415
+ grounding = response_metadata.get("grounding_metadata") or {}
416
+ chunks = grounding.get("grounding_chunks") or []
417
+ if not chunks:
418
+ return []
419
+
420
+ confidence_by_index: dict[int, float] = {}
421
+ for support in grounding.get("grounding_supports") or []:
422
+ indices = support.get("grounding_chunk_indices") or []
423
+ scores = support.get("confidence_scores") or []
424
+ for idx, score in zip(indices, scores):
425
+ if not isinstance(idx, int):
426
+ continue
427
+ numeric = float(score) if isinstance(score, (int, float)) else 0.0
428
+ if numeric > confidence_by_index.get(idx, 0.0):
429
+ confidence_by_index[idx] = numeric
430
+
431
+ updated: list[SourceMatch] = []
432
+ for idx, chunk in enumerate(chunks):
433
+ web = (chunk or {}).get("web") or {}
434
+ uri = str(web.get("uri") or "").strip()
435
+ if not uri:
436
+ continue
437
+ title = str(web.get("title") or uri).strip()
438
+ doc_id = f"google_search::{uri}"
439
+ if doc_id in matches_by_doc_id:
440
+ continue
441
+ score = confidence_by_index.get(idx, 1.0)
442
+ source_match = SourceMatch(
443
+ doc_id=doc_id,
444
+ title=title,
445
+ url=uri,
446
+ source_key="google_search",
447
+ source_label="Web",
448
+ score=score,
449
+ )
450
+ matches_by_doc_id[doc_id] = source_match
451
+ updated.append(source_match)
452
+ return updated
453
+
454
+
455
+ GOOGLE_SEARCH_TOOL_NAME = "google_search"
456
+
457
+
458
  async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
459
  normalized_history = normalize_history(request.history)
460
  matches_by_doc_id: dict[str, SourceMatch] = {}
 
465
  include_reasoning = bool(request.include_reasoning) and is_google_genai_model(
466
  request.model_name
467
  )
468
+ google_search_call_id = ""
469
+ google_search_queries: list[str] = []
470
+ google_search_match_count = 0
471
 
472
  logfire.info("Running query", query=request.query)
473
  agent = build_agent(request.model_name, include_thoughts=include_reasoning)
 
537
  "text": text_delta,
538
  },
539
  )
540
+
541
+ token_metadata = getattr(token, "response_metadata", None)
542
+ new_queries = [
543
+ q
544
+ for q in extract_web_search_queries(token_metadata)
545
+ if q not in google_search_queries
546
+ ]
547
+ new_grounding = extract_grounding_source_matches(
548
+ token_metadata,
549
+ matches_by_doc_id,
550
+ )
551
+ if (new_queries or new_grounding) and not google_search_call_id:
552
+ google_search_call_id = uuid4().hex
553
+ yield ChatEvent(
554
+ "tool_call_started",
555
+ {
556
+ "message_id": message_id,
557
+ "call_id": google_search_call_id,
558
+ "tool_name": GOOGLE_SEARCH_TOOL_NAME,
559
+ "args": {"query": "; ".join(new_queries) if new_queries else ""},
560
+ "args_text": "; ".join(new_queries),
561
+ },
562
+ )
563
+ if new_queries:
564
+ google_search_queries.extend(new_queries)
565
+ for source_match in new_grounding:
566
+ google_search_match_count += 1
567
+ yield ChatEvent(
568
+ "source_match",
569
+ {
570
+ "message_id": message_id,
571
+ "doc_id": source_match.doc_id,
572
+ "title": source_match.title,
573
+ "url": source_match.url,
574
+ "source_key": source_match.source_key,
575
+ "source_label": source_match.source_label,
576
+ "score": source_match.score,
577
+ "call_id": google_search_call_id,
578
+ },
579
+ )
580
  continue
581
 
582
  if chunk["type"] != "updates":
 
628
 
629
  if step != "model" or getattr(message, "type", None) != "ai":
630
  continue
631
+
632
+ message_metadata = getattr(message, "response_metadata", None)
633
+ new_queries = [
634
+ q
635
+ for q in extract_web_search_queries(message_metadata)
636
+ if q not in google_search_queries
637
+ ]
638
+ new_grounding = extract_grounding_source_matches(
639
+ message_metadata,
640
+ matches_by_doc_id,
641
+ )
642
+ if (new_queries or new_grounding) and not google_search_call_id:
643
+ google_search_call_id = uuid4().hex
644
+ yield ChatEvent(
645
+ "tool_call_started",
646
+ {
647
+ "message_id": message_id,
648
+ "call_id": google_search_call_id,
649
+ "tool_name": GOOGLE_SEARCH_TOOL_NAME,
650
+ "args": {"query": "; ".join(new_queries) if new_queries else ""},
651
+ "args_text": "; ".join(new_queries),
652
+ },
653
+ )
654
+ if new_queries:
655
+ google_search_queries.extend(new_queries)
656
+ for source_match in new_grounding:
657
+ google_search_match_count += 1
658
+ yield ChatEvent(
659
+ "source_match",
660
+ {
661
+ "message_id": message_id,
662
+ "doc_id": source_match.doc_id,
663
+ "title": source_match.title,
664
+ "url": source_match.url,
665
+ "source_key": source_match.source_key,
666
+ "source_label": source_match.source_label,
667
+ "score": source_match.score,
668
+ "call_id": google_search_call_id,
669
+ },
670
+ )
671
+
672
  if getattr(message, "tool_calls", None):
673
  continue
674
  completed_answer = message_content_to_text(message.content)
675
 
676
+ if google_search_call_id:
677
+ joined_queries = "; ".join(google_search_queries)
678
+ if google_search_match_count == 0:
679
+ output_text = "Google search ran but returned no grounding results."
680
+ else:
681
+ plural = "" if google_search_match_count == 1 else "s"
682
+ output_text = (
683
+ f"Google search returned {google_search_match_count} web result{plural}."
684
+ )
685
+ yield ChatEvent(
686
+ "tool_call_completed",
687
+ {
688
+ "message_id": message_id,
689
+ "call_id": google_search_call_id,
690
+ "tool_name": GOOGLE_SEARCH_TOOL_NAME,
691
+ "args": {"query": joined_queries},
692
+ "args_text": joined_queries,
693
+ "output_text": output_text,
694
+ },
695
+ )
696
+
697
  answer = "".join(answer_chunks).strip() or completed_answer.strip()
698
  yield ChatEvent(
699
  "message_completed",
scripts/prompts.py CHANGED
@@ -1,17 +1,73 @@
1
  system_message_openai_agent = """You are an AI teacher for applied AI, LLM, RAG, and Python topics.
2
 
3
- Your job is to answer student questions clearly and accurately. Use the retrieval tool when the question depends on the course or documentation corpus. Do not use retrieval when it is unnecessary.
4
-
5
- Rules:
6
- - Call `retrieve_tutor_context` for questions that require facts from the course or documentation corpus.
7
- - Do not call `retrieve_tutor_context` for greetings, small talk, general conversation, or questions about your own role/capabilities in this app.
8
- - For questions about what you can do, answer directly from your role in this app without retrieval.
9
- - When you do use retrieval, base factual claims about the corpus on the retrieved results.
10
- - If the retrieval results are weak or missing, say that the answer is not well covered by the current knowledge base.
11
- - Synthesize retrieved material into a clear teaching answer. Do not copy tool output verbatim unless a code block should be preserved.
12
- - Prefer a few solid paragraphs over shallow bullet spam.
13
- - If retrieved content includes code, include complete runnable code blocks when that is useful.
14
- - End with a short invitation for the student to ask a follow-up question.
15
-
16
- The retrieval tool returns JSON with matched passages and source metadata. Use that evidence faithfully when retrieval is needed.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  """
 
1
  system_message_openai_agent = """You are an AI teacher for applied AI, LLM, RAG, and Python topics.
2
 
3
+ Your job is to answer student questions clearly and accurately. You have
4
+ three tools available:
5
+
6
+ - `retrieve_tutor_context` — retrieval over the course and documentation
7
+ corpus. Use this for anything that depends on course content.
8
+ - `google_search` (Gemini built-in, when available) — live web search for
9
+ current events, recent library releases, or facts outside the corpus.
10
+ - `url_context` (Gemini built-in, when available) — read a specific URL
11
+ in depth when the user provides one or when a search result needs to be
12
+ inspected closely.
13
+
14
+ ## When to use the retrieval tool
15
+
16
+ USE retrieval for:
17
+ - Questions about course content, concepts, code examples, or documentation.
18
+ - Factual questions where you need to ground the answer in the corpus.
19
+
20
+ DO NOT use retrieval for:
21
+ - Greetings or small talk.
22
+ - Questions about your own role or capabilities in this app (answer directly).
23
+ - Questions you can answer fully from general knowledge with no corpus dependency.
24
+
25
+ ## When to use web search / URL reading
26
+
27
+ USE `google_search` for:
28
+ - Questions about recent events, releases, or API changes that may post-date
29
+ the corpus.
30
+ - Facts the corpus likely does not cover (product pricing, news, etc.).
31
+
32
+ USE `url_context` for:
33
+ - Any URL the user pastes in their question.
34
+ - A search result you need to read in detail to answer accurately.
35
+
36
+ Prefer `retrieve_tutor_context` first when the question is clearly about
37
+ course material. Combine tools when it helps (e.g. retrieve corpus context,
38
+ then search the web for the latest update).
39
+
40
+ ## How to call the retrieval tool
41
+
42
+ You have two strategies. Pick one per turn:
43
+
44
+ 1. PARALLEL CALLS (preferred for broad or multi-part questions):
45
+ If the question covers multiple sub-topics, asks for a comparison, or is
46
+ broad enough that one query will miss context, issue 2-4 parallel calls
47
+ with DIFFERENT queries. Each query should target a distinct angle,
48
+ sub-topic, or phrasing. Do not issue near-duplicate queries.
49
+
50
+ Example: "Compare RAG and fine-tuning for domain adaptation"
51
+ → parallel queries: "RAG for domain adaptation", "fine-tuning for domain
52
+ adaptation", "RAG vs fine-tuning tradeoffs".
53
+
54
+ 2. SEQUENTIAL FOLLOW-UP (for narrow questions or when first results are weak):
55
+ Start with one focused query. If the results are off-topic, too sparse,
56
+ or miss a key aspect of the question, call the tool again with a
57
+ refined query (different keywords, more specific, or targeting the gap).
58
+ Stop after at most 2 sequential calls.
59
+
60
+ ## Answering rules
61
+
62
+ - Ground factual claims about the corpus in the retrieved results.
63
+ - If retrieval results are weak or missing, say the topic is not well
64
+ covered by the current knowledge base rather than guessing.
65
+ - Synthesize retrieved material into a clear teaching explanation. Do not
66
+ paste tool output verbatim, except for code blocks that should be
67
+ preserved as-is.
68
+ - Prefer a few solid paragraphs over shallow bullet lists.
69
+ - Include complete, runnable code blocks when code is relevant.
70
+ - End with a short invitation for a follow-up question.
71
+
72
+ The retrieval tool returns JSON with matched passages and source metadata.
73
  """
scripts/setup.py CHANGED
@@ -14,7 +14,7 @@ except Exception:
14
  VECTOR_DB_DIR = "data/chroma-db-all_sources"
15
  VECTOR_COLLECTION_NAME = "chroma-db-all_sources"
16
  DOCUMENT_DICT_PATH = f"{VECTOR_DB_DIR}/document_dict_all_sources.pkl"
17
- DEFAULT_MODEL_NAME = "google-genai:gemini-flash-latest"
18
 
19
  AVAILABLE_SOURCES_UI = [
20
  "Transformers Docs",
@@ -23,7 +23,6 @@ AVAILABLE_SOURCES_UI = [
23
  "LlamaIndex Docs",
24
  "LangChain Docs",
25
  "OpenAI Cookbooks",
26
- "Towards AI Blog",
27
  "8 Hour Primer",
28
  "Advanced LLM Developer",
29
  "Python Primer",
@@ -37,7 +36,6 @@ DEFAULT_SELECTED_SOURCES_UI = [
37
  "Advanced LLM Developer",
38
  "8 Hour Primer",
39
  "Python Primer",
40
- "Towards AI Blog",
41
  "Transformers Docs",
42
  "PEFT Docs",
43
  "TRL Docs",
@@ -53,7 +51,6 @@ AVAILABLE_SOURCES = [
53
  "llama_index",
54
  "langchain",
55
  "openai_cookbooks",
56
- "tai_blog",
57
  "8-hour_primer",
58
  "llm_developer",
59
  "python_primer",
@@ -68,7 +65,6 @@ SOURCE_UI_TO_KEY = {
68
  "LlamaIndex Docs": "llama_index",
69
  "LangChain Docs": "langchain",
70
  "OpenAI Cookbooks": "openai_cookbooks",
71
- "Towards AI Blog": "tai_blog",
72
  "8 Hour Primer": "8-hour_primer",
73
  "Advanced LLM Developer": "llm_developer",
74
  "Python Primer": "python_primer",
@@ -76,6 +72,16 @@ SOURCE_UI_TO_KEY = {
76
  "Agentic AI Engineering": "agentic_ai_engineering",
77
  }
78
 
 
 
 
 
 
 
 
 
 
 
79
  SOURCE_KEY_TO_LABEL = {value: key for key, value in SOURCE_UI_TO_KEY.items()}
80
  DEFAULT_SELECTED_SOURCE_KEYS = tuple(
81
  SOURCE_UI_TO_KEY[label] for label in DEFAULT_SELECTED_SOURCES_UI
@@ -110,6 +116,7 @@ mongo_db = (
110
  __all__ = [
111
  "AVAILABLE_SOURCES",
112
  "AVAILABLE_SOURCES_UI",
 
113
  "DEFAULT_SELECTED_SOURCE_KEYS",
114
  "DEFAULT_SELECTED_SOURCES_UI",
115
  "CONCURRENCY_COUNT",
 
14
  VECTOR_DB_DIR = "data/chroma-db-all_sources"
15
  VECTOR_COLLECTION_NAME = "chroma-db-all_sources"
16
  DOCUMENT_DICT_PATH = f"{VECTOR_DB_DIR}/document_dict_all_sources.pkl"
17
+ DEFAULT_MODEL_NAME = "google-genai:gemini-3-flash-preview"
18
 
19
  AVAILABLE_SOURCES_UI = [
20
  "Transformers Docs",
 
23
  "LlamaIndex Docs",
24
  "LangChain Docs",
25
  "OpenAI Cookbooks",
 
26
  "8 Hour Primer",
27
  "Advanced LLM Developer",
28
  "Python Primer",
 
36
  "Advanced LLM Developer",
37
  "8 Hour Primer",
38
  "Python Primer",
 
39
  "Transformers Docs",
40
  "PEFT Docs",
41
  "TRL Docs",
 
51
  "llama_index",
52
  "langchain",
53
  "openai_cookbooks",
 
54
  "8-hour_primer",
55
  "llm_developer",
56
  "python_primer",
 
65
  "LlamaIndex Docs": "llama_index",
66
  "LangChain Docs": "langchain",
67
  "OpenAI Cookbooks": "openai_cookbooks",
 
68
  "8 Hour Primer": "8-hour_primer",
69
  "Advanced LLM Developer": "llm_developer",
70
  "Python Primer": "python_primer",
 
72
  "Agentic AI Engineering": "agentic_ai_engineering",
73
  }
74
 
75
+ COURSE_SOURCE_KEYS = frozenset(
76
+ {
77
+ "8-hour_primer",
78
+ "llm_developer",
79
+ "python_primer",
80
+ "master_ai_for_work",
81
+ "agentic_ai_engineering",
82
+ }
83
+ )
84
+
85
  SOURCE_KEY_TO_LABEL = {value: key for key, value in SOURCE_UI_TO_KEY.items()}
86
  DEFAULT_SELECTED_SOURCE_KEYS = tuple(
87
  SOURCE_UI_TO_KEY[label] for label in DEFAULT_SELECTED_SOURCES_UI
 
116
  __all__ = [
117
  "AVAILABLE_SOURCES",
118
  "AVAILABLE_SOURCES_UI",
119
+ "COURSE_SOURCE_KEYS",
120
  "DEFAULT_SELECTED_SOURCE_KEYS",
121
  "DEFAULT_SELECTED_SOURCES_UI",
122
  "CONCURRENCY_COUNT",
tests/test_api.py CHANGED
@@ -26,15 +26,39 @@ class ApiTestCase(unittest.TestCase):
26
  self.assertEqual(response.status_code, 200)
27
  self.assertEqual(response.json(), {"status": "ok"})
28
 
29
- def test_list_sources(self) -> None:
30
  with TestClient(app) as client:
31
- response = client.get("/api/sources")
32
 
33
  self.assertEqual(response.status_code, 200)
34
  body = response.json()
35
- self.assertIn("sources", body)
36
- self.assertTrue(body["sources"])
37
- self.assertTrue(any(item["selectedByDefault"] for item in body["sources"]))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
  def test_chat_stream_returns_ai_sdk_parts(self) -> None:
40
  async def fake_stream_chat(request):
@@ -156,6 +180,52 @@ class ApiTestCase(unittest.TestCase):
156
  self.assertEqual(parts[0]["type"], "error")
157
  self.assertEqual(parts[0]["errorText"], "backend failed")
158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
 
160
  if __name__ == "__main__":
161
  unittest.main()
 
26
  self.assertEqual(response.status_code, 200)
27
  self.assertEqual(response.json(), {"status": "ok"})
28
 
29
+ def test_list_tools(self) -> None:
30
  with TestClient(app) as client:
31
+ response = client.get("/api/tools")
32
 
33
  self.assertEqual(response.status_code, 200)
34
  body = response.json()
35
+ self.assertIn("tools", body)
36
+ tools = body["tools"]
37
+ retrieval = next(tool for tool in tools if tool["key"] == "retrieval")
38
+ self.assertEqual(retrieval["kind"], "configurable")
39
+ self.assertTrue(retrieval["sources"])
40
+ self.assertTrue(
41
+ any(source["selectedByDefault"] for source in retrieval["sources"])
42
+ )
43
+ self.assertTrue(
44
+ all(source["group"] in {"courses", "docs"} for source in retrieval["sources"])
45
+ )
46
+ # Gemini is the default model, so web search + url reading are present.
47
+ tool_keys = {tool["key"] for tool in tools}
48
+ self.assertIn("web_search", tool_keys)
49
+ self.assertIn("url_context", tool_keys)
50
+
51
+ def test_list_tools_for_non_gemini_model(self) -> None:
52
+ with TestClient(app) as client:
53
+ response = client.get(
54
+ "/api/tools", params={"model": "anthropic:claude-sonnet-4-6"}
55
+ )
56
+
57
+ self.assertEqual(response.status_code, 200)
58
+ tool_keys = {tool["key"] for tool in response.json()["tools"]}
59
+ self.assertIn("retrieval", tool_keys)
60
+ self.assertNotIn("web_search", tool_keys)
61
+ self.assertNotIn("url_context", tool_keys)
62
 
63
  def test_chat_stream_returns_ai_sdk_parts(self) -> None:
64
  async def fake_stream_chat(request):
 
180
  self.assertEqual(parts[0]["type"], "error")
181
  self.assertEqual(parts[0]["errorText"], "backend failed")
182
 
183
+ def test_chat_stream_restarts_reasoning_after_tool_activity(self) -> None:
184
+ async def fake_stream_chat(_request):
185
+ yield ChatEvent("message_started", {"message_id": "message_1"})
186
+ yield ChatEvent("reasoning_delta", {"message_id": "message_1", "text": "First thought"})
187
+ yield ChatEvent(
188
+ "tool_call_started",
189
+ {
190
+ "message_id": "message_1",
191
+ "call_id": "call_1",
192
+ "tool_name": "retrieve_tutor_context",
193
+ "args": {"query": "What is RAG?"},
194
+ "args_text": "What is RAG?",
195
+ },
196
+ )
197
+ yield ChatEvent(
198
+ "tool_call_completed",
199
+ {
200
+ "message_id": "message_1",
201
+ "call_id": "call_1",
202
+ "output_text": "payload",
203
+ },
204
+ )
205
+ yield ChatEvent("reasoning_delta", {"message_id": "message_1", "text": "Second thought"})
206
+ yield ChatEvent("text_delta", {"message_id": "message_1", "text": "Final answer"})
207
+ yield ChatEvent(
208
+ "message_completed",
209
+ {
210
+ "message_id": "message_1",
211
+ "answer": "Final answer",
212
+ },
213
+ )
214
+
215
+ with patch("scripts.api.stream_chat", fake_stream_chat):
216
+ with TestClient(app) as client:
217
+ with client.stream("POST", "/api/chat", json={"query": "Hello"}) as response:
218
+ body = "".join(response.iter_text())
219
+
220
+ self.assertEqual(response.status_code, 200)
221
+ payloads = parse_sse_payloads(body)
222
+ parts = [json.loads(item) for item in payloads[:-1]]
223
+ part_types = [part["type"] for part in parts]
224
+
225
+ self.assertEqual(part_types.count("reasoning-start"), 2)
226
+ self.assertEqual(part_types.count("reasoning-end"), 2)
227
+ self.assertLess(part_types.index("tool-input-start"), part_types.index("text-start"))
228
+
229
 
230
  if __name__ == "__main__":
231
  unittest.main()
tests/test_chat_service.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import types
4
+ import unittest
5
+ from unittest.mock import patch
6
+
7
+ from langchain_core.messages import AIMessage, HumanMessage
8
+
9
+ from scripts.chat_service import sync_thread_with_history
10
+ from scripts.chat_types import ChatTurn
11
+
12
+
13
+ class FakeAgent:
14
+ def __init__(self, messages):
15
+ self._messages = list(messages)
16
+ self.updated_states: list[tuple[dict[str, object], dict[str, object]]] = []
17
+
18
+ def get_state(self, _config):
19
+ return types.SimpleNamespace(values={"messages": list(self._messages)})
20
+
21
+ def update_state(self, config, payload):
22
+ self.updated_states.append((config, payload))
23
+
24
+
25
+ class ChatServiceTestCase(unittest.TestCase):
26
+ def test_shorter_history_branches_to_fresh_thread(self) -> None:
27
+ agent = FakeAgent(
28
+ [
29
+ HumanMessage(content="How do I create an agent?"),
30
+ AIMessage(content="Use a model and tools."),
31
+ ]
32
+ )
33
+
34
+ with patch("scripts.chat_service.new_thread_id", return_value="thread_regen"):
35
+ active_thread_id = sync_thread_with_history(agent, "thread_0", ())
36
+
37
+ self.assertEqual(active_thread_id, "thread_regen")
38
+ self.assertEqual(agent.updated_states, [])
39
+
40
+ def test_edited_history_restores_messages_into_branched_thread(self) -> None:
41
+ agent = FakeAgent(
42
+ [
43
+ HumanMessage(content="How do I create an agent?"),
44
+ AIMessage(content="Use a model and tools."),
45
+ ]
46
+ )
47
+ edited_history = (
48
+ ChatTurn(role="user", content="How do I create a RAG agent?"),
49
+ ChatTurn(role="assistant", content="Use retrieval and a model."),
50
+ )
51
+
52
+ with patch("scripts.chat_service.new_thread_id", return_value="thread_edit"):
53
+ active_thread_id = sync_thread_with_history(
54
+ agent,
55
+ "thread_0",
56
+ edited_history,
57
+ )
58
+
59
+ self.assertEqual(active_thread_id, "thread_edit")
60
+ self.assertEqual(len(agent.updated_states), 1)
61
+ config, payload = agent.updated_states[0]
62
+ self.assertEqual(config, {"configurable": {"thread_id": "thread_edit"}})
63
+ restored_messages = payload["messages"]
64
+ self.assertEqual(len(restored_messages), 2)
65
+ self.assertEqual(restored_messages[0].content, "How do I create a RAG agent?")
66
+ self.assertEqual(restored_messages[1].content, "Use retrieval and a model.")
67
+
68
+
69
+ if __name__ == "__main__":
70
+ unittest.main()