@waterwx/dsh-novel-forge 2.0.0-alpha.1 → 2.0.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.
Files changed (206) hide show
  1. package/README.md +286 -285
  2. package/lib/client.js +19561 -19439
  3. package/lib/index.js +8159 -4456
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +0 -6
  6. package/lib/types/assets.d.ts +20 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +1 -3
  9. package/lib/types/bookshelf.d.ts +13 -3
  10. package/lib/types/client/api.d.ts +136 -27
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -2
  13. package/lib/types/client/llmLive.d.ts +31 -0
  14. package/lib/types/client/locales.d.ts +15 -70
  15. package/lib/types/client/mount.d.ts +0 -2
  16. package/lib/types/client/panel/AiTag.d.ts +1 -0
  17. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  18. package/lib/types/client/panel/AuthorHome.d.ts +13 -11
  19. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  20. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  21. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  22. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  23. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  24. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  25. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  26. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  27. package/lib/types/client/panel/ModelManager.d.ts +2 -2
  28. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  29. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  30. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  31. package/lib/types/client/panel/SettingsView.d.ts +12 -9
  32. package/lib/types/client/panel/ShelfView.d.ts +10 -1
  33. package/lib/types/client/panel/SubPage.d.ts +27 -0
  34. package/lib/types/client/panel/TensionView.d.ts +18 -0
  35. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  36. package/lib/types/client/panel/helpers.d.ts +5 -3
  37. package/lib/types/client/panel/market-radar-state.d.ts +56 -0
  38. package/lib/types/client/panel/toast.d.ts +2 -0
  39. package/lib/types/client/panel/views.d.ts +21 -35
  40. package/lib/types/client/run-status.d.ts +21 -0
  41. package/lib/types/client/sidebar-entry.d.ts +0 -2
  42. package/lib/types/docx-core.d.ts +16 -0
  43. package/lib/types/docx.d.ts +2 -3
  44. package/lib/types/engine.d.ts +78 -103
  45. package/lib/types/global-assets.d.ts +6 -0
  46. package/lib/types/home.d.ts +11 -0
  47. package/lib/types/index.d.ts +26 -12
  48. package/lib/types/llm-live.d.ts +70 -0
  49. package/lib/types/llm-retry.d.ts +38 -0
  50. package/lib/types/market-radar-scan.d.ts +18 -0
  51. package/lib/types/market-radar-sources.d.ts +31 -0
  52. package/lib/types/migrate-output.d.ts +25 -0
  53. package/lib/types/model-capability.d.ts +93 -0
  54. package/lib/types/output-budget.d.ts +86 -0
  55. package/lib/types/prompt-slots.d.ts +57 -0
  56. package/lib/types/prompting.d.ts +10 -0
  57. package/lib/types/protocol.d.ts +816 -601
  58. package/lib/types/revision.d.ts +76 -0
  59. package/lib/types/routes.d.ts +9 -3
  60. package/lib/types/run.d.ts +23 -1
  61. package/lib/types/settings-persist.d.ts +59 -0
  62. package/lib/types/snapshots.d.ts +46 -0
  63. package/lib/types/stage-contract.d.ts +58 -0
  64. package/lib/types/tension.d.ts +53 -0
  65. package/lib/types/timeline.d.ts +37 -0
  66. package/package.json +50 -25
  67. package/src/action-guard.ts +127 -0
  68. package/src/ai-scan.ts +0 -24
  69. package/src/assets.ts +1801 -879
  70. package/src/assistant.ts +1098 -770
  71. package/src/author-assets.ts +131 -147
  72. package/src/bookshelf.ts +193 -167
  73. package/src/client/api.ts +767 -617
  74. package/src/client/chip-prefs.ts +121 -0
  75. package/src/client/docx.ts +32 -69
  76. package/src/client/index.ts +2 -0
  77. package/src/client/llmLive.ts +111 -0
  78. package/src/client/locales.ts +15 -274
  79. package/src/client/mount.tsx +3 -2
  80. package/src/client/panel/AdaptModeView.tsx +535 -520
  81. package/src/client/panel/AiTag.tsx +7 -0
  82. package/src/client/panel/AssetsTab.tsx +448 -458
  83. package/src/client/panel/AssistantTab.tsx +355 -301
  84. package/src/client/panel/AuthorAssetsView.tsx +205 -207
  85. package/src/client/panel/AuthorHome.tsx +309 -204
  86. package/src/client/panel/BookAnalysisView.tsx +63 -0
  87. package/src/client/panel/CmdPalette.tsx +102 -0
  88. package/src/client/panel/CreateBookView.tsx +366 -352
  89. package/src/client/panel/DirectorView.tsx +200 -0
  90. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -172
  91. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  92. package/src/client/panel/ImportModal.tsx +302 -302
  93. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  94. package/src/client/panel/LiveFeedLog.tsx +204 -0
  95. package/src/client/panel/MarketRadarView.tsx +415 -0
  96. package/src/client/panel/ModelManager.tsx +205 -205
  97. package/src/client/panel/NovelPanel.tsx +3144 -2205
  98. package/src/client/panel/PageHeader.tsx +37 -0
  99. package/src/client/panel/ProgressConsole.tsx +63 -64
  100. package/src/client/panel/PromptSlotsView.tsx +97 -0
  101. package/src/client/panel/ReaderView.tsx +306 -306
  102. package/src/client/panel/RunPanel.tsx +56 -24
  103. package/src/client/panel/SettingsView.tsx +175 -106
  104. package/src/client/panel/ShelfView.tsx +63 -39
  105. package/src/client/panel/SubPage.tsx +42 -0
  106. package/src/client/panel/TensionView.tsx +212 -0
  107. package/src/client/panel/TimelineView.tsx +208 -0
  108. package/src/client/panel/WorldTab.tsx +202 -202
  109. package/src/client/panel/helpers.ts +7 -5
  110. package/src/client/panel/market-radar-state.ts +79 -0
  111. package/src/client/panel/panel.module.css +5767 -4360
  112. package/src/client/panel/toast.ts +51 -0
  113. package/src/client/panel/views.tsx +316 -314
  114. package/src/client/run-status.ts +171 -0
  115. package/src/client/sidebar-entry.ts +3 -3
  116. package/src/docx-core.ts +53 -0
  117. package/src/docx.ts +44 -83
  118. package/src/engine.ts +1197 -1638
  119. package/src/global-assets.ts +58 -0
  120. package/src/home.ts +33 -0
  121. package/src/index.ts +181 -101
  122. package/src/llm-live.ts +245 -0
  123. package/src/llm-retry.ts +77 -0
  124. package/src/market-radar-scan.ts +35 -0
  125. package/src/market-radar-sources.ts +173 -0
  126. package/src/migrate-output.ts +76 -0
  127. package/src/model-capability.ts +215 -0
  128. package/src/novel-context.ts +286 -287
  129. package/src/output-budget.ts +147 -0
  130. package/src/prompt-slots.ts +125 -0
  131. package/src/prompting.ts +26 -0
  132. package/src/protocol.ts +835 -614
  133. package/src/revision.ts +283 -0
  134. package/src/routes.ts +1286 -910
  135. package/src/run.ts +113 -17
  136. package/src/settings-persist.ts +114 -0
  137. package/src/snapshots.ts +174 -0
  138. package/src/stage-contract.ts +186 -0
  139. package/src/tension.ts +251 -0
  140. package/src/timeline.ts +159 -0
  141. package/assets/styles/arcane-thick-3d.webp +0 -0
  142. package/assets/styles/chinese-ink.webp +0 -0
  143. package/assets/styles/clay-stopmotion-3d.webp +0 -0
  144. package/assets/styles/dark-ghibli.webp +0 -0
  145. package/assets/styles/dark-gothic-3d.webp +0 -0
  146. package/assets/styles/film-photo.webp +0 -0
  147. package/assets/styles/guofeng-heavy.webp +0 -0
  148. package/assets/styles/guoman-2d-gufeng.webp +0 -0
  149. package/assets/styles/guoman-3d-tianyuan.webp +0 -0
  150. package/assets/styles/guoman-3d-xuanhuan.webp +0 -0
  151. package/assets/styles/horror-game-3d.webp +0 -0
  152. package/assets/styles/hyperreal-3d.webp +0 -0
  153. package/assets/styles/japan-cel-dark.webp +0 -0
  154. package/assets/styles/korean-webtoon.webp +0 -0
  155. package/assets/styles/low-cyberpunk-3d.webp +0 -0
  156. package/assets/styles/nextgen-cg.webp +0 -0
  157. package/assets/styles/noir-film.webp +0 -0
  158. package/assets/styles/openworld-survival.webp +0 -0
  159. package/assets/styles/paper-cut-3d.webp +0 -0
  160. package/assets/styles/pixar-adult-3d.webp +0 -0
  161. package/assets/styles/pop-marvel-2d.webp +0 -0
  162. package/assets/styles/sandiao-qban.webp +0 -0
  163. package/assets/styles/shinkai-anime.webp +0 -0
  164. package/assets/styles/tactical-stealth.webp +0 -0
  165. package/assets/styles/urban-lightreal-3d.webp +0 -0
  166. package/assets/styles/vaporwave-3d.webp +0 -0
  167. package/assets/styles/vhs90.webp +0 -0
  168. package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
  169. package/lib/types/client/panel/FlowGuide.d.ts +0 -22
  170. package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -20
  171. package/lib/types/client/panel/MangaWorkspace.d.ts +0 -12
  172. package/lib/types/client/panel/ModelPicker.d.ts +0 -11
  173. package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
  174. package/lib/types/client/panel/PropLibrary.d.ts +0 -8
  175. package/lib/types/client/panel/SceneLibrary.d.ts +0 -16
  176. package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
  177. package/lib/types/client/panel/StyleCard.d.ts +0 -8
  178. package/lib/types/client/panel/StyleGallery.d.ts +0 -1
  179. package/lib/types/comic-presets.d.ts +0 -12
  180. package/lib/types/manga-genre-rules.d.ts +0 -22
  181. package/lib/types/prompt-utils.d.ts +0 -35
  182. package/lib/types/review-policy.d.ts +0 -97
  183. package/lib/types/shot-language.d.ts +0 -46
  184. package/lib/types/story-beat-language.d.ts +0 -35
  185. package/lib/types/style-library.d.ts +0 -33
  186. package/skills/chapter-batch/SKILL.md +0 -81
  187. package/src/client/mount.tsx._new +0 -97
  188. package/src/client/mount.tsx._nf +0 -97
  189. package/src/client/mount.tsx._tmp +0 -97
  190. package/src/client/panel/BookshelfBar.tsx +0 -170
  191. package/src/client/panel/FlowGuide.tsx +0 -84
  192. package/src/client/panel/MangaRoleLibrary.tsx +0 -1002
  193. package/src/client/panel/MangaWorkspace.tsx +0 -715
  194. package/src/client/panel/ProgressHomeView.tsx +0 -87
  195. package/src/client/panel/PropLibrary.tsx +0 -175
  196. package/src/client/panel/SceneLibrary.tsx +0 -453
  197. package/src/client/panel/StoryboardTab.tsx +0 -472
  198. package/src/client/panel/StyleCard.tsx +0 -90
  199. package/src/client/panel/StyleGallery.tsx +0 -58
  200. package/src/comic-presets.ts +0 -67
  201. package/src/manga-genre-rules.ts +0 -87
  202. package/src/prompt-utils.ts +0 -73
  203. package/src/review-policy.ts +0 -161
  204. package/src/shot-language.ts +0 -181
  205. package/src/story-beat-language.ts +0 -130
  206. package/src/style-library.ts +0 -84
@@ -2,8 +2,8 @@
2
2
  * 书架首页视图:书卡网格(封面/书名/简介/进度)+ 搜索筛选 + 开书入口。
3
3
  * 进入小说工坊默认展示;点击书卡进入该书工作台,+ 进入开书向导页。
4
4
  */
5
- import { useEffect, useMemo, useState } from 'react'
6
- import { BookOpen, Download, Library, PenLine, Plus, Search } from 'lucide-react'
5
+ import { useEffect, useMemo, useState, type ReactNode } from 'react'
6
+ import { BookOpen, Download, PenLine, Plus, Search, Wand2 } from 'lucide-react'
7
7
  import type { NovelApi } from '../api.ts'
8
8
  import type { BookshelfSnapshot } from '../../protocol.ts'
9
9
  import css from './panel.module.css'
@@ -120,9 +120,13 @@ export function ShelfView({
120
120
  onReadBook,
121
121
  onAddBook,
122
122
  onImportBook,
123
+ onOpenAdapt,
124
+ toolbarExtra,
123
125
  }: {
124
126
  api: NovelApi
125
127
  shelf: BookshelfSnapshot
128
+ /** 书架工具行右端插槽:组入口按钮由 AuthorHome 下放(创作 / 灵感策划 / 资产)。 */
129
+ toolbarExtra?: ReactNode
126
130
  /** 点击书卡:激活该书并进入工作台。 */
127
131
  onOpenBook: (id: string) => void
128
132
  /** 点击「阅读」:激活该书并进入沉浸式阅读页。 */
@@ -131,6 +135,8 @@ export function ShelfView({
131
135
  onAddBook: () => void
132
136
  /** 点击「导入」:打开导入弹窗(已有项目目录 / txt/md 全本)。 */
133
137
  onImportBook: () => void
138
+ /** 点击「改编模式」:进入改编六步向导(入口收在书架页头,与导入并排)。 */
139
+ onOpenAdapt?: () => void
134
140
  }) {
135
141
  const [query, setQuery] = useState('')
136
142
  /** 筛选:all=全部 / active=进行中 / done=已完结 / none=未开书。 */
@@ -156,40 +162,71 @@ export function ShelfView({
156
162
  })
157
163
  }, [shelf.books, query, filter])
158
164
 
165
+ const activeName = shelf.books.find(b => b.id === shelf.activeBookId)?.bookName ?? ''
166
+
159
167
  return (
160
- <div className={css.shelfView}>
161
- <div className={css.shelfHero}>
162
- <div className={css.shelfTitleRow}>
163
- <h2 className={css.panelTitle} style={{ margin: 0 }}><Library size={20} style={{ verticalAlign: -3 }} /> AI小说工坊 · 书架</h2>
168
+ <div className={css.subPage}>
169
+ <div className={css.subPageHead}>
170
+ <span className={css.subPageTitle}>书架</span>
171
+ <span className={css.subPageMeta}>
172
+ {shelf.books.length} 本书{activeName !== '' ? ` · 当前书《${activeName}》` : ''}
173
+ </span>
174
+ <div className={css.subPageActions}>
175
+ {onOpenAdapt !== undefined && (
176
+ <button type="button" className={css.button} onClick={onOpenAdapt} title="导入全文 → 六步改编成新书">
177
+ <Wand2 size={13} style={{ verticalAlign: -2 }} /> 改编模式
178
+ </button>
179
+ )}
180
+ <button type="button" className={css.button} onClick={onImportBook} title="导入已有项目目录或 txt / md 全本">
181
+ <Download size={13} style={{ verticalAlign: -2 }} /> 导入已有小说
182
+ </button>
183
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} onClick={onAddBook} title="书名 + 大纲,开书即建项目">
184
+ <Plus size={13} style={{ verticalAlign: -2 }} /> 开一本新书
185
+ </button>
186
+ </div>
187
+ </div>
188
+ <div className={css.subPageBody}>
189
+ <div className={css.shelfToolbar}>
190
+ <div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
191
+ <Search size={15} style={{ position: 'absolute', left: 10, color: 'var(--nf-text-3)', pointerEvents: 'none' }} />
164
192
  <input
165
193
  className={`${css.input} ${css.shelfSearch}`}
166
194
  type="search"
167
- placeholder="🔍 搜索书名 / 简介…"
195
+ placeholder="搜索书名 / 简介…"
168
196
  value={query}
169
197
  onChange={e => { setQuery(e.target.value) }}
198
+ style={{ paddingLeft: 'var(--nf-space-30, 30px)' }}
170
199
  />
171
200
  </div>
172
- {shelf.books.length > 0 && (
173
- <div className={css.shelfStats}>
174
- {[
175
- { id: 'all' as const, label: '总书数', value: shelf.books.length },
176
- { id: 'active' as const, label: '进行中', value: stats.active },
177
- { id: 'done' as const, label: '已完结', value: stats.done },
178
- { id: 'none' as const, label: '未开书', value: stats.none },
179
- ].map(s => (
180
- <div
181
- key={s.id}
182
- className={`${css.shelfStat} ${filter === s.id ? css.shelfStatActive : ''}`}
183
- title={s.id === 'all' ? '显示全部书籍' : `只看「${s.label}」的书(再点一次取消筛选)`}
184
- onClick={() => { setFilter(prev => prev === s.id ? 'all' : s.id) }}
185
- >
186
- <b>{s.value}</b><span>{s.label}</span>
187
- </div>
188
- ))}
201
+ {toolbarExtra !== undefined && (
202
+ <div className={css.shelfToolbarExtra} role="tablist" aria-label="创作组入口">
203
+ {toolbarExtra}
189
204
  </div>
190
205
  )}
191
206
  </div>
192
207
 
208
+ {shelf.books.length > 0 && (
209
+ <div className={css.shelfStatGrid}>
210
+ {[
211
+ { id: 'all' as const, label: '总书数', value: shelf.books.length },
212
+ { id: 'active' as const, label: '进行中', value: stats.active },
213
+ { id: 'done' as const, label: '已完结', value: stats.done },
214
+ { id: 'none' as const, label: '未开书', value: stats.none },
215
+ ].map(s => (
216
+ <div
217
+ key={s.id}
218
+ className={css.shelfStatCard}
219
+ data-active={filter === s.id ? '' : undefined}
220
+ title={s.id === 'all' ? '显示全部书籍' : `只看「${s.label}」的书`}
221
+ onClick={() => { setFilter(s.id) }}
222
+ >
223
+ <span>{s.label}</span>
224
+ <b className={`${css.numSerif} ${css.tabular}`}>{s.value}</b>
225
+ </div>
226
+ ))}
227
+ </div>
228
+ )}
229
+
193
230
  {shelf.books.length === 0 ? (
194
231
  /* 空书架引导卡 */
195
232
  <div className={css.shelfEmpty}>
@@ -200,7 +237,7 @@ export function ShelfView({
200
237
  + 开第一本书
201
238
  </button>
202
239
  <button type="button" className={`${css.button}`} onClick={onImportBook}>
203
- 📥 导入已有小说
240
+ 导入已有小说
204
241
  </button>
205
242
  </div>
206
243
  ) : visible.length === 0 ? (
@@ -221,22 +258,9 @@ export function ShelfView({
221
258
  onRead={() => { onReadBook(book.id) }}
222
259
  />
223
260
  ))}
224
-
225
- {/* 开书入口:进入独立向导页 */}
226
- <div className={`${css.bookCard} ${css.bookAddCard}`} onClick={onAddBook} role="button" tabIndex={0} aria-label="开一本新书" onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onAddBook() } }}>
227
- <div className={css.bookAddIcon}><Plus size={28} /></div>
228
- <span>开一本新书</span>
229
- <span className={css.meta}>书名 + 大纲,开书即建项目</span>
230
- </div>
231
-
232
- {/* 导入入口:已有项目目录 / txt/md 全本 */}
233
- <div className={`${css.bookCard} ${css.bookAddCard}`} onClick={onImportBook} role="button" tabIndex={0} aria-label="导入已有小说" onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onImportBook() } }}>
234
- <div className={css.bookAddIcon}><Download size={28} /></div>
235
- <span>导入已有小说</span>
236
- <span className={css.meta}>项目目录或 txt/md 全本</span>
237
- </div>
238
261
  </div>
239
262
  )}
263
+ </div>
240
264
  </div>
241
265
  )
242
266
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 统一子页骨架(1:1 对齐 demo-subpages.html 的 .subpage / .subpage-head / .subpage-body)。
3
+ *
4
+ * 所有作者级与书内子页都应套这层壳:
5
+ * - head:返回钮(可选)+ 大标题 + meta + 右侧操作区
6
+ * - body:页面内容(内部按 demo 用 .card / .box / .grid / .row 等块)
7
+ * 业务逻辑继续留在各自的 View 组件里,这里只负责「壳」。
8
+ */
9
+ import type { ReactNode } from 'react'
10
+ import css from './panel.module.css'
11
+
12
+ export function SubPage(props: {
13
+ /** 页面大标题(衬线 display 档)。 */
14
+ title: string
15
+ /** 标题右侧的说明行(如「4 本书 · 当前书《X》」)。 */
16
+ meta?: ReactNode
17
+ /** 返回钮:作者级返回书架,书内返回总编台。 */
18
+ back?: { label: string; onClick: () => void }
19
+ /** 页头右侧操作区(主 CTA 放最右)。 */
20
+ actions?: ReactNode
21
+ /** 主题系统局部模式(透传到根 div 的 data-nf-mode,供设置页等预览明暗)。 */
22
+ dataNfMode?: 'light' | 'dark'
23
+ /** 主题系统局部密度(透传到根 div 的 data-nf-density)。 */
24
+ dataNfDensity?: 'comfort' | 'compact' | 'spacious'
25
+ children: ReactNode
26
+ }): JSX.Element {
27
+ return (
28
+ <div className={css.subPage} data-nf-mode={props.dataNfMode} data-nf-density={props.dataNfDensity}>
29
+ <div className={css.subPageHead}>
30
+ {props.back !== undefined && (
31
+ <button type="button" className={css.subPageBack} onClick={props.back.onClick}>
32
+ ← {props.back.label}
33
+ </button>
34
+ )}
35
+ <span className={css.subPageTitle}>{props.title}</span>
36
+ {props.meta !== undefined && <span className={css.subPageMeta}>{props.meta}</span>}
37
+ {props.actions !== undefined && <div className={css.subPageActions}>{props.actions}</div>}
38
+ </div>
39
+ <div className={css.subPageBody}>{props.children}</div>
40
+ </div>
41
+ )
42
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * 张力曲线:把「每章该有多紧」画出来并可逐章调整。
3
+ *
4
+ * 图上是三条线:
5
+ * 参考(虚线,形状预设) / 目标(实线,作者或规划设定) / 实际(散点,审稿打分)。
6
+ * 下方列出规则核对发现的曲线级问题(连续同值、高位不回落、长期低位、实际偏离目标)。
7
+ *
8
+ * 张力只影响写作提示词里的"松紧"指引,不改变任何校验规则。
9
+ */
10
+
11
+ import { useCallback, useEffect, useMemo, useState } from 'react'
12
+ import type { NovelApi } from '../api.ts'
13
+ import type { TensionCurvePoint, TensionCurvePreset, TensionIssue, TensionResponse } from '../../protocol.ts'
14
+ import { TENSION_PRESET_LABELS } from '../../tension.ts'
15
+ import css from './panel.module.css'
16
+
17
+ const PRESETS: TensionCurvePreset[] = ['escalation', 'suspense', 'wave', 'frontLoad', 'flat', 'custom']
18
+
19
+ /** 简易折线图(SVG,无依赖):宽高固定,按章节数均分横轴。 */
20
+ function CurveChart({ points, onPick }: { points: TensionCurvePoint[]; onPick: (no: number) => void }): JSX.Element {
21
+ const W = 720
22
+ const H = 200
23
+ const padX = 28
24
+ const padY = 16
25
+ const n = points.length
26
+ const x = (i: number): number => (n <= 1 ? W / 2 : padX + (i / (n - 1)) * (W - padX * 2))
27
+ const y = (v: number): number => padY + (1 - v / 100) * (H - padY * 2)
28
+ const line = (pick: (p: TensionCurvePoint) => number | undefined): string => points
29
+ .map((p, i) => {
30
+ const v = pick(p)
31
+ return v === undefined ? null : `${x(i).toFixed(1)},${y(v).toFixed(1)}`
32
+ })
33
+ .filter((s): s is string => s !== null)
34
+ .join(' ')
35
+ const refLine = line(p => p.reference)
36
+ const targetLine = line(p => p.target)
37
+ return (
38
+ <svg viewBox={`0 0 ${W} ${H}`} width="100%" height={H} role="img" aria-label="张力曲线">
39
+ {/* 网格:0 / 40 / 80 三条参考线 */}
40
+ {[0, 40, 80, 100].map(v => (
41
+ <g key={v}>
42
+ <line x1={padX} x2={W - padX} y1={y(v)} y2={y(v)} stroke="var(--nf-border)" strokeWidth={v === 0 || v === 100 ? 1 : 0.5} strokeDasharray={v === 0 || v === 100 ? '' : '3 4'} />
43
+ <text x={4} y={y(v) + 3} fontSize={9} fill="var(--nf-text-2)">{v}</text>
44
+ </g>
45
+ ))}
46
+ {refLine !== '' && <polyline points={refLine} fill="none" stroke="var(--nf-text-2)" strokeWidth={1} strokeDasharray="4 4" opacity={0.7} />}
47
+ {targetLine !== '' && <polyline points={targetLine} fill="none" stroke="var(--nf-accent)" strokeWidth={2} />}
48
+ {points.map((p, i) => (
49
+ <g key={p.no}>
50
+ {p.actual !== undefined && <circle cx={x(i)} cy={y(p.actual)} r={3} fill="var(--nf-success)" />}
51
+ {p.target !== undefined && <circle cx={x(i)} cy={y(p.target)} r={2.5} fill="var(--nf-accent)" />}
52
+ <rect
53
+ x={x(i) - 6}
54
+ y={padY}
55
+ width={12}
56
+ height={H - padY * 2}
57
+ fill="transparent"
58
+ style={{ cursor: 'pointer' }}
59
+ onClick={() => onPick(p.no)}
60
+ >
61
+ <title>{`第${p.no}章 ${p.title}|目标 ${p.target ?? '—'}|实际 ${p.actual ?? '—'}|参考 ${p.reference ?? '—'}`}</title>
62
+ </rect>
63
+ </g>
64
+ ))}
65
+ </svg>
66
+ )
67
+ }
68
+
69
+ export interface TensionViewProps {
70
+ api: NovelApi
71
+ /** 一键按建议修订(合并审稿/张力/时间线后改这一章;由父级统一发起)。 */
72
+ onRevise?: (chapterNo: number) => void
73
+ /** 外部忙碌态(修订进行中时禁用按钮)。 */
74
+ busy?: boolean
75
+ }
76
+
77
+ export default function TensionView({ api, onRevise, busy: outerBusy }: TensionViewProps): JSX.Element {
78
+ const [data, setData] = useState<TensionResponse | null>(null)
79
+ const [busy, setBusy] = useState(false)
80
+ const [error, setError] = useState('')
81
+ const [notice, setNotice] = useState('')
82
+ const [editing, setEditing] = useState<number | null>(null)
83
+ const [draft, setDraft] = useState(50)
84
+
85
+ const refresh = useCallback(async (showError = true): Promise<void> => {
86
+ try {
87
+ setData(await api.tension())
88
+ } catch (err) {
89
+ if (showError) setError((err as Error).message)
90
+ }
91
+ }, [api])
92
+
93
+ useEffect(() => { void refresh(false) }, [refresh])
94
+
95
+ const run = useCallback(async (label: string, fn: () => Promise<void>): Promise<void> => {
96
+ setBusy(true); setError(''); setNotice('')
97
+ try {
98
+ await fn()
99
+ setNotice(label)
100
+ } catch (err) {
101
+ setError((err as Error).message)
102
+ } finally {
103
+ setBusy(false)
104
+ }
105
+ }, [])
106
+
107
+ const changePreset = (preset: TensionCurvePreset): void => {
108
+ void run(`已切换参考曲线:${TENSION_PRESET_LABELS[preset]}`, async () => {
109
+ await api.tensionPreset(preset)
110
+ await refresh()
111
+ })
112
+ }
113
+
114
+ const saveEdit = (): void => {
115
+ if (editing === null) return
116
+ void run(`第 ${editing} 章目标张力已设为 ${draft}`, async () => {
117
+ await api.tensionSet(editing, draft)
118
+ setEditing(null)
119
+ await refresh()
120
+ })
121
+ }
122
+
123
+ const clearEdit = (): void => {
124
+ if (editing === null) return
125
+ void run(`已清除第 ${editing} 章的目标张力`, async () => {
126
+ await api.tensionSet(editing)
127
+ setEditing(null)
128
+ await refresh()
129
+ })
130
+ }
131
+
132
+ const issues: TensionIssue[] = data?.issues ?? []
133
+ const points = data?.points ?? []
134
+ const stat = useMemo(() => {
135
+ const targets = points.map(p => p.target).filter((v): v is number => v !== undefined)
136
+ const actuals = points.map(p => p.actual).filter((v): v is number => v !== undefined)
137
+ const avg = (xs: number[]): string => (xs.length === 0 ? '—' : String(Math.round(xs.reduce((a, b) => a + b, 0) / xs.length)))
138
+ return { targetAvg: avg(targets), actualAvg: avg(actuals), rated: actuals.length, total: points.length }
139
+ }, [points])
140
+
141
+ return (
142
+ <div className={css.card} style={{ gap: 'var(--nf-space-12)' }}>
143
+ <div className={css.row} style={{ justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 'var(--nf-space-8)' }}>
144
+ <span className={css.cardTitle}>张力曲线</span>
145
+ <span className={css.meta}>{points.length} 章 · 已评 {stat.rated} 章 · 目标均 {stat.targetAvg} / 实际均 {stat.actualAvg}</span>
146
+ </div>
147
+
148
+ <div className={css.row} style={{ gap: 'var(--nf-space-8)', alignItems: 'center', flexWrap: 'wrap' }}>
149
+ <select className={css.input} value={data?.preset ?? 'escalation'} disabled={busy} onChange={e => changePreset(e.target.value as TensionCurvePreset)}>
150
+ {PRESETS.map(p => <option key={p} value={p}>{TENSION_PRESET_LABELS[p]}</option>)}
151
+ </select>
152
+ <span className={css.meta}>虚线=参考曲线形状;实线=目标;绿点=审稿实际。点图中任意一章可改目标张力。</span>
153
+ </div>
154
+
155
+ {notice !== '' && <span style={{ color: 'var(--nf-success)', fontSize: 'var(--nf-text-12)' }}>{notice}</span>}
156
+ {error !== '' && <span style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-text-12)' }}>{error}</span>}
157
+
158
+ {points.length === 0
159
+ ? <span className={css.meta}>还没有章节计划。先在「总编台 → 卷/章节计划」排出章节,这里才有曲线可看。</span>
160
+ : <CurveChart points={points} onPick={no => {
161
+ const p = points.find(x => x.no === no)
162
+ setEditing(no)
163
+ setDraft(p?.target ?? p?.actual ?? 50)
164
+ }} />}
165
+
166
+ {issues.length > 0 && (
167
+ <div style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: 8, background: 'var(--nf-bg-inset)' }}>
168
+ <b style={{ fontSize: 'var(--nf-fs-12)' }}>曲线级问题 {issues.length} 条</b>
169
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 6 }}>
170
+ {issues.map((issue, i) => (
171
+ <div key={i} style={{ fontSize: 'var(--nf-fs-12)' }}>
172
+ <span className={css.lfPill} data-phase={issue.severity === 'high' ? 'failed' : 'requesting'}>{issue.severity === 'high' ? '高' : issue.severity === 'medium' ? '中' : '低'}</span>
173
+ <span style={{ marginLeft: 6 }}>{issue.chapters.length > 0 ? `第 ${issue.chapters.join('、')} 章:` : ''}{issue.item}</span>
174
+ {issue.suggestion !== '' && <div className={css.meta} style={{ marginLeft: 6 }}>建议:{issue.suggestion}</div>}
175
+ {onRevise !== undefined && issue.chapters.length > 0 && (
176
+ <button
177
+ type="button"
178
+ className={`${css.button} ${css.buttonSmall}`}
179
+ style={{ marginLeft: 6, marginTop: 2 }}
180
+ disabled={busy || outerBusy === true}
181
+ title="把该章的张力偏差与审稿意见、时间线矛盾合并成一轮修订(只改这一章,改完自动复核)"
182
+ onClick={() => { onRevise(issue.chapters[0]!) }}
183
+ >
184
+ 一键按建议修订(第 {issue.chapters[0]} 章)
185
+ </button>
186
+ )}
187
+ </div>
188
+ ))}
189
+ </div>
190
+ </div>
191
+ )}
192
+
193
+ {editing !== null && (
194
+ <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.45)', zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={() => setEditing(null)}>
195
+ <div onClick={e => e.stopPropagation()} style={{ background: 'var(--nf-bg)', border: '1px solid var(--nf-border)', borderRadius: 10, width: 'min(420px, 92vw)', padding: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
196
+ <b style={{ fontSize: 'var(--nf-fs-13)' }}>第 {editing} 章 · 目标张力</b>
197
+ <input type="range" min={0} max={100} value={draft} onChange={e => setDraft(Number(e.target.value))} />
198
+ <div className={css.row} style={{ justifyContent: 'space-between' }}>
199
+ <span className={css.meta}>{draft} / 100({draft < 40 ? '偏松:铺垫、日常、消化信息' : draft > 80 ? '偏紧:对抗、揭示、抉择、代价' : '中等:推进为主' })</span>
200
+ <input className={css.input} style={{ width: 72 }} type="number" min={0} max={100} value={draft} onChange={e => setDraft(Math.max(0, Math.min(100, Number(e.target.value))))} />
201
+ </div>
202
+ <div className={css.row} style={{ justifyContent: 'flex-end', gap: 6 }}>
203
+ <button type="button" className={css.button} onClick={clearEdit} disabled={busy}>清除</button>
204
+ <button type="button" className={css.button} onClick={() => setEditing(null)}>取消</button>
205
+ <button type="button" className={css.button + ' ' + css.buttonPrimary} onClick={saveEdit} disabled={busy}>保存</button>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ )}
210
+ </div>
211
+ )
212
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * 故事时间线:把逐章抽取的「时间点 / 地点 / 在场角色 / 事件」按章节顺序排开,
3
+ * 带规则初筛 + AI 复核的矛盾清单,并支持就地修正(手改后标记为 manual)。
4
+ *
5
+ * 与「编年录」的分工:编年录记事实状态,这里记**顺序**——专门抓时间倒流、
6
+ * 地点瞬移、跨度不合理这类长篇最容易崩的地方。
7
+ */
8
+
9
+ import { useCallback, useEffect, useMemo, useState } from 'react'
10
+ import type { NovelApi } from '../api.ts'
11
+ import type { TimelineEvent, TimelineIssue } from '../../protocol.ts'
12
+ import css from './panel.module.css'
13
+
14
+ type Severity = TimelineIssue['severity']
15
+
16
+ const SEVERITY_LABEL: Record<Severity, string> = { high: '高', medium: '中', low: '低' }
17
+
18
+ export interface TimelineViewProps {
19
+ api: NovelApi
20
+ /** 章节号列表(用于「抽取本章」下拉)。 */
21
+ chapters: number[]
22
+ /** 一键按建议修订(合并审稿/时间线/张力后改这一章;由父级统一发起)。 */
23
+ onRevise?: (chapterNo: number) => void
24
+ /** 外部忙碌态(修订进行中时禁用按钮)。 */
25
+ busy?: boolean
26
+ }
27
+
28
+ export default function TimelineView({ api, chapters, onRevise, busy: outerBusy }: TimelineViewProps): JSX.Element {
29
+ const [events, setEvents] = useState<TimelineEvent[]>([])
30
+ const [issues, setIssues] = useState<TimelineIssue[]>([])
31
+ const [busy, setBusy] = useState(false)
32
+ const [error, setError] = useState('')
33
+ const [notice, setNotice] = useState('')
34
+ const [extractNo, setExtractNo] = useState<number | ''>(chapters[chapters.length - 1] ?? '')
35
+ const [editing, setEditing] = useState<TimelineEvent | null>(null)
36
+
37
+ const refresh = useCallback(async (showError = true): Promise<void> => {
38
+ try {
39
+ const r = await api.timeline()
40
+ setEvents(r.events)
41
+ setIssues(r.issues ?? [])
42
+ } catch (err) {
43
+ if (showError) setError((err as Error).message)
44
+ }
45
+ }, [api])
46
+
47
+ useEffect(() => { void refresh(false) }, [refresh])
48
+
49
+ const grouped = useMemo(() => {
50
+ const map = new Map<number, TimelineEvent[]>()
51
+ for (const e of events) {
52
+ const list = map.get(e.chapterNo) ?? []
53
+ list.push(e)
54
+ map.set(e.chapterNo, list)
55
+ }
56
+ return [...map.entries()].sort((a, b) => a[0] - b[0])
57
+ }, [events])
58
+
59
+ const run = useCallback(async (label: string, fn: () => Promise<void>): Promise<void> => {
60
+ setBusy(true); setError(''); setNotice('')
61
+ try {
62
+ await fn()
63
+ setNotice(label)
64
+ } catch (err) {
65
+ setError((err as Error).message)
66
+ } finally {
67
+ setBusy(false)
68
+ }
69
+ }, [])
70
+
71
+ const handleExtract = (): void => {
72
+ if (extractNo === '') return
73
+ void run(`已抽取第 ${extractNo} 章时间线`, async () => {
74
+ await api.timelineExtract(Number(extractNo))
75
+ await refresh()
76
+ })
77
+ }
78
+
79
+ const handleCheck = (): void => {
80
+ void run('时间线检查完成', async () => {
81
+ const r = await api.timelineCheck()
82
+ setIssues(r.issues ?? [])
83
+ })
84
+ }
85
+
86
+ const handleSaveEdit = (): void => {
87
+ if (editing === null) return
88
+ void run('已保存修改', async () => {
89
+ await api.timelineUpdate({ ...editing, source: 'manual' })
90
+ setEditing(null)
91
+ await refresh()
92
+ })
93
+ }
94
+
95
+ const handleRemove = (id: string): void => {
96
+ void run('已删除该事件', async () => {
97
+ await api.timelineRemove(id)
98
+ await refresh()
99
+ })
100
+ }
101
+
102
+ return (
103
+ <div className={css.card} style={{ gap: 'var(--nf-space-12)' }}>
104
+ <div className={css.row} style={{ justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 'var(--nf-space-8)' }}>
105
+ <span className={css.cardTitle}>故事时间线</span>
106
+ <span className={css.meta}>{events.length} 条事件 · {grouped.length} 章</span>
107
+ </div>
108
+
109
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-8)', alignItems: 'center' }}>
110
+ <select
111
+ className={css.input}
112
+ value={extractNo}
113
+ onChange={e => setExtractNo(e.target.value === '' ? '' : Number(e.target.value))}
114
+ title="选择要抽取时间线的章节"
115
+ >
116
+ <option value="">选择章节…</option>
117
+ {chapters.map(no => <option key={no} value={no}>第 {no} 章</option>)}
118
+ </select>
119
+ <button type="button" className={css.button} disabled={busy || extractNo === ''} onClick={handleExtract}>抽取本章时间线</button>
120
+ <button type="button" className={css.button} disabled={busy || events.length === 0} onClick={handleCheck}>检查时间线矛盾</button>
121
+ </div>
122
+
123
+ {notice !== '' && <span style={{ color: 'var(--nf-success)', fontSize: 'var(--nf-text-12)' }}>{notice}</span>}
124
+ {error !== '' && <span style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-text-12)' }}>{error}</span>}
125
+
126
+ {issues.length > 0 && (
127
+ <div style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: 8, background: 'var(--nf-bg-inset)' }}>
128
+ <b style={{ fontSize: 'var(--nf-fs-12)' }}>发现 {issues.length} 处时间线问题</b>
129
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 6 }}>
130
+ {issues.map((issue, i) => (
131
+ <div key={i} style={{ fontSize: 'var(--nf-fs-12)' }}>
132
+ <span className={css.lfPill} data-phase={issue.severity === 'high' ? 'failed' : 'requesting'}>{SEVERITY_LABEL[issue.severity]}</span>
133
+ <span style={{ marginLeft: 6 }}>{issue.chapters.length > 0 ? `第 ${issue.chapters.join('、')} 章:` : ''}{issue.item}</span>
134
+ {issue.suggestion !== '' && <div className={css.meta} style={{ marginLeft: 6 }}>建议:{issue.suggestion}</div>}
135
+ {onRevise !== undefined && issue.chapters.length > 0 && (
136
+ <button
137
+ type="button"
138
+ className={`${css.button} ${css.buttonSmall}`}
139
+ style={{ marginLeft: 6, marginTop: 2 }}
140
+ disabled={busy || outerBusy === true}
141
+ title="把该章的时间线矛盾与审稿意见、张力偏差合并成一轮修订(只改这一章,改完自动复核)"
142
+ onClick={() => { onRevise(issue.chapters[0]!) }}
143
+ >
144
+ 一键按建议修订(第 {issue.chapters[0]} 章)
145
+ </button>
146
+ )}
147
+ </div>
148
+ ))}
149
+ </div>
150
+ </div>
151
+ )}
152
+
153
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10, overflowY: 'auto', maxHeight: '52vh' }}>
154
+ {events.length === 0 && <span className={css.meta}>还没有时间线事件。生成章节后会自动抽取,也可以在上面选一章手工抽取。</span>}
155
+ {grouped.map(([no, list]) => (
156
+ <div key={no}>
157
+ <div className={css.meta} style={{ marginBottom: 4 }}>第 {no} 章 · {list.length} 条</div>
158
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
159
+ {list.map(e => (
160
+ <div key={e.id} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 8px', fontSize: 'var(--nf-fs-12)' }}>
161
+ <div style={{ display: 'flex', gap: 6, alignItems: 'baseline', flexWrap: 'wrap' }}>
162
+ <b>{e.time !== '' ? e.time : '(未标注时间)'}</b>
163
+ {e.place !== '' && <span className={css.meta}>@{e.place}</span>}
164
+ {e.characters.length > 0 && <span className={css.meta}>{e.characters.join('、')}</span>}
165
+ {e.source === 'manual' && <span className={css.meta}>(手改)</span>}
166
+ <span style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
167
+ <button type="button" className={css.iconButton} title="修正这条事件" onClick={() => setEditing({ ...e })}>✎</button>
168
+ <button type="button" className={css.iconButton} title="删除这条事件" onClick={() => handleRemove(e.id)}>×</button>
169
+ </span>
170
+ </div>
171
+ <div style={{ marginTop: 2 }}>{e.event}</div>
172
+ </div>
173
+ ))}
174
+ </div>
175
+ </div>
176
+ ))}
177
+ </div>
178
+
179
+ {editing !== null && (
180
+ <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.45)', zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={() => setEditing(null)}>
181
+ <div onClick={ev => ev.stopPropagation()} style={{ background: 'var(--nf-bg)', border: '1px solid var(--nf-border)', borderRadius: 10, width: 'min(560px, 94vw)', padding: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
182
+ <b style={{ fontSize: 'var(--nf-fs-13)' }}>修正时间线事件 · 第 {editing.chapterNo} 章</b>
183
+ <label className={css.field}>
184
+ <span className={css.fieldLabel}>故事内时间</span>
185
+ <input className={css.input} value={editing.time} onChange={e => setEditing({ ...editing, time: e.target.value })} placeholder="如:第三日黄昏 / 入宗三个月后" />
186
+ </label>
187
+ <label className={css.field}>
188
+ <span className={css.fieldLabel}>地点</span>
189
+ <input className={css.input} value={editing.place} onChange={e => setEditing({ ...editing, place: e.target.value })} />
190
+ </label>
191
+ <label className={css.field}>
192
+ <span className={css.fieldLabel}>在场角色(顿号或逗号分隔)</span>
193
+ <input className={css.input} value={editing.characters.join('、')} onChange={e => setEditing({ ...editing, characters: e.target.value.split(/[、,,\s]+/).filter(Boolean) })} />
194
+ </label>
195
+ <label className={css.field}>
196
+ <span className={css.fieldLabel}>事件</span>
197
+ <textarea className={css.textarea} rows={3} value={editing.event} onChange={e => setEditing({ ...editing, event: e.target.value })} />
198
+ </label>
199
+ <div className={css.row} style={{ justifyContent: 'flex-end', gap: 6 }}>
200
+ <button type="button" className={css.button} onClick={() => setEditing(null)}>取消</button>
201
+ <button type="button" className={css.button + ' ' + css.buttonPrimary} disabled={busy} onClick={handleSaveEdit}>保存</button>
202
+ </div>
203
+ </div>
204
+ </div>
205
+ )}
206
+ </div>
207
+ )
208
+ }