);
}
/* ------------------------------ TopBar ------------------------------ */
function TopBar({ p, stepIndex, onStep, onReset }) {
const total = p.setup.targetWords || 0;
const written = (p.chapters || []).reduce((a, c) => a + chapterWords(c), 0);
return (
B
AI Book Builder
idea · outline · manuscript
{p.stage !== "welcome" && (
{STEPS.map((s, i) => (
))}
)}
{p.stage === "workspace" && (
Manuscript
{fmt(written)} / {fmt(total)}
)}
);
}
/* ------------------------------ Welcome ------------------------------ */
function Welcome({ onStart, p }) {
const has = p.chapters.length > 0 || p.title || p.concept;
return (
A studio for long-form writing
Write the whole book — one deliberate stage at a time.
Plan and draft fiction or nonfiction up to 120,000 words. We move from idea to
title, to a working outline, to chapter summaries, and finally into a drafting desk where the
manuscript grows section by section — so nothing is dashed off in a single breathless block.
{has && A project is in progress — your place is saved in this browser.}
Staged, not rushed
Onboarding, then title & concept, then outline, then chapter summaries, then drafting.
Each result is yours to edit before the next step.
Consistent to the end
Every chapter is drafted with the outline and the summaries of what came before it in view,
keeping voice, plot, and argument coherent across the manuscript.
);
}
/* ------------------------------ Outline ------------------------------ */
function OutlineStage({ p, update, onNext, onBack }) {
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
useEffect(() => {
if (p.setup.outlineMode === "auto" && !p.outline && !busy) gen();
// eslint-disable-next-line
}, []);
async function gen() {
setErr(""); setBusy(true);
try {
const fic = p.setup.type === "fiction";
const text = await callClaude({
system: "You are a developmental editor creating a high-level book outline. Write clean, readable Markdown. Be specific and original — no filler.",
user: `Create a high-level outline for this book. Include:\n- A one-line logline.\n- A short synopsis (3-5 sentences).\n${fic ? "- The dramatic structure as parts/acts with the key turning points, midpoint, and climax.\n- The main character arc(s) in brief." : "- The argument's structure as parts/sections with the throughline.\n- The key takeaways the reader should leave with."}\nDo NOT write per-chapter breakdowns yet — that comes next. Keep it under ~350 words. Use Markdown headings and short bullet points.\n\n${bookContext(p)}`,
});
update({ outline: stripFences(text) });
} catch (e) { setErr("Couldn't generate the outline. " + e.message); }
setBusy(false);
}
return (
Step four
The outline
A map of the whole book before chapters. {p.setup.outlineMode === "guided" ? "Generate, then shape it yourself." : ""} Edit anything.
{err &&
{err}
}
{busy && !p.outline ? (
Drafting your outline
) : (
);
}
/* --------------------------- Chapter Plan --------------------------- */
function distributeWords(total, chapters) {
const n = chapters.length || 1;
const sugg = chapters.map((c) => (c.targetWords && c.targetWords > 0 ? c.targetWords : null));
const known = sugg.filter((x) => x != null);
let weights;
if (known.length === n) {
const sum = known.reduce((a, b) => a + b, 0) || 1;
weights = sugg.map((x) => x / sum);
} else {
weights = chapters.map(() => 1 / n);
}
return chapters.map((c, i) => ({ ...c, targetWords: Math.max(300, Math.round((total * weights[i]) / 100) * 100) }));
}
function ChapterPlan({ p, update, onNext, onBack }) {
const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState("");
const [err, setErr] = useState("");
const [regen, setRegen] = useState(null);
async function genBatch(start, end, existing) {
const fic = p.setup.type === "fiction";
const priorTitles = existing.slice(0, start).map((c, i) => `${i + 1}. ${c.title}`).join("\n") || "(none yet)";
const text = await callClaude({
system: "You are a developmental editor breaking a book into chapters. Return ONLY a JSON array of objects with keys: title, summary, words. No markdown, no commentary.",
user: `Plan ${fic ? "chapters" : "chapters/sections"} ${start + 1} through ${end} of ${p.setup.chapterCount} for this book, consistent with the outline below.\nFor each: a distinctive "title", a "summary" of 30-55 words describing what happens / is covered (and how it advances the ${fic ? "story and character arc" : "argument"}), and "words" = recommended word count.\nKeep continuity with earlier chapters; do not repeat material. Aim so the full book totals about ${fmt(p.setup.targetWords)} words.\n\n${bookContext(p)}\n\nOUTLINE:\n${p.outline}\n\nEARLIER CHAPTER TITLES:\n${priorTitles}\n\nReturn JSON array of exactly ${end - start} items.`,
});
const arr = safeParse(text);
if (!Array.isArray(arr)) throw new Error("Unexpected format");
return arr.map((o) => ({
id: uid(),
title: String(o.title || "Untitled"),
summary: String(o.summary || ""),
targetWords: parseInt(o.words) || 0,
sections: [],
done: false,
}));
}
async function genAll() {
setErr(""); setBusy(true); setProgress("");
try {
const count = p.setup.chapterCount;
const BATCH = 6;
let chapters = [];
for (let st = 0; st < count; st += BATCH) {
const e = Math.min(count, st + BATCH);
setProgress(`Chapters ${st + 1}–${e} of ${count}`);
const batch = await genBatch(st, e, chapters);
chapters = chapters.concat(batch);
}
chapters = distributeWords(p.setup.targetWords, chapters);
update({ chapters, activeChapter: 0 });
} catch (e) { setErr("Couldn't generate the chapter plan. " + e.message); }
setBusy(false); setProgress("");
}
async function regenOne(idx) {
setErr(""); setRegen(idx);
try {
const fic = p.setup.type === "fiction";
const ch = p.chapters[idx];
const surrounding = p.chapters
.map((c, i) => `${i + 1}. ${c.title}${i === idx ? " <-- regenerate this one" : ""}`)
.join("\n");
const text = await callClaude({
system: "You are a developmental editor. Return ONLY a JSON object with keys: title, summary, words. No commentary.",
user: `Rewrite a fresh, original version of chapter ${idx + 1} so it fits between its neighbours without overlap, advancing the ${fic ? "story" : "argument"}. Provide "title", a 30-55 word "summary", and recommended "words" (current target ${ch.targetWords}).\n\n${bookContext(p)}\n\nOUTLINE:\n${p.outline}\n\nALL CHAPTERS:\n${surrounding}\n\nReturn one JSON object.`,
});
const o = safeParse(text);
if (o && (o.title || o.summary)) {
update((prev) => {
const chapters = prev.chapters.slice();
chapters[idx] = { ...chapters[idx], title: String(o.title || chapters[idx].title), summary: String(o.summary || chapters[idx].summary), targetWords: parseInt(o.words) || chapters[idx].targetWords };
return { chapters };
});
}
} catch (e) { setErr("Couldn't regenerate that chapter. " + e.message); }
setRegen(null);
}
const editChapter = (idx, patch) => update((prev) => {
const chapters = prev.chapters.slice();
chapters[idx] = { ...chapters[idx], ...patch };
return { chapters };
});
const sumWords = p.chapters.reduce((a, c) => a + (c.targetWords || 0), 0);
const has = p.chapters.length > 0;
return (
Step five
Chapter titles & summaries
{p.setup.chapterCount} planned. Recommended word counts shown — edit titles, summaries, or counts; regenerate any single one.
{has && <> Planned total: {fmt(sumWords)} words.>}
{err &&
{err}
}
{!has && !busy && (
No chapters yet. Generate the plan from your outline, then refine.
)}
{busy && !has && (
Planning chapters — {progress}
)}
{p.chapters.map((c, i) => (
{i + 1}
editChapter(i, { title: e.target.value })} />
))}
);
}
/* ------------------------------ Workspace ------------------------------ */
function Workspace({ p, update, onBack }) {
const idx = Math.min(p.activeChapter || 0, Math.max(0, p.chapters.length - 1));
const ch = p.chapters[idx];
const [busy, setBusy] = useState(false);
const [regenSec, setRegenSec] = useState(null);
const [err, setErr] = useState("");
const setActive = (i) => update({ activeChapter: i });
const editChapter = (i, patch) => update((prev) => {
const chapters = prev.chapters.slice();
chapters[i] = { ...chapters[i], ...(typeof patch === "function" ? patch(chapters[i]) : patch) };
return { chapters };
});
function buildContext() {
const fic = p.setup.type === "fiction";
const allChapters = p.chapters.map((c, i) => `${i + 1}. ${c.title} — ${c.summary}`).join("\n");
const written = chapterWords(ch);
const existing = (ch.sections || []).map((s) => s.text).join("\n\n");
const tail = existing.split(/\s+/).slice(-400).join(" ");
return { fic, allChapters, written, existing, tail };
}
async function genSection() {
setErr(""); setBusy(true);
try {
const { fic, allChapters, written, tail } = buildContext();
const remaining = Math.max(0, (ch.targetWords || 0) - written);
const first = (ch.sections || []).length === 0;
const text = await callClaude({
maxTokens: 1500,
system: `You are ghostwriting a ${fic ? "novel" : "nonfiction book"} as the author's collaborator. Write polished, publication-quality prose in the established voice. Output ONLY the prose for the next section — no headings, no labels, no meta-commentary, no summaries. Be original; avoid cliché and filler.`,
user:
`Write the next section of Chapter ${idx + 1}: "${ch.title}". Aim for roughly 450-650 words of continuous ${fic ? "narrative" : "exposition"}.\n` +
`${first ? "This is the opening of the chapter — establish it cleanly." : "Continue seamlessly from the prose so far; do not recap."}\n` +
`Maintain consistent ${fic ? "POV, tense, voice and continuity" : "argument, voice and terminology"}.\n` +
`If the chapter reaches its natural conclusion (about ${fmt(ch.targetWords)} words total, ~${fmt(remaining)} remain), bring it to a satisfying close and then put the token [[END]] on its own final line.\n\n` +
`${bookContext(p)}\n\nOUTLINE:\n${p.outline}\n\nALL CHAPTERS (for continuity):\n${allChapters}\n\nTHIS CHAPTER'S SUMMARY:\n${ch.summary}\n\n` +
(tail ? `THE PROSE SO FAR (end of it):\n...${tail}\n` : ``),
});
let body = stripFences(text);
let done = false;
if (/\[\[END\]\]/.test(body)) { done = true; body = body.replace(/\[\[END\]\]/g, "").trim(); }
editChapter(idx, (c) => {
const sections = (c.sections || []).concat([{ id: uid(), text: body }]);
const total = sections.reduce((a, s) => a + wordCount(s.text), 0);
return { sections, done: done || total >= (c.targetWords || 0) * 0.95 };
});
} catch (e) { setErr("Couldn't draft that section. " + e.message); }
setBusy(false);
}
async function regenerateSection(secIdx) {
setErr(""); setRegenSec(secIdx);
try {
const { fic, allChapters } = buildContext();
const before = (ch.sections || []).slice(0, secIdx).map((s) => s.text).join("\n\n");
const tail = before.split(/\s+/).slice(-400).join(" ");
const text = await callClaude({
maxTokens: 1500,
system: `You are ghostwriting a ${fic ? "novel" : "nonfiction book"}. Output ONLY replacement prose for one section — no headings or meta-commentary. Match the established voice; be original.`,
user: `Rewrite a fresh version of this section of Chapter ${idx + 1}: "${ch.title}" (~450-650 words). It must flow from the prose before it and set up what follows, without repeating other sections.\n\n${bookContext(p)}\n\nTHIS CHAPTER'S SUMMARY:\n${ch.summary}\n\nALL CHAPTERS:\n${allChapters}\n\n${tail ? "PROSE BEFORE THIS SECTION (end):\n..." + tail : "This is the opening section."}`,
});
const body = stripFences(text).replace(/\[\[END\]\]/g, "").trim();
editChapter(idx, (c) => {
const sections = c.sections.slice();
sections[secIdx] = { ...sections[secIdx], text: body };
return { sections };
});
} catch (e) { setErr("Couldn't regenerate that section. " + e.message); }
setRegenSec(null);
}
function editSectionText(secIdx, txt) {
editChapter(idx, (c) => {
const sections = c.sections.slice();
sections[secIdx] = { ...sections[secIdx], text: txt };
return { sections };
});
}
function deleteSection(secIdx) {
editChapter(idx, (c) => ({ sections: c.sections.filter((_, i) => i !== secIdx) }));
}
function exportManuscript() {
const lines = [];
lines.push("# " + (p.title || "Untitled") + "\n");
if (p.concept) lines.push("> " + p.concept + "\n");
p.chapters.forEach((c, i) => {
lines.push(`\n\n## Chapter ${i + 1}. ${c.title}\n`);
(c.sections || []).forEach((s) => lines.push(s.text + "\n\n"));
});
const blob = new Blob([lines.join("\n")], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = (p.title || "manuscript").replace(/[^a-z0-9]+/gi, "-").toLowerCase() + ".md";
a.click();
URL.revokeObjectURL(url);
}
const written = chapterWords(ch);
const totalWritten = p.chapters.reduce((a, c) => a + chapterWords(c), 0);
const pctChap = ch.targetWords ? Math.min(100, Math.round((written / ch.targetWords) * 100)) : 0;
const pctBook = p.setup.targetWords ? Math.min(100, Math.round((totalWritten / p.setup.targetWords) * 100)) : 0;
return (