Files
novel-app/static/js/pages/deconstruct.js
2026-08-17 17:11:30 +08:00

251 lines
9.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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: '<span class="badge text-bg-secondary">待生成</span>',
streaming: '<span class="badge text-bg-warning">生成中</span>',
confirm: '<span class="badge text-bg-info">待确认</span>',
confirmed: '<span class="badge text-bg-success">已确认</span>',
skipped: '<span class="badge text-bg-dark">已跳过</span>',
};
function buildPanels() {
sectionsEl.innerHTML = SECTIONS.map((s, i) => ''
+ '<div class="accordion-item" data-section="' + s.id + '">'
+ ' <h2 class="accordion-header">'
+ ' <button class="accordion-button' + (i === 0 ? "" : " collapsed") + '" type="button"'
+ ' data-bs-toggle="collapse" data-bs-target="#dcPanel-' + s.id + '">'
+ ' <span class="me-2">' + (i + 1) + '. ' + s.label + '</span>'
+ ' <span class="dc-badge">' + BADGE.pending + '</span>'
+ ' </button>'
+ ' </h2>'
+ ' <div id="dcPanel-' + s.id + '" class="accordion-collapse collapse' + (i === 0 ? " show" : "") + '">'
+ ' <div class="accordion-body">'
+ ' <textarea class="form-control dc-text" rows="8" placeholder="等待生成…"></textarea>'
+ ' <div class="mt-2 d-flex gap-2">'
+ ' <button type="button" class="btn btn-outline-success btn-sm dc-gen">生成</button>'
+ ' <button type="button" class="btn btn-outline-secondary btn-sm dc-skip">跳过 (用默认值)</button>'
+ ' <button type="button" class="btn btn-outline-primary btn-sm dc-confirm" disabled>确认</button>'
+ ' </div>'
+ ' </div>'
+ ' </div>'
+ '</div>').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);