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 +6 -1
- README.md +47 -0
- frontend/.env.example +1 -0
- frontend/.env.local +1 -0
- frontend/app/globals.css +417 -0
- frontend/app/layout.tsx +21 -0
- frontend/app/page.tsx +5 -0
- frontend/components/chat-message.tsx +586 -0
- frontend/components/chat-shell.tsx +515 -0
- frontend/components/markdown-block.tsx +31 -0
- frontend/components/source-panel.tsx +158 -0
- frontend/components/source-sidebar.tsx +254 -0
- frontend/eslint.config.mjs +6 -0
- frontend/next-env.d.ts +6 -0
- frontend/next.config.mjs +14 -0
- frontend/postcss.config.mjs +7 -0
- scripts/api.py +121 -25
- scripts/chat_service.py +220 -8
- scripts/prompts.py +70 -14
- scripts/setup.py +12 -5
- tests/test_api.py +75 -5
- tests/test_chat_service.py +70 -0
.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
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
|
| 149 |
-
|
| 150 |
-
|
| 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":
|
| 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 |
-
|
| 283 |
-
def list_sources() -> dict[str, Any]:
|
| 284 |
defaults = set(DEFAULT_SELECTED_SOURCES_UI)
|
| 285 |
-
|
| 286 |
-
|
|
|
|
|
|
|
| 287 |
{
|
| 288 |
"label": label,
|
| 289 |
-
"key":
|
|
|
|
| 290 |
"selectedByDefault": label in defaults,
|
| 291 |
}
|
| 292 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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.
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
-
|
| 7 |
-
|
| 8 |
-
-
|
| 9 |
-
|
| 10 |
-
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 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
|
| 30 |
with TestClient(app) as client:
|
| 31 |
-
response = client.get("/api/
|
| 32 |
|
| 33 |
self.assertEqual(response.status_code, 200)
|
| 34 |
body = response.json()
|
| 35 |
-
self.assertIn("
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|