231 lines
8.3 KiB
JavaScript
231 lines
8.3 KiB
JavaScript
/* 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();
|