@waterwx/dsh-novel-forge 2.1.5 → 2.3.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 +13 -9
- package/lib/client.js +3825 -2159
- package/lib/index.js +1935 -1300
- 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/ChapterPlanPanel.d.ts +24 -0
- package/lib/types/client/panel/ConfirmDialog.d.ts +2 -0
- package/lib/types/client/panel/HomeHero.d.ts +60 -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/helpers.d.ts +12 -0
- package/lib/types/engine.d.ts +31 -14
- package/lib/types/llm-live.d.ts +2 -2
- package/lib/types/llm-retry.d.ts +11 -0
- package/lib/types/prompt-cache.d.ts +43 -0
- package/lib/types/prompts/adapt.d.ts +21 -0
- package/lib/types/prompts/audit.d.ts +15 -0
- package/lib/types/prompts/bible.d.ts +10 -0
- package/lib/types/prompts/catalog.d.ts +40 -0
- package/lib/types/prompts/characters.d.ts +19 -0
- package/lib/types/prompts/common.d.ts +9 -0
- package/lib/types/prompts/digest.d.ts +27 -0
- package/lib/types/prompts/facts.d.ts +26 -0
- package/lib/types/prompts/foreshadow.d.ts +10 -0
- package/lib/types/prompts/market.d.ts +18 -0
- package/lib/types/prompts/outline.d.ts +25 -0
- package/lib/types/prompts/plan.d.ts +22 -0
- package/lib/types/prompts/plotlines.d.ts +21 -0
- package/lib/types/prompts/polish.d.ts +22 -0
- package/lib/types/prompts/review.d.ts +30 -0
- package/lib/types/prompts/world.d.ts +13 -0
- package/lib/types/prompts/writing.d.ts +29 -0
- package/lib/types/protocol.d.ts +53 -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 +2 -1
package/lib/index.js
CHANGED
|
@@ -3741,6 +3741,7 @@ const usageTotals = {
|
|
|
3741
3741
|
inputTokens: 0,
|
|
3742
3742
|
outputTokens: 0,
|
|
3743
3743
|
reasoningTokens: 0,
|
|
3744
|
+
cacheReadTokens: 0,
|
|
3744
3745
|
totalTokens: 0,
|
|
3745
3746
|
elapsedMs: 0,
|
|
3746
3747
|
callsWithoutUsage: 0,
|
|
@@ -3788,6 +3789,7 @@ function recordUsage(frame) {
|
|
|
3788
3789
|
usageTotals.inputTokens += usage.inputTokens;
|
|
3789
3790
|
usageTotals.outputTokens += usage.outputTokens;
|
|
3790
3791
|
usageTotals.reasoningTokens += usage.reasoningTokens ?? 0;
|
|
3792
|
+
usageTotals.cacheReadTokens += usage.cacheReadTokens ?? 0;
|
|
3791
3793
|
usageTotals.totalTokens += usage.totalTokens ?? usage.inputTokens + usage.outputTokens;
|
|
3792
3794
|
}
|
|
3793
3795
|
const label = frame.label ?? "未标注调用";
|
|
@@ -3798,6 +3800,7 @@ function recordUsage(frame) {
|
|
|
3798
3800
|
inputTokens: 0,
|
|
3799
3801
|
outputTokens: 0,
|
|
3800
3802
|
reasoningTokens: 0,
|
|
3803
|
+
cacheReadTokens: 0,
|
|
3801
3804
|
elapsedMs: 0
|
|
3802
3805
|
};
|
|
3803
3806
|
bucket.calls += 1;
|
|
@@ -3807,6 +3810,7 @@ function recordUsage(frame) {
|
|
|
3807
3810
|
bucket.inputTokens += usage.inputTokens;
|
|
3808
3811
|
bucket.outputTokens += usage.outputTokens;
|
|
3809
3812
|
bucket.reasoningTokens += usage.reasoningTokens ?? 0;
|
|
3813
|
+
bucket.cacheReadTokens = (bucket.cacheReadTokens ?? 0) + (usage.cacheReadTokens ?? 0);
|
|
3810
3814
|
}
|
|
3811
3815
|
usageTotals.byLabel.set(label, bucket);
|
|
3812
3816
|
}
|
|
@@ -3819,6 +3823,7 @@ function liveUsage() {
|
|
|
3819
3823
|
outputTokens: usageTotals.outputTokens,
|
|
3820
3824
|
reasoningTokens: usageTotals.reasoningTokens,
|
|
3821
3825
|
totalTokens: usageTotals.totalTokens,
|
|
3826
|
+
cacheReadTokens: usageTotals.cacheReadTokens,
|
|
3822
3827
|
elapsedMs: usageTotals.elapsedMs,
|
|
3823
3828
|
callsWithoutUsage: usageTotals.callsWithoutUsage,
|
|
3824
3829
|
byLabel: [...usageTotals.byLabel.values()].sort((a, b) => b.inputTokens + b.outputTokens + b.reasoningTokens - (a.inputTokens + a.outputTokens + a.reasoningTokens))
|
|
@@ -3831,6 +3836,7 @@ function resetLiveUsage() {
|
|
|
3831
3836
|
usageTotals.inputTokens = 0;
|
|
3832
3837
|
usageTotals.outputTokens = 0;
|
|
3833
3838
|
usageTotals.reasoningTokens = 0;
|
|
3839
|
+
usageTotals.cacheReadTokens = 0;
|
|
3834
3840
|
usageTotals.totalTokens = 0;
|
|
3835
3841
|
usageTotals.elapsedMs = 0;
|
|
3836
3842
|
usageTotals.callsWithoutUsage = 0;
|
|
@@ -3975,6 +3981,24 @@ function isRetryableLlmError(error) {
|
|
|
3975
3981
|
return RETRYABLE_PATTERNS.some((re) => re.test(message));
|
|
3976
3982
|
}
|
|
3977
3983
|
/**
|
|
3984
|
+
* 判断一次失败是「取消」(作者关页面 / 主动中断)还是模型故障。
|
|
3985
|
+
*
|
|
3986
|
+
* 取消有两种现场:适配器给出 `aborted` 收尾块,或抛出 `AbortError` / 本插件自己的
|
|
3987
|
+
* 断开错误。它必须和真实失败分开记账——否则面板「本次运行失败 N 次」会把作者的正常
|
|
3988
|
+
* 中断统计成模型不稳定,进而误导备用模型档位判断(与规则 1「用户取消不重试」同一口径)。
|
|
3989
|
+
*
|
|
3990
|
+
* @param input 收尾块 kind(字符串)或捕获到的异常。
|
|
3991
|
+
* @returns 属于取消时为 true。
|
|
3992
|
+
*/
|
|
3993
|
+
function isCancellation(input) {
|
|
3994
|
+
if (typeof input === "string") return input === "aborted";
|
|
3995
|
+
if (input instanceof Error) {
|
|
3996
|
+
if (input.name === "AbortError") return true;
|
|
3997
|
+
return /已取消|用户取消|客户端已断开|\baborted\b/i.test(input.message);
|
|
3998
|
+
}
|
|
3999
|
+
return false;
|
|
4000
|
+
}
|
|
4001
|
+
/**
|
|
3978
4002
|
* 判断是否应当切换到备用模型。
|
|
3979
4003
|
*
|
|
3980
4004
|
* @param fallbackModel 配置的备用模型(空串 = 未配置)。
|
|
@@ -4205,6 +4229,16 @@ function applyModelCapability(base, model, options = {}) {
|
|
|
4205
4229
|
//#endregion
|
|
4206
4230
|
//#region src/stream-fallback.ts
|
|
4207
4231
|
/**
|
|
4232
|
+
* 统一的取消错误:与 `DOMException('AbortError')` 同名,便于上层用 `name` 判定。
|
|
4233
|
+
* @param reason 取消原因(signal.reason,或适配器抛出的原始异常)。
|
|
4234
|
+
*/
|
|
4235
|
+
function abortError(reason) {
|
|
4236
|
+
if (reason instanceof Error) return reason;
|
|
4237
|
+
const err = new Error(typeof reason === "string" ? reason : "客户端已断开连接");
|
|
4238
|
+
err.name = "AbortError";
|
|
4239
|
+
return err;
|
|
4240
|
+
}
|
|
4241
|
+
/**
|
|
4208
4242
|
* 执行流式生成,主模型失败且「一个字都没产出」时换备用模型重试一次。
|
|
4209
4243
|
*
|
|
4210
4244
|
* 第三泛型(`TNext`)取 `unknown` 而非 `void`:三处调用方的外层生成器都声明为
|
|
@@ -4215,11 +4249,13 @@ function applyModelCapability(base, model, options = {}) {
|
|
|
4215
4249
|
* @throws 两次都失败时抛最后一次的错误;成功但文本过短时抛「结果过短」。
|
|
4216
4250
|
*/
|
|
4217
4251
|
async function* streamWithFallback(options) {
|
|
4218
|
-
const { ctx, config, request, liveLabel, maxTokensError, minChars } = options;
|
|
4252
|
+
const { ctx, config, request, liveLabel, maxTokensError, minChars, signal } = options;
|
|
4219
4253
|
const primaryModel = request.model ?? config.model;
|
|
4220
4254
|
let text = "";
|
|
4221
4255
|
let lastError;
|
|
4256
|
+
const abortedByCaller = () => signal?.aborted === true;
|
|
4222
4257
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
4258
|
+
if (abortedByCaller()) throw abortError(signal?.reason);
|
|
4223
4259
|
const model = attempt === 0 ? primaryModel : (config.fallbackModel ?? "").trim();
|
|
4224
4260
|
if (model === "") break;
|
|
4225
4261
|
const live = beginLiveCall({
|
|
@@ -4231,29 +4267,44 @@ async function* streamWithFallback(options) {
|
|
|
4231
4267
|
const assembler = new BlockAssembler();
|
|
4232
4268
|
let produced = 0;
|
|
4233
4269
|
const converged = applyModelCapability(request, model, { maxTokensFor: (m) => fullTextBudget(config, m, primaryModel) });
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4270
|
+
try {
|
|
4271
|
+
for await (const chunk of ctx.llm.stream(signal === void 0 ? converged : {
|
|
4272
|
+
...converged,
|
|
4273
|
+
signal
|
|
4274
|
+
})) {
|
|
4275
|
+
assembler.push(chunk);
|
|
4276
|
+
if (chunk.type === "text-delta") {
|
|
4277
|
+
produced += chunk.text.length;
|
|
4278
|
+
markFirstToken(live);
|
|
4279
|
+
yield {
|
|
4280
|
+
frame: "delta",
|
|
4281
|
+
text: chunk.text
|
|
4282
|
+
};
|
|
4283
|
+
}
|
|
4243
4284
|
}
|
|
4285
|
+
} catch (error) {
|
|
4286
|
+
const aborted = isCancellation(error) || abortedByCaller();
|
|
4287
|
+
endLiveCall(live, assembler, {
|
|
4288
|
+
chars: produced,
|
|
4289
|
+
phase: aborted ? "cancelled" : "failed",
|
|
4290
|
+
error: error instanceof Error ? error.message : String(error)
|
|
4291
|
+
});
|
|
4292
|
+
if (aborted) throw abortError(signal?.reason ?? error);
|
|
4293
|
+
throw error;
|
|
4244
4294
|
}
|
|
4245
4295
|
const finish = assembler.finish;
|
|
4246
4296
|
lastError = void 0;
|
|
4247
|
-
if (finish.kind === "error" || finish.kind === "aborted") lastError = /* @__PURE__ */ new Error(`${liveLabel}
|
|
4297
|
+
if (finish.kind === "error" || finish.kind === "aborted") lastError = /* @__PURE__ */ new Error(`${isCancellation(finish.kind) ? "已取消" : `${liveLabel}失败`}(${finish.kind}): ${finish.failure.message}`);
|
|
4248
4298
|
else if (finish.kind === "max-tokens") lastError = new Error(maxTokensError);
|
|
4249
4299
|
text = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
|
|
4300
|
+
const cancelled = lastError !== void 0 && isCancellation(lastError);
|
|
4250
4301
|
endLiveCall(live, assembler, lastError === void 0 ? {
|
|
4251
4302
|
chars: text.length,
|
|
4252
4303
|
preview: text.slice(0, 320),
|
|
4253
4304
|
phase: text === "" ? "failed" : "completed"
|
|
4254
4305
|
} : {
|
|
4255
4306
|
chars: text.length,
|
|
4256
|
-
phase: "failed",
|
|
4307
|
+
phase: cancelled ? "cancelled" : "failed",
|
|
4257
4308
|
error: lastError.message
|
|
4258
4309
|
});
|
|
4259
4310
|
if (lastError === void 0) break;
|
|
@@ -4400,422 +4451,70 @@ function renderLengthContract(entries) {
|
|
|
4400
4451
|
if (entries.length === 0) return "";
|
|
4401
4452
|
return `为保证输出完整:${entries.map(([label, limit]) => `${label}不超过 ${limit} 字`).join(";")}。每个字段只保留完成本阶段决策所需的信息,写完后立即结束输出,不要展开细纲或重复说明。`;
|
|
4402
4453
|
}
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
],
|
|
4422
|
-
suspense: [
|
|
4423
|
-
30,
|
|
4424
|
-
42,
|
|
4425
|
-
38,
|
|
4426
|
-
50,
|
|
4427
|
-
46,
|
|
4428
|
-
58,
|
|
4429
|
-
62,
|
|
4430
|
-
56,
|
|
4431
|
-
70,
|
|
4432
|
-
78,
|
|
4433
|
-
86,
|
|
4434
|
-
74
|
|
4435
|
-
],
|
|
4436
|
-
wave: [
|
|
4437
|
-
45,
|
|
4438
|
-
30,
|
|
4439
|
-
55,
|
|
4440
|
-
34,
|
|
4441
|
-
62,
|
|
4442
|
-
40,
|
|
4443
|
-
70,
|
|
4444
|
-
46,
|
|
4445
|
-
78,
|
|
4446
|
-
52,
|
|
4447
|
-
84,
|
|
4448
|
-
60
|
|
4449
|
-
],
|
|
4450
|
-
frontLoad: [
|
|
4451
|
-
78,
|
|
4452
|
-
66,
|
|
4453
|
-
58,
|
|
4454
|
-
50,
|
|
4455
|
-
46,
|
|
4456
|
-
44,
|
|
4457
|
-
42,
|
|
4458
|
-
44,
|
|
4459
|
-
46,
|
|
4460
|
-
48,
|
|
4461
|
-
50,
|
|
4462
|
-
52
|
|
4463
|
-
],
|
|
4464
|
-
flat: [
|
|
4465
|
-
40,
|
|
4466
|
-
42,
|
|
4467
|
-
44,
|
|
4468
|
-
42,
|
|
4469
|
-
46,
|
|
4470
|
-
44,
|
|
4471
|
-
48,
|
|
4472
|
-
46,
|
|
4473
|
-
50,
|
|
4474
|
-
48,
|
|
4475
|
-
52,
|
|
4476
|
-
50
|
|
4477
|
-
]
|
|
4478
|
-
};
|
|
4479
|
-
/** 参考曲线的中文标签(面板展示)。 */
|
|
4480
|
-
const TENSION_PRESET_LABELS = {
|
|
4481
|
-
escalation: "递进爬升",
|
|
4482
|
-
suspense: "悬疑加压",
|
|
4483
|
-
wave: "波浪呼吸",
|
|
4484
|
-
frontLoad: "前紧后稳",
|
|
4485
|
-
flat: "平稳推进",
|
|
4486
|
-
custom: "自定义"
|
|
4487
|
-
};
|
|
4488
|
-
/** 全部可选预设(UI 下拉用)。 */
|
|
4489
|
-
const TENSION_PRESETS = [
|
|
4490
|
-
"escalation",
|
|
4491
|
-
"suspense",
|
|
4492
|
-
"wave",
|
|
4493
|
-
"frontLoad",
|
|
4494
|
-
"flat",
|
|
4495
|
-
"custom"
|
|
4496
|
-
];
|
|
4497
|
-
/** 把参考形状按章节数重采样(最近邻,保持形状不引入平滑失真)。 */
|
|
4498
|
-
function referenceCurve(preset, chapterCount) {
|
|
4499
|
-
if (chapterCount <= 0) return [];
|
|
4500
|
-
if (preset === "custom") return [];
|
|
4501
|
-
const shape = REFERENCE_SHAPES[preset];
|
|
4502
|
-
if (chapterCount === 1) return [shape[Math.floor(shape.length / 2)] ?? 50];
|
|
4503
|
-
return Array.from({ length: chapterCount }, (_, i) => {
|
|
4504
|
-
const pos = i / (chapterCount - 1) * (shape.length - 1);
|
|
4505
|
-
const lo = Math.floor(pos);
|
|
4506
|
-
const hi = Math.min(shape.length - 1, lo + 1);
|
|
4507
|
-
const t = pos - lo;
|
|
4508
|
-
const a = shape[lo] ?? 50;
|
|
4509
|
-
const b = shape[hi] ?? a;
|
|
4510
|
-
return Math.round(a + (b - a) * t);
|
|
4511
|
-
});
|
|
4512
|
-
}
|
|
4513
|
-
/** 读一章的目标张力(未设置时返回 undefined)。 */
|
|
4514
|
-
function targetTension(chapter) {
|
|
4515
|
-
return typeof chapter.tension === "number" ? clampTension(chapter.tension) : void 0;
|
|
4516
|
-
}
|
|
4517
|
-
/** 读一章的实际张力(审稿时模型打分,未设置时返回 undefined)。 */
|
|
4518
|
-
function actualTension(chapter) {
|
|
4519
|
-
const fromReview = chapter.review?.tension;
|
|
4520
|
-
if (typeof fromReview === "number") return clampTension(fromReview);
|
|
4521
|
-
return typeof chapter.tensionActual === "number" ? clampTension(chapter.tensionActual) : void 0;
|
|
4454
|
+
//#endregion
|
|
4455
|
+
//#region src/timeline.ts
|
|
4456
|
+
/** 时间线矛盾初筛的最少事件数(太少没有意义)。 */
|
|
4457
|
+
const MIN_EVENTS_FOR_CHECK = 2;
|
|
4458
|
+
/** 归一化事件(清洗字符串、补 id/时间戳)。 */
|
|
4459
|
+
function normalizeTimelineEvent(raw, chapterNo, index, now = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
4460
|
+
const characters = Array.isArray(raw.characters) ? raw.characters.filter((c) => typeof c === "string" && c.trim() !== "") : [];
|
|
4461
|
+
return {
|
|
4462
|
+
id: typeof raw.id === "string" && raw.id !== "" ? raw.id : `tl-${chapterNo}-${index}-${Math.random().toString(36).slice(2, 8)}`,
|
|
4463
|
+
chapterNo: typeof raw.chapterNo === "number" ? raw.chapterNo : chapterNo,
|
|
4464
|
+
time: (raw.time ?? "").trim(),
|
|
4465
|
+
order: typeof raw.order === "number" && Number.isFinite(raw.order) ? raw.order : void 0,
|
|
4466
|
+
place: (raw.place ?? "").trim(),
|
|
4467
|
+
characters: characters.map((c) => c.trim()),
|
|
4468
|
+
event: (raw.event ?? "").trim(),
|
|
4469
|
+
source: raw.source === "manual" ? "manual" : "extracted",
|
|
4470
|
+
createdAt: raw.createdAt ?? now
|
|
4471
|
+
};
|
|
4522
4472
|
}
|
|
4523
|
-
/**
|
|
4524
|
-
function
|
|
4525
|
-
|
|
4526
|
-
|
|
4473
|
+
/** 时间线排序:先章节、再 order(缺省排在该章末尾)、最后 createdAt。 */
|
|
4474
|
+
function sortTimeline(events) {
|
|
4475
|
+
return [...events].sort((a, b) => {
|
|
4476
|
+
if (a.chapterNo !== b.chapterNo) return a.chapterNo - b.chapterNo;
|
|
4477
|
+
const ao = a.order ?? Number.MAX_SAFE_INTEGER;
|
|
4478
|
+
const bo = b.order ?? Number.MAX_SAFE_INTEGER;
|
|
4479
|
+
if (ao !== bo) return ao - bo;
|
|
4480
|
+
return a.createdAt.localeCompare(b.createdAt);
|
|
4481
|
+
});
|
|
4527
4482
|
}
|
|
4528
4483
|
/**
|
|
4529
|
-
*
|
|
4484
|
+
* 渲染注入到「写作/规划」提示词的时间线锚点块。
|
|
4530
4485
|
*
|
|
4531
4486
|
* @param project 项目状态。
|
|
4532
|
-
* @param
|
|
4533
|
-
* @returns
|
|
4487
|
+
* @param beforeChapter 只注入该章之前的锚点(含该章本身不注入,避免剧透)。
|
|
4488
|
+
* @returns 提示词片段(无事件时返回空串)。带显式截断声明。
|
|
4534
4489
|
*/
|
|
4535
|
-
function
|
|
4536
|
-
const
|
|
4537
|
-
|
|
4538
|
-
const
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
parts.push(delta >= 10 ? "比上一章明显更紧" : delta <= -10 ? "比上一章明显放松(给读者呼吸口)" : "与上一章持平");
|
|
4549
|
-
}
|
|
4550
|
-
if (ref !== void 0) parts.push(`参考曲线(${TENSION_PRESET_LABELS[preset ?? "custom"]})在此处约为 ${ref}`);
|
|
4551
|
-
if (parts.length === 0) return "";
|
|
4552
|
-
return [
|
|
4553
|
-
"==================== 本章张力(写作时把握松紧) ====================",
|
|
4554
|
-
parts.join(";") + "。",
|
|
4555
|
-
"低张力段用于铺垫关系、交代信息、留呼吸;高张力段用于对抗、揭示、抉择与代价。",
|
|
4556
|
-
"同一章内可以有起伏,但整章的平均松紧要贴近目标值;不要连续多章都停在同一个紧张度。"
|
|
4557
|
-
].join("\n");
|
|
4490
|
+
function renderTimelineBlock(project, beforeChapter) {
|
|
4491
|
+
const all = sortTimeline(project.timeline ?? []).filter((e) => e.chapterNo < beforeChapter);
|
|
4492
|
+
if (all.length === 0) return "";
|
|
4493
|
+
const shown = all.slice(-12);
|
|
4494
|
+
const omitted = all.length - shown.length;
|
|
4495
|
+
return `${omitted > 0 ? `【故事时间线(共 ${all.length} 条,此处只列最近 ${shown.length} 条;更早 ${omitted} 条未列出)】` : `【故事时间线(共 ${shown.length} 条)】`}\n${shown.map((e) => {
|
|
4496
|
+
const parts = [`[第${e.chapterNo}章]`];
|
|
4497
|
+
if (e.time !== "") parts.push(e.time);
|
|
4498
|
+
if (e.place !== "") parts.push(`@${e.place}`);
|
|
4499
|
+
if (e.characters.length > 0) parts.push(`(${e.characters.join("、")})`);
|
|
4500
|
+
parts.push(e.event);
|
|
4501
|
+
return "- " + parts.join(" ");
|
|
4502
|
+
}).join("\n")}\n写作时必须与前文时间顺序、地点衔接一致;不得让时间倒流或让人物瞬移。`;
|
|
4558
4503
|
}
|
|
4559
4504
|
/**
|
|
4560
|
-
*
|
|
4505
|
+
* 规则初筛时间线矛盾(不调模型,瞬时计算)。
|
|
4561
4506
|
*
|
|
4562
|
-
*
|
|
4563
|
-
* 1)
|
|
4564
|
-
* 2)
|
|
4565
|
-
* 3)
|
|
4566
|
-
* 4) 长期低位(连续 ≥5 章 <40,容易拖沓)。
|
|
4507
|
+
* 覆盖三类最容易犯又最容易用规则抓到的错:
|
|
4508
|
+
* 1) 同一章内事件顺序号倒退;
|
|
4509
|
+
* 2) 全书顺序号在章节推进后倒退(时间倒流);
|
|
4510
|
+
* 3) 相邻两章之间地点瞬移(同一角色在未交代移动的情况下跨地点)。
|
|
4567
4511
|
*
|
|
4568
4512
|
* @param project 项目状态。
|
|
4569
4513
|
* @returns 问题清单(可能为空)。
|
|
4570
4514
|
*/
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
* 面板要能在每一章上给出「一键按建议修订」,合并层也要能把建议落到中间章。
|
|
4575
|
-
*/
|
|
4576
|
-
function runChapters(from, to) {
|
|
4577
|
-
const out = [];
|
|
4578
|
-
for (let no = from; no <= to; no++) out.push(no);
|
|
4579
|
-
return out;
|
|
4580
|
-
}
|
|
4581
|
-
function detectTensionIssues(project) {
|
|
4582
|
-
const values = [...project.chapters ?? []].sort((a, b) => a.no - b.no).map((c) => ({
|
|
4583
|
-
no: c.no,
|
|
4584
|
-
target: targetTension(c),
|
|
4585
|
-
actual: actualTension(c)
|
|
4586
|
-
}));
|
|
4587
|
-
if (values.filter((v) => v.target !== void 0 || v.actual !== void 0).length < 3) return [];
|
|
4588
|
-
const issues = [];
|
|
4589
|
-
for (const v of values) {
|
|
4590
|
-
if (v.target === void 0 || v.actual === void 0) continue;
|
|
4591
|
-
const gap = v.actual - v.target;
|
|
4592
|
-
if (Math.abs(gap) > 25) issues.push({
|
|
4593
|
-
severity: Math.abs(gap) > 40 ? "high" : "medium",
|
|
4594
|
-
chapters: [v.no],
|
|
4595
|
-
item: `第${v.no}章张力偏差较大:目标 ${v.target},实际 ${v.actual}(${gap > 0 ? "比目标更紧" : "比目标更松"})`,
|
|
4596
|
-
suggestion: gap > 0 ? "本章对抗/揭示密度偏高,考虑把部分冲突后移,留出呼吸段。" : "本章推进偏平,考虑加一处对抗、揭示或代价。"
|
|
4597
|
-
});
|
|
4598
|
-
}
|
|
4599
|
-
let run = 1;
|
|
4600
|
-
for (let i = 1; i < values.length; i++) {
|
|
4601
|
-
const a = values[i - 1].target ?? values[i - 1].actual;
|
|
4602
|
-
const b = values[i].target ?? values[i].actual;
|
|
4603
|
-
if (a !== void 0 && b !== void 0 && Math.abs(b - a) < 6) {
|
|
4604
|
-
run++;
|
|
4605
|
-
if (run === 4) {
|
|
4606
|
-
const from = values[i - run + 1].no;
|
|
4607
|
-
issues.push({
|
|
4608
|
-
severity: "medium",
|
|
4609
|
-
chapters: runChapters(from, values[i].no),
|
|
4610
|
-
item: `第${from}-${values[i].no}章张力几乎无变化(连续 ${run} 章)`,
|
|
4611
|
-
suggestion: "安排一次明显抬升或一次释放,让曲线有起伏,避免读者疲劳。"
|
|
4612
|
-
});
|
|
4613
|
-
}
|
|
4614
|
-
} else run = 1;
|
|
4615
|
-
}
|
|
4616
|
-
let highRun = 0;
|
|
4617
|
-
for (const v of values) {
|
|
4618
|
-
const val = v.target ?? v.actual;
|
|
4619
|
-
if (val !== void 0 && val >= 80) {
|
|
4620
|
-
highRun++;
|
|
4621
|
-
if (highRun === 3) issues.push({
|
|
4622
|
-
severity: "medium",
|
|
4623
|
-
chapters: runChapters(v.no - 2, v.no),
|
|
4624
|
-
item: `连续 ${highRun} 章处在高位(≥80),缺少呼吸口`,
|
|
4625
|
-
suggestion: "高潮之后补一章缓冲(关系推进、信息消化或日常),再进入下一轮对抗。"
|
|
4626
|
-
});
|
|
4627
|
-
} else highRun = 0;
|
|
4628
|
-
}
|
|
4629
|
-
let lowRun = 0;
|
|
4630
|
-
for (const v of values) {
|
|
4631
|
-
const val = v.target ?? v.actual;
|
|
4632
|
-
if (val !== void 0 && val < 40) {
|
|
4633
|
-
lowRun++;
|
|
4634
|
-
if (lowRun === 5) issues.push({
|
|
4635
|
-
severity: "high",
|
|
4636
|
-
chapters: runChapters(v.no - 4, v.no),
|
|
4637
|
-
item: `连续 ${lowRun} 章张力低于 40,推进可能偏慢`,
|
|
4638
|
-
suggestion: "提前兑现一个目标、抛出一个新风险,或让配角线产生冲撞。"
|
|
4639
|
-
});
|
|
4640
|
-
} else lowRun = 0;
|
|
4641
|
-
}
|
|
4642
|
-
return issues;
|
|
4643
|
-
}
|
|
4644
|
-
/**
|
|
4645
|
-
* 组装面板需要的曲线数据(章节号 / 目标 / 实际 / 参考)。
|
|
4646
|
-
*
|
|
4647
|
-
* @param project 项目状态。
|
|
4648
|
-
* @returns 逐章数据点与预设名。
|
|
4649
|
-
*/
|
|
4650
|
-
function buildCurveData(project) {
|
|
4651
|
-
const preset = project.tensionCurve?.preset ?? "escalation";
|
|
4652
|
-
const chapters = [...project.chapters ?? []].sort((a, b) => a.no - b.no);
|
|
4653
|
-
const custom = project.tensionCurve?.custom ?? [];
|
|
4654
|
-
const reference = preset === "custom" ? custom : referenceCurve(preset, chapters.length);
|
|
4655
|
-
return {
|
|
4656
|
-
preset,
|
|
4657
|
-
points: chapters.map((c, i) => {
|
|
4658
|
-
const point = {
|
|
4659
|
-
no: c.no,
|
|
4660
|
-
title: c.title
|
|
4661
|
-
};
|
|
4662
|
-
const t = targetTension(c);
|
|
4663
|
-
const a = actualTension(c);
|
|
4664
|
-
if (t !== void 0) point.target = t;
|
|
4665
|
-
if (a !== void 0) point.actual = a;
|
|
4666
|
-
if (reference[i] !== void 0) point.reference = reference[i];
|
|
4667
|
-
return point;
|
|
4668
|
-
})
|
|
4669
|
-
};
|
|
4670
|
-
}
|
|
4671
|
-
//#endregion
|
|
4672
|
-
//#region src/prompt-slots.ts
|
|
4673
|
-
/** 允许作者编辑的槽位(白名单;不在此列表的提示词一律不可改)。 */
|
|
4674
|
-
const PROMPT_SLOTS = [
|
|
4675
|
-
{
|
|
4676
|
-
id: "styleExtra",
|
|
4677
|
-
label: "文风补充",
|
|
4678
|
-
hint: "追加到写作提示词末尾的风格偏好(句式、叙述习惯、用词倾向)。",
|
|
4679
|
-
placeholder: "例如:多用短句和对话推进,少用成语;环境描写只写能影响动作的部分。",
|
|
4680
|
-
maxChars: 1200
|
|
4681
|
-
},
|
|
4682
|
-
{
|
|
4683
|
-
id: "antiAiExtra",
|
|
4684
|
-
label: "反 AI 补充",
|
|
4685
|
-
hint: "本书额外的\"别这么写\"清单,与内置反 AI 规则叠加(不能覆盖内置规则)。",
|
|
4686
|
-
placeholder: "例如:不要每段都用比喻;不要用\"空气中弥漫着\"这类句式。",
|
|
4687
|
-
maxChars: 1200
|
|
4688
|
-
},
|
|
4689
|
-
{
|
|
4690
|
-
id: "dialogueExtra",
|
|
4691
|
-
label: "对话补充",
|
|
4692
|
-
hint: "角色说话方式的额外约束,写入时会与角色卡一起注入。",
|
|
4693
|
-
placeholder: "例如:长辈说话带方言词;主角对熟人用绰号称呼。",
|
|
4694
|
-
maxChars: 800
|
|
4695
|
-
},
|
|
4696
|
-
{
|
|
4697
|
-
id: "endingHookExtra",
|
|
4698
|
-
label: "章末钩子偏好",
|
|
4699
|
-
hint: "对结尾钩子的额外要求(与章节计划里的 endingHook 叠加)。",
|
|
4700
|
-
placeholder: "例如:结尾尽量落在一个未闭合的选择上,而不是新出现的敌人。",
|
|
4701
|
-
maxChars: 600
|
|
4702
|
-
}
|
|
4703
|
-
];
|
|
4704
|
-
/** 槽位 id → 规格。 */
|
|
4705
|
-
const SLOT_BY_ID = new Map(PROMPT_SLOTS.map((s) => [s.id, s]));
|
|
4706
|
-
/**
|
|
4707
|
-
* 读取并规整项目里的槽位内容(去空白、按上限截断)。
|
|
4708
|
-
*
|
|
4709
|
-
* @param project 项目状态。
|
|
4710
|
-
* @returns 非空槽位列表(保持 PROMPT_SLOTS 的声明顺序)。
|
|
4711
|
-
*/
|
|
4712
|
-
function readPromptSlots(project) {
|
|
4713
|
-
const stored = project.promptSlots ?? {};
|
|
4714
|
-
const out = [];
|
|
4715
|
-
for (const spec of PROMPT_SLOTS) {
|
|
4716
|
-
const raw = (stored[spec.id] ?? "").trim();
|
|
4717
|
-
if (raw === "") continue;
|
|
4718
|
-
const truncated = raw.length > spec.maxChars;
|
|
4719
|
-
out.push({
|
|
4720
|
-
id: spec.id,
|
|
4721
|
-
label: spec.label,
|
|
4722
|
-
text: truncated ? raw.slice(0, spec.maxChars) : raw,
|
|
4723
|
-
truncated,
|
|
4724
|
-
chars: raw.length
|
|
4725
|
-
});
|
|
4726
|
-
}
|
|
4727
|
-
return out;
|
|
4728
|
-
}
|
|
4729
|
-
/**
|
|
4730
|
-
* 把槽位渲染成注入提示词的段落。
|
|
4731
|
-
*
|
|
4732
|
-
* @param project 项目状态。
|
|
4733
|
-
* @returns 提示词片段;无槽位时返回空串。
|
|
4734
|
-
*/
|
|
4735
|
-
function renderPromptSlots(project) {
|
|
4736
|
-
const slots = readPromptSlots(project);
|
|
4737
|
-
if (slots.length === 0) return "";
|
|
4738
|
-
const lines = ["==================== 作者偏好(本书自定义,优先级低于道藏/红线/合规) ===================="];
|
|
4739
|
-
for (const slot of slots) lines.push(`【${slot.label}】${slot.text}${slot.truncated ? `(已按 ${SLOT_BY_ID.get(slot.id)?.maxChars ?? 0} 字上限截断,原 ${slot.chars} 字)` : ""}`);
|
|
4740
|
-
lines.push("以上偏好只影响表达方式;与道藏、写作红线、内容合规红线冲突时,一律以后者为准。");
|
|
4741
|
-
return lines.join("\n");
|
|
4742
|
-
}
|
|
4743
|
-
/**
|
|
4744
|
-
* 校验并规整一次槽位写入。
|
|
4745
|
-
*
|
|
4746
|
-
* @param id 槽位 id。
|
|
4747
|
-
* @param value 内容(空串表示清空该槽位)。
|
|
4748
|
-
* @returns 归一化后的内容。
|
|
4749
|
-
* @throws 槽位 id 不在白名单时抛错。
|
|
4750
|
-
*/
|
|
4751
|
-
function normalizeSlotValue(id, value) {
|
|
4752
|
-
if (SLOT_BY_ID.get(id) === void 0) throw new Error(`未知提示词槽位:${id}`);
|
|
4753
|
-
return value.replace(/\r\n/g, "\n").trim();
|
|
4754
|
-
}
|
|
4755
|
-
//#endregion
|
|
4756
|
-
//#region src/timeline.ts
|
|
4757
|
-
/** 时间线矛盾初筛的最少事件数(太少没有意义)。 */
|
|
4758
|
-
const MIN_EVENTS_FOR_CHECK = 2;
|
|
4759
|
-
/** 归一化事件(清洗字符串、补 id/时间戳)。 */
|
|
4760
|
-
function normalizeTimelineEvent(raw, chapterNo, index, now = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
4761
|
-
const characters = Array.isArray(raw.characters) ? raw.characters.filter((c) => typeof c === "string" && c.trim() !== "") : [];
|
|
4762
|
-
return {
|
|
4763
|
-
id: typeof raw.id === "string" && raw.id !== "" ? raw.id : `tl-${chapterNo}-${index}-${Math.random().toString(36).slice(2, 8)}`,
|
|
4764
|
-
chapterNo: typeof raw.chapterNo === "number" ? raw.chapterNo : chapterNo,
|
|
4765
|
-
time: (raw.time ?? "").trim(),
|
|
4766
|
-
order: typeof raw.order === "number" && Number.isFinite(raw.order) ? raw.order : void 0,
|
|
4767
|
-
place: (raw.place ?? "").trim(),
|
|
4768
|
-
characters: characters.map((c) => c.trim()),
|
|
4769
|
-
event: (raw.event ?? "").trim(),
|
|
4770
|
-
source: raw.source === "manual" ? "manual" : "extracted",
|
|
4771
|
-
createdAt: raw.createdAt ?? now
|
|
4772
|
-
};
|
|
4773
|
-
}
|
|
4774
|
-
/** 时间线排序:先章节、再 order(缺省排在该章末尾)、最后 createdAt。 */
|
|
4775
|
-
function sortTimeline(events) {
|
|
4776
|
-
return [...events].sort((a, b) => {
|
|
4777
|
-
if (a.chapterNo !== b.chapterNo) return a.chapterNo - b.chapterNo;
|
|
4778
|
-
const ao = a.order ?? Number.MAX_SAFE_INTEGER;
|
|
4779
|
-
const bo = b.order ?? Number.MAX_SAFE_INTEGER;
|
|
4780
|
-
if (ao !== bo) return ao - bo;
|
|
4781
|
-
return a.createdAt.localeCompare(b.createdAt);
|
|
4782
|
-
});
|
|
4783
|
-
}
|
|
4784
|
-
/**
|
|
4785
|
-
* 渲染注入到「写作/规划」提示词的时间线锚点块。
|
|
4786
|
-
*
|
|
4787
|
-
* @param project 项目状态。
|
|
4788
|
-
* @param beforeChapter 只注入该章之前的锚点(含该章本身不注入,避免剧透)。
|
|
4789
|
-
* @returns 提示词片段(无事件时返回空串)。带显式截断声明。
|
|
4790
|
-
*/
|
|
4791
|
-
function renderTimelineBlock(project, beforeChapter) {
|
|
4792
|
-
const all = sortTimeline(project.timeline ?? []).filter((e) => e.chapterNo < beforeChapter);
|
|
4793
|
-
if (all.length === 0) return "";
|
|
4794
|
-
const shown = all.slice(-12);
|
|
4795
|
-
const omitted = all.length - shown.length;
|
|
4796
|
-
return `${omitted > 0 ? `【故事时间线(共 ${all.length} 条,此处只列最近 ${shown.length} 条;更早 ${omitted} 条未列出)】` : `【故事时间线(共 ${shown.length} 条)】`}\n${shown.map((e) => {
|
|
4797
|
-
const parts = [`[第${e.chapterNo}章]`];
|
|
4798
|
-
if (e.time !== "") parts.push(e.time);
|
|
4799
|
-
if (e.place !== "") parts.push(`@${e.place}`);
|
|
4800
|
-
if (e.characters.length > 0) parts.push(`(${e.characters.join("、")})`);
|
|
4801
|
-
parts.push(e.event);
|
|
4802
|
-
return "- " + parts.join(" ");
|
|
4803
|
-
}).join("\n")}\n写作时必须与前文时间顺序、地点衔接一致;不得让时间倒流或让人物瞬移。`;
|
|
4804
|
-
}
|
|
4805
|
-
/**
|
|
4806
|
-
* 规则初筛时间线矛盾(不调模型,瞬时计算)。
|
|
4807
|
-
*
|
|
4808
|
-
* 覆盖三类最容易犯又最容易用规则抓到的错:
|
|
4809
|
-
* 1) 同一章内事件顺序号倒退;
|
|
4810
|
-
* 2) 全书顺序号在章节推进后倒退(时间倒流);
|
|
4811
|
-
* 3) 相邻两章之间地点瞬移(同一角色在未交代移动的情况下跨地点)。
|
|
4812
|
-
*
|
|
4813
|
-
* @param project 项目状态。
|
|
4814
|
-
* @returns 问题清单(可能为空)。
|
|
4815
|
-
*/
|
|
4816
|
-
function detectTimelineIssues(project) {
|
|
4817
|
-
const stored = project.timeline ?? [];
|
|
4818
|
-
if (stored.length < MIN_EVENTS_FOR_CHECK) return [];
|
|
4515
|
+
function detectTimelineIssues(project) {
|
|
4516
|
+
const stored = project.timeline ?? [];
|
|
4517
|
+
if (stored.length < MIN_EVENTS_FOR_CHECK) return [];
|
|
4819
4518
|
const issues = [];
|
|
4820
4519
|
const byChapter = /* @__PURE__ */ new Map();
|
|
4821
4520
|
for (const e of stored) {
|
|
@@ -5031,26 +4730,6 @@ function pruneSnapshots(outputDir, keep = 20) {
|
|
|
5031
4730
|
return removed;
|
|
5032
4731
|
}
|
|
5033
4732
|
//#endregion
|
|
5034
|
-
//#region src/prompting.ts
|
|
5035
|
-
/** 章节写作约束:把写作骨架渲染成可附加到系统提示词末尾的必达/禁止/自查块。 */
|
|
5036
|
-
function renderChapterWriterSkeleton(meta) {
|
|
5037
|
-
const pov = meta.pov ?? "第三人称有限视角,严格跟随主角所见所知。";
|
|
5038
|
-
const tone = meta.tonePreference ?? "文风贴合本书设定,用具体细节与动作推进。";
|
|
5039
|
-
const hook = meta.endingHookPreference ?? "章末留一个明确的钩子(新信息、新风险或未闭合的选择)。";
|
|
5040
|
-
const antiAi = meta.antiAiRules ?? "";
|
|
5041
|
-
return [
|
|
5042
|
-
"==================== 章节生成骨架(必达 / 禁止 / 输出前自查) ====================",
|
|
5043
|
-
"【叙事视角】" + pov,
|
|
5044
|
-
"【本章必达】本章必须发生实质变化——局面、关系、信息、风险、决策至少动一项;开篇直接进入情境,不复述上一章。",
|
|
5045
|
-
"【禁止事项】不写以总结、复盘、解释为主的段落;不新增核心角色,不写入与上下文冲突的设定;不为凑字数注水。",
|
|
5046
|
-
"【篇幅】目标 " + meta.targetChars + " 字,区间 " + meta.minChars + "-" + meta.maxChars + " 字;不够就继续推进有效情节,不许草草收尾。",
|
|
5047
|
-
"【结尾】" + hook,
|
|
5048
|
-
"【表达】用具体动作、对话与可感知细节往前推;" + tone,
|
|
5049
|
-
"【反 AI】" + antiAi,
|
|
5050
|
-
"【输出前自查】逐项确认:读者的回报、关键转折、章末净变化是否看得见?上一章留下的钩子有没有交代?人物的硬事实有没有被写反?确认无误再输出,正文里不要出现核查过程。"
|
|
5051
|
-
].join("\n");
|
|
5052
|
-
}
|
|
5053
|
-
//#endregion
|
|
5054
4733
|
//#region src/action-guard.ts
|
|
5055
4734
|
/** 带分级的动作失败(executeAction 抛出,供 fail-stop 判定)。 */
|
|
5056
4735
|
var NovelActionError = class extends Error {
|
|
@@ -5280,30 +4959,1326 @@ function buildChapterContext(project, chapter, outputDir, options = {}) {
|
|
|
5280
4959
|
chapterTitle: chapter.title
|
|
5281
4960
|
};
|
|
5282
4961
|
}
|
|
5283
|
-
/**
|
|
5284
|
-
* 将上下文渲染为 prompt 文本块
|
|
5285
|
-
* 各阶段可按需选择注入哪些块。
|
|
5286
|
-
*/
|
|
5287
|
-
function renderContextBlocks(ctx) {
|
|
5288
|
-
const roleName = {
|
|
5289
|
-
protagonist: "主角",
|
|
5290
|
-
supporting: "配角",
|
|
5291
|
-
antagonist: "反派",
|
|
5292
|
-
other: "其他",
|
|
5293
|
-
female_lead: "女主",
|
|
5294
|
-
female_support: "女配",
|
|
5295
|
-
support: "配角",
|
|
5296
|
-
extra: "路人"
|
|
5297
|
-
};
|
|
5298
|
-
return {
|
|
5299
|
-
rolesBlock: ctx.relevantRoles.length > 0 ? "==================== 相关角色卡 ====================\n" + ctx.relevantRoles.map((r) => `- ${r.name}(${roleName[r.role] ?? r.role}):${r.traits.join("、")}${r.goals ? `;目标:${r.goals}` : ""}${r.knowledge ? `\n 已知信息:${r.knowledge.join(";")}(未列出的该角色不知道)` : ""}`).join("\n") : "",
|
|
5300
|
-
worldRulesBlock: ctx.relevantWorldRules.length > 0 ? "==================== 世界规则 ====================\n" + ctx.relevantWorldRules.map((r) => `- ${r}`).join("\n") : "",
|
|
5301
|
-
factsBlock: ctx.recentFacts.length > 0 || ctx.relatedFacts.length > 0 ? "==================== 事实库(最近 + 相关) ====================\n" + (ctx.recentFacts.length > 0 ? `【最近 ${ctx.recentFacts.length} 条】\n${ctx.recentFacts.join("\n")}` : "") + (ctx.relatedFacts.length > 0 ? `\n【相关旧事实 ${ctx.relatedFacts.length} 条】\n${ctx.relatedFacts.join("\n")}` : "") : "",
|
|
5302
|
-
plotlinesBlock: ctx.activePlotlines.length > 0 ? "==================== 活跃剧情线 ====================\n" + ctx.activePlotlines.map((l) => `- [${l.kind}${l.status === "paused" ? "·暂停" : ""}] ${l.name}:${l.goal}${l.progress ? `(进度:${l.progress})` : ""}`).join("\n") : "",
|
|
5303
|
-
foreshadowsBlock: ctx.activeForeshadows.length > 0 ? "==================== 活跃伏笔 ====================\n" + ctx.activeForeshadows.map((f) => `- [${f.status === "planted" ? "已埋设" : "推进中"}] ${f.description}${f.targetChapter ? `(预计第${f.targetChapter}章回收)` : ""}`).join("\n") : "",
|
|
5304
|
-
continuityBlock: ctx.prevChapterTail !== "" ? `==================== 上一章结尾(紧接此状态继续) ====================\n${ctx.prevChapterTail}` : "",
|
|
5305
|
-
redLinesBlock: ctx.bookRedLines.length > 0 ? "==================== 本书红线 ====================\n" + ctx.bookRedLines.map((r) => `- ${r}`).join("\n") : ""
|
|
5306
|
-
};
|
|
4962
|
+
/**
|
|
4963
|
+
* 将上下文渲染为 prompt 文本块
|
|
4964
|
+
* 各阶段可按需选择注入哪些块。
|
|
4965
|
+
*/
|
|
4966
|
+
function renderContextBlocks(ctx) {
|
|
4967
|
+
const roleName = {
|
|
4968
|
+
protagonist: "主角",
|
|
4969
|
+
supporting: "配角",
|
|
4970
|
+
antagonist: "反派",
|
|
4971
|
+
other: "其他",
|
|
4972
|
+
female_lead: "女主",
|
|
4973
|
+
female_support: "女配",
|
|
4974
|
+
support: "配角",
|
|
4975
|
+
extra: "路人"
|
|
4976
|
+
};
|
|
4977
|
+
return {
|
|
4978
|
+
rolesBlock: ctx.relevantRoles.length > 0 ? "==================== 相关角色卡 ====================\n" + ctx.relevantRoles.map((r) => `- ${r.name}(${roleName[r.role] ?? r.role}):${r.traits.join("、")}${r.goals ? `;目标:${r.goals}` : ""}${r.knowledge ? `\n 已知信息:${r.knowledge.join(";")}(未列出的该角色不知道)` : ""}`).join("\n") : "",
|
|
4979
|
+
worldRulesBlock: ctx.relevantWorldRules.length > 0 ? "==================== 世界规则 ====================\n" + ctx.relevantWorldRules.map((r) => `- ${r}`).join("\n") : "",
|
|
4980
|
+
factsBlock: ctx.recentFacts.length > 0 || ctx.relatedFacts.length > 0 ? "==================== 事实库(最近 + 相关) ====================\n" + (ctx.recentFacts.length > 0 ? `【最近 ${ctx.recentFacts.length} 条】\n${ctx.recentFacts.join("\n")}` : "") + (ctx.relatedFacts.length > 0 ? `\n【相关旧事实 ${ctx.relatedFacts.length} 条】\n${ctx.relatedFacts.join("\n")}` : "") : "",
|
|
4981
|
+
plotlinesBlock: ctx.activePlotlines.length > 0 ? "==================== 活跃剧情线 ====================\n" + ctx.activePlotlines.map((l) => `- [${l.kind}${l.status === "paused" ? "·暂停" : ""}] ${l.name}:${l.goal}${l.progress ? `(进度:${l.progress})` : ""}`).join("\n") : "",
|
|
4982
|
+
foreshadowsBlock: ctx.activeForeshadows.length > 0 ? "==================== 活跃伏笔 ====================\n" + ctx.activeForeshadows.map((f) => `- [${f.status === "planted" ? "已埋设" : "推进中"}] ${f.description}${f.targetChapter ? `(预计第${f.targetChapter}章回收)` : ""}`).join("\n") : "",
|
|
4983
|
+
continuityBlock: ctx.prevChapterTail !== "" ? `==================== 上一章结尾(紧接此状态继续) ====================\n${ctx.prevChapterTail}` : "",
|
|
4984
|
+
redLinesBlock: ctx.bookRedLines.length > 0 ? "==================== 本书红线 ====================\n" + ctx.bookRedLines.map((r) => `- ${r}`).join("\n") : ""
|
|
4985
|
+
};
|
|
4986
|
+
}
|
|
4987
|
+
//#endregion
|
|
4988
|
+
//#region src/prompts/bible.ts
|
|
4989
|
+
/** System prompt for story-bible extraction. */
|
|
4990
|
+
function bibleSystemPrompt() {
|
|
4991
|
+
return [
|
|
4992
|
+
"你是一位资深网文编辑兼设定架构师。你会收到一份小说大纲,请把它提炼成结构化的「道藏」,供后续写作时严格引用。",
|
|
4993
|
+
"要求:",
|
|
4994
|
+
"1. 忠于大纲,不自行发明大纲之外的设定。",
|
|
4995
|
+
"2. 角色卡覆盖大纲明确出现的角色(主角必含),每个角色给出性格标签、目标、关键关系。",
|
|
4996
|
+
"3. 世界规则覆盖力量体系、金手指机制、势力、地理等所有硬性规则,逐条列出。",
|
|
4997
|
+
"4. 红线列出大纲中明确禁止的内容(如无后宫、不圣母、无无脑碾压等)。",
|
|
4998
|
+
"5. 风格列出叙事基调、节奏、POV 等写作风格要点。",
|
|
4999
|
+
"6. 角色名必须用正文/编年录中的真实姓名;若大纲只写「主角」未点名,而已写章节或编年录中有名字,则用该真实姓名;禁止输出「主角(描述)」这类把身份塞进名字的占位名。",
|
|
5000
|
+
"输出必须是合法 JSON 对象,不要输出任何其他文字或 Markdown 代码块标记。",
|
|
5001
|
+
"重要:所有字符串值内部不得包含换行符(不要用多行字符串),JSON 必须在一段内完整结束。",
|
|
5002
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
|
|
5003
|
+
"JSON 结构:",
|
|
5004
|
+
"{\"genre\": \"题材与基调一句话\", \"worldRules\": [\"规则1\", \"规则2\", ...], \"characters\": [{\"name\": \"角色名\", \"role\": \"protagonist|supporting|antagonist|other\", \"traits\": [\"标签1\", ...], \"goals\": \"目标与动机\", \"relations\": \"关键关系\"}], \"redLines\": [\"红线1\", ...], \"style\": [\"风格1\", ...]}"
|
|
5005
|
+
].join("\n");
|
|
5006
|
+
}
|
|
5007
|
+
//#endregion
|
|
5008
|
+
//#region src/prompts/plan.ts
|
|
5009
|
+
/** System prompt for volume planning. */
|
|
5010
|
+
function volumeSystemPrompt() {
|
|
5011
|
+
return [
|
|
5012
|
+
"你是一位资深网文总编。你会收到一份小说大纲,请把全书划分为若干「卷」(分卷),每卷有明确的剧情定位与起止章节。",
|
|
5013
|
+
"要求:",
|
|
5014
|
+
"1. 大纲已有分卷时,严格遵循大纲的分卷结构;没有时按剧情弧线合理划分(3-8 卷)。",
|
|
5015
|
+
"2. 卷定位一句话说明该卷的剧情重心。",
|
|
5016
|
+
"3. chapterStart/chapterEnd 给出该卷覆盖的章节区间(从 1 开始连续编号)。",
|
|
5017
|
+
"输出必须是合法 JSON 数组,不要输出任何其他文字:",
|
|
5018
|
+
"[{\"no\": 1, \"title\": \"卷名\", \"summary\": \"卷定位与剧情重心\", \"chapterStart\": 1, \"chapterEnd\": 80}]",
|
|
5019
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
5020
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
|
|
5021
|
+
renderLengthContract([["卷名", STRUCTURED_FIELD_LIMITS.chapterTitle], ["每卷定位", STRUCTURED_FIELD_LIMITS.chapterSummary]])
|
|
5022
|
+
].join("\n");
|
|
5023
|
+
}
|
|
5024
|
+
/** The chapter-planning prompt template. */
|
|
5025
|
+
function planSystemPrompt(volumes) {
|
|
5026
|
+
return [
|
|
5027
|
+
"你是一位资深中文网文策划编辑,擅长把小说大纲拆解为可执行的章节计划。",
|
|
5028
|
+
"你会收到一份小说大纲。请根据大纲的设定、主线与节奏,规划出一份章节计划。",
|
|
5029
|
+
"要求:",
|
|
5030
|
+
"1. 每章必须有明确的核心剧情推进(不能只是过渡或凑字数)。",
|
|
5031
|
+
"2. 章节之间要衔接自然,前章结尾为后章埋下钩子。",
|
|
5032
|
+
"3. 严格遵循大纲的人设、金手指规则、战力体系与世界观设定,不得自行发明冲突设定。",
|
|
5033
|
+
"4. 输出必须是合法的 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记。",
|
|
5034
|
+
"5. 数组每个元素格式:{\"title\": \"章节标题(10字以内,有网文感)\", \"beats\": \"结构化剧情要点(150-250字,必须包含四段,段间用换行分隔):\\n本章目标:本章要完成的核心推进;\\n剧情要点:主要情节的起承转合(2-4 句);\\n爽点/钩子:本章的爽点兑现或情绪钩子;\\n结尾钩子:本章结尾为下一章埋下的悬念\"}",
|
|
5035
|
+
"6. 每个章节对象可额外包含以下可选字段(尽量给出,缺失则跳过):tension(0-100 整数:本章张力目标,对抗/揭示/抉择/代价的强度;铺垫与日常章明显偏低,与相邻章节形成起伏)、mustAdvance(数组,本章必须推进的局面/关系/信息/风险/决策变化);mustPreserve(数组,本章必须保持不破坏的项);characterHardFacts(数组,本章不可违背的人物硬事实:身份/阵营/境界/当前位置/知情度);endingHook(字符串,本章结尾钩子要求);obligation(字符串,本章义务合约一句话)。",
|
|
5036
|
+
"重要:beats 字段内部必须使用 \\n 转义表示换行(JSON 字符串内不得有真实换行符),其余字符串值也不得包含真实换行符,JSON 必须在一段内完整结束。",
|
|
5037
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
|
|
5038
|
+
volumes !== void 0 && volumes.length > 0 ? ["\n全书分卷结构(规划章节时需落在对应卷内):"].concat(volumes.map((v) => `第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})` + (v.strategy !== void 0 && v.strategy !== "" ? `\n 卷战略:${v.strategy}` : "") + (v.pacing !== void 0 && v.pacing !== "" ? `\n 卷节奏板:${v.pacing}` : ""))).join("\n") : ""
|
|
5039
|
+
].join("\n");
|
|
5040
|
+
}
|
|
5041
|
+
/**
|
|
5042
|
+
* 续写规划的附加要求(拼在 planSystemPrompt 之后)。
|
|
5043
|
+
*
|
|
5044
|
+
* 与 user 侧的「续写硬性要求」分工:这里管**标题与接续的唯一性**,
|
|
5045
|
+
* user 侧管「已发生事件不得重写」与全书进度感知。
|
|
5046
|
+
*/
|
|
5047
|
+
function planContinuationSuffix() {
|
|
5048
|
+
return "\n重要:本次是**续写规划**——已有章节的剧情不得重写或重复,新章节标题不得与已有章节标题相同,新章节的剧情必须从上一章结尾自然接续(人物状态、时间线、地点衔接一致)。";
|
|
5049
|
+
}
|
|
5050
|
+
/** 自动导演:整本编排/节奏/风险/再平衡建议(不输出正文)。 */
|
|
5051
|
+
function directorSystemPrompt() {
|
|
5052
|
+
return "你是一位长篇网文自动导演,负责整本编排、节奏、风险与再平衡。只给出可执行建议,不输出正文。";
|
|
5053
|
+
}
|
|
5054
|
+
//#endregion
|
|
5055
|
+
//#region src/prompt-cache.ts
|
|
5056
|
+
/**
|
|
5057
|
+
* 分层顺序。写死不开放配置——「哪层在前」是缓存语义决定的,不是产品偏好。
|
|
5058
|
+
*/
|
|
5059
|
+
const LAYER_ORDER = [
|
|
5060
|
+
"fixed",
|
|
5061
|
+
"book",
|
|
5062
|
+
"chapter"
|
|
5063
|
+
];
|
|
5064
|
+
/**
|
|
5065
|
+
* 按「固定 → 本书 → 本章」组装系统提示词。
|
|
5066
|
+
*
|
|
5067
|
+
* 同层内保持书写顺序(稳定排序);空白块丢弃,不在结果里留空段。
|
|
5068
|
+
* @param blocks 调用方按书写顺序给出的块(层标可以乱,组装后一定分层有序)。
|
|
5069
|
+
* @param separator 块间分隔符(默认换行)。
|
|
5070
|
+
*/
|
|
5071
|
+
function assemblePromptBlocks(blocks, separator = "\n") {
|
|
5072
|
+
const rank = (layer) => LAYER_ORDER.indexOf(layer);
|
|
5073
|
+
return blocks.map((block, index) => ({
|
|
5074
|
+
block,
|
|
5075
|
+
index
|
|
5076
|
+
})).filter((entry) => entry.block.text.trim() !== "").sort((a, b) => rank(a.block.layer) - rank(b.block.layer) || a.index - b.index).map((entry) => entry.block.text).join(separator);
|
|
5077
|
+
}
|
|
5078
|
+
//#endregion
|
|
5079
|
+
//#region src/prompt-slots.ts
|
|
5080
|
+
/** 允许作者编辑的槽位(白名单;不在此列表的提示词一律不可改)。 */
|
|
5081
|
+
const PROMPT_SLOTS = [
|
|
5082
|
+
{
|
|
5083
|
+
id: "styleExtra",
|
|
5084
|
+
label: "文风补充",
|
|
5085
|
+
hint: "追加到写作提示词末尾的风格偏好(句式、叙述习惯、用词倾向)。",
|
|
5086
|
+
placeholder: "例如:多用短句和对话推进,少用成语;环境描写只写能影响动作的部分。",
|
|
5087
|
+
maxChars: 1200
|
|
5088
|
+
},
|
|
5089
|
+
{
|
|
5090
|
+
id: "antiAiExtra",
|
|
5091
|
+
label: "反 AI 补充",
|
|
5092
|
+
hint: "本书额外的\"别这么写\"清单,与内置反 AI 规则叠加(不能覆盖内置规则)。",
|
|
5093
|
+
placeholder: "例如:不要每段都用比喻;不要用\"空气中弥漫着\"这类句式。",
|
|
5094
|
+
maxChars: 1200
|
|
5095
|
+
},
|
|
5096
|
+
{
|
|
5097
|
+
id: "dialogueExtra",
|
|
5098
|
+
label: "对话补充",
|
|
5099
|
+
hint: "角色说话方式的额外约束,写入时会与角色卡一起注入。",
|
|
5100
|
+
placeholder: "例如:长辈说话带方言词;主角对熟人用绰号称呼。",
|
|
5101
|
+
maxChars: 800
|
|
5102
|
+
},
|
|
5103
|
+
{
|
|
5104
|
+
id: "endingHookExtra",
|
|
5105
|
+
label: "章末钩子偏好",
|
|
5106
|
+
hint: "对结尾钩子的额外要求(与章节计划里的 endingHook 叠加)。",
|
|
5107
|
+
placeholder: "例如:结尾尽量落在一个未闭合的选择上,而不是新出现的敌人。",
|
|
5108
|
+
maxChars: 600
|
|
5109
|
+
}
|
|
5110
|
+
];
|
|
5111
|
+
/** 槽位 id → 规格。 */
|
|
5112
|
+
const SLOT_BY_ID = new Map(PROMPT_SLOTS.map((s) => [s.id, s]));
|
|
5113
|
+
/**
|
|
5114
|
+
* 读取并规整项目里的槽位内容(去空白、按上限截断)。
|
|
5115
|
+
*
|
|
5116
|
+
* @param project 项目状态。
|
|
5117
|
+
* @returns 非空槽位列表(保持 PROMPT_SLOTS 的声明顺序)。
|
|
5118
|
+
*/
|
|
5119
|
+
function readPromptSlots(project) {
|
|
5120
|
+
const stored = project.promptSlots ?? {};
|
|
5121
|
+
const out = [];
|
|
5122
|
+
for (const spec of PROMPT_SLOTS) {
|
|
5123
|
+
const raw = (stored[spec.id] ?? "").trim();
|
|
5124
|
+
if (raw === "") continue;
|
|
5125
|
+
const truncated = raw.length > spec.maxChars;
|
|
5126
|
+
out.push({
|
|
5127
|
+
id: spec.id,
|
|
5128
|
+
label: spec.label,
|
|
5129
|
+
text: truncated ? raw.slice(0, spec.maxChars) : raw,
|
|
5130
|
+
truncated,
|
|
5131
|
+
chars: raw.length
|
|
5132
|
+
});
|
|
5133
|
+
}
|
|
5134
|
+
return out;
|
|
5135
|
+
}
|
|
5136
|
+
/**
|
|
5137
|
+
* 把槽位渲染成注入提示词的段落。
|
|
5138
|
+
*
|
|
5139
|
+
* @param project 项目状态。
|
|
5140
|
+
* @returns 提示词片段;无槽位时返回空串。
|
|
5141
|
+
*/
|
|
5142
|
+
function renderPromptSlots(project) {
|
|
5143
|
+
const slots = readPromptSlots(project);
|
|
5144
|
+
if (slots.length === 0) return "";
|
|
5145
|
+
const lines = ["==================== 作者偏好(本书自定义,优先级低于道藏/红线/合规) ===================="];
|
|
5146
|
+
for (const slot of slots) lines.push(`【${slot.label}】${slot.text}${slot.truncated ? `(已按 ${SLOT_BY_ID.get(slot.id)?.maxChars ?? 0} 字上限截断,原 ${slot.chars} 字)` : ""}`);
|
|
5147
|
+
lines.push("以上偏好只影响表达方式;与道藏、写作红线、内容合规红线冲突时,一律以后者为准。");
|
|
5148
|
+
return lines.join("\n");
|
|
5149
|
+
}
|
|
5150
|
+
/**
|
|
5151
|
+
* 校验并规整一次槽位写入。
|
|
5152
|
+
*
|
|
5153
|
+
* @param id 槽位 id。
|
|
5154
|
+
* @param value 内容(空串表示清空该槽位)。
|
|
5155
|
+
* @returns 归一化后的内容。
|
|
5156
|
+
* @throws 槽位 id 不在白名单时抛错。
|
|
5157
|
+
*/
|
|
5158
|
+
function normalizeSlotValue(id, value) {
|
|
5159
|
+
if (SLOT_BY_ID.get(id) === void 0) throw new Error(`未知提示词槽位:${id}`);
|
|
5160
|
+
return value.replace(/\r\n/g, "\n").trim();
|
|
5161
|
+
}
|
|
5162
|
+
//#endregion
|
|
5163
|
+
//#region src/prompting.ts
|
|
5164
|
+
/** 章节写作约束:把写作骨架渲染成可附加到系统提示词末尾的必达/禁止/自查块。 */
|
|
5165
|
+
function renderChapterWriterSkeleton(meta) {
|
|
5166
|
+
const pov = meta.pov ?? "第三人称有限视角,严格跟随主角所见所知。";
|
|
5167
|
+
const tone = meta.tonePreference ?? "文风贴合本书设定,用具体细节与动作推进。";
|
|
5168
|
+
const hook = meta.endingHookPreference ?? "章末留一个明确的钩子(新信息、新风险或未闭合的选择)。";
|
|
5169
|
+
const antiAi = meta.antiAiRules ?? "";
|
|
5170
|
+
return [
|
|
5171
|
+
"==================== 章节生成骨架(必达 / 禁止 / 输出前自查) ====================",
|
|
5172
|
+
"【叙事视角】" + pov,
|
|
5173
|
+
"【本章必达】本章必须发生实质变化——局面、关系、信息、风险、决策至少动一项;开篇直接进入情境,不复述上一章。",
|
|
5174
|
+
"【禁止事项】不写以总结、复盘、解释为主的段落;不新增核心角色,不写入与上下文冲突的设定;不为凑字数注水。",
|
|
5175
|
+
"【篇幅】目标 " + meta.targetChars + " 字,区间 " + meta.minChars + "-" + meta.maxChars + " 字;不够就继续推进有效情节,不许草草收尾。",
|
|
5176
|
+
"【结尾】" + hook,
|
|
5177
|
+
"【表达】用具体动作、对话与可感知细节往前推;" + tone,
|
|
5178
|
+
"【反 AI】" + antiAi,
|
|
5179
|
+
"【输出前自查】逐项确认:读者的回报、关键转折、章末净变化是否看得见?上一章留下的钩子有没有交代?人物的硬事实有没有被写反?确认无误再输出,正文里不要出现核查过程。"
|
|
5180
|
+
].join("\n");
|
|
5181
|
+
}
|
|
5182
|
+
/**
|
|
5183
|
+
* 本项目设计的参考曲线形状(0-100 的采样序列)。
|
|
5184
|
+
* 只描述"形状",不含任何外部数值表。
|
|
5185
|
+
*/
|
|
5186
|
+
const REFERENCE_SHAPES = {
|
|
5187
|
+
escalation: [
|
|
5188
|
+
20,
|
|
5189
|
+
26,
|
|
5190
|
+
34,
|
|
5191
|
+
32,
|
|
5192
|
+
44,
|
|
5193
|
+
52,
|
|
5194
|
+
48,
|
|
5195
|
+
62,
|
|
5196
|
+
70,
|
|
5197
|
+
66,
|
|
5198
|
+
82,
|
|
5199
|
+
92
|
|
5200
|
+
],
|
|
5201
|
+
suspense: [
|
|
5202
|
+
30,
|
|
5203
|
+
42,
|
|
5204
|
+
38,
|
|
5205
|
+
50,
|
|
5206
|
+
46,
|
|
5207
|
+
58,
|
|
5208
|
+
62,
|
|
5209
|
+
56,
|
|
5210
|
+
70,
|
|
5211
|
+
78,
|
|
5212
|
+
86,
|
|
5213
|
+
74
|
|
5214
|
+
],
|
|
5215
|
+
wave: [
|
|
5216
|
+
45,
|
|
5217
|
+
30,
|
|
5218
|
+
55,
|
|
5219
|
+
34,
|
|
5220
|
+
62,
|
|
5221
|
+
40,
|
|
5222
|
+
70,
|
|
5223
|
+
46,
|
|
5224
|
+
78,
|
|
5225
|
+
52,
|
|
5226
|
+
84,
|
|
5227
|
+
60
|
|
5228
|
+
],
|
|
5229
|
+
frontLoad: [
|
|
5230
|
+
78,
|
|
5231
|
+
66,
|
|
5232
|
+
58,
|
|
5233
|
+
50,
|
|
5234
|
+
46,
|
|
5235
|
+
44,
|
|
5236
|
+
42,
|
|
5237
|
+
44,
|
|
5238
|
+
46,
|
|
5239
|
+
48,
|
|
5240
|
+
50,
|
|
5241
|
+
52
|
|
5242
|
+
],
|
|
5243
|
+
flat: [
|
|
5244
|
+
40,
|
|
5245
|
+
42,
|
|
5246
|
+
44,
|
|
5247
|
+
42,
|
|
5248
|
+
46,
|
|
5249
|
+
44,
|
|
5250
|
+
48,
|
|
5251
|
+
46,
|
|
5252
|
+
50,
|
|
5253
|
+
48,
|
|
5254
|
+
52,
|
|
5255
|
+
50
|
|
5256
|
+
]
|
|
5257
|
+
};
|
|
5258
|
+
/** 参考曲线的中文标签(面板展示)。 */
|
|
5259
|
+
const TENSION_PRESET_LABELS = {
|
|
5260
|
+
escalation: "递进爬升",
|
|
5261
|
+
suspense: "悬疑加压",
|
|
5262
|
+
wave: "波浪呼吸",
|
|
5263
|
+
frontLoad: "前紧后稳",
|
|
5264
|
+
flat: "平稳推进",
|
|
5265
|
+
custom: "自定义"
|
|
5266
|
+
};
|
|
5267
|
+
/** 全部可选预设(UI 下拉用)。 */
|
|
5268
|
+
const TENSION_PRESETS = [
|
|
5269
|
+
"escalation",
|
|
5270
|
+
"suspense",
|
|
5271
|
+
"wave",
|
|
5272
|
+
"frontLoad",
|
|
5273
|
+
"flat",
|
|
5274
|
+
"custom"
|
|
5275
|
+
];
|
|
5276
|
+
/** 把参考形状按章节数重采样(最近邻,保持形状不引入平滑失真)。 */
|
|
5277
|
+
function referenceCurve(preset, chapterCount) {
|
|
5278
|
+
if (chapterCount <= 0) return [];
|
|
5279
|
+
if (preset === "custom") return [];
|
|
5280
|
+
const shape = REFERENCE_SHAPES[preset];
|
|
5281
|
+
if (chapterCount === 1) return [shape[Math.floor(shape.length / 2)] ?? 50];
|
|
5282
|
+
return Array.from({ length: chapterCount }, (_, i) => {
|
|
5283
|
+
const pos = i / (chapterCount - 1) * (shape.length - 1);
|
|
5284
|
+
const lo = Math.floor(pos);
|
|
5285
|
+
const hi = Math.min(shape.length - 1, lo + 1);
|
|
5286
|
+
const t = pos - lo;
|
|
5287
|
+
const a = shape[lo] ?? 50;
|
|
5288
|
+
const b = shape[hi] ?? a;
|
|
5289
|
+
return Math.round(a + (b - a) * t);
|
|
5290
|
+
});
|
|
5291
|
+
}
|
|
5292
|
+
/** 读一章的目标张力(未设置时返回 undefined)。 */
|
|
5293
|
+
function targetTension(chapter) {
|
|
5294
|
+
return typeof chapter.tension === "number" ? clampTension(chapter.tension) : void 0;
|
|
5295
|
+
}
|
|
5296
|
+
/** 读一章的实际张力(审稿时模型打分,未设置时返回 undefined)。 */
|
|
5297
|
+
function actualTension(chapter) {
|
|
5298
|
+
const fromReview = chapter.review?.tension;
|
|
5299
|
+
if (typeof fromReview === "number") return clampTension(fromReview);
|
|
5300
|
+
return typeof chapter.tensionActual === "number" ? clampTension(chapter.tensionActual) : void 0;
|
|
5301
|
+
}
|
|
5302
|
+
/** 收敛到合法区间并取整。 */
|
|
5303
|
+
function clampTension(value) {
|
|
5304
|
+
if (!Number.isFinite(value)) return 0;
|
|
5305
|
+
return Math.max(0, Math.min(100, Math.round(value)));
|
|
5306
|
+
}
|
|
5307
|
+
/**
|
|
5308
|
+
* 渲染注入写作提示词的「本章张力」块。
|
|
5309
|
+
*
|
|
5310
|
+
* @param project 项目状态。
|
|
5311
|
+
* @param chapterNo 本章章号。
|
|
5312
|
+
* @returns 提示词片段;没有张力信息时返回空串。
|
|
5313
|
+
*/
|
|
5314
|
+
function renderTensionBlock(project, chapterNo) {
|
|
5315
|
+
const preset = project.tensionCurve?.preset;
|
|
5316
|
+
const chapters = [...project.chapters ?? []].sort((a, b) => a.no - b.no);
|
|
5317
|
+
const idx = chapters.findIndex((c) => c.no === chapterNo);
|
|
5318
|
+
if (idx === -1) return "";
|
|
5319
|
+
const target = targetTension(chapters[idx]);
|
|
5320
|
+
const prev = idx > 0 ? targetTension(chapters[idx - 1]) : void 0;
|
|
5321
|
+
if (target === void 0 && prev === void 0) return "";
|
|
5322
|
+
const ref = preset !== void 0 && preset !== "custom" ? referenceCurve(preset, chapters.length)[idx] : void 0;
|
|
5323
|
+
const parts = [];
|
|
5324
|
+
if (target !== void 0) parts.push(`本章目标张力 ${target}/100`);
|
|
5325
|
+
if (prev !== void 0 && target !== void 0) {
|
|
5326
|
+
const delta = target - prev;
|
|
5327
|
+
parts.push(delta >= 10 ? "比上一章明显更紧" : delta <= -10 ? "比上一章明显放松(给读者呼吸口)" : "与上一章持平");
|
|
5328
|
+
}
|
|
5329
|
+
if (ref !== void 0) parts.push(`参考曲线(${TENSION_PRESET_LABELS[preset ?? "custom"]})在此处约为 ${ref}`);
|
|
5330
|
+
if (parts.length === 0) return "";
|
|
5331
|
+
return [
|
|
5332
|
+
"==================== 本章张力(写作时把握松紧) ====================",
|
|
5333
|
+
parts.join(";") + "。",
|
|
5334
|
+
"低张力段用于铺垫关系、交代信息、留呼吸;高张力段用于对抗、揭示、抉择与代价。",
|
|
5335
|
+
"同一章内可以有起伏,但整章的平均松紧要贴近目标值;不要连续多章都停在同一个紧张度。"
|
|
5336
|
+
].join("\n");
|
|
5337
|
+
}
|
|
5338
|
+
/**
|
|
5339
|
+
* 规则核对张力曲线(不调模型)。
|
|
5340
|
+
*
|
|
5341
|
+
* 检查四类曲线级问题:
|
|
5342
|
+
* 1) 实际与目标偏差过大(>25);
|
|
5343
|
+
* 2) 连续多章几乎同值(≥4 章落差 <6,平台期无起伏);
|
|
5344
|
+
* 3) 高点之后没有回落(连续 ≥3 章处在高位 ≥80);
|
|
5345
|
+
* 4) 长期低位(连续 ≥5 章 <40,容易拖沓)。
|
|
5346
|
+
*
|
|
5347
|
+
* @param project 项目状态。
|
|
5348
|
+
* @returns 问题清单(可能为空)。
|
|
5349
|
+
*/
|
|
5350
|
+
/**
|
|
5351
|
+
* run 型问题(连续同值 / 连续高位 / 连续低位)的受影响章号。
|
|
5352
|
+
* 返回**完整区间**而不是首尾两点:中间章同样受这条曲线问题影响,
|
|
5353
|
+
* 面板要能在每一章上给出「一键按建议修订」,合并层也要能把建议落到中间章。
|
|
5354
|
+
*/
|
|
5355
|
+
function runChapters(from, to) {
|
|
5356
|
+
const out = [];
|
|
5357
|
+
for (let no = from; no <= to; no++) out.push(no);
|
|
5358
|
+
return out;
|
|
5359
|
+
}
|
|
5360
|
+
function detectTensionIssues(project) {
|
|
5361
|
+
const values = [...project.chapters ?? []].sort((a, b) => a.no - b.no).map((c) => ({
|
|
5362
|
+
no: c.no,
|
|
5363
|
+
target: targetTension(c),
|
|
5364
|
+
actual: actualTension(c)
|
|
5365
|
+
}));
|
|
5366
|
+
if (values.filter((v) => v.target !== void 0 || v.actual !== void 0).length < 3) return [];
|
|
5367
|
+
const issues = [];
|
|
5368
|
+
for (const v of values) {
|
|
5369
|
+
if (v.target === void 0 || v.actual === void 0) continue;
|
|
5370
|
+
const gap = v.actual - v.target;
|
|
5371
|
+
if (Math.abs(gap) > 25) issues.push({
|
|
5372
|
+
severity: Math.abs(gap) > 40 ? "high" : "medium",
|
|
5373
|
+
chapters: [v.no],
|
|
5374
|
+
item: `第${v.no}章张力偏差较大:目标 ${v.target},实际 ${v.actual}(${gap > 0 ? "比目标更紧" : "比目标更松"})`,
|
|
5375
|
+
suggestion: gap > 0 ? "本章对抗/揭示密度偏高,考虑把部分冲突后移,留出呼吸段。" : "本章推进偏平,考虑加一处对抗、揭示或代价。"
|
|
5376
|
+
});
|
|
5377
|
+
}
|
|
5378
|
+
let run = 1;
|
|
5379
|
+
for (let i = 1; i < values.length; i++) {
|
|
5380
|
+
const a = values[i - 1].target ?? values[i - 1].actual;
|
|
5381
|
+
const b = values[i].target ?? values[i].actual;
|
|
5382
|
+
if (a !== void 0 && b !== void 0 && Math.abs(b - a) < 6) {
|
|
5383
|
+
run++;
|
|
5384
|
+
if (run === 4) {
|
|
5385
|
+
const from = values[i - run + 1].no;
|
|
5386
|
+
issues.push({
|
|
5387
|
+
severity: "medium",
|
|
5388
|
+
chapters: runChapters(from, values[i].no),
|
|
5389
|
+
item: `第${from}-${values[i].no}章张力几乎无变化(连续 ${run} 章)`,
|
|
5390
|
+
suggestion: "安排一次明显抬升或一次释放,让曲线有起伏,避免读者疲劳。"
|
|
5391
|
+
});
|
|
5392
|
+
}
|
|
5393
|
+
} else run = 1;
|
|
5394
|
+
}
|
|
5395
|
+
let highRun = 0;
|
|
5396
|
+
for (const v of values) {
|
|
5397
|
+
const val = v.target ?? v.actual;
|
|
5398
|
+
if (val !== void 0 && val >= 80) {
|
|
5399
|
+
highRun++;
|
|
5400
|
+
if (highRun === 3) issues.push({
|
|
5401
|
+
severity: "medium",
|
|
5402
|
+
chapters: runChapters(v.no - 2, v.no),
|
|
5403
|
+
item: `连续 ${highRun} 章处在高位(≥80),缺少呼吸口`,
|
|
5404
|
+
suggestion: "高潮之后补一章缓冲(关系推进、信息消化或日常),再进入下一轮对抗。"
|
|
5405
|
+
});
|
|
5406
|
+
} else highRun = 0;
|
|
5407
|
+
}
|
|
5408
|
+
let lowRun = 0;
|
|
5409
|
+
for (const v of values) {
|
|
5410
|
+
const val = v.target ?? v.actual;
|
|
5411
|
+
if (val !== void 0 && val < 40) {
|
|
5412
|
+
lowRun++;
|
|
5413
|
+
if (lowRun === 5) issues.push({
|
|
5414
|
+
severity: "high",
|
|
5415
|
+
chapters: runChapters(v.no - 4, v.no),
|
|
5416
|
+
item: `连续 ${lowRun} 章张力低于 40,推进可能偏慢`,
|
|
5417
|
+
suggestion: "提前兑现一个目标、抛出一个新风险,或让配角线产生冲撞。"
|
|
5418
|
+
});
|
|
5419
|
+
} else lowRun = 0;
|
|
5420
|
+
}
|
|
5421
|
+
return issues;
|
|
5422
|
+
}
|
|
5423
|
+
/**
|
|
5424
|
+
* 组装面板需要的曲线数据(章节号 / 目标 / 实际 / 参考)。
|
|
5425
|
+
*
|
|
5426
|
+
* @param project 项目状态。
|
|
5427
|
+
* @returns 逐章数据点与预设名。
|
|
5428
|
+
*/
|
|
5429
|
+
function buildCurveData(project) {
|
|
5430
|
+
const preset = project.tensionCurve?.preset ?? "escalation";
|
|
5431
|
+
const chapters = [...project.chapters ?? []].sort((a, b) => a.no - b.no);
|
|
5432
|
+
const custom = project.tensionCurve?.custom ?? [];
|
|
5433
|
+
const reference = preset === "custom" ? custom : referenceCurve(preset, chapters.length);
|
|
5434
|
+
return {
|
|
5435
|
+
preset,
|
|
5436
|
+
points: chapters.map((c, i) => {
|
|
5437
|
+
const point = {
|
|
5438
|
+
no: c.no,
|
|
5439
|
+
title: c.title
|
|
5440
|
+
};
|
|
5441
|
+
const t = targetTension(c);
|
|
5442
|
+
const a = actualTension(c);
|
|
5443
|
+
if (t !== void 0) point.target = t;
|
|
5444
|
+
if (a !== void 0) point.actual = a;
|
|
5445
|
+
if (reference[i] !== void 0) point.reference = reference[i];
|
|
5446
|
+
return point;
|
|
5447
|
+
})
|
|
5448
|
+
};
|
|
5449
|
+
}
|
|
5450
|
+
//#endregion
|
|
5451
|
+
//#region src/prompts/common.ts
|
|
5452
|
+
/**
|
|
5453
|
+
* 提示词共享件:内容合规红线。
|
|
5454
|
+
*
|
|
5455
|
+
* 所有书籍、所有章节无条件生效,优先级高于单书大纲/道藏中的任何设定与作者
|
|
5456
|
+
* 自定义红线。原来住在 engine.ts 里,随二期拆分成域模块后迁到此处——
|
|
5457
|
+
* 各域模块(writing/review/polish…)都注入它,放共享件里避免循环导入。
|
|
5458
|
+
*/
|
|
5459
|
+
/** 内容合规红线(平台硬性要求)。注入点:章节生成 + 审稿 + 润色 + 局部修订系统提示(命中即 high)。 */
|
|
5460
|
+
const COMPLIANCE_REDLINES = [
|
|
5461
|
+
"1. 不得出现反对宪法所确定的基本原则的内容。",
|
|
5462
|
+
"2. 不得出现危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的内容。",
|
|
5463
|
+
"3. 不得出现危害国家荣誉和利益的内容。",
|
|
5464
|
+
"4. 不得出现煽动民族仇恨、民族歧视、破坏民族团结的内容。",
|
|
5465
|
+
"5. 不得出现破坏国家宗教政策、宣扬邪教和愚昧迷信的内容(不得以真实宗教、邪教或迷信活动为背景进行宣扬)。",
|
|
5466
|
+
"6. 不得出现散布谣言、扰乱社会秩序、破坏社会稳定的内容。",
|
|
5467
|
+
"7. 不得出现淫秽色情、赌博、暴力、凶杀、恐怖或教唆犯罪的内容(网文语境:禁止露骨性描写、血腥暴力渲染、赌博教唆、犯罪手法详细教学)。",
|
|
5468
|
+
"8. 不得出现侮辱或者诽谤他人、侵害他人合法权益的内容(不得以真实人物、组织为原型进行侮辱或影射攻击)。",
|
|
5469
|
+
"9. 不得出现法律法规禁止的其他内容。"
|
|
5470
|
+
];
|
|
5471
|
+
//#endregion
|
|
5472
|
+
//#region src/prompts/world.ts
|
|
5473
|
+
/** 把大世界结构化数据渲染成提示词块(境界体系按顺序强约束)。 */
|
|
5474
|
+
function renderWorld(world) {
|
|
5475
|
+
if (world === void 0) return "";
|
|
5476
|
+
const sections = ["==================== 大世界(结构化设定,写作时严格遵守) ===================="];
|
|
5477
|
+
if (world.realms.length > 0) {
|
|
5478
|
+
sections.push("境界体系(由低到高,不得随意跳级或自创境界):");
|
|
5479
|
+
world.realms.forEach((realm, i) => {
|
|
5480
|
+
sections.push(`${i + 1}. ${realm.name}${realm.description !== "" ? ` — ${realm.description}` : ""}`);
|
|
5481
|
+
});
|
|
5482
|
+
}
|
|
5483
|
+
if (world.regions.length > 0) {
|
|
5484
|
+
sections.push("地理区域:");
|
|
5485
|
+
for (const region of world.regions) sections.push(`- ${region.name}${region.description !== "" ? `:${region.description}` : ""}${region.faction !== void 0 && region.faction !== "" ? `(势力:${region.faction})` : ""}`);
|
|
5486
|
+
}
|
|
5487
|
+
if (world.factions.length > 0) {
|
|
5488
|
+
sections.push("势力分布:");
|
|
5489
|
+
for (const faction of world.factions) sections.push(`- ${faction.name}(${faction.kind})${faction.description !== "" ? `:${faction.description}` : ""}${faction.region !== void 0 && faction.region !== "" ? `(驻地:${faction.region})` : ""}`);
|
|
5490
|
+
}
|
|
5491
|
+
return sections.join("\n");
|
|
5492
|
+
}
|
|
5493
|
+
/** 大世界提炼:从大纲 + 道藏生成结构化境界体系/区域/势力。 */
|
|
5494
|
+
function extractWorldSystemPrompt() {
|
|
5495
|
+
return [
|
|
5496
|
+
"你是一位网文世界观架构师。请根据小说大纲与道藏,提炼结构化「大世界」数据。",
|
|
5497
|
+
"输出必须是合法 JSON 对象:",
|
|
5498
|
+
"{\"realms\": [{\"name\": \"境界名\", \"description\": \"突破条件/寿命/标志等\"}], \"regions\": [{\"name\": \"区域名\", \"description\": \"描述\", \"faction\": \"关联势力名或空\"}], \"factions\": [{\"name\": \"势力名\", \"kind\": \"宗门/家族/王朝/组织等\", \"description\": \"描述\", \"region\": \"驻地区域或空\"}]}",
|
|
5499
|
+
"要求:",
|
|
5500
|
+
"1. realms 按由低到高顺序排列(修仙题材必须含完整境界链;无境界设定的题材可输出空数组)。",
|
|
5501
|
+
"2. 数量贴合大纲:realms 3-12 个,regions 2-10 个,factions 2-10 个。",
|
|
5502
|
+
"3. 内容严格来自大纲与道藏,不要凭空发明与大纲冲突的设定。",
|
|
5503
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
5504
|
+
].join("\n");
|
|
5505
|
+
}
|
|
5506
|
+
//#endregion
|
|
5507
|
+
//#region src/prompts/writing.ts
|
|
5508
|
+
/** Build the writing system prompt (bible + outline + active foreshadows).
|
|
5509
|
+
* `targetChars` 来自每章计划(规划时快照,= 设置的每章目标字数);无则退回默认 3500。
|
|
5510
|
+
* 字数区间按目标动态生成(±15%,取整到百位),避免系统提示词与设置互相冲突。
|
|
5511
|
+
* `lengthRule` 可覆盖第 1 条字数要求(整章修订/改编时按原文长度为准)。 */
|
|
5512
|
+
function writeSystemPrompt(project, targetChars, lengthRule, beforeChapter) {
|
|
5513
|
+
const bible = project.bible;
|
|
5514
|
+
const blocks = [];
|
|
5515
|
+
const target = targetChars !== void 0 && targetChars > 0 ? targetChars : 3500;
|
|
5516
|
+
const lo = Math.max(1e3, Math.round(target * .85 / 100) * 100);
|
|
5517
|
+
const hi = Math.max(lo + 100, Math.round(target * 1.15 / 100) * 100);
|
|
5518
|
+
const rules = ["写作硬性要求:"];
|
|
5519
|
+
rules.push(lengthRule ?? `1. 每章 ${lo}-${hi} 字(目标 ${target} 字,按中文字符计),只输出章节正文,不要输出标题、章回名、作者的话或任何 Markdown 标记。`);
|
|
5520
|
+
rules.push("2. 以主角视角展开,动作、对话、心理描写交替推进,禁止大段设定说明。");
|
|
5521
|
+
rules.push("3. 尊重大纲与道藏:人设不崩、金手指规则不自相矛盾、战力不随意膨胀。");
|
|
5522
|
+
rules.push("4. 章末留一个钩子(悬念、反转或新线索),吸引读者读下一章。");
|
|
5523
|
+
rules.push("5. 语言流畅自然,符合中文网文语感,避免翻译腔与病句。");
|
|
5524
|
+
rules.push("6. 对话与冲突密度:每章至少 1 处实质对话或正面对抗/交锋场面;推理与心理活动必须用动作、环境细节、微表情、对话呈现,禁止整章纯内心独白铺陈(禁止\"解说式\"交代线索)。");
|
|
5525
|
+
rules.push("7. 反派与对手的行动力:本章出现的反派/对手必须有其行动、反制或压迫感(布局、试探、追索、交锋至少占其一),不得作为纯背景板存在。");
|
|
5526
|
+
rules.push("8. 配角辨识度:重要新登场配角应给姓名或可辨识的独有特征;禁止通篇用\"瘦高个/灰衣人/戴面具者\"等身形标签代称同一角色。");
|
|
5527
|
+
rules.push("9. 信息呈现方式:关键线索、设定、局势通过对话、动作、发现物呈现,禁止主角内心\"讲解\"给读者听。");
|
|
5528
|
+
blocks.push({
|
|
5529
|
+
layer: "fixed",
|
|
5530
|
+
text: rules.join("\n")
|
|
5531
|
+
});
|
|
5532
|
+
blocks.push({
|
|
5533
|
+
layer: "fixed",
|
|
5534
|
+
text: [
|
|
5535
|
+
"==================== 内容合规红线(平台硬性要求,最高优先级,违反即失败) ====================",
|
|
5536
|
+
COMPLIANCE_REDLINES.join("\n"),
|
|
5537
|
+
"以上九条为硬性底线,任何情况下不得以任何形式出现或影射;若剧情确需涉及(如批判、反讽),只能以明确否定、揭露、批判的立场呈现,且不得展开细节。"
|
|
5538
|
+
].join("\n")
|
|
5539
|
+
});
|
|
5540
|
+
const book = [];
|
|
5541
|
+
if (bible !== void 0) {
|
|
5542
|
+
book.push("==================== 道藏(写作时严格遵守) ====================");
|
|
5543
|
+
if (bible.genre !== "") book.push(`题材基调:${bible.genre}`);
|
|
5544
|
+
if (bible.worldRules.length > 0) book.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
|
|
5545
|
+
const roleLib = project.roles ?? [];
|
|
5546
|
+
const labelName = {
|
|
5547
|
+
protagonist: "主角",
|
|
5548
|
+
female_lead: "女主",
|
|
5549
|
+
female_support: "女配",
|
|
5550
|
+
support: "配角",
|
|
5551
|
+
antagonist: "反派",
|
|
5552
|
+
extra: "路人"
|
|
5553
|
+
};
|
|
5554
|
+
const seenRole = /* @__PURE__ */ new Set();
|
|
5555
|
+
const mergedRoles = [];
|
|
5556
|
+
for (const r of roleLib) {
|
|
5557
|
+
seenRole.add(r.name);
|
|
5558
|
+
const card = bible.characters.find((c) => c.name === r.name);
|
|
5559
|
+
const traits = card !== void 0 ? card.traits : Array.isArray(r.traits) ? r.traits : [];
|
|
5560
|
+
const goals = card !== void 0 && card.goals !== "" ? card.goals : r.goals;
|
|
5561
|
+
const relations = card !== void 0 && card.relations !== "" ? card.relations : Array.isArray(r.relations) && r.relations.length > 0 ? r.relations.join("、") : "";
|
|
5562
|
+
mergedRoles.push(`- ${r.name}(${labelName[r.roleLabel]}):${r.identity}${traits.length > 0 ? `;性格:${traits.join("、")}` : ""}${goals !== "" ? `;目标:${goals}` : ""}${relations !== "" ? `;关系:${relations}` : ""}`);
|
|
5563
|
+
if (card !== void 0 && Array.isArray(card.knowledge) && card.knowledge.length > 0) mergedRoles.push(` 已知信息(该角色知道的:${card.knowledge.join(";")};未列出的信息该角色一律不知道,不得写其知晓或提及)`);
|
|
5564
|
+
}
|
|
5565
|
+
for (const card of bible.characters) {
|
|
5566
|
+
if (seenRole.has(card.name)) continue;
|
|
5567
|
+
seenRole.add(card.name);
|
|
5568
|
+
const roleName = {
|
|
5569
|
+
protagonist: "主角",
|
|
5570
|
+
supporting: "配角",
|
|
5571
|
+
antagonist: "反派",
|
|
5572
|
+
other: "其他"
|
|
5573
|
+
}[card.role];
|
|
5574
|
+
mergedRoles.push(`- ${card.name}(${roleName}):${card.traits.join("、")}${card.goals !== "" ? `;目标:${card.goals}` : ""}${card.relations !== "" ? `;关系:${card.relations}` : ""}`);
|
|
5575
|
+
if (Array.isArray(card.knowledge) && card.knowledge.length > 0) mergedRoles.push(` 已知信息(该角色知道的:${card.knowledge.join(";")};未列出的信息该角色一律不知道,不得写其知晓或提及)`);
|
|
5576
|
+
}
|
|
5577
|
+
if (mergedRoles.length > 0) {
|
|
5578
|
+
book.push("角色卡(角色库与道藏已合并去重):");
|
|
5579
|
+
book.push(...mergedRoles);
|
|
5580
|
+
}
|
|
5581
|
+
if (bible.redLines.length > 0) book.push("写作红线(违反即失败):\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
|
|
5582
|
+
if (bible.style.length > 0) book.push("风格要求:\n" + bible.style.map((r) => `- ${r}`).join("\n"));
|
|
5583
|
+
}
|
|
5584
|
+
const worldBlock = renderWorld(project.world);
|
|
5585
|
+
if (worldBlock !== "") book.push(worldBlock);
|
|
5586
|
+
book.push("==================== 全书大纲 ====================");
|
|
5587
|
+
const outlineBlock = project.outline.length > 6e3 ? project.outline.slice(0, 6e3) + "\n…(大纲过长已节选,完整内容见总纲页)" : project.outline;
|
|
5588
|
+
book.push(outlineBlock);
|
|
5589
|
+
book.push("==================== 大纲结束 ====================");
|
|
5590
|
+
const assetsBlock = renderAllAssets(project.assets);
|
|
5591
|
+
if (assetsBlock !== "") book.push(assetsBlock);
|
|
5592
|
+
const slotBlock = renderPromptSlots(project);
|
|
5593
|
+
if (slotBlock !== "") book.push(slotBlock);
|
|
5594
|
+
blocks.push({
|
|
5595
|
+
layer: "book",
|
|
5596
|
+
text: book.join("\n")
|
|
5597
|
+
});
|
|
5598
|
+
const chapter = [];
|
|
5599
|
+
const active = project.foreshadows.filter((f) => f.status === "planted" || f.status === "progressing");
|
|
5600
|
+
if (active.length > 0) {
|
|
5601
|
+
chapter.push("==================== 活跃伏笔(近期需推进或回收的线索) ====================");
|
|
5602
|
+
for (const f of active) chapter.push(`- [${f.status === "planted" ? "已埋设" : "推进中"}] ${f.description}${f.targetChapter !== void 0 ? `(预计 ${f.targetChapter} 章回收)` : ""}`);
|
|
5603
|
+
}
|
|
5604
|
+
const lines = (project.plotlines ?? []).filter((l) => l.status === "active" || l.status === "paused");
|
|
5605
|
+
if (lines.length > 0) {
|
|
5606
|
+
const kindName = {
|
|
5607
|
+
main: "主线",
|
|
5608
|
+
branch: "支线",
|
|
5609
|
+
character: "人物线",
|
|
5610
|
+
mystery: "悬念线"
|
|
5611
|
+
};
|
|
5612
|
+
chapter.push("==================== 剧情线(本章应推进至少一条活跃线) ====================");
|
|
5613
|
+
for (const l of lines) chapter.push(`- [${kindName[l.kind]}${l.status === "paused" ? "·暂停中" : ""}] ${l.name}:${l.goal}${l.progress !== "" ? `(当前进度:${l.progress})` : ""}`);
|
|
5614
|
+
}
|
|
5615
|
+
chapter.push(renderChapterWriterSkeleton({
|
|
5616
|
+
targetChars: target,
|
|
5617
|
+
minChars: lo,
|
|
5618
|
+
maxChars: hi,
|
|
5619
|
+
pov: "第三人称有限视角,严格跟随主角所见所知。",
|
|
5620
|
+
endingHookPreference: "章末留一个明确钩子(新信息、新风险或未闭合选择)。",
|
|
5621
|
+
tonePreference: "动作、对话、心理交替推进;重大信息用对话/动作/发现呈现。",
|
|
5622
|
+
antiAiRules: "严格遵循上方「反 AI 规则」与写法资产,避免套话与 AI 腔。"
|
|
5623
|
+
}));
|
|
5624
|
+
const timelineBlock = renderTimelineBlock(project, beforeChapter ?? Number.MAX_SAFE_INTEGER);
|
|
5625
|
+
if (timelineBlock !== "") chapter.push(timelineBlock);
|
|
5626
|
+
if (beforeChapter !== void 0) {
|
|
5627
|
+
const tensionBlock = renderTensionBlock(project, beforeChapter);
|
|
5628
|
+
if (tensionBlock !== "") chapter.push(tensionBlock);
|
|
5629
|
+
}
|
|
5630
|
+
blocks.push({
|
|
5631
|
+
layer: "chapter",
|
|
5632
|
+
text: chapter.join("\n")
|
|
5633
|
+
});
|
|
5634
|
+
return assemblePromptBlocks(blocks);
|
|
5635
|
+
}
|
|
5636
|
+
/** Build the rewrite system prompt (fix review issues / instructions). */
|
|
5637
|
+
function rewriteSystemPrompt(project, targetChars, beforeChapter) {
|
|
5638
|
+
return writeSystemPrompt(project, targetChars, "1. 输出完整的新正文(不要只输出修改片段、标题、章回名、作者的话或任何 Markdown 标记),字数与原章相当(允许 ±20%)。", beforeChapter) + "\n\n额外要求:你正在【修订】一章已写好的正文。保留原文中好的部分,只修改需要修改的地方,输出完整的新正文(不要只输出修改片段),字数与原文相当。";
|
|
5639
|
+
}
|
|
5640
|
+
/**
|
|
5641
|
+
* 正文续写补齐的附加要求(拼在 writeSystemPrompt 之后)。
|
|
5642
|
+
*
|
|
5643
|
+
* 触发场景:生成的正文不足目标字数 90% 时,最多补两轮。
|
|
5644
|
+
*/
|
|
5645
|
+
function bodyContinuationSuffix() {
|
|
5646
|
+
return "\n\n【续写任务】严格承接已有正文往下写,不要重复已有内容、不要重写开头、不要总结前文,直接续写新情节,并在结尾留一个章末钩子。";
|
|
5647
|
+
}
|
|
5648
|
+
//#endregion
|
|
5649
|
+
//#region src/prompts/review.ts
|
|
5650
|
+
/** The review system prompt. */
|
|
5651
|
+
function reviewSystemPrompt(project) {
|
|
5652
|
+
const bible = project.bible;
|
|
5653
|
+
const blocks = [];
|
|
5654
|
+
const fixed = [
|
|
5655
|
+
"你是一位严格的网文审稿编辑。你会收到一章正文以及本书的道藏与红线。",
|
|
5656
|
+
"请从以下维度审查本章:",
|
|
5657
|
+
"1. 人设一致性:角色行为是否符合下方角色卡的设定(性格/目标/知情度/说话方式)。",
|
|
5658
|
+
"2. 设定一致性:金手指规则、战力体系、世界观是否与道藏冲突。",
|
|
5659
|
+
"3. 红线检查:是否触犯下方「本书红线」与「内容合规红线」。",
|
|
5660
|
+
"4. 文笔质量:语病、翻译腔、AI 套话(\"不禁\"\"仿佛\"\"一时间\"等高频词滥用)、流水账。",
|
|
5661
|
+
"5. 节奏与爽点:本章是否有推进、有钩子,是否拖沓灌水。",
|
|
5662
|
+
"6. 逻辑漏洞:前后矛盾、时间线错误、对话失真。",
|
|
5663
|
+
"7. 反 AI 规则:下方「本地 AI 味扫描」里已给出**本轮确定性命中的规则与次数**,直接以它为事实依据判级(禁止类命中即列为问题;鼓励类只作低优先级建议、不阻塞通过)。未列出的规则说明本章无确定性命中,不必逐条复述,也不要凭印象补报。",
|
|
5664
|
+
"8. 呈现方式:整章是否纯内心推理铺陈(无对话/无对抗,推理全靠解说);反派是否纯背景板无行动;重要配角是否无名标签化(瘦高个/灰衣人全程代称)——命中即列为问题。",
|
|
5665
|
+
"9. 内容合规(最高优先级):逐条核对下方「内容合规红线」,任何一条命中(含影射、暗示、详细描写)必须列为 high,并给出改写建议。",
|
|
5666
|
+
"输出必须是合法 JSON 对象,不要输出任何其他文字:",
|
|
5667
|
+
"{\"score\": 0-100的整数, \"riskScore\": 0-100的整数(越高越需人工处理,可结合本地AI味指数), \"tension\": 0-100的整数(本章张力强度:对抗/揭示/抉择/代价;铺垫与日常章应明显偏低), \"verdict\": \"一句话总评\", \"issues\": [{\"severity\": \"high|medium|low\", \"dimension\": \"character|setting|redline|writing|pacing|logic|anti-ai|presentation|compliance\", \"item\": \"问题描述\", \"suggestion\": \"修改建议\", \"ruleName\": \"命中的反AI规则名(见反AI规则清单)\", \"ruleType\": \"forbidden|risk|encourage\", \"category\": \"套话|句式|段落|心理|设定|节奏|对话|其他\", \"excerpt\": \"命中的原文摘录(不超过50字)\", \"reason\": \"判定理由\", \"canAutoRewrite\": true|false}]}",
|
|
5668
|
+
"tension 是本章张力强度评分(不是质量分):对决/揭秘/生死抉择偏高,铺垫章、日常章、过渡章偏低;同一本书内保持同一尺度。",
|
|
5669
|
+
"反 AI 类 issue 尽量给出 ruleName/ruleType/category/excerpt/reason/canAutoRewrite,便于统计与自动改写。",
|
|
5670
|
+
"风格符合度:若下方给出「风格指纹比对」,以它为准判断本章是否偏离绑定写法——偏差显著时按 writing 维度、category 用「风格」上报;比对里说没有显著偏差时,不要报风格问题。",
|
|
5671
|
+
"AI 套话高频模板词示例(集中出现必须整体降密度):仿佛、似乎、极其、完美、深不见底、形成了、莫名、无法形容、难以言喻、精心雕琢、肤光胜雪、眉目如画、歌舞升平、觥筹交错、妙语连珠、不可名状、另一层真相、命运、真相。",
|
|
5672
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
5673
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。",
|
|
5674
|
+
"==================== 内容合规红线(平台硬性要求,最高优先级) ====================",
|
|
5675
|
+
COMPLIANCE_REDLINES.join("\n"),
|
|
5676
|
+
"以上九条为硬性底线:正文中任何一条命中(含影射、暗示、详细展开)都必须列为 high,并给出改写建议;作者自定义红线不得豁免这九条。"
|
|
5677
|
+
];
|
|
5678
|
+
blocks.push({
|
|
5679
|
+
layer: "fixed",
|
|
5680
|
+
text: fixed.join("\n")
|
|
5681
|
+
});
|
|
5682
|
+
const book = [];
|
|
5683
|
+
const assetsBlock = renderAllAssets(project.assets);
|
|
5684
|
+
if (assetsBlock !== "") book.push("\n" + assetsBlock);
|
|
5685
|
+
if (bible !== void 0) {
|
|
5686
|
+
book.push("\n==================== 道藏 ====================");
|
|
5687
|
+
if (bible.worldRules.length > 0) book.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
|
|
5688
|
+
if (bible.characters.length > 0) {
|
|
5689
|
+
book.push("角色卡:");
|
|
5690
|
+
for (const card of bible.characters) {
|
|
5691
|
+
book.push(`- ${card.name}(${card.role}):${card.traits.join("、")}`);
|
|
5692
|
+
if (Array.isArray(card.knowledge) && card.knowledge.length > 0) book.push(` 该角色知道:${card.knowledge.join(";")}(未列出的信息该角色不知道)`);
|
|
5693
|
+
}
|
|
5694
|
+
}
|
|
5695
|
+
if (bible.redLines.length > 0) book.push("红线:\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
|
|
5696
|
+
}
|
|
5697
|
+
blocks.push({
|
|
5698
|
+
layer: "book",
|
|
5699
|
+
text: book.join("\n")
|
|
5700
|
+
});
|
|
5701
|
+
return assemblePromptBlocks(blocks);
|
|
5702
|
+
}
|
|
5703
|
+
/** 验证模式系统提示:修订后逐条核对原意见是否解决,只挑新增 high,不重复挑剔主观项。 */
|
|
5704
|
+
function verifySystemPrompt(project) {
|
|
5705
|
+
return [
|
|
5706
|
+
"你是一位网文审稿验证员。作者已按上一轮审稿意见修订了本章,你需要验证修订效果。",
|
|
5707
|
+
"你的任务(严格按此执行):",
|
|
5708
|
+
"1. 逐条核对「上一轮意见」中的每一条(按编号 1、2、3...)是否已在修订稿中解决。",
|
|
5709
|
+
"2. 只挑修订【新引入】的 high 级问题(设定矛盾/逻辑硬伤/事实错误)——新引入的 medium/low 主观项(文笔/套话/节奏)不要列。",
|
|
5710
|
+
"3. 禁止重复挑剔上一轮已指出且本次已解决的主观项(如\"缓缓/微微\"等套话、错别字)——即使换个说法再提也不行。",
|
|
5711
|
+
"4. 严禁为了显得专业而新增\"换一批毛病\"式的意见。",
|
|
5712
|
+
"输出必须是合法 JSON 对象,包含以下字段:",
|
|
5713
|
+
"- resolvedIds:已解决的上一轮意见编号数组(如 [1, 3, 5])。",
|
|
5714
|
+
"- unresolvedIds:未解决或部分解决的上一轮意见编号数组(如 [2, 4])。",
|
|
5715
|
+
"- issues:未解决的原意见 + 新引入的 high 级问题列表(格式同审稿:severity/item/suggestion)。未解决的原意见 item 需注明\"未解决(编号N):原意见摘要\"。",
|
|
5716
|
+
"- score:按修订稿整体质量给 50-90 分(解决全部 high 且无新增 high 时给 70 以上)。",
|
|
5717
|
+
"- verdict:一句话结论。",
|
|
5718
|
+
"完整格式:{\"resolvedIds\": [1,3], \"unresolvedIds\": [2], \"score\": 75, \"verdict\": \"一句话\", \"issues\": [{\"severity\": \"high\", \"dimension\": \"character|setting|redline|writing|pacing|logic|anti-ai|presentation|compliance\", \"item\": \"未解决(2):xxx\", \"suggestion\": \"xxx\"}]}",
|
|
5719
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
5720
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。",
|
|
5721
|
+
renderBibleForVerify(project)
|
|
5722
|
+
].join("\n");
|
|
5723
|
+
}
|
|
5724
|
+
/**
|
|
5725
|
+
* 验证模式下注入道藏。
|
|
5726
|
+
*
|
|
5727
|
+
* 原实现是 `JSON.stringify(project.bible).slice(0, 3000)`——有两个问题:
|
|
5728
|
+
* 1. **腰斩的 JSON 不是 JSON**:`slice` 从中间切断,审稿员会看到语法残缺的
|
|
5729
|
+
* 对象(字符串没闭合、数组没结尾),既浪费 token 又可能误读设定。
|
|
5730
|
+
* 2. **静默截断设定**:世界规则/角色卡排在后面的字段最先被切掉,而那恰恰是
|
|
5731
|
+
* 核对设定冲突最需要的部分。
|
|
5732
|
+
*
|
|
5733
|
+
* 改为结构化渲染 + 按「世界规则/角色/红线」分配独立预算。
|
|
5734
|
+
*/
|
|
5735
|
+
function renderBibleForVerify(project) {
|
|
5736
|
+
const bible = project.bible;
|
|
5737
|
+
if (bible === void 0) return "本书道藏(核对设定冲突用):\n(无)";
|
|
5738
|
+
const parts = [];
|
|
5739
|
+
if (bible.worldRules.length > 0) parts.push(`世界规则:\n${bible.worldRules.map((r) => `- ${r}`).join("\n")}`);
|
|
5740
|
+
if (bible.characters.length > 0) parts.push(`角色卡:\n${bible.characters.map((c) => {
|
|
5741
|
+
const goals = c.goals !== "" ? `;目标:${c.goals}` : "";
|
|
5742
|
+
const rel = c.relations !== "" ? `;关系:${c.relations}` : "";
|
|
5743
|
+
return `- ${c.name}:${c.traits.join("、")}${goals}${rel}`;
|
|
5744
|
+
}).join("\n")}`);
|
|
5745
|
+
if (bible.redLines.length > 0) parts.push(`红线:\n${bible.redLines.map((r) => `- ${r}`).join("\n")}`);
|
|
5746
|
+
if (parts.length === 0) return "本书道藏(核对设定冲突用):\n(无)";
|
|
5747
|
+
const block = parts.join("\n");
|
|
5748
|
+
const limit = 6e3;
|
|
5749
|
+
return `本书道藏(核对设定冲突用):\n${block.length > limit ? block.slice(0, limit) + `\n…(道藏过长已节选,但角色名/世界规则已尽量保留;如需完整设定请查总纲页)` : block}`;
|
|
5750
|
+
}
|
|
5751
|
+
/** Build the author-review system prompt (narrative structure, not prose). */
|
|
5752
|
+
function authorReviewSystemPrompt() {
|
|
5753
|
+
return [
|
|
5754
|
+
"你是一位网文作者复盘助手。你会收到:本章正文、上一章结尾(钩子)、上一章作者复盘(如有)、活跃剧情线与编年录近期事实。",
|
|
5755
|
+
"请从叙事结构层面复盘本章(不评文笔,那是审稿的事):",
|
|
5756
|
+
"1. hookHonored:上一章结尾的钩子/悬念是否在本章兑现或推进(true/false)。",
|
|
5757
|
+
"2. hookNote:钩子兑现情况一句话;未兑现时说明并给出\"建议在第几章补\"的建议。",
|
|
5758
|
+
"3. endingHook:本章结尾钩子强度,0-10 的整数(低于 6 说明结尾平淡,读者可能不想看下一章)。",
|
|
5759
|
+
"4. plotlineProgress:本章推进了哪条剧情线(主线/支线名),或\"无实质推进\"(连续无推进要提醒)。",
|
|
5760
|
+
"5. advancedLines:本章实际推进的剧情线名称数组——从「活跃剧情线」清单中选出推进了的线(名称必须与清单中的线名一字不差;没推进任何线则输出空数组)。",
|
|
5761
|
+
"6. continuity:与上一章结尾的衔接检查(人物位置/时间/伤势/资源/对话状态),发现问题要指出。",
|
|
5762
|
+
"7. trend:结合上一章复盘看近期节奏趋势(是否连续拖沓、爽点密度是否下降、是否需要调整)。",
|
|
5763
|
+
"8. stateChanges:本章发生的关键状态变化数组(人物状态/世界局面/关系/资源,每条约 20 字,最多 6 条),供整本与事实库回灌。",
|
|
5764
|
+
"9. newConflicts:本章新引入或明显升级的冲突数组(最多 4 条)。",
|
|
5765
|
+
"10. clues:本章埋下或推进的新线索/伏笔数组(最多 4 条)。",
|
|
5766
|
+
"11. absentRisks:本章缺席但按卷级职责应出场/该推进关系的重要角色,及其缺席带来的风险(最多 4 条)。",
|
|
5767
|
+
"输出必须是合法 JSON 对象,不要输出任何其他文字:",
|
|
5768
|
+
"{\"hookHonored\": true或false, \"hookNote\": \"一句话\", \"endingHook\": 0-10整数, \"plotlineProgress\": \"一句话\", \"advancedLines\": [\"线名\"], \"continuity\": \"一句话\", \"trend\": \"一句话\", \"stateChanges\": [\"状态变化\"], \"newConflicts\": [\"新冲突\"], \"clues\": [\"新线索\"], \"absentRisks\": [\"本章缺席但值得注意的角色及风险(卷级职责/缺席风险,最多4条)\"]}",
|
|
5769
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
5770
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
5771
|
+
].join("\n");
|
|
5772
|
+
}
|
|
5773
|
+
//#endregion
|
|
5774
|
+
//#region src/prompts/polish.ts
|
|
5775
|
+
/** The de-AI-ify polish system prompt (with project writing assets injected). */
|
|
5776
|
+
function polishSystemPrompt(project) {
|
|
5777
|
+
const assetsBlock = renderAllAssets(project.assets);
|
|
5778
|
+
const bible = project.bible;
|
|
5779
|
+
const blocks = [];
|
|
5780
|
+
const fixed = [
|
|
5781
|
+
"你是一位中文网文润色师。你会收到一章正文,请做「去 AI 味」润色:",
|
|
5782
|
+
"1. 删除/替换 AI 高频套话与模式词:如\"不禁\"\"仿佛\"\"一时间\"\"不由得\"\"顿时\"\"然而\"\"缓缓\"\"轻轻\"\"微微\"\"默默\"\"似乎\"\"终于\"等滥用。",
|
|
5783
|
+
"2. 把书面翻译腔改成口语化的中文网文语感。",
|
|
5784
|
+
"3. 拆分过长的排比句与堆砌的修饰语。",
|
|
5785
|
+
"4. 保留全部情节、人物、对话内容、已确立事实不变,只改表达。",
|
|
5786
|
+
"5. 输出完整的新正文,不要输出任何说明文字或 Markdown 标记。",
|
|
5787
|
+
"6. 必须遵守下方「反 AI 规则」与「写法资产」的表达边界;写法资产要求保留的风格特征(句式、台词、节奏)不得在润色中丢失。",
|
|
5788
|
+
"7. 必须遵守下方「内容合规红线」与「本书红线」(如有),任何一条命中(含影射、暗示)都必须避免。",
|
|
5789
|
+
"内容合规红线(平台硬性要求,最高优先级):",
|
|
5790
|
+
COMPLIANCE_REDLINES.join("\n")
|
|
5791
|
+
];
|
|
5792
|
+
blocks.push({
|
|
5793
|
+
layer: "fixed",
|
|
5794
|
+
text: fixed.join("\n")
|
|
5795
|
+
});
|
|
5796
|
+
const book = [];
|
|
5797
|
+
if (bible !== void 0 && bible.redLines.length > 0) book.push("本书红线:\n" + bible.redLines.map((r) => "- " + r).join("\n"));
|
|
5798
|
+
if (assetsBlock !== "") book.push(assetsBlock);
|
|
5799
|
+
blocks.push({
|
|
5800
|
+
layer: "book",
|
|
5801
|
+
text: book.join("\n")
|
|
5802
|
+
});
|
|
5803
|
+
return assemblePromptBlocks(blocks);
|
|
5804
|
+
}
|
|
5805
|
+
/**
|
|
5806
|
+
* 局部修订(只改一个自然段)的系统提示。
|
|
5807
|
+
*
|
|
5808
|
+
* 与整章修订的区别:不套用字数区间、不注入全书资料(大纲/资产/时间线),
|
|
5809
|
+
* 只带合规红线、本书红线、反 AI 套话口径与角色卡——局部改写要的是「表达
|
|
5810
|
+
* 卫生」,不是重新生成。同样按分层组装(约束口径排最前)。
|
|
5811
|
+
*/
|
|
5812
|
+
function localRevisionSystemPrompt(project) {
|
|
5813
|
+
const bible = project.bible;
|
|
5814
|
+
const blocks = [];
|
|
5815
|
+
blocks.push({
|
|
5816
|
+
layer: "fixed",
|
|
5817
|
+
text: [
|
|
5818
|
+
"你是一位中文网文润色师。你会收到一章中的一个段落,请按修改要求重写该段。",
|
|
5819
|
+
"硬性约束:",
|
|
5820
|
+
"1. 只改表达,不改情节走向、人物设定、已确立事实、对话核心内容。",
|
|
5821
|
+
"2. 必须遵守下方「内容合规红线」,任何一条命中(含影射、暗示)都必须避免。",
|
|
5822
|
+
"3. 必须遵守下方「本书红线」(如有)。",
|
|
5823
|
+
"4. 避免 AI 套话:不禁、仿佛、一时间、不由得、顿时、然而、缓缓、轻轻、微微、默默、似乎、终于等滥用。",
|
|
5824
|
+
"5. 保留角色口吻与性格,角色行为需符合下方角色卡(如有)。",
|
|
5825
|
+
"6. 只输出修改后的【这一个段落】的完整新文本,不要输出任何说明、标题或 Markdown 标记。",
|
|
5826
|
+
"内容合规红线(平台硬性要求,最高优先级):",
|
|
5827
|
+
COMPLIANCE_REDLINES.join("\n")
|
|
5828
|
+
].join("\n")
|
|
5829
|
+
});
|
|
5830
|
+
const book = [];
|
|
5831
|
+
if (bible !== void 0) {
|
|
5832
|
+
if (bible.redLines.length > 0) book.push("本书红线:\n" + bible.redLines.map((r) => "- " + r).join("\n"));
|
|
5833
|
+
if (bible.characters.length > 0) {
|
|
5834
|
+
book.push("相关角色卡:");
|
|
5835
|
+
for (const card of bible.characters) book.push("- " + card.name + "(" + card.role + "):" + card.traits.join("、"));
|
|
5836
|
+
}
|
|
5837
|
+
}
|
|
5838
|
+
blocks.push({
|
|
5839
|
+
layer: "book",
|
|
5840
|
+
text: book.join("\n")
|
|
5841
|
+
});
|
|
5842
|
+
return assemblePromptBlocks(blocks);
|
|
5843
|
+
}
|
|
5844
|
+
//#endregion
|
|
5845
|
+
//#region src/prompts/characters.ts
|
|
5846
|
+
/** AI 从全书提炼角色库:大纲 + 道藏 + 编年录 + 章节摘要 → 结构化角色清单。 */
|
|
5847
|
+
function extractRolesSystemPrompt() {
|
|
5848
|
+
return [
|
|
5849
|
+
"你是一位网文角色库管理员。请根据本书的大纲、设定、编年录与章节摘要,提炼完整的角色库。",
|
|
5850
|
+
"覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如\"矮胖姑娘\")用其身份简称收录并标注;反复出现且有剧情作用的身份型角色(站长、律师、警察、法官、店主等)必须收录。",
|
|
5851
|
+
"数量控制:最多输出 20 个角色;覆盖优先——主角、主要反派、重要配角(女主/关键配角)必须全收,所有有名有姓者必收;只有真正一次性路人(无名字、无剧情作用)才可省略。",
|
|
5852
|
+
"重要:正常一部完整故事应提炼 8-20 个角色;若少于 6 个通常说明漏提炼,必须重新核对正文摘录。",
|
|
5853
|
+
"重要:正文中若有明确的主角与主要反派,必须分别以 protagonist / antagonist 收录,禁止遗漏;反派确实未出场时才可省略。",
|
|
5854
|
+
"输出优先级:主角(protagonist)与主要反派(antagonist)必须优先输出并完整刻画,其次女主/重要配角;判断不出名字时用正文中的身份称呼。",
|
|
5855
|
+
"每个角色输出:",
|
|
5856
|
+
"1. name:角色名(或身份简称)。主角/有名配角必须用正文中实际出现的人名(如「沈放」),禁止用「主角(38岁超市理货员)」这类把身份塞进名字的占位名;正文确实没点名时才可用身份简称(如「富商」「灰衣老人」)。",
|
|
5857
|
+
"2. roleLabel:定位——protagonist=主角;female_lead=女主(唯一知己/感情线核心,无后宫前提下只此一位);female_support=重要女配;support=普通配角;antagonist=反派;extra=路人/背景。",
|
|
5858
|
+
"3. identity:身份一句话(宗门/势力/血脉/职业)。",
|
|
5859
|
+
"4. traits:3-6 个性格标签。",
|
|
5860
|
+
"5. goals:目标与动机一句话。",
|
|
5861
|
+
"6. relations:关系网数组,格式[\"角色名(关系)\", ...]。",
|
|
5862
|
+
"7. arc:成长线数组,格式[\"阶段:说明\", ...](如\"出场:祭品身份\"/\"转折:祭祀被中断脱身\")。",
|
|
5863
|
+
"8. knowledge:该角色已经知道的关键信息(3-8 条),不知道的信息不要写进去。",
|
|
5864
|
+
"精简要求:identity 控制在 30 字内;traits 3-6 个短标签;goals 60 字内;relations 2-5 条;arc 2-4 条;knowledge 每条 40 字内。整体输出量要紧凑,避免冗长。",
|
|
5865
|
+
"重要:用户消息里列出的「已收录角色」绝不要再次输出——这些角色已经在角色库里,跳过它们,只提炼未收录的。",
|
|
5866
|
+
"输出必须是合法 JSON 数组,不要输出其他文字:[{\"name\":\"...\", \"roleLabel\":\"...\", \"identity\":\"...\", \"traits\":[...], \"goals\":\"...\", \"relations\":[...], \"arc\":[...], \"knowledge\":[...]}]",
|
|
5867
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
5868
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。"
|
|
5869
|
+
].join("\n");
|
|
5870
|
+
}
|
|
5871
|
+
/**
|
|
5872
|
+
* 角色补漏后缀(拼在 extractRolesSystemPrompt 之后)。
|
|
5873
|
+
*
|
|
5874
|
+
* 第一轮偶发漏掉「身份型/功能性角色」(站长/律师/警察等反复出现者),
|
|
5875
|
+
* 用这一轮专项补捞;没有遗漏就输出 []。
|
|
5876
|
+
*/
|
|
5877
|
+
function roleCatchUpSuffix(names) {
|
|
5878
|
+
return "\n上一次已提炼角色:" + names + "。\n现在只输出「遗漏的角色」JSON 数组:检查正文摘录中反复出现、有固定身份称呼(如站长、律师、警察、法官、店主、老师)且对剧情有作用的角色;一次性路人不要输出。没有遗漏就输出 []。字段与上面相同。";
|
|
5879
|
+
}
|
|
5880
|
+
/** 角色当前状态聚合:角色名单 + 已确立事实库 → 每角色一句话状态。 */
|
|
5881
|
+
function roleStatusSystemPrompt() {
|
|
5882
|
+
return [
|
|
5883
|
+
"你是一位网文角色档案管理员。请根据「角色名单」与「已确立事实库」,为每个角色输出「当前状态」一句话(境界/修为/伤势/资源/心境)。",
|
|
5884
|
+
"输出必须是合法 JSON 数组,格式:[{\"name\": \"角色名\", \"status\": \"当前状态一句话\"}]",
|
|
5885
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
5886
|
+
].join("\n");
|
|
5887
|
+
}
|
|
5888
|
+
//#endregion
|
|
5889
|
+
//#region src/prompts/plotlines.ts
|
|
5890
|
+
/** 建议剧情线:基于大纲/卷计划/已写章节/编年录,提炼候选线。 */
|
|
5891
|
+
function suggestPlotlinesSystemPrompt() {
|
|
5892
|
+
return [
|
|
5893
|
+
"你是一位网文剧情架构师。根据本书的大纲、卷计划、已写章节标题与编年录,为作者提炼建议的剧情线(主线/支线/人物线/悬念线)。",
|
|
5894
|
+
"每条线要:名称简洁有力;目标写清楚这条线最终要完成什么;progress 写当前推进到哪(没有就空字符串)。",
|
|
5895
|
+
"建议 4-8 条,覆盖:1 条主线、1-2 条人物线、1-2 条悬念线、1-3 条支线。避免与大纲明显重复的废话线。",
|
|
5896
|
+
"输出必须是合法 JSON 数组,格式:[{\"name\": \"线名\", \"kind\": \"main|branch|character|mystery\", \"goal\": \"目标\", \"progress\": \"\"}]",
|
|
5897
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
5898
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。"
|
|
5899
|
+
].join("\n");
|
|
5900
|
+
}
|
|
5901
|
+
/**
|
|
5902
|
+
* 剧情线进度刷新(批量)。
|
|
5903
|
+
*
|
|
5904
|
+
* @param count - 本次要判断的线数。单条时问单线进度,多条时要求逐条判断、
|
|
5905
|
+
* name 与输入逐字相同(用于对齐回写,见 plotline-align.ts)。
|
|
5906
|
+
*/
|
|
5907
|
+
function refreshPlotlinesSystemPrompt(count) {
|
|
5908
|
+
return [
|
|
5909
|
+
count === 1 ? "你是一位网文剧情线管理员。请根据「剧情线信息」与「本书已写章节摘要/编年录」,判断这条线目前推进到了哪一步。" : `你是一位网文剧情线管理员。下面给了 ${count} 条剧情线,请**逐条**判断各自推进到了哪一步。`,
|
|
5910
|
+
"每条线输出一句话(30-60 字):该线当前的状态、最近一次推进发生在第几章、下一步可能的方向。还没开始推进的线,明确说\"尚未推进\"。",
|
|
5911
|
+
count === 1 ? "输出必须是合法 JSON 对象:{\"progress\": \"一句话\"}" : `输出必须是合法 JSON 对象:{"items": [{"name": "线名(与输入完全一致)", "progress": "一句话"}]},items 的条数必须等于 ${count},name 必须与输入里的线名逐字相同(用于对齐)`,
|
|
5912
|
+
"重要:不要输出任何其他文字。"
|
|
5913
|
+
].join("\n");
|
|
5914
|
+
}
|
|
5915
|
+
/** 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
|
|
5916
|
+
function plotlineHealthSystemPrompt() {
|
|
5917
|
+
return [
|
|
5918
|
+
"你是一位网文剧情架构师。请对本书的「剧情线体系」做健康检查,判断当前是否需要新增剧情线、应在多少章后添加。",
|
|
5919
|
+
"评估维度:各线最近推进到第几章(已写章节与关联章节的差值越大越危险)、各线状态、已写章节总数、卷计划当前进度、编年录近期事实。",
|
|
5920
|
+
"输出规则:",
|
|
5921
|
+
"1. verdict:一句话结论——\"需要新增线\" / \"暂不需要\" / \"再写 N 章后需要\"(N 给出具体章数)。",
|
|
5922
|
+
"2. timing:说明建议添加的时机(如:第 25 章前引入新支线,因为主线预计第 22 章告一段落)。",
|
|
5923
|
+
"3. reasons:3-5 条依据(引用具体数据:哪条线多少章没推进、已写章节数、卷进度等)。",
|
|
5924
|
+
"4. lines:对每条线给健康度——ok(近期推进过)/ warning(超过 5 章未推进)/ stale(超过 10 章未推进或悬置过久)。",
|
|
5925
|
+
"输出必须是合法 JSON 对象:{\"verdict\": \"...\", \"timing\": \"...\", \"reasons\": [\"...\"], \"lines\": [{\"name\": \"线名\", \"health\": \"ok|warning|stale\", \"note\": \"一句说明\"}]}",
|
|
5926
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
5927
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。"
|
|
5928
|
+
].join("\n");
|
|
5929
|
+
}
|
|
5930
|
+
/** 剧情方案:基于健康检查结果设计下一阶段方向与建议新线。 */
|
|
5931
|
+
function plotlinePlanSystemPrompt() {
|
|
5932
|
+
return [
|
|
5933
|
+
"你是一位网文剧情架构师。请为本书设计「下一阶段的剧情方案」:给出未来 5-10 章的剧情方向,并建议 2-3 条值得新增的剧情线。",
|
|
5934
|
+
"要求:方向必须结合本书大纲/卷计划/现有线/编年录;新线要能落地(和当前主角处境、已有伏笔、下一阶段舞台相关),不得重复已有线。",
|
|
5935
|
+
"输出必须是合法 JSON 对象:{\"direction\": \"下一阶段方向 60-120 字\", \"suggestions\": [{\"name\": \"线名\", \"kind\": \"main|branch|character|mystery\", \"goal\": \"目标\", \"progress\": \"初始进度(可空)\"}]}",
|
|
5936
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
5937
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。"
|
|
5938
|
+
].join("\n");
|
|
5939
|
+
}
|
|
5940
|
+
//#endregion
|
|
5941
|
+
//#region src/prompts/foreshadow.ts
|
|
5942
|
+
/** System prompt for foreshadow suggestions. */
|
|
5943
|
+
function foreshadowSystemPrompt() {
|
|
5944
|
+
return [
|
|
5945
|
+
"你是一位网文伏笔设计师。你会收到大纲和已写的章节信息,请为小说建议 3-8 条值得埋设的伏笔。",
|
|
5946
|
+
"要求:",
|
|
5947
|
+
"1. 伏笔必须有明确的回收价值(推动主线、人物弧光、世界观揭秘)。",
|
|
5948
|
+
"2. 描述要具体,指出埋设章节与预计回收章节(可空缺)。",
|
|
5949
|
+
"3. 优先从大纲的暗线(如记忆代价、残片收集、身世谜团)中提炼。",
|
|
5950
|
+
"输出必须是合法 JSON 数组:",
|
|
5951
|
+
"[{\"description\": \"伏笔描述\", \"plantedChapter\": 章节号或null, \"targetChapter\": 章节号或null}]",
|
|
5952
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
5953
|
+
].join("\n");
|
|
5954
|
+
}
|
|
5955
|
+
//#endregion
|
|
5956
|
+
//#region src/prompts/outline.ts
|
|
5957
|
+
/** 创意灵感:一句话/题材 → 多方向开书灵感。 */
|
|
5958
|
+
function ideaInspirationSystemPrompt() {
|
|
5959
|
+
return ["你是中文网文开书灵感策划。根据用户一句话/题材方向,给出多个可开书的差异化创意。", "严禁输出具体人名/作品名;用身份/通用称谓。每个创意都要有明确钩子、题材、视角、长期兑现。"].join("\n");
|
|
5960
|
+
}
|
|
5961
|
+
/**
|
|
5962
|
+
* 大纲方案:作者的一句「想法」→ 2-3 个方向差异明显的完整大纲。
|
|
5963
|
+
*
|
|
5964
|
+
* @param n - 本次要输出的方案数(1-3)。
|
|
5965
|
+
*/
|
|
5966
|
+
function outlineCandidatesSystemPrompt(n) {
|
|
5967
|
+
return [
|
|
5968
|
+
"你是一位资深网文策划。作者只给了一句「想法」,你需要把它扩展成 2-3 个【方向差异明显】的完整小说大纲方案,供作者挑选。",
|
|
5969
|
+
"每个方案必须满足:",
|
|
5970
|
+
"1. bookName:书名(6 字以内,抓眼球、点题)。",
|
|
5971
|
+
"2. genre:题材(如 仙侠修真 / 都市异能 / 玄幻 / 悬疑)。",
|
|
5972
|
+
"3. sellingPoint:核心卖点一句话(金手指/爽点/差异化,40 字内)。",
|
|
5973
|
+
"4. outline:完整大纲文本(至少 800 字,可直接作为开书大纲),结构包含:书名与题材、金手指/核心设定、主角人设与动机、主线剧情走向(至少 5 个阶段)、关键配角与势力、卖点与爽点设计、预计分卷(3-5 卷)。",
|
|
5974
|
+
"方向差异要求:",
|
|
5975
|
+
"- 方案之间的金手指/剧情走向必须明显不同(如:苟道发育流 vs 随身老爷爷流 vs 群像争霸流),不能只是换书名。",
|
|
5976
|
+
"- 忠实于作者想法的核心要素,但允许在不同方向上进行合理演绎。",
|
|
5977
|
+
"- 不输出任何与已列「需避开的方向」雷同的方案。",
|
|
5978
|
+
"输出必须是合法 JSON 数组,只输出数组本身:",
|
|
5979
|
+
"[{\"id\": \"唯一id\", \"bookName\": \"...\", \"genre\": \"...\", \"sellingPoint\": \"...\", \"outline\": \"...\"}]",
|
|
5980
|
+
`本次只输出 ${n} 个方案。`,
|
|
5981
|
+
"重要:所有字符串值内部不得包含换行符(大纲内部分段请用「。\n」或「;」自然断句),JSON 必须在一段内完整结束。",
|
|
5982
|
+
"重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。"
|
|
5983
|
+
].join("\n");
|
|
5984
|
+
}
|
|
5985
|
+
/** 反推大纲 · 阶段一:分批为每章输出一行「事件摘要」。 */
|
|
5986
|
+
function reverseOutlineBatchSystemPrompt() {
|
|
5987
|
+
return "你是一位网文编辑。下面是一本书若干章正文的节选。请为每一章输出一行「事件摘要」,格式严格为:第N章《标题》:关键事件+主角状态变化+新增伏笔或线索。每章恰好一行,不要空行,不要评价,不要输出其他内容。";
|
|
5988
|
+
}
|
|
5989
|
+
/** 反推大纲 · 阶段二:各章事件摘要 → 全书总纲(Markdown)。 */
|
|
5990
|
+
function reverseOutlineSystemPrompt() {
|
|
5991
|
+
return [
|
|
5992
|
+
"你是一位经验丰富的小说主编。根据下面全书各章事件摘要,反推出这本书的总纲(大纲),可直接作为后续写作依据。要求:",
|
|
5993
|
+
"1. 第一行写《书名》(从摘要中的书名或内容推断,若无则用《未命名》)。",
|
|
5994
|
+
"2. 按故事弧线划分卷/部分:每卷给出卷名与主旨(标注覆盖章节范围)。",
|
|
5995
|
+
"3. 每一章列出:章节号 + 标题 + 一句话核心情节(若原章无标题可自拟)。",
|
|
5996
|
+
"4. 最后给出:全书主线、主要人物弧线、已埋设待回收的伏笔清单。",
|
|
5997
|
+
"5. 输出为纯文本 Markdown 结构(# 一级标题、## 二级标题、- 列表),不要多余寒暄。"
|
|
5998
|
+
].join("\n");
|
|
5999
|
+
}
|
|
6000
|
+
/** 反推大纲 · 改编线:从拆章节选反推总纲,作为改编与续写的骨架。 */
|
|
6001
|
+
function reverseOutlineFromAdaptationSystemPrompt() {
|
|
6002
|
+
return [
|
|
6003
|
+
"你是一位经验丰富的小说主编。根据下面若干章节的正文节选,反推出这本书的总纲(可作为后续改编与章节续写的骨架)。",
|
|
6004
|
+
"要求:",
|
|
6005
|
+
"1. 第一行写《书名》(可从正文推断,否则《未命名》)。",
|
|
6006
|
+
"2. 按故事弧线划分卷/部分:每卷给卷名与主旨(标注覆盖章节范围)。",
|
|
6007
|
+
"3. 对每章列出:章节号 + 标题 + 一句话核心情节。",
|
|
6008
|
+
"4. 最后给出:全书主线、主要人物弧线、已埋设待回收的伏笔清单。",
|
|
6009
|
+
"5. 输出纯文本 Markdown 结构(# 一级标题、## 二级标题、- 列表),不要寒暄,不要其他输出。"
|
|
6010
|
+
].join("\n");
|
|
6011
|
+
}
|
|
6012
|
+
//#endregion
|
|
6013
|
+
//#region src/prompts/digest.ts
|
|
6014
|
+
/** 拆书阶段一:单章正文 → 结构化笔记(供后续章节级分析复用)。 */
|
|
6015
|
+
function breakdownNoteSystemPrompt() {
|
|
6016
|
+
return [
|
|
6017
|
+
"你是中文网文拆书助手。把单章正文整理成结构化笔记,供后续章节级分析复用。",
|
|
6018
|
+
"只输出 JSON 对象:",
|
|
6019
|
+
"{\"summary\": \"1-2句\", \"plotPoints\": [\"...\"], \"characters\": [\"...\"], \"worldbuilding\": [\"...\"], \"styleTechniques\": [\"...\"], \"marketHighlights\": [\"...\"], \"weaknessSignals\": [\"...\"]}",
|
|
6020
|
+
"硬规则:只提取正文明确出现的信息;每数组最多 4 项;不要补写原文外的动机/意图;evidence 不在此阶段输出。",
|
|
6021
|
+
"重要:直接输出 JSON,不要输出其他文字;字符串内不含换行。"
|
|
6022
|
+
].join("\n");
|
|
6023
|
+
}
|
|
6024
|
+
/**
|
|
6025
|
+
* 拆书阶段二:分节分析配置。
|
|
6026
|
+
*
|
|
6027
|
+
* @param preset - quick(总览/剧情/人物/文风)| standard(+ 商业化卖点)。
|
|
6028
|
+
*/
|
|
6029
|
+
function breakdownSections(preset) {
|
|
6030
|
+
const sections = [
|
|
6031
|
+
{
|
|
6032
|
+
key: "overview",
|
|
6033
|
+
title: "拆书总览",
|
|
6034
|
+
focus: "一句话定位、题材标签、整体优势与短板",
|
|
6035
|
+
system: [
|
|
6036
|
+
"你是资深中文网文拆书分析师,负责《拆书总览》小节。",
|
|
6037
|
+
"基于给定章节笔记做低风险综合判断,输出 JSON:{\"markdown\": \"可直接展示的分析稿(简体中文,先给结论再说明体现在哪、为何成立)\", \"structured\": {\"oneLinePositioning\": \"一句话定位\", \"genreTags\": [\"题材标签\"], \"sellingPointTags\": [\"卖点标签\"], \"strengths\": [\"整体优势\"], \"weaknesses\": [\"整体短板\"]}}",
|
|
6038
|
+
"硬规则:只基于笔记归纳;推断用「更偏向/可能」等谨慎措辞;证据不足写「材料不足」;不虚构原文细节。",
|
|
6039
|
+
"重要:直接输出 JSON,字符串内不含换行。"
|
|
6040
|
+
].join("\n")
|
|
6041
|
+
},
|
|
6042
|
+
{
|
|
6043
|
+
key: "plot",
|
|
6044
|
+
title: "剧情结构",
|
|
6045
|
+
focus: "主线梗概、阶段推进、冲突升级、节奏风险",
|
|
6046
|
+
system: [
|
|
6047
|
+
"你是资深中文网文拆书分析师,负责《剧情结构》小节。",
|
|
6048
|
+
"基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"mainlineSummary\": \"主线梗概\", \"phaseProgressions\": [\"阶段推进\"], \"escalationDesigns\": [\"冲突升级\"], \"paceRisks\": [\"节奏风险\"], \"reusablePatterns\": [\"可复用套路\"]}}",
|
|
6049
|
+
"硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
|
|
6050
|
+
"重要:直接输出 JSON,字符串内不含换行。"
|
|
6051
|
+
].join("\n")
|
|
6052
|
+
},
|
|
6053
|
+
{
|
|
6054
|
+
key: "character",
|
|
6055
|
+
title: "人物系统",
|
|
6056
|
+
focus: "主角定位、配角功能、关系网络、成长弧线、辨识度风险",
|
|
6057
|
+
system: [
|
|
6058
|
+
"你是资深中文网文拆书分析师,负责《人物系统》小节。",
|
|
6059
|
+
"基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"protagonistPositioning\": \"主角定位\", \"supportingFunctions\": [\"配角功能\"], \"relationshipNetwork\": [\"关系网络\"], \"growthArcs\": [\"成长弧线\"], \"clarityRisks\": [\"辨识度风险\"]}}",
|
|
6060
|
+
"硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
|
|
6061
|
+
"重要:直接输出 JSON,字符串内不含换行。"
|
|
6062
|
+
].join("\n")
|
|
6063
|
+
},
|
|
6064
|
+
{
|
|
6065
|
+
key: "style",
|
|
6066
|
+
title: "文风与技法",
|
|
6067
|
+
focus: "叙事视角、语言风格、描写方式、节奏控制、钩子设计、可复用写法",
|
|
6068
|
+
system: [
|
|
6069
|
+
"你是资深中文网文拆书分析师,负责《文风与技法》小节。",
|
|
6070
|
+
"基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"narrativePov\": \"叙事视角\", \"languageStyle\": \"语言风格\", \"dialoguePatterns\": [\"对话特征\"], \"rhythmControl\": [\"节奏控制\"], \"hookDesigns\": [\"钩子设计\"], \"reusableTechniques\": [\"可复用写法\"]}}",
|
|
6071
|
+
"硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
|
|
6072
|
+
"重要:直接输出 JSON,字符串内不含换行。"
|
|
6073
|
+
].join("\n")
|
|
6074
|
+
}
|
|
6075
|
+
];
|
|
6076
|
+
if (preset === "standard") sections.push({
|
|
6077
|
+
key: "market",
|
|
6078
|
+
title: "商业化卖点",
|
|
6079
|
+
focus: "读者爽点、点击驱动、人物/题材卖点、商业化风险",
|
|
6080
|
+
system: [
|
|
6081
|
+
"你是资深中文网文拆书分析师,负责《商业化卖点》小节。",
|
|
6082
|
+
"基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"hookPoints\": [\"读者爽点\"], \"clickDrivers\": [\"点击驱动\"], \"characterSellingPoints\": [\"人物卖点\"], \"genreSellingPoints\": [\"题材卖点\"], \"commercialRisks\": [\"商业化风险\"]}}",
|
|
6083
|
+
"硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
|
|
6084
|
+
"重要:直接输出 JSON,字符串内不含换行。"
|
|
6085
|
+
].join("\n")
|
|
6086
|
+
});
|
|
6087
|
+
return sections;
|
|
6088
|
+
}
|
|
6089
|
+
/** 单章摘要(叙事记忆):供后续章节写作时保持连贯性。 */
|
|
6090
|
+
function chapterSummarySystemPrompt() {
|
|
6091
|
+
return [
|
|
6092
|
+
"你是一位网文编辑。请为下面一章写一段 120-200 字的摘要,供后续章节写作时保持连贯性。",
|
|
6093
|
+
"摘要必须包含:本章发生的关键事件、主角状态变化(境界/资源/伤势/心境)、新增的伏笔或线索、角色关系变化。",
|
|
6094
|
+
"用客观陈述句,不要评价,不要剧透式感叹。只输出摘要正文。"
|
|
6095
|
+
].join("\n");
|
|
6096
|
+
}
|
|
6097
|
+
/** 书分析/拆书:输入一本书/章节文本 → 卖点/结构/可借鉴点/风险。 */
|
|
6098
|
+
function bookAnalysisSystemPrompt() {
|
|
6099
|
+
return ["你是资深中文网文拆书编辑。分析输入的一本书或章节文本,提炼可复用的创作思路。", "严禁照搬具体作品的人名/专有设定/简介句子;只能提炼读者需求、结构手法和风险。"].join("\n");
|
|
6100
|
+
}
|
|
6101
|
+
//#endregion
|
|
6102
|
+
//#region src/prompts/facts.ts
|
|
6103
|
+
/** 摘要 + 事实(两件事一次出)。 */
|
|
6104
|
+
function summaryFactsSystemPrompt() {
|
|
6105
|
+
return [
|
|
6106
|
+
"你是一位网文编辑。请为下面一章做两件事,输出合法 JSON 对象:",
|
|
6107
|
+
"{\"summary\": \"120-200字摘要,含关键事件/主角状态变化(境界资源伤势心境)/新增伏笔线索/角色关系变化,客观陈述不评价\", \"facts\": [\"已确立事实1\", \"…3-6条\"]}",
|
|
6108
|
+
"facts 指:本章明确写出的、对后续有约束力的事实——人物当前状态、重要关系变化、地点与时间线、已落地或新增的伏笔线索、关键道具去向。",
|
|
6109
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
6110
|
+
].join("\n");
|
|
6111
|
+
}
|
|
6112
|
+
/**
|
|
6113
|
+
* 摘要 + 事实 + 时间线(三件事一次出)。
|
|
6114
|
+
*
|
|
6115
|
+
* @param wantTimeline - 关闭时间线时连 timeline 字段都不问,省掉输出 token
|
|
6116
|
+
* 与模型的无用工作量。
|
|
6117
|
+
*/
|
|
6118
|
+
function summaryFactsTimelineSystemPrompt(wantTimeline) {
|
|
6119
|
+
const NL = String.fromCharCode(10);
|
|
6120
|
+
return [
|
|
6121
|
+
"你是一位网文编辑。请为下面一章做三件事,输出**一个**合法 JSON 对象:",
|
|
6122
|
+
"{\"summary\": \"120-200字摘要,含关键事件/主角状态变化(境界资源伤势心境)/新增伏笔线索/角色关系变化,客观陈述不评价\",",
|
|
6123
|
+
" \"facts\": [\"已确立事实1\", \"…3-6条\"],",
|
|
6124
|
+
...wantTimeline ? [" \"timeline\": [{\"time\":\"第三日黄昏\",\"order\":1,\"place\":\"青云宗外门\",\"characters\":[\"沈青\"],\"event\":\"不超过40字\"}]"] : [],
|
|
6125
|
+
"",
|
|
6126
|
+
"facts 指:本章明确写出的、对后续有约束力的事实——人物当前状态、重要关系变化、地点与时间线、已落地或新增的伏笔线索、关键道具去向。",
|
|
6127
|
+
...wantTimeline ? [
|
|
6128
|
+
"timeline 指:只抽取正文里真实发生的场景节点(3-8 条),不要抽预告、回忆性泛泛陈述;纯粹的回忆/插叙请在 event 里注明「(回忆)」并给出其故事内时间。",
|
|
6129
|
+
"time 用正文里的说法(如「第三日黄昏」「入宗三个月后」「同日夜」);正文没写时间就填相对说法(如「紧接上一场景」)。",
|
|
6130
|
+
"order 是本章内的先后序号,从 1 递增;place 是一处一个词;characters 只列在场的角色名(用正文里的名字,不要写「主角」)。"
|
|
6131
|
+
] : [],
|
|
6132
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束,不要输出 Markdown 代码块标记。"
|
|
6133
|
+
].join(NL);
|
|
6134
|
+
}
|
|
6135
|
+
/** 事实抽取:本章正文 → 已确立事实(每行一条,3-6 条)。 */
|
|
6136
|
+
function extractFactsSystemPrompt() {
|
|
6137
|
+
return [
|
|
6138
|
+
"你是一位网文编辑。请从本章正文中抽取「已确立事实」,供后续章节保持一致。",
|
|
6139
|
+
"事实指:人物当前状态(境界/修为/伤势/资源/心境)、重要关系变化、地点与时间线、已落地或新增的伏笔线索、关键道具去向。",
|
|
6140
|
+
"要求:",
|
|
6141
|
+
"1. 只抽取本章明确写出的、对后续有约束力的内容;纯心理活动与无关细节不要。",
|
|
6142
|
+
"2. 每行一条事实,用客观陈述句,不含主观评价。",
|
|
6143
|
+
"3. 输出 3-6 条,每行一条,不要编号、不要前缀、不要解释。"
|
|
6144
|
+
].join("\n");
|
|
6145
|
+
}
|
|
6146
|
+
/** 时间线抽取:一章正文 → 故事时间线事件(时间/地点/在场角色/事件)。 */
|
|
6147
|
+
function timelineExtractSystemPrompt() {
|
|
6148
|
+
const NL = String.fromCharCode(10);
|
|
6149
|
+
return [
|
|
6150
|
+
"你是小说连续性编辑。任务:把一章正文压缩成「故事时间线」事件,供后续章节保持时间与地点的连续性。",
|
|
6151
|
+
"要求:",
|
|
6152
|
+
"1. 只抽取**正文里真实发生**的场景节点(3-8 条),不要抽取预告、回忆性的泛泛陈述;纯粹的回忆/插叙请在 event 里注明「(回忆)」并给出其故事内时间。",
|
|
6153
|
+
"2. time 用正文里的说法(如「第三日黄昏」「入宗三个月后」「同日夜」);正文没写时间就填相对说法(如「紧接上一场景」)。",
|
|
6154
|
+
"3. order 是本章内的先后序号,从 1 递增;跨章我会用章号排序,你只需要保证章内顺序正确。",
|
|
6155
|
+
"4. place 是场景地点(一处一个词,如「青云宗外门」);characters 只列**在场**的角色名(用正文里的名字,不要写「主角」)。",
|
|
6156
|
+
"5. event 一句话说清发生了什么(不超过 40 字)。",
|
|
6157
|
+
"输出必须是合法 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记:",
|
|
6158
|
+
"[{\"time\":\"第三日黄昏\",\"order\":1,\"place\":\"青云宗外门\",\"characters\":[\"沈青\"],\"event\":\"沈青被外门弟子当众羞辱\"}]",
|
|
6159
|
+
"重要:字符串值内部不得包含换行符;JSON 必须在一段内完整结束。"
|
|
6160
|
+
].join("@NL@").replace("@NL@", NL);
|
|
6161
|
+
}
|
|
6162
|
+
/** 时间线复核:规则初筛之外,让模型找时序矛盾(时间倒流/瞬移/跨度不合理/季节错位)。 */
|
|
6163
|
+
function timelineReviewSystemPrompt() {
|
|
6164
|
+
const NL = String.fromCharCode(10);
|
|
6165
|
+
return [
|
|
6166
|
+
"你是小说连续性编辑,只做一件事:找**时间线**上的矛盾。",
|
|
6167
|
+
"检查项:1) 时间倒流(后一章早于前一章且没有明确插叙标注);2) 地点瞬移(同一人物无移动交代就换场景);",
|
|
6168
|
+
"3) 时间跨度与事件量明显不匹配(如「一日之内」发生跨越数月的剧情);4) 季节/年龄/节日前后矛盾;5) 回忆与现实混淆。",
|
|
6169
|
+
"只报**能在给定事件表里指认**的问题,不要臆测;没有问题就返回空数组。",
|
|
6170
|
+
"输出必须是合法 JSON 数组,不要任何其他文字:",
|
|
6171
|
+
"[{\"severity\":\"high|medium|low\",\"chapters\":[3,7],\"item\":\"问题一句话\",\"suggestion\":\"怎么修\"}]",
|
|
6172
|
+
"重要:字符串值内部不得包含换行符。"
|
|
6173
|
+
].join(NL);
|
|
6174
|
+
}
|
|
6175
|
+
//#endregion
|
|
6176
|
+
//#region src/prompts/audit.ts
|
|
6177
|
+
/** 单批质检:设定 + 事实库 + 该批章节节选 → 矛盾清单。 */
|
|
6178
|
+
function auditBatchSystemPrompt() {
|
|
6179
|
+
return [
|
|
6180
|
+
"你是一位严谨的网文连续性审校编辑。你会收到一本小说的道藏、事实库和一批章节正文节选。",
|
|
6181
|
+
"请找出这批章节中的一致性矛盾,例如:",
|
|
6182
|
+
"- 人物状态冲突:境界/修为/伤势/资源在同一章内或跨章前后矛盾。",
|
|
6183
|
+
"- 设定违背:正文与世界观规则、金手指规则、写作红线冲突。",
|
|
6184
|
+
"- 时间线错乱:事件顺序、时间跨度、地点移动不合逻辑。",
|
|
6185
|
+
"- 细节穿帮:人名/地名/物品/数字前后不一致。",
|
|
6186
|
+
"要求:",
|
|
6187
|
+
"1. 只报告有实质证据的矛盾,不要泛泛而谈写作质量问题。",
|
|
6188
|
+
"2. 每条必须定位到具体章节号。",
|
|
6189
|
+
"3. 输出必须是合法 JSON 数组,格式:[{\"chapterNo\": 章节号, \"severity\": \"high|medium|low\", \"item\": \"矛盾描述\", \"suggestion\": \"修改建议\"}]",
|
|
6190
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
6191
|
+
].join("\n");
|
|
6192
|
+
}
|
|
6193
|
+
/** 影响分析:一处改动 → 全书波及位置清单(设定/大纲/正文/事实库/简介)。 */
|
|
6194
|
+
function impactAnalysisSystemPrompt() {
|
|
6195
|
+
return [
|
|
6196
|
+
"你是一位网文一致性审校。作者要做一处修改,请找出这次改动会波及的所有位置(设定、大纲、已写章节正文、事实库、简介中可能因此过时或矛盾的内容)。",
|
|
6197
|
+
"输出必须是合法 JSON 数组,格式:[{\"location\": \"位置(第N章/大纲/道藏-世界规则/大世界-境界/事实库/简介)\", \"quote\": \"原文片段(20-60字)\", \"suggestion\": \"修改建议\", \"kind\": \"must|optional|note\"}]",
|
|
6198
|
+
"kind 含义:must=必须同步改否则矛盾;optional=建议改(影响观感);note=备注(如旧称保留为古称、或无需改但需知晓)。",
|
|
6199
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
6200
|
+
].join("\n");
|
|
6201
|
+
}
|
|
6202
|
+
//#endregion
|
|
6203
|
+
//#region src/prompts/adapt.ts
|
|
6204
|
+
/** 改编 P0 分析:system 侧只声明角色(指令主体在 user 侧,位置效应更不易被长上下文稀释)。 */
|
|
6205
|
+
function adaptationAnalyzeSystemPrompt() {
|
|
6206
|
+
return "你是改编策划分析助手。";
|
|
6207
|
+
}
|
|
6208
|
+
/** 改编 P0 分析:user 侧指令(原文设定卡片 + 可改范围矩阵)。 */
|
|
6209
|
+
function adaptationAnalyzeUser(sampleBodies) {
|
|
6210
|
+
return [
|
|
6211
|
+
"你是一位资深网文编辑兼改编策划。下面给你一部已完结/连载小说的若干章正文节选。请通读并输出该书的「原文设定卡片」与「可改范围矩阵」。",
|
|
6212
|
+
"输出合法 JSON 对象:",
|
|
6213
|
+
"{\"bookName\": \"书名\", \"outline\": \"一句话主线梗概(100字内)\", \"dimensions\": [{\"key\":\"realm\",\"title\":\"大世界\",\"mutability\":\"big|small|free|locked|visual\",\"current\":\"当前值\",\"evidence\":\"证据(出现章节/频次)\",\"candidates\":[{\"name\":\"候选体系名\",\"desc\":\"该体系一句话说明\"}],\"impact\":\"改了会影响什么\",\"risk\":\"high|medium|low\"}]}",
|
|
6214
|
+
"dimensions 至少覆盖以下维度(key/title):realm 大世界、cultivation 修为体系、protagonist 主角、goldenFinger 金手指、supporting 配角与势力人物名、faction 势力/组织、style 文风与叙事、ending 结局走向、timeline 时间线/编年、foreshadow 伏笔/暗线。",
|
|
6215
|
+
"mutability 取值:locked=建议保留、big=可改影响大、small=可改影响小、free=可自由改、visual=仅视觉包装。",
|
|
6216
|
+
"每个 dimension.current 必须忠于文本,能引用原文就用原文(尤其角色名/境界名/势力名/金手指名)。",
|
|
6217
|
+
"重要:所有字符串值内部不得包含换行符;JSON 必须在一段内完整结束;直接输出 JSON,不要 Markdown 代码块。"
|
|
6218
|
+
].join("\n") + "\n\n" + sampleBodies.join("\n\n---\n\n");
|
|
6219
|
+
}
|
|
6220
|
+
/** 改编方案:system 侧角色声明(指令主体在 user 侧)。 */
|
|
6221
|
+
function adaptationProposeSystemPrompt() {
|
|
6222
|
+
return "你是改编策划。";
|
|
6223
|
+
}
|
|
6224
|
+
/** 改编方案:user 侧指令 + 用户勾选的改动与已分析的可改矩阵。 */
|
|
6225
|
+
function adaptationProposeUser(selLines, dimLines) {
|
|
6226
|
+
return [
|
|
6227
|
+
"下面给出改编决策:用户想改哪些维度、改成什么值。请生成一份可执行的「改编方案」。",
|
|
6228
|
+
"要求输出合法 JSON 对象:",
|
|
6229
|
+
"{\"mappings\": [{\"source\":\"原值\",\"target\":\"新值\",\"scope\":\"name|realm|faction|term|other\",\"note\":\"说明\"}], \"rules\": {\"preserve\":[\"必须保留的要素\"],\"change\":[\"允许改变的要素\"],\"constraints\":[\"改编红线/一致性要求\"]}, \"impacts\": [{\"item\":\"受影响项\",\"detail\":\"说明\",\"risk\":\"high|medium|low\",\"chapters\":[章号]}]}",
|
|
6230
|
+
"mappings 需把用户确认的新值展开为「原→新」条目(如主角名/境界名/势力名/术语),并补充用户未填但关联的必改项(如改了修为体系名,相关的境界名一并列映射)。",
|
|
6231
|
+
"rules.preserve 至少包含:故事骨架、人物动机、伏笔逻辑、爽点结构。",
|
|
6232
|
+
"impacts 列出每个改动会影响的内容(术语/角色/章节/伏笔),能定位章号尽量定位;无法定位则给章节区间提示。",
|
|
6233
|
+
"重要:所有字符串值内部不得包含换行符;JSON 必须在一段内完整结束;直接输出 JSON,不要 Markdown 代码块。",
|
|
6234
|
+
"",
|
|
6235
|
+
"用户要改:",
|
|
6236
|
+
selLines,
|
|
6237
|
+
"",
|
|
6238
|
+
"原文可改矩阵(已分析):",
|
|
6239
|
+
dimLines
|
|
6240
|
+
].join("\n");
|
|
6241
|
+
}
|
|
6242
|
+
/** 改编重写:按映射表与规则把一章正文结构性重写(不只是换词)。 */
|
|
6243
|
+
function adaptationRewriteSystemPrompt() {
|
|
6244
|
+
return [
|
|
6245
|
+
"你是一位资深网文改编编剧。你会收到某一章的正文,以及本书的改编映射表与改编规则。",
|
|
6246
|
+
"任务:把这一章按改编方案**重写**成新版本(结构性改编,不只是换词)。",
|
|
6247
|
+
"要求:",
|
|
6248
|
+
"1. 严格遵守映射表(原值→新值),正文中所有源值都要替换为新值;涉及改名/改体系/改势力时,相关表述一起调整,使上下文自洽。",
|
|
6249
|
+
"2. 改编规则:必须保留的内容不得破坏(故事骨架/人物动机/伏笔逻辑/爽点结构);允许改变的内容可以放开调整;红线/一致性要求必须遵守。",
|
|
6250
|
+
"3. 若某个改动牵动叙事(如结局走向/时间线/世界观),要把这章的叙述顺势改得通顺、可信。",
|
|
6251
|
+
"4. 输出**只包含这一章重写后的正文**,不要重复标题,不要任何解释、开头或结尾。",
|
|
6252
|
+
"5. 字数与原章基本相当(允许 ±20%)。"
|
|
6253
|
+
].join("\n");
|
|
6254
|
+
}
|
|
6255
|
+
//#endregion
|
|
6256
|
+
//#region src/prompts/market.ts
|
|
6257
|
+
/** 市场雷达系统提示(分析师角色与输出纪律)。 */
|
|
6258
|
+
function marketRadarSystemPrompt() {
|
|
6259
|
+
return [
|
|
6260
|
+
"你是中文网络文学市场分析师。只分析输入中的公开榜单元数据或用户给出的市场线索,不补写作品正文,不假装知道未提供的信息。",
|
|
6261
|
+
"只根据输入归纳热门题材和市场信号;对未提供的信息不要臆测。",
|
|
6262
|
+
"重点分析:热门题材组合、主角身份、金手指机制、开篇危机、关系卖点、标题句式、拥挤套路和差异化机会。",
|
|
6263
|
+
"kind 只能使用 genre、protagonist、advantage、opening、relationship、title_pattern、opportunity、crowding,不得创造近义枚举值。",
|
|
6264
|
+
"榜单高频不等于适合照搬。机会建议必须说明读者满足点,同时避开直接复制具体作品。",
|
|
6265
|
+
"productionFoundation 的题材/推进模式优先引用「资源库」中已有的 existingId(名称一字不差);只有确无合适资产时才给出新资产(existingId 为 null)。",
|
|
6266
|
+
"creativeBrief 严禁复用榜单作品的人名、专有设定、简介句子和完整书名;只能提炼读者需求、爽点机制和结构机会,不得输出任何具体人名/作品名,一律使用身份或通用场景称谓。"
|
|
6267
|
+
].join("\n");
|
|
6268
|
+
}
|
|
6269
|
+
/** 雷达→灵感:基于市场信号/生产底座/创意简报,生成贴合市场的开书灵感。 */
|
|
6270
|
+
function marketIdeaInspirationSystemPrompt() {
|
|
6271
|
+
return ["你是中文网文开书灵感策划,擅长从市场榜单分析中提炼可开书方向。", "严禁输出具体人名/作品名;用身份/通用称谓。每个创意都要有明确钩子、题材、视角、长期兑现,且要与市场分析呼应但做差异化。"].join("\n");
|
|
6272
|
+
}
|
|
6273
|
+
/** 作品简介:面向读者的门面(卖点 + 开局钩子,不剧透)。 */
|
|
6274
|
+
function blurbSystemPrompt() {
|
|
6275
|
+
return [
|
|
6276
|
+
"你是一位网文平台编辑,擅长写抓人的作品简介。",
|
|
6277
|
+
"要求:",
|
|
6278
|
+
"1. 120-250 字,突出核心卖点(金手指/题材/爽点/人设反差),用一两句抛出开局钩子。",
|
|
6279
|
+
"2. 不剧透结局与关键反转;语气贴合题材(热血/悬疑/轻松/虐心)。",
|
|
6280
|
+
"3. 中文,直接输出简介正文,不要 Markdown、不要引号包裹、不要「简介:」前缀。"
|
|
6281
|
+
].join("\n");
|
|
5307
6282
|
}
|
|
5308
6283
|
//#endregion
|
|
5309
6284
|
//#region src/protocol.ts
|
|
@@ -5368,6 +6343,8 @@ const NOVEL_API = {
|
|
|
5368
6343
|
chapterCheck: "/api/dsh-novel-forge/chapter/check",
|
|
5369
6344
|
/** 保存手动编辑的正文(自动备份 .bak)。 */
|
|
5370
6345
|
chapterSave: "/api/dsh-novel-forge/chapter/save",
|
|
6346
|
+
/** 补写本章计划的固定六项(目标 / 必达 / 保持 / 硬事实 / 结尾钩子 / 剧情要点)。 */
|
|
6347
|
+
chapterPlan: "/api/dsh-novel-forge/chapter/plan",
|
|
5371
6348
|
assistant: "/api/dsh-novel-forge/assistant",
|
|
5372
6349
|
assistantHistory: "/api/dsh-novel-forge/assistant-history",
|
|
5373
6350
|
/** 清空助手对话记录。 */
|
|
@@ -5548,23 +6525,10 @@ const EXPORT_SCOPE_VALUES = [
|
|
|
5548
6525
|
* auto-review + rewrite, polish (de-AI-ify), narrative summaries, foreshadow
|
|
5549
6526
|
* tracking, project persistence, and whole-book export. Pure Node (no
|
|
5550
6527
|
* web-server dependencies), so routes stay thin and logic is testable.
|
|
6528
|
+
*
|
|
6529
|
+
* 系统提示词全部住在 src/prompts/(按域拆分 + catalog 注册表),本文件只做
|
|
6530
|
+
* 组装与调用——提示词文案的修改去对应域文件,不要在这里新起一段。
|
|
5551
6531
|
*/
|
|
5552
|
-
/**
|
|
5553
|
-
* 内容合规红线(平台硬性要求):所有书籍、所有章节无条件生效,
|
|
5554
|
-
* 优先级高于单书大纲/道藏中的任何设定与作者自定义红线。
|
|
5555
|
-
* 注入点:章节生成系统提示 + 审稿系统提示(命中即 high)。
|
|
5556
|
-
*/
|
|
5557
|
-
const COMPLIANCE_REDLINES = [
|
|
5558
|
-
"1. 不得出现反对宪法所确定的基本原则的内容。",
|
|
5559
|
-
"2. 不得出现危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的内容。",
|
|
5560
|
-
"3. 不得出现危害国家荣誉和利益的内容。",
|
|
5561
|
-
"4. 不得出现煽动民族仇恨、民族歧视、破坏民族团结的内容。",
|
|
5562
|
-
"5. 不得出现破坏国家宗教政策、宣扬邪教和愚昧迷信的内容(不得以真实宗教、邪教或迷信活动为背景进行宣扬)。",
|
|
5563
|
-
"6. 不得出现散布谣言、扰乱社会秩序、破坏社会稳定的内容。",
|
|
5564
|
-
"7. 不得出现淫秽色情、赌博、暴力、凶杀、恐怖或教唆犯罪的内容(网文语境:禁止露骨性描写、血腥暴力渲染、赌博教唆、犯罪手法详细教学)。",
|
|
5565
|
-
"8. 不得出现侮辱或者诽谤他人、侵害他人合法权益的内容(不得以真实人物、组织为原型进行侮辱或影射攻击)。",
|
|
5566
|
-
"9. 不得出现法律法规禁止的其他内容。"
|
|
5567
|
-
];
|
|
5568
6532
|
/** 审稿维度取值:归一化模型输出的 dimension 字段。维度清单以 reviewSystemPrompt 的九条为准。 */
|
|
5569
6533
|
const REVIEW_DIMENSION_IDS = /* @__PURE__ */ new Set([
|
|
5570
6534
|
"character",
|
|
@@ -5647,26 +6611,44 @@ function saveProject(outputDir, project) {
|
|
|
5647
6611
|
writeFileSync(tmp, data, "utf8");
|
|
5648
6612
|
renameSync(tmp, target);
|
|
5649
6613
|
}
|
|
6614
|
+
/** `mergeVolatileFromDisk` 会覆盖的易变字段的全集(也是 `keep` 的合法取值域)。 */
|
|
6615
|
+
const VOLATILE_FIELDS = [
|
|
6616
|
+
"bible",
|
|
6617
|
+
"roles",
|
|
6618
|
+
"plotlines",
|
|
6619
|
+
"roleStatus",
|
|
6620
|
+
"blurb",
|
|
6621
|
+
"coverPath",
|
|
6622
|
+
"facts",
|
|
6623
|
+
"assets",
|
|
6624
|
+
"world",
|
|
6625
|
+
"volumes"
|
|
6626
|
+
];
|
|
5650
6627
|
/**
|
|
5651
6628
|
* 并发保护:长任务(章节计划生成/正文生成)在内存中持有旧快照,
|
|
5652
6629
|
* 期间其他请求可能修改了「易变字段」(道藏/角色库/剧情线/人物志存档/简介/封面)。
|
|
5653
6630
|
* 保存前用磁盘最新版本合并这些字段,避免旧快照覆盖新修改(曾导致角色卡丢失)。
|
|
5654
|
-
*
|
|
6631
|
+
*
|
|
6632
|
+
* ⚠️ `keep` 不是可选的礼貌参数,而是本函数唯一的正确用法保证:
|
|
6633
|
+
* 调用方只要自己写过这些字段中的任何一个(哪怕只是 `project.plotlines[0].chapters.push(no)`),
|
|
6634
|
+
* **必须**把它列进 `keep`,否则本函数会用磁盘旧值把本轮成果整块覆盖掉。
|
|
6635
|
+
* 历史上正是漏了 `facts` / `plotlines`,导致作者复盘回灌的事实与剧情线关联被静默丢弃。
|
|
6636
|
+
*
|
|
6637
|
+
* @param outputDir - 项目目录。
|
|
6638
|
+
* @param project - 待保存的内存项目(原地修改)。
|
|
6639
|
+
* @param keep - 本次调用方已自行修改过的易变字段,这些字段不从磁盘覆盖。
|
|
5655
6640
|
*/
|
|
5656
|
-
function mergeVolatileFromDisk(outputDir, project) {
|
|
6641
|
+
function mergeVolatileFromDisk(outputDir, project, keep = []) {
|
|
5657
6642
|
try {
|
|
5658
6643
|
const disk = loadProject(outputDir);
|
|
5659
6644
|
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;
|
|
6645
|
+
const skip = new Set(keep);
|
|
6646
|
+
const target = project;
|
|
6647
|
+
const source = disk;
|
|
6648
|
+
for (const field of VOLATILE_FIELDS) {
|
|
6649
|
+
if (skip.has(field)) continue;
|
|
6650
|
+
target[field] = source[field];
|
|
6651
|
+
}
|
|
5670
6652
|
} catch {}
|
|
5671
6653
|
}
|
|
5672
6654
|
/**
|
|
@@ -6051,6 +7033,22 @@ function readChapterFile(outputDir, chapter) {
|
|
|
6051
7033
|
if (!existsSync(path)) return void 0;
|
|
6052
7034
|
return readFileSync(path, "utf8");
|
|
6053
7035
|
}
|
|
7036
|
+
/**
|
|
7037
|
+
* 章节正文的**写盘时间**(文件 mtime,ISO)—— 面板「今日产出 / 日环比」的唯一本地事实来源。
|
|
7038
|
+
*
|
|
7039
|
+
* 为什么不把它写进 novel-project.json:它是**派生数据**。落盘后一旦文件被外部替换、
|
|
7040
|
+
* 从备份回拷、或手工改名,存档里的时间戳就开始撒谎;mtime 永远跟着真实文件走。
|
|
7041
|
+
* 代价是每次 /status 要对每个有正文的章 stat 一次 —— 本地磁盘上是微秒级,
|
|
7042
|
+
* 相对它换来的"今天的产出是真的",这个代价可以接受。
|
|
7043
|
+
*/
|
|
7044
|
+
function chapterWrittenAt(outputDir, chapter) {
|
|
7045
|
+
if (chapter.file === void 0) return void 0;
|
|
7046
|
+
try {
|
|
7047
|
+
return statSync(join(outputDir, chapter.file)).mtime.toISOString();
|
|
7048
|
+
} catch {
|
|
7049
|
+
return;
|
|
7050
|
+
}
|
|
7051
|
+
}
|
|
6054
7052
|
/** Create a fresh project from an outline. */
|
|
6055
7053
|
function createProject(outline, outlinePath) {
|
|
6056
7054
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -6105,15 +7103,26 @@ async function complete(ctx, config, options) {
|
|
|
6105
7103
|
...converged.reasoningEffort === void 0 ? {} : { reasoningEffort: ReasoningEffortId(converged.reasoningEffort) }
|
|
6106
7104
|
};
|
|
6107
7105
|
const assembler = new BlockAssembler();
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
7106
|
+
try {
|
|
7107
|
+
for await (const chunk of ctx.llm.stream(options.signal === void 0 ? request : {
|
|
7108
|
+
...request,
|
|
7109
|
+
signal: options.signal
|
|
7110
|
+
})) {
|
|
7111
|
+
if (chunk.type === "text-delta") markFirstToken(live);
|
|
7112
|
+
assembler.push(chunk);
|
|
7113
|
+
}
|
|
7114
|
+
} catch (error) {
|
|
7115
|
+
endLiveCall(live, assembler, {
|
|
7116
|
+
phase: isCancellation(error) ? "cancelled" : "failed",
|
|
7117
|
+
error: error.message
|
|
7118
|
+
});
|
|
7119
|
+
throw error;
|
|
6111
7120
|
}
|
|
6112
7121
|
const finish = assembler.finish;
|
|
6113
7122
|
if (finish.kind === "error" || finish.kind === "aborted") {
|
|
6114
|
-
const message =
|
|
7123
|
+
const message = `${isCancellation(finish.kind) ? "调用已取消" : "LLM 调用失败"}(${finish.kind}): ${finish.failure.message}`;
|
|
6115
7124
|
endLiveCall(live, assembler, {
|
|
6116
|
-
phase: "failed",
|
|
7125
|
+
phase: isCancellation(finish.kind) ? "cancelled" : "failed",
|
|
6117
7126
|
error: message
|
|
6118
7127
|
});
|
|
6119
7128
|
throw new Error(message);
|
|
@@ -6290,24 +7299,6 @@ function parseJsonObject(text) {
|
|
|
6290
7299
|
if (typeof value !== "object" || value === null) throw new Error("模型输出不是 JSON 对象");
|
|
6291
7300
|
return value;
|
|
6292
7301
|
}
|
|
6293
|
-
/** System prompt for story-bible extraction. */
|
|
6294
|
-
function bibleSystemPrompt() {
|
|
6295
|
-
return [
|
|
6296
|
-
"你是一位资深网文编辑兼设定架构师。你会收到一份小说大纲,请把它提炼成结构化的「道藏」,供后续写作时严格引用。",
|
|
6297
|
-
"要求:",
|
|
6298
|
-
"1. 忠于大纲,不自行发明大纲之外的设定。",
|
|
6299
|
-
"2. 角色卡覆盖大纲明确出现的角色(主角必含),每个角色给出性格标签、目标、关键关系。",
|
|
6300
|
-
"3. 世界规则覆盖力量体系、金手指机制、势力、地理等所有硬性规则,逐条列出。",
|
|
6301
|
-
"4. 红线列出大纲中明确禁止的内容(如无后宫、不圣母、无无脑碾压等)。",
|
|
6302
|
-
"5. 风格列出叙事基调、节奏、POV 等写作风格要点。",
|
|
6303
|
-
"6. 角色名必须用正文/编年录中的真实姓名;若大纲只写「主角」未点名,而已写章节或编年录中有名字,则用该真实姓名;禁止输出「主角(描述)」这类把身份塞进名字的占位名。",
|
|
6304
|
-
"输出必须是合法 JSON 对象,不要输出任何其他文字或 Markdown 代码块标记。",
|
|
6305
|
-
"重要:所有字符串值内部不得包含换行符(不要用多行字符串),JSON 必须在一段内完整结束。",
|
|
6306
|
-
"重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
|
|
6307
|
-
"JSON 结构:",
|
|
6308
|
-
"{\"genre\": \"题材与基调一句话\", \"worldRules\": [\"规则1\", \"规则2\", ...], \"characters\": [{\"name\": \"角色名\", \"role\": \"protagonist|supporting|antagonist|other\", \"traits\": [\"标签1\", ...], \"goals\": \"目标与动机\", \"relations\": \"关键关系\"}], \"redLines\": [\"红线1\", ...], \"style\": [\"风格1\", ...]}"
|
|
6309
|
-
].join("\n");
|
|
6310
|
-
}
|
|
6311
7302
|
/** Extract the story bible from an outline. */
|
|
6312
7303
|
async function extractBible(ctx, config, outline, project) {
|
|
6313
7304
|
const written = (project?.chapters ?? []).filter((c) => c.status !== "pending" && c.status !== "generating" && c.file !== void 0);
|
|
@@ -6360,184 +7351,41 @@ async function extractBible(ctx, config, outline, project) {
|
|
|
6360
7351
|
if (bible.worldRules.length === 0 && bible.characters.length === 0 && bible.redLines.length === 0) throw new Error("道藏生成失败:模型没有返回有效内容");
|
|
6361
7352
|
return bible;
|
|
6362
7353
|
}
|
|
6363
|
-
/** System prompt for volume planning. */
|
|
6364
|
-
function volumeSystemPrompt() {
|
|
6365
|
-
return [
|
|
6366
|
-
"你是一位资深网文总编。你会收到一份小说大纲,请把全书划分为若干「卷」(分卷),每卷有明确的剧情定位与起止章节。",
|
|
6367
|
-
"要求:",
|
|
6368
|
-
"1. 大纲已有分卷时,严格遵循大纲的分卷结构;没有时按剧情弧线合理划分(3-8 卷)。",
|
|
6369
|
-
"2. 卷定位一句话说明该卷的剧情重心。",
|
|
6370
|
-
"3. chapterStart/chapterEnd 给出该卷覆盖的章节区间(从 1 开始连续编号)。",
|
|
6371
|
-
"输出必须是合法 JSON 数组,不要输出任何其他文字:",
|
|
6372
|
-
"[{\"no\": 1, \"title\": \"卷名\", \"summary\": \"卷定位与剧情重心\", \"chapterStart\": 1, \"chapterEnd\": 80}]",
|
|
6373
|
-
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
6374
|
-
"重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
|
|
6375
|
-
renderLengthContract([["卷名", STRUCTURED_FIELD_LIMITS.chapterTitle], ["每卷定位", STRUCTURED_FIELD_LIMITS.chapterSummary]])
|
|
6376
|
-
].join("\n");
|
|
6377
|
-
}
|
|
6378
7354
|
/** Plan volumes from an outline. */
|
|
6379
7355
|
async function planVolumes(ctx, config, outline) {
|
|
6380
7356
|
const user = `请为下面这部小说划分卷:\n\n${outline}`;
|
|
6381
|
-
const expectedVolumes = Math.min(24, Math.max(3, Math.round(outline.length / 1200)));
|
|
6382
|
-
const parsed = parseJsonArray(await complete(ctx, config, {
|
|
6383
|
-
system: volumeSystemPrompt(),
|
|
6384
|
-
user,
|
|
6385
|
-
temperature: .4,
|
|
6386
|
-
maxTokens: resolveOutputBudget("volume-strategy", { volumes: expectedVolumes })
|
|
6387
|
-
}));
|
|
6388
|
-
const volumes = [];
|
|
6389
|
-
for (let i = 0; i < parsed.length; i++) {
|
|
6390
|
-
const entry = parsed[i];
|
|
6391
|
-
if (typeof entry !== "object" || entry === null) continue;
|
|
6392
|
-
const no = typeof entry.no === "number" ? entry.no : i + 1;
|
|
6393
|
-
const title = typeof entry.title === "string" ? entry.title.trim() : `第${no}卷`;
|
|
6394
|
-
const summary = typeof entry.summary === "string" ? entry.summary.trim() : "";
|
|
6395
|
-
const start = typeof entry.chapterStart === "number" ? entry.chapterStart : void 0;
|
|
6396
|
-
const end = typeof entry.chapterEnd === "number" ? entry.chapterEnd : void 0;
|
|
6397
|
-
volumes.push({
|
|
6398
|
-
no,
|
|
6399
|
-
title: title.slice(0, STRUCTURED_FIELD_LIMITS.chapterTitle),
|
|
6400
|
-
summary: summary.slice(0, STRUCTURED_FIELD_LIMITS.chapterSummary),
|
|
6401
|
-
chapterStart: start ?? 1,
|
|
6402
|
-
chapterEnd: end ?? 1
|
|
6403
|
-
});
|
|
6404
|
-
}
|
|
6405
|
-
if (volumes.length === 0) throw new Error("卷计划生成失败:模型没有返回有效卷");
|
|
6406
|
-
return volumes;
|
|
6407
|
-
}
|
|
6408
|
-
/** Assign a chapter to its volume by number. */
|
|
6409
|
-
function volumeOf(chapterNo, volumes) {
|
|
6410
|
-
if (volumes === void 0 || volumes.length === 0) return 0;
|
|
6411
|
-
for (const volume of volumes) if (chapterNo >= volume.chapterStart && chapterNo <= volume.chapterEnd) return volume.no;
|
|
6412
|
-
return volumes[volumes.length - 1]?.no ?? 0;
|
|
6413
|
-
}
|
|
6414
|
-
/** The chapter-planning prompt template. */
|
|
6415
|
-
function planSystemPrompt(volumes) {
|
|
6416
|
-
return [
|
|
6417
|
-
"你是一位资深中文网文策划编辑,擅长把小说大纲拆解为可执行的章节计划。",
|
|
6418
|
-
"你会收到一份小说大纲。请根据大纲的设定、主线与节奏,规划出一份章节计划。",
|
|
6419
|
-
"要求:",
|
|
6420
|
-
"1. 每章必须有明确的核心剧情推进(不能只是过渡或凑字数)。",
|
|
6421
|
-
"2. 章节之间要衔接自然,前章结尾为后章埋下钩子。",
|
|
6422
|
-
"3. 严格遵循大纲的人设、金手指规则、战力体系与世界观设定,不得自行发明冲突设定。",
|
|
6423
|
-
"4. 输出必须是合法的 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记。",
|
|
6424
|
-
"5. 数组每个元素格式:{\"title\": \"章节标题(10字以内,有网文感)\", \"beats\": \"结构化剧情要点(150-250字,必须包含四段,段间用换行分隔):\\n本章目标:本章要完成的核心推进;\\n剧情要点:主要情节的起承转合(2-4 句);\\n爽点/钩子:本章的爽点兑现或情绪钩子;\\n结尾钩子:本章结尾为下一章埋下的悬念\"}",
|
|
6425
|
-
"6. 每个章节对象可额外包含以下可选字段(尽量给出,缺失则跳过):tension(0-100 整数:本章张力目标,对抗/揭示/抉择/代价的强度;铺垫与日常章明显偏低,与相邻章节形成起伏)、mustAdvance(数组,本章必须推进的局面/关系/信息/风险/决策变化);mustPreserve(数组,本章必须保持不破坏的项);characterHardFacts(数组,本章不可违背的人物硬事实:身份/阵营/境界/当前位置/知情度);endingHook(字符串,本章结尾钩子要求);obligation(字符串,本章义务合约一句话)。",
|
|
6426
|
-
"重要:beats 字段内部必须使用 \\n 转义表示换行(JSON 字符串内不得有真实换行符),其余字符串值也不得包含真实换行符,JSON 必须在一段内完整结束。",
|
|
6427
|
-
"重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
|
|
6428
|
-
volumes !== void 0 && volumes.length > 0 ? ["\n全书分卷结构(规划章节时需落在对应卷内):"].concat(volumes.map((v) => `第${v.no}卷《${v.title}》:${v.summary}(章节 ${v.chapterStart}-${v.chapterEnd})` + (v.strategy !== void 0 && v.strategy !== "" ? `\n 卷战略:${v.strategy}` : "") + (v.pacing !== void 0 && v.pacing !== "" ? `\n 卷节奏板:${v.pacing}` : ""))).join("\n") : ""
|
|
6429
|
-
].join("\n");
|
|
6430
|
-
}
|
|
6431
|
-
/** Build the writing system prompt (bible + outline + active foreshadows).
|
|
6432
|
-
* `targetChars` 来自每章计划(规划时快照,= 设置的每章目标字数);无则退回默认 3500。
|
|
6433
|
-
* 字数区间按目标动态生成(±15%,取整到百位),避免系统提示词与设置互相冲突。
|
|
6434
|
-
* `lengthRule` 可覆盖第 1 条字数要求(整章修订/改编时按原文长度为准)。 */
|
|
6435
|
-
function writeSystemPrompt(project, targetChars, lengthRule, beforeChapter) {
|
|
6436
|
-
const bible = project.bible;
|
|
6437
|
-
const sections = [];
|
|
6438
|
-
if (bible !== void 0) {
|
|
6439
|
-
sections.push("==================== 道藏(写作时严格遵守) ====================");
|
|
6440
|
-
if (bible.genre !== "") sections.push(`题材基调:${bible.genre}`);
|
|
6441
|
-
if (bible.worldRules.length > 0) sections.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
|
|
6442
|
-
const roleLib = project.roles ?? [];
|
|
6443
|
-
const labelName = {
|
|
6444
|
-
protagonist: "主角",
|
|
6445
|
-
female_lead: "女主",
|
|
6446
|
-
female_support: "女配",
|
|
6447
|
-
support: "配角",
|
|
6448
|
-
antagonist: "反派",
|
|
6449
|
-
extra: "路人"
|
|
6450
|
-
};
|
|
6451
|
-
const seenRole = /* @__PURE__ */ new Set();
|
|
6452
|
-
const mergedRoles = [];
|
|
6453
|
-
for (const r of roleLib) {
|
|
6454
|
-
seenRole.add(r.name);
|
|
6455
|
-
const card = bible.characters.find((c) => c.name === r.name);
|
|
6456
|
-
const traits = card !== void 0 ? card.traits : Array.isArray(r.traits) ? r.traits : [];
|
|
6457
|
-
const goals = card !== void 0 && card.goals !== "" ? card.goals : r.goals;
|
|
6458
|
-
const relations = card !== void 0 && card.relations !== "" ? card.relations : Array.isArray(r.relations) && r.relations.length > 0 ? r.relations.join("、") : "";
|
|
6459
|
-
mergedRoles.push(`- ${r.name}(${labelName[r.roleLabel]}):${r.identity}${traits.length > 0 ? `;性格:${traits.join("、")}` : ""}${goals !== "" ? `;目标:${goals}` : ""}${relations !== "" ? `;关系:${relations}` : ""}`);
|
|
6460
|
-
if (card !== void 0 && Array.isArray(card.knowledge) && card.knowledge.length > 0) mergedRoles.push(` 已知信息(该角色知道的:${card.knowledge.join(";")};未列出的信息该角色一律不知道,不得写其知晓或提及)`);
|
|
6461
|
-
}
|
|
6462
|
-
for (const card of bible.characters) {
|
|
6463
|
-
if (seenRole.has(card.name)) continue;
|
|
6464
|
-
seenRole.add(card.name);
|
|
6465
|
-
const roleName = {
|
|
6466
|
-
protagonist: "主角",
|
|
6467
|
-
supporting: "配角",
|
|
6468
|
-
antagonist: "反派",
|
|
6469
|
-
other: "其他"
|
|
6470
|
-
}[card.role];
|
|
6471
|
-
mergedRoles.push(`- ${card.name}(${roleName}):${card.traits.join("、")}${card.goals !== "" ? `;目标:${card.goals}` : ""}${card.relations !== "" ? `;关系:${card.relations}` : ""}`);
|
|
6472
|
-
if (Array.isArray(card.knowledge) && card.knowledge.length > 0) mergedRoles.push(` 已知信息(该角色知道的:${card.knowledge.join(";")};未列出的信息该角色一律不知道,不得写其知晓或提及)`);
|
|
6473
|
-
}
|
|
6474
|
-
if (mergedRoles.length > 0) {
|
|
6475
|
-
sections.push("角色卡(角色库与道藏已合并去重):");
|
|
6476
|
-
sections.push(...mergedRoles);
|
|
6477
|
-
}
|
|
6478
|
-
if (bible.redLines.length > 0) sections.push("写作红线(违反即失败):\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
|
|
6479
|
-
if (bible.style.length > 0) sections.push("风格要求:\n" + bible.style.map((r) => `- ${r}`).join("\n"));
|
|
6480
|
-
}
|
|
6481
|
-
const worldBlock = renderWorld(project.world);
|
|
6482
|
-
if (worldBlock !== "") sections.push(worldBlock);
|
|
6483
|
-
sections.push("==================== 全书大纲 ====================");
|
|
6484
|
-
const outlineBlock = project.outline.length > 6e3 ? project.outline.slice(0, 6e3) + "\n…(大纲过长已节选,完整内容见总纲页)" : project.outline;
|
|
6485
|
-
sections.push(outlineBlock);
|
|
6486
|
-
sections.push("==================== 大纲结束 ====================");
|
|
6487
|
-
const assetsBlock = renderAllAssets(project.assets);
|
|
6488
|
-
if (assetsBlock !== "") sections.push(assetsBlock);
|
|
6489
|
-
const active = project.foreshadows.filter((f) => f.status === "planted" || f.status === "progressing");
|
|
6490
|
-
if (active.length > 0) {
|
|
6491
|
-
sections.push("==================== 活跃伏笔(近期需推进或回收的线索) ====================");
|
|
6492
|
-
for (const f of active) sections.push(`- [${f.status === "planted" ? "已埋设" : "推进中"}] ${f.description}${f.targetChapter !== void 0 ? `(预计 ${f.targetChapter} 章回收)` : ""}`);
|
|
6493
|
-
}
|
|
6494
|
-
const lines = (project.plotlines ?? []).filter((l) => l.status === "active" || l.status === "paused");
|
|
6495
|
-
if (lines.length > 0) {
|
|
6496
|
-
const kindName = {
|
|
6497
|
-
main: "主线",
|
|
6498
|
-
branch: "支线",
|
|
6499
|
-
character: "人物线",
|
|
6500
|
-
mystery: "悬念线"
|
|
6501
|
-
};
|
|
6502
|
-
sections.push("==================== 剧情线(本章应推进至少一条活跃线) ====================");
|
|
6503
|
-
for (const l of lines) sections.push(`- [${kindName[l.kind]}${l.status === "paused" ? "·暂停中" : ""}] ${l.name}:${l.goal}${l.progress !== "" ? `(当前进度:${l.progress})` : ""}`);
|
|
6504
|
-
}
|
|
6505
|
-
sections.push("");
|
|
6506
|
-
sections.push("写作硬性要求:");
|
|
6507
|
-
const target = targetChars !== void 0 && targetChars > 0 ? targetChars : 3500;
|
|
6508
|
-
const lo = Math.max(1e3, Math.round(target * .85 / 100) * 100);
|
|
6509
|
-
const hi = Math.max(lo + 100, Math.round(target * 1.15 / 100) * 100);
|
|
6510
|
-
sections.push(lengthRule ?? `1. 每章 ${lo}-${hi} 字(目标 ${target} 字,按中文字符计),只输出章节正文,不要输出标题、章回名、作者的话或任何 Markdown 标记。`);
|
|
6511
|
-
sections.push("2. 以主角视角展开,动作、对话、心理描写交替推进,禁止大段设定说明。");
|
|
6512
|
-
sections.push("3. 尊重大纲与道藏:人设不崩、金手指规则不自相矛盾、战力不随意膨胀。");
|
|
6513
|
-
sections.push("4. 章末留一个钩子(悬念、反转或新线索),吸引读者读下一章。");
|
|
6514
|
-
sections.push("5. 语言流畅自然,符合中文网文语感,避免翻译腔与病句。");
|
|
6515
|
-
sections.push("6. 对话与冲突密度:每章至少 1 处实质对话或正面对抗/交锋场面;推理与心理活动必须用动作、环境细节、微表情、对话呈现,禁止整章纯内心独白铺陈(禁止\"解说式\"交代线索)。");
|
|
6516
|
-
sections.push("7. 反派与对手的行动力:本章出现的反派/对手必须有其行动、反制或压迫感(布局、试探、追索、交锋至少占其一),不得作为纯背景板存在。");
|
|
6517
|
-
sections.push("8. 配角辨识度:重要新登场配角应给姓名或可辨识的独有特征;禁止通篇用\"瘦高个/灰衣人/戴面具者\"等身形标签代称同一角色。");
|
|
6518
|
-
sections.push("9. 信息呈现方式:关键线索、设定、局势通过对话、动作、发现物呈现,禁止主角内心\"讲解\"给读者听。");
|
|
6519
|
-
sections.push("");
|
|
6520
|
-
sections.push("==================== 内容合规红线(平台硬性要求,最高优先级,违反即失败) ====================");
|
|
6521
|
-
sections.push(COMPLIANCE_REDLINES.join("\n"));
|
|
6522
|
-
sections.push("以上九条为硬性底线,任何情况下不得以任何形式出现或影射;若剧情确需涉及(如批判、反讽),只能以明确否定、揭露、批判的立场呈现,且不得展开细节。");
|
|
6523
|
-
sections.push(renderChapterWriterSkeleton({
|
|
6524
|
-
targetChars: target,
|
|
6525
|
-
minChars: lo,
|
|
6526
|
-
maxChars: hi,
|
|
6527
|
-
pov: "第三人称有限视角,严格跟随主角所见所知。",
|
|
6528
|
-
endingHookPreference: "章末留一个明确钩子(新信息、新风险或未闭合选择)。",
|
|
6529
|
-
tonePreference: "动作、对话、心理交替推进;重大信息用对话/动作/发现呈现。",
|
|
6530
|
-
antiAiRules: "严格遵循上方「反 AI 规则」与写法资产,避免套话与 AI 腔。"
|
|
7357
|
+
const expectedVolumes = Math.min(24, Math.max(3, Math.round(outline.length / 1200)));
|
|
7358
|
+
const parsed = parseJsonArray(await complete(ctx, config, {
|
|
7359
|
+
system: volumeSystemPrompt(),
|
|
7360
|
+
user,
|
|
7361
|
+
temperature: .4,
|
|
7362
|
+
maxTokens: resolveOutputBudget("volume-strategy", { volumes: expectedVolumes })
|
|
6531
7363
|
}));
|
|
6532
|
-
const
|
|
6533
|
-
|
|
6534
|
-
|
|
6535
|
-
|
|
6536
|
-
|
|
7364
|
+
const volumes = [];
|
|
7365
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
7366
|
+
const entry = parsed[i];
|
|
7367
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
7368
|
+
const no = typeof entry.no === "number" ? entry.no : i + 1;
|
|
7369
|
+
const title = typeof entry.title === "string" ? entry.title.trim() : `第${no}卷`;
|
|
7370
|
+
const summary = typeof entry.summary === "string" ? entry.summary.trim() : "";
|
|
7371
|
+
const start = typeof entry.chapterStart === "number" ? entry.chapterStart : void 0;
|
|
7372
|
+
const end = typeof entry.chapterEnd === "number" ? entry.chapterEnd : void 0;
|
|
7373
|
+
volumes.push({
|
|
7374
|
+
no,
|
|
7375
|
+
title: title.slice(0, STRUCTURED_FIELD_LIMITS.chapterTitle),
|
|
7376
|
+
summary: summary.slice(0, STRUCTURED_FIELD_LIMITS.chapterSummary),
|
|
7377
|
+
chapterStart: start ?? 1,
|
|
7378
|
+
chapterEnd: end ?? 1
|
|
7379
|
+
});
|
|
6537
7380
|
}
|
|
6538
|
-
|
|
6539
|
-
|
|
6540
|
-
|
|
7381
|
+
if (volumes.length === 0) throw new Error("卷计划生成失败:模型没有返回有效卷");
|
|
7382
|
+
return volumes;
|
|
7383
|
+
}
|
|
7384
|
+
/** Assign a chapter to its volume by number. */
|
|
7385
|
+
function volumeOf(chapterNo, volumes) {
|
|
7386
|
+
if (volumes === void 0 || volumes.length === 0) return 0;
|
|
7387
|
+
for (const volume of volumes) if (chapterNo >= volume.chapterStart && chapterNo <= volume.chapterEnd) return volume.no;
|
|
7388
|
+
return volumes[volumes.length - 1]?.no ?? 0;
|
|
6541
7389
|
}
|
|
6542
7390
|
/**
|
|
6543
7391
|
* Plan chapters from an outline (optionally for one volume).
|
|
@@ -6614,7 +7462,7 @@ async function planChapters(ctx, config, project, chapterCount, volumeNo, output
|
|
|
6614
7462
|
`请规划 ${chapterCount} 章。输出 JSON 数组(不要输出其他文字):`
|
|
6615
7463
|
].join("\n");
|
|
6616
7464
|
const parsed = await completeJsonArray(ctx, config, {
|
|
6617
|
-
system: planSystemPrompt(project.volumes) + (continuation ?
|
|
7465
|
+
system: planSystemPrompt(project.volumes) + (continuation ? planContinuationSuffix() : ""),
|
|
6618
7466
|
user,
|
|
6619
7467
|
temperature: .7,
|
|
6620
7468
|
maxTokens: resolveOutputBudget("chapter-list", { chapters: chapterCount }),
|
|
@@ -6688,49 +7536,6 @@ function retrieveKnowledge(project, query, topN = 3) {
|
|
|
6688
7536
|
if (top.length === 0) return "";
|
|
6689
7537
|
return "【书内知识库参考(与本章相关,写作时须遵守/可引用)】" + (hits.length > top.length ? `(命中 ${hits.length} 条,此处只注入最相关的 ${top.length} 条)` : "") + "\n" + top.map((x) => `- 《${x.d.title}》:${x.d.content.slice(0, 300)}`).join("\n");
|
|
6690
7538
|
}
|
|
6691
|
-
/** The review system prompt. */
|
|
6692
|
-
function reviewSystemPrompt(project) {
|
|
6693
|
-
const bible = project.bible;
|
|
6694
|
-
const sections = [
|
|
6695
|
-
"你是一位严格的网文审稿编辑。你会收到一章正文以及本书的道藏与红线。",
|
|
6696
|
-
"请从以下维度审查本章:",
|
|
6697
|
-
"1. 人设一致性:角色行为是否符合下方角色卡的设定(性格/目标/知情度/说话方式)。",
|
|
6698
|
-
"2. 设定一致性:金手指规则、战力体系、世界观是否与道藏冲突。",
|
|
6699
|
-
"3. 红线检查:是否触犯下方「本书红线」与「内容合规红线」。",
|
|
6700
|
-
"4. 文笔质量:语病、翻译腔、AI 套话(\"不禁\"\"仿佛\"\"一时间\"等高频词滥用)、流水账。",
|
|
6701
|
-
"5. 节奏与爽点:本章是否有推进、有钩子,是否拖沓灌水。",
|
|
6702
|
-
"6. 逻辑漏洞:前后矛盾、时间线错误、对话失真。",
|
|
6703
|
-
"7. 反 AI 规则:下方「本地 AI 味扫描」里已给出**本轮确定性命中的规则与次数**,直接以它为事实依据判级(禁止类命中即列为问题;鼓励类只作低优先级建议、不阻塞通过)。未列出的规则说明本章无确定性命中,不必逐条复述,也不要凭印象补报。",
|
|
6704
|
-
"8. 呈现方式:整章是否纯内心推理铺陈(无对话/无对抗,推理全靠解说);反派是否纯背景板无行动;重要配角是否无名标签化(瘦高个/灰衣人全程代称)——命中即列为问题。",
|
|
6705
|
-
"9. 内容合规(最高优先级):逐条核对下方「内容合规红线」,任何一条命中(含影射、暗示、详细描写)必须列为 high,并给出改写建议。",
|
|
6706
|
-
"输出必须是合法 JSON 对象,不要输出任何其他文字:",
|
|
6707
|
-
"{\"score\": 0-100的整数, \"riskScore\": 0-100的整数(越高越需人工处理,可结合本地AI味指数), \"tension\": 0-100的整数(本章张力强度:对抗/揭示/抉择/代价;铺垫与日常章应明显偏低), \"verdict\": \"一句话总评\", \"issues\": [{\"severity\": \"high|medium|low\", \"dimension\": \"character|setting|redline|writing|pacing|logic|anti-ai|presentation|compliance\", \"item\": \"问题描述\", \"suggestion\": \"修改建议\", \"ruleName\": \"命中的反AI规则名(见反AI规则清单)\", \"ruleType\": \"forbidden|risk|encourage\", \"category\": \"套话|句式|段落|心理|设定|节奏|对话|其他\", \"excerpt\": \"命中的原文摘录(不超过50字)\", \"reason\": \"判定理由\", \"canAutoRewrite\": true|false}]}",
|
|
6708
|
-
"tension 是本章张力强度评分(不是质量分):对决/揭秘/生死抉择偏高,铺垫章、日常章、过渡章偏低;同一本书内保持同一尺度。",
|
|
6709
|
-
"反 AI 类 issue 尽量给出 ruleName/ruleType/category/excerpt/reason/canAutoRewrite,便于统计与自动改写。",
|
|
6710
|
-
"风格符合度:若下方给出「风格指纹比对」,以它为准判断本章是否偏离绑定写法——偏差显著时按 writing 维度、category 用「风格」上报;比对里说没有显著偏差时,不要报风格问题。",
|
|
6711
|
-
"AI 套话高频模板词示例(集中出现必须整体降密度):仿佛、似乎、极其、完美、深不见底、形成了、莫名、无法形容、难以言喻、精心雕琢、肤光胜雪、眉目如画、歌舞升平、觥筹交错、妙语连珠、不可名状、另一层真相、命运、真相。",
|
|
6712
|
-
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
6713
|
-
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
6714
|
-
];
|
|
6715
|
-
const assetsBlock = renderAllAssets(project.assets);
|
|
6716
|
-
if (assetsBlock !== "") sections.push("\n" + assetsBlock);
|
|
6717
|
-
if (bible !== void 0) {
|
|
6718
|
-
sections.push("\n==================== 道藏 ====================");
|
|
6719
|
-
if (bible.worldRules.length > 0) sections.push("世界规则:\n" + bible.worldRules.map((r) => `- ${r}`).join("\n"));
|
|
6720
|
-
if (bible.characters.length > 0) {
|
|
6721
|
-
sections.push("角色卡:");
|
|
6722
|
-
for (const card of bible.characters) {
|
|
6723
|
-
sections.push(`- ${card.name}(${card.role}):${card.traits.join("、")}`);
|
|
6724
|
-
if (Array.isArray(card.knowledge) && card.knowledge.length > 0) sections.push(` 该角色知道:${card.knowledge.join(";")}(未列出的信息该角色不知道)`);
|
|
6725
|
-
}
|
|
6726
|
-
}
|
|
6727
|
-
if (bible.redLines.length > 0) sections.push("红线:\n" + bible.redLines.map((r) => `- ${r}`).join("\n"));
|
|
6728
|
-
}
|
|
6729
|
-
sections.push("\n==================== 内容合规红线(平台硬性要求,最高优先级) ====================");
|
|
6730
|
-
sections.push(COMPLIANCE_REDLINES.join("\n"));
|
|
6731
|
-
sections.push("以上九条为硬性底线:正文中任何一条命中(含影射、暗示、详细展开)都必须列为 high,并给出改写建议;作者自定义红线不得豁免这九条。");
|
|
6732
|
-
return sections.join("\n");
|
|
6733
|
-
}
|
|
6734
7539
|
/**
|
|
6735
7540
|
* 风格基线的取样上限与取法。
|
|
6736
7541
|
*
|
|
@@ -6929,76 +7734,6 @@ async function reviewChapterText(ctx, config, project, text, previousReport) {
|
|
|
6929
7734
|
reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6930
7735
|
};
|
|
6931
7736
|
}
|
|
6932
|
-
/** 验证模式系统提示:修订后逐条核对原意见是否解决,只挑新增 high,不重复挑剔主观项。 */
|
|
6933
|
-
function verifySystemPrompt(project) {
|
|
6934
|
-
return [
|
|
6935
|
-
"你是一位网文审稿验证员。作者已按上一轮审稿意见修订了本章,你需要验证修订效果。",
|
|
6936
|
-
"你的任务(严格按此执行):",
|
|
6937
|
-
"1. 逐条核对「上一轮意见」中的每一条(按编号 1、2、3...)是否已在修订稿中解决。",
|
|
6938
|
-
"2. 只挑修订【新引入】的 high 级问题(设定矛盾/逻辑硬伤/事实错误)——新引入的 medium/low 主观项(文笔/套话/节奏)不要列。",
|
|
6939
|
-
"3. 禁止重复挑剔上一轮已指出且本次已解决的主观项(如\"缓缓/微微\"等套话、错别字)——即使换个说法再提也不行。",
|
|
6940
|
-
"4. 严禁为了显得专业而新增\"换一批毛病\"式的意见。",
|
|
6941
|
-
"输出必须是合法 JSON 对象,包含以下字段:",
|
|
6942
|
-
"- resolvedIds:已解决的上一轮意见编号数组(如 [1, 3, 5])。",
|
|
6943
|
-
"- unresolvedIds:未解决或部分解决的上一轮意见编号数组(如 [2, 4])。",
|
|
6944
|
-
"- issues:未解决的原意见 + 新引入的 high 级问题列表(格式同审稿:severity/item/suggestion)。未解决的原意见 item 需注明\"未解决(编号N):原意见摘要\"。",
|
|
6945
|
-
"- score:按修订稿整体质量给 50-90 分(解决全部 high 且无新增 high 时给 70 以上)。",
|
|
6946
|
-
"- verdict:一句话结论。",
|
|
6947
|
-
"完整格式:{\"resolvedIds\": [1,3], \"unresolvedIds\": [2], \"score\": 75, \"verdict\": \"一句话\", \"issues\": [{\"severity\": \"high\", \"dimension\": \"character|setting|redline|writing|pacing|logic|anti-ai|presentation|compliance\", \"item\": \"未解决(2):xxx\", \"suggestion\": \"xxx\"}]}",
|
|
6948
|
-
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
6949
|
-
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。",
|
|
6950
|
-
renderBibleForVerify(project)
|
|
6951
|
-
].join("\n");
|
|
6952
|
-
}
|
|
6953
|
-
/**
|
|
6954
|
-
* 验证模式下注入道藏。
|
|
6955
|
-
*
|
|
6956
|
-
* 原实现是 `JSON.stringify(project.bible).slice(0, 3000)`——有两个问题:
|
|
6957
|
-
* 1. **腰斩的 JSON 不是 JSON**:`slice` 从中间切断,审稿员会看到语法残缺的
|
|
6958
|
-
* 对象(字符串没闭合、数组没结尾),既浪费 token 又可能误读设定。
|
|
6959
|
-
* 2. **静默截断设定**:世界规则/角色卡排在后面的字段最先被切掉,而那恰恰是
|
|
6960
|
-
* 核对设定冲突最需要的部分。
|
|
6961
|
-
*
|
|
6962
|
-
* 改为结构化渲染 + 按「世界规则/角色/红线」分配独立预算。
|
|
6963
|
-
*/
|
|
6964
|
-
function renderBibleForVerify(project) {
|
|
6965
|
-
const bible = project.bible;
|
|
6966
|
-
if (bible === void 0) return "本书道藏(核对设定冲突用):\n(无)";
|
|
6967
|
-
const parts = [];
|
|
6968
|
-
if (bible.worldRules.length > 0) parts.push(`世界规则:\n${bible.worldRules.map((r) => `- ${r}`).join("\n")}`);
|
|
6969
|
-
if (bible.characters.length > 0) parts.push(`角色卡:\n${bible.characters.map((c) => {
|
|
6970
|
-
const goals = c.goals !== "" ? `;目标:${c.goals}` : "";
|
|
6971
|
-
const rel = c.relations !== "" ? `;关系:${c.relations}` : "";
|
|
6972
|
-
return `- ${c.name}:${c.traits.join("、")}${goals}${rel}`;
|
|
6973
|
-
}).join("\n")}`);
|
|
6974
|
-
if (bible.redLines.length > 0) parts.push(`红线:\n${bible.redLines.map((r) => `- ${r}`).join("\n")}`);
|
|
6975
|
-
if (parts.length === 0) return "本书道藏(核对设定冲突用):\n(无)";
|
|
6976
|
-
const block = parts.join("\n");
|
|
6977
|
-
const limit = 6e3;
|
|
6978
|
-
return `本书道藏(核对设定冲突用):\n${block.length > limit ? block.slice(0, limit) + `\n…(道藏过长已节选,但角色名/世界规则已尽量保留;如需完整设定请查总纲页)` : block}`;
|
|
6979
|
-
}
|
|
6980
|
-
/** Build the author-review system prompt (narrative structure, not prose). */
|
|
6981
|
-
function authorReviewSystemPrompt() {
|
|
6982
|
-
return [
|
|
6983
|
-
"你是一位网文作者复盘助手。你会收到:本章正文、上一章结尾(钩子)、上一章作者复盘(如有)、活跃剧情线与编年录近期事实。",
|
|
6984
|
-
"请从叙事结构层面复盘本章(不评文笔,那是审稿的事):",
|
|
6985
|
-
"1. hookHonored:上一章结尾的钩子/悬念是否在本章兑现或推进(true/false)。",
|
|
6986
|
-
"2. hookNote:钩子兑现情况一句话;未兑现时说明并给出\"建议在第几章补\"的建议。",
|
|
6987
|
-
"3. endingHook:本章结尾钩子强度,0-10 的整数(低于 6 说明结尾平淡,读者可能不想看下一章)。",
|
|
6988
|
-
"4. plotlineProgress:本章推进了哪条剧情线(主线/支线名),或\"无实质推进\"(连续无推进要提醒)。",
|
|
6989
|
-
"5. advancedLines:本章实际推进的剧情线名称数组——从「活跃剧情线」清单中选出推进了的线(名称必须与清单中的线名一字不差;没推进任何线则输出空数组)。",
|
|
6990
|
-
"6. continuity:与上一章结尾的衔接检查(人物位置/时间/伤势/资源/对话状态),发现问题要指出。",
|
|
6991
|
-
"7. trend:结合上一章复盘看近期节奏趋势(是否连续拖沓、爽点密度是否下降、是否需要调整)。",
|
|
6992
|
-
"8. stateChanges:本章发生的关键状态变化数组(人物状态/世界局面/关系/资源,每条约 20 字,最多 6 条),供整本与事实库回灌。",
|
|
6993
|
-
"9. newConflicts:本章新引入或明显升级的冲突数组(最多 4 条)。",
|
|
6994
|
-
"10. clues:本章埋下或推进的新线索/伏笔数组(最多 4 条)。",
|
|
6995
|
-
"11. absentRisks:本章缺席但按卷级职责应出场/该推进关系的重要角色,及其缺席带来的风险(最多 4 条)。",
|
|
6996
|
-
"输出必须是合法 JSON 对象,不要输出任何其他文字:",
|
|
6997
|
-
"{\"hookHonored\": true或false, \"hookNote\": \"一句话\", \"endingHook\": 0-10整数, \"plotlineProgress\": \"一句话\", \"advancedLines\": [\"线名\"], \"continuity\": \"一句话\", \"trend\": \"一句话\", \"stateChanges\": [\"状态变化\"], \"newConflicts\": [\"新冲突\"], \"clues\": [\"新线索\"], \"absentRisks\": [\"本章缺席但值得注意的角色及风险(卷级职责/缺席风险,最多4条)\"]}",
|
|
6998
|
-
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
6999
|
-
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
7000
|
-
].join("\n");
|
|
7001
|
-
}
|
|
7002
7737
|
/** 作者复盘:对一章做叙事结构复盘(钩子兑现/结尾钩子/推进/连续性/趋势)。 */
|
|
7003
7738
|
async function authorReviewChapter(ctx, config, project, chapterNo, body, prevTail) {
|
|
7004
7739
|
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
@@ -7048,14 +7783,7 @@ function autoLinkPlotlines(project, chapterNo, advancedLines) {
|
|
|
7048
7783
|
}
|
|
7049
7784
|
/** 建议剧情线:基于大纲/卷计划/已写章节/编年录,提炼候选线。 */
|
|
7050
7785
|
async function suggestPlotlines(ctx, config, project) {
|
|
7051
|
-
const system =
|
|
7052
|
-
"你是一位网文剧情架构师。根据本书的大纲、卷计划、已写章节标题与编年录,为作者提炼建议的剧情线(主线/支线/人物线/悬念线)。",
|
|
7053
|
-
"每条线要:名称简洁有力;目标写清楚这条线最终要完成什么;progress 写当前推进到哪(没有就空字符串)。",
|
|
7054
|
-
"建议 4-8 条,覆盖:1 条主线、1-2 条人物线、1-2 条悬念线、1-3 条支线。避免与大纲明显重复的废话线。",
|
|
7055
|
-
"输出必须是合法 JSON 数组,格式:[{\"name\": \"线名\", \"kind\": \"main|branch|character|mystery\", \"goal\": \"目标\", \"progress\": \"\"}]",
|
|
7056
|
-
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
7057
|
-
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
7058
|
-
].join("\n");
|
|
7786
|
+
const system = suggestPlotlinesSystemPrompt();
|
|
7059
7787
|
const written = project.chapters.filter((c) => c.status !== "pending");
|
|
7060
7788
|
const raw = parseJsonArray(await complete(ctx, config, {
|
|
7061
7789
|
system,
|
|
@@ -7106,12 +7834,7 @@ async function refreshPlotlineProgress(ctx, config, project, target) {
|
|
|
7106
7834
|
}
|
|
7107
7835
|
/** 实际的批量刷新实现(被上面的重载收敛到一处)。 */
|
|
7108
7836
|
async function refreshLinesFor(ctx, config, project, lines) {
|
|
7109
|
-
const system =
|
|
7110
|
-
lines.length === 1 ? "你是一位网文剧情线管理员。请根据「剧情线信息」与「本书已写章节摘要/编年录」,判断这条线目前推进到了哪一步。" : `你是一位网文剧情线管理员。下面给了 ${lines.length} 条剧情线,请**逐条**判断各自推进到了哪一步。`,
|
|
7111
|
-
"每条线输出一句话(30-60 字):该线当前的状态、最近一次推进发生在第几章、下一步可能的方向。还没开始推进的线,明确说\"尚未推进\"。",
|
|
7112
|
-
lines.length === 1 ? "输出必须是合法 JSON 对象:{\"progress\": \"一句话\"}" : `输出必须是合法 JSON 对象:{"items": [{"name": "线名(与输入完全一致)", "progress": "一句话"}]},items 的条数必须等于 ${lines.length},name 必须与输入里的线名逐字相同(用于对齐)`,
|
|
7113
|
-
"重要:不要输出任何其他文字。"
|
|
7114
|
-
].join("\n");
|
|
7837
|
+
const system = refreshPlotlinesSystemPrompt(lines.length);
|
|
7115
7838
|
const sharedContext = [`章节摘要(最近 8 章):\n${project.chapters.filter((c) => c.status !== "pending" && c.summary !== void 0 && c.summary !== "").slice(-8).map((c) => `第${c.no}章《${c.title}》:${c.summary.slice(0, 120)}`).join("\n")}`, (project.facts ?? []).length > 0 ? `编年录近期事实(最近 15 条):\n${(project.facts ?? []).slice(-15).map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 100)}`).join("\n")}` : ""];
|
|
7116
7839
|
const describe = (l) => [
|
|
7117
7840
|
`剧情线:${l.name}(${l.kind})`,
|
|
@@ -7146,28 +7869,7 @@ async function refreshLinesFor(ctx, config, project, lines) {
|
|
|
7146
7869
|
}
|
|
7147
7870
|
/** AI 从全书提炼角色库:大纲 + 道藏 + 编年录 + 章节摘要 → 结构化角色清单。 */
|
|
7148
7871
|
async function extractRoles(ctx, config, project) {
|
|
7149
|
-
const system =
|
|
7150
|
-
"你是一位网文角色库管理员。请根据本书的大纲、设定、编年录与章节摘要,提炼完整的角色库。",
|
|
7151
|
-
"覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如\"矮胖姑娘\")用其身份简称收录并标注;反复出现且有剧情作用的身份型角色(站长、律师、警察、法官、店主等)必须收录。",
|
|
7152
|
-
"数量控制:最多输出 20 个角色;覆盖优先——主角、主要反派、重要配角(女主/关键配角)必须全收,所有有名有姓者必收;只有真正一次性路人(无名字、无剧情作用)才可省略。",
|
|
7153
|
-
"重要:正常一部完整故事应提炼 8-20 个角色;若少于 6 个通常说明漏提炼,必须重新核对正文摘录。",
|
|
7154
|
-
"重要:正文中若有明确的主角与主要反派,必须分别以 protagonist / antagonist 收录,禁止遗漏;反派确实未出场时才可省略。",
|
|
7155
|
-
"输出优先级:主角(protagonist)与主要反派(antagonist)必须优先输出并完整刻画,其次女主/重要配角;判断不出名字时用正文中的身份称呼。",
|
|
7156
|
-
"每个角色输出:",
|
|
7157
|
-
"1. name:角色名(或身份简称)。主角/有名配角必须用正文中实际出现的人名(如「沈放」),禁止用「主角(38岁超市理货员)」这类把身份塞进名字的占位名;正文确实没点名时才可用身份简称(如「富商」「灰衣老人」)。",
|
|
7158
|
-
"2. roleLabel:定位——protagonist=主角;female_lead=女主(唯一知己/感情线核心,无后宫前提下只此一位);female_support=重要女配;support=普通配角;antagonist=反派;extra=路人/背景。",
|
|
7159
|
-
"3. identity:身份一句话(宗门/势力/血脉/职业)。",
|
|
7160
|
-
"4. traits:3-6 个性格标签。",
|
|
7161
|
-
"5. goals:目标与动机一句话。",
|
|
7162
|
-
"6. relations:关系网数组,格式[\"角色名(关系)\", ...]。",
|
|
7163
|
-
"7. arc:成长线数组,格式[\"阶段:说明\", ...](如\"出场:祭品身份\"/\"转折:祭祀被中断脱身\")。",
|
|
7164
|
-
"8. knowledge:该角色已经知道的关键信息(3-8 条),不知道的信息不要写进去。",
|
|
7165
|
-
"精简要求:identity 控制在 30 字内;traits 3-6 个短标签;goals 60 字内;relations 2-5 条;arc 2-4 条;knowledge 每条 40 字内。整体输出量要紧凑,避免冗长。",
|
|
7166
|
-
"重要:用户消息里列出的「已收录角色」绝不要再次输出——这些角色已经在角色库里,跳过它们,只提炼未收录的。",
|
|
7167
|
-
"输出必须是合法 JSON 数组,不要输出其他文字:[{\"name\":\"...\", \"roleLabel\":\"...\", \"identity\":\"...\", \"traits\":[...], \"goals\":\"...\", \"relations\":[...], \"arc\":[...], \"knowledge\":[...]}]",
|
|
7168
|
-
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
7169
|
-
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
7170
|
-
].join("\n");
|
|
7872
|
+
const system = extractRolesSystemPrompt();
|
|
7171
7873
|
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
|
|
7172
7874
|
const existingRoles = project.roles ?? [];
|
|
7173
7875
|
const sampleChapters = written.length <= 10 ? written : (() => {
|
|
@@ -7265,8 +7967,7 @@ async function extractRoles(ctx, config, project) {
|
|
|
7265
7967
|
if (role !== void 0) roles.push(role);
|
|
7266
7968
|
}
|
|
7267
7969
|
if (roles.length > 0) {
|
|
7268
|
-
const
|
|
7269
|
-
const patchSystem = system + "\n上一次已提炼角色:" + names + "。\n现在只输出「遗漏的角色」JSON 数组:检查正文摘录中反复出现、有固定身份称呼(如站长、律师、警察、法官、店主、老师)且对剧情有作用的角色;一次性路人不要输出。没有遗漏就输出 []。字段与上面相同。";
|
|
7970
|
+
const patchSystem = system + roleCatchUpSuffix(roles.map((r) => r.name).join("、"));
|
|
7270
7971
|
try {
|
|
7271
7972
|
const patchRaw = parseJsonArray(await complete(ctx, config, {
|
|
7272
7973
|
system: patchSystem,
|
|
@@ -7542,23 +8243,7 @@ function importBookText(filePath, outputDir) {
|
|
|
7542
8243
|
async function suggestOutlines(ctx, config, idea, count = 3, exclude = []) {
|
|
7543
8244
|
const n = Math.max(1, Math.min(3, Math.floor(count)));
|
|
7544
8245
|
const parsed = parseJsonArray(await complete(ctx, config, {
|
|
7545
|
-
system:
|
|
7546
|
-
"你是一位资深网文策划。作者只给了一句「想法」,你需要把它扩展成 2-3 个【方向差异明显】的完整小说大纲方案,供作者挑选。",
|
|
7547
|
-
"每个方案必须满足:",
|
|
7548
|
-
"1. bookName:书名(6 字以内,抓眼球、点题)。",
|
|
7549
|
-
"2. genre:题材(如 仙侠修真 / 都市异能 / 玄幻 / 悬疑)。",
|
|
7550
|
-
"3. sellingPoint:核心卖点一句话(金手指/爽点/差异化,40 字内)。",
|
|
7551
|
-
"4. outline:完整大纲文本(至少 800 字,可直接作为开书大纲),结构包含:书名与题材、金手指/核心设定、主角人设与动机、主线剧情走向(至少 5 个阶段)、关键配角与势力、卖点与爽点设计、预计分卷(3-5 卷)。",
|
|
7552
|
-
"方向差异要求:",
|
|
7553
|
-
"- 方案之间的金手指/剧情走向必须明显不同(如:苟道发育流 vs 随身老爷爷流 vs 群像争霸流),不能只是换书名。",
|
|
7554
|
-
"- 忠实于作者想法的核心要素,但允许在不同方向上进行合理演绎。",
|
|
7555
|
-
"- 不输出任何与已列「需避开的方向」雷同的方案。",
|
|
7556
|
-
"输出必须是合法 JSON 数组,只输出数组本身:",
|
|
7557
|
-
"[{\"id\": \"唯一id\", \"bookName\": \"...\", \"genre\": \"...\", \"sellingPoint\": \"...\", \"outline\": \"...\"}]",
|
|
7558
|
-
`本次只输出 ${n} 个方案。`,
|
|
7559
|
-
"重要:所有字符串值内部不得包含换行符(大纲内部分段请用「。\n」或「;」自然断句),JSON 必须在一段内完整结束。",
|
|
7560
|
-
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
7561
|
-
].join("\n"),
|
|
8246
|
+
system: outlineCandidatesSystemPrompt(n),
|
|
7562
8247
|
user: [
|
|
7563
8248
|
`作者的想法:${idea}`,
|
|
7564
8249
|
idea.trim().length < 40 ? "作者的想法非常简短(可能只有一句)。请基于通用网文套路合理扩展补全:为每个方案自洽地设计金手指/核心设定、主角人设与动机、主线走向,使其成为完整可开书的大纲;不同方案的方向仍须明显差异。" : "",
|
|
@@ -7620,13 +8305,7 @@ async function breakdownBook(ctx, config, project, outputDir, scope = "recent",
|
|
|
7620
8305
|
}
|
|
7621
8306
|
const notes = [];
|
|
7622
8307
|
let usedTokens = 0;
|
|
7623
|
-
const noteSystem =
|
|
7624
|
-
"你是中文网文拆书助手。把单章正文整理成结构化笔记,供后续章节级分析复用。",
|
|
7625
|
-
"只输出 JSON 对象:",
|
|
7626
|
-
"{\"summary\": \"1-2句\", \"plotPoints\": [\"...\"], \"characters\": [\"...\"], \"worldbuilding\": [\"...\"], \"styleTechniques\": [\"...\"], \"marketHighlights\": [\"...\"], \"weaknessSignals\": [\"...\"]}",
|
|
7627
|
-
"硬规则:只提取正文明确出现的信息;每数组最多 4 项;不要补写原文外的动机/意图;evidence 不在此阶段输出。",
|
|
7628
|
-
"重要:直接输出 JSON,不要输出其他文字;字符串内不含换行。"
|
|
7629
|
-
].join("\n");
|
|
8308
|
+
const noteSystem = breakdownNoteSystemPrompt();
|
|
7630
8309
|
for (const ch of chunks) {
|
|
7631
8310
|
const noteUser = [
|
|
7632
8311
|
`第${ch.no}章《${ch.title}》`,
|
|
@@ -7646,63 +8325,7 @@ async function breakdownBook(ctx, config, project, outputDir, scope = "recent",
|
|
|
7646
8325
|
usedTokens += 800;
|
|
7647
8326
|
} catch {}
|
|
7648
8327
|
}
|
|
7649
|
-
const sectionsConfig =
|
|
7650
|
-
{
|
|
7651
|
-
key: "overview",
|
|
7652
|
-
title: "拆书总览",
|
|
7653
|
-
focus: "一句话定位、题材标签、整体优势与短板",
|
|
7654
|
-
system: [
|
|
7655
|
-
"你是资深中文网文拆书分析师,负责《拆书总览》小节。",
|
|
7656
|
-
"基于给定章节笔记做低风险综合判断,输出 JSON:{\"markdown\": \"可直接展示的分析稿(简体中文,先给结论再说明体现在哪、为何成立)\", \"structured\": {\"oneLinePositioning\": \"一句话定位\", \"genreTags\": [\"题材标签\"], \"sellingPointTags\": [\"卖点标签\"], \"strengths\": [\"整体优势\"], \"weaknesses\": [\"整体短板\"]}}",
|
|
7657
|
-
"硬规则:只基于笔记归纳;推断用「更偏向/可能」等谨慎措辞;证据不足写「材料不足」;不虚构原文细节。",
|
|
7658
|
-
"重要:直接输出 JSON,字符串内不含换行。"
|
|
7659
|
-
].join("\n")
|
|
7660
|
-
},
|
|
7661
|
-
{
|
|
7662
|
-
key: "plot",
|
|
7663
|
-
title: "剧情结构",
|
|
7664
|
-
focus: "主线梗概、阶段推进、冲突升级、节奏风险",
|
|
7665
|
-
system: [
|
|
7666
|
-
"你是资深中文网文拆书分析师,负责《剧情结构》小节。",
|
|
7667
|
-
"基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"mainlineSummary\": \"主线梗概\", \"phaseProgressions\": [\"阶段推进\"], \"escalationDesigns\": [\"冲突升级\"], \"paceRisks\": [\"节奏风险\"], \"reusablePatterns\": [\"可复用套路\"]}}",
|
|
7668
|
-
"硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
|
|
7669
|
-
"重要:直接输出 JSON,字符串内不含换行。"
|
|
7670
|
-
].join("\n")
|
|
7671
|
-
},
|
|
7672
|
-
{
|
|
7673
|
-
key: "character",
|
|
7674
|
-
title: "人物系统",
|
|
7675
|
-
focus: "主角定位、配角功能、关系网络、成长弧线、辨识度风险",
|
|
7676
|
-
system: [
|
|
7677
|
-
"你是资深中文网文拆书分析师,负责《人物系统》小节。",
|
|
7678
|
-
"基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"protagonistPositioning\": \"主角定位\", \"supportingFunctions\": [\"配角功能\"], \"relationshipNetwork\": [\"关系网络\"], \"growthArcs\": [\"成长弧线\"], \"clarityRisks\": [\"辨识度风险\"]}}",
|
|
7679
|
-
"硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
|
|
7680
|
-
"重要:直接输出 JSON,字符串内不含换行。"
|
|
7681
|
-
].join("\n")
|
|
7682
|
-
},
|
|
7683
|
-
{
|
|
7684
|
-
key: "style",
|
|
7685
|
-
title: "文风与技法",
|
|
7686
|
-
focus: "叙事视角、语言风格、描写方式、节奏控制、钩子设计、可复用写法",
|
|
7687
|
-
system: [
|
|
7688
|
-
"你是资深中文网文拆书分析师,负责《文风与技法》小节。",
|
|
7689
|
-
"基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"narrativePov\": \"叙事视角\", \"languageStyle\": \"语言风格\", \"dialoguePatterns\": [\"对话特征\"], \"rhythmControl\": [\"节奏控制\"], \"hookDesigns\": [\"钩子设计\"], \"reusableTechniques\": [\"可复用写法\"]}}",
|
|
7690
|
-
"硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
|
|
7691
|
-
"重要:直接输出 JSON,字符串内不含换行。"
|
|
7692
|
-
].join("\n")
|
|
7693
|
-
}
|
|
7694
|
-
];
|
|
7695
|
-
if (preset === "standard") sectionsConfig.push({
|
|
7696
|
-
key: "market",
|
|
7697
|
-
title: "商业化卖点",
|
|
7698
|
-
focus: "读者爽点、点击驱动、人物/题材卖点、商业化风险",
|
|
7699
|
-
system: [
|
|
7700
|
-
"你是资深中文网文拆书分析师,负责《商业化卖点》小节。",
|
|
7701
|
-
"基于给定章节笔记分析,输出 JSON:{\"markdown\": \"分析稿(简体中文,先结论后依据)\", \"structured\": {\"hookPoints\": [\"读者爽点\"], \"clickDrivers\": [\"点击驱动\"], \"characterSellingPoints\": [\"人物卖点\"], \"genreSellingPoints\": [\"题材卖点\"], \"commercialRisks\": [\"商业化风险\"]}}",
|
|
7702
|
-
"硬规则:只基于笔记归纳;推断谨慎措辞;不虚构。",
|
|
7703
|
-
"重要:直接输出 JSON,字符串内不含换行。"
|
|
7704
|
-
].join("\n")
|
|
7705
|
-
});
|
|
8328
|
+
const sectionsConfig = breakdownSections(preset);
|
|
7706
8329
|
const notesText = notes.join("\n\n");
|
|
7707
8330
|
const sections = [];
|
|
7708
8331
|
const evidence = [];
|
|
@@ -7969,18 +8592,7 @@ async function registerLlmModel(ctx, req) {
|
|
|
7969
8592
|
}
|
|
7970
8593
|
/** 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
|
|
7971
8594
|
async function analyzePlotlineHealth(ctx, config, project) {
|
|
7972
|
-
const system =
|
|
7973
|
-
"你是一位网文剧情架构师。请对本书的「剧情线体系」做健康检查,判断当前是否需要新增剧情线、应在多少章后添加。",
|
|
7974
|
-
"评估维度:各线最近推进到第几章(已写章节与关联章节的差值越大越危险)、各线状态、已写章节总数、卷计划当前进度、编年录近期事实。",
|
|
7975
|
-
"输出规则:",
|
|
7976
|
-
"1. verdict:一句话结论——\"需要新增线\" / \"暂不需要\" / \"再写 N 章后需要\"(N 给出具体章数)。",
|
|
7977
|
-
"2. timing:说明建议添加的时机(如:第 25 章前引入新支线,因为主线预计第 22 章告一段落)。",
|
|
7978
|
-
"3. reasons:3-5 条依据(引用具体数据:哪条线多少章没推进、已写章节数、卷进度等)。",
|
|
7979
|
-
"4. lines:对每条线给健康度——ok(近期推进过)/ warning(超过 5 章未推进)/ stale(超过 10 章未推进或悬置过久)。",
|
|
7980
|
-
"输出必须是合法 JSON 对象:{\"verdict\": \"...\", \"timing\": \"...\", \"reasons\": [\"...\"], \"lines\": [{\"name\": \"线名\", \"health\": \"ok|warning|stale\", \"note\": \"一句说明\"}]}",
|
|
7981
|
-
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
7982
|
-
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
7983
|
-
].join("\n");
|
|
8595
|
+
const system = plotlineHealthSystemPrompt();
|
|
7984
8596
|
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
|
|
7985
8597
|
const lines = (project.plotlines ?? []).filter((l) => l.status === "active" || l.status === "paused");
|
|
7986
8598
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
@@ -8015,13 +8627,7 @@ async function analyzePlotlineHealth(ctx, config, project) {
|
|
|
8015
8627
|
}
|
|
8016
8628
|
/** 剧情方案:基于健康检查结果设计下一阶段方向与建议新线。 */
|
|
8017
8629
|
async function designPlotlinePlan(ctx, config, project, health) {
|
|
8018
|
-
const system =
|
|
8019
|
-
"你是一位网文剧情架构师。请为本书设计「下一阶段的剧情方案」:给出未来 5-10 章的剧情方向,并建议 2-3 条值得新增的剧情线。",
|
|
8020
|
-
"要求:方向必须结合本书大纲/卷计划/现有线/编年录;新线要能落地(和当前主角处境、已有伏笔、下一阶段舞台相关),不得重复已有线。",
|
|
8021
|
-
"输出必须是合法 JSON 对象:{\"direction\": \"下一阶段方向 60-120 字\", \"suggestions\": [{\"name\": \"线名\", \"kind\": \"main|branch|character|mystery\", \"goal\": \"目标\", \"progress\": \"初始进度(可空)\"}]}",
|
|
8022
|
-
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
8023
|
-
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
8024
|
-
].join("\n");
|
|
8630
|
+
const system = plotlinePlanSystemPrompt();
|
|
8025
8631
|
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
|
|
8026
8632
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
8027
8633
|
system,
|
|
@@ -8062,12 +8668,8 @@ async function designPlotlinePlan(ctx, config, project, health) {
|
|
|
8062
8668
|
}
|
|
8063
8669
|
return {
|
|
8064
8670
|
direction: typeof raw.direction === "string" ? raw.direction.slice(0, 300) : "",
|
|
8065
|
-
suggestions
|
|
8066
|
-
};
|
|
8067
|
-
}
|
|
8068
|
-
/** Build the rewrite system prompt (fix review issues / instructions). */
|
|
8069
|
-
function rewriteSystemPrompt(project, targetChars, beforeChapter) {
|
|
8070
|
-
return writeSystemPrompt(project, targetChars, "1. 输出完整的新正文(不要只输出修改片段、标题、章回名、作者的话或任何 Markdown 标记),字数与原章相当(允许 ±20%)。", beforeChapter) + "\n\n额外要求:你正在【修订】一章已写好的正文。保留原文中好的部分,只修改需要修改的地方,输出完整的新正文(不要只输出修改片段),字数与原文相当。";
|
|
8671
|
+
suggestions
|
|
8672
|
+
};
|
|
8071
8673
|
}
|
|
8072
8674
|
/**
|
|
8073
8675
|
* Stream a chapter rewrite. With `target` (a passage of the body), only that
|
|
@@ -8075,7 +8677,7 @@ function rewriteSystemPrompt(project, targetChars, beforeChapter) {
|
|
|
8075
8677
|
* untouched (local revision). Without `target`, the whole chapter is
|
|
8076
8678
|
* rewritten. Yields delta text; persists when done.
|
|
8077
8679
|
*/
|
|
8078
|
-
async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo, instructions, target) {
|
|
8680
|
+
async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo, instructions, target, signal) {
|
|
8079
8681
|
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
8080
8682
|
if (chapter === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 不在计划中`);
|
|
8081
8683
|
const body = readChapterFile(outputDir, chapter);
|
|
@@ -8113,29 +8715,7 @@ async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo,
|
|
|
8113
8715
|
"2. 保留该段的情节走向与角色口吻,只按修改要求调整。",
|
|
8114
8716
|
"3. 段落长度与原文相当。"
|
|
8115
8717
|
].filter((line) => line !== "").join("\n");
|
|
8116
|
-
const system = localTarget === void 0 ? rewriteSystemPrompt(project, chapter.targetChars || config.chapterChars, chapter.no) : (
|
|
8117
|
-
const bible = project.bible;
|
|
8118
|
-
const lines = [
|
|
8119
|
-
"你是一位中文网文润色师。你会收到一章中的一个段落,请按修改要求重写该段。",
|
|
8120
|
-
"硬性约束:",
|
|
8121
|
-
"1. 只改表达,不改情节走向、人物设定、已确立事实、对话核心内容。",
|
|
8122
|
-
"2. 必须遵守下方「内容合规红线」,任何一条命中(含影射、暗示)都必须避免。",
|
|
8123
|
-
"3. 必须遵守下方「本书红线」(如有)。",
|
|
8124
|
-
"4. 避免 AI 套话:不禁、仿佛、一时间、不由得、顿时、然而、缓缓、轻轻、微微、默默、似乎、终于等滥用。",
|
|
8125
|
-
"5. 保留角色口吻与性格,角色行为需符合下方角色卡(如有)。",
|
|
8126
|
-
"6. 只输出修改后的【这一个段落】的完整新文本,不要输出任何说明、标题或 Markdown 标记。"
|
|
8127
|
-
];
|
|
8128
|
-
if (bible !== void 0) {
|
|
8129
|
-
if (bible.redLines.length > 0) lines.push("本书红线:\n" + bible.redLines.map((r) => "- " + r).join("\n"));
|
|
8130
|
-
if (bible.characters.length > 0) {
|
|
8131
|
-
lines.push("相关角色卡:");
|
|
8132
|
-
for (const card of bible.characters) lines.push("- " + card.name + "(" + card.role + "):" + card.traits.join("、"));
|
|
8133
|
-
}
|
|
8134
|
-
}
|
|
8135
|
-
lines.push("内容合规红线(平台硬性要求,最高优先级):");
|
|
8136
|
-
lines.push(COMPLIANCE_REDLINES.join("\n"));
|
|
8137
|
-
return lines.join("\n");
|
|
8138
|
-
})();
|
|
8718
|
+
const system = localTarget === void 0 ? rewriteSystemPrompt(project, chapter.targetChars || config.chapterChars, chapter.no) : localRevisionSystemPrompt(project);
|
|
8139
8719
|
const messages = [createUserMessage({
|
|
8140
8720
|
content: [{
|
|
8141
8721
|
type: "text",
|
|
@@ -8160,6 +8740,7 @@ async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo,
|
|
|
8160
8740
|
ctx,
|
|
8161
8741
|
config,
|
|
8162
8742
|
request,
|
|
8743
|
+
signal,
|
|
8163
8744
|
liveLabel: `修订 · 第${chapterNo}章`,
|
|
8164
8745
|
liveUser: messages[0]?.content !== void 0 ? JSON.stringify(messages[0].content).slice(0, 2e3) : void 0,
|
|
8165
8746
|
maxTokensError: "修订输出达到 maxTokens 上限,请增大配置后重试",
|
|
@@ -8183,29 +8764,9 @@ async function* rewriteChapterStream(ctx, config, project, outputDir, chapterNo,
|
|
|
8183
8764
|
draft: newBody
|
|
8184
8765
|
};
|
|
8185
8766
|
}
|
|
8186
|
-
/** The de-AI-ify polish system prompt (with project writing assets injected). */
|
|
8187
|
-
function polishSystemPrompt(project) {
|
|
8188
|
-
const assetsBlock = renderAllAssets(project.assets);
|
|
8189
|
-
const bible = project.bible;
|
|
8190
|
-
const lines = [
|
|
8191
|
-
"你是一位中文网文润色师。你会收到一章正文,请做「去 AI 味」润色:",
|
|
8192
|
-
"1. 删除/替换 AI 高频套话与模式词:如\"不禁\"\"仿佛\"\"一时间\"\"不由得\"\"顿时\"\"然而\"\"缓缓\"\"轻轻\"\"微微\"\"默默\"\"似乎\"\"终于\"等滥用。",
|
|
8193
|
-
"2. 把书面翻译腔改成口语化的中文网文语感。",
|
|
8194
|
-
"3. 拆分过长的排比句与堆砌的修饰语。",
|
|
8195
|
-
"4. 保留全部情节、人物、对话内容、已确立事实不变,只改表达。",
|
|
8196
|
-
"5. 输出完整的新正文,不要输出任何说明文字或 Markdown 标记。",
|
|
8197
|
-
"6. 必须遵守下方「反 AI 规则」与「写法资产」的表达边界;写法资产要求保留的风格特征(句式、台词、节奏)不得在润色中丢失。",
|
|
8198
|
-
"7. 必须遵守下方「内容合规红线」与「本书红线」(如有),任何一条命中(含影射、暗示)都必须避免。"
|
|
8199
|
-
];
|
|
8200
|
-
if (bible !== void 0 && bible.redLines.length > 0) lines.push("本书红线:\n" + bible.redLines.map((r) => "- " + r).join("\n"));
|
|
8201
|
-
lines.push("内容合规红线(平台硬性要求,最高优先级):");
|
|
8202
|
-
lines.push(COMPLIANCE_REDLINES.join("\n"));
|
|
8203
|
-
if (assetsBlock !== "") lines.push(assetsBlock);
|
|
8204
|
-
return lines.join("\n");
|
|
8205
|
-
}
|
|
8206
8767
|
/** Stream a chapter polish (de-AI-ify). Draft-mode: the polished body lands
|
|
8207
8768
|
* in `chapter.pendingDraft` and is only applied on draft/apply. */
|
|
8208
|
-
async function* polishChapterStream(ctx, config, project, outputDir, chapterNo) {
|
|
8769
|
+
async function* polishChapterStream(ctx, config, project, outputDir, chapterNo, signal) {
|
|
8209
8770
|
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
8210
8771
|
if (chapter === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 不在计划中`);
|
|
8211
8772
|
const body = readChapterFile(outputDir, chapter);
|
|
@@ -8234,6 +8795,7 @@ async function* polishChapterStream(ctx, config, project, outputDir, chapterNo)
|
|
|
8234
8795
|
ctx,
|
|
8235
8796
|
config,
|
|
8236
8797
|
request,
|
|
8798
|
+
signal,
|
|
8237
8799
|
liveLabel: `去 AI 味润色 · 第${chapterNo}章`,
|
|
8238
8800
|
liveUser: body.slice(0, 2e3),
|
|
8239
8801
|
maxTokensError: "润色输出达到 maxTokens 上限,请增大配置后重试",
|
|
@@ -8254,7 +8816,7 @@ function countHanzi(text) {
|
|
|
8254
8816
|
return m === null ? 0 : m.length;
|
|
8255
8817
|
}
|
|
8256
8818
|
/** Generate one chapter (streaming). Yields progress frames; persists when done. */
|
|
8257
|
-
async function* generateChapterStream(ctx, config, project, outputDir, chapterNo) {
|
|
8819
|
+
async function* generateChapterStream(ctx, config, project, outputDir, chapterNo, signal) {
|
|
8258
8820
|
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
8259
8821
|
if (chapter === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 不在计划中`);
|
|
8260
8822
|
let continuity = "";
|
|
@@ -8341,6 +8903,7 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
|
|
|
8341
8903
|
ctx,
|
|
8342
8904
|
config,
|
|
8343
8905
|
request,
|
|
8906
|
+
signal,
|
|
8344
8907
|
liveLabel: `正文生成 · 第${chapter.no}章`,
|
|
8345
8908
|
liveUser: user,
|
|
8346
8909
|
maxTokensError: "达到 maxTokens 上限,正文可能不完整,请增大 maxTokens 后重试",
|
|
@@ -8357,7 +8920,7 @@ async function* generateChapterStream(ctx, config, project, outputDir, chapterNo
|
|
|
8357
8920
|
`请承接上文继续写约 ${need} 字,不要重复已有内容,不要重写开头,不要总结前文,直接续写新情节,结尾留一个章末钩子。只输出续写的正文,不要输出标题或任何说明。`
|
|
8358
8921
|
].join("\n");
|
|
8359
8922
|
const add = (await complete(ctx, config, {
|
|
8360
|
-
system: writeSystemPrompt(project, target, void 0, chapter.no) +
|
|
8923
|
+
system: writeSystemPrompt(project, target, void 0, chapter.no) + bodyContinuationSuffix(),
|
|
8361
8924
|
user: contUser,
|
|
8362
8925
|
temperature: .85,
|
|
8363
8926
|
maxTokens: Math.max(config.maxTokens, 12e3),
|
|
@@ -8393,11 +8956,7 @@ async function summarizeChapter(ctx, config, project, outputDir, chapterNo) {
|
|
|
8393
8956
|
const body = readChapterFile(outputDir, chapter);
|
|
8394
8957
|
if (body === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 的正文文件不存在`);
|
|
8395
8958
|
chapter.summary = (await complete(ctx, config, {
|
|
8396
|
-
system:
|
|
8397
|
-
"你是一位网文编辑。请为下面一章写一段 120-200 字的摘要,供后续章节写作时保持连贯性。",
|
|
8398
|
-
"摘要必须包含:本章发生的关键事件、主角状态变化(境界/资源/伤势/心境)、新增的伏笔或线索、角色关系变化。",
|
|
8399
|
-
"用客观陈述句,不要评价,不要剧透式感叹。只输出摘要正文。"
|
|
8400
|
-
].join("\n"),
|
|
8959
|
+
system: chapterSummarySystemPrompt(),
|
|
8401
8960
|
user: stripChapterHeadings(body),
|
|
8402
8961
|
temperature: .3,
|
|
8403
8962
|
maxTokens: Math.max(config.maxTokens, 4e3)
|
|
@@ -8410,40 +8969,36 @@ async function summarizeChapter(ctx, config, project, outputDir, chapterNo) {
|
|
|
8410
8969
|
* 反向推大纲:从已写章节正文反推出全书总纲(分卷 + 章节要点 + 主线/人物弧线/伏笔清单)。
|
|
8411
8970
|
* 两阶段:分批提取章节事件摘要 → 汇总生成大纲。不修改章节/设定,只返回大纲文本。
|
|
8412
8971
|
*/
|
|
8413
|
-
async function reverseOutlineFromChapters(ctx, config, project, outputDir, onProgress) {
|
|
8972
|
+
async function reverseOutlineFromChapters(ctx, config, project, outputDir, onProgress, signal) {
|
|
8414
8973
|
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
8974
|
if (written.length === 0) throw new NovelActionError("contract", "本书还没有已写章节,无法反推大纲");
|
|
8416
8975
|
const BATCH = 10;
|
|
8417
8976
|
const notes = [];
|
|
8418
8977
|
const total = written.length;
|
|
8419
8978
|
for (let i = 0; i < written.length; i += BATCH) {
|
|
8979
|
+
const bodies = written.slice(i, i + BATCH).map((c) => {
|
|
8980
|
+
const body = readChapterFile(outputDir, c) ?? "";
|
|
8981
|
+
return "第" + c.no + "章《" + (c.title || "无题") + "》\n" + stripChapterHeadings(body).slice(0, 1e3);
|
|
8982
|
+
}).join("\n\n---\n\n");
|
|
8420
8983
|
const note = await complete(ctx, config, {
|
|
8421
|
-
system:
|
|
8422
|
-
user:
|
|
8423
|
-
const body = readChapterFile(outputDir, c) ?? "";
|
|
8424
|
-
return "第" + c.no + "章《" + (c.title || "无题") + "》\n" + stripChapterHeadings(body).slice(0, 1e3);
|
|
8425
|
-
}).join("\n\n---\n\n"),
|
|
8984
|
+
system: reverseOutlineBatchSystemPrompt(),
|
|
8985
|
+
user: bodies,
|
|
8426
8986
|
temperature: .2,
|
|
8427
8987
|
maxTokens: Math.max(config.maxTokens, 3e3),
|
|
8428
|
-
reasoning: config.analysisReasoning ?? "low"
|
|
8988
|
+
reasoning: config.analysisReasoning ?? "low",
|
|
8989
|
+
signal
|
|
8429
8990
|
});
|
|
8430
8991
|
notes.push(note.trim());
|
|
8431
8992
|
onProgress?.(Math.min(i + BATCH, total), total, "章节摘要");
|
|
8432
8993
|
}
|
|
8433
8994
|
onProgress?.(total, total, "生成大纲");
|
|
8434
8995
|
const outline = await complete(ctx, config, {
|
|
8435
|
-
system:
|
|
8436
|
-
"你是一位经验丰富的小说主编。根据下面全书各章事件摘要,反推出这本书的总纲(大纲),可直接作为后续写作依据。要求:",
|
|
8437
|
-
"1. 第一行写《书名》(从摘要中的书名或内容推断,若无则用《未命名》)。",
|
|
8438
|
-
"2. 按故事弧线划分卷/部分:每卷给出卷名与主旨(标注覆盖章节范围)。",
|
|
8439
|
-
"3. 每一章列出:章节号 + 标题 + 一句话核心情节(若原章无标题可自拟)。",
|
|
8440
|
-
"4. 最后给出:全书主线、主要人物弧线、已埋设待回收的伏笔清单。",
|
|
8441
|
-
"5. 输出为纯文本 Markdown 结构(# 一级标题、## 二级标题、- 列表),不要多余寒暄。"
|
|
8442
|
-
].join("\n"),
|
|
8996
|
+
system: reverseOutlineSystemPrompt(),
|
|
8443
8997
|
user: notes.join("\n\n"),
|
|
8444
8998
|
temperature: .4,
|
|
8445
8999
|
maxTokens: Math.max(config.maxTokens, 6e3),
|
|
8446
|
-
reasoning: config.analysisReasoning ?? "low"
|
|
9000
|
+
reasoning: config.analysisReasoning ?? "low",
|
|
9001
|
+
signal
|
|
8447
9002
|
});
|
|
8448
9003
|
onProgress?.(total, total, "完成");
|
|
8449
9004
|
return outline.trim();
|
|
@@ -8469,16 +9024,8 @@ async function analyzeAdaptation(ctx, config, text) {
|
|
|
8469
9024
|
}
|
|
8470
9025
|
if (n > 1) pick(n - 1);
|
|
8471
9026
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
8472
|
-
system:
|
|
8473
|
-
user:
|
|
8474
|
-
"你是一位资深网文编辑兼改编策划。下面给你一部已完结/连载小说的若干章正文节选。请通读并输出该书的「原文设定卡片」与「可改范围矩阵」。",
|
|
8475
|
-
"输出合法 JSON 对象:",
|
|
8476
|
-
"{\"bookName\": \"书名\", \"outline\": \"一句话主线梗概(100字内)\", \"dimensions\": [{\"key\":\"realm\",\"title\":\"大世界\",\"mutability\":\"big|small|free|locked|visual\",\"current\":\"当前值\",\"evidence\":\"证据(出现章节/频次)\",\"candidates\":[{\"name\":\"候选体系名\",\"desc\":\"该体系一句话说明\"}],\"impact\":\"改了会影响什么\",\"risk\":\"high|medium|low\"}]}",
|
|
8477
|
-
"dimensions 至少覆盖以下维度(key/title):realm 大世界、cultivation 修为体系、protagonist 主角、goldenFinger 金手指、supporting 配角与势力人物名、faction 势力/组织、style 文风与叙事、ending 结局走向、timeline 时间线/编年、foreshadow 伏笔/暗线。",
|
|
8478
|
-
"mutability 取值:locked=建议保留、big=可改影响大、small=可改影响小、free=可自由改、visual=仅视觉包装。",
|
|
8479
|
-
"每个 dimension.current 必须忠于文本,能引用原文就用原文(尤其角色名/境界名/势力名/金手指名)。",
|
|
8480
|
-
"重要:所有字符串值内部不得包含换行符;JSON 必须在一段内完整结束;直接输出 JSON,不要 Markdown 代码块。"
|
|
8481
|
-
].join("\n") + "\n\n" + sampleBodies.join("\n\n---\n\n"),
|
|
9027
|
+
system: adaptationAnalyzeSystemPrompt(),
|
|
9028
|
+
user: adaptationAnalyzeUser(sampleBodies),
|
|
8482
9029
|
temperature: .3,
|
|
8483
9030
|
maxTokens: Math.max(config.maxTokens, 6e3),
|
|
8484
9031
|
reasoning: config.analysisReasoning ?? "low"
|
|
@@ -8551,15 +9098,7 @@ async function reverseOutlineFromAdaptationText(ctx, config, chapters) {
|
|
|
8551
9098
|
sample.push("第" + c.no + "章《" + (c.title || "无题") + "》\n" + c.body.slice(0, 600));
|
|
8552
9099
|
}
|
|
8553
9100
|
return (await complete(ctx, config, {
|
|
8554
|
-
system:
|
|
8555
|
-
"你是一位经验丰富的小说主编。根据下面若干章节的正文节选,反推出这本书的总纲(可作为后续改编与章节续写的骨架)。",
|
|
8556
|
-
"要求:",
|
|
8557
|
-
"1. 第一行写《书名》(可从正文推断,否则《未命名》)。",
|
|
8558
|
-
"2. 按故事弧线划分卷/部分:每卷给卷名与主旨(标注覆盖章节范围)。",
|
|
8559
|
-
"3. 对每章列出:章节号 + 标题 + 一句话核心情节。",
|
|
8560
|
-
"4. 最后给出:全书主线、主要人物弧线、已埋设待回收的伏笔清单。",
|
|
8561
|
-
"5. 输出纯文本 Markdown 结构(# 一级标题、## 二级标题、- 列表),不要寒暄,不要其他输出。"
|
|
8562
|
-
].join("\n"),
|
|
9101
|
+
system: reverseOutlineFromAdaptationSystemPrompt(),
|
|
8563
9102
|
user: sample.join("\n\n---\n\n"),
|
|
8564
9103
|
temperature: .4,
|
|
8565
9104
|
maxTokens: Math.max(config.maxTokens, 6e3),
|
|
@@ -8568,23 +9107,11 @@ async function reverseOutlineFromAdaptationText(ctx, config, chapters) {
|
|
|
8568
9107
|
}
|
|
8569
9108
|
/** 改编方案:由用户勾选的维度与新值,生成 LLM 映射表/规则/影响清单。 */
|
|
8570
9109
|
async function proposeAdaptation(ctx, config, _text, selections, dimensions) {
|
|
9110
|
+
const selLines = selections.map((s) => "- " + s.title + "(" + s.key + "):" + s.current + " → " + s.target).join("\n");
|
|
9111
|
+
const dimLines = (dimensions ?? []).map((d) => "- " + d.title + ":" + d.current + "(可改度:" + d.mutability + ",风险:" + d.risk + ")").join("\n");
|
|
8571
9112
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
8572
|
-
system:
|
|
8573
|
-
user:
|
|
8574
|
-
"下面给出改编决策:用户想改哪些维度、改成什么值。请生成一份可执行的「改编方案」。",
|
|
8575
|
-
"要求输出合法 JSON 对象:",
|
|
8576
|
-
"{\"mappings\": [{\"source\":\"原值\",\"target\":\"新值\",\"scope\":\"name|realm|faction|term|other\",\"note\":\"说明\"}], \"rules\": {\"preserve\":[\"必须保留的要素\"],\"change\":[\"允许改变的要素\"],\"constraints\":[\"改编红线/一致性要求\"]}, \"impacts\": [{\"item\":\"受影响项\",\"detail\":\"说明\",\"risk\":\"high|medium|low\",\"chapters\":[章号]}]}",
|
|
8577
|
-
"mappings 需把用户确认的新值展开为「原→新」条目(如主角名/境界名/势力名/术语),并补充用户未填但关联的必改项(如改了修为体系名,相关的境界名一并列映射)。",
|
|
8578
|
-
"rules.preserve 至少包含:故事骨架、人物动机、伏笔逻辑、爽点结构。",
|
|
8579
|
-
"impacts 列出每个改动会影响的内容(术语/角色/章节/伏笔),能定位章号尽量定位;无法定位则给章节区间提示。",
|
|
8580
|
-
"重要:所有字符串值内部不得包含换行符;JSON 必须在一段内完整结束;直接输出 JSON,不要 Markdown 代码块。",
|
|
8581
|
-
"",
|
|
8582
|
-
"用户要改:",
|
|
8583
|
-
selections.map((s) => "- " + s.title + "(" + s.key + "):" + s.current + " → " + s.target).join("\n"),
|
|
8584
|
-
"",
|
|
8585
|
-
"原文可改矩阵(已分析):",
|
|
8586
|
-
(dimensions ?? []).map((d) => "- " + d.title + ":" + d.current + "(可改度:" + d.mutability + ",风险:" + d.risk + ")").join("\n")
|
|
8587
|
-
].join("\n"),
|
|
9113
|
+
system: adaptationProposeSystemPrompt(),
|
|
9114
|
+
user: adaptationProposeUser(selLines, dimLines),
|
|
8588
9115
|
temperature: .3,
|
|
8589
9116
|
maxTokens: Math.max(config.maxTokens, 6e3),
|
|
8590
9117
|
reasoning: config.analysisReasoning ?? "low"
|
|
@@ -8684,16 +9211,7 @@ async function rewriteAdaptationBook(ctx, config, text, mappings, rules, options
|
|
|
8684
9211
|
rules.change.length > 0 ? "允许改变:\n" + rules.change.map((x) => `- ${x}`).join("\n") : "",
|
|
8685
9212
|
rules.constraints.length > 0 ? "改编红线/一致性要求:\n" + rules.constraints.map((x) => `- ${x}`).join("\n") : ""
|
|
8686
9213
|
].filter((s) => s !== "").join("\n") : "";
|
|
8687
|
-
const system =
|
|
8688
|
-
"你是一位资深网文改编编剧。你会收到某一章的正文,以及本书的改编映射表与改编规则。",
|
|
8689
|
-
"任务:把这一章按改编方案**重写**成新版本(结构性改编,不只是换词)。",
|
|
8690
|
-
"要求:",
|
|
8691
|
-
"1. 严格遵守映射表(原值→新值),正文中所有源值都要替换为新值;涉及改名/改体系/改势力时,相关表述一起调整,使上下文自洽。",
|
|
8692
|
-
"2. 改编规则:必须保留的内容不得破坏(故事骨架/人物动机/伏笔逻辑/爽点结构);允许改变的内容可以放开调整;红线/一致性要求必须遵守。",
|
|
8693
|
-
"3. 若某个改动牵动叙事(如结局走向/时间线/世界观),要把这章的叙述顺势改得通顺、可信。",
|
|
8694
|
-
"4. 输出**只包含这一章重写后的正文**,不要重复标题,不要任何解释、开头或结尾。",
|
|
8695
|
-
"5. 字数与原章基本相当(允许 ±20%)。"
|
|
8696
|
-
].join("\n");
|
|
9214
|
+
const system = adaptationRewriteSystemPrompt();
|
|
8697
9215
|
const adaptedParts = [];
|
|
8698
9216
|
const rewritten = [];
|
|
8699
9217
|
const skipped = [];
|
|
@@ -8712,12 +9230,19 @@ async function rewriteAdaptationBook(ctx, config, text, mappings, rules, options
|
|
|
8712
9230
|
].filter((s) => s !== "").join("\n\n");
|
|
8713
9231
|
let body = "";
|
|
8714
9232
|
try {
|
|
9233
|
+
if (options.signal?.aborted === true) return {
|
|
9234
|
+
adaptedText: adaptedParts.join("\n"),
|
|
9235
|
+
rewritten,
|
|
9236
|
+
skipped,
|
|
9237
|
+
hits
|
|
9238
|
+
};
|
|
8715
9239
|
body = stripRewriteHeading(await complete(ctx, config, {
|
|
8716
9240
|
system,
|
|
8717
9241
|
user,
|
|
8718
9242
|
temperature: .7,
|
|
8719
9243
|
maxTokens: Math.max(config.maxTokens, Math.min(16e3, c.body.length * 3)),
|
|
8720
|
-
reasoning: config.analysisReasoning ?? "low"
|
|
9244
|
+
reasoning: config.analysisReasoning ?? "low",
|
|
9245
|
+
signal: options.signal
|
|
8721
9246
|
}));
|
|
8722
9247
|
if (body.length < 50) body = "";
|
|
8723
9248
|
} catch {
|
|
@@ -8934,12 +9459,7 @@ async function summarizeAndExtractFacts(ctx, config, project, outputDir, chapter
|
|
|
8934
9459
|
factCount: 0
|
|
8935
9460
|
};
|
|
8936
9461
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
8937
|
-
system:
|
|
8938
|
-
"你是一位网文编辑。请为下面一章做两件事,输出合法 JSON 对象:",
|
|
8939
|
-
"{\"summary\": \"120-200字摘要,含关键事件/主角状态变化(境界资源伤势心境)/新增伏笔线索/角色关系变化,客观陈述不评价\", \"facts\": [\"已确立事实1\", \"…3-6条\"]}",
|
|
8940
|
-
"facts 指:本章明确写出的、对后续有约束力的事实——人物当前状态、重要关系变化、地点与时间线、已落地或新增的伏笔线索、关键道具去向。",
|
|
8941
|
-
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
8942
|
-
].join("\n"),
|
|
9462
|
+
system: summaryFactsSystemPrompt(),
|
|
8943
9463
|
user: stripChapterHeadings(body),
|
|
8944
9464
|
temperature: .2,
|
|
8945
9465
|
maxTokens: Math.max(config.maxTokens, 5e3)
|
|
@@ -8989,20 +9509,7 @@ async function summarizeFactsAndTimeline(ctx, config, project, outputDir, chapte
|
|
|
8989
9509
|
const wantTimeline = options.timeline !== false;
|
|
8990
9510
|
const NL = String.fromCharCode(10);
|
|
8991
9511
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
8992
|
-
system:
|
|
8993
|
-
"你是一位网文编辑。请为下面一章做三件事,输出**一个**合法 JSON 对象:",
|
|
8994
|
-
"{\"summary\": \"120-200字摘要,含关键事件/主角状态变化(境界资源伤势心境)/新增伏笔线索/角色关系变化,客观陈述不评价\",",
|
|
8995
|
-
" \"facts\": [\"已确立事实1\", \"…3-6条\"],",
|
|
8996
|
-
...wantTimeline ? [" \"timeline\": [{\"time\":\"第三日黄昏\",\"order\":1,\"place\":\"青云宗外门\",\"characters\":[\"沈青\"],\"event\":\"不超过40字\"}]"] : [],
|
|
8997
|
-
"",
|
|
8998
|
-
"facts 指:本章明确写出的、对后续有约束力的事实——人物当前状态、重要关系变化、地点与时间线、已落地或新增的伏笔线索、关键道具去向。",
|
|
8999
|
-
...wantTimeline ? [
|
|
9000
|
-
"timeline 指:只抽取正文里真实发生的场景节点(3-8 条),不要抽预告、回忆性泛泛陈述;纯粹的回忆/插叙请在 event 里注明「(回忆)」并给出其故事内时间。",
|
|
9001
|
-
"time 用正文里的说法(如「第三日黄昏」「入宗三个月后」「同日夜」);正文没写时间就填相对说法(如「紧接上一场景」)。",
|
|
9002
|
-
"order 是本章内的先后序号,从 1 递增;place 是一处一个词;characters 只列在场的角色名(用正文里的名字,不要写「主角」)。"
|
|
9003
|
-
] : [],
|
|
9004
|
-
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束,不要输出 Markdown 代码块标记。"
|
|
9005
|
-
].join(NL),
|
|
9512
|
+
system: summaryFactsTimelineSystemPrompt(wantTimeline),
|
|
9006
9513
|
user: `第 ${chapterNo} 章《${chapter.title}》正文:${NL}${NL}${stripChapterHeadings(body).slice(0, 24e3)}`,
|
|
9007
9514
|
temperature: .2,
|
|
9008
9515
|
maxTokens: options.maxTokens ?? Math.max(config.maxTokens, wantTimeline ? 8e3 : 5e3),
|
|
@@ -9068,14 +9575,7 @@ async function extractFacts(ctx, config, project, outputDir, chapterNo) {
|
|
|
9068
9575
|
const body = readChapterFile(outputDir, chapter);
|
|
9069
9576
|
if (body === void 0) return 0;
|
|
9070
9577
|
const lines = (await complete(ctx, config, {
|
|
9071
|
-
system:
|
|
9072
|
-
"你是一位网文编辑。请从本章正文中抽取「已确立事实」,供后续章节保持一致。",
|
|
9073
|
-
"事实指:人物当前状态(境界/修为/伤势/资源/心境)、重要关系变化、地点与时间线、已落地或新增的伏笔线索、关键道具去向。",
|
|
9074
|
-
"要求:",
|
|
9075
|
-
"1. 只抽取本章明确写出的、对后续有约束力的内容;纯心理活动与无关细节不要。",
|
|
9076
|
-
"2. 每行一条事实,用客观陈述句,不含主观评价。",
|
|
9077
|
-
"3. 输出 3-6 条,每行一条,不要编号、不要前缀、不要解释。"
|
|
9078
|
-
].join("\n"),
|
|
9578
|
+
system: extractFactsSystemPrompt(),
|
|
9079
9579
|
user: stripChapterHeadings(body),
|
|
9080
9580
|
temperature: .2,
|
|
9081
9581
|
maxTokens: Math.max(config.maxTokens, 4e3)
|
|
@@ -9094,19 +9594,7 @@ async function extractFacts(ctx, config, project, outputDir, chapterNo) {
|
|
|
9094
9594
|
const AUDIT_BATCH_SIZE = 10;
|
|
9095
9595
|
/** 单批质检:设定 + 事实库 + 该批章节节选 → 矛盾清单。 */
|
|
9096
9596
|
async function auditBatch(ctx, config, project, outputDir, batch) {
|
|
9097
|
-
const system =
|
|
9098
|
-
"你是一位严谨的网文连续性审校编辑。你会收到一本小说的道藏、事实库和一批章节正文节选。",
|
|
9099
|
-
"请找出这批章节中的一致性矛盾,例如:",
|
|
9100
|
-
"- 人物状态冲突:境界/修为/伤势/资源在同一章内或跨章前后矛盾。",
|
|
9101
|
-
"- 设定违背:正文与世界观规则、金手指规则、写作红线冲突。",
|
|
9102
|
-
"- 时间线错乱:事件顺序、时间跨度、地点移动不合逻辑。",
|
|
9103
|
-
"- 细节穿帮:人名/地名/物品/数字前后不一致。",
|
|
9104
|
-
"要求:",
|
|
9105
|
-
"1. 只报告有实质证据的矛盾,不要泛泛而谈写作质量问题。",
|
|
9106
|
-
"2. 每条必须定位到具体章节号。",
|
|
9107
|
-
"3. 输出必须是合法 JSON 数组,格式:[{\"chapterNo\": 章节号, \"severity\": \"high|medium|low\", \"item\": \"矛盾描述\", \"suggestion\": \"修改建议\"}]",
|
|
9108
|
-
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
9109
|
-
].join("\n");
|
|
9597
|
+
const system = auditBatchSystemPrompt();
|
|
9110
9598
|
const factsBlock = (project.facts ?? []).slice(-60).map((f) => `[第${f.chapterNo}章] ${f.text}`).join("\n");
|
|
9111
9599
|
const chapterBlocks = batch.map((c) => {
|
|
9112
9600
|
const excerpt = stripChapterHeadings(readChapterFile(outputDir, c) ?? "");
|
|
@@ -9169,13 +9657,7 @@ async function auditBook(ctx, config, project, outputDir, onProgress) {
|
|
|
9169
9657
|
}
|
|
9170
9658
|
/** 小说简介:生成或按已写开头补全(面向读者的作品门面)。 */
|
|
9171
9659
|
async function generateBlurb(ctx, config, project, partial = "") {
|
|
9172
|
-
const system =
|
|
9173
|
-
"你是一位网文平台编辑,擅长写抓人的作品简介。",
|
|
9174
|
-
"要求:",
|
|
9175
|
-
"1. 120-250 字,突出核心卖点(金手指/题材/爽点/人设反差),用一两句抛出开局钩子。",
|
|
9176
|
-
"2. 不剧透结局与关键反转;语气贴合题材(热血/悬疑/轻松/虐心)。",
|
|
9177
|
-
"3. 中文,直接输出简介正文,不要 Markdown、不要引号包裹、不要「简介:」前缀。"
|
|
9178
|
-
].join("\n");
|
|
9660
|
+
const system = blurbSystemPrompt();
|
|
9179
9661
|
const genreBlock = project.bible?.genre !== void 0 ? `题材:${project.bible.genre}` : "";
|
|
9180
9662
|
const volumeBlock = (project.volumes ?? []).slice(0, 3).map((v) => v.title).join("、");
|
|
9181
9663
|
return (await complete(ctx, config, {
|
|
@@ -9265,12 +9747,7 @@ function bookOverview(project, scope = "recent") {
|
|
|
9265
9747
|
* 定位一次改动波及的所有位置。助手在修改后主动调用,做连锁维护。
|
|
9266
9748
|
*/
|
|
9267
9749
|
async function analyzeImpact(ctx, config, project, outputDir, change) {
|
|
9268
|
-
const system =
|
|
9269
|
-
"你是一位网文一致性审校。作者要做一处修改,请找出这次改动会波及的所有位置(设定、大纲、已写章节正文、事实库、简介中可能因此过时或矛盾的内容)。",
|
|
9270
|
-
"输出必须是合法 JSON 数组,格式:[{\"location\": \"位置(第N章/大纲/道藏-世界规则/大世界-境界/事实库/简介)\", \"quote\": \"原文片段(20-60字)\", \"suggestion\": \"修改建议\", \"kind\": \"must|optional|note\"}]",
|
|
9271
|
-
"kind 含义:must=必须同步改否则矛盾;optional=建议改(影响观感);note=备注(如旧称保留为古称、或无需改但需知晓)。",
|
|
9272
|
-
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
9273
|
-
].join("\n");
|
|
9750
|
+
const system = impactAnalysisSystemPrompt();
|
|
9274
9751
|
const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
|
|
9275
9752
|
const base = [
|
|
9276
9753
|
`要做的修改:${change}`,
|
|
@@ -9309,38 +9786,9 @@ async function analyzeImpact(ctx, config, project, outputDir, change) {
|
|
|
9309
9786
|
}
|
|
9310
9787
|
return items.slice(0, 30);
|
|
9311
9788
|
}
|
|
9312
|
-
/** 把大世界结构化数据渲染成提示词块(境界体系按顺序强约束)。 */
|
|
9313
|
-
function renderWorld(world) {
|
|
9314
|
-
if (world === void 0) return "";
|
|
9315
|
-
const sections = ["==================== 大世界(结构化设定,写作时严格遵守) ===================="];
|
|
9316
|
-
if (world.realms.length > 0) {
|
|
9317
|
-
sections.push("境界体系(由低到高,不得随意跳级或自创境界):");
|
|
9318
|
-
world.realms.forEach((realm, i) => {
|
|
9319
|
-
sections.push(`${i + 1}. ${realm.name}${realm.description !== "" ? ` — ${realm.description}` : ""}`);
|
|
9320
|
-
});
|
|
9321
|
-
}
|
|
9322
|
-
if (world.regions.length > 0) {
|
|
9323
|
-
sections.push("地理区域:");
|
|
9324
|
-
for (const region of world.regions) sections.push(`- ${region.name}${region.description !== "" ? `:${region.description}` : ""}${region.faction !== void 0 && region.faction !== "" ? `(势力:${region.faction})` : ""}`);
|
|
9325
|
-
}
|
|
9326
|
-
if (world.factions.length > 0) {
|
|
9327
|
-
sections.push("势力分布:");
|
|
9328
|
-
for (const faction of world.factions) sections.push(`- ${faction.name}(${faction.kind})${faction.description !== "" ? `:${faction.description}` : ""}${faction.region !== void 0 && faction.region !== "" ? `(驻地:${faction.region})` : ""}`);
|
|
9329
|
-
}
|
|
9330
|
-
return sections.join("\n");
|
|
9331
|
-
}
|
|
9332
9789
|
/** 提炼大世界:从大纲 + 道藏生成结构化境界体系/区域/势力。 */
|
|
9333
9790
|
async function extractWorld(ctx, config, project) {
|
|
9334
|
-
const system =
|
|
9335
|
-
"你是一位网文世界观架构师。请根据小说大纲与道藏,提炼结构化「大世界」数据。",
|
|
9336
|
-
"输出必须是合法 JSON 对象:",
|
|
9337
|
-
"{\"realms\": [{\"name\": \"境界名\", \"description\": \"突破条件/寿命/标志等\"}], \"regions\": [{\"name\": \"区域名\", \"description\": \"描述\", \"faction\": \"关联势力名或空\"}], \"factions\": [{\"name\": \"势力名\", \"kind\": \"宗门/家族/王朝/组织等\", \"description\": \"描述\", \"region\": \"驻地区域或空\"}]}",
|
|
9338
|
-
"要求:",
|
|
9339
|
-
"1. realms 按由低到高顺序排列(修仙题材必须含完整境界链;无境界设定的题材可输出空数组)。",
|
|
9340
|
-
"2. 数量贴合大纲:realms 3-12 个,regions 2-10 个,factions 2-10 个。",
|
|
9341
|
-
"3. 内容严格来自大纲与道藏,不要凭空发明与大纲冲突的设定。",
|
|
9342
|
-
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
9343
|
-
].join("\n");
|
|
9791
|
+
const system = extractWorldSystemPrompt();
|
|
9344
9792
|
const bibleBlock = project.bible !== void 0 ? [project.bible.genre !== "" ? `题材:${project.bible.genre}` : "", project.bible.worldRules.length > 0 ? `世界规则:\n${project.bible.worldRules.map((r) => `- ${r}`).join("\n")}` : ""].filter((s) => s !== "").join("\n") : "";
|
|
9345
9793
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
9346
9794
|
system,
|
|
@@ -9422,11 +9870,7 @@ async function refreshCharacters(ctx, config, project, outputDir) {
|
|
|
9422
9870
|
}
|
|
9423
9871
|
let statuses = /* @__PURE__ */ new Map();
|
|
9424
9872
|
if (facts.length > 0) {
|
|
9425
|
-
const system =
|
|
9426
|
-
"你是一位网文角色档案管理员。请根据「角色名单」与「已确立事实库」,为每个角色输出「当前状态」一句话(境界/修为/伤势/资源/心境)。",
|
|
9427
|
-
"输出必须是合法 JSON 数组,格式:[{\"name\": \"角色名\", \"status\": \"当前状态一句话\"}]",
|
|
9428
|
-
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
9429
|
-
].join("\n");
|
|
9873
|
+
const system = roleStatusSystemPrompt();
|
|
9430
9874
|
const rosterBlock = roster.map((ch) => `- ${ch.name}(${ch.traits.join("、")})`).join("\n");
|
|
9431
9875
|
const factsBlock = facts.map((f) => `[第${f.chapterNo}章] ${f.text}`).join("\n");
|
|
9432
9876
|
const user = [
|
|
@@ -9468,19 +9912,6 @@ async function refreshCharacters(ctx, config, project, outputDir) {
|
|
|
9468
9912
|
});
|
|
9469
9913
|
return cards;
|
|
9470
9914
|
}
|
|
9471
|
-
/** System prompt for foreshadow suggestions. */
|
|
9472
|
-
function foreshadowSystemPrompt() {
|
|
9473
|
-
return [
|
|
9474
|
-
"你是一位网文伏笔设计师。你会收到大纲和已写的章节信息,请为小说建议 3-8 条值得埋设的伏笔。",
|
|
9475
|
-
"要求:",
|
|
9476
|
-
"1. 伏笔必须有明确的回收价值(推动主线、人物弧光、世界观揭秘)。",
|
|
9477
|
-
"2. 描述要具体,指出埋设章节与预计回收章节(可空缺)。",
|
|
9478
|
-
"3. 优先从大纲的暗线(如记忆代价、残片收集、身世谜团)中提炼。",
|
|
9479
|
-
"输出必须是合法 JSON 数组:",
|
|
9480
|
-
"[{\"description\": \"伏笔描述\", \"plantedChapter\": 章节号或null, \"targetChapter\": 章节号或null}]",
|
|
9481
|
-
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
9482
|
-
].join("\n");
|
|
9483
|
-
}
|
|
9484
9915
|
/** Suggest foreshadows from the outline + plan. */
|
|
9485
9916
|
async function suggestForeshadows(ctx, config, project) {
|
|
9486
9917
|
const user = [
|
|
@@ -9570,18 +10001,6 @@ async function extractStyleFormula(ctx, config, sampleText, depth) {
|
|
|
9570
10001
|
applyGuidance: typeof raw.applyGuidance === "string" ? raw.applyGuidance.trim().slice(0, 300) : ""
|
|
9571
10002
|
};
|
|
9572
10003
|
}
|
|
9573
|
-
/** 市场雷达系统提示(对齐上游 marketRadar 分析师角色)。 */
|
|
9574
|
-
function marketRadarSystemPrompt() {
|
|
9575
|
-
return [
|
|
9576
|
-
"你是中文网络文学市场分析师。只分析输入中的公开榜单元数据或用户给出的市场线索,不补写作品正文,不假装知道未提供的信息。",
|
|
9577
|
-
"只根据输入归纳热门题材和市场信号;对未提供的信息不要臆测。",
|
|
9578
|
-
"重点分析:热门题材组合、主角身份、金手指机制、开篇危机、关系卖点、标题句式、拥挤套路和差异化机会。",
|
|
9579
|
-
"kind 只能使用 genre、protagonist、advantage、opening、relationship、title_pattern、opportunity、crowding,不得创造近义枚举值。",
|
|
9580
|
-
"榜单高频不等于适合照搬。机会建议必须说明读者满足点,同时避开直接复制具体作品。",
|
|
9581
|
-
"productionFoundation 的题材/推进模式优先引用「资源库」中已有的 existingId(名称一字不差);只有确无合适资产时才给出新资产(existingId 为 null)。",
|
|
9582
|
-
"creativeBrief 严禁复用榜单作品的人名、专有设定、简介句子和完整书名;只能提炼读者需求、爽点机制和结构机会,不得输出任何具体人名/作品名,一律使用身份或通用场景称谓。"
|
|
9583
|
-
].join("\n");
|
|
9584
|
-
}
|
|
9585
10004
|
/** 题材雷达:输入平台/题材/榜单文本 → 信号 + 生产底座 + 开书创意。 */
|
|
9586
10005
|
async function runMarketRadar(ctx, config, req) {
|
|
9587
10006
|
const genreCatalog = BUILTIN_GENRE_LIBRARY.map((g, i) => `- [${i}] ${g.name}:${g.description}${g.template !== void 0 ? `(写法:${g.template})` : ""}`).join("\n");
|
|
@@ -9694,7 +10113,7 @@ async function runMarketCreativeBrief(ctx, config, req) {
|
|
|
9694
10113
|
/** 书分析/拆书:输入一本书/章节文本 → 卖点/结构/可借鉴点/风险。 */
|
|
9695
10114
|
async function runBookAnalysis(ctx, config, req) {
|
|
9696
10115
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
9697
|
-
system:
|
|
10116
|
+
system: bookAnalysisSystemPrompt(),
|
|
9698
10117
|
user: [
|
|
9699
10118
|
"请分析下面文本:\n" + (req.text ?? "").slice(0, 8e3),
|
|
9700
10119
|
"输出必须是合法 JSON 对象。",
|
|
@@ -9716,7 +10135,7 @@ async function runBookAnalysis(ctx, config, req) {
|
|
|
9716
10135
|
async function runIdeaInspiration(ctx, config, req) {
|
|
9717
10136
|
const count = Math.max(1, Math.min(10, req.count ?? 5));
|
|
9718
10137
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
9719
|
-
system:
|
|
10138
|
+
system: ideaInspirationSystemPrompt(),
|
|
9720
10139
|
user: [
|
|
9721
10140
|
`我的方向:${req.idea ?? ""}`,
|
|
9722
10141
|
`请给 ${count} 个开书创意。`,
|
|
@@ -9740,7 +10159,7 @@ async function runIdeaInspiration(ctx, config, req) {
|
|
|
9740
10159
|
/** 雷达→灵感:基于市场信号/生产底座/创意简报,生成贴合市场的开书灵感。 */
|
|
9741
10160
|
async function runMarketIdeaInspiration(ctx, config, req) {
|
|
9742
10161
|
const count = Math.max(1, Math.min(10, req.count ?? 5));
|
|
9743
|
-
const system =
|
|
10162
|
+
const system = marketIdeaInspirationSystemPrompt();
|
|
9744
10163
|
const signalBlock = (req.signals ?? []).map((s) => `- [${s.kind}] ${s.title}:${s.detail}`).join("\n") || "(无)";
|
|
9745
10164
|
const f = req.foundation;
|
|
9746
10165
|
const foundationBlock = f !== void 0 ? `题材:${f.genre.name}(${f.genre.description})\n主推进:${f.primaryStoryMode.name}(${f.primaryStoryMode.driver})${f.secondaryStoryMode !== void 0 ? `\n副推进:${f.secondaryStoryMode.name}` : ""}` : "(无)";
|
|
@@ -9779,22 +10198,23 @@ async function runDirectorAdvice(ctx, config, project, req) {
|
|
|
9779
10198
|
const plotlinesBlock = (project.plotlines ?? []).filter((l) => l.status === "active" || l.status === "paused").map((l) => `[${l.kind}] ${l.name}:${l.goal}${l.progress ? `(${l.progress})` : ""}`).join("\n") || "无";
|
|
9780
10199
|
const foreBlock = (project.foreshadows ?? []).filter((f) => f.status === "planted" || f.status === "progressing").map((f) => `- ${f.description}${f.targetChapter ? `(约${f.targetChapter}章回收)` : ""}`).join("\n") || "无";
|
|
9781
10200
|
const recentFacts = (project.facts ?? []).slice(-8).map((f) => `[第${f.chapterNo}章] ${f.text}`).join("\n");
|
|
10201
|
+
const user = [
|
|
10202
|
+
`书名:《${project.bookName}》`,
|
|
10203
|
+
req.focus !== void 0 && req.focus !== "" ? `聚焦:${req.focus}` : "",
|
|
10204
|
+
`当前进度:已写 ${done.length} 章,最后一章《${last?.title ?? "无"}》摘要:${last?.summary ?? ""}`,
|
|
10205
|
+
"",
|
|
10206
|
+
`分卷:\n${volumesBlock}`,
|
|
10207
|
+
`剧情线:\n${plotlinesBlock}`,
|
|
10208
|
+
`活跃伏笔:\n${foreBlock}`,
|
|
10209
|
+
`最近事实:\n${recentFacts}`,
|
|
10210
|
+
"",
|
|
10211
|
+
"请作为本书的自动导演,给出下一卷/下一阶段的编排建议:续写方向、阶段弧光、节奏板、风险提示、需要修复/再平衡的点。",
|
|
10212
|
+
"输出必须是合法 JSON 对象。",
|
|
10213
|
+
"JSON 结构:{\"summary\":\"总体判断一句话\",\"nextArc\":[\"下一阶段关键剧情节点(每条约一句话)\"],\"pacing\":\"节奏板(起承转合/爽点密度/章节节奏)\",\"risks\":[\"风险/跑偏提示\"],\"fixes\":[\"需要修复/再平衡的点\"]}"
|
|
10214
|
+
].filter((s) => s !== "").join("\n\n");
|
|
9782
10215
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
9783
|
-
system:
|
|
9784
|
-
user
|
|
9785
|
-
`书名:《${project.bookName}》`,
|
|
9786
|
-
req.focus !== void 0 && req.focus !== "" ? `聚焦:${req.focus}` : "",
|
|
9787
|
-
`当前进度:已写 ${done.length} 章,最后一章《${last?.title ?? "无"}》摘要:${last?.summary ?? ""}`,
|
|
9788
|
-
"",
|
|
9789
|
-
`分卷:\n${volumesBlock}`,
|
|
9790
|
-
`剧情线:\n${plotlinesBlock}`,
|
|
9791
|
-
`活跃伏笔:\n${foreBlock}`,
|
|
9792
|
-
`最近事实:\n${recentFacts}`,
|
|
9793
|
-
"",
|
|
9794
|
-
"请作为本书的自动导演,给出下一卷/下一阶段的编排建议:续写方向、阶段弧光、节奏板、风险提示、需要修复/再平衡的点。",
|
|
9795
|
-
"输出必须是合法 JSON 对象。",
|
|
9796
|
-
"JSON 结构:{\"summary\":\"总体判断一句话\",\"nextArc\":[\"下一阶段关键剧情节点(每条约一句话)\"],\"pacing\":\"节奏板(起承转合/爽点密度/章节节奏)\",\"risks\":[\"风险/跑偏提示\"],\"fixes\":[\"需要修复/再平衡的点\"]}"
|
|
9797
|
-
].filter((s) => s !== "").join("\n\n"),
|
|
10216
|
+
system: directorSystemPrompt(),
|
|
10217
|
+
user,
|
|
9798
10218
|
temperature: .4,
|
|
9799
10219
|
maxTokens: Math.max(config.maxTokens, 4e3),
|
|
9800
10220
|
liveLabel: "自动导演"
|
|
@@ -9828,18 +10248,7 @@ async function extractTimelineForChapter(ctx, config, project, outputDir, chapte
|
|
|
9828
10248
|
if (body === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 的正文文件不存在`);
|
|
9829
10249
|
const NL = String.fromCharCode(10);
|
|
9830
10250
|
const text = await complete(ctx, config, {
|
|
9831
|
-
system:
|
|
9832
|
-
"你是小说连续性编辑。任务:把一章正文压缩成「故事时间线」事件,供后续章节保持时间与地点的连续性。",
|
|
9833
|
-
"要求:",
|
|
9834
|
-
"1. 只抽取**正文里真实发生**的场景节点(3-8 条),不要抽取预告、回忆性的泛泛陈述;纯粹的回忆/插叙请在 event 里注明「(回忆)」并给出其故事内时间。",
|
|
9835
|
-
"2. time 用正文里的说法(如「第三日黄昏」「入宗三个月后」「同日夜」);正文没写时间就填相对说法(如「紧接上一场景」)。",
|
|
9836
|
-
"3. order 是本章内的先后序号,从 1 递增;跨章我会用章号排序,你只需要保证章内顺序正确。",
|
|
9837
|
-
"4. place 是场景地点(一处一个词,如「青云宗外门」);characters 只列**在场**的角色名(用正文里的名字,不要写「主角」)。",
|
|
9838
|
-
"5. event 一句话说清发生了什么(不超过 40 字)。",
|
|
9839
|
-
"输出必须是合法 JSON 数组,不要输出任何其他文字或 Markdown 代码块标记:",
|
|
9840
|
-
"[{\"time\":\"第三日黄昏\",\"order\":1,\"place\":\"青云宗外门\",\"characters\":[\"沈青\"],\"event\":\"沈青被外门弟子当众羞辱\"}]",
|
|
9841
|
-
"重要:字符串值内部不得包含换行符;JSON 必须在一段内完整结束。"
|
|
9842
|
-
].join("@NL@").replace("@NL@", NL),
|
|
10251
|
+
system: timelineExtractSystemPrompt(),
|
|
9843
10252
|
user: `第 ${chapterNo} 章《${chapter.title}》正文:@NL@@NL@${stripChapterHeadings(body).slice(0, 24e3)}`.replace("@NL@", NL),
|
|
9844
10253
|
temperature: .2,
|
|
9845
10254
|
maxTokens: Math.max(config.maxTokens, 4e3),
|
|
@@ -9882,15 +10291,7 @@ async function reviewTimeline(ctx, config, project) {
|
|
|
9882
10291
|
return "- " + parts.join(" ");
|
|
9883
10292
|
});
|
|
9884
10293
|
const text = await complete(ctx, config, {
|
|
9885
|
-
system:
|
|
9886
|
-
"你是小说连续性编辑,只做一件事:找**时间线**上的矛盾。",
|
|
9887
|
-
"检查项:1) 时间倒流(后一章早于前一章且没有明确插叙标注);2) 地点瞬移(同一人物无移动交代就换场景);",
|
|
9888
|
-
"3) 时间跨度与事件量明显不匹配(如「一日之内」发生跨越数月的剧情);4) 季节/年龄/节日前后矛盾;5) 回忆与现实混淆。",
|
|
9889
|
-
"只报**能在给定事件表里指认**的问题,不要臆测;没有问题就返回空数组。",
|
|
9890
|
-
"输出必须是合法 JSON 数组,不要任何其他文字:",
|
|
9891
|
-
"[{\"severity\":\"high|medium|low\",\"chapters\":[3,7],\"item\":\"问题一句话\",\"suggestion\":\"怎么修\"}]",
|
|
9892
|
-
"重要:字符串值内部不得包含换行符。"
|
|
9893
|
-
].join(NL),
|
|
10294
|
+
system: timelineReviewSystemPrompt(),
|
|
9894
10295
|
user: `故事时间线(按章节顺序):@NL@${lines.join(NL)}`.replace("@NL@", NL),
|
|
9895
10296
|
temperature: .2,
|
|
9896
10297
|
maxTokens: Math.max(config.maxTokens, 4e3),
|
|
@@ -10532,15 +10933,19 @@ function revisionTodoText(item) {
|
|
|
10532
10933
|
/**
|
|
10533
10934
|
* 选出生产单下一章要处理的章。
|
|
10534
10935
|
*
|
|
10535
|
-
*
|
|
10936
|
+
* 四种章:已 `approved` 快进;已列入 `pendingManual` 的快进(人工已接管);
|
|
10937
|
+
* 连败达上限的熔断跳过;其余为下一章。
|
|
10536
10938
|
*
|
|
10537
10939
|
* @param chapters - 全书章节计划。
|
|
10538
10940
|
* @param currentNo - 当前章号(含)。
|
|
10539
10941
|
* @param endNo - 区间终点(含)。
|
|
10540
10942
|
* @param failedAttempts - 章号 → 已失败次数(来自 `RunState.failedAttempts`)。
|
|
10943
|
+
* @param pendingManual - 已列入待人工的章号(来自 `RunState.pendingManual`)。
|
|
10944
|
+
* 必须传入,否则重写 N 轮仍不过的章会被反复选中,每轮烧两次 rewrite + 两次 verify。
|
|
10541
10945
|
*/
|
|
10542
|
-
function pickNextChapter(chapters, currentNo, endNo, failedAttempts) {
|
|
10946
|
+
function pickNextChapter(chapters, currentNo, endNo, failedAttempts, pendingManual = []) {
|
|
10543
10947
|
const circuitBroken = [];
|
|
10948
|
+
const manual = new Set(pendingManual);
|
|
10544
10949
|
let advanceTo = currentNo - 1;
|
|
10545
10950
|
for (let no = currentNo; no <= endNo; no++) {
|
|
10546
10951
|
const ch = chapters.find((c) => c.no === no);
|
|
@@ -10549,6 +10954,10 @@ function pickNextChapter(chapters, currentNo, endNo, failedAttempts) {
|
|
|
10549
10954
|
advanceTo = no;
|
|
10550
10955
|
continue;
|
|
10551
10956
|
}
|
|
10957
|
+
if (manual.has(no)) {
|
|
10958
|
+
advanceTo = no;
|
|
10959
|
+
continue;
|
|
10960
|
+
}
|
|
10552
10961
|
const attempts = failedAttempts[String(no)] ?? 0;
|
|
10553
10962
|
if (attempts >= 3) {
|
|
10554
10963
|
circuitBroken.push({
|
|
@@ -10768,7 +11177,7 @@ var ProductionRunner = class {
|
|
|
10768
11177
|
this.persist();
|
|
10769
11178
|
break;
|
|
10770
11179
|
}
|
|
10771
|
-
const picked = pickNextChapter(project.chapters, this.state.currentNo, this.state.endNo, this.state.failedAttempts ?? {});
|
|
11180
|
+
const picked = pickNextChapter(project.chapters, this.state.currentNo, this.state.endNo, this.state.failedAttempts ?? {}, this.state.pendingManual ?? []);
|
|
10772
11181
|
if (picked.advanceTo >= this.state.currentNo) this.state.currentNo = picked.advanceTo;
|
|
10773
11182
|
for (const skipped of picked.circuitBroken) this.skipFailedChapter(skipped.chapter, skipped.attempts);
|
|
10774
11183
|
if (picked.next === void 0) {
|
|
@@ -10903,11 +11312,12 @@ var ProductionRunner = class {
|
|
|
10903
11312
|
text
|
|
10904
11313
|
});
|
|
10905
11314
|
}
|
|
10906
|
-
mergeVolatileFromDisk(outputDir, project);
|
|
11315
|
+
mergeVolatileFromDisk(outputDir, project, ["plotlines", "facts"]);
|
|
10907
11316
|
saveProject(outputDir, project);
|
|
10908
11317
|
}
|
|
10909
11318
|
} catch (e) {
|
|
10910
11319
|
console.warn("[dsh-novel-forge] run author review:", e.message);
|
|
11320
|
+
this.log(`第${no}章 作者复盘失败(正文已保留,可稍后补跑复盘):${e.message}`);
|
|
10911
11321
|
}
|
|
10912
11322
|
} catch (error) {
|
|
10913
11323
|
chapter.status = "error";
|
|
@@ -10973,8 +11383,15 @@ var ProductionRunner = class {
|
|
|
10973
11383
|
baseReport: verify
|
|
10974
11384
|
});
|
|
10975
11385
|
}
|
|
10976
|
-
if (this.state !== null)
|
|
10977
|
-
|
|
11386
|
+
if (this.state !== null) {
|
|
11387
|
+
if (!this.state.pendingManual.includes(no)) {
|
|
11388
|
+
this.state.pendingManual.push(no);
|
|
11389
|
+
this.state.pendingManual.sort((a, b) => a - b);
|
|
11390
|
+
}
|
|
11391
|
+
this.state.currentNo = no + 1;
|
|
11392
|
+
this.persist();
|
|
11393
|
+
}
|
|
11394
|
+
this.log(`第${no}章 2 轮修订仍不过 → 保留草稿待人工(已跳过,可修正后单独重试该章)`);
|
|
10978
11395
|
}
|
|
10979
11396
|
applyDraft(project, chapter, draft, report) {
|
|
10980
11397
|
const config = this.deps.getConfig();
|
|
@@ -11937,7 +12354,7 @@ function historyToMessages(history) {
|
|
|
11937
12354
|
return messages;
|
|
11938
12355
|
}
|
|
11939
12356
|
/** One non-streaming LLM chat turn (used inside the tool loop). */
|
|
11940
|
-
async function chatOnce(ctx, config, system, history) {
|
|
12357
|
+
async function chatOnce(ctx, config, system, history, signal) {
|
|
11941
12358
|
const messages = historyToMessages(history);
|
|
11942
12359
|
const last = messages[messages.length - 1];
|
|
11943
12360
|
if (last?.role === "user" && Array.isArray(last.content)) {
|
|
@@ -11972,15 +12389,27 @@ async function chatOnce(ctx, config, system, history) {
|
|
|
11972
12389
|
user: messages.map((m) => JSON.stringify(m.content)).join(" / ").slice(0, 8e3)
|
|
11973
12390
|
});
|
|
11974
12391
|
const assembler = new BlockAssembler();
|
|
11975
|
-
|
|
11976
|
-
|
|
11977
|
-
|
|
12392
|
+
try {
|
|
12393
|
+
for await (const chunk of ctx.llm.stream(signal === void 0 ? request : {
|
|
12394
|
+
...request,
|
|
12395
|
+
signal
|
|
12396
|
+
})) {
|
|
12397
|
+
if (chunk.type === "text-delta") markFirstToken(live);
|
|
12398
|
+
assembler.push(chunk);
|
|
12399
|
+
}
|
|
12400
|
+
} catch (error) {
|
|
12401
|
+
endLiveCall(live, assembler, {
|
|
12402
|
+
phase: isCancellation(error) || signal?.aborted === true ? "cancelled" : "failed",
|
|
12403
|
+
error: error instanceof Error ? error.message : String(error)
|
|
12404
|
+
});
|
|
12405
|
+
throw error;
|
|
11978
12406
|
}
|
|
11979
12407
|
const finish = assembler.finish;
|
|
11980
12408
|
if (finish.kind === "error" || finish.kind === "aborted") {
|
|
11981
|
-
const
|
|
12409
|
+
const cancelled = isCancellation(finish.kind);
|
|
12410
|
+
const message = `${cancelled ? "助手对话已取消" : "助手调用失败"}(${finish.kind}): ${finish.failure.message}`;
|
|
11982
12411
|
endLiveCall(live, assembler, {
|
|
11983
|
-
phase: "failed",
|
|
12412
|
+
phase: cancelled ? "cancelled" : "failed",
|
|
11984
12413
|
error: message
|
|
11985
12414
|
});
|
|
11986
12415
|
throw new Error(message);
|
|
@@ -12004,7 +12433,7 @@ async function chatOnce(ctx, config, system, history) {
|
|
|
12004
12433
|
}, runOnce);
|
|
12005
12434
|
}
|
|
12006
12435
|
/** Run one user turn. Yields stream frames; persists history. */
|
|
12007
|
-
async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
|
|
12436
|
+
async function* runAssistantTurn(ctx, config, project, outputDir, userMessage, signal) {
|
|
12008
12437
|
const history = loadAssistantHistory(outputDir);
|
|
12009
12438
|
const stage = computeBookStage(project);
|
|
12010
12439
|
const strictStage = isGenericContinue(userMessage);
|
|
@@ -12071,7 +12500,8 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
|
|
|
12071
12500
|
};
|
|
12072
12501
|
for (;;) {
|
|
12073
12502
|
if (iterations++ > 20) break;
|
|
12074
|
-
|
|
12503
|
+
if (signal?.aborted === true) throw new Error("客户端已断开连接");
|
|
12504
|
+
const reply = await chatOnce(ctx, config, system, history, signal);
|
|
12075
12505
|
if (reply.length > 120 && /^[0-9a-fA-F\s]+$/.test(reply.slice(0, 2e3))) {
|
|
12076
12506
|
garbleCount++;
|
|
12077
12507
|
if (garbleCount >= 2) {
|
|
@@ -12805,6 +13235,12 @@ function addGlobalMode(m) {
|
|
|
12805
13235
|
//#region src/routes.ts
|
|
12806
13236
|
/** Cap on JSON request bodies (generous: cover images travel as base64). */
|
|
12807
13237
|
const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024;
|
|
13238
|
+
/** 允许「服务器本地文件」模式导入的扩展名:纯文本小说。不含 docx/pdf 等二进制。 */
|
|
13239
|
+
const IMPORTABLE_EXTENSIONS = [
|
|
13240
|
+
".txt",
|
|
13241
|
+
".md",
|
|
13242
|
+
".markdown"
|
|
13243
|
+
];
|
|
12808
13244
|
/** 包内置风格效果图目录(assets/styles,随 npm 包分发)。 */
|
|
12809
13245
|
/** 解析请求级目标书目录:优先按请求携带的 bookId 查书架,否则回退全局 active 书目录。 */
|
|
12810
13246
|
function resolveOutputDir(config, bookId) {
|
|
@@ -12814,6 +13250,42 @@ function resolveOutputDir(config, bookId) {
|
|
|
12814
13250
|
}
|
|
12815
13251
|
return config.outputDir;
|
|
12816
13252
|
}
|
|
13253
|
+
/**
|
|
13254
|
+
* 校验一个「客户端自报的目录」是否确实是已登记的书目录。
|
|
13255
|
+
*
|
|
13256
|
+
* 背景:部分路由(封面读取等)历史上直接采信 `?dir=` 参数拼路径读文件。
|
|
13257
|
+
* 本服务只有 loopback 围栏、没有 token,而围栏允许 `Origin` 缺失的请求
|
|
13258
|
+
* (非浏览器客户端、curl、恶意扩展都能过),所以形如
|
|
13259
|
+
* `GET /cover?dir=C:/Users/x` 会让服务读出该目录下任意 `novel-project.json`
|
|
13260
|
+
* 指向的文件并 base64 回吐。
|
|
13261
|
+
*
|
|
13262
|
+
* 正确做法不是「校验路径合法」(攻击者可以传任意合法路径),而是
|
|
13263
|
+
* **只承认自己登记过的目录**:书架里的每本书 + 设置里的默认输出目录。
|
|
13264
|
+
* 攻击者能传的值因此被压缩到一个可枚举的集合内。
|
|
13265
|
+
*
|
|
13266
|
+
* @returns 归一化后的目录;不在白名单内返回 undefined。
|
|
13267
|
+
*/
|
|
13268
|
+
function resolveRegisteredDir(config, requested) {
|
|
13269
|
+
if (requested === null || requested === "") return config.outputDir;
|
|
13270
|
+
let normalized;
|
|
13271
|
+
try {
|
|
13272
|
+
normalized = resolve(requested).replace(/[\\/]+$/, "");
|
|
13273
|
+
} catch {
|
|
13274
|
+
return;
|
|
13275
|
+
}
|
|
13276
|
+
const store = loadBookshelf();
|
|
13277
|
+
const allowed = [config.outputDir, ...store.books.map((b) => b.outputDir)];
|
|
13278
|
+
for (const dir of allowed) {
|
|
13279
|
+
if (dir === "") continue;
|
|
13280
|
+
let candidate;
|
|
13281
|
+
try {
|
|
13282
|
+
candidate = resolve(dir).replace(/[\\/]+$/, "");
|
|
13283
|
+
} catch {
|
|
13284
|
+
continue;
|
|
13285
|
+
}
|
|
13286
|
+
if (candidate.toLowerCase() === normalized.toLowerCase()) return candidate;
|
|
13287
|
+
}
|
|
13288
|
+
}
|
|
12817
13289
|
/** Loopback-only fence (mirrors the family plugins' pairing routes). */
|
|
12818
13290
|
function isLoopbackRequest(request) {
|
|
12819
13291
|
const address = request.socket.remoteAddress;
|
|
@@ -12861,6 +13333,50 @@ async function readJsonBody(req) {
|
|
|
12861
13333
|
return;
|
|
12862
13334
|
}
|
|
12863
13335
|
}
|
|
13336
|
+
/**
|
|
13337
|
+
* 把「客户端是否还在听」绑成一个 `AbortSignal`。
|
|
13338
|
+
*
|
|
13339
|
+
* ## 为什么必须有
|
|
13340
|
+
*
|
|
13341
|
+
* Node 的 `res.write()` 对已销毁的 socket **不抛异常也不返回 false** ——
|
|
13342
|
+
* 请求处理器会继续跑完。7 条 NDJSON 路由此前零 `close` 监听,于是:
|
|
13343
|
+
* 浏览器关标签页 → `for await (const step of generateChapterStream(...))`
|
|
13344
|
+
* 继续跑完整章生成(2-5 分钟,maxTokens 常 20000),审稿、复盘、落盘全部照跑。
|
|
13345
|
+
* **「关掉页面」不等于「停止烧 token」**,而且 `chapter.status` 已被写盘为
|
|
13346
|
+
* `generating`,只能靠 `/status` 的 10 分钟兜底复位。
|
|
13347
|
+
*
|
|
13348
|
+
* 宿主 SDK 明确约定「implementations must honor `options.signal`」
|
|
13349
|
+
* (`@deepseek-ai/dsh-llm` `LLMAdapter.stream` 文档),所以只要把 signal
|
|
13350
|
+
* 一路传到 `ctx.llm.stream` 就能真正掐断。
|
|
13351
|
+
*
|
|
13352
|
+
* ## 用法
|
|
13353
|
+
*
|
|
13354
|
+
* ```ts
|
|
13355
|
+
* const abort = abortOnClientClose(req, res)
|
|
13356
|
+
* try {
|
|
13357
|
+
* for await (const step of generateChapterStream(ctx, config, project, dir, no, abort.signal)) { ... }
|
|
13358
|
+
* } catch (err) {
|
|
13359
|
+
* if (isAbortError(err)) return // 客户端主动断开:静默收尾,不回错误帧
|
|
13360
|
+
* ...
|
|
13361
|
+
* }
|
|
13362
|
+
* ```
|
|
13363
|
+
*
|
|
13364
|
+
* @returns controller;`signal` 传给下游,`aborted` 可直接判断。
|
|
13365
|
+
*/
|
|
13366
|
+
function abortOnClientClose(req, res) {
|
|
13367
|
+
const controller = new AbortController();
|
|
13368
|
+
const onClose = () => {
|
|
13369
|
+
if (!res.writableEnded) controller.abort(/* @__PURE__ */ new Error("客户端已断开连接"));
|
|
13370
|
+
};
|
|
13371
|
+
res.once("close", onClose);
|
|
13372
|
+
req.once("aborted", onClose);
|
|
13373
|
+
return controller;
|
|
13374
|
+
}
|
|
13375
|
+
/** 判断错误是否为「客户端断开」导致的中止(据此静默收尾而不是回错误帧)。 */
|
|
13376
|
+
function isAbortError(err) {
|
|
13377
|
+
if (err instanceof Error && (err.name === "AbortError" || err.message.includes("客户端已断开连接"))) return true;
|
|
13378
|
+
return false;
|
|
13379
|
+
}
|
|
12864
13380
|
/** Default chapter count for planning when the request omits it. */
|
|
12865
13381
|
const DEFAULT_PLAN_COUNT = 30;
|
|
12866
13382
|
/**
|
|
@@ -12906,15 +13422,39 @@ function makeRoutes(deps) {
|
|
|
12906
13422
|
return true;
|
|
12907
13423
|
};
|
|
12908
13424
|
/** Load (and sync) the project, or respond 400. */
|
|
12909
|
-
|
|
12910
|
-
|
|
12911
|
-
|
|
13425
|
+
/**
|
|
13426
|
+
* 取本次请求的目标项目。
|
|
13427
|
+
*
|
|
13428
|
+
* 目录解析与 {@link resolveOutputDir} 同源:按请求携带的 `bookId` 查书架,
|
|
13429
|
+
* 未携带才回退全局 active 书。**此前本函数只看 `getConfig().outputDir`**,
|
|
13430
|
+
* 而约一半路由走它、另一半走 resolveOutputDir,前端每个请求都带 bookId
|
|
13431
|
+
* 却被后端一半静默忽略 —— 多书场景下对非激活书调用会写到激活书目录。
|
|
13432
|
+
*
|
|
13433
|
+
* 顺带修掉写盘副作用:`syncProjectWithDisk` 无条件更新 `updatedAt`,
|
|
13434
|
+
* 使 `saveProject` 的 no-op 检测永不命中,于是每个「读」请求都在重写整个
|
|
13435
|
+
* novel-project.json(长篇项目可达数MB,每 4 秒一次)。这里改成
|
|
13436
|
+
* 同步后按内容比对,仅在真的变化时落盘。
|
|
13437
|
+
*
|
|
13438
|
+
* @param req - 用于解析 `bookId`(GET 查 query,POST 查 body 由调用方保证)。
|
|
13439
|
+
* @param res - 用于回写错误。
|
|
13440
|
+
* @param body - POST 请求体(可能含 bookId)。
|
|
13441
|
+
*/
|
|
13442
|
+
const requireProjectAt = (req, res, body) => {
|
|
13443
|
+
const outputDir = resolveOutputDir(getConfig(), body?.bookId ?? (() => {
|
|
13444
|
+
try {
|
|
13445
|
+
return new URL(req.url ?? "/", "http://localhost").searchParams.get("bookId") ?? void 0;
|
|
13446
|
+
} catch {
|
|
13447
|
+
return;
|
|
13448
|
+
}
|
|
13449
|
+
})() ?? void 0);
|
|
13450
|
+
const project = loadProject(outputDir);
|
|
12912
13451
|
if (project === void 0) {
|
|
12913
13452
|
writeJson(res, 400, { error: "输出目录中没有项目,请先加载大纲" });
|
|
12914
13453
|
return;
|
|
12915
13454
|
}
|
|
12916
|
-
|
|
12917
|
-
|
|
13455
|
+
const before = JSON.stringify(project);
|
|
13456
|
+
syncProjectWithDisk(project, outputDir);
|
|
13457
|
+
if (JSON.stringify(project) !== before) saveProject(outputDir, project);
|
|
12918
13458
|
return project;
|
|
12919
13459
|
};
|
|
12920
13460
|
const statusRoute = {
|
|
@@ -12939,6 +13479,11 @@ function makeRoutes(deps) {
|
|
|
12939
13479
|
syncProjectWithDisk(project, outputDir);
|
|
12940
13480
|
saveProject(outputDir, project);
|
|
12941
13481
|
}
|
|
13482
|
+
const writtenAtByNo = /* @__PURE__ */ new Map();
|
|
13483
|
+
for (const c of project?.chapters ?? []) {
|
|
13484
|
+
const at = chapterWrittenAt(outputDir, c);
|
|
13485
|
+
if (at !== void 0) writtenAtByNo.set(c.no, at);
|
|
13486
|
+
}
|
|
12942
13487
|
const slim = new URL(req.url ?? "/", "http://localhost").searchParams.get("slim") === "1";
|
|
12943
13488
|
const truncations = [];
|
|
12944
13489
|
let projectPayload;
|
|
@@ -12958,7 +13503,8 @@ function makeRoutes(deps) {
|
|
|
12958
13503
|
review: c.review !== void 0 ? {
|
|
12959
13504
|
score: c.review.score,
|
|
12960
13505
|
passed: c.review.passed
|
|
12961
|
-
} : void 0
|
|
13506
|
+
} : void 0,
|
|
13507
|
+
writtenAt: writtenAtByNo.get(c.no)
|
|
12962
13508
|
})),
|
|
12963
13509
|
createdAt: project.createdAt,
|
|
12964
13510
|
updatedAt: project.updatedAt
|
|
@@ -12969,6 +13515,10 @@ function makeRoutes(deps) {
|
|
|
12969
13515
|
if (allFacts.length > shownFacts.length) truncations.push(`编年录:共 ${allFacts.length} 条,仅返回最近 ${shownFacts.length} 条(较早 ${allFacts.length - shownFacts.length} 条未返回)`);
|
|
12970
13516
|
projectPayload = {
|
|
12971
13517
|
...project,
|
|
13518
|
+
chapters: project.chapters.map((c) => ({
|
|
13519
|
+
...c,
|
|
13520
|
+
writtenAt: writtenAtByNo.get(c.no)
|
|
13521
|
+
})),
|
|
12972
13522
|
facts: shownFacts
|
|
12973
13523
|
};
|
|
12974
13524
|
}
|
|
@@ -13183,46 +13733,18 @@ function makeRoutes(deps) {
|
|
|
13183
13733
|
const send = (frame) => {
|
|
13184
13734
|
res.write(JSON.stringify(frame) + "\n");
|
|
13185
13735
|
};
|
|
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
|
-
});
|
|
13736
|
+
const abort = abortOnClientClose(req, res);
|
|
13205
13737
|
try {
|
|
13206
13738
|
send({
|
|
13207
13739
|
type: "start",
|
|
13208
13740
|
no,
|
|
13209
13741
|
title: chapter.title
|
|
13210
13742
|
});
|
|
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({
|
|
13743
|
+
for await (const step of generateChapterStream(ctx, config, project, config.outputDir, no, abort.signal)) if (step.frame === "delta") send({
|
|
13744
|
+
type: "delta",
|
|
13745
|
+
text: step.text
|
|
13746
|
+
});
|
|
13747
|
+
else if (step.frame === "done") send({
|
|
13226
13748
|
type: "done",
|
|
13227
13749
|
no,
|
|
13228
13750
|
file: step.file,
|
|
@@ -13230,14 +13752,6 @@ function makeRoutes(deps) {
|
|
|
13230
13752
|
title: chapter.title,
|
|
13231
13753
|
warn: step.warn
|
|
13232
13754
|
});
|
|
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
13755
|
try {
|
|
13242
13756
|
const wantTimeline = config.autoTimeline ?? true;
|
|
13243
13757
|
await summarizeFactsAndTimeline(ctx, config, project, config.outputDir, no, {
|
|
@@ -13274,7 +13788,7 @@ function makeRoutes(deps) {
|
|
|
13274
13788
|
const review = await authorReviewChapter(ctx, config, project, no, currentBody, prevTail);
|
|
13275
13789
|
chapter.authorReview = review;
|
|
13276
13790
|
if (review.advancedLines !== void 0) autoLinkPlotlines(project, no, review.advancedLines);
|
|
13277
|
-
mergeVolatileFromDisk(config.outputDir, project);
|
|
13791
|
+
mergeVolatileFromDisk(config.outputDir, project, ["plotlines"]);
|
|
13278
13792
|
saveProject(config.outputDir, project);
|
|
13279
13793
|
send({
|
|
13280
13794
|
type: "author-review",
|
|
@@ -13287,6 +13801,14 @@ function makeRoutes(deps) {
|
|
|
13287
13801
|
}
|
|
13288
13802
|
res.end();
|
|
13289
13803
|
} catch (error) {
|
|
13804
|
+
if (isAbortError(error)) {
|
|
13805
|
+
chapter.status = "pending";
|
|
13806
|
+
chapter.generatingAt = void 0;
|
|
13807
|
+
chapter.error = void 0;
|
|
13808
|
+
syncProjectWithDisk(project, config.outputDir);
|
|
13809
|
+
saveProject(config.outputDir, project);
|
|
13810
|
+
return;
|
|
13811
|
+
}
|
|
13290
13812
|
chapter.status = "error";
|
|
13291
13813
|
chapter.error = error.message;
|
|
13292
13814
|
mergeVolatileFromDisk(config.outputDir, project);
|
|
@@ -13362,8 +13884,9 @@ function makeRoutes(deps) {
|
|
|
13362
13884
|
const send = (frame) => {
|
|
13363
13885
|
res.write(JSON.stringify(frame) + "\n");
|
|
13364
13886
|
};
|
|
13887
|
+
const abort = abortOnClientClose(req, res);
|
|
13365
13888
|
try {
|
|
13366
|
-
for await (const step of rewriteChapterStream(ctx, config, project, config.outputDir, no, body?.instructions ?? "", body?.target)) if (step.frame === "delta") send({
|
|
13889
|
+
for await (const step of rewriteChapterStream(ctx, config, project, config.outputDir, no, body?.instructions ?? "", body?.target, abort.signal)) if (step.frame === "delta") send({
|
|
13367
13890
|
type: "delta",
|
|
13368
13891
|
text: step.text
|
|
13369
13892
|
});
|
|
@@ -13375,6 +13898,7 @@ function makeRoutes(deps) {
|
|
|
13375
13898
|
});
|
|
13376
13899
|
res.end();
|
|
13377
13900
|
} catch (error) {
|
|
13901
|
+
if (isAbortError(error)) return;
|
|
13378
13902
|
if (!res.writableEnded) {
|
|
13379
13903
|
send({
|
|
13380
13904
|
type: "error",
|
|
@@ -13417,8 +13941,9 @@ function makeRoutes(deps) {
|
|
|
13417
13941
|
const send = (frame) => {
|
|
13418
13942
|
res.write(JSON.stringify(frame) + "\n");
|
|
13419
13943
|
};
|
|
13944
|
+
const abort = abortOnClientClose(req, res);
|
|
13420
13945
|
try {
|
|
13421
|
-
for await (const step of polishChapterStream(ctx, config, project, config.outputDir, no)) if (step.frame === "delta") send({
|
|
13946
|
+
for await (const step of polishChapterStream(ctx, config, project, config.outputDir, no, abort.signal)) if (step.frame === "delta") send({
|
|
13422
13947
|
type: "delta",
|
|
13423
13948
|
text: step.text
|
|
13424
13949
|
});
|
|
@@ -13430,6 +13955,7 @@ function makeRoutes(deps) {
|
|
|
13430
13955
|
});
|
|
13431
13956
|
res.end();
|
|
13432
13957
|
} catch (error) {
|
|
13958
|
+
if (isAbortError(error)) return;
|
|
13433
13959
|
if (!res.writableEnded) {
|
|
13434
13960
|
send({
|
|
13435
13961
|
type: "error",
|
|
@@ -13448,7 +13974,7 @@ function makeRoutes(deps) {
|
|
|
13448
13974
|
handler: async (req, res) => {
|
|
13449
13975
|
if (!guard(req, res, "POST")) return;
|
|
13450
13976
|
const config = getConfig();
|
|
13451
|
-
const project =
|
|
13977
|
+
const project = requireProjectAt(req, res);
|
|
13452
13978
|
if (project === void 0) return;
|
|
13453
13979
|
const body = await readJsonBody(req);
|
|
13454
13980
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
@@ -13499,7 +14025,7 @@ function makeRoutes(deps) {
|
|
|
13499
14025
|
handler: async (req, res) => {
|
|
13500
14026
|
if (!guard(req, res, "POST")) return;
|
|
13501
14027
|
const config = getConfig();
|
|
13502
|
-
const project =
|
|
14028
|
+
const project = requireProjectAt(req, res);
|
|
13503
14029
|
if (project === void 0) return;
|
|
13504
14030
|
const body = await readJsonBody(req);
|
|
13505
14031
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
@@ -13525,7 +14051,7 @@ function makeRoutes(deps) {
|
|
|
13525
14051
|
handler: async (req, res) => {
|
|
13526
14052
|
if (!guard(req, res, "POST")) return;
|
|
13527
14053
|
const config = getConfig();
|
|
13528
|
-
const project =
|
|
14054
|
+
const project = requireProjectAt(req, res);
|
|
13529
14055
|
if (project === void 0) return;
|
|
13530
14056
|
const body = await readJsonBody(req);
|
|
13531
14057
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
@@ -13669,7 +14195,7 @@ function makeRoutes(deps) {
|
|
|
13669
14195
|
handler: async (req, res) => {
|
|
13670
14196
|
if (!guard(req, res, "POST")) return;
|
|
13671
14197
|
const config = getConfig();
|
|
13672
|
-
const project =
|
|
14198
|
+
const project = requireProjectAt(req, res);
|
|
13673
14199
|
if (project === void 0) return;
|
|
13674
14200
|
const body = await readJsonBody(req);
|
|
13675
14201
|
const text = body?.text?.trim() ?? "";
|
|
@@ -13691,7 +14217,7 @@ function makeRoutes(deps) {
|
|
|
13691
14217
|
handler: async (req, res) => {
|
|
13692
14218
|
if (!guard(req, res, "POST")) return;
|
|
13693
14219
|
const config = getConfig();
|
|
13694
|
-
const project =
|
|
14220
|
+
const project = requireProjectAt(req, res);
|
|
13695
14221
|
if (project === void 0) return;
|
|
13696
14222
|
const body = await readJsonBody(req);
|
|
13697
14223
|
if (!Number.isInteger(body?.chapterNo)) {
|
|
@@ -13762,8 +14288,9 @@ function makeRoutes(deps) {
|
|
|
13762
14288
|
const send = (frame) => {
|
|
13763
14289
|
res.write(JSON.stringify(frame) + "\n");
|
|
13764
14290
|
};
|
|
14291
|
+
const abort = abortOnClientClose(req, res);
|
|
13765
14292
|
try {
|
|
13766
|
-
for await (const step of runAssistantTurn(ctx, config, project, outputDir, message)) if (step.frame === "delta") send({
|
|
14293
|
+
for await (const step of runAssistantTurn(ctx, config, project, outputDir, message, abort.signal)) if (step.frame === "delta") send({
|
|
13767
14294
|
type: "delta",
|
|
13768
14295
|
text: step.text
|
|
13769
14296
|
});
|
|
@@ -13786,6 +14313,7 @@ function makeRoutes(deps) {
|
|
|
13786
14313
|
send({ type: "done" });
|
|
13787
14314
|
res.end();
|
|
13788
14315
|
} catch (error) {
|
|
14316
|
+
if (isAbortError(error)) return;
|
|
13789
14317
|
if (!res.writableEnded) {
|
|
13790
14318
|
send({
|
|
13791
14319
|
type: "error",
|
|
@@ -14923,6 +15451,10 @@ function makeRoutes(deps) {
|
|
|
14923
15451
|
writeJson(res, 400, { error: "filePath 不能为空(或请上传文件内容)" });
|
|
14924
15452
|
return;
|
|
14925
15453
|
}
|
|
15454
|
+
if (!IMPORTABLE_EXTENSIONS.includes(extname(filePath).toLowerCase())) {
|
|
15455
|
+
writeJson(res, 400, { error: `仅支持纯文本小说文件(${IMPORTABLE_EXTENSIONS.join(" / ")})` });
|
|
15456
|
+
return;
|
|
15457
|
+
}
|
|
14926
15458
|
if (!existsSync(filePath)) {
|
|
14927
15459
|
writeJson(res, 400, { error: `文件不存在:${filePath}` });
|
|
14928
15460
|
return;
|
|
@@ -15026,7 +15558,7 @@ function makeRoutes(deps) {
|
|
|
15026
15558
|
handler: async (req, res) => {
|
|
15027
15559
|
if (!guard(req, res, "POST")) return;
|
|
15028
15560
|
const config = getConfig();
|
|
15029
|
-
const project =
|
|
15561
|
+
const project = requireProjectAt(req, res);
|
|
15030
15562
|
if (project === void 0) return;
|
|
15031
15563
|
try {
|
|
15032
15564
|
const cards = await refreshCharacters(ctx, config, project, config.outputDir);
|
|
@@ -15046,7 +15578,7 @@ function makeRoutes(deps) {
|
|
|
15046
15578
|
handler: async (req, res) => {
|
|
15047
15579
|
if (!guard(req, res, "POST")) return;
|
|
15048
15580
|
const config = getConfig();
|
|
15049
|
-
const project =
|
|
15581
|
+
const project = requireProjectAt(req, res);
|
|
15050
15582
|
if (project === void 0) return;
|
|
15051
15583
|
try {
|
|
15052
15584
|
writeJson(res, 200, {
|
|
@@ -15065,7 +15597,7 @@ function makeRoutes(deps) {
|
|
|
15065
15597
|
handler: async (req, res) => {
|
|
15066
15598
|
if (!guard(req, res, "POST")) return;
|
|
15067
15599
|
const config = getConfig();
|
|
15068
|
-
const project =
|
|
15600
|
+
const project = requireProjectAt(req, res);
|
|
15069
15601
|
if (project === void 0) return;
|
|
15070
15602
|
if (project.bible === void 0) {
|
|
15071
15603
|
writeJson(res, 400, { error: "尚未生成道藏,请先生成" });
|
|
@@ -15100,7 +15632,7 @@ function makeRoutes(deps) {
|
|
|
15100
15632
|
handler: async (req, res) => {
|
|
15101
15633
|
if (!guard(req, res, "POST")) return;
|
|
15102
15634
|
const config = getConfig();
|
|
15103
|
-
const project =
|
|
15635
|
+
const project = requireProjectAt(req, res);
|
|
15104
15636
|
if (project === void 0) return;
|
|
15105
15637
|
const body = await readJsonBody(req);
|
|
15106
15638
|
try {
|
|
@@ -15134,8 +15666,11 @@ function makeRoutes(deps) {
|
|
|
15134
15666
|
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
15135
15667
|
return;
|
|
15136
15668
|
}
|
|
15137
|
-
const
|
|
15138
|
-
|
|
15669
|
+
const targetDir = resolveRegisteredDir(config, new URL(req.url ?? "/", "http://localhost").searchParams.get("dir"));
|
|
15670
|
+
if (targetDir === void 0) {
|
|
15671
|
+
writeJson(res, 400, { error: "dir 不是已登记的书籍目录" });
|
|
15672
|
+
return;
|
|
15673
|
+
}
|
|
15139
15674
|
const coverPath = loadProject(targetDir)?.coverPath;
|
|
15140
15675
|
if (coverPath === void 0 || coverPath === "") {
|
|
15141
15676
|
writeJson(res, 200, { dataUrl: null });
|
|
@@ -15151,7 +15686,7 @@ function makeRoutes(deps) {
|
|
|
15151
15686
|
}
|
|
15152
15687
|
if (req.method === "POST") {
|
|
15153
15688
|
if (!guard(req, res, "POST")) return;
|
|
15154
|
-
const project =
|
|
15689
|
+
const project = requireProjectAt(req, res);
|
|
15155
15690
|
if (project === void 0) return;
|
|
15156
15691
|
const body = await readJsonBody(req);
|
|
15157
15692
|
try {
|
|
@@ -15207,7 +15742,7 @@ function makeRoutes(deps) {
|
|
|
15207
15742
|
handler: async (req, res) => {
|
|
15208
15743
|
if (!guard(req, res, "POST")) return;
|
|
15209
15744
|
const config = getConfig();
|
|
15210
|
-
const project =
|
|
15745
|
+
const project = requireProjectAt(req, res);
|
|
15211
15746
|
if (project === void 0) return;
|
|
15212
15747
|
const body = await readJsonBody(req);
|
|
15213
15748
|
try {
|
|
@@ -15239,7 +15774,7 @@ function makeRoutes(deps) {
|
|
|
15239
15774
|
handler: async (req, res) => {
|
|
15240
15775
|
if (!guard(req, res, "POST")) return;
|
|
15241
15776
|
const config = getConfig();
|
|
15242
|
-
const project =
|
|
15777
|
+
const project = requireProjectAt(req, res);
|
|
15243
15778
|
if (project === void 0) return;
|
|
15244
15779
|
const bookName = (await readJsonBody(req))?.bookName?.trim();
|
|
15245
15780
|
if (bookName === void 0 || bookName === "") {
|
|
@@ -15261,7 +15796,7 @@ function makeRoutes(deps) {
|
|
|
15261
15796
|
handler: async (req, res) => {
|
|
15262
15797
|
if (!guard(req, res, "POST")) return;
|
|
15263
15798
|
const config = getConfig();
|
|
15264
|
-
const project =
|
|
15799
|
+
const project = requireProjectAt(req, res);
|
|
15265
15800
|
if (project === void 0) return;
|
|
15266
15801
|
const body = await readJsonBody(req);
|
|
15267
15802
|
if (project.plotlines === void 0) project.plotlines = [];
|
|
@@ -15390,7 +15925,7 @@ function makeRoutes(deps) {
|
|
|
15390
15925
|
handler: async (req, res) => {
|
|
15391
15926
|
if (!guard(req, res, "POST")) return;
|
|
15392
15927
|
const config = getConfig();
|
|
15393
|
-
const project =
|
|
15928
|
+
const project = requireProjectAt(req, res);
|
|
15394
15929
|
if (project === void 0) return;
|
|
15395
15930
|
const body = await readJsonBody(req);
|
|
15396
15931
|
const hits = [];
|
|
@@ -15444,7 +15979,7 @@ function makeRoutes(deps) {
|
|
|
15444
15979
|
handler: async (req, res) => {
|
|
15445
15980
|
if (!guard(req, res, "POST")) return;
|
|
15446
15981
|
const config = getConfig();
|
|
15447
|
-
const project =
|
|
15982
|
+
const project = requireProjectAt(req, res);
|
|
15448
15983
|
if (project === void 0) return;
|
|
15449
15984
|
const body = await readJsonBody(req);
|
|
15450
15985
|
if (project.roles === void 0) project.roles = [];
|
|
@@ -15482,7 +16017,7 @@ function makeRoutes(deps) {
|
|
|
15482
16017
|
handler: async (req, res) => {
|
|
15483
16018
|
if (!guard(req, res, "POST")) return;
|
|
15484
16019
|
const config = getConfig();
|
|
15485
|
-
const project =
|
|
16020
|
+
const project = requireProjectAt(req, res);
|
|
15486
16021
|
if (project === void 0) return;
|
|
15487
16022
|
const no = (await readJsonBody(req))?.chapterNo;
|
|
15488
16023
|
if (!Number.isInteger(no) || no === void 0 || no < 1) {
|
|
@@ -15512,7 +16047,7 @@ function makeRoutes(deps) {
|
|
|
15512
16047
|
handler: async (req, res) => {
|
|
15513
16048
|
if (!guard(req, res, "POST")) return;
|
|
15514
16049
|
const config = getConfig();
|
|
15515
|
-
const project =
|
|
16050
|
+
const project = requireProjectAt(req, res);
|
|
15516
16051
|
if (project === void 0) return;
|
|
15517
16052
|
const no = (await readJsonBody(req))?.chapterNo;
|
|
15518
16053
|
if (!Number.isInteger(no) || no === void 0 || no < 1) {
|
|
@@ -15533,6 +16068,73 @@ function makeRoutes(deps) {
|
|
|
15533
16068
|
});
|
|
15534
16069
|
}
|
|
15535
16070
|
};
|
|
16071
|
+
/**
|
|
16072
|
+
* 补写本章计划(方案 §7「左侧章节计划」六项)。
|
|
16073
|
+
*
|
|
16074
|
+
* 这些字段原本只有规划步骤会写:AI 排偏了,作者却没有纠正入口,而它们正是生成
|
|
16075
|
+
* 与审稿的锚点(见 engine 的本章义务合约拼装)。上限与规划步骤保持一致 ——
|
|
16076
|
+
* 否则「手动能写 10 条、AI 一重排只剩 4 条」就是静默丢数据;超限必然回写 notices。
|
|
16077
|
+
*/
|
|
16078
|
+
const chapterPlanRoute = {
|
|
16079
|
+
kind: "exact",
|
|
16080
|
+
path: NOVEL_API.chapterPlan,
|
|
16081
|
+
handler: async (req, res) => {
|
|
16082
|
+
if (!guard(req, res, "POST")) return;
|
|
16083
|
+
const config = getConfig();
|
|
16084
|
+
const project = requireProjectAt(req, res);
|
|
16085
|
+
if (project === void 0) return;
|
|
16086
|
+
const body = await readJsonBody(req);
|
|
16087
|
+
const no = body?.chapterNo;
|
|
16088
|
+
if (!Number.isInteger(no) || no === void 0 || no < 1) {
|
|
16089
|
+
writeJson(res, 400, { error: "chapterNo 须为正整数" });
|
|
16090
|
+
return;
|
|
16091
|
+
}
|
|
16092
|
+
const chapter = project.chapters.find((c) => c.no === no);
|
|
16093
|
+
if (chapter === void 0) {
|
|
16094
|
+
writeJson(res, 404, { error: `章节 ${no} 不在计划中` });
|
|
16095
|
+
return;
|
|
16096
|
+
}
|
|
16097
|
+
const patch = body?.patch;
|
|
16098
|
+
if (patch === null || typeof patch !== "object") {
|
|
16099
|
+
writeJson(res, 400, { error: "patch 须为对象" });
|
|
16100
|
+
return;
|
|
16101
|
+
}
|
|
16102
|
+
const notices = [];
|
|
16103
|
+
const oneLine = (label, raw, max) => {
|
|
16104
|
+
if (typeof raw !== "string") return void 0;
|
|
16105
|
+
const value = raw.trim();
|
|
16106
|
+
if (value.length <= max) return value;
|
|
16107
|
+
notices.push(`${label}:${value.length} 字已裁到 ${max} 字`);
|
|
16108
|
+
return value.slice(0, max);
|
|
16109
|
+
};
|
|
16110
|
+
const items = (label, raw, max) => {
|
|
16111
|
+
if (!Array.isArray(raw)) return void 0;
|
|
16112
|
+
const value = raw.filter((x) => typeof x === "string" && x.trim() !== "").map((x) => x.trim());
|
|
16113
|
+
if (value.length <= max) return value;
|
|
16114
|
+
notices.push(`${label}:${value.length} 条只保留前 ${max} 条`);
|
|
16115
|
+
return value.slice(0, max);
|
|
16116
|
+
};
|
|
16117
|
+
const obligation = oneLine("本章目标", patch.obligation, 200);
|
|
16118
|
+
if (obligation !== void 0) chapter.obligation = obligation === "" ? void 0 : obligation;
|
|
16119
|
+
const mustAdvance = items("本章必达项", patch.mustAdvance, 4);
|
|
16120
|
+
if (mustAdvance !== void 0) chapter.mustAdvance = mustAdvance;
|
|
16121
|
+
const mustPreserve = items("必须保持", patch.mustPreserve, 4);
|
|
16122
|
+
if (mustPreserve !== void 0) chapter.mustPreserve = mustPreserve;
|
|
16123
|
+
const hardFacts = items("人物硬事实", patch.characterHardFacts, 6);
|
|
16124
|
+
if (hardFacts !== void 0) chapter.characterHardFacts = hardFacts;
|
|
16125
|
+
const endingHook = oneLine("结尾钩子", patch.endingHook, 120);
|
|
16126
|
+
if (endingHook !== void 0) chapter.endingHook = endingHook === "" ? void 0 : endingHook;
|
|
16127
|
+
const beats = oneLine("剧情推进", patch.beats, 2e3);
|
|
16128
|
+
if (beats !== void 0) chapter.beats = beats;
|
|
16129
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
16130
|
+
saveProject(config.outputDir, project);
|
|
16131
|
+
writeJson(res, 200, {
|
|
16132
|
+
ok: true,
|
|
16133
|
+
chapter,
|
|
16134
|
+
notices
|
|
16135
|
+
});
|
|
16136
|
+
}
|
|
16137
|
+
};
|
|
15536
16138
|
/** 作者复盘补跑:对已写章节补齐 authorReview(body.chapterNo=单章 JSON,缺省=全书 NDJSON 流)。 */
|
|
15537
16139
|
const reviewBackfillRoute = {
|
|
15538
16140
|
kind: "exact",
|
|
@@ -15540,9 +16142,10 @@ function makeRoutes(deps) {
|
|
|
15540
16142
|
handler: async (req, res) => {
|
|
15541
16143
|
if (!guard(req, res, "POST")) return;
|
|
15542
16144
|
const config = getConfig();
|
|
15543
|
-
const project =
|
|
16145
|
+
const project = requireProjectAt(req, res);
|
|
15544
16146
|
if (project === void 0) return;
|
|
15545
16147
|
const body = await readJsonBody(req);
|
|
16148
|
+
const abort = abortOnClientClose(req, res);
|
|
15546
16149
|
/** 对一章执行作者复盘(读取已落盘正文,不改变章节状态/正文)。 */
|
|
15547
16150
|
const runOne = async (chapter) => {
|
|
15548
16151
|
const currentBody = readChapterFile(config.outputDir, chapter);
|
|
@@ -15552,6 +16155,7 @@ function makeRoutes(deps) {
|
|
|
15552
16155
|
const prev = project.chapters.find((c) => c.no === chapter.no - 1);
|
|
15553
16156
|
if (prev !== void 0) prevTail = stripChapterHeadings(readChapterFile(config.outputDir, prev) ?? "").slice(-600);
|
|
15554
16157
|
}
|
|
16158
|
+
if (abort.signal.aborted) throw new Error("客户端已断开连接");
|
|
15555
16159
|
return authorReviewChapter(ctx, config, project, chapter.no, currentBody, prevTail);
|
|
15556
16160
|
};
|
|
15557
16161
|
if (typeof body?.chapterNo === "number" && body.chapterNo > 0) {
|
|
@@ -15726,7 +16330,7 @@ function makeRoutes(deps) {
|
|
|
15726
16330
|
handler: async (req, res) => {
|
|
15727
16331
|
if (!guard(req, res, "POST")) return;
|
|
15728
16332
|
const config = getConfig();
|
|
15729
|
-
const project =
|
|
16333
|
+
const project = requireProjectAt(req, res);
|
|
15730
16334
|
if (project === void 0) return;
|
|
15731
16335
|
res.writeHead(200, {
|
|
15732
16336
|
"content-type": "application/x-ndjson; charset=utf-8",
|
|
@@ -15737,6 +16341,7 @@ function makeRoutes(deps) {
|
|
|
15737
16341
|
const send = (frame) => {
|
|
15738
16342
|
res.write(JSON.stringify(frame) + "\n");
|
|
15739
16343
|
};
|
|
16344
|
+
const abort = abortOnClientClose(req, res);
|
|
15740
16345
|
try {
|
|
15741
16346
|
const outline = await reverseOutlineFromChapters(ctx, config, project, config.outputDir, (done, total, phase) => {
|
|
15742
16347
|
send({
|
|
@@ -15745,7 +16350,7 @@ function makeRoutes(deps) {
|
|
|
15745
16350
|
total,
|
|
15746
16351
|
phase
|
|
15747
16352
|
});
|
|
15748
|
-
});
|
|
16353
|
+
}, abort.signal);
|
|
15749
16354
|
project.outline = outline;
|
|
15750
16355
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
15751
16356
|
saveProject(config.outputDir, project);
|
|
@@ -15755,6 +16360,7 @@ function makeRoutes(deps) {
|
|
|
15755
16360
|
chars: outline.length
|
|
15756
16361
|
});
|
|
15757
16362
|
} catch (error) {
|
|
16363
|
+
if (isAbortError(error)) return;
|
|
15758
16364
|
send({
|
|
15759
16365
|
type: "error",
|
|
15760
16366
|
no: 0,
|
|
@@ -15865,7 +16471,7 @@ function makeRoutes(deps) {
|
|
|
15865
16471
|
handler: async (req, res) => {
|
|
15866
16472
|
if (!guard(req, res, "POST")) return;
|
|
15867
16473
|
const config = getConfig();
|
|
15868
|
-
const project =
|
|
16474
|
+
const project = requireProjectAt(req, res);
|
|
15869
16475
|
if (project === void 0) return;
|
|
15870
16476
|
const body = await readJsonBody(req);
|
|
15871
16477
|
try {
|
|
@@ -15875,8 +16481,16 @@ function makeRoutes(deps) {
|
|
|
15875
16481
|
}
|
|
15876
16482
|
}
|
|
15877
16483
|
};
|
|
15878
|
-
/**
|
|
15879
|
-
|
|
16484
|
+
/**
|
|
16485
|
+
* 生产单执行器:计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。
|
|
16486
|
+
*
|
|
16487
|
+
* 必须由调用方(index.ts)持有并注入,**不要在这里 new**:
|
|
16488
|
+
* 本工厂每次 `sync()` 都会重建,藏在闭包里的 runner 会被丢掉——
|
|
16489
|
+
* 旧实例的 `loop()` 仍在跑并继续写盘,而新实例 `isWorking()` 恒 false,
|
|
16490
|
+
* 于是 `moveOutputDir` 的「正在生产则拒绝迁移」守卫直接失效。
|
|
16491
|
+
* 测试可省略本项,内部自建一个一次性实例。
|
|
16492
|
+
*/
|
|
16493
|
+
const runner = deps.runner ?? new ProductionRunner({
|
|
15880
16494
|
ctx,
|
|
15881
16495
|
getConfig
|
|
15882
16496
|
});
|
|
@@ -16232,11 +16846,13 @@ function makeRoutes(deps) {
|
|
|
16232
16846
|
const send = (frame) => {
|
|
16233
16847
|
res.write(JSON.stringify(frame) + "\n");
|
|
16234
16848
|
};
|
|
16849
|
+
const abort = abortOnClientClose(req, res);
|
|
16235
16850
|
try {
|
|
16236
16851
|
const result = await rewriteAdaptationBook(ctx, getConfig(), text, mappings, body?.rules, {
|
|
16237
16852
|
maxChapters: body?.maxChapters,
|
|
16238
16853
|
startNo: body?.startNo,
|
|
16239
16854
|
endNo: body?.endNo,
|
|
16855
|
+
signal: abort.signal,
|
|
16240
16856
|
onProgress: (info) => send({
|
|
16241
16857
|
type: "progress",
|
|
16242
16858
|
completed: info.completed,
|
|
@@ -16245,6 +16861,7 @@ function makeRoutes(deps) {
|
|
|
16245
16861
|
title: info.title
|
|
16246
16862
|
})
|
|
16247
16863
|
});
|
|
16864
|
+
if (abort.signal.aborted) return;
|
|
16248
16865
|
send({
|
|
16249
16866
|
type: "done",
|
|
16250
16867
|
result: {
|
|
@@ -16257,6 +16874,7 @@ function makeRoutes(deps) {
|
|
|
16257
16874
|
}
|
|
16258
16875
|
});
|
|
16259
16876
|
} catch (err) {
|
|
16877
|
+
if (isAbortError(err)) return;
|
|
16260
16878
|
send({
|
|
16261
16879
|
type: "error",
|
|
16262
16880
|
message: err instanceof Error ? err.message : String(err)
|
|
@@ -16470,6 +17088,7 @@ function makeRoutes(deps) {
|
|
|
16470
17088
|
reviewBackfillRoute,
|
|
16471
17089
|
chapterResetRoute,
|
|
16472
17090
|
chapterApproveRoute,
|
|
17091
|
+
chapterPlanRoute,
|
|
16473
17092
|
configRoute,
|
|
16474
17093
|
moveOutputDirRoute,
|
|
16475
17094
|
bookSettingsRoute,
|
|
@@ -16845,6 +17464,21 @@ function apply(ctx, config) {
|
|
|
16845
17464
|
};
|
|
16846
17465
|
let disposeSection;
|
|
16847
17466
|
let disposeRoutes;
|
|
17467
|
+
/**
|
|
17468
|
+
* 生产单 runner 单例:**必须在 makeRoutes 之外**持有。
|
|
17469
|
+
*
|
|
17470
|
+
* 此前 runner 在 makeRoutes 工厂体内 `new`,而 `sync()` 每次配置变更都会
|
|
17471
|
+
* 重新 makeRoutes + dispose 全部旧路由 —— 于是:
|
|
17472
|
+
* 1. 旧 runner 的 `loop()` 仍在跑并继续 saveProject(失去引用但不停止),同时
|
|
17473
|
+
* 2. 新 runner 的 `isWorking()` 恒为 false,`moveOutputDir` 的守卫失效 →
|
|
17474
|
+
* 可以边迁移目录边写盘。
|
|
17475
|
+
* 提到这里之后,sync() 只重建路由表,runner 及其内存状态(working / 当前章 /
|
|
17476
|
+
* 暂停标志)跨重注册保持连续。
|
|
17477
|
+
*/
|
|
17478
|
+
const runner = new ProductionRunner({
|
|
17479
|
+
ctx,
|
|
17480
|
+
getConfig: resolve
|
|
17481
|
+
});
|
|
16848
17482
|
const sync = () => {
|
|
16849
17483
|
if (disposeSection !== void 0) {
|
|
16850
17484
|
disposeSection();
|
|
@@ -16866,7 +17500,8 @@ function apply(ctx, config) {
|
|
|
16866
17500
|
getConfig: resolve,
|
|
16867
17501
|
patchConfig,
|
|
16868
17502
|
rawConfig: () => current(),
|
|
16869
|
-
settingsNs
|
|
17503
|
+
settingsNs,
|
|
17504
|
+
runner
|
|
16870
17505
|
});
|
|
16871
17506
|
disposeRoutes = ctx.effect(() => {
|
|
16872
17507
|
const disposers = routes.map((route) => ctx.webServer.register(route));
|