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