initial: novel-app snapshot

This commit is contained in:
omo
2026-08-17 17:11:30 +08:00
commit 1ae06209ac
34 changed files with 2821 additions and 0 deletions

View 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);