MSG msgencrypted-auto commited on
Commit
8c6b423
·
1 Parent(s): 81da2d5

Feat/fixing stuff (#11)

Browse files

* studio plan

* studio html

* studio html

* studio html

* studio html

* studio html and js features voice

* plan slides quizz

* fix prompt and pptx

---------

Co-authored-by: msgencrypted-auto <msgencrypted.auto@gmail.com>

.cursor/plans/slides_from_chat_+_quiz_f53701a3.plan.md ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Slides from chat + Quiz
3
+ overview: Add "Generate slides from this conversation" buttons on all three Studio chat surfaces by injecting formatted chat history into the existing education-pptx pipeline, then ship a new quiz-maker skill with DOCX + HTML exports following the same agent/tab/API pattern as lesson slides.
4
+ todos:
5
+ - id: conv-helper
6
+ content: Add conversation_helpers.py + extend EducationPptxInput/conversation_context in prompts and runner
7
+ status: pending
8
+ - id: slides-from-chat-api
9
+ content: Extend generate_lesson_slides + api_generate_slides_from_conversation endpoint
10
+ status: pending
11
+ - id: studio-slide-buttons
12
+ content: Add Generate-slides-from-chat buttons on Research, Voice, Debug; shared renderSlideGenerationResult()
13
+ status: pending
14
+ - id: quiz-skill-backend
15
+ content: Create quiz-maker skill, models, prompts, create_quiz tool, iter_quiz_maker runner
16
+ status: pending
17
+ - id: quiz-classic-tab
18
+ content: Add tabs/quiz_maker.py and wire Classic Gradio tab
19
+ status: pending
20
+ - id: quiz-studio-ui
21
+ content: Add api_generate_quiz + Studio Quiz sidebar view with DOCX/HTML downloads
22
+ status: pending
23
+ isProject: false
24
+ ---
25
+
26
+ # Slides from conversation + Quiz maker skill
27
+
28
+ ## Context
29
+
30
+ The slide pipeline is already complete: [`AgentRunner.iter_education_pptx`](libs/agent/src/agent/runner.py) → LLM JSON [`SlideOutline`](libs/agent/src/agent/models.py) → [`create_pptx`](libs/agent/src/agent/tools/pptx.py) / [`create_docx`](libs/agent/src/agent/tools/docx.py) / HTML preview. Studio calls it via [`api_generate_slides`](apps/gradio-space/src/gradio_space/api/studio.py) → [`generate_lesson_slides`](apps/gradio-space/src/gradio_space/tabs/education_pptx.py).
31
+
32
+ **Gap:** no path from chat history → outline. The TeacherVoice plan explicitly deferred this as Phase 2; [`education_outline_user`](libs/agent/src/agent/prompts.py) already accepts `source_context` for RAG excerpts — conversation text fits the same injection pattern as a separate block (do not mix with RAG chunks).
33
+
34
+ **Quiz:** planned in [`skill_agent_pptx_5413e3c2.plan.md`](.cursor/plans/skill_agent_pptx_5413e3c2.plan.md) Phase 2 but not implemented. You chose **DOCX + HTML** export, mirroring slides.
35
+
36
+ ```mermaid
37
+ flowchart TB
38
+ subgraph chats [Studio chat panels]
39
+ Research["Research chat\nrole/content dicts"]
40
+ Voice["TeacherVoice\nGradio-style pairs"]
41
+ Debug["Debug chat\nuser/assistant pairs"]
42
+ end
43
+ subgraph api [Thin API layer]
44
+ Format["format_conversation_context()"]
45
+ GenSlides["generate_slides_from_conversation"]
46
+ end
47
+ subgraph agent [Existing pipeline]
48
+ Runner["iter_education_pptx"]
49
+ Outline["_generate_outline\n+ conversation_context"]
50
+ Export["create_pptx / docx / html"]
51
+ end
52
+ chats --> Format --> GenSlides --> Runner --> Outline --> Export
53
+ ```
54
+
55
+ ---
56
+
57
+ ## Part A — Generate slides from conversation (reuse existing pipeline)
58
+
59
+ ### A1. Normalize chat history (backend)
60
+
61
+ Add [`apps/gradio-space/src/gradio_space/conversation_helpers.py`](apps/gradio-space/src/gradio_space/conversation_helpers.py):
62
+
63
+ | `history_kind` | Input shape (already in Studio state) | Format |
64
+ |----------------|----------------------------------------|--------|
65
+ | `research` | `list[dict]` `{role, content}` | `User: …\nAssistant: …` |
66
+ | `gradio` | `list[list[str]]` `[user, assistant]` | same |
67
+ | `voice` | `list` from TeacherVoice API | detect dict vs pair; same output |
68
+
69
+ - Truncate to a safe token budget (~6–8k chars) keeping **most recent** turns.
70
+ - Return `(conversation_text, derived_topic)` where `derived_topic` is first non-empty user message (fallback when workspace topic empty).
71
+
72
+ ### A2. Extend agent input + prompt (minimal)
73
+
74
+ In [`libs/agent/src/agent/models.py`](libs/agent/src/agent/models.py):
75
+
76
+ ```python
77
+ conversation_context: str = ""
78
+ ```
79
+
80
+ In [`education_outline_user`](libs/agent/src/agent/prompts.py), when `conversation_context` is set, append:
81
+
82
+ > Base the slide outline on this conversation transcript. Prefer topics and facts discussed over general knowledge.
83
+
84
+ Pass through [`_generate_outline`](libs/agent/src/agent/runner.py) unchanged except forwarding the new field on `EducationPptxInput`.
85
+
86
+ ### A3. Extend Classic tab + Studio API (one code path)
87
+
88
+ In [`generate_lesson_slides`](apps/gradio-space/src/gradio_space/tabs/education_pptx.py):
89
+
90
+ - Add optional params: `conversation_context: str = ""`, `conversation_topic: str = ""`.
91
+ - When `conversation_context` is non-empty, use `conversation_topic or resolve_topic(...)` for `EducationPptxInput.topic`.
92
+ - Record conversation length in trace notes.
93
+
94
+ New Studio endpoint in [`api/studio.py`](apps/gradio-space/src/gradio_space/api/studio.py):
95
+
96
+ ```python
97
+ api_generate_slides_from_conversation(
98
+ history, history_kind, topic, grade, slide_count,
99
+ session_id, use_rag, doc_ids, source_mode, ...
100
+ )
101
+ ```
102
+
103
+ - Calls `format_conversation_context`, then delegates to the same finalizer used by `api_generate_slides` (extract shared `_finalize_slide_result()` if helpful — avoid duplicating gallery/canvas HTML assembly).
104
+ - Register as `@server.api(name="generate_slides_from_conversation")`.
105
+
106
+ **Do not** add a second export path — reuse PPTX/DOCX/HTML/gallery rendering already in `generateSlides()`.
107
+
108
+ ### A4. Studio UI — button on all three chats
109
+
110
+ In [`index.html`](apps/gradio-space/static/studio/index.html), add a secondary button below each chat send button:
111
+
112
+ - Research: `#btn-research-to-slides` — "Generate slides from chat"
113
+ - Voice: `#btn-voice-to-slides`
114
+ - Debug: `#btn-debug-to-slides`
115
+
116
+ In [`studio.js`](apps/gradio-space/static/studio/studio.js):
117
+
118
+ ```javascript
119
+ async function generateSlidesFromConversation(kind) {
120
+ const { history, historyKind } = pickHistory(kind); // research | voice | debug
121
+ if (!history?.length) { showError("Start a conversation first."); return; }
122
+ setWorkspaceView("slides"); // existing nav click helper
123
+ startProgressPanel();
124
+ const data = await callApi("generate_slides_from_conversation", [
125
+ history, historyKind, effectiveTopic(...), grade, slideCount, sessionId, ...
126
+ ]);
127
+ finishProgressPanel(data);
128
+ // reuse existing canvas/gallery/download block from generateSlides()
129
+ }
130
+ ```
131
+
132
+ Refactor: extract shared `renderSlideGenerationResult(data)` from `generateSlides()` so both flows call one renderer.
133
+
134
+ UX details:
135
+
136
+ - Disable buttons when history is empty (update in each `render*Chat()`).
137
+ - Pre-fill Slides column topic from workspace topic; API falls back to first user message.
138
+ - Keep existing source-mode / RAG controls on Slides column — conversation grounds content; RAG still adds indexed excerpts.
139
+
140
+ ### A5. Classic parity (optional, small)
141
+
142
+ Add matching buttons on Classic [`tabs/chat.py`](apps/gradio-space/src/gradio_space/tabs/chat.py) and [`tabs/teacher_voice.py`](apps/gradio-space/src/gradio_space/tabs/teacher_voice.py) that call the same `generate_lesson_slides(..., conversation_context=...)`. Low effort if time permits; not blocking Studio demo.
143
+
144
+ ---
145
+
146
+ ## Part B — Quiz maker skill (mirror education-pptx)
147
+
148
+ Follow the exact layering used by [`skills/education-pptx/SKILL.md`](skills/education-pptx/SKILL.md):
149
+
150
+ ```mermaid
151
+ flowchart LR
152
+ Skill["skills/quiz-maker/SKILL.md"]
153
+ Runner["iter_quiz_maker"]
154
+ Tool["create_quiz"]
155
+ Tab["tabs/quiz_maker.py"]
156
+ API["api_generate_quiz"]
157
+ UI["Studio Quiz view"]
158
+ Skill --> Runner --> Tool
159
+ Tab --> Runner
160
+ API --> Tab
161
+ UI --> API
162
+ ```
163
+
164
+ ### B1. Skill definition
165
+
166
+ Create [`skills/quiz-maker/SKILL.md`](skills/quiz-maker/SKILL.md):
167
+
168
+ ```yaml
169
+ ---
170
+ name: quiz-maker
171
+ description: Create a multiple-choice quiz from a topic and grade level
172
+ task: education
173
+ tools:
174
+ - create_quiz
175
+ model_hints:
176
+ - minicpm5-1b
177
+ ---
178
+ ```
179
+
180
+ Workflow body: ask topic + grade + question count (5–10), output JSON, call `create_quiz`, return preview + downloads.
181
+
182
+ Optional [`skills/quiz-maker/references/mcq-format.md`](skills/quiz-maker/references/mcq-format.md) — 4 choices, one correct, short explanation (keeps small models on-rails).
183
+
184
+ ### B2. Models + prompts
185
+
186
+ In [`models.py`](libs/agent/src/agent/models.py):
187
+
188
+ ```python
189
+ class QuizQuestion(BaseModel):
190
+ prompt: str
191
+ choices: list[str] = Field(min_length=4, max_length=4)
192
+ correct_index: int = Field(ge=0, le=3)
193
+ explanation: str = ""
194
+
195
+ class QuizOutline(BaseModel):
196
+ title: str
197
+ instructions: str = ""
198
+ questions: list[QuizQuestion] = Field(min_length=3, max_length=12)
199
+
200
+ class QuizMakerInput(BaseModel):
201
+ topic: str
202
+ grade: str
203
+ question_count: int = Field(ge=5, le=10, default=5)
204
+ # same source fields as EducationPptxInput: source_mode, urls, session_id, doc_ids, ...
205
+ conversation_context: str = "" # future-friendly; not wired in v1 UI
206
+ ```
207
+
208
+ In [`prompts.py`](libs/agent/src/agent/prompts.py): `quiz_outline_system`, `quiz_outline_user`, `quiz_outline_repair`, `fallback_quiz`, `quiz_to_markdown` — same retry/repair pattern as slides.
209
+
210
+ ### B3. Export tool
211
+
212
+ New [`libs/agent/src/agent/tools/quiz.py`](libs/agent/src/agent/tools/quiz.py):
213
+
214
+ - `create_quiz_docx(outline)` — title, instructions, numbered questions, A–D choices; **answer key on final page** (teacher-only section).
215
+ - `create_quiz_html(outline)` — printable student worksheet + collapsed answer key.
216
+ - Register `create_quiz` in [`tools_registry.py`](libs/agent/src/agent/tools_registry.py) (handler writes both files, returns paths dict or primary path + side effect like docx flow).
217
+
218
+ ### B4. Runner
219
+
220
+ In [`runner.py`](libs/agent/src/agent/runner.py):
221
+
222
+ - `QUIZ_MAKER_SKILL = "quiz-maker"`
223
+ - `iter_quiz_maker()` — copy structure of `_iter_education_pptx_steps`:
224
+ 1. load model
225
+ 2. `_gather_lesson_source_context()` (reuse as-is for web/RAG grounding)
226
+ 3. `_generate_quiz_outline()`
227
+ 4. `create_quiz` tool → docx + html
228
+ 5. markdown preview + trace
229
+
230
+ Add `QuizGenerationProgress` in [`progress.py`](libs/agent/src/agent/progress.py) (or genericize step labels — prefer small duplicate over refactor).
231
+
232
+ ### B5. Classic tab
233
+
234
+ New [`apps/gradio-space/src/gradio_space/tabs/quiz_maker.py`](apps/gradio-space/src/gradio_space/tabs/quiz_maker.py):
235
+
236
+ - Inputs: topic, grade, question count, source mode (reuse `SOURCE_MODES` / URL discover from [`education_pptx.py`](apps/gradio-space/src/gradio_space/tabs/education_pptx.py))
237
+ - Outputs: markdown preview, DOCX + HTML downloads, trace accordion
238
+ - Wire in [`app.py`](apps/gradio-space/src/gradio_space/app.py) + [`tabs/__init__.py`](apps/gradio-space/src/gradio_space/tabs/__init__.py) as **"Quiz maker"** tab after Lesson slides.
239
+
240
+ ### B6. Studio API + UI
241
+
242
+ In [`api/studio.py`](apps/gradio-space/src/gradio_space/api/studio.py):
243
+
244
+ - `api_generate_quiz(...)` wrapping tab generator; return `outline_md`, `preview_html`, `downloads: {docx, html}`, `trace_json`, `status`.
245
+
246
+ In Studio:
247
+
248
+ - Sidebar nav: **Quiz** (`data-view="quiz"`) in [`index.html`](apps/gradio-space/static/studio/index.html)
249
+ - New column/section: topic, grade, question count, source controls (copy Slides column pattern), Generate button, preview + downloads
250
+ - [`studio.css`](apps/gradio-space/static/studio/studio.css): `data-view="quiz"` layout (single-column like Voice/Coach)
251
+ - [`studio.js`](apps/gradio-space/static/studio/studio.js): `generateQuiz()` + progress steps
252
+
253
+ ### B7. Tests + docs
254
+
255
+ - Unit tests in `libs/agent/tests/`: JSON parse/repair for quiz outline, docx/html smoke test with `fallback_quiz`.
256
+ - Update [`apps/gradio-space/README.md`](apps/gradio-space/README.md): new API names + demo step ("research chat → Generate slides from chat"; "Quiz maker tab").
257
+
258
+ ---
259
+
260
+ ## Implementation order
261
+
262
+ 1. **Conversation helper + agent prompt extension** — unblocks backend for all chat sources
263
+ 2. **`generate_slides_from_conversation` API** — wire once, test with sample history
264
+ 3. **Studio buttons + shared render helper** — user-visible slides-from-chat on all three panels
265
+ 4. **Quiz skill stack** (models → tool → runner → Classic tab)
266
+ 5. **Studio Quiz view + API**
267
+ 6. Classic chat/voice slide buttons + README (if time)
268
+
269
+ ---
270
+
271
+ ## Risks
272
+
273
+ | Risk | Mitigation |
274
+ |------|------------|
275
+ | Long chat histories exceed model context | Truncate to recent turns; cap chars in `format_conversation_context` |
276
+ | Small model emits invalid quiz JSON | Same repair/retry/fallback pattern as slides |
277
+ | Three history formats diverge | Single normalizer with unit tests per kind |
278
+ | Scope creep (quiz from conversation) | Defer; `conversation_context` on `QuizMakerInput` is ready but UI not in v1 |
279
+
280
+ ---
281
+
282
+ ## Files to create
283
+
284
+ - [`apps/gradio-space/src/gradio_space/conversation_helpers.py`](apps/gradio-space/src/gradio_space/conversation_helpers.py)
285
+ - [`skills/quiz-maker/SKILL.md`](skills/quiz-maker/SKILL.md)
286
+ - [`libs/agent/src/agent/tools/quiz.py`](libs/agent/src/agent/tools/quiz.py)
287
+ - [`apps/gradio-space/src/gradio_space/tabs/quiz_maker.py`](apps/gradio-space/src/gradio_space/tabs/quiz_maker.py)
288
+
289
+ ## Files to modify
290
+
291
+ - [`libs/agent/src/agent/models.py`](libs/agent/src/agent/models.py), [`prompts.py`](libs/agent/src/agent/prompts.py), [`runner.py`](libs/agent/src/agent/runner.py), [`tools_registry.py`](libs/agent/src/agent/tools_registry.py)
292
+ - [`apps/gradio-space/src/gradio_space/tabs/education_pptx.py`](apps/gradio-space/src/gradio_space/tabs/education_pptx.py), [`api/studio.py`](apps/gradio-space/src/gradio_space/api/studio.py), [`app.py`](apps/gradio-space/src/gradio_space/app.py)
293
+ - [`apps/gradio-space/static/studio/index.html`](apps/gradio-space/static/studio/index.html), [`studio.js`](apps/gradio-space/static/studio/studio.js), [`studio.css`](apps/gradio-space/static/studio/studio.css)
.cursor/plans/studio_classic_parity_48cdd684.plan.md ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Studio Classic Parity
3
+ overview: "Bring the Studio HTML UI (`/`) to near feature parity with Classic Gradio (`/classic`) by layering changes in four phases: UI-only wins on existing API payloads, thin API wrappers around Classic tab functions, new Studio panels for missing flows, then polish for HF Space constraints."
4
+ todos:
5
+ - id: phase1-ui-wins
6
+ content: "Phase 1: EchoCoach charts/transcript/VoiceOut, slide gallery (flip skip_preview_images), debug trace drawer, K-12 grades — UI-only"
7
+ status: completed
8
+ - id: phase2-settings
9
+ content: "Phase 2a: Add reload_model + model_choices APIs and Settings slide-over drawer in Studio"
10
+ status: completed
11
+ - id: phase2-voice-audio
12
+ content: "Phase 2d+2e: teacher_voice_audio_turn API, recording start/stop/status APIs, browser MediaRecorder fallback in studio.js"
13
+ status: completed
14
+ - id: phase2-slides-web
15
+ content: "Phase 2b: Extend generate_slides API with source_mode/search_workflow/urls; add Source mode UI on Slides column"
16
+ status: completed
17
+ - id: phase2-debug-chat
18
+ content: "Phase 2c: Add debug_chat API wrapping rag_aware_chat; add Debug sidebar view with chat panel"
19
+ status: completed
20
+ - id: phase4-polish
21
+ content: "Phase 4: ASR/language presets on analyze_pitch, session memory, README API list + demo script update"
22
+ status: completed
23
+ isProject: false
24
+ ---
25
+
26
+ # Studio vs Classic and Parity Roadmap
27
+
28
+ ## Current split (recap)
29
+
30
+ Both UIs run from the same [`server.py`](apps/gradio-space/src/gradio_space/server.py): Studio at `/`, Classic at `/classic`.
31
+
32
+ ```mermaid
33
+ flowchart LR
34
+ subgraph browser [Browser]
35
+ Studio["/ Studio HTML+JS"]
36
+ Classic["/classic Gradio Blocks"]
37
+ end
38
+ subgraph server [gradio.Server]
39
+ Static["GET / static"]
40
+ APIs["@server.api studio endpoints"]
41
+ Mount["mount_gradio_app /classic"]
42
+ end
43
+ subgraph backends [Shared Python]
44
+ RM["research_mind helpers"]
45
+ PPTX["generate_lesson_slides"]
46
+ TV["teacher_voice"]
47
+ EC["echo_coach"]
48
+ end
49
+ Studio --> Static
50
+ Studio --> APIs
51
+ Classic --> Mount
52
+ APIs --> backends
53
+ Mount --> backends
54
+ ```
55
+
56
+ | Dimension | Studio (`/`) | Classic (`/classic`) |
57
+ |-----------|--------------|----------------------|
58
+ | **UX** | M3 sidebar + 3-column workspace; demo-first guided steps | Tab-per-feature; Settings + Advanced accordions |
59
+ | **Navigation** | Sidebar views (Research / Slides / Voice / Coach) | 5 tabs including Chat (debug) |
60
+ | **Audience** | Judges, teachers, first-time users | Power users, debugging |
61
+
62
+ **Feature gaps today** (Classic has, Studio lacks or simplifies):
63
+
64
+ - Chat (debug) tab
65
+ - Full settings (model reload, optional preset switch, voice stack info)
66
+ - Lesson **web search** source modes during generation (Studio hardcodes RAG-or-model-only in [`api_generate_slides`](apps/gradio-space/src/gradio_space/api/studio.py))
67
+ - Slide **preview image gallery** (Studio sets `skip_preview_images=True`)
68
+ - TeacherVoice **audio in + TTS out** (Studio uses `run_teacher_voice_text_turn` only)
69
+ - EchoCoach **charts, transcript HTML, VoiceOut playback** (API returns them; [`studio.js`](apps/gradio-space/static/studio/studio.js) ignores them)
70
+ - **Server mic recording** (Classic uses [`build_recording_block`](apps/gradio-space/src/gradio_space/ui/components.py); no Studio API yet)
71
+ - **Advanced trace JSON** on most actions
72
+ - K–12 grade list; language/ASR preset selectors
73
+
74
+ **Strategy:** Do not duplicate business logic. Every new Studio capability should be a thin wrapper in [`api/studio.py`](apps/gradio-space/src/gradio_space/api/studio.py) calling the same functions Classic tabs already use.
75
+
76
+ ---
77
+
78
+ ## Phase 1 — UI-only (no backend changes)
79
+
80
+ These are the smoothest wins: data is already returned by existing APIs.
81
+
82
+ | Add to Studio | Where | Effort |
83
+ |---------------|-------|--------|
84
+ | EchoCoach transcript + filler/pace chart images + VoiceOut `<audio>` | Extend [`render_echo_coach_panel`](apps/gradio-space/src/gradio_space/ui/studio_html.py) or render in `analyzePitch()` using `transcript_html`, `filler_chart`, `pace_chart`, `voiceout_path` from `analyze_pitch` response | Small |
85
+ | Slide thumbnail strip below canvas | Use `gallery` array already returned by `generate_slides` (flip `skip_preview_images=False` in one line) | Small |
86
+ | Collapsible **Debug trace** drawer | Show `trace_summary` / `progress_log` / parsed `progress.steps` already wired in [`studio.js`](apps/gradio-space/static/studio/studio.js) | Small |
87
+ | Expand grade dropdown to match Classic | [`index.html`](apps/gradio-space/static/studio/index.html) only | Trivial |
88
+
89
+ Keep Studio visually clean: traces and charts go in collapsed `<details>` panels, not always-visible Gradio-style accordions.
90
+
91
+ ---
92
+
93
+ ## Phase 2 — Thin API wrappers (reuse Classic functions)
94
+
95
+ Add endpoints in [`register_studio_apis`](apps/gradio-space/src/gradio_space/api/studio.py); wire from [`studio.js`](apps/gradio-space/static/studio/studio.js) via existing `callApi()`.
96
+
97
+ ### 2a. Settings drawer (sidebar link today → real panel)
98
+
99
+ ```python
100
+ # New wrappers — same as settings_panel.py
101
+ api_reload_model(model_key: str) -> ok(status_markdown=reload_model(key))
102
+ api_model_choices() -> ok(choices=..., active=..., allow_switch=...)
103
+ ```
104
+
105
+ - UI: slide-over drawer from "Classic / Settings" nav item (keep `/classic` link as escape hatch)
106
+ - Show: active model, backend, reload button, voice stack summary (copy strings from [`settings_panel.py`](apps/gradio-space/src/gradio_space/ui/settings_panel.py))
107
+ - Only expose preset dropdown when `allow_model_switch` is true (same guard as Classic)
108
+
109
+ ### 2b. Lesson web search source modes
110
+
111
+ Extend `api_generate_slides` signature to accept Classic's existing params:
112
+
113
+ - `source_mode`: `"none" | "web" | "rag"` (maps to `SOURCE_MODES` in [`education_pptx.py`](apps/gradio-space/src/gradio_space/tabs/education_pptx.py))
114
+ - `search_workflow`: `"two_step" | "auto"`
115
+ - `urls_text`, `selected_urls`, `upload_files`
116
+
117
+ Pass through to `generate_lesson_slides(...)` instead of hardcoded `"RAG (indexed sources)"` / `"Two-step"`.
118
+
119
+ UI: add a compact "Source mode" control in the Slides column (collapsed by default, mirroring Classic's "Research sources" accordion). When `web` + `two_step`, reuse the discover URL panel pattern already in the Research column.
120
+
121
+ ### 2c. Chat (debug)
122
+
123
+ ```python
124
+ api_debug_chat(message, history, use_rag, session_id, doc_ids, model_key=None)
125
+ -> rag_aware_chat(...) # from research_helpers.py
126
+ ```
127
+
128
+ UI: new sidebar nav item **Debug** (or footer link) opening a simple chat column — same pattern as Research chat in [`index.html`](apps/gradio-space/static/studio/index.html). Optional model override only when `allow_model_switch`.
129
+
130
+ ### 2d. TeacherVoice audio turn
131
+
132
+ ```python
133
+ api_teacher_voice_audio_turn(audio_path, mode, topic, session_id, use_rag, history, ...)
134
+ -> run_teacher_voice_turn(...) # same as teacher_voice.send_turn
135
+ ```
136
+
137
+ Keep existing `teacher_voice_turn` for text. Return `voiceout_path` in response for `<audio>` playback.
138
+
139
+ ### 2e. Recording control
140
+
141
+ Wrap [`start_server_recording`](libs/echocoach/src/echocoach/recording.py) / `stop_server_recording` / `recording_backend_status`:
142
+
143
+ ```python
144
+ api_recording_status() -> ok(backend, message)
145
+ api_recording_start(max_seconds)
146
+ api_recording_stop() -> ok(path, warning)
147
+ ```
148
+
149
+ **HF Space note:** server-side mic often fails in Docker. Add **browser `MediaRecorder` fallback** in Studio JS (record → `save_upload` → analyze/turn). Classic already documents upload as alternative; Studio should prefer browser mic on Space, server mic locally.
150
+
151
+ ---
152
+
153
+ ## Phase 3 — Studio UI structure for parity
154
+
155
+ Minimal layout changes to absorb new controls without breaking the demo flow.
156
+
157
+ ```mermaid
158
+ flowchart TB
159
+ subgraph sidebar [Sidebar]
160
+ Research
161
+ Slides
162
+ Voice
163
+ Coach
164
+ Debug["Debug (new)"]
165
+ Settings["Settings drawer (new)"]
166
+ end
167
+ subgraph cols [Workspace columns]
168
+ Left["Research + ingest + RAG chat"]
169
+ Center["Slides + source mode + gallery"]
170
+ Right["Voice mic/TTS + EchoCoach full results"]
171
+ end
172
+ sidebar --> cols
173
+ ```
174
+
175
+ Concrete HTML/JS edits:
176
+
177
+ - [`index.html`](apps/gradio-space/static/studio/index.html): Settings drawer markup; Debug view; source mode controls on Slides; mic buttons on Voice/Coach; `<details>` debug trace blocks
178
+ - [`studio.js`](apps/gradio-space/static/studio/studio.js): handlers for new APIs; MediaRecorder helper; render charts/audio/transcript
179
+ - [`studio.css`](apps/gradio-space/static/studio/studio.css): drawer, debug chat, chart row, gallery strip
180
+ - [`studio_html.py`](apps/gradio-space/src/gradio_space/ui/studio_html.py): richer EchoCoach + optional trace HTML helpers
181
+
182
+ **Do not rewrite Classic.** Classic stays the full fallback; Studio cross-links remain.
183
+
184
+ ---
185
+
186
+ ## Phase 4 — Polish and Space hardening
187
+
188
+ | Item | Approach |
189
+ |------|----------|
190
+ | EchoCoach `speak_rewrite` + language/ASR presets | Add optional params to `analyze_pitch` API (already accepted in Classic's `analyze_pitch`) |
191
+ | ResearchMind memory summary | Call `memory_summary(session_id)` in `list_documents` or dedicated `api_session_memory` |
192
+ | Per-tab doc filter | Already partially there via workspace checkboxes; add "limit to selected docs" hint in Slides/Voice when subset checked |
193
+ | Streaming slide progress | Harder: Classic yields interim updates via Gradio generator; Studio currently waits for final API response. **Defer** unless needed — Phase 1 progress steps from trace JSON may suffice |
194
+ | README / demo script | Update [`apps/gradio-space/README.md`](apps/gradio-space/README.md) with new API names and note Studio ≈ Classic parity |
195
+
196
+ ---
197
+
198
+ ## Recommended implementation order
199
+
200
+ 1. **Phase 1** (1–2 hours): EchoCoach full results, gallery, debug drawer, grades — immediate parity feel, zero backend risk
201
+ 2. **Phase 2a** Settings drawer — unblocks model reload without leaving Studio
202
+ 3. **Phase 2d + 2e** Voice audio + recording — highest user-visible gap for Voice/Coach columns
203
+ 4. **Phase 2b** Web search slides — completes Lesson slides parity
204
+ 5. **Phase 2c** Debug chat — last major Classic-only tab
205
+ 6. **Phase 4** presets, memory, docs
206
+
207
+ ---
208
+
209
+ ## Files touched (summary)
210
+
211
+ | File | Changes |
212
+ |------|---------|
213
+ | [`api/studio.py`](apps/gradio-space/src/gradio_space/api/studio.py) | New APIs; extend `generate_slides`, `analyze_pitch`, `teacher_voice_*` |
214
+ | [`static/studio/index.html`](apps/gradio-space/static/studio/index.html) | Settings drawer, Debug view, source mode, mic controls |
215
+ | [`static/studio/studio.js`](apps/gradio-space/static/studio/studio.js) | Wire new APIs, MediaRecorder, rich result rendering |
216
+ | [`static/studio/studio.css`](apps/gradio-space/static/studio/studio.css) | Drawer, gallery, charts layout |
217
+ | [`ui/studio_html.py`](apps/gradio-space/src/gradio_space/ui/studio_html.py) | EchoCoach + trace render helpers |
218
+ | [`README.md`](apps/gradio-space/README.md) | Updated API list and parity notes |
219
+
220
+ No changes to Classic tab logic beyond what Studio wrappers import.
apps/gradio-space/README.md CHANGED
@@ -6,8 +6,8 @@ Build Small hackathon UI — custom **Studio** frontend plus Classic Gradio tabs
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
@@ -25,20 +25,34 @@ This package uses **Gradio 6 Server mode** (`gradio.Server`):
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).
 
6
 
7
  | URL | What |
8
  |-----|------|
9
+ | `/` | **Studio UI** — custom HTML/CSS/JS served via `gradio.Server` (near parity with Classic) |
10
+ | `/classic` | Full Gradio Blocks app (fallback / power-user tabs) |
11
 
12
  ```bash
13
  uv run --package gradio-space python -m gradio_space.server
 
25
 
26
  ### Studio API names
27
 
28
+ **Research & slides**
29
+
30
+ - `list_sessions`, `list_documents`, `session_memory`
31
+ - `discover_sources`, `auto_search_ingest`, `ingest_sources`, `ingest_url`, `ingest_files`
32
+ - `research_chat`, `generate_slides` (supports `source_mode`: none / web / rag)
33
+
34
+ **Voice & coach**
35
+
36
+ - `teacher_voice_turn`, `teacher_voice_audio_turn`, `teacher_voice_clear`, `teacher_voice_speak`
37
+ - `load_sample_pitch`, `analyze_pitch` (language, ASR preset, `speak_rewrite`)
38
+ - `recording_status`, `recording_start`, `recording_stop`
39
+ - `voice_presets`
40
+
41
+ **Settings & debug**
42
+
43
+ - `model_status`, `model_choices`, `reload_model`
44
+ - `debug_chat`
45
+ - `save_upload`
46
 
47
  ## Demo script (judges)
48
 
49
+ 1. Open `/` — **Small Model Finetuning** project workspace
50
  2. Paste a URL in Research → **Ingest URL** → documents appear with **RAG Active**
51
+ 3. Center column → **Generate Slides** → slide preview canvas, thumbnail strip, and **Outline** panel
52
+ 4. Optional: expand **Research sources** → Web search or RAG modes
53
+ 5. Voice view → text or **mic** full conversation thread + **Speak full reply**
54
+ 6. Coach view **Load sample clip** or record → **Analyze pitch** (charts, transcript, VoiceOut)
55
+ 7. Debug sidebar → RAG scope overrides, plain chat or corpus-grounded test with traces
56
+ 8. Settings drawer → model status / reload (Classic at `/classic` still available)
57
 
58
  Space card metadata lives in the [repository root README.md](../../README.md).
apps/gradio-space/src/gradio_space/api/studio.py CHANGED
@@ -11,11 +11,32 @@ import gradio as gr
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,
@@ -25,12 +46,29 @@ from gradio_space.tabs.research_mind import (
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:
@@ -122,6 +160,69 @@ 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
 
@@ -129,7 +230,16 @@ def api_list_sessions() -> dict[str, Any]:
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(
@@ -147,6 +257,7 @@ def _ingest_response(
147
  documents_html=render_doc_cards(docs, rag_active=bool(docs)),
148
  trace_json=trace_json,
149
  trace_summary=trace_summary,
 
150
  )
151
 
152
 
@@ -164,6 +275,7 @@ def api_discover_sources(topic: str, session_id: str = "") -> dict[str, Any]:
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(
@@ -172,7 +284,8 @@ def api_discover_sources(topic: str, session_id: str = "") -> dict[str, Any]:
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
 
@@ -261,12 +374,54 @@ def api_research_chat(
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
 
@@ -277,25 +432,40 @@ def api_generate_slides(
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,
@@ -303,16 +473,16 @@ def api_generate_slides(
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:
@@ -344,6 +514,7 @@ def api_generate_slides(
344
  "docx": docx,
345
  "html": html_export,
346
  }
 
347
  return ok(
348
  topic=topic,
349
  session_id=sid,
@@ -351,14 +522,20 @@ def api_generate_slides(
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
 
@@ -368,8 +545,10 @@ def api_teacher_voice_turn(
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)
@@ -385,7 +564,7 @@ def api_teacher_voice_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,
@@ -403,10 +582,94 @@ def api_teacher_voice_turn(
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)
@@ -423,7 +686,7 @@ def api_analyze_pitch(
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))
@@ -433,6 +696,10 @@ def api_analyze_pitch(
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,
@@ -453,6 +720,80 @@ def api_model_status() -> dict[str, Any]:
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:
@@ -480,6 +821,10 @@ def register_studio_apis(server: gr.Server) -> None:
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)
@@ -513,6 +858,28 @@ def register_studio_apis(server: gr.Server) -> 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,
@@ -529,9 +896,24 @@ def register_studio_apis(server: gr.Server) -> None:
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")
@@ -541,25 +923,100 @@ def register_studio_apis(server: gr.Server) -> None:
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)
 
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.recording import (
15
+ ServerRecordingError,
16
+ recording_backend_status,
17
+ recording_elapsed_seconds,
18
+ recording_level_warning,
19
+ start_server_recording,
20
+ stop_server_recording,
21
+ )
22
+ from echocoach.teacher_voice import RAG_MODES, run_teacher_voice_text_turn, run_teacher_voice_turn
23
  from gradio_space.api.serializers import err, ok, unwrap_update, update_value
24
+ from gradio_space.model_loading import (
25
+ ensure_model_loaded,
26
+ get_active_model_key,
27
+ model_status,
28
+ reload_model,
29
+ )
30
+ from gradio_space.research_helpers import (
31
+ list_session_choices,
32
+ memory_summary,
33
+ pick_session_for_topic,
34
+ rag_aware_chat,
35
+ rag_scope_hint,
36
+ resolve_doc_ids,
37
+ resolve_session,
38
+ )
39
+ from gradio_space.tabs.education_pptx import SOURCE_MODES, SEARCH_WORKFLOWS, generate_lesson_slides
40
  from gradio_space.tabs.research_mind import (
41
  ask_question,
42
  auto_search_ingest,
 
46
  from gradio_space.ui.studio_html import (
47
  render_doc_cards,
48
  render_echo_coach_panel,
49
+ render_gallery_strip,
50
  render_slide_canvas,
51
+ render_trace_details,
52
  )
53
+ from gradio_space.voice_helpers import speak_last_assistant_reply
54
+ from inference.config import get_app_config
55
  from inference.factory import get_backend
56
+ from researchmind.config import get_config as get_research_config
57
  from researchmind.ingest import IngestPipeline
58
 
59
  _echo_config = get_echo_coach_config()
60
+ _app_config = get_app_config()
61
+ _SAMPLE_PITCH_AUDIO = (
62
+ Path(__file__).resolve().parents[5]
63
+ / "libs"
64
+ / "echocoach"
65
+ / "tests"
66
+ / "fixtures"
67
+ / "silence_2s.wav"
68
+ )
69
+
70
+ _SOURCE_LABELS = {value: label for label, value in SOURCE_MODES}
71
+ _WORKFLOW_LABELS = {value: label for label, value in SEARCH_WORKFLOWS}
72
 
73
 
74
  class _NoopProgress:
 
160
  return pick_session_for_topic(topic_hint)
161
 
162
 
163
+ def _voice_stack_summary() -> str:
164
+ asr = _echo_config.get_asr()
165
+ tts = _echo_config.get_tts()
166
+ lines = [
167
+ f"ASR: {asr.label} ({_echo_config.asr_preset})",
168
+ f"TTS: {tts.label} ({_echo_config.tts_preset})",
169
+ f"Coach model: {_echo_config.coach_model}",
170
+ f"Max recording: {_echo_config.max_seconds}s",
171
+ ]
172
+ return "\n".join(lines)
173
+
174
+
175
+ def _paths_summary() -> str:
176
+ rm = get_research_config()
177
+ lines = []
178
+ if _app_config.presets_path:
179
+ lines.append(f"Model presets: {_app_config.presets_path}")
180
+ else:
181
+ lines.append("Model presets: built-in defaults")
182
+ lines.append(f"ResearchMind store: {rm.data_dir.resolve()}")
183
+ return "\n".join(lines)
184
+
185
+
186
+ def _resolve_source_labels(
187
+ source_mode: str,
188
+ search_workflow: str,
189
+ use_rag: bool,
190
+ session_id: str,
191
+ doc_ids: list[str] | None,
192
+ ) -> tuple[str, str, str, list[str]]:
193
+ """Return source_label, workflow_label, effective_session, effective_docs."""
194
+ mode = (source_mode or "").strip().lower()
195
+ if not mode:
196
+ sid = (session_id or "").strip()
197
+ has_sources = _session_has_rag_sources(sid, doc_ids) if use_rag else False
198
+ if use_rag and has_sources:
199
+ return (
200
+ _SOURCE_LABELS["rag"],
201
+ _WORKFLOW_LABELS["two_step"],
202
+ sid,
203
+ doc_ids or [],
204
+ )
205
+ return _SOURCE_LABELS["none"], _WORKFLOW_LABELS["two_step"], "", []
206
+
207
+ workflow_key = (search_workflow or "two_step").strip().lower()
208
+ if workflow_key not in _WORKFLOW_LABELS:
209
+ workflow_key = "two_step"
210
+
211
+ if mode not in _SOURCE_LABELS:
212
+ mode = "none"
213
+
214
+ sid = (session_id or "").strip()
215
+ if mode == "rag" and not sid:
216
+ sid = ""
217
+
218
+ return (
219
+ _SOURCE_LABELS[mode],
220
+ _WORKFLOW_LABELS[workflow_key],
221
+ sid if mode == "rag" else sid,
222
+ doc_ids or [] if mode == "rag" else [],
223
+ )
224
+
225
+
226
  def api_list_sessions() -> dict[str, Any]:
227
  return ok(sessions=_sessions_payload())
228
 
 
230
  def api_list_documents(session_id: str = "") -> dict[str, Any]:
231
  docs = _documents_payload(session_id)
232
  html_cards = render_doc_cards(docs, rag_active=bool(docs))
233
+ return ok(
234
+ session_id=session_id,
235
+ documents=docs,
236
+ documents_html=html_cards,
237
+ memory_markdown=memory_summary(session_id),
238
+ )
239
+
240
+
241
+ def api_session_memory(session_id: str = "") -> dict[str, Any]:
242
+ return ok(memory_markdown=memory_summary(session_id))
243
 
244
 
245
  def _ingest_response(
 
257
  documents_html=render_doc_cards(docs, rag_active=bool(docs)),
258
  trace_json=trace_json,
259
  trace_summary=trace_summary,
260
+ trace_html=render_trace_details(trace_summary=trace_summary, trace_json=trace_json),
261
  )
262
 
263
 
 
275
  urls = list(url_payload.get("choices") or []) if isinstance(url_payload, dict) else []
276
  selected = list(url_payload.get("value") or urls) if isinstance(url_payload, dict) else urls
277
  sid = update_value(sess_up, session_id)
278
+ trace_str = trace_json if isinstance(trace_json, str) else ""
279
  if summary and "error" in summary.lower() and not urls:
280
  return err(strip_md_summary(summary), status=summary, urls=[], session_id=sid)
281
  return ok(
 
284
  selected_urls=selected,
285
  session_id=sid,
286
  trace_summary=trace_sum,
287
+ trace_json=trace_str,
288
+ trace_html=render_trace_details(trace_summary=trace_sum, trace_json=trace_str),
289
  )
290
 
291
 
 
374
  if msg.get("role") == "assistant":
375
  assistant = str(msg.get("content") or "")
376
  break
377
+ trace_str = trace_json if isinstance(trace_json, str) else ""
378
  return ok(
379
  history=hist,
380
  assistant=assistant,
381
  rag_hint=rag_hint,
382
+ trace_json=trace_str,
383
  trace_summary=trace_sum,
384
+ trace_html=render_trace_details(trace_summary=trace_sum, trace_json=trace_str),
385
+ )
386
+
387
+
388
+ def api_debug_chat(
389
+ message: str,
390
+ history: list[list[str]] | None = None,
391
+ use_rag: bool = False,
392
+ session_id: str = "",
393
+ doc_ids: list[str] | None = None,
394
+ model_key: str = "",
395
+ workspace_session_id: str = "",
396
+ workspace_doc_ids: list[str] | None = None,
397
+ ) -> dict[str, Any]:
398
+ if not (message or "").strip():
399
+ return err("Enter a message.")
400
+ key = (model_key or "").strip() or get_active_model_key()
401
+ load_error = ensure_model_loaded(key)
402
+ if load_error:
403
+ return err(load_error)
404
+
405
+ sid = resolve_session(session_id, workspace_session_id)
406
+ docs = resolve_doc_ids(doc_ids, workspace_doc_ids)
407
+ hist = history or []
408
+ reply, trace_json, trace_summary = rag_aware_chat(
409
+ message.strip(),
410
+ hist,
411
+ key,
412
+ use_rag,
413
+ sid,
414
+ docs,
415
+ )
416
+ new_history = list(hist)
417
+ new_history.append([message.strip(), reply])
418
+ return ok(
419
+ history=new_history,
420
+ assistant=reply,
421
+ rag_hint=rag_scope_hint(sid, docs),
422
+ trace_json=trace_json,
423
+ trace_summary=trace_summary,
424
+ trace_html=render_trace_details(trace_summary=trace_summary, trace_json=trace_json),
425
  )
426
 
427
 
 
432
  session_id: str = "",
433
  use_rag: bool = True,
434
  doc_ids: list[str] | None = None,
435
+ source_mode: str = "",
436
+ search_workflow: str = "two_step",
437
+ urls_text: str = "",
438
+ selected_urls: list[str] | None = None,
439
+ file_paths: list[str] | None = None,
440
  ) -> dict[str, Any]:
441
  rag_docs = doc_ids or []
442
  sid = (session_id or "").strip()
443
+ if not (source_mode or "").strip() and use_rag and not sid:
444
  sid = _pick_session(topic)
445
 
446
+ source_label, workflow_label, effective_sid, effective_docs = _resolve_source_labels(
447
+ source_mode,
448
+ search_workflow,
449
+ use_rag,
450
+ sid,
451
+ rag_docs,
452
+ )
 
453
 
454
+ rag_notice = ""
455
+ if (source_mode or "").strip().lower() == "rag" or (
456
+ not (source_mode or "").strip() and use_rag
457
+ ):
458
+ has_sources = _session_has_rag_sources(sid, rag_docs)
459
+ if use_rag and not has_sources and source_label == _SOURCE_LABELS["rag"]:
460
+ rag_notice = (
461
+ "Cross-Reference Sources is on, but this session has no indexed documents — "
462
+ "generated from model knowledge only. Ingest sources in Step 1 to enable RAG."
463
+ )
464
+ source_label = _SOURCE_LABELS["none"]
465
+ effective_sid = ""
466
+ effective_docs = []
467
+
468
+ upload_files = file_paths if file_paths else None
469
 
470
  gen = generate_lesson_slides(
471
  topic,
 
473
  int(slide_count),
474
  source_label,
475
  workflow_label,
476
+ urls_text or "",
477
+ selected_urls or [],
478
+ upload_files,
479
  effective_sid,
480
  effective_docs,
481
  topic,
482
  effective_sid,
483
  effective_docs,
484
  _NoopProgress(),
485
+ skip_preview_images=False,
486
  )
487
  last: tuple | None = None
488
  for item in gen:
 
514
  "docx": docx,
515
  "html": html_export,
516
  }
517
+ trace_str = trace_json if isinstance(trace_json, str) else ""
518
  return ok(
519
  topic=topic,
520
  session_id=sid,
 
522
  preview_html=preview_html,
523
  canvas_html=render_slide_canvas(preview_html),
524
  gallery=gallery or [],
525
+ gallery_html=render_gallery_strip(gallery or []),
526
  downloads=downloads,
527
  status=status,
528
  rag_fallback=bool(rag_notice),
529
  progress_log=processing_log,
530
  trace_summary=trace_sum,
531
+ trace_json=trace_str,
532
+ trace_html=render_trace_details(
533
+ trace_summary=trace_sum,
534
+ trace_json=trace_str,
535
+ progress_log=processing_log,
536
+ ),
537
  elapsed_seconds=_elapsed_seconds_from_log(processing_log),
538
+ progress=_progress_from_trace(trace_str),
539
  )
540
 
541
 
 
545
  topic: str = "",
546
  session_id: str = "",
547
  use_rag: bool = True,
548
+ history: list | None = None,
549
  doc_ids: list[str] | None = None,
550
+ language: str = "en",
551
+ asr_preset: str | None = None,
552
  ) -> dict[str, Any]:
553
  model_key = get_active_model_key()
554
  load_error = ensure_model_loaded(model_key)
 
564
  message.strip(),
565
  hist,
566
  mode=mode,
567
+ language=language,
568
  topic=topic.strip() or None,
569
  backend=get_backend(model_key),
570
  use_rag=use_rag and mode in RAG_MODES,
 
582
  )
583
 
584
 
585
+ def api_teacher_voice_audio_turn(
586
+ audio_path: str,
587
+ mode: TeacherVoiceMode = "lesson",
588
+ topic: str = "",
589
+ session_id: str = "",
590
+ use_rag: bool = True,
591
+ history: list | None = None,
592
+ doc_ids: list[str] | None = None,
593
+ language: str = "en",
594
+ asr_preset: str | None = None,
595
+ ) -> dict[str, Any]:
596
+ model_key = get_active_model_key()
597
+ load_error = ensure_model_loaded(model_key)
598
+ if load_error:
599
+ return err(load_error)
600
+
601
+ if not audio_path or not Path(audio_path).is_file():
602
+ return err("Record or upload audio first.")
603
+
604
+ hist = history or []
605
+ preset = asr_preset or _echo_config.asr_preset
606
+ max_turn = min(15, _echo_config.max_seconds)
607
+ try:
608
+ result = run_teacher_voice_turn(
609
+ audio_path,
610
+ hist,
611
+ mode=mode,
612
+ language=language,
613
+ asr_preset=preset,
614
+ topic=topic.strip() or None,
615
+ backend=get_backend(model_key),
616
+ use_rag=use_rag and mode in RAG_MODES,
617
+ session_id=session_id or None,
618
+ doc_ids=doc_ids or None,
619
+ max_turn_seconds=max_turn,
620
+ )
621
+ except Exception as exc: # noqa: BLE001
622
+ return err(str(exc))
623
+
624
+ return ok(
625
+ history=result.history,
626
+ assistant=result.assistant_text,
627
+ status=result.rag_status or "Turn complete.",
628
+ voiceout_path=result.voiceout_path,
629
+ user_text=result.user_text,
630
+ )
631
+
632
+
633
+ def api_teacher_voice_clear() -> dict[str, Any]:
634
+ return ok(
635
+ history=[],
636
+ assistant="",
637
+ status="Conversation cleared.",
638
+ )
639
+
640
+
641
+ def api_teacher_voice_speak(
642
+ history: list | None = None,
643
+ language: str = "en",
644
+ first_sentence_only: bool = False,
645
+ ) -> dict[str, Any]:
646
+ playback, status = speak_last_assistant_reply(
647
+ history or [],
648
+ language,
649
+ first_sentence_only=first_sentence_only,
650
+ )
651
+ if not playback:
652
+ return err(status)
653
+ return ok(voiceout_path=playback, status=status)
654
+
655
+
656
+ def api_load_sample_pitch() -> dict[str, Any]:
657
+ if not _SAMPLE_PITCH_AUDIO.is_file():
658
+ return err(
659
+ f"Sample clip missing at `{_SAMPLE_PITCH_AUDIO}`. "
660
+ "Run `uv run python libs/echocoach/tests/make_fixture.py`."
661
+ )
662
+ return ok(
663
+ audio_path=str(_SAMPLE_PITCH_AUDIO),
664
+ status="Sample clip loaded — click Analyze pitch when ready.",
665
+ )
666
+
667
+
668
  def api_analyze_pitch(
669
  audio_path: str,
670
  language: str = "en",
671
  asr_preset: str | None = None,
672
+ speak_rewrite: bool = False,
673
  ) -> dict[str, Any]:
674
  model_key = get_active_model_key()
675
  load_error = ensure_model_loaded(model_key)
 
686
  language=language,
687
  asr_preset=preset,
688
  backend=get_backend(model_key),
689
+ speak_rewrite=speak_rewrite,
690
  )
691
  except Exception as exc: # noqa: BLE001
692
  return err(str(exc))
 
696
  wpm=result.pace.wpm,
697
  tip=result.coach.one_tip,
698
  report_md=result.report_markdown,
699
+ transcript_html=result.transcript_html,
700
+ filler_chart=result.filler_chart_path,
701
+ pace_chart=result.pace_chart_path,
702
+ voiceout_path=result.voiceout_path,
703
  )
704
  return ok(
705
  transcript_html=result.transcript_html,
 
720
  return ok(model_key=key, status_markdown=status_md)
721
 
722
 
723
+ def api_model_choices() -> dict[str, Any]:
724
+ active = _app_config.active
725
+ allow_switch = bool(
726
+ _app_config.allow_model_switch and len(_app_config.models) > 1
727
+ )
728
+ choices = []
729
+ if allow_switch:
730
+ choices = [{"key": k, "label": label} for label, k in _app_config.model_choices()]
731
+ return ok(
732
+ active_model=_app_config.active_model,
733
+ active_label=active.label,
734
+ active_backend=active.backend,
735
+ allow_model_switch=allow_switch,
736
+ choices=choices,
737
+ voice_stack=_voice_stack_summary(),
738
+ paths=_paths_summary(),
739
+ )
740
+
741
+
742
+ def api_reload_model(model_key: str = "") -> dict[str, Any]:
743
+ key = (model_key or "").strip() or get_active_model_key()
744
+ status_md = reload_model(key)
745
+ if status_md.lower().startswith("error") or "failed" in status_md.lower():
746
+ return err(status_md, status_markdown=status_md, model_key=key)
747
+ return ok(status_markdown=status_md, model_key=key)
748
+
749
+
750
+ def api_recording_status() -> dict[str, Any]:
751
+ status = recording_backend_status()
752
+ return ok(
753
+ backend=status,
754
+ message=status,
755
+ max_seconds=_echo_config.max_seconds,
756
+ )
757
+
758
+
759
+ def api_recording_start(max_seconds: int | None = None) -> dict[str, Any]:
760
+ limit = int(max_seconds or _echo_config.max_seconds)
761
+ try:
762
+ start_server_recording(limit)
763
+ except ServerRecordingError as exc:
764
+ return err(str(exc))
765
+ return ok(
766
+ status=f"Recording… speak now, then stop (auto-stops after {limit}s).",
767
+ max_seconds=limit,
768
+ )
769
+
770
+
771
+ def api_recording_stop() -> dict[str, Any]:
772
+ try:
773
+ elapsed = recording_elapsed_seconds()
774
+ path = stop_server_recording()
775
+ warning = recording_level_warning(path)
776
+ except ServerRecordingError as exc:
777
+ return err(str(exc))
778
+ except Exception as exc: # noqa: BLE001
779
+ return err(f"Recording failed: {exc}")
780
+
781
+ status = f"Recording saved ({elapsed:.1f}s)."
782
+ if warning:
783
+ status += f" Warning: {warning}"
784
+ return ok(path=str(path), elapsed_seconds=elapsed, status=status, warning=warning or "")
785
+
786
+
787
+ def api_voice_presets() -> dict[str, Any]:
788
+ return ok(
789
+ languages=[{"label": label, "value": value} for label, value in _echo_config.language_choices()],
790
+ asr_presets=[{"label": label, "value": value} for label, value in _echo_config.asr_choices()],
791
+ default_language=_echo_config.language_choices()[0][1] if _echo_config.language_choices() else "en",
792
+ default_asr=_echo_config.asr_preset,
793
+ max_seconds=_echo_config.max_seconds,
794
+ )
795
+
796
+
797
  def api_save_upload(filename: str, content_base64: str) -> dict[str, Any]:
798
  """Save uploaded file bytes to a temp path for downstream ingest/analyze."""
799
  if not content_base64:
 
821
  def _list_documents(session_id: str = "") -> dict[str, Any]:
822
  return api_list_documents(session_id)
823
 
824
+ @server.api(name="session_memory")
825
+ def _session_memory(session_id: str = "") -> dict[str, Any]:
826
+ return api_session_memory(session_id)
827
+
828
  @server.api(name="discover_sources")
829
  def _discover_sources(topic: str, session_id: str = "") -> dict[str, Any]:
830
  return api_discover_sources(topic, session_id)
 
858
  ) -> dict[str, Any]:
859
  return api_research_chat(question, session_id, doc_ids, history)
860
 
861
+ @server.api(name="debug_chat")
862
+ def _debug_chat(
863
+ message: str,
864
+ history: list[list[str]] | None = None,
865
+ use_rag: bool = False,
866
+ session_id: str = "",
867
+ doc_ids: list[str] | None = None,
868
+ model_key: str = "",
869
+ workspace_session_id: str = "",
870
+ workspace_doc_ids: list[str] | None = None,
871
+ ) -> dict[str, Any]:
872
+ return api_debug_chat(
873
+ message,
874
+ history,
875
+ use_rag,
876
+ session_id,
877
+ doc_ids,
878
+ model_key,
879
+ workspace_session_id,
880
+ workspace_doc_ids,
881
+ )
882
+
883
  @server.api(name="ingest_files")
884
  def _ingest_files(
885
  topic: str,
 
896
  session_id: str = "",
897
  use_rag: bool = True,
898
  doc_ids: list[str] | None = None,
899
+ source_mode: str = "",
900
+ search_workflow: str = "two_step",
901
+ urls_text: str = "",
902
+ selected_urls: list[str] | None = None,
903
+ file_paths: list[str] | None = None,
904
  ) -> dict[str, Any]:
905
  return api_generate_slides(
906
+ topic,
907
+ grade,
908
+ slide_count,
909
+ session_id,
910
+ use_rag,
911
+ doc_ids,
912
+ source_mode,
913
+ search_workflow,
914
+ urls_text,
915
+ selected_urls,
916
+ file_paths,
917
  )
918
 
919
  @server.api(name="teacher_voice_turn")
 
923
  topic: str = "",
924
  session_id: str = "",
925
  use_rag: bool = True,
926
+ history: list | None = None,
927
  doc_ids: list[str] | None = None,
928
+ language: str = "en",
929
+ asr_preset: str | None = None,
930
  ) -> dict[str, Any]:
931
  return api_teacher_voice_turn(
932
+ message,
933
+ mode,
934
+ topic,
935
+ session_id,
936
+ use_rag,
937
+ history,
938
+ doc_ids,
939
+ language,
940
+ asr_preset,
941
  )
942
 
943
+ @server.api(name="teacher_voice_audio_turn")
944
+ def _teacher_voice_audio_turn(
945
+ audio_path: str,
946
+ mode: Literal["explain", "lesson", "pitch"] = "lesson",
947
+ topic: str = "",
948
+ session_id: str = "",
949
+ use_rag: bool = True,
950
+ history: list | None = None,
951
+ doc_ids: list[str] | None = None,
952
+ language: str = "en",
953
+ asr_preset: str | None = None,
954
+ ) -> dict[str, Any]:
955
+ return api_teacher_voice_audio_turn(
956
+ audio_path,
957
+ mode,
958
+ topic,
959
+ session_id,
960
+ use_rag,
961
+ history,
962
+ doc_ids,
963
+ language,
964
+ asr_preset,
965
+ )
966
+
967
+ @server.api(name="teacher_voice_clear")
968
+ def _teacher_voice_clear() -> dict[str, Any]:
969
+ return api_teacher_voice_clear()
970
+
971
+ @server.api(name="teacher_voice_speak")
972
+ def _teacher_voice_speak(
973
+ history: list | None = None,
974
+ language: str = "en",
975
+ first_sentence_only: bool = False,
976
+ ) -> dict[str, Any]:
977
+ return api_teacher_voice_speak(history, language, first_sentence_only)
978
+
979
+ @server.api(name="load_sample_pitch")
980
+ def _load_sample_pitch() -> dict[str, Any]:
981
+ return api_load_sample_pitch()
982
+
983
  @server.api(name="analyze_pitch")
984
  def _analyze_pitch(
985
  audio_path: str,
986
  language: str = "en",
987
  asr_preset: str | None = None,
988
+ speak_rewrite: bool = False,
989
  ) -> dict[str, Any]:
990
+ return api_analyze_pitch(audio_path, language, asr_preset, speak_rewrite)
991
 
992
  @server.api(name="model_status")
993
  def _model_status() -> dict[str, Any]:
994
  return api_model_status()
995
 
996
+ @server.api(name="model_choices")
997
+ def _model_choices() -> dict[str, Any]:
998
+ return api_model_choices()
999
+
1000
+ @server.api(name="reload_model")
1001
+ def _reload_model(model_key: str = "") -> dict[str, Any]:
1002
+ return api_reload_model(model_key)
1003
+
1004
+ @server.api(name="recording_status")
1005
+ def _recording_status() -> dict[str, Any]:
1006
+ return api_recording_status()
1007
+
1008
+ @server.api(name="recording_start")
1009
+ def _recording_start(max_seconds: int | None = None) -> dict[str, Any]:
1010
+ return api_recording_start(max_seconds)
1011
+
1012
+ @server.api(name="recording_stop")
1013
+ def _recording_stop() -> dict[str, Any]:
1014
+ return api_recording_stop()
1015
+
1016
+ @server.api(name="voice_presets")
1017
+ def _voice_presets() -> dict[str, Any]:
1018
+ return api_voice_presets()
1019
+
1020
  @server.api(name="save_upload")
1021
  def _save_upload(filename: str, content_base64: str) -> dict[str, Any]:
1022
  return api_save_upload(filename, content_base64)
apps/gradio-space/src/gradio_space/ui/components.py CHANGED
@@ -97,8 +97,8 @@ def build_workspace_bar() -> WorkspaceWidgets:
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
  )
 
97
  with gr.Row(elem_classes=["workspace-bar"]):
98
  topic = gr.Textbox(
99
  label="Topic",
100
+ placeholder="e.g. Small language model finetuning",
101
+ value="small model finetuning",
102
  scale=3,
103
  max_lines=1,
104
  )
apps/gradio-space/src/gradio_space/ui/studio_html.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import html
 
4
  from typing import Any
5
 
6
 
@@ -48,12 +49,69 @@ def render_slide_canvas(preview_html: str, *, empty_message: str | None = None)
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:
@@ -66,7 +124,7 @@ def render_echo_coach_panel(
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>
@@ -77,9 +135,39 @@ def render_echo_coach_panel(
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">
@@ -97,5 +185,8 @@ def render_echo_coach_panel(
97
  </div>
98
  </div>
99
  {f'<p class="studio-coach-tip">{tip_html}</p>' if tip_html else ''}
 
 
 
100
  {report_block}
101
  </div>"""
 
1
  from __future__ import annotations
2
 
3
  import html
4
+ import json
5
  from typing import Any
6
 
7
 
 
49
  return f'<div class="studio-canvas-inner">{preview_html}</div>'
50
 
51
 
52
+ def render_gallery_strip(image_paths: list[str]) -> str:
53
+ if not image_paths:
54
+ return ""
55
+ items: list[str] = []
56
+ for index, path in enumerate(image_paths):
57
+ if not path:
58
+ continue
59
+ src = html.escape(f"/file={path}")
60
+ alt = html.escape(f"Slide {index + 1}")
61
+ items.append(
62
+ f'<a class="studio-gallery-item" href="{src}" target="_blank" rel="noopener">'
63
+ f'<img src="{src}" alt="{alt}" loading="lazy" /></a>'
64
+ )
65
+ if not items:
66
+ return ""
67
+ return f'<div class="studio-gallery-strip">{"".join(items)}</div>'
68
+
69
+
70
+ def render_trace_details(
71
+ *,
72
+ trace_summary: str = "",
73
+ trace_json: str = "",
74
+ progress_log: str = "",
75
+ ) -> str:
76
+ blocks: list[str] = []
77
+ if trace_summary:
78
+ safe = html.escape(trace_summary)
79
+ blocks.append(f'<pre class="studio-trace-summary">{safe}</pre>')
80
+ if progress_log:
81
+ if "<" in progress_log and ">" in progress_log:
82
+ blocks.append(f'<div class="studio-trace-log">{progress_log}</div>')
83
+ else:
84
+ blocks.append(
85
+ f'<pre class="studio-trace-log">{html.escape(progress_log)}</pre>'
86
+ )
87
+ if trace_json:
88
+ try:
89
+ parsed = json.loads(trace_json)
90
+ pretty = html.escape(json.dumps(parsed, indent=2))
91
+ except json.JSONDecodeError:
92
+ pretty = html.escape(trace_json)
93
+ blocks.append(f'<pre class="studio-trace-json">{pretty}</pre>')
94
+ if not blocks:
95
+ return ""
96
+ return f'<div class="studio-trace-details">{"".join(blocks)}</div>'
97
+
98
+
99
+ def _file_url(path: str | None) -> str:
100
+ if not path:
101
+ return ""
102
+ return html.escape(f"/file={path}")
103
+
104
+
105
  def render_echo_coach_panel(
106
  *,
107
  pace_score: int | None = None,
108
  wpm: float | None = None,
109
  tip: str | None = None,
110
  report_md: str | None = None,
111
+ transcript_html: str | None = None,
112
+ filler_chart: str | None = None,
113
+ pace_chart: str | None = None,
114
+ voiceout_path: str | None = None,
115
  listening: bool = False,
116
  ) -> str:
117
  if listening:
 
124
  <p class="studio-coach-hint">Speak your lesson, then analyze for pace and filler feedback.</p>
125
  </div>"""
126
 
127
+ if pace_score is None and not tip and not report_md and not transcript_html:
128
  return """
129
  <div class="studio-coach-panel studio-coach-idle">
130
  <p class="studio-coach-hint">Record a pitch in the Coach view, then click <strong>Analyze pitch</strong> for metrics.</p>
 
135
  tip_html = html.escape(tip or "")
136
  report_block = ""
137
  if report_md:
138
+ safe = html.escape(report_md[:1200])
139
  report_block = f'<div class="studio-coach-report">{safe}</div>'
140
 
141
+ transcript_block = ""
142
+ if transcript_html:
143
+ transcript_block = f'<div class="studio-coach-transcript">{transcript_html}</div>'
144
+
145
+ charts: list[str] = []
146
+ filler_url = _file_url(filler_chart)
147
+ pace_url = _file_url(pace_chart)
148
+ if filler_url:
149
+ charts.append(
150
+ f'<figure class="studio-coach-chart"><figcaption>Filler words</figcaption>'
151
+ f'<img src="{filler_url}" alt="Filler words chart" /></figure>'
152
+ )
153
+ if pace_url:
154
+ charts.append(
155
+ f'<figure class="studio-coach-chart"><figcaption>Pace timeline</figcaption>'
156
+ f'<img src="{pace_url}" alt="Pace timeline chart" /></figure>'
157
+ )
158
+ charts_block = ""
159
+ if charts:
160
+ charts_block = f'<div class="studio-coach-charts">{"".join(charts)}</div>'
161
+
162
+ voiceout_block = ""
163
+ voiceout_url = _file_url(voiceout_path)
164
+ if voiceout_url:
165
+ voiceout_block = (
166
+ f'<div class="studio-coach-voiceout">'
167
+ f'<p class="studio-coach-voiceout-label">VoiceOut feedback</p>'
168
+ f'<audio controls src="{voiceout_url}"></audio></div>'
169
+ )
170
+
171
  return f"""
172
  <div class="studio-coach-panel studio-coach-results">
173
  <div class="studio-coach-header">
 
185
  </div>
186
  </div>
187
  {f'<p class="studio-coach-tip">{tip_html}</p>' if tip_html else ''}
188
+ {transcript_block}
189
+ {charts_block}
190
+ {voiceout_block}
191
  {report_block}
192
  </div>"""
apps/gradio-space/static/studio/index.html CHANGED
@@ -29,7 +29,9 @@
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>
@@ -42,7 +44,7 @@
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>
@@ -54,7 +56,7 @@
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>
@@ -67,6 +69,7 @@
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>
@@ -159,6 +162,10 @@
159
  Ask
160
  </button>
161
  <p id="research-chat-status" class="status-text"></p>
 
 
 
 
162
  </div>
163
  </div>
164
  </div>
@@ -179,10 +186,19 @@
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>
@@ -191,6 +207,40 @@
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>
@@ -209,6 +259,10 @@
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">
@@ -223,7 +277,16 @@
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
 
@@ -244,25 +307,158 @@
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>
 
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
+ <button type="button" class="nav-item" data-view="debug"><span class="material-symbols-outlined">bug_report</span>Debug</button>
33
+ <button type="button" id="btn-open-settings" class="nav-item"><span class="material-symbols-outlined">settings</span>Settings</button>
34
+ <a href="/classic" class="nav-item nav-link"><span class="material-symbols-outlined">open_in_new</span>Classic UI</a>
35
  </nav>
36
  <div class="sidebar-footer">
37
  <p class="sidebar-foot-label">Powered by local small models</p>
 
44
  <nav class="breadcrumb">
45
  <span>Projects</span>
46
  <span class="material-symbols-outlined crumb-sep">chevron_right</span>
47
+ <strong id="project-title">Small Model Finetuning</strong>
48
  </nav>
49
  <div class="topbar-actions">
50
  <a href="/classic" class="btn btn-ghost">Classic UI</a>
 
56
  <div class="workspace-context-inner">
57
  <label class="field ws-field">
58
  <span>Workspace topic</span>
59
+ <input id="workspace-topic" type="text" class="input" value="small model finetuning" placeholder="e.g. Small language model finetuning" />
60
  </label>
61
  <label class="field ws-field">
62
  <span>ResearchMind session</span>
 
69
  <summary>Source scope (RAG)</summary>
70
  <div id="workspace-doc-list" class="workspace-doc-list"></div>
71
  <p id="workspace-rag-hint" class="status-text"></p>
72
+ <p id="workspace-memory" class="status-text workspace-memory"></p>
73
  </details>
74
  </div>
75
  </div>
 
162
  Ask
163
  </button>
164
  <p id="research-chat-status" class="status-text"></p>
165
+ <details class="studio-debug-trace" id="research-trace-details">
166
+ <summary>Debug trace</summary>
167
+ <div id="research-trace-panel"></div>
168
+ </details>
169
  </div>
170
  </div>
171
  </div>
 
186
  <label class="field">
187
  <span>Grade</span>
188
  <select id="lesson-grade" class="input">
189
+ <option value="K">K</option>
190
+ <option value="1">1</option>
191
+ <option value="2">2</option>
192
+ <option value="3">3</option>
193
+ <option value="4">4</option>
194
  <option value="5">5</option>
195
+ <option value="6" selected>6</option>
196
  <option value="7">7</option>
197
  <option value="8">8</option>
198
+ <option value="9">9</option>
199
+ <option value="10">10</option>
200
+ <option value="11">11</option>
201
+ <option value="12">12</option>
202
  <option value="Adult">Adult</option>
203
  </select>
204
  </label>
 
207
  <input id="slide-count" type="range" min="3" max="8" value="5" />
208
  </label>
209
  </div>
210
+ <details class="slide-source-details" id="slide-source-details">
211
+ <summary>Research sources (optional)</summary>
212
+ <label class="field">
213
+ <span>Source mode</span>
214
+ <select id="slide-source-mode" class="input">
215
+ <option value="">Auto (RAG toggle)</option>
216
+ <option value="none">None (model only)</option>
217
+ <option value="web">Web search</option>
218
+ <option value="rag">RAG (indexed sources)</option>
219
+ </select>
220
+ </label>
221
+ <label class="field slide-web-workflow hidden" id="slide-web-workflow-wrap">
222
+ <span>Web search workflow</span>
223
+ <select id="slide-search-workflow" class="input">
224
+ <option value="two_step">Discover &amp; confirm</option>
225
+ <option value="auto">Auto search &amp; ingest</option>
226
+ </select>
227
+ </label>
228
+ <div class="slide-web-discover hidden" id="slide-web-discover-wrap">
229
+ <button type="button" id="btn-slide-discover" class="btn btn-secondary btn-block">Discover sources</button>
230
+ <div id="slide-url-choices-panel" class="url-choices-panel hidden">
231
+ <div id="slide-url-choices-list" class="url-choices-list"></div>
232
+ </div>
233
+ <label class="field">
234
+ <span>URLs (one per line)</span>
235
+ <textarea id="slide-urls-text" class="input" rows="2" placeholder="https://…"></textarea>
236
+ </label>
237
+ </div>
238
+ <label class="upload-zone upload-zone-compact">
239
+ <input id="slide-source-files" type="file" accept=".pdf,.docx" multiple hidden />
240
+ <span class="material-symbols-outlined">upload_file</span>
241
+ <span>Upload PDF or Doc for generation</span>
242
+ </label>
243
+ </details>
244
  <div class="controls-actions">
245
  <button type="button" id="btn-generate" class="btn btn-primary">
246
  <span class="material-symbols-outlined">auto_awesome</span>
 
259
  <p id="progress-current" class="progress-current">Idle</p>
260
  <ol id="progress-steps" class="progress-steps"></ol>
261
  <div id="progress-log" class="progress-log hidden" aria-live="polite"></div>
262
+ <details class="studio-debug-trace" id="slides-trace-details">
263
+ <summary>Debug trace</summary>
264
+ <div id="slides-trace-panel"></div>
265
+ </details>
266
  </div>
267
  </div>
268
  <div id="slide-canvas" class="slide-canvas">
 
277
  <div class="studio-canvas-empty"><p>Generate slides to preview your lesson here.</p></div>
278
  </div>
279
  </div>
280
+ <div id="slide-gallery" class="studio-gallery-strip hidden"></div>
281
+ <details class="slide-outline-details hidden" id="slide-outline-details">
282
+ <summary>Outline (markdown)</summary>
283
+ <div id="slide-outline" class="slide-outline"></div>
284
+ </details>
285
  <div id="downloads" class="downloads hidden"></div>
286
+ <details class="slide-export-help">
287
+ <summary>Export help — open in Google Docs</summary>
288
+ <p class="status-text">Download the <strong>.docx</strong> file, upload it to <a href="https://drive.google.com" target="_blank" rel="noopener">Google Drive</a>, then choose <strong>Open with → Google Docs</strong>. You can also upload the <strong>.html</strong> file via Google Docs → File → Open → Upload.</p>
289
+ </details>
290
  </div>
291
  </section>
292
 
 
307
  <button type="button" class="mode-card active" data-mode="lesson">Coach</button>
308
  <button type="button" class="mode-card" data-mode="pitch">Practice</button>
309
  </div>
310
+ <label class="field voice-topic-wrap" id="voice-topic-wrap">
311
+ <span>Focus topic</span>
312
+ <input id="voice-topic" type="text" class="input" placeholder="Uses workspace topic when empty" />
313
+ </label>
314
+ <details class="voice-rag-sources" id="voice-rag-sources">
315
+ <summary>ResearchMind sources (optional)</summary>
316
+ <p class="status-text">Set focus topic, then discover or ingest sources. Enable RAG above to ground answers in your library.</p>
317
+ <div class="ingest-action-row">
318
+ <button type="button" id="btn-voice-discover" class="btn btn-secondary">Discover on web</button>
319
+ <button type="button" id="btn-voice-auto-ingest" class="btn btn-secondary">Auto-ingest from web</button>
320
+ </div>
321
+ <div id="voice-url-choices-panel" class="url-choices-panel hidden">
322
+ <div id="voice-url-choices-list" class="url-choices-list"></div>
323
+ </div>
324
+ <label class="field">
325
+ <span>Paste URLs (one per line)</span>
326
+ <textarea id="voice-urls-text" class="input" rows="2" placeholder="https://…"></textarea>
327
+ </label>
328
+ <label class="upload-zone upload-zone-compact">
329
+ <input id="voice-ingest-file" type="file" accept=".pdf,.docx" multiple hidden />
330
+ <span class="material-symbols-outlined">upload_file</span>
331
+ <span>Upload PDF or Doc</span>
332
+ </label>
333
+ <button type="button" id="btn-voice-ingest" class="btn btn-secondary btn-block">Ingest sources</button>
334
+ <p id="voice-ingest-status" class="status-text"></p>
335
+ </details>
336
+ <div id="voice-chat-messages" class="research-chat-messages voice-chat-messages">
337
+ <p class="research-chat-empty">Type a message or record audio, then send.</p>
338
+ </div>
339
  <label class="field voice-panel" id="voice-panel">
340
  <span>Ask the teacher</span>
341
+ <textarea id="voice-message" class="input" rows="3" placeholder="What is the difference between pretraining and finetuning a small model?"></textarea>
342
+ <div class="recording-row">
343
+ <button type="button" id="btn-voice-record-start" class="btn btn-secondary">Start mic</button>
344
+ <button type="button" id="btn-voice-record-stop" class="btn btn-secondary" disabled>Stop mic</button>
345
+ <input id="voice-audio-upload" type="file" accept="audio/*" class="input input-compact" />
346
+ </div>
347
+ <p id="voice-record-status" class="status-text"></p>
348
+ <button type="button" id="btn-voice-send" class="btn btn-secondary btn-block">Send text</button>
349
+ <button type="button" id="btn-voice-audio-send" class="btn btn-primary btn-block">Send voice turn</button>
350
  </label>
351
+ <p id="voice-turn-status" class="status-text"></p>
352
+ <div class="voice-replay-row">
353
+ <button type="button" id="btn-voice-speak-full" class="btn btn-secondary">Speak full reply</button>
354
+ <button type="button" id="btn-voice-speak-quick" class="btn btn-secondary">Speak first sentence</button>
355
+ <button type="button" id="btn-voice-clear" class="btn btn-ghost">Clear conversation</button>
356
+ </div>
357
+ <div id="voice-audio-out" class="voice-audio-out"></div>
358
  </div>
359
  <div class="card coach-panel-wrap">
360
  <h2 class="section-label">EchoCoach Feedback</h2>
361
+ <div class="recording-row">
362
+ <button type="button" id="btn-coach-record-start" class="btn btn-secondary">Start mic</button>
363
+ <button type="button" id="btn-coach-record-stop" class="btn btn-secondary" disabled>Stop mic</button>
364
+ </div>
365
+ <p id="coach-record-status" class="status-text"></p>
366
+ <button type="button" id="btn-coach-sample" class="btn btn-ghost btn-block">Load sample clip</button>
367
  <label class="field">
368
+ <span>Or upload pitch (WAV)</span>
369
  <input id="coach-audio" type="file" accept="audio/*" />
370
  </label>
371
+ <div class="controls-grid coach-presets">
372
+ <label class="field">
373
+ <span>Language</span>
374
+ <select id="coach-language" class="input"></select>
375
+ </label>
376
+ <label class="field">
377
+ <span>ASR preset</span>
378
+ <select id="coach-asr" class="input"></select>
379
+ </label>
380
+ </div>
381
+ <label class="toggle-row">
382
+ <span>Speak full rewrite (VoiceOut)</span>
383
+ <input id="coach-speak-rewrite" type="checkbox" />
384
+ </label>
385
  <button type="button" id="btn-analyze" class="btn btn-secondary btn-block">Analyze pitch</button>
386
  <div id="coach-panel"></div>
387
  </div>
388
  </section>
389
+
390
+ <section class="col col-debug">
391
+ <div class="card card-tall">
392
+ <h2 class="section-label">Chat (debug)</h2>
393
+ <p class="status-text">Plain chat or corpus-grounded answers — traces appear below when RAG is on.</p>
394
+ <label class="toggle-row">
395
+ <span>Use ResearchMind RAG</span>
396
+ <input id="debug-use-rag" type="checkbox" />
397
+ </label>
398
+ <details class="debug-rag-scope" id="debug-rag-scope">
399
+ <summary>RAG scope (override workspace defaults)</summary>
400
+ <label class="field">
401
+ <span>Session</span>
402
+ <div class="debug-session-row">
403
+ <select id="debug-session" class="input">
404
+ <option value="">Workspace default</option>
405
+ </select>
406
+ <button type="button" id="debug-refresh-sessions" class="btn btn-ghost btn-icon" title="Refresh sessions">↻</button>
407
+ </div>
408
+ </label>
409
+ <div id="debug-doc-list" class="debug-doc-list"></div>
410
+ <p id="debug-rag-hint" class="status-text"></p>
411
+ </details>
412
+ <div id="debug-model-wrap" class="hidden">
413
+ <label class="field">
414
+ <span>Model preset override</span>
415
+ <select id="debug-model-key" class="input"></select>
416
+ </label>
417
+ </div>
418
+ <div id="debug-chat-messages" class="research-chat-messages debug-chat-messages">
419
+ <p class="research-chat-empty">Send a message to test the active local model.</p>
420
+ </div>
421
+ <label class="field">
422
+ <span>Message</span>
423
+ <textarea id="debug-message" class="input" rows="3" placeholder="Hello, model…"></textarea>
424
+ </label>
425
+ <button type="button" id="btn-debug-send" class="btn btn-primary btn-block">Send</button>
426
+ <details class="studio-debug-trace" id="debug-trace-details">
427
+ <summary>Debug trace</summary>
428
+ <div id="debug-trace-panel"></div>
429
+ </details>
430
+ </div>
431
+ </section>
432
  </main>
433
 
434
+ <div id="settings-drawer" class="settings-drawer hidden" aria-hidden="true">
435
+ <div class="settings-drawer-backdrop" id="settings-backdrop"></div>
436
+ <aside class="settings-drawer-panel" role="dialog" aria-labelledby="settings-title">
437
+ <div class="settings-drawer-head">
438
+ <h2 id="settings-title">Settings</h2>
439
+ <button type="button" id="btn-close-settings" class="btn btn-ghost btn-icon material-symbols-outlined" aria-label="Close">close</button>
440
+ </div>
441
+ <div id="settings-model-wrap">
442
+ <p id="settings-active-model" class="status-text"></p>
443
+ <label class="field hidden" id="settings-model-select-wrap">
444
+ <span>Model preset</span>
445
+ <select id="settings-model-key" class="input"></select>
446
+ </label>
447
+ </div>
448
+ <div id="settings-status" class="settings-status"></div>
449
+ <button type="button" id="btn-reload-model" class="btn btn-secondary btn-block">Reload model</button>
450
+ <details class="settings-details">
451
+ <summary>Voice stack</summary>
452
+ <pre id="settings-voice-stack" class="settings-pre"></pre>
453
+ </details>
454
+ <details class="settings-details">
455
+ <summary>Paths &amp; files</summary>
456
+ <pre id="settings-paths" class="settings-pre"></pre>
457
+ </details>
458
+ <a href="/classic" class="btn btn-ghost btn-block">Open Classic UI</a>
459
+ </aside>
460
+ </div>
461
+
462
  <script type="module" src="/static/studio/studio.js"></script>
463
  </body>
464
  </html>
apps/gradio-space/static/studio/studio.css CHANGED
@@ -980,3 +980,271 @@ body {
980
  .workspace { margin-left: 0; padding-top: calc(var(--topbar-h) + var(--context-bar-h) + 1rem); }
981
  .studio-banner { left: 0; }
982
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
980
  .workspace { margin-left: 0; padding-top: calc(var(--topbar-h) + var(--context-bar-h) + 1rem); }
981
  .studio-banner { left: 0; }
982
  }
983
+
984
+ /* Parity additions */
985
+ .studio-gallery-strip {
986
+ display: flex;
987
+ gap: 0.5rem;
988
+ overflow-x: auto;
989
+ padding: 0.75rem 0;
990
+ margin-top: 0.5rem;
991
+ }
992
+
993
+ .studio-gallery-item {
994
+ flex: 0 0 auto;
995
+ border-radius: 8px;
996
+ overflow: hidden;
997
+ border: 1px solid var(--outline-variant);
998
+ }
999
+
1000
+ .studio-gallery-item img {
1001
+ display: block;
1002
+ height: 72px;
1003
+ width: auto;
1004
+ }
1005
+
1006
+ .slide-outline-details,
1007
+ .slide-export-help {
1008
+ margin-top: 0.75rem;
1009
+ }
1010
+
1011
+ .slide-outline-details summary,
1012
+ .slide-export-help summary {
1013
+ cursor: pointer;
1014
+ font-weight: 600;
1015
+ font-size: 0.875rem;
1016
+ }
1017
+
1018
+ .slide-outline {
1019
+ margin-top: 0.5rem;
1020
+ padding: 0.75rem;
1021
+ border: 1px solid var(--outline-variant);
1022
+ border-radius: 8px;
1023
+ background: #fff;
1024
+ font-size: 0.875rem;
1025
+ line-height: 1.5;
1026
+ max-height: 240px;
1027
+ overflow-y: auto;
1028
+ }
1029
+
1030
+ .debug-rag-scope {
1031
+ margin: 0.75rem 0;
1032
+ }
1033
+
1034
+ .debug-rag-scope summary {
1035
+ cursor: pointer;
1036
+ font-weight: 600;
1037
+ font-size: 0.875rem;
1038
+ }
1039
+
1040
+ .debug-session-row {
1041
+ display: flex;
1042
+ gap: 0.5rem;
1043
+ align-items: center;
1044
+ }
1045
+
1046
+ .debug-session-row .input {
1047
+ flex: 1;
1048
+ }
1049
+
1050
+ .debug-doc-list {
1051
+ margin: 0.5rem 0;
1052
+ max-height: 140px;
1053
+ overflow-y: auto;
1054
+ }
1055
+
1056
+ .studio-debug-trace {
1057
+ margin-top: 0.75rem;
1058
+ font-size: 0.8125rem;
1059
+ }
1060
+
1061
+ .studio-debug-trace summary {
1062
+ cursor: pointer;
1063
+ color: var(--secondary);
1064
+ font-weight: 600;
1065
+ }
1066
+
1067
+ .studio-trace-summary,
1068
+ .studio-trace-json,
1069
+ .studio-trace-log {
1070
+ font-size: 0.75rem;
1071
+ white-space: pre-wrap;
1072
+ overflow-x: auto;
1073
+ max-height: 240px;
1074
+ background: var(--surface-container-low);
1075
+ padding: 0.5rem;
1076
+ border-radius: 8px;
1077
+ margin-top: 0.5rem;
1078
+ }
1079
+
1080
+ .slide-source-details {
1081
+ margin: 0.75rem 0;
1082
+ font-size: 0.875rem;
1083
+ }
1084
+
1085
+ .slide-source-details summary {
1086
+ cursor: pointer;
1087
+ font-weight: 600;
1088
+ color: var(--secondary);
1089
+ }
1090
+
1091
+ .recording-row {
1092
+ display: flex;
1093
+ flex-wrap: wrap;
1094
+ gap: 0.5rem;
1095
+ align-items: center;
1096
+ margin: 0.5rem 0;
1097
+ }
1098
+
1099
+ .input-compact {
1100
+ font-size: 0.8125rem;
1101
+ max-width: 160px;
1102
+ }
1103
+
1104
+ .voice-audio-out audio,
1105
+ .studio-coach-voiceout audio {
1106
+ width: 100%;
1107
+ margin-top: 0.5rem;
1108
+ }
1109
+
1110
+ .voice-chat-messages {
1111
+ max-height: 220px;
1112
+ margin: 0.75rem 0;
1113
+ }
1114
+
1115
+ .voice-rag-sources {
1116
+ margin: 0.75rem 0;
1117
+ }
1118
+
1119
+ .voice-rag-sources summary {
1120
+ cursor: pointer;
1121
+ font-weight: 600;
1122
+ font-size: 0.875rem;
1123
+ }
1124
+
1125
+ .voice-replay-row {
1126
+ display: flex;
1127
+ flex-wrap: wrap;
1128
+ gap: 0.5rem;
1129
+ margin-top: 0.5rem;
1130
+ }
1131
+
1132
+ .voice-replay-row .btn-ghost {
1133
+ margin-left: auto;
1134
+ }
1135
+
1136
+ .studio-coach-transcript {
1137
+ margin-top: 0.75rem;
1138
+ font-size: 0.875rem;
1139
+ line-height: 1.5;
1140
+ }
1141
+
1142
+ .studio-coach-charts {
1143
+ display: grid;
1144
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
1145
+ gap: 0.75rem;
1146
+ margin-top: 0.75rem;
1147
+ }
1148
+
1149
+ .studio-coach-chart {
1150
+ margin: 0;
1151
+ }
1152
+
1153
+ .studio-coach-chart img {
1154
+ width: 100%;
1155
+ border-radius: 8px;
1156
+ border: 1px solid var(--outline-variant);
1157
+ }
1158
+
1159
+ .studio-coach-chart figcaption {
1160
+ font-size: 0.75rem;
1161
+ color: var(--secondary);
1162
+ margin-bottom: 0.25rem;
1163
+ }
1164
+
1165
+ .studio-coach-voiceout-label {
1166
+ font-size: 0.8125rem;
1167
+ font-weight: 600;
1168
+ margin: 0.5rem 0 0.25rem;
1169
+ }
1170
+
1171
+ .workspace-memory {
1172
+ margin-top: 0.35rem;
1173
+ font-size: 0.8125rem;
1174
+ }
1175
+
1176
+ .settings-drawer {
1177
+ position: fixed;
1178
+ inset: 0;
1179
+ z-index: 200;
1180
+ }
1181
+
1182
+ .settings-drawer-backdrop {
1183
+ position: absolute;
1184
+ inset: 0;
1185
+ background: rgba(0, 0, 0, 0.35);
1186
+ }
1187
+
1188
+ .settings-drawer-panel {
1189
+ position: absolute;
1190
+ top: 0;
1191
+ right: 0;
1192
+ width: min(360px, 92vw);
1193
+ height: 100%;
1194
+ background: var(--surface);
1195
+ box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
1196
+ padding: 1.25rem;
1197
+ overflow-y: auto;
1198
+ display: flex;
1199
+ flex-direction: column;
1200
+ gap: 0.75rem;
1201
+ }
1202
+
1203
+ .settings-drawer-head {
1204
+ display: flex;
1205
+ align-items: center;
1206
+ justify-content: space-between;
1207
+ }
1208
+
1209
+ .settings-drawer-head h2 {
1210
+ margin: 0;
1211
+ font-size: 1.125rem;
1212
+ }
1213
+
1214
+ .settings-status {
1215
+ font-size: 0.875rem;
1216
+ line-height: 1.5;
1217
+ }
1218
+
1219
+ .settings-pre {
1220
+ font-size: 0.75rem;
1221
+ white-space: pre-wrap;
1222
+ margin: 0.5rem 0 0;
1223
+ }
1224
+
1225
+ .settings-details summary {
1226
+ cursor: pointer;
1227
+ font-weight: 600;
1228
+ font-size: 0.875rem;
1229
+ }
1230
+
1231
+ .coach-presets {
1232
+ margin-top: 0.5rem;
1233
+ }
1234
+
1235
+ .workspace[data-view="debug"] .col-research,
1236
+ .workspace[data-view="debug"] .col-slides,
1237
+ .workspace[data-view="debug"] .col-studio { display: none; }
1238
+
1239
+ .workspace[data-view="debug"] {
1240
+ grid-template-columns: 1fr;
1241
+ max-width: 640px;
1242
+ margin-left: auto;
1243
+ margin-right: auto;
1244
+ }
1245
+
1246
+ .debug-chat-messages {
1247
+ min-height: 200px;
1248
+ max-height: 320px;
1249
+ }
1250
+
apps/gradio-space/static/studio/studio.js CHANGED
@@ -9,18 +9,31 @@ const SLIDE_PIPELINE_STEPS = [
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) {
@@ -29,12 +42,6 @@ function effectiveTopic(local) {
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);
@@ -62,6 +69,34 @@ function renderMarkdownLite(text) {
62
  .replace(/\[(\d+)\]/g, "<sup>[$1]</sup>");
63
  }
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  function getIngestWorkflow() {
66
  return $("#ingest-workflow")?.value || "direct";
67
  }
@@ -76,8 +111,24 @@ function syncIngestWorkflowUi() {
76
  );
77
  }
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  function syncResearchLayout() {
80
  syncIngestWorkflowUi();
 
81
  updateResearchDocCount(state.workspaceDocIds?.length || 0);
82
  }
83
 
@@ -94,19 +145,36 @@ function updateResearchDocCount(count) {
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");
@@ -127,9 +195,177 @@ function renderUrlChoices(urls, selected) {
127
  box.addEventListener("change", syncUrlSelectAll);
128
  });
129
  syncUrlSelectAll();
130
- if (getIngestWorkflow() === "select") {
131
- panel?.classList.remove("hidden");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  }
134
 
135
  function syncUrlSelectAll() {
@@ -149,6 +385,7 @@ function applyIngestResult(data) {
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
  }
@@ -161,14 +398,25 @@ async function discoverSources() {
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) {
@@ -179,7 +427,7 @@ async function autoSearchIngest() {
179
  applyIngestResult(data);
180
  state.discoveredUrls = [];
181
  state.selectedUrls = [];
182
- renderUrlChoices([], []);
183
  await refreshWorkspaceSessions(state.workspaceSessionId);
184
  }
185
 
@@ -187,9 +435,7 @@ async function ingestSources({ urlsText = "", selectedUrls = [], pendingFiles =
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;
@@ -234,18 +480,29 @@ function renderResearchChat() {
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() {
@@ -265,9 +522,109 @@ async function askResearchQuestion() {
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";
@@ -281,7 +638,7 @@ function updateWorkspaceRagHint() {
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;
@@ -362,14 +719,12 @@ function finishProgressPanel(data) {
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
  })
@@ -380,21 +735,18 @@ function finishProgressPanel(data) {
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) {
@@ -411,9 +763,7 @@ function showError(msg) {
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;
@@ -426,9 +776,7 @@ async function callApi(name, args = []) {
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);
@@ -442,19 +790,22 @@ async function callApi(name, args = []) {
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);
@@ -464,7 +815,7 @@ function renderWorkspaceDocList(docs) {
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) => {
@@ -484,10 +835,8 @@ async function refreshWorkspaceSessions(selectId) {
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;
@@ -500,18 +849,87 @@ async function refreshWorkspaceSessions(selectId) {
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() {
@@ -521,6 +939,13 @@ async function initWorkspace() {
521
  updateResearchRagBadge();
522
  await refreshWorkspaceSessions();
523
  await refreshDocuments();
 
 
 
 
 
 
 
524
  }
525
 
526
  async function ingestUrl() {
@@ -532,16 +957,24 @@ async function ingestFiles(files) {
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();
@@ -554,6 +987,11 @@ async function generateSlides() {
554
  state.workspaceSessionId,
555
  useRag,
556
  docIds,
 
 
 
 
 
557
  ]);
558
  } catch (_err) {
559
  $("#progress-eta").textContent = "Failed";
@@ -567,34 +1005,76 @@ async function generateSlides() {
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,
@@ -603,28 +1083,170 @@ async function sendVoiceTurn() {
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;
@@ -632,9 +1254,7 @@ function bindUi() {
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();
@@ -642,14 +1262,14 @@ function bindUi() {
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();
@@ -659,6 +1279,7 @@ function bindUi() {
659
  $("#workspace-session").addEventListener("change", (e) => {
660
  state.workspaceSessionId = e.target.value;
661
  refreshDocuments().catch(() => {});
 
662
  });
663
 
664
  $("#workspace-refresh-sessions").addEventListener("click", () => {
@@ -666,33 +1287,71 @@ function bindUi() {
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", () => {
@@ -701,7 +1360,7 @@ function bindUi() {
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.";
@@ -713,6 +1372,7 @@ function bindUi() {
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
  }
 
9
  ];
10
 
11
  const state = {
12
+ workspaceTopic: "small model finetuning",
13
  workspaceSessionId: "",
14
  workspaceDocIds: [],
15
  discoveredUrls: [],
16
  selectedUrls: [],
17
+ slideDiscoveredUrls: [],
18
+ slideSelectedUrls: [],
19
+ voiceDiscoveredUrls: [],
20
+ voiceSelectedUrls: [],
21
  researchChatHistory: [],
22
+ debugChatHistory: [],
23
  voiceMode: "lesson",
24
  history: [],
25
  downloads: null,
26
  client: null,
27
  progressTimer: null,
28
  progressStartedAt: null,
29
+ voicePresets: null,
30
+ modelChoices: null,
31
+ recordingTarget: null,
32
+ browserRecorder: null,
33
+ browserRecordChunks: [],
34
+ pendingVoiceAudioPath: null,
35
+ pendingCoachAudioPath: null,
36
+ useBrowserMic: true,
37
  };
38
 
39
  function effectiveTopic(local) {
 
42
  return (state.workspaceTopic || "").trim();
43
  }
44
 
 
 
 
 
 
 
45
  function selectedWorkspaceDocIds() {
46
  const boxes = document.querySelectorAll("#workspace-doc-list input[type=checkbox]:checked");
47
  return [...boxes].map((el) => el.value);
 
69
  .replace(/\[(\d+)\]/g, "<sup>[$1]</sup>");
70
  }
71
 
72
+ function stripMd(text) {
73
+ return String(text).replace(/\*\*/g, "").replace(/`/g, "");
74
+ }
75
+
76
+ function fileUrl(path) {
77
+ if (!path) return "";
78
+ return `/file=${encodeURIComponent(path)}`;
79
+ }
80
+
81
+ function setTracePanel(panelId, data) {
82
+ const panel = $(panelId);
83
+ if (!panel) return;
84
+ const html = data?.trace_html || "";
85
+ if (html) {
86
+ panel.innerHTML = html;
87
+ panel.closest("details")?.classList.remove("hidden");
88
+ } else if (data?.trace_summary || data?.trace_json) {
89
+ const parts = [];
90
+ if (data.trace_summary) {
91
+ parts.push(`<pre class="studio-trace-summary">${escapeHtml(data.trace_summary)}</pre>`);
92
+ }
93
+ if (data.trace_json) {
94
+ parts.push(`<pre class="studio-trace-json">${escapeHtml(data.trace_json)}</pre>`);
95
+ }
96
+ panel.innerHTML = parts.join("");
97
+ }
98
+ }
99
+
100
  function getIngestWorkflow() {
101
  return $("#ingest-workflow")?.value || "direct";
102
  }
 
111
  );
112
  }
113
 
114
+ function syncSlideSourceUi() {
115
+ const mode = $("#slide-source-mode")?.value || "";
116
+ const isWeb = mode === "web";
117
+ $("#slide-web-workflow-wrap")?.classList.toggle("hidden", !isWeb);
118
+ $("#slide-web-discover-wrap")?.classList.toggle("hidden", !isWeb);
119
+ if (isWeb && $("#slide-search-workflow")?.value === "two_step") {
120
+ $("#slide-url-choices-panel")?.classList.toggle(
121
+ "hidden",
122
+ !state.slideDiscoveredUrls.length
123
+ );
124
+ } else {
125
+ $("#slide-url-choices-panel")?.classList.add("hidden");
126
+ }
127
+ }
128
+
129
  function syncResearchLayout() {
130
  syncIngestWorkflowUi();
131
+ syncSlideSourceUi();
132
  updateResearchDocCount(state.workspaceDocIds?.length || 0);
133
  }
134
 
 
145
  }
146
 
147
  function openResearchView() {
148
+ document.querySelector('.nav-item[data-view="research"]')?.click();
149
+ window.setTimeout(() => $("#research-question")?.focus(), 80);
 
 
 
150
  }
151
 
152
+ function getSelectedDiscoveredUrls(listId = "#url-choices-list") {
153
+ const boxes = document.querySelectorAll(`${listId} input[type=checkbox]:checked`);
154
  return [...boxes].map((el) => el.value);
155
  }
156
 
157
+ function renderUrlChoices(urls, selected, listId, panelId, urlState) {
158
+ urlState.discovered = urls || [];
159
+ urlState.selected = selected?.length ? selected : [...urlState.discovered];
160
+ const list = $(listId);
161
+ const panel = $(panelId);
162
+ if (!urlState.discovered.length) {
163
+ if (list) list.innerHTML = "";
164
+ panel?.classList.add("hidden");
165
+ return;
166
+ }
167
+ list.innerHTML = urlState.discovered
168
+ .map((url) => {
169
+ const checked = urlState.selected.includes(url) ? "checked" : "";
170
+ const label = url.length > 72 ? `${url.slice(0, 69)}…` : url;
171
+ return `<label class="url-choice-item"><input type="checkbox" value="${escapeHtml(url)}" ${checked} /><span title="${escapeHtml(url)}">${escapeHtml(label)}</span></label>`;
172
+ })
173
+ .join("");
174
+ panel?.classList.remove("hidden");
175
+ }
176
+
177
+ function renderResearchUrlChoices(urls, selected) {
178
  state.discoveredUrls = urls || [];
179
  state.selectedUrls = selected?.length ? selected : [...state.discoveredUrls];
180
  const list = $("#url-choices-list");
 
195
  box.addEventListener("change", syncUrlSelectAll);
196
  });
197
  syncUrlSelectAll();
198
+ if (getIngestWorkflow() === "select") panel?.classList.remove("hidden");
199
+ }
200
+
201
+ function voiceEffectiveTopic() {
202
+ if (state.voiceMode === "pitch") return effectiveTopic("");
203
+ return effectiveTopic($("#voice-topic")?.value || "");
204
+ }
205
+
206
+ function voiceUseRag() {
207
+ return $("#use-rag").checked && state.voiceMode !== "pitch";
208
+ }
209
+
210
+ function voiceMessageText(content) {
211
+ if (content == null) return "";
212
+ if (typeof content === "string") return content;
213
+ if (Array.isArray(content)) {
214
+ const textPart = content.find((part) => typeof part === "string");
215
+ return textPart || "";
216
+ }
217
+ if (typeof content === "object" && content.text) return String(content.text);
218
+ return String(content);
219
+ }
220
+
221
+ function ingestSucceeded(status) {
222
+ const text = (status || "").toLowerCase();
223
+ return !(
224
+ text.includes("error") ||
225
+ text.includes("enter a research topic") ||
226
+ text.includes("add urls") ||
227
+ text.includes("no verified urls found")
228
+ );
229
+ }
230
+
231
+ function applyVoiceIngestResult(data) {
232
+ $("#voice-ingest-status").textContent = stripMd(data.status || "Ingest complete.");
233
+ state.workspaceSessionId = data.session_id || state.workspaceSessionId;
234
+ $("#workspace-session").value = state.workspaceSessionId;
235
+ if (data.documents_html) {
236
+ $("#documents-panel").innerHTML = data.documents_html;
237
+ }
238
+ renderWorkspaceDocList(data.documents || []);
239
+ updateResearchRagBadge();
240
+ updateResearchDocCount((data.documents || []).length);
241
+ if (ingestSucceeded(data.status)) {
242
+ $("#use-rag").checked = true;
243
+ }
244
+ }
245
+
246
+ async function discoverVoiceSources() {
247
+ const topic = voiceEffectiveTopic();
248
+ if (!topic) {
249
+ showError("Set a focus or workspace topic before discovering sources.");
250
+ return;
251
+ }
252
+ const data = await callApi("discover_sources", [topic, state.workspaceSessionId]);
253
+ $("#voice-ingest-status").textContent = stripMd(data.status || "Discovery complete.");
254
+ renderVoiceUrlChoices(data.urls || [], data.selected_urls || data.urls || []);
255
+ if (data.session_id) {
256
+ state.workspaceSessionId = data.session_id;
257
+ $("#workspace-session").value = data.session_id;
258
+ }
259
+ await refreshWorkspaceSessions(state.workspaceSessionId);
260
+ }
261
+
262
+ async function autoVoiceIngest() {
263
+ const topic = voiceEffectiveTopic();
264
+ if (!topic) {
265
+ showError("Set a focus or workspace topic before auto-ingest.");
266
+ return;
267
+ }
268
+ const data = await callApi("auto_search_ingest", [topic, state.workspaceSessionId]);
269
+ applyVoiceIngestResult(data);
270
+ state.voiceDiscoveredUrls = [];
271
+ state.voiceSelectedUrls = [];
272
+ renderVoiceUrlChoices([], []);
273
+ await refreshWorkspaceSessions(state.workspaceSessionId);
274
+ }
275
+
276
+ async function ingestVoiceSources() {
277
+ const topic = voiceEffectiveTopic();
278
+ const pasted = $("#voice-urls-text")?.value.trim() || "";
279
+ const selected = getSelectedDiscoveredUrls("#voice-url-choices-list");
280
+ const paths = [];
281
+ const files = $("#voice-ingest-file")?.files;
282
+ if (files?.length) {
283
+ for (const file of files) {
284
+ paths.push(await uploadFile(file));
285
+ }
286
+ }
287
+ if (!pasted && !selected.length && !paths.length) {
288
+ showError("Add URLs, select suggested sources, or upload a file — then ingest.");
289
+ return;
290
+ }
291
+ const data = await callApi("ingest_sources", [
292
+ topic,
293
+ state.workspaceSessionId,
294
+ pasted,
295
+ selected,
296
+ paths,
297
+ ]);
298
+ applyVoiceIngestResult(data);
299
+ if (pasted) $("#voice-urls-text").value = "";
300
+ if (files?.length) $("#voice-ingest-file").value = "";
301
+ await refreshWorkspaceSessions(state.workspaceSessionId);
302
+ }
303
+
304
+ function syncVoiceModeUi() {
305
+ const ragMode = state.voiceMode === "explain" || state.voiceMode === "lesson";
306
+ $("#voice-topic-wrap")?.classList.toggle("hidden", !ragMode);
307
+ $("#voice-rag-sources")?.classList.toggle("hidden", !ragMode);
308
+ const placeholders = {
309
+ explain: "e.g. How does finetuning differ from pretraining?",
310
+ lesson: "What is the difference between pretraining and finetuning a small model?",
311
+ pitch: "e.g. Here is my opening line — how can I improve it?",
312
+ };
313
+ const messageEl = $("#voice-message");
314
+ if (messageEl) messageEl.placeholder = placeholders[state.voiceMode] || placeholders.lesson;
315
+ }
316
+
317
+ function renderVoiceChat() {
318
+ const container = $("#voice-chat-messages");
319
+ if (!container) return;
320
+ if (!state.history.length) {
321
+ container.innerHTML =
322
+ '<p class="research-chat-empty">Type a message or record audio, then send.</p>';
323
+ return;
324
  }
325
+ const parts = [];
326
+ for (const item of state.history) {
327
+ if (item && typeof item === "object" && item.role) {
328
+ const role = item.role === "user" ? "user" : "assistant";
329
+ const label = role === "user" ? "You" : "Teacher";
330
+ const body = renderMarkdownLite(voiceMessageText(item.content));
331
+ parts.push(
332
+ `<div class="research-chat-bubble research-chat-${role}"><div class="research-chat-role">${label}</div><div class="research-chat-body">${body}</div></div>`
333
+ );
334
+ } else if (Array.isArray(item) && item.length === 2) {
335
+ const [user, assistant] = item;
336
+ parts.push(
337
+ `<div class="research-chat-bubble research-chat-user"><div class="research-chat-role">You</div><div class="research-chat-body">${renderMarkdownLite(user)}</div></div>` +
338
+ `<div class="research-chat-bubble research-chat-assistant"><div class="research-chat-role">Teacher</div><div class="research-chat-body">${renderMarkdownLite(assistant)}</div></div>`
339
+ );
340
+ }
341
+ }
342
+ container.innerHTML = parts.join("");
343
+ container.scrollTop = container.scrollHeight;
344
+ }
345
+
346
+ function renderVoiceUrlChoices(urls, selected) {
347
+ state.voiceDiscoveredUrls = urls || [];
348
+ state.voiceSelectedUrls = selected?.length ? selected : [...state.voiceDiscoveredUrls];
349
+ renderUrlChoices(
350
+ urls,
351
+ selected,
352
+ "#voice-url-choices-list",
353
+ "#voice-url-choices-panel",
354
+ { discovered: state.voiceDiscoveredUrls, selected: state.voiceSelectedUrls }
355
+ );
356
+ }
357
+
358
+ function renderSlideUrlChoices(urls, selected) {
359
+ state.slideDiscoveredUrls = urls || [];
360
+ state.slideSelectedUrls = selected?.length ? selected : [...state.slideDiscoveredUrls];
361
+ renderUrlChoices(
362
+ urls,
363
+ selected,
364
+ "#slide-url-choices-list",
365
+ "#slide-url-choices-panel",
366
+ { discovered: state.slideDiscoveredUrls, selected: state.slideSelectedUrls }
367
+ );
368
+ syncSlideSourceUi();
369
  }
370
 
371
  function syncUrlSelectAll() {
 
385
  $("#documents-panel").innerHTML =
386
  data.documents_html || '<p class="studio-empty-docs">No documents indexed yet.</p>';
387
  renderWorkspaceDocList(data.documents || []);
388
+ setTracePanel("#research-trace-panel", data);
389
  updateResearchRagBadge();
390
  updateResearchDocCount((data.documents || []).length);
391
  }
 
398
  }
399
  const data = await callApi("discover_sources", [topic, state.workspaceSessionId]);
400
  $("#ingest-status").textContent = stripMd(data.status || "Discovery complete.");
401
+ renderResearchUrlChoices(data.urls || [], data.selected_urls || data.urls || []);
402
  if (data.session_id) {
403
  state.workspaceSessionId = data.session_id;
404
  $("#workspace-session").value = data.session_id;
405
  }
406
+ setTracePanel("#research-trace-panel", data);
407
  await refreshWorkspaceSessions(state.workspaceSessionId);
408
  }
409
 
410
+ async function discoverSlideSources() {
411
+ const topic = effectiveTopic($("#lesson-topic")?.value);
412
+ if (!topic) {
413
+ showError("Set a topic before discovering sources.");
414
+ return;
415
+ }
416
+ const data = await callApi("discover_sources", [topic, state.workspaceSessionId]);
417
+ renderSlideUrlChoices(data.urls || [], data.selected_urls || data.urls || []);
418
+ }
419
+
420
  async function autoSearchIngest() {
421
  const topic = effectiveTopic("");
422
  if (!topic) {
 
427
  applyIngestResult(data);
428
  state.discoveredUrls = [];
429
  state.selectedUrls = [];
430
+ renderResearchUrlChoices([], []);
431
  await refreshWorkspaceSessions(state.workspaceSessionId);
432
  }
433
 
 
435
  const topic = effectiveTopic("");
436
  const workflow = getIngestWorkflow();
437
  let selected = selectedUrls;
438
+ if (workflow === "select") selected = getSelectedDiscoveredUrls();
 
 
439
  const pasted = workflow === "direct" ? urlsText : urlsText || $("#ingest-url").value.trim();
440
  const paths = [];
441
  const files = pendingFiles || $("#ingest-file").files;
 
480
  container.scrollTop = container.scrollHeight;
481
  }
482
 
483
+ function renderDebugChat() {
484
+ const container = $("#debug-chat-messages");
485
+ if (!state.debugChatHistory.length) {
486
+ container.innerHTML =
487
+ '<p class="research-chat-empty">Send a message to test the active local model.</p>';
488
+ return;
489
+ }
490
+ container.innerHTML = state.debugChatHistory
491
+ .map(([user, assistant]) => {
492
+ return `<div class="research-chat-bubble research-chat-user"><div class="research-chat-role">You</div><div class="research-chat-body">${renderMarkdownLite(user)}</div></div><div class="research-chat-bubble research-chat-assistant"><div class="research-chat-role">Model</div><div class="research-chat-body">${renderMarkdownLite(assistant)}</div></div>`;
493
+ })
494
+ .join("");
495
+ container.scrollTop = container.scrollHeight;
496
+ }
497
+
498
  function updateResearchRagBadge() {
499
  const badge = $("#research-rag-badge");
500
  if (!badge) return;
501
  const nDocs = (state.workspaceDocIds || []).length;
502
  const selected = selectedWorkspaceDocIds().length;
503
+ if (selected) badge.textContent = `RAG · ${selected} doc(s)`;
504
+ else if (nDocs) badge.textContent = `RAG · ${nDocs} in session`;
505
+ else badge.textContent = "RAG · corpus";
 
 
 
 
506
  }
507
 
508
  async function askResearchQuestion() {
 
522
  renderResearchChat();
523
  $("#research-question").value = "";
524
  $("#research-chat-status").textContent = stripMd(data.rag_hint || "");
525
+ setTracePanel("#research-trace-panel", data);
526
  updateResearchRagBadge();
527
  }
528
 
529
+ async function sendDebugMessage() {
530
+ const message = $("#debug-message").value.trim();
531
+ if (!message) {
532
+ showError("Enter a message.");
533
+ return;
534
+ }
535
+ const useRag = $("#debug-use-rag").checked;
536
+ const debugSession = $("#debug-session")?.value || "";
537
+ const debugDocIds = selectedDebugDocIds();
538
+ const workspaceDocIds = selectedWorkspaceDocIds();
539
+ const modelKey = $("#debug-model-key")?.value || "";
540
+ const data = await callApi("debug_chat", [
541
+ message,
542
+ state.debugChatHistory,
543
+ useRag,
544
+ debugSession,
545
+ debugDocIds,
546
+ modelKey,
547
+ state.workspaceSessionId,
548
+ workspaceDocIds,
549
+ ]);
550
+ state.debugChatHistory = data.history || [];
551
+ renderDebugChat();
552
+ $("#debug-message").value = "";
553
+ if (data.rag_hint) {
554
+ $("#debug-rag-hint").textContent = stripMd(data.rag_hint);
555
+ }
556
+ setTracePanel("#debug-trace-panel", data);
557
+ }
558
+
559
+ function effectiveDebugSessionId() {
560
+ return ($("#debug-session")?.value || "").trim() || state.workspaceSessionId;
561
+ }
562
+
563
+ function selectedDebugDocIds() {
564
+ const boxes = document.querySelectorAll("#debug-doc-list input[type=checkbox]");
565
+ if (!boxes.length) return [];
566
+ return [...document.querySelectorAll("#debug-doc-list input[type=checkbox]:checked")].map(
567
+ (el) => el.value
568
+ );
569
+ }
570
+
571
+ function renderDebugDocList(docs) {
572
+ const container = $("#debug-doc-list");
573
+ if (!container) return;
574
+ if (!docs?.length) {
575
+ container.innerHTML = '<p class="status-text">No documents in this session yet.</p>';
576
+ updateDebugRagHint();
577
+ return;
578
+ }
579
+ container.innerHTML = docs
580
+ .map(
581
+ (d) =>
582
+ `<label class="workspace-doc-item"><input type="checkbox" value="${d.id}" checked />${escapeHtml(d.title)}</label>`
583
+ )
584
+ .join("");
585
+ container.querySelectorAll("input[type=checkbox]").forEach((box) => {
586
+ box.addEventListener("change", updateDebugRagHint);
587
+ });
588
+ updateDebugRagHint();
589
+ }
590
+
591
+ function updateDebugRagHint() {
592
+ const el = $("#debug-rag-hint");
593
+ if (!el) return;
594
+ const sid = effectiveDebugSessionId();
595
+ const selected = selectedDebugDocIds();
596
+ const total = document.querySelectorAll("#debug-doc-list input[type=checkbox]").length;
597
+ if (selected.length && selected.length < total) {
598
+ el.textContent = `RAG scope: ${selected.length} selected document(s).`;
599
+ } else if (sid) {
600
+ el.textContent = total
601
+ ? `RAG scope: all ${total} document(s) in session.`
602
+ : "RAG scope: all documents in session.";
603
+ } else {
604
+ el.textContent = "RAG scope: entire indexed corpus (all sessions).";
605
+ }
606
+ }
607
+
608
+ async function refreshDebugSessions(selectId) {
609
+ const data = await callApi("list_sessions", []);
610
+ const sessions = data.sessions || [];
611
+ const select = $("#debug-session");
612
+ if (!select) return;
613
+ const current = selectId ?? select.value;
614
+ select.innerHTML =
615
+ '<option value="">Workspace default</option>' +
616
+ sessions.map((s) => `<option value="${s.id}">${s.label || s.topic}</option>`).join("");
617
+ if (current && sessions.some((s) => s.id === current)) {
618
+ select.value = current;
619
+ }
620
+ }
621
+
622
+ async function refreshDebugDocuments() {
623
+ const sessionId = effectiveDebugSessionId();
624
+ const data = await callApi("list_documents", [sessionId]);
625
+ renderDebugDocList(data.documents || []);
626
+ }
627
+
628
  function updateProjectTitle() {
629
  const topic = state.workspaceTopic || "";
630
  const short = topic.split(" for ")[0] || topic || "Project";
 
638
  if (sid) {
639
  hint = nDocs
640
  ? `RAG scope: ${nDocs} selected document(s) in session.`
641
+ : "RAG scope: all documents in session.";
642
  }
643
  const el = $("#workspace-rag-hint");
644
  if (el) el.textContent = hint;
 
719
  clearInterval(state.progressTimer);
720
  state.progressTimer = null;
721
  }
 
722
  const stepsEl = $("#progress-steps");
723
  const traceSteps = data?.progress?.steps || [];
724
  if (traceSteps.length) {
725
  stepsEl.innerHTML = traceSteps
726
  .map((step) => {
727
+ const duration = step.duration_s != null ? ` (${step.duration_s}s)` : "";
 
728
  const detail = step.detail ? ` — ${step.detail}` : "";
729
  return `<li class="progress-step done">${step.label}${duration}${detail}</li>`;
730
  })
 
735
  node.classList.add("done");
736
  });
737
  }
 
738
  if (data?.progress_log) {
739
  const logEl = $("#progress-log");
740
  const log = data.progress_log;
741
+ if (/<[a-z][\s\S]*>/i.test(log)) logEl.innerHTML = log;
742
+ else logEl.textContent = stripMd(log);
 
 
 
743
  logEl.classList.remove("hidden");
744
  }
745
  if (data?.elapsed_seconds != null) {
746
  $("#progress-elapsed").textContent = `Elapsed: ${Number(data.elapsed_seconds).toFixed(1)}s`;
747
  }
748
  $("#progress-eta").textContent = "Complete";
749
+ setTracePanel("#slides-trace-panel", data);
750
  }
751
 
752
  function showError(msg) {
 
763
  function unwrapApiPayload(result) {
764
  const raw = result?.data ?? result;
765
  if (Array.isArray(raw)) {
766
+ if (raw.length === 1 && raw[0] !== null && typeof raw[0] === "object") return raw[0];
 
 
767
  return raw;
768
  }
769
  return raw;
 
776
  const client = await getClient();
777
  const result = await client.predict(`/${name}`, args);
778
  const data = unwrapApiPayload(result);
779
+ if (data && data.ok === false) throw new Error(data.error || "Request failed");
 
 
780
  return data;
781
  } catch (err) {
782
  const message = err?.message || String(err);
 
790
  function fileToBase64(file) {
791
  return new Promise((resolve, reject) => {
792
  const reader = new FileReader();
793
+ reader.onload = () => resolve(reader.result.split(",")[1]);
 
 
 
794
  reader.onerror = reject;
795
  reader.readAsDataURL(file);
796
  });
797
  }
798
 
799
+ async function uploadFile(file) {
800
+ const b64 = await fileToBase64(file);
801
+ const saved = await callApi("save_upload", [file.name, b64]);
802
+ return saved.path;
803
+ }
804
+
805
  function renderWorkspaceDocList(docs) {
806
  const container = $("#workspace-doc-list");
807
  if (!docs?.length) {
808
+ container.innerHTML = '<p class="status-text">No documents in this session yet.</p>';
809
  state.workspaceDocIds = [];
810
  updateWorkspaceRagHint();
811
  updateResearchDocCount(0);
 
815
  container.innerHTML = docs
816
  .map(
817
  (d) =>
818
+ `<label class="workspace-doc-item"><input type="checkbox" value="${d.id}" checked />${escapeHtml(d.title)}</label>`
819
  )
820
  .join("");
821
  container.querySelectorAll("input[type=checkbox]").forEach((box) => {
 
835
  const select = $("#workspace-session");
836
  const current = selectId || state.workspaceSessionId;
837
  select.innerHTML =
838
+ '<option value="">New session (on ingest)</option>' +
839
+ sessions.map((s) => `<option value="${s.id}">${s.label || s.topic}</option>`).join("");
 
 
840
  if (current && sessions.some((s) => s.id === current)) {
841
  select.value = current;
842
  state.workspaceSessionId = current;
 
849
  updateProjectTitle();
850
  }
851
  }
852
+ await refreshDebugSessions();
853
  }
854
 
855
  async function refreshDocuments() {
856
  const data = await callApi("list_documents", [state.workspaceSessionId]);
857
  $("#documents-panel").innerHTML =
858
+ data.documents_html || '<p class="studio-empty-docs">No documents indexed yet.</p>';
 
859
  if (data.session_id) {
860
  state.workspaceSessionId = data.session_id;
861
  $("#workspace-session").value = data.session_id;
862
  }
863
  renderWorkspaceDocList(data.documents || []);
864
+ const mem = $("#workspace-memory");
865
+ if (mem && data.memory_markdown) {
866
+ mem.textContent = stripMd(data.memory_markdown);
867
+ }
868
+ }
869
+
870
+ async function initVoicePresets() {
871
+ const data = await callApi("voice_presets", []);
872
+ state.voicePresets = data;
873
+ const langSelect = $("#coach-language");
874
+ const asrSelect = $("#coach-asr");
875
+ if (langSelect) {
876
+ langSelect.innerHTML = (data.languages || [])
877
+ .map((o) => `<option value="${o.value}">${o.label}</option>`)
878
+ .join("");
879
+ langSelect.value = data.default_language || "en";
880
+ }
881
+ if (asrSelect) {
882
+ asrSelect.innerHTML = (data.asr_presets || [])
883
+ .map((o) => `<option value="${o.value}">${o.label}</option>`)
884
+ .join("");
885
+ asrSelect.value = data.default_asr || "";
886
+ }
887
+ }
888
+
889
+ async function initSettings() {
890
+ const data = await callApi("model_choices", []);
891
+ state.modelChoices = data;
892
+ $("#settings-active-model").textContent = `${data.active_label} (${data.active_backend})`;
893
+ $("#settings-voice-stack").textContent = data.voice_stack || "";
894
+ $("#settings-paths").textContent = data.paths || "";
895
+ const status = await callApi("model_status", []);
896
+ $("#settings-status").innerHTML = renderMarkdownLite(status.status_markdown || "");
897
+
898
+ const wrap = $("#settings-model-select-wrap");
899
+ const debugWrap = $("#debug-model-wrap");
900
+ const select = $("#settings-model-key");
901
+ const debugSelect = $("#debug-model-key");
902
+ if (data.allow_model_switch && data.choices?.length) {
903
+ wrap?.classList.remove("hidden");
904
+ debugWrap?.classList.remove("hidden");
905
+ const options = data.choices
906
+ .map((c) => `<option value="${c.key}">${c.label}</option>`)
907
+ .join("");
908
+ if (select) {
909
+ select.innerHTML = options;
910
+ select.value = data.active_model;
911
+ }
912
+ if (debugSelect) {
913
+ debugSelect.innerHTML = options;
914
+ debugSelect.value = data.active_model;
915
+ }
916
+ }
917
+ }
918
+
919
+ function openSettingsDrawer() {
920
+ $("#settings-drawer")?.classList.remove("hidden");
921
+ $("#settings-drawer")?.setAttribute("aria-hidden", "false");
922
+ }
923
+
924
+ function closeSettingsDrawer() {
925
+ $("#settings-drawer")?.classList.add("hidden");
926
+ $("#settings-drawer")?.setAttribute("aria-hidden", "true");
927
+ }
928
+
929
+ async function reloadModelFromSettings() {
930
+ const key = $("#settings-model-key")?.value || "";
931
+ const data = await callApi("reload_model", [key]);
932
+ $("#settings-status").innerHTML = renderMarkdownLite(data.status_markdown || "Reloaded.");
933
  }
934
 
935
  async function initWorkspace() {
 
939
  updateResearchRagBadge();
940
  await refreshWorkspaceSessions();
941
  await refreshDocuments();
942
+ await initVoicePresets();
943
+ await initSettings();
944
+ syncVoiceModeUi();
945
+ renderVoiceChat();
946
+ await refreshDebugDocuments();
947
+ const recStatus = await callApi("recording_status", []);
948
+ state.useBrowserMic = !recStatus.backend || /unavailable|no capture/i.test(recStatus.message || "");
949
  }
950
 
951
  async function ingestUrl() {
 
957
  await ingestSources({ pendingFiles: files });
958
  }
959
 
 
 
 
 
960
  async function generateSlides() {
961
  const topic = effectiveTopic($("#lesson-topic").value);
962
  const grade = $("#lesson-grade").value;
963
  const slideCount = Number($("#slide-count").value);
964
  const useRag = $("#use-rag").checked;
965
  const docIds = effectiveDocIds([]);
966
+ const sourceMode = $("#slide-source-mode")?.value || "";
967
+ const searchWorkflow = $("#slide-search-workflow")?.value || "two_step";
968
+ const urlsText = $("#slide-urls-text")?.value.trim() || "";
969
+ const selectedUrls = getSelectedDiscoveredUrls("#slide-url-choices-list");
970
+
971
+ const filePaths = [];
972
+ const slideFiles = $("#slide-source-files")?.files;
973
+ if (slideFiles?.length) {
974
+ for (const file of slideFiles) {
975
+ filePaths.push(await uploadFile(file));
976
+ }
977
+ }
978
 
979
  startProgressPanel();
980
  const waitTimer = advanceProgressWhileWaiting();
 
987
  state.workspaceSessionId,
988
  useRag,
989
  docIds,
990
+ sourceMode,
991
+ searchWorkflow,
992
+ urlsText,
993
+ selectedUrls,
994
+ filePaths,
995
  ]);
996
  } catch (_err) {
997
  $("#progress-eta").textContent = "Failed";
 
1005
  }
1006
 
1007
  finishProgressPanel(data);
 
1008
  $("#generate-status").textContent = stripMd(data.status || "Slides generated.");
1009
  const canvasHtml =
1010
  data.canvas_html ||
1011
+ (data.preview_html ? `<div class="studio-canvas-inner">${data.preview_html}</div>` : "");
 
 
1012
  $("#slide-canvas").innerHTML =
1013
+ canvasHtml || '<div class="studio-canvas-empty"><p>Preview unavailable.</p></div>';
1014
+
1015
+ const galleryEl = $("#slide-gallery");
1016
+ if (data.gallery_html) {
1017
+ galleryEl.innerHTML = data.gallery_html;
1018
+ galleryEl.classList.remove("hidden");
1019
+ } else if (data.gallery?.length) {
1020
+ galleryEl.innerHTML = data.gallery
1021
+ .map(
1022
+ (path, i) =>
1023
+ `<a class="studio-gallery-item" href="${fileUrl(path)}" target="_blank" rel="noopener"><img src="${fileUrl(path)}" alt="Slide ${i + 1}" loading="lazy" /></a>`
1024
+ )
1025
+ .join("");
1026
+ galleryEl.classList.remove("hidden");
1027
+ } else {
1028
+ galleryEl.classList.add("hidden");
1029
+ galleryEl.innerHTML = "";
1030
+ }
1031
 
1032
  state.downloads = data.downloads;
1033
  const dl = $("#downloads");
1034
  if (data.downloads?.pptx) {
1035
  dl.classList.remove("hidden");
1036
  dl.innerHTML = `
1037
+ <a href="${fileUrl(data.downloads.pptx)}" download>PPTX</a>
1038
+ <a href="${fileUrl(data.downloads.docx)}" download>DOCX</a>
1039
+ <a href="${fileUrl(data.downloads.html)}" download>HTML</a>`;
1040
  $("#btn-export").disabled = false;
1041
  }
1042
+
1043
+ const outlineDetails = $("#slide-outline-details");
1044
+ const outlineEl = $("#slide-outline");
1045
+ if (data.outline_md) {
1046
+ outlineEl.innerHTML = renderMarkdownLite(data.outline_md);
1047
+ outlineDetails?.classList.remove("hidden");
1048
+ } else {
1049
+ outlineEl.innerHTML = "";
1050
+ outlineDetails?.classList.add("hidden");
1051
+ }
1052
+ }
1053
+
1054
+ function renderVoiceReply(data, { keepAudio = false } = {}) {
1055
+ state.history = data.history ?? state.history;
1056
+ renderVoiceChat();
1057
+ if (data.status) {
1058
+ $("#voice-turn-status").textContent = stripMd(data.status);
1059
+ }
1060
+ const out = $("#voice-audio-out");
1061
+ if (data.voiceout_path) {
1062
+ out.innerHTML = `<audio controls src="${fileUrl(data.voiceout_path)}"></audio>`;
1063
+ } else if (!keepAudio) {
1064
+ out.innerHTML = "";
1065
+ }
1066
  }
1067
 
1068
  async function sendVoiceTurn() {
1069
  const message = $("#voice-message").value.trim();
1070
+ if (!message) {
1071
+ showError("Enter a message first.");
1072
+ return;
1073
+ }
1074
+ const topic = voiceEffectiveTopic();
1075
+ const useRag = voiceUseRag();
1076
  const docIds = effectiveDocIds([]);
1077
+ const language = state.voicePresets?.default_language || "en";
1078
  const data = await callApi("teacher_voice_turn", [
1079
  message,
1080
  state.voiceMode,
 
1083
  useRag,
1084
  state.history,
1085
  docIds,
1086
+ language,
1087
+ null,
1088
  ]);
1089
+ $("#voice-message").value = "";
1090
+ renderVoiceReply(data);
1091
  }
1092
 
1093
+ async function sendVoiceAudioTurn(audioPath) {
1094
+ const topic = voiceEffectiveTopic();
1095
+ const useRag = voiceUseRag();
1096
+ const docIds = effectiveDocIds([]);
1097
+ const language = state.voicePresets?.default_language || "en";
1098
+ const asr = state.voicePresets?.default_asr || null;
1099
+ const data = await callApi("teacher_voice_audio_turn", [
1100
+ audioPath,
1101
+ state.voiceMode,
1102
+ topic,
1103
+ state.workspaceSessionId,
1104
+ useRag,
1105
+ state.history,
1106
+ docIds,
1107
+ language,
1108
+ asr,
1109
+ ]);
1110
+ if (data.user_text) $("#voice-message").value = data.user_text;
1111
+ renderVoiceReply(data);
1112
+ }
1113
+
1114
+ async function speakVoiceReply(firstSentenceOnly) {
1115
+ const language = state.voicePresets?.default_language || "en";
1116
+ const data = await callApi("teacher_voice_speak", [state.history, language, firstSentenceOnly]);
1117
+ $("#voice-turn-status").textContent = stripMd(data.status || "VoiceOut ready.");
1118
+ if (data.voiceout_path) {
1119
+ $("#voice-audio-out").innerHTML = `<audio controls src="${fileUrl(data.voiceout_path)}"></audio>`;
1120
  }
1121
+ }
1122
+
1123
+ async function clearVoiceConversation() {
1124
+ const data = await callApi("teacher_voice_clear", []);
1125
+ state.history = [];
1126
+ renderVoiceChat();
1127
+ $("#voice-message").value = "";
1128
+ $("#voice-turn-status").textContent = stripMd(data.status || "Conversation cleared.");
1129
+ $("#voice-audio-out").innerHTML = "";
1130
+ }
1131
+
1132
+ async function loadSamplePitch() {
1133
+ const data = await callApi("load_sample_pitch", []);
1134
+ state.pendingCoachAudioPath = data.audio_path;
1135
+ $("#coach-record-status").textContent = stripMd(data.status || "Sample clip loaded.");
1136
+ }
1137
+
1138
+ async function analyzePitchWithPath(audioPath) {
1139
+ const language = $("#coach-language")?.value || "en";
1140
+ const asr = $("#coach-asr")?.value || null;
1141
+ const speakRewrite = $("#coach-speak-rewrite")?.checked || false;
1142
  $("#coach-panel").innerHTML = `
1143
  <div class="studio-coach-panel studio-coach-live">
1144
  <div class="studio-coach-header"><span class="studio-coach-dot"></span>
1145
  <span class="studio-coach-label">Analyzing…</span></div>
1146
  </div>`;
1147
+ const data = await callApi("analyze_pitch", [audioPath, language, asr, speakRewrite]);
 
 
1148
  $("#coach-panel").innerHTML = data.coach_panel_html || "";
1149
  }
1150
 
1151
+ async function analyzePitch() {
1152
+ let path = state.pendingCoachAudioPath;
1153
+ const file = $("#coach-audio").files?.[0];
1154
+ if (file) path = await uploadFile(file);
1155
+ if (!path) {
1156
+ showError("Record or upload audio to analyze.");
1157
+ return;
1158
+ }
1159
+ await analyzePitchWithPath(path);
1160
+ }
1161
+
1162
+ async function startBrowserRecording(statusEl) {
1163
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
1164
+ state.browserRecordChunks = [];
1165
+ state.browserRecorder = new MediaRecorder(stream);
1166
+ state.browserRecorder.ondataavailable = (e) => {
1167
+ if (e.data.size > 0) state.browserRecordChunks.push(e.data);
1168
+ };
1169
+ state.browserRecorder.start();
1170
+ if (statusEl) statusEl.textContent = "Recording… click Stop when done.";
1171
+ }
1172
+
1173
+ async function stopBrowserRecording(statusEl) {
1174
+ return new Promise((resolve, reject) => {
1175
+ const recorder = state.browserRecorder;
1176
+ if (!recorder) {
1177
+ reject(new Error("No active recording."));
1178
+ return;
1179
+ }
1180
+ recorder.onstop = async () => {
1181
+ recorder.stream.getTracks().forEach((t) => t.stop());
1182
+ state.browserRecorder = null;
1183
+ const blob = new Blob(state.browserRecordChunks, { type: "audio/webm" });
1184
+ state.browserRecordChunks = [];
1185
+ try {
1186
+ const file = new File([blob], "browser_recording.webm", { type: "audio/webm" });
1187
+ const path = await uploadFile(file);
1188
+ if (statusEl) statusEl.textContent = "Recording saved.";
1189
+ resolve(path);
1190
+ } catch (err) {
1191
+ reject(err);
1192
+ }
1193
+ };
1194
+ recorder.stop();
1195
+ });
1196
+ }
1197
+
1198
+ async function startRecording(target, statusEl, startBtn, stopBtn) {
1199
+ state.recordingTarget = target;
1200
+ startBtn.disabled = true;
1201
+ stopBtn.disabled = false;
1202
+ if (state.useBrowserMic) {
1203
+ try {
1204
+ await startBrowserRecording(statusEl);
1205
+ } catch (err) {
1206
+ startBtn.disabled = false;
1207
+ stopBtn.disabled = true;
1208
+ showError(`Microphone error: ${err.message}`);
1209
+ }
1210
+ return;
1211
+ }
1212
+ try {
1213
+ const maxSec = state.voicePresets?.max_seconds || 30;
1214
+ const data = await callApi("recording_start", [maxSec]);
1215
+ if (statusEl) statusEl.textContent = stripMd(data.status || "Recording…");
1216
+ } catch (_err) {
1217
+ startBtn.disabled = false;
1218
+ stopBtn.disabled = true;
1219
+ }
1220
+ }
1221
+
1222
+ async function stopRecording(statusEl, startBtn, stopBtn) {
1223
+ startBtn.disabled = false;
1224
+ stopBtn.disabled = true;
1225
+ let path = null;
1226
+ if (state.useBrowserMic) {
1227
+ path = await stopBrowserRecording(statusEl);
1228
+ } else {
1229
+ const data = await callApi("recording_stop", []);
1230
+ path = data.path;
1231
+ if (statusEl) statusEl.textContent = stripMd(data.status || "Recording saved.");
1232
+ }
1233
+ if (state.recordingTarget === "voice") state.pendingVoiceAudioPath = path;
1234
+ if (state.recordingTarget === "coach") state.pendingCoachAudioPath = path;
1235
+ state.recordingTarget = null;
1236
+ return path;
1237
+ }
1238
+
1239
+ async function sendVoiceFromRecording() {
1240
+ let path = state.pendingVoiceAudioPath;
1241
+ const file = $("#voice-audio-upload").files?.[0];
1242
+ if (file) path = await uploadFile(file);
1243
+ if (!path) {
1244
+ showError("Record or upload audio first.");
1245
+ return;
1246
+ }
1247
+ await sendVoiceAudioTurn(path);
1248
+ }
1249
+
1250
  function bindUi() {
1251
  $("#slide-count").addEventListener("input", (e) => {
1252
  $("#slide-count-val").textContent = e.target.value;
 
1254
 
1255
  document.querySelectorAll(".nav-item[data-view]").forEach((btn) => {
1256
  btn.addEventListener("click", () => {
1257
+ document.querySelectorAll(".nav-item[data-view]").forEach((b) => b.classList.remove("active"));
 
 
1258
  btn.classList.add("active");
1259
  $(".workspace").dataset.view = btn.dataset.view;
1260
  syncResearchLayout();
 
1262
  });
1263
  });
1264
 
1265
+ $("#btn-open-settings")?.addEventListener("click", openSettingsDrawer);
1266
+ $("#btn-close-settings")?.addEventListener("click", closeSettingsDrawer);
1267
+ $("#settings-backdrop")?.addEventListener("click", closeSettingsDrawer);
1268
+ $("#btn-reload-model")?.addEventListener("click", () => reloadModelFromSettings().catch(() => {}));
1269
 
1270
+ $("#btn-open-research-view")?.addEventListener("click", openResearchView);
1271
+ $("#sidebar-open")?.addEventListener("click", () => $("#sidebar").classList.add("open"));
1272
+ $("#sidebar-close")?.addEventListener("click", () => $("#sidebar").classList.remove("open"));
 
 
 
1273
 
1274
  $("#workspace-topic").addEventListener("input", (e) => {
1275
  state.workspaceTopic = e.target.value.trim();
 
1279
  $("#workspace-session").addEventListener("change", (e) => {
1280
  state.workspaceSessionId = e.target.value;
1281
  refreshDocuments().catch(() => {});
1282
+ refreshDebugDocuments().catch(() => {});
1283
  });
1284
 
1285
  $("#workspace-refresh-sessions").addEventListener("click", () => {
 
1287
  });
1288
 
1289
  $("#btn-ingest-url").addEventListener("click", () => ingestUrl().catch(() => {}));
1290
+ $("#ingest-file").addEventListener("change", (e) => ingestFiles(e.target.files).catch(() => {}));
 
 
1291
  $("#ingest-workflow")?.addEventListener("change", syncIngestWorkflowUi);
1292
+ $("#btn-discover").addEventListener("click", () => discoverSources().catch(() => {}));
1293
+ $("#btn-auto-ingest").addEventListener("click", () => autoSearchIngest().catch(() => {}));
1294
  $("#url-select-all")?.addEventListener("change", (e) => {
 
1295
  document.querySelectorAll("#url-choices-list input[type=checkbox]").forEach((box) => {
1296
+ box.checked = e.target.checked;
1297
  });
1298
  syncUrlSelectAll();
1299
  });
1300
+
1301
+ $("#slide-source-mode")?.addEventListener("change", syncSlideSourceUi);
1302
+ $("#slide-search-workflow")?.addEventListener("change", syncSlideSourceUi);
1303
+ $("#btn-slide-discover")?.addEventListener("click", () => discoverSlideSources().catch(() => {}));
1304
+
1305
+ $("#btn-research-ask").addEventListener("click", () => askResearchQuestion().catch(() => {}));
1306
  $("#research-question")?.addEventListener("keydown", (e) => {
1307
  if (e.key === "Enter" && !e.shiftKey) {
1308
  e.preventDefault();
1309
  askResearchQuestion().catch(() => {});
1310
  }
1311
  });
1312
+
1313
  $("#btn-generate").addEventListener("click", () => generateSlides().catch(() => {}));
1314
  $("#btn-voice-send").addEventListener("click", () => sendVoiceTurn().catch(() => {}));
1315
+ $("#btn-voice-audio-send").addEventListener("click", () => sendVoiceFromRecording().catch(() => {}));
1316
+ $("#btn-voice-discover")?.addEventListener("click", () => discoverVoiceSources().catch(() => {}));
1317
+ $("#btn-voice-auto-ingest")?.addEventListener("click", () => autoVoiceIngest().catch(() => {}));
1318
+ $("#btn-voice-ingest")?.addEventListener("click", () => ingestVoiceSources().catch(() => {}));
1319
+ $("#voice-ingest-file")?.addEventListener("change", (e) => ingestVoiceSources().catch(() => {}));
1320
+ $("#btn-voice-speak-full")?.addEventListener("click", () => speakVoiceReply(false).catch(() => {}));
1321
+ $("#btn-voice-speak-quick")?.addEventListener("click", () => speakVoiceReply(true).catch(() => {}));
1322
+ $("#btn-voice-clear")?.addEventListener("click", () => clearVoiceConversation().catch(() => {}));
1323
+ $("#btn-coach-sample")?.addEventListener("click", () => loadSamplePitch().catch(() => {}));
1324
  $("#btn-analyze").addEventListener("click", () => analyzePitch().catch(() => {}));
1325
+ $("#btn-debug-send").addEventListener("click", () => sendDebugMessage().catch(() => {}));
1326
+ $("#debug-session")?.addEventListener("change", () => refreshDebugDocuments().catch(() => {}));
1327
+ $("#debug-refresh-sessions")?.addEventListener("click", () => {
1328
+ refreshDebugSessions().catch(() => {});
1329
+ refreshDebugDocuments().catch(() => {});
1330
+ });
1331
+ $("#debug-use-rag")?.addEventListener("change", updateDebugRagHint);
1332
+ $("#debug-message")?.addEventListener("keydown", (e) => {
1333
+ if (e.key === "Enter" && !e.shiftKey) {
1334
+ e.preventDefault();
1335
+ sendDebugMessage().catch(() => {});
1336
+ }
1337
+ });
1338
+
1339
+ $("#btn-voice-record-start")?.addEventListener("click", () =>
1340
+ startRecording("voice", $("#voice-record-status"), $("#btn-voice-record-start"), $("#btn-voice-record-stop")).catch(() => {})
1341
+ );
1342
+ $("#btn-voice-record-stop")?.addEventListener("click", () =>
1343
+ stopRecording($("#voice-record-status"), $("#btn-voice-record-start"), $("#btn-voice-record-stop")).catch(() => {})
1344
+ );
1345
+ $("#btn-coach-record-start")?.addEventListener("click", () =>
1346
+ startRecording("coach", $("#coach-record-status"), $("#btn-coach-record-start"), $("#btn-coach-record-stop")).catch(() => {})
1347
+ );
1348
+ $("#btn-coach-record-stop")?.addEventListener("click", () =>
1349
+ stopRecording($("#coach-record-status"), $("#btn-coach-record-start"), $("#btn-coach-record-stop")).catch(() => {})
1350
+ );
1351
 
1352
  $("#btn-export").addEventListener("click", () => {
1353
  const p = state.downloads?.pptx;
1354
+ if (p) window.open(fileUrl(p), "_blank");
1355
  });
1356
 
1357
  $("#btn-new-session").addEventListener("click", () => {
 
1360
  state.discoveredUrls = [];
1361
  state.selectedUrls = [];
1362
  renderResearchChat();
1363
+ renderResearchUrlChoices([], []);
1364
  $("#workspace-session").value = "";
1365
  $("#ingest-status").textContent =
1366
  "Set workspace topic and ingest sources to start a new ResearchMind session.";
 
1372
  document.querySelectorAll(".mode-card").forEach((b) => b.classList.remove("active"));
1373
  btn.classList.add("active");
1374
  state.voiceMode = btn.dataset.mode;
1375
+ syncVoiceModeUi();
1376
  });
1377
  });
1378
  }
libs/agent/src/agent/prompts.py CHANGED
@@ -12,19 +12,20 @@ Follow the skill workflow below and output ONLY valid JSON (no markdown fences).
12
  Skill workflow:
13
  {skill_body}
14
 
15
- JSON schema:
16
  {{
17
- "title": "string presentation title",
18
  "slides": [
19
  {{
20
- "title": "string slide heading",
21
- "bullets": ["string", "..."],
22
- "speaker_note": "string one sentence for the teacher"
23
  }}
24
  ]
25
  }}
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.
@@ -91,12 +92,48 @@ def education_outline_retry_user(req: EducationPptxInput, *, example_json: str)
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"
 
12
  Skill workflow:
13
  {skill_body}
14
 
15
+ Required JSON shape (replace every value with real lesson content for the requested topic):
16
  {{
17
+ "title": "Photosynthesis for Grade 6",
18
  "slides": [
19
  {{
20
+ "title": "What is photosynthesis?",
21
+ "bullets": ["Plants make food using sunlight", "Happens in chloroplasts"],
22
+ "speaker_note": "Ask students what plants need to grow."
23
  }}
24
  ]
25
  }}
26
 
27
  Rules:
28
+ - Fill in concrete titles and bullets about the user's topic — never copy the example text or type names.
29
  - Use exactly the requested number of content slides (title slide is added separately by the tool).
30
  - At most 3 bullets per slide; each bullet under 12 words.
31
  - speaker_note: one short sentence (under 20 words) or omit.
 
92
  f"Topic: {req.topic}\n"
93
  f"Grade level: {req.grade}\n"
94
  f"Number of content slides: {req.slide_count}\n\n"
95
+ "Your previous response was empty, invalid, or copied schema placeholders. "
96
+ "Write real lesson content for the topic below. "
97
+ "Return ONLY valid JSON matching this structure (replace every value for the topic):\n"
98
  f"{example_json}"
99
  )
100
 
101
 
102
+ _SCHEMA_ECHO_MARKERS = (
103
+ "string —",
104
+ "string -",
105
+ "string—",
106
+ "string-",
107
+ )
108
+
109
+
110
+ def _looks_like_schema_field(text: str) -> bool:
111
+ lowered = text.strip().lower()
112
+ if not lowered:
113
+ return False
114
+ if any(marker in lowered for marker in _SCHEMA_ECHO_MARKERS):
115
+ return True
116
+ if lowered in {"string", "..."}:
117
+ return True
118
+ return False
119
+
120
+
121
+ def outline_looks_like_schema_echo(outline: SlideOutline) -> bool:
122
+ """True when the model echoed prompt schema placeholders instead of lesson content."""
123
+ if _looks_like_schema_field(outline.title):
124
+ return True
125
+
126
+ schema_slides = 0
127
+ for slide in outline.slides:
128
+ if _looks_like_schema_field(slide.title):
129
+ schema_slides += 1
130
+ continue
131
+ bullets = [str(b).strip() for b in slide.bullets if str(b).strip()]
132
+ if bullets and all(_looks_like_schema_field(b) for b in bullets):
133
+ schema_slides += 1
134
+ return schema_slides >= max(1, len(outline.slides) // 2)
135
+
136
+
137
  def fallback_outline(req: EducationPptxInput) -> SlideOutline:
138
  """Deterministic outline when the model returns empty or unparseable JSON."""
139
  topic = req.topic.strip() or "Lesson"
libs/agent/src/agent/runner.py CHANGED
@@ -33,6 +33,7 @@ from agent.prompts import (
33
  education_outline_user,
34
  fallback_outline,
35
  outline_json_example,
 
36
  outline_max_tokens,
37
  outline_to_markdown,
38
  )
@@ -530,6 +531,10 @@ class AgentRunner:
530
  ) -> SlideOutline:
531
  data = self._sanitize_outline_data(self._extract_json(raw))
532
  outline = SlideOutline.model_validate(data)
 
 
 
 
533
  original_count = len(outline.slides)
534
  outline = self._normalize_slide_count(outline, expected_slides)
535
  if trace and original_count != expected_slides:
 
33
  education_outline_user,
34
  fallback_outline,
35
  outline_json_example,
36
+ outline_looks_like_schema_echo,
37
  outline_max_tokens,
38
  outline_to_markdown,
39
  )
 
531
  ) -> SlideOutline:
532
  data = self._sanitize_outline_data(self._extract_json(raw))
533
  outline = SlideOutline.model_validate(data)
534
+ if outline_looks_like_schema_echo(outline):
535
+ raise ValueError(
536
+ "Model echoed JSON schema placeholders instead of lesson content"
537
+ )
538
  original_count = len(outline.slides)
539
  outline = self._normalize_slide_count(outline, expected_slides)
540
  if trace and original_count != expected_slides:
libs/agent/tests/test_runner.py CHANGED
@@ -1,6 +1,6 @@
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
@@ -84,6 +84,34 @@ def test_parse_outline_or_error_empty():
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)
 
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_looks_like_schema_echo, 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
 
84
  assert "empty" in err.lower()
85
 
86
 
87
+ def test_parse_outline_rejects_schema_echo():
88
+ runner = AgentRunner()
89
+ raw = (
90
+ '{"title": "string — presentation title", "slides": ['
91
+ '{"title": "string — slide heading", "bullets": ["string", "..."], '
92
+ '"speaker_note": "string — one sentence for the teacher"}'
93
+ "]}"
94
+ )
95
+ import pytest
96
+
97
+ with pytest.raises(ValueError, match="schema placeholders"):
98
+ runner._parse_outline(raw, expected_slides=5)
99
+
100
+
101
+ def test_outline_looks_like_schema_echo():
102
+ echo = SlideOutline(
103
+ title="string — presentation title",
104
+ slides=[SlideSpec(title="string — slide heading", bullets=["string", "..."])],
105
+ )
106
+ assert outline_looks_like_schema_echo(echo) is True
107
+
108
+ real = SlideOutline(
109
+ title="Small model finetuning",
110
+ slides=[SlideSpec(title="What is finetuning?", bullets=["Adapting a base model"])],
111
+ )
112
+ assert outline_looks_like_schema_echo(real) is False
113
+
114
+
115
  def test_fallback_outline_slide_count():
116
  req = EducationPptxInput(topic="ai agent", grade="6", slide_count=5)
117
  outline = fallback_outline(req)