@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/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
- return join("D:\\ryan work\\harness\\novels", bookName.replace(/[\\/:*?"<>|]/g, "").trim().slice(0, 40) || "未命名小说");
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("D:\\ryan work\\harness\\novels\\归墟玉主\\《归墟玉主》全书大纲_重新排版版.docx"),
6751
- outputDir: z.string().default("D:\\ryan work\\harness\\novels\\归墟玉主"),
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 = "D:\\ryan work\\harness\\novels\\归墟玉主\\《归墟玉主》全书大纲_重新排版版.docx";
6773
- const DEFAULT_OUTPUT_DIR = "D:\\ryan work\\harness\\novels\\归墟玉主";
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 大纲(默认 D:\\ryan work\\harness\\novels\\归墟玉主 大纲)或粘贴大纲文本;用 LLM 提炼道藏(人设/世界观/金手指规则/写作红线,即设定圣经);生成卷计划与章节计划;逐章调用 LLM 生成 3000-4000 字正文并保存为 Markdown(默认输出到 D:\\ryan work\\harness\\novels\\归墟玉主);每章自动生成摘要(叙事记忆)、自动 AI 审稿(人设/设定/红线/文笔/爽点/逻辑),支持按审稿意见重写、去 AI 味润色、暗线(伏笔)管理、批量连写与全本导出(txt/md)。限制:生成消耗 LLM API 额度;输出目录与模型可在插件设置中修改;章节正文质量取决于大纲完整度。用户提到「小说 / 大纲 / 写小说 / 章节 / 审稿 / 润色 / 归墟玉主」时即指本插件,请据此协作。";
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 {