@waterwx/dsh-novel-forge 1.3.1 → 1.3.2-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/README.md +288 -279
  2. package/lib/client.js +56309 -12221
  3. package/lib/index.js +14239 -5595
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +36 -0
  6. package/lib/types/assets.d.ts +24 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +25 -0
  9. package/lib/types/bookshelf.d.ts +11 -1
  10. package/lib/types/client/api.d.ts +186 -10
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -0
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/llmLive.d.ts +31 -0
  15. package/lib/types/client/locales.d.ts +28 -67
  16. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  17. package/lib/types/client/panel/AiTag.d.ts +1 -0
  18. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  19. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  20. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  21. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  22. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  23. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  24. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  25. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  26. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  27. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  28. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  29. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  30. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  31. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  32. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  33. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  34. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  35. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  36. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  37. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  38. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  39. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  40. package/lib/types/client/panel/SubPage.d.ts +27 -0
  41. package/lib/types/client/panel/TensionView.d.ts +18 -0
  42. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  43. package/lib/types/client/panel/helpers.d.ts +5 -3
  44. package/lib/types/client/panel/toast.d.ts +2 -0
  45. package/lib/types/client/panel/views.d.ts +25 -7
  46. package/lib/types/client/run-status.d.ts +21 -0
  47. package/lib/types/client/text.d.ts +9 -0
  48. package/lib/types/docx-core.d.ts +16 -0
  49. package/lib/types/docx.d.ts +2 -3
  50. package/lib/types/engine.d.ts +186 -45
  51. package/lib/types/global-assets.d.ts +6 -0
  52. package/lib/types/index.d.ts +22 -8
  53. package/lib/types/llm-live.d.ts +75 -0
  54. package/lib/types/llm-retry.d.ts +38 -0
  55. package/lib/types/market-radar-scan.d.ts +19 -0
  56. package/lib/types/market-radar-sources.d.ts +31 -0
  57. package/lib/types/migrate-output.d.ts +25 -0
  58. package/lib/types/novel-context.d.ts +80 -0
  59. package/lib/types/prompt-slots.d.ts +57 -0
  60. package/lib/types/prompt-utils.d.ts +35 -0
  61. package/lib/types/prompting.d.ts +10 -0
  62. package/lib/types/protocol.d.ts +1362 -166
  63. package/lib/types/review-policy.d.ts +97 -0
  64. package/lib/types/revision.d.ts +76 -0
  65. package/lib/types/routes.d.ts +4 -0
  66. package/lib/types/run.d.ts +23 -1
  67. package/lib/types/snapshots.d.ts +46 -0
  68. package/lib/types/stage-contract.d.ts +60 -0
  69. package/lib/types/tension.d.ts +53 -0
  70. package/lib/types/timeline.d.ts +37 -0
  71. package/package.json +52 -28
  72. package/src/action-guard.ts +127 -0
  73. package/src/ai-scan.ts +179 -0
  74. package/src/assets.ts +1806 -520
  75. package/src/assistant.ts +1098 -770
  76. package/src/author-assets.ts +149 -0
  77. package/src/bookshelf.ts +187 -151
  78. package/src/client/api.ts +378 -21
  79. package/src/client/chip-prefs.ts +121 -0
  80. package/src/client/docx.ts +37 -69
  81. package/src/client/index.ts +3 -1
  82. package/src/client/llmLive.ts +111 -0
  83. package/src/client/locales.ts +28 -253
  84. package/src/client/mount.tsx +1 -1
  85. package/src/client/panel/AdaptModeView.tsx +535 -0
  86. package/src/client/panel/AiTag.tsx +7 -0
  87. package/src/client/panel/AssetsTab.tsx +448 -458
  88. package/src/client/panel/AssistantTab.tsx +355 -301
  89. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  90. package/src/client/panel/AuthorHome.tsx +309 -0
  91. package/src/client/panel/BookAnalysisView.tsx +63 -0
  92. package/src/client/panel/CmdPalette.tsx +102 -0
  93. package/src/client/panel/CreateBookView.tsx +366 -348
  94. package/src/client/panel/DirectorView.tsx +200 -0
  95. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  96. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  97. package/src/client/panel/ImportModal.tsx +303 -0
  98. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  99. package/src/client/panel/LiveFeedLog.tsx +204 -0
  100. package/src/client/panel/MarketRadarView.tsx +377 -0
  101. package/src/client/panel/ModelManager.tsx +205 -0
  102. package/src/client/panel/NovelPanel.tsx +3135 -2651
  103. package/src/client/panel/PageHeader.tsx +37 -0
  104. package/src/client/panel/ProgressConsole.tsx +63 -0
  105. package/src/client/panel/PromptSlotsView.tsx +97 -0
  106. package/src/client/panel/ReaderView.tsx +306 -306
  107. package/src/client/panel/ReasoningSection.tsx +31 -0
  108. package/src/client/panel/RunPanel.tsx +64 -32
  109. package/src/client/panel/SettingsView.tsx +354 -0
  110. package/src/client/panel/ShelfView.tsx +266 -236
  111. package/src/client/panel/SubPage.tsx +42 -0
  112. package/src/client/panel/TensionView.tsx +212 -0
  113. package/src/client/panel/TimelineView.tsx +208 -0
  114. package/src/client/panel/WorldTab.tsx +202 -202
  115. package/src/client/panel/helpers.ts +7 -5
  116. package/src/client/panel/panel.module.css +4481 -1655
  117. package/src/client/panel/toast.ts +51 -0
  118. package/src/client/panel/views.tsx +401 -314
  119. package/src/client/run-status.ts +171 -0
  120. package/src/client/sidebar-entry.ts +1 -1
  121. package/src/client/text.ts +28 -0
  122. package/src/docx-core.ts +53 -0
  123. package/src/docx.ts +44 -83
  124. package/src/engine.ts +4672 -2943
  125. package/src/global-assets.ts +59 -0
  126. package/src/index.ts +89 -33
  127. package/src/llm-live.ts +260 -0
  128. package/src/llm-retry.ts +77 -0
  129. package/src/market-radar-scan.ts +41 -0
  130. package/src/market-radar-sources.ts +173 -0
  131. package/src/migrate-output.ts +76 -0
  132. package/src/novel-context.ts +286 -0
  133. package/src/prompt-slots.ts +125 -0
  134. package/src/prompting.ts +26 -0
  135. package/src/protocol.ts +1388 -158
  136. package/src/review-policy.ts +161 -0
  137. package/src/revision.ts +283 -0
  138. package/src/routes.ts +1934 -434
  139. package/src/run.ts +118 -21
  140. package/src/snapshots.ts +174 -0
  141. package/src/stage-contract.ts +189 -0
  142. package/src/tension.ts +251 -0
  143. package/src/timeline.ts +159 -0
  144. package/lib/client.js.map +0 -1
  145. package/lib/index.js.map +0 -1
  146. package/lib/types/comic-presets.d.ts +0 -12
  147. package/scripts/assistant-frames.txt +0 -8
  148. package/scripts/diagnose-adapter.mts +0 -81
  149. package/scripts/diagnose-assistant.mjs +0 -58
  150. package/scripts/diagnose-bible.mjs +0 -72
  151. package/scripts/diagnose-harness.mts +0 -75
  152. package/scripts/novel-forge-restart.log +0 -10
  153. package/scripts/novel-forge-web.stderr.log +0 -2
  154. package/scripts/novel-forge-web.stdout.log +0 -1
  155. package/scripts/outline-sample.txt +0 -449
  156. package/scripts/probe.mjs +0 -6
  157. package/scripts/restart-web.ps1 +0 -88
  158. package/scripts/smoke.mts +0 -72
  159. package/scripts/upload-github.ps1 +0 -51
  160. package/src/client/panel/BookshelfBar.tsx +0 -170
  161. package/src/comic-presets.ts +0 -67
@@ -1,301 +1,355 @@
1
- /**
2
- * AI 助手(编辑老师):与 AI 编辑对话讨论剧情,助手通过动作指令直接修改
3
- * 大纲 / 设定圣经 / 章节。流式渲染回复;工具调用显示为步骤卡片(中文名 +
4
- * 状态 + 耗时),顶部状态条展示当前在做什么;支持清空对话记录。
5
- */
6
- import { useCallback, useEffect, useRef, useState } from 'react'
7
- import type { NovelApi } from '../api.ts'
8
- import { tt } from './helpers.ts'
9
- import type { AssistantFrame, AssistantMessage } from '../../protocol.ts'
10
- import css from './panel.module.css'
11
-
12
- /** 工具中文名 + 图标映射(作者看得懂)。 */
13
- const TOOL_LABELS: Record<string, { icon: string; label: string }> = {
14
- book_overview: { icon: '📖', label: '读取全书上下文' },
15
- impact_analysis: { icon: '🔗', label: '影响分析' },
16
- outline_text: { icon: '📄', label: '读取大纲' },
17
- outline_replace: { icon: '📝', label: '修改大纲' },
18
- bible_set_rule: { icon: '📖', label: '修改设定规则' },
19
- bible_set_redline: { icon: '🚫', label: '修改写作红线' },
20
- chapter_text: { icon: '📄', label: '读取章节' },
21
- chapter_rewrite: { icon: '✏️', label: '修订章节' },
22
- chapter_generate: { icon: '✨', label: '生成章节' },
23
- chapter_review: { icon: '🔍', label: 'AI 审稿' },
24
- foreshadow_add: { icon: '🪤', label: '新增伏笔' },
25
- foreshadow_update: { icon: '🪤', label: '更新伏笔' },
26
- export_txt: { icon: '📦', label: '导出 TXT' },
27
- assets_status: { icon: '🎨', label: '查看写作资产' },
28
- assets_set_genre: { icon: '🏷️', label: '设置题材' },
29
- assets_set_progression: { icon: '📈', label: '设置推进模式' },
30
- assets_add_rule: { icon: '🚫', label: '新增反AI规则' },
31
- error: { icon: '⚠️', label: '出错了' },
32
- }
33
-
34
- /** One chat bubble (either side). */
35
- interface ChatLine {
36
- id: number
37
- role: 'user' | 'assistant'
38
- text: string
39
- /** Tool steps interleaved with the assistant reply. */
40
- tools: Array<{ name: string; status: 'start' | 'done' | 'error'; detail?: string; startedAt?: number; elapsedMs?: number }>
41
- /** Live output while a tool runs (generated text streamed into the bubble). */
42
- live?: string
43
- }
44
-
45
- /** Props. */
46
- export interface AssistantTabProps {
47
- api: NovelApi
48
- }
49
-
50
- /** The assistant conversation tab. */
51
- export function AssistantTab({ api }: AssistantTabProps) {
52
- const [lines, setLines] = useState<ChatLine[]>([])
53
- const [input, setInput] = useState('')
54
- const [busy, setBusy] = useState(false)
55
- const [error, setError] = useState('')
56
- const [notice, setNotice] = useState('')
57
- /** 思考耗时(秒,busy 时每秒刷新)。 */
58
- const [thinkSeconds, setThinkSeconds] = useState(0)
59
- const idRef = useRef(0)
60
- const scrollRef = useRef<HTMLDivElement | null>(null)
61
-
62
- /** busy 计时:显示"已思考 Xs",让作者知道在干活。 */
63
- useEffect(() => {
64
- if (!busy) {
65
- setThinkSeconds(0)
66
- return
67
- }
68
- const started = Date.now()
69
- const timer = window.setInterval(() => {
70
- setThinkSeconds(Math.floor((Date.now() - started) / 1000))
71
- }, 1000)
72
- return () => { window.clearInterval(timer) }
73
- }, [busy])
74
-
75
- /** Append a bubble (or extend the current assistant bubble). */
76
- const pushLine = useCallback((line: Omit<ChatLine, 'id'>) => {
77
- setLines(prev => {
78
- const last = prev[prev.length - 1]
79
- // Extend the live assistant bubble while streaming.
80
- if (line.role === 'assistant' && last !== undefined && last.role === 'assistant' && last.tools.length === 0) {
81
- return [...prev.slice(0, -1), { ...last, text: last.text + line.text }]
82
- }
83
- return [...prev, { ...line, id: idRef.current++ }]
84
- })
85
- }, [])
86
-
87
- /** Push a tool event: start 创建步骤,done/error 更新同名步骤(含耗时)。 */
88
- const pushTool = useCallback((tool: ChatLine['tools'][number]) => {
89
- setLines(prev => {
90
- const last = prev[prev.length - 1]
91
- if (last === undefined || last.role !== 'assistant') {
92
- return [...prev, {
93
- id: idRef.current++,
94
- role: 'assistant',
95
- text: '',
96
- tools: [{ ...tool, startedAt: tool.status === 'start' ? Date.now() : undefined }],
97
- }]
98
- }
99
- const tools = [...last.tools]
100
- if (tool.status === 'start') {
101
- tools.push({ ...tool, startedAt: Date.now() })
102
- } else {
103
- // 找到同名未完成的步骤,更新状态与耗时。
104
- for (let i = tools.length - 1; i >= 0; i--) {
105
- const step = tools[i]!
106
- if (step.name === tool.name && step.status === 'start') {
107
- tools[i] = {
108
- ...step,
109
- status: tool.status,
110
- detail: tool.detail,
111
- elapsedMs: Date.now() - (step.startedAt ?? Date.now()),
112
- }
113
- break
114
- }
115
- }
116
- }
117
- return [...prev.slice(0, -1), { ...last, tools, live: undefined }]
118
- })
119
- }, [])
120
-
121
- /** Append live tool output onto the current assistant bubble. */
122
- const pushToolDelta = useCallback((text: string) => {
123
- setLines(prev => {
124
- const last = prev[prev.length - 1]
125
- if (last === undefined || last.role !== 'assistant') return prev
126
- return [...prev.slice(0, -1), { ...last, live: (last.live ?? '') + text }]
127
- })
128
- }, [])
129
-
130
- /** 当前进行中的工具(状态条显示)。 */
131
- const activeTool = (() => {
132
- if (!busy) return null
133
- for (let i = lines.length - 1; i >= 0; i--) {
134
- const line = lines[i]!
135
- if (line.role !== 'assistant') continue
136
- for (let j = line.tools.length - 1; j >= 0; j--) {
137
- if (line.tools[j]!.status === 'start') return line.tools[j]!
138
- }
139
- }
140
- return null
141
- })()
142
-
143
- /** Load persisted history on mount. */
144
- useEffect(() => {
145
- let cancelled = false
146
- void (async () => {
147
- try {
148
- const history = await api.assistantHistory()
149
- if (cancelled) return
150
- const restored: ChatLine[] = []
151
- for (const entry of history) {
152
- if (entry.role === 'user') {
153
- restored.push({ id: idRef.current++, role: 'user', text: entry.content, tools: [] })
154
- } else if (entry.role === 'assistant') {
155
- restored.push({ id: idRef.current++, role: 'assistant', text: entry.content, tools: [] })
156
- }
157
- }
158
- setLines(restored)
159
- } catch (err) {
160
- if (!cancelled) setError((err as Error).message)
161
- }
162
- })()
163
- return () => { cancelled = true }
164
- }, [api])
165
-
166
- /** Auto-scroll to the newest line. */
167
- useEffect(() => {
168
- scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
169
- }, [lines])
170
-
171
- /** 清空对话记录(服务端删除历史 + 本地清空)。 */
172
- const handleClear = async (): Promise<void> => {
173
- if (!window.confirm('清空全部聊天记录?此操作不可恢复(不影响大纲/设定/章节)。')) return
174
- setBusy(true)
175
- setError('')
176
- try {
177
- await api.assistantClear()
178
- setLines([])
179
- setNotice('对话已清空,编辑老师会重新了解项目')
180
- } catch (err) {
181
- setError((err as Error).message)
182
- } finally {
183
- setBusy(false)
184
- }
185
- }
186
-
187
- /** Send one message. */
188
- const handleSend = async (): Promise<void> => {
189
- const message = input.trim()
190
- if (message === '' || busy) return
191
- setInput('')
192
- setError('')
193
- setNotice('')
194
- pushLine({ role: 'user', text: message, tools: [] })
195
- // Start an empty assistant bubble.
196
- setLines(prev => [...prev, { id: idRef.current++, role: 'assistant', text: '', tools: [] }])
197
- setBusy(true)
198
- try {
199
- await api.assistant(message, (frame: AssistantFrame) => {
200
- if (frame.type === 'delta') {
201
- pushLine({ role: 'assistant', text: frame.text, tools: [] })
202
- } else if (frame.type === 'tool') {
203
- pushTool({
204
- name: frame.name,
205
- status: frame.status,
206
- detail: frame.detail,
207
- })
208
- } else if (frame.type === 'toolDelta') {
209
- pushToolDelta(frame.text)
210
- } else if (frame.type === 'error') {
211
- pushTool({ name: 'error', status: 'error', detail: frame.message })
212
- }
213
- })
214
- } catch (err) {
215
- setError((err as Error).message)
216
- } finally {
217
- setBusy(false)
218
- }
219
- }
220
-
221
- return (
222
- <div className={css.card} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
223
- <div className={css.row} style={{ justifyContent: 'space-between' }}>
224
- <span className={css.cardTitle}>{tt('tab.assistant')}</span>
225
- <button type="button" className={css.iconButton} title="清空聊天记录" aria-label="清空聊天记录" onClick={() => { void handleClear() }}>
226
- 🗑️
227
- </button>
228
- </div>
229
- <span className={css.meta}>{tt('assistant.hint')}</span>
230
- {notice !== '' && <span style={{ color: 'var(--nf-success)', fontSize: 12 }}>{notice}</span>}
231
- {error !== '' && <span style={{ color: 'var(--nf-error)', fontSize: 12 }}>{tt('common.error')}: {error}</span>}
232
- {/* 状态条:编辑老师正在做什么 */}
233
- <div className={`${css.assistantStatus} ${busy ? css.assistantStatusBusy : ''}`}>
234
- {busy
235
- ? `🤖 编辑老师 · ${activeTool !== null ? `正在「${TOOL_LABELS[activeTool.name]?.label ?? activeTool.name}」` : `正在思考…(已 ${thinkSeconds}s)`}`
236
- : '💬 编辑老师 · 等你开口'}
237
- </div>
238
- <div
239
- ref={scrollRef}
240
- className={css.chatScroll}
241
- >
242
- {lines.length === 0 && <span className={css.meta}>{tt('assistant.empty')}</span>}
243
- {lines.map(line => (
244
- <div key={line.id} className={line.role === 'user' ? css.chatBubbleUser : css.chatBubbleAssistant}>
245
- {line.role === 'user' && <div className={css.chatRole}>你</div>}
246
- {line.text !== '' && <div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{line.text}</div>}
247
- {line.live !== undefined && line.live !== '' && (
248
- <div className={css.toolLive}>{line.live}</div>
249
- )}
250
- {/* 步骤卡片 */}
251
- {line.tools.length > 0 && (
252
- <div className={css.toolSteps}>
253
- {line.tools.map((tool, i) => {
254
- const meta = TOOL_LABELS[tool.name] ?? { icon: '⚙️', label: tool.name }
255
- return (
256
- <div
257
- key={i}
258
- className={`${css.toolStep} ${tool.status === 'error' ? css.toolStepError : tool.status === 'start' ? css.toolStepActive : ''}`}
259
- >
260
- <span className={css.toolStepIcon}>{meta.icon}</span>
261
- <span className={css.toolStepName}>{meta.label}</span>
262
- <span className={css.toolStepStatus}>
263
- {tool.status === 'start'
264
- ? '⏳ 进行中'
265
- : tool.status === 'done'
266
- ? `✓ ${tool.elapsedMs !== undefined ? `${(tool.elapsedMs / 1000).toFixed(1)}s` : ''}`
267
- : '✗ 失败'}
268
- </span>
269
- {tool.status === 'error' && tool.detail !== undefined && (
270
- <span className={css.toolStepDetail}>{tool.detail}</span>
271
- )}
272
- </div>
273
- )
274
- })}
275
- </div>
276
- )}
277
- </div>
278
- ))}
279
- {busy && <span className={css.meta} style={{ color: 'var(--nf-accent)' }}>…</span>}
280
- </div>
281
- <div className={css.row} style={{ marginTop: 8 }}>
282
- <textarea
283
- className={css.textarea}
284
- style={{ minHeight: 64, flex: 1 }}
285
- placeholder={tt('assistant.placeholder')}
286
- value={input}
287
- onChange={e => { setInput(e.target.value) }}
288
- onKeyDown={e => {
289
- if (e.key === 'Enter' && !e.shiftKey) {
290
- e.preventDefault()
291
- void handleSend()
292
- }
293
- }}
294
- />
295
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || input.trim() === ''} onClick={() => { void handleSend() }}>
296
- {tt('assistant.send')}
297
- </button>
298
- </div>
299
- </div>
300
- )
301
- }
1
+ /**
2
+ * AI 助手(编辑老师):与 AI 编辑对话讨论剧情,助手通过动作指令直接修改
3
+ * 大纲 / 道藏 / 章节。流式渲染回复;工具调用显示为步骤卡片(中文名 +
4
+ * 状态 + 耗时),顶部状态条展示当前在做什么;支持清空对话记录。
5
+ */
6
+ import { useCallback, useEffect, useRef, useState } from 'react'
7
+ import type { NovelApi } from '../api.ts'
8
+ import { tt } from './helpers.ts'
9
+ import type { AssistantFrame, StageInfo } from '../../protocol.ts'
10
+ import css from './panel.module.css'
11
+
12
+ /** 工具中文名 + 图标映射(作者看得懂)。 */
13
+ const TOOL_LABELS: Record<string, { icon: string; label: string }> = {
14
+ book_overview: { icon: '', label: '读取全书上下文' },
15
+ impact_analysis: { icon: '', label: '影响分析' },
16
+ outline_text: { icon: '', label: '读取大纲' },
17
+ outline_replace: { icon: '', label: '修改大纲' },
18
+ bible_set_rule: { icon: '', label: '修改设定规则' },
19
+ bible_set_redline: { icon: '', label: '修改写作红线' },
20
+ chapter_text: { icon: '', label: '读取章节' },
21
+ chapter_rewrite: { icon: '', label: '修订章节' },
22
+ chapter_generate: { icon: '', label: '生成章节' },
23
+ chapter_review: { icon: '', label: 'AI 审稿' },
24
+ foreshadow_add: { icon: '', label: '新增伏笔' },
25
+ foreshadow_update: { icon: '', label: '更新伏笔' },
26
+ export_txt: { icon: '', label: '导出 TXT' },
27
+ assets_status: { icon: '', label: '查看写作资产' },
28
+ assets_set_genre: { icon: '', label: '设置题材' },
29
+ assets_set_progression: { icon: '', label: '设置推进模式' },
30
+ assets_add_rule: { icon: '', label: '新增反AI规则' },
31
+ book_analysis: { icon: '', label: '拆书分析' },
32
+ director_advice: { icon: '', label: '自动编辑' },
33
+ knowledge_add: { icon: '', label: '加入知识库' },
34
+ knowledge_search: { icon: '', label: '检索知识库' },
35
+ plotline_list: { icon: '', label: '查看剧情线' },
36
+ plotline_add: { icon: '', label: '新增剧情线' },
37
+ director_todo_add: { icon: '', label: '记入编辑待办' },
38
+ director_todo_list: { icon: '', label: '查看编辑待办' },
39
+ knowledge_list: { icon: '', label: '列出全部知识库' },
40
+ breakdown: { icon: '', label: '拆书分析' },
41
+ audit: { icon: '', label: '全书质检' },
42
+ blurb: { icon: '', label: '生成简介' },
43
+ error: { icon: '', label: '出错了' },
44
+ }
45
+
46
+ /** 需要渲染成结构化结果卡片的工具(其余工具结果仍走文字回复,避免大段章节/总纲刷屏)。 */
47
+ const CARD_TOOLS = new Set(['director_advice', 'book_analysis', 'breakdown', 'audit', 'knowledge_list', 'plotline_list', 'director_todo_list'])
48
+
49
+ /** One chat bubble (either side). */
50
+ interface ChatLine {
51
+ id: number
52
+ role: 'user' | 'assistant'
53
+ text: string
54
+ /** Tool steps interleaved with the assistant reply. */
55
+ tools: Array<{ name: string; status: 'start' | 'done' | 'error'; detail?: string; startedAt?: number; elapsedMs?: number }>
56
+ /** Live output while a tool runs (generated text streamed into the bubble). */
57
+ live?: string
58
+ /** 结构化工具完整结果(用于渲染成卡片,如拆书/导演/质检/知识库列表)。 */
59
+ resultCard?: { name: string; text: string }
60
+ }
61
+
62
+ /** Props. */
63
+ export interface AssistantTabProps {
64
+ api: NovelApi
65
+ /** 宿主算出的创作阶段(/status.stage):让作者在同一屏看到「现在该做什么」。 */
66
+ stage?: StageInfo | null
67
+ }
68
+
69
+ /** The assistant conversation tab. */
70
+ export function AssistantTab({ api, stage }: AssistantTabProps) {
71
+ const [lines, setLines] = useState<ChatLine[]>([])
72
+ const [input, setInput] = useState('')
73
+ const [busy, setBusy] = useState(false)
74
+ const [error, setError] = useState('')
75
+ const [notice, setNotice] = useState('')
76
+ /** 思考耗时(秒,busy 时每秒刷新)。 */
77
+ const [thinkSeconds, setThinkSeconds] = useState(0)
78
+ const idRef = useRef(0)
79
+ const scrollRef = useRef<HTMLDivElement | null>(null)
80
+
81
+ /** busy 计时:显示"已思考 Xs",让作者知道在干活。 */
82
+ useEffect(() => {
83
+ if (!busy) {
84
+ setThinkSeconds(0)
85
+ return
86
+ }
87
+ const started = Date.now()
88
+ const timer = window.setInterval(() => {
89
+ setThinkSeconds(Math.floor((Date.now() - started) / 1000))
90
+ }, 1000)
91
+ return () => { window.clearInterval(timer) }
92
+ }, [busy])
93
+
94
+ /** Append a bubble (or extend the current assistant bubble). */
95
+ const pushLine = useCallback((line: Omit<ChatLine, 'id'>) => {
96
+ setLines(prev => {
97
+ const last = prev[prev.length - 1]
98
+ // Extend the live assistant bubble while streaming.
99
+ if (line.role === 'assistant' && last !== undefined && last.role === 'assistant' && last.tools.length === 0) {
100
+ return [...prev.slice(0, -1), { ...last, text: last.text + line.text }]
101
+ }
102
+ return [...prev, { ...line, id: idRef.current++ }]
103
+ })
104
+ }, [])
105
+
106
+ /** Push a tool event: start 创建步骤,done/error 更新同名步骤(含耗时)。 */
107
+ const pushTool = useCallback((tool: ChatLine['tools'][number]) => {
108
+ setLines(prev => {
109
+ const last = prev[prev.length - 1]
110
+ if (last === undefined || last.role !== 'assistant') {
111
+ return [...prev, {
112
+ id: idRef.current++,
113
+ role: 'assistant',
114
+ text: '',
115
+ tools: [{ ...tool, startedAt: tool.status === 'start' ? Date.now() : undefined }],
116
+ }]
117
+ }
118
+ const tools = [...last.tools]
119
+ if (tool.status === 'start') {
120
+ tools.push({ ...tool, startedAt: Date.now() })
121
+ } else {
122
+ // 找到同名未完成的步骤,更新状态与耗时。
123
+ for (let i = tools.length - 1; i >= 0; i--) {
124
+ const step = tools[i]!
125
+ if (step.name === tool.name && step.status === 'start') {
126
+ tools[i] = {
127
+ ...step,
128
+ status: tool.status,
129
+ detail: tool.detail,
130
+ elapsedMs: Date.now() - (step.startedAt ?? Date.now()),
131
+ }
132
+ break
133
+ }
134
+ }
135
+ }
136
+ return [...prev.slice(0, -1), { ...last, tools, live: undefined }]
137
+ })
138
+ }, [])
139
+
140
+ /** Append live tool output onto the current assistant bubble. */
141
+ const pushToolDelta = useCallback((text: string) => {
142
+ setLines(prev => {
143
+ const last = prev[prev.length - 1]
144
+ if (last === undefined || last.role !== 'assistant') return prev
145
+ return [...prev.slice(0, -1), { ...last, live: (last.live ?? '') + text }]
146
+ })
147
+ }, [])
148
+
149
+ /** 把结构化工具的结果挂到当前助手气泡(用于渲染结果卡片)。 */
150
+ const pushResultCard = useCallback((card: { name: string; text: string }) => {
151
+ setLines(prev => {
152
+ const last = prev[prev.length - 1]
153
+ if (last === undefined || last.role !== 'assistant') {
154
+ return [...prev, { id: idRef.current++, role: 'assistant', text: '', tools: [], resultCard: card }]
155
+ }
156
+ return [...prev.slice(0, -1), { ...last, resultCard: card }]
157
+ })
158
+ }, [])
159
+
160
+ /** 当前进行中的工具(状态条显示)。 */
161
+ const activeTool = (() => {
162
+ if (!busy) return null
163
+ for (let i = lines.length - 1; i >= 0; i--) {
164
+ const line = lines[i]!
165
+ if (line.role !== 'assistant') continue
166
+ for (let j = line.tools.length - 1; j >= 0; j--) {
167
+ if (line.tools[j]!.status === 'start') return line.tools[j]!
168
+ }
169
+ }
170
+ return null
171
+ })()
172
+
173
+ /** Load persisted history on mount. */
174
+ useEffect(() => {
175
+ let cancelled = false
176
+ void (async () => {
177
+ try {
178
+ const history = await api.assistantHistory()
179
+ if (cancelled) return
180
+ const restored: ChatLine[] = []
181
+ for (const entry of history) {
182
+ if (entry.role === 'user') {
183
+ restored.push({ id: idRef.current++, role: 'user', text: entry.content, tools: [] })
184
+ } else if (entry.role === 'assistant') {
185
+ restored.push({ id: idRef.current++, role: 'assistant', text: entry.content, tools: [] })
186
+ }
187
+ }
188
+ setLines(restored)
189
+ } catch (err) {
190
+ if (!cancelled) setError((err as Error).message)
191
+ }
192
+ })()
193
+ return () => { cancelled = true }
194
+ }, [api])
195
+
196
+ /** Auto-scroll to the newest line. */
197
+ useEffect(() => {
198
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
199
+ }, [lines])
200
+
201
+ /** 清空对话记录(服务端删除历史 + 本地清空)。 */
202
+ const handleClear = async (): Promise<void> => {
203
+ if (!window.confirm('清空全部聊天记录?此操作不可恢复(不影响大纲/设定/章节)。')) return
204
+ setBusy(true)
205
+ setError('')
206
+ try {
207
+ await api.assistantClear()
208
+ setLines([])
209
+ setNotice('对话已清空,编辑老师会重新了解项目')
210
+ } catch (err) {
211
+ setError((err as Error).message)
212
+ } finally {
213
+ setBusy(false)
214
+ }
215
+ }
216
+
217
+ /** Send one message. */
218
+ const handleSend = async (): Promise<void> => {
219
+ const message = input.trim()
220
+ if (message === '' || busy) return
221
+ setInput('')
222
+ setError('')
223
+ setNotice('')
224
+ pushLine({ role: 'user', text: message, tools: [] })
225
+ // Start an empty assistant bubble.
226
+ setLines(prev => [...prev, { id: idRef.current++, role: 'assistant', text: '', tools: [] }])
227
+ setBusy(true)
228
+ try {
229
+ await api.assistant(message, (frame: AssistantFrame) => {
230
+ if (frame.type === 'delta') {
231
+ pushLine({ role: 'assistant', text: frame.text, tools: [] })
232
+ } else if (frame.type === 'tool') {
233
+ pushTool({
234
+ name: frame.name,
235
+ status: frame.status,
236
+ detail: frame.detail,
237
+ })
238
+ } else if (frame.type === 'toolDelta') {
239
+ pushToolDelta(frame.text)
240
+ } else if (frame.type === 'toolResult') {
241
+ pushResultCard({ name: frame.name, text: frame.text })
242
+ } else if (frame.type === 'error') {
243
+ pushTool({ name: 'error', status: 'error', detail: frame.message })
244
+ }
245
+ })
246
+ } catch (err) {
247
+ setError((err as Error).message)
248
+ } finally {
249
+ setBusy(false)
250
+ }
251
+ }
252
+
253
+ return (
254
+ <div className={css.card} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
255
+ <div className={css.row} style={{ justifyContent: 'space-between', alignItems: 'center', gap: 'var(--nf-space-8)' }}>
256
+ <span className={css.meta}>{tt('assistant.hint')}</span>
257
+ <button type="button" className={css.iconButton} title="清空聊天记录" aria-label="清空聊天记录" onClick={() => { void handleClear() }}>
258
+
259
+ </button>
260
+ </div>
261
+ {notice !== '' && <span style={{ color: 'var(--nf-success)', fontSize: 'var(--nf-fs-12)' }}>{notice}</span>}
262
+ {error !== '' && <span style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-fs-12)' }}>{tt('common.error')}: {error}</span>}
263
+ {/* 状态条:编辑老师正在做什么 */}
264
+ <div className={`${css.assistantStatus} ${busy ? css.assistantStatusBusy : ''}`}>
265
+ {busy
266
+ ? ` 编辑老师 · ${activeTool !== null ? `正在「${TOOL_LABELS[activeTool.name]?.label ?? activeTool.name}」` : `正在思考…(已 ${thinkSeconds}s)`}`
267
+ : ' 编辑老师 · 等你开口'}
268
+ </div>
269
+ {/* 阶段条:宿主根据项目真实状态算出的当前阶段与建议下一步(与工作台角标同源)。 */}
270
+ {stage != null && (
271
+ <div
272
+ className={css.meta}
273
+ title={`阶段判定:${stage.reason}`}
274
+ style={{ display: 'flex', alignItems: 'center', gap: 'var(--nf-space-8)', flexWrap: 'wrap', fontSize: 'var(--nf-fs-12)' }}
275
+ >
276
+ <span>当前阶段 · {stage.label}</span>
277
+ {stage.recommend.length > 0 && (
278
+ <span>建议下一步:{stage.recommend.map(t => TOOL_LABELS[t]?.label ?? t).join(' / ')}</span>
279
+ )}
280
+ {stage.allow.length === 0 && <span>(本阶段由面板操作推进)</span>}
281
+ </div>
282
+ )}
283
+ <div
284
+ ref={scrollRef}
285
+ className={css.chatScroll}
286
+ >
287
+ {lines.length === 0 && <span className={css.meta}>{tt('assistant.empty')}</span>}
288
+ {lines.map(line => (
289
+ <div key={line.id} className={line.role === 'user' ? css.chatBubbleUser : css.chatBubbleAssistant}>
290
+ {line.role === 'user' && <div className={css.chatRole}>你</div>}
291
+ {line.text !== '' && <div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{line.text}</div>}
292
+ {line.live !== undefined && line.live !== '' && (
293
+ <div className={css.toolLive}>{line.live}</div>
294
+ )}
295
+ {/* 步骤卡片 */}
296
+ {line.tools.length > 0 && (
297
+ <div className={css.toolSteps}>
298
+ {line.tools.map((tool, i) => {
299
+ const meta = TOOL_LABELS[tool.name] ?? { icon: '', label: tool.name }
300
+ return (
301
+ <div
302
+ key={i}
303
+ className={`${css.toolStep} ${tool.status === 'error' ? css.toolStepError : tool.status === 'start' ? css.toolStepActive : ''}`}
304
+ >
305
+ <span className={css.toolStepIcon}>{meta.icon}</span>
306
+ <span className={css.toolStepName}>{meta.label}</span>
307
+ <span className={css.toolStepStatus}>
308
+ {tool.status === 'start'
309
+ ? '⏳ 进行中'
310
+ : tool.status === 'done'
311
+ ? `✓ ${tool.elapsedMs !== undefined ? `${(tool.elapsedMs / 1000).toFixed(1)}s` : ''}`
312
+ : '✗ 失败'}
313
+ </span>
314
+ {tool.status === 'error' && tool.detail !== undefined && (
315
+ <span className={css.toolStepDetail}>{tool.detail}</span>
316
+ )}
317
+ </div>
318
+ )
319
+ })}
320
+ </div>
321
+ )}
322
+ {/* 结构化结果卡片(拆书/导演/质检/知识库列表等) */}
323
+ {line.resultCard !== undefined && CARD_TOOLS.has(line.resultCard.name) && (
324
+ <div style={{ marginTop: 6, border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10, 10px)', background: 'var(--nf-bg-inset)', padding: 'var(--nf-space-8, 8px)' }}>
325
+ <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--nf-accent)', marginBottom: 4 }}>
326
+ {TOOL_LABELS[line.resultCard.name]?.icon ?? ''} {TOOL_LABELS[line.resultCard.name]?.label ?? line.resultCard.name}
327
+ </div>
328
+ <pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0, fontSize: 12, lineHeight: 1.5, color: 'var(--nf-text)', maxHeight: 240, overflowY: 'auto' }}>{line.resultCard.text}</pre>
329
+ </div>
330
+ )}
331
+ </div>
332
+ ))}
333
+ {busy && <span className={css.meta} style={{ color: 'var(--nf-accent)' }}>…</span>}
334
+ </div>
335
+ <div className={css.row} style={{ marginTop: 'var(--nf-space-8)' }}>
336
+ <textarea
337
+ className={css.textarea}
338
+ style={{ minHeight: 64, flex: 1 }}
339
+ placeholder={tt('assistant.placeholder')}
340
+ value={input}
341
+ onChange={e => { setInput(e.target.value) }}
342
+ onKeyDown={e => {
343
+ if (e.key === 'Enter' && !e.shiftKey) {
344
+ e.preventDefault()
345
+ void handleSend()
346
+ }
347
+ }}
348
+ />
349
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || input.trim() === ''} onClick={() => { void handleSend() }}>
350
+ {tt('assistant.send')}
351
+ </button>
352
+ </div>
353
+ </div>
354
+ )
355
+ }