@waterwx/dsh-novel-forge 0.1.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/LICENSE +202 -0
- package/README.md +180 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.js +3924 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +3120 -0
- package/lib/index.js.map +1 -0
- package/lib/types/assets.d.ts +35 -0
- package/lib/types/assistant.d.ts +43 -0
- package/lib/types/bookshelf.d.ts +35 -0
- package/lib/types/client/api.d.ts +68 -0
- package/lib/types/client/docx.d.ts +15 -0
- package/lib/types/client/index.d.ts +14 -0
- package/lib/types/client/locales.d.ts +139 -0
- package/lib/types/client/mount.d.ts +9 -0
- package/lib/types/client/panel/AssetsTab.d.ts +7 -0
- package/lib/types/client/panel/AssistantTab.d.ts +7 -0
- package/lib/types/client/panel/BookshelfBar.d.ts +11 -0
- package/lib/types/client/panel/NovelPanel.d.ts +13 -0
- package/lib/types/client/panel/controller.d.ts +19 -0
- package/lib/types/client/panel/helpers.d.ts +8 -0
- package/lib/types/client/sidebar-entry.d.ts +13 -0
- package/lib/types/docx.d.ts +19 -0
- package/lib/types/engine.d.ts +95 -0
- package/lib/types/index.d.ts +55 -0
- package/lib/types/protocol.d.ts +521 -0
- package/lib/types/routes.d.ts +29 -0
- package/package.json +105 -0
- package/src/assets.ts +518 -0
- package/src/assistant.ts +547 -0
- package/src/bookshelf.ts +137 -0
- package/src/client/api.ts +254 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/docx.ts +69 -0
- package/src/client/index.ts +34 -0
- package/src/client/locales.ts +271 -0
- package/src/client/mount.tsx +97 -0
- package/src/client/panel/AssetsTab.tsx +341 -0
- package/src/client/panel/AssistantTab.tsx +188 -0
- package/src/client/panel/BookshelfBar.tsx +116 -0
- package/src/client/panel/NovelPanel.tsx +990 -0
- package/src/client/panel/controller.ts +45 -0
- package/src/client/panel/helpers.ts +17 -0
- package/src/client/panel/panel.module.css +894 -0
- package/src/client/sidebar-entry.ts +122 -0
- package/src/docx.ts +83 -0
- package/src/engine.ts +1019 -0
- package/src/index.ts +184 -0
- package/src/protocol.ts +539 -0
- package/src/routes.ts +955 -0
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The novel-forge workbench panel: tabs — 工作流 (guided pipeline), 大纲
|
|
3
|
+
* (outline), 章节 (chapter plan + per-chapter write/review/rewrite/polish),
|
|
4
|
+
* 设定库 (story bible), 伏笔 (foreshadows), 设置 (config). Generation and
|
|
5
|
+
* review streams land in the progress console.
|
|
6
|
+
*/
|
|
7
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
8
|
+
import type { NovelApi } from '../api.ts'
|
|
9
|
+
import type { PanelController } from './controller.ts'
|
|
10
|
+
import { tt } from './helpers.ts'
|
|
11
|
+
import { AssistantTab } from './AssistantTab.tsx'
|
|
12
|
+
import { AssetsTab } from './AssetsTab.tsx'
|
|
13
|
+
import { BookshelfBar } from './BookshelfBar.tsx'
|
|
14
|
+
import { extractDocxTextFromBuffer } from '../docx.ts'
|
|
15
|
+
import type {
|
|
16
|
+
BookshelfSnapshot,
|
|
17
|
+
ChapterPlan,
|
|
18
|
+
Foreshadow,
|
|
19
|
+
JobFrame,
|
|
20
|
+
NovelConfig,
|
|
21
|
+
ProjectState,
|
|
22
|
+
ReviewReport,
|
|
23
|
+
StoryBible,
|
|
24
|
+
Volume,
|
|
25
|
+
} from '../../protocol.ts'
|
|
26
|
+
import css from './panel.module.css'
|
|
27
|
+
|
|
28
|
+
/** The panel's tab identifiers. */
|
|
29
|
+
export type NovelTab = 'workflow' | 'overview' | 'plan' | 'bible' | 'assets' | 'foreshadow' | 'assistant' | 'settings'
|
|
30
|
+
|
|
31
|
+
/** Panel shell props. */
|
|
32
|
+
export interface NovelPanelProps {
|
|
33
|
+
/** The panel state owner (open/close/toggle). */
|
|
34
|
+
controller: PanelController
|
|
35
|
+
/** The API client every tab operates through. */
|
|
36
|
+
api: NovelApi
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One progress console line. */
|
|
40
|
+
interface ProgressLine {
|
|
41
|
+
id: number
|
|
42
|
+
text: string
|
|
43
|
+
kind: 'info' | 'done' | 'error'
|
|
44
|
+
}
|
|
45
|
+
|
|
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') },
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
/** Whether any chapter is being generated right now. */
|
|
59
|
+
function anyGenerating(chapters: ChapterPlan[] | undefined): boolean {
|
|
60
|
+
return (chapters ?? []).some(c => c.status === 'generating' || c.status === 'reviewing')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Status badge class + label. */
|
|
64
|
+
function statusBadge(chapter: ChapterPlan): { cls: string; label: string } {
|
|
65
|
+
switch (chapter.status) {
|
|
66
|
+
case 'pending': return { cls: css.badgePending, label: tt('plan.pending') }
|
|
67
|
+
case 'generating': return { cls: css.badgeGenerating, label: tt('plan.generating') }
|
|
68
|
+
case 'written': return { cls: css.badgeWritten, label: tt('plan.written') }
|
|
69
|
+
case 'reviewing': return { cls: css.badgeGenerating, label: tt('plan.reviewing') }
|
|
70
|
+
case 'approved': return { cls: css.badgeDone, label: tt('plan.approved') }
|
|
71
|
+
case 'rejected': return { cls: css.badgeRejected, label: tt('plan.rejected') }
|
|
72
|
+
case 'error': return { cls: css.badgeError, label: tt('plan.error') }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One review issue line (severity-colored, theme-aware). */
|
|
77
|
+
function severityColor(severity: string): string {
|
|
78
|
+
return severity === 'high' ? 'var(--nf-error)' : severity === 'medium' ? 'var(--nf-warn)' : 'var(--nf-info)'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The novel-forge panel. */
|
|
82
|
+
export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
83
|
+
const [activeTab, setActiveTab] = useState<NovelTab>('workflow')
|
|
84
|
+
const [config, setConfig] = useState<NovelConfig | null>(null)
|
|
85
|
+
const [project, setProject] = useState<ProjectState | null>(null)
|
|
86
|
+
const [generatedFiles, setGeneratedFiles] = useState<string[]>([])
|
|
87
|
+
const [outlineText, setOutlineText] = useState('')
|
|
88
|
+
const [customDocxPath, setCustomDocxPath] = useState('')
|
|
89
|
+
const [shelf, setShelf] = useState<BookshelfSnapshot | null>(null)
|
|
90
|
+
const [dragActive, setDragActive] = useState(false)
|
|
91
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
|
92
|
+
const [planCount, setPlanCount] = useState(30)
|
|
93
|
+
const [busy, setBusy] = useState(false)
|
|
94
|
+
const [busyLabel, setBusyLabel] = useState('')
|
|
95
|
+
const [error, setError] = useState('')
|
|
96
|
+
const [notice, setNotice] = useState('')
|
|
97
|
+
const [progress, setProgress] = useState<ProgressLine[]>([])
|
|
98
|
+
const [configDraft, setConfigDraft] = useState<NovelConfig | null>(null)
|
|
99
|
+
const [expandedChapter, setExpandedChapter] = useState<number | null>(null)
|
|
100
|
+
const [chapterText, setChapterText] = useState('')
|
|
101
|
+
const [rewriteInstruction, setRewriteInstruction] = useState('')
|
|
102
|
+
const [localTarget, setLocalTarget] = useState('')
|
|
103
|
+
const progressId = useRef(0)
|
|
104
|
+
|
|
105
|
+
/** Refresh bookshelf. */
|
|
106
|
+
const refreshShelf = useCallback(async () => {
|
|
107
|
+
try {
|
|
108
|
+
const snapshot = await api.bookshelf()
|
|
109
|
+
setShelf(snapshot)
|
|
110
|
+
} catch { /* shelf is best-effort */ }
|
|
111
|
+
}, [api])
|
|
112
|
+
|
|
113
|
+
/** Append a progress console line. */
|
|
114
|
+
const pushProgress = useCallback((text: string, kind: ProgressLine['kind'] = 'info') => {
|
|
115
|
+
setProgress(prev => [...prev.slice(-300), { id: progressId.current++, text, kind }])
|
|
116
|
+
}, [])
|
|
117
|
+
|
|
118
|
+
/** Refresh status (config + project + files). */
|
|
119
|
+
const refresh = useCallback(async (showError = true) => {
|
|
120
|
+
try {
|
|
121
|
+
const status = await api.status()
|
|
122
|
+
setConfig(status.config)
|
|
123
|
+
setConfigDraft(status.config)
|
|
124
|
+
setProject(status.project ?? null)
|
|
125
|
+
setGeneratedFiles(status.generatedFiles)
|
|
126
|
+
const nextOutline = status.project?.outline
|
|
127
|
+
if (nextOutline !== undefined && outlineText === '') {
|
|
128
|
+
setOutlineText(nextOutline)
|
|
129
|
+
}
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (showError) setError((err as Error).message)
|
|
132
|
+
}
|
|
133
|
+
}, [api, outlineText])
|
|
134
|
+
|
|
135
|
+
/** Handle a docx file (pick or drag): parse locally, save outline. */
|
|
136
|
+
const handleDocxFile = useCallback(async (file: File) => {
|
|
137
|
+
setBusy(true)
|
|
138
|
+
setBusyLabel(tt('overview.loadingOutline'))
|
|
139
|
+
setError('')
|
|
140
|
+
try {
|
|
141
|
+
const buffer = await file.arrayBuffer()
|
|
142
|
+
const outline = extractDocxTextFromBuffer(buffer)
|
|
143
|
+
if (outline.length < 50) {
|
|
144
|
+
throw new Error('大纲内容过短(<50 字符),请检查文件')
|
|
145
|
+
}
|
|
146
|
+
setOutlineText(outline)
|
|
147
|
+
await api.saveOutline(outline)
|
|
148
|
+
await refresh(false)
|
|
149
|
+
pushProgress(`已从「${file.name}」读取大纲(${outline.length} 字)`, 'done')
|
|
150
|
+
} catch (err) {
|
|
151
|
+
setError((err as Error).message)
|
|
152
|
+
pushProgress(`读取大纲失败:${(err as Error).message}`, 'error')
|
|
153
|
+
} finally {
|
|
154
|
+
setBusy(false)
|
|
155
|
+
setBusyLabel('')
|
|
156
|
+
}
|
|
157
|
+
}, [api, pushProgress, refresh])
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
void refresh()
|
|
161
|
+
void refreshShelf()
|
|
162
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
163
|
+
}, [])
|
|
164
|
+
|
|
165
|
+
/** Load the outline from docx (default path or custom). */
|
|
166
|
+
const handleLoadDocx = async (useCustom: boolean): Promise<void> => {
|
|
167
|
+
setBusy(true)
|
|
168
|
+
setBusyLabel(tt('overview.loadingOutline'))
|
|
169
|
+
setError('')
|
|
170
|
+
try {
|
|
171
|
+
const result = await api.loadOutline(useCustom ? customDocxPath || undefined : undefined)
|
|
172
|
+
setOutlineText(result.outline)
|
|
173
|
+
// Persist into the project (load-or-create).
|
|
174
|
+
await api.saveOutline(result.outline)
|
|
175
|
+
await refresh(false)
|
|
176
|
+
pushProgress(`大纲已读取(${result.chars} 字):${result.bookName}${result.path !== undefined ? ` ← ${result.path}` : ''}`, 'done')
|
|
177
|
+
} catch (err) {
|
|
178
|
+
setError((err as Error).message)
|
|
179
|
+
pushProgress(`读取大纲失败:${(err as Error).message}`, 'error')
|
|
180
|
+
} finally {
|
|
181
|
+
setBusy(false)
|
|
182
|
+
setBusyLabel('')
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Save the edited outline. */
|
|
187
|
+
const handleSaveOutline = async (): Promise<void> => {
|
|
188
|
+
setBusy(true)
|
|
189
|
+
setError('')
|
|
190
|
+
try {
|
|
191
|
+
await api.saveOutline(outlineText)
|
|
192
|
+
setNotice(tt('overview.saved'))
|
|
193
|
+
pushProgress(tt('overview.saved'), 'done')
|
|
194
|
+
await refresh(false)
|
|
195
|
+
} catch (err) {
|
|
196
|
+
setError((err as Error).message)
|
|
197
|
+
} finally {
|
|
198
|
+
setBusy(false)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Extract the story bible. */
|
|
203
|
+
const handleBible = async (): Promise<void> => {
|
|
204
|
+
setBusy(true)
|
|
205
|
+
setBusyLabel(tt('bible.gen'))
|
|
206
|
+
setError('')
|
|
207
|
+
try {
|
|
208
|
+
const result = await api.bible(outlineText || undefined)
|
|
209
|
+
setProject(prev => prev === null ? prev : { ...prev, bible: result.bible, updatedAt: new Date().toISOString() })
|
|
210
|
+
const bible: StoryBible = result.bible
|
|
211
|
+
pushProgress(tt('workflow.bibleDone', {
|
|
212
|
+
n: bible.worldRules.length,
|
|
213
|
+
c: bible.characters.length,
|
|
214
|
+
r: bible.redLines.length,
|
|
215
|
+
}), 'done')
|
|
216
|
+
} catch (err) {
|
|
217
|
+
setError((err as Error).message)
|
|
218
|
+
pushProgress(`提炼设定圣经失败:${(err as Error).message}`, 'error')
|
|
219
|
+
} finally {
|
|
220
|
+
setBusy(false)
|
|
221
|
+
setBusyLabel('')
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Plan volumes. */
|
|
226
|
+
const handleVolumes = async (): Promise<void> => {
|
|
227
|
+
setBusy(true)
|
|
228
|
+
setBusyLabel(tt('workflow.genVolumes'))
|
|
229
|
+
setError('')
|
|
230
|
+
try {
|
|
231
|
+
const result = await api.volumes(outlineText || undefined)
|
|
232
|
+
setProject(prev => prev === null ? prev : { ...prev, volumes: result.volumes, updatedAt: new Date().toISOString() })
|
|
233
|
+
pushProgress(tt('workflow.volumesDone', { n: result.volumes.length }), 'done')
|
|
234
|
+
} catch (err) {
|
|
235
|
+
setError((err as Error).message)
|
|
236
|
+
pushProgress(`生成卷计划失败:${(err as Error).message}`, 'error')
|
|
237
|
+
} finally {
|
|
238
|
+
setBusy(false)
|
|
239
|
+
setBusyLabel('')
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Generate the chapter plan via LLM. */
|
|
244
|
+
const handlePlan = async (): Promise<void> => {
|
|
245
|
+
setBusy(true)
|
|
246
|
+
setBusyLabel(tt('plan.generate'))
|
|
247
|
+
setError('')
|
|
248
|
+
try {
|
|
249
|
+
const result = await api.plan(outlineText || undefined, planCount)
|
|
250
|
+
setProject(prev => {
|
|
251
|
+
const base = prev ?? {
|
|
252
|
+
bookName: '', outline: outlineText, chapters: [] as ChapterPlan[],
|
|
253
|
+
foreshadows: [] as Foreshadow[], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
254
|
+
}
|
|
255
|
+
return { ...base, chapters: [...base.chapters, ...result.chapters], updatedAt: new Date().toISOString() }
|
|
256
|
+
})
|
|
257
|
+
pushProgress(tt('workflow.planDone', { n: result.chapters.length }), 'done')
|
|
258
|
+
} catch (err) {
|
|
259
|
+
setError((err as Error).message)
|
|
260
|
+
pushProgress(`生成章节计划失败:${(err as Error).message}`, 'error')
|
|
261
|
+
} finally {
|
|
262
|
+
setBusy(false)
|
|
263
|
+
setBusyLabel('')
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Shared frame handler for generate/rewrite/polish streams. */
|
|
268
|
+
const applyJobFrame = useCallback((frame: JobFrame, label: (no: number) => string) => {
|
|
269
|
+
if (frame.type === 'start') {
|
|
270
|
+
setProject(prev => prev === null ? prev : {
|
|
271
|
+
...prev,
|
|
272
|
+
chapters: prev.chapters.map(c => c.no === frame.no ? { ...c, status: 'generating', error: undefined } : c),
|
|
273
|
+
})
|
|
274
|
+
pushProgress(label(frame.no))
|
|
275
|
+
} else if (frame.type === 'delta') {
|
|
276
|
+
if (frame.text.length % 3000 < 600) {
|
|
277
|
+
pushProgress(`…已生成 ${frame.text.length} 字`)
|
|
278
|
+
}
|
|
279
|
+
} else if (frame.type === 'done' || frame.type === 'rewritten') {
|
|
280
|
+
setProject(prev => prev === null ? prev : {
|
|
281
|
+
...prev,
|
|
282
|
+
chapters: prev.chapters.map(c => c.no === frame.no ? { ...c, status: 'written', chars: frame.chars, file: frame.file, review: undefined } : c),
|
|
283
|
+
})
|
|
284
|
+
pushProgress(tt('progress.done', { no: frame.no, chars: frame.chars, file: frame.file }), 'done')
|
|
285
|
+
setGeneratedFiles(prev => prev.includes(frame.file) ? prev : [...prev, frame.file])
|
|
286
|
+
} else if (frame.type === 'review') {
|
|
287
|
+
setProject(prev => prev === null ? prev : {
|
|
288
|
+
...prev,
|
|
289
|
+
chapters: prev.chapters.map(c => c.no === frame.no ? { ...c, status: frame.report.passed ? 'approved' : 'rejected', review: frame.report } : c),
|
|
290
|
+
})
|
|
291
|
+
pushProgress(tt('progress.reviewed', {
|
|
292
|
+
no: frame.no,
|
|
293
|
+
score: frame.report.score,
|
|
294
|
+
verdict: frame.report.verdict,
|
|
295
|
+
}), frame.report.passed ? 'done' : 'error')
|
|
296
|
+
} else if (frame.type === 'error') {
|
|
297
|
+
setProject(prev => prev === null ? prev : {
|
|
298
|
+
...prev,
|
|
299
|
+
chapters: prev.chapters.map(c => c.no === frame.no ? { ...c, status: 'error', error: frame.message } : c),
|
|
300
|
+
})
|
|
301
|
+
pushProgress(tt('progress.error', { no: frame.no, message: frame.message }), 'error')
|
|
302
|
+
}
|
|
303
|
+
}, [pushProgress])
|
|
304
|
+
|
|
305
|
+
/** Generate one chapter, streaming frames into the console. */
|
|
306
|
+
const handleWriteChapter = async (no: number, skipReview: boolean): Promise<void> => {
|
|
307
|
+
setBusy(true)
|
|
308
|
+
setBusyLabel(`${tt('plan.write')} 第${no}章`)
|
|
309
|
+
setError('')
|
|
310
|
+
try {
|
|
311
|
+
await api.generate(no, skipReview, frame => { applyJobFrame(frame, n => tt('progress.generating', { no: n, title: (project?.chapters.find(c => c.no === n)?.title ?? '') })) })
|
|
312
|
+
} catch (err) {
|
|
313
|
+
setError((err as Error).message)
|
|
314
|
+
pushProgress(`第 ${no} 章失败:${(err as Error).message}`, 'error')
|
|
315
|
+
} finally {
|
|
316
|
+
setBusy(false)
|
|
317
|
+
setBusyLabel('')
|
|
318
|
+
await refresh(false)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Batch-write all remaining chapters in sequence. */
|
|
323
|
+
const handleWriteAll = async (): Promise<void> => {
|
|
324
|
+
const remaining = chapters.filter(c => c.status === 'pending' || c.status === 'error')
|
|
325
|
+
if (remaining.length === 0) return
|
|
326
|
+
setBusy(true)
|
|
327
|
+
setBusyLabel(`${tt('plan.writeAllPending')}(共 ${remaining.length} 章)`)
|
|
328
|
+
setError('')
|
|
329
|
+
let failed = 0
|
|
330
|
+
for (const chapter of remaining) {
|
|
331
|
+
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) {
|
|
335
|
+
failed++
|
|
336
|
+
pushProgress(`第 ${chapter.no} 章失败:${(err as Error).message}`, 'error')
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
setBusy(false)
|
|
340
|
+
setBusyLabel('')
|
|
341
|
+
await refresh(false)
|
|
342
|
+
pushProgress(failed === 0
|
|
343
|
+
? `批量生成完成:${remaining.length} 章全部完成`
|
|
344
|
+
: `批量生成结束:${remaining.length - failed} 章完成,${failed} 章失败`, failed === 0 ? 'done' : 'error')
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Review one chapter. */
|
|
348
|
+
const handleReview = async (no: number): Promise<void> => {
|
|
349
|
+
setBusy(true)
|
|
350
|
+
setBusyLabel(`${tt('plan.review')} 第${no}章`)
|
|
351
|
+
setError('')
|
|
352
|
+
try {
|
|
353
|
+
const result = await api.review(no)
|
|
354
|
+
const report: ReviewReport = result.report
|
|
355
|
+
setProject(prev => prev === null ? prev : {
|
|
356
|
+
...prev,
|
|
357
|
+
chapters: prev.chapters.map(c => c.no === no ? { ...c, status: report.passed ? 'approved' : 'rejected', review: report } : c),
|
|
358
|
+
})
|
|
359
|
+
pushProgress(tt('progress.reviewed', { no, score: report.score, verdict: report.verdict }), report.passed ? 'done' : 'error')
|
|
360
|
+
} catch (err) {
|
|
361
|
+
setError((err as Error).message)
|
|
362
|
+
} finally {
|
|
363
|
+
setBusy(false)
|
|
364
|
+
setBusyLabel('')
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
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
|
+
/** Approve a chapter manually. */
|
|
405
|
+
const handleApprove = (no: number): void => {
|
|
406
|
+
setProject(prev => prev === null ? prev : {
|
|
407
|
+
...prev,
|
|
408
|
+
chapters: prev.chapters.map(c => c.no === no ? { ...c, status: 'approved' } : c),
|
|
409
|
+
updatedAt: new Date().toISOString(),
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Toggle chapter preview. */
|
|
414
|
+
const handleToggleChapter = async (no: number): Promise<void> => {
|
|
415
|
+
if (expandedChapter === no) {
|
|
416
|
+
setExpandedChapter(null)
|
|
417
|
+
setChapterText('')
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
setExpandedChapter(no)
|
|
421
|
+
setChapterText('')
|
|
422
|
+
try {
|
|
423
|
+
const result = await api.chapter(no)
|
|
424
|
+
setChapterText(result.markdown)
|
|
425
|
+
} catch (err) {
|
|
426
|
+
setChapterText(`(${(err as Error).message})`)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Suggest foreshadows via LLM. */
|
|
431
|
+
const handleSuggestForeshadows = async (): Promise<void> => {
|
|
432
|
+
setBusy(true)
|
|
433
|
+
setBusyLabel(tt('foreshadow.suggest'))
|
|
434
|
+
setError('')
|
|
435
|
+
try {
|
|
436
|
+
const result = await api.foreshadow({ suggest: true })
|
|
437
|
+
setProject(prev => prev === null ? prev : { ...prev, foreshadows: [...(prev?.foreshadows ?? []), ...result.foreshadows], updatedAt: new Date().toISOString() })
|
|
438
|
+
pushProgress(`AI 已建议 ${result.foreshadows.length} 条伏笔`, 'done')
|
|
439
|
+
} catch (err) {
|
|
440
|
+
setError((err as Error).message)
|
|
441
|
+
pushProgress(`伏笔建议失败:${(err as Error).message}`, 'error')
|
|
442
|
+
} finally {
|
|
443
|
+
setBusy(false)
|
|
444
|
+
setBusyLabel('')
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Save the settings draft. */
|
|
449
|
+
const handleSaveConfig = async (): Promise<void> => {
|
|
450
|
+
if (configDraft === null) return
|
|
451
|
+
setBusy(true)
|
|
452
|
+
setError('')
|
|
453
|
+
try {
|
|
454
|
+
const result = await api.patchConfig({
|
|
455
|
+
outlinePath: configDraft.outlinePath,
|
|
456
|
+
outputDir: configDraft.outputDir,
|
|
457
|
+
provider: configDraft.provider,
|
|
458
|
+
model: configDraft.model,
|
|
459
|
+
chapterChars: configDraft.chapterChars,
|
|
460
|
+
maxTokens: configDraft.maxTokens,
|
|
461
|
+
reviewPassScore: configDraft.reviewPassScore,
|
|
462
|
+
autoReview: configDraft.autoReview,
|
|
463
|
+
})
|
|
464
|
+
setConfig(result.config)
|
|
465
|
+
setConfigDraft(result.config)
|
|
466
|
+
setNotice(tt('settings.saved'))
|
|
467
|
+
pushProgress(tt('settings.saved'), 'done')
|
|
468
|
+
await refresh(false)
|
|
469
|
+
} catch (err) {
|
|
470
|
+
setError((err as Error).message)
|
|
471
|
+
} finally {
|
|
472
|
+
setBusy(false)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Export the book. */
|
|
477
|
+
const handleExport = async (format: 'txt' | 'md'): Promise<void> => {
|
|
478
|
+
setBusy(true)
|
|
479
|
+
setError('')
|
|
480
|
+
try {
|
|
481
|
+
const result = await api.exportBook(format)
|
|
482
|
+
setNotice(tt('settings.exported', { file: result.file, chars: result.chars, chapters: result.chapters }))
|
|
483
|
+
pushProgress(tt('settings.exported', { file: result.file, chars: result.chars, chapters: result.chapters }), 'done')
|
|
484
|
+
} catch (err) {
|
|
485
|
+
setError((err as Error).message)
|
|
486
|
+
} finally {
|
|
487
|
+
setBusy(false)
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const busyAny = anyGenerating(project?.chapters)
|
|
492
|
+
const chapters = project?.chapters ?? []
|
|
493
|
+
const doneCount = chapters.filter(c => c.status === 'approved' || c.status === 'written' || c.status === 'rejected').length
|
|
494
|
+
const pendingCount = chapters.filter(c => c.status === 'pending' || c.status === 'error').length
|
|
495
|
+
const bible: StoryBible | undefined = project?.bible
|
|
496
|
+
const volumes: Volume[] | undefined = project?.volumes
|
|
497
|
+
const foreshadows: Foreshadow[] = project?.foreshadows ?? []
|
|
498
|
+
|
|
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
|
+
)
|
|
514
|
+
|
|
515
|
+
return (
|
|
516
|
+
<div className={css.panel}>
|
|
517
|
+
<div className={css.panelHeader}>
|
|
518
|
+
<h2 className={css.panelTitle}>
|
|
519
|
+
{tt('panel.title')}
|
|
520
|
+
{project?.bookName !== '' && project?.bookName !== undefined && (
|
|
521
|
+
<span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)', fontSize: 11 }}>{project.bookName}</span>
|
|
522
|
+
)}
|
|
523
|
+
</h2>
|
|
524
|
+
<button type="button" className={css.iconButton} title={tt('common.close')} aria-label={tt('common.close')} onClick={() => { controller.close() }}>×</button>
|
|
525
|
+
</div>
|
|
526
|
+
{shelf !== null && (
|
|
527
|
+
<BookshelfBar
|
|
528
|
+
api={api}
|
|
529
|
+
shelf={shelf}
|
|
530
|
+
onSwitch={() => {
|
|
531
|
+
void refreshShelf()
|
|
532
|
+
// 切换书后重置本地编辑状态,重新拉取目标书。
|
|
533
|
+
setOutlineText('')
|
|
534
|
+
setProject(null)
|
|
535
|
+
setGeneratedFiles([])
|
|
536
|
+
setChapterText('')
|
|
537
|
+
setExpandedChapter(null)
|
|
538
|
+
setProgress([])
|
|
539
|
+
void refresh(false)
|
|
540
|
+
}}
|
|
541
|
+
/>
|
|
542
|
+
)}
|
|
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}
|
|
547
|
+
</button>
|
|
548
|
+
))}
|
|
549
|
+
</div>
|
|
550
|
+
<div className={css.panelContent}>
|
|
551
|
+
{error !== '' && <div className={css.card} style={{ borderColor: 'var(--nf-error)' }}><span style={{ color: 'var(--nf-error)' }}>{tt('common.error')}: {error}</span></div>}
|
|
552
|
+
{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' && (
|
|
556
|
+
<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)}
|
|
572
|
+
</div>
|
|
573
|
+
</div>
|
|
574
|
+
)}
|
|
575
|
+
|
|
576
|
+
{activeTab === 'overview' && (
|
|
577
|
+
<>
|
|
578
|
+
<div className={css.card}>
|
|
579
|
+
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
580
|
+
<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>
|
|
616
|
+
</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
|
+
/>
|
|
624
|
+
</div>
|
|
625
|
+
<div className={css.card}>
|
|
626
|
+
<span className={css.cardTitle}>{tt('status.files')}({generatedFiles.length})</span>
|
|
627
|
+
<div className={css.fileList}>
|
|
628
|
+
{generatedFiles.length === 0 && <span>{tt('status.projectNone')}</span>}
|
|
629
|
+
{generatedFiles.map(file => <span key={file}>{file}</span>)}
|
|
630
|
+
</div>
|
|
631
|
+
</div>
|
|
632
|
+
</>
|
|
633
|
+
)}
|
|
634
|
+
|
|
635
|
+
{activeTab === 'plan' && (
|
|
636
|
+
<>
|
|
637
|
+
<div className={css.card}>
|
|
638
|
+
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
639
|
+
<span className={css.cardTitle}>{tt('tab.plan')}</span>
|
|
640
|
+
<div className={css.row}>
|
|
641
|
+
<span className={css.meta}>{tt('plan.generateHint')}</span>
|
|
642
|
+
<input
|
|
643
|
+
className={css.input}
|
|
644
|
+
style={{ width: 72 }}
|
|
645
|
+
type="number"
|
|
646
|
+
min={1}
|
|
647
|
+
max={200}
|
|
648
|
+
value={planCount}
|
|
649
|
+
onChange={e => { const v = Number(e.target.value); if (Number.isInteger(v)) setPlanCount(v) }}
|
|
650
|
+
/>
|
|
651
|
+
<span className={css.meta}>{tt('plan.count')}</span>
|
|
652
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || outlineText.length < 50} onClick={() => { void handlePlan() }}>
|
|
653
|
+
{tt('plan.generate')}
|
|
654
|
+
</button>
|
|
655
|
+
</div>
|
|
656
|
+
</div>
|
|
657
|
+
{volumes !== undefined && volumes.length > 0 && (
|
|
658
|
+
<div className={css.row}>
|
|
659
|
+
{volumes.map(v => (
|
|
660
|
+
<span key={v.no} className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>
|
|
661
|
+
{v.no}. {v.title}({v.chapterStart}-{v.chapterEnd})
|
|
662
|
+
</span>
|
|
663
|
+
))}
|
|
664
|
+
</div>
|
|
665
|
+
)}
|
|
666
|
+
</div>
|
|
667
|
+
|
|
668
|
+
{chapters.length > 0 && (
|
|
669
|
+
<div className={css.card}>
|
|
670
|
+
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
671
|
+
<span className={css.meta}>共 {chapters.length} 章 · 已完成 {doneCount} · 待生成 {pendingCount}</span>
|
|
672
|
+
{pendingCount > 0 && (
|
|
673
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleWriteAll() }}>
|
|
674
|
+
{tt('plan.writeAllPending')}({pendingCount})
|
|
675
|
+
</button>
|
|
676
|
+
)}
|
|
677
|
+
</div>
|
|
678
|
+
<div className={css.chapterList}>
|
|
679
|
+
{chapters.map(chapter => {
|
|
680
|
+
const badge = statusBadge(chapter)
|
|
681
|
+
const expanded = expandedChapter === chapter.no
|
|
682
|
+
const review: ReviewReport | undefined = chapter.review
|
|
683
|
+
return (
|
|
684
|
+
<div key={chapter.no} className={css.chapter}>
|
|
685
|
+
<span className={css.chapterNum}>{chapter.no}</span>
|
|
686
|
+
<div className={css.chapterMain}>
|
|
687
|
+
<div className={css.chapterTitle}>
|
|
688
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ padding: '1px 6px' }} onClick={() => { void handleToggleChapter(chapter.no) }}>
|
|
689
|
+
{expanded ? '−' : '+'}
|
|
690
|
+
</button>
|
|
691
|
+
<span>{chapter.title}</span>
|
|
692
|
+
{chapter.status === 'approved' && chapter.chars !== undefined && (
|
|
693
|
+
<span className={css.meta}>{chapter.chars}{tt('common.chars')}</span>
|
|
694
|
+
)}
|
|
695
|
+
{chapter.volume > 0 && <span className={css.meta}>{tt('plan.volumes')}{chapter.volume}</span>}
|
|
696
|
+
</div>
|
|
697
|
+
{!expanded && <div className={css.chapterBeats} title={chapter.beats}>{chapter.beats}</div>}
|
|
698
|
+
{expanded && (
|
|
699
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
700
|
+
<div className={css.meta}><b>{tt('plan.beats')}:</b> {chapter.beats}</div>
|
|
701
|
+
{chapter.summary !== undefined && chapter.summary !== '' && (
|
|
702
|
+
<div className={css.meta}><b>{tt('plan.summary')}:</b> {chapter.summary}</div>
|
|
703
|
+
)}
|
|
704
|
+
<pre className={css.chapterPreview}>{chapterText || `(${tt('common.loading')})`}</pre>
|
|
705
|
+
{review !== undefined && (
|
|
706
|
+
<div className={css.reviewBox}>
|
|
707
|
+
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
708
|
+
<b>{tt('plan.reviewReport')}</b>
|
|
709
|
+
<span style={{ color: review.passed ? 'var(--nf-success)' : 'var(--nf-error)' }}>
|
|
710
|
+
{tt('plan.reviewScore')}: {review.score} — {review.passed ? tt('plan.reviewPass') : tt('plan.reviewFail')}
|
|
711
|
+
</span>
|
|
712
|
+
</div>
|
|
713
|
+
<div className={css.meta}><b>{tt('plan.reviewVerdict')}:</b> {review.verdict}</div>
|
|
714
|
+
{review.issues.length > 0 && (
|
|
715
|
+
<ul style={{ margin: 0, paddingLeft: 18, fontSize: 12 }}>
|
|
716
|
+
{review.issues.map((issue, i) => (
|
|
717
|
+
<li key={i} style={{ color: severityColor(issue.severity) }}>
|
|
718
|
+
[{issue.severity}] {issue.item} → {issue.suggestion}
|
|
719
|
+
</li>
|
|
720
|
+
))}
|
|
721
|
+
</ul>
|
|
722
|
+
)}
|
|
723
|
+
</div>
|
|
724
|
+
)}
|
|
725
|
+
{(chapter.status === 'rejected' || chapter.status === 'written' || chapter.status === 'approved') && (
|
|
726
|
+
<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
|
+
/>
|
|
737
|
+
</div>
|
|
738
|
+
<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')}
|
|
748
|
+
</button>
|
|
749
|
+
</div>
|
|
750
|
+
</div>
|
|
751
|
+
)}
|
|
752
|
+
</div>
|
|
753
|
+
)}
|
|
754
|
+
</div>
|
|
755
|
+
<span className={`${css.badge} ${badge.cls}`}>{badge.label}</span>
|
|
756
|
+
<div className={css.chapterActions}>
|
|
757
|
+
{(chapter.status === 'pending' || chapter.status === 'error') && (
|
|
758
|
+
<button
|
|
759
|
+
type="button"
|
|
760
|
+
className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
|
|
761
|
+
disabled={busy || busyAny}
|
|
762
|
+
onClick={() => { void handleWriteChapter(chapter.no, true) }}
|
|
763
|
+
>
|
|
764
|
+
{tt('plan.write')}
|
|
765
|
+
</button>
|
|
766
|
+
)}
|
|
767
|
+
{(chapter.status === 'written' || chapter.status === 'rejected') && (
|
|
768
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || busyAny} onClick={() => { void handleReview(chapter.no) }}>
|
|
769
|
+
{tt('plan.review')}
|
|
770
|
+
</button>
|
|
771
|
+
)}
|
|
772
|
+
{chapter.status === 'written' && (
|
|
773
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || busyAny} onClick={() => { handleApprove(chapter.no) }}>
|
|
774
|
+
{tt('plan.approve')}
|
|
775
|
+
</button>
|
|
776
|
+
)}
|
|
777
|
+
{(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) }}>
|
|
779
|
+
{tt('plan.polish')}
|
|
780
|
+
</button>
|
|
781
|
+
)}
|
|
782
|
+
{chapter.status === 'rejected' && (
|
|
783
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || busyAny} onClick={() => { void handleWriteChapter(chapter.no, true) }}>
|
|
784
|
+
{tt('plan.rewrite')}
|
|
785
|
+
</button>
|
|
786
|
+
)}
|
|
787
|
+
</div>
|
|
788
|
+
</div>
|
|
789
|
+
)
|
|
790
|
+
})}
|
|
791
|
+
</div>
|
|
792
|
+
</div>
|
|
793
|
+
)}
|
|
794
|
+
|
|
795
|
+
<div className={css.card}>
|
|
796
|
+
<span className={css.cardTitle}>{tt('plan.progress')}</span>
|
|
797
|
+
<div className={css.progress}>
|
|
798
|
+
{progress.length === 0 && <span className={css.meta}>{tt('progress.empty')}</span>}
|
|
799
|
+
{progress.map(line => (
|
|
800
|
+
<div key={line.id} className={line.kind === 'done' ? css.progressLineDone : line.kind === 'error' ? css.progressLineError : css.progressLine}>
|
|
801
|
+
{line.text}
|
|
802
|
+
</div>
|
|
803
|
+
))}
|
|
804
|
+
</div>
|
|
805
|
+
</div>
|
|
806
|
+
</>
|
|
807
|
+
)}
|
|
808
|
+
|
|
809
|
+
{activeTab === 'bible' && (
|
|
810
|
+
<div className={css.card}>
|
|
811
|
+
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
812
|
+
<span className={css.cardTitle}>{tt('bible.title')}</span>
|
|
813
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleBible() }}>
|
|
814
|
+
{tt('bible.gen')}
|
|
815
|
+
</button>
|
|
816
|
+
</div>
|
|
817
|
+
{bible === undefined ? (
|
|
818
|
+
<span className={css.meta}>{tt('bible.none')}</span>
|
|
819
|
+
) : (
|
|
820
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
821
|
+
{bible.genre !== '' && (
|
|
822
|
+
<div><b>{tt('bible.genre')}:</b> <span className={css.meta}>{bible.genre}</span></div>
|
|
823
|
+
)}
|
|
824
|
+
{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>
|
|
828
|
+
</div>
|
|
829
|
+
)}
|
|
830
|
+
{bible.characters.length > 0 && (
|
|
831
|
+
<div>
|
|
832
|
+
<b>{tt('bible.characters')}({bible.characters.length})</b>
|
|
833
|
+
{bible.characters.map(card => (
|
|
834
|
+
<div key={card.name} style={{ marginTop: 4, fontSize: 12 }}>
|
|
835
|
+
<b>{card.name}</b> <span className={css.meta}>[{card.role}] {card.traits.join('、')}</span>
|
|
836
|
+
{card.goals !== '' && <div className={css.meta}>目标:{card.goals}</div>}
|
|
837
|
+
{card.relations !== '' && <div className={css.meta}>关系:{card.relations}</div>}
|
|
838
|
+
</div>
|
|
839
|
+
))}
|
|
840
|
+
</div>
|
|
841
|
+
)}
|
|
842
|
+
{bible.redLines.length > 0 && (
|
|
843
|
+
<div>
|
|
844
|
+
<b>{tt('bible.redLines')}({bible.redLines.length})</b>
|
|
845
|
+
<ul style={{ margin: 0, paddingLeft: 18, fontSize: 12, color: 'var(--nf-error)' }}>{bible.redLines.map((r, i) => <li key={i}>{r}</li>)}</ul>
|
|
846
|
+
</div>
|
|
847
|
+
)}
|
|
848
|
+
{bible.style.length > 0 && (
|
|
849
|
+
<div>
|
|
850
|
+
<b>{tt('bible.style')}({bible.style.length})</b>
|
|
851
|
+
<ul style={{ margin: 0, paddingLeft: 18, fontSize: 12 }}>{bible.style.map((r, i) => <li key={i}>{r}</li>)}</ul>
|
|
852
|
+
</div>
|
|
853
|
+
)}
|
|
854
|
+
</div>
|
|
855
|
+
)}
|
|
856
|
+
</div>
|
|
857
|
+
)}
|
|
858
|
+
|
|
859
|
+
{activeTab === 'assets' && (
|
|
860
|
+
<AssetsTab api={api} />
|
|
861
|
+
)}
|
|
862
|
+
|
|
863
|
+
{activeTab === 'foreshadow' && (
|
|
864
|
+
<div className={css.card}>
|
|
865
|
+
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
866
|
+
<span className={css.cardTitle}>{tt('foreshadow.title')}({foreshadows.length})</span>
|
|
867
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSuggestForeshadows() }}>
|
|
868
|
+
{tt('foreshadow.suggest')}
|
|
869
|
+
</button>
|
|
870
|
+
</div>
|
|
871
|
+
{foreshadows.length === 0 ? (
|
|
872
|
+
<span className={css.meta}>{tt('foreshadow.none')}</span>
|
|
873
|
+
) : (
|
|
874
|
+
<div className={css.chapterList}>
|
|
875
|
+
{foreshadows.map(f => {
|
|
876
|
+
const statusLabel = { planned: tt('foreshadow.planned'), planted: tt('foreshadow.planted'), progressing: tt('foreshadow.progressing'), resolved: tt('foreshadow.resolved'), abandoned: tt('foreshadow.abandoned') }[f.status]
|
|
877
|
+
const statusColor = f.status === 'resolved' ? 'var(--nf-success)' : f.status === 'planted' || f.status === 'progressing' ? 'var(--nf-accent)' : f.status === 'abandoned' ? 'var(--nf-text-3)' : 'var(--nf-info)'
|
|
878
|
+
return (
|
|
879
|
+
<div key={f.id} className={css.chapter}>
|
|
880
|
+
<div className={css.chapterMain}>
|
|
881
|
+
<div className={css.chapterTitle}>
|
|
882
|
+
<span>{f.description}</span>
|
|
883
|
+
</div>
|
|
884
|
+
<div className={css.meta}>
|
|
885
|
+
{f.plantedChapter !== undefined && <span>{tt('foreshadow.plantedAt')} 第{f.plantedChapter}章 · </span>}
|
|
886
|
+
{f.targetChapter !== undefined && <span>{tt('foreshadow.target')} 第{f.targetChapter}章 · </span>}
|
|
887
|
+
{f.resolvedNote !== undefined && f.resolvedNote !== '' && <span>回收:{f.resolvedNote} · </span>}
|
|
888
|
+
</div>
|
|
889
|
+
</div>
|
|
890
|
+
<span className={css.badge} style={{ borderColor: statusColor, color: statusColor }}>{statusLabel}</span>
|
|
891
|
+
<div className={css.row} style={{ gap: 4 }}>
|
|
892
|
+
{f.status === 'planned' && (
|
|
893
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
|
|
894
|
+
void api.foreshadow({ id: f.id, status: 'planted', plantedChapter: doneCount + 1 }).then(r => setProject(prev => prev === null ? prev : { ...prev, foreshadows: r.foreshadows }))
|
|
895
|
+
}}>
|
|
896
|
+
{tt('foreshadow.setPlanted')}
|
|
897
|
+
</button>
|
|
898
|
+
)}
|
|
899
|
+
{(f.status === 'planted' || f.status === 'progressing') && (
|
|
900
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
|
|
901
|
+
void api.foreshadow({ id: f.id, status: 'resolved', resolvedNote: `第${doneCount}章回收` }).then(r => setProject(prev => prev === null ? prev : { ...prev, foreshadows: r.foreshadows }))
|
|
902
|
+
}}>
|
|
903
|
+
{tt('foreshadow.setResolved')}
|
|
904
|
+
</button>
|
|
905
|
+
)}
|
|
906
|
+
</div>
|
|
907
|
+
</div>
|
|
908
|
+
)
|
|
909
|
+
})}
|
|
910
|
+
</div>
|
|
911
|
+
)}
|
|
912
|
+
</div>
|
|
913
|
+
)}
|
|
914
|
+
|
|
915
|
+
{activeTab === 'assistant' && (
|
|
916
|
+
<AssistantTab api={api} />
|
|
917
|
+
)}
|
|
918
|
+
|
|
919
|
+
{activeTab === 'settings' && configDraft !== null && (
|
|
920
|
+
<div className={css.card}>
|
|
921
|
+
<span className={css.cardTitle}>{tt('settings.title')}</span>
|
|
922
|
+
<div className={css.field}>
|
|
923
|
+
<label className={css.fieldLabel}>{tt('settings.outlinePath')}</label>
|
|
924
|
+
<input className={css.input} value={configDraft.outlinePath} onChange={e => { setConfigDraft({ ...configDraft, outlinePath: e.target.value }) }} />
|
|
925
|
+
</div>
|
|
926
|
+
<div className={css.field}>
|
|
927
|
+
<label className={css.fieldLabel}>{tt('settings.outputDir')}</label>
|
|
928
|
+
<input className={css.input} value={configDraft.outputDir} onChange={e => { setConfigDraft({ ...configDraft, outputDir: e.target.value }) }} />
|
|
929
|
+
</div>
|
|
930
|
+
<div className={css.row}>
|
|
931
|
+
<div className={css.field} style={{ flex: 1 }}>
|
|
932
|
+
<label className={css.fieldLabel}>{tt('settings.provider')}</label>
|
|
933
|
+
<input className={css.input} value={configDraft.provider} onChange={e => { setConfigDraft({ ...configDraft, provider: e.target.value }) }} />
|
|
934
|
+
</div>
|
|
935
|
+
<div className={css.field} style={{ flex: 1 }}>
|
|
936
|
+
<label className={css.fieldLabel}>{tt('settings.model')}</label>
|
|
937
|
+
<input className={css.input} value={configDraft.model} onChange={e => { setConfigDraft({ ...configDraft, model: e.target.value }) }} />
|
|
938
|
+
</div>
|
|
939
|
+
</div>
|
|
940
|
+
<div className={css.row}>
|
|
941
|
+
<div className={css.field} style={{ flex: 1 }}>
|
|
942
|
+
<label className={css.fieldLabel}>{tt('settings.chapterChars')}</label>
|
|
943
|
+
<input className={css.input} type="number" min={1000} max={20000} value={configDraft.chapterChars} onChange={e => { setConfigDraft({ ...configDraft, chapterChars: Number(e.target.value) }) }} />
|
|
944
|
+
</div>
|
|
945
|
+
<div className={css.field} style={{ flex: 1 }}>
|
|
946
|
+
<label className={css.fieldLabel}>{tt('settings.maxTokens')}</label>
|
|
947
|
+
<input className={css.input} type="number" min={2000} max={64000} value={configDraft.maxTokens} onChange={e => { setConfigDraft({ ...configDraft, maxTokens: Number(e.target.value) }) }} />
|
|
948
|
+
</div>
|
|
949
|
+
</div>
|
|
950
|
+
<div className={css.row}>
|
|
951
|
+
<div className={css.field} style={{ flex: 1 }}>
|
|
952
|
+
<label className={css.fieldLabel}>{tt('settings.reviewPassScore')}</label>
|
|
953
|
+
<input className={css.input} type="number" min={0} max={100} value={configDraft.reviewPassScore} onChange={e => { setConfigDraft({ ...configDraft, reviewPassScore: Number(e.target.value) }) }} />
|
|
954
|
+
</div>
|
|
955
|
+
<div className={css.field} style={{ flex: 1 }}>
|
|
956
|
+
<label className={css.fieldLabel}>{tt('settings.autoReview')}</label>
|
|
957
|
+
<select
|
|
958
|
+
className={css.input}
|
|
959
|
+
value={configDraft.autoReview ? '1' : '0'}
|
|
960
|
+
onChange={e => { setConfigDraft({ ...configDraft, autoReview: e.target.value === '1' }) }}
|
|
961
|
+
>
|
|
962
|
+
<option value="1">✓ 是</option>
|
|
963
|
+
<option value="0">✗ 否</option>
|
|
964
|
+
</select>
|
|
965
|
+
</div>
|
|
966
|
+
</div>
|
|
967
|
+
<div className={css.row}>
|
|
968
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSaveConfig() }}>
|
|
969
|
+
{tt('settings.save')}
|
|
970
|
+
</button>
|
|
971
|
+
<button type="button" className={css.button} onClick={() => { void api.openFolder() }}>
|
|
972
|
+
{tt('settings.openFolder')}
|
|
973
|
+
</button>
|
|
974
|
+
<span className={css.meta}>当前:{config?.provider} / {config?.model} · {config?.outputDir}</span>
|
|
975
|
+
</div>
|
|
976
|
+
<div className={css.row}>
|
|
977
|
+
<span className={css.cardTitle}>{tt('settings.export')}</span>
|
|
978
|
+
<button type="button" className={css.button} disabled={busy || chapters.length === 0} onClick={() => { void handleExport('txt') }}>
|
|
979
|
+
{tt('settings.exportTxt')}
|
|
980
|
+
</button>
|
|
981
|
+
<button type="button" className={css.button} disabled={busy || chapters.length === 0} onClick={() => { void handleExport('md') }}>
|
|
982
|
+
{tt('settings.exportMd')}
|
|
983
|
+
</button>
|
|
984
|
+
</div>
|
|
985
|
+
</div>
|
|
986
|
+
)}
|
|
987
|
+
</div>
|
|
988
|
+
</div>
|
|
989
|
+
)
|
|
990
|
+
}
|