fix: the session owns the menu button, and study loses the exam's review
Four things, all from one screenshot pair. The Review button in a study session was inherited from the exam player. Reviewing a block before handing it in is an exam idea; a study session has nothing to hand in — it keeps going until every question is answered and at that point it *is* the review. The review link, the top-bar button and the rail button are exam-only now, and a study session whose questions are all answered says "Finish session" and submits rather than opening a dialog to ask a second time. The drawer's "Qbank" pointed at /questions, which has never been a route — /questions/:id is the editor. It went nowhere. It points at /question-bank, and Collections and AI Mode join the list. While a session is open on a narrow screen, the navbar burger now opens that session's questions instead of the site menu, which is a tab inside the same drawer. Two menu buttons an inch apart, one of which leaves the session you are sitting, is the wrong offer. The player claims the button only while it has no rail, and hands it back when it leaves. And the drawer says what AMBOSS's does: a Review badge once everything is answered, the mode in the title, a progress bar under the count, and the session and question clocks pinned beneath the list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
1c1f327d4a
commit
56ecbb0889
12 changed files with 525 additions and 28 deletions
|
|
@ -1,6 +1,8 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -11,7 +13,7 @@ from app.database import get_db
|
|||
from app.models.user import User
|
||||
from app.models.ai_model_config import AIModelConfig
|
||||
from app.models.invite import InviteCode
|
||||
from app.services import invites, site_settings
|
||||
from app.services import ai_service, invites, site_settings
|
||||
from app.schemas.auth import UserResponse, UserUpdateRole, UserCreate
|
||||
from app.schemas.admin import AIModelConfigCreate, AIModelConfigResponse, AIModelConfigUpdate
|
||||
from app.utils.auth import require_admin, get_current_user, get_password_hash
|
||||
|
|
@ -146,6 +148,13 @@ class LiteLLMSearchRequest(BaseModel):
|
|||
mode: str | None = None
|
||||
|
||||
|
||||
#: Short, unambiguous, and clinical enough that a medical model has no excuse.
|
||||
#: Used to make a voice speak and a transcriber listen.
|
||||
TEST_PHRASE = "Inspiratory stridor at rest."
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _proxy_models(base: str, key: str | None, mode: str | None = None) -> tuple[list[str], bool]:
|
||||
"""What the proxy will serve, and whether the answer knows about modes.
|
||||
|
||||
|
|
@ -308,10 +317,17 @@ def test_model(
|
|||
raise HTTPException(status_code=404, detail="Model config not found")
|
||||
|
||||
if model.task == "tts":
|
||||
# Not a failure. A voice is tested by hearing it, and the page offers
|
||||
# that; saying so in red taught administrators to distrust a working
|
||||
# configuration.
|
||||
return {"message": f"{model.model_id} is a voice — press Preview to hear it."}
|
||||
# A voice is tested by making it speak. The old answer was an
|
||||
# instruction ("press Preview") returned as an error, which taught
|
||||
# administrators to distrust a working configuration.
|
||||
try:
|
||||
audio = ai_service.generate_tts_audio(
|
||||
TEST_PHRASE, model_id=model.model_id, api_key=model.api_key or None)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=str(e)[:300])
|
||||
if not audio:
|
||||
raise HTTPException(status_code=502, detail=f"{model.model_id} returned no audio")
|
||||
return {"message": f"✓ {model.model_id} spoke {len(audio):,} bytes of audio"}
|
||||
|
||||
if model.task == "stt":
|
||||
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
|
||||
|
|
@ -325,15 +341,33 @@ def test_model(
|
|||
status_code=404,
|
||||
detail=f"{model.model_id} is not served by the proxy"
|
||||
+ (" as a transcription model" if by_mode else ""))
|
||||
return {"message": f"✓ {model.model_id} is served by the proxy" + (
|
||||
" for speech transcription" if by_mode
|
||||
# The route that says what a model is for is not open to this
|
||||
# key, so this is presence, not suitability.
|
||||
else " — the proxy would not say whether it transcribes")}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
raise HTTPException(status_code=502, detail=str(e)[:300])
|
||||
|
||||
# Presence is not proof. A voice we already have says a known phrase,
|
||||
# and the model is asked what it heard — the only test that shows
|
||||
# transcription actually working end to end.
|
||||
spoken = None
|
||||
try:
|
||||
tts_id, tts_key = ai_service.get_model_for_task(db, "tts")
|
||||
if tts_id:
|
||||
spoken = ai_service.generate_tts_audio(TEST_PHRASE, model_id=tts_id, api_key=tts_key)
|
||||
except Exception:
|
||||
log.warning("Could not synthesise audio to test %s", model.model_id, exc_info=True)
|
||||
if not spoken:
|
||||
return {"message": f"✓ {model.model_id} is served by the proxy. No voice is "
|
||||
"configured, so it could not be given anything to hear."}
|
||||
try:
|
||||
heard = ai_service.transcribe_audio(
|
||||
spoken, filename="test.mp3", content_type="audio/mpeg",
|
||||
model_id=model.model_id, api_key=model.api_key or None)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=str(e)[:300])
|
||||
if not heard:
|
||||
raise HTTPException(status_code=502, detail=f"{model.model_id} heard nothing")
|
||||
return {"message": f"✓ {model.model_id} heard “{heard.strip()}”"}
|
||||
|
||||
try:
|
||||
import litellm
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Suspense } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate, Outlet, Link, useLocation, useParams } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import { SessionDrawerProvider } from './context/SessionDrawer'
|
||||
import { ThemeProvider } from './context/ThemeContext'
|
||||
import Navbar from './components/Navbar'
|
||||
import SiteFooter from './components/SiteFooter'
|
||||
|
|
@ -238,7 +239,9 @@ export default function App() {
|
|||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<SessionDrawerProvider>
|
||||
<AppRoutes />
|
||||
</SessionDrawerProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||
import { Link, useLocation } from 'react-router-dom'
|
||||
import ScrollStrip from './ScrollStrip'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useSessionDrawer } from '../context/SessionDrawer'
|
||||
import api from '../api/client'
|
||||
import ExamSwitcher from './ExamSwitcher'
|
||||
import GlobalSearch from './GlobalSearch'
|
||||
|
|
@ -185,6 +186,8 @@ export default function Navbar({ onSignIn, onRegister }) {
|
|||
// The section bar is wanted when you decide to go elsewhere and in the way
|
||||
// every other moment, so it leaves on the way down and returns on the way up.
|
||||
const sectionBarHidden = useHidingBar()
|
||||
// Registered by the quiz player while it is on screen without a rail.
|
||||
const sessionDrawer = useSessionDrawer()
|
||||
const [jobs, setJobs] = useState([])
|
||||
const location = useLocation()
|
||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||
|
|
@ -258,11 +261,16 @@ export default function Navbar({ onSignIn, onRegister }) {
|
|||
<FeedbackBadge />
|
||||
<JobsBadge jobs={jobs} />
|
||||
<AccountMenu user={user} onLogout={logout} />
|
||||
{/* While a session is open on a narrow screen, this button
|
||||
belongs to the session: it opens the list of questions, and
|
||||
the site menu is a tab inside that drawer. Two menu buttons
|
||||
an inch apart, one of which leaves the session you are
|
||||
sitting, is the wrong offer. */}
|
||||
<button
|
||||
className="nav-burger"
|
||||
onClick={() => setMenuOpen(v => !v)}
|
||||
aria-label="Menu"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => (sessionDrawer.opener ? sessionDrawer.opener() : setMenuOpen(v => !v))}
|
||||
aria-label={sessionDrawer.opener ? 'Session menu' : 'Menu'}
|
||||
aria-expanded={sessionDrawer.opener ? undefined : menuOpen}
|
||||
>
|
||||
<span style={{
|
||||
display: 'block', width: 22, height: 2, background: 'currentColor',
|
||||
|
|
|
|||
38
frontend/src/context/SessionDrawer.jsx
Normal file
38
frontend/src/context/SessionDrawer.jsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* While a session is open, the menu button belongs to the session.
|
||||
*
|
||||
* On a narrow screen the quiz player has no rail, so the list of questions
|
||||
* lives behind the same burger that would otherwise open the site menu. Two
|
||||
* menu buttons an inch apart, one of which leaves the session you are sitting,
|
||||
* is the wrong offer — so the player takes the button over while it is on
|
||||
* screen and hands back the site menu as a tab inside its own drawer.
|
||||
*
|
||||
* The player registers an opener; the navbar asks whether one exists. Nothing
|
||||
* else needs to know.
|
||||
*/
|
||||
const Context = createContext({ opener: null, register: () => () => {} })
|
||||
|
||||
export function SessionDrawerProvider({ children }) {
|
||||
const [opener, setOpener] = useState(null)
|
||||
const current = useRef(null)
|
||||
|
||||
const register = useCallback((fn) => {
|
||||
current.current = fn
|
||||
setOpener(() => fn)
|
||||
return () => {
|
||||
// Only the registration still in force may clear it: a player unmounting
|
||||
// after another has claimed the button must not take it away.
|
||||
if (current.current === fn) {
|
||||
current.current = null
|
||||
setOpener(null)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const value = useMemo(() => ({ opener, register }), [opener, register])
|
||||
return <Context.Provider value={value}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
export const useSessionDrawer = () => useContext(Context)
|
||||
47
frontend/src/context/SessionDrawer.test.jsx
Normal file
47
frontend/src/context/SessionDrawer.test.jsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useEffect } from 'react'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { SessionDrawerProvider, useSessionDrawer } from './SessionDrawer'
|
||||
|
||||
/** Stands in for the navbar burger. */
|
||||
function Burger() {
|
||||
const { opener } = useSessionDrawer()
|
||||
return (
|
||||
<button onClick={() => (opener ? opener() : document.body.setAttribute('data-site-menu', 'open'))}>
|
||||
{opener ? 'Session menu' : 'Menu'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Stands in for the quiz player. */
|
||||
function Player({ claim, onOpen }) {
|
||||
const { register } = useSessionDrawer()
|
||||
useEffect(() => (claim ? register(onOpen) : undefined), [claim, register, onOpen])
|
||||
return null
|
||||
}
|
||||
|
||||
it('hands the button to a session that claims it, and back when it leaves', async () => {
|
||||
const onOpen = vi.fn()
|
||||
const { rerender } = render(
|
||||
<SessionDrawerProvider><Burger /><Player claim onOpen={onOpen} /></SessionDrawerProvider>)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Session menu' }))
|
||||
expect(onOpen).toHaveBeenCalled()
|
||||
expect(document.body.dataset.siteMenu).toBeUndefined()
|
||||
|
||||
// The player leaves; the site menu is the button's job again.
|
||||
rerender(<SessionDrawerProvider><Burger /></SessionDrawerProvider>)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Menu' }))
|
||||
expect(document.body.dataset.siteMenu).toBe('open')
|
||||
document.body.removeAttribute('data-site-menu')
|
||||
})
|
||||
|
||||
it('a player that never claims it leaves the site menu alone', async () => {
|
||||
const onOpen = vi.fn()
|
||||
render(<SessionDrawerProvider><Burger /><Player claim={false} onOpen={onOpen} /></SessionDrawerProvider>)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Menu' }))
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
expect(document.body.dataset.siteMenu).toBe('open')
|
||||
document.body.removeAttribute('data-site-menu')
|
||||
})
|
||||
131
frontend/src/hooks/useDictation.js
Normal file
131
frontend/src/hooks/useDictation.js
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
|
||||
/**
|
||||
* Speaking instead of typing.
|
||||
*
|
||||
* Two ways to do it, and the browser's own is tried first: `SpeechRecognition`
|
||||
* transcribes as you talk, costs nothing, and needs no round trip. Where it is
|
||||
* missing — Firefox, and every browser on iOS that is not Safari — the audio
|
||||
* is recorded and sent to our own transcriber instead, which is slower but
|
||||
* works everywhere a microphone does.
|
||||
*
|
||||
* The fallback is also taken when the browser's recogniser fails mid-sentence,
|
||||
* because a half-finished dictation is worse than a slow one.
|
||||
*/
|
||||
|
||||
/** Read when asked, not when this module loads: a constant captured at import
|
||||
time cannot be seen to be right or wrong. */
|
||||
const recognition = () => (typeof window === 'undefined'
|
||||
? null
|
||||
: window.SpeechRecognition || window.webkitSpeechRecognition || null)
|
||||
|
||||
/** Recording needs both halves; iOS Safari shipped one without the other for
|
||||
years, and a button that throws is worse than a button that is not there. */
|
||||
const canRecord = () => typeof window !== 'undefined'
|
||||
&& typeof window.MediaRecorder !== 'undefined'
|
||||
&& Boolean(navigator?.mediaDevices?.getUserMedia)
|
||||
|
||||
/** Whether anything here can work at all. */
|
||||
export const canDictate = () => Boolean(recognition()) || canRecord()
|
||||
|
||||
export default function useDictation({ onText, onError } = {}) {
|
||||
const [listening, setListening] = useState(false)
|
||||
const [transcribing, setTranscribing] = useState(false)
|
||||
const recogniser = useRef(null)
|
||||
const recorder = useRef(null)
|
||||
const chunks = useRef([])
|
||||
const stream = useRef(null)
|
||||
const heard = useRef('')
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
stream.current?.getTracks().forEach(track => track.stop())
|
||||
stream.current = null
|
||||
recorder.current = null
|
||||
recogniser.current = null
|
||||
setListening(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => cleanup, [cleanup])
|
||||
|
||||
/** Record, then send it to our own transcriber. */
|
||||
const record = useCallback(async () => {
|
||||
if (!canRecord()) {
|
||||
onError?.('This browser cannot record audio. Try typing instead.')
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
try {
|
||||
stream.current = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
} catch {
|
||||
onError?.('No microphone. Check the permission for this site.')
|
||||
return
|
||||
}
|
||||
chunks.current = []
|
||||
const media = new MediaRecorder(stream.current)
|
||||
recorder.current = media
|
||||
media.ondataavailable = event => { if (event.data.size) chunks.current.push(event.data) }
|
||||
media.onstop = async () => {
|
||||
const blob = new Blob(chunks.current, { type: media.mimeType || 'audio/webm' })
|
||||
cleanup()
|
||||
if (!blob.size) return
|
||||
setTranscribing(true)
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', blob, 'dictation.webm')
|
||||
const res = await api.post('/tts/transcribe', form)
|
||||
if (res.data?.text) onText?.(res.data.text)
|
||||
} catch (err) {
|
||||
const detail = err?.response?.data?.detail
|
||||
onError?.(typeof detail === 'string' ? detail : 'Could not transcribe that')
|
||||
} finally {
|
||||
setTranscribing(false)
|
||||
}
|
||||
}
|
||||
media.start()
|
||||
setListening(true)
|
||||
}, [cleanup, onError, onText])
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (listening || transcribing) return
|
||||
const Engine = recognition()
|
||||
if (!Engine) { await record(); return }
|
||||
|
||||
heard.current = ''
|
||||
const engine = new Engine()
|
||||
engine.lang = navigator.language || 'en-US'
|
||||
engine.interimResults = false
|
||||
engine.continuous = true
|
||||
engine.onresult = (event) => {
|
||||
for (let i = event.resultIndex; i < event.results.length; i += 1) {
|
||||
if (event.results[i].isFinal) heard.current += event.results[i][0].transcript
|
||||
}
|
||||
}
|
||||
engine.onerror = (event) => {
|
||||
cleanup()
|
||||
// Refused permission is the learner's answer, not a fault to route around.
|
||||
if (event.error === 'not-allowed' || event.error === 'service-not-allowed') {
|
||||
onError?.('No microphone. Check the permission for this site.')
|
||||
return
|
||||
}
|
||||
if (canRecord()) record()
|
||||
else onError?.('Could not hear anything. Try typing instead.')
|
||||
}
|
||||
engine.onend = () => {
|
||||
setListening(false)
|
||||
const text = heard.current.trim()
|
||||
if (text) onText?.(text)
|
||||
}
|
||||
recogniser.current = engine
|
||||
engine.start()
|
||||
setListening(true)
|
||||
}, [cleanup, listening, onError, onText, record, transcribing])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (recogniser.current) { recogniser.current.stop(); return }
|
||||
if (recorder.current?.state === 'recording') recorder.current.stop()
|
||||
else cleanup()
|
||||
}, [cleanup])
|
||||
|
||||
return { listening, transcribing, start, stop, toggle: () => (listening ? stop() : start()) }
|
||||
}
|
||||
112
frontend/src/hooks/useDictation.test.jsx
Normal file
112
frontend/src/hooks/useDictation.test.jsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import useDictation, { canDictate } from './useDictation'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { post: vi.fn() } }))
|
||||
|
||||
function Harness({ onError }) {
|
||||
const { listening, transcribing, toggle } = useDictation({
|
||||
onText: text => { document.title = text }, onError,
|
||||
})
|
||||
return (
|
||||
<button onClick={toggle}>
|
||||
{transcribing ? 'transcribing' : listening ? 'listening' : 'idle'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
let engines = []
|
||||
|
||||
const withBrowserRecogniser = () => {
|
||||
engines = []
|
||||
window.SpeechRecognition = function Recogniser() {
|
||||
const self = this
|
||||
self.start = vi.fn(() => { engines.push(self) })
|
||||
self.stop = vi.fn(() => self.onend?.())
|
||||
engines.push(self)
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
const withMediaRecorderOnly = () => {
|
||||
delete window.SpeechRecognition
|
||||
delete window.webkitSpeechRecognition
|
||||
const track = { stop: vi.fn() }
|
||||
navigator.mediaDevices = { getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [track] }) }
|
||||
window.MediaRecorder = function Recorder() {
|
||||
const self = this
|
||||
self.state = 'recording'
|
||||
self.mimeType = 'audio/webm'
|
||||
self.start = vi.fn()
|
||||
self.stop = vi.fn(() => {
|
||||
self.ondataavailable?.({ data: new Blob(['x'], { type: 'audio/webm' }) })
|
||||
self.onstop?.()
|
||||
})
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.clearAllMocks(); document.title = '' })
|
||||
afterEach(() => {
|
||||
delete window.SpeechRecognition
|
||||
delete window.webkitSpeechRecognition
|
||||
delete window.MediaRecorder
|
||||
delete navigator.mediaDevices
|
||||
})
|
||||
|
||||
it('uses the browser recogniser when there is one, and never calls the server', async () => {
|
||||
withBrowserRecogniser()
|
||||
render(<Harness />)
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
expect(screen.getByRole('button')).toHaveTextContent('listening')
|
||||
|
||||
const engine = engines[engines.length - 1]
|
||||
engine.onresult({ resultIndex: 0, results: [{ isFinal: true, 0: { transcript: 'What causes stridor' } }] })
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
await waitFor(() => expect(document.title).toBe('What causes stridor'))
|
||||
// Free and local: nothing was uploaded.
|
||||
expect(api.post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records and sends to our own transcriber where the browser has none', async () => {
|
||||
withMediaRecorderOnly()
|
||||
api.post.mockResolvedValue({ data: { text: 'A neonate with bilious vomiting' } })
|
||||
render(<Harness />)
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
await waitFor(() => expect(screen.getByRole('button')).toHaveTextContent('listening'))
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/tts/transcribe', expect.any(FormData)))
|
||||
await waitFor(() => expect(document.title).toBe('A neonate with bilious vomiting'))
|
||||
})
|
||||
|
||||
it('falls back to recording when the browser recogniser fails mid-sentence', async () => {
|
||||
withBrowserRecogniser()
|
||||
const track = { stop: vi.fn() }
|
||||
navigator.mediaDevices = { getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [track] }) }
|
||||
window.MediaRecorder = function Recorder() { this.start = vi.fn(); this.state = 'recording'; return this }
|
||||
render(<Harness />)
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
engines[engines.length - 1].onerror({ error: 'network' })
|
||||
// A half-finished dictation is worse than a slow one.
|
||||
await waitFor(() => expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('a refused microphone is an answer, not a fault to route around', async () => {
|
||||
withBrowserRecogniser()
|
||||
navigator.mediaDevices = { getUserMedia: vi.fn() }
|
||||
const onError = vi.fn()
|
||||
render(<Harness onError={onError} />)
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
engines[engines.length - 1].onerror({ error: 'not-allowed' })
|
||||
expect(onError).toHaveBeenCalledWith('No microphone. Check the permission for this site.')
|
||||
expect(navigator.mediaDevices.getUserMedia).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers nothing at all where neither exists', () => {
|
||||
delete window.SpeechRecognition
|
||||
delete window.webkitSpeechRecognition
|
||||
delete navigator.mediaDevices
|
||||
expect(canDictate()).toBe(false)
|
||||
})
|
||||
|
|
@ -104,3 +104,23 @@
|
|||
}
|
||||
.ai-practise:hover:not(:disabled) { background: var(--option-sel-bg); }
|
||||
.ai-practise:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
/* Speaking instead of typing. Red while it is listening, because a microphone
|
||||
you have forgotten is on is the one thing this must never be. */
|
||||
.ai-mic {
|
||||
flex-shrink: 0; width: 42px; height: 42px; font-size: 1rem; cursor: pointer;
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
background: var(--card-bg); color: var(--text-muted);
|
||||
}
|
||||
.ai-mic:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); }
|
||||
.ai-mic.is-live {
|
||||
background: var(--wrong-bg); border-color: var(--wrong-fg); color: var(--wrong-fg);
|
||||
animation: ai-mic-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.ai-mic:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
@keyframes ai-mic-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.35); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(220, 38, 38, 0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .ai-mic.is-live { animation: none; } }
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
|
|||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import api from '../api/client'
|
||||
import useDictation, { canDictate } from '../hooks/useDictation'
|
||||
import './AiModePage.css'
|
||||
|
||||
const apiError = (err, fallback) => {
|
||||
|
|
@ -86,6 +87,12 @@ export default function AiModePage() {
|
|||
const [practising, setPractising] = useState(null)
|
||||
const endRef = useRef(null)
|
||||
const navigate = useNavigate()
|
||||
// Speaking instead of typing. The browser's own recogniser where there is
|
||||
// one, our transcriber where there is not.
|
||||
const dictation = useDictation({
|
||||
onText: text => setDraft(d => (d ? `${d} ${text}` : text)),
|
||||
onError: setError,
|
||||
})
|
||||
|
||||
const loadThreads = useCallback(() => api.get('/ai/conversations')
|
||||
.then(res => { setThreads(res.data || []); return res.data || [] })
|
||||
|
|
@ -236,6 +243,15 @@ export default function AiModePage() {
|
|||
placeholder="Ask about anything in your library…"
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) send(e) }} />
|
||||
{canDictate() && (
|
||||
<button type="button" className={`ai-mic${dictation.listening ? ' is-live' : ''}`}
|
||||
disabled={dictation.transcribing}
|
||||
aria-pressed={dictation.listening}
|
||||
aria-label={dictation.listening ? 'Stop dictating' : 'Dictate your question'}
|
||||
onClick={dictation.toggle}>
|
||||
{dictation.transcribing ? '…' : dictation.listening ? '■' : '🎤'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-primary" type="submit" disabled={sending || !draft.trim()}>
|
||||
{sending ? 'Asking…' : 'Ask'}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useAuth } from '../context/AuthContext'
|
|||
import api from '../api/client'
|
||||
import useMediaQuery from '../hooks/useMediaQuery'
|
||||
import useAwayDetector from '../hooks/useAwayDetector'
|
||||
import { useSessionDrawer } from '../context/SessionDrawer'
|
||||
import FigureStrip from '../components/FigureStrip'
|
||||
import FeedbackForm from '../components/FeedbackForm'
|
||||
import ShareSession from '../components/ShareSession'
|
||||
|
|
@ -412,7 +413,18 @@ export default function QuizPage() {
|
|||
// The session rail is the navigator whenever there is room for it; the
|
||||
// dropdown only exists for screens too narrow to show it. Matches the
|
||||
// 1150px breakpoint in QuizPlayer.css that hides the rail.
|
||||
const sessionDrawer = useSessionDrawer()
|
||||
const hasRail = useMediaQuery('(min-width: 1151px)')
|
||||
|
||||
// The site's burger opens this session's questions while there is no rail.
|
||||
// Only while there is no rail: on a desktop the list is already beside the
|
||||
// question, and taking the button over there would hide the site menu for
|
||||
// no reason.
|
||||
const { register: registerDrawer } = sessionDrawer
|
||||
useEffect(() => {
|
||||
if (hasRail) return undefined
|
||||
return registerDrawer(() => { setDrawerTab('questions'); setNavOpen(true) })
|
||||
}, [hasRail, registerDrawer])
|
||||
const [expandedImagePath, setExpandedImagePath] = useState('')
|
||||
const [imageZoom, setImageZoom] = useState(1)
|
||||
const [startedAt, setStartedAt] = useState(null)
|
||||
|
|
@ -1227,11 +1239,15 @@ const timerStarted = timeLeft !== null
|
|||
)}
|
||||
|
||||
{isLast ? (
|
||||
// The end of the block. The dialog it opens names how many are still
|
||||
// unanswered and that they count against you, which is the warning
|
||||
// worth giving before anything is handed in.
|
||||
<button className="btn btn-primary" onClick={() => setShowReview(true)} disabled={submitting}>
|
||||
{isStudy ? 'Review & Complete' : 'End block'}
|
||||
// The end of the block. In an exam the dialog names how many are still
|
||||
// unanswered before anything is handed in — the warning worth giving.
|
||||
// A study session with everything answered has nothing left to warn
|
||||
// about, so it finishes rather than asking twice.
|
||||
<button className="btn btn-primary" disabled={submitting}
|
||||
onClick={() => (isStudy && answeredCount >= totalCount
|
||||
? handleSubmit(false)
|
||||
: setShowReview(true))}>
|
||||
{isStudy ? 'Finish session' : 'End block'}
|
||||
</button>
|
||||
) : (
|
||||
/* Skip, when nothing has been chosen. Moving on from a question you
|
||||
|
|
@ -1417,8 +1433,10 @@ const timerStarted = timeLeft !== null
|
|||
<div className="quiz-drawer" onClick={e => e.target === e.currentTarget && setNavOpen(false)}>
|
||||
<div className="quiz-drawer-panel" role="dialog" aria-modal="true" aria-label="Session">
|
||||
<div className="quiz-drawer-head">
|
||||
{/* The same burger that opened it closes it: on a phone this
|
||||
drawer is what that button does while a session is open. */}
|
||||
<button type="button" className="quiz-drawer-close" aria-label="Close"
|
||||
onClick={() => setNavOpen(false)}>✕</button>
|
||||
onClick={() => setNavOpen(false)}>☰</button>
|
||||
<div className="quiz-drawer-tabs" role="tablist">
|
||||
<button type="button" role="tab" aria-selected={drawerTab === 'menu'}
|
||||
onClick={() => setDrawerTab('menu')}>Main menu</button>
|
||||
|
|
@ -1430,17 +1448,35 @@ const timerStarted = timeLeft !== null
|
|||
{drawerTab === 'questions' ? (
|
||||
<>
|
||||
<div className="quiz-drawer-title">
|
||||
<strong>{quiz.title}</strong>
|
||||
{/* Everything answered means there is nothing left to sit;
|
||||
what you are doing now is reading it back. */}
|
||||
{answeredCount >= totalCount && totalCount > 0 && (
|
||||
<span className="quiz-drawer-badge">Review</span>
|
||||
)}
|
||||
<strong>{isStudy ? 'Study mode' : 'Exam mode'}: {quiz.title}</strong>
|
||||
<small>{answeredCount}/{totalCount}</small>
|
||||
<span className="quiz-drawer-bar" aria-hidden="true">
|
||||
<span style={{ width: `${totalCount ? (answeredCount / totalCount) * 100 : 0}%` }} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="quiz-rail-list quiz-drawer-list">
|
||||
{questions.map((q, i) => <QuestionRailItem key={q.id} q={q} i={i} />)}
|
||||
</div>
|
||||
{/* How long this is taking, where it is being read — the same
|
||||
two figures the desktop shows beside the explanation. */}
|
||||
<div className="quiz-drawer-foot">
|
||||
<SessionClock sessionSeconds={sessionSeconds} questionSeconds={questionSeconds}
|
||||
answered={answeredCount} paused={clockPaused}
|
||||
onTogglePause={() => setClockPaused(v => !v)} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<nav className="quiz-drawer-menu" aria-label="Main menu">
|
||||
{/* Qbank pointed at /questions, which has never been a route —
|
||||
/questions/:id is the editor. It went nowhere. */}
|
||||
{[
|
||||
['/', 'Dashboard'], ['/sessions', 'Sessions'], ['/questions', 'Qbank'],
|
||||
['/', 'Dashboard'], ['/sessions', 'Sessions'], ['/question-bank', 'Qbank'],
|
||||
['/collections', 'Collections'], ['/ai', 'AI Mode'],
|
||||
['/study-plans', 'Study plans'], ['/articles', 'Reading'],
|
||||
['/flashcards', 'Cards'], ['/settings', 'Settings'],
|
||||
].map(([to, label]) => (
|
||||
|
|
@ -1473,7 +1509,10 @@ const timerStarted = timeLeft !== null
|
|||
<button type="button" title="Keyboard shortcuts" aria-label="Keyboard shortcuts" onClick={() => setTool('shortcuts')}>⌨ <span>Shortcuts</span></button>
|
||||
<button type="button" title="Calculator" aria-label="Calculator" onClick={() => setTool('calculator')}>▦ <span>Calculator</span></button>
|
||||
<button type="button" title="Lab values" aria-label="Lab values" onClick={() => setTool('labs')}>⚗ <span>Lab values</span></button>
|
||||
<button type="button" className="quiz-review-button" onClick={() => setShowReview(true)}>Review & Complete</button>
|
||||
{!isStudy && (
|
||||
<button type="button" className="quiz-review-button"
|
||||
onClick={() => setShowReview(true)}>Review & Complete</button>
|
||||
)}
|
||||
<button type="button" aria-label="Previous question" disabled={currentIdx === 0} onClick={() => safeNavigate(currentIdx - 1)}>‹</button>
|
||||
<button type="button" aria-label="Next question" disabled={isLast} onClick={() => safeNavigate(currentIdx + 1)}>Next ›</button>
|
||||
</div>
|
||||
|
|
@ -1821,7 +1860,7 @@ const timerStarted = timeLeft !== null
|
|||
<span><span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', background: 'var(--correct-bg)', border: '1px solid var(--correct-bd)', marginRight: 4 }} />{answeredCount} answered</span>
|
||||
<span><span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', background: 'var(--border)', marginRight: 4 }} />{totalCount - answeredCount} remaining</span>
|
||||
</div>
|
||||
{answeredCount > 0 && !isLast && (
|
||||
{!isStudy && answeredCount > 0 && !isLast && (
|
||||
<button className="btn btn-primary btn-sm" style={{ marginTop: 10, width: '100%' }}
|
||||
onClick={() => setShowReview(true)} disabled={submitting}>
|
||||
Review & Complete
|
||||
|
|
@ -1838,7 +1877,12 @@ const timerStarted = timeLeft !== null
|
|||
<button type="button" className="btn btn-secondary btn-sm quiz-exit"
|
||||
onClick={() => leaveNow()}>Exit session</button>
|
||||
{quizNavigation('bottom')}
|
||||
{answeredCount > 0 && (
|
||||
{/* Reviewing a block before handing it in is an exam idea. A study
|
||||
session has nothing to hand in: it keeps going until every
|
||||
question is answered, and at that point it is the review. This
|
||||
button sat in both because the study player inherited the exam
|
||||
player's bar. */}
|
||||
{!isStudy && answeredCount > 0 && (
|
||||
<button className="btn btn-secondary btn-sm quiz-review-link"
|
||||
onClick={() => setShowReview(true)} disabled={submitting}>
|
||||
Review ({answeredCount}/{totalCount})
|
||||
|
|
|
|||
|
|
@ -532,6 +532,32 @@ describe('quiz player', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('a study session offers no review to hand in', async () => {
|
||||
await begin()
|
||||
fireEvent.keyDown(window, { key: '1' })
|
||||
// Reviewing a block before handing it in is an exam idea. Study keeps
|
||||
// going until every question is answered, and then it is the review.
|
||||
expect(screen.queryByRole('button', { name: /^Review \(/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Review & Complete' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('an exam still offers it, because a block is handed in', async () => {
|
||||
await begin(false)
|
||||
fireEvent.keyDown(window, { key: '1' })
|
||||
expect(screen.getByRole('button', { name: /^Review \(1\/2\)/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('a finished study session submits without asking twice', async () => {
|
||||
await begin()
|
||||
fireEvent.keyDown(window, { key: '1' })
|
||||
await userEvent.click(screen.getAllByRole('button', { name: /Next/ })[0])
|
||||
await findStem('Full second clinical question.')
|
||||
fireEvent.keyDown(window, { key: '1' })
|
||||
// Everything answered: there is nothing left to warn about.
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Finish session' })[0])
|
||||
expect(await screen.findByText('Submitted results')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts timed quizzes in exam mode without a mode prompt', async () => {
|
||||
quizModeVar = 'timed'
|
||||
await begin(false)
|
||||
|
|
|
|||
|
|
@ -406,11 +406,29 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; }
|
|||
.quiz-drawer-tabs button[aria-selected="true"] { background: var(--option-sel-bg); color: var(--primary); }
|
||||
|
||||
.quiz-drawer-title {
|
||||
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
|
||||
padding: 12px 14px 8px; flex: none;
|
||||
display: grid; grid-template-columns: 1fr auto; align-items: baseline;
|
||||
gap: 4px 10px; padding: 12px 14px 10px; flex: none;
|
||||
}
|
||||
.quiz-drawer-title strong { font-size: 0.9rem; font-weight: 650; }
|
||||
.quiz-drawer-title small { font-size: 0.78rem; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
/* Nothing left to sit: what you are doing now is reading it back. */
|
||||
.quiz-drawer-badge {
|
||||
grid-column: 1 / -1; justify-self: start;
|
||||
padding: 3px 10px; margin-bottom: 2px; border-radius: 20px;
|
||||
font-size: 0.66rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
|
||||
background: var(--bg); color: var(--text-muted); border: 1px solid var(--border);
|
||||
}
|
||||
.quiz-drawer-bar {
|
||||
grid-column: 1 / -1; height: 3px; margin-top: 6px; border-radius: 2px;
|
||||
background: var(--border); overflow: hidden;
|
||||
}
|
||||
.quiz-drawer-bar span { display: block; height: 100%; background: var(--primary); }
|
||||
|
||||
/* How long this is taking, pinned under the list rather than scrolling with it. */
|
||||
.quiz-drawer-foot {
|
||||
flex: none; padding: 10px 14px; border-top: 1px solid var(--border);
|
||||
}
|
||||
.quiz-drawer-foot .quiz-clock { flex-wrap: wrap; }
|
||||
|
||||
/* The list is the only thing that scrolls. */
|
||||
.quiz-drawer-list { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue