harden database interactions and improve error handling
This commit is contained in:
parent
e1f168a302
commit
b9f7d1ff25
16 changed files with 980 additions and 159 deletions
|
|
@ -80,8 +80,16 @@ onMounted(() => {
|
|||
onUnmounted(() => { if (tipTimer) clearInterval(tipTimer); });
|
||||
|
||||
|
||||
function getTopicProgress(topicId: string): any {
|
||||
if (!import.meta.client) return null;
|
||||
try {
|
||||
const p = localStorage.getItem(`progress:${topicId}`);
|
||||
return p ? JSON.parse(p) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function completedCount(): number {
|
||||
return course.value?.topics?.filter((t: any) => t.progress?.lessonComplete).length ?? 0;
|
||||
return course.value?.topics?.filter((t: any) => getTopicProgress(t.id)?.lessonComplete).length ?? 0;
|
||||
}
|
||||
|
||||
const auditReport = computed(() => {
|
||||
|
|
@ -110,6 +118,55 @@ function scoreColor(score: number): string {
|
|||
const editingTitle = ref(false);
|
||||
const titleDraft = ref("");
|
||||
|
||||
// ── dev dropdown ──────────────────────────────────────────────────────────
|
||||
|
||||
const devOpenId = ref<string | null>(null);
|
||||
const devKey = ref("");
|
||||
const devPromptTopicId = ref<string | null>(null);
|
||||
const devPendingAction = ref<"regenerate-lesson" | "regenerate-audio" | null>(null);
|
||||
const devLoading = ref(false);
|
||||
const devMsg = ref<{ text: string; ok: boolean } | null>(null);
|
||||
|
||||
function openDevPrompt(topicId: string, action: "regenerate-lesson" | "regenerate-audio") {
|
||||
devOpenId.value = null;
|
||||
devPromptTopicId.value = topicId;
|
||||
devPendingAction.value = action;
|
||||
devMsg.value = null;
|
||||
}
|
||||
|
||||
async function runDevAction() {
|
||||
if (!devPromptTopicId.value || !devPendingAction.value || devLoading.value) return;
|
||||
devLoading.value = true;
|
||||
devMsg.value = null;
|
||||
|
||||
try {
|
||||
const result = await $fetch<{ status: string }>(
|
||||
`/api/topics/${devPromptTopicId.value}/dev/${devPendingAction.value}`,
|
||||
{ method: "POST", headers: { "x-license-key": devKey.value } }
|
||||
);
|
||||
|
||||
if (result.status === "ready") {
|
||||
devMsg.value = { text: "Done.", ok: true };
|
||||
await refresh();
|
||||
setTimeout(() => {
|
||||
devPromptTopicId.value = null;
|
||||
devKey.value = "";
|
||||
devPendingAction.value = null;
|
||||
devMsg.value = null;
|
||||
}, 900);
|
||||
} else {
|
||||
devMsg.value = { text: "Returned an error — check server logs.", ok: false };
|
||||
}
|
||||
} catch (err: any) {
|
||||
devMsg.value = {
|
||||
text: err?.statusCode === 401 ? "Invalid license key." : "Request failed — check server logs.",
|
||||
ok: false,
|
||||
};
|
||||
} finally {
|
||||
devLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEditTitle() {
|
||||
titleDraft.value = course.value?.title ?? "";
|
||||
editingTitle.value = true;
|
||||
|
|
@ -332,75 +389,93 @@ async function saveTitle() {
|
|||
|
||||
<!-- topic list -->
|
||||
<div class="topic-list">
|
||||
<TransitionGroup name="topic-appear" tag="div" class="topic-list-inner">
|
||||
<NuxtLink
|
||||
<div class="topic-list-inner">
|
||||
<div
|
||||
v-for="(topic, idx) in course.topics"
|
||||
:key="topic.id"
|
||||
:to="`/learn/${topic.id}`"
|
||||
class="topic-row topic-row--unlocked"
|
||||
:class="{
|
||||
'topic-row--complete': topic.progress?.lessonComplete,
|
||||
'topic-row--available': !topic.progress?.lessonComplete && topic.status === 'ready',
|
||||
'topic-row--pending': topic.status === 'pending' || topic.status === 'generating',
|
||||
}"
|
||||
class="topic-item"
|
||||
>
|
||||
<span class="topic-index" :style="topic.status === 'pending' ? 'opacity:0.4' : ''">
|
||||
{{ String(idx + 1).padStart(2, "0") }}
|
||||
</span>
|
||||
<NuxtLink
|
||||
:to="`/learn/${topic.id}`"
|
||||
class="topic-row topic-row--unlocked"
|
||||
:class="{
|
||||
'topic-row--complete': getTopicProgress(topic.id)?.lessonComplete,
|
||||
'topic-row--available': !getTopicProgress(topic.id)?.lessonComplete && topic.status === 'ready',
|
||||
'topic-row--pending': topic.status === 'pending' || topic.status === 'generating',
|
||||
}"
|
||||
>
|
||||
<span class="topic-index" :style="topic.status === 'pending' ? 'opacity:0.4' : ''">
|
||||
{{ String(idx + 1).padStart(2, "0") }}
|
||||
</span>
|
||||
|
||||
<div class="topic-info">
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||||
<span class="topic-name" :style="topic.status === 'pending' ? 'opacity:0.55' : ''">{{ topic.title }}</span>
|
||||
<span v-if="topic.progress?.lessonComplete" class="done-badge">done</span>
|
||||
<span
|
||||
v-if="topic.progress?.lessonComplete && topic.progress?.tookBranches"
|
||||
class="branch-dot"
|
||||
title="You needed a little extra help here — that's completely normal."
|
||||
/>
|
||||
<span v-if="topic.status === 'pending'" class="topic-status-label topic-status-label--pending">Not yet generated</span>
|
||||
<span v-else-if="topic.status === 'generating'" class="topic-status-label topic-status-label--generating">
|
||||
<span class="generating-dot" />Generating...
|
||||
</span>
|
||||
<span v-else-if="topic.status === 'error'" class="topic-status-label topic-status-label--error">Error</span>
|
||||
<div class="topic-info">
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||||
<span class="topic-name" :style="topic.status === 'pending' ? 'opacity:0.55' : ''">{{ topic.title }}</span>
|
||||
<span v-if="getTopicProgress(topic.id)?.lessonComplete" class="done-badge">done</span>
|
||||
<span
|
||||
v-if="getTopicProgress(topic.id)?.lessonComplete && getTopicProgress(topic.id)?.tookBranches"
|
||||
class="branch-dot"
|
||||
title="You needed a little extra help here — that's completely normal."
|
||||
/>
|
||||
<span v-if="topic.status === 'pending'" class="topic-status-label topic-status-label--pending">Not yet generated</span>
|
||||
<span v-else-if="topic.status === 'generating'" class="topic-status-label topic-status-label--generating">
|
||||
<span class="generating-dot" />Generating...
|
||||
</span>
|
||||
<span v-else-if="topic.status === 'error'" class="topic-status-label topic-status-label--error">Error</span>
|
||||
</div>
|
||||
|
||||
<div class="topic-meta">
|
||||
<div class="diff-dots">
|
||||
<span
|
||||
v-for="d in 5"
|
||||
:key="d"
|
||||
class="diff-dot"
|
||||
:class="d <= topic.difficulty ? 'diff-dot--filled' : 'diff-dot--empty'"
|
||||
/>
|
||||
</div>
|
||||
<span class="diff-label">
|
||||
{{ ['', 'intro', 'basic', 'intermediate', 'advanced', 'expert'][topic.difficulty] }}
|
||||
</span>
|
||||
<span v-if="getTopicProgress(topic.id)?.quizScore != null" class="diff-label">
|
||||
· quiz {{ getTopicProgress(topic.id).quizScore }}/4
|
||||
</span>
|
||||
<span
|
||||
v-if="topic.lessonCost != null"
|
||||
class="lesson-cost-badge"
|
||||
title="AI generation cost for this lesson"
|
||||
>
|
||||
~${{ topic.lessonCost.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topic-meta">
|
||||
<div class="diff-dots">
|
||||
<span
|
||||
v-for="d in 5"
|
||||
:key="d"
|
||||
class="diff-dot"
|
||||
:class="d <= topic.difficulty ? 'diff-dot--filled' : 'diff-dot--empty'"
|
||||
/>
|
||||
</div>
|
||||
<span class="diff-label">
|
||||
{{ ['', 'intro', 'basic', 'intermediate', 'advanced', 'expert'][topic.difficulty] }}
|
||||
</span>
|
||||
<span v-if="topic.progress?.quizScore != null" class="diff-label">
|
||||
· quiz {{ topic.progress.quizScore }}/4
|
||||
</span>
|
||||
<span
|
||||
v-if="topic.progress?.lessonComplete && topic.lessonCost != null"
|
||||
class="lesson-cost-badge"
|
||||
title="AI generation cost for this lesson"
|
||||
>
|
||||
~${{ topic.lessonCost.toFixed(2) }}
|
||||
</span>
|
||||
<svg v-if="topic.status !== 'error'" class="topic-arrow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
<button
|
||||
v-else
|
||||
class="topic-retry-btn"
|
||||
@click.prevent="$fetch(`/api/topics/${topic.id}/generate`, { method: 'POST' }).then(() => refresh())"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- dev dropdown — sibling of NuxtLink so clicks don't trigger navigation -->
|
||||
<div class="dev-wrap">
|
||||
<button
|
||||
class="dev-cog"
|
||||
:class="{ 'dev-cog--open': devOpenId === topic.id }"
|
||||
@click="devOpenId = devOpenId === topic.id ? null : topic.id"
|
||||
title="Dev tools"
|
||||
>⚙</button>
|
||||
<div v-if="devOpenId === topic.id" class="dev-drop">
|
||||
<button class="dev-item" @click="openDevPrompt(topic.id, 'regenerate-lesson')">Regenerate lesson</button>
|
||||
<button class="dev-item" @click="openDevPrompt(topic.id, 'regenerate-audio')">Regenerate audio</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg v-if="topic.status !== 'error'" class="topic-arrow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
<button
|
||||
v-else
|
||||
class="topic-retry-btn"
|
||||
@click.prevent="$fetch(`/api/topics/${topic.id}/generate`, { method: 'POST' }).then(() => refresh())"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</NuxtLink>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
@ -408,6 +483,32 @@ async function saveTitle() {
|
|||
</template>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- dev key prompt modal -->
|
||||
<Transition name="dev-fade">
|
||||
<div v-if="devPromptTopicId" class="dev-overlay" @click.self="devPromptTopicId = null">
|
||||
<div class="dev-modal">
|
||||
<p class="dev-modal-title">{{ devPendingAction === 'regenerate-lesson' ? 'Regenerate Lesson' : 'Regenerate Audio' }}</p>
|
||||
<p class="dev-modal-sub">Enter your license key to continue.</p>
|
||||
<input
|
||||
v-model="devKey"
|
||||
class="dev-modal-input"
|
||||
type="password"
|
||||
placeholder="License key"
|
||||
@keydown.enter="runDevAction"
|
||||
@keydown.esc="devPromptTopicId = null"
|
||||
/>
|
||||
<p v-if="devMsg" class="dev-modal-msg" :class="devMsg.ok ? 'dev-modal-msg--ok' : 'dev-modal-msg--err'">{{ devMsg.text }}</p>
|
||||
<div class="dev-modal-actions">
|
||||
<button class="dev-modal-cancel" :disabled="devLoading" @click="devPromptTopicId = null">Cancel</button>
|
||||
<button class="dev-modal-run" :disabled="devLoading || !devKey" @click="runDevAction">
|
||||
{{ devLoading ? 'Running…' : 'Run' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</main>
|
||||
</template>
|
||||
|
||||
|
|
@ -640,6 +741,10 @@ async function saveTitle() {
|
|||
/* topic list */
|
||||
.topic-list {}
|
||||
|
||||
.topic-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.topic-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -869,6 +974,122 @@ async function saveTitle() {
|
|||
font-size: 10px; color: var(--text-3); letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* dev dropdown */
|
||||
.dev-wrap {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 2.5rem;
|
||||
transform: translateY(-50%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.dev-cog {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
width: 26px; height: 26px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
color: var(--text-3);
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
opacity: 0;
|
||||
}
|
||||
.topic-item:hover .dev-cog,
|
||||
.dev-cog--open { opacity: 1; }
|
||||
.dev-cog:hover,
|
||||
.dev-cog--open { border-color: var(--border-2); color: var(--text); }
|
||||
|
||||
.dev-drop {
|
||||
position: absolute;
|
||||
top: calc(100% + 5px);
|
||||
right: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px oklch(0% 0 0 / 0.1);
|
||||
min-width: 170px;
|
||||
z-index: 50;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dev-item {
|
||||
display: block; width: 100%; text-align: left;
|
||||
background: none; border: none;
|
||||
padding: 9px 13px;
|
||||
font-size: 12px; color: var(--text-2);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.dev-item:hover { background: var(--surface-2); }
|
||||
.dev-item + .dev-item { border-top: 1px solid var(--border); }
|
||||
|
||||
/* dev modal */
|
||||
.dev-overlay {
|
||||
position: fixed; inset: 0; z-index: 200;
|
||||
background: rgba(0,0,0,0.35);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.dev-modal {
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
width: 100%; max-width: 340px;
|
||||
display: flex; flex-direction: column; gap: 0.875rem;
|
||||
box-shadow: 0 8px 32px oklch(0% 0 0 / 0.18);
|
||||
}
|
||||
|
||||
.dev-modal-title {
|
||||
font-size: 14px; font-weight: 600; color: var(--text); margin: 0;
|
||||
}
|
||||
|
||||
.dev-modal-sub {
|
||||
font-size: 13px; color: var(--text-3); margin: 0;
|
||||
}
|
||||
|
||||
.dev-modal-input {
|
||||
width: 100%; padding: 9px 11px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 7px;
|
||||
font-size: 13px; color: var(--text);
|
||||
background: var(--bg);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dev-modal-input:focus { border-color: var(--accent); }
|
||||
|
||||
.dev-modal-msg { font-size: 12px; margin: 0; }
|
||||
.dev-modal-msg--ok { color: var(--green); }
|
||||
.dev-modal-msg--err { color: oklch(50% 0.18 25); }
|
||||
|
||||
.dev-modal-actions { display: flex; gap: 0.625rem; justify-content: flex-end; }
|
||||
|
||||
.dev-modal-cancel {
|
||||
background: none; border: 1px solid var(--border);
|
||||
border-radius: 7px; padding: 7px 14px;
|
||||
font-size: 12px; color: var(--text-3); cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.dev-modal-cancel:hover:not(:disabled) { border-color: var(--border-2); }
|
||||
.dev-modal-cancel:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.dev-modal-run {
|
||||
background: var(--accent); color: #fff;
|
||||
border: none; border-radius: 7px;
|
||||
padding: 7px 18px;
|
||||
font-size: 12px; font-weight: 500;
|
||||
cursor: pointer; transition: opacity 0.15s;
|
||||
}
|
||||
.dev-modal-run:hover:not(:disabled) { opacity: 0.85; }
|
||||
.dev-modal-run:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
.dev-fade-enter-active { transition: opacity 0.15s ease; }
|
||||
.dev-fade-leave-active { transition: opacity 0.1s ease; }
|
||||
.dev-fade-enter-from, .dev-fade-leave-to { opacity: 0; }
|
||||
|
||||
/* retry button on error topics */
|
||||
.topic-retry-btn {
|
||||
font-size: 11px; letter-spacing: 0.06em;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ interface CourseSummary {
|
|||
stage: string | null
|
||||
createdAt: string
|
||||
topicCount: number
|
||||
completedCount: number
|
||||
topicIds: string[]
|
||||
costAI: number | null
|
||||
costAudio: number | null
|
||||
organisation: string | null
|
||||
|
|
@ -111,9 +111,19 @@ async function confirmDelete() {
|
|||
}
|
||||
}
|
||||
|
||||
function completedCount(course: CourseSummary): number {
|
||||
if (!import.meta.client) return 0;
|
||||
return course.topicIds.filter(id => {
|
||||
try {
|
||||
const p = localStorage.getItem(`progress:${id}`);
|
||||
return p ? JSON.parse(p).lessonComplete === true : false;
|
||||
} catch { return false; }
|
||||
}).length;
|
||||
}
|
||||
|
||||
function progressPct(course: CourseSummary): number {
|
||||
if (!course.topicCount) return 0;
|
||||
return Math.round((course.completedCount / course.topicCount) * 100);
|
||||
return Math.round((completedCount(course) / course.topicCount) * 100);
|
||||
}
|
||||
|
||||
// deterministic warm color from subject string
|
||||
|
|
@ -205,7 +215,7 @@ function subjectColor(subject: string): string {
|
|||
<div class="progress-track">
|
||||
<div class="progress-fill" :style="{ width: `${progressPct(course)}%`, background: subjectColor(course.subject) }" />
|
||||
</div>
|
||||
<span class="progress-label">{{ course.completedCount }} / {{ course.topicCount }}</span>
|
||||
<span class="progress-label">{{ completedCount(course) }} / {{ course.topicCount }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
|
|
|
|||
|
|
@ -541,26 +541,22 @@ async function completeLesson() {
|
|||
completing.value = true;
|
||||
completeError.value = false;
|
||||
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 10000);
|
||||
|
||||
try {
|
||||
await $fetch(`/api/topics/${topicId}/progress`, {
|
||||
method: "POST",
|
||||
signal: ctrl.signal,
|
||||
body: {
|
||||
lessonComplete: true,
|
||||
tookBranches: branchesEntered.value > 0,
|
||||
branchCount: branchesEntered.value,
|
||||
},
|
||||
});
|
||||
const existing = localStorage.getItem(`progress:${topicId}`);
|
||||
const prev = existing ? JSON.parse(existing) : {};
|
||||
localStorage.setItem(`progress:${topicId}`, JSON.stringify({
|
||||
...prev,
|
||||
lessonComplete: true,
|
||||
tookBranches: branchesEntered.value > 0,
|
||||
branchCount: branchesEntered.value,
|
||||
}));
|
||||
|
||||
lessonDone.value = true;
|
||||
} catch {
|
||||
completing.value = false;
|
||||
completeError.value = true;
|
||||
return;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
completing.value = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -571,6 +567,7 @@ function goBack() {
|
|||
history.back();
|
||||
}
|
||||
|
||||
|
||||
function stepBack() {
|
||||
stopAllAudio();
|
||||
if (lessonState.mode === "branch" || lessonState.mode === "confirm" || lessonState.mode === "hardStop") {
|
||||
|
|
@ -1377,6 +1374,26 @@ onBeforeUnmount(() => {
|
|||
}
|
||||
.karaoke-skip:hover { opacity: 0.85; }
|
||||
|
||||
.karaoke-start {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: oklch(54% 0.140 44);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
padding: 0.85rem 2rem;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.12);
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
.karaoke-start:hover { opacity: 0.88; }
|
||||
.karaoke-start:active { transform: scale(0.97); }
|
||||
|
||||
.karaoke-fade-enter-active { transition: opacity 0.2s ease; }
|
||||
.karaoke-fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.karaoke-fade-enter-from,
|
||||
|
|
@ -2099,4 +2116,5 @@ onBeforeUnmount(() => {
|
|||
transition: opacity 0.15s;
|
||||
}
|
||||
.branch-loading-skip:hover { opacity: 0.85; }
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export default defineEventHandler(async (event) => {
|
|||
const pathStr = Array.isArray(pathParam) ? pathParam.join("/") : pathParam;
|
||||
|
||||
// prevent path traversal
|
||||
const baseDir = resolve(process.cwd(), "private/audio");
|
||||
const baseDir = resolve(process.cwd(), "data/audio");
|
||||
const filePath = normalize(resolve(baseDir, pathStr));
|
||||
|
||||
if (!filePath.startsWith(baseDir)) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { db } from "../../../db/index";
|
||||
import { courses, topics, userProgress, lessons } from "../../../db/schema";
|
||||
import { courses, topics, lessons } from "../../../db/schema";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
|
|
@ -16,13 +16,6 @@ export default defineEventHandler(async (event) => {
|
|||
orderBy: (t, { asc }) => asc(t.order),
|
||||
});
|
||||
|
||||
const progressRows = await db.query.userProgress.findMany({
|
||||
where: eq(userProgress.courseId, id),
|
||||
});
|
||||
|
||||
const progressMap: Record<string, typeof progressRows[0]> = {};
|
||||
for (const p of progressRows) progressMap[p.topicId] = p;
|
||||
|
||||
const topicIds = topicRows.map((t) => t.id);
|
||||
|
||||
const lessonRows = topicIds.length
|
||||
|
|
@ -37,7 +30,6 @@ export default defineEventHandler(async (event) => {
|
|||
topics: topicRows.map((t) => ({
|
||||
...t,
|
||||
prerequisiteTopicIds: JSON.parse(t.prerequisiteTopicIds ?? "[]"),
|
||||
progress: progressMap[t.id] ?? null,
|
||||
hasLesson: !!lessonMap[t.id],
|
||||
lessonCost: lessonMap[t.id]?.costTotal ?? null,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { db } from "../../db/index";
|
||||
import { courses, topics, userProgress } from "../../db/schema";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { courses, topics } from "../../db/schema";
|
||||
import { inArray } from "drizzle-orm";
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const allCourses = await db.query.courses.findMany({
|
||||
|
|
@ -11,27 +11,18 @@ export default defineEventHandler(async () => {
|
|||
|
||||
const courseIds = allCourses.map((c) => c.id);
|
||||
|
||||
const [allTopics, allProgress] = await Promise.all([
|
||||
db.query.topics.findMany({ where: inArray(topics.courseId, courseIds) }),
|
||||
db.query.userProgress.findMany({ where: inArray(userProgress.courseId, courseIds) }),
|
||||
]);
|
||||
const allTopics = await db.query.topics.findMany({ where: inArray(topics.courseId, courseIds) });
|
||||
|
||||
// group in memory
|
||||
const topicsByCourse = new Map<string, number>();
|
||||
const topicsByCourse = new Map<string, string[]>();
|
||||
for (const t of allTopics) {
|
||||
topicsByCourse.set(t.courseId, (topicsByCourse.get(t.courseId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const completedByCourse = new Map<string, number>();
|
||||
for (const p of allProgress) {
|
||||
if (p.lessonComplete) {
|
||||
completedByCourse.set(p.courseId, (completedByCourse.get(p.courseId) ?? 0) + 1);
|
||||
}
|
||||
const arr = topicsByCourse.get(t.courseId) ?? [];
|
||||
arr.push(t.id);
|
||||
topicsByCourse.set(t.courseId, arr);
|
||||
}
|
||||
|
||||
return allCourses.map((course) => ({
|
||||
...course,
|
||||
topicCount: topicsByCourse.get(course.id) ?? 0,
|
||||
completedCount: completedByCourse.get(course.id) ?? 0,
|
||||
topicCount: topicsByCourse.get(course.id)?.length ?? 0,
|
||||
topicIds: topicsByCourse.get(course.id) ?? [],
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
83
server/api/topics/[id]/dev/regenerate-audio.post.ts
Normal file
83
server/api/topics/[id]/dev/regenerate-audio.post.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { db } from "../../../../db/index";
|
||||
import { lessons } from "../../../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
generateStepTTS,
|
||||
generateQuestionTTS,
|
||||
generateOptionTTS,
|
||||
} from "../../../../utils/generateTTS";
|
||||
import { verifyLicenseKey } from "../../../../utils/license";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const key = getHeader(event, "x-license-key") ?? "";
|
||||
if (!verifyLicenseKey(key)) {
|
||||
throw createError({ statusCode: 401, message: "Unauthorized" });
|
||||
}
|
||||
|
||||
const topicId = getRouterParam(event, "id")!;
|
||||
|
||||
const lesson = await db.query.lessons.findFirst({ where: eq(lessons.topicId, topicId) });
|
||||
if (!lesson) throw createError({ statusCode: 404, message: "Lesson not found" });
|
||||
|
||||
const content = JSON.parse(lesson.content);
|
||||
const steps: any[] = content.steps ?? [];
|
||||
|
||||
type Task = () => Promise<void>;
|
||||
const tasks: Task[] = [];
|
||||
|
||||
for (let si = 0; si < steps.length; si++) {
|
||||
const step = steps[si];
|
||||
const siCopy = si;
|
||||
|
||||
if (step.type === "concept" || step.type === "example") {
|
||||
const text = [step.body, step.callout].filter(Boolean).join(" ");
|
||||
if (!text.trim()) continue;
|
||||
|
||||
tasks.push(async () => {
|
||||
const r = await generateStepTTS(text, lesson.id, siCopy);
|
||||
if (r) { step.audioPath = r.audioPath; step.audioChunks = r.audioChunks; }
|
||||
});
|
||||
|
||||
} else if (step.type === "summary") {
|
||||
const text = Array.isArray(step.bullets) ? step.bullets.join(". ") : "";
|
||||
if (!text.trim()) continue;
|
||||
|
||||
tasks.push(async () => {
|
||||
const r = await generateStepTTS(text, lesson.id, siCopy);
|
||||
if (r) { step.audioPath = r.audioPath; step.audioChunks = r.audioChunks; }
|
||||
});
|
||||
|
||||
} else if (step.type === "question") {
|
||||
if (step.body?.trim()) {
|
||||
tasks.push(async () => {
|
||||
const r = await generateQuestionTTS(step.body, lesson.id, siCopy);
|
||||
if (r) { step.questionAudioPath = r.audioPath; step.questionAudioChunks = r.audioChunks; }
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(step.options)) {
|
||||
step.optionAudioPaths = new Array(step.options.length).fill(null);
|
||||
for (let oi = 0; oi < step.options.length; oi++) {
|
||||
const optText = step.options[oi];
|
||||
const oiCopy = oi;
|
||||
if (optText?.trim()) {
|
||||
tasks.push(async () => {
|
||||
const r = await generateOptionTTS(optText, lesson.id, siCopy, oiCopy);
|
||||
if (r) step.optionAudioPaths[oiCopy] = r.audioPath;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const BATCH = 3;
|
||||
for (let i = 0; i < tasks.length; i += BATCH) {
|
||||
await Promise.all(tasks.slice(i, i + BATCH).map((fn) => fn()));
|
||||
}
|
||||
|
||||
content.steps = steps;
|
||||
await db.update(lessons).set({ content: JSON.stringify(content) }).where(eq(lessons.id, lesson.id));
|
||||
|
||||
return { status: "ready" };
|
||||
});
|
||||
31
server/api/topics/[id]/dev/regenerate-lesson.post.ts
Normal file
31
server/api/topics/[id]/dev/regenerate-lesson.post.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { db } from "../../../../db/index";
|
||||
import { topics, lessons } from "../../../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { generateLesson } from "../../../../utils/generateLesson";
|
||||
import { verifyLicenseKey } from "../../../../utils/license";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const key = getHeader(event, "x-license-key") ?? "";
|
||||
if (!verifyLicenseKey(key)) {
|
||||
throw createError({ statusCode: 401, message: "Unauthorized" });
|
||||
}
|
||||
|
||||
const id = getRouterParam(event, "id")!;
|
||||
|
||||
const topic = await db.query.topics.findFirst({ where: eq(topics.id, id) });
|
||||
if (!topic) throw createError({ statusCode: 404, message: "Topic not found" });
|
||||
|
||||
// wipe existing lesson
|
||||
await db.delete(lessons).where(eq(lessons.topicId, id));
|
||||
|
||||
// reset topic status so generateLesson doesnt bail
|
||||
await db.update(topics).set({ status: "pending" }).where(eq(topics.id, id));
|
||||
|
||||
try {
|
||||
await generateLesson(id);
|
||||
return { status: "ready" };
|
||||
} catch (err: any) {
|
||||
console.error(`[dev/regenerate-lesson] topic ${id}: ${err?.message ?? err}`);
|
||||
return { status: "error" };
|
||||
}
|
||||
});
|
||||
371
server/plugins/repairBrokenLessons.ts
Normal file
371
server/plugins/repairBrokenLessons.ts
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
import { db } from "../db/index";
|
||||
import { topics, lessons } from "../db/schema";
|
||||
import { eq, and, lt } from "drizzle-orm";
|
||||
import { generateLesson } from "../utils/generateLesson";
|
||||
import { generateBranches } from "../utils/generateBranches";
|
||||
import {
|
||||
generateStepTTS,
|
||||
generateQuestionTTS,
|
||||
generateOptionTTS,
|
||||
generateTTSToPath,
|
||||
} from "../utils/generateTTS";
|
||||
|
||||
const INTERVAL_MS = 5 * 60 * 1000;
|
||||
const STALE_MINUTES = 15;
|
||||
|
||||
function staleCutoff() {
|
||||
return new Date(Date.now() - STALE_MINUTES * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
async function repairCycle() {
|
||||
|
||||
// ── 1. failed topic generation ──────────────────────────────────────────
|
||||
try {
|
||||
const errored = await db
|
||||
.update(topics)
|
||||
.set({ status: "pending" })
|
||||
.where(eq(topics.status, "error"))
|
||||
.returning({ id: topics.id });
|
||||
|
||||
if (errored.length > 0) {
|
||||
console.log(`[repair] resetting ${errored.length} errored topic(s) to pending`);
|
||||
|
||||
for (const t of errored) {
|
||||
generateLesson(t.id).catch((err: any) => {
|
||||
console.error(`[repair] generateLesson failed for ${t.id.slice(0, 8)}: ${err?.message ?? err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[repair] failed to repair errored topics:", err?.message ?? err);
|
||||
}
|
||||
|
||||
// ── 2. stale generating topics (server died mid-generation) ─────────────
|
||||
try {
|
||||
// topics dont have createdAt so we use a rough heuristic: if a lesson
|
||||
// exists for this topic, generation got far enough; otherwise something
|
||||
// died early. Either way reset to pending and let generateLesson figure it out.
|
||||
const stuckGenerating = await db.query.topics.findMany({
|
||||
where: eq(topics.status, "generating"),
|
||||
});
|
||||
|
||||
const cutoff = staleCutoff();
|
||||
for (const t of stuckGenerating) {
|
||||
const lesson = await db.query.lessons.findFirst({ where: eq(lessons.topicId, t.id) });
|
||||
|
||||
if (lesson) {
|
||||
// lesson row exists — server died after insert but before topic status update
|
||||
console.log(`[repair] topic ${t.id.slice(0, 8)} stuck generating but lesson exists — marking ready`);
|
||||
await db.update(topics).set({ status: "ready" }).where(eq(topics.id, t.id));
|
||||
} else {
|
||||
// no lesson at all — server died before the insert, need to regenerate
|
||||
console.log(`[repair] topic ${t.id.slice(0, 8)} stuck generating with no lesson — resetting to pending`);
|
||||
await db.update(topics).set({ status: "pending" }).where(eq(topics.id, t.id));
|
||||
generateLesson(t.id).catch((err: any) => {
|
||||
console.error(`[repair] generateLesson (stuck) failed for ${t.id.slice(0, 8)}: ${err?.message ?? err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[repair] failed to repair stuck generating topics:", err?.message ?? err);
|
||||
}
|
||||
|
||||
// ── 3. failed branch generation ─────────────────────────────────────────
|
||||
try {
|
||||
const errored = await db
|
||||
.update(lessons)
|
||||
.set({ branchStatus: "pending" })
|
||||
.where(eq(lessons.branchStatus, "error"))
|
||||
.returning({ id: lessons.id, topicId: lessons.topicId });
|
||||
|
||||
if (errored.length > 0) {
|
||||
console.log(`[repair] resetting ${errored.length} errored lesson branch(es) to pending`);
|
||||
|
||||
for (const l of errored) {
|
||||
generateBranches(l.topicId, l.id).catch((err: any) => {
|
||||
console.error(`[repair] generateBranches failed for ${l.id.slice(0, 8)}: ${err?.message ?? err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[repair] failed to repair errored lesson branches:", err?.message ?? err);
|
||||
}
|
||||
|
||||
// ── 4. stale generating branches (server died mid-branch) ────────────────
|
||||
try {
|
||||
const cutoff = staleCutoff();
|
||||
|
||||
const stale = await db
|
||||
.update(lessons)
|
||||
.set({ branchStatus: "pending" })
|
||||
.where(
|
||||
and(
|
||||
eq(lessons.branchStatus, "generating"),
|
||||
lt(lessons.createdAt, cutoff)
|
||||
)
|
||||
)
|
||||
.returning({ id: lessons.id, topicId: lessons.topicId });
|
||||
|
||||
if (stale.length > 0) {
|
||||
console.log(`[repair] resetting ${stale.length} stale generating branch(es) to pending`);
|
||||
|
||||
for (const l of stale) {
|
||||
generateBranches(l.topicId, l.id).catch((err: any) => {
|
||||
console.error(`[repair] generateBranches (stale generating) failed for ${l.id.slice(0, 8)}: ${err?.message ?? err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[repair] failed to repair stale generating branches:", err?.message ?? err);
|
||||
}
|
||||
|
||||
// ── 5. stale pending branches (never kicked off or abandoned) ────────────
|
||||
try {
|
||||
const cutoff = staleCutoff();
|
||||
|
||||
const stale = await db
|
||||
.update(lessons)
|
||||
.set({ branchStatus: "pending" })
|
||||
.where(
|
||||
and(
|
||||
eq(lessons.branchStatus, "pending"),
|
||||
lt(lessons.createdAt, cutoff)
|
||||
)
|
||||
)
|
||||
.returning({ id: lessons.id, topicId: lessons.topicId });
|
||||
|
||||
if (stale.length > 0) {
|
||||
console.log(`[repair] restarting ${stale.length} stale pending branch(es)`);
|
||||
|
||||
for (const l of stale) {
|
||||
generateBranches(l.topicId, l.id).catch((err: any) => {
|
||||
console.error(`[repair] generateBranches (stale pending) failed for ${l.id.slice(0, 8)}: ${err?.message ?? err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[repair] failed to repair stale pending branches:", err?.message ?? err);
|
||||
}
|
||||
|
||||
// ── 6. missing main lesson audio ─────────────────────────────────────────
|
||||
try {
|
||||
const readyTopics = await db.query.topics.findMany({ where: eq(topics.status, "ready") });
|
||||
|
||||
for (const topic of readyTopics) {
|
||||
const lesson = await db.query.lessons.findFirst({ where: eq(lessons.topicId, topic.id) });
|
||||
if (!lesson) continue;
|
||||
|
||||
let content: { keyConcepts: string[]; analogiesUsed: string[]; steps: any[] };
|
||||
try {
|
||||
content = JSON.parse(lesson.content);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content.steps)) continue;
|
||||
|
||||
type Task = () => Promise<void>;
|
||||
const tasks: Task[] = [];
|
||||
let dirty = false;
|
||||
|
||||
for (let si = 0; si < content.steps.length; si++) {
|
||||
const step = content.steps[si];
|
||||
|
||||
if (step.type === "concept" || step.type === "example" || step.type === "summary") {
|
||||
const text = step.type === "summary"
|
||||
? (Array.isArray(step.bullets) ? step.bullets.join(". ") : "")
|
||||
: [step.body, step.callout].filter(Boolean).join(" ");
|
||||
|
||||
if (!text.trim() || step.audioPath) continue;
|
||||
|
||||
const siCopy = si;
|
||||
tasks.push(async () => {
|
||||
const r = await generateStepTTS(text, lesson.id, siCopy);
|
||||
if (r) {
|
||||
content.steps[siCopy].audioPath = r.audioPath;
|
||||
content.steps[siCopy].audioChunks = r.audioChunks;
|
||||
dirty = true;
|
||||
}
|
||||
});
|
||||
|
||||
} else if (step.type === "question") {
|
||||
if (step.body?.trim() && !step.questionAudioPath) {
|
||||
const siCopy = si;
|
||||
const body = step.body;
|
||||
tasks.push(async () => {
|
||||
const r = await generateQuestionTTS(body, lesson.id, siCopy);
|
||||
if (r) {
|
||||
content.steps[siCopy].questionAudioPath = r.audioPath;
|
||||
content.steps[siCopy].questionAudioChunks = r.audioChunks;
|
||||
dirty = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(step.options)) {
|
||||
if (!step.optionAudioPaths) {
|
||||
content.steps[si].optionAudioPaths = new Array(step.options.length).fill(null);
|
||||
}
|
||||
|
||||
for (let oi = 0; oi < step.options.length; oi++) {
|
||||
const optText = step.options[oi];
|
||||
if (!optText?.trim()) continue;
|
||||
if (content.steps[si].optionAudioPaths?.[oi]) continue;
|
||||
|
||||
const siCopy = si;
|
||||
const oiCopy = oi;
|
||||
tasks.push(async () => {
|
||||
const r = await generateOptionTTS(optText, lesson.id, siCopy, oiCopy);
|
||||
if (r) {
|
||||
content.steps[siCopy].optionAudioPaths[oiCopy] = r.audioPath;
|
||||
dirty = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.length === 0) continue;
|
||||
|
||||
console.log(`[repair] lesson ${lesson.id.slice(0, 8)} — ${tasks.length} missing audio task(s)`);
|
||||
|
||||
const BATCH = 3;
|
||||
for (let i = 0; i < tasks.length; i += BATCH) {
|
||||
await Promise.all(tasks.slice(i, i + BATCH).map((fn) => fn()));
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
await db.update(lessons)
|
||||
.set({ content: JSON.stringify(content) })
|
||||
.where(eq(lessons.id, lesson.id));
|
||||
console.log(`[repair] lesson ${lesson.id.slice(0, 8)} audio repaired`);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[repair] failed during lesson audio repair:", err?.message ?? err);
|
||||
}
|
||||
|
||||
// ── 7. missing branch audio ───────────────────────────────────────────────
|
||||
try {
|
||||
const readyLessons = await db.query.lessons.findMany({
|
||||
where: eq(lessons.branchStatus, "ready"),
|
||||
});
|
||||
|
||||
for (const lesson of readyLessons) {
|
||||
let content: { keyConcepts: string[]; analogiesUsed: string[]; steps: any[] };
|
||||
try {
|
||||
content = JSON.parse(lesson.content);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content.steps)) continue;
|
||||
|
||||
type Task = () => Promise<void>;
|
||||
const tasks: Task[] = [];
|
||||
let dirty = false;
|
||||
|
||||
for (let si = 0; si < content.steps.length; si++) {
|
||||
const step = content.steps[si];
|
||||
if (step.type !== "question" || !step.branches) continue;
|
||||
|
||||
const wrongOptions = (step.options as string[]).filter((o: string) => o !== step.answer);
|
||||
|
||||
for (let bi = 0; bi < wrongOptions.length; bi++) {
|
||||
const wrongOpt = wrongOptions[bi];
|
||||
const branch = step.branches[wrongOpt];
|
||||
if (!branch) continue;
|
||||
|
||||
|
||||
for (let bsi = 0; bsi < (branch.steps ?? []).length; bsi++) {
|
||||
const bStep = branch.steps[bsi];
|
||||
const text = [bStep.body, bStep.callout].filter(Boolean).join(" ");
|
||||
if (!text.trim() || bStep.audioPath) continue;
|
||||
|
||||
const siCopy = si; const biCopy = bi; const bsiCopy = bsi; const woCopy = wrongOpt;
|
||||
tasks.push(async () => {
|
||||
const r = await generateTTSToPath(text, lesson.id, `branch_${siCopy}_${biCopy}_step_${bsiCopy}.mp3`);
|
||||
if (r) {
|
||||
content.steps[siCopy].branches[woCopy].steps[bsiCopy].audioPath = r.audioPath;
|
||||
content.steps[siCopy].branches[woCopy].steps[bsiCopy].audioChunks = r.audioChunks;
|
||||
dirty = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const cq = branch.confirmQuestion;
|
||||
if (cq?.body?.trim() && !cq.questionAudioPath) {
|
||||
const siCopy = si; const biCopy = bi; const woCopy = wrongOpt;
|
||||
tasks.push(async () => {
|
||||
const r = await generateTTSToPath(cq.body, lesson.id, `branch_${siCopy}_${biCopy}_confirm_q.mp3`);
|
||||
if (r) {
|
||||
content.steps[siCopy].branches[woCopy].confirmQuestion.questionAudioPath = r.audioPath;
|
||||
content.steps[siCopy].branches[woCopy].confirmQuestion.questionAudioChunks = r.audioChunks;
|
||||
dirty = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(cq?.options)) {
|
||||
if (!cq.optionAudioPaths) {
|
||||
branch.confirmQuestion.optionAudioPaths = new Array(cq.options.length).fill(null);
|
||||
}
|
||||
|
||||
for (let oi = 0; oi < cq.options.length; oi++) {
|
||||
const optText = cq.options[oi];
|
||||
if (!optText?.trim()) continue;
|
||||
if (branch.confirmQuestion.optionAudioPaths?.[oi]) continue;
|
||||
|
||||
const siCopy = si; const biCopy = bi; const oiCopy = oi; const woCopy = wrongOpt;
|
||||
tasks.push(async () => {
|
||||
const r = await generateTTSToPath(optText, lesson.id, `branch_${siCopy}_${biCopy}_confirm_opt_${oiCopy}.mp3`);
|
||||
if (r) {
|
||||
content.steps[siCopy].branches[woCopy].confirmQuestion.optionAudioPaths[oiCopy] = r.audioPath;
|
||||
dirty = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (branch.hardStop?.trim() && !branch.hardStopAudioPath) {
|
||||
const siCopy = si; const biCopy = bi; const woCopy = wrongOpt;
|
||||
tasks.push(async () => {
|
||||
const r = await generateTTSToPath(branch.hardStop, lesson.id, `branch_${siCopy}_${biCopy}_hardstop.mp3`);
|
||||
if (r) {
|
||||
content.steps[siCopy].branches[woCopy].hardStopAudioPath = r.audioPath;
|
||||
dirty = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.length === 0) continue;
|
||||
|
||||
console.log(`[repair] lesson ${lesson.id.slice(0, 8)} — ${tasks.length} missing branch audio task(s)`);
|
||||
|
||||
const BATCH = 3;
|
||||
for (let i = 0; i < tasks.length; i += BATCH) {
|
||||
await Promise.all(tasks.slice(i, i + BATCH).map((fn) => fn()));
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
await db.update(lessons)
|
||||
.set({ content: JSON.stringify(content) })
|
||||
.where(eq(lessons.id, lesson.id));
|
||||
console.log(`[repair] lesson ${lesson.id.slice(0, 8)} branch audio repaired`);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[repair] failed during branch audio repair:", err?.message ?? err);
|
||||
}
|
||||
}
|
||||
|
||||
export default defineNitroPlugin(() => {
|
||||
console.log("[repair] repairBrokenLessons plugin started");
|
||||
|
||||
repairCycle();
|
||||
|
||||
setInterval(repairCycle, INTERVAL_MS);
|
||||
});
|
||||
|
|
@ -4,7 +4,10 @@ import { eq } from "drizzle-orm";
|
|||
import { askAI } from "./openrouter";
|
||||
|
||||
function parseJSON<T>(raw: string): T {
|
||||
let text = raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
|
||||
let text = raw
|
||||
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
||||
.replace(/^\s*thought\s*\n/i, "")
|
||||
.trim();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ function log(lessonId: string, msg: string) {
|
|||
}
|
||||
|
||||
function parseJSON<T>(raw: string): T {
|
||||
let text = raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
|
||||
let text = raw
|
||||
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
||||
.replace(/^\s*thought\s*\n/i, "")
|
||||
.trim();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
|
|
@ -195,10 +198,7 @@ Only generate branches for the 3 wrong options. Do not generate a branch for the
|
|||
}
|
||||
}
|
||||
|
||||
const BATCH = 4;
|
||||
for (let i = 0; i < ttsTasks.length; i += BATCH) {
|
||||
await Promise.all(ttsTasks.slice(i, i + BATCH).map((fn) => fn()));
|
||||
}
|
||||
await Promise.all(ttsTasks.map((fn) => fn()));
|
||||
|
||||
log(lessonId, ` step ${si} branch TTS done`);
|
||||
branchSuccesses++;
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@ async function setStage(courseId: string, stage: Stage) {
|
|||
}
|
||||
|
||||
function parseJSON<T>(raw: string): T {
|
||||
let text = raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
|
||||
let text = raw
|
||||
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
||||
.replace(/^\s*thought\s*\n/i, "")
|
||||
.trim();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -14,8 +14,11 @@ function log(topicId: string, msg: string) {
|
|||
}
|
||||
|
||||
function parseJSON<T>(raw: string): T {
|
||||
// strip <think>...</think> blocks from reasoning models (deepseek-r1 etc.)
|
||||
let text = raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
|
||||
// strip reasoning preambles — <think>...</think> tags and bare "thought\n" prefix
|
||||
let text = raw
|
||||
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
||||
.replace(/^\s*thought\s*\n/i, "")
|
||||
.trim();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
|
|
@ -103,11 +106,7 @@ async function generateLessonAudio(
|
|||
}
|
||||
}
|
||||
|
||||
// run in batches of 4
|
||||
const BATCH = 4;
|
||||
for (let i = 0; i < tasks.length; i += BATCH) {
|
||||
await Promise.all(tasks.slice(i, i + BATCH).map((fn) => fn()));
|
||||
}
|
||||
await Promise.all(tasks.map((fn) => fn()));
|
||||
|
||||
return { steps, cost };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,47 @@
|
|||
import { mkdir, writeFile, access } from "fs/promises";
|
||||
import { resolve } from "path";
|
||||
import { askAI } from "./openrouter";
|
||||
import { ttsLimiter } from "./limiter";
|
||||
|
||||
const NARRATION_SYSTEM_PROMPT = `You are a narration script editor for an AI voice actor. Your job is to take educational lesson text and prepare it to be read aloud naturally and engagingly.
|
||||
const NARRATION_SYSTEM_PROMPT = `You are a narration script editor for an AI voice actor reading educational lesson content. Your job is to prepare text so it sounds natural, warm, and engaging when spoken aloud.
|
||||
|
||||
Rules:
|
||||
- Do NOT change the meaning, facts, or structure of the content. You are not rewriting it.
|
||||
- Fix anything that would sound awkward or robotic when spoken: remove markdown formatting (asterisks, backticks, hashes), spell out acronyms where helpful, rephrase code snippets or technical shorthand into speakable language.
|
||||
- Add square bracket cues to give the voice character and pacing. These are the only ones you may use: [pause], [long pause], [sighs], [laughs], [clears throat], [hesitates].
|
||||
- Use [pause] at natural breath points — after key ideas, before a new concept, or mid-sentence where a human would pause for effect. Don't overdo it; one every few sentences at most.
|
||||
- Use [sighs] or [laughs] very sparingly — only where a human narrator genuinely would. A [sighs] before a tricky concept, a [laughs] when something is ironic or light. Maybe once or twice per lesson, if at all.
|
||||
- Keep the tone warm, clear, and conversational — like a knowledgeable friend explaining something, not a textbook being read aloud.
|
||||
- Return ONLY the modified narration text. No commentary, no explanation, no quotes around the output.`;
|
||||
## Content rules
|
||||
- Do NOT change the meaning, facts, or structure. You are not rewriting the lesson.
|
||||
- Fix anything that sounds robotic or awkward when spoken: strip markdown (asterisks, backticks, hashes, bullet dashes), spell out acronyms where helpful, rephrase code snippets or URLs into speakable language (e.g. "the fetch function" not "\`fetch()\`").
|
||||
|
||||
## Voice control tags
|
||||
You have a rich set of square bracket tags to shape how the voice sounds. Use them tastefully — a well-placed tag is powerful, overuse kills it.
|
||||
|
||||
**Pacing**
|
||||
[pause] — a natural breath beat, use at transitions or after key ideas
|
||||
[long pause] — a longer held silence, use for emphasis or before something important
|
||||
[short pause] — a very brief beat
|
||||
|
||||
**Non-verbal sounds** (use sparingly, one or two per lesson max)
|
||||
[breath] — a natural inhale, good at the start of a new thought or after a long sentence
|
||||
[sighs] — before a tricky concept, or when something is a bit of a pain
|
||||
[laughs] — when something is genuinely ironic, surprising, or lightly funny
|
||||
[chuckles] — softer than laughs, more conversational
|
||||
[exhales] — a quiet breath out, good for winding down a dense section
|
||||
[clears throat] — before jumping into something more formal or detailed
|
||||
[gasp] — for something genuinely surprising
|
||||
|
||||
**Delivery style** (can be chained, effect lasts until next tag or end of sentence)
|
||||
[curious] — lean in, raise intrigue
|
||||
[excited] — energy up, good for "here's the cool part"
|
||||
[whispers] — draw the listener in for an aside
|
||||
[nervous] — for content where a student might feel anxious (e.g. exams)
|
||||
[calm] — reassuring, slows things down
|
||||
[sarcastic] — very sparingly, only when the tone clearly calls for it
|
||||
|
||||
## Placement guidance
|
||||
- [pause] can go mid-sentence before a key term, or at the end of a sentence before shifting topic
|
||||
- Emotional tags go BEFORE the text they should affect, and return to neutral naturally after a sentence or two
|
||||
- Don't open with a tag — let the voice settle first
|
||||
- Avoid back-to-back tags with no words between them
|
||||
|
||||
## Output
|
||||
Return ONLY the modified narration text. No commentary, no labels, no quotes.`;
|
||||
|
||||
async function humaniseTTSText(text: string): Promise<string> {
|
||||
try {
|
||||
|
|
@ -47,26 +77,45 @@ async function callElevenLabs(
|
|||
apiKey: string,
|
||||
voiceId: string
|
||||
): Promise<{ audio: Buffer; chunks: AudioChunk[]; cost: number } | null> {
|
||||
const res = await fetch(
|
||||
`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}/with-timestamps`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"xi-api-key": apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
model_id: "eleven_turbo_v2_5",
|
||||
output_format: "mp3_44100_128",
|
||||
}),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
const MAX_RETRIES = 5;
|
||||
let delay = 2000;
|
||||
|
||||
let res!: Response;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
res = await fetch(
|
||||
`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}/with-timestamps`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"xi-api-key": apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
model_id: "eleven_v3",
|
||||
output_format: "mp3_44100_128",
|
||||
}),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
}
|
||||
);
|
||||
|
||||
if (res.ok) break;
|
||||
|
||||
if (res.status === 429 && attempt < MAX_RETRIES) {
|
||||
console.warn(`[tts] ElevenLabs 429 — retry ${attempt + 1}/${MAX_RETRIES} in ${delay}ms`);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
delay *= 2;
|
||||
continue;
|
||||
}
|
||||
);
|
||||
|
||||
const errText = await res.text().catch(() => "");
|
||||
console.error(`[tts] ElevenLabs error ${res.status}: ${errText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => "");
|
||||
console.error(`[tts] ElevenLabs error ${res.status}: ${errText}`);
|
||||
console.error(`[tts] ElevenLabs failed after ${MAX_RETRIES} retries: ${res.status} ${errText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -140,6 +189,8 @@ async function callFishAudio(
|
|||
format: "mp3",
|
||||
mp3_bitrate: 128,
|
||||
streaming: false,
|
||||
normalize: false,
|
||||
model: "s2",
|
||||
}),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
|
|
@ -172,13 +223,15 @@ async function callTTS(
|
|||
const apiKey = config.fishAudioApiKey as string;
|
||||
const voiceId = (config.public.fishAudioVoiceId || config.fishAudioVoiceId) as string;
|
||||
if (!apiKey) return null;
|
||||
return callFishAudio(text, apiKey, voiceId);
|
||||
console.log(`[tts] queued (fish) — active: ${ttsLimiter.active}, queued: ${ttsLimiter.queued}`);
|
||||
return ttsLimiter.run(() => callFishAudio(text, apiKey, voiceId));
|
||||
}
|
||||
|
||||
const apiKey = config.elevenlabsApiKey as string;
|
||||
const voiceId = (config.public.elevenlabsVoiceId || config.elevenlabsVoiceId) as string;
|
||||
if (!apiKey) return null;
|
||||
return callElevenLabs(text, apiKey, voiceId);
|
||||
console.log(`[tts] queued (elevenlabs) — active: ${ttsLimiter.active}, queued: ${ttsLimiter.queued}`);
|
||||
return ttsLimiter.run(() => callElevenLabs(text, apiKey, voiceId));
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -191,12 +244,12 @@ export async function generateStepTTS(
|
|||
const result = await callTTS(text);
|
||||
if (!result) return null;
|
||||
|
||||
const dir = resolve(process.cwd(), `public/audio/lessons/${lessonId}`);
|
||||
const dir = resolve(process.cwd(), `data/audio/lessons/${lessonId}`);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const filename = `step_${stepIndex}.mp3`;
|
||||
await writeFile(`${dir}/${filename}`, result.audio);
|
||||
|
||||
const audioPath = `/audio/lessons/${lessonId}/${filename}`;
|
||||
const audioPath = `/api/audio/lessons/${lessonId}/${filename}`;
|
||||
console.log(`[tts] step ${stepIndex} for lesson ${lessonId} — ${result.chunks.length} chunks | $${result.cost.toFixed(4)}`);
|
||||
return { audioPath, audioChunks: result.chunks, cost: result.cost };
|
||||
} catch (err: any) {
|
||||
|
|
@ -214,12 +267,12 @@ export async function generateQuestionTTS(
|
|||
const result = await callTTS(text);
|
||||
if (!result) return null;
|
||||
|
||||
const dir = resolve(process.cwd(), `public/audio/lessons/${lessonId}`);
|
||||
const dir = resolve(process.cwd(), `data/audio/lessons/${lessonId}`);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const filename = `step_${stepIndex}_question.mp3`;
|
||||
await writeFile(`${dir}/${filename}`, result.audio);
|
||||
|
||||
const audioPath = `/audio/lessons/${lessonId}/${filename}`;
|
||||
const audioPath = `/api/audio/lessons/${lessonId}/${filename}`;
|
||||
return { audioPath, audioChunks: result.chunks, cost: result.cost };
|
||||
} catch (err: any) {
|
||||
console.error(`[tts] question ${stepIndex} for lesson ${lessonId} failed: ${err?.message ?? err}`);
|
||||
|
|
@ -237,12 +290,12 @@ export async function generateOptionTTS(
|
|||
const result = await callTTS(text);
|
||||
if (!result) return null;
|
||||
|
||||
const dir = resolve(process.cwd(), `public/audio/lessons/${lessonId}`);
|
||||
const dir = resolve(process.cwd(), `data/audio/lessons/${lessonId}`);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const filename = `step_${stepIndex}_option_${optionIndex}.mp3`;
|
||||
await writeFile(`${dir}/${filename}`, result.audio);
|
||||
|
||||
const audioPath = `/audio/lessons/${lessonId}/${filename}`;
|
||||
const audioPath = `/api/audio/lessons/${lessonId}/${filename}`;
|
||||
return { audioPath, cost: result.cost };
|
||||
} catch (err: any) {
|
||||
console.error(`[tts] option ${stepIndex}/${optionIndex} for lesson ${lessonId} failed: ${err?.message ?? err}`);
|
||||
|
|
@ -282,6 +335,8 @@ export async function generateClip(
|
|||
format: "mp3",
|
||||
mp3_bitrate: 128,
|
||||
streaming: false,
|
||||
normalize: false,
|
||||
model: "s2",
|
||||
}),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
|
|
@ -304,7 +359,7 @@ export async function generateClip(
|
|||
},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
model_id: opts?.model ?? "eleven_turbo_v2_5",
|
||||
model_id: opts?.model ?? "eleven_v3",
|
||||
output_format: "mp3_44100_128",
|
||||
...(opts?.voice_settings ? { voice_settings: opts.voice_settings } : {}),
|
||||
}),
|
||||
|
|
@ -323,7 +378,7 @@ export async function generateClip(
|
|||
buffer = Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
await mkdir(resolve(process.cwd(), "public/audio/labels"), { recursive: true });
|
||||
await mkdir(resolve(process.cwd(), "data/audio/labels"), { recursive: true });
|
||||
await writeFile(outPath, buffer);
|
||||
return { cost };
|
||||
} catch (err: any) {
|
||||
|
|
@ -341,11 +396,11 @@ export async function generateTTSToPath(
|
|||
const result = await callTTS(text);
|
||||
if (!result) return null;
|
||||
|
||||
const dir = resolve(process.cwd(), `public/audio/lessons/${lessonId}`);
|
||||
const dir = resolve(process.cwd(), `data/audio/lessons/${lessonId}`);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(`${dir}/${filename}`, result.audio);
|
||||
|
||||
const audioPath = `/audio/lessons/${lessonId}/${filename}`;
|
||||
const audioPath = `/api/audio/lessons/${lessonId}/${filename}`;
|
||||
return { audioPath, audioChunks: result.chunks, cost: result.cost };
|
||||
} catch (err: any) {
|
||||
console.error(`[tts] ${filename} for lesson ${lessonId} failed: ${err?.message ?? err}`);
|
||||
|
|
|
|||
41
server/utils/limiter.ts
Normal file
41
server/utils/limiter.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
class Limiter {
|
||||
private running = 0;
|
||||
private queue: (() => void)[] = [];
|
||||
|
||||
constructor(private max: number) {}
|
||||
|
||||
async run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
await this.acquire();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.release();
|
||||
}
|
||||
}
|
||||
|
||||
private acquire(): Promise<void> {
|
||||
if (this.running < this.max) {
|
||||
this.running++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
this.queue.push(() => { this.running++; resolve(); });
|
||||
});
|
||||
}
|
||||
|
||||
private release() {
|
||||
this.running--;
|
||||
const next = this.queue.shift();
|
||||
if (next) next();
|
||||
}
|
||||
|
||||
get active() { return this.running; }
|
||||
|
||||
get queued() { return this.queue.length; }
|
||||
}
|
||||
|
||||
// ElevenLabs recommends max 2-3 concurrent requests
|
||||
export const ttsLimiter = new Limiter(2);
|
||||
|
||||
// OpenRouter concurrent request cap
|
||||
export const aiLimiter = new Limiter(4);
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { aiLimiter } from "./limiter";
|
||||
|
||||
interface Message {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
|
|
@ -39,7 +41,8 @@ export async function askAI(messages: Message[], options: AskAIOptions = {}): Pr
|
|||
const t0 = Date.now();
|
||||
|
||||
try {
|
||||
const res = await $fetch<{ id?: string; choices: { message: { content: string } }[]; usage?: { prompt_tokens?: number; completion_tokens?: number; cost?: number } }>(
|
||||
console.log(`[openrouter] queued — active: ${aiLimiter.active}, queued: ${aiLimiter.queued}`);
|
||||
const res = await aiLimiter.run(() => $fetch<{ id?: string; choices: { message: { content: string } }[]; usage?: { prompt_tokens?: number; completion_tokens?: number; cost?: number } }>(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
|
|
@ -57,7 +60,7 @@ export async function askAI(messages: Message[], options: AskAIOptions = {}): Pr
|
|||
},
|
||||
signal: AbortSignal.timeout(600_000),
|
||||
}
|
||||
);
|
||||
));
|
||||
|
||||
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
const content = res.choices?.[0]?.message?.content;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue