251 lines
9.1 KiB
JavaScript
251 lines
9.1 KiB
JavaScript
/* 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);
|