initial: novel-app snapshot
This commit is contained in:
250
static/js/pages/deconstruct.js
Normal file
250
static/js/pages/deconstruct.js
Normal file
@@ -0,0 +1,250 @@
|
||||
/* 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);
|
||||
Reference in New Issue
Block a user