@waterwx/dsh-novel-forge 1.3.2 → 1.4.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 +2 -2
- package/lib/client.js +548 -267
- package/lib/client.js.map +1 -1
- package/lib/index.js +183 -9
- package/lib/index.js.map +1 -1
- package/lib/types/bookshelf.d.ts +6 -1
- package/lib/types/client/api.d.ts +4 -0
- package/lib/types/client/panel/ImportModal.d.ts +7 -0
- package/lib/types/client/panel/ShelfView.d.ts +3 -1
- package/lib/types/engine.d.ts +6 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/protocol.d.ts +32 -0
- package/package.json +97 -97
- package/src/bookshelf.ts +18 -2
- package/src/client/api.ts +420 -410
- package/src/client/panel/ImportModal.tsx +191 -0
- package/src/client/panel/NovelPanel.tsx +5058 -5046
- package/src/client/panel/ShelfView.tsx +249 -236
- package/src/client/panel/panel.module.css +3037 -2941
- package/src/engine.ts +3001 -2943
- package/src/index.ts +7 -5
- package/src/protocol.ts +1244 -1207
- package/src/routes.ts +2332 -2272
- package/scripts/assistant-frames.txt +0 -8
- package/scripts/diagnose-adapter.mts +0 -81
- package/scripts/diagnose-assistant.mjs +0 -58
- package/scripts/diagnose-bible.mjs +0 -72
- package/scripts/diagnose-harness.mts +0 -75
- package/scripts/novel-forge-restart.log +0 -10
- package/scripts/novel-forge-web.stderr.log +0 -2
- package/scripts/novel-forge-web.stdout.log +0 -1
- package/scripts/outline-sample.txt +0 -449
- package/scripts/probe.mjs +0 -6
- package/scripts/restart-web.ps1 +0 -88
- package/scripts/smoke.mts +0 -72
- package/scripts/upload-github.ps1 +0 -51
package/lib/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { basename, extname, join } from "node:path";
|
|
1
3
|
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
2
4
|
import z from "schemastery";
|
|
3
5
|
import { exec } from "node:child_process";
|
|
4
6
|
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { join } from "node:path";
|
|
6
7
|
import { BlockAssembler, ReasoningEffortId, createAssistantMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
7
|
-
import { homedir } from "node:os";
|
|
8
8
|
import { randomBytes } from "node:crypto";
|
|
9
9
|
import { strFromU8, unzipSync } from "fflate";
|
|
10
10
|
//#region src/assets.ts
|
|
@@ -1948,6 +1948,94 @@ async function extractScenes(ctx, config, project) {
|
|
|
1948
1948
|
}
|
|
1949
1949
|
return scenes;
|
|
1950
1950
|
}
|
|
1951
|
+
/** 从 txt/md 全本拆分章节并建立项目:正文落盘为章节文件,status=written(待审稿)。 */
|
|
1952
|
+
function importBookText(filePath, outputDir) {
|
|
1953
|
+
const lines = readFileSync(filePath, "utf8").split(/\r?\n/);
|
|
1954
|
+
const chapterHead = /^\s*(?:#\s*)?第\s*(\d+|[一二三四五六七八九十百千]+)\s*[章回节卷]\s*(.*?)\s*$/;
|
|
1955
|
+
const cnNum = {
|
|
1956
|
+
一: 1,
|
|
1957
|
+
二: 2,
|
|
1958
|
+
三: 3,
|
|
1959
|
+
四: 4,
|
|
1960
|
+
五: 5,
|
|
1961
|
+
六: 6,
|
|
1962
|
+
七: 7,
|
|
1963
|
+
八: 8,
|
|
1964
|
+
九: 9,
|
|
1965
|
+
十: 10,
|
|
1966
|
+
百: 100,
|
|
1967
|
+
千: 1e3
|
|
1968
|
+
};
|
|
1969
|
+
const parseCn = (s) => {
|
|
1970
|
+
if (/^\d+$/.test(s)) return Number(s);
|
|
1971
|
+
let total = 0;
|
|
1972
|
+
let section = 0;
|
|
1973
|
+
for (const ch of s) {
|
|
1974
|
+
const v = cnNum[ch];
|
|
1975
|
+
if (v === void 0) return 0;
|
|
1976
|
+
if (v >= 10) {
|
|
1977
|
+
total += (section > 0 ? section : 1) * v;
|
|
1978
|
+
section = 0;
|
|
1979
|
+
} else section = v;
|
|
1980
|
+
}
|
|
1981
|
+
return total + section;
|
|
1982
|
+
};
|
|
1983
|
+
const chunks = [];
|
|
1984
|
+
let current = null;
|
|
1985
|
+
for (const line of lines) {
|
|
1986
|
+
const m = chapterHead.exec(line);
|
|
1987
|
+
if (m !== null) {
|
|
1988
|
+
if (current !== null) chunks.push(current);
|
|
1989
|
+
const no = parseCn(m[1]);
|
|
1990
|
+
const title = (m[2] ?? "").trim() || "";
|
|
1991
|
+
current = {
|
|
1992
|
+
no: no > 0 ? no : chunks.length + 1,
|
|
1993
|
+
title,
|
|
1994
|
+
body: []
|
|
1995
|
+
};
|
|
1996
|
+
} else if (current !== null) current.body.push(line);
|
|
1997
|
+
}
|
|
1998
|
+
if (current !== null) chunks.push(current);
|
|
1999
|
+
if (chunks.length === 0) throw new Error("未识别到章节(需要\"第X章\"格式,或带 # 的章节标题)");
|
|
2000
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2001
|
+
const ordered = chunks.filter((c) => {
|
|
2002
|
+
if (seen.has(c.no)) return false;
|
|
2003
|
+
seen.add(c.no);
|
|
2004
|
+
return true;
|
|
2005
|
+
}).sort((a, b) => a.no - b.no);
|
|
2006
|
+
const bookName = basename(filePath, extname(filePath)).slice(0, 40) || "导入小说";
|
|
2007
|
+
const project = createProject(bookName);
|
|
2008
|
+
mkdirSync(outputDir, { recursive: true });
|
|
2009
|
+
const skipped = [];
|
|
2010
|
+
for (const c of ordered) {
|
|
2011
|
+
const body = c.body.join("\n").trim();
|
|
2012
|
+
if (body.length < 50) {
|
|
2013
|
+
skipped.push("第" + c.no + "章" + (c.title !== "" ? "「" + c.title + "」" : "") + "(内容过短,已跳过)");
|
|
2014
|
+
continue;
|
|
2015
|
+
}
|
|
2016
|
+
const chapter = {
|
|
2017
|
+
no: c.no,
|
|
2018
|
+
volume: 0,
|
|
2019
|
+
title: c.title !== "" ? c.title : "第" + c.no + "章",
|
|
2020
|
+
beats: "",
|
|
2021
|
+
targetChars: 0,
|
|
2022
|
+
status: "written",
|
|
2023
|
+
file: "",
|
|
2024
|
+
chars: 0
|
|
2025
|
+
};
|
|
2026
|
+
chapter.file = chapterFileName(chapter);
|
|
2027
|
+
writeFileSync(join(outputDir, chapter.file), "# 第" + c.no + "章 " + chapter.title + "\n\n" + body + "\n", "utf8");
|
|
2028
|
+
chapter.chars = body.length;
|
|
2029
|
+
project.chapters.push(chapter);
|
|
2030
|
+
}
|
|
2031
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2032
|
+
saveProject(outputDir, project);
|
|
2033
|
+
return {
|
|
2034
|
+
bookName,
|
|
2035
|
+
chapters: project.chapters.length,
|
|
2036
|
+
skipped
|
|
2037
|
+
};
|
|
2038
|
+
}
|
|
1951
2039
|
/**
|
|
1952
2040
|
* 为单个角色提炼「动漫形象描述词」:扫描该角色出场的已写章节正文,
|
|
1953
2041
|
* 截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
|
|
@@ -3501,6 +3589,25 @@ function seedBookshelfFromOutputDir(outputDir) {
|
|
|
3501
3589
|
createBook(loadProject(outputDir)?.bookName ?? outputDir.split(/[\\/]/).pop() ?? "未命名小说", outputDir);
|
|
3502
3590
|
return true;
|
|
3503
3591
|
}
|
|
3592
|
+
/** 导入已有项目目录到书架:校验 novel-project.json,已存在则直接激活。 */
|
|
3593
|
+
function importDir(outputDir) {
|
|
3594
|
+
const project = loadProject(outputDir);
|
|
3595
|
+
if (project === void 0) throw new Error(`目录中未找到有效的 novel-project.json:${outputDir}`);
|
|
3596
|
+
const store = loadBookshelf();
|
|
3597
|
+
const existed = store.books.find((b) => b.outputDir === outputDir);
|
|
3598
|
+
if (existed !== void 0) {
|
|
3599
|
+
store.activeBookId = existed.id;
|
|
3600
|
+
saveBookshelf(store);
|
|
3601
|
+
return {
|
|
3602
|
+
book: existed,
|
|
3603
|
+
existed: true
|
|
3604
|
+
};
|
|
3605
|
+
}
|
|
3606
|
+
return {
|
|
3607
|
+
book: createBook(project.bookName !== "" ? project.bookName : outputDir.split(/[\\/]/).pop() ?? "未命名小说", outputDir),
|
|
3608
|
+
existed: false
|
|
3609
|
+
};
|
|
3610
|
+
}
|
|
3504
3611
|
/** 激活一本书。 */
|
|
3505
3612
|
function activateBook(id) {
|
|
3506
3613
|
const store = loadBookshelf();
|
|
@@ -3525,9 +3632,10 @@ function removeBook(id) {
|
|
|
3525
3632
|
function activeBookOutputDir() {
|
|
3526
3633
|
return activeBook(loadBookshelf())?.outputDir;
|
|
3527
3634
|
}
|
|
3528
|
-
/**
|
|
3635
|
+
/** 默认输出目录推断:~/.dsh/novels/书名。 */
|
|
3529
3636
|
function defaultOutputDirFor(bookName) {
|
|
3530
|
-
|
|
3637
|
+
const clean = bookName.replace(/[\\/:*?"<>|]/g, "").trim().slice(0, 40) || "未命名小说";
|
|
3638
|
+
return join(homedir(), ".dsh", "novels", clean);
|
|
3531
3639
|
}
|
|
3532
3640
|
//#endregion
|
|
3533
3641
|
//#region src/run.ts
|
|
@@ -3877,6 +3985,10 @@ const NOVEL_API = {
|
|
|
3877
3985
|
/** 清空助手对话记录。 */
|
|
3878
3986
|
assistantClear: "/api/dsh-novel-forge/assistant/clear",
|
|
3879
3987
|
bookshelf: "/api/dsh-novel-forge/bookshelf",
|
|
3988
|
+
/** 导入已有项目目录(含 novel-project.json)到书架。 */
|
|
3989
|
+
bookshelfImportDir: "/api/dsh-novel-forge/bookshelf/import-dir",
|
|
3990
|
+
/** 导入 txt/md 全本:拆章建项目并登记书架。 */
|
|
3991
|
+
bookshelfImportText: "/api/dsh-novel-forge/bookshelf/import-text",
|
|
3880
3992
|
/** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
|
|
3881
3993
|
reset: "/api/dsh-novel-forge/reset",
|
|
3882
3994
|
/** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
|
|
@@ -5728,6 +5840,58 @@ function makeRoutes(deps) {
|
|
|
5728
5840
|
writeJson(res, 200, bookshelfSnapshot(loadBookshelf()));
|
|
5729
5841
|
}
|
|
5730
5842
|
};
|
|
5843
|
+
/** 导入已有项目目录(Mode A):校验 novel-project.json,登记/激活书架。 */
|
|
5844
|
+
const bookshelfImportDirRoute = {
|
|
5845
|
+
kind: "exact",
|
|
5846
|
+
path: NOVEL_API.bookshelfImportDir,
|
|
5847
|
+
handler: async (req, res) => {
|
|
5848
|
+
if (!guard(req, res, "POST")) return;
|
|
5849
|
+
const outputDir = (await readJsonBody(req))?.outputDir?.trim();
|
|
5850
|
+
if (outputDir === void 0 || outputDir === "") {
|
|
5851
|
+
writeJson(res, 400, { error: "outputDir 不能为空" });
|
|
5852
|
+
return;
|
|
5853
|
+
}
|
|
5854
|
+
try {
|
|
5855
|
+
const { book, existed } = importDir(outputDir);
|
|
5856
|
+
writeJson(res, 200, {
|
|
5857
|
+
book,
|
|
5858
|
+
existed
|
|
5859
|
+
});
|
|
5860
|
+
} catch (err) {
|
|
5861
|
+
writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
5862
|
+
}
|
|
5863
|
+
}
|
|
5864
|
+
};
|
|
5865
|
+
/** 导入 txt/md 全本(Mode B):拆章落盘建项目,登记书架。 */
|
|
5866
|
+
const bookshelfImportTextRoute = {
|
|
5867
|
+
kind: "exact",
|
|
5868
|
+
path: NOVEL_API.bookshelfImportText,
|
|
5869
|
+
handler: async (req, res) => {
|
|
5870
|
+
if (!guard(req, res, "POST")) return;
|
|
5871
|
+
const body = await readJsonBody(req);
|
|
5872
|
+
const filePath = body?.filePath?.trim();
|
|
5873
|
+
if (filePath === void 0 || filePath === "") {
|
|
5874
|
+
writeJson(res, 400, { error: "filePath 不能为空" });
|
|
5875
|
+
return;
|
|
5876
|
+
}
|
|
5877
|
+
if (!existsSync(filePath)) {
|
|
5878
|
+
writeJson(res, 400, { error: `文件不存在:${filePath}` });
|
|
5879
|
+
return;
|
|
5880
|
+
}
|
|
5881
|
+
try {
|
|
5882
|
+
const bookName = basename(filePath, extname(filePath)).slice(0, 40) || "导入小说";
|
|
5883
|
+
const outDir = body?.outputDir?.trim() !== void 0 && body.outputDir.trim() !== "" ? body.outputDir.trim() : defaultOutputDirFor(bookName);
|
|
5884
|
+
const result = importBookText(filePath, outDir);
|
|
5885
|
+
const { book } = importDir(outDir);
|
|
5886
|
+
writeJson(res, 200, {
|
|
5887
|
+
...result,
|
|
5888
|
+
book
|
|
5889
|
+
});
|
|
5890
|
+
} catch (err) {
|
|
5891
|
+
writeJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
5892
|
+
}
|
|
5893
|
+
}
|
|
5894
|
+
};
|
|
5731
5895
|
/** 全书一致性质检。 */
|
|
5732
5896
|
const auditRoute = {
|
|
5733
5897
|
kind: "exact",
|
|
@@ -6651,6 +6815,8 @@ function makeRoutes(deps) {
|
|
|
6651
6815
|
bookshelfRoute,
|
|
6652
6816
|
bookshelfActivateRoute,
|
|
6653
6817
|
bookshelfRemoveRoute,
|
|
6818
|
+
bookshelfImportDirRoute,
|
|
6819
|
+
bookshelfImportTextRoute,
|
|
6654
6820
|
resetRoute,
|
|
6655
6821
|
auditRoute,
|
|
6656
6822
|
charactersRefreshRoute,
|
|
@@ -6730,6 +6896,14 @@ function makeRoutes(deps) {
|
|
|
6730
6896
|
}
|
|
6731
6897
|
//#endregion
|
|
6732
6898
|
//#region src/index.ts
|
|
6899
|
+
/**
|
|
6900
|
+
* dsh-novel-forge — host half. Mounts the AI novel-forge workbench: docx
|
|
6901
|
+
* outline import, LLM chapter planning, chapter-by-chapter generation
|
|
6902
|
+
* (3000-4000 chars each), Markdown output into your chosen folder, and the
|
|
6903
|
+
* /api/dsh-novel-forge route family. The browser half (./client) renders the
|
|
6904
|
+
* workbench panel. Everything rides official NPM SDK packages — no dsh source
|
|
6905
|
+
* changes.
|
|
6906
|
+
*/
|
|
6733
6907
|
/** Stable cordis plugin name. */
|
|
6734
6908
|
const name = "novel-forge";
|
|
6735
6909
|
/** Services required before the novel-forge surfaces can mount. */
|
|
@@ -6747,8 +6921,8 @@ const NOVEL_SETTINGS_NAMESPACE = settingsNamespace("dsh-novel-forge");
|
|
|
6747
6921
|
const Config = z.object({
|
|
6748
6922
|
announceToAgent: z.boolean().default(true),
|
|
6749
6923
|
enabled: z.boolean().default(true),
|
|
6750
|
-
outlinePath: z.string().default("
|
|
6751
|
-
outputDir: z.string().default("
|
|
6924
|
+
outlinePath: z.string().default(""),
|
|
6925
|
+
outputDir: z.string().default(join(homedir(), ".dsh", "novels")),
|
|
6752
6926
|
provider: z.string().default("deepseek-official"),
|
|
6753
6927
|
model: z.string().default("deepseek-v4-flash"),
|
|
6754
6928
|
reasoningEffort: z.union([
|
|
@@ -6769,8 +6943,8 @@ const Config = z.object({
|
|
|
6769
6943
|
});
|
|
6770
6944
|
/** Schema defaults, re-read for hand-built test contexts. */
|
|
6771
6945
|
const DEFAULT_ANNOUNCE = true;
|
|
6772
|
-
const DEFAULT_OUTLINE_PATH = "
|
|
6773
|
-
const DEFAULT_OUTPUT_DIR = "
|
|
6946
|
+
const DEFAULT_OUTLINE_PATH = "";
|
|
6947
|
+
const DEFAULT_OUTPUT_DIR = join(homedir(), ".dsh", "novels");
|
|
6774
6948
|
const DEFAULT_PROVIDER = "deepseek-official";
|
|
6775
6949
|
const DEFAULT_MODEL = "deepseek-v4-flash";
|
|
6776
6950
|
const DEFAULT_REASONING_EFFORT = "off";
|
|
@@ -6783,7 +6957,7 @@ const DEFAULT_AUTO_REVIEW_AFTER_REVISE = true;
|
|
|
6783
6957
|
/** Order of the announcement section within the tool-guidance band. */
|
|
6784
6958
|
const SECTION_ORDER = 160;
|
|
6785
6959
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
6786
|
-
const NOVEL_GUIDANCE = "本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx
|
|
6960
|
+
const NOVEL_GUIDANCE = "本机已安装 dsh-novel-forge 插件(AI 编译小说工作台):侧边栏「小说工坊」入口。能力:读取 docx 大纲或粘贴大纲文本;用 LLM 提炼道藏(人设/世界观/金手指规则/写作红线,即设定圣经);生成卷计划与章节计划;逐章调用 LLM 生成 3000-4000 字正文并保存为 Markdown(默认输出到用户主目录 ~/.dsh/novels);每章自动生成摘要(叙事记忆)、自动 AI 审稿(人设/设定/红线/文笔/爽点/逻辑),支持按审稿意见重写、去 AI 味润色、暗线(伏笔)管理、批量连写与全本导出(txt/md)。限制:生成消耗 LLM API 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 润色」时即指本插件,请据此协作。";
|
|
6787
6961
|
/** Resolve a config-like value into the full runtime config. */
|
|
6788
6962
|
function resolveConfig(value) {
|
|
6789
6963
|
return {
|