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.
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
|
|
@ -36,7 +36,7 @@ AI-powered pediatric learning platform. Upload PDF study materials, automaticall
|
|||
| AI/LLM | LiteLLM proxy (Claude, Gemini, GPT, Bedrock, and more) |
|
||||
| Embeddings | Configurable — any LiteLLM proxy model or direct AWS Bedrock (1024-dim) |
|
||||
| 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) |
|
||||
| Email | SMTP (smtp2go or any SMTP server) |
|
||||
| Bot protection | Cloudflare Turnstile (runtime-configurable, no rebuild needed) |
|
||||
|
|
@ -311,7 +311,6 @@ Accessible at `/admin` for admin users. Three tabs:
|
|||
|
||||
### More Settings
|
||||
- **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)
|
||||
- **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
|
||||
|
|
@ -461,9 +460,8 @@ The landing page at `/home` uses the shared `Navbar` component. When not logged
|
|||
|
||||
## 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` |
|
||||
| AWS Polly | `polly/Joanna`, `polly/Matthew`, `polly/Amy` | `AWS_ACCESS_KEY_ID` + IAM `polly:SynthesizeSpeech` |
|
||||
| ElevenLabs | `elevenlabs/<voice-id>` | `ELEVENLABS_API_KEY` |
|
||||
| Google Cloud | `google/<voice-name>` | `GOOGLE_TTS_API_KEY` |
|
||||
| Kokoro via LiteLLM | `local-kokoro-tts:am_adam`, `local-kokoro-tts:af_bella` | `LITELLM_API_KEY` |
|
||||
|
||||
The part before `:` is the LiteLLM speech model route. The part after `:` is the Kokoro speaker voice.
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ def seed_admin():
|
|||
|
||||
def seed_default_models():
|
||||
"""Seed default AI model configs if none exist."""
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.models.ai_model_config import AIModelConfig
|
||||
db = SessionLocal()
|
||||
try:
|
||||
|
|
@ -91,8 +93,12 @@ def seed_default_models():
|
|||
|
||||
# Always ensure LiteLLM-routed local speech models exist (idempotent).
|
||||
tts_voices = [
|
||||
("Kokoro TTS (LiteLLM)", "local-kokoro-tts", True),
|
||||
("Qwen3 TTS (LiteLLM)", "local-qwen3-tts", False),
|
||||
("Kokoro Adam", "local-kokoro-tts:am_adam", True),
|
||||
("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 = [
|
||||
("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.
|
||||
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_default = False
|
||||
if old.model_id == "local-chatterbox-turbo":
|
||||
old.is_active = False
|
||||
old.is_default = False
|
||||
|
||||
has_default_tts = db.query(AIModelConfig).filter(
|
||||
AIModelConfig.task == "tts", AIModelConfig.is_default == True, AIModelConfig.is_active == True,
|
||||
).first() is not None
|
||||
if old.model_id == "local-qwen3-tts":
|
||||
old.is_active = False
|
||||
old.is_default = False
|
||||
|
||||
for name, model_id, _ in tts_voices:
|
||||
exists = db.query(AIModelConfig).filter(AIModelConfig.model_id == model_id).first()
|
||||
if not exists:
|
||||
is_def = not has_default_tts
|
||||
db.add(AIModelConfig(name=name, model_id=model_id, task="tts", is_active=True, is_default=is_def))
|
||||
if is_def:
|
||||
has_default_tts = True
|
||||
db.execute(text("""
|
||||
INSERT INTO ai_model_configs (name, model_id, task, is_active, is_default, created_at)
|
||||
VALUES (:name, :model_id, 'tts', true, false, NOW())
|
||||
ON CONFLICT (model_id, task) DO NOTHING
|
||||
"""), {"name": name, "model_id": model_id})
|
||||
|
||||
# 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})
|
||||
local_default = db.query(AIModelConfig).filter(
|
||||
AIModelConfig.task == "tts",
|
||||
AIModelConfig.model_id == "local-kokoro-tts",
|
||||
AIModelConfig.model_id == "local-kokoro-tts:am_adam",
|
||||
).first()
|
||||
if local_default:
|
||||
local_default.is_active = True
|
||||
|
|
@ -143,7 +150,11 @@ def seed_default_models():
|
|||
).first()
|
||||
if not exists:
|
||||
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:
|
||||
has_default = True
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ def list_available_models(
|
|||
class LiteLLMSearchRequest(BaseModel):
|
||||
api_key: str | None = None
|
||||
api_base: str | None = None
|
||||
mode: str | None = None
|
||||
|
||||
|
||||
@router.post("/litellm/models")
|
||||
|
|
@ -155,8 +156,16 @@ def search_litellm_models(
|
|||
if base:
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {key}"} if key else {}
|
||||
resp = httpx.post(f"{base}/v1/models", headers=headers, timeout=10) if False else \
|
||||
httpx.get(f"{base}/v1/models", headers=headers, timeout=10)
|
||||
if data.mode:
|
||||
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()
|
||||
models = sorted([m["id"] for m in resp.json().get("data", [])])
|
||||
return {"models": models, "source": base}
|
||||
|
|
@ -326,6 +335,47 @@ class TTSVoiceSearchRequest(BaseModel):
|
|||
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")
|
||||
def search_tts_voices(
|
||||
data: TTSVoiceSearchRequest,
|
||||
|
|
@ -351,15 +401,20 @@ def search_tts_voices(
|
|||
resp = httpx.get(f"{base}/model/info", headers=headers, timeout=10)
|
||||
resp.raise_for_status()
|
||||
models = resp.json().get("data", [])
|
||||
return {"voices": [
|
||||
{
|
||||
"model_id": m.get("model_name"),
|
||||
"name": m.get("model_name"),
|
||||
voices = []
|
||||
for m in models:
|
||||
model_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"},
|
||||
}
|
||||
for m in models
|
||||
if m.get("model_name") and (m.get("model_info") or {}).get("mode") == "audio_speech"
|
||||
]}
|
||||
})
|
||||
return {"voices": voices}
|
||||
except HTTPException:
|
||||
raise
|
||||
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)
|
||||
registration_enabled = r.get("settings:registration_enabled")
|
||||
embedding_model = r.get("settings:embedding_model")
|
||||
polly_enabled = r.get("settings:polly_enabled")
|
||||
sso_only = r.get("settings:sso_only")
|
||||
return {
|
||||
"registration_enabled": registration_enabled != "false",
|
||||
"embedding_model": embedding_model or settings.LITELLM_EMBEDDING_MODEL or "",
|
||||
"polly_enabled": polly_enabled != "false",
|
||||
"sso_only": sso_only == "true",
|
||||
"sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
|
||||
"sso_provider_name": settings.OIDC_PROVIDER_NAME,
|
||||
|
|
@ -393,7 +446,6 @@ def get_settings(admin: User = Depends(require_admin)):
|
|||
return {
|
||||
"registration_enabled": True,
|
||||
"embedding_model": settings.LITELLM_EMBEDDING_MODEL or "",
|
||||
"polly_enabled": True,
|
||||
"sso_only": False,
|
||||
"sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
|
||||
"sso_provider_name": settings.OIDC_PROVIDER_NAME,
|
||||
|
|
@ -417,10 +469,6 @@ def update_settings(
|
|||
if "embedding_model" in settings_data:
|
||||
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:
|
||||
value = "true" if settings_data["sso_only"] else "false"
|
||||
r.set("settings:sso_only", value)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ from fastapi.responses import Response
|
|||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
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
|
||||
|
||||
|
||||
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]:
|
||||
config = db.query(AIModelConfig).filter(
|
||||
AIModelConfig.task == "tts",
|
||||
|
|
@ -51,7 +40,7 @@ def _default_tts_model(db: Session) -> tuple[str, str | None]:
|
|||
).first()
|
||||
if config:
|
||||
return config.model_id, config.api_key or None
|
||||
return "local-kokoro-tts", None
|
||||
return "local-kokoro-tts:am_adam", None
|
||||
|
||||
|
||||
@router.get("/voices")
|
||||
|
|
@ -65,8 +54,6 @@ def get_voices(
|
|||
AIModelConfig.is_active == True,
|
||||
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()
|
||||
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]
|
||||
|
||||
if request.voice and request.voice.startswith("local-"):
|
||||
if request.voice.startswith("polly/") and not _polly_enabled():
|
||||
raise HTTPException(status_code=400, detail="AWS Polly is currently disabled")
|
||||
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == request.voice).first()
|
||||
model_id = config.model_id if config else request.voice
|
||||
api_key = config.api_key if (config and config.api_key) else None
|
||||
config = db.query(AIModelConfig).filter(
|
||||
AIModelConfig.task == "tts",
|
||||
AIModelConfig.is_active == True,
|
||||
AIModelConfig.model_id == request.voice,
|
||||
).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:
|
||||
model_id, api_key = _default_tts_model(db)
|
||||
|
||||
|
|
|
|||
|
|
@ -380,16 +380,15 @@ def generate_tts_audio(
|
|||
model_id: str | None = None,
|
||||
api_key: str | None = 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:
|
||||
tts-1:alloy → OpenAI TTS (voice after colon)
|
||||
tts-1-hd:nova → OpenAI TTS HD
|
||||
elevenlabs/<voice> → ElevenLabs
|
||||
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)
|
||||
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
|
||||
|
||||
|
|
@ -397,6 +396,10 @@ def generate_tts_audio(
|
|||
|
||||
# ── Local LiteLLM TTS models ─────────────────────────────────
|
||||
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
|
||||
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
|
||||
if not base:
|
||||
|
|
@ -406,7 +409,7 @@ def generate_tts_audio(
|
|||
resp = httpx.post(
|
||||
f"{base}/v1/audio/speech",
|
||||
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,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
|
@ -480,34 +483,6 @@ def generate_tts_audio(
|
|||
logger.error(f"Google TTS failed: {e}")
|
||||
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) ────────────────────────────────────────
|
||||
# model_id may encode voice as "tts-1:nova", "tts-1-hd:alloy", etc.
|
||||
clean_model = use_model.replace("openai/", "")
|
||||
|
|
|
|||
|
|
@ -705,7 +705,7 @@ Convert text to speech audio.
|
|||
}
|
||||
```
|
||||
- **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).
|
||||
|
||||
- **Auth:** Admin
|
||||
- **Response:** `{"registration_enabled": bool, "embedding_model": "string", "polly_enabled": bool}`
|
||||
- **Response:** `{"registration_enabled": bool, "embedding_model": "string"}`
|
||||
|
||||
#### PUT `/api/admin/settings`
|
||||
|
||||
Update system settings.
|
||||
|
||||
- **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"}`
|
||||
|
||||
### 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 |
|
||||
| `elevenlabs/<voice_id>` | ElevenLabs | Uses `eleven_turbo_v2_5` model |
|
||||
| `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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@
|
|||
--navbar-fg: #f0e8d8;
|
||||
--badge-radius: 4px;
|
||||
--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;
|
||||
}
|
||||
|
||||
|
|
@ -71,9 +71,9 @@ body[data-theme="markdown"] {
|
|||
font-size: 15.5px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
[data-theme="markdown"] .navbar .logo { color: #e0a84a; font-family: var(--font-heading); letter-spacing: 0; }
|
||||
[data-theme="markdown"] h1 { font-family: var(--font-heading); font-weight: 700; letter-spacing: -0.02em; }
|
||||
[data-theme="markdown"] h2, [data-theme="markdown"] .card h2 { font-family: var(--font-heading); font-weight: 600; }
|
||||
[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: 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: 680; letter-spacing: -0.02em; line-height: 1.2; }
|
||||
[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"] .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; }
|
||||
|
||||
.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 ────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
|
|
@ -182,6 +210,27 @@ body {
|
|||
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; }
|
||||
.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 */
|
||||
.question-card .options { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
|
@ -189,7 +238,7 @@ body {
|
|||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 13px 16px; border: 1.5px solid var(--border); border-radius: 8px;
|
||||
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;
|
||||
}
|
||||
.question-card .option:hover { background: var(--option-hover); border-color: var(--text-subtle); }
|
||||
|
|
@ -349,6 +398,9 @@ body {
|
|||
/* Mobile quiz: hide sidebar, show toggle */
|
||||
.quiz-sidebar { display: none; }
|
||||
.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) ───────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export default function AdminPage() {
|
|||
const [tab, setTab] = useState('models')
|
||||
const [users, setUsers] = 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 [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
|
@ -182,12 +182,11 @@ export default function AdminPage() {
|
|||
const searchEmbeddingModels = async () => {
|
||||
setEmbedSearchError('')
|
||||
setEmbedSearchResults([])
|
||||
setEmbedSearchFilter('')
|
||||
setEmbedSearchLoading(true)
|
||||
try {
|
||||
const res = await api.post('/admin/litellm/models', {})
|
||||
const all = res.data.models || []
|
||||
// Filter to likely embedding models
|
||||
setEmbedSearchResults(all)
|
||||
const res = await api.post('/admin/litellm/models', { mode: 'embedding' })
|
||||
setEmbedSearchResults(res.data.models || [])
|
||||
} catch (err) {
|
||||
setEmbedSearchError(err.response?.data?.detail || 'Failed to query models')
|
||||
} finally {
|
||||
|
|
@ -647,37 +646,6 @@ export default function AdminPage() {
|
|||
</label>
|
||||
</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 */}
|
||||
{settings.sso_configured && (
|
||||
<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 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) {
|
||||
const words = (text || '').trim().split(/\s+/).filter(Boolean)
|
||||
if (!words.length) return []
|
||||
|
|
@ -53,20 +144,6 @@ function buildQuestionSpeechText(question, index) {
|
|||
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) {
|
||||
if (!segments.length) return null
|
||||
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 [favorites, setFavorites] = useState([])
|
||||
const [activeReadSegment, setActiveReadSegment] = useState(null)
|
||||
const [manualHighlights, setManualHighlights] = useState({})
|
||||
const timerRef = useRef(null)
|
||||
const toastRef = useRef(null)
|
||||
const hasStarted = useRef(false)
|
||||
|
|
@ -308,6 +386,21 @@ export default function QuizPage() {
|
|||
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 questions = quiz?.questions || []
|
||||
const current = questions[currentIdx]
|
||||
|
|
@ -359,7 +452,7 @@ export default function QuizPage() {
|
|||
}
|
||||
}, [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 mode = savedMode === 'exam' ? 'exam' : 'study'
|
||||
const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0
|
||||
|
|
@ -393,7 +486,7 @@ export default function QuizPage() {
|
|||
setAnswers(savedAnswers)
|
||||
setCurrentIdx(savedIdx)
|
||||
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)
|
||||
// Restore timer — calculate remaining from started_at + total_time
|
||||
if (saved.total_time && saved.started_at) {
|
||||
|
|
@ -436,7 +529,7 @@ export default function QuizPage() {
|
|||
headers: { 'x-quiz-session': SESSION_ID },
|
||||
})
|
||||
if (progressRes.data) {
|
||||
await resumeQuiz(progressRes.data)
|
||||
await resumeQuiz(progressRes.data, voicesRes.data)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.response?.status === 409) {
|
||||
|
|
@ -516,10 +609,50 @@ const timerStarted = timeLeft !== null
|
|||
}, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => {})
|
||||
}, 1500) // debounce 1.5s
|
||||
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 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 } = {}) => {
|
||||
if (!keepReadThrough) setReadThrough(false)
|
||||
setCurrentIdx(targetIdx)
|
||||
|
|
@ -577,7 +710,34 @@ const timerStarted = timeLeft !== null
|
|||
const answeredCount = Object.keys(answers).length
|
||||
const totalCount = questions.length
|
||||
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 questionSpeechRange = current
|
||||
? getSpeechChunkRange(
|
||||
questionStem(current),
|
||||
QUESTION_HIGHLIGHT_WORDS,
|
||||
activeReadForCurrent && activeReadSegment.type === 'question' ? activeReadSegment.chunkIndex : null,
|
||||
)
|
||||
: null
|
||||
|
||||
const toggleFavorite = async (questionId) => {
|
||||
const isFavorited = favorites.includes(questionId)
|
||||
|
|
@ -670,10 +830,10 @@ const timerStarted = timeLeft !== null
|
|||
)}
|
||||
|
||||
{/* 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>
|
||||
<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' }}>
|
||||
<span style={{
|
||||
background: isStudy ? '#d1fae5' : '#e0e7ff',
|
||||
|
|
@ -710,22 +870,25 @@ const timerStarted = timeLeft !== null
|
|||
<div className="fill" style={{ width: `${((currentIdx + 1) / totalCount) * 100}%` }} />
|
||||
</div>
|
||||
|
||||
{quizNavigation('top')}
|
||||
|
||||
{/* Two-column layout: content + desktop sidebar */}
|
||||
<div className="quiz-layout">
|
||||
{/* Main content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{current && (
|
||||
<div className="question-card" style={{
|
||||
boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(250, 204, 21, 0.28)' : undefined,
|
||||
borderColor: activeReadForCurrent ? '#facc15' : undefined,
|
||||
boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(59, 130, 246, 0.22)' : undefined,
|
||||
borderColor: activeReadForCurrent ? '#60a5fa' : undefined,
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 12 }}>
|
||||
<h3 style={{ marginBottom: 0, flex: 1 }}>
|
||||
Q{currentIdx + 1}.{' '}
|
||||
<HighlightedSpeechText
|
||||
<ManualHighlightText
|
||||
text={questionStem(current)}
|
||||
maxWords={QUESTION_HIGHLIGHT_WORDS}
|
||||
activeChunk={activeReadForCurrent && activeReadSegment.type === 'question' ? activeReadSegment.chunkIndex : null}
|
||||
textId={`${current.id}::question`}
|
||||
highlights={highlightsFor('question')}
|
||||
speechRange={questionSpeechRange}
|
||||
/>
|
||||
</h3>
|
||||
<button
|
||||
|
|
@ -778,11 +941,17 @@ const timerStarted = timeLeft !== null
|
|||
{readThrough ? 'Stop listen-through' : 'Listen through'}
|
||||
</button>
|
||||
)}
|
||||
{voices.length > 0 && (
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
||||
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'}
|
||||
</span>
|
||||
)}
|
||||
<div className="manual-highlight-toolbar" aria-label="Question highlight tools">
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => updateManualHighlights('add')} title="Highlight selected text">
|
||||
Highlight
|
||||
</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>
|
||||
<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'}
|
||||
|
|
@ -806,19 +975,26 @@ const timerStarted = timeLeft !== null
|
|||
const activeOptionChunk = activeReadForCurrent && activeReadSegment.type === 'option' && activeReadSegment.index === i
|
||||
? activeReadSegment.chunkIndex
|
||||
: null
|
||||
const optionSpeechRange = getSpeechChunkRange(opt, OPTION_HIGHLIGHT_WORDS, activeOptionChunk)
|
||||
const optionFieldKey = `option-${i}`
|
||||
return (
|
||||
<div key={i}
|
||||
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
|
||||
onClick={() => !hasAnswered && setAnswer(current.id, opt)}
|
||||
onClick={() => !hasAnswered && !hasActiveTextSelection() && setAnswer(current.id, opt)}
|
||||
style={{
|
||||
cursor: hasAnswered ? 'default' : 'pointer',
|
||||
borderColor: activeOptionChunk !== null ? '#facc15' : undefined,
|
||||
boxShadow: activeOptionChunk !== null ? '0 0 0 3px rgba(250, 204, 21, 0.25)' : undefined,
|
||||
borderColor: activeOptionChunk !== null ? '#60a5fa' : 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',
|
||||
}}>
|
||||
<span className="option-letter">{letter}</span>
|
||||
<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>
|
||||
{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>}
|
||||
|
|
@ -849,26 +1025,7 @@ const timerStarted = timeLeft !== null
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Prev / Next + mobile nav toggle */}
|
||||
<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>
|
||||
{quizNavigation('bottom')}
|
||||
|
||||
{/* Mobile: collapsible number grid */}
|
||||
{navOpen && (
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@ apply plugin: 'com.android.application'
|
|||
android {
|
||||
namespace "com.pedshub.quiz"
|
||||
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 {
|
||||
applicationId "com.pedshub.quiz"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
|
|
@ -13,12 +19,25 @@ android {
|
|||
aaptOptions {
|
||||
// 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
|
||||
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 {
|
||||
release {
|
||||
minifyEnabled false
|
||||
if (hasReleaseSigning) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@
|
|||
}
|
||||
},
|
||||
"android": {
|
||||
"allowMixedContent": true,
|
||||
"allowMixedContent": false,
|
||||
"backgroundColor": "#0f172a",
|
||||
"webContentsDebuggingEnabled": true,
|
||||
"webContentsDebuggingEnabled": false,
|
||||
"androidScheme": "https"
|
||||
},
|
||||
"ios": {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
<div class="form-group">
|
||||
<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>
|
||||
|
||||
<button id="btn-connect" class="btn-primary">Connect</button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
(function() {
|
||||
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 connectingScreen = document.getElementById('connecting-screen');
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@
|
|||
}
|
||||
},
|
||||
"android": {
|
||||
"allowMixedContent": true,
|
||||
"allowMixedContent": false,
|
||||
"backgroundColor": "#0f172a",
|
||||
"webContentsDebuggingEnabled": true,
|
||||
"webContentsDebuggingEnabled": false,
|
||||
"androidScheme": "https"
|
||||
},
|
||||
"ios": {
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@
|
|||
}
|
||||
},
|
||||
"android": {
|
||||
"allowMixedContent": true,
|
||||
"allowMixedContent": false,
|
||||
"backgroundColor": "#0f172a",
|
||||
"webContentsDebuggingEnabled": true,
|
||||
"webContentsDebuggingEnabled": false,
|
||||
"androidScheme": "https"
|
||||
},
|
||||
"ios": {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
<div class="form-group">
|
||||
<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>
|
||||
|
||||
<button id="btn-connect" class="btn-primary">Connect</button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
(function() {
|
||||
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 connectingScreen = document.getElementById('connecting-screen');
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@
|
|||
"sync": "npx cap sync",
|
||||
"android": "npx cap open android",
|
||||
"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": {
|
||||
"@capacitor/android": "^6.0.0",
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
<div class="form-group">
|
||||
<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>
|
||||
|
||||
<button id="btn-connect" class="btn-primary">Connect</button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
(function() {
|
||||
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 connectingScreen = document.getElementById('connecting-screen');
|
||||
|
|
|
|||
Loading…
Reference in a new issue