initial: novel-app snapshot
This commit is contained in:
230
static/js/pages/chapter-write.js
Normal file
230
static/js/pages/chapter-write.js
Normal 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();
|
||||
Reference in New Issue
Block a user