@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.
- package/README.md +51 -25
- package/cordis.patch.yml +1 -1
- package/lib/client.js +3456 -1316
- package/lib/client.js.map +1 -1
- package/lib/index.js +436 -37
- package/lib/index.js.map +1 -1
- package/lib/types/bookshelf.d.ts +2 -0
- package/lib/types/client/api.d.ts +32 -2
- package/lib/types/client/panel/AssetsTab.d.ts +6 -1
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/engine.d.ts +25 -6
- package/lib/types/protocol.d.ts +82 -0
- package/package.json +1 -13
- package/src/assistant.ts +24 -3
- package/src/bookshelf.ts +11 -0
- package/src/client/api.ts +38 -3
- package/src/client/panel/AssetsTab.tsx +132 -17
- package/src/client/panel/BookshelfBar.tsx +62 -8
- package/src/client/panel/NovelPanel.tsx +1341 -172
- package/src/client/panel/panel.module.css +863 -59
- package/src/engine.ts +235 -21
- package/src/protocol.ts +84 -0
- package/src/routes.ts +225 -8
|
@@ -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 =
|
|
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
|
|
47
|
-
const
|
|
48
|
-
{
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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} 章失败:${(
|
|
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
|
-
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
<
|
|
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.
|
|
544
|
-
{
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
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 !== '' &&
|
|
554
|
-
|
|
555
|
-
{activeTab === 'workflow' && (
|
|
1302
|
+
{busy && busyLabel !== '' && (
|
|
556
1303
|
<div className={css.card}>
|
|
557
|
-
<
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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 &&
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
|
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 }}
|
|
728
|
-
|
|
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
|
-
<
|
|
740
|
-
|
|
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
|
|
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
|
-
<
|
|
827
|
-
|
|
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
|
-
{
|
|
860
|
-
|
|
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
|
}
|