@waterwx/dsh-novel-forge 1.3.1 → 1.3.2-alpha

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.
Files changed (161) hide show
  1. package/README.md +288 -279
  2. package/lib/client.js +56309 -12221
  3. package/lib/index.js +14239 -5595
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +36 -0
  6. package/lib/types/assets.d.ts +24 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +25 -0
  9. package/lib/types/bookshelf.d.ts +11 -1
  10. package/lib/types/client/api.d.ts +186 -10
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -0
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/llmLive.d.ts +31 -0
  15. package/lib/types/client/locales.d.ts +28 -67
  16. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  17. package/lib/types/client/panel/AiTag.d.ts +1 -0
  18. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  19. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  20. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  21. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  22. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  23. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  24. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  25. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  26. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  27. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  28. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  29. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  30. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  31. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  32. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  33. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  34. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  35. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  36. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  37. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  38. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  39. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  40. package/lib/types/client/panel/SubPage.d.ts +27 -0
  41. package/lib/types/client/panel/TensionView.d.ts +18 -0
  42. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  43. package/lib/types/client/panel/helpers.d.ts +5 -3
  44. package/lib/types/client/panel/toast.d.ts +2 -0
  45. package/lib/types/client/panel/views.d.ts +25 -7
  46. package/lib/types/client/run-status.d.ts +21 -0
  47. package/lib/types/client/text.d.ts +9 -0
  48. package/lib/types/docx-core.d.ts +16 -0
  49. package/lib/types/docx.d.ts +2 -3
  50. package/lib/types/engine.d.ts +186 -45
  51. package/lib/types/global-assets.d.ts +6 -0
  52. package/lib/types/index.d.ts +22 -8
  53. package/lib/types/llm-live.d.ts +75 -0
  54. package/lib/types/llm-retry.d.ts +38 -0
  55. package/lib/types/market-radar-scan.d.ts +19 -0
  56. package/lib/types/market-radar-sources.d.ts +31 -0
  57. package/lib/types/migrate-output.d.ts +25 -0
  58. package/lib/types/novel-context.d.ts +80 -0
  59. package/lib/types/prompt-slots.d.ts +57 -0
  60. package/lib/types/prompt-utils.d.ts +35 -0
  61. package/lib/types/prompting.d.ts +10 -0
  62. package/lib/types/protocol.d.ts +1362 -166
  63. package/lib/types/review-policy.d.ts +97 -0
  64. package/lib/types/revision.d.ts +76 -0
  65. package/lib/types/routes.d.ts +4 -0
  66. package/lib/types/run.d.ts +23 -1
  67. package/lib/types/snapshots.d.ts +46 -0
  68. package/lib/types/stage-contract.d.ts +60 -0
  69. package/lib/types/tension.d.ts +53 -0
  70. package/lib/types/timeline.d.ts +37 -0
  71. package/package.json +52 -28
  72. package/src/action-guard.ts +127 -0
  73. package/src/ai-scan.ts +179 -0
  74. package/src/assets.ts +1806 -520
  75. package/src/assistant.ts +1098 -770
  76. package/src/author-assets.ts +149 -0
  77. package/src/bookshelf.ts +187 -151
  78. package/src/client/api.ts +378 -21
  79. package/src/client/chip-prefs.ts +121 -0
  80. package/src/client/docx.ts +37 -69
  81. package/src/client/index.ts +3 -1
  82. package/src/client/llmLive.ts +111 -0
  83. package/src/client/locales.ts +28 -253
  84. package/src/client/mount.tsx +1 -1
  85. package/src/client/panel/AdaptModeView.tsx +535 -0
  86. package/src/client/panel/AiTag.tsx +7 -0
  87. package/src/client/panel/AssetsTab.tsx +448 -458
  88. package/src/client/panel/AssistantTab.tsx +355 -301
  89. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  90. package/src/client/panel/AuthorHome.tsx +309 -0
  91. package/src/client/panel/BookAnalysisView.tsx +63 -0
  92. package/src/client/panel/CmdPalette.tsx +102 -0
  93. package/src/client/panel/CreateBookView.tsx +366 -348
  94. package/src/client/panel/DirectorView.tsx +200 -0
  95. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  96. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  97. package/src/client/panel/ImportModal.tsx +303 -0
  98. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  99. package/src/client/panel/LiveFeedLog.tsx +204 -0
  100. package/src/client/panel/MarketRadarView.tsx +377 -0
  101. package/src/client/panel/ModelManager.tsx +205 -0
  102. package/src/client/panel/NovelPanel.tsx +3135 -2651
  103. package/src/client/panel/PageHeader.tsx +37 -0
  104. package/src/client/panel/ProgressConsole.tsx +63 -0
  105. package/src/client/panel/PromptSlotsView.tsx +97 -0
  106. package/src/client/panel/ReaderView.tsx +306 -306
  107. package/src/client/panel/ReasoningSection.tsx +31 -0
  108. package/src/client/panel/RunPanel.tsx +64 -32
  109. package/src/client/panel/SettingsView.tsx +354 -0
  110. package/src/client/panel/ShelfView.tsx +266 -236
  111. package/src/client/panel/SubPage.tsx +42 -0
  112. package/src/client/panel/TensionView.tsx +212 -0
  113. package/src/client/panel/TimelineView.tsx +208 -0
  114. package/src/client/panel/WorldTab.tsx +202 -202
  115. package/src/client/panel/helpers.ts +7 -5
  116. package/src/client/panel/panel.module.css +4481 -1655
  117. package/src/client/panel/toast.ts +51 -0
  118. package/src/client/panel/views.tsx +401 -314
  119. package/src/client/run-status.ts +171 -0
  120. package/src/client/sidebar-entry.ts +1 -1
  121. package/src/client/text.ts +28 -0
  122. package/src/docx-core.ts +53 -0
  123. package/src/docx.ts +44 -83
  124. package/src/engine.ts +4672 -2943
  125. package/src/global-assets.ts +59 -0
  126. package/src/index.ts +89 -33
  127. package/src/llm-live.ts +260 -0
  128. package/src/llm-retry.ts +77 -0
  129. package/src/market-radar-scan.ts +41 -0
  130. package/src/market-radar-sources.ts +173 -0
  131. package/src/migrate-output.ts +76 -0
  132. package/src/novel-context.ts +286 -0
  133. package/src/prompt-slots.ts +125 -0
  134. package/src/prompting.ts +26 -0
  135. package/src/protocol.ts +1388 -158
  136. package/src/review-policy.ts +161 -0
  137. package/src/revision.ts +283 -0
  138. package/src/routes.ts +1934 -434
  139. package/src/run.ts +118 -21
  140. package/src/snapshots.ts +174 -0
  141. package/src/stage-contract.ts +189 -0
  142. package/src/tension.ts +251 -0
  143. package/src/timeline.ts +159 -0
  144. package/lib/client.js.map +0 -1
  145. package/lib/index.js.map +0 -1
  146. package/lib/types/comic-presets.d.ts +0 -12
  147. package/scripts/assistant-frames.txt +0 -8
  148. package/scripts/diagnose-adapter.mts +0 -81
  149. package/scripts/diagnose-assistant.mjs +0 -58
  150. package/scripts/diagnose-bible.mjs +0 -72
  151. package/scripts/diagnose-harness.mts +0 -75
  152. package/scripts/novel-forge-restart.log +0 -10
  153. package/scripts/novel-forge-web.stderr.log +0 -2
  154. package/scripts/novel-forge-web.stdout.log +0 -1
  155. package/scripts/outline-sample.txt +0 -449
  156. package/scripts/probe.mjs +0 -6
  157. package/scripts/restart-web.ps1 +0 -88
  158. package/scripts/smoke.mts +0 -72
  159. package/scripts/upload-github.ps1 +0 -51
  160. package/src/client/panel/BookshelfBar.tsx +0 -170
  161. package/src/comic-presets.ts +0 -67
@@ -0,0 +1,149 @@
1
+ /**
2
+ * 作者资产库/总数据(跨书)— 按作者维度聚合的可复用资产。
3
+ * 与书架同惯例持久化到 ~/.dsh/dsh-novel-forge-author-assets.json。
4
+ * 提供读取/持久化/新增或更新/删除,以及「导入默认」:把书架书的写作资产/角色 +
5
+ * 内置全局库(题材/反AI规则/风格模板/推进模式)批量沉淀成资产条目(默认库)。
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
9
+ import { homedir } from 'node:os'
10
+ import { join } from 'node:path'
11
+ import { randomBytes } from 'node:crypto'
12
+ import type { AuthorAssetLibrary, AuthorStyleAsset } from './protocol.ts'
13
+
14
+ import { loadBookshelf } from './bookshelf.ts'
15
+ import { loadProject } from './engine.ts'
16
+
17
+ /** 作者资产库配置文件路径。 */
18
+ export function authorAssetsFile(): string {
19
+ return join(homedir(), '.dsh', 'dsh-novel-forge-author-assets.json')
20
+ }
21
+
22
+ /** 默认空资产库。 */
23
+ function defaultLibrary(): AuthorAssetLibrary {
24
+ return { version: 1, items: [] }
25
+ }
26
+
27
+ /** 读取作者资产库(不存在/损坏则返回空库,不抛错)。 */
28
+ export function loadAuthorAssets(): AuthorAssetLibrary {
29
+ const file = authorAssetsFile()
30
+ if (!existsSync(file)) return defaultLibrary()
31
+ try {
32
+ let raw = readFileSync(file, 'utf8')
33
+ if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1)
34
+ const parsed = JSON.parse(raw) as Partial<AuthorAssetLibrary>
35
+ if (!Array.isArray(parsed.items)) return defaultLibrary()
36
+ return { version: 1, items: parsed.items }
37
+ } catch (error) {
38
+ // 资产库损坏回退默认值会遮蔽数据丢失,留痕到服务端日志。
39
+ console.error(`[novel-forge] author assets file unreadable/corrupt: ${file} — ${(error as Error).message}`)
40
+ return defaultLibrary()
41
+ }
42
+ }
43
+
44
+ /** 持久化作者资产库。 */
45
+ export function saveAuthorAssets(library: AuthorAssetLibrary): void {
46
+ const file = authorAssetsFile()
47
+ mkdirSync(join(homedir(), '.dsh'), { recursive: true })
48
+ writeFileSync(file, JSON.stringify(library, null, 2), 'utf8')
49
+ }
50
+
51
+ /** 新建一条资产(自动生成 id 与时间戳)。 */
52
+ export function createAuthorAsset(input: Omit<AuthorStyleAsset, 'id' | 'createdAt' | 'updatedAt'>): AuthorStyleAsset {
53
+ const now = new Date().toISOString()
54
+ const asset: AuthorStyleAsset = {
55
+ ...input,
56
+ id: 'aa-' + Date.now().toString(36) + '-' + randomBytes(3).toString('hex'),
57
+ sourceBooks: input.sourceBooks ?? [],
58
+ tags: input.tags ?? [],
59
+ createdAt: now,
60
+ updatedAt: now,
61
+ }
62
+ const library = loadAuthorAssets()
63
+ library.items.push(asset)
64
+ saveAuthorAssets(library)
65
+ return asset
66
+ }
67
+
68
+ /** 按 id 更新一条资产;不存在则追加。 */
69
+ export function upsertAuthorAsset(input: AuthorStyleAsset): AuthorAssetLibrary {
70
+ const library = loadAuthorAssets()
71
+ const idx = library.items.findIndex(a => a.id === input.id)
72
+ const now = new Date().toISOString()
73
+ if (idx >= 0) {
74
+ const prev = library.items[idx]
75
+ if (prev !== undefined) {
76
+ library.items[idx] = {
77
+ ...prev,
78
+ ...input,
79
+ id: prev.id,
80
+ createdAt: prev.createdAt,
81
+ updatedAt: now,
82
+ }
83
+ }
84
+ } else {
85
+ library.items.push({ ...input, createdAt: input.createdAt !== '' ? input.createdAt : now, updatedAt: now })
86
+ }
87
+ saveAuthorAssets(library)
88
+ return library
89
+ }
90
+
91
+ /** 删除一条资产;返回更新后的资产库。 */
92
+ export function removeAuthorAsset(id: string): AuthorAssetLibrary {
93
+ const library = loadAuthorAssets()
94
+ library.items = library.items.filter(a => a.id !== id)
95
+ saveAuthorAssets(library)
96
+ return library
97
+ }
98
+
99
+ /**
100
+ * 导入默认:把书架所有书的写作资产/角色 + 内置全局库批量沉淀到作者资产库。
101
+ * 按 `kind:name` 去重(已存在则跳过),不覆盖用户已有条目。
102
+ * @returns 更新后的资产库。
103
+ */
104
+ export function importDefaultAuthorAssets(): AuthorAssetLibrary {
105
+ const library = loadAuthorAssets()
106
+ const seen = new Set(library.items.map(a => a.kind + ':' + a.name))
107
+ const now = new Date().toISOString()
108
+ const push = (kind: AuthorStyleAsset['kind'], name: string, summary: string, content: string, tags: string[], sourceBooks: string[], structured?: Record<string, unknown>): void => {
109
+ const key = kind + ':' + name.trim()
110
+ if (name.trim() === '' || seen.has(key)) return
111
+ seen.add(key)
112
+ library.items.push({
113
+ id: 'aa-' + Date.now().toString(36) + '-' + randomBytes(3).toString('hex'),
114
+ kind, name: name.trim(), summary: summary.trim(), content: content.trim(),
115
+ sourceBooks, tags, structured, createdAt: now, updatedAt: now,
116
+ });
117
+ };
118
+
119
+ // 1) 清理内置公共库重复沉淀:作者库只保留个人沉淀,不再收录内置公共资产。
120
+ const builtinKinds = new Set<AuthorStyleAsset['kind']>(['genre', 'antiAi', 'progression', 'style', 'plotBeat'])
121
+ library.items = library.items.filter(a => !(a.tags.includes('内置') && builtinKinds.has(a.kind) && (a.sourceBooks ?? []).length === 0))
122
+
123
+ // 2) 书架里的书:写作资产 + 角色模板
124
+ const shelf = loadBookshelf()
125
+ for (const book of shelf.books) {
126
+ const project = loadProject(book.outputDir)
127
+ if (project === undefined) continue
128
+ const src = [project.bookName]
129
+ const assets = project.assets
130
+ if (assets !== undefined) {
131
+ if (assets.genre !== undefined) push('genre', assets.genre.name, assets.genre.description, assets.genre.description, ['书', '题材'], src, { description: assets.genre.description });
132
+ if (assets.primaryProgression !== undefined) push('progression', assets.primaryProgression.name, assets.primaryProgression.driver, '驱动:' + assets.primaryProgression.driver + '\n期待:' + assets.primaryProgression.readerExpectation, src, [], { driver: assets.primaryProgression.driver, primary: true });
133
+ for (const p of assets.auxiliaryProgressions ?? []) push('progression', p.name, p.driver, '驱动:' + p.driver + '\n期待:' + p.readerExpectation, src, [], { driver: p.driver, primary: false });
134
+ for (const r of assets.antiAiRules ?? []) push('antiAi', r.name, r.avoid, '避免:' + r.avoid + '\n修正:' + r.fix, src, [], { avoid: r.avoid, fix: r.fix, detectPatterns: r.detectPatterns });
135
+ for (const s of assets.styleAssets ?? []) push('style', s.name, s.proseRules.join(';'),
136
+ '叙述:' + s.proseRules.join(';') + '\n台词:' + s.dialogueRules.join(';') + '\n描写:' + s.descriptionRules.join(';') + '\n边界:' + s.boundaries.join(';'), src, [], { sourceText: s.sourceText });
137
+ }
138
+ // 作者库角色模板只收男主/女主(可复用原型),其余角色/路人不入库,避免刷屏。
139
+ for (const role of project.roles ?? []) {
140
+ if (role.roleLabel !== 'protagonist' && role.roleLabel !== 'female_lead') continue
141
+ const persona = ('定位:' + role.roleLabel + '\n身份:' + role.identity + '\n性格:' + role.traits.join('、') + '\n目标:' + role.goals + '\n关系:' + role.relations.join('、') + '\n成长线:' + (role.arc.length > 0 ? role.arc.join(' > ') : '—') + '\n知情度:' + role.knowledge.join('、')).trim();
142
+ push('roleTemplate', role.name, role.identity, persona, src, [], { roleLabel: role.roleLabel, traits: role.traits, relations: role.relations, arc: role.arc, knowledge: role.knowledge });
143
+ }
144
+ }
145
+
146
+ saveAuthorAssets(library)
147
+ return library
148
+ }
149
+
package/src/bookshelf.ts CHANGED
@@ -1,151 +1,187 @@
1
- /**
2
- * 书架(Bookshelf)— 多书管理:一本书记录一个独立输出目录。
3
- * 状态持久化到 ~/.dsh/dsh-novel-forge-bookshelf.json(跟随 dsh 配置惯例)。
4
- */
5
-
6
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
7
- import { homedir } from 'node:os'
8
- import { join } from 'node:path'
9
- import { randomBytes } from 'node:crypto'
10
- import type { BookEntry, BookshelfSnapshot } from './protocol.ts'
11
- import { loadProject } from './engine.ts'
12
-
13
- /** 书架配置文件路径。 */
14
- export function bookshelfFile(): string {
15
- return join(homedir(), '.dsh', 'dsh-novel-forge-bookshelf.json')
16
- }
17
-
18
- interface BookshelfStore {
19
- books: BookEntry[]
20
- activeBookId: string | null
21
- }
22
-
23
- function defaultStore(): BookshelfStore {
24
- return { books: [], activeBookId: null }
25
- }
26
-
27
- /** 读取书架(无则返回空)。 */
28
- export function loadBookshelf(): BookshelfStore {
29
- const file = bookshelfFile()
30
- if (!existsSync(file)) return defaultStore()
31
- try {
32
- let raw = readFileSync(file, 'utf8')
33
- if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1)
34
- const parsed = JSON.parse(raw) as BookshelfStore
35
- if (!Array.isArray(parsed.books)) return defaultStore()
36
- return { books: parsed.books, activeBookId: parsed.activeBookId ?? null }
37
- } catch {
38
- return defaultStore()
39
- }
40
- }
41
-
42
- /** 持久化书架。 */
43
- function saveBookshelf(store: BookshelfStore): void {
44
- const file = bookshelfFile()
45
- mkdirSync(join(homedir(), '.dsh'), { recursive: true })
46
- writeFileSync(file, JSON.stringify(store, null, 2), 'utf8')
47
- }
48
-
49
- /** 当前激活的书。 */
50
- export function activeBook(store: BookshelfStore): BookEntry | undefined {
51
- return store.books.find(b => b.id === store.activeBookId)
52
- }
53
-
54
- /** 书架快照(含每本书的进度摘要)。 */
55
- export function bookshelfSnapshot(store: BookshelfStore): BookshelfSnapshot {
56
- return {
57
- books: store.books.map(book => {
58
- const project = loadProject(book.outputDir)
59
- const done = project === undefined ? 0 : project.chapters.filter(c => c.status === 'approved' || c.status === 'written' || c.status === 'rejected').length
60
- const hasCover = project?.coverPath !== undefined && project.coverPath !== '' && existsSync(join(book.outputDir, project.coverPath))
61
- return {
62
- ...book,
63
- done,
64
- total: project?.chapters.length ?? 0,
65
- hasProject: project !== undefined,
66
- hasCover,
67
- blurb: project?.blurb,
68
- }
69
- }),
70
- activeBookId: store.activeBookId,
71
- }
72
- }
73
-
74
- /** 新建一本书(自动成为当前书)。 */
75
- export function createBook(bookName: string, outputDir: string): BookEntry {
76
- const store = loadBookshelf()
77
- const id = `book-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`
78
- const now = new Date().toISOString()
79
- const book: BookEntry = { id, bookName, outputDir, createdAt: now, updatedAt: now }
80
- store.books.push(book)
81
- store.activeBookId = id
82
- saveBookshelf(store)
83
- return book
84
- }
85
-
86
- /** 更新某本书的书名(开书向导导入大纲后书名以大纲首行为准)。 */
87
- export function renameBook(id: string, bookName: string): boolean {
88
- const store = loadBookshelf()
89
- const book = store.books.find(b => b.id === id)
90
- if (book === undefined) return false
91
- book.bookName = bookName
92
- book.updatedAt = new Date().toISOString()
93
- saveBookshelf(store)
94
- return true
95
- }
96
-
97
- /**
98
- * 播种:书架为空时,把指定输出目录下已有的项目自动登记为第一本书。
99
- * 兼容升级场景 —— 旧版插件直接在输出目录写项目,从未登记书架。
100
- * @param outputDir - 候选输出目录(通常为 settings 的默认输出目录)。
101
- * @returns 是否发生了播种。
102
- */
103
- export function seedBookshelfFromOutputDir(outputDir: string): boolean {
104
- const store = loadBookshelf()
105
- if (store.books.length > 0) return false
106
- if (!existsSync(outputDir)) return false
107
- // 有项目文件,或至少有章节文件,才视为"已有的书"。
108
- const hasProject = existsSync(join(outputDir, 'novel-project.json'))
109
- const hasChapters = existsSync(outputDir)
110
- if (!hasProject && !hasChapters) return false
111
- const project = loadProject(outputDir)
112
- const bookName = project?.bookName ?? outputDir.split(/[\\/]/).pop() ?? '未命名小说'
113
- createBook(bookName, outputDir)
114
- return true
115
- }
116
-
117
- /** 激活一本书。 */
118
- export function activateBook(id: string): BookEntry | undefined {
119
- const store = loadBookshelf()
120
- const book = store.books.find(b => b.id === id)
121
- if (book === undefined) return undefined
122
- store.activeBookId = id
123
- book.updatedAt = new Date().toISOString()
124
- saveBookshelf(store)
125
- return book
126
- }
127
-
128
- /** 移除一本书。 */
129
- export function removeBook(id: string): boolean {
130
- const store = loadBookshelf()
131
- const idx = store.books.findIndex(b => b.id === id)
132
- if (idx === -1) return false
133
- store.books.splice(idx, 1)
134
- if (store.activeBookId === id) {
135
- store.activeBookId = store.books[0]?.id ?? null
136
- }
137
- saveBookshelf(store)
138
- return true
139
- }
140
-
141
- /** 当前书输出目录(无书架则 undefined,回退 settings)。 */
142
- export function activeBookOutputDir(): string | undefined {
143
- const book = activeBook(loadBookshelf())
144
- return book?.outputDir
145
- }
146
-
147
- /** 默认输出目录推断:小说目录/书名。 */
148
- export function defaultOutputDirFor(bookName: string): string {
149
- const clean = bookName.replace(/[\\/:*?"<>|]/g, '').trim().slice(0, 40) || '未命名小说'
150
- return join('D:\\ryan work\\harness\\novels', clean)
151
- }
1
+ /**
2
+ * 书架(Bookshelf)— 多书管理:一本书记录一个独立输出目录。
3
+ * 状态持久化到 ~/.dsh/dsh-novel-forge-bookshelf.json(跟随 dsh 配置惯例)。
4
+ */
5
+
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
7
+ import { homedir } from 'node:os'
8
+ import { join } from 'node:path'
9
+ import { randomBytes } from 'node:crypto'
10
+ import type { BookEntry, BookshelfSnapshot } from './protocol.ts'
11
+ import { loadProject } from './engine.ts'
12
+
13
+ /** 书架配置文件路径。 */
14
+ export function bookshelfFile(): string {
15
+ return join(homedir(), '.dsh', 'dsh-novel-forge-bookshelf.json')
16
+ }
17
+
18
+ interface BookshelfStore {
19
+ books: BookEntry[]
20
+ activeBookId: string | null
21
+ }
22
+
23
+ function defaultStore(): BookshelfStore {
24
+ return { books: [], activeBookId: null }
25
+ }
26
+
27
+ /** 读取书架(无则返回空)。 */
28
+ export function loadBookshelf(): BookshelfStore {
29
+ const file = bookshelfFile()
30
+ if (!existsSync(file)) return defaultStore()
31
+ try {
32
+ let raw = readFileSync(file, 'utf8')
33
+ if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1)
34
+ const parsed = JSON.parse(raw) as BookshelfStore
35
+ if (!Array.isArray(parsed.books)) return defaultStore()
36
+ return { books: parsed.books, activeBookId: parsed.activeBookId ?? null }
37
+ } catch (error) {
38
+ // 书架文件损坏回退默认值会遮蔽数据丢失,留痕到服务端日志。
39
+ console.error(`[novel-forge] bookshelf file unreadable/corrupt: ${file} — ${(error as Error).message}`)
40
+ return defaultStore()
41
+ }
42
+ }
43
+
44
+ /** 持久化书架。 */
45
+ function saveBookshelf(store: BookshelfStore): void {
46
+ const file = bookshelfFile()
47
+ mkdirSync(join(homedir(), '.dsh'), { recursive: true })
48
+ writeFileSync(file, JSON.stringify(store, null, 2), 'utf8')
49
+ }
50
+
51
+ /** 当前激活的书。 */
52
+ export function activeBook(store: BookshelfStore): BookEntry | undefined {
53
+ return store.books.find(b => b.id === store.activeBookId)
54
+ }
55
+
56
+ /** 书架快照(含每本书的进度摘要)。 */
57
+ export function bookshelfSnapshot(store: BookshelfStore): BookshelfSnapshot {
58
+ return {
59
+ books: store.books.map(book => {
60
+ const project = loadProject(book.outputDir)
61
+ const done = project === undefined ? 0 : project.chapters.filter(c => c.status === 'approved' || c.status === 'written' || c.status === 'rejected').length
62
+ const hasCover = project?.coverPath !== undefined && project.coverPath !== '' && existsSync(join(book.outputDir, project.coverPath))
63
+ return {
64
+ ...book,
65
+ done,
66
+ total: project?.chapters.length ?? 0,
67
+ hasProject: project !== undefined,
68
+ hasCover,
69
+ blurb: project?.blurb,
70
+ }
71
+ }),
72
+ activeBookId: store.activeBookId,
73
+ }
74
+ }
75
+
76
+ /** 新建一本书(自动成为当前书)。 */
77
+ export function createBook(bookName: string, outputDir: string): BookEntry {
78
+ const store = loadBookshelf()
79
+ const id = `book-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`
80
+ const now = new Date().toISOString()
81
+ const book: BookEntry = { id, bookName, outputDir, createdAt: now, updatedAt: now }
82
+ store.books.push(book)
83
+ store.activeBookId = id
84
+ saveBookshelf(store)
85
+ return book
86
+ }
87
+
88
+ /** 更新某本书的书名(开书向导导入大纲后书名以大纲首行为准)。 */
89
+ export function renameBook(id: string, bookName: string): boolean {
90
+ const store = loadBookshelf()
91
+ const book = store.books.find(b => b.id === id)
92
+ if (book === undefined) return false
93
+ book.bookName = bookName
94
+ book.updatedAt = new Date().toISOString()
95
+ saveBookshelf(store)
96
+ return true
97
+ }
98
+
99
+ /**
100
+ * 播种:书架为空时,把指定输出目录下已有的项目自动登记为第一本书。
101
+ * 兼容升级场景 —— 旧版插件直接在输出目录写项目,从未登记书架。
102
+ * @param outputDir - 候选输出目录(通常为 settings 的默认输出目录)。
103
+ * @returns 是否发生了播种。
104
+ */
105
+ export function seedBookshelfFromOutputDir(outputDir: string): boolean {
106
+ const store = loadBookshelf()
107
+ if (store.books.length > 0) return false
108
+ if (!existsSync(outputDir)) return false
109
+ // 有项目文件,或至少有章节文件,才视为"已有的书"。
110
+ const hasProject = existsSync(join(outputDir, 'novel-project.json'))
111
+ const hasChapters = existsSync(outputDir)
112
+ if (!hasProject && !hasChapters) return false
113
+ const project = loadProject(outputDir)
114
+ const bookName = project?.bookName ?? outputDir.split(/[\\/]/).pop() ?? '未命名小说'
115
+ createBook(bookName, outputDir)
116
+ return true
117
+ }
118
+
119
+ /** 导入已有项目目录到书架:校验 novel-project.json,已存在则直接激活。 */
120
+ export function importDir(outputDir: string): { book: BookEntry; existed: boolean } {
121
+ const project = loadProject(outputDir)
122
+ if (project === undefined) throw new Error(`目录中未找到有效的 novel-project.json:${outputDir}`)
123
+ const store = loadBookshelf()
124
+ const existed = store.books.find(b => b.outputDir === outputDir)
125
+ if (existed !== undefined) {
126
+ store.activeBookId = existed.id
127
+ saveBookshelf(store)
128
+ return { book: existed, existed: true }
129
+ }
130
+ const bookName = project.bookName !== '' ? project.bookName : (outputDir.split(/[\\/]/).pop() ?? '未命名小说')
131
+ const book = createBook(bookName, outputDir)
132
+ return { book, existed: false }
133
+ }
134
+
135
+ /** 激活一本书。 */
136
+ export function activateBook(id: string): BookEntry | undefined {
137
+ const store = loadBookshelf()
138
+ const book = store.books.find(b => b.id === id)
139
+ if (book === undefined) return undefined
140
+ store.activeBookId = id
141
+ book.updatedAt = new Date().toISOString()
142
+ saveBookshelf(store)
143
+ return book
144
+ }
145
+
146
+ /** 移除一本书。 */
147
+ export function removeBook(id: string): boolean {
148
+ const store = loadBookshelf()
149
+ const idx = store.books.findIndex(b => b.id === id)
150
+ if (idx === -1) return false
151
+ store.books.splice(idx, 1)
152
+ if (store.activeBookId === id) {
153
+ store.activeBookId = store.books[0]?.id ?? null
154
+ }
155
+ saveBookshelf(store)
156
+ return true
157
+ }
158
+
159
+ /** 当前书输出目录(无书架则 undefined,回退 settings)。 */
160
+ export function activeBookOutputDir(): string | undefined {
161
+ const book = activeBook(loadBookshelf())
162
+ return book?.outputDir
163
+ }
164
+
165
+ /**
166
+ * 书目录重指向:把 outputDir 等于 from 的书条目全部改为 to(目录迁移联动)。
167
+ * @returns 重指向的条目数。
168
+ */
169
+ export function repointBookDirs(from: string, to: string): number {
170
+ const store = loadBookshelf()
171
+ let count = 0
172
+ for (const book of store.books) {
173
+ if (book.outputDir === from) {
174
+ book.outputDir = to
175
+ book.updatedAt = new Date().toISOString()
176
+ count += 1
177
+ }
178
+ }
179
+ if (count > 0) saveBookshelf(store)
180
+ return count
181
+ }
182
+
183
+ /** 默认输出目录推断:~/.dsh/novels/书名。 */
184
+ export function defaultOutputDirFor(bookName: string): string {
185
+ const clean = bookName.replace(/[\\/:*?"<>|]/g, '').trim().slice(0, 40) || '未命名小说'
186
+ return join(homedir(), '.dsh', 'novels', clean)
187
+ }