/* deconstruct page: 拆书 UI - 顺序生成 5 个 section, 用户逐个确认后创建小说. */ import { escapeHtml, getCsrf, postJson, showError } from "../utils.js"; import { createSSEStream } from "../components/sse-client.js"; const SECTIONS = [ { id: "concept", label: "主题构思" }, { id: "world", label: "世界观" }, { id: "characters", label: "角色" }, { id: "outline", label: "大纲" }, { id: "foreshadowing", label: "伏笔计划" }, ]; // state: pending -> streaming -> confirm -> confirmed | skipped const state = {}; SECTIONS.forEach((s) => { state[s.id] = { status: "pending", text: "" }; }); let activeStream = null; let autoRunning = false; const alertsEl = document.getElementById("dcAlerts"); const sectionsEl = document.getElementById("dcSections"); const startBtn = document.getElementById("dcStartBtn"); const createBtn = document.getElementById("dcCreateBtn"); /* ---------------- UI 构建 ---------------- */ const BADGE = { pending: '待生成', streaming: '生成中', confirm: '待确认', confirmed: '已确认', skipped: '已跳过', }; function buildPanels() { sectionsEl.innerHTML = SECTIONS.map((s, i) => '' + '
' + '

' + ' ' + '

' + '
' + '
' + ' ' + '
' + ' ' + ' ' + ' ' + '
' + '
' + '
' + '
').join(""); sectionsEl.querySelectorAll(".accordion-item").forEach((item) => { const id = item.dataset.section; item.querySelector(".dc-gen").addEventListener("click", () => streamSection(id)); item.querySelector(".dc-skip").addEventListener("click", () => skipSection(id)); item.querySelector(".dc-confirm").addEventListener("click", () => confirmSection(id)); item.querySelector(".dc-text").addEventListener("input", (e) => { state[id].text = e.target.value; }); }); } function setStatus(id, status) { state[id].status = status; const item = sectionsEl.querySelector('[data-section="' + id + '"]'); item.querySelector(".dc-badge").innerHTML = BADGE[status]; const genBtn = item.querySelector(".dc-gen"); const confirmBtn = item.querySelector(".dc-confirm"); genBtn.disabled = status === "streaming"; genBtn.textContent = (status === "pending" || status === "streaming") ? "生成" : "重新生成"; confirmBtn.disabled = status !== "confirm"; updateCreateBtn(); } function openPanel(id) { const panel = document.getElementById("dcPanel-" + id); if (panel && !panel.classList.contains("show")) { new bootstrap.Collapse(panel, { toggle: true }); } } function updateCreateBtn() { createBtn.disabled = !SECTIONS.every( (s) => state[s.id].status === "confirmed" || state[s.id].status === "skipped" ); } /* ---------------- 拆书流程 ---------------- */ function inputs(section) { const confirmed = {}; SECTIONS.forEach((s) => { if (state[s.id].status === "confirmed" && s.id !== section) confirmed[s.id] = state[s.id].text; }); return { title: document.getElementById("dcTitle").value.trim(), genre: document.getElementById("dcGenre").value, style: document.getElementById("dcStyle").value.trim(), target_words: document.getElementById("dcWords").value, reference_novel: document.getElementById("dcRef").value.trim(), section, confirmed: JSON.stringify(confirmed), csrf_token: getCsrf(), }; } function streamSection(id) { const inp = inputs(id); if (!inp.title) { showError(alertsEl, "请先填写书名"); return; } if (activeStream) activeStream.close(); const item = sectionsEl.querySelector('[data-section="' + id + '"]'); const textarea = item.querySelector(".dc-text"); textarea.value = ""; state[id].text = ""; setStatus(id, "streaming"); openPanel(id); const url = "/api/deconstruct/stream?" + new URLSearchParams(inp).toString(); activeStream = createSSEStream(url, { onToken: (t) => { textarea.value += t; state[id].text = textarea.value; textarea.scrollTop = textarea.scrollHeight; }, onSectionEnd: (section, content) => { textarea.value = content; state[section].text = content; setStatus(section, "confirm"); activeStream = null; if (autoRunning) nextSection(section); }, onError: (msg) => { showError(alertsEl, "生成失败: " + msg); setStatus(id, "pending"); activeStream = null; autoRunning = false; }, }); } function nextSection(doneId) { const idx = SECTIONS.findIndex((s) => s.id === doneId); const next = SECTIONS.slice(idx + 1).find((s) => state[s.id].status === "pending"); if (next) streamSection(next.id); else autoRunning = false; } function confirmSection(id) { const item = sectionsEl.querySelector('[data-section="' + id + '"]'); state[id].text = item.querySelector(".dc-text").value; setStatus(id, "confirmed"); } function skipSection(id) { if (activeStream) { activeStream.close(); activeStream = null; } if (!state[id].text) state[id].text = "(用户跳过, 由 AI 在写作时自由发挥)"; setStatus(id, "skipped"); } /* ---------------- 解析 + 创建小说 ---------------- */ function parseCharacters(text) { const out = []; text.split(/【角色】/).slice(1).forEach((chunk) => { const lines = chunk.trim().split("\n").filter((l) => l.trim()); if (!lines.length) return; const first = lines[0].trim(); const name = first.split(/[\s,,::/]/)[0].trim() || "未命名"; out.push({ name, role: "角色", description: chunk.trim() }); }); return out; } function parseOutline(text) { const chapters = []; let volume = 1; let seq = 0; text.split("\n").forEach((line) => { const t = line.trim(); if (!t) return; const vm = t.match(/第\s*(\d+)\s*卷/); if (vm) { volume = parseInt(vm[1], 10); return; } const cm = t.match(/第\s*(\d+)\s*章[\s:::]*(.*)/); if (cm) { chapters.push({ volume, chapter_number: parseInt(cm[1], 10), title: (cm[2] || "").split(/[—\-–]/)[0].trim(), outline: t, }); seq = cm[1] ? parseInt(cm[1], 10) : seq; return; } seq += 1; chapters.push({ volume: 1, chapter_number: seq, title: "", outline: t }); }); return chapters; } function parseForeshadowing(text) { return text.split(/【伏笔】/).slice(1).map((chunk) => ({ description: chunk.trim(), })).filter((f) => f.description); } async function createNovel() { const inp = inputs("concept"); const characters = parseCharacters(state.characters.text); const outlineChapters = parseOutline(state.outline.text); const foreshadowing = parseForeshadowing(state.foreshadowing.text); if (state.characters.status === "confirmed" && !characters.length) { alert("角色文本未能解析出【角色】条目, 将只创建小说本体。"); } createBtn.disabled = true; try { const resp = await postJson("/api/novels", { title: inp.title, genre: inp.genre, style: inp.style, target_words: parseInt(inp.target_words, 10), outline_chapters: outlineChapters, characters, foreshadowing, }); window.location.href = "/novel/" + resp.id; } catch (err) { showError(alertsEl, "创建失败: " + err.message); createBtn.disabled = false; } } /* ---------------- 入口 ---------------- */ buildPanels(); startBtn.addEventListener("click", () => { autoRunning = true; const first = SECTIONS.find((s) => state[s.id].status === "pending") || SECTIONS[0]; streamSection(first.id); }); createBtn.addEventListener("click", createNovel);