Improve quiz TTS and Android release build
Some checks failed
Mobile Android Release / android-release (push) Failing after 1s
Some checks failed
Mobile Android Release / android-release (push) Failing after 1s
This commit is contained in:
parent
d59c8bed6f
commit
fdebda993c
22 changed files with 541 additions and 204 deletions
116
.forgejo/workflows/mobile-android.yml
Normal file
116
.forgejo/workflows/mobile-android.yml
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
name: Mobile Android Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
release_name:
|
||||||
|
description: 'Forgejo release name'
|
||||||
|
required: false
|
||||||
|
default: 'Manual Android Build'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
android-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Set up Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
|
- name: Validate Android signing secrets
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
test -n "$ANDROID_KEYSTORE_BASE64"
|
||||||
|
test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
|
test -n "$ANDROID_KEY_ALIAS"
|
||||||
|
test -n "$ANDROID_KEY_PASSWORD"
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
working-directory: frontend
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Install mobile dependencies
|
||||||
|
working-directory: mobile
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Decode Android keystore
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
run: |
|
||||||
|
mkdir -p mobile/android/keystore
|
||||||
|
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > mobile/android/keystore/release.jks
|
||||||
|
|
||||||
|
- name: Sync Capacitor Android
|
||||||
|
working-directory: mobile
|
||||||
|
run: npx cap sync android
|
||||||
|
|
||||||
|
- name: Build signed release APK
|
||||||
|
working-directory: mobile/android
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_FILE: ${{ github.workspace }}/mobile/android/keystore/release.jks
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
chmod +x gradlew
|
||||||
|
./gradlew assembleRelease
|
||||||
|
mkdir -p ../../dist
|
||||||
|
cp app/build/outputs/apk/release/app-release.apk ../../dist/pedshub-${GITHUB_REF_NAME:-manual}.apk
|
||||||
|
|
||||||
|
- name: Create Forgejo release and upload APK
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
RELEASE_NAME: ${{ inputs.release_name }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
tag="${GITHUB_REF_NAME:-manual-${GITHUB_RUN_NUMBER}}"
|
||||||
|
apk="dist/pedshub-${tag}.apk"
|
||||||
|
name="${RELEASE_NAME:-PedsHub Android ${tag}}"
|
||||||
|
body="Automated signed Android APK build for ${tag}."
|
||||||
|
api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
|
||||||
|
|
||||||
|
release_json=$(curl -fsS "${api}/releases/tags/${tag}" \
|
||||||
|
-H "Authorization: token ${GITHUB_TOKEN}" || true)
|
||||||
|
if [ -z "$release_json" ]; then
|
||||||
|
payload=$(python3 - <<PY
|
||||||
|
import json
|
||||||
|
print(json.dumps({
|
||||||
|
"tag_name": "${tag}",
|
||||||
|
"target_commitish": "${GITHUB_SHA}",
|
||||||
|
"name": "${name}",
|
||||||
|
"body": "${body}",
|
||||||
|
"draft": False,
|
||||||
|
"prerelease": False,
|
||||||
|
}))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
release_json=$(curl -fsS -X POST "${api}/releases" \
|
||||||
|
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$payload")
|
||||||
|
fi
|
||||||
|
|
||||||
|
release_id=$(printf '%s' "$release_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||||
|
curl -fsS -X POST "${api}/releases/${release_id}/assets?name=$(basename "$apk")" \
|
||||||
|
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
|
-H "Content-Type: application/vnd.android.package-archive" \
|
||||||
|
--data-binary "@${apk}"
|
||||||
14
README.md
14
README.md
|
|
@ -10,7 +10,7 @@ AI-powered pediatric learning platform. Upload PDF study materials, automaticall
|
||||||
- **AI Tutor (TeachChat)**: Ask follow-up questions mid-study — AI knows the current question, correct answer, and related content. Renders markdown tables, code, and follow-up suggestion chips.
|
- **AI Tutor (TeachChat)**: Ask follow-up questions mid-study — AI knows the current question, correct answer, and related content. Renders markdown tables, code, and follow-up suggestion chips.
|
||||||
- **Tag Classification**: AI classifies questions with subjects, diseases, and keywords — filter your question bank by any combination of tags
|
- **Tag Classification**: AI classifies questions with subjects, diseases, and keywords — filter your question bank by any combination of tags
|
||||||
- **Multi-Category Filtering**: Filter questions by question category, tags, or quiz source — combine multiple filters for precise study sets
|
- **Multi-Category Filtering**: Filter questions by question category, tags, or quiz source — combine multiple filters for precise study sets
|
||||||
- **Text-to-Speech**: OpenAI TTS, AWS Polly, ElevenLabs, Google Cloud — voice selection per quiz
|
- **Text-to-Speech**: LiteLLM-routed local TTS, OpenAI TTS, ElevenLabs, Google Cloud — voice selection per quiz
|
||||||
- **Semantic Search**: pgvector embeddings — finds questions by meaning, not just keywords
|
- **Semantic Search**: pgvector embeddings — finds questions by meaning, not just keywords
|
||||||
- **Question Bank**: All questions searchable, filterable by category and tags, with inline study mode
|
- **Question Bank**: All questions searchable, filterable by category and tags, with inline study mode
|
||||||
- **Image Validation**: AI `has_figure` gating — only links extracted images to questions the AI flagged as having a figure, preventing mismatched images
|
- **Image Validation**: AI `has_figure` gating — only links extracted images to questions the AI flagged as having a figure, preventing mismatched images
|
||||||
|
|
@ -36,7 +36,7 @@ AI-powered pediatric learning platform. Upload PDF study materials, automaticall
|
||||||
| AI/LLM | LiteLLM proxy (Claude, Gemini, GPT, Bedrock, and more) |
|
| AI/LLM | LiteLLM proxy (Claude, Gemini, GPT, Bedrock, and more) |
|
||||||
| Embeddings | Configurable — any LiteLLM proxy model or direct AWS Bedrock (1024-dim) |
|
| Embeddings | Configurable — any LiteLLM proxy model or direct AWS Bedrock (1024-dim) |
|
||||||
| Document vectors | ChromaDB |
|
| Document vectors | ChromaDB |
|
||||||
| TTS | OpenAI (direct), AWS Polly, ElevenLabs, Google Cloud TTS |
|
| TTS | LiteLLM-routed local TTS, OpenAI (direct), ElevenLabs, Google Cloud TTS |
|
||||||
| Queue | Celery + Redis (4 fork workers) |
|
| Queue | Celery + Redis (4 fork workers) |
|
||||||
| Email | SMTP (smtp2go or any SMTP server) |
|
| Email | SMTP (smtp2go or any SMTP server) |
|
||||||
| Bot protection | Cloudflare Turnstile (runtime-configurable, no rebuild needed) |
|
| Bot protection | Cloudflare Turnstile (runtime-configurable, no rebuild needed) |
|
||||||
|
|
@ -311,7 +311,6 @@ Accessible at `/admin` for admin users. Three tabs:
|
||||||
|
|
||||||
### More Settings
|
### More Settings
|
||||||
- **Public Registration** — enable/disable new user sign-ups
|
- **Public Registration** — enable/disable new user sign-ups
|
||||||
- **AWS Polly** — global enable/disable toggle for all Polly voices
|
|
||||||
- **Classify Questions** — trigger AI tag classification for all untagged questions (runs as background task)
|
- **Classify Questions** — trigger AI tag classification for all untagged questions (runs as background task)
|
||||||
- **Embedding Model** — set the model used for semantic search vectors:
|
- **Embedding Model** — set the model used for semantic search vectors:
|
||||||
- Type a model name and click **Save**, or click **Search LiteLLM** to browse proxy models
|
- Type a model name and click **Save**, or click **Search LiteLLM** to browse proxy models
|
||||||
|
|
@ -461,9 +460,8 @@ The landing page at `/home` uses the shared `Navbar` component. When not logged
|
||||||
|
|
||||||
## TTS Providers
|
## TTS Providers
|
||||||
|
|
||||||
| Provider | Model ID format | Key needed |
|
| Provider | Model/voice ID format | Key needed |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| OpenAI | `tts-1:alloy`, `tts-1:nova`, `tts-1-hd:*` | `OPENAI_API_KEY` |
|
| Kokoro via LiteLLM | `local-kokoro-tts:am_adam`, `local-kokoro-tts:af_bella` | `LITELLM_API_KEY` |
|
||||||
| AWS Polly | `polly/Joanna`, `polly/Matthew`, `polly/Amy` | `AWS_ACCESS_KEY_ID` + IAM `polly:SynthesizeSpeech` |
|
|
||||||
| ElevenLabs | `elevenlabs/<voice-id>` | `ELEVENLABS_API_KEY` |
|
The part before `:` is the LiteLLM speech model route. The part after `:` is the Kokoro speaker voice.
|
||||||
| Google Cloud | `google/<voice-name>` | `GOOGLE_TTS_API_KEY` |
|
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,8 @@ def seed_admin():
|
||||||
|
|
||||||
def seed_default_models():
|
def seed_default_models():
|
||||||
"""Seed default AI model configs if none exist."""
|
"""Seed default AI model configs if none exist."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.models.ai_model_config import AIModelConfig
|
from app.models.ai_model_config import AIModelConfig
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
|
@ -91,8 +93,12 @@ def seed_default_models():
|
||||||
|
|
||||||
# Always ensure LiteLLM-routed local speech models exist (idempotent).
|
# Always ensure LiteLLM-routed local speech models exist (idempotent).
|
||||||
tts_voices = [
|
tts_voices = [
|
||||||
("Kokoro TTS (LiteLLM)", "local-kokoro-tts", True),
|
("Kokoro Adam", "local-kokoro-tts:am_adam", True),
|
||||||
("Qwen3 TTS (LiteLLM)", "local-qwen3-tts", False),
|
("Kokoro Michael", "local-kokoro-tts:am_michael", False),
|
||||||
|
("Kokoro Bella", "local-kokoro-tts:af_bella", False),
|
||||||
|
("Kokoro Nicole", "local-kokoro-tts:af_nicole", False),
|
||||||
|
("Kokoro Emma", "local-kokoro-tts:bf_emma", False),
|
||||||
|
("Kokoro Lewis", "local-kokoro-tts:bm_lewis", False),
|
||||||
]
|
]
|
||||||
stt_models = [
|
stt_models = [
|
||||||
("Parakeet STT (LiteLLM)", "local-parakeet-v3", True),
|
("Parakeet STT (LiteLLM)", "local-parakeet-v3", True),
|
||||||
|
|
@ -101,30 +107,31 @@ def seed_default_models():
|
||||||
]
|
]
|
||||||
# Deactivate external/direct legacy TTS entries; speech should route through LiteLLM.
|
# Deactivate external/direct legacy TTS entries; speech should route through LiteLLM.
|
||||||
for old in db.query(AIModelConfig).filter(AIModelConfig.task == "tts").all():
|
for old in db.query(AIModelConfig).filter(AIModelConfig.task == "tts").all():
|
||||||
if old.model_id and old.model_id.startswith(("tts-", "openai/", "elevenlabs/", "eleven_labs/", "google/", "polly/", "sherpa/")):
|
if old.model_id and not old.model_id.startswith("local-"):
|
||||||
|
old.is_active = False
|
||||||
|
old.is_default = False
|
||||||
|
if old.model_id == "local-kokoro-tts":
|
||||||
old.is_active = False
|
old.is_active = False
|
||||||
old.is_default = False
|
old.is_default = False
|
||||||
if old.model_id == "local-chatterbox-turbo":
|
if old.model_id == "local-chatterbox-turbo":
|
||||||
old.is_active = False
|
old.is_active = False
|
||||||
old.is_default = False
|
old.is_default = False
|
||||||
|
if old.model_id == "local-qwen3-tts":
|
||||||
has_default_tts = db.query(AIModelConfig).filter(
|
old.is_active = False
|
||||||
AIModelConfig.task == "tts", AIModelConfig.is_default == True, AIModelConfig.is_active == True,
|
old.is_default = False
|
||||||
).first() is not None
|
|
||||||
|
|
||||||
for name, model_id, _ in tts_voices:
|
for name, model_id, _ in tts_voices:
|
||||||
exists = db.query(AIModelConfig).filter(AIModelConfig.model_id == model_id).first()
|
db.execute(text("""
|
||||||
if not exists:
|
INSERT INTO ai_model_configs (name, model_id, task, is_active, is_default, created_at)
|
||||||
is_def = not has_default_tts
|
VALUES (:name, :model_id, 'tts', true, false, NOW())
|
||||||
db.add(AIModelConfig(name=name, model_id=model_id, task="tts", is_active=True, is_default=is_def))
|
ON CONFLICT (model_id, task) DO NOTHING
|
||||||
if is_def:
|
"""), {"name": name, "model_id": model_id})
|
||||||
has_default_tts = True
|
|
||||||
|
|
||||||
# LiteLLM Kokoro is the intended default for read-aloud; admins can change it later.
|
# LiteLLM Kokoro is the intended default for read-aloud; admins can change it later.
|
||||||
db.query(AIModelConfig).filter(AIModelConfig.task == "tts").update({"is_default": False})
|
db.query(AIModelConfig).filter(AIModelConfig.task == "tts").update({"is_default": False})
|
||||||
local_default = db.query(AIModelConfig).filter(
|
local_default = db.query(AIModelConfig).filter(
|
||||||
AIModelConfig.task == "tts",
|
AIModelConfig.task == "tts",
|
||||||
AIModelConfig.model_id == "local-kokoro-tts",
|
AIModelConfig.model_id == "local-kokoro-tts:am_adam",
|
||||||
).first()
|
).first()
|
||||||
if local_default:
|
if local_default:
|
||||||
local_default.is_active = True
|
local_default.is_active = True
|
||||||
|
|
@ -143,7 +150,11 @@ def seed_default_models():
|
||||||
).first()
|
).first()
|
||||||
if not exists:
|
if not exists:
|
||||||
is_def = preferred_default and not has_default
|
is_def = preferred_default and not has_default
|
||||||
db.add(AIModelConfig(name=name, model_id=model_id, task=task, is_active=True, is_default=is_def))
|
db.execute(text("""
|
||||||
|
INSERT INTO ai_model_configs (name, model_id, task, is_active, is_default, created_at)
|
||||||
|
VALUES (:name, :model_id, :task, true, :is_default, NOW())
|
||||||
|
ON CONFLICT (model_id, task) DO NOTHING
|
||||||
|
"""), {"name": name, "model_id": model_id, "task": task, "is_default": is_def})
|
||||||
if is_def:
|
if is_def:
|
||||||
has_default = True
|
has_default = True
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,7 @@ def list_available_models(
|
||||||
class LiteLLMSearchRequest(BaseModel):
|
class LiteLLMSearchRequest(BaseModel):
|
||||||
api_key: str | None = None
|
api_key: str | None = None
|
||||||
api_base: str | None = None
|
api_base: str | None = None
|
||||||
|
mode: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/litellm/models")
|
@router.post("/litellm/models")
|
||||||
|
|
@ -155,8 +156,16 @@ def search_litellm_models(
|
||||||
if base:
|
if base:
|
||||||
try:
|
try:
|
||||||
headers = {"Authorization": f"Bearer {key}"} if key else {}
|
headers = {"Authorization": f"Bearer {key}"} if key else {}
|
||||||
resp = httpx.post(f"{base}/v1/models", headers=headers, timeout=10) if False else \
|
if data.mode:
|
||||||
httpx.get(f"{base}/v1/models", headers=headers, timeout=10)
|
info_base = base.rstrip("/").removesuffix("/v1")
|
||||||
|
resp = httpx.get(f"{info_base}/model/info", headers=headers, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
models = sorted([
|
||||||
|
m.get("model_name") for m in resp.json().get("data", [])
|
||||||
|
if m.get("model_name") and (m.get("model_info") or {}).get("mode") == data.mode
|
||||||
|
])
|
||||||
|
return {"models": models, "source": info_base, "mode": data.mode}
|
||||||
|
resp = httpx.get(f"{base}/v1/models", headers=headers, timeout=10)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
models = sorted([m["id"] for m in resp.json().get("data", [])])
|
models = sorted([m["id"] for m in resp.json().get("data", [])])
|
||||||
return {"models": models, "source": base}
|
return {"models": models, "source": base}
|
||||||
|
|
@ -326,6 +335,47 @@ class TTSVoiceSearchRequest(BaseModel):
|
||||||
region: str | None = None
|
region: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
KOKORO_VOICE_FALLBACKS = [
|
||||||
|
("am_adam", "Kokoro Adam"),
|
||||||
|
("am_michael", "Kokoro Michael"),
|
||||||
|
("af_bella", "Kokoro Bella"),
|
||||||
|
("af_nicole", "Kokoro Nicole"),
|
||||||
|
("bf_emma", "Kokoro Emma"),
|
||||||
|
("bm_lewis", "Kokoro Lewis"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _kokoro_voice_options(model_name: str) -> list[dict]:
|
||||||
|
base = settings.LOCAL_SPEECH_GATEWAY_URL.rstrip("/")
|
||||||
|
voices = []
|
||||||
|
friendly_names = {voice_id: name for voice_id, name in KOKORO_VOICE_FALLBACKS}
|
||||||
|
if base:
|
||||||
|
try:
|
||||||
|
resp = httpx.get(f"{base}/v1/audio/voices", timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
voices = [
|
||||||
|
v for v in resp.json().get("voices", [])
|
||||||
|
if v.get("profile") == "kokoro" and v.get("voice")
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
voices = []
|
||||||
|
|
||||||
|
if not voices:
|
||||||
|
voices = [
|
||||||
|
{"voice": voice_id, "name": name, "profile": "kokoro"}
|
||||||
|
for voice_id, name in KOKORO_VOICE_FALLBACKS
|
||||||
|
]
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"model_id": f"{model_name}:{v['voice']}",
|
||||||
|
"name": friendly_names.get(v["voice"], v.get("name") or f"Kokoro {v['voice']}"),
|
||||||
|
"labels": {"provider": "litellm", "model": model_name, "voice": v["voice"]},
|
||||||
|
}
|
||||||
|
for v in voices
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tts/voices")
|
@router.post("/tts/voices")
|
||||||
def search_tts_voices(
|
def search_tts_voices(
|
||||||
data: TTSVoiceSearchRequest,
|
data: TTSVoiceSearchRequest,
|
||||||
|
|
@ -351,15 +401,20 @@ def search_tts_voices(
|
||||||
resp = httpx.get(f"{base}/model/info", headers=headers, timeout=10)
|
resp = httpx.get(f"{base}/model/info", headers=headers, timeout=10)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
models = resp.json().get("data", [])
|
models = resp.json().get("data", [])
|
||||||
return {"voices": [
|
voices = []
|
||||||
{
|
for m in models:
|
||||||
"model_id": m.get("model_name"),
|
model_name = m.get("model_name")
|
||||||
"name": m.get("model_name"),
|
if not model_name or (m.get("model_info") or {}).get("mode") != "audio_speech":
|
||||||
|
continue
|
||||||
|
if model_name == "local-kokoro-tts":
|
||||||
|
voices.extend(_kokoro_voice_options(model_name))
|
||||||
|
continue
|
||||||
|
voices.append({
|
||||||
|
"model_id": model_name,
|
||||||
|
"name": model_name,
|
||||||
"labels": {"provider": "litellm", "mode": "audio_speech"},
|
"labels": {"provider": "litellm", "mode": "audio_speech"},
|
||||||
}
|
})
|
||||||
for m in models
|
return {"voices": voices}
|
||||||
if m.get("model_name") and (m.get("model_info") or {}).get("mode") == "audio_speech"
|
|
||||||
]}
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -379,12 +434,10 @@ def get_settings(admin: User = Depends(require_admin)):
|
||||||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
registration_enabled = r.get("settings:registration_enabled")
|
registration_enabled = r.get("settings:registration_enabled")
|
||||||
embedding_model = r.get("settings:embedding_model")
|
embedding_model = r.get("settings:embedding_model")
|
||||||
polly_enabled = r.get("settings:polly_enabled")
|
|
||||||
sso_only = r.get("settings:sso_only")
|
sso_only = r.get("settings:sso_only")
|
||||||
return {
|
return {
|
||||||
"registration_enabled": registration_enabled != "false",
|
"registration_enabled": registration_enabled != "false",
|
||||||
"embedding_model": embedding_model or settings.LITELLM_EMBEDDING_MODEL or "",
|
"embedding_model": embedding_model or settings.LITELLM_EMBEDDING_MODEL or "",
|
||||||
"polly_enabled": polly_enabled != "false",
|
|
||||||
"sso_only": sso_only == "true",
|
"sso_only": sso_only == "true",
|
||||||
"sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
|
"sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
|
||||||
"sso_provider_name": settings.OIDC_PROVIDER_NAME,
|
"sso_provider_name": settings.OIDC_PROVIDER_NAME,
|
||||||
|
|
@ -393,7 +446,6 @@ def get_settings(admin: User = Depends(require_admin)):
|
||||||
return {
|
return {
|
||||||
"registration_enabled": True,
|
"registration_enabled": True,
|
||||||
"embedding_model": settings.LITELLM_EMBEDDING_MODEL or "",
|
"embedding_model": settings.LITELLM_EMBEDDING_MODEL or "",
|
||||||
"polly_enabled": True,
|
|
||||||
"sso_only": False,
|
"sso_only": False,
|
||||||
"sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
|
"sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
|
||||||
"sso_provider_name": settings.OIDC_PROVIDER_NAME,
|
"sso_provider_name": settings.OIDC_PROVIDER_NAME,
|
||||||
|
|
@ -417,10 +469,6 @@ def update_settings(
|
||||||
if "embedding_model" in settings_data:
|
if "embedding_model" in settings_data:
|
||||||
r.set("settings:embedding_model", settings_data["embedding_model"])
|
r.set("settings:embedding_model", settings_data["embedding_model"])
|
||||||
|
|
||||||
if "polly_enabled" in settings_data:
|
|
||||||
value = "true" if settings_data["polly_enabled"] else "false"
|
|
||||||
r.set("settings:polly_enabled", value)
|
|
||||||
|
|
||||||
if "sso_only" in settings_data:
|
if "sso_only" in settings_data:
|
||||||
value = "true" if settings_data["sso_only"] else "false"
|
value = "true" if settings_data["sso_only"] else "false"
|
||||||
r.set("settings:sso_only", value)
|
r.set("settings:sso_only", value)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ from fastapi.responses import Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.ai_model_config import AIModelConfig
|
from app.models.ai_model_config import AIModelConfig
|
||||||
|
|
@ -32,16 +31,6 @@ def _task_model(db: Session, task: str, fallback: str) -> tuple[str, str | None]
|
||||||
return fallback, None
|
return fallback, None
|
||||||
|
|
||||||
|
|
||||||
def _polly_enabled() -> bool:
|
|
||||||
try:
|
|
||||||
import redis as redis_lib
|
|
||||||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
|
||||||
val = r.get("settings:polly_enabled")
|
|
||||||
return val != "false"
|
|
||||||
except Exception:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _default_tts_model(db: Session) -> tuple[str, str | None]:
|
def _default_tts_model(db: Session) -> tuple[str, str | None]:
|
||||||
config = db.query(AIModelConfig).filter(
|
config = db.query(AIModelConfig).filter(
|
||||||
AIModelConfig.task == "tts",
|
AIModelConfig.task == "tts",
|
||||||
|
|
@ -51,7 +40,7 @@ def _default_tts_model(db: Session) -> tuple[str, str | None]:
|
||||||
).first()
|
).first()
|
||||||
if config:
|
if config:
|
||||||
return config.model_id, config.api_key or None
|
return config.model_id, config.api_key or None
|
||||||
return "local-kokoro-tts", None
|
return "local-kokoro-tts:am_adam", None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/voices")
|
@router.get("/voices")
|
||||||
|
|
@ -65,8 +54,6 @@ def get_voices(
|
||||||
AIModelConfig.is_active == True,
|
AIModelConfig.is_active == True,
|
||||||
AIModelConfig.model_id.like("local-%"),
|
AIModelConfig.model_id.like("local-%"),
|
||||||
)
|
)
|
||||||
if not _polly_enabled():
|
|
||||||
query = query.filter(~AIModelConfig.model_id.like("polly/%"))
|
|
||||||
db_models = query.order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all()
|
db_models = query.order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all()
|
||||||
return [{"id": m.model_id, "name": m.name, "is_default": m.is_default} for m in db_models]
|
return [{"id": m.model_id, "name": m.name, "is_default": m.is_default} for m in db_models]
|
||||||
|
|
||||||
|
|
@ -92,11 +79,16 @@ def text_to_speech(
|
||||||
text = request.text[:2000]
|
text = request.text[:2000]
|
||||||
|
|
||||||
if request.voice and request.voice.startswith("local-"):
|
if request.voice and request.voice.startswith("local-"):
|
||||||
if request.voice.startswith("polly/") and not _polly_enabled():
|
config = db.query(AIModelConfig).filter(
|
||||||
raise HTTPException(status_code=400, detail="AWS Polly is currently disabled")
|
AIModelConfig.task == "tts",
|
||||||
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == request.voice).first()
|
AIModelConfig.is_active == True,
|
||||||
model_id = config.model_id if config else request.voice
|
AIModelConfig.model_id == request.voice,
|
||||||
api_key = config.api_key if (config and config.api_key) else None
|
).first()
|
||||||
|
if not config:
|
||||||
|
model_id, api_key = _default_tts_model(db)
|
||||||
|
else:
|
||||||
|
model_id = config.model_id
|
||||||
|
api_key = config.api_key or None
|
||||||
else:
|
else:
|
||||||
model_id, api_key = _default_tts_model(db)
|
model_id, api_key = _default_tts_model(db)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -380,16 +380,15 @@ def generate_tts_audio(
|
||||||
model_id: str | None = None,
|
model_id: str | None = None,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
) -> bytes | None:
|
) -> bytes | None:
|
||||||
"""Generate TTS audio. Supports local Sherpa, OpenAI, ElevenLabs, Google Cloud TTS, and AWS Polly.
|
"""Generate TTS audio. Supports local LiteLLM, local Sherpa, OpenAI, ElevenLabs, and Google Cloud TTS.
|
||||||
|
|
||||||
model_id conventions:
|
model_id conventions:
|
||||||
tts-1:alloy → OpenAI TTS (voice after colon)
|
tts-1:alloy → OpenAI TTS (voice after colon)
|
||||||
tts-1-hd:nova → OpenAI TTS HD
|
tts-1-hd:nova → OpenAI TTS HD
|
||||||
elevenlabs/<voice> → ElevenLabs
|
elevenlabs/<voice> → ElevenLabs
|
||||||
google/<voice_name> → Google Cloud TTS (e.g. google/en-US-Wavenet-D)
|
google/<voice_name> → Google Cloud TTS (e.g. google/en-US-Wavenet-D)
|
||||||
polly/<VoiceId> → AWS Polly Neural (e.g. polly/Joanna)
|
|
||||||
sherpa/<profile>:<voice> → Local speech gateway (e.g. sherpa/kokoro:am_adam)
|
sherpa/<profile>:<voice> → Local speech gateway (e.g. sherpa/kokoro:am_adam)
|
||||||
local-<model> → Local LiteLLM TTS model (e.g. local-chatterbox-turbo)
|
local-<model>:<voice> → Local LiteLLM TTS model + voice (e.g. local-kokoro-tts:am_adam)
|
||||||
"""
|
"""
|
||||||
import httpx, base64
|
import httpx, base64
|
||||||
|
|
||||||
|
|
@ -397,6 +396,10 @@ def generate_tts_audio(
|
||||||
|
|
||||||
# ── Local LiteLLM TTS models ─────────────────────────────────
|
# ── Local LiteLLM TTS models ─────────────────────────────────
|
||||||
if use_model.startswith("local-"):
|
if use_model.startswith("local-"):
|
||||||
|
local_model = use_model
|
||||||
|
local_voice = "am_adam" if use_model.startswith("local-kokoro-tts") else "alloy"
|
||||||
|
if ":" in use_model:
|
||||||
|
local_model, local_voice = use_model.split(":", 1)
|
||||||
key = api_key or settings.LITELLM_API_KEY
|
key = api_key or settings.LITELLM_API_KEY
|
||||||
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
|
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
|
||||||
if not base:
|
if not base:
|
||||||
|
|
@ -406,7 +409,7 @@ def generate_tts_audio(
|
||||||
resp = httpx.post(
|
resp = httpx.post(
|
||||||
f"{base}/v1/audio/speech",
|
f"{base}/v1/audio/speech",
|
||||||
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"} if key else {"Content-Type": "application/json"},
|
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"} if key else {"Content-Type": "application/json"},
|
||||||
json={"model": use_model, "input": text, "voice": "alloy", "response_format": "mp3"},
|
json={"model": local_model, "input": text, "voice": local_voice, "response_format": "mp3"},
|
||||||
timeout=90,
|
timeout=90,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
@ -480,34 +483,6 @@ def generate_tts_audio(
|
||||||
logger.error(f"Google TTS failed: {e}")
|
logger.error(f"Google TTS failed: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ── AWS Polly ───────────────────────────────────────────────
|
|
||||||
if use_model.startswith("polly/"):
|
|
||||||
voice_id = use_model[len("polly/"):]
|
|
||||||
access_key = api_key or settings.AWS_ACCESS_KEY_ID
|
|
||||||
secret_key = settings.AWS_SECRET_ACCESS_KEY
|
|
||||||
region = settings.AWS_REGION or "us-east-1"
|
|
||||||
if not access_key or not secret_key:
|
|
||||||
logger.error("AWS credentials not configured (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY)")
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
import boto3
|
|
||||||
polly = boto3.client(
|
|
||||||
"polly",
|
|
||||||
aws_access_key_id=access_key,
|
|
||||||
aws_secret_access_key=secret_key,
|
|
||||||
region_name=region,
|
|
||||||
)
|
|
||||||
response = polly.synthesize_speech(
|
|
||||||
Text=text,
|
|
||||||
OutputFormat="mp3",
|
|
||||||
VoiceId=voice_id,
|
|
||||||
Engine="neural",
|
|
||||||
)
|
|
||||||
return response["AudioStream"].read()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"AWS Polly TTS failed: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# ── OpenAI (default) ────────────────────────────────────────
|
# ── OpenAI (default) ────────────────────────────────────────
|
||||||
# model_id may encode voice as "tts-1:nova", "tts-1-hd:alloy", etc.
|
# model_id may encode voice as "tts-1:nova", "tts-1-hd:alloy", etc.
|
||||||
clean_model = use_model.replace("openai/", "")
|
clean_model = use_model.replace("openai/", "")
|
||||||
|
|
|
||||||
|
|
@ -705,7 +705,7 @@ Convert text to speech audio.
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- **Response:** Binary `audio/mpeg` data
|
- **Response:** Binary `audio/mpeg` data
|
||||||
- **Notes:** Supports OpenAI TTS (`tts-1:alloy`), ElevenLabs (`elevenlabs/<voice_id>`), Google Cloud TTS (`google/<voice_name>`), and AWS Polly (`polly/<VoiceId>`).
|
- **Notes:** Supports LiteLLM-routed local TTS voices, e.g. `local-kokoro-tts:am_adam`. The prefix before `:` is the LiteLLM model route; the suffix is the speaker voice sent to that route.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -993,14 +993,14 @@ Discover available TTS voices from a provider.
|
||||||
Get system settings (registration enabled, embedding model, Polly enabled).
|
Get system settings (registration enabled, embedding model, Polly enabled).
|
||||||
|
|
||||||
- **Auth:** Admin
|
- **Auth:** Admin
|
||||||
- **Response:** `{"registration_enabled": bool, "embedding_model": "string", "polly_enabled": bool}`
|
- **Response:** `{"registration_enabled": bool, "embedding_model": "string"}`
|
||||||
|
|
||||||
#### PUT `/api/admin/settings`
|
#### PUT `/api/admin/settings`
|
||||||
|
|
||||||
Update system settings.
|
Update system settings.
|
||||||
|
|
||||||
- **Auth:** Admin
|
- **Auth:** Admin
|
||||||
- **Request body:** `{"registration_enabled": bool, "embedding_model": "string", "polly_enabled": bool}`
|
- **Request body:** `{"registration_enabled": bool, "embedding_model": "string"}`
|
||||||
- **Response:** `{"success": true, "message": "Settings updated"}`
|
- **Response:** `{"success": true, "message": "Settings updated"}`
|
||||||
|
|
||||||
### Embedding Management
|
### Embedding Management
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,6 @@ Multi-provider TTS generation. Provider is determined by `model_id` convention:
|
||||||
| `tts-1-hd:nova` | OpenAI TTS HD | Voice after colon |
|
| `tts-1-hd:nova` | OpenAI TTS HD | Voice after colon |
|
||||||
| `elevenlabs/<voice_id>` | ElevenLabs | Uses `eleven_turbo_v2_5` model |
|
| `elevenlabs/<voice_id>` | ElevenLabs | Uses `eleven_turbo_v2_5` model |
|
||||||
| `google/<voice_name>` | Google Cloud TTS | Parses language code from voice name |
|
| `google/<voice_name>` | Google Cloud TTS | Parses language code from voice name |
|
||||||
| `polly/<VoiceId>` | AWS Polly Neural | e.g. `polly/Joanna` |
|
|
||||||
|
|
||||||
**OpenAI key resolution order:** per-model API key > `OPENAI_API_KEY` (direct) > `LITELLM_API_KEY` (proxy). When using `OPENAI_API_KEY`, calls go directly to `api.openai.com`; otherwise uses `LITELLM_API_BASE`.
|
**OpenAI key resolution order:** per-model API key > `OPENAI_API_KEY` (direct) > `LITELLM_API_KEY` (proxy). When using `OPENAI_API_KEY`, calls go directly to `api.openai.com`; otherwise uses `LITELLM_API_BASE`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@
|
||||||
--navbar-fg: #f0e8d8;
|
--navbar-fg: #f0e8d8;
|
||||||
--badge-radius: 4px;
|
--badge-radius: 4px;
|
||||||
--font-body: 'Source Serif 4', Georgia, serif;
|
--font-body: 'Source Serif 4', Georgia, serif;
|
||||||
--font-heading: 'Playfair Display', 'Source Serif 4', serif;
|
--font-heading: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
--font-ui: 'Inter', system-ui, sans-serif;
|
--font-ui: 'Inter', system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,9 +71,9 @@ body[data-theme="markdown"] {
|
||||||
font-size: 15.5px;
|
font-size: 15.5px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
[data-theme="markdown"] .navbar .logo { color: #e0a84a; font-family: var(--font-heading); letter-spacing: 0; }
|
[data-theme="markdown"] .navbar .logo { color: #e0a84a; font-family: var(--font-heading); letter-spacing: -0.02em; }
|
||||||
[data-theme="markdown"] h1 { font-family: var(--font-heading); font-weight: 700; letter-spacing: -0.02em; }
|
[data-theme="markdown"] h1 { font-family: var(--font-heading); font-weight: 760; letter-spacing: -0.03em; line-height: 1.12; }
|
||||||
[data-theme="markdown"] h2, [data-theme="markdown"] .card h2 { font-family: var(--font-heading); font-weight: 600; }
|
[data-theme="markdown"] h2, [data-theme="markdown"] .card h2 { font-family: var(--font-heading); font-weight: 680; letter-spacing: -0.02em; line-height: 1.2; }
|
||||||
[data-theme="markdown"] h3 { font-family: var(--font-ui); font-weight: 600; }
|
[data-theme="markdown"] h3 { font-family: var(--font-ui); font-weight: 600; }
|
||||||
[data-theme="markdown"] .card h2 { border-bottom: 1px solid var(--border); padding-bottom: 10px; margin-bottom: 18px; }
|
[data-theme="markdown"] .card h2 { border-bottom: 1px solid var(--border); padding-bottom: 10px; margin-bottom: 18px; }
|
||||||
[data-theme="markdown"] .question-card { border-left: 3px solid var(--primary); }
|
[data-theme="markdown"] .question-card { border-left: 3px solid var(--primary); }
|
||||||
|
|
@ -135,6 +135,34 @@ body {
|
||||||
}
|
}
|
||||||
.card h2 { margin-bottom: 16px; font-size: 1.15rem; color: var(--text); font-weight: 600; }
|
.card h2 { margin-bottom: 16px; font-size: 1.15rem; color: var(--text); font-weight: 600; }
|
||||||
|
|
||||||
|
.quiz-header-card .quiz-header-title,
|
||||||
|
[data-theme="markdown"] .quiz-header-card .quiz-header-title {
|
||||||
|
margin: 0 0 2px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
font-family: var(--font-ui, 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif);
|
||||||
|
font-size: clamp(0.98rem, 1.8vw, 1.14rem);
|
||||||
|
font-weight: 720;
|
||||||
|
line-height: 1.18;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-nav-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.quiz-nav-controls-top {
|
||||||
|
margin: -4px 0 16px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: color-mix(in srgb, var(--card-bg) 92%, transparent);
|
||||||
|
border: var(--card-border);
|
||||||
|
border-radius: var(--card-radius);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Buttons ────────────────────────────────────────────────── */
|
/* ── Buttons ────────────────────────────────────────────────── */
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
|
@ -182,6 +210,27 @@ body {
|
||||||
box-shadow: var(--card-shadow);
|
box-shadow: var(--card-shadow);
|
||||||
}
|
}
|
||||||
.question-card h3 { font-size: 1.05rem; line-height: 1.6; margin-bottom: 20px; color: var(--text); font-weight: 600; }
|
.question-card h3 { font-size: 1.05rem; line-height: 1.6; margin-bottom: 20px; color: var(--text); font-weight: 600; }
|
||||||
|
.manual-highlight-toolbar { display: inline-flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.manual-highlight-segment { user-select: text; -webkit-user-select: text; }
|
||||||
|
.manual-highlight-active {
|
||||||
|
background: linear-gradient(transparent 38%, rgba(253, 224, 71, 0.72) 38%);
|
||||||
|
border-radius: 2px;
|
||||||
|
box-decoration-break: clone;
|
||||||
|
-webkit-box-decoration-break: clone;
|
||||||
|
}
|
||||||
|
.speech-highlight-active {
|
||||||
|
background: rgba(96, 165, 250, 0.18);
|
||||||
|
box-shadow: inset 0 -2px 0 rgba(59, 130, 246, 0.72);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 2px;
|
||||||
|
box-decoration-break: clone;
|
||||||
|
-webkit-box-decoration-break: clone;
|
||||||
|
}
|
||||||
|
.manual-highlight-active.speech-highlight-active {
|
||||||
|
background:
|
||||||
|
linear-gradient(transparent 38%, rgba(253, 224, 71, 0.72) 38%),
|
||||||
|
rgba(96, 165, 250, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
/* Options */
|
/* Options */
|
||||||
.question-card .options { display: flex; flex-direction: column; gap: 10px; }
|
.question-card .options { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
|
@ -189,7 +238,7 @@ body {
|
||||||
display: flex; align-items: center; gap: 12px;
|
display: flex; align-items: center; gap: 12px;
|
||||||
padding: 13px 16px; border: 1.5px solid var(--border); border-radius: 8px;
|
padding: 13px 16px; border: 1.5px solid var(--border); border-radius: 8px;
|
||||||
cursor: pointer; transition: border-color 0.12s, background 0.12s;
|
cursor: pointer; transition: border-color 0.12s, background 0.12s;
|
||||||
background: var(--option-bg); font-size: 0.9rem; color: var(--text); user-select: none;
|
background: var(--option-bg); font-size: 0.9rem; color: var(--text); user-select: text;
|
||||||
-webkit-tap-highlight-color: transparent; touch-action: manipulation;
|
-webkit-tap-highlight-color: transparent; touch-action: manipulation;
|
||||||
}
|
}
|
||||||
.question-card .option:hover { background: var(--option-hover); border-color: var(--text-subtle); }
|
.question-card .option:hover { background: var(--option-hover); border-color: var(--text-subtle); }
|
||||||
|
|
@ -349,6 +398,9 @@ body {
|
||||||
/* Mobile quiz: hide sidebar, show toggle */
|
/* Mobile quiz: hide sidebar, show toggle */
|
||||||
.quiz-sidebar { display: none; }
|
.quiz-sidebar { display: none; }
|
||||||
.quiz-nav-toggle { display: inline-flex; }
|
.quiz-nav-toggle { display: inline-flex; }
|
||||||
|
.quiz-nav-controls { gap: 6px; }
|
||||||
|
.quiz-nav-controls .btn { flex: 1; justify-content: center; padding-left: 10px; padding-right: 10px; }
|
||||||
|
.quiz-nav-controls .quiz-nav-toggle { flex: 0 0 auto; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Lesson content (markdown/HTML) ───────────────────────────── */
|
/* ── Lesson content (markdown/HTML) ───────────────────────────── */
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export default function AdminPage() {
|
||||||
const [tab, setTab] = useState('models')
|
const [tab, setTab] = useState('models')
|
||||||
const [users, setUsers] = useState([])
|
const [users, setUsers] = useState([])
|
||||||
const [models, setModels] = useState([])
|
const [models, setModels] = useState([])
|
||||||
const [settings, setSettings] = useState({ registration_enabled: true, embedding_model: '', polly_enabled: true })
|
const [settings, setSettings] = useState({ registration_enabled: true, embedding_model: '' })
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [success, setSuccess] = useState('')
|
const [success, setSuccess] = useState('')
|
||||||
|
|
@ -182,12 +182,11 @@ export default function AdminPage() {
|
||||||
const searchEmbeddingModels = async () => {
|
const searchEmbeddingModels = async () => {
|
||||||
setEmbedSearchError('')
|
setEmbedSearchError('')
|
||||||
setEmbedSearchResults([])
|
setEmbedSearchResults([])
|
||||||
|
setEmbedSearchFilter('')
|
||||||
setEmbedSearchLoading(true)
|
setEmbedSearchLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/admin/litellm/models', {})
|
const res = await api.post('/admin/litellm/models', { mode: 'embedding' })
|
||||||
const all = res.data.models || []
|
setEmbedSearchResults(res.data.models || [])
|
||||||
// Filter to likely embedding models
|
|
||||||
setEmbedSearchResults(all)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setEmbedSearchError(err.response?.data?.detail || 'Failed to query models')
|
setEmbedSearchError(err.response?.data?.detail || 'Failed to query models')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -647,37 +646,6 @@ export default function AdminPage() {
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AWS Polly */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
|
|
||||||
<div>
|
|
||||||
<strong style={{ display: 'block', marginBottom: 4 }}>AWS Polly Voices</strong>
|
|
||||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
|
||||||
Enable or disable AWS Polly TTS voices globally. Individual voices can still be toggled in the AI Models tab.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.polly_enabled}
|
|
||||||
onChange={async (e) => {
|
|
||||||
const enabled = e.target.checked
|
|
||||||
setSettings(s => ({ ...s, polly_enabled: enabled }))
|
|
||||||
try {
|
|
||||||
await api.put('/admin/settings', { polly_enabled: enabled })
|
|
||||||
setSuccess(`AWS Polly ${enabled ? 'enabled' : 'disabled'}`)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.response?.data?.detail || 'Failed to update setting')
|
|
||||||
setSettings(s => ({ ...s, polly_enabled: !enabled }))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{ width: 'auto', accentColor: 'var(--primary)' }}
|
|
||||||
/>
|
|
||||||
<span style={{ fontSize: '0.9rem', fontWeight: 600 }}>
|
|
||||||
{settings.polly_enabled ? 'Enabled' : 'Disabled'}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SSO Only */}
|
{/* SSO Only */}
|
||||||
{settings.sso_configured && (
|
{settings.sso_configured && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,97 @@ const QUESTION_HIGHLIGHT_WORDS = 8
|
||||||
const OPTION_HIGHLIGHT_WORDS = 7
|
const OPTION_HIGHLIGHT_WORDS = 7
|
||||||
const TTS_PRELOAD_AHEAD = 5
|
const TTS_PRELOAD_AHEAD = 5
|
||||||
|
|
||||||
|
function mergeTextRanges(ranges) {
|
||||||
|
const sorted = ranges
|
||||||
|
.filter(r => Number.isFinite(r.start) && Number.isFinite(r.end) && r.end > r.start)
|
||||||
|
.sort((a, b) => a.start - b.start || a.end - b.end)
|
||||||
|
const merged = []
|
||||||
|
sorted.forEach(range => {
|
||||||
|
const last = merged[merged.length - 1]
|
||||||
|
if (!last || range.start > last.end) {
|
||||||
|
merged.push({ start: range.start, end: range.end })
|
||||||
|
} else {
|
||||||
|
last.end = Math.max(last.end, range.end)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTextRange(ranges, removeRange) {
|
||||||
|
const next = []
|
||||||
|
ranges.forEach(range => {
|
||||||
|
if (removeRange.end <= range.start || removeRange.start >= range.end) {
|
||||||
|
next.push(range)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (removeRange.start > range.start) next.push({ start: range.start, end: removeRange.start })
|
||||||
|
if (removeRange.end < range.end) next.push({ start: removeRange.end, end: range.end })
|
||||||
|
})
|
||||||
|
return mergeTextRanges(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSpeechChunkRange(text, maxWords, activeChunk) {
|
||||||
|
if (activeChunk === null || activeChunk === undefined) return null
|
||||||
|
const words = []
|
||||||
|
const re = /\S+/g
|
||||||
|
let match
|
||||||
|
while ((match = re.exec(text || ''))) words.push({ start: match.index, end: match.index + match[0].length })
|
||||||
|
const startWord = activeChunk * maxWords
|
||||||
|
if (!words[startWord]) return null
|
||||||
|
const endWord = Math.min(startWord + maxWords - 1, words.length - 1)
|
||||||
|
return { start: words[startWord].start, end: words[endWord].end }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getManualHighlightSelection() {
|
||||||
|
const selection = window.getSelection?.()
|
||||||
|
if (!selection || selection.rangeCount === 0 || !selection.toString().trim()) return null
|
||||||
|
|
||||||
|
const offsetFromNode = (node, offset) => {
|
||||||
|
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node
|
||||||
|
const span = element?.closest?.('[data-manual-highlight-id]')
|
||||||
|
if (!span) return null
|
||||||
|
return {
|
||||||
|
id: span.dataset.manualHighlightId,
|
||||||
|
offset: Number(span.dataset.start || 0) + offset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
const start = offsetFromNode(range.startContainer, range.startOffset)
|
||||||
|
const end = offsetFromNode(range.endContainer, range.endOffset)
|
||||||
|
if (!start || !end || start.id !== end.id) return null
|
||||||
|
const ordered = start.offset <= end.offset ? { start: start.offset, end: end.offset } : { start: end.offset, end: start.offset }
|
||||||
|
if (ordered.end <= ordered.start) return null
|
||||||
|
return { id: start.id, ...ordered }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManualHighlightText({ text, textId, highlights = [], speechRange = null }) {
|
||||||
|
const ranges = mergeTextRanges(highlights)
|
||||||
|
const boundaries = new Set([0, (text || '').length])
|
||||||
|
ranges.forEach(range => { boundaries.add(range.start); boundaries.add(range.end) })
|
||||||
|
if (speechRange) { boundaries.add(speechRange.start); boundaries.add(speechRange.end) }
|
||||||
|
const points = [...boundaries]
|
||||||
|
.filter(point => point >= 0 && point <= (text || '').length)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
|
||||||
|
return points.slice(0, -1).map((start, i) => {
|
||||||
|
const end = points[i + 1]
|
||||||
|
if (end <= start) return null
|
||||||
|
const manuallyHighlighted = ranges.some(range => start >= range.start && end <= range.end)
|
||||||
|
const speechHighlighted = speechRange && start >= speechRange.start && end <= speechRange.end
|
||||||
|
const className = [
|
||||||
|
'manual-highlight-segment',
|
||||||
|
manuallyHighlighted ? 'manual-highlight-active' : '',
|
||||||
|
speechHighlighted ? 'speech-highlight-active' : '',
|
||||||
|
].filter(Boolean).join(' ')
|
||||||
|
return (
|
||||||
|
<span key={`${start}-${end}`} className={className} data-manual-highlight-id={textId} data-start={start}>
|
||||||
|
{text.slice(start, end)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function splitSpeechChunks(text, maxWords) {
|
function splitSpeechChunks(text, maxWords) {
|
||||||
const words = (text || '').trim().split(/\s+/).filter(Boolean)
|
const words = (text || '').trim().split(/\s+/).filter(Boolean)
|
||||||
if (!words.length) return []
|
if (!words.length) return []
|
||||||
|
|
@ -53,20 +144,6 @@ function buildQuestionSpeechText(question, index) {
|
||||||
return segments.map(s => s.text).join(' ')
|
return segments.map(s => s.text).join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
function HighlightedSpeechText({ text, activeChunk, maxWords }) {
|
|
||||||
const chunks = splitSpeechChunks(text, maxWords)
|
|
||||||
return chunks.map((chunk, i) => (
|
|
||||||
<span key={i} style={{
|
|
||||||
background: activeChunk === i ? '#fef08a' : 'transparent',
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: activeChunk === i ? '1px 3px' : 0,
|
|
||||||
transition: 'background 0.15s ease',
|
|
||||||
}}>
|
|
||||||
{chunk}{i < chunks.length - 1 ? ' ' : ''}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActiveSpeechSegment(audio, segments) {
|
function getActiveSpeechSegment(audio, segments) {
|
||||||
if (!segments.length) return null
|
if (!segments.length) return null
|
||||||
if (!audio || !Number.isFinite(audio.duration) || audio.duration <= 0) return 0
|
if (!audio || !Number.isFinite(audio.duration) || audio.duration <= 0) return 0
|
||||||
|
|
@ -296,6 +373,7 @@ export default function QuizPage() {
|
||||||
const [startedAt, setStartedAt] = useState(null)
|
const [startedAt, setStartedAt] = useState(null)
|
||||||
const [favorites, setFavorites] = useState([])
|
const [favorites, setFavorites] = useState([])
|
||||||
const [activeReadSegment, setActiveReadSegment] = useState(null)
|
const [activeReadSegment, setActiveReadSegment] = useState(null)
|
||||||
|
const [manualHighlights, setManualHighlights] = useState({})
|
||||||
const timerRef = useRef(null)
|
const timerRef = useRef(null)
|
||||||
const toastRef = useRef(null)
|
const toastRef = useRef(null)
|
||||||
const hasStarted = useRef(false)
|
const hasStarted = useRef(false)
|
||||||
|
|
@ -308,6 +386,21 @@ export default function QuizPage() {
|
||||||
toastRef.current = setTimeout(() => setToast(''), 3000)
|
toastRef.current = setTimeout(() => setToast(''), 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(`quiz-highlights:${id}`)
|
||||||
|
setManualHighlights(saved ? JSON.parse(saved) : {})
|
||||||
|
} catch {
|
||||||
|
setManualHighlights({})
|
||||||
|
}
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`quiz-highlights:${id}`, JSON.stringify(manualHighlights))
|
||||||
|
} catch { }
|
||||||
|
}, [id, manualHighlights])
|
||||||
|
|
||||||
const [leaveTarget, setLeaveTarget] = useState(null)
|
const [leaveTarget, setLeaveTarget] = useState(null)
|
||||||
const questions = quiz?.questions || []
|
const questions = quiz?.questions || []
|
||||||
const current = questions[currentIdx]
|
const current = questions[currentIdx]
|
||||||
|
|
@ -359,7 +452,7 @@ export default function QuizPage() {
|
||||||
}
|
}
|
||||||
}, [quizMode, questions, currentIdx, selectedVoice, voices.length, fetchTtsAudio])
|
}, [quizMode, questions, currentIdx, selectedVoice, voices.length, fetchTtsAudio])
|
||||||
|
|
||||||
const resumeQuiz = useCallback(async (saved) => {
|
const resumeQuiz = useCallback(async (saved, availableVoices = []) => {
|
||||||
const savedMode = saved.mode || saved.quizMode
|
const savedMode = saved.mode || saved.quizMode
|
||||||
const mode = savedMode === 'exam' ? 'exam' : 'study'
|
const mode = savedMode === 'exam' ? 'exam' : 'study'
|
||||||
const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0
|
const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0
|
||||||
|
|
@ -393,7 +486,7 @@ export default function QuizPage() {
|
||||||
setAnswers(savedAnswers)
|
setAnswers(savedAnswers)
|
||||||
setCurrentIdx(savedIdx)
|
setCurrentIdx(savedIdx)
|
||||||
setAttemptId(saved.attempt_id || saved.attemptId)
|
setAttemptId(saved.attempt_id || saved.attemptId)
|
||||||
if (saved.voice) setSelectedVoice(saved.voice)
|
if (saved.voice && availableVoices.some(v => v.id === saved.voice)) setSelectedVoice(saved.voice)
|
||||||
if (saved.started_at) setStartedAt(saved.started_at)
|
if (saved.started_at) setStartedAt(saved.started_at)
|
||||||
// Restore timer — calculate remaining from started_at + total_time
|
// Restore timer — calculate remaining from started_at + total_time
|
||||||
if (saved.total_time && saved.started_at) {
|
if (saved.total_time && saved.started_at) {
|
||||||
|
|
@ -436,7 +529,7 @@ export default function QuizPage() {
|
||||||
headers: { 'x-quiz-session': SESSION_ID },
|
headers: { 'x-quiz-session': SESSION_ID },
|
||||||
})
|
})
|
||||||
if (progressRes.data) {
|
if (progressRes.data) {
|
||||||
await resumeQuiz(progressRes.data)
|
await resumeQuiz(progressRes.data, voicesRes.data)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.response?.status === 409) {
|
if (err.response?.status === 409) {
|
||||||
|
|
@ -516,10 +609,50 @@ const timerStarted = timeLeft !== null
|
||||||
}, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => {})
|
}, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => {})
|
||||||
}, 1500) // debounce 1.5s
|
}, 1500) // debounce 1.5s
|
||||||
return () => clearTimeout(saveProgressRef.current)
|
return () => clearTimeout(saveProgressRef.current)
|
||||||
}, [answers, currentIdx, attemptId, timeLeft, startedAt, totalTime])
|
}, [answers, currentIdx, attemptId, quizMode, selectedVoice, timeLeft, startedAt, totalTime])
|
||||||
|
|
||||||
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
|
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
|
||||||
|
|
||||||
|
const updateManualHighlights = (action) => {
|
||||||
|
if (!current) return
|
||||||
|
const selected = getManualHighlightSelection()
|
||||||
|
if (!selected) {
|
||||||
|
showToast('Select question or option text first')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const [questionKey, fieldKey] = selected.id.split('::')
|
||||||
|
if (questionKey !== String(current.id)) {
|
||||||
|
showToast('Select text from the current question')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setManualHighlights(prev => {
|
||||||
|
const questionRanges = prev[current.id] || {}
|
||||||
|
const existing = questionRanges[fieldKey] || []
|
||||||
|
const nextRanges = action === 'remove'
|
||||||
|
? removeTextRange(existing, selected)
|
||||||
|
: mergeTextRanges([...existing, { start: selected.start, end: selected.end }])
|
||||||
|
const nextQuestion = { ...questionRanges, [fieldKey]: nextRanges }
|
||||||
|
if (!nextRanges.length) delete nextQuestion[fieldKey]
|
||||||
|
return { ...prev, [current.id]: nextQuestion }
|
||||||
|
})
|
||||||
|
window.getSelection?.().removeAllRanges()
|
||||||
|
showToast(action === 'remove' ? 'Highlight removed' : 'Highlighted')
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearCurrentHighlights = () => {
|
||||||
|
if (!current || !manualHighlights[current.id]) return
|
||||||
|
setManualHighlights(prev => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[current.id]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
showToast('Question highlights cleared')
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightsFor = (fieldKey) => manualHighlights[current?.id]?.[fieldKey] || []
|
||||||
|
|
||||||
|
const hasActiveTextSelection = () => Boolean(window.getSelection?.().toString().trim())
|
||||||
|
|
||||||
const safeNavigate = (targetIdx, { keepReadThrough = false } = {}) => {
|
const safeNavigate = (targetIdx, { keepReadThrough = false } = {}) => {
|
||||||
if (!keepReadThrough) setReadThrough(false)
|
if (!keepReadThrough) setReadThrough(false)
|
||||||
setCurrentIdx(targetIdx)
|
setCurrentIdx(targetIdx)
|
||||||
|
|
@ -577,7 +710,34 @@ const timerStarted = timeLeft !== null
|
||||||
const answeredCount = Object.keys(answers).length
|
const answeredCount = Object.keys(answers).length
|
||||||
const totalCount = questions.length
|
const totalCount = questions.length
|
||||||
const isLast = currentIdx === totalCount - 1
|
const isLast = currentIdx === totalCount - 1
|
||||||
|
const quizNavigation = (position = 'bottom') => (
|
||||||
|
<div className={`quiz-nav-controls quiz-nav-controls-${position}`}>
|
||||||
|
<button className="btn btn-secondary"
|
||||||
|
onClick={() => safeNavigate(Math.max(0, currentIdx - 1))}
|
||||||
|
disabled={currentIdx === 0}>← Prev</button>
|
||||||
|
|
||||||
|
<button className="quiz-nav-toggle btn btn-secondary btn-sm"
|
||||||
|
onClick={() => setNavOpen(v => !v)}>
|
||||||
|
{currentIdx + 1} / {totalCount} {navOpen ? '▼' : '▲'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isLast ? (
|
||||||
|
<button className="btn btn-primary" onClick={() => handleSubmit(false)} disabled={submitting}>
|
||||||
|
{submitting ? 'Submitting...' : 'Submit Quiz'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-primary" onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>Next →</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
const activeReadForCurrent = current && activeReadSegment?.questionId === current.id
|
const activeReadForCurrent = current && activeReadSegment?.questionId === current.id
|
||||||
|
const questionSpeechRange = current
|
||||||
|
? getSpeechChunkRange(
|
||||||
|
questionStem(current),
|
||||||
|
QUESTION_HIGHLIGHT_WORDS,
|
||||||
|
activeReadForCurrent && activeReadSegment.type === 'question' ? activeReadSegment.chunkIndex : null,
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
|
||||||
const toggleFavorite = async (questionId) => {
|
const toggleFavorite = async (questionId) => {
|
||||||
const isFavorited = favorites.includes(questionId)
|
const isFavorited = favorites.includes(questionId)
|
||||||
|
|
@ -670,10 +830,10 @@ const timerStarted = timeLeft !== null
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="card" style={{ marginBottom: 14 }}>
|
<div className="card quiz-header-card" style={{ marginBottom: 14 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ marginBottom: 2, fontSize: '1rem' }}>{quiz.title}</h2>
|
<h2 className="quiz-header-title">{quiz.title}</h2>
|
||||||
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
background: isStudy ? '#d1fae5' : '#e0e7ff',
|
background: isStudy ? '#d1fae5' : '#e0e7ff',
|
||||||
|
|
@ -710,22 +870,25 @@ const timerStarted = timeLeft !== null
|
||||||
<div className="fill" style={{ width: `${((currentIdx + 1) / totalCount) * 100}%` }} />
|
<div className="fill" style={{ width: `${((currentIdx + 1) / totalCount) * 100}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{quizNavigation('top')}
|
||||||
|
|
||||||
{/* Two-column layout: content + desktop sidebar */}
|
{/* Two-column layout: content + desktop sidebar */}
|
||||||
<div className="quiz-layout">
|
<div className="quiz-layout">
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
{current && (
|
{current && (
|
||||||
<div className="question-card" style={{
|
<div className="question-card" style={{
|
||||||
boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(250, 204, 21, 0.28)' : undefined,
|
boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(59, 130, 246, 0.22)' : undefined,
|
||||||
borderColor: activeReadForCurrent ? '#facc15' : undefined,
|
borderColor: activeReadForCurrent ? '#60a5fa' : undefined,
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 12 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 12 }}>
|
||||||
<h3 style={{ marginBottom: 0, flex: 1 }}>
|
<h3 style={{ marginBottom: 0, flex: 1 }}>
|
||||||
Q{currentIdx + 1}.{' '}
|
Q{currentIdx + 1}.{' '}
|
||||||
<HighlightedSpeechText
|
<ManualHighlightText
|
||||||
text={questionStem(current)}
|
text={questionStem(current)}
|
||||||
maxWords={QUESTION_HIGHLIGHT_WORDS}
|
textId={`${current.id}::question`}
|
||||||
activeChunk={activeReadForCurrent && activeReadSegment.type === 'question' ? activeReadSegment.chunkIndex : null}
|
highlights={highlightsFor('question')}
|
||||||
|
speechRange={questionSpeechRange}
|
||||||
/>
|
/>
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
|
|
@ -778,11 +941,17 @@ const timerStarted = timeLeft !== null
|
||||||
{readThrough ? 'Stop listen-through' : 'Listen through'}
|
{readThrough ? 'Stop listen-through' : 'Listen through'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{voices.length > 0 && (
|
<div className="manual-highlight-toolbar" aria-label="Question highlight tools">
|
||||||
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
<button className="btn btn-secondary btn-sm" onClick={() => updateManualHighlights('add')} title="Highlight selected text">
|
||||||
Preloading next {Math.min(TTS_PRELOAD_AHEAD, Math.max(totalCount - currentIdx - 1, 0))} question{Math.min(TTS_PRELOAD_AHEAD, Math.max(totalCount - currentIdx - 1, 0)) === 1 ? '' : 's'}
|
Highlight
|
||||||
</span>
|
</button>
|
||||||
)}
|
<button className="btn btn-secondary btn-sm" onClick={() => updateManualHighlights('remove')} title="Remove highlight from selected text">
|
||||||
|
Remove highlight
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={clearCurrentHighlights} disabled={!manualHighlights[current.id]} title="Clear all highlights on this question">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="badge" style={{ background: '#e0e7ff', color: '#3730a3', margin: '6px 0 12px', display: 'inline-block' }}>
|
<span className="badge" style={{ background: '#e0e7ff', color: '#3730a3', margin: '6px 0 12px', display: 'inline-block' }}>
|
||||||
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
|
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
|
||||||
|
|
@ -806,19 +975,26 @@ const timerStarted = timeLeft !== null
|
||||||
const activeOptionChunk = activeReadForCurrent && activeReadSegment.type === 'option' && activeReadSegment.index === i
|
const activeOptionChunk = activeReadForCurrent && activeReadSegment.type === 'option' && activeReadSegment.index === i
|
||||||
? activeReadSegment.chunkIndex
|
? activeReadSegment.chunkIndex
|
||||||
: null
|
: null
|
||||||
|
const optionSpeechRange = getSpeechChunkRange(opt, OPTION_HIGHLIGHT_WORDS, activeOptionChunk)
|
||||||
|
const optionFieldKey = `option-${i}`
|
||||||
return (
|
return (
|
||||||
<div key={i}
|
<div key={i}
|
||||||
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
|
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
|
||||||
onClick={() => !hasAnswered && setAnswer(current.id, opt)}
|
onClick={() => !hasAnswered && !hasActiveTextSelection() && setAnswer(current.id, opt)}
|
||||||
style={{
|
style={{
|
||||||
cursor: hasAnswered ? 'default' : 'pointer',
|
cursor: hasAnswered ? 'default' : 'pointer',
|
||||||
borderColor: activeOptionChunk !== null ? '#facc15' : undefined,
|
borderColor: activeOptionChunk !== null ? '#60a5fa' : undefined,
|
||||||
boxShadow: activeOptionChunk !== null ? '0 0 0 3px rgba(250, 204, 21, 0.25)' : undefined,
|
boxShadow: activeOptionChunk !== null ? '0 0 0 3px rgba(59, 130, 246, 0.2)' : undefined,
|
||||||
transition: 'background 0.15s ease, box-shadow 0.15s ease',
|
transition: 'background 0.15s ease, box-shadow 0.15s ease',
|
||||||
}}>
|
}}>
|
||||||
<span className="option-letter">{letter}</span>
|
<span className="option-letter">{letter}</span>
|
||||||
<span style={{ flex: 1 }}>
|
<span style={{ flex: 1 }}>
|
||||||
<HighlightedSpeechText text={opt} maxWords={OPTION_HIGHLIGHT_WORDS} activeChunk={activeOptionChunk} />
|
<ManualHighlightText
|
||||||
|
text={opt}
|
||||||
|
textId={`${current.id}::${optionFieldKey}`}
|
||||||
|
highlights={highlightsFor(optionFieldKey)}
|
||||||
|
speechRange={optionSpeechRange}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
{showCorrect && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}>✓ Correct</span>}
|
{showCorrect && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}>✓ Correct</span>}
|
||||||
{showWrong && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}>✗ Wrong</span>}
|
{showWrong && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}>✗ Wrong</span>}
|
||||||
|
|
@ -849,26 +1025,7 @@ const timerStarted = timeLeft !== null
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Prev / Next + mobile nav toggle */}
|
{quizNavigation('bottom')}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14, gap: 8 }}>
|
|
||||||
<button className="btn btn-secondary"
|
|
||||||
onClick={() => safeNavigate(Math.max(0, currentIdx - 1))}
|
|
||||||
disabled={currentIdx === 0}>← Prev</button>
|
|
||||||
|
|
||||||
{/* Mobile-only nav toggle */}
|
|
||||||
<button className="quiz-nav-toggle btn btn-secondary btn-sm"
|
|
||||||
onClick={() => setNavOpen(v => !v)}>
|
|
||||||
{currentIdx + 1} / {totalCount} {navOpen ? '▼' : '▲'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isLast ? (
|
|
||||||
<button className="btn btn-primary" onClick={() => handleSubmit(false)} disabled={submitting}>
|
|
||||||
{submitting ? 'Submitting...' : 'Submit Quiz'}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button className="btn btn-primary" onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>Next →</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile: collapsible number grid */}
|
{/* Mobile: collapsible number grid */}
|
||||||
{navOpen && (
|
{navOpen && (
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,12 @@ apply plugin: 'com.android.application'
|
||||||
android {
|
android {
|
||||||
namespace "com.pedshub.quiz"
|
namespace "com.pedshub.quiz"
|
||||||
compileSdk rootProject.ext.compileSdkVersion
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
|
def releaseKeystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")
|
||||||
|
def releaseKeystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||||
|
def releaseKeyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
||||||
|
def releaseKeyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
||||||
|
def hasReleaseSigning = releaseKeystoreFile && releaseKeystorePassword && releaseKeyAlias && releaseKeyPassword
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.pedshub.quiz"
|
applicationId "com.pedshub.quiz"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
|
@ -13,12 +19,25 @@ android {
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
signingConfigs {
|
||||||
|
release {
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
storeFile file(releaseKeystoreFile)
|
||||||
|
storePassword releaseKeystorePassword
|
||||||
|
keyAlias releaseKeyAlias
|
||||||
|
keyPassword releaseKeyPassword
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
minifyEnabled false
|
minifyEnabled false
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"allowMixedContent": true,
|
"allowMixedContent": false,
|
||||||
"backgroundColor": "#0f172a",
|
"backgroundColor": "#0f172a",
|
||||||
"webContentsDebuggingEnabled": true,
|
"webContentsDebuggingEnabled": false,
|
||||||
"androidScheme": "https"
|
"androidScheme": "https"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Server URL</label>
|
<label>Server URL</label>
|
||||||
<input type="url" id="server-url" placeholder="https://pedshub.com" autocapitalize="none" autocorrect="off" spellcheck="false">
|
<input type="url" id="server-url" placeholder="https://quiz.danvics.com" autocapitalize="none" autocorrect="off" spellcheck="false">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="btn-connect" class="btn-primary">Connect</button>
|
<button id="btn-connect" class="btn-primary">Connect</button>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
(function() {
|
(function() {
|
||||||
var STORAGE_KEY = 'pedshub_server_url';
|
var STORAGE_KEY = 'pedshub_server_url';
|
||||||
var DEFAULT_URL = 'https://pedshub.com';
|
var DEFAULT_URL = 'https://quiz.danvics.com';
|
||||||
|
|
||||||
var setupScreen = document.getElementById('setup-screen');
|
var setupScreen = document.getElementById('setup-screen');
|
||||||
var connectingScreen = document.getElementById('connecting-screen');
|
var connectingScreen = document.getElementById('connecting-screen');
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"allowMixedContent": true,
|
"allowMixedContent": false,
|
||||||
"backgroundColor": "#0f172a",
|
"backgroundColor": "#0f172a",
|
||||||
"webContentsDebuggingEnabled": true,
|
"webContentsDebuggingEnabled": false,
|
||||||
"androidScheme": "https"
|
"androidScheme": "https"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"allowMixedContent": true,
|
"allowMixedContent": false,
|
||||||
"backgroundColor": "#0f172a",
|
"backgroundColor": "#0f172a",
|
||||||
"webContentsDebuggingEnabled": true,
|
"webContentsDebuggingEnabled": false,
|
||||||
"androidScheme": "https"
|
"androidScheme": "https"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Server URL</label>
|
<label>Server URL</label>
|
||||||
<input type="url" id="server-url" placeholder="https://pedshub.com" autocapitalize="none" autocorrect="off" spellcheck="false">
|
<input type="url" id="server-url" placeholder="https://quiz.danvics.com" autocapitalize="none" autocorrect="off" spellcheck="false">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="btn-connect" class="btn-primary">Connect</button>
|
<button id="btn-connect" class="btn-primary">Connect</button>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
(function() {
|
(function() {
|
||||||
var STORAGE_KEY = 'pedshub_server_url';
|
var STORAGE_KEY = 'pedshub_server_url';
|
||||||
var DEFAULT_URL = 'https://pedshub.com';
|
var DEFAULT_URL = 'https://quiz.danvics.com';
|
||||||
|
|
||||||
var setupScreen = document.getElementById('setup-screen');
|
var setupScreen = document.getElementById('setup-screen');
|
||||||
var connectingScreen = document.getElementById('connecting-screen');
|
var connectingScreen = document.getElementById('connecting-screen');
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@
|
||||||
"sync": "npx cap sync",
|
"sync": "npx cap sync",
|
||||||
"android": "npx cap open android",
|
"android": "npx cap open android",
|
||||||
"ios": "npx cap open ios",
|
"ios": "npx cap open ios",
|
||||||
"build:android": "npx cap sync android && cd android && ./gradlew assembleRelease"
|
"build:android": "npx cap sync android && cd android && ./gradlew assembleRelease",
|
||||||
|
"build:android:release": "npx cap sync android && cd android && ./gradlew assembleRelease",
|
||||||
|
"build:android:debug": "npx cap sync android && cd android && ./gradlew assembleDebug"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^6.0.0",
|
"@capacitor/android": "^6.0.0",
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Server URL</label>
|
<label>Server URL</label>
|
||||||
<input type="url" id="server-url" placeholder="https://pedshub.com" autocapitalize="none" autocorrect="off" spellcheck="false">
|
<input type="url" id="server-url" placeholder="https://quiz.danvics.com" autocapitalize="none" autocorrect="off" spellcheck="false">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="btn-connect" class="btn-primary">Connect</button>
|
<button id="btn-connect" class="btn-primary">Connect</button>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
(function() {
|
(function() {
|
||||||
var STORAGE_KEY = 'pedshub_server_url';
|
var STORAGE_KEY = 'pedshub_server_url';
|
||||||
var DEFAULT_URL = 'https://pedshub.com';
|
var DEFAULT_URL = 'https://quiz.danvics.com';
|
||||||
|
|
||||||
var setupScreen = document.getElementById('setup-screen');
|
var setupScreen = document.getElementById('setup-screen');
|
||||||
var connectingScreen = document.getElementById('connecting-screen');
|
var connectingScreen = document.getElementById('connecting-screen');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue