"use client"; import { useState, useRef, useCallback, useId, useEffect } from "react"; import { ProseEditor, ProseToolbarProvider, SharedProseToolbar } from "./ProseEditor"; import { EbookProgressRing } from "@/app/components/EbookProgressRing"; import { VoiceStudio } from "@/app/components/VoiceStudio"; import { saveEbookJob, getEbookJob, newJobId, } from "@/lib/ebook-job-store"; import { harmonizeBookManifest } from "@/lib/editorial-style-bible"; import { BOOK_TEMPLATES, BOOK_TEMPLATE_IDS } from "@/lib/book-templates"; import type { BookTemplateId } from "@/lib/book-templates"; import type { VoiceDNA, ContentMap, BookArchitecture, SectionAssignment, SectionDraft, ChapterDraft, FrontBackMatter, BackMatter, EbookJobState, EbookManifest, } from "@/lib/schemas/ebook"; // ─── Types ──────────────────────────────────────────────────────────────────── type PipelineStage = | "idle" | "transcribing" | "filtering" | "analyzing" | "mapping" | "architecting" | "assigning" | "writing" | "polishing" | "frontmatter" | "exporting" | "complete" | "failed"; const STAGE_LABELS: Record = { idle: "Ready", transcribing: "Transcribing audio…", filtering: "Filtering signal…", analyzing: "Extracting voice DNA…", mapping: "Mapping content…", architecting: "Designing chapters…", assigning: "Assigning segments…", writing: "Writing sections…", polishing: "Polishing chapters…", frontmatter: "Writing front matter…", exporting: "Generating PDF, EPUB & Word…", complete: "Complete", failed: "Failed", }; const STAGE_ORDER: PipelineStage[] = [ "idle", "transcribing", "filtering", "analyzing", "mapping", "architecting", "assigning", "writing", "polishing", "frontmatter", "exporting", "complete", ]; type SignalFilterState = "idle" | "applied" | "skipped"; type QualityReport = { score: number; pass: boolean; issues: { severity: "warn" | "error"; message: string }[] }; export type EbookPipelineSnapshot = { stage: PipelineStage; progress: { total: number; completed: number }; totalWords: number; reviewReady: boolean; qualityReport: QualityReport | null; error: string | null; bookTitle: string | null; chapterCount: number; frontMatterSections: number; }; function routeLabel(url: string): string { return url.split("/").filter(Boolean).slice(-2).join("/"); } function parseSignalFilterLog(logEntries: string[]): { state: SignalFilterState; detail: string | null } { const relevant = [...logEntries].reverse().find( (entry) => entry.includes("Signal filter unavailable") || entry.includes("Signal filtered") || entry.includes("Signal filter complete") ); if (!relevant) return { state: "idle", detail: null }; const message = relevant.replace(/^\[[^\]]+\]\s*/, ""); if (message.includes("Signal filter unavailable")) { return { state: "skipped", detail: message }; } return { state: "applied", detail: message }; } // ─── Helpers ────────────────────────────────────────────────────────────────── async function postJson(url: string, body: unknown, retries = 1): Promise { const route = routeLabel(url); for (let attempt = 0; attempt <= retries; attempt++) { let res: Response; try { res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); } catch (err) { const cause = err instanceof Error ? err.message : "Unknown network failure"; throw new Error([`Request failed: ${route}`, `Cause: ${cause}`].join("\n")); } if (!res.ok) { const rawText = await res.text(); let err: { error?: string; details?: string; route?: string } = {}; try { err = rawText ? JSON.parse(rawText) as { error?: string; details?: string; route?: string } : {}; } catch { err = rawText ? { details: rawText } : {}; } const msg = err.error || `HTTP ${res.status} error from ${route}`; // Retry once on transient gateway/auth errors (Codespaces proxy warm-up or LLM timeout) if (attempt < retries && (res.status === 401 || res.status === 502 || res.status === 503 || res.status === 504)) { await new Promise((r) => setTimeout(r, 3000)); continue; } // Surface a helpful message for persistent 401s if (res.status === 401) { throw new Error("Session expired or API key invalid — please refresh the page and try again"); } throw new Error([ `Request failed: ${err.route || route}`, `Status: ${res.status} ${res.statusText}`, `Error: ${msg}`, err.details ? `Details: ${err.details}` : "", ].filter(Boolean).join("\n")); } return res.json() as Promise; } throw new Error(`Request failed after retries: ${route}`); } async function streamSection( assignment: SectionAssignment, authorConfig?: { instructions: string; targetAudience: string } ): Promise<{ body: string; claimLedger: Array<{ claim: string; excerptNumbers: number[] }>; passiveVoiceCount: number; unfullfilledHook: string | null; sequenceBreakCount: number }> { const result = await postJson<{ body: string; claimLedger?: Array<{ claim: string; excerptNumbers: number[] }>; passiveVoiceCount?: number; unfullfilledHook?: string | null; sequenceBreakCount?: number }>( "/api/ebook/write-section", { assignment, ...(authorConfig ? { authorConfig } : {}) } ); return { body: (result.body ?? "").trim(), claimLedger: result.claimLedger ?? [], passiveVoiceCount: result.passiveVoiceCount ?? 0, unfullfilledHook: result.unfullfilledHook ?? null, sequenceBreakCount: result.sequenceBreakCount ?? 0, }; } function countWords(text: string): number { return text.trim().split(/\s+/).filter(Boolean).length; } // ─── Upgrade 4 (writer): Illustration / story label extractor ──────────────── // Scans written prose for story-opening sentences and returns short labels // (first 100 chars) so later sections can be told not to retell the same story. const STORY_OPENERS = /\b(when i was|i remember|there was a|let me tell you|i once|one day|a man named|a woman named|i met a|i spoke to|i was in|years ago|i had a|the story of|he told me|she told me|they told me|i saw a|i witnessed)\b/i; function extractIllustrationLabels(body: string): string[] { const labels: string[] = []; const sentences = body.replace(/^#{1,3} .+$/gm, "").split(/(?<=[.!?])\s+/).filter(Boolean); for (const sentence of sentences) { if (STORY_OPENERS.test(sentence)) { labels.push(sentence.replace(/[#>*_]/g, "").trim().slice(0, 100)); } } return labels; } // ─── Upgrade 6: N-gram overlap dedup gate ──────────────────────────────────── function ngramTokens(text: string, n = 4): Set { const words = text.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean); const grams = new Set(); for (let i = 0; i <= words.length - n; i++) { grams.add(words.slice(i, i + n).join(" ")); } return grams; } function ngramOverlapRatio(a: string, b: string, n = 4): number { const setA = ngramTokens(a, n); const setB = ngramTokens(b, n); if (setA.size === 0 || setB.size === 0) return 0; let shared = 0; for (const gram of setA) { if (setB.has(gram)) shared++; } return shared / Math.min(setA.size, setB.size); } /** Returns sentences from newBody that overlap ≥ threshold with any sentence in corpus */ function detectDuplicateSentences( newBody: string, corpus: string, threshold = 0.55 ): string[] { const corpusSentences = corpus.match(/[^.!?]+[.!?]+/g) ?? []; const newSentences = newBody.match(/[^.!?]+[.!?]+/g) ?? []; const flagged: string[] = []; for (const ns of newSentences) { if (ns.trim().split(/\s+/).length < 8) continue; // skip fragments const hit = corpusSentences.some((cs) => ngramOverlapRatio(ns, cs) >= threshold); if (hit) flagged.push(ns.trim()); } return flagged; } // ─── Amendment 1: Coverage Ledger Builder ───────────────────────────────────── // Returns a compact heading + one-sentence summary for every written section so // the LLM can see what ground has been covered without re-reading full bodies. function buildCoverageLedger( sections: SectionDraft[], assignmentLookup?: Map, // key: `ch-sec`, value: keyPoints[] ): { heading: string; summary: string }[] { return sections.map((s) => { // Capture the first sentence of the first paragraph as the prose anchor const firstSentence = (s.body ?? "") .split(/\n\n+/)[0] ?.replace(/^#{1,3} .+$/gm, "") ?.match(/[^.!?]+[.!?]+/)?.[0] ?.trim() ?.slice(0, 120) ?? ""; // Append key points from assignments so the block lists what was actually taught const kps = assignmentLookup?.get(`${s.chapterNumber}-${s.sectionNumber}`) ?? []; const keyPointHint = kps.length > 0 ? ` | Key points: ${kps.slice(0, 3).join("; ")}` : ""; const summary = `${firstSentence}${keyPointHint}`.slice(0, 260); return { heading: s.heading, summary }; }).filter((e) => e.heading && e.summary.length > 10); } // ─── Amendment 4: Thesis Sentence Extractor ─────────────────────────────────── // The opening sentence of each paragraph is the most reliable thesis carrier. // Extract up to `maxPerSection` per section, capped at `hardCap` total. function extractBannedRecaps(sections: SectionDraft[], maxPerSection = 4, hardCap = 35): string[] { const all: string[] = []; for (const s of sections) { const paras = (s.body ?? "").split(/\n\n+/).filter(Boolean); for (const para of paras.slice(0, maxPerSection)) { const opener = para.replace(/^#{1,3} .+$/gm, "") .match(/[^.!?]+[.!?]+/)?.[0]?.trim() ?? ""; if (opener.split(/\s+/).length >= 8) all.push(opener.slice(0, 150)); } if (all.length >= hardCap) break; } return all.slice(0, hardCap); } // ─── Prose Corpus Sample Builder ──────────────────────────────────────────── // Extracts the first sentence of each paragraph from the accumulated written corpus. // This is the comparison corpus sent to write-section so filterConsumedExcerpts can // do prose-vs-prose n-gram overlap instead of excerpt-vs-metadata comparison. function buildProseCorpusSample(corpus: string, maxSentences = 120): string[] { return corpus .split(/\n{2,}/) .map((p) => p.replace(/^[>\s#*\-]+/, "").split(/(?<=[.!?])\s+/)[0]?.trim() ) .filter((s): s is string => Boolean(s) && s.split(/\s+/).length >= 8) .slice(0, maxSentences); } // ─── Amendment 6: Lexical Fingerprint Extractor ─────────────────────────────── // Counts 3-gram frequency across the written corpus and returns the top-N phrases // the LLM should diversify away from (excluding scripture-heavy n-grams). const STOP_WORDS = new Set([ "the","a","an","and","but","or","of","to","in","on","at","is","are","was","were", "be","been","being","have","has","had","do","does","did","will","would","can","could", "should","may","might","must","shall","not","no","so","if","as","by","for","from", "with","that","this","it","he","she","we","they","i","you","his","her","our","their", "its","my","your","who","which","what","when","where","how","all","also","more","just", "like","about","then","there","than","up","out","only","over","after","before","since", "while","although","because","into","through","during","some","any","each","both", ]); function extractOverusedPhrases(corpus: string, topN = 10): string[] { if (!corpus || corpus.length < 800) return []; const words = corpus.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean); const freq = new Map(); for (let i = 0; i <= words.length - 3; i++) { const [a, b, c] = [words[i], words[i + 1], words[i + 2]]; // Skip trigrams where the first two tokens are both stop words if (STOP_WORDS.has(a) && STOP_WORDS.has(b)) continue; // Skip scripture citation patterns like "john three sixteen" if (/^\d+$/.test(c)) continue; const gram = `${a} ${b} ${c}`; freq.set(gram, (freq.get(gram) ?? 0) + 1); } return Array.from(freq.entries()) .filter(([, cnt]) => cnt >= 3) // must appear ≥3 times to be worth flagging .sort((a, b) => b[1] - a[1]) .slice(0, topN) .map(([gram]) => gram); } // ─── Seq-Amendment 3: Argument-Turn Extractor ───────────────────────────────── // Scans excerpts for speaker pivot phrases — the moments the teacher changes // direction or signals a key point. These mark paragraph boundaries the LLM // must NOT merge across. const TURN_SIGNALS = /\b(but here'?s? (?:the )?(?:thing|truth|key|point)|now watch this|let me show you|look at (?:verse|this)|here'?s? what i want you to see|here'?s? the (?:point|key|thing)|but watch|but look at this|now look|pay attention|don'?t miss this|notice this|watch this|get this|and notice|the (?:point|key|truth|secret) is|so here'?s? (?:what|where|the)|now here'?s? the|here'?s? where it gets|but this is (?:important|key|critical)|so what does (?:that|this) mean|what does that look like|now what about|and this is where|see what happened|watch what|now consider|look at what)\b/gi; function extractSequenceTurns(excerpts: string[]): string[] { const turns: string[] = []; for (const excerpt of excerpts) { const sentences = excerpt.split(/(?<=[.!?])\s+/).filter(Boolean); for (const sentence of sentences) { if (TURN_SIGNALS.test(sentence)) { TURN_SIGNALS.lastIndex = 0; const cleaned = sentence.replace(/[#>*_]/g, "").trim().slice(0, 140); if (!turns.includes(cleaned)) turns.push(cleaned); } } } return turns.slice(0, 20); // cap to avoid prompt bloat } // ─── Seq-Amendment 4: Story Setup → Payoff Extractor ───────────────────────── // Finds story-opening sentences and the principle/payoff sentence that follows // within 4 sentences. Passed to the writer as ordered pairs: setup must come // before payoff — reversing them violates the speaker's teaching logic. const PRINCIPLE_SIGNALS = /\b(the (?:lesson|point|truth|key|principle|answer|secret) (?:is|here is)|what (?:this|that) (?:teaches|shows|tells|means)|(?:and )?(?:that'?s? why|that'?s? the|this is why|this means)|so the (?:point|truth|lesson)|here'?s? the truth|the moral (?:is|of)|what god (?:was|is) saying|what (?:he|she|they) (?:was|were|is) trying to say|the takeaway|the (?:real )?question is|the (?:real )?issue (?:is|here)|so what|and so|therefore)\b/i; function extractStoryPayoffPairs(excerpts: string[]): { setup: string; principle: string }[] { const pairs: { setup: string; principle: string }[] = []; for (const excerpt of excerpts) { const sentences = excerpt.split(/(?<=[.!?])\s+/).filter(Boolean); for (let i = 0; i < sentences.length; i++) { if (!STORY_OPENERS.test(sentences[i])) continue; // Look forward up to 5 sentences for the payoff for (let j = i + 1; j < Math.min(i + 6, sentences.length); j++) { if (PRINCIPLE_SIGNALS.test(sentences[j])) { pairs.push({ setup: sentences[i].replace(/[#>*_]/g, "").trim().slice(0, 130), principle: sentences[j].replace(/[#>*_]/g, "").trim().slice(0, 130), }); break; } } } } return pairs.slice(0, 8); } // ─── Seq-Amendment 5: Scripture Position Extractor ─────────────────────────── // Records which excerpt index (0-based) each scripture reference first appears // in. Passed to the LLM so it knows a verse from Excerpt 4 must not appear in // paragraphs anchored to Excerpts 1–3. const SCRIPTURE_REF_RE = /\b(?:genesis|exodus|leviticus|numbers|deuteronomy|joshua|judges|ruth|(?:1|2)\s*samuel|(?:1|2)\s*kings|(?:1|2)\s*chronicles|ezra|nehemiah|esther|job|psalm(?:s)?|proverbs|ecclesiastes|(?:song of solomon|song of songs)|isaiah|jeremiah|lamentations|ezekiel|daniel|hosea|joel|amos|obadiah|jonah|micah|nahum|habakkuk|zephaniah|haggai|zechariah|malachi|matthew|mark|luke|john|acts|romans|(?:1|2)\s*corinthians|galatians|ephesians|philippians|colossians|(?:1|2)\s*thessalonians|(?:1|2)\s*timothy|titus|philemon|hebrews|james|(?:1|2|3)\s*(?:john|peter)|jude|revelation)\s+\d+:\d+/gi; function extractScripturePositions(excerpts: string[]): { reference: string; excerptIndex: number }[] { const seen = new Set(); const positions: { reference: string; excerptIndex: number }[] = []; for (let i = 0; i < excerpts.length; i++) { const matches = excerpts[i].matchAll(SCRIPTURE_REF_RE); for (const match of matches) { const ref = match[0].replace(/\s+/g, " ").trim().toLowerCase(); if (!seen.has(ref)) { seen.add(ref); positions.push({ reference: match[0].trim(), excerptIndex: i }); } } } return positions; } // ─── Seq-Amendment 2: Server-side sequence watermark ───────────────────────── // For each written paragraph, finds the best-matching excerpt by 4-gram overlap // and verifies excerpt indices are non-decreasing. Returns any positions where // the LLM jumped back to an earlier excerpt (sequence inversion). function checkSequenceWatermark( paragraphs: string[], excerpts: string[], minScore = 0.06 ): { paragraphIdx: number; expectedMin: number; got: number }[] { if (excerpts.length < 2) return []; const breaks: { paragraphIdx: number; expectedMin: number; got: number }[] = []; let lastExcerptIdx = -1; const words = (text: string) => text.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean); const ngrams = (text: string, n: number): Set => { const w = words(text); const s = new Set(); for (let i = 0; i <= w.length - n; i++) s.add(w.slice(i, i + n).join(" ")); return s; }; const overlapScore = (a: string, b: string): number => { const sa = ngrams(a, 4); const sb = ngrams(b, 4); if (sa.size === 0) return 0; let shared = 0; for (const g of sa) { if (sb.has(g)) shared++; } return shared / sa.size; }; for (let pIdx = 0; pIdx < paragraphs.length; pIdx++) { let bestScore = minScore; let bestExcerptIdx = -1; for (let eIdx = 0; eIdx < excerpts.length; eIdx++) { const score = overlapScore(paragraphs[pIdx], excerpts[eIdx]); if (score > bestScore) { bestScore = score; bestExcerptIdx = eIdx; } } if (bestExcerptIdx >= 0) { if (lastExcerptIdx >= 0 && bestExcerptIdx < lastExcerptIdx) { breaks.push({ paragraphIdx: pIdx + 1, expectedMin: lastExcerptIdx + 1, got: bestExcerptIdx + 1 }); } lastExcerptIdx = Math.max(lastExcerptIdx, bestExcerptIdx); } } return breaks; } // ─── Audio Upload Card ──────────────────────────────────────────────────────── function AudioCard({ index, file, onFile, transcriptFile, onTranscriptFile, disabled, }: { index: number; file: File | null; onFile: (f: File | null) => void; transcriptFile: File | null; onTranscriptFile: (f: File | null) => void; disabled: boolean; }) { const audioInputId = useId(); const txInputId = useId(); const onAudioDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); const f = e.dataTransfer.files[0]; // Accept audio and video containers (MP4/MOV/M4A often carry sermon audio on iOS) if (f) onFile(f); }, [onFile] ); const onTxDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) onTranscriptFile(f); }, [onTranscriptFile] ); const hasContent = file || transcriptFile; return (
{/* Slot label */}
Slot {index + 1} {hasContent && ( ✓ Ready )}
{/* ── Audio upload ── */} {/* ── Divider ── */}
OR
{/* ── Transcript upload ── */}
); } // ─── Stage Tracker ─────────────────────────────────────────────────────────── const STAGE_STEPS: { key: PipelineStage; label: string; description: string }[] = [ { key: "transcribing", label: "Transcribe", description: "Converting audio to text via Deepgram nova-2" }, { key: "filtering", label: "Signal Filter", description: "Stripping prayers, announcements, and non-teaching content from transcript" }, { key: "analyzing", label: "Voice DNA", description: "Extracting author's signature phrases, tone, and teaching style" }, { key: "mapping", label: "Content Map", description: "Inventorying every teaching segment, scripture, and quote" }, { key: "architecting", label: "Chapters", description: "Designing chapter and section structure from the content" }, { key: "writing", label: "Writing", description: "Drafting each section strictly from transcript source material" }, { key: "polishing", label: "Polish", description: "Adding chapter intros, conclusions, and key takeaways" }, { key: "frontmatter", label: "Front Matter", description: "Writing introduction and conclusion from your words" }, { key: "exporting", label: "Export", description: "Generating PDF and EPUB files for download" }, ]; // Collapse adjacent stages so assigning/polishing/frontmatter light up their parent step function resolveActiveStep(current: PipelineStage): PipelineStage { if (current === "assigning") return "architecting"; if (current === "polishing") return "polishing"; return current; } function EbookStageTracker({ current, progress, signalFilterState, signalFilterDetail, }: { current: PipelineStage; progress: { total: number; completed: number }; signalFilterState: SignalFilterState; signalFilterDetail: string | null; }) { const currentIdx = STAGE_ORDER.indexOf(current); const activeKey = resolveActiveStep(current); const activeStep = STAGE_STEPS.find((s) => s.key === activeKey); return (
{/* Header */}
{current === "complete" ? "Production Complete" : current === "failed" ? "Production Failed" : "Pipeline Active"}
{current === "writing" && progress.total > 0 && ( Section {progress.completed} / {progress.total} )}
{/* Agent step pills */}
{STAGE_STEPS.map((step) => { const idx = STAGE_ORDER.indexOf(step.key); const done = idx < currentIdx || current === "complete"; const active = step.key === activeKey && current !== "complete" && current !== "failed" && current !== "idle"; const skipped = step.key === "filtering" && signalFilterState === "skipped"; return (
{step.label}
); })}
{signalFilterState === "skipped" && signalFilterDetail && current !== "failed" && (

Signal filter was skipped. Downstream steps are running on the raw transcript.

{signalFilterDetail}

)} {/* Active step description */} {activeStep && current !== "complete" && current !== "failed" && (

{activeStep.description}

)}
); } // ─── Chapter Preview Card ───────────────────────────────────────────────────── function ChapterCard({ chapter, editable = false, onChange, }: { chapter: ChapterDraft; editable?: boolean; onChange?: (next: ChapterDraft) => void; }) { const [open, setOpen] = useState(false); const done = chapter.status === "complete"; const patchChapter = (patch: Partial) => { if (!onChange) return; onChange({ ...chapter, ...patch }); }; const patchSection = (sectionNumber: number, patch: Partial) => { if (!onChange) return; onChange({ ...chapter, sections: chapter.sections.map((section) => ( section.sectionNumber === sectionNumber ? { ...section, ...patch } : section )), }); }; const patchListField = (field: "keyTakeaways" | "reflectionQuestions", value: string) => { if (!onChange) return; const items = value.split(/\n+/).map((item) => item.trim()).filter(Boolean); onChange({ ...chapter, [field]: items } as ChapterDraft); }; return (
{open && (
{editable && (
patchChapter({ title: e.target.value })} className="w-full min-h-[48px] rounded-xl border border-slate-700/60 bg-slate-950/70 px-3 py-2 text-base text-slate-100 outline-none ring-0 focus:border-cyan-500/40" />
patchChapter({ intro: v })} rows={4} placeholder="Chapter opening paragraph…" />
)} {chapter.sections.map((s) => (
{editable ? ( patchSection(s.sectionNumber, { body: v, wordCount: countWords(v) })} rows={10} placeholder="Write section body…" /> ) : ( <>

{s.heading}

{(s.body ?? "").slice(0, 220)}{(s.body ?? "").length > 220 ? "…" : ""}

)}
))} {editable ? (