Spaces:
Running on Zero
Running on Zero
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 +293 -0
- .cursor/plans/studio_classic_parity_48cdd684.plan.md +220 -0
- apps/gradio-space/README.md +27 -13
- apps/gradio-space/src/gradio_space/api/studio.py +490 -33
- apps/gradio-space/src/gradio_space/ui/components.py +2 -2
- apps/gradio-space/src/gradio_space/ui/studio_html.py +93 -2
- apps/gradio-space/static/studio/index.html +204 -8
- apps/gradio-space/static/studio/studio.css +268 -0
- apps/gradio-space/static/studio/studio.js +762 -102
- libs/agent/src/agent/prompts.py +44 -7
- libs/agent/src/agent/runner.py +5 -0
- libs/agent/tests/test_runner.py +29 -1
.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 (
|
| 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 |
-
|
| 29 |
-
|
| 30 |
-
- `
|
| 31 |
-
- `
|
| 32 |
-
- `
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
## Demo script (judges)
|
| 36 |
|
| 37 |
-
1. Open `/` —
|
| 38 |
2. Paste a URL in Research → **Ingest URL** → documents appear with **RAG Active**
|
| 39 |
-
3. Center column → **Generate Slides** → slide preview canvas
|
| 40 |
-
4.
|
| 41 |
-
5.
|
| 42 |
-
6.
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
from gradio_space.api.serializers import err, ok, unwrap_update, update_value
|
| 16 |
-
from gradio_space.model_loading import
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
|
|
|
| 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=
|
| 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 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
)
|
| 294 |
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 309 |
effective_sid,
|
| 310 |
effective_docs,
|
| 311 |
topic,
|
| 312 |
effective_sid,
|
| 313 |
effective_docs,
|
| 314 |
_NoopProgress(),
|
| 315 |
-
skip_preview_images=
|
| 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=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
elapsed_seconds=_elapsed_seconds_from_log(processing_log),
|
| 361 |
-
progress=_progress_from_trace(
|
| 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
|
| 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=
|
| 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=
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 545 |
doc_ids: list[str] | None = None,
|
|
|
|
|
|
|
| 546 |
) -> dict[str, Any]:
|
| 547 |
return api_teacher_voice_turn(
|
| 548 |
-
message,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 101 |
-
value="
|
| 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[:
|
| 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 |
-
<
|
|
|
|
|
|
|
| 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">
|
| 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="
|
| 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="
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 250 |
-
<
|
| 251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|
| 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 & confirm</option>
|
| 225 |
+
<option value="auto">Auto search & 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 & 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: "
|
| 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 |
-
|
| 98 |
-
|
| 99 |
-
window.setTimeout(() => {
|
| 100 |
-
$("#research-question")?.focus();
|
| 101 |
-
}, 80);
|
| 102 |
}
|
| 103 |
|
| 104 |
-
function getSelectedDiscoveredUrls() {
|
| 105 |
-
const boxes = document.querySelectorAll(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 244 |
-
|
| 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 |
-
:
|
| 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 |
-
|
| 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 =
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 587 |
-
<a href="
|
| 588 |
-
<a href="
|
| 589 |
$("#btn-export").disabled = false;
|
| 590 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 591 |
}
|
| 592 |
|
| 593 |
async function sendVoiceTurn() {
|
| 594 |
const message = $("#voice-message").value.trim();
|
| 595 |
-
|
| 596 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 608 |
-
|
| 609 |
}
|
| 610 |
|
| 611 |
-
async function
|
| 612 |
-
const
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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-
|
|
|
|
|
|
|
|
|
|
| 646 |
|
| 647 |
-
$("#
|
| 648 |
-
|
| 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")
|
| 674 |
-
$("#btn-auto-ingest")
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 696 |
});
|
| 697 |
|
| 698 |
$("#btn-new-session").addEventListener("click", () => {
|
|
@@ -701,7 +1360,7 @@ function bindUi() {
|
|
| 701 |
state.discoveredUrls = [];
|
| 702 |
state.selectedUrls = [];
|
| 703 |
renderResearchChat();
|
| 704 |
-
|
| 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
|
| 16 |
{{
|
| 17 |
-
"title": "
|
| 18 |
"slides": [
|
| 19 |
{{
|
| 20 |
-
"title": "
|
| 21 |
-
"bullets": ["
|
| 22 |
-
"speaker_note": "
|
| 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
|
| 95 |
-
"
|
|
|
|
| 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)
|