MSG commited on
Commit
81da2d5
·
1 Parent(s): 7a28b9f

Feat/UI ux opti wip (#10)

Browse files

* offbrand plan ui and ux

* wip studio html

* new server app gradio

* docker + gradio static space

* wip readme

* progress runner wip

* studio

* ui

* static stuff

* gradio space wip

* try fixx index

* ui

* index fix ui sources

* studio wip

* embeddings wip

* fix wip index and stuff

Files changed (37) hide show
  1. .cursor/plans/off_brand_studio_ui_5c8c7dff.plan.md +202 -0
  2. .env.example +2 -0
  3. Dockerfile +1 -0
  4. README.md +9 -0
  5. USAGE.md +8 -1
  6. apps/gradio-space/README.md +40 -1
  7. apps/gradio-space/src/gradio_space/__init__.py +1 -1
  8. apps/gradio-space/src/gradio_space/api/__init__.py +3 -0
  9. apps/gradio-space/src/gradio_space/api/serializers.py +42 -0
  10. apps/gradio-space/src/gradio_space/api/studio.py +565 -0
  11. apps/gradio-space/src/gradio_space/app.py +28 -32
  12. apps/gradio-space/src/gradio_space/research_helpers.py +27 -0
  13. apps/gradio-space/src/gradio_space/server.py +88 -0
  14. apps/gradio-space/src/gradio_space/tabs/chat.py +74 -15
  15. apps/gradio-space/src/gradio_space/tabs/education_pptx.py +173 -13
  16. apps/gradio-space/src/gradio_space/tabs/research_mind.py +73 -7
  17. apps/gradio-space/src/gradio_space/tabs/teacher_voice.py +91 -9
  18. apps/gradio-space/src/gradio_space/ui/components.py +80 -0
  19. apps/gradio-space/src/gradio_space/ui/studio_html.py +101 -0
  20. apps/gradio-space/src/gradio_space/ui/styles.css +122 -3
  21. apps/gradio-space/static/studio/index.html +268 -0
  22. apps/gradio-space/static/studio/studio.css +982 -0
  23. apps/gradio-space/static/studio/studio.js +724 -0
  24. libs/agent/src/agent/progress.py +206 -0
  25. libs/agent/src/agent/prompts.py +50 -3
  26. libs/agent/src/agent/runner.py +269 -23
  27. libs/agent/src/agent/trace.py +24 -0
  28. libs/agent/tests/test_runner.py +39 -2
  29. libs/inference/src/inference/device_utils.py +70 -0
  30. libs/inference/src/inference/factory.py +2 -0
  31. libs/inference/src/inference/llama_cpp.py +25 -7
  32. libs/inference/src/inference/response_clean.py +8 -0
  33. libs/inference/src/inference/transformers.py +128 -36
  34. libs/inference/tests/test_device_utils.py +20 -0
  35. libs/inference/tests/test_response_clean.py +10 -1
  36. libs/researchmind/src/researchmind/embeddings.py +71 -7
  37. outputs/traces/5ffe463cd9ff.json +0 -28
.cursor/plans/off_brand_studio_ui_5c8c7dff.plan.md ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Off Brand Studio UI
3
+ overview: Win the Off Brand track by serving a custom Photosynthesis "AI Studio" workspace at `/` via `gradio.Server`, while keeping the fully functional existing Gradio app at `/classic`. The Studio UI implements the mockup's shell (sidebar, top bar, 3-column workspace) wired to existing Python backends through `@app.api` endpoints.
4
+ todos:
5
+ - id: server-scaffold
6
+ content: "Create server.py: gradio.Server, static mount, mount build_demo() at /classic, update entrypoint"
7
+ status: completed
8
+ - id: api-wrappers
9
+ content: Add api/studio.py + serializers wrapping research_mind, education_pptx, teacher_voice, echo_coach functions
10
+ status: completed
11
+ - id: static-shell
12
+ content: Port mockup to static/studio/ (index.html, studio.css, studio.js) with M3 tokens and sidebar/topbar
13
+ status: completed
14
+ - id: research-column
15
+ content: "Wire left column: ingest URL/upload, document cards, RAG Active badge via list_documents API"
16
+ status: completed
17
+ - id: slides-column
18
+ content: "Wire center column: topic/grade/slider, generate_slides API, hero preview canvas, export links"
19
+ status: completed
20
+ - id: voice-coach-column
21
+ content: "Wire right column: TeacherVoice mode cards + turn API; EchoCoach post-analysis metrics panel"
22
+ status: completed
23
+ - id: polish-docs
24
+ content: Loading/error states, mobile sidebar, Classic cross-link, README Off Brand section + demo script
25
+ status: completed
26
+ isProject: false
27
+ ---
28
+
29
+ # Off Brand Studio UI (Hybrid gr.Server)
30
+
31
+ ## Goal
32
+
33
+ Judges for **Off Brand** reward UIs that clearly escape default Gradio chrome. Your mockup is the right vision; the winning implementation is **not** pasting that HTML into `gr.HTML` inside Blocks — it is **`gradio.Server` serving a real custom frontend** with Gradio's queue/API engine underneath ([Gradio Server mode blog](https://huggingface.co/blog/introducing-gradio-server)).
34
+
35
+ **Strategy:** One polished **Studio demo surface** at `/` + **full feature parity** at `/classic`.
36
+
37
+ ```mermaid
38
+ flowchart LR
39
+ subgraph client [Browser]
40
+ StudioUI["/ Studio HTML+JS"]
41
+ ClassicUI["/classic gr.Blocks"]
42
+ end
43
+ subgraph server [gradio.Server]
44
+ Static["GET / static assets"]
45
+ APIs["@app.api studio endpoints"]
46
+ Mount["mount_gradio_app /classic"]
47
+ end
48
+ subgraph backends [Existing Python]
49
+ RM["research_mind + research_helpers"]
50
+ PPTX["education_pptx.generate_lesson_slides"]
51
+ TV["teacher_voice.run_teacher_voice_turn"]
52
+ EC["echo_coach.run_echo_coach"]
53
+ end
54
+ StudioUI --> Static
55
+ StudioUI --> APIs
56
+ ClassicUI --> Mount
57
+ APIs --> RM
58
+ APIs --> PPTX
59
+ APIs --> TV
60
+ APIs --> EC
61
+ ```
62
+
63
+ ## What to steal from the mockup (and what to skip)
64
+
65
+ | Keep | Skip (fake SaaS chrome) |
66
+ |------|-------------------------|
67
+ | Fixed sidebar + top app bar | Publish, notifications, Free Tier profile |
68
+ | 3-column workspace (3/6/3 grid) | Global "Search Studio" (unless wired to doc filter) |
69
+ | M3 tokens + Hanken Grotesk + Material icons | Live EchoCoach waveform while editing slides |
70
+ | Indexed document cards with status chips | Undo/redo on slides (no backend yet) |
71
+ | Center hero slide preview canvas | Tailwind CDN in production (bundle CSS instead) |
72
+ | Teacher Voice mode cards | "New Project" unless backed by session creation |
73
+
74
+ Replace decorative chrome with **real actions**: **Export downloads**, **Open Classic**, **Settings** (links to `/classic` settings accordion or a minimal model-status drawer).
75
+
76
+ ## Architecture
77
+
78
+ ### New entrypoint
79
+
80
+ Replace [`apps/gradio-space/src/gradio_space/app.py`](apps/gradio-space/src/gradio_space/app.py) launch path with a new [`apps/gradio-space/src/gradio_space/server.py`](apps/gradio-space/src/gradio_space/server.py):
81
+
82
+ 1. `server = gradio.Server()`
83
+ 2. Mount static files: `apps/gradio-space/static/studio/`
84
+ 3. `@server.get("/")` → `index.html`
85
+ 4. Register `@server.api(...)` handlers (thin wrappers — no business logic duplication)
86
+ 5. `mount_gradio_app(server, build_demo(), path="/classic", css=load_css(), theme=get_theme(), allowed_paths=[...])`
87
+ 6. `server.launch(...)` with same env vars (`PORT`, `GRADIO_SERVER_NAME`, allowed paths)
88
+
89
+ Keep `build_demo()` unchanged in structure; Classic remains the escape hatch for Chat (debug), Advanced trace, and edge-case flows.
90
+
91
+ ### API layer (reuse existing functions)
92
+
93
+ Create [`apps/gradio-space/src/gradio_space/api/studio.py`](apps/gradio-space/src/gradio_space/api/studio.py) exposing JSON-friendly endpoints by calling existing tab logic:
94
+
95
+ | Endpoint | Wraps | Returns |
96
+ |----------|-------|---------|
97
+ | `list_sessions` | `list_session_choices()` | `{sessions: [{id, label, topic}]}` |
98
+ | `list_documents` | `IngestPipeline().store.list_documents` | `{documents: [{id, title, source_type, uri, status}]}` |
99
+ | `ingest_url` / `ingest_upload` | `ingest_selected` from [`research_mind.py`](apps/gradio-space/src/gradio_space/tabs/research_mind.py) | `{status, session_id, documents}` |
100
+ | `discover_sources` | `discover_sources` | `{urls, session_id, status}` |
101
+ | `generate_slides` | `generate_lesson_slides` from [`education_pptx.py`](apps/gradio-space/src/gradio_space/tabs/education_pptx.py) | `{preview_html, gallery_paths, downloads, outline_md, status}` |
102
+ | `teacher_voice_turn` | `run_teacher_voice_turn` / text turn | `{history, status, audio_path?}` |
103
+ | `analyze_pitch` | `analyze_pitch` from [`echo_coach.py`](apps/gradio-space/src/gradio_space/tabs/echo_coach.py) | `{transcript_html, report_md, charts, voiceout_path, metrics}` |
104
+ | `model_status` | existing settings panel helpers | `{model_key, backend, loaded}` |
105
+
106
+ Add small **response serializers** in [`apps/gradio-space/src/gradio_space/api/serializers.py`](apps/gradio-space/src/gradio_space/api/serializers.py) to convert Gradio-style tuples / `gr.update` outputs into plain dicts.
107
+
108
+ Add **HTML render helpers** in [`apps/gradio-space/src/gradio_space/ui/studio_html.py`](apps/gradio-space/src/gradio_space/ui/studio_html.py):
109
+ - `render_doc_card(doc)` — matches mock indexed-document rows
110
+ - `render_slide_canvas(preview_html)` — wraps existing slide preview HTML in canvas chrome
111
+ - `render_echo_coach_panel(result)` — dark metrics panel (post-analysis, not fake live listening)
112
+
113
+ ### Custom frontend files
114
+
115
+ Port mockup into [`apps/gradio-space/static/studio/`](apps/gradio-space/static/studio/):
116
+
117
+ - `index.html` — structure only (sidebar, header, 3 columns)
118
+ - `studio.css` — M3 tokens from mock (`primary: #a83300`, surfaces, typography); **no Tailwind CDN** — extract needed utilities to ~300–400 lines CSS
119
+ - `studio.js` — fetch `@app.api` endpoints via Gradio client or direct `/call/{api_name}` routes; handle loading states, errors, file upload (multipart)
120
+
121
+ **Sidebar navigation:** client-side view modes (Research / Slides / Voice / Coach) that show/hide or emphasize columns — not separate backend routes. Default landing: **Slides** center-focused with left rail visible (matches mock).
122
+
123
+ **Demo seed:** on load, if a session titled "Photosynthesis" exists, select it; otherwise create/use topic placeholder "Photosynthesis for 6th Grade" to match mock copy.
124
+
125
+ ## Visual design alignment
126
+
127
+ Update design tokens to align mock + existing CSS:
128
+
129
+ - Primary CTA: `#a83300` (mock) — update [`styles.css`](apps/gradio-space/src/gradio_space/ui/styles.css) `.primary-cta` from `#e86c00` OR pick one orange and use everywhere
130
+ - Font: Hanken Grotesk via Google Fonts in Studio `index.html` `<head>`; Classic can stay Inter
131
+ - Icons: Material Symbols Outlined (same as mock)
132
+ - Cards: white surface, `border-outline-variant`, `rounded-xl`, subtle shadow — match mock panels
133
+
134
+ Classic `/classic` gets a **minimal header link** ("Open Studio UI") so judges can compare.
135
+
136
+ ## UX flows (Studio `/`)
137
+
138
+ ### Flow A — Hackathon demo script (2 minutes)
139
+
140
+ 1. Land on Studio → breadcrumb **Projects → Photosynthesis**
141
+ 2. Left: paste Wikipedia URL → **Ingest** → doc cards populate, **RAG Active** badge
142
+ 3. Center: topic + grade 6 + slide slider → **Generate Slides** → hero preview fills canvas
143
+ 4. Right: select **Coach** mode → record/send voice turn about the equation slide
144
+ 5. Optional: switch sidebar to **Coach** → run EchoCoach analyze → metrics panel populates (real data, not animated placeholder)
145
+
146
+ ### Flow B — Power users
147
+
148
+ - Sidebar **Settings** → `/classic` (model reload, trace, Chat debug)
149
+ - Download PPTX/DOCX from generate response
150
+
151
+ ## Gradio Server specifics (Off Brand differentiator)
152
+
153
+ Document in [`apps/gradio-space/README.md`](apps/gradio-space/README.md):
154
+
155
+ - Entry: `python -m gradio_space.server`
156
+ - Custom UI at `/`, Gradio Blocks at `/classic`
157
+ - List `@app.api` names for programmatic use (shows `gradio_client` compatibility — bonus points)
158
+
159
+ Use `@server.api(name="...")` for all long-running calls (`generate_slides`, `analyze_pitch`, ingest) so requests get **queuing + progress** automatically.
160
+
161
+ Set `footer_links=[]` on Studio launch; Classic can keep minimal footer or empty as well.
162
+
163
+ ## File change summary
164
+
165
+ | File | Action |
166
+ |------|--------|
167
+ | `server.py` | **New** — Server entry, static mount, API registration, Classic mount |
168
+ | `api/studio.py`, `api/serializers.py` | **New** — API wrappers |
169
+ | `static/studio/index.html`, `studio.css`, `studio.js` | **New** — Custom frontend |
170
+ | `ui/studio_html.py` | **New** — Server-side HTML fragments |
171
+ | `ui/design_tokens.css` | **New** — shared token file imported by Studio CSS |
172
+ | `app.py` | **Minor** — extract `build_demo()` only; move `main()` to `server.py` |
173
+ | `README.md` | **Update** — Studio vs Classic, demo script, Off Brand notes |
174
+ | Root `README.md` | **One paragraph** — screenshot of Studio UI for judges |
175
+
176
+ ## Risk controls
177
+
178
+ - **Scope guard:** Studio implements the 4 sidebar modes as **one page** with column emphasis — do not rewrite Chat (debug) in custom UI
179
+ - **Fallback:** if any Studio API fails, show error banner + link to equivalent `/classic` tab
180
+ - **HF Space:** static assets served from repo (no CDN dependency); confirm `allowed_paths` for previews/downloads unchanged
181
+ - **EchoCoach honesty:** metrics panel shows **after analyze**, label as "Analysis results" — not fake "Listening..." unless mic is actively recording
182
+
183
+ ## Verification checklist
184
+
185
+ - [ ] `/` loads custom UI with no default Gradio tab bar visible
186
+ - [ ] `/classic` still runs all 5 tabs with existing behavior
187
+ - [ ] Ingest → document list → generate slides → preview works end-to-end on Photosynthesis topic
188
+ - [ ] TeacherVoice turn works from Studio right panel
189
+ - [ ] EchoCoach analyze populates real pitch/pace metrics
190
+ - [ ] Mobile: sidebar collapses to hamburger; single-column stack below 1024px
191
+ - [ ] README documents `gr.Server` architecture for judges
192
+
193
+ ## Implementation order
194
+
195
+ 1. **Server scaffold** — `server.py`, static mount, `/classic` mount, smoke launch
196
+ 2. **Design tokens + HTML shell** — visual parity with mock (non-functional)
197
+ 3. **Research APIs + left column** — sessions, ingest, doc cards
198
+ 4. **Slides API + center column** — generate + preview + downloads
199
+ 5. **Voice/Coach APIs + right column** — mode cards, recording upload, EchoCoach panel
200
+ 6. **Polish** — loading skeletons, error states, demo seed, README/screenshot
201
+
202
+ Estimated effort: **1.5–2 days** for a judge-ready Studio demo with Classic fallback.
.env.example CHANGED
@@ -12,6 +12,8 @@ ALLOW_MODEL_SWITCH=false
12
  # --- ResearchMind (MemRAG + scraper) ---
13
  # RESEARCHMIND_DATA_DIR=outputs/researchmind
14
  # RESEARCHMIND_EMBED_MODEL=all-MiniLM-L6-v2
 
 
15
  # RESEARCHMIND_AUTO_SEARCH=false
16
  # RESEARCHMIND_TOP_K=5
17
  # RESEARCHMIND_CHUNK_SIZE=512
 
12
  # --- ResearchMind (MemRAG + scraper) ---
13
  # RESEARCHMIND_DATA_DIR=outputs/researchmind
14
  # RESEARCHMIND_EMBED_MODEL=all-MiniLM-L6-v2
15
+ # RESEARCHMIND_EMBED_DEVICE=cpu
16
+ # INFERENCE_DEVICE=auto
17
  # RESEARCHMIND_AUTO_SEARCH=false
18
  # RESEARCHMIND_TOP_K=5
19
  # RESEARCHMIND_CHUNK_SIZE=512
Dockerfile CHANGED
@@ -21,6 +21,7 @@ COPY libs/inference/pyproject.toml libs/inference/README.md libs/inference/
21
  COPY libs/agent/pyproject.toml libs/agent/README.md libs/agent/
22
  COPY libs/echocoach/pyproject.toml libs/echocoach/README.md libs/echocoach/
23
  COPY apps/gradio-space/src apps/gradio-space/src
 
24
  COPY libs/inference/src libs/inference/src
25
  COPY libs/agent/src libs/agent/src
26
  COPY libs/echocoach/src libs/echocoach/src
 
21
  COPY libs/agent/pyproject.toml libs/agent/README.md libs/agent/
22
  COPY libs/echocoach/pyproject.toml libs/echocoach/README.md libs/echocoach/
23
  COPY apps/gradio-space/src apps/gradio-space/src
24
+ COPY apps/gradio-space/static apps/gradio-space/static
25
  COPY libs/inference/src libs/inference/src
26
  COPY libs/agent/src libs/agent/src
27
  COPY libs/echocoach/src libs/echocoach/src
README.md CHANGED
@@ -34,6 +34,15 @@ uv run --package gradio-space python -m gradio_space.app
34
 
35
  Open [http://localhost:7860](http://localhost:7860).
36
 
 
 
 
 
 
 
 
 
 
37
  - **Lesson slides** — topic, grade, slide count → downloadable PowerPoint
38
  - **Research Agent** — scrape/index sources into MemRAG, then ask questions offline with citations
39
 
 
34
 
35
  Open [http://localhost:7860](http://localhost:7860).
36
 
37
+ ### Studio UI (Off Brand track)
38
+
39
+ The default landing page is a **custom AI Studio workspace** at `/` — not default Gradio chrome. It uses **Gradio 6 Server mode** (`gradio.Server`): Material 3 layout, sidebar + three-column workspace (Research → Slides → Voice/Coach), and `@server.api` endpoints wired to the same Python backends as Classic.
40
+
41
+ - **`/`** — Studio UI (ingest sources, generate slides, TeacherVoice, EchoCoach)
42
+ - **`/classic`** — full Gradio Blocks app (all tabs, settings, Chat debug)
43
+
44
+ See [apps/gradio-space/README.md](apps/gradio-space/README.md) for API names and a 2-minute judge demo script.
45
+
46
  - **Lesson slides** — topic, grade, slide count → downloadable PowerPoint
47
  - **Research Agent** — scrape/index sources into MemRAG, then ask questions offline with citations
48
 
USAGE.md CHANGED
@@ -49,6 +49,13 @@ uv run --package gradio-space python -m gradio_space.app
49
 
50
  Open [http://localhost:7860](http://localhost:7860).
51
 
 
 
 
 
 
 
 
52
  The model loads on the **first Generate** (Lesson slides) or chat message. Agent traces are written to `outputs/traces/`. After code changes, restart the process to pick up updates.
53
 
54
  ### Lesson slides — research sources
@@ -230,7 +237,7 @@ docker run --rm -p 7860:7860 \
230
  hackathon-space
231
  ```
232
 
233
- Open [http://localhost:7860](http://localhost:7860). Stop with `Ctrl+C`.
234
 
235
  To use a pre-downloaded local model inside Docker, mount it and set `MODEL_PATH`:
236
 
 
49
 
50
  Open [http://localhost:7860](http://localhost:7860).
51
 
52
+ | URL | UI |
53
+ |-----|-----|
54
+ | `/` | **Studio** — custom HTML/CSS/JS workspace (Off Brand entry) |
55
+ | `/classic` | **Classic** — full Gradio tabs, settings, Chat (debug) |
56
+
57
+ The header in Classic includes a link back to Studio UI.
58
+
59
  The model loads on the **first Generate** (Lesson slides) or chat message. Agent traces are written to `outputs/traces/`. After code changes, restart the process to pick up updates.
60
 
61
  ### Lesson slides — research sources
 
237
  hackathon-space
238
  ```
239
 
240
+ Open [http://localhost:7860](http://localhost:7860) — Studio at `/`, Classic tabs at `/classic`. Stop with `Ctrl+C`.
241
 
242
  To use a pre-downloaded local model inside Docker, mount it and set `MODEL_PATH`:
243
 
apps/gradio-space/README.md CHANGED
@@ -1,5 +1,44 @@
1
  # gradio-space
2
 
3
- Gradio chat UI package for the hackathon Space. Entrypoint: `python -m gradio_space.app`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  Space card metadata lives in the [repository root README.md](../../README.md).
 
1
  # gradio-space
2
 
3
+ Build Small hackathon UI custom **Studio** frontend plus Classic Gradio tabs.
4
+
5
+ ## Entry points
6
+
7
+ | URL | What |
8
+ |-----|------|
9
+ | `/` | **Studio UI** — custom HTML/CSS/JS served via `gradio.Server` |
10
+ | `/classic` | Full Gradio Blocks app (all tabs, settings, debug) |
11
+
12
+ ```bash
13
+ uv run --package gradio-space python -m gradio_space.server
14
+ # or
15
+ uv run --package gradio-space python -m gradio_space.app
16
+ ```
17
+
18
+ ## Off Brand architecture
19
+
20
+ This package uses **Gradio 6 Server mode** (`gradio.Server`):
21
+
22
+ - Custom routes: `GET /`, static assets at `/static/studio/`
23
+ - API endpoints via `@server.api(name=...)` — callable from `@gradio/client` and `gradio_client`
24
+ - Classic UI mounted with `mount_gradio_app(..., path="/classic")`
25
+
26
+ ### Studio API names
27
+
28
+ - `list_sessions`, `list_documents`
29
+ - `ingest_url`, `ingest_files`, `save_upload`
30
+ - `generate_slides`
31
+ - `teacher_voice_turn`
32
+ - `analyze_pitch`
33
+ - `model_status`
34
+
35
+ ## Demo script (judges)
36
+
37
+ 1. Open `/` — Photosynthesis project workspace
38
+ 2. Paste a URL in Research → **Ingest URL** → documents appear with **RAG Active**
39
+ 3. Center column → **Generate Slides** → slide preview canvas fills
40
+ 4. Right column → Teacher Voice **Coach** mode → send a question
41
+ 5. Coach view → upload/record audio → **Analyze pitch** for EchoCoach metrics
42
+ 6. Fallback: `/classic` for Chat (debug), traces, and model settings
43
 
44
  Space card metadata lives in the [repository root README.md](../../README.md).
apps/gradio-space/src/gradio_space/__init__.py CHANGED
@@ -2,6 +2,6 @@ __all__ = ["main"]
2
 
3
 
4
  def main() -> None:
5
- from gradio_space.app import main as _main
6
 
7
  _main()
 
2
 
3
 
4
  def main() -> None:
5
+ from gradio_space.server import main as _main
6
 
7
  _main()
apps/gradio-space/src/gradio_space/api/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from gradio_space.api.studio import register_studio_apis
2
+
3
+ __all__ = ["register_studio_apis"]
apps/gradio-space/src/gradio_space/api/serializers.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def unwrap_update(value: Any) -> Any:
7
+ """Extract payload from gr.update() return values when calling tab handlers directly."""
8
+ if value is None:
9
+ return None
10
+ if isinstance(value, dict):
11
+ return value
12
+ if hasattr(value, "model_dump"):
13
+ return value.model_dump(exclude_none=True)
14
+ if hasattr(value, "to_dict"):
15
+ return value.to_dict()
16
+ return value
17
+
18
+
19
+ def update_value(value: Any, default: Any = "") -> Any:
20
+ payload = unwrap_update(value)
21
+ if isinstance(payload, dict) and "value" in payload:
22
+ return payload["value"]
23
+ return payload if payload is not None else default
24
+
25
+
26
+ def update_choices(value: Any) -> list[Any]:
27
+ payload = unwrap_update(value)
28
+ if isinstance(payload, dict):
29
+ return list(payload.get("choices") or [])
30
+ return []
31
+
32
+
33
+ def ok(data: dict[str, Any] | None = None, **kwargs: Any) -> dict[str, Any]:
34
+ out: dict[str, Any] = {"ok": True}
35
+ if data:
36
+ out.update(data)
37
+ out.update(kwargs)
38
+ return out
39
+
40
+
41
+ def err(message: str, **kwargs: Any) -> dict[str, Any]:
42
+ return {"ok": False, "error": message, **kwargs}
apps/gradio-space/src/gradio_space/api/studio.py ADDED
@@ -0,0 +1,565 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import re
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+
9
+ import gradio as gr
10
+
11
+ from echocoach.config import get_echo_coach_config
12
+ from echocoach.pipeline import run_echo_coach
13
+ from echocoach.prompts import TeacherVoiceMode
14
+ from echocoach.teacher_voice import RAG_MODES, run_teacher_voice_text_turn
15
+ from gradio_space.api.serializers import err, ok, unwrap_update, update_value
16
+ from gradio_space.model_loading import ensure_model_loaded, get_active_model_key, model_status
17
+ from gradio_space.research_helpers import list_session_choices, pick_session_for_topic
18
+ from gradio_space.tabs.education_pptx import generate_lesson_slides
19
+ from gradio_space.tabs.research_mind import (
20
+ ask_question,
21
+ auto_search_ingest,
22
+ discover_sources,
23
+ ingest_selected,
24
+ )
25
+ from gradio_space.ui.studio_html import (
26
+ render_doc_cards,
27
+ render_echo_coach_panel,
28
+ render_slide_canvas,
29
+ )
30
+ from inference.factory import get_backend
31
+ from researchmind.ingest import IngestPipeline
32
+
33
+ _echo_config = get_echo_coach_config()
34
+
35
+
36
+ class _NoopProgress:
37
+ def __call__(self, *args: Any, **kwargs: Any) -> None:
38
+ return None
39
+
40
+ def tqdm(self, iterable: Any, **kwargs: Any) -> Any:
41
+ return iterable
42
+
43
+
44
+ def _elapsed_seconds_from_log(processing_log: str) -> float | None:
45
+ match = re.search(r"Elapsed:\s*([\d.]+)s", processing_log or "")
46
+ if not match:
47
+ match = re.search(r"\*\*Elapsed:\*\* ([\d.]+)s", processing_log or "")
48
+ if not match:
49
+ return None
50
+ return float(match.group(1))
51
+
52
+
53
+ def _progress_from_trace(trace_json: str) -> dict[str, Any]:
54
+ import json
55
+
56
+ try:
57
+ trace = json.loads(trace_json)
58
+ except json.JSONDecodeError:
59
+ return {"steps": []}
60
+ steps = []
61
+ for step in trace.get("steps", []):
62
+ if step.get("type") != "step":
63
+ continue
64
+ duration_ms = step.get("duration_ms")
65
+ steps.append(
66
+ {
67
+ "name": step.get("name"),
68
+ "label": step.get("label"),
69
+ "detail": step.get("detail", ""),
70
+ "duration_s": round(duration_ms / 1000, 1) if duration_ms is not None else None,
71
+ "status": "done",
72
+ }
73
+ )
74
+ return {"steps": steps}
75
+
76
+
77
+ def _doc_meta(doc: Any) -> str:
78
+ uri = str(doc.uri or "")
79
+ if len(uri) > 48:
80
+ uri = uri[:45] + "…"
81
+ return f"{doc.source_type} · {uri}"
82
+
83
+
84
+ def _documents_payload(session_id: str) -> list[dict[str, Any]]:
85
+ store = IngestPipeline().store
86
+ docs = store.list_documents(session_id=session_id or None)
87
+ return [
88
+ {
89
+ "id": d.id,
90
+ "title": d.title,
91
+ "source_type": d.source_type,
92
+ "uri": d.uri,
93
+ "meta": _doc_meta(d),
94
+ }
95
+ for d in docs
96
+ ]
97
+
98
+
99
+ def _session_has_rag_sources(session_id: str, doc_ids: list[str] | None) -> bool:
100
+ if not session_id:
101
+ return False
102
+ docs = _documents_payload(session_id)
103
+ if not docs:
104
+ return False
105
+ if doc_ids:
106
+ valid = {d["id"] for d in docs}
107
+ return any(doc_id in valid for doc_id in doc_ids)
108
+ return True
109
+
110
+
111
+ def _sessions_payload() -> list[dict[str, str]]:
112
+ sessions: list[dict[str, str]] = []
113
+ for label, sid in list_session_choices():
114
+ if sid == "":
115
+ continue
116
+ topic = label.split(" (")[0] if " (" in label else label
117
+ sessions.append({"id": sid, "label": label, "topic": topic})
118
+ return sessions
119
+
120
+
121
+ def _pick_session(topic_hint: str = "") -> str:
122
+ return pick_session_for_topic(topic_hint)
123
+
124
+
125
+ def api_list_sessions() -> dict[str, Any]:
126
+ return ok(sessions=_sessions_payload())
127
+
128
+
129
+ def api_list_documents(session_id: str = "") -> dict[str, Any]:
130
+ docs = _documents_payload(session_id)
131
+ html_cards = render_doc_cards(docs, rag_active=bool(docs))
132
+ return ok(session_id=session_id, documents=docs, documents_html=html_cards)
133
+
134
+
135
+ def _ingest_response(
136
+ status: str,
137
+ session_id: str,
138
+ trace_json: str = "",
139
+ trace_summary: str = "",
140
+ ) -> dict[str, Any]:
141
+ sid = session_id or ""
142
+ docs = _documents_payload(sid)
143
+ return ok(
144
+ status=status,
145
+ session_id=sid,
146
+ documents=docs,
147
+ documents_html=render_doc_cards(docs, rag_active=bool(docs)),
148
+ trace_json=trace_json,
149
+ trace_summary=trace_summary,
150
+ )
151
+
152
+
153
+ def api_discover_sources(topic: str, session_id: str = "") -> dict[str, Any]:
154
+ if not (topic or "").strip():
155
+ return err("Enter a workspace topic before discovering sources.")
156
+ summary, url_up, sess_up, trace_sum, trace_json, _memory, _doc_up, _acc_up = discover_sources(
157
+ topic,
158
+ session_id,
159
+ "",
160
+ "",
161
+ _NoopProgress(),
162
+ )
163
+ url_payload = unwrap_update(url_up)
164
+ urls = list(url_payload.get("choices") or []) if isinstance(url_payload, dict) else []
165
+ selected = list(url_payload.get("value") or urls) if isinstance(url_payload, dict) else urls
166
+ sid = update_value(sess_up, session_id)
167
+ if summary and "error" in summary.lower() and not urls:
168
+ return err(strip_md_summary(summary), status=summary, urls=[], session_id=sid)
169
+ return ok(
170
+ status=summary,
171
+ urls=urls,
172
+ selected_urls=selected,
173
+ session_id=sid,
174
+ trace_summary=trace_sum,
175
+ trace_json=trace_json if isinstance(trace_json, str) else "",
176
+ )
177
+
178
+
179
+ def api_auto_search_ingest(topic: str, session_id: str = "") -> dict[str, Any]:
180
+ if not (topic or "").strip():
181
+ return err("Enter a workspace topic before auto-ingest.")
182
+ status, _url_up, sess_up, trace_sum, trace_json, _memory, _doc_up, _acc_up = auto_search_ingest(
183
+ topic,
184
+ session_id,
185
+ "",
186
+ "",
187
+ _NoopProgress(),
188
+ )
189
+ sid = update_value(sess_up, session_id)
190
+ if status and "error" in status.lower() and "ingested" not in status.lower():
191
+ return err(strip_md_summary(status), status=status, session_id=sid)
192
+ return _ingest_response(status, sid, trace_json=str(trace_json or ""), trace_summary=trace_sum)
193
+
194
+
195
+ def api_ingest_sources(
196
+ topic: str,
197
+ session_id: str = "",
198
+ urls_text: str = "",
199
+ selected_urls: list[str] | None = None,
200
+ file_paths: list[str] | None = None,
201
+ ) -> dict[str, Any]:
202
+ has_urls = bool((urls_text or "").strip() or (selected_urls or []))
203
+ has_files = bool(file_paths)
204
+ if not has_urls and not has_files:
205
+ return err("Add URLs, select suggested sources, or upload a file — then ingest.")
206
+ status, _memory, trace_json, trace_sum, sess_up, _doc_up = ingest_selected(
207
+ topic,
208
+ urls_text,
209
+ selected_urls or [],
210
+ file_paths,
211
+ session_id or None,
212
+ "",
213
+ "",
214
+ _NoopProgress(),
215
+ )
216
+ sid = update_value(sess_up, session_id)
217
+ if status and "error" in status.lower() and "ingested" not in status.lower():
218
+ return err(strip_md_summary(status), status=status, session_id=sid)
219
+ return _ingest_response(status, sid, trace_json=str(trace_json or ""), trace_summary=trace_sum)
220
+
221
+
222
+ def strip_md_summary(text: str) -> str:
223
+ return re.sub(r"\*\*", "", str(text or "")).strip()
224
+
225
+
226
+ def api_ingest_url(topic: str, url: str, session_id: str = "") -> dict[str, Any]:
227
+ if not url.strip():
228
+ return err("Paste a URL to ingest.")
229
+ return api_ingest_sources(topic, session_id, urls_text=url.strip())
230
+
231
+
232
+ def api_ingest_files(
233
+ topic: str,
234
+ session_id: str,
235
+ file_paths: list[str],
236
+ ) -> dict[str, Any]:
237
+ if not file_paths:
238
+ return err("Upload at least one PDF or DOCX file.")
239
+ return api_ingest_sources(topic, session_id, file_paths=file_paths)
240
+
241
+
242
+ def api_research_chat(
243
+ question: str,
244
+ session_id: str = "",
245
+ doc_ids: list[str] | None = None,
246
+ history: list[dict[str, str]] | None = None,
247
+ ) -> dict[str, Any]:
248
+ if not question.strip():
249
+ return err("Enter a question.")
250
+ hist, trace_json, trace_sum, rag_hint, _cleared = ask_question(
251
+ question,
252
+ session_id,
253
+ doc_ids or [],
254
+ history or [],
255
+ "",
256
+ doc_ids or [],
257
+ _NoopProgress(),
258
+ )
259
+ assistant = ""
260
+ for msg in reversed(hist or []):
261
+ if msg.get("role") == "assistant":
262
+ assistant = str(msg.get("content") or "")
263
+ break
264
+ return ok(
265
+ history=hist,
266
+ assistant=assistant,
267
+ rag_hint=rag_hint,
268
+ trace_json=trace_json if isinstance(trace_json, str) else "",
269
+ trace_summary=trace_sum,
270
+ )
271
+
272
+
273
+ def api_generate_slides(
274
+ topic: str,
275
+ grade: str = "6",
276
+ slide_count: int = 5,
277
+ session_id: str = "",
278
+ use_rag: bool = True,
279
+ doc_ids: list[str] | None = None,
280
+ ) -> dict[str, Any]:
281
+ rag_docs = doc_ids or []
282
+ sid = (session_id or "").strip()
283
+ if use_rag and not sid:
284
+ sid = _pick_session(topic)
285
+
286
+ has_sources = _session_has_rag_sources(sid, rag_docs) if use_rag else False
287
+ use_rag_effective = bool(use_rag and has_sources)
288
+ rag_notice = ""
289
+ if use_rag and not use_rag_effective:
290
+ rag_notice = (
291
+ "Cross-Reference Sources is on, but this session has no indexed documents — "
292
+ "generated from model knowledge only. Ingest sources in Step 1 to enable RAG."
293
+ )
294
+
295
+ source_label = "RAG (indexed sources)" if use_rag_effective else "None (model only)"
296
+ workflow_label = "Two-step (discover & confirm)"
297
+ effective_sid = sid if use_rag_effective else ""
298
+ effective_docs = rag_docs if use_rag_effective else []
299
+
300
+ gen = generate_lesson_slides(
301
+ topic,
302
+ grade,
303
+ int(slide_count),
304
+ source_label,
305
+ workflow_label,
306
+ "",
307
+ [],
308
+ None,
309
+ effective_sid,
310
+ effective_docs,
311
+ topic,
312
+ effective_sid,
313
+ effective_docs,
314
+ _NoopProgress(),
315
+ skip_preview_images=True,
316
+ )
317
+ last: tuple | None = None
318
+ for item in gen:
319
+ last = item
320
+ if last is None:
321
+ return err("Generation failed before producing output.")
322
+
323
+ (
324
+ outline_md,
325
+ preview_html,
326
+ gallery,
327
+ pptx,
328
+ docx,
329
+ html_export,
330
+ processing_log,
331
+ trace_sum,
332
+ trace_json,
333
+ status,
334
+ ) = last
335
+
336
+ if preview_html and "form-error" in preview_html:
337
+ return err(status or "Generation failed.", status=status, progress_log=processing_log)
338
+
339
+ if rag_notice:
340
+ status = f"{rag_notice}\n\n{status or 'Slides generated.'}".strip()
341
+
342
+ downloads = {
343
+ "pptx": pptx,
344
+ "docx": docx,
345
+ "html": html_export,
346
+ }
347
+ return ok(
348
+ topic=topic,
349
+ session_id=sid,
350
+ outline_md=outline_md,
351
+ preview_html=preview_html,
352
+ canvas_html=render_slide_canvas(preview_html),
353
+ gallery=gallery or [],
354
+ downloads=downloads,
355
+ status=status,
356
+ rag_fallback=bool(rag_notice),
357
+ progress_log=processing_log,
358
+ trace_summary=trace_sum,
359
+ trace_json=trace_json,
360
+ elapsed_seconds=_elapsed_seconds_from_log(processing_log),
361
+ progress=_progress_from_trace(trace_json),
362
+ )
363
+
364
+
365
+ def api_teacher_voice_turn(
366
+ message: str,
367
+ mode: TeacherVoiceMode = "lesson",
368
+ topic: str = "",
369
+ session_id: str = "",
370
+ use_rag: bool = True,
371
+ history: list[list[str]] | None = None,
372
+ doc_ids: list[str] | None = None,
373
+ ) -> dict[str, Any]:
374
+ model_key = get_active_model_key()
375
+ load_error = ensure_model_loaded(model_key)
376
+ if load_error:
377
+ return err(load_error)
378
+
379
+ if not message.strip():
380
+ return err("Enter a message or record audio first.")
381
+
382
+ hist = history or []
383
+ try:
384
+ result = run_teacher_voice_text_turn(
385
+ message.strip(),
386
+ hist,
387
+ mode=mode,
388
+ language=_echo_config.language_choices()[0][1],
389
+ topic=topic.strip() or None,
390
+ backend=get_backend(model_key),
391
+ use_rag=use_rag and mode in RAG_MODES,
392
+ session_id=session_id or None,
393
+ doc_ids=doc_ids or None,
394
+ )
395
+ except Exception as exc: # noqa: BLE001
396
+ return err(str(exc))
397
+
398
+ return ok(
399
+ history=result.history,
400
+ assistant=result.assistant_text,
401
+ status=result.rag_status or "Turn complete.",
402
+ voiceout_path=result.voiceout_path,
403
+ )
404
+
405
+
406
+ def api_analyze_pitch(
407
+ audio_path: str,
408
+ language: str = "en",
409
+ asr_preset: str | None = None,
410
+ ) -> dict[str, Any]:
411
+ model_key = get_active_model_key()
412
+ load_error = ensure_model_loaded(model_key)
413
+ if load_error:
414
+ return err(load_error)
415
+
416
+ if not audio_path or not Path(audio_path).is_file():
417
+ return err("Record or upload audio before analyzing.")
418
+
419
+ preset = asr_preset or _echo_config.asr_preset
420
+ try:
421
+ result = run_echo_coach(
422
+ audio_path,
423
+ language=language,
424
+ asr_preset=preset,
425
+ backend=get_backend(model_key),
426
+ speak_rewrite=False,
427
+ )
428
+ except Exception as exc: # noqa: BLE001
429
+ return err(str(exc))
430
+
431
+ panel = render_echo_coach_panel(
432
+ pace_score=result.pace.score,
433
+ wpm=result.pace.wpm,
434
+ tip=result.coach.one_tip,
435
+ report_md=result.report_markdown,
436
+ )
437
+ return ok(
438
+ transcript_html=result.transcript_html,
439
+ report_md=result.report_markdown,
440
+ pace_score=result.pace.score,
441
+ wpm=result.pace.wpm,
442
+ tip=result.coach.one_tip,
443
+ filler_chart=result.filler_chart_path,
444
+ pace_chart=result.pace_chart_path,
445
+ voiceout_path=result.voiceout_path,
446
+ coach_panel_html=panel,
447
+ )
448
+
449
+
450
+ def api_model_status() -> dict[str, Any]:
451
+ key = get_active_model_key()
452
+ status_md = model_status(key)
453
+ return ok(model_key=key, status_markdown=status_md)
454
+
455
+
456
+ def api_save_upload(filename: str, content_base64: str) -> dict[str, Any]:
457
+ """Save uploaded file bytes to a temp path for downstream ingest/analyze."""
458
+ if not content_base64:
459
+ return err("Empty upload.")
460
+ try:
461
+ raw = base64.b64decode(content_base64)
462
+ except Exception as exc: # noqa: BLE001
463
+ return err(f"Invalid upload encoding: {exc}")
464
+
465
+ suffix = Path(filename or "upload.bin").suffix or ".bin"
466
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix, prefix="studio_")
467
+ tmp.write(raw)
468
+ tmp.close()
469
+ return ok(path=tmp.name, filename=filename)
470
+
471
+
472
+ def register_studio_apis(server: gr.Server) -> None:
473
+ """Register Studio JSON APIs on a gradio.Server instance."""
474
+
475
+ @server.api(name="list_sessions")
476
+ def _list_sessions() -> dict[str, Any]:
477
+ return api_list_sessions()
478
+
479
+ @server.api(name="list_documents")
480
+ def _list_documents(session_id: str = "") -> dict[str, Any]:
481
+ return api_list_documents(session_id)
482
+
483
+ @server.api(name="discover_sources")
484
+ def _discover_sources(topic: str, session_id: str = "") -> dict[str, Any]:
485
+ return api_discover_sources(topic, session_id)
486
+
487
+ @server.api(name="auto_search_ingest")
488
+ def _auto_search_ingest(topic: str, session_id: str = "") -> dict[str, Any]:
489
+ return api_auto_search_ingest(topic, session_id)
490
+
491
+ @server.api(name="ingest_sources")
492
+ def _ingest_sources(
493
+ topic: str,
494
+ session_id: str = "",
495
+ urls_text: str = "",
496
+ selected_urls: list[str] | None = None,
497
+ file_paths: list[str] | None = None,
498
+ ) -> dict[str, Any]:
499
+ return api_ingest_sources(
500
+ topic, session_id, urls_text, selected_urls, file_paths
501
+ )
502
+
503
+ @server.api(name="ingest_url")
504
+ def _ingest_url(topic: str, url: str, session_id: str = "") -> dict[str, Any]:
505
+ return api_ingest_url(topic, url, session_id)
506
+
507
+ @server.api(name="research_chat")
508
+ def _research_chat(
509
+ question: str,
510
+ session_id: str = "",
511
+ doc_ids: list[str] | None = None,
512
+ history: list[dict[str, str]] | None = None,
513
+ ) -> dict[str, Any]:
514
+ return api_research_chat(question, session_id, doc_ids, history)
515
+
516
+ @server.api(name="ingest_files")
517
+ def _ingest_files(
518
+ topic: str,
519
+ session_id: str,
520
+ file_paths: list[str],
521
+ ) -> dict[str, Any]:
522
+ return api_ingest_files(topic, session_id, file_paths)
523
+
524
+ @server.api(name="generate_slides")
525
+ def _generate_slides(
526
+ topic: str,
527
+ grade: str = "6",
528
+ slide_count: int = 5,
529
+ session_id: str = "",
530
+ use_rag: bool = True,
531
+ doc_ids: list[str] | None = None,
532
+ ) -> dict[str, Any]:
533
+ return api_generate_slides(
534
+ topic, grade, slide_count, session_id, use_rag, doc_ids
535
+ )
536
+
537
+ @server.api(name="teacher_voice_turn")
538
+ def _teacher_voice_turn(
539
+ message: str,
540
+ mode: Literal["explain", "lesson", "pitch"] = "lesson",
541
+ topic: str = "",
542
+ session_id: str = "",
543
+ use_rag: bool = True,
544
+ history: list[list[str]] | None = None,
545
+ doc_ids: list[str] | None = None,
546
+ ) -> dict[str, Any]:
547
+ return api_teacher_voice_turn(
548
+ message, mode, topic, session_id, use_rag, history, doc_ids
549
+ )
550
+
551
+ @server.api(name="analyze_pitch")
552
+ def _analyze_pitch(
553
+ audio_path: str,
554
+ language: str = "en",
555
+ asr_preset: str | None = None,
556
+ ) -> dict[str, Any]:
557
+ return api_analyze_pitch(audio_path, language, asr_preset)
558
+
559
+ @server.api(name="model_status")
560
+ def _model_status() -> dict[str, Any]:
561
+ return api_model_status()
562
+
563
+ @server.api(name="save_upload")
564
+ def _save_upload(filename: str, content_base64: str) -> dict[str, Any]:
565
+ return api_save_upload(filename, content_base64)
apps/gradio-space/src/gradio_space/app.py CHANGED
@@ -1,8 +1,10 @@
1
- import os
2
-
3
  import gradio as gr
4
 
5
- from gradio_space.model_loading import preload_active_model
 
 
 
 
6
  from gradio_space.tabs import (
7
  build_chat_tab,
8
  build_education_pptx_tab,
@@ -10,12 +12,8 @@ from gradio_space.tabs import (
10
  build_research_mind_tab,
11
  build_teacher_voice_tab,
12
  )
13
- from gradio_space.tabs.education_pptx import gradio_allowed_paths
14
- from gradio_space.tabs.echo_coach import echo_coach_allowed_paths
15
- from gradio_space.tabs.research_mind import researchmind_allowed_paths
16
- from gradio_space.tabs.teacher_voice import teacher_voice_allowed_paths
17
  from gradio_space.ui.settings_panel import build_settings_panel
18
- from gradio_space.ui.theme import get_theme, load_css
19
 
20
 
21
  def build_demo() -> gr.Blocks:
@@ -26,6 +24,7 @@ def build_demo() -> gr.Blocks:
26
  <div class="brand-block">
27
  <h1>Build Small</h1>
28
  <p>Local lesson slides, research, voice coaching — offline on small models.
 
29
  <a href="https://huggingface.co/build-small-hackathon" target="_blank">Hackathon</a></p>
30
  </div>
31
  """
@@ -47,42 +46,39 @@ def build_demo() -> gr.Blocks:
47
  outputs=[settings_open, settings_acc],
48
  )
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  with gr.Tabs():
51
  with gr.Tab("Lesson slides"):
52
- build_education_pptx_tab()
53
  with gr.Tab("ResearchMind"):
54
- build_research_mind_tab()
55
  with gr.Tab("EchoCoach"):
56
  build_echo_coach_tab()
57
  with gr.Tab("TeacherVoice"):
58
- build_teacher_voice_tab()
59
  with gr.Tab("Chat (debug)"):
60
- build_chat_tab()
61
 
62
  return demo
63
 
64
 
65
  def main() -> None:
66
- preload_active_model()
67
- demo = build_demo()
68
- port = int(os.environ.get("PORT", "7860"))
69
- server_name = os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0")
70
- print(
71
- f"\n Local UI (browser mic works here): http://127.0.0.1:{port}\n"
72
- f" Bound address: {server_name}:{port}\n"
73
- )
74
- demo.launch(
75
- server_name=server_name,
76
- server_port=port,
77
- theme=get_theme(),
78
- css=load_css(),
79
- allowed_paths=[
80
- *gradio_allowed_paths(),
81
- *researchmind_allowed_paths(),
82
- *echo_coach_allowed_paths(),
83
- *teacher_voice_allowed_paths(),
84
- ],
85
- )
86
 
87
 
88
  if __name__ == "__main__":
 
 
 
1
  import gradio as gr
2
 
3
+ from gradio_space.research_helpers import (
4
+ pick_session_for_topic,
5
+ refresh_doc_choices,
6
+ refresh_sessions,
7
+ )
8
  from gradio_space.tabs import (
9
  build_chat_tab,
10
  build_education_pptx_tab,
 
12
  build_research_mind_tab,
13
  build_teacher_voice_tab,
14
  )
15
+ from gradio_space.ui.components import build_workspace_bar
 
 
 
16
  from gradio_space.ui.settings_panel import build_settings_panel
 
17
 
18
 
19
  def build_demo() -> gr.Blocks:
 
24
  <div class="brand-block">
25
  <h1>Build Small</h1>
26
  <p>Local lesson slides, research, voice coaching — offline on small models.
27
+ <a href="/">Studio UI</a> ·
28
  <a href="https://huggingface.co/build-small-hackathon" target="_blank">Hackathon</a></p>
29
  </div>
30
  """
 
46
  outputs=[settings_open, settings_acc],
47
  )
48
 
49
+ workspace = build_workspace_bar()
50
+
51
+ def _seed_workspace_from_topic(topic: str):
52
+ sid = pick_session_for_topic(topic)
53
+ sess_up = refresh_sessions(sid)
54
+ doc_up = refresh_doc_choices(sid, [])
55
+ return sess_up, doc_up
56
+
57
+ demo.load(
58
+ fn=_seed_workspace_from_topic,
59
+ inputs=[workspace.topic],
60
+ outputs=[workspace.session_dd, workspace.doc_dd],
61
+ )
62
+
63
  with gr.Tabs():
64
  with gr.Tab("Lesson slides"):
65
+ build_education_pptx_tab(workspace)
66
  with gr.Tab("ResearchMind"):
67
+ build_research_mind_tab(workspace)
68
  with gr.Tab("EchoCoach"):
69
  build_echo_coach_tab()
70
  with gr.Tab("TeacherVoice"):
71
+ build_teacher_voice_tab(workspace)
72
  with gr.Tab("Chat (debug)"):
73
+ build_chat_tab(workspace)
74
 
75
  return demo
76
 
77
 
78
  def main() -> None:
79
+ from gradio_space.server import main as server_main
80
+
81
+ server_main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
 
84
  if __name__ == "__main__":
apps/gradio-space/src/gradio_space/research_helpers.py CHANGED
@@ -12,6 +12,33 @@ from inference.factory import get_backend
12
  from researchmind.ingest import IngestPipeline
13
 
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  def list_session_choices() -> list[tuple[str, str]]:
16
  store = IngestPipeline().store
17
  sessions = store.list_sessions()
 
12
  from researchmind.ingest import IngestPipeline
13
 
14
 
15
+ def resolve_topic(local: str | None, workspace: str | None) -> str:
16
+ """Tab-local topic overrides workspace default when set."""
17
+ return (local or "").strip() or (workspace or "").strip()
18
+
19
+
20
+ def resolve_session(local: str | None, workspace: str | None) -> str:
21
+ return (local or "").strip() or (workspace or "").strip()
22
+
23
+
24
+ def resolve_doc_ids(local: list[str] | None, workspace: list[str] | None) -> list[str]:
25
+ if local:
26
+ return list(local)
27
+ return list(workspace or [])
28
+
29
+
30
+ def pick_session_for_topic(topic_hint: str = "") -> str:
31
+ """Best-effort session id for a topic hint (substring match on session topic)."""
32
+ hint = (topic_hint or "").lower().strip()
33
+ store = IngestPipeline().store
34
+ sessions = store.list_sessions()
35
+ if hint:
36
+ for s in sessions:
37
+ if hint in (s.topic or "").lower():
38
+ return s.id
39
+ return sessions[0].id if sessions else ""
40
+
41
+
42
  def list_session_choices() -> list[tuple[str, str]]:
43
  store = IngestPipeline().store
44
  sessions = store.list_sessions()
apps/gradio-space/src/gradio_space/server.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import gradio as gr
7
+ from fastapi.responses import FileResponse
8
+ from fastapi.staticfiles import StaticFiles
9
+
10
+ from gradio import mount_gradio_app
11
+
12
+ from gradio_space.api.studio import register_studio_apis
13
+ from gradio_space.app import build_demo
14
+ from gradio_space.model_loading import preload_active_model
15
+ from gradio_space.tabs.education_pptx import gradio_allowed_paths
16
+ from gradio_space.tabs.echo_coach import echo_coach_allowed_paths
17
+ from gradio_space.tabs.research_mind import researchmind_allowed_paths
18
+ from gradio_space.tabs.teacher_voice import teacher_voice_allowed_paths
19
+ from gradio_space.ui.theme import get_theme, load_css
20
+
21
+ _PKG_ROOT = Path(__file__).resolve().parent
22
+ _APP_ROOT = _PKG_ROOT.parents[1]
23
+ _STATIC_DIR = _APP_ROOT / "static" / "studio"
24
+
25
+
26
+ def _all_allowed_paths() -> list[str]:
27
+ paths: list[str] = []
28
+ for fn in (
29
+ gradio_allowed_paths,
30
+ researchmind_allowed_paths,
31
+ echo_coach_allowed_paths,
32
+ teacher_voice_allowed_paths,
33
+ ):
34
+ paths.extend(fn())
35
+ return list(dict.fromkeys(paths))
36
+
37
+
38
+ def create_server() -> gr.Server:
39
+ server = gr.Server(title="Build Small Studio")
40
+
41
+ register_studio_apis(server)
42
+
43
+ if _STATIC_DIR.is_dir():
44
+ server.mount("/static/studio", StaticFiles(directory=str(_STATIC_DIR)), name="studio_static")
45
+
46
+ @server.get("/")
47
+ async def studio_index() -> FileResponse:
48
+ return FileResponse(_STATIC_DIR / "index.html")
49
+
50
+ @server.get("/studio")
51
+ async def studio_alias() -> FileResponse:
52
+ return FileResponse(_STATIC_DIR / "index.html")
53
+
54
+ demo = build_demo()
55
+ mount_gradio_app(
56
+ server,
57
+ demo,
58
+ path="/classic",
59
+ theme=get_theme(),
60
+ css=load_css(),
61
+ allowed_paths=_all_allowed_paths(),
62
+ footer_links=[],
63
+ )
64
+
65
+ return server
66
+
67
+
68
+ def main() -> None:
69
+ preload_active_model()
70
+ server = create_server()
71
+ port = int(os.environ.get("PORT", "7860"))
72
+ server_name = os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0")
73
+ print(
74
+ f"\n Build Small Studio: http://127.0.0.1:{port}/\n"
75
+ f" Classic Gradio UI: http://127.0.0.1:{port}/classic\n"
76
+ f" Bound address: {server_name}:{port}\n"
77
+ )
78
+ server.launch(
79
+ server_name=server_name,
80
+ server_port=port,
81
+ footer_links=[],
82
+ allowed_paths=_all_allowed_paths(),
83
+ show_error=True,
84
+ )
85
+
86
+
87
+ if __name__ == "__main__":
88
+ main()
apps/gradio-space/src/gradio_space/tabs/chat.py CHANGED
@@ -6,14 +6,21 @@ from gradio_space.research_helpers import (
6
  rag_scope_hint,
7
  refresh_doc_choices,
8
  refresh_sessions,
 
 
 
 
 
 
 
 
9
  )
10
- from gradio_space.ui.components import build_advanced_panel, DOC_CHOICE_LIST_CLASSES, tab_hero
11
  from inference.config import get_app_config
12
 
13
  _app_config = get_app_config()
14
 
15
 
16
- def build_chat_tab() -> None:
17
  tab_hero(
18
  "Test the active local model with optional ResearchMind RAG.",
19
  )
@@ -25,11 +32,11 @@ def build_chat_tab() -> None:
25
  model_key = _app_config.active_model
26
 
27
  with gr.Group():
28
- gr.Markdown("#### RAG scope")
29
  with gr.Row():
30
  use_rag = gr.Checkbox(label="Use ResearchMind RAG", value=False)
31
  session_dd = gr.Dropdown(
32
- label="Session",
33
  choices=list_session_choices(),
34
  value="",
35
  interactive=True,
@@ -38,7 +45,7 @@ def build_chat_tab() -> None:
38
  refresh_sessions_btn = gr.Button("↻", size="sm", scale=0, min_width=40)
39
 
40
  doc_dd = gr.CheckboxGroup(
41
- label="Documents to search (empty = all docs in session, or entire corpus if no session)",
42
  choices=[],
43
  value=[],
44
  elem_classes=DOC_CHOICE_LIST_CLASSES,
@@ -54,7 +61,9 @@ def build_chat_tab() -> None:
54
  label="Model preset (debug override)",
55
  )
56
 
57
- def _chat(message, history, mkey, use_rag_flag, sid, docs):
 
 
58
  reply, trace_json, trace_summary = rag_aware_chat(
59
  message, history, mkey, use_rag_flag, sid, docs
60
  )
@@ -63,7 +72,14 @@ def build_chat_tab() -> None:
63
  chat_iface = gr.ChatInterface(
64
  fn=_chat,
65
  additional_outputs=[advanced.trace_box, advanced.trace_summary],
66
- additional_inputs=[model_dropdown, use_rag, session_dd, doc_dd],
 
 
 
 
 
 
 
67
  examples=[
68
  [
69
  "What do my ingested sources say about AI agents?",
@@ -71,6 +87,8 @@ def build_chat_tab() -> None:
71
  True,
72
  "",
73
  [],
 
 
74
  ],
75
  [
76
  "Hello! What can you help me with?",
@@ -78,12 +96,16 @@ def build_chat_tab() -> None:
78
  False,
79
  "",
80
  [],
 
 
81
  ],
82
  ],
83
  )
84
  else:
85
 
86
- def _chat(message, history, use_rag_flag, sid, docs):
 
 
87
  reply, trace_json, trace_summary = rag_aware_chat(
88
  message, history, model_key, use_rag_flag, sid, docs
89
  )
@@ -92,18 +114,32 @@ def build_chat_tab() -> None:
92
  chat_iface = gr.ChatInterface(
93
  fn=_chat,
94
  additional_outputs=[advanced.trace_box, advanced.trace_summary],
95
- additional_inputs=[use_rag, session_dd, doc_dd],
 
 
 
 
 
 
96
  examples=[
97
- ["What do my ingested sources say about AI agents?", True, "", []],
98
- ["Hello! What can you help me with?", False, "", []],
99
  ],
100
  )
101
 
102
  _ = chat_iface # keep reference for linter
103
 
104
- def _update_hint(sid: str, docs: list[str] | None, rag_on: bool) -> str:
 
 
 
 
 
 
105
  if not rag_on:
106
  return "_Plain chat — model only, no document retrieval._"
 
 
107
  return rag_scope_hint(sid, docs)
108
 
109
  refresh_sessions_btn.click(fn=refresh_sessions, inputs=[session_dd], outputs=[session_dd])
@@ -113,8 +149,31 @@ def build_chat_tab() -> None:
113
  outputs=[doc_dd],
114
  ).then(
115
  fn=_update_hint,
116
- inputs=[session_dd, doc_dd, use_rag],
 
 
 
 
 
117
  outputs=[rag_hint],
118
  )
119
- doc_dd.change(fn=_update_hint, inputs=[session_dd, doc_dd, use_rag], outputs=[rag_hint])
120
- use_rag.change(fn=_update_hint, inputs=[session_dd, doc_dd, use_rag], outputs=[rag_hint])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  rag_scope_hint,
7
  refresh_doc_choices,
8
  refresh_sessions,
9
+ resolve_doc_ids,
10
+ resolve_session,
11
+ )
12
+ from gradio_space.ui.components import (
13
+ build_advanced_panel,
14
+ DOC_CHOICE_LIST_CLASSES,
15
+ tab_hero,
16
+ WorkspaceWidgets,
17
  )
 
18
  from inference.config import get_app_config
19
 
20
  _app_config = get_app_config()
21
 
22
 
23
+ def build_chat_tab(workspace: WorkspaceWidgets) -> None:
24
  tab_hero(
25
  "Test the active local model with optional ResearchMind RAG.",
26
  )
 
32
  model_key = _app_config.active_model
33
 
34
  with gr.Group():
35
+ gr.Markdown("#### RAG scope (override workspace defaults)")
36
  with gr.Row():
37
  use_rag = gr.Checkbox(label="Use ResearchMind RAG", value=False)
38
  session_dd = gr.Dropdown(
39
+ label="Session (empty = workspace default)",
40
  choices=list_session_choices(),
41
  value="",
42
  interactive=True,
 
45
  refresh_sessions_btn = gr.Button("↻", size="sm", scale=0, min_width=40)
46
 
47
  doc_dd = gr.CheckboxGroup(
48
+ label="Documents (empty = workspace default or all in session)",
49
  choices=[],
50
  value=[],
51
  elem_classes=DOC_CHOICE_LIST_CLASSES,
 
61
  label="Model preset (debug override)",
62
  )
63
 
64
+ def _chat(message, history, mkey, use_rag_flag, sid, docs, ws_sid, ws_docs):
65
+ sid = resolve_session(sid, ws_sid)
66
+ docs = resolve_doc_ids(docs, ws_docs)
67
  reply, trace_json, trace_summary = rag_aware_chat(
68
  message, history, mkey, use_rag_flag, sid, docs
69
  )
 
72
  chat_iface = gr.ChatInterface(
73
  fn=_chat,
74
  additional_outputs=[advanced.trace_box, advanced.trace_summary],
75
+ additional_inputs=[
76
+ model_dropdown,
77
+ use_rag,
78
+ session_dd,
79
+ doc_dd,
80
+ workspace.session_dd,
81
+ workspace.doc_dd,
82
+ ],
83
  examples=[
84
  [
85
  "What do my ingested sources say about AI agents?",
 
87
  True,
88
  "",
89
  [],
90
+ "",
91
+ [],
92
  ],
93
  [
94
  "Hello! What can you help me with?",
 
96
  False,
97
  "",
98
  [],
99
+ "",
100
+ [],
101
  ],
102
  ],
103
  )
104
  else:
105
 
106
+ def _chat(message, history, use_rag_flag, sid, docs, ws_sid, ws_docs):
107
+ sid = resolve_session(sid, ws_sid)
108
+ docs = resolve_doc_ids(docs, ws_docs)
109
  reply, trace_json, trace_summary = rag_aware_chat(
110
  message, history, model_key, use_rag_flag, sid, docs
111
  )
 
114
  chat_iface = gr.ChatInterface(
115
  fn=_chat,
116
  additional_outputs=[advanced.trace_box, advanced.trace_summary],
117
+ additional_inputs=[
118
+ use_rag,
119
+ session_dd,
120
+ doc_dd,
121
+ workspace.session_dd,
122
+ workspace.doc_dd,
123
+ ],
124
  examples=[
125
+ ["What do my ingested sources say about AI agents?", True, "", [], "", []],
126
+ ["Hello! What can you help me with?", False, "", [], "", []],
127
  ],
128
  )
129
 
130
  _ = chat_iface # keep reference for linter
131
 
132
+ def _update_hint(
133
+ sid: str,
134
+ docs: list[str] | None,
135
+ rag_on: bool,
136
+ ws_sid: str,
137
+ ws_docs: list[str] | None,
138
+ ) -> str:
139
  if not rag_on:
140
  return "_Plain chat — model only, no document retrieval._"
141
+ sid = resolve_session(sid, ws_sid)
142
+ docs = resolve_doc_ids(docs, ws_docs)
143
  return rag_scope_hint(sid, docs)
144
 
145
  refresh_sessions_btn.click(fn=refresh_sessions, inputs=[session_dd], outputs=[session_dd])
 
149
  outputs=[doc_dd],
150
  ).then(
151
  fn=_update_hint,
152
+ inputs=[session_dd, doc_dd, use_rag, workspace.session_dd, workspace.doc_dd],
153
+ outputs=[rag_hint],
154
+ )
155
+ doc_dd.change(
156
+ fn=_update_hint,
157
+ inputs=[session_dd, doc_dd, use_rag, workspace.session_dd, workspace.doc_dd],
158
  outputs=[rag_hint],
159
  )
160
+ use_rag.change(
161
+ fn=_update_hint,
162
+ inputs=[session_dd, doc_dd, use_rag, workspace.session_dd, workspace.doc_dd],
163
+ outputs=[rag_hint],
164
+ )
165
+
166
+ def _sync_session_from_workspace(ws_session: str, local_session: str) -> str:
167
+ if not (local_session or "").strip():
168
+ return ws_session
169
+ return local_session
170
+
171
+ workspace.session_dd.change(
172
+ fn=_sync_session_from_workspace,
173
+ inputs=[workspace.session_dd, session_dd],
174
+ outputs=[session_dd],
175
+ ).then(
176
+ fn=refresh_doc_choices,
177
+ inputs=[session_dd, doc_dd],
178
+ outputs=[doc_dd],
179
+ )
apps/gradio-space/src/gradio_space/tabs/education_pptx.py CHANGED
@@ -1,8 +1,10 @@
 
1
  from pathlib import Path
2
 
3
  import gradio as gr
4
 
5
- from agent.runner import AgentRunner
 
6
  from agent.tools.pptx import get_outputs_dir
7
  from gradio_space.model_loading import ensure_model_loaded, get_active_model_key
8
  from gradio_space.research_helpers import (
@@ -10,8 +12,11 @@ from gradio_space.research_helpers import (
10
  merge_lesson_urls,
11
  refresh_doc_choices,
12
  refresh_sessions,
 
 
 
13
  )
14
- from gradio_space.ui.components import build_advanced_panel, DOC_CHOICE_LIST_CLASSES
15
  from inference.factory import get_backend
16
  from researchmind.config import get_config
17
 
@@ -51,6 +56,10 @@ def _error_html(message: str) -> str:
51
 
52
 
53
  def _empty_outputs(message: str) -> tuple:
 
 
 
 
54
  return (
55
  message,
56
  _error_html(message),
@@ -58,12 +67,73 @@ def _empty_outputs(message: str) -> tuple:
58
  None,
59
  None,
60
  None,
 
61
  message,
62
  message,
63
  message,
64
  )
65
 
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  def update_source_visibility(source_mode_label: str, search_workflow_label: str):
68
  mode = _source_mode_value(source_mode_label)
69
  workflow = _search_workflow_value(search_workflow_label)
@@ -91,8 +161,12 @@ def update_source_visibility(source_mode_label: str, search_workflow_label: str)
91
  def discover_lesson_sources(
92
  topic: str,
93
  session_id: str,
 
 
94
  progress: gr.Progress = gr.Progress(),
95
  ) -> tuple[str, object, object]:
 
 
96
  progress(0, desc="Discovering sources…")
97
  model_key = get_active_model_key()
98
  load_error = ensure_model_loaded(model_key)
@@ -145,27 +219,44 @@ def generate_lesson_slides(
145
  upload_files: list[str] | None,
146
  session_id: str,
147
  doc_ids: list[str] | None,
 
 
 
148
  progress: gr.Progress = gr.Progress(),
149
- ) -> tuple[str, str, list[str], str | None, str | None, str | None, str, str, str]:
150
- progress(0, desc="Loading model…")
 
 
 
 
 
 
 
 
 
151
  model_key = get_active_model_key()
152
  load_error = ensure_model_loaded(model_key)
153
  if load_error:
154
- return _empty_outputs(load_error)
 
155
 
156
  if not topic.strip():
157
  message = "Please enter a lesson topic."
158
- return _empty_outputs(message)
 
159
 
160
  source_mode = _source_mode_value(source_mode_label)
161
  search_workflow = _search_workflow_value(search_workflow_label)
162
  merged_urls = merge_lesson_urls(urls_text, selected_urls)
163
  files = [Path(p) for p in (upload_files or [])]
164
 
 
 
 
 
165
  try:
166
- progress(0.1, desc="Generating lesson slides…")
167
  runner = AgentRunner()
168
- result = runner.run_education_pptx(
169
  topic=topic,
170
  grade=grade,
171
  slide_count=int(slide_count),
@@ -177,10 +268,35 @@ def generate_lesson_slides(
177
  files=files,
178
  session_id=session_id or None,
179
  doc_ids=doc_ids or [],
180
- )
 
 
 
 
 
 
 
181
  except Exception as exc: # noqa: BLE001
182
  message = f"Agent error: {exc}"
183
- return _empty_outputs(message)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
 
185
  progress(1.0, desc="Done")
186
  gallery = [str(Path(p).resolve()) for p in result.preview_images]
@@ -190,20 +306,26 @@ def generate_lesson_slides(
190
  f"Trace saved: `{result.trace_path}`"
191
  )
192
  source_status = result.source_summary or "_No external sources used (model only)._"
193
- return (
 
 
 
 
 
194
  result.markdown_preview,
195
  result.html_preview,
196
  gallery,
197
  str(Path(result.pptx_path).resolve()),
198
  str(Path(result.docx_path).resolve()),
199
  str(Path(result.html_export_path).resolve()),
 
200
  trace_summary,
201
  result.trace.to_json(),
202
  source_status,
203
  )
204
 
205
 
206
- def build_education_pptx_tab() -> None:
207
  gr.Markdown("### Create lesson slides", elem_classes=["lesson-tab-heading"])
208
  gr.HTML(
209
  '<p class="tab-subtitle">Enter your topic below, adjust grade and length if needed, then generate.</p>'
@@ -291,6 +413,14 @@ def build_education_pptx_tab() -> None:
291
  )
292
 
293
  source_status = gr.Markdown(value="_Ready to generate._", elem_classes=["lesson-status"])
 
 
 
 
 
 
 
 
294
 
295
  with gr.Tabs():
296
  with gr.Tab("Slide preview"):
@@ -362,7 +492,7 @@ then choose **Open with → Google Docs**. You can also upload the `.html` file
362
 
363
  discover_btn.click(
364
  fn=discover_lesson_sources,
365
- inputs=[topic, session_dd],
366
  outputs=[source_status, url_choices, session_dd],
367
  )
368
 
@@ -379,6 +509,9 @@ then choose **Open with → Google Docs**. You can also upload the `.html` file
379
  upload_files,
380
  session_dd,
381
  doc_dd,
 
 
 
382
  ],
383
  outputs=[
384
  outline_preview,
@@ -387,10 +520,37 @@ then choose **Open with → Google Docs**. You can also upload the `.html` file
387
  pptx_file,
388
  docx_file,
389
  html_file,
 
390
  advanced.trace_summary,
391
  advanced.trace_box,
392
  source_status,
393
  ],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  )
395
 
396
 
 
1
+ from html import escape
2
  from pathlib import Path
3
 
4
  import gradio as gr
5
 
6
+ from agent.progress import SlideGenerationProgress
7
+ from agent.runner import AgentResult, AgentRunner
8
  from agent.tools.pptx import get_outputs_dir
9
  from gradio_space.model_loading import ensure_model_loaded, get_active_model_key
10
  from gradio_space.research_helpers import (
 
12
  merge_lesson_urls,
13
  refresh_doc_choices,
14
  refresh_sessions,
15
+ resolve_doc_ids,
16
+ resolve_session,
17
+ resolve_topic,
18
  )
19
+ from gradio_space.ui.components import build_advanced_panel, DOC_CHOICE_LIST_CLASSES, WorkspaceWidgets
20
  from inference.factory import get_backend
21
  from researchmind.config import get_config
22
 
 
56
 
57
 
58
  def _empty_outputs(message: str) -> tuple:
59
+ log_html = (
60
+ f'<div class="slide-gen-log"><div class="slide-gen-log-banner error">'
61
+ f"{message}</div></div>"
62
+ )
63
  return (
64
  message,
65
  _error_html(message),
 
67
  None,
68
  None,
69
  None,
70
+ log_html,
71
  message,
72
  message,
73
  message,
74
  )
75
 
76
 
77
+ def _running_preview_html(step_label: str = "Generating slides…") -> str:
78
+ safe = (
79
+ step_label.replace("&", "&amp;")
80
+ .replace("<", "&lt;")
81
+ .replace(">", "&gt;")
82
+ )
83
+ return (
84
+ '<div class="lesson-running-preview">'
85
+ '<div class="lesson-running-spinner" aria-hidden="true"></div>'
86
+ f"<p><strong>{safe}</strong></p>"
87
+ "<p class=\"lesson-running-hint\">Local models can take 30–90s on CPU. "
88
+ "Steps update live below.</p>"
89
+ "</div>"
90
+ )
91
+
92
+
93
+ def _interim_outputs(
94
+ slide_progress: SlideGenerationProgress,
95
+ *,
96
+ status: str = "_Generating lesson slides…_",
97
+ step_label: str = "Generating slides…",
98
+ ) -> tuple:
99
+ log_html = slide_progress.format_log_html(running=True)
100
+ return (
101
+ "",
102
+ _running_preview_html(step_label),
103
+ [],
104
+ None,
105
+ None,
106
+ None,
107
+ log_html,
108
+ "",
109
+ "",
110
+ status,
111
+ )
112
+
113
+
114
+ def _format_processing_log(
115
+ progress: SlideGenerationProgress,
116
+ *,
117
+ trace_summary: str = "",
118
+ source_status: str = "",
119
+ ) -> str:
120
+ footer_parts: list[str] = []
121
+ if source_status:
122
+ footer_parts.append(
123
+ f"<p><strong>Sources:</strong> {escape(strip_md_inline(source_status))}</p>"
124
+ )
125
+ if trace_summary:
126
+ footer_parts.append(
127
+ f'<pre class="slide-gen-log-trace">{escape(trace_summary)}</pre>'
128
+ )
129
+ footer_html = "".join(footer_parts)
130
+ return progress.format_log_html(running=False, footer_html=footer_html)
131
+
132
+
133
+ def strip_md_inline(text: str) -> str:
134
+ return str(text).replace("**", "").replace("_", "").replace("`", "")
135
+
136
+
137
  def update_source_visibility(source_mode_label: str, search_workflow_label: str):
138
  mode = _source_mode_value(source_mode_label)
139
  workflow = _search_workflow_value(search_workflow_label)
 
161
  def discover_lesson_sources(
162
  topic: str,
163
  session_id: str,
164
+ workspace_topic: str,
165
+ workspace_session: str,
166
  progress: gr.Progress = gr.Progress(),
167
  ) -> tuple[str, object, object]:
168
+ topic = resolve_topic(topic, workspace_topic)
169
+ session_id = resolve_session(session_id, workspace_session)
170
  progress(0, desc="Discovering sources…")
171
  model_key = get_active_model_key()
172
  load_error = ensure_model_loaded(model_key)
 
219
  upload_files: list[str] | None,
220
  session_id: str,
221
  doc_ids: list[str] | None,
222
+ workspace_topic: str = "",
223
+ workspace_session: str = "",
224
+ workspace_doc_ids: list[str] | None = None,
225
  progress: gr.Progress = gr.Progress(),
226
+ *,
227
+ skip_preview_images: bool = False,
228
+ ):
229
+ topic = resolve_topic(topic, workspace_topic)
230
+ session_id = resolve_session(session_id, workspace_session)
231
+ doc_ids = resolve_doc_ids(doc_ids, workspace_doc_ids)
232
+ slide_progress = SlideGenerationProgress(
233
+ on_update=lambda fraction, desc: progress(fraction, desc=desc),
234
+ )
235
+ slide_progress.begin("load_model", "Load language model")
236
+
237
  model_key = get_active_model_key()
238
  load_error = ensure_model_loaded(model_key)
239
  if load_error:
240
+ yield _empty_outputs(load_error)
241
+ return
242
 
243
  if not topic.strip():
244
  message = "Please enter a lesson topic."
245
+ yield _empty_outputs(message)
246
+ return
247
 
248
  source_mode = _source_mode_value(source_mode_label)
249
  search_workflow = _search_workflow_value(search_workflow_label)
250
  merged_urls = merge_lesson_urls(urls_text, selected_urls)
251
  files = [Path(p) for p in (upload_files or [])]
252
 
253
+ current_step = "Load language model"
254
+ yield _interim_outputs(slide_progress, step_label=current_step)
255
+
256
+ result = None
257
  try:
 
258
  runner = AgentRunner()
259
+ for item in runner.iter_education_pptx(
260
  topic=topic,
261
  grade=grade,
262
  slide_count=int(slide_count),
 
268
  files=files,
269
  session_id=session_id or None,
270
  doc_ids=doc_ids or [],
271
+ progress=slide_progress,
272
+ skip_preview_images=skip_preview_images,
273
+ ):
274
+ if isinstance(item, AgentResult):
275
+ result = item
276
+ break
277
+ current_step = item.steps[-1].label if item.steps else current_step
278
+ yield _interim_outputs(slide_progress, step_label=current_step)
279
  except Exception as exc: # noqa: BLE001
280
  message = f"Agent error: {exc}"
281
+ slide_progress.finish()
282
+ yield (
283
+ message,
284
+ _error_html(message),
285
+ [],
286
+ None,
287
+ None,
288
+ None,
289
+ slide_progress.format_log_html(running=False),
290
+ message,
291
+ message,
292
+ message,
293
+ )
294
+ return
295
+
296
+ if result is None:
297
+ message = "Agent error: generation finished without a result."
298
+ yield _empty_outputs(message)
299
+ return
300
 
301
  progress(1.0, desc="Done")
302
  gallery = [str(Path(p).resolve()) for p in result.preview_images]
 
306
  f"Trace saved: `{result.trace_path}`"
307
  )
308
  source_status = result.source_summary or "_No external sources used (model only)._"
309
+ processing_log = _format_processing_log(
310
+ slide_progress,
311
+ trace_summary=trace_summary,
312
+ source_status=source_status,
313
+ )
314
+ yield (
315
  result.markdown_preview,
316
  result.html_preview,
317
  gallery,
318
  str(Path(result.pptx_path).resolve()),
319
  str(Path(result.docx_path).resolve()),
320
  str(Path(result.html_export_path).resolve()),
321
+ processing_log,
322
  trace_summary,
323
  result.trace.to_json(),
324
  source_status,
325
  )
326
 
327
 
328
+ def build_education_pptx_tab(workspace: WorkspaceWidgets) -> None:
329
  gr.Markdown("### Create lesson slides", elem_classes=["lesson-tab-heading"])
330
  gr.HTML(
331
  '<p class="tab-subtitle">Enter your topic below, adjust grade and length if needed, then generate.</p>'
 
413
  )
414
 
415
  source_status = gr.Markdown(value="_Ready to generate._", elem_classes=["lesson-status"])
416
+ processing_log = gr.HTML(
417
+ value=(
418
+ '<div class="slide-gen-log slide-gen-log-idle">'
419
+ "<p>Generation steps and timings appear here when you run.</p>"
420
+ "</div>"
421
+ ),
422
+ elem_classes=["lesson-processing-log"],
423
+ )
424
 
425
  with gr.Tabs():
426
  with gr.Tab("Slide preview"):
 
492
 
493
  discover_btn.click(
494
  fn=discover_lesson_sources,
495
+ inputs=[topic, session_dd, workspace.topic, workspace.session_dd],
496
  outputs=[source_status, url_choices, session_dd],
497
  )
498
 
 
509
  upload_files,
510
  session_dd,
511
  doc_dd,
512
+ workspace.topic,
513
+ workspace.session_dd,
514
+ workspace.doc_dd,
515
  ],
516
  outputs=[
517
  outline_preview,
 
520
  pptx_file,
521
  docx_file,
522
  html_file,
523
+ processing_log,
524
  advanced.trace_summary,
525
  advanced.trace_box,
526
  source_status,
527
  ],
528
+ show_progress="hidden",
529
+ )
530
+
531
+ def _sync_topic_from_workspace(ws_topic: str, local_topic: str) -> str:
532
+ if not (local_topic or "").strip():
533
+ return ws_topic
534
+ return local_topic
535
+
536
+ def _sync_session_from_workspace(ws_session: str, local_session: str) -> str:
537
+ if not (local_session or "").strip():
538
+ return ws_session
539
+ return local_session
540
+
541
+ workspace.topic.change(
542
+ fn=_sync_topic_from_workspace,
543
+ inputs=[workspace.topic, topic],
544
+ outputs=[topic],
545
+ )
546
+ workspace.session_dd.change(
547
+ fn=_sync_session_from_workspace,
548
+ inputs=[workspace.session_dd, session_dd],
549
+ outputs=[session_dd],
550
+ ).then(
551
+ fn=refresh_doc_choices,
552
+ inputs=[session_dd, doc_dd],
553
+ outputs=[doc_dd],
554
  )
555
 
556
 
apps/gradio-space/src/gradio_space/tabs/research_mind.py CHANGED
@@ -17,10 +17,13 @@ from gradio_space.research_helpers import (
17
  rag_scope_hint,
18
  refresh_doc_choices,
19
  refresh_sessions,
 
 
 
20
  run_research_question,
21
  trace_summary_markdown,
22
  )
23
- from gradio_space.ui.components import build_advanced_panel, DOC_CHOICE_LIST_CLASSES
24
  from inference.factory import get_backend
25
 
26
  logger = logging.getLogger(__name__)
@@ -35,8 +38,12 @@ def _require_topic(topic: str | None) -> str | None:
35
  def discover_sources(
36
  topic: str,
37
  session_id: str,
 
 
38
  progress: gr.Progress = gr.Progress(),
39
  ) -> tuple[str, object, str, str, str, str, object, object]:
 
 
40
  progress(0, desc="Searching web…")
41
  model_key = get_active_model_key()
42
  load_error = ensure_model_loaded(model_key)
@@ -114,8 +121,12 @@ def discover_sources(
114
  def auto_search_ingest(
115
  topic: str,
116
  session_id: str,
 
 
117
  progress: gr.Progress = gr.Progress(),
118
  ) -> tuple[str, object, str, str, str, str, object, object]:
 
 
119
  progress(0, desc="Auto search & ingest…")
120
  model_key = get_active_model_key()
121
  load_error = ensure_model_loaded(model_key)
@@ -187,8 +198,12 @@ def ingest_selected(
187
  selected_urls: list[str] | None,
188
  upload_files: list[str] | None,
189
  session_id: str | None,
 
 
190
  progress: gr.Progress = gr.Progress(),
191
  ) -> tuple[str, str, str, str, object, object]:
 
 
192
  progress(0, desc="Ingesting sources…")
193
  sid = session_id or ""
194
  model_key = get_active_model_key()
@@ -269,8 +284,12 @@ def ask_question(
269
  session_id: str,
270
  doc_ids: list[str] | None,
271
  chat_history: list[dict],
 
 
272
  progress: gr.Progress = gr.Progress(),
273
  ) -> tuple[list[dict], str, str, str, str]:
 
 
274
  if not question.strip():
275
  return chat_history or [], "Enter a question.", "", rag_scope_hint(session_id, doc_ids), question
276
 
@@ -298,7 +317,7 @@ def ask_question(
298
  return history, err, err, rag_scope_hint(session_id, doc_ids), question
299
 
300
 
301
- def build_research_mind_tab() -> None:
302
  gr.Markdown("### ResearchMind", elem_classes=["form-tab-heading"])
303
  gr.HTML(
304
  '<p class="tab-subtitle">'
@@ -439,19 +458,27 @@ def build_research_mind_tab() -> None:
439
 
440
  discover_btn.click(
441
  fn=discover_sources,
442
- inputs=[topic, session_dd],
443
  outputs=discover_outputs,
444
  )
445
 
446
  auto_btn.click(
447
  fn=auto_search_ingest,
448
- inputs=[topic, session_dd],
449
  outputs=discover_outputs,
450
  )
451
 
452
  ingest_btn.click(
453
  fn=ingest_selected,
454
- inputs=[topic, urls_text, url_choices, upload_files, session_dd],
 
 
 
 
 
 
 
 
455
  outputs=[
456
  ingest_status,
457
  memory_md,
@@ -464,15 +491,54 @@ def build_research_mind_tab() -> None:
464
 
465
  ask_btn.click(
466
  fn=ask_question,
467
- inputs=[question, session_dd, doc_dd, chatbot],
 
 
 
 
 
 
 
468
  outputs=[chatbot, advanced.trace_box, advanced.trace_summary, rag_hint, question],
469
  )
470
  question.submit(
471
  fn=ask_question,
472
- inputs=[question, session_dd, doc_dd, chatbot],
 
 
 
 
 
 
 
473
  outputs=[chatbot, advanced.trace_box, advanced.trace_summary, rag_hint, question],
474
  )
475
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
 
477
  def researchmind_allowed_paths() -> list[str]:
478
  from researchmind.config import get_config
 
17
  rag_scope_hint,
18
  refresh_doc_choices,
19
  refresh_sessions,
20
+ resolve_doc_ids,
21
+ resolve_session,
22
+ resolve_topic,
23
  run_research_question,
24
  trace_summary_markdown,
25
  )
26
+ from gradio_space.ui.components import build_advanced_panel, DOC_CHOICE_LIST_CLASSES, WorkspaceWidgets
27
  from inference.factory import get_backend
28
 
29
  logger = logging.getLogger(__name__)
 
38
  def discover_sources(
39
  topic: str,
40
  session_id: str,
41
+ workspace_topic: str = "",
42
+ workspace_session: str = "",
43
  progress: gr.Progress = gr.Progress(),
44
  ) -> tuple[str, object, str, str, str, str, object, object]:
45
+ topic = resolve_topic(topic, workspace_topic)
46
+ session_id = resolve_session(session_id, workspace_session)
47
  progress(0, desc="Searching web…")
48
  model_key = get_active_model_key()
49
  load_error = ensure_model_loaded(model_key)
 
121
  def auto_search_ingest(
122
  topic: str,
123
  session_id: str,
124
+ workspace_topic: str = "",
125
+ workspace_session: str = "",
126
  progress: gr.Progress = gr.Progress(),
127
  ) -> tuple[str, object, str, str, str, str, object, object]:
128
+ topic = resolve_topic(topic, workspace_topic)
129
+ session_id = resolve_session(session_id, workspace_session)
130
  progress(0, desc="Auto search & ingest…")
131
  model_key = get_active_model_key()
132
  load_error = ensure_model_loaded(model_key)
 
198
  selected_urls: list[str] | None,
199
  upload_files: list[str] | None,
200
  session_id: str | None,
201
+ workspace_topic: str = "",
202
+ workspace_session: str = "",
203
  progress: gr.Progress = gr.Progress(),
204
  ) -> tuple[str, str, str, str, object, object]:
205
+ topic = resolve_topic(topic, workspace_topic) or None
206
+ session_id = resolve_session(session_id or "", workspace_session) or None
207
  progress(0, desc="Ingesting sources…")
208
  sid = session_id or ""
209
  model_key = get_active_model_key()
 
284
  session_id: str,
285
  doc_ids: list[str] | None,
286
  chat_history: list[dict],
287
+ workspace_session: str = "",
288
+ workspace_doc_ids: list[str] | None = None,
289
  progress: gr.Progress = gr.Progress(),
290
  ) -> tuple[list[dict], str, str, str, str]:
291
+ session_id = resolve_session(session_id, workspace_session)
292
+ doc_ids = resolve_doc_ids(doc_ids, workspace_doc_ids)
293
  if not question.strip():
294
  return chat_history or [], "Enter a question.", "", rag_scope_hint(session_id, doc_ids), question
295
 
 
317
  return history, err, err, rag_scope_hint(session_id, doc_ids), question
318
 
319
 
320
+ def build_research_mind_tab(workspace: WorkspaceWidgets) -> None:
321
  gr.Markdown("### ResearchMind", elem_classes=["form-tab-heading"])
322
  gr.HTML(
323
  '<p class="tab-subtitle">'
 
458
 
459
  discover_btn.click(
460
  fn=discover_sources,
461
+ inputs=[topic, session_dd, workspace.topic, workspace.session_dd],
462
  outputs=discover_outputs,
463
  )
464
 
465
  auto_btn.click(
466
  fn=auto_search_ingest,
467
+ inputs=[topic, session_dd, workspace.topic, workspace.session_dd],
468
  outputs=discover_outputs,
469
  )
470
 
471
  ingest_btn.click(
472
  fn=ingest_selected,
473
+ inputs=[
474
+ topic,
475
+ urls_text,
476
+ url_choices,
477
+ upload_files,
478
+ session_dd,
479
+ workspace.topic,
480
+ workspace.session_dd,
481
+ ],
482
  outputs=[
483
  ingest_status,
484
  memory_md,
 
491
 
492
  ask_btn.click(
493
  fn=ask_question,
494
+ inputs=[
495
+ question,
496
+ session_dd,
497
+ doc_dd,
498
+ chatbot,
499
+ workspace.session_dd,
500
+ workspace.doc_dd,
501
+ ],
502
  outputs=[chatbot, advanced.trace_box, advanced.trace_summary, rag_hint, question],
503
  )
504
  question.submit(
505
  fn=ask_question,
506
+ inputs=[
507
+ question,
508
+ session_dd,
509
+ doc_dd,
510
+ chatbot,
511
+ workspace.session_dd,
512
+ workspace.doc_dd,
513
+ ],
514
  outputs=[chatbot, advanced.trace_box, advanced.trace_summary, rag_hint, question],
515
  )
516
 
517
+ def _sync_topic_from_workspace(ws_topic: str, local_topic: str) -> str:
518
+ if not (local_topic or "").strip():
519
+ return ws_topic
520
+ return local_topic
521
+
522
+ def _sync_session_from_workspace(ws_session: str, local_session: str) -> str:
523
+ if not (local_session or "").strip():
524
+ return ws_session
525
+ return local_session
526
+
527
+ workspace.topic.change(
528
+ fn=_sync_topic_from_workspace,
529
+ inputs=[workspace.topic, topic],
530
+ outputs=[topic],
531
+ )
532
+ workspace.session_dd.change(
533
+ fn=_sync_session_from_workspace,
534
+ inputs=[workspace.session_dd, session_dd],
535
+ outputs=[session_dd],
536
+ ).then(
537
+ fn=refresh_doc_choices,
538
+ inputs=[session_dd, doc_dd],
539
+ outputs=[doc_dd],
540
+ )
541
+
542
 
543
  def researchmind_allowed_paths() -> list[str]:
544
  from researchmind.config import get_config
apps/gradio-space/src/gradio_space/tabs/teacher_voice.py CHANGED
@@ -13,6 +13,9 @@ from gradio_space.research_helpers import (
13
  rag_scope_hint,
14
  refresh_doc_choices,
15
  refresh_sessions,
 
 
 
16
  trace_as_dict,
17
  )
18
  from gradio_space.tabs.research_mind import (
@@ -25,6 +28,7 @@ from gradio_space.ui.components import (
25
  build_recording_block,
26
  DOC_CHOICE_LIST_CLASSES,
27
  wire_recording_handlers,
 
28
  )
29
  from gradio_space.voice_helpers import speak_last_assistant_reply
30
  from inference.factory import get_backend
@@ -93,8 +97,14 @@ def send_turn(
93
  use_rag: bool,
94
  session_id: str,
95
  doc_ids: list[str] | None,
 
 
 
96
  progress: gr.Progress = gr.Progress(),
97
  ) -> tuple:
 
 
 
98
  progress(0, desc="Loading model…")
99
  model_key = get_active_model_key()
100
  load_error = ensure_model_loaded(model_key)
@@ -141,8 +151,14 @@ def send_text_turn(
141
  use_rag: bool,
142
  session_id: str,
143
  doc_ids: list[str] | None,
 
 
 
144
  progress: gr.Progress = gr.Progress(),
145
  ) -> tuple:
 
 
 
146
  progress(0, desc="Loading model…")
147
  model_key = get_active_model_key()
148
  load_error = ensure_model_loaded(model_key)
@@ -230,14 +246,30 @@ def _enable_rag_after_ingest(
230
  return gr.update(), _update_rag_hint(False, session_id, doc_ids)
231
 
232
 
233
- def _discover_for_json(topic: str, session_id: str, progress: gr.Progress = gr.Progress()):
234
- results = list(discover_sources(topic, session_id, progress))
 
 
 
 
 
 
 
 
235
  results[4] = trace_as_dict(results[4])
236
  return tuple(results)
237
 
238
 
239
- def _auto_ingest_for_json(topic: str, session_id: str, progress: gr.Progress = gr.Progress()):
240
- results = list(auto_search_ingest(topic, session_id, progress))
 
 
 
 
 
 
 
 
241
  results[4] = trace_as_dict(results[4])
242
  return tuple(results)
243
 
@@ -248,10 +280,21 @@ def _ingest_for_json(
248
  selected_urls: list[str],
249
  upload_files: list[str] | None,
250
  session_id: str,
 
 
251
  progress: gr.Progress = gr.Progress(),
252
  ):
253
  results = list(
254
- ingest_selected(topic, urls_text, selected_urls, upload_files, session_id, progress)
 
 
 
 
 
 
 
 
 
255
  )
256
  results[2] = trace_as_dict(results[2])
257
  return tuple(results)
@@ -291,7 +334,7 @@ def _on_mode_change(mode: str) -> tuple:
291
  return topic_up, message_up, rag_acc, use_rag
292
 
293
 
294
- def build_teacher_voice_tab() -> None:
295
  lang_choices = _config.language_choices()
296
  asr_choices = _config.asr_choices()
297
  default_lang = lang_choices[0][1] if lang_choices else "en"
@@ -527,7 +570,7 @@ def build_teacher_voice_tab() -> None:
527
 
528
  discover_btn.click(
529
  fn=_discover_for_json,
530
- inputs=[topic_tb, session_dd],
531
  outputs=discover_outputs,
532
  ).then(
533
  fn=_update_rag_hint,
@@ -537,7 +580,7 @@ def build_teacher_voice_tab() -> None:
537
 
538
  auto_btn.click(
539
  fn=_auto_ingest_for_json,
540
- inputs=[topic_tb, session_dd],
541
  outputs=discover_outputs,
542
  ).then(
543
  fn=_enable_rag_after_ingest,
@@ -547,7 +590,15 @@ def build_teacher_voice_tab() -> None:
547
 
548
  ingest_btn.click(
549
  fn=_ingest_for_json,
550
- inputs=[topic_tb, urls_text, url_choices, upload_files, session_dd],
 
 
 
 
 
 
 
 
551
  outputs=[
552
  ingest_status,
553
  indexed_md,
@@ -579,6 +630,9 @@ def build_teacher_voice_tab() -> None:
579
  use_rag,
580
  session_dd,
581
  doc_dd,
 
 
 
582
  ]
583
 
584
  voice_turn_inputs = [
@@ -591,6 +645,9 @@ def build_teacher_voice_tab() -> None:
591
  use_rag,
592
  session_dd,
593
  doc_dd,
 
 
 
594
  ]
595
 
596
  send_text_btn.click(send_text_turn, inputs=text_turn_inputs, outputs=turn_outputs)
@@ -611,6 +668,31 @@ def build_teacher_voice_tab() -> None:
611
  outputs=[voiceout, status, speak_status],
612
  )
613
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
614
 
615
  def teacher_voice_allowed_paths() -> list[str]:
616
  paths: list[str] = []
 
13
  rag_scope_hint,
14
  refresh_doc_choices,
15
  refresh_sessions,
16
+ resolve_doc_ids,
17
+ resolve_session,
18
+ resolve_topic,
19
  trace_as_dict,
20
  )
21
  from gradio_space.tabs.research_mind import (
 
28
  build_recording_block,
29
  DOC_CHOICE_LIST_CLASSES,
30
  wire_recording_handlers,
31
+ WorkspaceWidgets,
32
  )
33
  from gradio_space.voice_helpers import speak_last_assistant_reply
34
  from inference.factory import get_backend
 
97
  use_rag: bool,
98
  session_id: str,
99
  doc_ids: list[str] | None,
100
+ workspace_topic: str,
101
+ workspace_session: str,
102
+ workspace_doc_ids: list[str] | None,
103
  progress: gr.Progress = gr.Progress(),
104
  ) -> tuple:
105
+ topic = resolve_topic(topic, workspace_topic)
106
+ session_id = resolve_session(session_id, workspace_session)
107
+ doc_ids = resolve_doc_ids(doc_ids, workspace_doc_ids)
108
  progress(0, desc="Loading model…")
109
  model_key = get_active_model_key()
110
  load_error = ensure_model_loaded(model_key)
 
151
  use_rag: bool,
152
  session_id: str,
153
  doc_ids: list[str] | None,
154
+ workspace_topic: str,
155
+ workspace_session: str,
156
+ workspace_doc_ids: list[str] | None,
157
  progress: gr.Progress = gr.Progress(),
158
  ) -> tuple:
159
+ topic = resolve_topic(topic, workspace_topic)
160
+ session_id = resolve_session(session_id, workspace_session)
161
+ doc_ids = resolve_doc_ids(doc_ids, workspace_doc_ids)
162
  progress(0, desc="Loading model…")
163
  model_key = get_active_model_key()
164
  load_error = ensure_model_loaded(model_key)
 
246
  return gr.update(), _update_rag_hint(False, session_id, doc_ids)
247
 
248
 
249
+ def _discover_for_json(
250
+ topic: str,
251
+ session_id: str,
252
+ workspace_topic: str,
253
+ workspace_session: str,
254
+ progress: gr.Progress = gr.Progress(),
255
+ ):
256
+ results = list(
257
+ discover_sources(topic, session_id, workspace_topic, workspace_session, progress)
258
+ )
259
  results[4] = trace_as_dict(results[4])
260
  return tuple(results)
261
 
262
 
263
+ def _auto_ingest_for_json(
264
+ topic: str,
265
+ session_id: str,
266
+ workspace_topic: str,
267
+ workspace_session: str,
268
+ progress: gr.Progress = gr.Progress(),
269
+ ):
270
+ results = list(
271
+ auto_search_ingest(topic, session_id, workspace_topic, workspace_session, progress)
272
+ )
273
  results[4] = trace_as_dict(results[4])
274
  return tuple(results)
275
 
 
280
  selected_urls: list[str],
281
  upload_files: list[str] | None,
282
  session_id: str,
283
+ workspace_topic: str,
284
+ workspace_session: str,
285
  progress: gr.Progress = gr.Progress(),
286
  ):
287
  results = list(
288
+ ingest_selected(
289
+ topic,
290
+ urls_text,
291
+ selected_urls,
292
+ upload_files,
293
+ session_id,
294
+ workspace_topic,
295
+ workspace_session,
296
+ progress,
297
+ )
298
  )
299
  results[2] = trace_as_dict(results[2])
300
  return tuple(results)
 
334
  return topic_up, message_up, rag_acc, use_rag
335
 
336
 
337
+ def build_teacher_voice_tab(workspace: WorkspaceWidgets) -> None:
338
  lang_choices = _config.language_choices()
339
  asr_choices = _config.asr_choices()
340
  default_lang = lang_choices[0][1] if lang_choices else "en"
 
570
 
571
  discover_btn.click(
572
  fn=_discover_for_json,
573
+ inputs=[topic_tb, session_dd, workspace.topic, workspace.session_dd],
574
  outputs=discover_outputs,
575
  ).then(
576
  fn=_update_rag_hint,
 
580
 
581
  auto_btn.click(
582
  fn=_auto_ingest_for_json,
583
+ inputs=[topic_tb, session_dd, workspace.topic, workspace.session_dd],
584
  outputs=discover_outputs,
585
  ).then(
586
  fn=_enable_rag_after_ingest,
 
590
 
591
  ingest_btn.click(
592
  fn=_ingest_for_json,
593
+ inputs=[
594
+ topic_tb,
595
+ urls_text,
596
+ url_choices,
597
+ upload_files,
598
+ session_dd,
599
+ workspace.topic,
600
+ workspace.session_dd,
601
+ ],
602
  outputs=[
603
  ingest_status,
604
  indexed_md,
 
630
  use_rag,
631
  session_dd,
632
  doc_dd,
633
+ workspace.topic,
634
+ workspace.session_dd,
635
+ workspace.doc_dd,
636
  ]
637
 
638
  voice_turn_inputs = [
 
645
  use_rag,
646
  session_dd,
647
  doc_dd,
648
+ workspace.topic,
649
+ workspace.session_dd,
650
+ workspace.doc_dd,
651
  ]
652
 
653
  send_text_btn.click(send_text_turn, inputs=text_turn_inputs, outputs=turn_outputs)
 
668
  outputs=[voiceout, status, speak_status],
669
  )
670
 
671
+ def _sync_topic_from_workspace(ws_topic: str, local_topic: str) -> str:
672
+ if not (local_topic or "").strip():
673
+ return ws_topic
674
+ return local_topic
675
+
676
+ def _sync_session_from_workspace(ws_session: str, local_session: str) -> str:
677
+ if not (local_session or "").strip():
678
+ return ws_session
679
+ return local_session
680
+
681
+ workspace.topic.change(
682
+ fn=_sync_topic_from_workspace,
683
+ inputs=[workspace.topic, topic_tb],
684
+ outputs=[topic_tb],
685
+ )
686
+ workspace.session_dd.change(
687
+ fn=_sync_session_from_workspace,
688
+ inputs=[workspace.session_dd, session_dd],
689
+ outputs=[session_dd],
690
+ ).then(
691
+ fn=refresh_doc_choices,
692
+ inputs=[session_dd, doc_dd],
693
+ outputs=[doc_dd],
694
+ )
695
+
696
 
697
  def teacher_voice_allowed_paths() -> list[str]:
698
  paths: list[str] = []
apps/gradio-space/src/gradio_space/ui/components.py CHANGED
@@ -51,6 +51,86 @@ def tab_hero(subtitle: str, steps: list[str] | None = None, active_step: int = 0
51
  return gr.HTML(html)
52
 
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  @dataclass
55
  class SessionPickerWidgets:
56
  session_dd: gr.Dropdown
 
51
  return gr.HTML(html)
52
 
53
 
54
+ @dataclass
55
+ class WorkspaceWidgets:
56
+ """Global workspace defaults (topic + ResearchMind session/RAG scope)."""
57
+
58
+ topic: gr.Textbox
59
+ session_dd: gr.Dropdown
60
+ refresh_btn: gr.Button
61
+ doc_dd: gr.CheckboxGroup
62
+ rag_hint: gr.Markdown
63
+
64
+ def wire(self) -> None:
65
+ self.refresh_btn.click(
66
+ fn=refresh_sessions,
67
+ inputs=[self.session_dd],
68
+ outputs=[self.session_dd],
69
+ )
70
+ self.session_dd.change(
71
+ fn=refresh_doc_choices,
72
+ inputs=[self.session_dd, self.doc_dd],
73
+ outputs=[self.doc_dd],
74
+ ).then(
75
+ fn=rag_scope_hint,
76
+ inputs=[self.session_dd, self.doc_dd],
77
+ outputs=[self.rag_hint],
78
+ )
79
+ self.doc_dd.change(
80
+ fn=rag_scope_hint,
81
+ inputs=[self.session_dd, self.doc_dd],
82
+ outputs=[self.rag_hint],
83
+ )
84
+
85
+
86
+ def build_workspace_bar() -> WorkspaceWidgets:
87
+ gr.Markdown(
88
+ "### Workspace",
89
+ elem_classes=["workspace-heading"],
90
+ )
91
+ gr.HTML(
92
+ '<p class="workspace-subtitle">'
93
+ "Default topic and ResearchMind session for all tabs. "
94
+ "Each tool can override these locally when needed."
95
+ "</p>"
96
+ )
97
+ with gr.Row(elem_classes=["workspace-bar"]):
98
+ topic = gr.Textbox(
99
+ label="Topic",
100
+ placeholder="e.g. Photosynthesis for 6th grade",
101
+ value="photosynthesis",
102
+ scale=3,
103
+ max_lines=1,
104
+ )
105
+ session_dd = gr.Dropdown(
106
+ label="ResearchMind session",
107
+ choices=list_session_choices(),
108
+ value="",
109
+ interactive=True,
110
+ scale=3,
111
+ )
112
+ refresh_btn = gr.Button("↻", size="sm", min_width=40, scale=0)
113
+
114
+ with gr.Accordion("Source scope (RAG)", open=False, elem_classes=["workspace-sources"]):
115
+ doc_dd = gr.CheckboxGroup(
116
+ label="Documents (empty = all in session)",
117
+ choices=[],
118
+ value=[],
119
+ elem_classes=DOC_CHOICE_LIST_CLASSES,
120
+ )
121
+ rag_hint = gr.Markdown(value=rag_scope_hint("", []))
122
+
123
+ workspace = WorkspaceWidgets(
124
+ topic=topic,
125
+ session_dd=session_dd,
126
+ refresh_btn=refresh_btn,
127
+ doc_dd=doc_dd,
128
+ rag_hint=rag_hint,
129
+ )
130
+ workspace.wire()
131
+ return workspace
132
+
133
+
134
  @dataclass
135
  class SessionPickerWidgets:
136
  session_dd: gr.Dropdown
apps/gradio-space/src/gradio_space/ui/studio_html.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ from typing import Any
5
+
6
+
7
+ def _icon_for_source(source_type: str) -> str:
8
+ st = (source_type or "").lower()
9
+ if st in ("web", "url", "scrape"):
10
+ return "language"
11
+ if st == "pdf":
12
+ return "picture_as_pdf"
13
+ return "description"
14
+
15
+
16
+ def render_doc_cards(documents: list[dict[str, Any]], *, rag_active: bool) -> str:
17
+ if not documents:
18
+ return (
19
+ '<p class="studio-empty-docs">No documents indexed yet. Paste a URL or upload a file.</p>'
20
+ )
21
+ badge = (
22
+ '<span class="studio-badge studio-badge-rag">RAG Active</span>'
23
+ if rag_active
24
+ else '<span class="studio-badge studio-badge-muted">No sources</span>'
25
+ )
26
+ cards: list[str] = []
27
+ for doc in documents:
28
+ title = html.escape(str(doc.get("title") or "Untitled"))
29
+ meta = html.escape(str(doc.get("meta") or ""))
30
+ icon = _icon_for_source(str(doc.get("source_type") or ""))
31
+ cards.append(
32
+ f"""
33
+ <div class="studio-doc-card" data-doc-id="{html.escape(str(doc.get("id", "")))}">
34
+ <span class="material-symbols-outlined studio-doc-icon">{icon}</span>
35
+ <div class="studio-doc-body">
36
+ <p class="studio-doc-title">{title}</p>
37
+ <p class="studio-doc-meta">{meta}</p>
38
+ </div>
39
+ </div>"""
40
+ )
41
+ return f'<div class="studio-doc-header">{badge}</div><div class="studio-doc-list">{"".join(cards)}</div>'
42
+
43
+
44
+ def render_slide_canvas(preview_html: str, *, empty_message: str | None = None) -> str:
45
+ if not preview_html or not preview_html.strip():
46
+ msg = html.escape(empty_message or "Generate slides to preview your lesson here.")
47
+ return f'<div class="studio-canvas-empty"><p>{msg}</p></div>'
48
+ return f'<div class="studio-canvas-inner">{preview_html}</div>'
49
+
50
+
51
+ def render_echo_coach_panel(
52
+ *,
53
+ pace_score: int | None = None,
54
+ wpm: float | None = None,
55
+ tip: str | None = None,
56
+ report_md: str | None = None,
57
+ listening: bool = False,
58
+ ) -> str:
59
+ if listening:
60
+ return """
61
+ <div class="studio-coach-panel studio-coach-live">
62
+ <div class="studio-coach-header">
63
+ <span class="studio-coach-dot"></span>
64
+ <span class="studio-coach-label">Recording…</span>
65
+ </div>
66
+ <p class="studio-coach-hint">Speak your lesson, then analyze for pace and filler feedback.</p>
67
+ </div>"""
68
+
69
+ if pace_score is None and not tip and not report_md:
70
+ return """
71
+ <div class="studio-coach-panel studio-coach-idle">
72
+ <p class="studio-coach-hint">Record a pitch in the Coach view, then click <strong>Analyze pitch</strong> for metrics.</p>
73
+ </div>"""
74
+
75
+ score = pace_score if pace_score is not None else "—"
76
+ pace = f"{wpm:.0f}" if wpm is not None else "—"
77
+ tip_html = html.escape(tip or "")
78
+ report_block = ""
79
+ if report_md:
80
+ safe = html.escape(report_md[:600])
81
+ report_block = f'<div class="studio-coach-report">{safe}</div>'
82
+
83
+ return f"""
84
+ <div class="studio-coach-panel studio-coach-results">
85
+ <div class="studio-coach-header">
86
+ <span class="studio-coach-label">Analysis results</span>
87
+ <span class="studio-coach-tag">EchoCoach</span>
88
+ </div>
89
+ <div class="studio-coach-metrics">
90
+ <div class="studio-metric">
91
+ <p class="studio-metric-label">Pace score</p>
92
+ <p class="studio-metric-value">{score}</p>
93
+ </div>
94
+ <div class="studio-metric">
95
+ <p class="studio-metric-label">Pace (WPM)</p>
96
+ <p class="studio-metric-value">{pace}</p>
97
+ </div>
98
+ </div>
99
+ {f'<p class="studio-coach-tip">{tip_html}</p>' if tip_html else ''}
100
+ {report_block}
101
+ </div>"""
apps/gradio-space/src/gradio_space/ui/styles.css CHANGED
@@ -178,14 +178,14 @@
178
  /* Only explicit primary CTAs get accent color */
179
  button.primary-cta,
180
  .primary-cta > button {
181
- background: #e86c00 !important;
182
- border-color: #cf6000 !important;
183
  color: #fff !important;
184
  }
185
 
186
  button.primary-cta:hover,
187
  .primary-cta > button:hover {
188
- background: #cf6000 !important;
189
  }
190
 
191
  /* Neutralize Gradio orange on tabs, labels, sliders */
@@ -312,6 +312,99 @@ button.primary-cta:hover,
312
  margin: 0.35rem 0 !important;
313
  }
314
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  .form-section-label {
316
  font-size: 0.8rem;
317
  font-weight: 600;
@@ -509,3 +602,29 @@ button.primary-cta:hover,
509
  font-size: 0.8rem;
510
  color: #888;
511
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  /* Only explicit primary CTAs get accent color */
179
  button.primary-cta,
180
  .primary-cta > button {
181
+ background: #a83300 !important;
182
+ border-color: #832600 !important;
183
  color: #fff !important;
184
  }
185
 
186
  button.primary-cta:hover,
187
  .primary-cta > button:hover {
188
+ background: #832600 !important;
189
  }
190
 
191
  /* Neutralize Gradio orange on tabs, labels, sliders */
 
312
  margin: 0.35rem 0 !important;
313
  }
314
 
315
+ .lesson-processing-log {
316
+ margin: 0.5rem 0 1rem !important;
317
+ }
318
+
319
+ .lesson-processing-log .slide-gen-log,
320
+ .slide-gen-log {
321
+ font-size: 0.82rem;
322
+ color: #374151;
323
+ background: #f9fafb;
324
+ border: 1px solid #e5e7eb;
325
+ border-radius: 10px;
326
+ padding: 0.75rem 1rem;
327
+ line-height: 1.45;
328
+ }
329
+
330
+ .slide-gen-log-banner {
331
+ font-weight: 600;
332
+ margin-bottom: 0.45rem;
333
+ }
334
+
335
+ .slide-gen-log-banner.running { color: #a83300; }
336
+ .slide-gen-log-banner.done { color: #2d6a4f; }
337
+ .slide-gen-log-banner.error { color: #ba1a1a; }
338
+
339
+ .slide-gen-log-meta {
340
+ color: #6b7280;
341
+ font-size: 0.8rem;
342
+ margin-bottom: 0.35rem;
343
+ }
344
+
345
+ .slide-gen-log-steps {
346
+ margin: 0.35rem 0 0;
347
+ padding-left: 1.1rem;
348
+ }
349
+
350
+ .slide-gen-log-step {
351
+ margin-bottom: 0.25rem;
352
+ }
353
+
354
+ .slide-gen-log-step.active {
355
+ color: #a83300;
356
+ font-weight: 600;
357
+ }
358
+
359
+ .slide-gen-log-step.done {
360
+ color: #2d6a4f;
361
+ }
362
+
363
+ .slide-gen-log-dur,
364
+ .slide-gen-log-detail {
365
+ color: #6b7280;
366
+ font-weight: 400;
367
+ }
368
+
369
+ .slide-gen-log-idle p {
370
+ margin: 0;
371
+ color: #6b7280;
372
+ }
373
+
374
+ .lesson-running-preview {
375
+ display: flex;
376
+ flex-direction: column;
377
+ align-items: center;
378
+ justify-content: center;
379
+ min-height: 220px;
380
+ padding: 2rem;
381
+ text-align: center;
382
+ color: #374151;
383
+ background: linear-gradient(180deg, #fafafa 0%, #f3f4f6 100%);
384
+ border: 1px dashed #d1d5db;
385
+ border-radius: 12px;
386
+ }
387
+
388
+ .lesson-running-spinner {
389
+ width: 36px;
390
+ height: 36px;
391
+ border: 3px solid #e5e7eb;
392
+ border-top-color: #a83300;
393
+ border-radius: 50%;
394
+ animation: lesson-spin 0.9s linear infinite;
395
+ margin-bottom: 1rem;
396
+ }
397
+
398
+ .lesson-running-hint {
399
+ margin: 0.35rem 0 0;
400
+ font-size: 0.85rem;
401
+ color: #6b7280;
402
+ }
403
+
404
+ @keyframes lesson-spin {
405
+ to { transform: rotate(360deg); }
406
+ }
407
+
408
  .form-section-label {
409
  font-size: 0.8rem;
410
  font-weight: 600;
 
602
  font-size: 0.8rem;
603
  color: #888;
604
  }
605
+
606
+ /* Global workspace bar (topic + session/RAG defaults) */
607
+ .workspace-heading {
608
+ margin: 0.75rem 0 0 !important;
609
+ font-size: 0.95rem !important;
610
+ }
611
+
612
+ .workspace-subtitle {
613
+ color: #666;
614
+ font-size: 0.82rem;
615
+ margin: 0 0 0.5rem 0 !important;
616
+ }
617
+
618
+ .workspace-bar {
619
+ align-items: flex-end !important;
620
+ gap: 0.75rem !important;
621
+ padding: 0.5rem 0.75rem !important;
622
+ margin-bottom: 0.5rem !important;
623
+ background: #f9fafb;
624
+ border: 1px solid #e5e7eb;
625
+ border-radius: 10px;
626
+ }
627
+
628
+ .workspace-sources {
629
+ margin-bottom: 0.75rem !important;
630
+ }
apps/gradio-space/static/studio/index.html ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Build Small — AI Studio</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet" />
10
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=swap" rel="stylesheet" />
11
+ <link rel="stylesheet" href="/static/studio/studio.css" />
12
+ </head>
13
+ <body>
14
+ <div id="studio-error" class="studio-banner studio-banner-error hidden" role="alert"></div>
15
+ <div id="studio-loading" class="studio-banner studio-banner-loading hidden">Working…</div>
16
+
17
+ <aside id="sidebar" class="sidebar">
18
+ <button type="button" id="sidebar-close" class="sidebar-close material-symbols-outlined" aria-label="Close menu">close</button>
19
+ <div class="sidebar-brand">
20
+ <h1>Build Small</h1>
21
+ <p>AI Studio</p>
22
+ </div>
23
+ <button type="button" id="btn-new-session" class="btn btn-primary btn-block">
24
+ <span class="material-symbols-outlined">add</span>
25
+ New session topic
26
+ </button>
27
+ <nav class="sidebar-nav">
28
+ <button type="button" class="nav-item" data-view="research"><span class="material-symbols-outlined">search</span>Research</button>
29
+ <button type="button" class="nav-item active" data-view="slides"><span class="material-symbols-outlined">present_to_all</span>Slides</button>
30
+ <button type="button" class="nav-item" data-view="voice"><span class="material-symbols-outlined">mic</span>Voice</button>
31
+ <button type="button" class="nav-item" data-view="coach"><span class="material-symbols-outlined">school</span>Coach</button>
32
+ <a href="/classic" class="nav-item nav-link"><span class="material-symbols-outlined">settings</span>Classic / Settings</a>
33
+ </nav>
34
+ <div class="sidebar-footer">
35
+ <p class="sidebar-foot-label">Powered by local small models</p>
36
+ <a href="/classic">Open Classic UI</a>
37
+ </div>
38
+ </aside>
39
+
40
+ <header class="topbar">
41
+ <button type="button" id="sidebar-open" class="topbar-icon material-symbols-outlined" aria-label="Open menu">menu</button>
42
+ <nav class="breadcrumb">
43
+ <span>Projects</span>
44
+ <span class="material-symbols-outlined crumb-sep">chevron_right</span>
45
+ <strong id="project-title">Photosynthesis</strong>
46
+ </nav>
47
+ <div class="topbar-actions">
48
+ <a href="/classic" class="btn btn-ghost">Classic UI</a>
49
+ <button type="button" id="btn-export" class="btn btn-primary" disabled>Export</button>
50
+ </div>
51
+ </header>
52
+
53
+ <div class="workspace-context-bar" id="workspace-context-bar">
54
+ <div class="workspace-context-inner">
55
+ <label class="field ws-field">
56
+ <span>Workspace topic</span>
57
+ <input id="workspace-topic" type="text" class="input" value="photosynthesis" placeholder="e.g. Photosynthesis for 6th grade" />
58
+ </label>
59
+ <label class="field ws-field">
60
+ <span>ResearchMind session</span>
61
+ <select id="workspace-session" class="input">
62
+ <option value="">New session (on ingest)</option>
63
+ </select>
64
+ </label>
65
+ <button type="button" id="workspace-refresh-sessions" class="btn btn-ghost btn-icon" title="Refresh sessions">↻</button>
66
+ <details class="workspace-docs-details" id="workspace-docs-details">
67
+ <summary>Source scope (RAG)</summary>
68
+ <div id="workspace-doc-list" class="workspace-doc-list"></div>
69
+ <p id="workspace-rag-hint" class="status-text"></p>
70
+ </details>
71
+ </div>
72
+ </div>
73
+
74
+ <main class="workspace" data-view="slides">
75
+ <section class="col col-research">
76
+ <div class="research-layout">
77
+ <div class="research-sources">
78
+ <div class="research-sources-head">
79
+ <h2 class="section-label">
80
+ <span class="label-full">Step 1 · Research Library</span>
81
+ <span class="label-compact">Sources for slides</span>
82
+ </h2>
83
+ <span id="research-doc-count" class="research-doc-count hidden">0 docs</span>
84
+ </div>
85
+ <div class="card card-ingest">
86
+ <p class="card-title">Add sources</p>
87
+ <label class="field ingest-workflow-field">
88
+ <span>Ingest workflow</span>
89
+ <select id="ingest-workflow" class="input">
90
+ <option value="select">Discover &amp; select URLs</option>
91
+ <option value="auto">Auto-scrape (no URL pick)</option>
92
+ <option value="direct">Paste URL or upload</option>
93
+ </select>
94
+ </label>
95
+ <div id="ingest-discover-row" class="ingest-action-row">
96
+ <button type="button" id="btn-discover" class="btn btn-secondary btn-block">
97
+ <span class="material-symbols-outlined">travel_explore</span>
98
+ Discover on web
99
+ </button>
100
+ </div>
101
+ <div id="ingest-auto-row" class="ingest-action-row hidden">
102
+ <button type="button" id="btn-auto-ingest" class="btn btn-secondary btn-block">
103
+ <span class="material-symbols-outlined">bolt</span>
104
+ Run auto-ingest
105
+ </button>
106
+ <p class="status-text ingest-hint-full">Skips URL review — searches, verifies, and indexes in one step.</p>
107
+ </div>
108
+ <div id="url-choices-panel" class="url-choices-panel hidden">
109
+ <div class="url-choices-head">
110
+ <p class="card-title">Suggested URLs</p>
111
+ <label class="url-select-all">
112
+ <input id="url-select-all" type="checkbox" checked />
113
+ Select all
114
+ </label>
115
+ </div>
116
+ <div id="url-choices-list" class="url-choices-list"></div>
117
+ </div>
118
+ <div class="ingest-url-row">
119
+ <input id="ingest-url" type="url" class="input" placeholder="Paste URL…" />
120
+ <button type="button" id="btn-ingest-url" class="btn btn-secondary">Ingest</button>
121
+ </div>
122
+ <p class="ingest-url-caption status-text">Ingests pasted URL plus any selected sources above.</p>
123
+ <label class="upload-zone upload-zone-compact">
124
+ <input id="ingest-file" type="file" accept=".pdf,.docx" multiple hidden />
125
+ <span class="material-symbols-outlined">upload_file</span>
126
+ <span>Upload PDF or Doc</span>
127
+ </label>
128
+ <p id="ingest-status" class="status-text"></p>
129
+ </div>
130
+ <div class="card card-grow card-docs-rail">
131
+ <div class="card-row">
132
+ <h3 class="card-title">Indexed documents</h3>
133
+ </div>
134
+ <div id="documents-panel" class="documents-panel-scroll"></div>
135
+ </div>
136
+ <div class="research-rail-footer">
137
+ <button type="button" id="btn-open-research-view" class="btn btn-ghost btn-block research-rail-link">
138
+ <span class="material-symbols-outlined">forum</span>
139
+ Open Research chat
140
+ </button>
141
+ </div>
142
+ </div>
143
+ <div class="research-chat" id="research-chat-panel">
144
+ <h2 class="section-label label-full">Step 2 · Ask with RAG</h2>
145
+ <div class="card card-chat">
146
+ <div class="card-row">
147
+ <h3 class="card-title">Research chat</h3>
148
+ <span id="research-rag-badge" class="rag-badge">RAG ready</span>
149
+ </div>
150
+ <div id="research-chat-messages" class="research-chat-messages">
151
+ <p class="research-chat-empty">Ingest sources, then ask questions — answers include citations from your library.</p>
152
+ </div>
153
+ <label class="field">
154
+ <span>Your question</span>
155
+ <textarea id="research-question" class="input" rows="3" placeholder="What do these sources say about your topic?"></textarea>
156
+ </label>
157
+ <button type="button" id="btn-research-ask" class="btn btn-primary btn-block">
158
+ <span class="material-symbols-outlined">chat</span>
159
+ Ask
160
+ </button>
161
+ <p id="research-chat-status" class="status-text"></p>
162
+ </div>
163
+ </div>
164
+ </div>
165
+ </section>
166
+
167
+ <section class="col col-slides">
168
+ <div class="card card-tall">
169
+ <div class="card-header">
170
+ <div class="step-badge">2</div>
171
+ <h2>Lesson Creation</h2>
172
+ </div>
173
+ <div class="controls-panel">
174
+ <div class="controls-grid">
175
+ <label class="field">
176
+ <span>Topic override (optional)</span>
177
+ <input id="lesson-topic" type="text" class="input" placeholder="Uses workspace topic when empty" />
178
+ </label>
179
+ <label class="field">
180
+ <span>Grade</span>
181
+ <select id="lesson-grade" class="input">
182
+ <option value="6" selected>6</option>
183
+ <option value="5">5</option>
184
+ <option value="7">7</option>
185
+ <option value="8">8</option>
186
+ <option value="Adult">Adult</option>
187
+ </select>
188
+ </label>
189
+ <label class="field field-wide">
190
+ <span>Slides: <strong id="slide-count-val">5</strong></span>
191
+ <input id="slide-count" type="range" min="3" max="8" value="5" />
192
+ </label>
193
+ </div>
194
+ <div class="controls-actions">
195
+ <button type="button" id="btn-generate" class="btn btn-primary">
196
+ <span class="material-symbols-outlined">auto_awesome</span>
197
+ Generate Slides
198
+ </button>
199
+ </div>
200
+ <p id="generate-status" class="status-text">Ready to generate.</p>
201
+ <div id="progress-panel" class="progress-panel">
202
+ <div class="progress-panel-head">
203
+ <span id="progress-elapsed" class="progress-elapsed">Elapsed: 0s</span>
204
+ <span id="progress-eta" class="progress-eta"></span>
205
+ </div>
206
+ <div class="progress-bar-track" aria-hidden="true">
207
+ <div id="progress-bar-fill" class="progress-bar-fill" style="width: 0%"></div>
208
+ </div>
209
+ <p id="progress-current" class="progress-current">Idle</p>
210
+ <ol id="progress-steps" class="progress-steps"></ol>
211
+ <div id="progress-log" class="progress-log hidden" aria-live="polite"></div>
212
+ </div>
213
+ </div>
214
+ <div id="slide-canvas" class="slide-canvas">
215
+ <div id="canvas-overlay" class="canvas-overlay hidden" aria-live="polite">
216
+ <div class="canvas-overlay-inner">
217
+ <span class="canvas-spinner" aria-hidden="true"></span>
218
+ <p id="canvas-overlay-text">Generating slides…</p>
219
+ <p class="canvas-overlay-hint">Local CPU models often take 30–90 seconds.</p>
220
+ </div>
221
+ </div>
222
+ <div id="slide-canvas-content" class="slide-canvas-content">
223
+ <div class="studio-canvas-empty"><p>Generate slides to preview your lesson here.</p></div>
224
+ </div>
225
+ </div>
226
+ <div id="downloads" class="downloads hidden"></div>
227
+ </div>
228
+ </section>
229
+
230
+ <section class="col col-studio">
231
+ <h2 class="section-label">Step 3 · Studio Controls</h2>
232
+ <div class="card">
233
+ <p class="card-title">RAG Scope</p>
234
+ <label class="toggle-row">
235
+ <span>Cross-Reference Sources</span>
236
+ <input id="use-rag" type="checkbox" checked />
237
+ </label>
238
+ <p class="status-text">Session and documents use workspace defaults above unless overridden per tool.</p>
239
+ </div>
240
+ <div class="card">
241
+ <p class="card-title">Teacher Voice Mode</p>
242
+ <div class="mode-cards" id="voice-modes">
243
+ <button type="button" class="mode-card" data-mode="explain">Explain</button>
244
+ <button type="button" class="mode-card active" data-mode="lesson">Coach</button>
245
+ <button type="button" class="mode-card" data-mode="pitch">Practice</button>
246
+ </div>
247
+ <label class="field voice-panel" id="voice-panel">
248
+ <span>Ask the teacher</span>
249
+ <textarea id="voice-message" class="input" rows="3" placeholder="What are the main steps of photosynthesis?"></textarea>
250
+ <button type="button" id="btn-voice-send" class="btn btn-secondary btn-block">Send message</button>
251
+ <div id="voice-reply" class="voice-reply"></div>
252
+ </label>
253
+ </div>
254
+ <div class="card coach-panel-wrap">
255
+ <h2 class="section-label">EchoCoach Feedback</h2>
256
+ <label class="field">
257
+ <span>Record or upload pitch (WAV)</span>
258
+ <input id="coach-audio" type="file" accept="audio/*" />
259
+ </label>
260
+ <button type="button" id="btn-analyze" class="btn btn-secondary btn-block">Analyze pitch</button>
261
+ <div id="coach-panel"></div>
262
+ </div>
263
+ </section>
264
+ </main>
265
+
266
+ <script type="module" src="/static/studio/studio.js"></script>
267
+ </body>
268
+ </html>
apps/gradio-space/static/studio/studio.css ADDED
@@ -0,0 +1,982 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --primary: #a83300;
3
+ --primary-container: #cb4a18;
4
+ --primary-fixed: #ffdbd0;
5
+ --on-primary: #ffffff;
6
+ --background: #f7f9fb;
7
+ --surface: #f7f9fb;
8
+ --surface-container-low: #f2f4f6;
9
+ --surface-container: #eceef0;
10
+ --secondary: #565e74;
11
+ --secondary-container: #dae2fd;
12
+ --on-secondary-container: #5c647a;
13
+ --outline-variant: #e1bfb5;
14
+ --on-surface: #191c1e;
15
+ --inverse-surface: #2d3133;
16
+ --inverse-on-surface: #eff1f3;
17
+ --error: #ba1a1a;
18
+ --sidebar-w: 280px;
19
+ --topbar-h: 64px;
20
+ --context-bar-h: 72px;
21
+ --radius-lg: 12px;
22
+ --radius-xl: 16px;
23
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
24
+ }
25
+
26
+ * { box-sizing: border-box; }
27
+
28
+ body {
29
+ margin: 0;
30
+ font-family: "Hanken Grotesk", system-ui, sans-serif;
31
+ background: var(--background);
32
+ color: var(--on-surface);
33
+ -webkit-font-smoothing: antialiased;
34
+ }
35
+
36
+ .material-symbols-outlined {
37
+ font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
38
+ vertical-align: middle;
39
+ }
40
+
41
+ .hidden { display: none !important; }
42
+
43
+ .studio-banner {
44
+ position: fixed;
45
+ top: 0;
46
+ left: var(--sidebar-w);
47
+ right: 0;
48
+ z-index: 100;
49
+ padding: 0.5rem 1rem;
50
+ font-size: 0.875rem;
51
+ text-align: center;
52
+ }
53
+
54
+ .studio-banner-loading { background: var(--secondary-container); color: var(--on-secondary-container); }
55
+ .studio-banner-error { background: #ffdad6; color: #93000a; }
56
+
57
+ .sidebar {
58
+ position: fixed;
59
+ inset: 0 auto 0 0;
60
+ width: var(--sidebar-w);
61
+ background: var(--surface);
62
+ border-right: 1px solid var(--outline-variant);
63
+ display: flex;
64
+ flex-direction: column;
65
+ padding: 1.5rem 1rem;
66
+ gap: 0.5rem;
67
+ z-index: 50;
68
+ }
69
+
70
+ .sidebar-brand h1 {
71
+ margin: 0;
72
+ font-size: 1.15rem;
73
+ color: var(--primary);
74
+ }
75
+
76
+ .sidebar-brand p {
77
+ margin: 0.15rem 0 0;
78
+ font-size: 0.875rem;
79
+ color: var(--secondary);
80
+ }
81
+
82
+ .sidebar-nav {
83
+ display: flex;
84
+ flex-direction: column;
85
+ gap: 0.25rem;
86
+ flex: 1;
87
+ margin-top: 0.5rem;
88
+ }
89
+
90
+ .nav-item {
91
+ display: flex;
92
+ align-items: center;
93
+ gap: 0.75rem;
94
+ padding: 0.5rem 1rem;
95
+ border: none;
96
+ border-radius: 8px;
97
+ background: transparent;
98
+ color: var(--secondary);
99
+ font: inherit;
100
+ font-size: 0.9rem;
101
+ cursor: pointer;
102
+ text-decoration: none;
103
+ text-align: left;
104
+ }
105
+
106
+ .nav-item:hover { background: var(--surface-container-low); }
107
+ .nav-item.active {
108
+ background: var(--secondary-container);
109
+ color: var(--on-secondary-container);
110
+ font-weight: 600;
111
+ }
112
+
113
+ .sidebar-footer {
114
+ border-top: 1px solid var(--outline-variant);
115
+ padding-top: 1rem;
116
+ font-size: 0.8rem;
117
+ color: var(--secondary);
118
+ }
119
+
120
+ .sidebar-footer a { color: var(--primary); }
121
+
122
+ .sidebar-close, .topbar-icon { display: none; }
123
+
124
+ .topbar {
125
+ position: fixed;
126
+ top: 0;
127
+ left: var(--sidebar-w);
128
+ right: 0;
129
+ height: var(--topbar-h);
130
+ background: var(--surface);
131
+ border-bottom: 1px solid var(--outline-variant);
132
+ display: flex;
133
+ align-items: center;
134
+ justify-content: space-between;
135
+ padding: 0 2rem;
136
+ z-index: 40;
137
+ }
138
+
139
+ .breadcrumb {
140
+ display: flex;
141
+ align-items: center;
142
+ gap: 0.5rem;
143
+ font-size: 0.9rem;
144
+ color: var(--secondary);
145
+ }
146
+
147
+ .breadcrumb strong {
148
+ color: var(--on-surface);
149
+ border-bottom: 2px solid var(--primary);
150
+ padding-bottom: 0.35rem;
151
+ }
152
+
153
+ .crumb-sep { font-size: 1rem; }
154
+
155
+ .topbar-actions { display: flex; gap: 0.75rem; align-items: center; }
156
+
157
+ .workspace-context-bar {
158
+ position: fixed;
159
+ top: var(--topbar-h);
160
+ left: var(--sidebar-w);
161
+ right: 0;
162
+ z-index: 35;
163
+ background: #fff;
164
+ border-bottom: 1px solid var(--outline-variant);
165
+ padding: 0.5rem 1.5rem;
166
+ }
167
+
168
+ .workspace-context-inner {
169
+ display: flex;
170
+ flex-wrap: wrap;
171
+ align-items: flex-end;
172
+ gap: 0.75rem;
173
+ max-width: 1440px;
174
+ }
175
+
176
+ .ws-field {
177
+ min-width: 160px;
178
+ flex: 1 1 180px;
179
+ }
180
+
181
+ .ws-field span {
182
+ display: block;
183
+ font-size: 0.68rem;
184
+ font-weight: 600;
185
+ text-transform: uppercase;
186
+ letter-spacing: 0.05em;
187
+ color: var(--secondary);
188
+ margin-bottom: 0.25rem;
189
+ }
190
+
191
+ .btn-icon {
192
+ min-width: 2.25rem;
193
+ padding: 0.45rem 0.6rem;
194
+ }
195
+
196
+ .workspace-docs-details {
197
+ flex: 1 1 220px;
198
+ font-size: 0.85rem;
199
+ }
200
+
201
+ .workspace-docs-details summary {
202
+ cursor: pointer;
203
+ font-weight: 600;
204
+ color: var(--secondary);
205
+ margin-bottom: 0.35rem;
206
+ }
207
+
208
+ .workspace-doc-list {
209
+ display: flex;
210
+ flex-direction: column;
211
+ gap: 0.35rem;
212
+ max-height: 140px;
213
+ overflow-y: auto;
214
+ margin-top: 0.35rem;
215
+ }
216
+
217
+ .workspace-doc-item {
218
+ display: flex;
219
+ gap: 0.5rem;
220
+ align-items: flex-start;
221
+ font-size: 0.82rem;
222
+ padding: 0.25rem 0;
223
+ }
224
+
225
+ .workspace {
226
+ margin-left: var(--sidebar-w);
227
+ padding: calc(var(--topbar-h) + var(--context-bar-h) + 1.5rem) 1.5rem 1.5rem;
228
+ display: grid;
229
+ grid-template-columns: 1fr 2fr 1fr;
230
+ gap: 1.5rem;
231
+ min-height: 100vh;
232
+ max-width: 1440px;
233
+ }
234
+
235
+ .col { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
236
+
237
+ .section-label {
238
+ margin: 0;
239
+ font-size: 0.72rem;
240
+ font-weight: 600;
241
+ text-transform: uppercase;
242
+ letter-spacing: 0.06em;
243
+ color: var(--secondary);
244
+ }
245
+
246
+ .card {
247
+ background: #fff;
248
+ border: 1px solid var(--outline-variant);
249
+ border-radius: var(--radius-xl);
250
+ padding: 1rem;
251
+ box-shadow: var(--shadow-sm);
252
+ }
253
+
254
+ .card-tall { flex: 1; display: flex; flex-direction: column; }
255
+ .card-grow { flex: 1; overflow: auto; }
256
+ .card-title { margin: 0 0 0.75rem; font-weight: 600; font-size: 0.95rem; }
257
+ .card-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem; }
258
+ .card-header h2 { margin: 0; font-size: 1.1rem; }
259
+
260
+ .step-badge {
261
+ width: 2rem;
262
+ height: 2rem;
263
+ border-radius: 50%;
264
+ background: var(--primary-fixed);
265
+ color: var(--primary);
266
+ display: flex;
267
+ align-items: center;
268
+ justify-content: center;
269
+ font-weight: 700;
270
+ font-size: 0.875rem;
271
+ }
272
+
273
+ .btn {
274
+ display: inline-flex;
275
+ align-items: center;
276
+ justify-content: center;
277
+ gap: 0.35rem;
278
+ padding: 0.5rem 1rem;
279
+ border-radius: 8px;
280
+ border: 1px solid transparent;
281
+ font: inherit;
282
+ font-size: 0.875rem;
283
+ font-weight: 500;
284
+ cursor: pointer;
285
+ transition: opacity 0.15s, transform 0.1s;
286
+ }
287
+
288
+ .btn:active { transform: scale(0.98); }
289
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
290
+ .btn-primary { background: var(--primary); color: var(--on-primary); }
291
+ .btn-secondary { background: #fff; border-color: var(--outline-variant); color: var(--on-surface); }
292
+ .btn-ghost { background: transparent; border-color: var(--outline-variant); color: var(--secondary); text-decoration: none; }
293
+ .btn-block { width: 100%; margin-top: 0.5rem; }
294
+
295
+ .input {
296
+ width: 100%;
297
+ padding: 0.6rem 0.75rem;
298
+ border: 1px solid var(--outline-variant);
299
+ border-radius: 8px;
300
+ font: inherit;
301
+ font-size: 0.9rem;
302
+ background: #fff;
303
+ }
304
+
305
+ .input:focus {
306
+ outline: none;
307
+ border-color: var(--primary);
308
+ box-shadow: 0 0 0 2px rgba(168, 51, 0, 0.15);
309
+ }
310
+
311
+ .upload-zone {
312
+ display: flex;
313
+ flex-direction: column;
314
+ align-items: center;
315
+ gap: 0.35rem;
316
+ margin-top: 0.5rem;
317
+ padding: 1rem;
318
+ border: 1px dashed var(--outline-variant);
319
+ border-radius: 8px;
320
+ color: var(--secondary);
321
+ cursor: pointer;
322
+ font-size: 0.875rem;
323
+ }
324
+
325
+ .upload-zone:hover { background: var(--surface-container-low); }
326
+
327
+ .status-text {
328
+ margin: 0.5rem 0 0;
329
+ font-size: 0.82rem;
330
+ color: var(--secondary);
331
+ line-height: 1.4;
332
+ }
333
+
334
+ .controls-panel {
335
+ background: var(--surface-container-low);
336
+ border: 1px solid var(--outline-variant);
337
+ border-radius: var(--radius-lg);
338
+ padding: 1rem;
339
+ margin-bottom: 1rem;
340
+ }
341
+
342
+ .controls-grid {
343
+ display: grid;
344
+ grid-template-columns: 1fr 1fr;
345
+ gap: 0.75rem;
346
+ }
347
+
348
+ .field { display: flex; flex-direction: column; gap: 0.35rem; font-size: 0.82rem; color: var(--secondary); }
349
+ .field-wide { grid-column: 1 / -1; }
350
+
351
+ .controls-actions { display: flex; justify-content: flex-end; margin-top: 0.75rem; }
352
+
353
+ .slide-canvas {
354
+ position: relative;
355
+ flex: 1;
356
+ min-height: 320px;
357
+ border: 2px dashed var(--outline-variant);
358
+ border-radius: var(--radius-xl);
359
+ background: rgba(216, 218, 220, 0.15);
360
+ overflow: auto;
361
+ padding: 1rem;
362
+ }
363
+
364
+ .slide-canvas-content {
365
+ min-height: 280px;
366
+ }
367
+
368
+ .canvas-overlay {
369
+ position: absolute;
370
+ inset: 0;
371
+ z-index: 5;
372
+ display: flex;
373
+ align-items: center;
374
+ justify-content: center;
375
+ background: rgba(247, 249, 251, 0.88);
376
+ backdrop-filter: blur(2px);
377
+ border-radius: var(--radius-lg);
378
+ }
379
+
380
+ .canvas-overlay-inner {
381
+ text-align: center;
382
+ padding: 1.5rem;
383
+ }
384
+
385
+ .canvas-spinner,
386
+ .lesson-running-spinner {
387
+ display: inline-block;
388
+ width: 36px;
389
+ height: 36px;
390
+ border: 3px solid var(--outline-variant);
391
+ border-top-color: var(--primary);
392
+ border-radius: 50%;
393
+ animation: studio-spin 0.9s linear infinite;
394
+ margin-bottom: 0.75rem;
395
+ }
396
+
397
+ .canvas-overlay-hint {
398
+ margin: 0.35rem 0 0;
399
+ font-size: 0.85rem;
400
+ color: var(--secondary);
401
+ }
402
+
403
+ @keyframes studio-spin {
404
+ to { transform: rotate(360deg); }
405
+ }
406
+
407
+ .btn.is-loading {
408
+ opacity: 0.85;
409
+ pointer-events: none;
410
+ }
411
+
412
+ .progress-panel {
413
+ margin-top: 0.75rem;
414
+ padding: 0.75rem 1rem;
415
+ border: 1px solid var(--outline-variant);
416
+ border-radius: var(--radius-lg);
417
+ background: var(--surface-container-low);
418
+ font-size: 0.875rem;
419
+ }
420
+
421
+ .progress-panel-head {
422
+ display: flex;
423
+ justify-content: space-between;
424
+ gap: 1rem;
425
+ margin-bottom: 0.5rem;
426
+ color: var(--secondary);
427
+ font-weight: 600;
428
+ }
429
+
430
+ .progress-bar-track {
431
+ height: 6px;
432
+ background: var(--surface-container);
433
+ border-radius: 999px;
434
+ overflow: hidden;
435
+ margin-bottom: 0.5rem;
436
+ }
437
+
438
+ .progress-bar-fill {
439
+ height: 100%;
440
+ background: linear-gradient(90deg, var(--primary), var(--primary-container));
441
+ border-radius: 999px;
442
+ transition: width 0.4s ease;
443
+ }
444
+
445
+ .progress-current {
446
+ margin: 0 0 0.35rem;
447
+ font-weight: 600;
448
+ color: var(--primary);
449
+ }
450
+
451
+ .progress-steps {
452
+ margin: 0;
453
+ padding-left: 1.25rem;
454
+ display: grid;
455
+ gap: 0.35rem;
456
+ }
457
+
458
+ .progress-step.pending { color: var(--secondary); }
459
+ .progress-step.active { color: var(--primary); font-weight: 600; }
460
+ .progress-step.done { color: #2d6a4f; }
461
+
462
+ .progress-log {
463
+ margin: 0.75rem 0 0;
464
+ padding: 0.75rem;
465
+ border-radius: var(--radius-lg);
466
+ background: var(--surface);
467
+ border: 1px solid var(--outline-variant);
468
+ white-space: pre-wrap;
469
+ font-size: 0.8rem;
470
+ max-height: 220px;
471
+ overflow: auto;
472
+ }
473
+
474
+ @media (max-width: 1100px) {
475
+ .workspace { grid-template-columns: 1fr; }
476
+ .col-research, .col-studio { order: 2; }
477
+ .col-slides { order: 1; }
478
+ }
479
+
480
+ .studio-canvas-empty {
481
+ display: flex;
482
+ align-items: center;
483
+ justify-content: center;
484
+ height: 100%;
485
+ min-height: 280px;
486
+ color: var(--secondary);
487
+ text-align: center;
488
+ padding: 1rem;
489
+ }
490
+
491
+ .studio-canvas-inner { font-size: 0.95rem; line-height: 1.5; }
492
+
493
+ .downloads {
494
+ margin-top: 0.75rem;
495
+ display: flex;
496
+ flex-wrap: wrap;
497
+ gap: 0.5rem;
498
+ }
499
+
500
+ .downloads a {
501
+ font-size: 0.82rem;
502
+ color: var(--primary);
503
+ }
504
+
505
+ .studio-badge {
506
+ font-size: 0.62rem;
507
+ font-weight: 700;
508
+ padding: 0.15rem 0.5rem;
509
+ border-radius: 999px;
510
+ text-transform: uppercase;
511
+ }
512
+
513
+ .studio-badge-rag { background: var(--primary-fixed); color: #390c00; }
514
+ .studio-badge-muted { background: var(--surface-container); color: var(--secondary); }
515
+
516
+ .studio-doc-header { margin-bottom: 0.5rem; }
517
+ .studio-doc-list { display: flex; flex-direction: column; gap: 0.5rem; }
518
+
519
+ .studio-doc-card {
520
+ display: flex;
521
+ gap: 0.5rem;
522
+ padding: 0.5rem;
523
+ border: 1px solid var(--outline-variant);
524
+ border-radius: 8px;
525
+ background: #fff;
526
+ }
527
+
528
+ .studio-doc-card:hover { border-color: var(--primary); }
529
+ .studio-doc-icon { color: var(--primary); font-size: 1.25rem; }
530
+ .studio-doc-title { margin: 0; font-size: 0.875rem; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
531
+ .studio-doc-meta { margin: 0.15rem 0 0; font-size: 0.68rem; color: var(--secondary); }
532
+
533
+ .studio-empty-docs { font-size: 0.85rem; color: var(--secondary); margin: 0; }
534
+
535
+ .toggle-row {
536
+ display: flex;
537
+ align-items: center;
538
+ justify-content: space-between;
539
+ padding: 0.5rem;
540
+ background: var(--surface-container-low);
541
+ border-radius: 8px;
542
+ font-size: 0.875rem;
543
+ }
544
+
545
+ .mode-cards { display: flex; flex-direction: column; gap: 0.35rem; }
546
+
547
+ .mode-card {
548
+ display: flex;
549
+ justify-content: space-between;
550
+ padding: 0.6rem 0.75rem;
551
+ border: 1px solid var(--outline-variant);
552
+ border-radius: 8px;
553
+ background: #fff;
554
+ font: inherit;
555
+ cursor: pointer;
556
+ text-align: left;
557
+ }
558
+
559
+ .mode-card.active {
560
+ background: var(--primary-fixed);
561
+ border-color: var(--primary);
562
+ color: var(--primary);
563
+ font-weight: 600;
564
+ }
565
+
566
+ .voice-reply {
567
+ margin-top: 0.75rem;
568
+ font-size: 0.875rem;
569
+ line-height: 1.5;
570
+ color: var(--on-surface);
571
+ white-space: pre-wrap;
572
+ }
573
+
574
+ .studio-coach-panel {
575
+ margin-top: 0.75rem;
576
+ padding: 1rem;
577
+ border-radius: var(--radius-lg);
578
+ background: var(--inverse-surface);
579
+ color: var(--inverse-on-surface);
580
+ font-size: 0.85rem;
581
+ }
582
+
583
+ .studio-coach-header {
584
+ display: flex;
585
+ justify-content: space-between;
586
+ align-items: center;
587
+ margin-bottom: 0.75rem;
588
+ }
589
+
590
+ .studio-coach-label {
591
+ font-size: 0.65rem;
592
+ text-transform: uppercase;
593
+ letter-spacing: 0.08em;
594
+ opacity: 0.85;
595
+ }
596
+
597
+ .studio-coach-tag {
598
+ font-size: 0.65rem;
599
+ background: rgba(255, 255, 255, 0.1);
600
+ padding: 0.15rem 0.5rem;
601
+ border-radius: 4px;
602
+ }
603
+
604
+ .studio-coach-dot {
605
+ width: 0.5rem;
606
+ height: 0.5rem;
607
+ border-radius: 50%;
608
+ background: #ef4444;
609
+ display: inline-block;
610
+ margin-right: 0.35rem;
611
+ animation: pulse 1.2s infinite;
612
+ }
613
+
614
+ @keyframes pulse {
615
+ 0%, 100% { opacity: 1; }
616
+ 50% { opacity: 0.4; }
617
+ }
618
+
619
+ .studio-coach-metrics {
620
+ display: grid;
621
+ grid-template-columns: 1fr 1fr;
622
+ gap: 0.5rem;
623
+ margin-bottom: 0.75rem;
624
+ }
625
+
626
+ .studio-metric {
627
+ padding: 0.5rem;
628
+ border: 1px solid rgba(255, 255, 255, 0.1);
629
+ border-radius: 8px;
630
+ background: rgba(255, 255, 255, 0.05);
631
+ }
632
+
633
+ .studio-metric-label {
634
+ margin: 0;
635
+ font-size: 0.62rem;
636
+ text-transform: uppercase;
637
+ opacity: 0.65;
638
+ }
639
+
640
+ .studio-metric-value {
641
+ margin: 0.25rem 0 0;
642
+ font-size: 1.25rem;
643
+ font-weight: 700;
644
+ color: var(--primary-fixed);
645
+ }
646
+
647
+ .studio-coach-tip {
648
+ margin: 0;
649
+ font-style: italic;
650
+ opacity: 0.85;
651
+ line-height: 1.45;
652
+ }
653
+
654
+ .studio-coach-hint { margin: 0; opacity: 0.8; line-height: 1.45; }
655
+
656
+ .workspace[data-view="research"] .col-slides,
657
+ .workspace[data-view="research"] .col-studio { display: none; }
658
+ .workspace[data-view="research"] {
659
+ grid-template-columns: 1fr;
660
+ max-width: 1120px;
661
+ }
662
+
663
+ .label-compact,
664
+ .research-rail-footer,
665
+ .research-doc-count { display: none; }
666
+
667
+ .research-layout {
668
+ display: grid;
669
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
670
+ gap: 1.25rem;
671
+ width: 100%;
672
+ }
673
+
674
+ .research-sources,
675
+ .research-chat {
676
+ display: flex;
677
+ flex-direction: column;
678
+ gap: 1rem;
679
+ min-width: 0;
680
+ }
681
+
682
+ .research-sources-head {
683
+ display: flex;
684
+ align-items: center;
685
+ justify-content: space-between;
686
+ gap: 0.5rem;
687
+ }
688
+
689
+ .research-doc-count {
690
+ font-size: 0.68rem;
691
+ font-weight: 600;
692
+ padding: 0.15rem 0.45rem;
693
+ border-radius: 999px;
694
+ background: var(--surface-container);
695
+ color: var(--secondary);
696
+ white-space: nowrap;
697
+ }
698
+
699
+ .ingest-url-row {
700
+ display: flex;
701
+ flex-direction: column;
702
+ gap: 0.5rem;
703
+ margin-top: 0.35rem;
704
+ }
705
+
706
+ .ingest-url-row .btn { margin-top: 0; white-space: nowrap; }
707
+
708
+ .ingest-url-caption { margin-top: 0.35rem; }
709
+
710
+ /* Slides view: compact left-rail research (sources only) */
711
+ .workspace[data-view="slides"] .research-layout {
712
+ grid-template-columns: 1fr;
713
+ }
714
+
715
+ .workspace[data-view="slides"] .research-chat {
716
+ display: none;
717
+ }
718
+
719
+ .workspace[data-view="slides"] .label-full { display: none; }
720
+ .workspace[data-view="slides"] .label-compact { display: inline; }
721
+ .workspace[data-view="slides"] .research-rail-footer { display: block; }
722
+ .workspace[data-view="slides"] .research-doc-count { display: inline-block; }
723
+ .workspace[data-view="slides"] .ingest-hint-full { display: none; }
724
+
725
+ .workspace[data-view="slides"] .col-research {
726
+ gap: 0.65rem;
727
+ }
728
+
729
+ .workspace[data-view="slides"] .card-ingest,
730
+ .workspace[data-view="slides"] .card-docs-rail {
731
+ padding: 0.75rem;
732
+ }
733
+
734
+ .workspace[data-view="slides"] .card-ingest .card-title,
735
+ .workspace[data-view="slides"] .card-docs-rail .card-title {
736
+ font-size: 0.88rem;
737
+ margin-bottom: 0.5rem;
738
+ }
739
+
740
+ .workspace[data-view="slides"] .ingest-workflow-field span {
741
+ font-size: 0.72rem;
742
+ }
743
+
744
+ .workspace[data-view="slides"] .ingest-url-row {
745
+ flex-direction: row;
746
+ align-items: stretch;
747
+ }
748
+
749
+ .workspace[data-view="slides"] .ingest-url-row .input {
750
+ flex: 1;
751
+ min-width: 0;
752
+ padding: 0.5rem 0.6rem;
753
+ font-size: 0.82rem;
754
+ }
755
+
756
+ .workspace[data-view="slides"] .ingest-url-row .btn {
757
+ flex: 0 0 auto;
758
+ padding: 0.5rem 0.75rem;
759
+ }
760
+
761
+ .workspace[data-view="slides"] .ingest-url-caption {
762
+ display: none;
763
+ }
764
+
765
+ .workspace[data-view="slides"] .upload-zone-compact {
766
+ flex-direction: row;
767
+ justify-content: center;
768
+ padding: 0.55rem 0.65rem;
769
+ margin-top: 0.35rem;
770
+ font-size: 0.8rem;
771
+ }
772
+
773
+ .workspace[data-view="slides"] .upload-zone-compact .material-symbols-outlined {
774
+ font-size: 1.1rem;
775
+ }
776
+
777
+ .workspace[data-view="slides"] .url-choices-list {
778
+ max-height: 110px;
779
+ }
780
+
781
+ .workspace[data-view="slides"] .url-choices-panel {
782
+ padding: 0.5rem;
783
+ }
784
+
785
+ .workspace[data-view="slides"] .documents-panel-scroll {
786
+ max-height: 160px;
787
+ overflow-y: auto;
788
+ }
789
+
790
+ .workspace[data-view="slides"] .card-docs-rail {
791
+ flex: 0 1 auto;
792
+ }
793
+
794
+ .workspace[data-view="slides"] .research-rail-footer {
795
+ margin-top: auto;
796
+ padding-top: 0.25rem;
797
+ }
798
+
799
+ .workspace[data-view="slides"] .research-rail-link {
800
+ font-size: 0.82rem;
801
+ justify-content: center;
802
+ }
803
+
804
+ .workspace[data-view="research"] .research-rail-footer {
805
+ display: none;
806
+ }
807
+
808
+ .workspace[data-view="research"] .ingest-url-row .btn {
809
+ width: 100%;
810
+ }
811
+
812
+ .workspace[data-view="research"] .ingest-url-row .btn::after {
813
+ content: " selected sources";
814
+ }
815
+
816
+ .workspace[data-view="research"] .ingest-url-caption {
817
+ display: block;
818
+ }
819
+
820
+ .ingest-action-row {
821
+ display: flex;
822
+ flex-direction: column;
823
+ gap: 0.35rem;
824
+ margin: 0.5rem 0;
825
+ }
826
+
827
+ .url-choices-panel {
828
+ margin: 0.5rem 0 0.75rem;
829
+ padding: 0.75rem;
830
+ border: 1px solid var(--outline-variant);
831
+ border-radius: var(--radius-lg);
832
+ background: var(--surface-container-low);
833
+ }
834
+
835
+ .url-choices-head {
836
+ display: flex;
837
+ align-items: center;
838
+ justify-content: space-between;
839
+ gap: 0.5rem;
840
+ margin-bottom: 0.5rem;
841
+ }
842
+
843
+ .url-choices-head .card-title { margin: 0; }
844
+
845
+ .url-select-all {
846
+ display: flex;
847
+ align-items: center;
848
+ gap: 0.35rem;
849
+ font-size: 0.78rem;
850
+ color: var(--secondary);
851
+ cursor: pointer;
852
+ white-space: nowrap;
853
+ }
854
+
855
+ .url-choices-list {
856
+ display: flex;
857
+ flex-direction: column;
858
+ gap: 0.35rem;
859
+ max-height: 180px;
860
+ overflow-y: auto;
861
+ }
862
+
863
+ .url-choice-item {
864
+ display: flex;
865
+ align-items: flex-start;
866
+ gap: 0.5rem;
867
+ font-size: 0.8rem;
868
+ line-height: 1.35;
869
+ padding: 0.35rem 0.25rem;
870
+ border-radius: 6px;
871
+ }
872
+
873
+ .url-choice-item:hover { background: rgba(255, 255, 255, 0.65); }
874
+
875
+ .url-choice-item span {
876
+ word-break: break-all;
877
+ color: var(--on-surface);
878
+ }
879
+
880
+ .card-chat {
881
+ display: flex;
882
+ flex-direction: column;
883
+ min-height: 420px;
884
+ }
885
+
886
+ .card-row {
887
+ display: flex;
888
+ align-items: center;
889
+ justify-content: space-between;
890
+ gap: 0.5rem;
891
+ }
892
+
893
+ .rag-badge {
894
+ font-size: 0.68rem;
895
+ font-weight: 600;
896
+ text-transform: uppercase;
897
+ letter-spacing: 0.04em;
898
+ padding: 0.2rem 0.5rem;
899
+ border-radius: 999px;
900
+ background: var(--secondary-container);
901
+ color: var(--on-secondary-container);
902
+ }
903
+
904
+ .research-chat-messages {
905
+ flex: 1;
906
+ min-height: 220px;
907
+ max-height: 360px;
908
+ overflow-y: auto;
909
+ display: flex;
910
+ flex-direction: column;
911
+ gap: 0.65rem;
912
+ margin-bottom: 0.75rem;
913
+ padding: 0.5rem;
914
+ border: 1px solid var(--outline-variant);
915
+ border-radius: var(--radius-lg);
916
+ background: var(--surface-container-low);
917
+ }
918
+
919
+ .research-chat-empty {
920
+ margin: auto;
921
+ text-align: center;
922
+ font-size: 0.85rem;
923
+ color: var(--secondary);
924
+ line-height: 1.45;
925
+ padding: 1rem;
926
+ }
927
+
928
+ .research-chat-bubble {
929
+ max-width: 95%;
930
+ padding: 0.65rem 0.75rem;
931
+ border-radius: 10px;
932
+ font-size: 0.86rem;
933
+ line-height: 1.45;
934
+ }
935
+
936
+ .research-chat-user {
937
+ align-self: flex-end;
938
+ background: var(--primary-fixed);
939
+ color: var(--on-surface);
940
+ }
941
+
942
+ .research-chat-assistant {
943
+ align-self: flex-start;
944
+ background: #fff;
945
+ border: 1px solid var(--outline-variant);
946
+ }
947
+
948
+ .research-chat-role {
949
+ font-size: 0.65rem;
950
+ font-weight: 700;
951
+ text-transform: uppercase;
952
+ letter-spacing: 0.05em;
953
+ color: var(--secondary);
954
+ margin-bottom: 0.25rem;
955
+ }
956
+
957
+ @media (max-width: 960px) {
958
+ .research-layout { grid-template-columns: 1fr; }
959
+ }
960
+
961
+ .workspace[data-view="voice"] .col-research,
962
+ .workspace[data-view="voice"] .col-slides { display: none; }
963
+ .workspace[data-view="voice"] { grid-template-columns: 1fr; max-width: 520px; margin-left: auto; margin-right: auto; }
964
+
965
+ .workspace[data-view="coach"] .col-research,
966
+ .workspace[data-view="coach"] .col-slides { display: none; }
967
+ .workspace[data-view="coach"] { grid-template-columns: 1fr; max-width: 520px; margin-left: auto; margin-right: auto; }
968
+
969
+ @media (max-width: 768px) {
970
+ :root { --sidebar-w: 0px; }
971
+ .sidebar {
972
+ transform: translateX(-100%);
973
+ transition: transform 0.2s ease;
974
+ width: min(280px, 85vw);
975
+ }
976
+ .sidebar.open { transform: translateX(0); }
977
+ .sidebar-close, .topbar-icon { display: inline-flex; background: none; border: none; cursor: pointer; color: var(--secondary); }
978
+ .topbar { left: 0; padding: 0 1rem; }
979
+ .workspace-context-bar { left: 0; padding: 0.5rem 1rem; }
980
+ .workspace { margin-left: 0; padding-top: calc(var(--topbar-h) + var(--context-bar-h) + 1rem); }
981
+ .studio-banner { left: 0; }
982
+ }
apps/gradio-space/static/studio/studio.js ADDED
@@ -0,0 +1,724 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client@1.14.0/+esm";
2
+
3
+ const $ = (sel) => document.querySelector(sel);
4
+ const SLIDE_PIPELINE_STEPS = [
5
+ "Load language model",
6
+ "Gather lesson sources",
7
+ "Generate slide outline",
8
+ "Build PPTX, DOCX, and HTML exports",
9
+ ];
10
+
11
+ const state = {
12
+ workspaceTopic: "photosynthesis",
13
+ workspaceSessionId: "",
14
+ workspaceDocIds: [],
15
+ discoveredUrls: [],
16
+ selectedUrls: [],
17
+ researchChatHistory: [],
18
+ voiceMode: "lesson",
19
+ history: [],
20
+ downloads: null,
21
+ client: null,
22
+ progressTimer: null,
23
+ progressStartedAt: null,
24
+ };
25
+
26
+ function effectiveTopic(local) {
27
+ const localVal = (local || "").trim();
28
+ if (localVal) return localVal;
29
+ return (state.workspaceTopic || "").trim();
30
+ }
31
+
32
+ function effectiveSession(local) {
33
+ const localVal = (local || "").trim();
34
+ if (localVal) return localVal;
35
+ return (state.workspaceSessionId || "").trim();
36
+ }
37
+
38
+ function selectedWorkspaceDocIds() {
39
+ const boxes = document.querySelectorAll("#workspace-doc-list input[type=checkbox]:checked");
40
+ return [...boxes].map((el) => el.value);
41
+ }
42
+
43
+ function effectiveDocIds(localIds) {
44
+ if (localIds && localIds.length) return localIds;
45
+ const selected = selectedWorkspaceDocIds();
46
+ if (selected.length) return selected;
47
+ return state.workspaceDocIds;
48
+ }
49
+
50
+ function escapeHtml(text) {
51
+ return String(text)
52
+ .replace(/&/g, "&amp;")
53
+ .replace(/</g, "&lt;")
54
+ .replace(/>/g, "&gt;")
55
+ .replace(/"/g, "&quot;");
56
+ }
57
+
58
+ function renderMarkdownLite(text) {
59
+ const safe = escapeHtml(stripMd(text || ""));
60
+ return safe
61
+ .replace(/\n/g, "<br>")
62
+ .replace(/\[(\d+)\]/g, "<sup>[$1]</sup>");
63
+ }
64
+
65
+ function getIngestWorkflow() {
66
+ return $("#ingest-workflow")?.value || "direct";
67
+ }
68
+
69
+ function syncIngestWorkflowUi() {
70
+ const mode = getIngestWorkflow();
71
+ $("#ingest-discover-row")?.classList.toggle("hidden", mode !== "select");
72
+ $("#ingest-auto-row")?.classList.toggle("hidden", mode !== "auto");
73
+ $("#url-choices-panel")?.classList.toggle(
74
+ "hidden",
75
+ mode !== "select" || !state.discoveredUrls.length
76
+ );
77
+ }
78
+
79
+ function syncResearchLayout() {
80
+ syncIngestWorkflowUi();
81
+ updateResearchDocCount(state.workspaceDocIds?.length || 0);
82
+ }
83
+
84
+ function updateResearchDocCount(count) {
85
+ const badge = $("#research-doc-count");
86
+ if (!badge) return;
87
+ if (!count) {
88
+ badge.classList.add("hidden");
89
+ badge.textContent = "0 docs";
90
+ return;
91
+ }
92
+ badge.classList.remove("hidden");
93
+ badge.textContent = count === 1 ? "1 doc" : `${count} docs`;
94
+ }
95
+
96
+ function openResearchView() {
97
+ const researchNav = document.querySelector('.nav-item[data-view="research"]');
98
+ researchNav?.click();
99
+ window.setTimeout(() => {
100
+ $("#research-question")?.focus();
101
+ }, 80);
102
+ }
103
+
104
+ function getSelectedDiscoveredUrls() {
105
+ const boxes = document.querySelectorAll("#url-choices-list input[type=checkbox]:checked");
106
+ return [...boxes].map((el) => el.value);
107
+ }
108
+
109
+ function renderUrlChoices(urls, selected) {
110
+ state.discoveredUrls = urls || [];
111
+ state.selectedUrls = selected?.length ? selected : [...state.discoveredUrls];
112
+ const list = $("#url-choices-list");
113
+ const panel = $("#url-choices-panel");
114
+ if (!state.discoveredUrls.length) {
115
+ list.innerHTML = "";
116
+ panel?.classList.add("hidden");
117
+ return;
118
+ }
119
+ list.innerHTML = state.discoveredUrls
120
+ .map((url) => {
121
+ const checked = state.selectedUrls.includes(url) ? "checked" : "";
122
+ const label = url.length > 72 ? `${url.slice(0, 69)}…` : url;
123
+ return `<label class="url-choice-item"><input type="checkbox" value="${escapeHtml(url)}" ${checked} /><span title="${escapeHtml(url)}">${escapeHtml(label)}</span></label>`;
124
+ })
125
+ .join("");
126
+ list.querySelectorAll("input[type=checkbox]").forEach((box) => {
127
+ box.addEventListener("change", syncUrlSelectAll);
128
+ });
129
+ syncUrlSelectAll();
130
+ if (getIngestWorkflow() === "select") {
131
+ panel?.classList.remove("hidden");
132
+ }
133
+ }
134
+
135
+ function syncUrlSelectAll() {
136
+ const boxes = [...document.querySelectorAll("#url-choices-list input[type=checkbox]")];
137
+ const selectAll = $("#url-select-all");
138
+ if (!selectAll || !boxes.length) return;
139
+ const checkedCount = boxes.filter((b) => b.checked).length;
140
+ selectAll.checked = checkedCount === boxes.length;
141
+ selectAll.indeterminate = checkedCount > 0 && checkedCount < boxes.length;
142
+ state.selectedUrls = getSelectedDiscoveredUrls();
143
+ }
144
+
145
+ function applyIngestResult(data) {
146
+ $("#ingest-status").textContent = stripMd(data.status || "Ingest complete.");
147
+ state.workspaceSessionId = data.session_id || state.workspaceSessionId;
148
+ $("#workspace-session").value = state.workspaceSessionId;
149
+ $("#documents-panel").innerHTML =
150
+ data.documents_html || '<p class="studio-empty-docs">No documents indexed yet.</p>';
151
+ renderWorkspaceDocList(data.documents || []);
152
+ updateResearchRagBadge();
153
+ updateResearchDocCount((data.documents || []).length);
154
+ }
155
+
156
+ async function discoverSources() {
157
+ const topic = effectiveTopic("");
158
+ if (!topic) {
159
+ showError("Set a workspace topic before discovering sources.");
160
+ return;
161
+ }
162
+ const data = await callApi("discover_sources", [topic, state.workspaceSessionId]);
163
+ $("#ingest-status").textContent = stripMd(data.status || "Discovery complete.");
164
+ renderUrlChoices(data.urls || [], data.selected_urls || data.urls || []);
165
+ if (data.session_id) {
166
+ state.workspaceSessionId = data.session_id;
167
+ $("#workspace-session").value = data.session_id;
168
+ }
169
+ await refreshWorkspaceSessions(state.workspaceSessionId);
170
+ }
171
+
172
+ async function autoSearchIngest() {
173
+ const topic = effectiveTopic("");
174
+ if (!topic) {
175
+ showError("Set a workspace topic before auto-ingest.");
176
+ return;
177
+ }
178
+ const data = await callApi("auto_search_ingest", [topic, state.workspaceSessionId]);
179
+ applyIngestResult(data);
180
+ state.discoveredUrls = [];
181
+ state.selectedUrls = [];
182
+ renderUrlChoices([], []);
183
+ await refreshWorkspaceSessions(state.workspaceSessionId);
184
+ }
185
+
186
+ async function ingestSources({ urlsText = "", selectedUrls = [], pendingFiles = null } = {}) {
187
+ const topic = effectiveTopic("");
188
+ const workflow = getIngestWorkflow();
189
+ let selected = selectedUrls;
190
+ if (workflow === "select") {
191
+ selected = getSelectedDiscoveredUrls();
192
+ }
193
+ const pasted = workflow === "direct" ? urlsText : urlsText || $("#ingest-url").value.trim();
194
+ const paths = [];
195
+ const files = pendingFiles || $("#ingest-file").files;
196
+ if (files?.length) {
197
+ for (const file of files) {
198
+ const b64 = await fileToBase64(file);
199
+ const saved = await callApi("save_upload", [file.name, b64]);
200
+ paths.push(saved.path);
201
+ }
202
+ }
203
+ if (!pasted && !selected.length && !paths.length) {
204
+ showError("Add URLs, select suggested sources, or upload a file — then ingest.");
205
+ return;
206
+ }
207
+ const data = await callApi("ingest_sources", [
208
+ topic,
209
+ state.workspaceSessionId,
210
+ pasted,
211
+ selected,
212
+ paths,
213
+ ]);
214
+ applyIngestResult(data);
215
+ if (pasted) $("#ingest-url").value = "";
216
+ if (files?.length) $("#ingest-file").value = "";
217
+ await refreshWorkspaceSessions(state.workspaceSessionId);
218
+ }
219
+
220
+ function renderResearchChat() {
221
+ const container = $("#research-chat-messages");
222
+ if (!state.researchChatHistory.length) {
223
+ container.innerHTML =
224
+ '<p class="research-chat-empty">Ingest sources, then ask questions — answers include citations from your library.</p>';
225
+ return;
226
+ }
227
+ container.innerHTML = state.researchChatHistory
228
+ .map((msg) => {
229
+ const role = msg.role === "user" ? "user" : "assistant";
230
+ const body = renderMarkdownLite(msg.content || "");
231
+ return `<div class="research-chat-bubble research-chat-${role}"><div class="research-chat-role">${role === "user" ? "You" : "ResearchMind"}</div><div class="research-chat-body">${body}</div></div>`;
232
+ })
233
+ .join("");
234
+ container.scrollTop = container.scrollHeight;
235
+ }
236
+
237
+ function updateResearchRagBadge() {
238
+ const badge = $("#research-rag-badge");
239
+ if (!badge) return;
240
+ const nDocs = (state.workspaceDocIds || []).length;
241
+ const selected = selectedWorkspaceDocIds().length;
242
+ if (selected) {
243
+ badge.textContent = `RAG · ${selected} doc(s)`;
244
+ } else if (nDocs) {
245
+ badge.textContent = `RAG · ${nDocs} in session`;
246
+ } else {
247
+ badge.textContent = "RAG · corpus";
248
+ }
249
+ }
250
+
251
+ async function askResearchQuestion() {
252
+ const question = $("#research-question").value.trim();
253
+ if (!question) {
254
+ showError("Enter a question.");
255
+ return;
256
+ }
257
+ const docIds = effectiveDocIds([]);
258
+ const data = await callApi("research_chat", [
259
+ question,
260
+ state.workspaceSessionId,
261
+ docIds,
262
+ state.researchChatHistory,
263
+ ]);
264
+ state.researchChatHistory = data.history || [];
265
+ renderResearchChat();
266
+ $("#research-question").value = "";
267
+ $("#research-chat-status").textContent = stripMd(data.rag_hint || "");
268
+ updateResearchRagBadge();
269
+ }
270
+
271
+ function updateProjectTitle() {
272
+ const topic = state.workspaceTopic || "";
273
+ const short = topic.split(" for ")[0] || topic || "Project";
274
+ $("#project-title").textContent = short.slice(0, 40);
275
+ }
276
+
277
+ function updateWorkspaceRagHint() {
278
+ const nDocs = selectedWorkspaceDocIds().length;
279
+ const sid = state.workspaceSessionId;
280
+ let hint = "RAG scope: entire indexed corpus (all sessions).";
281
+ if (sid) {
282
+ hint = nDocs
283
+ ? `RAG scope: ${nDocs} selected document(s) in session.`
284
+ : `RAG scope: all documents in session.`;
285
+ }
286
+ const el = $("#workspace-rag-hint");
287
+ if (el) el.textContent = hint;
288
+ }
289
+
290
+ async function getClient() {
291
+ if (!state.client) {
292
+ state.client = await Client.connect(window.location.origin);
293
+ }
294
+ return state.client;
295
+ }
296
+
297
+ function setLoading(on) {
298
+ $("#studio-loading").classList.toggle("hidden", !on);
299
+ }
300
+
301
+ function startProgressPanel() {
302
+ const panel = $("#progress-panel");
303
+ const stepsEl = $("#progress-steps");
304
+ panel.classList.remove("hidden");
305
+ state.progressStartedAt = Date.now();
306
+ stepsEl.innerHTML = SLIDE_PIPELINE_STEPS.map(
307
+ (label, index) =>
308
+ `<li data-step="${index}" class="progress-step pending">${label}</li>`
309
+ ).join("");
310
+ $("#progress-log").classList.add("hidden");
311
+ $("#progress-log").textContent = "";
312
+ $("#progress-eta").textContent = "Est. remaining: calculating…";
313
+ updateProgressElapsed();
314
+ if (state.progressTimer) clearInterval(state.progressTimer);
315
+ state.progressTimer = setInterval(updateProgressElapsed, 500);
316
+ }
317
+
318
+ function updateProgressElapsed() {
319
+ if (!state.progressStartedAt) return;
320
+ const elapsed = (Date.now() - state.progressStartedAt) / 1000;
321
+ $("#progress-elapsed").textContent = `Elapsed: ${elapsed.toFixed(1)}s`;
322
+ const eta = estimateRemaining(elapsed);
323
+ $("#progress-eta").textContent =
324
+ eta !== null ? `Est. remaining: ~${Math.max(0, Math.round(eta))}s` : "";
325
+ }
326
+
327
+ function estimateRemaining(elapsed) {
328
+ if (elapsed < 3) return null;
329
+ const stepNodes = [...document.querySelectorAll("#progress-steps .progress-step")];
330
+ const activeIndex = stepNodes.findIndex((node) => node.classList.contains("active"));
331
+ const doneCount = stepNodes.filter((node) => node.classList.contains("done")).length;
332
+ const progress = Math.max((doneCount + (activeIndex >= 0 ? 0.35 : 0)) / stepNodes.length, 0.15);
333
+ return elapsed / progress - elapsed;
334
+ }
335
+
336
+ function markProgressStep(index, status) {
337
+ const node = document.querySelector(`#progress-steps [data-step="${index}"]`);
338
+ if (!node) return;
339
+ node.classList.remove("pending", "active", "done");
340
+ node.classList.add(status);
341
+ }
342
+
343
+ function advanceProgressWhileWaiting() {
344
+ let current = 0;
345
+ markProgressStep(current, "active");
346
+ const timer = setInterval(() => {
347
+ if (!$("#progress-panel") || $("#progress-panel").classList.contains("hidden")) {
348
+ clearInterval(timer);
349
+ return;
350
+ }
351
+ if (current < SLIDE_PIPELINE_STEPS.length - 1) {
352
+ markProgressStep(current, "done");
353
+ current += 1;
354
+ markProgressStep(current, "active");
355
+ }
356
+ }, 9000);
357
+ return timer;
358
+ }
359
+
360
+ function finishProgressPanel(data) {
361
+ if (state.progressTimer) {
362
+ clearInterval(state.progressTimer);
363
+ state.progressTimer = null;
364
+ }
365
+
366
+ const stepsEl = $("#progress-steps");
367
+ const traceSteps = data?.progress?.steps || [];
368
+ if (traceSteps.length) {
369
+ stepsEl.innerHTML = traceSteps
370
+ .map((step) => {
371
+ const duration =
372
+ step.duration_s != null ? ` (${step.duration_s}s)` : "";
373
+ const detail = step.detail ? ` — ${step.detail}` : "";
374
+ return `<li class="progress-step done">${step.label}${duration}${detail}</li>`;
375
+ })
376
+ .join("");
377
+ } else {
378
+ document.querySelectorAll("#progress-steps .progress-step").forEach((node) => {
379
+ node.classList.remove("pending", "active");
380
+ node.classList.add("done");
381
+ });
382
+ }
383
+
384
+ if (data?.progress_log) {
385
+ const logEl = $("#progress-log");
386
+ const log = data.progress_log;
387
+ if (/<[a-z][\s\S]*>/i.test(log)) {
388
+ logEl.innerHTML = log;
389
+ } else {
390
+ logEl.textContent = stripMd(log);
391
+ }
392
+ logEl.classList.remove("hidden");
393
+ }
394
+ if (data?.elapsed_seconds != null) {
395
+ $("#progress-elapsed").textContent = `Elapsed: ${Number(data.elapsed_seconds).toFixed(1)}s`;
396
+ }
397
+ $("#progress-eta").textContent = "Complete";
398
+ }
399
+
400
+ function showError(msg) {
401
+ const el = $("#studio-error");
402
+ if (!msg) {
403
+ el.classList.add("hidden");
404
+ el.textContent = "";
405
+ return;
406
+ }
407
+ el.textContent = msg;
408
+ el.classList.remove("hidden");
409
+ }
410
+
411
+ function unwrapApiPayload(result) {
412
+ const raw = result?.data ?? result;
413
+ if (Array.isArray(raw)) {
414
+ if (raw.length === 1 && raw[0] !== null && typeof raw[0] === "object") {
415
+ return raw[0];
416
+ }
417
+ return raw;
418
+ }
419
+ return raw;
420
+ }
421
+
422
+ async function callApi(name, args = []) {
423
+ setLoading(true);
424
+ showError("");
425
+ try {
426
+ const client = await getClient();
427
+ const result = await client.predict(`/${name}`, args);
428
+ const data = unwrapApiPayload(result);
429
+ if (data && data.ok === false) {
430
+ throw new Error(data.error || "Request failed");
431
+ }
432
+ return data;
433
+ } catch (err) {
434
+ const message = err?.message || String(err);
435
+ showError(`${message} — try Classic UI at /classic`);
436
+ throw err;
437
+ } finally {
438
+ setLoading(false);
439
+ }
440
+ }
441
+
442
+ function fileToBase64(file) {
443
+ return new Promise((resolve, reject) => {
444
+ const reader = new FileReader();
445
+ reader.onload = () => {
446
+ const raw = reader.result.split(",")[1];
447
+ resolve(raw);
448
+ };
449
+ reader.onerror = reject;
450
+ reader.readAsDataURL(file);
451
+ });
452
+ }
453
+
454
+ function renderWorkspaceDocList(docs) {
455
+ const container = $("#workspace-doc-list");
456
+ if (!docs?.length) {
457
+ container.innerHTML = "<p class=\"status-text\">No documents in this session yet.</p>";
458
+ state.workspaceDocIds = [];
459
+ updateWorkspaceRagHint();
460
+ updateResearchDocCount(0);
461
+ return;
462
+ }
463
+ state.workspaceDocIds = docs.map((d) => d.id);
464
+ container.innerHTML = docs
465
+ .map(
466
+ (d) =>
467
+ `<label class="workspace-doc-item"><input type="checkbox" value="${d.id}" checked />${d.title}</label>`
468
+ )
469
+ .join("");
470
+ container.querySelectorAll("input[type=checkbox]").forEach((box) => {
471
+ box.addEventListener("change", () => {
472
+ updateWorkspaceRagHint();
473
+ updateResearchRagBadge();
474
+ });
475
+ });
476
+ updateWorkspaceRagHint();
477
+ updateResearchRagBadge();
478
+ updateResearchDocCount(docs.length);
479
+ }
480
+
481
+ async function refreshWorkspaceSessions(selectId) {
482
+ const data = await callApi("list_sessions", []);
483
+ const sessions = data.sessions || [];
484
+ const select = $("#workspace-session");
485
+ const current = selectId || state.workspaceSessionId;
486
+ select.innerHTML =
487
+ "<option value=\"\">New session (on ingest)</option>" +
488
+ sessions
489
+ .map((s) => `<option value="${s.id}">${s.label || s.topic}</option>`)
490
+ .join("");
491
+ if (current && sessions.some((s) => s.id === current)) {
492
+ select.value = current;
493
+ state.workspaceSessionId = current;
494
+ } else {
495
+ const hint = (state.workspaceTopic || "").toLowerCase();
496
+ const match = sessions.find((s) => (s.topic || "").toLowerCase().includes(hint));
497
+ if (match) {
498
+ select.value = match.id;
499
+ state.workspaceSessionId = match.id;
500
+ updateProjectTitle();
501
+ }
502
+ }
503
+ }
504
+
505
+ async function refreshDocuments() {
506
+ const data = await callApi("list_documents", [state.workspaceSessionId]);
507
+ $("#documents-panel").innerHTML =
508
+ data.documents_html ||
509
+ '<p class="studio-empty-docs">No documents indexed yet.</p>';
510
+ if (data.session_id) {
511
+ state.workspaceSessionId = data.session_id;
512
+ $("#workspace-session").value = data.session_id;
513
+ }
514
+ renderWorkspaceDocList(data.documents || []);
515
+ }
516
+
517
+ async function initWorkspace() {
518
+ $("#workspace-topic").value = state.workspaceTopic;
519
+ syncResearchLayout();
520
+ updateProjectTitle();
521
+ updateResearchRagBadge();
522
+ await refreshWorkspaceSessions();
523
+ await refreshDocuments();
524
+ }
525
+
526
+ async function ingestUrl() {
527
+ await ingestSources({ urlsText: $("#ingest-url").value.trim() });
528
+ }
529
+
530
+ async function ingestFiles(files) {
531
+ if (!files?.length) return;
532
+ await ingestSources({ pendingFiles: files });
533
+ }
534
+
535
+ function stripMd(text) {
536
+ return String(text).replace(/\*\*/g, "").replace(/`/g, "");
537
+ }
538
+
539
+ async function generateSlides() {
540
+ const topic = effectiveTopic($("#lesson-topic").value);
541
+ const grade = $("#lesson-grade").value;
542
+ const slideCount = Number($("#slide-count").value);
543
+ const useRag = $("#use-rag").checked;
544
+ const docIds = effectiveDocIds([]);
545
+
546
+ startProgressPanel();
547
+ const waitTimer = advanceProgressWhileWaiting();
548
+ let data;
549
+ try {
550
+ data = await callApi("generate_slides", [
551
+ topic,
552
+ grade,
553
+ slideCount,
554
+ state.workspaceSessionId,
555
+ useRag,
556
+ docIds,
557
+ ]);
558
+ } catch (_err) {
559
+ $("#progress-eta").textContent = "Failed";
560
+ throw _err;
561
+ } finally {
562
+ clearInterval(waitTimer);
563
+ if (state.progressTimer) {
564
+ clearInterval(state.progressTimer);
565
+ state.progressTimer = null;
566
+ }
567
+ }
568
+
569
+ finishProgressPanel(data);
570
+
571
+ $("#generate-status").textContent = stripMd(data.status || "Slides generated.");
572
+ const canvasHtml =
573
+ data.canvas_html ||
574
+ (data.preview_html
575
+ ? `<div class="studio-canvas-inner">${data.preview_html}</div>`
576
+ : "");
577
+ $("#slide-canvas").innerHTML =
578
+ canvasHtml ||
579
+ '<div class="studio-canvas-empty"><p>Preview unavailable.</p></div>';
580
+
581
+ state.downloads = data.downloads;
582
+ const dl = $("#downloads");
583
+ if (data.downloads?.pptx) {
584
+ dl.classList.remove("hidden");
585
+ dl.innerHTML = `
586
+ <a href="/file=${encodeURIComponent(data.downloads.pptx)}" download>PPTX</a>
587
+ <a href="/file=${encodeURIComponent(data.downloads.docx)}" download>DOCX</a>
588
+ <a href="/file=${encodeURIComponent(data.downloads.html)}" download>HTML</a>`;
589
+ $("#btn-export").disabled = false;
590
+ }
591
+ }
592
+
593
+ async function sendVoiceTurn() {
594
+ const message = $("#voice-message").value.trim();
595
+ const topic = effectiveTopic("");
596
+ const useRag = $("#use-rag").checked;
597
+ const docIds = effectiveDocIds([]);
598
+ const data = await callApi("teacher_voice_turn", [
599
+ message,
600
+ state.voiceMode,
601
+ topic,
602
+ state.workspaceSessionId,
603
+ useRag,
604
+ state.history,
605
+ docIds,
606
+ ]);
607
+ state.history = data.history || [];
608
+ $("#voice-reply").textContent = data.assistant || data.status || "";
609
+ }
610
+
611
+ async function analyzePitch() {
612
+ const file = $("#coach-audio").files?.[0];
613
+ if (!file) {
614
+ showError("Choose an audio file to analyze.");
615
+ return;
616
+ }
617
+ $("#coach-panel").innerHTML = `
618
+ <div class="studio-coach-panel studio-coach-live">
619
+ <div class="studio-coach-header"><span class="studio-coach-dot"></span>
620
+ <span class="studio-coach-label">Analyzing…</span></div>
621
+ </div>`;
622
+ const b64 = await fileToBase64(file);
623
+ const saved = await callApi("save_upload", [file.name, b64]);
624
+ const data = await callApi("analyze_pitch", [saved.path]);
625
+ $("#coach-panel").innerHTML = data.coach_panel_html || "";
626
+ }
627
+
628
+ function bindUi() {
629
+ $("#slide-count").addEventListener("input", (e) => {
630
+ $("#slide-count-val").textContent = e.target.value;
631
+ });
632
+
633
+ document.querySelectorAll(".nav-item[data-view]").forEach((btn) => {
634
+ btn.addEventListener("click", () => {
635
+ document.querySelectorAll(".nav-item[data-view]").forEach((b) =>
636
+ b.classList.remove("active")
637
+ );
638
+ btn.classList.add("active");
639
+ $(".workspace").dataset.view = btn.dataset.view;
640
+ syncResearchLayout();
641
+ $("#sidebar").classList.remove("open");
642
+ });
643
+ });
644
+
645
+ $("#btn-open-research-view")?.addEventListener("click", openResearchView);
646
+
647
+ $("#sidebar-open")?.addEventListener("click", () =>
648
+ $("#sidebar").classList.add("open")
649
+ );
650
+ $("#sidebar-close")?.addEventListener("click", () =>
651
+ $("#sidebar").classList.remove("open")
652
+ );
653
+
654
+ $("#workspace-topic").addEventListener("input", (e) => {
655
+ state.workspaceTopic = e.target.value.trim();
656
+ updateProjectTitle();
657
+ });
658
+
659
+ $("#workspace-session").addEventListener("change", (e) => {
660
+ state.workspaceSessionId = e.target.value;
661
+ refreshDocuments().catch(() => {});
662
+ });
663
+
664
+ $("#workspace-refresh-sessions").addEventListener("click", () => {
665
+ refreshWorkspaceSessions(state.workspaceSessionId).catch(() => {});
666
+ });
667
+
668
+ $("#btn-ingest-url").addEventListener("click", () => ingestUrl().catch(() => {}));
669
+ $("#ingest-file").addEventListener("change", (e) =>
670
+ ingestFiles(e.target.files).catch(() => {})
671
+ );
672
+ $("#ingest-workflow")?.addEventListener("change", syncIngestWorkflowUi);
673
+ $("#btn-discover")?.addEventListener("click", () => discoverSources().catch(() => {}));
674
+ $("#btn-auto-ingest")?.addEventListener("click", () => autoSearchIngest().catch(() => {}));
675
+ $("#url-select-all")?.addEventListener("change", (e) => {
676
+ const checked = e.target.checked;
677
+ document.querySelectorAll("#url-choices-list input[type=checkbox]").forEach((box) => {
678
+ box.checked = checked;
679
+ });
680
+ syncUrlSelectAll();
681
+ });
682
+ $("#btn-research-ask")?.addEventListener("click", () => askResearchQuestion().catch(() => {}));
683
+ $("#research-question")?.addEventListener("keydown", (e) => {
684
+ if (e.key === "Enter" && !e.shiftKey) {
685
+ e.preventDefault();
686
+ askResearchQuestion().catch(() => {});
687
+ }
688
+ });
689
+ $("#btn-generate").addEventListener("click", () => generateSlides().catch(() => {}));
690
+ $("#btn-voice-send").addEventListener("click", () => sendVoiceTurn().catch(() => {}));
691
+ $("#btn-analyze").addEventListener("click", () => analyzePitch().catch(() => {}));
692
+
693
+ $("#btn-export").addEventListener("click", () => {
694
+ const p = state.downloads?.pptx;
695
+ if (p) window.open(`/file=${encodeURIComponent(p)}`, "_blank");
696
+ });
697
+
698
+ $("#btn-new-session").addEventListener("click", () => {
699
+ state.workspaceSessionId = "";
700
+ state.researchChatHistory = [];
701
+ state.discoveredUrls = [];
702
+ state.selectedUrls = [];
703
+ renderResearchChat();
704
+ renderUrlChoices([], []);
705
+ $("#workspace-session").value = "";
706
+ $("#ingest-status").textContent =
707
+ "Set workspace topic and ingest sources to start a new ResearchMind session.";
708
+ refreshDocuments().catch(() => {});
709
+ });
710
+
711
+ document.querySelectorAll(".mode-card").forEach((btn) => {
712
+ btn.addEventListener("click", () => {
713
+ document.querySelectorAll(".mode-card").forEach((b) => b.classList.remove("active"));
714
+ btn.classList.add("active");
715
+ state.voiceMode = btn.dataset.mode;
716
+ });
717
+ });
718
+ }
719
+
720
+ bindUi();
721
+ initWorkspace().catch((err) => {
722
+ console.error(err);
723
+ showError("Could not connect to Studio API. Open /classic for full Gradio UI.");
724
+ });
libs/agent/src/agent/progress.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from html import escape
5
+ from time import monotonic
6
+ from typing import Any, Callable
7
+
8
+ ProgressUpdateFn = Callable[[float, str], None]
9
+ ProgressStepFn = Callable[["ProgressStep"], None]
10
+
11
+ # Typical share of wall time per phase (used for ETA while a step is running).
12
+ _STEP_WEIGHTS: dict[str, float] = {
13
+ "load_model": 0.12,
14
+ "gather_sources": 0.18,
15
+ "generate_outline": 0.45,
16
+ "repair_outline": 0.12,
17
+ "create_exports": 0.06,
18
+ "render_previews": 0.07,
19
+ }
20
+
21
+
22
+ @dataclass
23
+ class ProgressStep:
24
+ name: str
25
+ label: str
26
+ started_at: float
27
+ ended_at: float | None = None
28
+ detail: str = ""
29
+
30
+ @property
31
+ def duration_s(self) -> float | None:
32
+ if self.ended_at is None:
33
+ return None
34
+ return self.ended_at - self.started_at
35
+
36
+
37
+ @dataclass
38
+ class SlideGenerationProgress:
39
+ """Tracks slide-generation phases with timing and optional Gradio updates."""
40
+
41
+ on_update: ProgressUpdateFn | None = None
42
+ on_step: ProgressStepFn | None = None
43
+ steps: list[ProgressStep] = field(default_factory=list)
44
+ started_at: float = field(default_factory=monotonic)
45
+ _current: ProgressStep | None = field(default=None, repr=False)
46
+ _completed_weight: float = field(default=0.0, repr=False)
47
+
48
+ def begin(self, name: str, label: str, *, detail: str = "") -> None:
49
+ self._finish_current()
50
+ step = ProgressStep(
51
+ name=name,
52
+ label=label,
53
+ started_at=monotonic(),
54
+ detail=detail,
55
+ )
56
+ self._current = step
57
+ self.steps.append(step)
58
+ if self.on_step is not None:
59
+ self.on_step(step)
60
+ self._emit(label, detail)
61
+
62
+ def detail(self, detail: str) -> None:
63
+ if self._current is not None:
64
+ self._current.detail = detail
65
+ self._emit(self._current.label, detail)
66
+
67
+ def finish(self) -> None:
68
+ self._finish_current()
69
+ self._emit("Done", "")
70
+
71
+ def elapsed_s(self) -> float:
72
+ return monotonic() - self.started_at
73
+
74
+ def estimate_remaining_s(self) -> float | None:
75
+ elapsed = self.elapsed_s()
76
+ if elapsed < 0.5 or not self.steps:
77
+ return None
78
+
79
+ done_weight = self._completed_weight
80
+ current = self._current
81
+ if current is not None:
82
+ step_weight = _STEP_WEIGHTS.get(current.name, 0.08)
83
+ step_elapsed = monotonic() - current.started_at
84
+ if step_elapsed > 0.2:
85
+ done_weight += step_weight * min(0.85, step_elapsed / max(step_elapsed + 8.0, 1.0))
86
+
87
+ total_weight = sum(_STEP_WEIGHTS.values())
88
+ if done_weight <= 0.05:
89
+ return None
90
+ progress_ratio = min(done_weight / total_weight, 0.95)
91
+ projected_total = elapsed / progress_ratio
92
+ remaining = projected_total - elapsed
93
+ return max(0.0, remaining)
94
+
95
+ def format_log(self, *, include_eta: bool = True) -> str:
96
+ lines: list[str] = []
97
+ elapsed = self.elapsed_s()
98
+ lines.append(f"**Elapsed:** {elapsed:.1f}s")
99
+
100
+ if include_eta:
101
+ remaining = self.estimate_remaining_s()
102
+ if remaining is not None:
103
+ lines.append(f"**Est. remaining:** ~{remaining:.0f}s")
104
+
105
+ lines.append("")
106
+ for index, step in enumerate(self.steps, start=1):
107
+ icon = "✓" if step.ended_at is not None else "…"
108
+ duration = ""
109
+ if step.duration_s is not None:
110
+ duration = f" ({step.duration_s:.1f}s)"
111
+ line = f"{index}. {icon} **{step.label}**{duration}"
112
+ if step.detail:
113
+ line += f" — {step.detail}"
114
+ lines.append(line)
115
+
116
+ return "\n".join(lines)
117
+
118
+ def format_log_html(
119
+ self,
120
+ *,
121
+ running: bool = False,
122
+ footer_html: str = "",
123
+ ) -> str:
124
+ elapsed = self.elapsed_s()
125
+ eta = self.estimate_remaining_s() if running else None
126
+ banner = (
127
+ '<div class="slide-gen-log-banner running">Generating slides…</div>'
128
+ if running
129
+ else '<div class="slide-gen-log-banner done">Generation complete</div>'
130
+ )
131
+ eta_html = (
132
+ f'<div class="slide-gen-log-meta">Est. remaining: ~{int(eta)}s</div>'
133
+ if eta is not None and running
134
+ else ""
135
+ )
136
+ steps_html: list[str] = []
137
+ for step in self.steps:
138
+ done = step.ended_at is not None
139
+ status = "done" if done else "active"
140
+ icon = "✓" if done else "●"
141
+ duration = (
142
+ f' <span class="slide-gen-log-dur">({step.duration_s:.1f}s)</span>'
143
+ if step.duration_s is not None
144
+ else ""
145
+ )
146
+ detail = (
147
+ f' <span class="slide-gen-log-detail">— {escape(step.detail)}</span>'
148
+ if step.detail
149
+ else ""
150
+ )
151
+ steps_html.append(
152
+ f'<li class="slide-gen-log-step {status}">'
153
+ f'<span class="slide-gen-log-icon">{icon}</span>'
154
+ f'<span class="slide-gen-log-label">{escape(step.label)}</span>'
155
+ f"{duration}{detail}</li>"
156
+ )
157
+ steps_block = (
158
+ f'<ol class="slide-gen-log-steps">{"".join(steps_html)}</ol>'
159
+ if steps_html
160
+ else '<p class="slide-gen-log-empty">Waiting for first step…</p>'
161
+ )
162
+ return (
163
+ f'<div class="slide-gen-log">'
164
+ f"{banner}"
165
+ f'<div class="slide-gen-log-meta">Elapsed: {elapsed:.1f}s</div>'
166
+ f"{eta_html}"
167
+ f"{steps_block}"
168
+ f"{footer_html}"
169
+ f"</div>"
170
+ )
171
+
172
+ def to_dict(self) -> dict[str, Any]:
173
+ return {
174
+ "elapsed_s": round(self.elapsed_s(), 2),
175
+ "estimate_remaining_s": (
176
+ round(remaining, 1)
177
+ if (remaining := self.estimate_remaining_s()) is not None
178
+ else None
179
+ ),
180
+ "steps": [
181
+ {
182
+ "name": step.name,
183
+ "label": step.label,
184
+ "detail": step.detail,
185
+ "duration_s": (
186
+ round(step.duration_s, 2) if step.duration_s is not None else None
187
+ ),
188
+ "status": "done" if step.ended_at is not None else "running",
189
+ }
190
+ for step in self.steps
191
+ ],
192
+ }
193
+
194
+ def _finish_current(self) -> None:
195
+ if self._current is None or self._current.ended_at is not None:
196
+ return
197
+ self._current.ended_at = monotonic()
198
+ self._completed_weight += _STEP_WEIGHTS.get(self._current.name, 0.08)
199
+
200
+ def _emit(self, label: str, detail: str) -> None:
201
+ if self.on_update is None:
202
+ return
203
+ total_weight = sum(_STEP_WEIGHTS.values())
204
+ fraction = min(self._completed_weight / total_weight, 0.98)
205
+ desc = label if not detail else f"{label} — {detail}"
206
+ self.on_update(fraction, desc)
libs/agent/src/agent/prompts.py CHANGED
@@ -2,7 +2,7 @@ from __future__ import annotations
2
 
3
  import json
4
 
5
- from agent.models import EducationPptxInput
6
 
7
 
8
  def education_outline_system(skill_body: str) -> str:
@@ -26,12 +26,19 @@ JSON schema:
26
 
27
  Rules:
28
  - Use exactly the requested number of content slides (title slide is added separately by the tool).
29
- - Bullets should be short, age-appropriate, and factual.
 
 
30
  - When source excerpts are provided, prefer them over general knowledge and keep bullets consistent with those sources.
31
- - speaker_note is optional but helpful for each slide.
32
  """
33
 
34
 
 
 
 
 
 
 
35
  def education_outline_user(req: EducationPptxInput, *, source_context: str = "") -> str:
36
  base = (
37
  f"Topic: {req.topic}\n"
@@ -79,6 +86,46 @@ def outline_to_markdown(title: str, slides: list[dict]) -> str:
79
  return "\n".join(lines).strip()
80
 
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  def outline_json_example(slide_count: int) -> str:
83
  example = {
84
  "title": "Example Lesson",
 
2
 
3
  import json
4
 
5
+ from agent.models import EducationPptxInput, SlideOutline, SlideSpec
6
 
7
 
8
  def education_outline_system(skill_body: str) -> str:
 
26
 
27
  Rules:
28
  - Use exactly the requested number of content slides (title slide is added separately by the tool).
29
+ - At most 3 bullets per slide; each bullet under 12 words.
30
+ - speaker_note: one short sentence (under 20 words) or omit.
31
+ - Output compact JSON only — no preamble, no markdown fences, stop after the final `}}`.
32
  - When source excerpts are provided, prefer them over general knowledge and keep bullets consistent with those sources.
 
33
  """
34
 
35
 
36
+ def outline_max_tokens(slide_count: int) -> int:
37
+ """Cap generation length from slide count so CPU inference does not run to 2048 tokens."""
38
+ count = max(1, min(int(slide_count), 20))
39
+ return min(1024, 100 + count * 130)
40
+
41
+
42
  def education_outline_user(req: EducationPptxInput, *, source_context: str = "") -> str:
43
  base = (
44
  f"Topic: {req.topic}\n"
 
86
  return "\n".join(lines).strip()
87
 
88
 
89
+ def education_outline_retry_user(req: EducationPptxInput, *, example_json: str) -> str:
90
+ return (
91
+ f"Topic: {req.topic}\n"
92
+ f"Grade level: {req.grade}\n"
93
+ f"Number of content slides: {req.slide_count}\n\n"
94
+ "Your previous response was empty or invalid. "
95
+ "Return ONLY valid JSON matching this structure (replace placeholders for the topic):\n"
96
+ f"{example_json}"
97
+ )
98
+
99
+
100
+ def fallback_outline(req: EducationPptxInput) -> SlideOutline:
101
+ """Deterministic outline when the model returns empty or unparseable JSON."""
102
+ topic = req.topic.strip() or "Lesson"
103
+ grade = req.grade
104
+ seeds: list[tuple[str, list[str]]] = [
105
+ ("Introduction", [f"What is {topic}?", f"Overview for grade {grade}"]),
106
+ ("Key concepts", ["Main idea", "Supporting detail"]),
107
+ ("Examples", ["Real-world example", "Classroom activity"]),
108
+ ("Why it matters", ["Connection to students", "Discussion question"]),
109
+ ("Review", ["Summary points", "Check for understanding"]),
110
+ ("Going further", ["Extension idea", "Homework prompt"]),
111
+ ("Vocabulary", ["Important term", "Definition in student language"]),
112
+ ("Wrap-up", ["Recap", "Preview next lesson"]),
113
+ ]
114
+ slides: list[SlideSpec] = []
115
+ for index in range(req.slide_count):
116
+ title, bullets = seeds[index % len(seeds)]
117
+ if index >= len(seeds):
118
+ title = f"{title} ({index + 1})"
119
+ slides.append(
120
+ SlideSpec(
121
+ title=title,
122
+ bullets=bullets,
123
+ speaker_note="Template slide — edit using your lesson sources.",
124
+ )
125
+ )
126
+ return SlideOutline(title=topic[:1].upper() + topic[1:], slides=slides)
127
+
128
+
129
  def outline_json_example(slide_count: int) -> str:
130
  example = {
131
  "title": "Example Lesson",
libs/agent/src/agent/runner.py CHANGED
@@ -4,9 +4,11 @@ import json
4
  import re
5
  from dataclasses import dataclass
6
  from pathlib import Path
7
- from typing import Any, Literal
 
8
 
9
  from inference.base import InferenceBackend
 
10
  from researchmind.citations import format_context_block
11
  from researchmind.extract import extract_docx
12
  from researchmind.ingest import IngestPipeline
@@ -23,10 +25,15 @@ from agent.models import (
23
  SlideSpec,
24
  )
25
  from agent.preview import outline_to_html, render_slide_images
 
26
  from agent.prompts import (
27
  education_outline_repair,
 
28
  education_outline_system,
29
  education_outline_user,
 
 
 
30
  outline_to_markdown,
31
  )
32
  from agent.skills import SkillRegistry
@@ -75,7 +82,48 @@ class AgentRunner:
75
  files: list[Path] | None = None,
76
  session_id: str | None = None,
77
  doc_ids: list[str] | None = None,
 
 
78
  ) -> AgentResult:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  skill = self._skills.get(EDUCATION_PPTX_SKILL)
80
  req = EducationPptxInput(
81
  topic=topic.strip(),
@@ -95,16 +143,96 @@ class AgentRunner:
95
  user_input=req.model_dump(mode="json"),
96
  )
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  backend.load()
 
 
 
 
 
 
 
 
 
 
 
99
  source_context, source_summary, active_session = self._gather_lesson_source_context(
100
  req, backend, model_key, trace
101
  )
 
 
 
 
 
 
 
102
  if active_session:
103
  req = req.model_copy(update={"session_id": active_session})
104
-
 
 
 
 
 
 
 
 
 
 
105
  outline = self._generate_outline(
106
- skill, req, backend, trace, source_context=source_context
107
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  tool = self._tools.get("create_pptx")
109
  pptx_path = tool.handler(outline, run_id=trace.run_id)
110
  trace.log_tool(
@@ -126,16 +254,52 @@ class AgentRunner:
126
  {"title": outline.title},
127
  str(html_export_path),
128
  )
 
 
 
 
 
 
129
 
130
  trace.set_artifact(pptx_path)
131
 
132
  slides_dicts = [s.model_dump() for s in outline.slides]
133
  markdown = outline_to_markdown(outline.title, slides_dicts)
134
  html_preview = outline_to_html(outline)
135
- preview_images = [str(p) for p in render_slide_images(outline, trace.run_id)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  trace_path = trace.save()
137
 
138
- return AgentResult(
139
  markdown_preview=markdown,
140
  html_preview=html_preview,
141
  preview_images=preview_images,
@@ -242,6 +406,10 @@ class AgentRunner:
242
  summary = f"{ingest_summary}\n\n{retrieve_line}".strip() if ingest_summary else retrieve_line
243
  return context, summary, session_id
244
 
 
 
 
 
245
  def _generate_outline(
246
  self,
247
  skill: Any,
@@ -250,6 +418,7 @@ class AgentRunner:
250
  trace: TraceRecorder,
251
  *,
252
  source_context: str = "",
 
253
  ) -> SlideOutline:
254
  system = education_outline_system(skill.body)
255
  user = education_outline_user(req, source_context=source_context)
@@ -258,27 +427,100 @@ class AgentRunner:
258
  {"role": "user", "content": user},
259
  ]
260
  prompt_text = system + "\n\n" + user
261
- raw = backend.chat(messages, max_tokens=2048, temperature=0.3)
 
 
 
 
262
  trace.log_llm(prompt_text, raw)
263
 
264
- try:
265
- return self._parse_outline(raw, req.slide_count, trace)
266
- except (json.JSONDecodeError, ValueError) as first_error:
267
- repair_messages = messages + [
268
- {"role": "assistant", "content": raw},
269
- {
270
- "role": "user",
271
- "content": education_outline_repair(
272
- raw, str(first_error), expected_slides=req.slide_count
273
- ),
274
- },
275
  ]
276
- repair_prompt = education_outline_repair(
277
- raw, str(first_error), expected_slides=req.slide_count
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  )
279
- repaired = backend.chat(repair_messages, max_tokens=2048, temperature=0.1)
280
- trace.log_llm(repair_prompt, repaired)
281
- return self._parse_outline(repaired, req.slide_count, trace)
 
 
 
 
 
 
 
 
 
 
 
282
 
283
  def _parse_outline(
284
  self,
@@ -357,9 +599,13 @@ class AgentRunner:
357
  if fence:
358
  cleaned = fence.group(1).strip()
359
 
 
 
 
360
  start = cleaned.find("{")
361
  if start < 0:
362
- return json.loads(cleaned)
 
363
 
364
  end = AgentRunner._matching_brace_end(cleaned, start)
365
  if end is not None:
 
4
  import re
5
  from dataclasses import dataclass
6
  from pathlib import Path
7
+ from time import monotonic
8
+ from typing import Any, Iterator, Literal
9
 
10
  from inference.base import InferenceBackend
11
+ from inference.response_clean import strip_thinking_blocks
12
  from researchmind.citations import format_context_block
13
  from researchmind.extract import extract_docx
14
  from researchmind.ingest import IngestPipeline
 
25
  SlideSpec,
26
  )
27
  from agent.preview import outline_to_html, render_slide_images
28
+ from agent.progress import SlideGenerationProgress
29
  from agent.prompts import (
30
  education_outline_repair,
31
+ education_outline_retry_user,
32
  education_outline_system,
33
  education_outline_user,
34
+ fallback_outline,
35
+ outline_json_example,
36
+ outline_max_tokens,
37
  outline_to_markdown,
38
  )
39
  from agent.skills import SkillRegistry
 
82
  files: list[Path] | None = None,
83
  session_id: str | None = None,
84
  doc_ids: list[str] | None = None,
85
+ progress: SlideGenerationProgress | None = None,
86
+ skip_preview_images: bool = False,
87
  ) -> AgentResult:
88
+ result: AgentResult | None = None
89
+ for item in self.iter_education_pptx(
90
+ topic=topic,
91
+ grade=grade,
92
+ slide_count=slide_count,
93
+ model_key=model_key,
94
+ backend=backend,
95
+ source_mode=source_mode,
96
+ search_workflow=search_workflow,
97
+ urls=urls,
98
+ files=files,
99
+ session_id=session_id,
100
+ doc_ids=doc_ids,
101
+ progress=progress,
102
+ skip_preview_images=skip_preview_images,
103
+ ):
104
+ if isinstance(item, AgentResult):
105
+ result = item
106
+ if result is None:
107
+ raise RuntimeError("Slide generation did not return a result")
108
+ return result
109
+
110
+ def iter_education_pptx(
111
+ self,
112
+ *,
113
+ topic: str,
114
+ grade: str,
115
+ slide_count: int,
116
+ model_key: str,
117
+ backend: InferenceBackend,
118
+ source_mode: Literal["none", "web", "rag"] = "none",
119
+ search_workflow: Literal["two_step", "auto"] = "two_step",
120
+ urls: list[str] | None = None,
121
+ files: list[Path] | None = None,
122
+ session_id: str | None = None,
123
+ doc_ids: list[str] | None = None,
124
+ progress: SlideGenerationProgress | None = None,
125
+ skip_preview_images: bool = False,
126
+ ) -> Iterator[SlideGenerationProgress | AgentResult]:
127
  skill = self._skills.get(EDUCATION_PPTX_SKILL)
128
  req = EducationPptxInput(
129
  topic=topic.strip(),
 
143
  user_input=req.model_dump(mode="json"),
144
  )
145
 
146
+ try:
147
+ yield from self._iter_education_pptx_steps(
148
+ req=req,
149
+ skill=skill,
150
+ model_key=model_key,
151
+ backend=backend,
152
+ trace=trace,
153
+ progress=progress,
154
+ skip_preview_images=skip_preview_images,
155
+ )
156
+ except Exception as exc:
157
+ trace.log_note("Run failed", error=str(exc))
158
+ try:
159
+ trace.save()
160
+ except OSError:
161
+ pass
162
+ raise
163
+
164
+ def _iter_education_pptx_steps(
165
+ self,
166
+ *,
167
+ req: EducationPptxInput,
168
+ skill: Any,
169
+ model_key: str,
170
+ backend: InferenceBackend,
171
+ trace: TraceRecorder,
172
+ progress: SlideGenerationProgress | None,
173
+ skip_preview_images: bool,
174
+ ) -> Iterator[SlideGenerationProgress | AgentResult]:
175
+ if progress is not None:
176
+ progress.begin("load_model", "Load language model")
177
+ yield progress
178
+ load_started = monotonic()
179
  backend.load()
180
+ load_ms = int((monotonic() - load_started) * 1000)
181
+ trace.log_step("load_model", "Load language model", duration_ms=load_ms)
182
+
183
+ if progress is not None:
184
+ progress.begin(
185
+ "gather_sources",
186
+ "Gather lesson sources",
187
+ detail=req.source_mode,
188
+ )
189
+ yield progress
190
+ source_started = monotonic()
191
  source_context, source_summary, active_session = self._gather_lesson_source_context(
192
  req, backend, model_key, trace
193
  )
194
+ source_ms = int((monotonic() - source_started) * 1000)
195
+ trace.log_step(
196
+ "gather_sources",
197
+ "Gather lesson sources",
198
+ duration_ms=source_ms,
199
+ source_mode=req.source_mode,
200
+ )
201
  if active_session:
202
  req = req.model_copy(update={"session_id": active_session})
203
+ if progress is not None:
204
+ yield progress
205
+
206
+ if progress is not None:
207
+ progress.begin(
208
+ "generate_outline",
209
+ "Generate slide outline",
210
+ detail=f"{req.slide_count} slides · grade {req.grade}",
211
+ )
212
+ yield progress
213
+ outline_started = monotonic()
214
  outline = self._generate_outline(
215
+ skill, req, backend, trace, source_context=source_context, progress=progress
216
  )
217
+ outline_ms = int((monotonic() - outline_started) * 1000)
218
+ trace.log_step(
219
+ "generate_outline",
220
+ "Generate slide outline",
221
+ duration_ms=outline_ms,
222
+ slide_count=len(outline.slides),
223
+ )
224
+ for step in trace.steps:
225
+ if step.get("type") == "note" and step.get("phase") == "outline_fallback":
226
+ note = str(step.get("message") or "")
227
+ source_summary = f"{source_summary}\n\n_{note}_".strip() if source_summary else f"_{note}_"
228
+
229
+ if progress is not None:
230
+ yield progress
231
+
232
+ if progress is not None:
233
+ progress.begin("create_exports", "Build PPTX, DOCX, and HTML exports")
234
+ yield progress
235
+ export_started = monotonic()
236
  tool = self._tools.get("create_pptx")
237
  pptx_path = tool.handler(outline, run_id=trace.run_id)
238
  trace.log_tool(
 
254
  {"title": outline.title},
255
  str(html_export_path),
256
  )
257
+ export_ms = int((monotonic() - export_started) * 1000)
258
+ trace.log_step(
259
+ "create_exports",
260
+ "Build PPTX, DOCX, and HTML exports",
261
+ duration_ms=export_ms,
262
+ )
263
 
264
  trace.set_artifact(pptx_path)
265
 
266
  slides_dicts = [s.model_dump() for s in outline.slides]
267
  markdown = outline_to_markdown(outline.title, slides_dicts)
268
  html_preview = outline_to_html(outline)
269
+
270
+ if skip_preview_images:
271
+ preview_images: list[str] = []
272
+ trace.log_step(
273
+ "render_previews",
274
+ "Render slide thumbnails",
275
+ duration_ms=0,
276
+ detail="skipped (HTML preview only)",
277
+ )
278
+ else:
279
+ if progress is not None:
280
+ progress.begin(
281
+ "render_previews",
282
+ "Render slide thumbnails",
283
+ detail=f"{len(outline.slides) + 1} images",
284
+ )
285
+ yield progress
286
+ preview_started = monotonic()
287
+ preview_images = [str(p) for p in render_slide_images(outline, trace.run_id)]
288
+ preview_ms = int((monotonic() - preview_started) * 1000)
289
+ trace.log_step(
290
+ "render_previews",
291
+ "Render slide thumbnails",
292
+ duration_ms=preview_ms,
293
+ image_count=len(preview_images),
294
+ )
295
+
296
+ if progress is not None:
297
+ progress.finish()
298
+ yield progress
299
+
300
  trace_path = trace.save()
301
 
302
+ yield AgentResult(
303
  markdown_preview=markdown,
304
  html_preview=html_preview,
305
  preview_images=preview_images,
 
406
  summary = f"{ingest_summary}\n\n{retrieve_line}".strip() if ingest_summary else retrieve_line
407
  return context, summary, session_id
408
 
409
+ @staticmethod
410
+ def _normalize_outline_llm_text(raw: str) -> str:
411
+ return strip_thinking_blocks(raw)
412
+
413
  def _generate_outline(
414
  self,
415
  skill: Any,
 
418
  trace: TraceRecorder,
419
  *,
420
  source_context: str = "",
421
+ progress: SlideGenerationProgress | None = None,
422
  ) -> SlideOutline:
423
  system = education_outline_system(skill.body)
424
  user = education_outline_user(req, source_context=source_context)
 
427
  {"role": "user", "content": user},
428
  ]
429
  prompt_text = system + "\n\n" + user
430
+ token_budget = outline_max_tokens(req.slide_count)
431
+
432
+ raw = self._normalize_outline_llm_text(
433
+ backend.chat(messages, max_tokens=token_budget, temperature=0.0)
434
+ )
435
  trace.log_llm(prompt_text, raw)
436
 
437
+ if not raw:
438
+ trace.log_note(
439
+ "Empty outline response; retrying with JSON example",
440
+ phase="outline_retry",
441
+ )
442
+ example = outline_json_example(req.slide_count)
443
+ retry_user = education_outline_retry_user(req, example_json=example)
444
+ retry_messages = [
445
+ {"role": "system", "content": system},
446
+ {"role": "user", "content": retry_user},
 
447
  ]
448
+ retry_prompt = system + "\n\n" + retry_user
449
+ raw = self._normalize_outline_llm_text(
450
+ backend.chat(retry_messages, max_tokens=token_budget, temperature=0.0)
451
+ )
452
+ trace.log_llm(retry_prompt, raw)
453
+
454
+ outline, parse_error = self._parse_outline_or_error(raw, req.slide_count, trace)
455
+ if outline is not None:
456
+ return outline
457
+
458
+ if progress is not None:
459
+ progress.begin(
460
+ "repair_outline",
461
+ "Repair outline JSON",
462
+ detail=(parse_error or "invalid JSON")[:80],
463
+ )
464
+ repair_started = monotonic()
465
+ repair_user = education_outline_repair(
466
+ raw,
467
+ parse_error or "invalid JSON",
468
+ expected_slides=req.slide_count,
469
+ )
470
+ repair_messages = messages + [
471
+ {"role": "assistant", "content": raw},
472
+ {"role": "user", "content": repair_user},
473
+ ]
474
+ repaired = self._normalize_outline_llm_text(
475
+ backend.chat(
476
+ repair_messages,
477
+ max_tokens=min(512, token_budget),
478
+ temperature=0.0,
479
+ )
480
+ )
481
+ trace.log_llm(repair_user, repaired)
482
+ outline, repair_error = self._parse_outline_or_error(
483
+ repaired, req.slide_count, trace
484
+ )
485
+ repair_ms = int((monotonic() - repair_started) * 1000)
486
+ if outline is not None:
487
+ trace.log_step(
488
+ "repair_outline",
489
+ "Repair outline JSON",
490
+ duration_ms=repair_ms,
491
+ )
492
+ return outline
493
+
494
+ trace.log_step(
495
+ "repair_outline",
496
+ "Repair outline JSON",
497
+ duration_ms=repair_ms,
498
+ error=repair_error or parse_error,
499
+ )
500
+ trace.log_note(
501
+ "Model outline invalid after repair; using template slides.",
502
+ phase="outline_fallback",
503
+ )
504
+ if progress is not None:
505
+ progress.begin(
506
+ "fallback_outline",
507
+ "Use template outline",
508
+ detail=(repair_error or parse_error or "invalid JSON")[:80],
509
  )
510
+ return fallback_outline(req)
511
+
512
+ def _parse_outline_or_error(
513
+ self,
514
+ raw: str,
515
+ expected_slides: int,
516
+ trace: TraceRecorder | None,
517
+ ) -> tuple[SlideOutline | None, str]:
518
+ if not raw.strip():
519
+ return None, "Model returned empty output (no JSON)"
520
+ try:
521
+ return self._parse_outline(raw, expected_slides, trace), ""
522
+ except (json.JSONDecodeError, ValueError) as exc:
523
+ return None, str(exc)
524
 
525
  def _parse_outline(
526
  self,
 
599
  if fence:
600
  cleaned = fence.group(1).strip()
601
 
602
+ if not cleaned:
603
+ raise ValueError("Model returned empty output (no JSON)")
604
+
605
  start = cleaned.find("{")
606
  if start < 0:
607
+ preview = cleaned[:120].replace("\n", " ")
608
+ raise ValueError(f"Model response has no JSON object: {preview!r}")
609
 
610
  end = AgentRunner._matching_brace_end(cleaned, start)
611
  if end is not None:
libs/agent/src/agent/trace.py CHANGED
@@ -6,6 +6,7 @@ import uuid
6
  from dataclasses import dataclass, field
7
  from datetime import UTC, datetime
8
  from pathlib import Path
 
9
  from typing import Any
10
 
11
 
@@ -22,6 +23,29 @@ class TraceRecorder:
22
  steps: list[dict[str, Any]] = field(default_factory=list)
23
  artifact: str | None = None
24
  created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  def log_llm(self, prompt: str, output: str) -> None:
27
  self.steps.append(
 
6
  from dataclasses import dataclass, field
7
  from datetime import UTC, datetime
8
  from pathlib import Path
9
+ from time import monotonic
10
  from typing import Any
11
 
12
 
 
23
  steps: list[dict[str, Any]] = field(default_factory=list)
24
  artifact: str | None = None
25
  created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
26
+ _started_at: float = field(default_factory=monotonic, repr=False)
27
+
28
+ def log_step(
29
+ self,
30
+ name: str,
31
+ label: str,
32
+ *,
33
+ duration_ms: int | None = None,
34
+ detail: str = "",
35
+ **details: Any,
36
+ ) -> None:
37
+ payload: dict[str, Any] = {
38
+ "type": "step",
39
+ "name": name,
40
+ "label": label,
41
+ "elapsed_ms": int((monotonic() - self._started_at) * 1000),
42
+ }
43
+ if duration_ms is not None:
44
+ payload["duration_ms"] = duration_ms
45
+ if detail:
46
+ payload["detail"] = detail
47
+ payload.update(details)
48
+ self.steps.append(payload)
49
 
50
  def log_llm(self, prompt: str, output: str) -> None:
51
  self.steps.append(
libs/agent/tests/test_runner.py CHANGED
@@ -1,11 +1,15 @@
1
- from agent.models import SlideOutline, SlideSpec
2
  from agent.preview import outline_to_html, render_slide_images
 
3
  from agent.runner import AgentRunner
4
  from agent.tools.docx import create_docx, create_html_export
5
  from agent.tools.pptx import create_pptx
6
 
7
 
8
- def test_parse_outline_pads_when_model_returns_too_few():
 
 
 
9
  runner = AgentRunner()
10
  raw = (
11
  '{"title": "AI Agents", "slides": ['
@@ -54,6 +58,39 @@ def test_extract_json_ignores_duplicate_object():
54
  assert data["title"] == "First"
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  def test_create_pptx_writes_file(tmp_path, monkeypatch):
58
  monkeypatch.setenv("AGENT_OUTPUTS_DIR", str(tmp_path))
59
  outline = SlideOutline(
 
1
+ from agent.models import EducationPptxInput, SlideOutline, SlideSpec
2
  from agent.preview import outline_to_html, render_slide_images
3
+ from agent.prompts import fallback_outline, outline_max_tokens
4
  from agent.runner import AgentRunner
5
  from agent.tools.docx import create_docx, create_html_export
6
  from agent.tools.pptx import create_pptx
7
 
8
 
9
+ def test_outline_max_tokens_scales_with_slide_count():
10
+ assert outline_max_tokens(5) == 750
11
+ assert outline_max_tokens(1) == 230
12
+ assert outline_max_tokens(20) == 1024
13
  runner = AgentRunner()
14
  raw = (
15
  '{"title": "AI Agents", "slides": ['
 
58
  assert data["title"] == "First"
59
 
60
 
61
+ def test_extract_json_empty_raises():
62
+ import pytest
63
+
64
+ with pytest.raises(ValueError, match="empty output"):
65
+ AgentRunner._extract_json(" ")
66
+
67
+
68
+ def test_extract_json_after_thinking_block():
69
+ raw = (
70
+ "planning the lesson\n"
71
+ '{"title": "Agents", "slides": [{"title": "Intro", "bullets": ["What is an agent?"]}]}'
72
+ )
73
+ from inference.response_clean import strip_thinking_blocks
74
+
75
+ cleaned = strip_thinking_blocks(raw)
76
+ data = AgentRunner._extract_json(cleaned)
77
+ assert data["title"] == "Agents"
78
+
79
+
80
+ def test_parse_outline_or_error_empty():
81
+ runner = AgentRunner()
82
+ outline, err = runner._parse_outline_or_error("", 5, None)
83
+ assert outline is None
84
+ assert "empty" in err.lower()
85
+
86
+
87
+ def test_fallback_outline_slide_count():
88
+ req = EducationPptxInput(topic="ai agent", grade="6", slide_count=5)
89
+ outline = fallback_outline(req)
90
+ assert len(outline.slides) == 5
91
+ assert "ai agent" in outline.title.lower()
92
+
93
+
94
  def test_create_pptx_writes_file(tmp_path, monkeypatch):
95
  monkeypatch.setenv("AGENT_OUTPUTS_DIR", str(tmp_path))
96
  outline = SlideOutline(
libs/inference/src/inference/device_utils.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CUDA / CPU device selection and OOM helpers for inference backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gc
6
+ import os
7
+ from dataclasses import dataclass
8
+ from typing import Iterator, Literal
9
+
10
+ DevicePreference = Literal["auto", "cuda", "cpu"]
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class DevicePlan:
15
+ device: str
16
+ torch_dtype_name: str
17
+ device_map: str | dict[str, int] | None
18
+ label: str
19
+
20
+
21
+ def inference_device_preference() -> str:
22
+ return os.environ.get("INFERENCE_DEVICE", "auto").strip().lower()
23
+
24
+
25
+ def is_cuda_oom(exc: BaseException) -> bool:
26
+ msg = str(exc).lower()
27
+ return "cuda out of memory" in msg or "cudaoom" in msg.replace("_", "")
28
+
29
+
30
+ def clear_cuda_cache() -> None:
31
+ gc.collect()
32
+ try:
33
+ import torch
34
+ except ImportError:
35
+ return
36
+ if not torch.cuda.is_available():
37
+ return
38
+ torch.cuda.empty_cache()
39
+ try:
40
+ torch.cuda.ipc_collect()
41
+ except Exception:
42
+ pass
43
+ torch.cuda.synchronize()
44
+
45
+
46
+ def iter_inference_device_plans() -> Iterator[DevicePlan]:
47
+ """Yield load plans: each CUDA device (if allowed), then CPU."""
48
+ pref = inference_device_preference()
49
+ if pref == "cpu":
50
+ yield DevicePlan("cpu", "float32", None, "cpu")
51
+ return
52
+
53
+ try:
54
+ import torch
55
+ except ImportError:
56
+ yield DevicePlan("cpu", "float32", None, "cpu")
57
+ return
58
+
59
+ if pref in ("cuda", "auto") and torch.cuda.is_available():
60
+ for index in range(torch.cuda.device_count()):
61
+ name = torch.cuda.get_device_name(index)
62
+ yield DevicePlan(
63
+ f"cuda:{index}",
64
+ "float16",
65
+ {"": index},
66
+ f"cuda:{index} ({name})",
67
+ )
68
+
69
+ if pref in ("auto", "cpu"):
70
+ yield DevicePlan("cpu", "float32", None, "cpu")
libs/inference/src/inference/factory.py CHANGED
@@ -36,5 +36,7 @@ def get_backend(model_key: str | None = None) -> InferenceBackend:
36
 
37
  def reset_backend() -> None:
38
  global _backend, _backend_key
 
 
39
  _backend = None
40
  _backend_key = None
 
36
 
37
  def reset_backend() -> None:
38
  global _backend, _backend_key
39
+ if _backend is not None and hasattr(_backend, "unload"):
40
+ _backend.unload()
41
  _backend = None
42
  _backend_key = None
libs/inference/src/inference/llama_cpp.py CHANGED
@@ -33,18 +33,36 @@ class LlamaCppBackend:
33
  cache_dir=cache_dir,
34
  )
35
 
 
 
 
 
36
  def load(self) -> None:
37
  if self._model is not None:
38
  return
39
 
40
  self._model_path = self._resolve_model_path()
41
-
42
- self._model = Llama(
43
- model_path=self._model_path,
44
- n_ctx=self._config.n_ctx,
45
- n_gpu_layers=self._config.n_gpu_layers,
46
- verbose=False,
47
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
  def generate(
50
  self,
 
33
  cache_dir=cache_dir,
34
  )
35
 
36
+ def unload(self) -> None:
37
+ self._model = None
38
+ self._model_path = None
39
+
40
  def load(self) -> None:
41
  if self._model is not None:
42
  return
43
 
44
  self._model_path = self._resolve_model_path()
45
+ gpu_layers = self._config.n_gpu_layers
46
+ try:
47
+ self._model = Llama(
48
+ model_path=self._model_path,
49
+ n_ctx=self._config.n_ctx,
50
+ n_gpu_layers=gpu_layers,
51
+ verbose=False,
52
+ )
53
+ except Exception as exc:
54
+ if gpu_layers <= 0:
55
+ raise
56
+ print(
57
+ f"[inference] llama.cpp GPU offload failed ({exc}); using CPU (n_gpu_layers=0)…",
58
+ flush=True,
59
+ )
60
+ self._model = Llama(
61
+ model_path=self._model_path,
62
+ n_ctx=self._config.n_ctx,
63
+ n_gpu_layers=0,
64
+ verbose=False,
65
+ )
66
 
67
  def generate(
68
  self,
libs/inference/src/inference/response_clean.py CHANGED
@@ -203,6 +203,14 @@ def prepare_display_reply(text: str) -> str:
203
  return cleaned
204
 
205
 
 
 
 
 
 
 
 
 
206
  def strip_reasoning_output(text: str) -> str:
207
  """Remove model chain-of-thought / thinking traces from user-visible replies."""
208
  cleaned = text.strip()
 
203
  return cleaned
204
 
205
 
206
+ def strip_thinking_blocks(text: str) -> str:
207
+ """Remove chain-of-thought wrapper tags; keep remaining text (e.g. JSON) intact."""
208
+ cleaned = text.strip()
209
+ if not cleaned:
210
+ return ""
211
+ return _THINK_BLOCKS.sub("", cleaned).strip()
212
+
213
+
214
  def strip_reasoning_output(text: str) -> str:
215
  """Remove model chain-of-thought / thinking traces from user-visible replies."""
216
  cleaned = text.strip()
libs/inference/src/inference/transformers.py CHANGED
@@ -1,6 +1,12 @@
1
  from __future__ import annotations
2
 
3
  from inference.config import ModelConfig
 
 
 
 
 
 
4
 
5
 
6
  class TransformersBackend:
@@ -10,51 +16,40 @@ class TransformersBackend:
10
  self._tokenizer = None
11
  self._processor = None
12
  self._device_label: str | None = None
 
13
 
14
- def _resolve_device(self):
 
 
 
 
 
 
 
 
15
  import torch
16
 
17
- if torch.cuda.is_available():
18
- return "cuda", torch.float16, "auto"
19
- return "cpu", torch.float32, None
20
 
21
- def load(self) -> None:
22
- if self._model is not None:
23
- return
24
-
25
- if not self._config.model_id:
26
- raise ValueError(
27
- f"Preset {self._config.key!r} requires model_id for transformers backend"
28
- )
29
-
30
- try:
31
- import torch
32
- from transformers import (
33
- AutoModelForCausalLM,
34
- AutoModelForImageTextToText,
35
- AutoProcessor,
36
- AutoTokenizer,
37
- )
38
- except ImportError as exc:
39
- raise ImportError(
40
- "transformers backend requires torch and transformers. "
41
- "Install with: uv sync --all-packages"
42
- ) from exc
43
-
44
- device, torch_dtype, device_map = self._resolve_device()
45
- self._device_label = (
46
- f"cuda ({torch.cuda.get_device_name(0)})"
47
- if device == "cuda"
48
- else "cpu"
49
  )
50
 
 
51
  common_kwargs = {
52
  "trust_remote_code": self._config.trust_remote_code,
53
  }
54
  model_kwargs = {
55
  **common_kwargs,
56
  "dtype": torch_dtype,
57
- "device_map": device_map,
58
  }
59
 
60
  if self._config.multimodal:
@@ -88,8 +83,82 @@ class TransformersBackend:
88
  )
89
  self._model = PeftModel.from_pretrained(self._model, str(adapter))
90
 
91
- if device == "cpu":
92
- self._model.to(device)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
  @property
95
  def device_label(self) -> str:
@@ -168,6 +237,29 @@ class TransformersBackend:
168
  generated = output[0][inputs["input_ids"].shape[-1] :]
169
  return self._tokenizer.decode(generated, skip_special_tokens=True).strip()
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  def generate(
172
  self,
173
  prompt: str,
@@ -189,7 +281,7 @@ class TransformersBackend:
189
  temperature: float | None = None,
190
  ) -> str:
191
  normalized = self._normalize_messages(messages)
192
- return self._generate_from_messages(
193
  normalized,
194
  max_tokens=max_tokens,
195
  temperature=temperature,
 
1
  from __future__ import annotations
2
 
3
  from inference.config import ModelConfig
4
+ from inference.device_utils import (
5
+ DevicePlan,
6
+ clear_cuda_cache,
7
+ is_cuda_oom,
8
+ iter_inference_device_plans,
9
+ )
10
 
11
 
12
  class TransformersBackend:
 
16
  self._tokenizer = None
17
  self._processor = None
18
  self._device_label: str | None = None
19
+ self._active_plan: DevicePlan | None = None
20
 
21
+ def unload(self) -> None:
22
+ self._model = None
23
+ self._tokenizer = None
24
+ self._processor = None
25
+ self._device_label = None
26
+ self._active_plan = None
27
+ clear_cuda_cache()
28
+
29
+ def _torch_dtype(self, plan: DevicePlan):
30
  import torch
31
 
32
+ if plan.torch_dtype_name == "float16":
33
+ return torch.float16
34
+ return torch.float32
35
 
36
+ def _load_on_plan(self, plan: DevicePlan) -> None:
37
+ import torch
38
+ from transformers import (
39
+ AutoModelForCausalLM,
40
+ AutoModelForImageTextToText,
41
+ AutoProcessor,
42
+ AutoTokenizer,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  )
44
 
45
+ torch_dtype = self._torch_dtype(plan)
46
  common_kwargs = {
47
  "trust_remote_code": self._config.trust_remote_code,
48
  }
49
  model_kwargs = {
50
  **common_kwargs,
51
  "dtype": torch_dtype,
52
+ "device_map": plan.device_map,
53
  }
54
 
55
  if self._config.multimodal:
 
83
  )
84
  self._model = PeftModel.from_pretrained(self._model, str(adapter))
85
 
86
+ if plan.device == "cpu":
87
+ assert self._model is not None
88
+ self._model.to("cpu")
89
+
90
+ self._active_plan = plan
91
+ self._device_label = plan.label
92
+
93
+ def load(self) -> None:
94
+ if self._model is not None:
95
+ return
96
+
97
+ if not self._config.model_id:
98
+ raise ValueError(
99
+ f"Preset {self._config.key!r} requires model_id for transformers backend"
100
+ )
101
+
102
+ try:
103
+ import torch # noqa: F401
104
+ from transformers import ( # noqa: F401
105
+ AutoModelForCausalLM,
106
+ AutoModelForImageTextToText,
107
+ AutoProcessor,
108
+ AutoTokenizer,
109
+ )
110
+ except ImportError as exc:
111
+ raise ImportError(
112
+ "transformers backend requires torch and transformers. "
113
+ "Install with: uv sync --all-packages"
114
+ ) from exc
115
+
116
+ last_error: Exception | None = None
117
+ for plan in iter_inference_device_plans():
118
+ self.unload()
119
+ try:
120
+ self._load_on_plan(plan)
121
+ print(
122
+ f"[inference] Loaded {self._config.model_id} on {plan.label}",
123
+ flush=True,
124
+ )
125
+ return
126
+ except RuntimeError as exc:
127
+ if is_cuda_oom(exc):
128
+ last_error = exc
129
+ print(
130
+ f"[inference] CUDA OOM loading on {plan.label}; trying next device…",
131
+ flush=True,
132
+ )
133
+ continue
134
+ raise
135
+ except Exception as exc:
136
+ if plan.device.startswith("cuda") and is_cuda_oom(exc):
137
+ last_error = exc
138
+ print(
139
+ f"[inference] Failed on {plan.label} ({exc}); trying next device…",
140
+ flush=True,
141
+ )
142
+ continue
143
+ raise
144
+
145
+ if last_error is not None:
146
+ raise last_error
147
+ raise RuntimeError(f"Failed to load model {self._config.model_id!r} on any device")
148
+
149
+ def _on_cpu(self) -> bool:
150
+ return self._active_plan is not None and self._active_plan.device == "cpu"
151
+
152
+ def _move_model_to_cpu(self) -> None:
153
+ assert self._model is not None
154
+ clear_cuda_cache()
155
+ self._model = self._model.to("cpu")
156
+ if self._active_plan and self._active_plan.torch_dtype_name == "float16":
157
+ self._model = self._model.float()
158
+ self._active_plan = DevicePlan("cpu", "float32", None, "cpu (CUDA OOM fallback)")
159
+ self._device_label = self._active_plan.label
160
+ clear_cuda_cache()
161
+ print(f"[inference] Moved {self._config.model_id} to CPU after CUDA OOM", flush=True)
162
 
163
  @property
164
  def device_label(self) -> str:
 
237
  generated = output[0][inputs["input_ids"].shape[-1] :]
238
  return self._tokenizer.decode(generated, skip_special_tokens=True).strip()
239
 
240
+ def _run_with_oom_fallback(
241
+ self,
242
+ messages: list[dict[str, object]],
243
+ *,
244
+ max_tokens: int | None = None,
245
+ temperature: float | None = None,
246
+ ) -> str:
247
+ try:
248
+ return self._generate_from_messages(
249
+ messages,
250
+ max_tokens=max_tokens,
251
+ temperature=temperature,
252
+ )
253
+ except RuntimeError as exc:
254
+ if self._on_cpu() or not is_cuda_oom(exc):
255
+ raise
256
+ self._move_model_to_cpu()
257
+ return self._generate_from_messages(
258
+ messages,
259
+ max_tokens=max_tokens,
260
+ temperature=temperature,
261
+ )
262
+
263
  def generate(
264
  self,
265
  prompt: str,
 
281
  temperature: float | None = None,
282
  ) -> str:
283
  normalized = self._normalize_messages(messages)
284
+ return self._run_with_oom_fallback(
285
  normalized,
286
  max_tokens=max_tokens,
287
  temperature=temperature,
libs/inference/tests/test_device_utils.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from inference.device_utils import is_cuda_oom, iter_inference_device_plans
2
+
3
+
4
+ def test_is_cuda_oom_matches_pytorch_message() -> None:
5
+ exc = RuntimeError(
6
+ "CUDA out of memory. Tried to allocate 384.00 MiB. "
7
+ "GPU 0 has a total capacity of 3.68 GiB of which 350.19 MiB is free."
8
+ )
9
+ assert is_cuda_oom(exc)
10
+
11
+
12
+ def test_is_cuda_oom_rejects_other_errors() -> None:
13
+ assert not is_cuda_oom(RuntimeError("disk full"))
14
+
15
+
16
+ def test_iter_inference_device_plans_includes_cpu(monkeypatch) -> None:
17
+ monkeypatch.setenv("INFERENCE_DEVICE", "cpu")
18
+ plans = list(iter_inference_device_plans())
19
+ assert len(plans) == 1
20
+ assert plans[0].device == "cpu"
libs/inference/tests/test_response_clean.py CHANGED
@@ -1,6 +1,10 @@
1
  from __future__ import annotations
2
 
3
- from inference.response_clean import prepare_display_reply, strip_reasoning_output
 
 
 
 
4
 
5
  _RT_OPEN = "<" + "redacted_thinking" + ">"
6
  _RT_CLOSE = "</" + "redacted_thinking" + ">"
@@ -18,6 +22,11 @@ def test_strips_think_block():
18
  assert strip_reasoning_output(raw) == "Agents use memory [1]."
19
 
20
 
 
 
 
 
 
21
  def test_strips_malformed_think_prefix_and_extracts_summary():
22
  raw = """think> We need to summarize the document. First, identify sources.
23
 
 
1
  from __future__ import annotations
2
 
3
+ from inference.response_clean import (
4
+ prepare_display_reply,
5
+ strip_reasoning_output,
6
+ strip_thinking_blocks,
7
+ )
8
 
9
  _RT_OPEN = "<" + "redacted_thinking" + ">"
10
  _RT_CLOSE = "</" + "redacted_thinking" + ">"
 
22
  assert strip_reasoning_output(raw) == "Agents use memory [1]."
23
 
24
 
25
+ def test_strip_thinking_blocks_preserves_json_payload():
26
+ raw = f"{_THINK_OPEN}\nplanning...\n{_THINK_CLOSE}\n\n{{\"title\": \"T\"}}"
27
+ assert strip_thinking_blocks(raw) == '{"title": "T"}'
28
+
29
+
30
  def test_strips_malformed_think_prefix_and_extracts_summary():
31
  raw = """think> We need to summarize the document. First, identify sources.
32
 
libs/researchmind/src/researchmind/embeddings.py CHANGED
@@ -1,26 +1,90 @@
1
  from __future__ import annotations
2
 
 
 
3
  import numpy as np
4
 
 
 
5
  _embedder = None
6
  _embedder_model_name: str | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
 
9
  def get_embedder(model_name: str):
10
- global _embedder, _embedder_model_name
11
- if _embedder is None or _embedder_model_name != model_name:
12
- from sentence_transformers import SentenceTransformer
 
 
 
 
 
 
13
 
14
- _embedder = SentenceTransformer(model_name)
15
- _embedder_model_name = model_name
16
- return _embedder
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
 
19
  def embed_texts(texts: list[str], *, model_name: str) -> np.ndarray:
 
 
20
  if not texts:
21
  return np.zeros((0, 0), dtype=np.float32)
22
  model = get_embedder(model_name)
23
- vectors = model.encode(texts, normalize_embeddings=True, show_progress_bar=False)
 
 
 
 
 
 
 
 
 
 
 
 
24
  return np.asarray(vectors, dtype=np.float32)
25
 
26
 
 
1
  from __future__ import annotations
2
 
3
+ import os
4
+
5
  import numpy as np
6
 
7
+ from inference.device_utils import clear_cuda_cache, is_cuda_oom
8
+
9
  _embedder = None
10
  _embedder_model_name: str | None = None
11
+ _embedder_device: str | None = None
12
+
13
+
14
+ def _embed_device_preference() -> str:
15
+ return os.environ.get("RESEARCHMIND_EMBED_DEVICE", "cpu").strip().lower()
16
+
17
+
18
+ def _embed_device_candidates() -> list[str]:
19
+ pref = _embed_device_preference()
20
+ if pref == "cpu":
21
+ return ["cpu"]
22
+ if pref == "cuda":
23
+ return ["cuda", "cpu"]
24
+ try:
25
+ import torch
26
+
27
+ if torch.cuda.is_available():
28
+ return ["cuda", "cpu"]
29
+ except ImportError:
30
+ pass
31
+ return ["cpu"]
32
 
33
 
34
  def get_embedder(model_name: str):
35
+ global _embedder, _embedder_model_name, _embedder_device
36
+ if (
37
+ _embedder is not None
38
+ and _embedder_model_name == model_name
39
+ and _embedder_device is not None
40
+ ):
41
+ return _embedder
42
+
43
+ from sentence_transformers import SentenceTransformer
44
 
45
+ last_error: Exception | None = None
46
+ for device in _embed_device_candidates():
47
+ try:
48
+ _embedder = SentenceTransformer(model_name, device=device)
49
+ _embedder_model_name = model_name
50
+ _embedder_device = device
51
+ print(f"[researchmind] Embedding model on {device}", flush=True)
52
+ return _embedder
53
+ except Exception as exc:
54
+ if device != "cpu" and is_cuda_oom(exc):
55
+ last_error = exc
56
+ clear_cuda_cache()
57
+ print(
58
+ "[researchmind] CUDA OOM loading embedder; falling back to CPU…",
59
+ flush=True,
60
+ )
61
+ continue
62
+ raise
63
+
64
+ if last_error is not None:
65
+ raise last_error
66
+ raise RuntimeError(f"Failed to load embedding model {model_name!r}")
67
 
68
 
69
  def embed_texts(texts: list[str], *, model_name: str) -> np.ndarray:
70
+ global _embedder, _embedder_device
71
+
72
  if not texts:
73
  return np.zeros((0, 0), dtype=np.float32)
74
  model = get_embedder(model_name)
75
+ try:
76
+ vectors = model.encode(texts, normalize_embeddings=True, show_progress_bar=False)
77
+ except RuntimeError as exc:
78
+ if _embedder_device != "cpu" and is_cuda_oom(exc):
79
+ clear_cuda_cache()
80
+ _embedder = None
81
+ _embedder_device = None
82
+ model = get_embedder(model_name)
83
+ vectors = model.encode(
84
+ texts, normalize_embeddings=True, show_progress_bar=False
85
+ )
86
+ else:
87
+ raise
88
  return np.asarray(vectors, dtype=np.float32)
89
 
90
 
outputs/traces/5ffe463cd9ff.json DELETED
@@ -1,28 +0,0 @@
1
- {
2
- "run_id": "5ffe463cd9ff",
3
- "skill": "education-pptx",
4
- "model": "minicpm5-1b",
5
- "input": {
6
- "topic": "ai agent",
7
- "grade": "6",
8
- "slide_count": 5
9
- },
10
- "steps": [
11
- {
12
- "type": "llm",
13
- "prompt_hash": "92e1b034dc5f5dca",
14
- "output": "<think>\nThe user wants a lesson plan for teaching AI agent to grade level 6 students. I need to create a JSON outline with 5 slides. The topic is \"AI Agent\", grade level is 6, and content slides are 5.\n\nI'll structure the JSON with a title and then 5 slides. Each slide should have a title, bullets (short factual statements), and optionally a speaker_note. I'll make sure to keep it simple and age-appropriate for 6th graders.\n\nLet me outline the content:\n\n1. Introduction to AI Agent\n2. What is an AI Agent?\n3. Key features of an AI Agent\n4. How AI Agents work\n5. Real-world applications\n\nI'll make sure each slide has 2-3 bullets for brevity. The speaker_note can be brief if helpful.\n\nNow, I'll generate the JSON accordingly.\n</think>\n\n{\n \"title\": \"AI Agent: Teaching a 6th Grade\",\n \"slides\": [\n {\n \"title\": \"Introduction to AI Agent\",\n \"bullets\": [\n \"An AI Agent is a computer program that can perform tasks for you.\",\n \"It can learn from experience and improve over time.\",\n \"It can be used for many different tasks.\"\n ],\n \"speaker_note\": \"AI agents are like helpful digital assistants that can help with homework, answering questions, and more.\"\n },\n {\n \"title\": \"What is an AI Agent?\",\n \"bullets\": [\n \"An AI Agent is a program that can learn from experience and improve over time.\",\n \"It can be used for many different tasks, such as answering questions, helping with homework, and more.\"\n ],\n \"speaker_note\": \"Think of it as a smart program that can learn from your interactions and become better over time.\"\n },\n {\n \"title\": \"Key Features of an AI Agent\",\n \"bullets\": [\n \"It can learn from experience and improve over time.\",\n \"It can be used for many different tasks, such as answering questions, helping with homework, and more.\"\n ],\n \"speaker_note\": \"AI agents are designed to be helpful and efficient in their tasks.\"\n },\n {\n \"title\": \"How AI Agents Work\",\n \"bullets\": [\n \"An AI Agent uses algorithms and data to make decisions.\",\n \"It learns from user interactions to become more accurate over time.\",\n \"It can be programmed to perform specific tasks.\"\n ],\n \"speaker_note\": \"AI agents use algorithms to make decisions based on data.\"\n },\n {\n \"title\": \"Real-World Applications\",\n \"bullets\": [\n \"AI agents can help with homework, answering questions, and more.\",\n \"They can be used in many different fields, like education, customer service, and more.\"\n ],\n \"speaker_note\": \"AI agents are used in many different fields, including education and customer service.\"\n }\n ]\n}"
15
- },
16
- {
17
- "type": "tool",
18
- "name": "create_pptx",
19
- "arguments": {
20
- "title": "AI Agent: Teaching a 6th Grade",
21
- "slide_count": 5
22
- },
23
- "result": "/tmp/agent_outputs/ai_agent_teaching_a_6th_grade_5ffe463cd9ff.pptx"
24
- }
25
- ],
26
- "artifact": "/tmp/agent_outputs/ai_agent_teaching_a_6th_grade_5ffe463cd9ff.pptx",
27
- "created_at": "2026-06-11T12:15:49.294920+00:00"
28
- }