@waterwx/dsh-novel-forge 2.1.1-alpha → 2.1.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.
@@ -1,339 +1,339 @@
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
- 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
- }
66
-
67
- /** The assistant conversation tab. */
68
- export function AssistantTab({ api }: AssistantTabProps) {
69
- const [lines, setLines] = useState<ChatLine[]>([])
70
- const [input, setInput] = useState('')
71
- const [busy, setBusy] = useState(false)
72
- const [error, setError] = useState('')
73
- const [notice, setNotice] = useState('')
74
- /** 思考耗时(秒,busy 时每秒刷新)。 */
75
- const [thinkSeconds, setThinkSeconds] = useState(0)
76
- const idRef = useRef(0)
77
- const scrollRef = useRef<HTMLDivElement | null>(null)
78
-
79
- /** busy 计时:显示"已思考 Xs",让作者知道在干活。 */
80
- useEffect(() => {
81
- if (!busy) {
82
- setThinkSeconds(0)
83
- return
84
- }
85
- const started = Date.now()
86
- const timer = window.setInterval(() => {
87
- setThinkSeconds(Math.floor((Date.now() - started) / 1000))
88
- }, 1000)
89
- return () => { window.clearInterval(timer) }
90
- }, [busy])
91
-
92
- /** Append a bubble (or extend the current assistant bubble). */
93
- const pushLine = useCallback((line: Omit<ChatLine, 'id'>) => {
94
- setLines(prev => {
95
- const last = prev[prev.length - 1]
96
- // Extend the live assistant bubble while streaming.
97
- if (line.role === 'assistant' && last !== undefined && last.role === 'assistant' && last.tools.length === 0) {
98
- return [...prev.slice(0, -1), { ...last, text: last.text + line.text }]
99
- }
100
- return [...prev, { ...line, id: idRef.current++ }]
101
- })
102
- }, [])
103
-
104
- /** Push a tool event: start 创建步骤,done/error 更新同名步骤(含耗时)。 */
105
- const pushTool = useCallback((tool: ChatLine['tools'][number]) => {
106
- setLines(prev => {
107
- const last = prev[prev.length - 1]
108
- if (last === undefined || last.role !== 'assistant') {
109
- return [...prev, {
110
- id: idRef.current++,
111
- role: 'assistant',
112
- text: '',
113
- tools: [{ ...tool, startedAt: tool.status === 'start' ? Date.now() : undefined }],
114
- }]
115
- }
116
- const tools = [...last.tools]
117
- if (tool.status === 'start') {
118
- tools.push({ ...tool, startedAt: Date.now() })
119
- } else {
120
- // 找到同名未完成的步骤,更新状态与耗时。
121
- for (let i = tools.length - 1; i >= 0; i--) {
122
- const step = tools[i]!
123
- if (step.name === tool.name && step.status === 'start') {
124
- tools[i] = {
125
- ...step,
126
- status: tool.status,
127
- detail: tool.detail,
128
- elapsedMs: Date.now() - (step.startedAt ?? Date.now()),
129
- }
130
- break
131
- }
132
- }
133
- }
134
- return [...prev.slice(0, -1), { ...last, tools, live: undefined }]
135
- })
136
- }, [])
137
-
138
- /** Append live tool output onto the current assistant bubble. */
139
- const pushToolDelta = useCallback((text: string) => {
140
- setLines(prev => {
141
- const last = prev[prev.length - 1]
142
- if (last === undefined || last.role !== 'assistant') return prev
143
- return [...prev.slice(0, -1), { ...last, live: (last.live ?? '') + text }]
144
- })
145
- }, [])
146
-
147
- /** 把结构化工具的结果挂到当前助手气泡(用于渲染结果卡片)。 */
148
- const pushResultCard = useCallback((card: { name: string; text: string }) => {
149
- setLines(prev => {
150
- const last = prev[prev.length - 1]
151
- if (last === undefined || last.role !== 'assistant') {
152
- return [...prev, { id: idRef.current++, role: 'assistant', text: '', tools: [], resultCard: card }]
153
- }
154
- return [...prev.slice(0, -1), { ...last, resultCard: card }]
155
- })
156
- }, [])
157
-
158
- /** 当前进行中的工具(状态条显示)。 */
159
- const activeTool = (() => {
160
- if (!busy) return null
161
- for (let i = lines.length - 1; i >= 0; i--) {
162
- const line = lines[i]!
163
- if (line.role !== 'assistant') continue
164
- for (let j = line.tools.length - 1; j >= 0; j--) {
165
- if (line.tools[j]!.status === 'start') return line.tools[j]!
166
- }
167
- }
168
- return null
169
- })()
170
-
171
- /** Load persisted history on mount. */
172
- useEffect(() => {
173
- let cancelled = false
174
- void (async () => {
175
- try {
176
- const history = await api.assistantHistory()
177
- if (cancelled) return
178
- const restored: ChatLine[] = []
179
- for (const entry of history) {
180
- if (entry.role === 'user') {
181
- restored.push({ id: idRef.current++, role: 'user', text: entry.content, tools: [] })
182
- } else if (entry.role === 'assistant') {
183
- restored.push({ id: idRef.current++, role: 'assistant', text: entry.content, tools: [] })
184
- }
185
- }
186
- setLines(restored)
187
- } catch (err) {
188
- if (!cancelled) setError((err as Error).message)
189
- }
190
- })()
191
- return () => { cancelled = true }
192
- }, [api])
193
-
194
- /** Auto-scroll to the newest line. */
195
- useEffect(() => {
196
- scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
197
- }, [lines])
198
-
199
- /** 清空对话记录(服务端删除历史 + 本地清空)。 */
200
- const handleClear = async (): Promise<void> => {
201
- if (!window.confirm('清空全部聊天记录?此操作不可恢复(不影响大纲/设定/章节)。')) return
202
- setBusy(true)
203
- setError('')
204
- try {
205
- await api.assistantClear()
206
- setLines([])
207
- setNotice('对话已清空,编辑老师会重新了解项目')
208
- } catch (err) {
209
- setError((err as Error).message)
210
- } finally {
211
- setBusy(false)
212
- }
213
- }
214
-
215
- /** Send one message. */
216
- const handleSend = async (): Promise<void> => {
217
- const message = input.trim()
218
- if (message === '' || busy) return
219
- setInput('')
220
- setError('')
221
- setNotice('')
222
- pushLine({ role: 'user', text: message, tools: [] })
223
- // Start an empty assistant bubble.
224
- setLines(prev => [...prev, { id: idRef.current++, role: 'assistant', text: '', tools: [] }])
225
- setBusy(true)
226
- try {
227
- await api.assistant(message, (frame: AssistantFrame) => {
228
- if (frame.type === 'delta') {
229
- pushLine({ role: 'assistant', text: frame.text, tools: [] })
230
- } else if (frame.type === 'tool') {
231
- pushTool({
232
- name: frame.name,
233
- status: frame.status,
234
- detail: frame.detail,
235
- })
236
- } else if (frame.type === 'toolDelta') {
237
- pushToolDelta(frame.text)
238
- } else if (frame.type === 'toolResult') {
239
- pushResultCard({ name: frame.name, text: frame.text })
240
- } else if (frame.type === 'error') {
241
- pushTool({ name: 'error', status: 'error', detail: frame.message })
242
- }
243
- })
244
- } catch (err) {
245
- setError((err as Error).message)
246
- } finally {
247
- setBusy(false)
248
- }
249
- }
250
-
251
- return (
252
- <div className={css.card} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
253
- <div className={css.row} style={{ justifyContent: 'space-between', alignItems: 'center', gap: 'var(--nf-space-8)' }}>
254
- <span className={css.meta}>{tt('assistant.hint')}</span>
255
- <button type="button" className={css.iconButton} title="清空聊天记录" aria-label="清空聊天记录" onClick={() => { void handleClear() }}>
256
- 🗑️
257
- </button>
258
- </div>
259
- {notice !== '' && <span style={{ color: 'var(--nf-success)', fontSize: 'var(--nf-fs-12)' }}>{notice}</span>}
260
- {error !== '' && <span style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-fs-12)' }}>{tt('common.error')}: {error}</span>}
261
- {/* 状态条:编辑老师正在做什么 */}
262
- <div className={`${css.assistantStatus} ${busy ? css.assistantStatusBusy : ''}`}>
263
- {busy
264
- ? `🤖 编辑老师 · ${activeTool !== null ? `正在「${TOOL_LABELS[activeTool.name]?.label ?? activeTool.name}」` : `正在思考…(已 ${thinkSeconds}s)`}`
265
- : '💬 编辑老师 · 等你开口'}
266
- </div>
267
- <div
268
- ref={scrollRef}
269
- className={css.chatScroll}
270
- >
271
- {lines.length === 0 && <span className={css.meta}>{tt('assistant.empty')}</span>}
272
- {lines.map(line => (
273
- <div key={line.id} className={line.role === 'user' ? css.chatBubbleUser : css.chatBubbleAssistant}>
274
- {line.role === 'user' && <div className={css.chatRole}>你</div>}
275
- {line.text !== '' && <div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{line.text}</div>}
276
- {line.live !== undefined && line.live !== '' && (
277
- <div className={css.toolLive}>{line.live}</div>
278
- )}
279
- {/* 步骤卡片 */}
280
- {line.tools.length > 0 && (
281
- <div className={css.toolSteps}>
282
- {line.tools.map((tool, i) => {
283
- const meta = TOOL_LABELS[tool.name] ?? { icon: '⚙️', label: tool.name }
284
- return (
285
- <div
286
- key={i}
287
- className={`${css.toolStep} ${tool.status === 'error' ? css.toolStepError : tool.status === 'start' ? css.toolStepActive : ''}`}
288
- >
289
- <span className={css.toolStepIcon}>{meta.icon}</span>
290
- <span className={css.toolStepName}>{meta.label}</span>
291
- <span className={css.toolStepStatus}>
292
- {tool.status === 'start'
293
- ? '⏳ 进行中'
294
- : tool.status === 'done'
295
- ? `✓ ${tool.elapsedMs !== undefined ? `${(tool.elapsedMs / 1000).toFixed(1)}s` : ''}`
296
- : '✗ 失败'}
297
- </span>
298
- {tool.status === 'error' && tool.detail !== undefined && (
299
- <span className={css.toolStepDetail}>{tool.detail}</span>
300
- )}
301
- </div>
302
- )
303
- })}
304
- </div>
305
- )}
306
- {/* 结构化结果卡片(拆书/导演/质检/知识库列表等) */}
307
- {line.resultCard !== undefined && CARD_TOOLS.has(line.resultCard.name) && (
308
- <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)' }}>
309
- <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--nf-accent)', marginBottom: 4 }}>
310
- {TOOL_LABELS[line.resultCard.name]?.icon ?? ''} {TOOL_LABELS[line.resultCard.name]?.label ?? line.resultCard.name}
311
- </div>
312
- <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>
313
- </div>
314
- )}
315
- </div>
316
- ))}
317
- {busy && <span className={css.meta} style={{ color: 'var(--nf-accent)' }}>…</span>}
318
- </div>
319
- <div className={css.row} style={{ marginTop: 'var(--nf-space-8)' }}>
320
- <textarea
321
- className={css.textarea}
322
- style={{ minHeight: 64, flex: 1 }}
323
- placeholder={tt('assistant.placeholder')}
324
- value={input}
325
- onChange={e => { setInput(e.target.value) }}
326
- onKeyDown={e => {
327
- if (e.key === 'Enter' && !e.shiftKey) {
328
- e.preventDefault()
329
- void handleSend()
330
- }
331
- }}
332
- />
333
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || input.trim() === ''} onClick={() => { void handleSend() }}>
334
- {tt('assistant.send')}
335
- </button>
336
- </div>
337
- </div>
338
- )
339
- }
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
+ 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
+ }
66
+
67
+ /** The assistant conversation tab. */
68
+ export function AssistantTab({ api }: AssistantTabProps) {
69
+ const [lines, setLines] = useState<ChatLine[]>([])
70
+ const [input, setInput] = useState('')
71
+ const [busy, setBusy] = useState(false)
72
+ const [error, setError] = useState('')
73
+ const [notice, setNotice] = useState('')
74
+ /** 思考耗时(秒,busy 时每秒刷新)。 */
75
+ const [thinkSeconds, setThinkSeconds] = useState(0)
76
+ const idRef = useRef(0)
77
+ const scrollRef = useRef<HTMLDivElement | null>(null)
78
+
79
+ /** busy 计时:显示"已思考 Xs",让作者知道在干活。 */
80
+ useEffect(() => {
81
+ if (!busy) {
82
+ setThinkSeconds(0)
83
+ return
84
+ }
85
+ const started = Date.now()
86
+ const timer = window.setInterval(() => {
87
+ setThinkSeconds(Math.floor((Date.now() - started) / 1000))
88
+ }, 1000)
89
+ return () => { window.clearInterval(timer) }
90
+ }, [busy])
91
+
92
+ /** Append a bubble (or extend the current assistant bubble). */
93
+ const pushLine = useCallback((line: Omit<ChatLine, 'id'>) => {
94
+ setLines(prev => {
95
+ const last = prev[prev.length - 1]
96
+ // Extend the live assistant bubble while streaming.
97
+ if (line.role === 'assistant' && last !== undefined && last.role === 'assistant' && last.tools.length === 0) {
98
+ return [...prev.slice(0, -1), { ...last, text: last.text + line.text }]
99
+ }
100
+ return [...prev, { ...line, id: idRef.current++ }]
101
+ })
102
+ }, [])
103
+
104
+ /** Push a tool event: start 创建步骤,done/error 更新同名步骤(含耗时)。 */
105
+ const pushTool = useCallback((tool: ChatLine['tools'][number]) => {
106
+ setLines(prev => {
107
+ const last = prev[prev.length - 1]
108
+ if (last === undefined || last.role !== 'assistant') {
109
+ return [...prev, {
110
+ id: idRef.current++,
111
+ role: 'assistant',
112
+ text: '',
113
+ tools: [{ ...tool, startedAt: tool.status === 'start' ? Date.now() : undefined }],
114
+ }]
115
+ }
116
+ const tools = [...last.tools]
117
+ if (tool.status === 'start') {
118
+ tools.push({ ...tool, startedAt: Date.now() })
119
+ } else {
120
+ // 找到同名未完成的步骤,更新状态与耗时。
121
+ for (let i = tools.length - 1; i >= 0; i--) {
122
+ const step = tools[i]!
123
+ if (step.name === tool.name && step.status === 'start') {
124
+ tools[i] = {
125
+ ...step,
126
+ status: tool.status,
127
+ detail: tool.detail,
128
+ elapsedMs: Date.now() - (step.startedAt ?? Date.now()),
129
+ }
130
+ break
131
+ }
132
+ }
133
+ }
134
+ return [...prev.slice(0, -1), { ...last, tools, live: undefined }]
135
+ })
136
+ }, [])
137
+
138
+ /** Append live tool output onto the current assistant bubble. */
139
+ const pushToolDelta = useCallback((text: string) => {
140
+ setLines(prev => {
141
+ const last = prev[prev.length - 1]
142
+ if (last === undefined || last.role !== 'assistant') return prev
143
+ return [...prev.slice(0, -1), { ...last, live: (last.live ?? '') + text }]
144
+ })
145
+ }, [])
146
+
147
+ /** 把结构化工具的结果挂到当前助手气泡(用于渲染结果卡片)。 */
148
+ const pushResultCard = useCallback((card: { name: string; text: string }) => {
149
+ setLines(prev => {
150
+ const last = prev[prev.length - 1]
151
+ if (last === undefined || last.role !== 'assistant') {
152
+ return [...prev, { id: idRef.current++, role: 'assistant', text: '', tools: [], resultCard: card }]
153
+ }
154
+ return [...prev.slice(0, -1), { ...last, resultCard: card }]
155
+ })
156
+ }, [])
157
+
158
+ /** 当前进行中的工具(状态条显示)。 */
159
+ const activeTool = (() => {
160
+ if (!busy) return null
161
+ for (let i = lines.length - 1; i >= 0; i--) {
162
+ const line = lines[i]!
163
+ if (line.role !== 'assistant') continue
164
+ for (let j = line.tools.length - 1; j >= 0; j--) {
165
+ if (line.tools[j]!.status === 'start') return line.tools[j]!
166
+ }
167
+ }
168
+ return null
169
+ })()
170
+
171
+ /** Load persisted history on mount. */
172
+ useEffect(() => {
173
+ let cancelled = false
174
+ void (async () => {
175
+ try {
176
+ const history = await api.assistantHistory()
177
+ if (cancelled) return
178
+ const restored: ChatLine[] = []
179
+ for (const entry of history) {
180
+ if (entry.role === 'user') {
181
+ restored.push({ id: idRef.current++, role: 'user', text: entry.content, tools: [] })
182
+ } else if (entry.role === 'assistant') {
183
+ restored.push({ id: idRef.current++, role: 'assistant', text: entry.content, tools: [] })
184
+ }
185
+ }
186
+ setLines(restored)
187
+ } catch (err) {
188
+ if (!cancelled) setError((err as Error).message)
189
+ }
190
+ })()
191
+ return () => { cancelled = true }
192
+ }, [api])
193
+
194
+ /** Auto-scroll to the newest line. */
195
+ useEffect(() => {
196
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
197
+ }, [lines])
198
+
199
+ /** 清空对话记录(服务端删除历史 + 本地清空)。 */
200
+ const handleClear = async (): Promise<void> => {
201
+ if (!window.confirm('清空全部聊天记录?此操作不可恢复(不影响大纲/设定/章节)。')) return
202
+ setBusy(true)
203
+ setError('')
204
+ try {
205
+ await api.assistantClear()
206
+ setLines([])
207
+ setNotice('对话已清空,编辑老师会重新了解项目')
208
+ } catch (err) {
209
+ setError((err as Error).message)
210
+ } finally {
211
+ setBusy(false)
212
+ }
213
+ }
214
+
215
+ /** Send one message. */
216
+ const handleSend = async (): Promise<void> => {
217
+ const message = input.trim()
218
+ if (message === '' || busy) return
219
+ setInput('')
220
+ setError('')
221
+ setNotice('')
222
+ pushLine({ role: 'user', text: message, tools: [] })
223
+ // Start an empty assistant bubble.
224
+ setLines(prev => [...prev, { id: idRef.current++, role: 'assistant', text: '', tools: [] }])
225
+ setBusy(true)
226
+ try {
227
+ await api.assistant(message, (frame: AssistantFrame) => {
228
+ if (frame.type === 'delta') {
229
+ pushLine({ role: 'assistant', text: frame.text, tools: [] })
230
+ } else if (frame.type === 'tool') {
231
+ pushTool({
232
+ name: frame.name,
233
+ status: frame.status,
234
+ detail: frame.detail,
235
+ })
236
+ } else if (frame.type === 'toolDelta') {
237
+ pushToolDelta(frame.text)
238
+ } else if (frame.type === 'toolResult') {
239
+ pushResultCard({ name: frame.name, text: frame.text })
240
+ } else if (frame.type === 'error') {
241
+ pushTool({ name: 'error', status: 'error', detail: frame.message })
242
+ }
243
+ })
244
+ } catch (err) {
245
+ setError((err as Error).message)
246
+ } finally {
247
+ setBusy(false)
248
+ }
249
+ }
250
+
251
+ return (
252
+ <div className={css.card} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
253
+ <div className={css.row} style={{ justifyContent: 'space-between', alignItems: 'center', gap: 'var(--nf-space-8)' }}>
254
+ <span className={css.meta}>{tt('assistant.hint')}</span>
255
+ <button type="button" className={css.iconButton} title="清空聊天记录" aria-label="清空聊天记录" onClick={() => { void handleClear() }}>
256
+ 🗑️
257
+ </button>
258
+ </div>
259
+ {notice !== '' && <span style={{ color: 'var(--nf-success)', fontSize: 'var(--nf-fs-12)' }}>{notice}</span>}
260
+ {error !== '' && <span style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-fs-12)' }}>{tt('common.error')}: {error}</span>}
261
+ {/* 状态条:编辑老师正在做什么 */}
262
+ <div className={`${css.assistantStatus} ${busy ? css.assistantStatusBusy : ''}`}>
263
+ {busy
264
+ ? `🤖 编辑老师 · ${activeTool !== null ? `正在「${TOOL_LABELS[activeTool.name]?.label ?? activeTool.name}」` : `正在思考…(已 ${thinkSeconds}s)`}`
265
+ : '💬 编辑老师 · 等你开口'}
266
+ </div>
267
+ <div
268
+ ref={scrollRef}
269
+ className={css.chatScroll}
270
+ >
271
+ {lines.length === 0 && <span className={css.meta}>{tt('assistant.empty')}</span>}
272
+ {lines.map(line => (
273
+ <div key={line.id} className={line.role === 'user' ? css.chatBubbleUser : css.chatBubbleAssistant}>
274
+ {line.role === 'user' && <div className={css.chatRole}>你</div>}
275
+ {line.text !== '' && <div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{line.text}</div>}
276
+ {line.live !== undefined && line.live !== '' && (
277
+ <div className={css.toolLive}>{line.live}</div>
278
+ )}
279
+ {/* 步骤卡片 */}
280
+ {line.tools.length > 0 && (
281
+ <div className={css.toolSteps}>
282
+ {line.tools.map((tool, i) => {
283
+ const meta = TOOL_LABELS[tool.name] ?? { icon: '⚙️', label: tool.name }
284
+ return (
285
+ <div
286
+ key={i}
287
+ className={`${css.toolStep} ${tool.status === 'error' ? css.toolStepError : tool.status === 'start' ? css.toolStepActive : ''}`}
288
+ >
289
+ <span className={css.toolStepIcon}>{meta.icon}</span>
290
+ <span className={css.toolStepName}>{meta.label}</span>
291
+ <span className={css.toolStepStatus}>
292
+ {tool.status === 'start'
293
+ ? '⏳ 进行中'
294
+ : tool.status === 'done'
295
+ ? `✓ ${tool.elapsedMs !== undefined ? `${(tool.elapsedMs / 1000).toFixed(1)}s` : ''}`
296
+ : '✗ 失败'}
297
+ </span>
298
+ {tool.status === 'error' && tool.detail !== undefined && (
299
+ <span className={css.toolStepDetail}>{tool.detail}</span>
300
+ )}
301
+ </div>
302
+ )
303
+ })}
304
+ </div>
305
+ )}
306
+ {/* 结构化结果卡片(拆书/导演/质检/知识库列表等) */}
307
+ {line.resultCard !== undefined && CARD_TOOLS.has(line.resultCard.name) && (
308
+ <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)' }}>
309
+ <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--nf-accent)', marginBottom: 4 }}>
310
+ {TOOL_LABELS[line.resultCard.name]?.icon ?? ''} {TOOL_LABELS[line.resultCard.name]?.label ?? line.resultCard.name}
311
+ </div>
312
+ <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>
313
+ </div>
314
+ )}
315
+ </div>
316
+ ))}
317
+ {busy && <span className={css.meta} style={{ color: 'var(--nf-accent)' }}>…</span>}
318
+ </div>
319
+ <div className={css.row} style={{ marginTop: 'var(--nf-space-8)' }}>
320
+ <textarea
321
+ className={css.textarea}
322
+ style={{ minHeight: 64, flex: 1 }}
323
+ placeholder={tt('assistant.placeholder')}
324
+ value={input}
325
+ onChange={e => { setInput(e.target.value) }}
326
+ onKeyDown={e => {
327
+ if (e.key === 'Enter' && !e.shiftKey) {
328
+ e.preventDefault()
329
+ void handleSend()
330
+ }
331
+ }}
332
+ />
333
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || input.trim() === ''} onClick={() => { void handleSend() }}>
334
+ {tt('assistant.send')}
335
+ </button>
336
+ </div>
337
+ </div>
338
+ )
339
+ }