@waterwx/dsh-novel-forge 2.1.4 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/lib/client.js +4939 -2919
- package/lib/index.js +466 -128
- package/lib/types/assistant.d.ts +1 -1
- package/lib/types/client/api.d.ts +3 -1
- package/lib/types/client/llmLive.d.ts +7 -2
- package/lib/types/client/locales.d.ts +1 -0
- package/lib/types/client/panel/AuthorInbox.d.ts +23 -0
- package/lib/types/client/panel/CabinetSnapshot.d.ts +16 -0
- package/lib/types/client/panel/CardHead.d.ts +42 -0
- package/lib/types/client/panel/ChapterPlanPanel.d.ts +24 -0
- package/lib/types/client/panel/ConfirmDialog.d.ts +38 -0
- package/lib/types/client/panel/HomeHero.d.ts +60 -0
- package/lib/types/client/panel/NoticeBar.d.ts +26 -0
- package/lib/types/client/panel/ReviewIssueCard.d.ts +24 -0
- package/lib/types/client/panel/RunPanel.d.ts +3 -1
- package/lib/types/client/panel/Skeleton.d.ts +6 -0
- package/lib/types/client/panel/Sparkline.d.ts +34 -0
- package/lib/types/client/panel/helpers.d.ts +12 -0
- package/lib/types/engine.d.ts +28 -6
- package/lib/types/llm-live.d.ts +2 -2
- package/lib/types/llm-retry.d.ts +11 -0
- package/lib/types/protocol.d.ts +49 -2
- package/lib/types/routes.d.ts +6 -0
- package/lib/types/run-selection.d.ts +5 -2
- package/lib/types/stream-fallback.d.ts +8 -0
- package/package.json +5 -1
package/lib/index.js
CHANGED
|
@@ -3975,6 +3975,24 @@ function isRetryableLlmError(error) {
|
|
|
3975
3975
|
return RETRYABLE_PATTERNS.some((re) => re.test(message));
|
|
3976
3976
|
}
|
|
3977
3977
|
/**
|
|
3978
|
+
* 判断一次失败是「取消」(作者关页面 / 主动中断)还是模型故障。
|
|
3979
|
+
*
|
|
3980
|
+
* 取消有两种现场:适配器给出 `aborted` 收尾块,或抛出 `AbortError` / 本插件自己的
|
|
3981
|
+
* 断开错误。它必须和真实失败分开记账——否则面板「本次运行失败 N 次」会把作者的正常
|
|
3982
|
+
* 中断统计成模型不稳定,进而误导备用模型档位判断(与规则 1「用户取消不重试」同一口径)。
|
|
3983
|
+
*
|
|
3984
|
+
* @param input 收尾块 kind(字符串)或捕获到的异常。
|
|
3985
|
+
* @returns 属于取消时为 true。
|
|
3986
|
+
*/
|
|
3987
|
+
function isCancellation(input) {
|
|
3988
|
+
if (typeof input === "string") return input === "aborted";
|
|
3989
|
+
if (input instanceof Error) {
|
|
3990
|
+
if (input.name === "AbortError") return true;
|
|
3991
|
+
return /已取消|用户取消|客户端已断开|\baborted\b/i.test(input.message);
|
|
3992
|
+
}
|
|
3993
|
+
return false;
|
|
3994
|
+
}
|
|
3995
|
+
/**
|
|
3978
3996
|
* 判断是否应当切换到备用模型。
|
|
3979
3997
|
*
|
|
3980
3998
|
* @param fallbackModel 配置的备用模型(空串 = 未配置)。
|
|
@@ -4205,6 +4223,16 @@ function applyModelCapability(base, model, options = {}) {
|
|
|
4205
4223
|
//#endregion
|
|
4206
4224
|
//#region src/stream-fallback.ts
|
|
4207
4225
|
/**
|
|
4226
|
+
* 统一的取消错误:与 `DOMException('AbortError')` 同名,便于上层用 `name` 判定。
|
|
4227
|
+
* @param reason 取消原因(signal.reason,或适配器抛出的原始异常)。
|
|
4228
|
+
*/
|
|
4229
|
+
function abortError(reason) {
|
|
4230
|
+
if (reason instanceof Error) return reason;
|
|
4231
|
+
const err = new Error(typeof reason === "string" ? reason : "客户端已断开连接");
|
|
4232
|
+
err.name = "AbortError";
|
|
4233
|
+
return err;
|
|
4234
|
+
}
|
|
4235
|
+
/**
|
|
4208
4236
|
* 执行流式生成,主模型失败且「一个字都没产出」时换备用模型重试一次。
|
|
4209
4237
|
*
|
|
4210
4238
|
* 第三泛型(`TNext`)取 `unknown` 而非 `void`:三处调用方的外层生成器都声明为
|
|
@@ -4215,11 +4243,13 @@ function applyModelCapability(base, model, options = {}) {
|
|
|
4215
4243
|
* @throws 两次都失败时抛最后一次的错误;成功但文本过短时抛「结果过短」。
|
|
4216
4244
|
*/
|
|
4217
4245
|
async function* streamWithFallback(options) {
|
|
4218
|
-
const { ctx, config, request, liveLabel, maxTokensError, minChars } = options;
|
|
4246
|
+
const { ctx, config, request, liveLabel, maxTokensError, minChars, signal } = options;
|
|
4219
4247
|
const primaryModel = request.model ?? config.model;
|
|
4220
4248
|
let text = "";
|
|
4221
4249
|
let lastError;
|
|
4250
|
+
const abortedByCaller = () => signal?.aborted === true;
|
|
4222
4251
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
4252
|
+
if (abortedByCaller()) throw abortError(signal?.reason);
|
|
4223
4253
|
const model = attempt === 0 ? primaryModel : (config.fallbackModel ?? "").trim();
|
|
4224
4254
|
if (model === "") break;
|
|
4225
4255
|
const live = beginLiveCall({
|
|
@@ -4231,29 +4261,44 @@ async function* streamWithFallback(options) {
|
|
|
4231
4261
|
const assembler = new BlockAssembler();
|
|
4232
4262
|
let produced = 0;
|
|
4233
4263
|
const converged = applyModelCapability(request, model, { maxTokensFor: (m) => fullTextBudget(config, m, primaryModel) });
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4264
|
+
try {
|
|
4265
|
+
for await (const chunk of ctx.llm.stream(signal === void 0 ? converged : {
|
|
4266
|
+
...converged,
|
|
4267
|
+
signal
|
|
4268
|
+
})) {
|
|
4269
|
+
assembler.push(chunk);
|
|
4270
|
+
if (chunk.type === "text-delta") {
|
|
4271
|
+
produced += chunk.text.length;
|
|
4272
|
+
markFirstToken(live);
|
|
4273
|
+
yield {
|
|
4274
|
+
frame: "delta",
|
|
4275
|
+
text: chunk.text
|
|
4276
|
+
};
|
|
4277
|
+
}
|
|
4243
4278
|
}
|
|
4279
|
+
} catch (error) {
|
|
4280
|
+
const aborted = isCancellation(error) || abortedByCaller();
|
|
4281
|
+
endLiveCall(live, assembler, {
|
|
4282
|
+
chars: produced,
|
|
4283
|
+
phase: aborted ? "cancelled" : "failed",
|
|
4284
|
+
error: error instanceof Error ? error.message : String(error)
|
|
4285
|
+
});
|
|
4286
|
+
if (aborted) throw abortError(signal?.reason ?? error);
|
|
4287
|
+
throw error;
|
|
4244
4288
|
}
|
|
4245
4289
|
const finish = assembler.finish;
|
|
4246
4290
|
lastError = void 0;
|
|
4247
|
-
if (finish.kind === "error" || finish.kind === "aborted") lastError = /* @__PURE__ */ new Error(`${liveLabel}
|
|
4291
|
+
if (finish.kind === "error" || finish.kind === "aborted") lastError = /* @__PURE__ */ new Error(`${isCancellation(finish.kind) ? "已取消" : `${liveLabel}失败`}(${finish.kind}): ${finish.failure.message}`);
|
|
4248
4292
|
else if (finish.kind === "max-tokens") lastError = new Error(maxTokensError);
|
|
4249
4293
|
text = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
|
|
4294
|
+
const cancelled = lastError !== void 0 && isCancellation(lastError);
|
|
4250
4295
|
endLiveCall(live, assembler, lastError === void 0 ? {
|
|
4251
4296
|
chars: text.length,
|
|
4252
4297
|
preview: text.slice(0, 320),
|
|
4253
4298
|
phase: text === "" ? "failed" : "completed"
|
|
4254
4299
|
} : {
|
|
4255
4300
|
chars: text.length,
|
|
4256
|
-
phase: "failed",
|
|
4301
|
+
phase: cancelled ? "cancelled" : "failed",
|
|
4257
4302
|
error: lastError.message
|
|
4258
4303
|
});
|
|
4259
4304
|
if (lastError === void 0) break;
|
|
@@ -5368,6 +5413,8 @@ const NOVEL_API = {
|
|
|
5368
5413
|
chapterCheck: "/api/dsh-novel-forge/chapter/check",
|
|
5369
5414
|
/** 保存手动编辑的正文(自动备份 .bak)。 */
|
|
5370
5415
|
chapterSave: "/api/dsh-novel-forge/chapter/save",
|
|
5416
|
+
/** 补写本章计划的固定六项(目标 / 必达 / 保持 / 硬事实 / 结尾钩子 / 剧情要点)。 */
|
|
5417
|
+
chapterPlan: "/api/dsh-novel-forge/chapter/plan",
|
|
5371
5418
|
assistant: "/api/dsh-novel-forge/assistant",
|
|
5372
5419
|
assistantHistory: "/api/dsh-novel-forge/assistant-history",
|
|
5373
5420
|
/** 清空助手对话记录。 */
|
|
@@ -5647,26 +5694,44 @@ function saveProject(outputDir, project) {
|
|
|
5647
5694
|
writeFileSync(tmp, data, "utf8");
|
|
5648
5695
|
renameSync(tmp, target);
|
|
5649
5696
|
}
|
|
5697
|
+
/** `mergeVolatileFromDisk` 会覆盖的易变字段的全集(也是 `keep` 的合法取值域)。 */
|
|
5698
|
+
const VOLATILE_FIELDS = [
|
|
5699
|
+
"bible",
|
|
5700
|
+
"roles",
|
|
5701
|
+
"plotlines",
|
|
5702
|
+
"roleStatus",
|
|
5703
|
+
"blurb",
|
|
5704
|
+
"coverPath",
|
|
5705
|
+
"facts",
|
|
5706
|
+
"assets",
|
|
5707
|
+
"world",
|
|
5708
|
+
"volumes"
|
|
5709
|
+
];
|
|
5650
5710
|
/**
|
|
5651
5711
|
* 并发保护:长任务(章节计划生成/正文生成)在内存中持有旧快照,
|
|
5652
5712
|
* 期间其他请求可能修改了「易变字段」(道藏/角色库/剧情线/人物志存档/简介/封面)。
|
|
5653
5713
|
* 保存前用磁盘最新版本合并这些字段,避免旧快照覆盖新修改(曾导致角色卡丢失)。
|
|
5654
|
-
*
|
|
5714
|
+
*
|
|
5715
|
+
* ⚠️ `keep` 不是可选的礼貌参数,而是本函数唯一的正确用法保证:
|
|
5716
|
+
* 调用方只要自己写过这些字段中的任何一个(哪怕只是 `project.plotlines[0].chapters.push(no)`),
|
|
5717
|
+
* **必须**把它列进 `keep`,否则本函数会用磁盘旧值把本轮成果整块覆盖掉。
|
|
5718
|
+
* 历史上正是漏了 `facts` / `plotlines`,导致作者复盘回灌的事实与剧情线关联被静默丢弃。
|
|
5719
|
+
*
|
|
5720
|
+
* @param outputDir - 项目目录。
|
|
5721
|
+
* @param project - 待保存的内存项目(原地修改)。
|
|
5722
|
+
* @param keep - 本次调用方已自行修改过的易变字段,这些字段不从磁盘覆盖。
|
|
5655
5723
|
*/
|
|
5656
|
-
function mergeVolatileFromDisk(outputDir, project) {
|
|
5724
|
+
function mergeVolatileFromDisk(outputDir, project, keep = []) {
|
|
5657
5725
|
try {
|
|
5658
5726
|
const disk = loadProject(outputDir);
|
|
5659
5727
|
if (disk === void 0) return;
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
project.assets = disk.assets;
|
|
5668
|
-
project.world = disk.world;
|
|
5669
|
-
project.volumes = disk.volumes;
|
|
5728
|
+
const skip = new Set(keep);
|
|
5729
|
+
const target = project;
|
|
5730
|
+
const source = disk;
|
|
5731
|
+
for (const field of VOLATILE_FIELDS) {
|
|
5732
|
+
if (skip.has(field)) continue;
|
|
5733
|
+
target[field] = source[field];
|
|
5734
|
+
}
|
|
5670
5735
|
} catch {}
|
|
5671
5736
|
}
|
|
5672
5737
|
/**
|
|
@@ -6051,6 +6116,22 @@ function readChapterFile(outputDir, chapter) {
|
|
|
6051
6116
|
if (!existsSync(path)) return void 0;
|
|
6052
6117
|
return readFileSync(path, "utf8");
|
|
6053
6118
|
}
|
|
6119
|
+
/**
|
|
6120
|
+
* 章节正文的**写盘时间**(文件 mtime,ISO)—— 面板「今日产出 / 日环比」的唯一本地事实来源。
|
|
6121
|
+
*
|
|
6122
|
+
* 为什么不把它写进 novel-project.json:它是**派生数据**。落盘后一旦文件被外部替换、
|
|
6123
|
+
* 从备份回拷、或手工改名,存档里的时间戳就开始撒谎;mtime 永远跟着真实文件走。
|
|
6124
|
+
* 代价是每次 /status 要对每个有正文的章 stat 一次 —— 本地磁盘上是微秒级,
|
|
6125
|
+
* 相对它换来的"今天的产出是真的",这个代价可以接受。
|
|
6126
|
+
*/
|
|
6127
|
+
function chapterWrittenAt(outputDir, chapter) {
|
|
6128
|
+
if (chapter.file === void 0) return void 0;
|
|
6129
|
+
try {
|
|
6130
|
+
return statSync(join(outputDir, chapter.file)).mtime.toISOString();
|
|
6131
|
+
} catch {
|
|
6132
|
+
return;
|
|
6133
|
+
}
|
|
6134
|
+
}
|
|
6054
6135
|
/** Create a fresh project from an outline. */
|
|
6055
6136
|
function createProject(outline, outlinePath) {
|
|
6056
6137
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -6105,15 +6186,26 @@ async function complete(ctx, config, options) {
|
|
|
6105
6186
|
...converged.reasoningEffort === void 0 ? {} : { reasoningEffort: ReasoningEffortId(converged.reasoningEffort) }
|
|
6106
6187
|
};
|
|
6107
6188
|
const assembler = new BlockAssembler();
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6189
|
+
try {
|
|
6190
|
+
for await (const chunk of ctx.llm.stream(options.signal === void 0 ? request : {
|
|
6191
|
+
...request,
|
|
6192
|
+
signal: options.signal
|
|
6193
|
+
})) {
|
|
6194
|
+
if (chunk.type === "text-delta") markFirstToken(live);
|
|
6195
|
+
assembler.push(chunk);
|
|
6196
|
+
}
|
|
6197
|
+
} catch (error) {
|
|
6198
|
+
endLiveCall(live, assembler, {
|
|
6199
|
+
phase: isCancellation(error) ? "cancelled" : "failed",
|
|
6200
|
+
error: error.message
|
|
6201
|
+
});
|
|
6202
|
+
throw error;
|
|
6111
6203
|
}
|
|
6112
6204
|
const finish = assembler.finish;
|
|
6113
6205
|
if (finish.kind === "error" || finish.kind === "aborted") {
|
|
6114
|
-
const message =
|
|
6206
|
+
const message = `${isCancellation(finish.kind) ? "调用已取消" : "LLM 调用失败"}(${finish.kind}): ${finish.failure.message}`;
|
|
6115
6207
|
endLiveCall(live, assembler, {
|
|
6116
|
-
phase: "failed",
|
|
6208
|
+
phase: isCancellation(finish.kind) ? "cancelled" : "failed",
|
|
6117
6209
|
error: message
|
|
6118
6210
|
});
|
|
6119
6211
|
throw new Error(message);
|
|
@@ -8075,7 +8167,7 @@ function rewriteSystemPrompt(project, targetChars, beforeChapter) {
|
|
|
8075
8167
|
* untouched (local revision). Without `target`, the whole chapter is
|
|
8076
8168
|
* rewritten. Yields delta text; persists when done.
|
|
8077
8169
|
*/
|
|
8078
|
-
async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo, instructions, target) {
|
|
8170
|
+
async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo, instructions, target, signal) {
|
|
8079
8171
|
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
8080
8172
|
if (chapter === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 不在计划中`);
|
|
8081
8173
|
const body = readChapterFile(outputDir, chapter);
|
|
@@ -8160,6 +8252,7 @@ async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo,
|
|
|
8160
8252
|
ctx,
|
|
8161
8253
|
config,
|
|
8162
8254
|
request,
|
|
8255
|
+
signal,
|
|
8163
8256
|
liveLabel: `修订 · 第${chapterNo}章`,
|
|
8164
8257
|
liveUser: messages[0]?.content !== void 0 ? JSON.stringify(messages[0].content).slice(0, 2e3) : void 0,
|
|
8165
8258
|
maxTokensError: "修订输出达到 maxTokens 上限,请增大配置后重试",
|
|
@@ -8205,7 +8298,7 @@ function polishSystemPrompt(project) {
|
|
|
8205
8298
|
}
|
|
8206
8299
|
/** Stream a chapter polish (de-AI-ify). Draft-mode: the polished body lands
|
|
8207
8300
|
* in `chapter.pendingDraft` and is only applied on draft/apply. */
|
|
8208
|
-
async function* polishChapterStream(ctx, config, project, outputDir, chapterNo) {
|
|
8301
|
+
async function* polishChapterStream(ctx, config, project, outputDir, chapterNo, signal) {
|
|
8209
8302
|
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
8210
8303
|
if (chapter === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 不在计划中`);
|
|
8211
8304
|
const body = readChapterFile(outputDir, chapter);
|
|
@@ -8234,6 +8327,7 @@ async function* polishChapterStream(ctx, config, project, outputDir, chapterNo)
|
|
|
8234
8327
|
ctx,
|
|
8235
8328
|
config,
|
|
8236
8329
|
request,
|
|
8330
|
+
signal,
|
|
8237
8331
|
liveLabel: `去 AI 味润色 · 第${chapterNo}章`,
|
|
8238
8332
|
liveUser: body.slice(0, 2e3),
|
|
8239
8333
|
maxTokensError: "润色输出达到 maxTokens 上限,请增大配置后重试",
|
|
@@ -8254,7 +8348,7 @@ function countHanzi(text) {
|
|
|
8254
8348
|
return m === null ? 0 : m.length;
|
|
8255
8349
|
}
|
|
8256
8350
|
/** Generate one chapter (streaming). Yields progress frames; persists when done. */
|
|
8257
|
-
async function* generateChapterStream(ctx, config, project, outputDir, chapterNo) {
|
|
8351
|
+
async function* generateChapterStream(ctx, config, project, outputDir, chapterNo, signal) {
|
|
8258
8352
|
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
8259
8353
|
if (chapter === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 不在计划中`);
|
|
8260
8354
|
let continuity = "";
|
|
@@ -8341,6 +8435,7 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
|
|
|
8341
8435
|
ctx,
|
|
8342
8436
|
config,
|
|
8343
8437
|
request,
|
|
8438
|
+
signal,
|
|
8344
8439
|
liveLabel: `正文生成 · 第${chapter.no}章`,
|
|
8345
8440
|
liveUser: user,
|
|
8346
8441
|
maxTokensError: "达到 maxTokens 上限,正文可能不完整,请增大 maxTokens 后重试",
|
|
@@ -8410,7 +8505,7 @@ async function summarizeChapter(ctx, config, project, outputDir, chapterNo) {
|
|
|
8410
8505
|
* 反向推大纲:从已写章节正文反推出全书总纲(分卷 + 章节要点 + 主线/人物弧线/伏笔清单)。
|
|
8411
8506
|
* 两阶段:分批提取章节事件摘要 → 汇总生成大纲。不修改章节/设定,只返回大纲文本。
|
|
8412
8507
|
*/
|
|
8413
|
-
async function reverseOutlineFromChapters(ctx, config, project, outputDir, onProgress) {
|
|
8508
|
+
async function reverseOutlineFromChapters(ctx, config, project, outputDir, onProgress, signal) {
|
|
8414
8509
|
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.status !== "error").filter((c) => readChapterFile(outputDir, c) !== void 0).sort((a, b) => a.no - b.no);
|
|
8415
8510
|
if (written.length === 0) throw new NovelActionError("contract", "本书还没有已写章节,无法反推大纲");
|
|
8416
8511
|
const BATCH = 10;
|
|
@@ -8425,7 +8520,8 @@ async function reverseOutlineFromChapters(ctx, config, project, outputDir, onPro
|
|
|
8425
8520
|
}).join("\n\n---\n\n"),
|
|
8426
8521
|
temperature: .2,
|
|
8427
8522
|
maxTokens: Math.max(config.maxTokens, 3e3),
|
|
8428
|
-
reasoning: config.analysisReasoning ?? "low"
|
|
8523
|
+
reasoning: config.analysisReasoning ?? "low",
|
|
8524
|
+
signal
|
|
8429
8525
|
});
|
|
8430
8526
|
notes.push(note.trim());
|
|
8431
8527
|
onProgress?.(Math.min(i + BATCH, total), total, "章节摘要");
|
|
@@ -8443,7 +8539,8 @@ async function reverseOutlineFromChapters(ctx, config, project, outputDir, onPro
|
|
|
8443
8539
|
user: notes.join("\n\n"),
|
|
8444
8540
|
temperature: .4,
|
|
8445
8541
|
maxTokens: Math.max(config.maxTokens, 6e3),
|
|
8446
|
-
reasoning: config.analysisReasoning ?? "low"
|
|
8542
|
+
reasoning: config.analysisReasoning ?? "low",
|
|
8543
|
+
signal
|
|
8447
8544
|
});
|
|
8448
8545
|
onProgress?.(total, total, "完成");
|
|
8449
8546
|
return outline.trim();
|
|
@@ -8712,12 +8809,19 @@ async function rewriteAdaptationBook(ctx, config, text, mappings, rules, options
|
|
|
8712
8809
|
].filter((s) => s !== "").join("\n\n");
|
|
8713
8810
|
let body = "";
|
|
8714
8811
|
try {
|
|
8812
|
+
if (options.signal?.aborted === true) return {
|
|
8813
|
+
adaptedText: adaptedParts.join("\n"),
|
|
8814
|
+
rewritten,
|
|
8815
|
+
skipped,
|
|
8816
|
+
hits
|
|
8817
|
+
};
|
|
8715
8818
|
body = stripRewriteHeading(await complete(ctx, config, {
|
|
8716
8819
|
system,
|
|
8717
8820
|
user,
|
|
8718
8821
|
temperature: .7,
|
|
8719
8822
|
maxTokens: Math.max(config.maxTokens, Math.min(16e3, c.body.length * 3)),
|
|
8720
|
-
reasoning: config.analysisReasoning ?? "low"
|
|
8823
|
+
reasoning: config.analysisReasoning ?? "low",
|
|
8824
|
+
signal: options.signal
|
|
8721
8825
|
}));
|
|
8722
8826
|
if (body.length < 50) body = "";
|
|
8723
8827
|
} catch {
|
|
@@ -10532,15 +10636,19 @@ function revisionTodoText(item) {
|
|
|
10532
10636
|
/**
|
|
10533
10637
|
* 选出生产单下一章要处理的章。
|
|
10534
10638
|
*
|
|
10535
|
-
*
|
|
10639
|
+
* 四种章:已 `approved` 快进;已列入 `pendingManual` 的快进(人工已接管);
|
|
10640
|
+
* 连败达上限的熔断跳过;其余为下一章。
|
|
10536
10641
|
*
|
|
10537
10642
|
* @param chapters - 全书章节计划。
|
|
10538
10643
|
* @param currentNo - 当前章号(含)。
|
|
10539
10644
|
* @param endNo - 区间终点(含)。
|
|
10540
10645
|
* @param failedAttempts - 章号 → 已失败次数(来自 `RunState.failedAttempts`)。
|
|
10646
|
+
* @param pendingManual - 已列入待人工的章号(来自 `RunState.pendingManual`)。
|
|
10647
|
+
* 必须传入,否则重写 N 轮仍不过的章会被反复选中,每轮烧两次 rewrite + 两次 verify。
|
|
10541
10648
|
*/
|
|
10542
|
-
function pickNextChapter(chapters, currentNo, endNo, failedAttempts) {
|
|
10649
|
+
function pickNextChapter(chapters, currentNo, endNo, failedAttempts, pendingManual = []) {
|
|
10543
10650
|
const circuitBroken = [];
|
|
10651
|
+
const manual = new Set(pendingManual);
|
|
10544
10652
|
let advanceTo = currentNo - 1;
|
|
10545
10653
|
for (let no = currentNo; no <= endNo; no++) {
|
|
10546
10654
|
const ch = chapters.find((c) => c.no === no);
|
|
@@ -10549,6 +10657,10 @@ function pickNextChapter(chapters, currentNo, endNo, failedAttempts) {
|
|
|
10549
10657
|
advanceTo = no;
|
|
10550
10658
|
continue;
|
|
10551
10659
|
}
|
|
10660
|
+
if (manual.has(no)) {
|
|
10661
|
+
advanceTo = no;
|
|
10662
|
+
continue;
|
|
10663
|
+
}
|
|
10552
10664
|
const attempts = failedAttempts[String(no)] ?? 0;
|
|
10553
10665
|
if (attempts >= 3) {
|
|
10554
10666
|
circuitBroken.push({
|
|
@@ -10768,7 +10880,7 @@ var ProductionRunner = class {
|
|
|
10768
10880
|
this.persist();
|
|
10769
10881
|
break;
|
|
10770
10882
|
}
|
|
10771
|
-
const picked = pickNextChapter(project.chapters, this.state.currentNo, this.state.endNo, this.state.failedAttempts ?? {});
|
|
10883
|
+
const picked = pickNextChapter(project.chapters, this.state.currentNo, this.state.endNo, this.state.failedAttempts ?? {}, this.state.pendingManual ?? []);
|
|
10772
10884
|
if (picked.advanceTo >= this.state.currentNo) this.state.currentNo = picked.advanceTo;
|
|
10773
10885
|
for (const skipped of picked.circuitBroken) this.skipFailedChapter(skipped.chapter, skipped.attempts);
|
|
10774
10886
|
if (picked.next === void 0) {
|
|
@@ -10903,11 +11015,12 @@ var ProductionRunner = class {
|
|
|
10903
11015
|
text
|
|
10904
11016
|
});
|
|
10905
11017
|
}
|
|
10906
|
-
mergeVolatileFromDisk(outputDir, project);
|
|
11018
|
+
mergeVolatileFromDisk(outputDir, project, ["plotlines", "facts"]);
|
|
10907
11019
|
saveProject(outputDir, project);
|
|
10908
11020
|
}
|
|
10909
11021
|
} catch (e) {
|
|
10910
11022
|
console.warn("[dsh-novel-forge] run author review:", e.message);
|
|
11023
|
+
this.log(`第${no}章 作者复盘失败(正文已保留,可稍后补跑复盘):${e.message}`);
|
|
10911
11024
|
}
|
|
10912
11025
|
} catch (error) {
|
|
10913
11026
|
chapter.status = "error";
|
|
@@ -10973,8 +11086,15 @@ var ProductionRunner = class {
|
|
|
10973
11086
|
baseReport: verify
|
|
10974
11087
|
});
|
|
10975
11088
|
}
|
|
10976
|
-
if (this.state !== null)
|
|
10977
|
-
|
|
11089
|
+
if (this.state !== null) {
|
|
11090
|
+
if (!this.state.pendingManual.includes(no)) {
|
|
11091
|
+
this.state.pendingManual.push(no);
|
|
11092
|
+
this.state.pendingManual.sort((a, b) => a - b);
|
|
11093
|
+
}
|
|
11094
|
+
this.state.currentNo = no + 1;
|
|
11095
|
+
this.persist();
|
|
11096
|
+
}
|
|
11097
|
+
this.log(`第${no}章 2 轮修订仍不过 → 保留草稿待人工(已跳过,可修正后单独重试该章)`);
|
|
10978
11098
|
}
|
|
10979
11099
|
applyDraft(project, chapter, draft, report) {
|
|
10980
11100
|
const config = this.deps.getConfig();
|
|
@@ -11937,7 +12057,7 @@ function historyToMessages(history) {
|
|
|
11937
12057
|
return messages;
|
|
11938
12058
|
}
|
|
11939
12059
|
/** One non-streaming LLM chat turn (used inside the tool loop). */
|
|
11940
|
-
async function chatOnce(ctx, config, system, history) {
|
|
12060
|
+
async function chatOnce(ctx, config, system, history, signal) {
|
|
11941
12061
|
const messages = historyToMessages(history);
|
|
11942
12062
|
const last = messages[messages.length - 1];
|
|
11943
12063
|
if (last?.role === "user" && Array.isArray(last.content)) {
|
|
@@ -11972,15 +12092,27 @@ async function chatOnce(ctx, config, system, history) {
|
|
|
11972
12092
|
user: messages.map((m) => JSON.stringify(m.content)).join(" / ").slice(0, 8e3)
|
|
11973
12093
|
});
|
|
11974
12094
|
const assembler = new BlockAssembler();
|
|
11975
|
-
|
|
11976
|
-
|
|
11977
|
-
|
|
12095
|
+
try {
|
|
12096
|
+
for await (const chunk of ctx.llm.stream(signal === void 0 ? request : {
|
|
12097
|
+
...request,
|
|
12098
|
+
signal
|
|
12099
|
+
})) {
|
|
12100
|
+
if (chunk.type === "text-delta") markFirstToken(live);
|
|
12101
|
+
assembler.push(chunk);
|
|
12102
|
+
}
|
|
12103
|
+
} catch (error) {
|
|
12104
|
+
endLiveCall(live, assembler, {
|
|
12105
|
+
phase: isCancellation(error) || signal?.aborted === true ? "cancelled" : "failed",
|
|
12106
|
+
error: error instanceof Error ? error.message : String(error)
|
|
12107
|
+
});
|
|
12108
|
+
throw error;
|
|
11978
12109
|
}
|
|
11979
12110
|
const finish = assembler.finish;
|
|
11980
12111
|
if (finish.kind === "error" || finish.kind === "aborted") {
|
|
11981
|
-
const
|
|
12112
|
+
const cancelled = isCancellation(finish.kind);
|
|
12113
|
+
const message = `${cancelled ? "助手对话已取消" : "助手调用失败"}(${finish.kind}): ${finish.failure.message}`;
|
|
11982
12114
|
endLiveCall(live, assembler, {
|
|
11983
|
-
phase: "failed",
|
|
12115
|
+
phase: cancelled ? "cancelled" : "failed",
|
|
11984
12116
|
error: message
|
|
11985
12117
|
});
|
|
11986
12118
|
throw new Error(message);
|
|
@@ -12004,7 +12136,7 @@ async function chatOnce(ctx, config, system, history) {
|
|
|
12004
12136
|
}, runOnce);
|
|
12005
12137
|
}
|
|
12006
12138
|
/** Run one user turn. Yields stream frames; persists history. */
|
|
12007
|
-
async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
|
|
12139
|
+
async function* runAssistantTurn(ctx, config, project, outputDir, userMessage, signal) {
|
|
12008
12140
|
const history = loadAssistantHistory(outputDir);
|
|
12009
12141
|
const stage = computeBookStage(project);
|
|
12010
12142
|
const strictStage = isGenericContinue(userMessage);
|
|
@@ -12071,7 +12203,8 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
|
|
|
12071
12203
|
};
|
|
12072
12204
|
for (;;) {
|
|
12073
12205
|
if (iterations++ > 20) break;
|
|
12074
|
-
|
|
12206
|
+
if (signal?.aborted === true) throw new Error("客户端已断开连接");
|
|
12207
|
+
const reply = await chatOnce(ctx, config, system, history, signal);
|
|
12075
12208
|
if (reply.length > 120 && /^[0-9a-fA-F\s]+$/.test(reply.slice(0, 2e3))) {
|
|
12076
12209
|
garbleCount++;
|
|
12077
12210
|
if (garbleCount >= 2) {
|
|
@@ -12805,6 +12938,12 @@ function addGlobalMode(m) {
|
|
|
12805
12938
|
//#region src/routes.ts
|
|
12806
12939
|
/** Cap on JSON request bodies (generous: cover images travel as base64). */
|
|
12807
12940
|
const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024;
|
|
12941
|
+
/** 允许「服务器本地文件」模式导入的扩展名:纯文本小说。不含 docx/pdf 等二进制。 */
|
|
12942
|
+
const IMPORTABLE_EXTENSIONS = [
|
|
12943
|
+
".txt",
|
|
12944
|
+
".md",
|
|
12945
|
+
".markdown"
|
|
12946
|
+
];
|
|
12808
12947
|
/** 包内置风格效果图目录(assets/styles,随 npm 包分发)。 */
|
|
12809
12948
|
/** 解析请求级目标书目录:优先按请求携带的 bookId 查书架,否则回退全局 active 书目录。 */
|
|
12810
12949
|
function resolveOutputDir(config, bookId) {
|
|
@@ -12814,6 +12953,42 @@ function resolveOutputDir(config, bookId) {
|
|
|
12814
12953
|
}
|
|
12815
12954
|
return config.outputDir;
|
|
12816
12955
|
}
|
|
12956
|
+
/**
|
|
12957
|
+
* 校验一个「客户端自报的目录」是否确实是已登记的书目录。
|
|
12958
|
+
*
|
|
12959
|
+
* 背景:部分路由(封面读取等)历史上直接采信 `?dir=` 参数拼路径读文件。
|
|
12960
|
+
* 本服务只有 loopback 围栏、没有 token,而围栏允许 `Origin` 缺失的请求
|
|
12961
|
+
* (非浏览器客户端、curl、恶意扩展都能过),所以形如
|
|
12962
|
+
* `GET /cover?dir=C:/Users/x` 会让服务读出该目录下任意 `novel-project.json`
|
|
12963
|
+
* 指向的文件并 base64 回吐。
|
|
12964
|
+
*
|
|
12965
|
+
* 正确做法不是「校验路径合法」(攻击者可以传任意合法路径),而是
|
|
12966
|
+
* **只承认自己登记过的目录**:书架里的每本书 + 设置里的默认输出目录。
|
|
12967
|
+
* 攻击者能传的值因此被压缩到一个可枚举的集合内。
|
|
12968
|
+
*
|
|
12969
|
+
* @returns 归一化后的目录;不在白名单内返回 undefined。
|
|
12970
|
+
*/
|
|
12971
|
+
function resolveRegisteredDir(config, requested) {
|
|
12972
|
+
if (requested === null || requested === "") return config.outputDir;
|
|
12973
|
+
let normalized;
|
|
12974
|
+
try {
|
|
12975
|
+
normalized = resolve(requested).replace(/[\\/]+$/, "");
|
|
12976
|
+
} catch {
|
|
12977
|
+
return;
|
|
12978
|
+
}
|
|
12979
|
+
const store = loadBookshelf();
|
|
12980
|
+
const allowed = [config.outputDir, ...store.books.map((b) => b.outputDir)];
|
|
12981
|
+
for (const dir of allowed) {
|
|
12982
|
+
if (dir === "") continue;
|
|
12983
|
+
let candidate;
|
|
12984
|
+
try {
|
|
12985
|
+
candidate = resolve(dir).replace(/[\\/]+$/, "");
|
|
12986
|
+
} catch {
|
|
12987
|
+
continue;
|
|
12988
|
+
}
|
|
12989
|
+
if (candidate.toLowerCase() === normalized.toLowerCase()) return candidate;
|
|
12990
|
+
}
|
|
12991
|
+
}
|
|
12817
12992
|
/** Loopback-only fence (mirrors the family plugins' pairing routes). */
|
|
12818
12993
|
function isLoopbackRequest(request) {
|
|
12819
12994
|
const address = request.socket.remoteAddress;
|
|
@@ -12861,6 +13036,50 @@ async function readJsonBody(req) {
|
|
|
12861
13036
|
return;
|
|
12862
13037
|
}
|
|
12863
13038
|
}
|
|
13039
|
+
/**
|
|
13040
|
+
* 把「客户端是否还在听」绑成一个 `AbortSignal`。
|
|
13041
|
+
*
|
|
13042
|
+
* ## 为什么必须有
|
|
13043
|
+
*
|
|
13044
|
+
* Node 的 `res.write()` 对已销毁的 socket **不抛异常也不返回 false** ——
|
|
13045
|
+
* 请求处理器会继续跑完。7 条 NDJSON 路由此前零 `close` 监听,于是:
|
|
13046
|
+
* 浏览器关标签页 → `for await (const step of generateChapterStream(...))`
|
|
13047
|
+
* 继续跑完整章生成(2-5 分钟,maxTokens 常 20000),审稿、复盘、落盘全部照跑。
|
|
13048
|
+
* **「关掉页面」不等于「停止烧 token」**,而且 `chapter.status` 已被写盘为
|
|
13049
|
+
* `generating`,只能靠 `/status` 的 10 分钟兜底复位。
|
|
13050
|
+
*
|
|
13051
|
+
* 宿主 SDK 明确约定「implementations must honor `options.signal`」
|
|
13052
|
+
* (`@deepseek-ai/dsh-llm` `LLMAdapter.stream` 文档),所以只要把 signal
|
|
13053
|
+
* 一路传到 `ctx.llm.stream` 就能真正掐断。
|
|
13054
|
+
*
|
|
13055
|
+
* ## 用法
|
|
13056
|
+
*
|
|
13057
|
+
* ```ts
|
|
13058
|
+
* const abort = abortOnClientClose(req, res)
|
|
13059
|
+
* try {
|
|
13060
|
+
* for await (const step of generateChapterStream(ctx, config, project, dir, no, abort.signal)) { ... }
|
|
13061
|
+
* } catch (err) {
|
|
13062
|
+
* if (isAbortError(err)) return // 客户端主动断开:静默收尾,不回错误帧
|
|
13063
|
+
* ...
|
|
13064
|
+
* }
|
|
13065
|
+
* ```
|
|
13066
|
+
*
|
|
13067
|
+
* @returns controller;`signal` 传给下游,`aborted` 可直接判断。
|
|
13068
|
+
*/
|
|
13069
|
+
function abortOnClientClose(req, res) {
|
|
13070
|
+
const controller = new AbortController();
|
|
13071
|
+
const onClose = () => {
|
|
13072
|
+
if (!res.writableEnded) controller.abort(/* @__PURE__ */ new Error("客户端已断开连接"));
|
|
13073
|
+
};
|
|
13074
|
+
res.once("close", onClose);
|
|
13075
|
+
req.once("aborted", onClose);
|
|
13076
|
+
return controller;
|
|
13077
|
+
}
|
|
13078
|
+
/** 判断错误是否为「客户端断开」导致的中止(据此静默收尾而不是回错误帧)。 */
|
|
13079
|
+
function isAbortError(err) {
|
|
13080
|
+
if (err instanceof Error && (err.name === "AbortError" || err.message.includes("客户端已断开连接"))) return true;
|
|
13081
|
+
return false;
|
|
13082
|
+
}
|
|
12864
13083
|
/** Default chapter count for planning when the request omits it. */
|
|
12865
13084
|
const DEFAULT_PLAN_COUNT = 30;
|
|
12866
13085
|
/**
|
|
@@ -12906,15 +13125,39 @@ function makeRoutes(deps) {
|
|
|
12906
13125
|
return true;
|
|
12907
13126
|
};
|
|
12908
13127
|
/** Load (and sync) the project, or respond 400. */
|
|
12909
|
-
|
|
12910
|
-
|
|
12911
|
-
|
|
13128
|
+
/**
|
|
13129
|
+
* 取本次请求的目标项目。
|
|
13130
|
+
*
|
|
13131
|
+
* 目录解析与 {@link resolveOutputDir} 同源:按请求携带的 `bookId` 查书架,
|
|
13132
|
+
* 未携带才回退全局 active 书。**此前本函数只看 `getConfig().outputDir`**,
|
|
13133
|
+
* 而约一半路由走它、另一半走 resolveOutputDir,前端每个请求都带 bookId
|
|
13134
|
+
* 却被后端一半静默忽略 —— 多书场景下对非激活书调用会写到激活书目录。
|
|
13135
|
+
*
|
|
13136
|
+
* 顺带修掉写盘副作用:`syncProjectWithDisk` 无条件更新 `updatedAt`,
|
|
13137
|
+
* 使 `saveProject` 的 no-op 检测永不命中,于是每个「读」请求都在重写整个
|
|
13138
|
+
* novel-project.json(长篇项目可达数MB,每 4 秒一次)。这里改成
|
|
13139
|
+
* 同步后按内容比对,仅在真的变化时落盘。
|
|
13140
|
+
*
|
|
13141
|
+
* @param req - 用于解析 `bookId`(GET 查 query,POST 查 body 由调用方保证)。
|
|
13142
|
+
* @param res - 用于回写错误。
|
|
13143
|
+
* @param body - POST 请求体(可能含 bookId)。
|
|
13144
|
+
*/
|
|
13145
|
+
const requireProjectAt = (req, res, body) => {
|
|
13146
|
+
const outputDir = resolveOutputDir(getConfig(), body?.bookId ?? (() => {
|
|
13147
|
+
try {
|
|
13148
|
+
return new URL(req.url ?? "/", "http://localhost").searchParams.get("bookId") ?? void 0;
|
|
13149
|
+
} catch {
|
|
13150
|
+
return;
|
|
13151
|
+
}
|
|
13152
|
+
})() ?? void 0);
|
|
13153
|
+
const project = loadProject(outputDir);
|
|
12912
13154
|
if (project === void 0) {
|
|
12913
13155
|
writeJson(res, 400, { error: "输出目录中没有项目,请先加载大纲" });
|
|
12914
13156
|
return;
|
|
12915
13157
|
}
|
|
12916
|
-
|
|
12917
|
-
|
|
13158
|
+
const before = JSON.stringify(project);
|
|
13159
|
+
syncProjectWithDisk(project, outputDir);
|
|
13160
|
+
if (JSON.stringify(project) !== before) saveProject(outputDir, project);
|
|
12918
13161
|
return project;
|
|
12919
13162
|
};
|
|
12920
13163
|
const statusRoute = {
|
|
@@ -12939,6 +13182,11 @@ function makeRoutes(deps) {
|
|
|
12939
13182
|
syncProjectWithDisk(project, outputDir);
|
|
12940
13183
|
saveProject(outputDir, project);
|
|
12941
13184
|
}
|
|
13185
|
+
const writtenAtByNo = /* @__PURE__ */ new Map();
|
|
13186
|
+
for (const c of project?.chapters ?? []) {
|
|
13187
|
+
const at = chapterWrittenAt(outputDir, c);
|
|
13188
|
+
if (at !== void 0) writtenAtByNo.set(c.no, at);
|
|
13189
|
+
}
|
|
12942
13190
|
const slim = new URL(req.url ?? "/", "http://localhost").searchParams.get("slim") === "1";
|
|
12943
13191
|
const truncations = [];
|
|
12944
13192
|
let projectPayload;
|
|
@@ -12958,7 +13206,8 @@ function makeRoutes(deps) {
|
|
|
12958
13206
|
review: c.review !== void 0 ? {
|
|
12959
13207
|
score: c.review.score,
|
|
12960
13208
|
passed: c.review.passed
|
|
12961
|
-
} : void 0
|
|
13209
|
+
} : void 0,
|
|
13210
|
+
writtenAt: writtenAtByNo.get(c.no)
|
|
12962
13211
|
})),
|
|
12963
13212
|
createdAt: project.createdAt,
|
|
12964
13213
|
updatedAt: project.updatedAt
|
|
@@ -12969,6 +13218,10 @@ function makeRoutes(deps) {
|
|
|
12969
13218
|
if (allFacts.length > shownFacts.length) truncations.push(`编年录:共 ${allFacts.length} 条,仅返回最近 ${shownFacts.length} 条(较早 ${allFacts.length - shownFacts.length} 条未返回)`);
|
|
12970
13219
|
projectPayload = {
|
|
12971
13220
|
...project,
|
|
13221
|
+
chapters: project.chapters.map((c) => ({
|
|
13222
|
+
...c,
|
|
13223
|
+
writtenAt: writtenAtByNo.get(c.no)
|
|
13224
|
+
})),
|
|
12972
13225
|
facts: shownFacts
|
|
12973
13226
|
};
|
|
12974
13227
|
}
|
|
@@ -13183,46 +13436,18 @@ function makeRoutes(deps) {
|
|
|
13183
13436
|
const send = (frame) => {
|
|
13184
13437
|
res.write(JSON.stringify(frame) + "\n");
|
|
13185
13438
|
};
|
|
13186
|
-
const
|
|
13187
|
-
emitLive({
|
|
13188
|
-
type: "session_started",
|
|
13189
|
-
sessionId: liveSession,
|
|
13190
|
-
label: "正文生成",
|
|
13191
|
-
model: config.generateModel || config.model,
|
|
13192
|
-
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
13193
|
-
context: {
|
|
13194
|
-
interactionId: liveSession,
|
|
13195
|
-
taskId: `ch-${no}`
|
|
13196
|
-
}
|
|
13197
|
-
});
|
|
13198
|
-
emitLive({
|
|
13199
|
-
type: "phase_changed",
|
|
13200
|
-
sessionId: liveSession,
|
|
13201
|
-
phase: "streaming",
|
|
13202
|
-
phaseMessage: "模型正在返回正文",
|
|
13203
|
-
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
13204
|
-
});
|
|
13439
|
+
const abort = abortOnClientClose(req, res);
|
|
13205
13440
|
try {
|
|
13206
13441
|
send({
|
|
13207
13442
|
type: "start",
|
|
13208
13443
|
no,
|
|
13209
13444
|
title: chapter.title
|
|
13210
13445
|
});
|
|
13211
|
-
|
|
13212
|
-
|
|
13213
|
-
|
|
13214
|
-
|
|
13215
|
-
|
|
13216
|
-
});
|
|
13217
|
-
genChars += step.text.length;
|
|
13218
|
-
emitLive({
|
|
13219
|
-
type: "output_delta",
|
|
13220
|
-
sessionId: liveSession,
|
|
13221
|
-
content: step.text,
|
|
13222
|
-
totalChars: genChars,
|
|
13223
|
-
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
13224
|
-
});
|
|
13225
|
-
} else if (step.frame === "done") send({
|
|
13446
|
+
for await (const step of generateChapterStream(ctx, config, project, config.outputDir, no, abort.signal)) if (step.frame === "delta") send({
|
|
13447
|
+
type: "delta",
|
|
13448
|
+
text: step.text
|
|
13449
|
+
});
|
|
13450
|
+
else if (step.frame === "done") send({
|
|
13226
13451
|
type: "done",
|
|
13227
13452
|
no,
|
|
13228
13453
|
file: step.file,
|
|
@@ -13230,14 +13455,6 @@ function makeRoutes(deps) {
|
|
|
13230
13455
|
title: chapter.title,
|
|
13231
13456
|
warn: step.warn
|
|
13232
13457
|
});
|
|
13233
|
-
emitLive({
|
|
13234
|
-
type: "session_completed",
|
|
13235
|
-
sessionId: liveSession,
|
|
13236
|
-
totalChars: genChars,
|
|
13237
|
-
preview: "",
|
|
13238
|
-
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
13239
|
-
phase: "completed"
|
|
13240
|
-
});
|
|
13241
13458
|
try {
|
|
13242
13459
|
const wantTimeline = config.autoTimeline ?? true;
|
|
13243
13460
|
await summarizeFactsAndTimeline(ctx, config, project, config.outputDir, no, {
|
|
@@ -13274,7 +13491,7 @@ function makeRoutes(deps) {
|
|
|
13274
13491
|
const review = await authorReviewChapter(ctx, config, project, no, currentBody, prevTail);
|
|
13275
13492
|
chapter.authorReview = review;
|
|
13276
13493
|
if (review.advancedLines !== void 0) autoLinkPlotlines(project, no, review.advancedLines);
|
|
13277
|
-
mergeVolatileFromDisk(config.outputDir, project);
|
|
13494
|
+
mergeVolatileFromDisk(config.outputDir, project, ["plotlines"]);
|
|
13278
13495
|
saveProject(config.outputDir, project);
|
|
13279
13496
|
send({
|
|
13280
13497
|
type: "author-review",
|
|
@@ -13287,6 +13504,14 @@ function makeRoutes(deps) {
|
|
|
13287
13504
|
}
|
|
13288
13505
|
res.end();
|
|
13289
13506
|
} catch (error) {
|
|
13507
|
+
if (isAbortError(error)) {
|
|
13508
|
+
chapter.status = "pending";
|
|
13509
|
+
chapter.generatingAt = void 0;
|
|
13510
|
+
chapter.error = void 0;
|
|
13511
|
+
syncProjectWithDisk(project, config.outputDir);
|
|
13512
|
+
saveProject(config.outputDir, project);
|
|
13513
|
+
return;
|
|
13514
|
+
}
|
|
13290
13515
|
chapter.status = "error";
|
|
13291
13516
|
chapter.error = error.message;
|
|
13292
13517
|
mergeVolatileFromDisk(config.outputDir, project);
|
|
@@ -13362,8 +13587,9 @@ function makeRoutes(deps) {
|
|
|
13362
13587
|
const send = (frame) => {
|
|
13363
13588
|
res.write(JSON.stringify(frame) + "\n");
|
|
13364
13589
|
};
|
|
13590
|
+
const abort = abortOnClientClose(req, res);
|
|
13365
13591
|
try {
|
|
13366
|
-
for await (const step of rewriteChapterStream(ctx, config, project, config.outputDir, no, body?.instructions ?? "", body?.target)) if (step.frame === "delta") send({
|
|
13592
|
+
for await (const step of rewriteChapterStream(ctx, config, project, config.outputDir, no, body?.instructions ?? "", body?.target, abort.signal)) if (step.frame === "delta") send({
|
|
13367
13593
|
type: "delta",
|
|
13368
13594
|
text: step.text
|
|
13369
13595
|
});
|
|
@@ -13375,6 +13601,7 @@ function makeRoutes(deps) {
|
|
|
13375
13601
|
});
|
|
13376
13602
|
res.end();
|
|
13377
13603
|
} catch (error) {
|
|
13604
|
+
if (isAbortError(error)) return;
|
|
13378
13605
|
if (!res.writableEnded) {
|
|
13379
13606
|
send({
|
|
13380
13607
|
type: "error",
|
|
@@ -13417,8 +13644,9 @@ function makeRoutes(deps) {
|
|
|
13417
13644
|
const send = (frame) => {
|
|
13418
13645
|
res.write(JSON.stringify(frame) + "\n");
|
|
13419
13646
|
};
|
|
13647
|
+
const abort = abortOnClientClose(req, res);
|
|
13420
13648
|
try {
|
|
13421
|
-
for await (const step of polishChapterStream(ctx, config, project, config.outputDir, no)) if (step.frame === "delta") send({
|
|
13649
|
+
for await (const step of polishChapterStream(ctx, config, project, config.outputDir, no, abort.signal)) if (step.frame === "delta") send({
|
|
13422
13650
|
type: "delta",
|
|
13423
13651
|
text: step.text
|
|
13424
13652
|
});
|
|
@@ -13430,6 +13658,7 @@ function makeRoutes(deps) {
|
|
|
13430
13658
|
});
|
|
13431
13659
|
res.end();
|
|
13432
13660
|
} catch (error) {
|
|
13661
|
+
if (isAbortError(error)) return;
|
|
13433
13662
|
if (!res.writableEnded) {
|
|
13434
13663
|
send({
|
|
13435
13664
|
type: "error",
|
|
@@ -13448,7 +13677,7 @@ function makeRoutes(deps) {
|
|
|
13448
13677
|
handler: async (req, res) => {
|
|
13449
13678
|
if (!guard(req, res, "POST")) return;
|
|
13450
13679
|
const config = getConfig();
|
|
13451
|
-
const project =
|
|
13680
|
+
const project = requireProjectAt(req, res);
|
|
13452
13681
|
if (project === void 0) return;
|
|
13453
13682
|
const body = await readJsonBody(req);
|
|
13454
13683
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
@@ -13499,7 +13728,7 @@ function makeRoutes(deps) {
|
|
|
13499
13728
|
handler: async (req, res) => {
|
|
13500
13729
|
if (!guard(req, res, "POST")) return;
|
|
13501
13730
|
const config = getConfig();
|
|
13502
|
-
const project =
|
|
13731
|
+
const project = requireProjectAt(req, res);
|
|
13503
13732
|
if (project === void 0) return;
|
|
13504
13733
|
const body = await readJsonBody(req);
|
|
13505
13734
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
@@ -13525,7 +13754,7 @@ function makeRoutes(deps) {
|
|
|
13525
13754
|
handler: async (req, res) => {
|
|
13526
13755
|
if (!guard(req, res, "POST")) return;
|
|
13527
13756
|
const config = getConfig();
|
|
13528
|
-
const project =
|
|
13757
|
+
const project = requireProjectAt(req, res);
|
|
13529
13758
|
if (project === void 0) return;
|
|
13530
13759
|
const body = await readJsonBody(req);
|
|
13531
13760
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
@@ -13669,7 +13898,7 @@ function makeRoutes(deps) {
|
|
|
13669
13898
|
handler: async (req, res) => {
|
|
13670
13899
|
if (!guard(req, res, "POST")) return;
|
|
13671
13900
|
const config = getConfig();
|
|
13672
|
-
const project =
|
|
13901
|
+
const project = requireProjectAt(req, res);
|
|
13673
13902
|
if (project === void 0) return;
|
|
13674
13903
|
const body = await readJsonBody(req);
|
|
13675
13904
|
const text = body?.text?.trim() ?? "";
|
|
@@ -13691,7 +13920,7 @@ function makeRoutes(deps) {
|
|
|
13691
13920
|
handler: async (req, res) => {
|
|
13692
13921
|
if (!guard(req, res, "POST")) return;
|
|
13693
13922
|
const config = getConfig();
|
|
13694
|
-
const project =
|
|
13923
|
+
const project = requireProjectAt(req, res);
|
|
13695
13924
|
if (project === void 0) return;
|
|
13696
13925
|
const body = await readJsonBody(req);
|
|
13697
13926
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
@@ -13762,8 +13991,9 @@ function makeRoutes(deps) {
|
|
|
13762
13991
|
const send = (frame) => {
|
|
13763
13992
|
res.write(JSON.stringify(frame) + "\n");
|
|
13764
13993
|
};
|
|
13994
|
+
const abort = abortOnClientClose(req, res);
|
|
13765
13995
|
try {
|
|
13766
|
-
for await (const step of runAssistantTurn(ctx, config, project, outputDir, message)) if (step.frame === "delta") send({
|
|
13996
|
+
for await (const step of runAssistantTurn(ctx, config, project, outputDir, message, abort.signal)) if (step.frame === "delta") send({
|
|
13767
13997
|
type: "delta",
|
|
13768
13998
|
text: step.text
|
|
13769
13999
|
});
|
|
@@ -13786,6 +14016,7 @@ function makeRoutes(deps) {
|
|
|
13786
14016
|
send({ type: "done" });
|
|
13787
14017
|
res.end();
|
|
13788
14018
|
} catch (error) {
|
|
14019
|
+
if (isAbortError(error)) return;
|
|
13789
14020
|
if (!res.writableEnded) {
|
|
13790
14021
|
send({
|
|
13791
14022
|
type: "error",
|
|
@@ -14923,6 +15154,10 @@ function makeRoutes(deps) {
|
|
|
14923
15154
|
writeJson(res, 400, { error: "filePath 不能为空(或请上传文件内容)" });
|
|
14924
15155
|
return;
|
|
14925
15156
|
}
|
|
15157
|
+
if (!IMPORTABLE_EXTENSIONS.includes(extname(filePath).toLowerCase())) {
|
|
15158
|
+
writeJson(res, 400, { error: `仅支持纯文本小说文件(${IMPORTABLE_EXTENSIONS.join(" / ")})` });
|
|
15159
|
+
return;
|
|
15160
|
+
}
|
|
14926
15161
|
if (!existsSync(filePath)) {
|
|
14927
15162
|
writeJson(res, 400, { error: `文件不存在:${filePath}` });
|
|
14928
15163
|
return;
|
|
@@ -15026,7 +15261,7 @@ function makeRoutes(deps) {
|
|
|
15026
15261
|
handler: async (req, res) => {
|
|
15027
15262
|
if (!guard(req, res, "POST")) return;
|
|
15028
15263
|
const config = getConfig();
|
|
15029
|
-
const project =
|
|
15264
|
+
const project = requireProjectAt(req, res);
|
|
15030
15265
|
if (project === void 0) return;
|
|
15031
15266
|
try {
|
|
15032
15267
|
const cards = await refreshCharacters(ctx, config, project, config.outputDir);
|
|
@@ -15046,7 +15281,7 @@ function makeRoutes(deps) {
|
|
|
15046
15281
|
handler: async (req, res) => {
|
|
15047
15282
|
if (!guard(req, res, "POST")) return;
|
|
15048
15283
|
const config = getConfig();
|
|
15049
|
-
const project =
|
|
15284
|
+
const project = requireProjectAt(req, res);
|
|
15050
15285
|
if (project === void 0) return;
|
|
15051
15286
|
try {
|
|
15052
15287
|
writeJson(res, 200, {
|
|
@@ -15065,7 +15300,7 @@ function makeRoutes(deps) {
|
|
|
15065
15300
|
handler: async (req, res) => {
|
|
15066
15301
|
if (!guard(req, res, "POST")) return;
|
|
15067
15302
|
const config = getConfig();
|
|
15068
|
-
const project =
|
|
15303
|
+
const project = requireProjectAt(req, res);
|
|
15069
15304
|
if (project === void 0) return;
|
|
15070
15305
|
if (project.bible === void 0) {
|
|
15071
15306
|
writeJson(res, 400, { error: "尚未生成道藏,请先生成" });
|
|
@@ -15100,7 +15335,7 @@ function makeRoutes(deps) {
|
|
|
15100
15335
|
handler: async (req, res) => {
|
|
15101
15336
|
if (!guard(req, res, "POST")) return;
|
|
15102
15337
|
const config = getConfig();
|
|
15103
|
-
const project =
|
|
15338
|
+
const project = requireProjectAt(req, res);
|
|
15104
15339
|
if (project === void 0) return;
|
|
15105
15340
|
const body = await readJsonBody(req);
|
|
15106
15341
|
try {
|
|
@@ -15134,8 +15369,11 @@ function makeRoutes(deps) {
|
|
|
15134
15369
|
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
15135
15370
|
return;
|
|
15136
15371
|
}
|
|
15137
|
-
const
|
|
15138
|
-
|
|
15372
|
+
const targetDir = resolveRegisteredDir(config, new URL(req.url ?? "/", "http://localhost").searchParams.get("dir"));
|
|
15373
|
+
if (targetDir === void 0) {
|
|
15374
|
+
writeJson(res, 400, { error: "dir 不是已登记的书籍目录" });
|
|
15375
|
+
return;
|
|
15376
|
+
}
|
|
15139
15377
|
const coverPath = loadProject(targetDir)?.coverPath;
|
|
15140
15378
|
if (coverPath === void 0 || coverPath === "") {
|
|
15141
15379
|
writeJson(res, 200, { dataUrl: null });
|
|
@@ -15151,7 +15389,7 @@ function makeRoutes(deps) {
|
|
|
15151
15389
|
}
|
|
15152
15390
|
if (req.method === "POST") {
|
|
15153
15391
|
if (!guard(req, res, "POST")) return;
|
|
15154
|
-
const project =
|
|
15392
|
+
const project = requireProjectAt(req, res);
|
|
15155
15393
|
if (project === void 0) return;
|
|
15156
15394
|
const body = await readJsonBody(req);
|
|
15157
15395
|
try {
|
|
@@ -15207,7 +15445,7 @@ function makeRoutes(deps) {
|
|
|
15207
15445
|
handler: async (req, res) => {
|
|
15208
15446
|
if (!guard(req, res, "POST")) return;
|
|
15209
15447
|
const config = getConfig();
|
|
15210
|
-
const project =
|
|
15448
|
+
const project = requireProjectAt(req, res);
|
|
15211
15449
|
if (project === void 0) return;
|
|
15212
15450
|
const body = await readJsonBody(req);
|
|
15213
15451
|
try {
|
|
@@ -15239,7 +15477,7 @@ function makeRoutes(deps) {
|
|
|
15239
15477
|
handler: async (req, res) => {
|
|
15240
15478
|
if (!guard(req, res, "POST")) return;
|
|
15241
15479
|
const config = getConfig();
|
|
15242
|
-
const project =
|
|
15480
|
+
const project = requireProjectAt(req, res);
|
|
15243
15481
|
if (project === void 0) return;
|
|
15244
15482
|
const bookName = (await readJsonBody(req))?.bookName?.trim();
|
|
15245
15483
|
if (bookName === void 0 || bookName === "") {
|
|
@@ -15261,7 +15499,7 @@ function makeRoutes(deps) {
|
|
|
15261
15499
|
handler: async (req, res) => {
|
|
15262
15500
|
if (!guard(req, res, "POST")) return;
|
|
15263
15501
|
const config = getConfig();
|
|
15264
|
-
const project =
|
|
15502
|
+
const project = requireProjectAt(req, res);
|
|
15265
15503
|
if (project === void 0) return;
|
|
15266
15504
|
const body = await readJsonBody(req);
|
|
15267
15505
|
if (project.plotlines === void 0) project.plotlines = [];
|
|
@@ -15390,7 +15628,7 @@ function makeRoutes(deps) {
|
|
|
15390
15628
|
handler: async (req, res) => {
|
|
15391
15629
|
if (!guard(req, res, "POST")) return;
|
|
15392
15630
|
const config = getConfig();
|
|
15393
|
-
const project =
|
|
15631
|
+
const project = requireProjectAt(req, res);
|
|
15394
15632
|
if (project === void 0) return;
|
|
15395
15633
|
const body = await readJsonBody(req);
|
|
15396
15634
|
const hits = [];
|
|
@@ -15444,7 +15682,7 @@ function makeRoutes(deps) {
|
|
|
15444
15682
|
handler: async (req, res) => {
|
|
15445
15683
|
if (!guard(req, res, "POST")) return;
|
|
15446
15684
|
const config = getConfig();
|
|
15447
|
-
const project =
|
|
15685
|
+
const project = requireProjectAt(req, res);
|
|
15448
15686
|
if (project === void 0) return;
|
|
15449
15687
|
const body = await readJsonBody(req);
|
|
15450
15688
|
if (project.roles === void 0) project.roles = [];
|
|
@@ -15482,7 +15720,7 @@ function makeRoutes(deps) {
|
|
|
15482
15720
|
handler: async (req, res) => {
|
|
15483
15721
|
if (!guard(req, res, "POST")) return;
|
|
15484
15722
|
const config = getConfig();
|
|
15485
|
-
const project =
|
|
15723
|
+
const project = requireProjectAt(req, res);
|
|
15486
15724
|
if (project === void 0) return;
|
|
15487
15725
|
const no = (await readJsonBody(req))?.chapterNo;
|
|
15488
15726
|
if (!Number.isInteger(no) || no === void 0 || no < 1) {
|
|
@@ -15512,7 +15750,7 @@ function makeRoutes(deps) {
|
|
|
15512
15750
|
handler: async (req, res) => {
|
|
15513
15751
|
if (!guard(req, res, "POST")) return;
|
|
15514
15752
|
const config = getConfig();
|
|
15515
|
-
const project =
|
|
15753
|
+
const project = requireProjectAt(req, res);
|
|
15516
15754
|
if (project === void 0) return;
|
|
15517
15755
|
const no = (await readJsonBody(req))?.chapterNo;
|
|
15518
15756
|
if (!Number.isInteger(no) || no === void 0 || no < 1) {
|
|
@@ -15533,6 +15771,73 @@ function makeRoutes(deps) {
|
|
|
15533
15771
|
});
|
|
15534
15772
|
}
|
|
15535
15773
|
};
|
|
15774
|
+
/**
|
|
15775
|
+
* 补写本章计划(方案 §7「左侧章节计划」六项)。
|
|
15776
|
+
*
|
|
15777
|
+
* 这些字段原本只有规划步骤会写:AI 排偏了,作者却没有纠正入口,而它们正是生成
|
|
15778
|
+
* 与审稿的锚点(见 engine 的本章义务合约拼装)。上限与规划步骤保持一致 ——
|
|
15779
|
+
* 否则「手动能写 10 条、AI 一重排只剩 4 条」就是静默丢数据;超限必然回写 notices。
|
|
15780
|
+
*/
|
|
15781
|
+
const chapterPlanRoute = {
|
|
15782
|
+
kind: "exact",
|
|
15783
|
+
path: NOVEL_API.chapterPlan,
|
|
15784
|
+
handler: async (req, res) => {
|
|
15785
|
+
if (!guard(req, res, "POST")) return;
|
|
15786
|
+
const config = getConfig();
|
|
15787
|
+
const project = requireProjectAt(req, res);
|
|
15788
|
+
if (project === void 0) return;
|
|
15789
|
+
const body = await readJsonBody(req);
|
|
15790
|
+
const no = body?.chapterNo;
|
|
15791
|
+
if (!Number.isInteger(no) || no === void 0 || no < 1) {
|
|
15792
|
+
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
15793
|
+
return;
|
|
15794
|
+
}
|
|
15795
|
+
const chapter = project.chapters.find((c) => c.no === no);
|
|
15796
|
+
if (chapter === void 0) {
|
|
15797
|
+
writeJson(res, 404, { error: `章节 ${no} 不在计划中` });
|
|
15798
|
+
return;
|
|
15799
|
+
}
|
|
15800
|
+
const patch = body?.patch;
|
|
15801
|
+
if (patch === null || typeof patch !== "object") {
|
|
15802
|
+
writeJson(res, 400, { error: "patch 须为对象" });
|
|
15803
|
+
return;
|
|
15804
|
+
}
|
|
15805
|
+
const notices = [];
|
|
15806
|
+
const oneLine = (label, raw, max) => {
|
|
15807
|
+
if (typeof raw !== "string") return void 0;
|
|
15808
|
+
const value = raw.trim();
|
|
15809
|
+
if (value.length <= max) return value;
|
|
15810
|
+
notices.push(`${label}:${value.length} 字已裁到 ${max} 字`);
|
|
15811
|
+
return value.slice(0, max);
|
|
15812
|
+
};
|
|
15813
|
+
const items = (label, raw, max) => {
|
|
15814
|
+
if (!Array.isArray(raw)) return void 0;
|
|
15815
|
+
const value = raw.filter((x) => typeof x === "string" && x.trim() !== "").map((x) => x.trim());
|
|
15816
|
+
if (value.length <= max) return value;
|
|
15817
|
+
notices.push(`${label}:${value.length} 条只保留前 ${max} 条`);
|
|
15818
|
+
return value.slice(0, max);
|
|
15819
|
+
};
|
|
15820
|
+
const obligation = oneLine("本章目标", patch.obligation, 200);
|
|
15821
|
+
if (obligation !== void 0) chapter.obligation = obligation === "" ? void 0 : obligation;
|
|
15822
|
+
const mustAdvance = items("本章必达项", patch.mustAdvance, 4);
|
|
15823
|
+
if (mustAdvance !== void 0) chapter.mustAdvance = mustAdvance;
|
|
15824
|
+
const mustPreserve = items("必须保持", patch.mustPreserve, 4);
|
|
15825
|
+
if (mustPreserve !== void 0) chapter.mustPreserve = mustPreserve;
|
|
15826
|
+
const hardFacts = items("人物硬事实", patch.characterHardFacts, 6);
|
|
15827
|
+
if (hardFacts !== void 0) chapter.characterHardFacts = hardFacts;
|
|
15828
|
+
const endingHook = oneLine("结尾钩子", patch.endingHook, 120);
|
|
15829
|
+
if (endingHook !== void 0) chapter.endingHook = endingHook === "" ? void 0 : endingHook;
|
|
15830
|
+
const beats = oneLine("剧情推进", patch.beats, 2e3);
|
|
15831
|
+
if (beats !== void 0) chapter.beats = beats;
|
|
15832
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
15833
|
+
saveProject(config.outputDir, project);
|
|
15834
|
+
writeJson(res, 200, {
|
|
15835
|
+
ok: true,
|
|
15836
|
+
chapter,
|
|
15837
|
+
notices
|
|
15838
|
+
});
|
|
15839
|
+
}
|
|
15840
|
+
};
|
|
15536
15841
|
/** 作者复盘补跑:对已写章节补齐 authorReview(body.chapterNo=单章 JSON,缺省=全书 NDJSON 流)。 */
|
|
15537
15842
|
const reviewBackfillRoute = {
|
|
15538
15843
|
kind: "exact",
|
|
@@ -15540,9 +15845,10 @@ function makeRoutes(deps) {
|
|
|
15540
15845
|
handler: async (req, res) => {
|
|
15541
15846
|
if (!guard(req, res, "POST")) return;
|
|
15542
15847
|
const config = getConfig();
|
|
15543
|
-
const project =
|
|
15848
|
+
const project = requireProjectAt(req, res);
|
|
15544
15849
|
if (project === void 0) return;
|
|
15545
15850
|
const body = await readJsonBody(req);
|
|
15851
|
+
const abort = abortOnClientClose(req, res);
|
|
15546
15852
|
/** 对一章执行作者复盘(读取已落盘正文,不改变章节状态/正文)。 */
|
|
15547
15853
|
const runOne = async (chapter) => {
|
|
15548
15854
|
const currentBody = readChapterFile(config.outputDir, chapter);
|
|
@@ -15552,6 +15858,7 @@ function makeRoutes(deps) {
|
|
|
15552
15858
|
const prev = project.chapters.find((c) => c.no === chapter.no - 1);
|
|
15553
15859
|
if (prev !== void 0) prevTail = stripChapterHeadings(readChapterFile(config.outputDir, prev) ?? "").slice(-600);
|
|
15554
15860
|
}
|
|
15861
|
+
if (abort.signal.aborted) throw new Error("客户端已断开连接");
|
|
15555
15862
|
return authorReviewChapter(ctx, config, project, chapter.no, currentBody, prevTail);
|
|
15556
15863
|
};
|
|
15557
15864
|
if (typeof body?.chapterNo === "number" && body.chapterNo > 0) {
|
|
@@ -15726,7 +16033,7 @@ function makeRoutes(deps) {
|
|
|
15726
16033
|
handler: async (req, res) => {
|
|
15727
16034
|
if (!guard(req, res, "POST")) return;
|
|
15728
16035
|
const config = getConfig();
|
|
15729
|
-
const project =
|
|
16036
|
+
const project = requireProjectAt(req, res);
|
|
15730
16037
|
if (project === void 0) return;
|
|
15731
16038
|
res.writeHead(200, {
|
|
15732
16039
|
"content-type": "application/x-ndjson; charset=utf-8",
|
|
@@ -15737,6 +16044,7 @@ function makeRoutes(deps) {
|
|
|
15737
16044
|
const send = (frame) => {
|
|
15738
16045
|
res.write(JSON.stringify(frame) + "\n");
|
|
15739
16046
|
};
|
|
16047
|
+
const abort = abortOnClientClose(req, res);
|
|
15740
16048
|
try {
|
|
15741
16049
|
const outline = await reverseOutlineFromChapters(ctx, config, project, config.outputDir, (done, total, phase) => {
|
|
15742
16050
|
send({
|
|
@@ -15745,7 +16053,7 @@ function makeRoutes(deps) {
|
|
|
15745
16053
|
total,
|
|
15746
16054
|
phase
|
|
15747
16055
|
});
|
|
15748
|
-
});
|
|
16056
|
+
}, abort.signal);
|
|
15749
16057
|
project.outline = outline;
|
|
15750
16058
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
15751
16059
|
saveProject(config.outputDir, project);
|
|
@@ -15755,6 +16063,7 @@ function makeRoutes(deps) {
|
|
|
15755
16063
|
chars: outline.length
|
|
15756
16064
|
});
|
|
15757
16065
|
} catch (error) {
|
|
16066
|
+
if (isAbortError(error)) return;
|
|
15758
16067
|
send({
|
|
15759
16068
|
type: "error",
|
|
15760
16069
|
no: 0,
|
|
@@ -15865,7 +16174,7 @@ function makeRoutes(deps) {
|
|
|
15865
16174
|
handler: async (req, res) => {
|
|
15866
16175
|
if (!guard(req, res, "POST")) return;
|
|
15867
16176
|
const config = getConfig();
|
|
15868
|
-
const project =
|
|
16177
|
+
const project = requireProjectAt(req, res);
|
|
15869
16178
|
if (project === void 0) return;
|
|
15870
16179
|
const body = await readJsonBody(req);
|
|
15871
16180
|
try {
|
|
@@ -15875,8 +16184,16 @@ function makeRoutes(deps) {
|
|
|
15875
16184
|
}
|
|
15876
16185
|
}
|
|
15877
16186
|
};
|
|
15878
|
-
/**
|
|
15879
|
-
|
|
16187
|
+
/**
|
|
16188
|
+
* 生产单执行器:计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。
|
|
16189
|
+
*
|
|
16190
|
+
* 必须由调用方(index.ts)持有并注入,**不要在这里 new**:
|
|
16191
|
+
* 本工厂每次 `sync()` 都会重建,藏在闭包里的 runner 会被丢掉——
|
|
16192
|
+
* 旧实例的 `loop()` 仍在跑并继续写盘,而新实例 `isWorking()` 恒 false,
|
|
16193
|
+
* 于是 `moveOutputDir` 的「正在生产则拒绝迁移」守卫直接失效。
|
|
16194
|
+
* 测试可省略本项,内部自建一个一次性实例。
|
|
16195
|
+
*/
|
|
16196
|
+
const runner = deps.runner ?? new ProductionRunner({
|
|
15880
16197
|
ctx,
|
|
15881
16198
|
getConfig
|
|
15882
16199
|
});
|
|
@@ -16232,11 +16549,13 @@ function makeRoutes(deps) {
|
|
|
16232
16549
|
const send = (frame) => {
|
|
16233
16550
|
res.write(JSON.stringify(frame) + "\n");
|
|
16234
16551
|
};
|
|
16552
|
+
const abort = abortOnClientClose(req, res);
|
|
16235
16553
|
try {
|
|
16236
16554
|
const result = await rewriteAdaptationBook(ctx, getConfig(), text, mappings, body?.rules, {
|
|
16237
16555
|
maxChapters: body?.maxChapters,
|
|
16238
16556
|
startNo: body?.startNo,
|
|
16239
16557
|
endNo: body?.endNo,
|
|
16558
|
+
signal: abort.signal,
|
|
16240
16559
|
onProgress: (info) => send({
|
|
16241
16560
|
type: "progress",
|
|
16242
16561
|
completed: info.completed,
|
|
@@ -16245,6 +16564,7 @@ function makeRoutes(deps) {
|
|
|
16245
16564
|
title: info.title
|
|
16246
16565
|
})
|
|
16247
16566
|
});
|
|
16567
|
+
if (abort.signal.aborted) return;
|
|
16248
16568
|
send({
|
|
16249
16569
|
type: "done",
|
|
16250
16570
|
result: {
|
|
@@ -16257,6 +16577,7 @@ function makeRoutes(deps) {
|
|
|
16257
16577
|
}
|
|
16258
16578
|
});
|
|
16259
16579
|
} catch (err) {
|
|
16580
|
+
if (isAbortError(err)) return;
|
|
16260
16581
|
send({
|
|
16261
16582
|
type: "error",
|
|
16262
16583
|
message: err instanceof Error ? err.message : String(err)
|
|
@@ -16470,6 +16791,7 @@ function makeRoutes(deps) {
|
|
|
16470
16791
|
reviewBackfillRoute,
|
|
16471
16792
|
chapterResetRoute,
|
|
16472
16793
|
chapterApproveRoute,
|
|
16794
|
+
chapterPlanRoute,
|
|
16473
16795
|
configRoute,
|
|
16474
16796
|
moveOutputDirRoute,
|
|
16475
16797
|
bookSettingsRoute,
|
|
@@ -16845,6 +17167,21 @@ function apply(ctx, config) {
|
|
|
16845
17167
|
};
|
|
16846
17168
|
let disposeSection;
|
|
16847
17169
|
let disposeRoutes;
|
|
17170
|
+
/**
|
|
17171
|
+
* 生产单 runner 单例:**必须在 makeRoutes 之外**持有。
|
|
17172
|
+
*
|
|
17173
|
+
* 此前 runner 在 makeRoutes 工厂体内 `new`,而 `sync()` 每次配置变更都会
|
|
17174
|
+
* 重新 makeRoutes + dispose 全部旧路由 —— 于是:
|
|
17175
|
+
* 1. 旧 runner 的 `loop()` 仍在跑并继续 saveProject(失去引用但不停止),同时
|
|
17176
|
+
* 2. 新 runner 的 `isWorking()` 恒为 false,`moveOutputDir` 的守卫失效 →
|
|
17177
|
+
* 可以边迁移目录边写盘。
|
|
17178
|
+
* 提到这里之后,sync() 只重建路由表,runner 及其内存状态(working / 当前章 /
|
|
17179
|
+
* 暂停标志)跨重注册保持连续。
|
|
17180
|
+
*/
|
|
17181
|
+
const runner = new ProductionRunner({
|
|
17182
|
+
ctx,
|
|
17183
|
+
getConfig: resolve
|
|
17184
|
+
});
|
|
16848
17185
|
const sync = () => {
|
|
16849
17186
|
if (disposeSection !== void 0) {
|
|
16850
17187
|
disposeSection();
|
|
@@ -16866,7 +17203,8 @@ function apply(ctx, config) {
|
|
|
16866
17203
|
getConfig: resolve,
|
|
16867
17204
|
patchConfig,
|
|
16868
17205
|
rawConfig: () => current(),
|
|
16869
|
-
settingsNs
|
|
17206
|
+
settingsNs,
|
|
17207
|
+
runner
|
|
16870
17208
|
});
|
|
16871
17209
|
disposeRoutes = ctx.effect(() => {
|
|
16872
17210
|
const disposers = routes.map((route) => ctx.webServer.register(route));
|