@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,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
+ }