@waterwx/dsh-novel-forge 0.1.0 → 1.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.
@@ -4,7 +4,8 @@
4
4
  * 设定库 (story bible), 伏笔 (foreshadows), 设置 (config). Generation and
5
5
  * review streams land in the progress console.
6
6
  */
7
- import { useCallback, useEffect, useRef, useState } from 'react'
7
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
8
+ import type { ReactElement } from 'react'
8
9
  import type { NovelApi } from '../api.ts'
9
10
  import type { PanelController } from './controller.ts'
10
11
  import { tt } from './helpers.ts'
@@ -26,7 +27,10 @@ import type {
26
27
  import css from './panel.module.css'
27
28
 
28
29
  /** The panel's tab identifiers. */
29
- export type NovelTab = 'workflow' | 'overview' | 'plan' | 'bible' | 'assets' | 'foreshadow' | 'assistant' | 'settings'
30
+ export type NovelTab =
31
+ | 'workflow' | 'overview' | 'plan' | 'bible' | 'foreshadow' | 'assistant' | 'settings'
32
+ | 'characters'
33
+ | 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle'
30
34
 
31
35
  /** Panel shell props. */
32
36
  export interface NovelPanelProps {
@@ -41,20 +45,49 @@ interface ProgressLine {
41
45
  id: number
42
46
  text: string
43
47
  kind: 'info' | 'done' | 'error'
48
+ /** Live line: a single in-place-updating row (generation counter + bar). */
49
+ live?: boolean
50
+ /** 0..1 completion ratio for the live line's progress bar. */
51
+ ratio?: number
44
52
  }
45
53
 
46
- /** The tab bar definition. */
47
- const TABS: ReadonlyArray<{ id: NovelTab; label: string }> = [
48
- { id: 'workflow', label: tt('tab.workflow') },
49
- { id: 'overview', label: tt('tab.overview') },
50
- { id: 'plan', label: tt('tab.plan') },
51
- { id: 'bible', label: tt('tab.bible') },
52
- { id: 'assets', label: '写作资产' },
53
- { id: 'foreshadow', label: tt('tab.foreshadow') },
54
- { id: 'assistant', label: tt('tab.assistant') },
55
- { id: 'settings', label: tt('tab.settings') },
54
+ /** The navigation groups (AI-Novel-Writing-Assistant style grouping). */
55
+ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArray<{ id: NovelTab; label: string; icon: string }> }> = [
56
+ {
57
+ id: 'create',
58
+ label: '创作',
59
+ items: [
60
+ { id: 'workflow', label: tt('tab.workflow'), icon: '🛠️' },
61
+ { id: 'plan', label: tt('tab.plan'), icon: '📚' },
62
+ { id: 'overview', label: tt('tab.overview'), icon: '📄' },
63
+ ],
64
+ },
65
+ {
66
+ id: 'tools',
67
+ label: '工具',
68
+ items: [
69
+ { id: 'assistant', label: tt('tab.assistant'), icon: '💬' },
70
+ ],
71
+ },
72
+ {
73
+ id: 'db',
74
+ label: '数据库',
75
+ items: [
76
+ { id: 'bible', label: tt('tab.bible'), icon: '📖' },
77
+ { id: 'characters', label: '角色卡', icon: '👥' },
78
+ { id: 'foreshadow', label: tt('tab.foreshadow'), icon: '🔮' },
79
+ { id: 'assetsGenre', label: '题材基底', icon: '🏷️' },
80
+ { id: 'assetsProgression', label: '推进模式', icon: '📈' },
81
+ { id: 'assetsTemplates', label: '预置写法', icon: '🖋️' },
82
+ { id: 'assetsRules', label: '反 AI 规则', icon: '🚫' },
83
+ { id: 'assetsStyle', label: '自定义写法', icon: '🎨' },
84
+ ],
85
+ },
56
86
  ]
57
87
 
88
+ /** Settings tab — pinned to the bottom of the nav rail. */
89
+ const SETTINGS_TAB: { id: NovelTab; label: string; icon: string } = { id: 'settings', label: tt('tab.settings'), icon: '⚙️' }
90
+
58
91
  /** Whether any chapter is being generated right now. */
59
92
  function anyGenerating(chapters: ChapterPlan[] | undefined): boolean {
60
93
  return (chapters ?? []).some(c => c.status === 'generating' || c.status === 'reviewing')
@@ -78,6 +111,171 @@ function severityColor(severity: string): string {
78
111
  return severity === 'high' ? 'var(--nf-error)' : severity === 'medium' ? 'var(--nf-warn)' : 'var(--nf-info)'
79
112
  }
80
113
 
114
+ /** One row of a chapter-level diff (paragraph granularity). */
115
+ type DiffRow =
116
+ | { kind: 'same'; text: string }
117
+ | { kind: 'change'; old: string; neu: string }
118
+ | { kind: 'del'; text: string }
119
+ | { kind: 'add'; text: string }
120
+
121
+ /**
122
+ * Paragraph-level LCS diff between an original chapter body and its
123
+ * rewrite/polish draft. Adjacent delete+add runs merge into "change" pairs
124
+ * (the common case: a reworded paragraph shown as old → new).
125
+ */
126
+ function paragraphDiff(oldText: string, newText: string): DiffRow[] {
127
+ const split = (t: string): string[] =>
128
+ t.replace(/^#\s+.*$/m, '').trim().split(/\n{2,}/).map(p => p.trim()).filter(p => p !== '')
129
+ const a = split(oldText)
130
+ const b = split(newText)
131
+ const n = a.length
132
+ const m = b.length
133
+ const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
134
+ for (let i = n - 1; i >= 0; i--) {
135
+ for (let j = m - 1; j >= 0; j--) {
136
+ dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!)
137
+ }
138
+ }
139
+ const rows: DiffRow[] = []
140
+ let i = 0
141
+ let j = 0
142
+ while (i < n || j < m) {
143
+ if (i < n && j < m && a[i] === b[j]) {
144
+ rows.push({ kind: 'same', text: a[i]! })
145
+ i++
146
+ j++
147
+ } else if (i < n && (j >= m || dp[i + 1]![j]! >= dp[i]![j + 1]!)) {
148
+ rows.push({ kind: 'del', text: a[i]! })
149
+ i++
150
+ } else if (j < m) {
151
+ rows.push({ kind: 'add', text: b[j]! })
152
+ j++
153
+ } else if (i < n) {
154
+ rows.push({ kind: 'del', text: a[i]! })
155
+ i++
156
+ } else {
157
+ rows.push({ kind: 'add', text: b[j]! })
158
+ j++
159
+ }
160
+ }
161
+ // Merge adjacent del/add runs into change pairs.
162
+ const merged: DiffRow[] = []
163
+ let k = 0
164
+ while (k < rows.length) {
165
+ const row = rows[k]!
166
+ if (row.kind !== 'del' && row.kind !== 'add') {
167
+ merged.push(row)
168
+ k++
169
+ continue
170
+ }
171
+ const dels: string[] = []
172
+ const adds: string[] = []
173
+ while (k < rows.length && (rows[k]!.kind === 'del' || rows[k]!.kind === 'add')) {
174
+ if (rows[k]!.kind === 'del') dels.push((rows[k] as { text: string }).text)
175
+ else adds.push((rows[k] as { text: string }).text)
176
+ k++
177
+ }
178
+ if (dels.length > 0 && adds.length > 0) {
179
+ merged.push({ kind: 'change', old: dels.join('\n\n'), neu: adds.join('\n\n') })
180
+ } else if (dels.length > 0) {
181
+ for (const d of dels) merged.push({ kind: 'del', text: d })
182
+ } else {
183
+ for (const ad of adds) merged.push({ kind: 'add', text: ad })
184
+ }
185
+ }
186
+ return merged
187
+ }
188
+
189
+ /** 把章节 beats 按结构标签渲染(本章目标/剧情要点/爽点/结尾钩子 等)。 */
190
+ function renderBeats(beats: string): ReactElement {
191
+ const lines = beats.split('\n')
192
+ return (
193
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
194
+ {lines.map((line, i) => {
195
+ const trimmed = line.trim()
196
+ const match = /^([^::]{2,14})[::]/.exec(trimmed)
197
+ if (match !== null) {
198
+ return (
199
+ <div key={i}>
200
+ <b style={{ color: 'var(--nf-accent)' }}>{match[1]}</b>
201
+ {trimmed.slice(match[0].length)}
202
+ </div>
203
+ )
204
+ }
205
+ return <div key={i}>{line}</div>
206
+ })}
207
+ </div>
208
+ )
209
+ }
210
+
211
+ /** The diff list of a draft-vs-original comparison (scrollable). */
212
+ function DiffList({ original, draft }: { original: string; draft: string }): ReactElement { const rows = useMemo(() => paragraphDiff(original, draft), [original, draft])
213
+ const changed = rows.filter(r => r.kind === 'change').length
214
+ const added = rows.filter(r => r.kind === 'add').length
215
+ const removed = rows.filter(r => r.kind === 'del').length
216
+ const [onlyChanges, setOnlyChanges] = useState(false)
217
+ const shown = onlyChanges ? rows.filter(r => r.kind !== 'same') : rows
218
+ return (
219
+ <>
220
+ <div className={css.diffLegend}>
221
+ <span className={css.legendOld}>■ 原稿</span>
222
+ <span className={css.legendNew}>■ 新稿</span>
223
+ <span className={css.meta}>
224
+ 原 {original.length} 字 → 新 {draft.length} 字 · 修改 {changed} · 新增 {added} · 删除 {removed}
225
+ </span>
226
+ <label className={css.onlyChanges}>
227
+ <input
228
+ type="checkbox"
229
+ checked={onlyChanges}
230
+ onChange={e => { setOnlyChanges(e.target.checked) }}
231
+ />
232
+ 只看改动
233
+ </label>
234
+ </div>
235
+ <div className={css.diffList}>
236
+ {shown.map((row, idx) => {
237
+ if (row.kind === 'same') {
238
+ return (
239
+ <details key={idx} className={css.diffSame}>
240
+ <summary>第 {idx + 1} 段 · 未改动(点击展开)</summary>
241
+ <div className={css.diffSameBody}>{row.text}</div>
242
+ </details>
243
+ )
244
+ }
245
+ if (row.kind === 'change') {
246
+ return (
247
+ <div key={idx} className={css.diffChange}>
248
+ <div className={css.diffColumn}>
249
+ <span className={css.diffTagOld}>原稿</span>
250
+ <div className={css.diffOld}>{row.old}</div>
251
+ </div>
252
+ <div className={css.diffColumn}>
253
+ <span className={css.diffTagNew}>新稿</span>
254
+ <div className={css.diffNew}>{row.neu}</div>
255
+ </div>
256
+ </div>
257
+ )
258
+ }
259
+ if (row.kind === 'del') {
260
+ return (
261
+ <div key={idx} className={css.diffDel}>
262
+ <span className={css.diffTagOld}>原稿</span>
263
+ <span className={css.diffText}>{row.text}</span>
264
+ </div>
265
+ )
266
+ }
267
+ return (
268
+ <div key={idx} className={css.diffAdd}>
269
+ <span className={css.diffTagNew}>新稿</span>
270
+ <span className={css.diffText}>{row.text}</span>
271
+ </div>
272
+ )
273
+ })}
274
+ </div>
275
+ </>
276
+ )
277
+ }
278
+
81
279
  /** The novel-forge panel. */
82
280
  export function NovelPanel({ controller, api }: NovelPanelProps) {
83
281
  const [activeTab, setActiveTab] = useState<NovelTab>('workflow')
@@ -98,9 +296,105 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
98
296
  const [configDraft, setConfigDraft] = useState<NovelConfig | null>(null)
99
297
  const [expandedChapter, setExpandedChapter] = useState<number | null>(null)
100
298
  const [chapterText, setChapterText] = useState('')
101
- const [rewriteInstruction, setRewriteInstruction] = useState('')
102
- const [localTarget, setLocalTarget] = useState('')
103
299
  const progressId = useRef(0)
300
+ /** id of the single live progress row (generation counter), if any. */
301
+ const liveProgressId = useRef<number | null>(null)
302
+ /** last chars value rendered into the live row (throttle for streaming). */
303
+ const lastDeltaChars = useRef(0)
304
+ /** cumulative chars received this job (delta frames carry increments). */
305
+ const liveChars = useRef(0)
306
+ /** chapter no of the job currently streaming (delta frames carry no `no`). */
307
+ const currentJobNo = useRef(0)
308
+ /** prominent top-of-panel progress bar while a chapter is being written. */
309
+ const [liveBar, setLiveBar] = useState<{ text: string; ratio?: number } | null>(null)
310
+ /** 润色/修订工作区:左栏原文(可选中局部目标)+ 右栏指令/预览/应用。 */
311
+ const [workspace, setWorkspace] = useState<{
312
+ no: number
313
+ title: string
314
+ original: string
315
+ instruction: string
316
+ draft: string | null
317
+ } | null>(null)
318
+ /** 工作区左栏当前选中的文字(局部修订目标)。 */
319
+ const [wsSelected, setWsSelected] = useState('')
320
+ /** 工作区预览区:diff 对比视图开关。 */
321
+ const [wsShowDiff, setWsShowDiff] = useState(false)
322
+ /** 工作区原文 textarea 引用(用于捕获选中文字)。 */
323
+ const wsEditorRef = useRef<HTMLTextAreaElement | null>(null)
324
+ /** 有未采纳草稿的章节号(refresh 后检测到遗留草稿时提示)。 */
325
+ const [draftNo, setDraftNo] = useState<number | null>(null)
326
+ /** 大纲页「更新大纲」编辑区是否展开。 */
327
+ const [updatingOutline, setUpdatingOutline] = useState(false)
328
+ /** 全书质检结果(null = 未运行)。 */
329
+ const [auditIssues, setAuditIssues] = useState<import('../../protocol.ts').AuditIssue[] | null>(null)
330
+ /** 角色卡(从事实库聚合)。 */
331
+ const [charCards, setCharCards] = useState<import('../../protocol.ts').RoleStatusCard[] | null>(null)
332
+ /** 世界观规则编辑草稿(bible tab,每行一条)。 */
333
+ const [worldRulesDraft, setWorldRulesDraft] = useState('')
334
+ /** AI 助手悬浮窗:是否打开。 */
335
+ const [assistantOpen, setAssistantOpen] = useState(false)
336
+ /** 悬浮窗位置(相对面板,localStorage 记忆)。 */
337
+ const [assistantPos, setAssistantPos] = useState(() => {
338
+ try {
339
+ const raw = window.localStorage.getItem('dsh-novel-forge.assistant.float')
340
+ if (raw !== null) {
341
+ const parsed = JSON.parse(raw) as { x?: unknown; y?: unknown }
342
+ return { x: typeof parsed.x === 'number' ? parsed.x : 260, y: typeof parsed.y === 'number' ? parsed.y : 60 }
343
+ }
344
+ } catch { /* ignore */ }
345
+ return { x: 260, y: 60 }
346
+ })
347
+ /** 悬浮窗尺寸(localStorage 记忆)。 */
348
+ const [assistantSize, setAssistantSize] = useState(() => {
349
+ try {
350
+ const raw = window.localStorage.getItem('dsh-novel-forge.assistant.size')
351
+ if (raw !== null) {
352
+ const parsed = JSON.parse(raw) as { w?: unknown; h?: unknown }
353
+ return { w: typeof parsed.w === 'number' ? parsed.w : 420, h: typeof parsed.h === 'number' ? parsed.h : 460 }
354
+ }
355
+ } catch { /* ignore */ }
356
+ return { w: 420, h: 460 }
357
+ })
358
+ /** 拖拽/缩放状态。 */
359
+ const dragState = useRef<{ type: 'move' | 'resize'; startX: number; startY: number; origX: number; origY: number; origW: number; origH: number } | null>(null)
360
+
361
+ /** 悬浮窗位置/尺寸持久化。 */
362
+ useEffect(() => {
363
+ try {
364
+ window.localStorage.setItem('dsh-novel-forge.assistant.float', JSON.stringify(assistantPos))
365
+ window.localStorage.setItem('dsh-novel-forge.assistant.size', JSON.stringify(assistantSize))
366
+ } catch { /* ignore */ }
367
+ }, [assistantPos, assistantSize])
368
+
369
+ /** 全局拖拽/缩放监听(挂一次,靠 dragState 判断)。 */
370
+ useEffect(() => {
371
+ const onMove = (e: MouseEvent): void => {
372
+ const s = dragState.current
373
+ if (s === null) return
374
+ if (s.type === 'move') {
375
+ setAssistantPos({
376
+ x: Math.max(-340, Math.min(s.origX + e.clientX - s.startX, 3000)),
377
+ y: Math.max(0, Math.min(s.origY + e.clientY - s.startY, 3000)),
378
+ })
379
+ } else {
380
+ setAssistantSize({
381
+ w: Math.max(320, Math.min(s.origW + e.clientX - s.startX, 1400)),
382
+ h: Math.max(220, Math.min(s.origH + e.clientY - s.startY, 1200)),
383
+ })
384
+ }
385
+ }
386
+ const onUp = (): void => { dragState.current = null }
387
+ window.addEventListener('mousemove', onMove)
388
+ window.addEventListener('mouseup', onUp)
389
+ return () => {
390
+ window.removeEventListener('mousemove', onMove)
391
+ window.removeEventListener('mouseup', onUp)
392
+ }
393
+ }, [])
394
+ /** 左侧导航折叠状态(localStorage 记忆,参照 AI-Novel-Writing-Assistant 侧边栏)。 */
395
+ const [navCollapsed, setNavCollapsed] = useState(() => {
396
+ try { return window.localStorage.getItem('dsh-novel-forge.nav.collapsed') === 'true' } catch { return false }
397
+ })
104
398
 
105
399
  /** Refresh bookshelf. */
106
400
  const refreshShelf = useCallback(async () => {
@@ -115,6 +409,26 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
115
409
  setProgress(prev => [...prev.slice(-300), { id: progressId.current++, text, kind }])
116
410
  }, [])
117
411
 
412
+ /** Update the single live progress row in place (create it on first call). */
413
+ const setLiveProgress = useCallback((text: string, ratio?: number) => {
414
+ setProgress(prev => {
415
+ if (liveProgressId.current !== null) {
416
+ return prev.map(l => l.id === liveProgressId.current ? { ...l, text, ratio } : l)
417
+ }
418
+ const id = progressId.current++
419
+ liveProgressId.current = id
420
+ return [...prev.slice(-300), { id, text, kind: 'info', live: true, ratio }]
421
+ })
422
+ }, [])
423
+
424
+ /** Remove the live progress row (a job finished / failed). */
425
+ const clearLiveProgress = useCallback(() => {
426
+ if (liveProgressId.current === null) return
427
+ const id = liveProgressId.current
428
+ liveProgressId.current = null
429
+ setProgress(prev => prev.filter(l => l.id !== id))
430
+ }, [])
431
+
118
432
  /** Refresh status (config + project + files). */
119
433
  const refresh = useCallback(async (showError = true) => {
120
434
  try {
@@ -123,6 +437,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
123
437
  setConfigDraft(status.config)
124
438
  setProject(status.project ?? null)
125
439
  setGeneratedFiles(status.generatedFiles)
440
+ const withDraft = status.project?.chapters.find(c => c.pendingDraft !== undefined && c.pendingDraft !== '')
441
+ setDraftNo(withDraft?.no ?? null)
126
442
  const nextOutline = status.project?.outline
127
443
  if (nextOutline !== undefined && outlineText === '') {
128
444
  setOutlineText(nextOutline)
@@ -199,6 +515,108 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
199
515
  }
200
516
  }
201
517
 
518
+ /** 大纲页「更新大纲」展开/收起(展开时预填当前大纲文本)。 */
519
+ const handleToggleUpdateOutline = (): void => {
520
+ if (!updatingOutline && project !== null) setOutlineText(project.outline)
521
+ setUpdatingOutline(v => !v)
522
+ }
523
+
524
+ /** 重置项目:清空全部进度,从新大纲重新开始(二次确认)。 */
525
+ const handleResetProject = async (): Promise<void> => {
526
+ if (project === null) return
527
+ if (!window.confirm('将清空本书全部进度(设定圣经/卷计划/章节计划/已生成章节/伏笔/写作资产/事实库),且不可恢复。确定用新大纲重置?')) return
528
+ setBusy(true)
529
+ setError('')
530
+ try {
531
+ const result = await api.reset(outlineText)
532
+ setUpdatingOutline(false)
533
+ pushProgress(`已重置项目:${result.bookName}(从新大纲重新开始)`, 'done')
534
+ } catch (err) {
535
+ setError((err as Error).message)
536
+ pushProgress(`重置失败:${(err as Error).message}`, 'error')
537
+ } finally {
538
+ setBusy(false)
539
+ setBusyLabel('')
540
+ await refresh(false)
541
+ }
542
+ }
543
+
544
+ /** 全书一致性质检(LLM 扫描已生成章节)。 */
545
+ const handleAudit = async (): Promise<void> => {
546
+ setBusy(true)
547
+ setBusyLabel('全书一致性质检中…')
548
+ setError('')
549
+ try {
550
+ const result = await api.audit()
551
+ setAuditIssues(result.issues)
552
+ pushProgress(result.issues.length === 0
553
+ ? `全书质检完成:${result.auditedChapters} 章未发现矛盾 🎉`
554
+ : `全书质检完成:发现 ${result.issues.length} 处疑似矛盾`, result.issues.length === 0 ? 'done' : 'error')
555
+ } catch (err) {
556
+ setError((err as Error).message)
557
+ pushProgress(`全书质检失败:${(err as Error).message}`, 'error')
558
+ } finally {
559
+ setBusy(false)
560
+ setBusyLabel('')
561
+ }
562
+ }
563
+
564
+ /** 角色卡刷新。 */
565
+ const handleCharactersRefresh = async (): Promise<void> => {
566
+ setBusy(true)
567
+ setBusyLabel('聚合角色卡中…')
568
+ setError('')
569
+ try {
570
+ const result = await api.charactersRefresh()
571
+ setCharCards(result.cards)
572
+ pushProgress(`角色卡已刷新:${result.cards.length} 个角色`, 'done')
573
+ } catch (err) {
574
+ setError((err as Error).message)
575
+ pushProgress(`角色卡刷新失败:${(err as Error).message}`, 'error')
576
+ } finally {
577
+ setBusy(false)
578
+ setBusyLabel('')
579
+ }
580
+ }
581
+
582
+ /** 事实库回填(历史章节批量抽取)。 */
583
+ const handleFactsBackfill = async (): Promise<void> => {
584
+ if (doneCount === 0) return
585
+ setBusy(true)
586
+ setBusyLabel('回填历史章节事实中…')
587
+ setError('')
588
+ try {
589
+ const result = await api.factsBackfill()
590
+ pushProgress(result.filled > 0
591
+ ? `事实库回填完成:${result.filled} 章已抽取事实`
592
+ : '事实库无需回填(所有章节都已有事实记录)', 'done')
593
+ await refresh(false)
594
+ } catch (err) {
595
+ setError((err as Error).message)
596
+ pushProgress(`事实库回填失败:${(err as Error).message}`, 'error')
597
+ } finally {
598
+ setBusy(false)
599
+ setBusyLabel('')
600
+ }
601
+ }
602
+
603
+ /** 保存世界观规则编辑(bible tab,每行一条)。 */
604
+ const handleSaveWorldRules = async (): Promise<void> => {
605
+ if (bible === undefined) return
606
+ const rules = worldRulesDraft.split('\n').map(line => line.trim()).filter(line => line !== '')
607
+ setBusy(true)
608
+ setError('')
609
+ try {
610
+ const result = await api.biblePatch({ worldRules: rules })
611
+ setProject(prev => prev === null ? prev : { ...prev, bible: result.bible, updatedAt: new Date().toISOString() })
612
+ pushProgress(`世界观规则已保存(${rules.length} 条)`, 'done')
613
+ } catch (err) {
614
+ setError((err as Error).message)
615
+ } finally {
616
+ setBusy(false)
617
+ }
618
+ }
619
+
202
620
  /** Extract the story bible. */
203
621
  const handleBible = async (): Promise<void> => {
204
622
  setBusy(true)
@@ -264,19 +682,137 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
264
682
  }
265
683
  }
266
684
 
685
+ /** 打开某章的工作区(读取服务器原文;有遗留草稿时预载草稿;可预填修订指令)。 */
686
+ const openWorkspace = useCallback(async (no: number, instruction = ''): Promise<void> => {
687
+ try {
688
+ const [chapterRes, statusRes] = await Promise.all([api.chapter(no), api.status()])
689
+ const chapter = statusRes.project?.chapters.find(c => c.no === no)
690
+ if (chapter === undefined) return
691
+ setWorkspace({
692
+ no,
693
+ title: chapter.title,
694
+ original: chapterRes.markdown,
695
+ instruction,
696
+ draft: chapter.pendingDraft !== undefined && chapter.pendingDraft !== '' ? chapter.pendingDraft : null,
697
+ })
698
+ setWsSelected('')
699
+ setWsShowDiff(false)
700
+ } catch { /* best-effort */ }
701
+ }, [api])
702
+
703
+ /** 捕获工作区原文 textarea 中选中的文字(局部修订目标)。 */
704
+ const captureWsSelection = (): void => {
705
+ const el = wsEditorRef.current
706
+ if (el === null) return
707
+ const start = el.selectionStart ?? 0
708
+ const end = el.selectionEnd ?? 0
709
+ if (end > start) setWsSelected(el.value.slice(start, end).trim())
710
+ else setWsSelected('')
711
+ }
712
+
713
+ /** 工作区:去 AI 味润色(流式 → 预览草稿)。 */
714
+ const handleWsPolish = async (): Promise<void> => {
715
+ if (workspace === null) return
716
+ setBusy(true)
717
+ setBusyLabel(`${tt('plan.polish')} 第${workspace.no}章`)
718
+ setError('')
719
+ try {
720
+ await api.polish(workspace.no, frame => { applyJobFrame(frame, n => tt('progress.polishing', { no: n })) })
721
+ } catch (err) {
722
+ setError((err as Error).message)
723
+ pushProgress(`第 ${workspace.no} 章润色失败:${(err as Error).message}`, 'error')
724
+ } finally {
725
+ setBusy(false)
726
+ setBusyLabel('')
727
+ await refresh(false)
728
+ }
729
+ }
730
+
731
+ /** 工作区:按指令修订(whole=true 整章,false 仅修订选中片段)。 */
732
+ const handleWsRewrite = async (whole: boolean): Promise<void> => {
733
+ if (workspace === null) return
734
+ const target = whole ? '' : wsSelected
735
+ setBusy(true)
736
+ setBusyLabel(`${tt('plan.rewrite')} 第${workspace.no}章`)
737
+ setError('')
738
+ try {
739
+ await api.rewrite(workspace.no, workspace.instruction, target, frame => { applyJobFrame(frame, n => tt('progress.rewriting', { no: n })) })
740
+ } catch (err) {
741
+ setError((err as Error).message)
742
+ pushProgress(`第 ${workspace.no} 章修订失败:${(err as Error).message}`, 'error')
743
+ } finally {
744
+ setBusy(false)
745
+ setBusyLabel('')
746
+ await refresh(false)
747
+ }
748
+ }
749
+
750
+ /** 采纳草稿:覆盖正文文件并刷新。 */
751
+ const handleDraftApply = async (no: number): Promise<void> => {
752
+ setBusy(true)
753
+ setError('')
754
+ try {
755
+ const result = await api.draftApply(no)
756
+ setWorkspace(null)
757
+ setDraftNo(null)
758
+ pushProgress(`已采纳第 ${no} 章新稿(${result.chars} 字)→ ${result.file}`, 'done')
759
+ } catch (err) {
760
+ setError((err as Error).message)
761
+ pushProgress(`采纳第 ${no} 章草稿失败:${(err as Error).message}`, 'error')
762
+ } finally {
763
+ setBusy(false)
764
+ setBusyLabel('')
765
+ await refresh(false)
766
+ }
767
+ }
768
+
769
+ /** 放弃草稿:保留原稿,仅清空草稿。 */
770
+ const handleDraftDiscard = async (no: number): Promise<void> => {
771
+ setBusy(true)
772
+ setError('')
773
+ try {
774
+ await api.draftDiscard(no)
775
+ setWorkspace(null)
776
+ setDraftNo(null)
777
+ pushProgress(`已放弃第 ${no} 章草稿,保留原稿`, 'info')
778
+ } catch (err) {
779
+ setError((err as Error).message)
780
+ pushProgress(`放弃第 ${no} 章草稿失败:${(err as Error).message}`, 'error')
781
+ } finally {
782
+ setBusy(false)
783
+ setBusyLabel('')
784
+ await refresh(false)
785
+ }
786
+ }
787
+
267
788
  /** Shared frame handler for generate/rewrite/polish streams. */
268
789
  const applyJobFrame = useCallback((frame: JobFrame, label: (no: number) => string) => {
269
790
  if (frame.type === 'start') {
791
+ clearLiveProgress()
792
+ setLiveBar(null)
793
+ lastDeltaChars.current = 0
794
+ liveChars.current = 0
795
+ currentJobNo.current = frame.no
270
796
  setProject(prev => prev === null ? prev : {
271
797
  ...prev,
272
798
  chapters: prev.chapters.map(c => c.no === frame.no ? { ...c, status: 'generating', error: undefined } : c),
273
799
  })
274
800
  pushProgress(label(frame.no))
275
801
  } else if (frame.type === 'delta') {
276
- if (frame.text.length % 3000 < 600) {
277
- pushProgress(`…已生成 ${frame.text.length} 字`)
802
+ // One live row updated in place — no per-token console spam. The
803
+ // server streams incremental text, so accumulate locally.
804
+ const chars = (liveChars.current += frame.text.length)
805
+ const target = project?.chapters.find(c => c.no === currentJobNo.current)?.targetChars ?? 0
806
+ if (chars < 50 || chars - lastDeltaChars.current >= 200) {
807
+ lastDeltaChars.current = chars
808
+ const text = target > 0 ? `已生成 ${chars} / ${target} 字` : `已生成 ${chars} 字`
809
+ const ratio = target > 0 ? Math.min(chars / target, 1) : undefined
810
+ setLiveProgress(text, ratio)
811
+ setLiveBar({ text, ratio })
278
812
  }
279
813
  } else if (frame.type === 'done' || frame.type === 'rewritten') {
814
+ clearLiveProgress()
815
+ setLiveBar(null)
280
816
  setProject(prev => prev === null ? prev : {
281
817
  ...prev,
282
818
  chapters: prev.chapters.map(c => c.no === frame.no ? { ...c, status: 'written', chars: frame.chars, file: frame.file, review: undefined } : c),
@@ -284,6 +820,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
284
820
  pushProgress(tt('progress.done', { no: frame.no, chars: frame.chars, file: frame.file }), 'done')
285
821
  setGeneratedFiles(prev => prev.includes(frame.file) ? prev : [...prev, frame.file])
286
822
  } else if (frame.type === 'review') {
823
+ clearLiveProgress()
824
+ setLiveBar(null)
287
825
  setProject(prev => prev === null ? prev : {
288
826
  ...prev,
289
827
  chapters: prev.chapters.map(c => c.no === frame.no ? { ...c, status: frame.report.passed ? 'approved' : 'rejected', review: frame.report } : c),
@@ -293,14 +831,28 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
293
831
  score: frame.report.score,
294
832
  verdict: frame.report.verdict,
295
833
  }), frame.report.passed ? 'done' : 'error')
834
+ } else if (frame.type === 'drafted') {
835
+ // 润色/重写完成:产物作为待确认草稿,展示在工作区预览,由用户决定。
836
+ clearLiveProgress()
837
+ setLiveBar(null)
838
+ setProject(prev => prev === null ? prev : {
839
+ ...prev,
840
+ chapters: prev.chapters.map(c => c.no === frame.no ? { ...c, pendingDraft: frame.draft } : c),
841
+ })
842
+ pushProgress(`第 ${frame.no} 章润色完成(${frame.chars} 字),请查看预览后应用或放弃`)
843
+ setDraftNo(frame.no)
844
+ setWsShowDiff(false)
845
+ setWorkspace(prev => prev !== null && prev.no === frame.no ? { ...prev, draft: frame.draft } : prev)
296
846
  } else if (frame.type === 'error') {
847
+ clearLiveProgress()
848
+ setLiveBar(null)
297
849
  setProject(prev => prev === null ? prev : {
298
850
  ...prev,
299
851
  chapters: prev.chapters.map(c => c.no === frame.no ? { ...c, status: 'error', error: frame.message } : c),
300
852
  })
301
853
  pushProgress(tt('progress.error', { no: frame.no, message: frame.message }), 'error')
302
854
  }
303
- }, [pushProgress])
855
+ }, [pushProgress, setLiveProgress, clearLiveProgress, project])
304
856
 
305
857
  /** Generate one chapter, streaming frames into the console. */
306
858
  const handleWriteChapter = async (no: number, skipReview: boolean): Promise<void> => {
@@ -319,7 +871,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
319
871
  }
320
872
  }
321
873
 
322
- /** Batch-write all remaining chapters in sequence. */
874
+ /** Batch-write all remaining chapters in sequence (auto-retry once per chapter). */
323
875
  const handleWriteAll = async (): Promise<void> => {
324
876
  const remaining = chapters.filter(c => c.status === 'pending' || c.status === 'error')
325
877
  if (remaining.length === 0) return
@@ -327,13 +879,27 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
327
879
  setBusyLabel(`${tt('plan.writeAllPending')}(共 ${remaining.length} 章)`)
328
880
  setError('')
329
881
  let failed = 0
882
+ const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
330
883
  for (const chapter of remaining) {
331
884
  pushProgress(`▶ 开始生成第 ${chapter.no} 章《${chapter.title}》`)
332
- try {
333
- await api.generate(chapter.no, true, frame => { applyJobFrame(frame, n => tt('progress.generating', { no: n, title: (project?.chapters.find(c => c.no === n)?.title ?? '') })) })
334
- } catch (err) {
885
+ let lastError: unknown = null
886
+ // 失败自动重试:最多尝试 2 次,间隔 3 秒(网络抖动/LLM 偶发失败自愈)。
887
+ for (let attempt = 1; attempt <= 2; attempt++) {
888
+ try {
889
+ await api.generate(chapter.no, true, frame => { applyJobFrame(frame, n => tt('progress.generating', { no: n, title: (project?.chapters.find(c => c.no === n)?.title ?? '') })) })
890
+ lastError = null
891
+ break
892
+ } catch (err) {
893
+ lastError = err
894
+ if (attempt < 2) {
895
+ pushProgress(`第 ${chapter.no} 章第 ${attempt} 次尝试失败(${(err as Error).message}),3 秒后自动重试…`, 'error')
896
+ await sleep(3000)
897
+ }
898
+ }
899
+ }
900
+ if (lastError !== null) {
335
901
  failed++
336
- pushProgress(`第 ${chapter.no} 章失败:${(err as Error).message}`, 'error')
902
+ pushProgress(`第 ${chapter.no} 章失败:${(lastError as Error).message}`, 'error')
337
903
  }
338
904
  }
339
905
  setBusy(false)
@@ -365,42 +931,6 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
365
931
  }
366
932
  }
367
933
 
368
- /** Rewrite one chapter (whole-chapter or local target). */
369
- const handleRewrite = async (no: number): Promise<void> => {
370
- setBusy(true)
371
- setBusyLabel(`${tt('plan.rewrite')} 第${no}章`)
372
- setError('')
373
- try {
374
- await api.rewrite(no, rewriteInstruction, localTarget, frame => { applyJobFrame(frame, n => tt('progress.rewriting', { no: n })) })
375
- setRewriteInstruction('')
376
- setLocalTarget('')
377
- } catch (err) {
378
- setError((err as Error).message)
379
- pushProgress(`第 ${no} 章修订失败:${(err as Error).message}`, 'error')
380
- } finally {
381
- setBusy(false)
382
- setBusyLabel('')
383
- await refresh(false)
384
- }
385
- }
386
-
387
- /** Polish one chapter. */
388
- const handlePolish = async (no: number): Promise<void> => {
389
- setBusy(true)
390
- setBusyLabel(`${tt('plan.polish')} 第${no}章`)
391
- setError('')
392
- try {
393
- await api.polish(no, frame => { applyJobFrame(frame, n => tt('progress.polishing', { no: n })) })
394
- } catch (err) {
395
- setError((err as Error).message)
396
- pushProgress(`第 ${no} 章润色失败:${(err as Error).message}`, 'error')
397
- } finally {
398
- setBusy(false)
399
- setBusyLabel('')
400
- await refresh(false)
401
- }
402
- }
403
-
404
934
  /** Approve a chapter manually. */
405
935
  const handleApprove = (no: number): void => {
406
936
  setProject(prev => prev === null ? prev : {
@@ -496,21 +1026,166 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
496
1026
  const volumes: Volume[] | undefined = project?.volumes
497
1027
  const foreshadows: Foreshadow[] = project?.foreshadows ?? []
498
1028
 
499
- /** Workflow timeline row: step dot + connector + label + optional action. */
500
- const workflowRow = (stepNo: number, done: boolean, label: string, hint: string, buttonLabel: string, onClick: () => void, disabled: boolean) => (
501
- <div className={css.workflowRow}>
502
- <span className={`${css.workflowDot} ${done ? css.workflowDotDone : css.workflowDotActive}`}>{done ? '✓' : stepNo}</span>
503
- <div className={css.workflowBody}>
504
- <span className={css.workflowLabel}>{label}</span>
505
- <span className={css.workflowHint}>{hint}</span>
506
- </div>
507
- {!done && (
508
- <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={disabled || busy} onClick={onClick}>
509
- {buttonLabel}
510
- </button>
511
- )}
512
- </div>
513
- )
1029
+ // --------------------------------------------------- dashboard (workflow)
1030
+ const approvedCount = chapters.filter(c => c.status === 'approved').length
1031
+ const reviewPendingCount = chapters.filter(c => c.status === 'written' || c.status === 'rejected').length
1032
+ const totalChars = chapters.reduce((sum, c) => sum + (c.chars ?? 0), 0)
1033
+ const firstChapter = chapters[0]
1034
+ const currentVolumeName = (() => {
1035
+ if (firstChapter === undefined || volumes === undefined || volumes.length === 0) return '—'
1036
+ const vol = volumes.find(v => v.no === firstChapter.volume)
1037
+ return vol !== undefined ? vol.title : `第 ${firstChapter.volume} 卷`
1038
+ })()
1039
+ const lastUpdated = project?.updatedAt !== undefined
1040
+ ? new Date(project.updatedAt).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
1041
+ : '—'
1042
+
1043
+ /** 创作旅程 6 阶段(完成/当前/未到)。 */
1044
+ const journeyStages: Array<{ id: string; label: string; done: boolean }> = [
1045
+ { id: 'outline', label: '大纲', done: project !== null },
1046
+ { id: 'bible', label: '设定', done: bible !== undefined },
1047
+ { id: 'volumes', label: '卷计划', done: volumes !== undefined },
1048
+ { id: 'plan', label: '章节计划', done: chapters.length > 0 },
1049
+ { id: 'write', label: '正文', done: chapters.some(c => (c.chars ?? 0) > 0) },
1050
+ { id: 'review', label: '审稿', done: approvedCount > 0 },
1051
+ ]
1052
+ const journeyDoneCount = journeyStages.filter(s => s.done).length
1053
+ const journeyPercent = Math.round((journeyDoneCount / journeyStages.length) * 100)
1054
+ const currentStageId = journeyStages.find(s => !s.done)?.id
1055
+
1056
+ /** 主行动卡片:推荐下一步(AI-Novel-Writing-Assistant 首页主卡模式)。 */
1057
+ const nextAction = useMemo((): { eyebrow: string; title: string; reason: string; actionLabel: string; onClick: () => void } | null => {
1058
+ if (project === null) {
1059
+ return {
1060
+ eyebrow: '开始你的第一本书',
1061
+ title: '导入小说大纲',
1062
+ reason: '从 docx 文件或粘贴文本开始,AI 会把一份大纲「编译」成完整的小说。',
1063
+ actionLabel: '导入大纲',
1064
+ onClick: () => { setActiveTab('overview') },
1065
+ }
1066
+ }
1067
+ if (bible === undefined) {
1068
+ return {
1069
+ eyebrow: '推荐下一步',
1070
+ title: '提炼设定圣经',
1071
+ reason: '人设、世界观、金手指规则、写作红线是后续所有生成的地基,越完整质量越高。',
1072
+ actionLabel: '生成设定圣经',
1073
+ onClick: () => { void handleBible() },
1074
+ }
1075
+ }
1076
+ if (volumes === undefined) {
1077
+ return {
1078
+ eyebrow: '推荐下一步',
1079
+ title: '规划全书卷结构',
1080
+ reason: '按剧情弧线划分卷,章节计划才有骨架可依。',
1081
+ actionLabel: '生成卷计划',
1082
+ onClick: () => { void handleVolumes() },
1083
+ }
1084
+ }
1085
+ if (chapters.length === 0) {
1086
+ return {
1087
+ eyebrow: '推荐下一步',
1088
+ title: '生成章节计划',
1089
+ reason: 'LLM 根据大纲拆解每章标题与剧情要点,然后就可以逐章生成正文。',
1090
+ actionLabel: '生成章节计划',
1091
+ onClick: () => { void handlePlan() },
1092
+ }
1093
+ }
1094
+ const drafting = chapters.find(c => c.pendingDraft !== undefined && c.pendingDraft !== '')
1095
+ if (drafting !== undefined) {
1096
+ return {
1097
+ eyebrow: '需要你确认',
1098
+ title: `第 ${drafting.no} 章有未采纳的润色草稿`,
1099
+ reason: '打开工作区查看对比,决定采纳新稿或保留原稿(原稿未被改动)。',
1100
+ actionLabel: '打开工作区',
1101
+ onClick: () => { void openWorkspace(drafting.no) },
1102
+ }
1103
+ }
1104
+ if (pendingCount > 0) {
1105
+ return {
1106
+ eyebrow: '继续创作',
1107
+ title: `还有 ${pendingCount} 章待生成`,
1108
+ reason: '批量生成剩余章节,顶部进度条会实时显示每章字数与进度。',
1109
+ actionLabel: `批量生成(${pendingCount})`,
1110
+ onClick: () => { void handleWriteAll() },
1111
+ }
1112
+ }
1113
+ if (reviewPendingCount > 0) {
1114
+ return {
1115
+ eyebrow: '推荐下一步',
1116
+ title: `${reviewPendingCount} 章待审稿`,
1117
+ reason: '审稿通过后章节才算完成;不通过的可按意见在工作区修订。',
1118
+ actionLabel: '去审稿',
1119
+ onClick: () => { setActiveTab('plan') },
1120
+ }
1121
+ }
1122
+ return {
1123
+ eyebrow: '全部完成 🎉',
1124
+ title: '《' + project.bookName + '》已全部生成',
1125
+ reason: '可以去 AI 味润色(对比后采纳)、按卷复查或导出全本(TXT/MD)。',
1126
+ actionLabel: '导出全本',
1127
+ onClick: () => { void handleExport('txt') },
1128
+ }
1129
+ }, [project, bible, volumes, chapters, pendingCount, reviewPendingCount, openWorkspace])
1130
+
1131
+ /** 待办队列(失败/草稿/待审稿,点击直达)。 */
1132
+ const todos = useMemo(() => {
1133
+ const items: Array<{ tone: 'danger' | 'warning' | 'info' | 'success'; title: string; description: string; actionLabel: string; onClick: () => void }> = []
1134
+ for (const chapter of chapters) {
1135
+ if (chapter.status === 'error') {
1136
+ items.push({
1137
+ tone: 'danger',
1138
+ title: `第 ${chapter.no} 章《${chapter.title}》生成失败`,
1139
+ description: chapter.error ?? '',
1140
+ actionLabel: '去处理',
1141
+ onClick: () => { setActiveTab('plan') },
1142
+ })
1143
+ }
1144
+ if (items.length >= 3) return items
1145
+ }
1146
+ const drafting = chapters.find(c => c.pendingDraft !== undefined && c.pendingDraft !== '')
1147
+ if (drafting !== undefined && items.length < 3) {
1148
+ items.push({
1149
+ tone: 'warning',
1150
+ title: `第 ${drafting.no} 章《${drafting.title}》有未采纳草稿`,
1151
+ description: '原稿未被改动,采纳或放弃由你决定',
1152
+ actionLabel: '打开工作区',
1153
+ onClick: () => { void openWorkspace(drafting.no) },
1154
+ })
1155
+ }
1156
+ for (const chapter of chapters) {
1157
+ if ((chapter.status === 'written' || chapter.status === 'rejected') && items.length < 3) {
1158
+ items.push({
1159
+ tone: 'info',
1160
+ title: `第 ${chapter.no} 章《${chapter.title}》待审稿`,
1161
+ description: chapter.status === 'rejected' ? '审稿未通过,可按意见修订' : '等待 AI 审稿确认',
1162
+ actionLabel: '去审稿',
1163
+ onClick: () => { setActiveTab('plan') },
1164
+ })
1165
+ }
1166
+ }
1167
+ return items
1168
+ }, [chapters, openWorkspace])
1169
+
1170
+ /** 资产健康(设定/卷/写作资产/伏笔)。 */
1171
+ const assetSummary = (() => {
1172
+ const assets = project?.assets
1173
+ const parts: string[] = []
1174
+ if (assets?.genre !== undefined) parts.push(`题材:${assets.genre.name}`)
1175
+ if (assets?.primaryProgression !== undefined) parts.push(`推进:${assets.primaryProgression.name}`)
1176
+ if ((assets?.styleAssets?.length ?? 0) > 0) parts.push(`写法:${assets!.styleAssets!.length} 套`)
1177
+ return parts.length > 0 ? parts.join(' · ') : '题材 / 推进 / 写法未绑定'
1178
+ })()
1179
+ const assetCount = (() => {
1180
+ const assets = project?.assets
1181
+ let n = 0
1182
+ if (assets?.genre !== undefined) n++
1183
+ if (assets?.primaryProgression !== undefined) n++
1184
+ n += assets?.auxiliaryProgressions?.length ?? 0
1185
+ n += assets?.styleAssets?.length ?? 0
1186
+ n += assets?.antiAiRules?.length ?? 0
1187
+ return n
1188
+ })()
514
1189
 
515
1190
  return (
516
1191
  <div className={css.panel}>
@@ -520,8 +1195,32 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
520
1195
  {project?.bookName !== '' && project?.bookName !== undefined && (
521
1196
  <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)', fontSize: 11 }}>{project.bookName}</span>
522
1197
  )}
1198
+ {chapters.length > 0 && (
1199
+ <span className={css.headerProgress}>
1200
+ 已完成 <b>{doneCount}</b>/{chapters.length} 章
1201
+ <span className={css.headerProgressDot} />
1202
+ 通过 <b>{chapters.filter(c => c.status === 'approved').length}</b>
1203
+ </span>
1204
+ )}
523
1205
  </h2>
524
- <button type="button" className={css.iconButton} title={tt('common.close')} aria-label={tt('common.close')} onClick={() => { controller.close() }}>×</button>
1206
+ <div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
1207
+ <button
1208
+ type="button"
1209
+ className={css.iconButton}
1210
+ title={navCollapsed ? '展开导航栏' : '收起导航栏'}
1211
+ aria-label={navCollapsed ? '展开导航栏' : '收起导航栏'}
1212
+ onClick={() => {
1213
+ setNavCollapsed(prev => {
1214
+ const next = !prev
1215
+ try { window.localStorage.setItem('dsh-novel-forge.nav.collapsed', String(next)) } catch { /* ignore */ }
1216
+ return next
1217
+ })
1218
+ }}
1219
+ >
1220
+ {navCollapsed ? '▸' : '◂'}
1221
+ </button>
1222
+ <button type="button" className={css.iconButton} title={tt('common.close')} aria-label={tt('common.close')} onClick={() => { controller.close() }}>×</button>
1223
+ </div>
525
1224
  </div>
526
1225
  {shelf !== null && (
527
1226
  <BookshelfBar
@@ -540,87 +1239,446 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
540
1239
  }}
541
1240
  />
542
1241
  )}
543
- <div className={css.tabBar} role="tablist">
544
- {TABS.map(tab => (
545
- <button key={tab.id} type="button" role="tab" aria-selected={activeTab === tab.id} data-active={activeTab === tab.id ? '' : undefined} className={css.tab} onClick={() => { setActiveTab(tab.id) }}>
546
- {tab.label}
1242
+ <div className={css.panelBody}>
1243
+ <nav className={`${css.panelNav} ${navCollapsed ? css.panelNavCollapsed : ''}`} role="tablist" aria-label="工作台导航">
1244
+ {/* 分组导航(创作 / 工具 / 数据库) */}
1245
+ {NAV_GROUPS.map(group => (
1246
+ <div key={group.id} className={css.navGroup}>
1247
+ {!navCollapsed && <div className={css.navGroupLabel}>{group.label}</div>}
1248
+ {group.items.map(tab => (
1249
+ <button
1250
+ key={tab.id}
1251
+ type="button"
1252
+ role="tab"
1253
+ aria-selected={activeTab === tab.id || (tab.id === 'assistant' && assistantOpen)}
1254
+ data-active={activeTab === tab.id || (tab.id === 'assistant' && assistantOpen) ? '' : undefined}
1255
+ className={css.navTab}
1256
+ title={tab.label}
1257
+ onClick={() => {
1258
+ if (tab.id === 'assistant') {
1259
+ setAssistantOpen(true)
1260
+ } else {
1261
+ setActiveTab(tab.id)
1262
+ }
1263
+ }}
1264
+ >
1265
+ <span className={css.navTabIcon}>{tab.icon}</span>
1266
+ {!navCollapsed && <span className={css.navTabLabel}>{tab.label}</span>}
1267
+ {/* 状态角标:章节待办 / 伏笔待埋 / 工作流进度 */}
1268
+ {tab.id === 'plan' && (pendingCount > 0 || reviewPendingCount > 0) && (
1269
+ <span className={`${css.navTabBadge} ${chapters.some(c => c.status === 'error') ? css.navTabBadgeDanger : css.navTabBadgeWarn}`}>
1270
+ {chapters.some(c => c.status === 'error') ? `!${pendingCount + reviewPendingCount}` : pendingCount + reviewPendingCount}
1271
+ </span>
1272
+ )}
1273
+ {tab.id === 'foreshadow' && foreshadows.some(f => f.status === 'planned') && (
1274
+ <span className={css.navTabBadge}>{foreshadows.filter(f => f.status === 'planned').length}</span>
1275
+ )}
1276
+ {tab.id === 'workflow' && chapters.length > 0 && (
1277
+ <span className={`${css.navTabBadge} ${css.navTabBadgeDone}`}>{journeyPercent}%</span>
1278
+ )}
1279
+ </button>
1280
+ ))}
1281
+ {!navCollapsed && <div className={css.navGroupSep} />}
1282
+ </div>
1283
+ ))}
1284
+ {/* 设置沉底 */}
1285
+ <div className={css.navSpacer} />
1286
+ <button
1287
+ type="button"
1288
+ role="tab"
1289
+ aria-selected={activeTab === SETTINGS_TAB.id}
1290
+ data-active={activeTab === SETTINGS_TAB.id ? '' : undefined}
1291
+ className={css.navTab}
1292
+ title={SETTINGS_TAB.label}
1293
+ onClick={() => { setActiveTab(SETTINGS_TAB.id) }}
1294
+ >
1295
+ <span className={css.navTabIcon}>{SETTINGS_TAB.icon}</span>
1296
+ {!navCollapsed && <span className={css.navTabLabel}>{SETTINGS_TAB.label}</span>}
547
1297
  </button>
548
- ))}
549
- </div>
550
- <div className={css.panelContent}>
1298
+ </nav>
1299
+ <div className={css.panelContent}>
551
1300
  {error !== '' && <div className={css.card} style={{ borderColor: 'var(--nf-error)' }}><span style={{ color: 'var(--nf-error)' }}>{tt('common.error')}: {error}</span></div>}
552
1301
  {notice !== '' && <div className={css.card}><span style={{ color: 'var(--nf-success)' }}>{notice}</span></div>}
553
- {busy && busyLabel !== '' && <div className={css.card}><span style={{ color: 'var(--nf-accent)' }}>{busyLabel}…</span></div>}
554
-
555
- {activeTab === 'workflow' && (
1302
+ {busy && busyLabel !== '' && (
556
1303
  <div className={css.card}>
557
- <span className={css.cardTitle}>{tt('workflow.title')}</span>
558
- <div className={css.meta}>{tt('workflow.progress', {
559
- bible: bible !== undefined ? '✓' : '—',
560
- volumes: volumes !== undefined ? '✓' : '—',
561
- plan: chapters.length > 0 ? '✓' : '—',
562
- done: doneCount,
563
- total: chapters.length,
564
- })}</div>
565
- <div className={css.workflowList}>
566
- {workflowRow(1, project !== null, tt('workflow.step1'), '从 docx 或粘贴文本导入全书大纲', tt('workflow.loadOutline'), () => { void handleLoadDocx(false) }, false)}
567
- {workflowRow(2, bible !== undefined, tt('workflow.step2'), '提炼人设 / 世界观 / 金手指规则 / 写作红线', tt('workflow.genBible'), () => { void handleBible() }, project === null)}
568
- {workflowRow(3, volumes !== undefined, tt('workflow.step3'), '按剧情弧线划分全书卷结构', tt('workflow.genVolumes'), () => { void handleVolumes() }, project === null)}
569
- {workflowRow(4, chapters.length > 0, tt('workflow.step4'), '每章标题 + 剧情要点 + 字数目标', tt('workflow.genPlan'), () => { void handlePlan() }, project === null)}
570
- {workflowRow(5, doneCount > 0, tt('workflow.step5'), '逐章生成,自动摘要 + AI 审稿', tt('plan.write'), () => { setActiveTab('plan') }, false)}
571
- {workflowRow(6, doneCount > 0, tt('workflow.step6'), '去 AI 味润色 / 导出全本', tt('settings.exportTxt'), () => { void handleExport('txt') }, false)}
1304
+ <div className={css.busyRow}>
1305
+ <span style={{ color: 'var(--nf-accent)' }}>{busyLabel}…</span>
1306
+ {liveBar !== null && <span className={css.liveText}>{liveBar.text}</span>}
1307
+ </div>
1308
+ {liveBar?.ratio !== undefined && (
1309
+ <div className={css.bigProgressBar}>
1310
+ <div className={css.bigProgressBarFill} style={{ width: `${Math.round(liveBar.ratio * 100)}%` }} />
1311
+ </div>
1312
+ )}
1313
+ </div>
1314
+ )}
1315
+
1316
+ {/* 遗留草稿提示:刷新页面后仍有未采纳的润色/修订草稿 */}
1317
+ {workspace === null && draftNo !== null && (
1318
+ <div className={css.card} style={{ borderColor: 'var(--nf-info)' }}>
1319
+ <div className={css.busyRow}>
1320
+ <span style={{ color: 'var(--nf-info)' }}>第 {draftNo} 章有未采纳的润色/修订草稿(原稿未被改动)</span>
1321
+ <span style={{ display: 'flex', gap: 8 }}>
1322
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void openWorkspace(draftNo) }}>
1323
+ 打开工作区
1324
+ </button>
1325
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleDraftDiscard(draftNo) }}>
1326
+ 放弃
1327
+ </button>
1328
+ </span>
572
1329
  </div>
573
1330
  </div>
574
1331
  )}
575
1332
 
1333
+ {/* 润色/修订工作区:左栏原文(可选中局部目标)+ 右栏指令/预览/应用 */}
1334
+ {workspace !== null && (
1335
+ <div className={css.card} style={{ borderColor: 'var(--nf-accent)' }}>
1336
+ <div className={css.busyRow}>
1337
+ <span className={css.cardTitle}>第 {workspace.no} 章《{workspace.title}》润色/修订</span>
1338
+ <button type="button" className={css.iconButton} title="关闭工作区" aria-label="关闭工作区" onClick={() => { setWorkspace(null) }}>×</button>
1339
+ </div>
1340
+ <div className={css.wsColumns}>
1341
+ <div className={css.wsColumn}>
1342
+ <div className={css.meta}>
1343
+ 原文({workspace.original.length} 字)— 在正文中选中文字可作为「修订选中」的局部目标
1344
+ </div>
1345
+ <textarea
1346
+ ref={wsEditorRef}
1347
+ className={`${css.textarea} ${css.wsEditor}`}
1348
+ value={workspace.original}
1349
+ onChange={e => { setWorkspace({ ...workspace, original: e.target.value }) }}
1350
+ onMouseUp={captureWsSelection}
1351
+ onKeyUp={captureWsSelection}
1352
+ onSelect={captureWsSelection}
1353
+ spellCheck={false}
1354
+ />
1355
+ </div>
1356
+ <div className={css.wsColumn}>
1357
+ <div className={css.meta} style={{ fontWeight: 600 }}>AI 修正指令</div>
1358
+ <textarea
1359
+ className={css.textarea}
1360
+ style={{ minHeight: 60 }}
1361
+ placeholder="输入修正要求,例如:压缩冗余、加强冲突、这段对话更口语化…(可留空)"
1362
+ value={workspace.instruction}
1363
+ onChange={e => { setWorkspace({ ...workspace, instruction: e.target.value }) }}
1364
+ />
1365
+ {wsSelected !== '' ? (
1366
+ <div className={css.wsSelected}>
1367
+ <div className={css.meta}>当前选中内容(将用于精准修订)</div>
1368
+ <div className={css.wsSelectedText}>{wsSelected}</div>
1369
+ </div>
1370
+ ) : (
1371
+ <div className={css.meta}>未选中内容时仅支持整章润色/修订。</div>
1372
+ )}
1373
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
1374
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleWsPolish() }}>
1375
+ ✨ 去AI味润色
1376
+ </button>
1377
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || workspace.instruction.trim() === ''} onClick={() => { void handleWsRewrite(true) }}>
1378
+ 整章修订
1379
+ </button>
1380
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || wsSelected === ''} onClick={() => { void handleWsRewrite(false) }}>
1381
+ 修订选中
1382
+ </button>
1383
+ </div>
1384
+ {workspace.draft !== null && (
1385
+ <div className={css.wsPreview}>
1386
+ <div className={css.busyRow}>
1387
+ <span className={css.meta}>优化预览({workspace.draft.length} 字)</span>
1388
+ <span style={{ display: 'flex', gap: 8 }}>
1389
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setWsShowDiff(v => !v) }}>
1390
+ {wsShowDiff ? '显示文本' : '查看对比'}
1391
+ </button>
1392
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleDraftApply(workspace.no) }}>
1393
+ ✅ 应用预览
1394
+ </button>
1395
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleDraftDiscard(workspace.no) }}>
1396
+ ↩️ 放弃
1397
+ </button>
1398
+ </span>
1399
+ </div>
1400
+ {wsShowDiff
1401
+ ? <DiffList original={workspace.original} draft={workspace.draft} />
1402
+ : <pre className={css.wsPreviewText}>{workspace.draft}</pre>}
1403
+ </div>
1404
+ )}
1405
+ </div>
1406
+ </div>
1407
+ </div>
1408
+ )}
1409
+
1410
+ {activeTab === 'workflow' && (
1411
+ <>
1412
+ {/* ⭐ 主行动大卡片 */}
1413
+ <div className={css.dashHero}>
1414
+ <div className={css.dashHeroEyebrow}>
1415
+ <span className={css.dashHeroSparkle}>✨</span>
1416
+ {nextAction?.eyebrow ?? '开始'}
1417
+ </div>
1418
+ {project !== null && (
1419
+ <div className={css.dashHeroTitle}>
1420
+ <span className={css.meta}>正在创作</span>
1421
+ <h3 className={css.dashHeroBook}>《{project.bookName}》</h3>
1422
+ </div>
1423
+ )}
1424
+ {nextAction !== null && (
1425
+ <div className={css.dashHeroAction}>
1426
+ <span className={css.dashHeroArrow}>→</span>
1427
+ <div className={css.dashHeroActionBody}>
1428
+ <div className={css.dashHeroActionTitle}>{nextAction.title}</div>
1429
+ <div className={css.meta}>{nextAction.reason}</div>
1430
+ </div>
1431
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { nextAction.onClick() }}>
1432
+ {nextAction.actionLabel}
1433
+ </button>
1434
+ </div>
1435
+ )}
1436
+ {/* 创作旅程进度 */}
1437
+ <div className={css.dashJourney}>
1438
+ <div className={css.busyRow}>
1439
+ <span className={css.meta} style={{ fontWeight: 600 }}>创作旅程</span>
1440
+ <span className={css.meta}>{journeyPercent}% · 已完成 {journeyDoneCount}/{journeyStages.length} 步</span>
1441
+ </div>
1442
+ <div className={css.dashJourneyBar}>
1443
+ <div className={css.dashJourneyFill} style={{ width: `${journeyPercent}%` }} />
1444
+ </div>
1445
+ <div className={css.dashJourneyStages}>
1446
+ {journeyStages.map(stage => (
1447
+ <span
1448
+ key={stage.id}
1449
+ className={`${css.dashStage} ${stage.done ? css.dashStageDone : stage.id === currentStageId ? css.dashStageCurrent : ''}`}
1450
+ >
1451
+ <span className={css.dashStageDot}>{stage.done ? '✓' : stage.id === currentStageId ? '●' : '○'}</span>
1452
+ {stage.label}
1453
+ </span>
1454
+ ))}
1455
+ </div>
1456
+ </div>
1457
+ {/* HeroFact 4 格 */}
1458
+ <div className={css.dashFacts}>
1459
+ <div className={css.dashFact}><span className={css.meta}>已沉淀章节</span><b>{doneCount} 章</b></div>
1460
+ <div className={css.dashFact}><span className={css.meta}>累计字数</span><b>{totalChars} 字</b></div>
1461
+ <div className={css.dashFact}><span className={css.meta}>当前卷</span><b>{currentVolumeName}</b></div>
1462
+ <div className={css.dashFact}><span className={css.meta}>最近创作</span><b>{lastUpdated}</b></div>
1463
+ </div>
1464
+ </div>
1465
+
1466
+ {/* 状态摘要条 */}
1467
+ <div className={css.assetGrid}>
1468
+ <div className={css.assetStat}>
1469
+ <span className={css.assetStatLabel}>已通过审稿</span>
1470
+ <span className={css.assetStatValue} style={{ color: approvedCount > 0 ? 'var(--nf-success)' : undefined }}>{approvedCount}</span>
1471
+ <span className={css.assetStatDetail}>章节已 approved</span>
1472
+ </div>
1473
+ <div className={css.assetStat}>
1474
+ <span className={css.assetStatLabel}>待生成</span>
1475
+ <span className={css.assetStatValue} style={{ color: pendingCount > 0 ? 'var(--nf-warn)' : undefined }}>{pendingCount}</span>
1476
+ <span className={css.assetStatDetail}>pending + error</span>
1477
+ </div>
1478
+ <div className={css.assetStat}>
1479
+ <span className={css.assetStatLabel}>待审稿</span>
1480
+ <span className={css.assetStatValue} style={{ color: reviewPendingCount > 0 ? 'var(--nf-info)' : undefined }}>{reviewPendingCount}</span>
1481
+ <span className={css.assetStatDetail}>written + rejected</span>
1482
+ </div>
1483
+ <div className={css.assetStat}>
1484
+ <span className={css.assetStatLabel}>总字数</span>
1485
+ <span className={css.assetStatValue}>{totalChars}</span>
1486
+ <span className={css.assetStatDetail}>已生成正文累计</span>
1487
+ </div>
1488
+ </div>
1489
+
1490
+ {/* 双栏:左待办队列 + 右资产健康 */}
1491
+ <div className={css.dashGrid}>
1492
+ <div className={css.card}>
1493
+ <span className={css.cardTitle}>待办队列</span>
1494
+ {todos.length === 0 ? (
1495
+ <span className={css.meta}>🎉 暂无待办,一切顺畅</span>
1496
+ ) : (
1497
+ todos.map((todo, i) => (
1498
+ <div key={i} className={`${css.todoItem} ${todo.tone === 'danger' ? css.todoDanger : todo.tone === 'warning' ? css.todoWarning : css.todoInfo}`}>
1499
+ <span className={css.todoText}>
1500
+ {todo.title}
1501
+ {todo.description !== '' && <span className={css.meta}> — {todo.description}</span>}
1502
+ </span>
1503
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { todo.onClick() }}>
1504
+ {todo.actionLabel}
1505
+ </button>
1506
+ </div>
1507
+ ))
1508
+ )}
1509
+ </div>
1510
+ <div className={css.card}>
1511
+ <div className={css.row} style={{ justifyContent: 'space-between' }}>
1512
+ <span className={css.cardTitle}>资产健康</span>
1513
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || doneCount === 0} onClick={() => { void handleAudit() }} title="LLM 扫描全本已生成章节,检查人名/境界/资源/时间线矛盾">
1514
+ 🔍 全书质检
1515
+ </button>
1516
+ </div>
1517
+ <div className={css.assetGrid}>
1518
+ <div className={css.assetStat}>
1519
+ <span className={css.assetStatLabel}>设定圣经</span>
1520
+ <span className={css.assetStatValue} style={{ color: bible !== undefined ? 'var(--nf-success)' : 'var(--nf-text-3)' }}>
1521
+ {bible !== undefined ? `✓ ${bible.worldRules.length} 条规则` : '未生成'}
1522
+ </span>
1523
+ <span className={css.assetStatDetail}>
1524
+ {bible !== undefined ? `${bible.characters.length} 角色 · ${bible.redLines.length} 红线` : '提炼人设 / 世界观 / 金手指'}
1525
+ </span>
1526
+ </div>
1527
+ <div className={css.assetStat}>
1528
+ <span className={css.assetStatLabel}>卷计划</span>
1529
+ <span className={css.assetStatValue} style={{ color: volumes !== undefined ? 'var(--nf-success)' : 'var(--nf-text-3)' }}>
1530
+ {volumes !== undefined ? `${volumes.length} 卷` : '未生成'}
1531
+ </span>
1532
+ <span className={css.assetStatDetail} title={volumes?.map(v => v.title).join(' / ')}>
1533
+ {volumes !== undefined ? volumes.map(v => v.title).join(' / ') : '按剧情弧线划分全书'}
1534
+ </span>
1535
+ </div>
1536
+ <div className={css.assetStat}>
1537
+ <span className={css.assetStatLabel}>写作资产</span>
1538
+ <span className={css.assetStatValue} style={{ color: assetCount > 0 ? 'var(--nf-success)' : 'var(--nf-text-3)' }}>
1539
+ {assetCount} 项
1540
+ </span>
1541
+ <span className={css.assetStatDetail} title={assetSummary}>{assetSummary}</span>
1542
+ </div>
1543
+ <div className={css.assetStat}>
1544
+ <span className={css.assetStatLabel}>伏笔</span>
1545
+ <span className={css.assetStatValue}>{foreshadows.length} 条</span>
1546
+ <span className={css.assetStatDetail}>
1547
+ {foreshadows.filter(f => f.status === 'planned').length} 待埋 · {foreshadows.filter(f => f.status === 'resolved').length} 已回收
1548
+ </span>
1549
+ </div>
1550
+ </div>
1551
+ </div>
1552
+ </div>
1553
+
1554
+ {/* 全书质检结果 */}
1555
+ {auditIssues !== null && (
1556
+ <div className={css.card} style={{ borderColor: auditIssues.length > 0 ? 'var(--nf-error)' : 'var(--nf-success)' }}>
1557
+ <div className={css.row} style={{ justifyContent: 'space-between' }}>
1558
+ <span className={css.cardTitle}>
1559
+ 🔍 全书质检{auditIssues.length === 0 ? ':未发现矛盾 🎉' : `:${auditIssues.length} 处疑似矛盾`}
1560
+ </span>
1561
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setAuditIssues(null) }}>
1562
+ 收起
1563
+ </button>
1564
+ </div>
1565
+ {auditIssues.length > 0 && (
1566
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
1567
+ {auditIssues.map((issue, i) => (
1568
+ <div key={i} className={`${css.todoItem} ${issue.severity === 'high' ? css.todoDanger : issue.severity === 'medium' ? css.todoWarning : css.todoInfo}`}>
1569
+ <span className={css.todoText}>
1570
+ <span>
1571
+ {issue.chapterNo > 0 ? `第 ${issue.chapterNo} 章` : '未定位章节'} · [{issue.severity}] {issue.item}
1572
+ </span>
1573
+ {issue.suggestion !== '' && <span className={css.meta}>建议:{issue.suggestion}</span>}
1574
+ </span>
1575
+ {issue.chapterNo > 0 && (
1576
+ <button
1577
+ type="button"
1578
+ className={`${css.button} ${css.buttonSmall}`}
1579
+ disabled={busy}
1580
+ onClick={() => { void openWorkspace(issue.chapterNo, `按质检意见修订:${issue.item}(建议:${issue.suggestion})`) }}
1581
+ >
1582
+ 去修订
1583
+ </button>
1584
+ )}
1585
+ </div>
1586
+ ))}
1587
+ </div>
1588
+ )}
1589
+ </div>
1590
+ )}
1591
+ </>
1592
+ )}
1593
+
576
1594
  {activeTab === 'overview' && (
577
1595
  <>
578
1596
  <div className={css.card}>
579
1597
  <div className={css.row} style={{ justifyContent: 'space-between' }}>
580
1598
  <span className={css.cardTitle}>{tt('tab.overview')}</span>
581
- {project !== null && <span className={css.meta}>{tt('overview.bookName')}: {project.bookName}</span>}
582
- </div>
583
- {/* 拖拽 / 文件选择导入 docx */}
584
- <div
585
- className={`${css.dropzone} ${dragActive ? css.dropzoneActive : ''}`}
586
- onClick={() => { fileInputRef.current?.click() }}
587
- onDragOver={e => { e.preventDefault(); setDragActive(true) }}
588
- onDragLeave={() => { setDragActive(false) }}
589
- onDrop={e => {
590
- e.preventDefault()
591
- setDragActive(false)
592
- const file = e.dataTransfer.files?.[0]
593
- if (file !== undefined) void handleDocxFile(file)
594
- }}
595
- >
596
- <span className={css.dropzoneIcon}>📄</span>
597
- <span>点击选择本机 docx 大纲,或将文件拖到这里</span>
598
- <span className={css.meta}>也支持粘贴文本到下方编辑区</span>
599
- <input
600
- ref={fileInputRef}
601
- type="file"
602
- accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
603
- style={{ display: 'none' }}
604
- onChange={e => {
605
- const file = e.target.files?.[0]
606
- if (file !== undefined) void handleDocxFile(file)
607
- e.target.value = ''
608
- }}
609
- />
610
- </div>
611
- <div className={css.row} style={{ justifyContent: 'space-between' }}>
612
- <span className={css.meta}>{tt('overview.outlineChars')}: {outlineText.length}</span>
613
- <button type="button" className={css.button} disabled={busy || outlineText.length < 50} onClick={() => { void handleSaveOutline() }}>
614
- {tt('overview.saveOutline')}
615
- </button>
1599
+ {project !== null && (
1600
+ <div className={css.row}>
1601
+ <span className={css.meta}>
1602
+ {tt('overview.bookName')}: {project.bookName} · {project.outline.length} 字
1603
+ {project.outlinePath !== undefined && <span> · {project.outlinePath}</span>}
1604
+ </span>
1605
+ <button type="button" className={css.button} disabled={busy} onClick={() => { handleToggleUpdateOutline() }}>
1606
+ {updatingOutline ? '收起' : '更新大纲'}
1607
+ </button>
1608
+ </div>
1609
+ )}
616
1610
  </div>
617
- <textarea
618
- className={css.textarea}
619
- value={outlineText}
620
- placeholder={tt('overview.outlineHint')}
621
- onChange={e => { setOutlineText(e.target.value) }}
622
- spellCheck={false}
623
- />
1611
+ {project === null ? (
1612
+ <>
1613
+ {/* 未开书:导入大纲(开书动作) */}
1614
+ <div
1615
+ className={`${css.dropzone} ${dragActive ? css.dropzoneActive : ''}`}
1616
+ onClick={() => { fileInputRef.current?.click() }}
1617
+ onDragOver={e => { e.preventDefault(); setDragActive(true) }}
1618
+ onDragLeave={() => { setDragActive(false) }}
1619
+ onDrop={e => {
1620
+ e.preventDefault()
1621
+ setDragActive(false)
1622
+ const file = e.dataTransfer.files?.[0]
1623
+ if (file !== undefined) void handleDocxFile(file)
1624
+ }}
1625
+ >
1626
+ <span className={css.dropzoneIcon}>📄</span>
1627
+ <span>点击选择本机 docx 大纲,或将文件拖到这里</span>
1628
+ <span className={css.meta}>也支持粘贴文本到下方编辑区</span>
1629
+ <input
1630
+ ref={fileInputRef}
1631
+ type="file"
1632
+ accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
1633
+ style={{ display: 'none' }}
1634
+ onChange={e => {
1635
+ const file = e.target.files?.[0]
1636
+ if (file !== undefined) void handleDocxFile(file)
1637
+ e.target.value = ''
1638
+ }}
1639
+ />
1640
+ </div>
1641
+ <div className={css.row} style={{ justifyContent: 'space-between' }}>
1642
+ <span className={css.meta}>{tt('overview.outlineChars')}: {outlineText.length}</span>
1643
+ <button type="button" className={css.button} disabled={busy || outlineText.length < 50} onClick={() => { void handleSaveOutline() }}>
1644
+ {tt('overview.saveOutline')}
1645
+ </button>
1646
+ </div>
1647
+ <textarea
1648
+ className={css.textarea}
1649
+ value={outlineText}
1650
+ placeholder={tt('overview.outlineHint')}
1651
+ onChange={e => { setOutlineText(e.target.value) }}
1652
+ spellCheck={false}
1653
+ />
1654
+ </>
1655
+ ) : updatingOutline ? (
1656
+ <>
1657
+ <div className={css.meta}>
1658
+ 大纲是本书的「出生证明」。更新时请二选一:<b>仅更新文本</b>(保留设定/章节/正文全部进度),或
1659
+ <b>重置项目</b>(从新大纲重新开始,清空设定/卷/章节/正文/伏笔/资产/事实库,不可恢复)。
1660
+ </div>
1661
+ <textarea
1662
+ className={css.textarea}
1663
+ value={outlineText}
1664
+ placeholder="粘贴新版大纲文本…"
1665
+ onChange={e => { setOutlineText(e.target.value) }}
1666
+ spellCheck={false}
1667
+ />
1668
+ <div className={css.row}>
1669
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || outlineText.length < 50} onClick={() => { void handleSaveOutline() }}>
1670
+ 仅更新文本(保留进度)
1671
+ </button>
1672
+ <button type="button" className={`${css.button} ${css.buttonDanger}`} disabled={busy || outlineText.length < 50} onClick={() => { void handleResetProject() }}>
1673
+ 重置项目并更新(清空进度)
1674
+ </button>
1675
+ <span className={css.meta}>{outlineText.length} 字</span>
1676
+ </div>
1677
+ </>
1678
+ ) : (
1679
+ /* 只读展示:大纲是开书时的出生证明 */
1680
+ <pre className={css.outlineReadonly}>{project.outline}</pre>
1681
+ )}
624
1682
  </div>
625
1683
  <div className={css.card}>
626
1684
  <span className={css.cardTitle}>{tt('status.files')}({generatedFiles.length})</span>
@@ -697,7 +1755,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
697
1755
  {!expanded && <div className={css.chapterBeats} title={chapter.beats}>{chapter.beats}</div>}
698
1756
  {expanded && (
699
1757
  <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
700
- <div className={css.meta}><b>{tt('plan.beats')}:</b> {chapter.beats}</div>
1758
+ <div className={css.meta}><b>{tt('plan.beats')}:</b></div>
1759
+ <div className={css.meta}>{renderBeats(chapter.beats)}</div>
701
1760
  {chapter.summary !== undefined && chapter.summary !== '' && (
702
1761
  <div className={css.meta}><b>{tt('plan.summary')}:</b> {chapter.summary}</div>
703
1762
  )}
@@ -724,27 +1783,12 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
724
1783
  )}
725
1784
  {(chapter.status === 'rejected' || chapter.status === 'written' || chapter.status === 'approved') && (
726
1785
  <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
727
- <div className={css.meta} style={{ fontWeight: 600 }}>修订(可整章或局部)</div>
728
- <div className={css.field}>
729
- <label className={css.fieldLabel}>要修改的原文片段(从上面正文复制一段;留空 = 整章修订)</label>
730
- <textarea
731
- className={css.textarea}
732
- style={{ minHeight: 56 }}
733
- placeholder="例如:林越咬紧牙关:…(复制正文中的原句)"
734
- value={localTarget}
735
- onChange={e => { setLocalTarget(e.target.value) }}
736
- />
1786
+ <div className={css.meta} style={{ fontWeight: 600 }}>
1787
+ 润色 / 修订 — 在右上角打开工作区:左栏原文可直接选中文字做局部修订,右栏输入指令后预览,确认后再应用(未应用不改动原稿)
737
1788
  </div>
738
1789
  <div className={css.row}>
739
- <input
740
- className={css.input}
741
- style={{ flex: 1 }}
742
- placeholder="修订指令(如:这段对话太生硬,改得更口语化)"
743
- value={rewriteInstruction}
744
- onChange={e => { setRewriteInstruction(e.target.value) }}
745
- />
746
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || busyAny} onClick={() => { void handleRewrite(chapter.no) }}>
747
- {tt('plan.rewrite')}
1790
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || busyAny} onClick={() => { void openWorkspace(chapter.no) }}>
1791
+ {tt('plan.rewrite')} / {tt('plan.polish')}
748
1792
  </button>
749
1793
  </div>
750
1794
  </div>
@@ -775,7 +1819,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
775
1819
  </button>
776
1820
  )}
777
1821
  {(chapter.status === 'written' || chapter.status === 'rejected' || chapter.status === 'approved') && (
778
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || busyAny} onClick={() => { void handlePolish(chapter.no) }}>
1822
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || busyAny} onClick={() => { void openWorkspace(chapter.no) }}>
779
1823
  {tt('plan.polish')}
780
1824
  </button>
781
1825
  )}
@@ -797,7 +1841,12 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
797
1841
  <div className={css.progress}>
798
1842
  {progress.length === 0 && <span className={css.meta}>{tt('progress.empty')}</span>}
799
1843
  {progress.map(line => (
800
- <div key={line.id} className={line.kind === 'done' ? css.progressLineDone : line.kind === 'error' ? css.progressLineError : css.progressLine}>
1844
+ <div key={line.id} className={line.kind === 'done' ? css.progressLineDone : line.kind === 'error' ? css.progressLineError : line.live === true ? css.progressLineLive : css.progressLine}>
1845
+ {line.live === true && (
1846
+ <span className={css.progressBar}>
1847
+ <span className={css.progressBarFill} style={{ width: `${Math.round((line.ratio ?? 0) * 100)}%` }} />
1848
+ </span>
1849
+ )}
801
1850
  {line.text}
802
1851
  </div>
803
1852
  ))}
@@ -807,6 +1856,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
807
1856
  )}
808
1857
 
809
1858
  {activeTab === 'bible' && (
1859
+ <>
810
1860
  <div className={css.card}>
811
1861
  <div className={css.row} style={{ justifyContent: 'space-between' }}>
812
1862
  <span className={css.cardTitle}>{tt('bible.title')}</span>
@@ -821,10 +1871,37 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
821
1871
  {bible.genre !== '' && (
822
1872
  <div><b>{tt('bible.genre')}:</b> <span className={css.meta}>{bible.genre}</span></div>
823
1873
  )}
1874
+ {/* 世界观规则独立卡片(可编辑,每行一条) */}
824
1875
  {bible.worldRules.length > 0 && (
825
- <div>
826
- <b>{tt('bible.worldRules')}({bible.worldRules.length})</b>
827
- <ul style={{ margin: 0, paddingLeft: 18, fontSize: 12 }}>{bible.worldRules.map((r, i) => <li key={i}>{r}</li>)}</ul>
1876
+ <div style={{ border: '1px solid var(--nf-border)', borderRadius: 10, padding: '8px 10px' }}>
1877
+ <div className={css.row} style={{ justifyContent: 'space-between' }}>
1878
+ <b>{tt('bible.worldRules')}({bible.worldRules.length})</b>
1879
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setWorldRulesDraft(bible.worldRules.join('\n')) }}>
1880
+ 编辑
1881
+ </button>
1882
+ </div>
1883
+ {worldRulesDraft !== '' && (
1884
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 6 }}>
1885
+ <textarea
1886
+ className={css.textarea}
1887
+ style={{ minHeight: 120 }}
1888
+ value={worldRulesDraft}
1889
+ onChange={e => { setWorldRulesDraft(e.target.value) }}
1890
+ placeholder="每条规则一行…"
1891
+ />
1892
+ <div className={css.row}>
1893
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSaveWorldRules() }}>
1894
+ 保存规则
1895
+ </button>
1896
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setWorldRulesDraft('') }}>
1897
+ 取消
1898
+ </button>
1899
+ </div>
1900
+ </div>
1901
+ )}
1902
+ {worldRulesDraft === '' && (
1903
+ <ul style={{ margin: '4px 0 0', paddingLeft: 18, fontSize: 12 }}>{bible.worldRules.map((r, i) => <li key={i}>{r}</li>)}</ul>
1904
+ )}
828
1905
  </div>
829
1906
  )}
830
1907
  {bible.characters.length > 0 && (
@@ -854,12 +1931,58 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
854
1931
  </div>
855
1932
  )}
856
1933
  </div>
1934
+ </>
857
1935
  )}
858
1936
 
859
- {activeTab === 'assets' && (
860
- <AssetsTab api={api} />
1937
+ {/* 角色卡:从事实库聚合的当前状态(独立导航页) */}
1938
+ {activeTab === 'characters' && (
1939
+ <div className={css.card}>
1940
+ <div className={css.row} style={{ justifyContent: 'space-between' }}>
1941
+ <span className={css.cardTitle}>角色卡(当前状态)</span>
1942
+ <div className={css.row}>
1943
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || doneCount === 0} onClick={() => { void handleFactsBackfill() }} title="历史章节(事实库功能上线前生成的)批量抽取事实">
1944
+ 📥 回填事实库
1945
+ </button>
1946
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy || (project?.facts ?? []).length === 0} onClick={() => { void handleCharactersRefresh() }}>
1947
+ ↻ 从事实库刷新
1948
+ </button>
1949
+ </div>
1950
+ </div>
1951
+ {charCards === null ? (
1952
+ <span className={css.meta}>
1953
+ {(project?.facts ?? []).length === 0
1954
+ ? '暂无事实库(生成章节后自动积累人物状态/境界/资源/关系等事实),刷新按钮将在有事实后可用。'
1955
+ : '点击「从事实库刷新」聚合各角色当前状态(境界/资源/伤势/心境)。'}
1956
+ </span>
1957
+ ) : charCards.length === 0 ? (
1958
+ <span className={css.meta}>未识别到角色信息。</span>
1959
+ ) : (
1960
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
1961
+ {charCards.map(card => (
1962
+ <div key={card.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 10px', fontSize: 12 }}>
1963
+ <div className={css.row} style={{ justifyContent: 'space-between' }}>
1964
+ <b>{card.name}</b>
1965
+ <span className={css.meta}>
1966
+ {card.role === 'protagonist' ? '主角' : card.role === 'supporting' ? '配角' : card.role === 'antagonist' ? '反派' : '其他'}
1967
+ {' · 出场 '}{card.appearances} 次 · 最近 第 {card.lastChapter} 章
1968
+ </span>
1969
+ </div>
1970
+ {card.status !== '' && <div className={css.meta}>状态:{card.status}</div>}
1971
+ </div>
1972
+ ))}
1973
+ </div>
1974
+ )}
1975
+ <span className={css.meta}>角色卡由「设定圣经」角色名单 + 「事实库」聚合而来,随章节生成自动保持最新。</span>
1976
+ </div>
861
1977
  )}
862
1978
 
1979
+ {/* 写作资产 5 个子分类(左侧导航直达) */}
1980
+ {activeTab === 'assetsGenre' && <AssetsTab api={api} initialTab="genre" />}
1981
+ {activeTab === 'assetsProgression' && <AssetsTab api={api} initialTab="progression" />}
1982
+ {activeTab === 'assetsTemplates' && <AssetsTab api={api} initialTab="templates" />}
1983
+ {activeTab === 'assetsRules' && <AssetsTab api={api} initialTab="rules" />}
1984
+ {activeTab === 'assetsStyle' && <AssetsTab api={api} initialTab="style" />}
1985
+
863
1986
  {activeTab === 'foreshadow' && (
864
1987
  <div className={css.card}>
865
1988
  <div className={css.row} style={{ justifyContent: 'space-between' }}>
@@ -912,10 +2035,6 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
912
2035
  </div>
913
2036
  )}
914
2037
 
915
- {activeTab === 'assistant' && (
916
- <AssistantTab api={api} />
917
- )}
918
-
919
2038
  {activeTab === 'settings' && configDraft !== null && (
920
2039
  <div className={css.card}>
921
2040
  <span className={css.cardTitle}>{tt('settings.title')}</span>
@@ -982,9 +2101,59 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
982
2101
  {tt('settings.exportMd')}
983
2102
  </button>
984
2103
  </div>
2104
+ {/* 事实库 / 时间线 */}
2105
+ {(project?.facts ?? []).length > 0 && (
2106
+ <div className={css.card}>
2107
+ <span className={css.cardTitle}>事实库 / 时间线({(project?.facts ?? []).length} 条)</span>
2108
+ <span className={css.meta}>
2109
+ 每章生成后自动抽取「已确立事实」(人物状态/境界资源/关系变化/伏笔落地),最近 20 条注入后续章节生成,保证长期一致。
2110
+ </span>
2111
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 300, overflowY: 'auto', fontSize: 12 }}>
2112
+ {[...(project?.facts ?? [])].reverse().slice(0, 60).map((fact, i) => (
2113
+ <div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
2114
+ <span className={css.badge} style={{ borderColor: 'var(--nf-text-3)', color: 'var(--nf-text-3)', flex: 'none', marginTop: 1 }}>
2115
+ 第 {fact.chapterNo} 章
2116
+ </span>
2117
+ <span className={css.meta}>{fact.text}</span>
2118
+ </div>
2119
+ ))}
2120
+ </div>
2121
+ </div>
2122
+ )}
985
2123
  </div>
986
2124
  )}
2125
+ </div>
987
2126
  </div>
2127
+
2128
+ {/* AI 助手悬浮窗:可拖动、可拉大小,不占用工作台 */}
2129
+ {assistantOpen && (
2130
+ <div
2131
+ className={css.assistantFloat}
2132
+ style={{ left: assistantPos.x, top: assistantPos.y, width: assistantSize.w, height: assistantSize.h }}
2133
+ >
2134
+ <div
2135
+ className={css.assistantFloatHeader}
2136
+ onMouseDown={e => {
2137
+ e.preventDefault()
2138
+ dragState.current = { type: 'move', startX: e.clientX, startY: e.clientY, origX: assistantPos.x, origY: assistantPos.y, origW: assistantSize.w, origH: assistantSize.h }
2139
+ }}
2140
+ >
2141
+ <span>💬 AI 助手 <span className={css.meta}>(拖动标题栏移动 · 右下角拉大小)</span></span>
2142
+ <button type="button" className={css.iconButton} title="关闭" aria-label="关闭 AI 助手" onClick={() => { setAssistantOpen(false) }}>×</button>
2143
+ </div>
2144
+ <div className={css.assistantFloatBody}>
2145
+ <AssistantTab api={api} />
2146
+ </div>
2147
+ <div
2148
+ className={css.assistantResize}
2149
+ onMouseDown={e => {
2150
+ e.preventDefault()
2151
+ e.stopPropagation()
2152
+ dragState.current = { type: 'resize', startX: e.clientX, startY: e.clientY, origX: assistantPos.x, origY: assistantPos.y, origW: assistantSize.w, origH: assistantSize.h }
2153
+ }}
2154
+ />
2155
+ </div>
2156
+ )}
988
2157
  </div>
989
2158
  )
990
2159
  }