MSG commited on
Commit
196a48f
·
1 Parent(s): 871f869

Feat/fix stuff and space basics (#13)

Browse files

* dark theme wip

* loading

* wip loader spinner

* wip research and rag

* benchmark wip

* clean coach page

apps/gradio-space/src/gradio_space/api/studio.py CHANGED
@@ -579,9 +579,9 @@ def api_teacher_voice_turn(
579
  assistant=result.assistant_text,
580
  status=result.rag_status or "Turn complete.",
581
  voiceout_path=result.voiceout_path,
 
582
  )
583
 
584
-
585
  def api_teacher_voice_audio_turn(
586
  audio_path: str,
587
  mode: TeacherVoiceMode = "lesson",
@@ -627,6 +627,7 @@ def api_teacher_voice_audio_turn(
627
  status=result.rag_status or "Turn complete.",
628
  voiceout_path=result.voiceout_path,
629
  user_text=result.user_text,
 
630
  )
631
 
632
 
 
579
  assistant=result.assistant_text,
580
  status=result.rag_status or "Turn complete.",
581
  voiceout_path=result.voiceout_path,
582
+ rag_references=result.rag_references,
583
  )
584
 
 
585
  def api_teacher_voice_audio_turn(
586
  audio_path: str,
587
  mode: TeacherVoiceMode = "lesson",
 
627
  status=result.rag_status or "Turn complete.",
628
  voiceout_path=result.voiceout_path,
629
  user_text=result.user_text,
630
+ rag_references=result.rag_references,
631
  )
632
 
633
 
apps/gradio-space/src/gradio_space/research_helpers.py CHANGED
@@ -126,6 +126,17 @@ def trace_summary_markdown(trace_path: str) -> str:
126
  "",
127
  ]
128
  for step in data.get("steps", []):
 
 
 
 
 
 
 
 
 
 
 
129
  if step.get("type") != "note":
130
  continue
131
  msg = step.get("message", "")
@@ -193,8 +204,8 @@ def format_citations_markdown(trace_json: str) -> str:
193
  return ""
194
  lines = ["", "---", "**Sources:**"]
195
  for i, cite in enumerate(citations[:5], start=1):
196
- title = cite.get("title") or cite.get("uri") or "Source"
197
- uri = cite.get("uri") or ""
198
  lines.append(f"{i}. [{title}]({uri})" if uri else f"{i}. {title}")
199
  if len(citations) > 5:
200
  lines.append(f"_…and {len(citations) - 5} more (see Advanced trace)._")
 
126
  "",
127
  ]
128
  for step in data.get("steps", []):
129
+ if step.get("type") == "step":
130
+ label = step.get("label") or step.get("name") or "step"
131
+ dur = step.get("duration_ms")
132
+ extra = {k: v for k, v in step.items() if k not in ("type", "name", "label", "duration_ms", "elapsed_ms")}
133
+ detail = ""
134
+ if dur is not None:
135
+ detail = f" ({dur} ms)"
136
+ if extra:
137
+ detail += " — " + ", ".join(f"{k}={v!r}" for k, v in extra.items())
138
+ lines.append(f"- **{label}**{detail}")
139
+ continue
140
  if step.get("type") != "note":
141
  continue
142
  msg = step.get("message", "")
 
204
  return ""
205
  lines = ["", "---", "**Sources:**"]
206
  for i, cite in enumerate(citations[:5], start=1):
207
+ title = cite.get("doc_title") or cite.get("title") or cite.get("doc_uri") or cite.get("uri") or "Source"
208
+ uri = cite.get("doc_uri") or cite.get("uri") or ""
209
  lines.append(f"{i}. [{title}]({uri})" if uri else f"{i}. {title}")
210
  if len(citations) > 5:
211
  lines.append(f"_…and {len(citations) - 5} more (see Advanced trace)._")
apps/gradio-space/src/gradio_space/ui/studio_html.py CHANGED
@@ -127,7 +127,7 @@ def render_echo_coach_panel(
127
  if pace_score is None and not tip and not report_md and not transcript_html:
128
  return """
129
  <div class="studio-coach-panel studio-coach-idle">
130
- <p class="studio-coach-hint">Record a pitch in the Coach view, then click <strong>Analyze pitch</strong> for metrics.</p>
131
  </div>"""
132
 
133
  score = pace_score if pace_score is not None else "—"
 
127
  if pace_score is None and not tip and not report_md and not transcript_html:
128
  return """
129
  <div class="studio-coach-panel studio-coach-idle">
130
+ <p class="studio-coach-hint">Record a pitch below, then click <strong>Analyze pitch</strong> for metrics.</p>
131
  </div>"""
132
 
133
  score = pace_score if pace_score is not None else "—"
apps/gradio-space/static/studio/index.html CHANGED
@@ -8,6 +8,13 @@
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>
@@ -28,7 +35,6 @@
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
  <button type="button" class="nav-item" data-view="debug"><span class="material-symbols-outlined">bug_report</span>Debug</button>
33
  <button type="button" id="btn-open-settings" class="nav-item"><span class="material-symbols-outlined">settings</span>Settings</button>
34
  <a href="/classic" class="nav-item nav-link"><span class="material-symbols-outlined">open_in_new</span>Classic UI</a>
@@ -47,6 +53,9 @@
47
  <strong id="project-title">Small Model Finetuning</strong>
48
  </nav>
49
  <div class="topbar-actions">
 
 
 
50
  <a href="/classic" class="btn btn-ghost">Classic UI</a>
51
  <button type="button" id="btn-export" class="btn btn-primary" disabled>Export</button>
52
  </div>
@@ -266,11 +275,11 @@
266
  </div>
267
  </div>
268
  <div id="slide-canvas" class="slide-canvas">
269
- <div id="canvas-overlay" class="canvas-overlay hidden" aria-live="polite">
270
- <div class="canvas-overlay-inner">
271
- <span class="canvas-spinner" aria-hidden="true"></span>
272
- <p id="canvas-overlay-text">Generating slides…</p>
273
- <p class="canvas-overlay-hint">Local CPU models often take 30–90 seconds.</p>
274
  </div>
275
  </div>
276
  <div id="slide-canvas-content" class="slide-canvas-content">
@@ -296,16 +305,16 @@
296
  <div class="card voice-rag-card">
297
  <p class="card-title">RAG Scope</p>
298
  <label class="toggle-row">
299
- <span>Cross-Reference Sources</span>
300
  <input id="use-rag" type="checkbox" checked />
301
  </label>
302
- <p class="status-text">Uses workspace session and documents unless overridden below.</p>
303
  </div>
304
  <div class="card voice-rail-controls">
305
  <p class="card-title">Mode</p>
306
  <div class="mode-cards voice-mode-cards" id="voice-modes">
307
  <button type="button" class="mode-card" data-mode="explain">Explain</button>
308
- <button type="button" class="mode-card active" data-mode="lesson">Coach</button>
309
  <button type="button" class="mode-card" data-mode="pitch">Practice</button>
310
  </div>
311
  <label class="field voice-topic-wrap" id="voice-topic-wrap">
@@ -371,43 +380,46 @@
371
  <div id="voice-audio-out" class="voice-audio-out"></div>
372
  </div>
373
  </div>
374
- </div>
375
- </div>
376
- <div class="card coach-panel-wrap view-coach-only">
377
- <div class="coach-card-head">
378
- <h2 class="section-label">EchoCoach · Pitch analysis</h2>
379
- <p class="coach-card-desc">Record or upload a short pitch for pace, filler highlights, and spoken feedback.</p>
380
- </div>
381
- <div class="coach-capture-row">
382
- <div class="coach-capture-controls">
383
- <div class="recording-row coach-recording-row">
384
- <button type="button" id="btn-coach-record-start" class="btn btn-secondary">Start mic</button>
385
- <button type="button" id="btn-coach-record-stop" class="btn btn-secondary" disabled>Stop mic</button>
386
- <button type="button" id="btn-coach-sample" class="btn btn-ghost">Load sample</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  </div>
388
- <p id="coach-record-status" class="status-text coach-record-status"></p>
389
- </div>
390
- <label class="field coach-upload-field">
391
- <span>Upload pitch (WAV)</span>
392
- <input id="coach-audio" type="file" accept="audio/*" />
393
- </label>
394
- </div>
395
- <div class="controls-grid coach-presets">
396
- <label class="field">
397
- <span>Language</span>
398
- <select id="coach-language" class="input"></select>
399
- </label>
400
- <label class="field">
401
- <span>ASR preset</span>
402
- <select id="coach-asr" class="input"></select>
403
- </label>
404
  </div>
405
- <label class="toggle-row coach-voiceout-toggle">
406
- <span>Speak full rewrite (VoiceOut)</span>
407
- <input id="coach-speak-rewrite" type="checkbox" />
408
- </label>
409
- <button type="button" id="btn-analyze" class="btn btn-primary btn-block coach-analyze-btn">Analyze pitch</button>
410
- <div id="coach-panel" class="coach-results-panel"></div>
411
  </div>
412
  </section>
413
 
@@ -415,7 +427,6 @@
415
  <div class="card card-tall coach-debug-card">
416
  <div class="coach-card-head">
417
  <h2 class="section-label">Chat (debug)</h2>
418
- <p class="coach-card-desc view-coach-only">Plain chat or corpus-grounded answers — traces appear below when RAG is on.</p>
419
  <p class="status-text view-debug-only">Plain chat or corpus-grounded answers — traces appear below when RAG is on.</p>
420
  </div>
421
  <label class="toggle-row">
@@ -467,6 +478,10 @@
467
  <h2 id="settings-title">Settings</h2>
468
  <button type="button" id="btn-close-settings" class="btn btn-ghost btn-icon material-symbols-outlined" aria-label="Close">close</button>
469
  </div>
 
 
 
 
470
  <div id="settings-model-wrap">
471
  <p id="settings-active-model" class="status-text"></p>
472
  <label class="field hidden" id="settings-model-select-wrap">
 
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
+ <script>
12
+ (function () {
13
+ var t = localStorage.getItem("studio-theme");
14
+ if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches))
15
+ document.documentElement.dataset.theme = "dark";
16
+ })();
17
+ </script>
18
  <link rel="stylesheet" href="/static/studio/studio.css" />
19
  </head>
20
  <body>
 
35
  <button type="button" class="nav-item" data-view="research"><span class="material-symbols-outlined">search</span>Research</button>
36
  <button type="button" class="nav-item active" data-view="slides"><span class="material-symbols-outlined">present_to_all</span>Slides</button>
37
  <button type="button" class="nav-item" data-view="voice"><span class="material-symbols-outlined">mic</span>Voice</button>
 
38
  <button type="button" class="nav-item" data-view="debug"><span class="material-symbols-outlined">bug_report</span>Debug</button>
39
  <button type="button" id="btn-open-settings" class="nav-item"><span class="material-symbols-outlined">settings</span>Settings</button>
40
  <a href="/classic" class="nav-item nav-link"><span class="material-symbols-outlined">open_in_new</span>Classic UI</a>
 
53
  <strong id="project-title">Small Model Finetuning</strong>
54
  </nav>
55
  <div class="topbar-actions">
56
+ <button type="button" id="theme-toggle-btn" class="btn btn-ghost btn-icon" aria-label="Toggle light/dark theme" title="Toggle theme">
57
+ <span class="material-symbols-outlined" id="theme-icon">dark_mode</span>
58
+ </button>
59
  <a href="/classic" class="btn btn-ghost">Classic UI</a>
60
  <button type="button" id="btn-export" class="btn btn-primary" disabled>Export</button>
61
  </div>
 
275
  </div>
276
  </div>
277
  <div id="slide-canvas" class="slide-canvas">
278
+ <div id="canvas-overlay" class="region-loading hidden" aria-live="polite">
279
+ <div class="region-loading-inner">
280
+ <span class="studio-spinner" aria-hidden="true"></span>
281
+ <p id="canvas-overlay-text" class="region-loading-text">Generating slides…</p>
282
+ <p class="region-loading-hint canvas-overlay-hint">Local CPU models often take 30–90 seconds.</p>
283
  </div>
284
  </div>
285
  <div id="slide-canvas-content" class="slide-canvas-content">
 
305
  <div class="card voice-rag-card">
306
  <p class="card-title">RAG Scope</p>
307
  <label class="toggle-row">
308
+ <span>Answer from my indexed sources</span>
309
  <input id="use-rag" type="checkbox" checked />
310
  </label>
311
+ <p class="status-text">Ground teacher replies in your workspace documents when enabled.</p>
312
  </div>
313
  <div class="card voice-rail-controls">
314
  <p class="card-title">Mode</p>
315
  <div class="mode-cards voice-mode-cards" id="voice-modes">
316
  <button type="button" class="mode-card" data-mode="explain">Explain</button>
317
+ <button type="button" class="mode-card active" data-mode="lesson">Lesson</button>
318
  <button type="button" class="mode-card" data-mode="pitch">Practice</button>
319
  </div>
320
  <label class="field voice-topic-wrap" id="voice-topic-wrap">
 
380
  <div id="voice-audio-out" class="voice-audio-out"></div>
381
  </div>
382
  </div>
383
+ <details class="card voice-pitch-analysis hidden" id="voice-pitch-analysis" open>
384
+ <summary class="voice-pitch-summary">
385
+ <span class="section-label">Deep pitch analysis</span>
386
+ <span class="voice-pitch-summary-hint">Pace, fillers, charts, and spoken rewrite</span>
387
+ </summary>
388
+ <div class="coach-panel-wrap">
389
+ <p class="coach-card-desc">Record or upload a short monologue (up to 30s), then analyze for metrics and feedback.</p>
390
+ <div class="coach-capture-row">
391
+ <div class="coach-capture-controls">
392
+ <div class="recording-row coach-recording-row">
393
+ <button type="button" id="btn-coach-record-start" class="btn btn-secondary">Start mic</button>
394
+ <button type="button" id="btn-coach-record-stop" class="btn btn-secondary" disabled>Stop mic</button>
395
+ <button type="button" id="btn-coach-sample" class="btn btn-ghost">Load sample</button>
396
+ </div>
397
+ <p id="coach-record-status" class="status-text coach-record-status"></p>
398
+ </div>
399
+ <label class="field coach-upload-field">
400
+ <span>Upload pitch (WAV)</span>
401
+ <input id="coach-audio" type="file" accept="audio/*" />
402
+ </label>
403
+ </div>
404
+ <div class="controls-grid coach-presets">
405
+ <label class="field">
406
+ <span>Language</span>
407
+ <select id="coach-language" class="input"></select>
408
+ </label>
409
+ <label class="field">
410
+ <span>ASR preset</span>
411
+ <select id="coach-asr" class="input"></select>
412
+ </label>
413
+ </div>
414
+ <label class="toggle-row coach-voiceout-toggle">
415
+ <span>Speak full rewrite (VoiceOut)</span>
416
+ <input id="coach-speak-rewrite" type="checkbox" />
417
+ </label>
418
+ <button type="button" id="btn-analyze" class="btn btn-primary btn-block coach-analyze-btn">Analyze pitch</button>
419
+ <div id="coach-panel" class="coach-results-panel"></div>
420
  </div>
421
+ </details>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  </div>
 
 
 
 
 
 
423
  </div>
424
  </section>
425
 
 
427
  <div class="card card-tall coach-debug-card">
428
  <div class="coach-card-head">
429
  <h2 class="section-label">Chat (debug)</h2>
 
430
  <p class="status-text view-debug-only">Plain chat or corpus-grounded answers — traces appear below when RAG is on.</p>
431
  </div>
432
  <label class="toggle-row">
 
478
  <h2 id="settings-title">Settings</h2>
479
  <button type="button" id="btn-close-settings" class="btn btn-ghost btn-icon material-symbols-outlined" aria-label="Close">close</button>
480
  </div>
481
+ <label class="toggle-row theme-toggle">
482
+ <span>Dark theme</span>
483
+ <input id="theme-toggle" type="checkbox" role="switch" aria-label="Dark theme" />
484
+ </label>
485
  <div id="settings-model-wrap">
486
  <p id="settings-active-model" class="status-text"></p>
487
  <label class="field hidden" id="settings-model-select-wrap">
apps/gradio-space/static/studio/studio.css CHANGED
@@ -5,6 +5,7 @@
5
  --on-primary: #ffffff;
6
  --background: #f7f9fb;
7
  --surface: #f7f9fb;
 
8
  --surface-container-low: #f2f4f6;
9
  --surface-container: #eceef0;
10
  --secondary: #565e74;
@@ -15,6 +16,14 @@
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;
@@ -23,6 +32,30 @@
23
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
24
  }
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  * { box-sizing: border-box; }
27
 
28
  body {
@@ -52,7 +85,7 @@ body {
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;
@@ -160,7 +193,7 @@ body {
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
  }
@@ -244,7 +277,7 @@ body {
244
  }
245
 
246
  .card {
247
- background: #fff;
248
  border: 1px solid var(--outline-variant);
249
  border-radius: var(--radius-xl);
250
  padding: 1rem;
@@ -288,7 +321,7 @@ body {
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
 
@@ -299,13 +332,14 @@ body {
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 {
@@ -350,13 +384,24 @@ body {
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
  }
@@ -365,39 +410,52 @@ body {
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 {
@@ -457,7 +515,7 @@ body {
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;
@@ -510,7 +568,7 @@ body {
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; }
@@ -522,7 +580,7 @@ body {
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); }
@@ -550,10 +608,11 @@ body {
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 {
@@ -870,7 +929,7 @@ body {
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;
@@ -941,7 +1000,7 @@ body {
941
 
942
  .research-chat-assistant {
943
  align-self: flex-start;
944
- background: #fff;
945
  border: 1px solid var(--outline-variant);
946
  }
947
 
@@ -961,8 +1020,7 @@ body {
961
  .workspace[data-view="voice"] .col-research,
962
  .workspace[data-view="voice"] .col-slides { display: none; }
963
 
964
- .workspace[data-view="voice"] .col-debug,
965
- .workspace[data-view="voice"] .view-coach-only { display: none; }
966
 
967
  .view-voice-only { display: none; }
968
 
@@ -995,6 +1053,54 @@ body {
995
 
996
  .workspace[data-view="voice"] .voice-main {
997
  min-width: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
998
  }
999
 
1000
  .workspace[data-view="voice"] .voice-main-card {
@@ -1115,69 +1221,9 @@ body {
1115
  }
1116
  }
1117
 
1118
- .workspace[data-view="coach"] .col-research,
1119
- .workspace[data-view="coach"] .col-slides { display: none; }
1120
-
1121
- .workspace[data-view="coach"] .view-voice-only { display: none; }
1122
-
1123
  .workspace[data-view="slides"] .col-studio,
1124
  .workspace[data-view="research"] .col-debug { display: none; }
1125
 
1126
- .workspace[data-view="coach"] {
1127
- grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
1128
- max-width: 1280px;
1129
- gap: 1.25rem;
1130
- align-items: start;
1131
- }
1132
-
1133
- .workspace[data-view="coach"] .coach-panel-wrap,
1134
- .workspace[data-view="coach"] .coach-debug-card {
1135
- display: flex;
1136
- flex-direction: column;
1137
- }
1138
-
1139
- .workspace[data-view="coach"] .coach-results-panel {
1140
- flex: 1;
1141
- min-height: 120px;
1142
- margin-top: 0.75rem;
1143
- overflow-y: auto;
1144
- }
1145
-
1146
- .workspace[data-view="coach"] .coach-results-panel:not(:empty) {
1147
- border-top: 1px solid var(--outline-variant);
1148
- padding-top: 0.75rem;
1149
- }
1150
-
1151
- .workspace[data-view="coach"] .debug-chat-messages {
1152
- min-height: 140px;
1153
- max-height: min(240px, 30vh);
1154
- margin-bottom: 0.5rem;
1155
- }
1156
-
1157
- .workspace[data-view="coach"] .coach-debug-compose {
1158
- padding-top: 0;
1159
- border-top: none;
1160
- }
1161
-
1162
- .workspace[data-view="coach"] .coach-debug-compose textarea {
1163
- min-height: 3.5rem;
1164
- resize: vertical;
1165
- }
1166
-
1167
- .workspace[data-view="coach"] .coach-debug-card .studio-debug-trace {
1168
- flex-shrink: 0;
1169
- margin-top: 0.5rem;
1170
- }
1171
-
1172
- .workspace[data-view="coach"] .coach-debug-card .toggle-row,
1173
- .workspace[data-view="coach"] .coach-debug-card .debug-rag-scope {
1174
- flex-shrink: 0;
1175
- }
1176
-
1177
- .view-coach-only { display: none; }
1178
- .workspace[data-view="coach"] .view-coach-only:not(.coach-panel-wrap) { display: block; }
1179
- .workspace[data-view="coach"] .view-debug-only { display: none; }
1180
-
1181
  .coach-card-head {
1182
  margin-bottom: 0.85rem;
1183
  }
@@ -1231,13 +1277,6 @@ body {
1231
  }
1232
 
1233
  @media (max-width: 960px) {
1234
- .workspace[data-view="coach"] {
1235
- grid-template-columns: 1fr;
1236
- max-width: 640px;
1237
- margin-left: auto;
1238
- margin-right: auto;
1239
- }
1240
-
1241
  .coach-capture-row {
1242
  grid-template-columns: 1fr;
1243
  }
@@ -1297,13 +1336,17 @@ body {
1297
  padding: 0.75rem;
1298
  border: 1px solid var(--outline-variant);
1299
  border-radius: 8px;
1300
- background: #fff;
1301
  font-size: 0.875rem;
1302
  line-height: 1.5;
1303
  max-height: 240px;
1304
  overflow-y: auto;
1305
  }
1306
 
 
 
 
 
1307
  .debug-rag-scope {
1308
  margin: 0.75rem 0;
1309
  }
@@ -1399,6 +1442,14 @@ body {
1399
  font-size: 0.875rem;
1400
  }
1401
 
 
 
 
 
 
 
 
 
1402
  .voice-replay-row {
1403
  display: flex;
1404
  flex-wrap: wrap;
@@ -1509,7 +1560,6 @@ body {
1509
  margin-top: 0;
1510
  }
1511
 
1512
- .workspace[data-view="debug"] .view-coach-only { display: none; }
1513
  .workspace[data-view="debug"] .view-debug-only { display: block; }
1514
 
1515
  .workspace[data-view="debug"] .col-research,
 
5
  --on-primary: #ffffff;
6
  --background: #f7f9fb;
7
  --surface: #f7f9fb;
8
+ --surface-elevated: #ffffff;
9
  --surface-container-low: #f2f4f6;
10
  --surface-container: #eceef0;
11
  --secondary: #565e74;
 
16
  --inverse-surface: #2d3133;
17
  --inverse-on-surface: #eff1f3;
18
  --error: #ba1a1a;
19
+ --banner-error-bg: #ffdad6;
20
+ --banner-error-text: #93000a;
21
+ --canvas-overlay-bg: rgba(247, 249, 251, 0.88);
22
+ --slide-canvas-bg: rgba(216, 218, 220, 0.15);
23
+ --hover-overlay: rgba(255, 255, 255, 0.65);
24
+ --badge-rag-text: #390c00;
25
+ --progress-done: #2d6a4f;
26
+ --input-focus-ring: rgba(168, 51, 0, 0.15);
27
  --sidebar-w: 280px;
28
  --topbar-h: 64px;
29
  --context-bar-h: 72px;
 
32
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
33
  }
34
 
35
+ [data-theme="dark"] {
36
+ color-scheme: dark;
37
+ --background: #121416;
38
+ --surface: #1a1c1e;
39
+ --surface-elevated: #242729;
40
+ --surface-container-low: #1e2022;
41
+ --surface-container: #2a2d30;
42
+ --secondary: #b8bcc8;
43
+ --secondary-container: #3d4454;
44
+ --on-secondary-container: #dae2fd;
45
+ --outline-variant: #4a403c;
46
+ --on-surface: #e3e2e6;
47
+ --primary-fixed: #5c2a12;
48
+ --banner-error-bg: #93000a;
49
+ --banner-error-text: #ffdad6;
50
+ --canvas-overlay-bg: rgba(18, 20, 22, 0.88);
51
+ --slide-canvas-bg: rgba(255, 255, 255, 0.04);
52
+ --hover-overlay: rgba(255, 255, 255, 0.06);
53
+ --badge-rag-text: #ffdbd0;
54
+ --progress-done: #6fcf97;
55
+ --input-focus-ring: rgba(203, 74, 24, 0.25);
56
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
57
+ }
58
+
59
  * { box-sizing: border-box; }
60
 
61
  body {
 
85
  }
86
 
87
  .studio-banner-loading { background: var(--secondary-container); color: var(--on-secondary-container); }
88
+ .studio-banner-error { background: var(--banner-error-bg); color: var(--banner-error-text); }
89
 
90
  .sidebar {
91
  position: fixed;
 
193
  left: var(--sidebar-w);
194
  right: 0;
195
  z-index: 35;
196
+ background: var(--surface-elevated);
197
  border-bottom: 1px solid var(--outline-variant);
198
  padding: 0.5rem 1.5rem;
199
  }
 
277
  }
278
 
279
  .card {
280
+ background: var(--surface-elevated);
281
  border: 1px solid var(--outline-variant);
282
  border-radius: var(--radius-xl);
283
  padding: 1rem;
 
321
  .btn:active { transform: scale(0.98); }
322
  .btn:disabled { opacity: 0.5; cursor: not-allowed; }
323
  .btn-primary { background: var(--primary); color: var(--on-primary); }
324
+ .btn-secondary { background: var(--surface-elevated); border-color: var(--outline-variant); color: var(--on-surface); }
325
  .btn-ghost { background: transparent; border-color: var(--outline-variant); color: var(--secondary); text-decoration: none; }
326
  .btn-block { width: 100%; margin-top: 0.5rem; }
327
 
 
332
  border-radius: 8px;
333
  font: inherit;
334
  font-size: 0.9rem;
335
+ background: var(--surface-elevated);
336
+ color: var(--on-surface);
337
  }
338
 
339
  .input:focus {
340
  outline: none;
341
  border-color: var(--primary);
342
+ box-shadow: 0 0 0 2px var(--input-focus-ring);
343
  }
344
 
345
  .upload-zone {
 
384
 
385
  .controls-actions { display: flex; justify-content: flex-end; margin-top: 0.75rem; }
386
 
387
+ .region-loading-host,
388
+ .card-ingest,
389
+ .card-chat,
390
+ .voice-main-card,
391
+ .coach-panel-wrap,
392
+ .coach-debug-card,
393
+ .controls-panel,
394
+ .voice-rail-controls {
395
+ position: relative;
396
+ }
397
+
398
  .slide-canvas {
399
  position: relative;
400
  flex: 1;
401
  min-height: 320px;
402
  border: 2px dashed var(--outline-variant);
403
  border-radius: var(--radius-xl);
404
+ background: var(--slide-canvas-bg);
405
  overflow: auto;
406
  padding: 1rem;
407
  }
 
410
  min-height: 280px;
411
  }
412
 
413
+ .region-loading {
414
  position: absolute;
415
  inset: 0;
416
  z-index: 5;
417
  display: flex;
418
  align-items: center;
419
  justify-content: center;
420
+ background: transparent;
421
+ pointer-events: none;
 
422
  }
423
 
424
+ .region-loading-inner {
425
+ display: flex;
426
+ flex-direction: column;
427
+ align-items: center;
428
+ gap: 0.5rem;
429
  text-align: center;
 
430
  }
431
 
432
+ .studio-spinner,
433
  .canvas-spinner,
434
  .lesson-running-spinner {
435
  display: inline-block;
436
+ width: 32px;
437
+ height: 32px;
438
  border: 3px solid var(--outline-variant);
439
  border-top-color: var(--primary);
440
  border-radius: 50%;
441
  animation: studio-spin 0.9s linear infinite;
442
+ flex-shrink: 0;
443
  }
444
 
445
+ .region-loading-text,
446
+ .canvas-overlay-text {
447
+ margin: 0;
448
  font-size: 0.85rem;
449
+ font-weight: 500;
450
+ color: var(--secondary);
451
+ }
452
+
453
+ .region-loading-hint,
454
+ .canvas-overlay-hint {
455
+ margin: 0;
456
+ font-size: 0.78rem;
457
  color: var(--secondary);
458
+ opacity: 0.85;
459
  }
460
 
461
  @keyframes studio-spin {
 
515
 
516
  .progress-step.pending { color: var(--secondary); }
517
  .progress-step.active { color: var(--primary); font-weight: 600; }
518
+ .progress-step.done { color: var(--progress-done); }
519
 
520
  .progress-log {
521
  margin: 0.75rem 0 0;
 
568
  text-transform: uppercase;
569
  }
570
 
571
+ .studio-badge-rag { background: var(--primary-fixed); color: var(--badge-rag-text); }
572
  .studio-badge-muted { background: var(--surface-container); color: var(--secondary); }
573
 
574
  .studio-doc-header { margin-bottom: 0.5rem; }
 
580
  padding: 0.5rem;
581
  border: 1px solid var(--outline-variant);
582
  border-radius: 8px;
583
+ background: var(--surface-elevated);
584
  }
585
 
586
  .studio-doc-card:hover { border-color: var(--primary); }
 
608
  padding: 0.6rem 0.75rem;
609
  border: 1px solid var(--outline-variant);
610
  border-radius: 8px;
611
+ background: var(--surface-elevated);
612
  font: inherit;
613
  cursor: pointer;
614
  text-align: left;
615
+ color: var(--on-surface);
616
  }
617
 
618
  .mode-card.active {
 
929
  border-radius: 6px;
930
  }
931
 
932
+ .url-choice-item:hover { background: var(--hover-overlay); }
933
 
934
  .url-choice-item span {
935
  word-break: break-all;
 
1000
 
1001
  .research-chat-assistant {
1002
  align-self: flex-start;
1003
+ background: var(--surface-elevated);
1004
  border: 1px solid var(--outline-variant);
1005
  }
1006
 
 
1020
  .workspace[data-view="voice"] .col-research,
1021
  .workspace[data-view="voice"] .col-slides { display: none; }
1022
 
1023
+ .workspace[data-view="voice"] .col-debug { display: none; }
 
1024
 
1025
  .view-voice-only { display: none; }
1026
 
 
1053
 
1054
  .workspace[data-view="voice"] .voice-main {
1055
  min-width: 0;
1056
+ display: flex;
1057
+ flex-direction: column;
1058
+ gap: 1rem;
1059
+ }
1060
+
1061
+ .workspace[data-view="voice"] .voice-pitch-analysis {
1062
+ margin: 0;
1063
+ }
1064
+
1065
+ .workspace[data-view="voice"] .voice-pitch-analysis[open] .voice-pitch-summary {
1066
+ margin-bottom: 0.75rem;
1067
+ }
1068
+
1069
+ .workspace[data-view="voice"] .voice-pitch-summary {
1070
+ cursor: pointer;
1071
+ list-style: none;
1072
+ display: flex;
1073
+ flex-direction: column;
1074
+ gap: 0.2rem;
1075
+ }
1076
+
1077
+ .workspace[data-view="voice"] .voice-pitch-summary::-webkit-details-marker {
1078
+ display: none;
1079
+ }
1080
+
1081
+ .workspace[data-view="voice"] .voice-pitch-summary-hint {
1082
+ font-size: 0.84rem;
1083
+ color: var(--secondary);
1084
+ font-weight: 400;
1085
+ }
1086
+
1087
+ .workspace[data-view="voice"] .voice-pitch-analysis .coach-panel-wrap {
1088
+ padding-top: 0.25rem;
1089
+ }
1090
+
1091
+ .workspace[data-view="voice"] .voice-discuss-btn {
1092
+ margin-top: 0.75rem;
1093
+ }
1094
+
1095
+ .workspace[data-view="voice"] .coach-results-panel {
1096
+ min-height: 80px;
1097
+ margin-top: 0.75rem;
1098
+ overflow-y: auto;
1099
+ }
1100
+
1101
+ .workspace[data-view="voice"] .coach-results-panel:not(:empty) {
1102
+ border-top: 1px solid var(--outline-variant);
1103
+ padding-top: 0.75rem;
1104
  }
1105
 
1106
  .workspace[data-view="voice"] .voice-main-card {
 
1221
  }
1222
  }
1223
 
 
 
 
 
 
1224
  .workspace[data-view="slides"] .col-studio,
1225
  .workspace[data-view="research"] .col-debug { display: none; }
1226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1227
  .coach-card-head {
1228
  margin-bottom: 0.85rem;
1229
  }
 
1277
  }
1278
 
1279
  @media (max-width: 960px) {
 
 
 
 
 
 
 
1280
  .coach-capture-row {
1281
  grid-template-columns: 1fr;
1282
  }
 
1336
  padding: 0.75rem;
1337
  border: 1px solid var(--outline-variant);
1338
  border-radius: 8px;
1339
+ background: var(--surface-elevated);
1340
  font-size: 0.875rem;
1341
  line-height: 1.5;
1342
  max-height: 240px;
1343
  overflow-y: auto;
1344
  }
1345
 
1346
+ .theme-toggle {
1347
+ margin-bottom: 0.25rem;
1348
+ }
1349
+
1350
  .debug-rag-scope {
1351
  margin: 0.75rem 0;
1352
  }
 
1442
  font-size: 0.875rem;
1443
  }
1444
 
1445
+ .voice-rag-refs {
1446
+ margin-top: 0.65rem;
1447
+ padding-top: 0.55rem;
1448
+ border-top: 1px solid var(--outline-variant);
1449
+ font-size: 0.82rem;
1450
+ color: var(--on-surface-variant);
1451
+ }
1452
+
1453
  .voice-replay-row {
1454
  display: flex;
1455
  flex-wrap: wrap;
 
1560
  margin-top: 0;
1561
  }
1562
 
 
1563
  .workspace[data-view="debug"] .view-debug-only { display: block; }
1564
 
1565
  .workspace[data-view="debug"] .col-research,
apps/gradio-space/static/studio/studio.js CHANGED
@@ -1,6 +1,30 @@
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",
@@ -33,6 +57,7 @@ const state = {
33
  browserRecordChunks: [],
34
  pendingVoiceAudioPath: null,
35
  pendingCoachAudioPath: null,
 
36
  useBrowserMic: true,
37
  };
38
 
@@ -249,14 +274,16 @@ async function discoverVoiceSources() {
249
  showError("Set a focus or workspace topic before discovering sources.");
250
  return;
251
  }
252
- const data = await callApi("discover_sources", [topic, state.workspaceSessionId]);
253
- $("#voice-ingest-status").textContent = stripMd(data.status || "Discovery complete.");
254
- renderVoiceUrlChoices(data.urls || [], data.selected_urls || data.urls || []);
255
- if (data.session_id) {
256
- state.workspaceSessionId = data.session_id;
257
- $("#workspace-session").value = data.session_id;
258
- }
259
- await refreshWorkspaceSessions(state.workspaceSessionId);
 
 
260
  }
261
 
262
  async function autoVoiceIngest() {
@@ -265,46 +292,53 @@ async function autoVoiceIngest() {
265
  showError("Set a focus or workspace topic before auto-ingest.");
266
  return;
267
  }
268
- const data = await callApi("auto_search_ingest", [topic, state.workspaceSessionId]);
269
- applyVoiceIngestResult(data);
270
- state.voiceDiscoveredUrls = [];
271
- state.voiceSelectedUrls = [];
272
- renderVoiceUrlChoices([], []);
273
- await refreshWorkspaceSessions(state.workspaceSessionId);
 
 
274
  }
275
 
276
  async function ingestVoiceSources() {
277
  const topic = voiceEffectiveTopic();
278
  const pasted = $("#voice-urls-text")?.value.trim() || "";
279
  const selected = getSelectedDiscoveredUrls("#voice-url-choices-list");
280
- const paths = [];
281
  const files = $("#voice-ingest-file")?.files;
282
- if (files?.length) {
283
- for (const file of files) {
284
- paths.push(await uploadFile(file));
285
- }
286
- }
287
- if (!pasted && !selected.length && !paths.length) {
288
  showError("Add URLs, select suggested sources, or upload a file — then ingest.");
289
  return;
290
  }
291
- const data = await callApi("ingest_sources", [
292
- topic,
293
- state.workspaceSessionId,
294
- pasted,
295
- selected,
296
- paths,
297
- ]);
298
- applyVoiceIngestResult(data);
299
- if (pasted) $("#voice-urls-text").value = "";
300
- if (files?.length) $("#voice-ingest-file").value = "";
301
- await refreshWorkspaceSessions(state.workspaceSessionId);
 
 
 
 
 
 
 
 
302
  }
303
 
304
  function syncVoiceModeUi() {
305
  const ragMode = state.voiceMode === "explain" || state.voiceMode === "lesson";
 
306
  $("#voice-topic-wrap")?.classList.toggle("hidden", !ragMode);
307
  $("#voice-rag-sources")?.classList.toggle("hidden", !ragMode);
 
 
308
  const placeholders = {
309
  explain: "e.g. How does finetuning differ from pretraining?",
310
  lesson: "What is the difference between pretraining and finetuning a small model?",
@@ -327,7 +361,10 @@ function renderVoiceChat() {
327
  if (item && typeof item === "object" && item.role) {
328
  const role = item.role === "user" ? "user" : "assistant";
329
  const label = role === "user" ? "You" : "Teacher";
330
- const body = renderMarkdownLite(voiceMessageText(item.content));
 
 
 
331
  parts.push(
332
  `<div class="research-chat-bubble research-chat-${role}"><div class="research-chat-role">${label}</div><div class="research-chat-body">${body}</div></div>`
333
  );
@@ -396,15 +433,17 @@ async function discoverSources() {
396
  showError("Set a workspace topic before discovering sources.");
397
  return;
398
  }
399
- const data = await callApi("discover_sources", [topic, state.workspaceSessionId]);
400
- $("#ingest-status").textContent = stripMd(data.status || "Discovery complete.");
401
- renderResearchUrlChoices(data.urls || [], data.selected_urls || data.urls || []);
402
- if (data.session_id) {
403
- state.workspaceSessionId = data.session_id;
404
- $("#workspace-session").value = data.session_id;
405
- }
406
- setTracePanel("#research-trace-panel", data);
407
- await refreshWorkspaceSessions(state.workspaceSessionId);
 
 
408
  }
409
 
410
  async function discoverSlideSources() {
@@ -413,8 +452,10 @@ async function discoverSlideSources() {
413
  showError("Set a topic before discovering sources.");
414
  return;
415
  }
416
- const data = await callApi("discover_sources", [topic, state.workspaceSessionId]);
417
- renderSlideUrlChoices(data.urls || [], data.selected_urls || data.urls || []);
 
 
418
  }
419
 
420
  async function autoSearchIngest() {
@@ -423,12 +464,14 @@ async function autoSearchIngest() {
423
  showError("Set a workspace topic before auto-ingest.");
424
  return;
425
  }
426
- const data = await callApi("auto_search_ingest", [topic, state.workspaceSessionId]);
427
- applyIngestResult(data);
428
- state.discoveredUrls = [];
429
- state.selectedUrls = [];
430
- renderResearchUrlChoices([], []);
431
- await refreshWorkspaceSessions(state.workspaceSessionId);
 
 
432
  }
433
 
434
  async function ingestSources({ urlsText = "", selectedUrls = [], pendingFiles = null } = {}) {
@@ -437,30 +480,32 @@ async function ingestSources({ urlsText = "", selectedUrls = [], pendingFiles =
437
  let selected = selectedUrls;
438
  if (workflow === "select") selected = getSelectedDiscoveredUrls();
439
  const pasted = workflow === "direct" ? urlsText : urlsText || $("#ingest-url").value.trim();
440
- const paths = [];
441
  const files = pendingFiles || $("#ingest-file").files;
442
- if (files?.length) {
443
- for (const file of files) {
444
- const b64 = await fileToBase64(file);
445
- const saved = await callApi("save_upload", [file.name, b64]);
446
- paths.push(saved.path);
447
- }
448
- }
449
- if (!pasted && !selected.length && !paths.length) {
450
  showError("Add URLs, select suggested sources, or upload a file — then ingest.");
451
  return;
452
  }
453
- const data = await callApi("ingest_sources", [
454
- topic,
455
- state.workspaceSessionId,
456
- pasted,
457
- selected,
458
- paths,
459
- ]);
460
- applyIngestResult(data);
461
- if (pasted) $("#ingest-url").value = "";
462
- if (files?.length) $("#ingest-file").value = "";
463
- await refreshWorkspaceSessions(state.workspaceSessionId);
 
 
 
 
 
 
 
 
 
 
464
  }
465
 
466
  function renderResearchChat() {
@@ -512,18 +557,20 @@ async function askResearchQuestion() {
512
  return;
513
  }
514
  const docIds = effectiveDocIds([]);
515
- const data = await callApi("research_chat", [
516
- question,
517
- state.workspaceSessionId,
518
- docIds,
519
- state.researchChatHistory,
520
- ]);
521
- state.researchChatHistory = data.history || [];
522
- renderResearchChat();
523
- $("#research-question").value = "";
524
- $("#research-chat-status").textContent = stripMd(data.rag_hint || "");
525
- setTracePanel("#research-trace-panel", data);
526
- updateResearchRagBadge();
 
 
527
  }
528
 
529
  async function sendDebugMessage() {
@@ -537,23 +584,25 @@ async function sendDebugMessage() {
537
  const debugDocIds = selectedDebugDocIds();
538
  const workspaceDocIds = selectedWorkspaceDocIds();
539
  const modelKey = $("#debug-model-key")?.value || "";
540
- const data = await callApi("debug_chat", [
541
- message,
542
- state.debugChatHistory,
543
- useRag,
544
- debugSession,
545
- debugDocIds,
546
- modelKey,
547
- state.workspaceSessionId,
548
- workspaceDocIds,
549
- ]);
550
- state.debugChatHistory = data.history || [];
551
- renderDebugChat();
552
- $("#debug-message").value = "";
553
- if (data.rag_hint) {
554
- $("#debug-rag-hint").textContent = stripMd(data.rag_hint);
555
- }
556
- setTracePanel("#debug-trace-panel", data);
 
 
557
  }
558
 
559
  function effectiveDebugSessionId() {
@@ -651,10 +700,55 @@ async function getClient() {
651
  return state.client;
652
  }
653
 
 
 
654
  function setLoading(on) {
 
655
  $("#studio-loading").classList.toggle("hidden", !on);
656
  }
657
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
658
  function startProgressPanel() {
659
  const panel = $("#progress-panel");
660
  const stepsEl = $("#progress-steps");
@@ -968,91 +1062,107 @@ async function generateSlides() {
968
  const urlsText = $("#slide-urls-text")?.value.trim() || "";
969
  const selectedUrls = getSelectedDiscoveredUrls("#slide-url-choices-list");
970
 
971
- const filePaths = [];
972
- const slideFiles = $("#slide-source-files")?.files;
973
- if (slideFiles?.length) {
974
- for (const file of slideFiles) {
975
- filePaths.push(await uploadFile(file));
976
- }
977
- }
 
 
 
 
978
 
979
- startProgressPanel();
980
- const waitTimer = advanceProgressWhileWaiting();
981
- let data;
982
- try {
983
- data = await callApi("generate_slides", [
984
- topic,
985
- grade,
986
- slideCount,
987
- state.workspaceSessionId,
988
- useRag,
989
- docIds,
990
- sourceMode,
991
- searchWorkflow,
992
- urlsText,
993
- selectedUrls,
994
- filePaths,
995
- ]);
996
- } catch (_err) {
997
- $("#progress-eta").textContent = "Failed";
998
- throw _err;
999
- } finally {
1000
- clearInterval(waitTimer);
1001
- if (state.progressTimer) {
1002
- clearInterval(state.progressTimer);
1003
- state.progressTimer = null;
1004
- }
1005
- }
1006
 
1007
- finishProgressPanel(data);
1008
- $("#generate-status").textContent = stripMd(data.status || "Slides generated.");
1009
- const canvasHtml =
1010
- data.canvas_html ||
1011
- (data.preview_html ? `<div class="studio-canvas-inner">${data.preview_html}</div>` : "");
1012
- $("#slide-canvas").innerHTML =
1013
- canvasHtml || '<div class="studio-canvas-empty"><p>Preview unavailable.</p></div>';
1014
-
1015
- const galleryEl = $("#slide-gallery");
1016
- if (data.gallery_html) {
1017
- galleryEl.innerHTML = data.gallery_html;
1018
- galleryEl.classList.remove("hidden");
1019
- } else if (data.gallery?.length) {
1020
- galleryEl.innerHTML = data.gallery
1021
- .map(
1022
- (path, i) =>
1023
- `<a class="studio-gallery-item" href="${fileUrl(path)}" target="_blank" rel="noopener"><img src="${fileUrl(path)}" alt="Slide ${i + 1}" loading="lazy" /></a>`
1024
- )
1025
- .join("");
1026
- galleryEl.classList.remove("hidden");
1027
- } else {
1028
- galleryEl.classList.add("hidden");
1029
- galleryEl.innerHTML = "";
1030
- }
1031
 
1032
- state.downloads = data.downloads;
1033
- const dl = $("#downloads");
1034
- if (data.downloads?.pptx) {
1035
- dl.classList.remove("hidden");
1036
- dl.innerHTML = `
1037
  <a href="${fileUrl(data.downloads.pptx)}" download>PPTX</a>
1038
  <a href="${fileUrl(data.downloads.docx)}" download>DOCX</a>
1039
  <a href="${fileUrl(data.downloads.html)}" download>HTML</a>`;
1040
- $("#btn-export").disabled = false;
1041
- }
1042
 
1043
- const outlineDetails = $("#slide-outline-details");
1044
- const outlineEl = $("#slide-outline");
1045
- if (data.outline_md) {
1046
- outlineEl.innerHTML = renderMarkdownLite(data.outline_md);
1047
- outlineDetails?.classList.remove("hidden");
1048
- } else {
1049
- outlineEl.innerHTML = "";
1050
- outlineDetails?.classList.add("hidden");
1051
- }
 
 
 
 
 
 
1052
  }
1053
 
1054
  function renderVoiceReply(data, { keepAudio = false } = {}) {
1055
  state.history = data.history ?? state.history;
 
 
 
 
 
 
1056
  renderVoiceChat();
1057
  if (data.status) {
1058
  $("#voice-turn-status").textContent = stripMd(data.status);
@@ -1075,19 +1185,21 @@ async function sendVoiceTurn() {
1075
  const useRag = voiceUseRag();
1076
  const docIds = effectiveDocIds([]);
1077
  const language = state.voicePresets?.default_language || "en";
1078
- const data = await callApi("teacher_voice_turn", [
1079
- message,
1080
- state.voiceMode,
1081
- topic,
1082
- state.workspaceSessionId,
1083
- useRag,
1084
- state.history,
1085
- docIds,
1086
- language,
1087
- null,
1088
- ]);
1089
- $("#voice-message").value = "";
1090
- renderVoiceReply(data);
 
 
1091
  }
1092
 
1093
  async function sendVoiceAudioTurn(audioPath) {
@@ -1096,19 +1208,21 @@ async function sendVoiceAudioTurn(audioPath) {
1096
  const docIds = effectiveDocIds([]);
1097
  const language = state.voicePresets?.default_language || "en";
1098
  const asr = state.voicePresets?.default_asr || null;
1099
- const data = await callApi("teacher_voice_audio_turn", [
1100
- audioPath,
1101
- state.voiceMode,
1102
- topic,
1103
- state.workspaceSessionId,
1104
- useRag,
1105
- state.history,
1106
- docIds,
1107
- language,
1108
- asr,
1109
- ]);
1110
- if (data.user_text) $("#voice-message").value = data.user_text;
1111
- renderVoiceReply(data);
 
 
1112
  }
1113
 
1114
  async function speakVoiceReply(firstSentenceOnly) {
@@ -1139,13 +1253,36 @@ async function analyzePitchWithPath(audioPath) {
1139
  const language = $("#coach-language")?.value || "en";
1140
  const asr = $("#coach-asr")?.value || null;
1141
  const speakRewrite = $("#coach-speak-rewrite")?.checked || false;
1142
- $("#coach-panel").innerHTML = `
1143
- <div class="studio-coach-panel studio-coach-live">
1144
- <div class="studio-coach-header"><span class="studio-coach-dot"></span>
1145
- <span class="studio-coach-label">Analyzing…</span></div>
1146
- </div>`;
1147
- const data = await callApi("analyze_pitch", [audioPath, language, asr, speakRewrite]);
1148
- $("#coach-panel").innerHTML = data.coach_panel_html || "";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1149
  }
1150
 
1151
  async function analyzePitch() {
@@ -1265,6 +1402,8 @@ function bindUi() {
1265
  $("#btn-open-settings")?.addEventListener("click", openSettingsDrawer);
1266
  $("#btn-close-settings")?.addEventListener("click", closeSettingsDrawer);
1267
  $("#settings-backdrop")?.addEventListener("click", closeSettingsDrawer);
 
 
1268
  $("#btn-reload-model")?.addEventListener("click", () => reloadModelFromSettings().catch(() => {}));
1269
 
1270
  $("#btn-open-research-view")?.addEventListener("click", openResearchView);
@@ -1375,6 +1514,8 @@ function bindUi() {
1375
  syncVoiceModeUi();
1376
  });
1377
  });
 
 
1378
  }
1379
 
1380
  bindUi();
 
1
  import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client@1.14.0/+esm";
2
 
3
  const $ = (sel) => document.querySelector(sel);
4
+ const THEME_KEY = "studio-theme";
5
+
6
+ function getPreferredTheme() {
7
+ const saved = localStorage.getItem(THEME_KEY);
8
+ if (saved === "light" || saved === "dark") return saved;
9
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
10
+ }
11
+
12
+ function applyTheme(theme) {
13
+ document.documentElement.dataset.theme = theme === "dark" ? "dark" : "light";
14
+ localStorage.setItem(THEME_KEY, theme);
15
+ const icon = $("#theme-icon");
16
+ if (icon) icon.textContent = theme === "dark" ? "light_mode" : "dark_mode";
17
+ const checkbox = $("#theme-toggle");
18
+ if (checkbox) checkbox.checked = theme === "dark";
19
+ }
20
+
21
+ function toggleTheme() {
22
+ const current = document.documentElement.dataset.theme === "dark" ? "dark" : "light";
23
+ applyTheme(current === "dark" ? "light" : "dark");
24
+ }
25
+
26
+ applyTheme(getPreferredTheme());
27
+
28
  const SLIDE_PIPELINE_STEPS = [
29
  "Load language model",
30
  "Gather lesson sources",
 
57
  browserRecordChunks: [],
58
  pendingVoiceAudioPath: null,
59
  pendingCoachAudioPath: null,
60
+ lastPitchAnalysis: null,
61
  useBrowserMic: true,
62
  };
63
 
 
274
  showError("Set a focus or workspace topic before discovering sources.");
275
  return;
276
  }
277
+ await withRegionLoading($(".voice-rail-controls"), "Discovering sources…", async () => {
278
+ const data = await callApi("discover_sources", [topic, state.workspaceSessionId]);
279
+ $("#voice-ingest-status").textContent = stripMd(data.status || "Discovery complete.");
280
+ renderVoiceUrlChoices(data.urls || [], data.selected_urls || data.urls || []);
281
+ if (data.session_id) {
282
+ state.workspaceSessionId = data.session_id;
283
+ $("#workspace-session").value = data.session_id;
284
+ }
285
+ await refreshWorkspaceSessions(state.workspaceSessionId);
286
+ });
287
  }
288
 
289
  async function autoVoiceIngest() {
 
292
  showError("Set a focus or workspace topic before auto-ingest.");
293
  return;
294
  }
295
+ await withRegionLoading($(".voice-rail-controls"), "Auto-ingesting sources…", async () => {
296
+ const data = await callApi("auto_search_ingest", [topic, state.workspaceSessionId]);
297
+ applyVoiceIngestResult(data);
298
+ state.voiceDiscoveredUrls = [];
299
+ state.voiceSelectedUrls = [];
300
+ renderVoiceUrlChoices([], []);
301
+ await refreshWorkspaceSessions(state.workspaceSessionId);
302
+ });
303
  }
304
 
305
  async function ingestVoiceSources() {
306
  const topic = voiceEffectiveTopic();
307
  const pasted = $("#voice-urls-text")?.value.trim() || "";
308
  const selected = getSelectedDiscoveredUrls("#voice-url-choices-list");
 
309
  const files = $("#voice-ingest-file")?.files;
310
+ if (!pasted && !selected.length && !files?.length) {
 
 
 
 
 
311
  showError("Add URLs, select suggested sources, or upload a file — then ingest.");
312
  return;
313
  }
314
+ await withRegionLoading($(".voice-rail-controls"), "Ingesting sources…", async () => {
315
+ const paths = [];
316
+ if (files?.length) {
317
+ for (const file of files) {
318
+ paths.push(await uploadFile(file));
319
+ }
320
+ }
321
+ const data = await callApi("ingest_sources", [
322
+ topic,
323
+ state.workspaceSessionId,
324
+ pasted,
325
+ selected,
326
+ paths,
327
+ ]);
328
+ applyVoiceIngestResult(data);
329
+ if (pasted) $("#voice-urls-text").value = "";
330
+ if (files?.length) $("#voice-ingest-file").value = "";
331
+ await refreshWorkspaceSessions(state.workspaceSessionId);
332
+ });
333
  }
334
 
335
  function syncVoiceModeUi() {
336
  const ragMode = state.voiceMode === "explain" || state.voiceMode === "lesson";
337
+ const practiceMode = state.voiceMode === "pitch";
338
  $("#voice-topic-wrap")?.classList.toggle("hidden", !ragMode);
339
  $("#voice-rag-sources")?.classList.toggle("hidden", !ragMode);
340
+ $(".voice-rag-card")?.classList.toggle("hidden", practiceMode);
341
+ $("#voice-pitch-analysis")?.classList.toggle("hidden", !practiceMode);
342
  const placeholders = {
343
  explain: "e.g. How does finetuning differ from pretraining?",
344
  lesson: "What is the difference between pretraining and finetuning a small model?",
 
361
  if (item && typeof item === "object" && item.role) {
362
  const role = item.role === "user" ? "user" : "assistant";
363
  const label = role === "user" ? "You" : "Teacher";
364
+ let body = renderMarkdownLite(voiceMessageText(item.content));
365
+ if (role === "assistant" && item.rag_references) {
366
+ body += `<div class="voice-rag-refs">${renderMarkdownLite(item.rag_references)}</div>`;
367
+ }
368
  parts.push(
369
  `<div class="research-chat-bubble research-chat-${role}"><div class="research-chat-role">${label}</div><div class="research-chat-body">${body}</div></div>`
370
  );
 
433
  showError("Set a workspace topic before discovering sources.");
434
  return;
435
  }
436
+ await withRegionLoading($(".card-ingest"), "Discovering sources…", async () => {
437
+ const data = await callApi("discover_sources", [topic, state.workspaceSessionId]);
438
+ $("#ingest-status").textContent = stripMd(data.status || "Discovery complete.");
439
+ renderResearchUrlChoices(data.urls || [], data.selected_urls || data.urls || []);
440
+ if (data.session_id) {
441
+ state.workspaceSessionId = data.session_id;
442
+ $("#workspace-session").value = data.session_id;
443
+ }
444
+ setTracePanel("#research-trace-panel", data);
445
+ await refreshWorkspaceSessions(state.workspaceSessionId);
446
+ });
447
  }
448
 
449
  async function discoverSlideSources() {
 
452
  showError("Set a topic before discovering sources.");
453
  return;
454
  }
455
+ await withRegionLoading($(".controls-panel"), "Discovering sources…", async () => {
456
+ const data = await callApi("discover_sources", [topic, state.workspaceSessionId]);
457
+ renderSlideUrlChoices(data.urls || [], data.selected_urls || data.urls || []);
458
+ });
459
  }
460
 
461
  async function autoSearchIngest() {
 
464
  showError("Set a workspace topic before auto-ingest.");
465
  return;
466
  }
467
+ await withRegionLoading($(".card-ingest"), "Auto-ingesting sources…", async () => {
468
+ const data = await callApi("auto_search_ingest", [topic, state.workspaceSessionId]);
469
+ applyIngestResult(data);
470
+ state.discoveredUrls = [];
471
+ state.selectedUrls = [];
472
+ renderResearchUrlChoices([], []);
473
+ await refreshWorkspaceSessions(state.workspaceSessionId);
474
+ });
475
  }
476
 
477
  async function ingestSources({ urlsText = "", selectedUrls = [], pendingFiles = null } = {}) {
 
480
  let selected = selectedUrls;
481
  if (workflow === "select") selected = getSelectedDiscoveredUrls();
482
  const pasted = workflow === "direct" ? urlsText : urlsText || $("#ingest-url").value.trim();
 
483
  const files = pendingFiles || $("#ingest-file").files;
484
+ if (!pasted && !selected.length && !files?.length) {
 
 
 
 
 
 
 
485
  showError("Add URLs, select suggested sources, or upload a file — then ingest.");
486
  return;
487
  }
488
+ await withRegionLoading($(".card-ingest"), "Ingesting sources…", async () => {
489
+ const paths = [];
490
+ if (files?.length) {
491
+ for (const file of files) {
492
+ const b64 = await fileToBase64(file);
493
+ const saved = await callApi("save_upload", [file.name, b64]);
494
+ paths.push(saved.path);
495
+ }
496
+ }
497
+ const data = await callApi("ingest_sources", [
498
+ topic,
499
+ state.workspaceSessionId,
500
+ pasted,
501
+ selected,
502
+ paths,
503
+ ]);
504
+ applyIngestResult(data);
505
+ if (pasted) $("#ingest-url").value = "";
506
+ if (files?.length) $("#ingest-file").value = "";
507
+ await refreshWorkspaceSessions(state.workspaceSessionId);
508
+ });
509
  }
510
 
511
  function renderResearchChat() {
 
557
  return;
558
  }
559
  const docIds = effectiveDocIds([]);
560
+ await withRegionLoading($("#research-chat-panel .card-chat"), "Searching sources…", async () => {
561
+ const data = await callApi("research_chat", [
562
+ question,
563
+ state.workspaceSessionId,
564
+ docIds,
565
+ state.researchChatHistory,
566
+ ]);
567
+ state.researchChatHistory = data.history || [];
568
+ renderResearchChat();
569
+ $("#research-question").value = "";
570
+ $("#research-chat-status").textContent = stripMd(data.rag_hint || "");
571
+ setTracePanel("#research-trace-panel", data);
572
+ updateResearchRagBadge();
573
+ });
574
  }
575
 
576
  async function sendDebugMessage() {
 
584
  const debugDocIds = selectedDebugDocIds();
585
  const workspaceDocIds = selectedWorkspaceDocIds();
586
  const modelKey = $("#debug-model-key")?.value || "";
587
+ await withRegionLoading($(".coach-debug-card"), "Thinking…", async () => {
588
+ const data = await callApi("debug_chat", [
589
+ message,
590
+ state.debugChatHistory,
591
+ useRag,
592
+ debugSession,
593
+ debugDocIds,
594
+ modelKey,
595
+ state.workspaceSessionId,
596
+ workspaceDocIds,
597
+ ]);
598
+ state.debugChatHistory = data.history || [];
599
+ renderDebugChat();
600
+ $("#debug-message").value = "";
601
+ if (data.rag_hint) {
602
+ $("#debug-rag-hint").textContent = stripMd(data.rag_hint);
603
+ }
604
+ setTracePanel("#debug-trace-panel", data);
605
+ });
606
  }
607
 
608
  function effectiveDebugSessionId() {
 
700
  return state.client;
701
  }
702
 
703
+ let globalLoadingSuppress = 0;
704
+
705
  function setLoading(on) {
706
+ if (on && globalLoadingSuppress > 0) return;
707
  $("#studio-loading").classList.toggle("hidden", !on);
708
  }
709
 
710
+ function setRegionLoading(container, on, message = "Working…", { overlayEl = null, hint = "" } = {}) {
711
+ if (!container) return;
712
+ let overlay = overlayEl || container.querySelector(":scope > .region-loading");
713
+ if (!overlay) {
714
+ overlay = document.createElement("div");
715
+ overlay.className = "region-loading hidden";
716
+ overlay.setAttribute("aria-live", "polite");
717
+ overlay.innerHTML = `
718
+ <div class="region-loading-inner">
719
+ <span class="studio-spinner" aria-hidden="true"></span>
720
+ <p class="region-loading-text"></p>
721
+ <p class="region-loading-hint hidden"></p>
722
+ </div>`;
723
+ container.insertBefore(overlay, container.firstChild);
724
+ if (getComputedStyle(container).position === "static") {
725
+ container.classList.add("region-loading-host");
726
+ }
727
+ }
728
+ const textEl =
729
+ overlay.querySelector(".region-loading-text") || overlay.querySelector("#canvas-overlay-text");
730
+ if (textEl) textEl.textContent = message;
731
+ const hintEl =
732
+ overlay.querySelector(".region-loading-hint") || overlay.querySelector(".canvas-overlay-hint");
733
+ if (hintEl) {
734
+ hintEl.textContent = hint;
735
+ hintEl.classList.toggle("hidden", !hint);
736
+ }
737
+ overlay.classList.toggle("hidden", !on);
738
+ container.setAttribute("aria-busy", on ? "true" : "false");
739
+ }
740
+
741
+ async function withRegionLoading(container, message, fn, options = {}) {
742
+ globalLoadingSuppress += 1;
743
+ setRegionLoading(container, true, message, options);
744
+ try {
745
+ return await fn();
746
+ } finally {
747
+ globalLoadingSuppress -= 1;
748
+ setRegionLoading(container, false, message, options);
749
+ }
750
+ }
751
+
752
  function startProgressPanel() {
753
  const panel = $("#progress-panel");
754
  const stepsEl = $("#progress-steps");
 
1062
  const urlsText = $("#slide-urls-text")?.value.trim() || "";
1063
  const selectedUrls = getSelectedDiscoveredUrls("#slide-url-choices-list");
1064
 
1065
+ await withRegionLoading(
1066
+ $("#slide-canvas"),
1067
+ "Generating slides…",
1068
+ async () => {
1069
+ const filePaths = [];
1070
+ const slideFiles = $("#slide-source-files")?.files;
1071
+ if (slideFiles?.length) {
1072
+ for (const file of slideFiles) {
1073
+ filePaths.push(await uploadFile(file));
1074
+ }
1075
+ }
1076
 
1077
+ startProgressPanel();
1078
+ const waitTimer = advanceProgressWhileWaiting();
1079
+ let data;
1080
+ try {
1081
+ data = await callApi("generate_slides", [
1082
+ topic,
1083
+ grade,
1084
+ slideCount,
1085
+ state.workspaceSessionId,
1086
+ useRag,
1087
+ docIds,
1088
+ sourceMode,
1089
+ searchWorkflow,
1090
+ urlsText,
1091
+ selectedUrls,
1092
+ filePaths,
1093
+ ]);
1094
+ } catch (_err) {
1095
+ $("#progress-eta").textContent = "Failed";
1096
+ throw _err;
1097
+ } finally {
1098
+ clearInterval(waitTimer);
1099
+ if (state.progressTimer) {
1100
+ clearInterval(state.progressTimer);
1101
+ state.progressTimer = null;
1102
+ }
1103
+ }
1104
 
1105
+ finishProgressPanel(data);
1106
+ $("#generate-status").textContent = stripMd(data.status || "Slides generated.");
1107
+ const canvasHtml =
1108
+ data.canvas_html ||
1109
+ (data.preview_html ? `<div class="studio-canvas-inner">${data.preview_html}</div>` : "");
1110
+ $("#slide-canvas-content").innerHTML =
1111
+ canvasHtml || '<div class="studio-canvas-empty"><p>Preview unavailable.</p></div>';
1112
+
1113
+ const galleryEl = $("#slide-gallery");
1114
+ if (data.gallery_html) {
1115
+ galleryEl.innerHTML = data.gallery_html;
1116
+ galleryEl.classList.remove("hidden");
1117
+ } else if (data.gallery?.length) {
1118
+ galleryEl.innerHTML = data.gallery
1119
+ .map(
1120
+ (path, i) =>
1121
+ `<a class="studio-gallery-item" href="${fileUrl(path)}" target="_blank" rel="noopener"><img src="${fileUrl(path)}" alt="Slide ${i + 1}" loading="lazy" /></a>`
1122
+ )
1123
+ .join("");
1124
+ galleryEl.classList.remove("hidden");
1125
+ } else {
1126
+ galleryEl.classList.add("hidden");
1127
+ galleryEl.innerHTML = "";
1128
+ }
1129
 
1130
+ state.downloads = data.downloads;
1131
+ const dl = $("#downloads");
1132
+ if (data.downloads?.pptx) {
1133
+ dl.classList.remove("hidden");
1134
+ dl.innerHTML = `
1135
  <a href="${fileUrl(data.downloads.pptx)}" download>PPTX</a>
1136
  <a href="${fileUrl(data.downloads.docx)}" download>DOCX</a>
1137
  <a href="${fileUrl(data.downloads.html)}" download>HTML</a>`;
1138
+ $("#btn-export").disabled = false;
1139
+ }
1140
 
1141
+ const outlineDetails = $("#slide-outline-details");
1142
+ const outlineEl = $("#slide-outline");
1143
+ if (data.outline_md) {
1144
+ outlineEl.innerHTML = renderMarkdownLite(data.outline_md);
1145
+ outlineDetails?.classList.remove("hidden");
1146
+ } else {
1147
+ outlineEl.innerHTML = "";
1148
+ outlineDetails?.classList.add("hidden");
1149
+ }
1150
+ },
1151
+ {
1152
+ overlayEl: $("#canvas-overlay"),
1153
+ hint: "Local CPU models often take 30–90 seconds.",
1154
+ }
1155
+ );
1156
  }
1157
 
1158
  function renderVoiceReply(data, { keepAudio = false } = {}) {
1159
  state.history = data.history ?? state.history;
1160
+ if (data.rag_references && state.history.length) {
1161
+ const last = state.history[state.history.length - 1];
1162
+ if (last && typeof last === "object" && last.role === "assistant") {
1163
+ last.rag_references = data.rag_references;
1164
+ }
1165
+ }
1166
  renderVoiceChat();
1167
  if (data.status) {
1168
  $("#voice-turn-status").textContent = stripMd(data.status);
 
1185
  const useRag = voiceUseRag();
1186
  const docIds = effectiveDocIds([]);
1187
  const language = state.voicePresets?.default_language || "en";
1188
+ await withRegionLoading($(".voice-main-card"), "Teacher is thinking…", async () => {
1189
+ const data = await callApi("teacher_voice_turn", [
1190
+ message,
1191
+ state.voiceMode,
1192
+ topic,
1193
+ state.workspaceSessionId,
1194
+ useRag,
1195
+ state.history,
1196
+ docIds,
1197
+ language,
1198
+ null,
1199
+ ]);
1200
+ $("#voice-message").value = "";
1201
+ renderVoiceReply(data);
1202
+ });
1203
  }
1204
 
1205
  async function sendVoiceAudioTurn(audioPath) {
 
1208
  const docIds = effectiveDocIds([]);
1209
  const language = state.voicePresets?.default_language || "en";
1210
  const asr = state.voicePresets?.default_asr || null;
1211
+ await withRegionLoading($(".voice-main-card"), "Processing voice…", async () => {
1212
+ const data = await callApi("teacher_voice_audio_turn", [
1213
+ audioPath,
1214
+ state.voiceMode,
1215
+ topic,
1216
+ state.workspaceSessionId,
1217
+ useRag,
1218
+ state.history,
1219
+ docIds,
1220
+ language,
1221
+ asr,
1222
+ ]);
1223
+ if (data.user_text) $("#voice-message").value = data.user_text;
1224
+ renderVoiceReply(data);
1225
+ });
1226
  }
1227
 
1228
  async function speakVoiceReply(firstSentenceOnly) {
 
1253
  const language = $("#coach-language")?.value || "en";
1254
  const asr = $("#coach-asr")?.value || null;
1255
  const speakRewrite = $("#coach-speak-rewrite")?.checked || false;
1256
+ await withRegionLoading($("#voice-pitch-analysis"), "Analyzing pitch…", async () => {
1257
+ const data = await callApi("analyze_pitch", [audioPath, language, asr, speakRewrite]);
1258
+ state.lastPitchAnalysis = data;
1259
+ const panel = $("#coach-panel");
1260
+ panel.innerHTML = data.coach_panel_html || "";
1261
+ const discussBtn = document.createElement("button");
1262
+ discussBtn.type = "button";
1263
+ discussBtn.className = "btn btn-secondary voice-discuss-btn";
1264
+ discussBtn.textContent = "Discuss in chat";
1265
+ discussBtn.addEventListener("click", () => discussPitchInChat().catch(() => {}));
1266
+ if (data.transcript_html || data.report_md || data.tip) {
1267
+ panel.appendChild(discussBtn);
1268
+ }
1269
+ });
1270
+ }
1271
+
1272
+ function discussPitchInChat() {
1273
+ const data = state.lastPitchAnalysis;
1274
+ if (!data) return;
1275
+ const parts = [];
1276
+ if (data.tip) parts.push(`Coach tip: ${stripMd(data.tip)}`);
1277
+ if (data.report_md) parts.push(stripMd(data.report_md).slice(0, 800));
1278
+ const prompt =
1279
+ parts.length > 0
1280
+ ? `Here is my pitch analysis. Help me improve based on this feedback:\n\n${parts.join("\n\n")}`
1281
+ : "I just ran pitch analysis — what should I work on next?";
1282
+ $("#voice-message").value = prompt;
1283
+ $("#voice-message").focus();
1284
+ const chat = $("#voice-chat-messages");
1285
+ if (chat) chat.scrollIntoView({ behavior: "smooth", block: "nearest" });
1286
  }
1287
 
1288
  async function analyzePitch() {
 
1402
  $("#btn-open-settings")?.addEventListener("click", openSettingsDrawer);
1403
  $("#btn-close-settings")?.addEventListener("click", closeSettingsDrawer);
1404
  $("#settings-backdrop")?.addEventListener("click", closeSettingsDrawer);
1405
+ $("#theme-toggle")?.addEventListener("change", toggleTheme);
1406
+ $("#theme-toggle-btn")?.addEventListener("click", toggleTheme);
1407
  $("#btn-reload-model")?.addEventListener("click", () => reloadModelFromSettings().catch(() => {}));
1408
 
1409
  $("#btn-open-research-view")?.addEventListener("click", openResearchView);
 
1514
  syncVoiceModeUi();
1515
  });
1516
  });
1517
+
1518
+ syncVoiceModeUi();
1519
  }
1520
 
1521
  bindUi();
libs/agent/src/agent/research_prompts.py CHANGED
@@ -18,6 +18,8 @@ def research_answer_system(skill_body: str, skill_path: Path) -> str:
18
  "Each context block is numbered [1], [2], … — one number per source document.",
19
  "Cite with those numbers only (e.g. [1]). Use at most a few citations per answer.",
20
  "Ignore any [n] markers inside source text; never list citation numbers in a row.",
 
 
21
  skill_body,
22
  ]
23
  if citation_ref:
@@ -33,4 +35,5 @@ Question: {question}
33
 
34
  Write a concise answer with inline [n] citations (one index per source document).
35
  Do not append a References section — it is added automatically.
36
- If context is insufficient, say so."""
 
 
18
  "Each context block is numbered [1], [2], … — one number per source document.",
19
  "Cite with those numbers only (e.g. [1]). Use at most a few citations per answer.",
20
  "Ignore any [n] markers inside source text; never list citation numbers in a row.",
21
+ "Write the final answer directly in 2–4 sentences. Do not use thinking tags, "
22
+ "chain-of-thought, or planning text.",
23
  skill_body,
24
  ]
25
  if citation_ref:
 
35
 
36
  Write a concise answer with inline [n] citations (one index per source document).
37
  Do not append a References section — it is added automatically.
38
+ Do not use redacted_thinking, think tags, or step-by-step planning — only the answer.
39
+ If context is insufficient, say so in one sentence."""
libs/agent/src/agent/runner.py CHANGED
@@ -906,7 +906,13 @@ class AgentRunner:
906
  model=model_key,
907
  user_input=req.model_dump(),
908
  )
 
909
  backend.load()
 
 
 
 
 
910
 
911
  answer_tool = self._tools.get("research_answer")
912
  raw_answer, citations, refs = answer_tool.handler(
@@ -916,6 +922,7 @@ class AgentRunner:
916
  skill_path=skill.path,
917
  session_id=req.session_id,
918
  doc_ids=req.doc_ids or None,
 
919
  )
920
  trace.log_llm(req.question, raw_answer)
921
  trace.log_note(
 
906
  model=model_key,
907
  user_input=req.model_dump(),
908
  )
909
+ load_started = monotonic()
910
  backend.load()
911
+ trace.log_step(
912
+ "load_model",
913
+ "Load language model",
914
+ duration_ms=int((monotonic() - load_started) * 1000),
915
+ )
916
 
917
  answer_tool = self._tools.get("research_answer")
918
  raw_answer, citations, refs = answer_tool.handler(
 
922
  skill_path=skill.path,
923
  session_id=req.session_id,
924
  doc_ids=req.doc_ids or None,
925
+ trace=trace,
926
  )
927
  trace.log_llm(req.question, raw_answer)
928
  trace.log_note(
libs/agent/src/agent/tools/research_tools.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  from pathlib import Path
 
4
  from typing import Any
5
 
6
  from researchmind.citations import Citation, clean_model_answer, format_context_block, format_references
@@ -51,10 +52,12 @@ def tool_research_answer(
51
  skill_path: Path,
52
  session_id: str | None = None,
53
  doc_ids: list[str] | None = None,
 
54
  ) -> tuple[str, list[Citation], str]:
55
  cfg = get_config()
56
  store = get_store()
57
  scope_session, scope_docs = resolve_retrieve_scope(session_id, doc_ids)
 
58
  chunks = retrieve(
59
  question,
60
  store,
@@ -62,6 +65,15 @@ def tool_research_answer(
62
  session_id=scope_session,
63
  doc_ids=scope_docs,
64
  )
 
 
 
 
 
 
 
 
 
65
  if not chunks:
66
  hint = rag_scope_warning(session_id=session_id, doc_ids=doc_ids)
67
  return hint, [], ""
@@ -73,9 +85,17 @@ def tool_research_answer(
73
  {"role": "system", "content": system},
74
  {"role": "user", "content": user},
75
  ]
 
76
  answer = clean_model_answer(
77
- backend.chat(messages, max_tokens=1024, temperature=0.3)
78
  )
 
 
 
 
 
 
 
79
  refs = format_references(citations)
80
  if session_id:
81
  store.add_message(session_id, "user", question, [c.chunk_id for c in citations])
 
1
  from __future__ import annotations
2
 
3
  from pathlib import Path
4
+ from time import monotonic
5
  from typing import Any
6
 
7
  from researchmind.citations import Citation, clean_model_answer, format_context_block, format_references
 
52
  skill_path: Path,
53
  session_id: str | None = None,
54
  doc_ids: list[str] | None = None,
55
+ trace: Any | None = None,
56
  ) -> tuple[str, list[Citation], str]:
57
  cfg = get_config()
58
  store = get_store()
59
  scope_session, scope_docs = resolve_retrieve_scope(session_id, doc_ids)
60
+ retrieve_started = monotonic()
61
  chunks = retrieve(
62
  question,
63
  store,
 
65
  session_id=scope_session,
66
  doc_ids=scope_docs,
67
  )
68
+ if trace is not None:
69
+ trace.log_step(
70
+ "retrieve",
71
+ "Retrieve passages",
72
+ duration_ms=int((monotonic() - retrieve_started) * 1000),
73
+ chunks=len(chunks),
74
+ session_id=scope_session or "",
75
+ doc_ids=scope_docs or [],
76
+ )
77
  if not chunks:
78
  hint = rag_scope_warning(session_id=session_id, doc_ids=doc_ids)
79
  return hint, [], ""
 
85
  {"role": "system", "content": system},
86
  {"role": "user", "content": user},
87
  ]
88
+ generate_started = monotonic()
89
  answer = clean_model_answer(
90
+ backend.chat(messages, max_tokens=512, temperature=0.2)
91
  )
92
+ if trace is not None:
93
+ trace.log_step(
94
+ "generate",
95
+ "Generate cited answer",
96
+ duration_ms=int((monotonic() - generate_started) * 1000),
97
+ citations=len(citations),
98
+ )
99
  refs = format_references(citations)
100
  if session_id:
101
  store.add_message(session_id, "user", question, [c.chunk_id for c in citations])
libs/echocoach/src/echocoach/prompts.py CHANGED
@@ -27,7 +27,7 @@ If a lesson topic is set, stay focused on it. When source excerpts are provided,
27
  PITCH_SYSTEM = """You are TeacherVoice, a supportive public-speaking coach in a live conversation.
28
  Give brief, actionable feedback on what the student just said (opening, clarity, energy, structure).
29
  Do not produce JSON or long reports — speak naturally in 2-4 sentences.
30
- Suggest one concrete improvement for their next attempt. For charts and pace analysis, they can use the EchoCoach tab."""
31
 
32
  _MODE_SYSTEM: dict[TeacherVoiceMode, str] = {
33
  "explain": EXPLAIN_SYSTEM,
 
27
  PITCH_SYSTEM = """You are TeacherVoice, a supportive public-speaking coach in a live conversation.
28
  Give brief, actionable feedback on what the student just said (opening, clarity, energy, structure).
29
  Do not produce JSON or long reports — speak naturally in 2-4 sentences.
30
+ Suggest one concrete improvement for their next attempt. For charts and pace analysis, expand **Deep pitch analysis** below the chat."""
31
 
32
  _MODE_SYSTEM: dict[TeacherVoiceMode, str] = {
33
  "explain": EXPLAIN_SYSTEM,
libs/echocoach/src/echocoach/teacher_voice.py CHANGED
@@ -12,7 +12,7 @@ from agent.trace import TraceRecorder
12
  from inference.base import InferenceBackend
13
  from inference.response_clean import (
14
  needs_teacher_compaction,
15
- prepare_display_reply,
16
  strip_reasoning_output,
17
  )
18
  from researchmind.ingest import IngestPipeline
@@ -161,6 +161,7 @@ def fetch_rag_context(
161
  def _rag_turn_via_agent(
162
  user_text: str,
163
  *,
 
164
  topic: str | None,
165
  session_id: str,
166
  doc_ids: list[str] | None,
@@ -198,8 +199,13 @@ def _rag_turn_via_agent(
198
  research_trace=result.trace_path,
199
  )
200
 
201
- assistant_text = result.answer.strip()
202
- display_reply = prepare_display_reply(assistant_text)
 
 
 
 
 
203
  rag_refs = result.references_markdown or None
204
  return assistant_text, rag_refs, rag_status, display_reply
205
 
@@ -251,25 +257,36 @@ def _compact_teacher_reply(
251
  return compact or seed
252
 
253
 
254
- def _finalize_non_rag_reply(
255
  raw_reply: str,
256
  *,
257
  mode: TeacherVoiceMode,
258
  backend: InferenceBackend,
259
  trace: TraceRecorder,
260
  ) -> tuple[str, str]:
 
261
  assistant_text = strip_reasoning_output(raw_reply).strip()
262
- if needs_teacher_compaction(raw_reply) or not assistant_text:
 
 
 
 
 
 
263
  assistant_text = _compact_teacher_reply(
264
  raw_reply,
265
  mode=mode,
266
  backend=backend,
267
  trace=trace,
268
  )
269
- display_reply = prepare_display_reply(raw_reply)
270
- if needs_teacher_compaction(display_reply):
271
- display_reply = prepare_display_reply(assistant_text)
272
- return assistant_text, display_reply
 
 
 
 
273
 
274
 
275
  def build_teacher_messages(
@@ -320,6 +337,7 @@ def _generate_teacher_reply(
320
  if use_rag and mode in RAG_MODES:
321
  assistant_text, rag_refs, rag_status, display_reply = _rag_turn_via_agent(
322
  user_text,
 
323
  topic=topic,
324
  session_id=session_id,
325
  doc_ids=doc_ids,
@@ -334,8 +352,8 @@ def _generate_teacher_reply(
334
  user_text=user_text,
335
  topic=topic,
336
  )
337
- raw_reply = backend.chat(messages, max_tokens=256, temperature=0.2)
338
- assistant_text, display_reply = _finalize_non_rag_reply(
339
  raw_reply,
340
  mode=mode,
341
  backend=backend,
 
12
  from inference.base import InferenceBackend
13
  from inference.response_clean import (
14
  needs_teacher_compaction,
15
+ reply_ends_complete_sentence,
16
  strip_reasoning_output,
17
  )
18
  from researchmind.ingest import IngestPipeline
 
161
  def _rag_turn_via_agent(
162
  user_text: str,
163
  *,
164
+ mode: TeacherVoiceMode,
165
  topic: str | None,
166
  session_id: str,
167
  doc_ids: list[str] | None,
 
199
  research_trace=result.trace_path,
200
  )
201
 
202
+ raw_answer = strip_references_for_tts(result.answer.strip())
203
+ assistant_text, display_reply = _finalize_voice_reply(
204
+ raw_answer,
205
+ mode=mode,
206
+ backend=backend,
207
+ trace=trace,
208
+ )
209
  rag_refs = result.references_markdown or None
210
  return assistant_text, rag_refs, rag_status, display_reply
211
 
 
257
  return compact or seed
258
 
259
 
260
+ def _finalize_voice_reply(
261
  raw_reply: str,
262
  *,
263
  mode: TeacherVoiceMode,
264
  backend: InferenceBackend,
265
  trace: TraceRecorder,
266
  ) -> tuple[str, str]:
267
+ """Normalize model output into a complete spoken reply and chat display text."""
268
  assistant_text = strip_reasoning_output(raw_reply).strip()
269
+ needs_fix = (
270
+ not assistant_text
271
+ or needs_teacher_compaction(raw_reply)
272
+ or needs_teacher_compaction(assistant_text)
273
+ or not reply_ends_complete_sentence(assistant_text)
274
+ )
275
+ if needs_fix:
276
  assistant_text = _compact_teacher_reply(
277
  raw_reply,
278
  mode=mode,
279
  backend=backend,
280
  trace=trace,
281
  )
282
+ if not reply_ends_complete_sentence(assistant_text):
283
+ assistant_text = _compact_teacher_reply(
284
+ assistant_text or raw_reply,
285
+ mode=mode,
286
+ backend=backend,
287
+ trace=trace,
288
+ )
289
+ return assistant_text, assistant_text
290
 
291
 
292
  def build_teacher_messages(
 
337
  if use_rag and mode in RAG_MODES:
338
  assistant_text, rag_refs, rag_status, display_reply = _rag_turn_via_agent(
339
  user_text,
340
+ mode=mode,
341
  topic=topic,
342
  session_id=session_id,
343
  doc_ids=doc_ids,
 
352
  user_text=user_text,
353
  topic=topic,
354
  )
355
+ raw_reply = backend.chat(messages, max_tokens=512, temperature=0.2)
356
+ assistant_text, display_reply = _finalize_voice_reply(
357
  raw_reply,
358
  mode=mode,
359
  backend=backend,
libs/echocoach/tests/test_teacher_voice.py CHANGED
@@ -6,6 +6,7 @@ import numpy as np
6
  import pytest
7
  import soundfile as sf
8
 
 
9
  from echocoach.prompts import PITCH_SYSTEM, system_prompt_for_mode
10
  from echocoach.teacher_voice import (
11
  RagContext,
@@ -131,7 +132,7 @@ def test_build_teacher_messages_includes_topic_and_rag():
131
 
132
 
133
  def test_pitch_mode_system_prompt():
134
- assert "EchoCoach" in system_prompt_for_mode("pitch")
135
  assert PITCH_SYSTEM == system_prompt_for_mode("pitch")
136
 
137
 
@@ -221,6 +222,7 @@ def test_rag_turn_via_agent_mock(monkeypatch, tmp_path):
221
  trace = TraceRecorder(skill="teacher-voice", model="test", user_input={})
222
  text, refs, status, display = _rag_turn_via_agent(
223
  "How do plants eat?",
 
224
  topic="Photosynthesis",
225
  session_id="",
226
  doc_ids=None,
@@ -251,6 +253,30 @@ def research_env(tmp_path, monkeypatch):
251
  monkeypatch.setenv("AGENT_OUTPUTS_DIR", str(tmp_path / "outputs"))
252
 
253
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  def test_run_teacher_voice_text_turn_mock(monkeypatch, tmp_path):
255
  from echocoach.teacher_voice import run_teacher_voice_text_turn
256
 
 
6
  import pytest
7
  import soundfile as sf
8
 
9
+ from inference.response_clean import reply_ends_complete_sentence
10
  from echocoach.prompts import PITCH_SYSTEM, system_prompt_for_mode
11
  from echocoach.teacher_voice import (
12
  RagContext,
 
132
 
133
 
134
  def test_pitch_mode_system_prompt():
135
+ assert "Deep pitch analysis" in system_prompt_for_mode("pitch")
136
  assert PITCH_SYSTEM == system_prompt_for_mode("pitch")
137
 
138
 
 
222
  trace = TraceRecorder(skill="teacher-voice", model="test", user_input={})
223
  text, refs, status, display = _rag_turn_via_agent(
224
  "How do plants eat?",
225
+ mode="explain",
226
  topic="Photosynthesis",
227
  session_id="",
228
  doc_ids=None,
 
253
  monkeypatch.setenv("AGENT_OUTPUTS_DIR", str(tmp_path / "outputs"))
254
 
255
 
256
+ def test_finalize_voice_reply_compacts_incomplete_sentence():
257
+ from echocoach.teacher_voice import _finalize_voice_reply
258
+ from agent.trace import TraceRecorder
259
+
260
+ class _Backend:
261
+ def chat(self, messages, *, max_tokens=512, temperature=0.2):
262
+ return (
263
+ "Finetuning adapts a pretrained small model to your task using extra labeled data. "
264
+ "You keep most of the base weights and train on a focused dataset. "
265
+ "That usually beats prompting alone for domain-specific work."
266
+ )
267
+
268
+ trace = TraceRecorder(skill="teacher-voice", model="test", user_input={})
269
+ text, display = _finalize_voice_reply(
270
+ "The lesson aims to teach how to fine-tune small",
271
+ mode="lesson",
272
+ backend=_Backend(),
273
+ trace=trace,
274
+ )
275
+ assert reply_ends_complete_sentence(text)
276
+ assert "fine-tune" in text.lower() or "finetun" in text.lower()
277
+ assert text == display
278
+
279
+
280
  def test_run_teacher_voice_text_turn_mock(monkeypatch, tmp_path):
281
  from echocoach.teacher_voice import run_teacher_voice_text_turn
282
 
libs/inference/src/inference/response_clean.py CHANGED
@@ -50,8 +50,10 @@ _COMPLETE_SENTENCE = re.compile(r"[.!?][\"')\]]*\s*$")
50
  _LIST_OUTLINE = re.compile(r"^\d+\.\s", re.MULTILINE)
51
  _REASONING_OPENERS = (
52
  "we need to",
 
53
  "first,",
54
  "first, the",
 
55
  "next,",
56
  "the user",
57
  "let me",
@@ -62,6 +64,11 @@ _REASONING_OPENERS = (
62
  "i should",
63
  "i recall",
64
  )
 
 
 
 
 
65
 
66
 
67
  def _normalize_extracted(text: str) -> str:
@@ -167,6 +174,14 @@ def looks_like_reasoning_only(text: str) -> bool:
167
  return bool(_SENTENCE_PART.search(text) and len(text) > 120)
168
 
169
 
 
 
 
 
 
 
 
 
170
  def needs_teacher_compaction(text: str) -> bool:
171
  cleaned = text.strip()
172
  if not cleaned:
@@ -203,12 +218,31 @@ def prepare_display_reply(text: str) -> str:
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:
@@ -218,6 +252,7 @@ def strip_reasoning_output(text: str) -> str:
218
  return ""
219
 
220
  cleaned = _THINK_BLOCKS.sub("", cleaned).strip()
 
221
  if cleaned and not _THINK_BLOCKS.search(text):
222
  extracted = _extract_best_answer(cleaned)
223
  if extracted:
 
50
  _LIST_OUTLINE = re.compile(r"^\d+\.\s", re.MULTILINE)
51
  _REASONING_OPENERS = (
52
  "we need to",
53
+ "we are given",
54
  "first,",
55
  "first, the",
56
+ "first, let's",
57
  "next,",
58
  "the user",
59
  "let me",
 
64
  "i should",
65
  "i recall",
66
  )
67
+ _THINK_TAG_PAIRS = (
68
+ (_RT_OPEN, _RT_CLOSE),
69
+ (_THINK_OPEN, _THINK_CLOSE),
70
+ ("<thinking>", "</thinking>"),
71
+ )
72
 
73
 
74
  def _normalize_extracted(text: str) -> str:
 
174
  return bool(_SENTENCE_PART.search(text) and len(text) > 120)
175
 
176
 
177
+ def reply_ends_complete_sentence(text: str) -> bool:
178
+ """True when visible reply text ends with sentence-ending punctuation."""
179
+ cleaned = strip_reasoning_output(text).strip()
180
+ if not cleaned:
181
+ return False
182
+ return bool(_COMPLETE_SENTENCE.search(cleaned))
183
+
184
+
185
  def needs_teacher_compaction(text: str) -> bool:
186
  cleaned = text.strip()
187
  if not cleaned:
 
218
  return cleaned
219
 
220
 
221
+ def _strip_unclosed_think_tags(text: str) -> str:
222
+ """Drop thinking blocks that never closed (common when generation hits max_tokens)."""
223
+ cleaned = text.strip()
224
+ if not cleaned:
225
+ return ""
226
+ for open_tag, close_tag in _THINK_TAG_PAIRS:
227
+ lower = cleaned.lower()
228
+ open_lower = open_tag.lower()
229
+ close_lower = close_tag.lower()
230
+ start = lower.find(open_lower)
231
+ if start == -1:
232
+ continue
233
+ if close_lower in lower[start + len(open_tag) :]:
234
+ continue
235
+ cleaned = cleaned[:start].strip()
236
+ return cleaned
237
+
238
+
239
  def strip_thinking_blocks(text: str) -> str:
240
  """Remove chain-of-thought wrapper tags; keep remaining text (e.g. JSON) intact."""
241
  cleaned = text.strip()
242
  if not cleaned:
243
  return ""
244
+ cleaned = _THINK_BLOCKS.sub("", cleaned).strip()
245
+ return _strip_unclosed_think_tags(cleaned)
246
 
247
 
248
  def strip_reasoning_output(text: str) -> str:
 
252
  return ""
253
 
254
  cleaned = _THINK_BLOCKS.sub("", cleaned).strip()
255
+ cleaned = _strip_unclosed_think_tags(cleaned)
256
  if cleaned and not _THINK_BLOCKS.search(text):
257
  extracted = _extract_best_answer(cleaned)
258
  if extracted:
libs/inference/tests/test_response_clean.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
 
3
  from inference.response_clean import (
4
  prepare_display_reply,
 
5
  strip_reasoning_output,
6
  strip_thinking_blocks,
7
  )
@@ -17,6 +18,12 @@ def test_strips_redacted_thinking_block():
17
  assert strip_reasoning_output(raw) == "The capital of France is Paris."
18
 
19
 
 
 
 
 
 
 
20
  def test_strips_think_block():
21
  raw = f"{_THINK_OPEN}\nplanning...\n{_THINK_CLOSE}\n\nAgents use memory [1]."
22
  assert strip_reasoning_output(raw) == "Agents use memory [1]."
@@ -107,6 +114,11 @@ So, three"""
107
  assert "Sentence 1:" not in out
108
 
109
 
 
 
 
 
 
110
  def test_prepare_display_reply_wraps_malformed_think_prefix():
111
  raw = "think> We need to plan the answer.\n\nThe answer is 42."
112
  out = prepare_display_reply(raw)
 
2
 
3
  from inference.response_clean import (
4
  prepare_display_reply,
5
+ reply_ends_complete_sentence,
6
  strip_reasoning_output,
7
  strip_thinking_blocks,
8
  )
 
18
  assert strip_reasoning_output(raw) == "The capital of France is Paris."
19
 
20
 
21
+ def test_strips_unclosed_redacted_thinking_block():
22
+ raw = f"{_RT_OPEN}\nplanning without a final answer that never closes"
23
+ assert strip_reasoning_output(raw) == ""
24
+ assert strip_thinking_blocks(raw) == ""
25
+
26
+
27
  def test_strips_think_block():
28
  raw = f"{_THINK_OPEN}\nplanning...\n{_THINK_CLOSE}\n\nAgents use memory [1]."
29
  assert strip_reasoning_output(raw) == "Agents use memory [1]."
 
114
  assert "Sentence 1:" not in out
115
 
116
 
117
+ def test_reply_ends_complete_sentence():
118
+ assert reply_ends_complete_sentence("Finetuning teaches a small model to specialize.")
119
+ assert not reply_ends_complete_sentence("The lesson aims to teach how to fine-tune small")
120
+
121
+
122
  def test_prepare_display_reply_wraps_malformed_think_prefix():
123
  raw = "think> We need to plan the answer.\n\nThe answer is 42."
124
  out = prepare_display_reply(raw)
libs/researchmind/src/researchmind/citations.py CHANGED
@@ -89,4 +89,4 @@ def clean_model_answer(answer: str) -> str:
89
  "The model returned planning text without a final answer. "
90
  "Try asking again or switch to a non-reasoning model preset."
91
  )
92
- return text
 
89
  "The model returned planning text without a final answer. "
90
  "Try asking again or switch to a non-reasoning model preset."
91
  )
92
+ return text
libs/researchmind/tests/test_citations.py CHANGED
@@ -65,3 +65,11 @@ def test_clean_model_answer_strips_thinking_block():
65
  raw = f"{think_open}\nplan\n{think_close}\n\nAgents use tools and memory [1]."
66
  cleaned = clean_model_answer(raw)
67
  assert cleaned == "Agents use tools and memory [1]."
 
 
 
 
 
 
 
 
 
65
  raw = f"{think_open}\nplan\n{think_close}\n\nAgents use tools and memory [1]."
66
  cleaned = clean_model_answer(raw)
67
  assert cleaned == "Agents use tools and memory [1]."
68
+
69
+
70
+ def test_clean_model_answer_rejects_unclosed_thinking():
71
+ rt_open = "<" + "redacted_thinking" + ">"
72
+ raw = f"{rt_open}\nWe are given a context and need to plan the answer."
73
+ cleaned = clean_model_answer(raw)
74
+ assert "redacted_thinking" not in cleaned
75
+ assert "planning text without a final answer" in cleaned
scripts/benchmark_rag_chat.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Benchmark ResearchMind RAG retrieval and optional full chat latency."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import statistics
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+
13
+ from researchmind.config import get_config
14
+ from researchmind.embeddings import embed_texts
15
+ from researchmind.ingest import IngestPipeline
16
+ from researchmind.retrieve import retrieve
17
+
18
+
19
+ def _load_sessions() -> list[tuple[str, str]]:
20
+ store = IngestPipeline().store
21
+ return [(s.id, s.topic or "Untitled") for s in store.list_sessions()]
22
+
23
+
24
+ def benchmark_retrieve(
25
+ question: str,
26
+ *,
27
+ session_id: str,
28
+ runs: int,
29
+ ) -> dict[str, object]:
30
+ cfg = get_config()
31
+ store = IngestPipeline().store
32
+ chunks_in_scope = store.get_chunks_with_embeddings(session_id=session_id or None)
33
+ timings: list[float] = []
34
+ retrieved = 0
35
+ for _ in range(runs):
36
+ started = time.perf_counter()
37
+ chunks = retrieve(question, store, config=cfg, session_id=session_id or None)
38
+ timings.append((time.perf_counter() - started) * 1000)
39
+ retrieved = len(chunks)
40
+
41
+ warm = timings[1:] if len(timings) > 1 else timings
42
+
43
+ embed_started = time.perf_counter()
44
+ embed_texts(["warmup query"], model_name=cfg.embed_model)
45
+ embed_warm_ms = (time.perf_counter() - embed_started) * 1000
46
+
47
+ return {
48
+ "question": question,
49
+ "session_id": session_id,
50
+ "chunks_in_scope": len(chunks_in_scope),
51
+ "retrieved_chunks": retrieved,
52
+ "top_k": cfg.top_k,
53
+ "max_context_chunks": cfg.max_context_chunks,
54
+ "embed_model": cfg.embed_model,
55
+ "embedder_warm_ms": round(embed_warm_ms, 1),
56
+ "retrieve_ms_cold": round(timings[0], 1) if timings else 0.0,
57
+ "retrieve_ms_mean": round(statistics.mean(warm), 1),
58
+ "retrieve_ms_stdev": round(statistics.stdev(warm), 1) if len(warm) > 1 else 0.0,
59
+ "retrieve_ms_min": round(min(warm), 1),
60
+ "retrieve_ms_max": round(max(warm), 1),
61
+ }
62
+
63
+
64
+ def benchmark_chat(
65
+ question: str,
66
+ *,
67
+ session_id: str,
68
+ model_key: str | None,
69
+ ) -> dict[str, object]:
70
+ from agent.runner import AgentRunner
71
+ from gradio_space.model_loading import ensure_model_loaded, get_active_model_key
72
+ from inference.factory import get_backend
73
+
74
+ key = model_key or get_active_model_key()
75
+ load_err = ensure_model_loaded(key)
76
+ if load_err:
77
+ return {"error": load_err, "model": key}
78
+
79
+ backend = get_backend(key)
80
+ runner = AgentRunner()
81
+ started = time.perf_counter()
82
+ result = runner.run_researchmind_chat(
83
+ question=question,
84
+ session_id=session_id,
85
+ model_key=key,
86
+ backend=backend,
87
+ doc_ids=None,
88
+ )
89
+ total_ms = (time.perf_counter() - started) * 1000
90
+ trace = json.loads(Path(result.trace_path).read_text(encoding="utf-8"))
91
+ steps = [
92
+ {
93
+ "name": step.get("name"),
94
+ "label": step.get("label"),
95
+ "duration_ms": step.get("duration_ms"),
96
+ }
97
+ for step in trace.get("steps", [])
98
+ if step.get("type") == "step"
99
+ ]
100
+ return {
101
+ "model": key,
102
+ "question": question,
103
+ "session_id": session_id,
104
+ "total_ms": round(total_ms, 1),
105
+ "citations": len(result.citations),
106
+ "answer_preview": result.answer[:240],
107
+ "steps": steps,
108
+ "trace_path": result.trace_path,
109
+ }
110
+
111
+
112
+ def main() -> int:
113
+ parser = argparse.ArgumentParser(description="Benchmark ResearchMind RAG chat")
114
+ parser.add_argument(
115
+ "--question",
116
+ default="how we can finetune model",
117
+ help="Question to benchmark",
118
+ )
119
+ parser.add_argument("--session-id", default="", help="Research session id")
120
+ parser.add_argument("--runs", type=int, default=5, help="Retrieve benchmark repetitions")
121
+ parser.add_argument(
122
+ "--full-chat",
123
+ action="store_true",
124
+ help="Also run one full RAG chat (loads local LLM)",
125
+ )
126
+ parser.add_argument("--model-key", default="", help="Override ACTIVE_MODEL preset")
127
+ args = parser.parse_args()
128
+
129
+ sessions = _load_sessions()
130
+ session_id = args.session_id.strip()
131
+ if not session_id:
132
+ session_id = sessions[0][0] if sessions else ""
133
+
134
+ if not session_id:
135
+ print("No indexed session found. Ingest sources first.")
136
+ return 1
137
+
138
+ retrieve_report = benchmark_retrieve(
139
+ args.question,
140
+ session_id=session_id,
141
+ runs=max(1, args.runs),
142
+ )
143
+ print(json.dumps({"retrieve": retrieve_report}, indent=2))
144
+
145
+ if args.full_chat:
146
+ chat_report = benchmark_chat(
147
+ args.question,
148
+ session_id=session_id,
149
+ model_key=args.model_key or None,
150
+ )
151
+ print(json.dumps({"chat": chat_report}, indent=2))
152
+ return 0
153
+
154
+
155
+ if __name__ == "__main__":
156
+ sys.exit(main())