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,230 @@
/* chapter write page: 大纲编辑 + 上下文面板 + SSE 流式生成 + 暂停/继续/重写. */
import { escapeHtml, fetchJson, postJson, showError } from "../utils.js";
import { createSSEStream } from "../components/sse-client.js";
const NOVEL_ID = window.NOVEL_ID;
const CHAPTER_ID = window.CHAPTER_ID;
const alertsEl = document.getElementById("cwAlerts");
const outlineEl = document.getElementById("cwOutline");
const outputEl = document.getElementById("cwOutput");
const wordCountEl = document.getElementById("cwWordCount");
const ctxSummaryEl = document.getElementById("cwContextSummary");
const btn = {
generate: document.getElementById("cwGenerate"),
pause: document.getElementById("cwPause"),
resume: document.getElementById("cwResume"),
regen: document.getElementById("cwRegen"),
edit: document.getElementById("cwEdit"),
save: document.getElementById("cwSave"),
approve: document.getElementById("cwApprove"),
saveOutline: document.getElementById("cwSaveOutline"),
};
let stream = null;
let accumulated = ""; // 已生成文本 (暂停断点)
let editing = false;
/* ---------------- 状态管理 ---------------- */
function setStreaming(on) {
btn.generate.disabled = on;
btn.pause.disabled = !on;
btn.regen.disabled = on || !accumulated;
btn.resume.disabled = on || !accumulated;
btn.saveOutline.disabled = on;
if (on) {
btn.edit.disabled = true;
btn.save.disabled = true;
btn.approve.disabled = true;
}
}
function setIdleWithContent() {
btn.generate.disabled = false;
btn.pause.disabled = true;
btn.resume.disabled = true;
btn.regen.disabled = false;
btn.edit.disabled = false;
btn.save.disabled = false;
btn.approve.disabled = false;
}
function renderOutput() {
outputEl.innerHTML = accumulated
? escapeHtml(accumulated)
: '<span class="text-muted">点击"生成"开始写作…</span>';
outputEl.scrollTop = outputEl.scrollHeight;
wordCountEl.textContent = accumulated ? "当前字数: " + accumulated.length : "";
}
/* ---------------- 加载章节 + 上下文 ---------------- */
async function loadChapter() {
try {
const ch = await fetchJson("/api/chapters/" + CHAPTER_ID);
document.getElementById("cwTitle").textContent =
"第" + (ch.volume || 1) + "卷 第" + (ch.chapter_number || 1) + "章 " + (ch.title || "");
outlineEl.value = ch.outline || "";
if (ch.content) {
accumulated = ch.content;
renderOutput();
setIdleWithContent();
}
} catch (err) {
showError(alertsEl, "加载章节失败: " + err.message);
}
}
async function loadContext() {
const el = document.getElementById("cwContext");
try {
const ctx = await fetchJson("/api/chapters/" + CHAPTER_ID + "/context");
let html = "";
html += "<details open><summary class=\"fw-bold\">前文摘要 ("
+ ctx.earlier_summaries.length + ")</summary>";
html += ctx.earlier_summaries.length
? ctx.earlier_summaries.map((s) =>
"<p class=\"small mb-1\">第" + s.volume + "卷 第" + s.chapter_number + "章 "
+ escapeHtml(s.title || "") + ": " + escapeHtml(s.summary) + "</p>").join("")
: '<p class="small text-muted">无</p>';
html += "</details>";
html += "<details class=\"mt-2\"><summary class=\"fw-bold\">前章正文 ("
+ ctx.prev_chapters.length + ")</summary>";
html += ctx.prev_chapters.length
? ctx.prev_chapters.map((c) =>
"<details class=\"ms-2\"><summary class=\"small\">第" + c.volume + "卷 第"
+ c.chapter_number + "章 " + escapeHtml(c.title || "") + "</summary>"
+ "<pre class=\"small\" style=\"white-space:pre-wrap;max-height:200px;overflow-y:auto;\">"
+ escapeHtml(c.content) + "</pre></details>").join("")
: '<p class="small text-muted">无 (这是第一章)</p>';
html += "</details>";
html += "<details class=\"mt-2\"><summary class=\"fw-bold\">相关角色 ("
+ ctx.characters.length + ")</summary>";
html += ctx.characters.length
? ctx.characters.map((c) =>
"<p class=\"small mb-1\"><strong>" + escapeHtml(c.name) + "</strong> ("
+ escapeHtml(c.role || "") + "): "
+ escapeHtml((c.description || "").slice(0, 200)) + "</p>").join("")
: '<p class="small text-muted">无</p>';
html += "</details>";
html += "<details class=\"mt-2\"><summary class=\"fw-bold\">未回收伏笔 ("
+ ctx.open_foreshadowing.length + ")</summary>";
html += ctx.open_foreshadowing.length
? ctx.open_foreshadowing.map((f) =>
"<p class=\"small mb-1\">" + escapeHtml(f.description) + "</p>").join("")
: '<p class="small text-muted">无</p>';
html += "</details>";
html += '<p class="small text-muted mt-2">上下文总大小: ' + ctx.total_chars + " 字符</p>";
el.innerHTML = html;
} catch (err) {
el.innerHTML = '<div class="alert alert-danger small">' + escapeHtml(err.message) + "</div>";
}
}
/* ---------------- 生成控制 ---------------- */
function startStream(prefix) {
if (stream) stream.close();
setStreaming(true);
ctxSummaryEl.innerHTML = "";
const params = new URLSearchParams({ csrf_token: document.querySelector('meta[name="csrf-token"]').content });
if (prefix) params.append("prefix", prefix);
stream = createSSEStream("/api/chapters/" + CHAPTER_ID + "/generate?" + params.toString(), {
onContext: (summary) => {
ctxSummaryEl.innerHTML = '<div class="alert alert-info small py-1">'
+ escapeHtml(summary) + "</div>";
},
onToken: (t) => {
accumulated += t;
renderOutput();
},
onDone: (content) => {
accumulated = content;
renderOutput();
stream = null;
setIdleWithContent();
},
onError: (msg) => {
showError(alertsEl, "生成失败: " + msg);
stream = null;
setStreaming(false);
btn.resume.disabled = !accumulated;
},
});
}
btn.generate.addEventListener("click", () => {
accumulated = "";
renderOutput();
startStream(null);
});
btn.pause.addEventListener("click", () => {
if (stream) { stream.close(); stream = null; }
setStreaming(false);
btn.resume.disabled = !accumulated;
btn.regen.disabled = !accumulated;
});
btn.resume.addEventListener("click", () => startStream(accumulated));
btn.regen.addEventListener("click", () => {
if (stream) { stream.close(); stream = null; }
accumulated = "";
renderOutput();
startStream(null);
});
btn.edit.addEventListener("click", () => {
editing = !editing;
outputEl.contentEditable = editing ? "true" : "false";
btn.edit.textContent = editing ? "完成编辑" : "编辑";
if (editing) outputEl.focus();
else accumulated = outputEl.innerText;
});
btn.save.addEventListener("click", async () => {
if (editing) accumulated = outputEl.innerText;
try {
await postJson("/api/chapters/" + CHAPTER_ID, { content: accumulated });
renderOutput();
ctxSummaryEl.innerHTML = '<div class="alert alert-success small py-1">已保存</div>';
} catch (err) {
showError(alertsEl, "保存失败: " + err.message);
}
});
btn.approve.addEventListener("click", async () => {
if (editing) accumulated = outputEl.innerText;
try {
await postJson("/api/chapters/" + CHAPTER_ID, { content: accumulated, status: "done" });
ctxSummaryEl.innerHTML = '<div class="alert alert-success small py-1">本章已通过 (status=done)</div>';
} catch (err) {
showError(alertsEl, "操作失败: " + err.message);
}
});
btn.saveOutline.addEventListener("click", async () => {
try {
await postJson("/api/chapters/" + CHAPTER_ID, { outline: outlineEl.value });
ctxSummaryEl.innerHTML = '<div class="alert alert-success small py-1">大纲已保存</div>';
loadContext();
} catch (err) {
showError(alertsEl, "保存大纲失败: " + err.message);
}
});
/* ---------------- init ---------------- */
loadChapter();
loadContext();