@huyooo/ai-chat-frontend-react 0.2.14 → 0.2.16

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 (76) hide show
  1. package/dist/index.css +0 -1
  2. package/dist/index.js +1 -5418
  3. package/package.json +4 -5
  4. package/dist/index.css.map +0 -1
  5. package/dist/index.js.map +0 -1
  6. package/src/adapter.ts +0 -68
  7. package/src/components/ChatPanel.tsx +0 -553
  8. package/src/components/common/ConfirmDialog.css +0 -136
  9. package/src/components/common/ConfirmDialog.tsx +0 -91
  10. package/src/components/common/CopyButton.css +0 -22
  11. package/src/components/common/CopyButton.tsx +0 -46
  12. package/src/components/common/IndexingSettings.css +0 -207
  13. package/src/components/common/IndexingSettings.tsx +0 -398
  14. package/src/components/common/SettingsPanel.css +0 -337
  15. package/src/components/common/SettingsPanel.tsx +0 -215
  16. package/src/components/common/Toast.css +0 -50
  17. package/src/components/common/Toast.tsx +0 -38
  18. package/src/components/common/ToggleSwitch.css +0 -52
  19. package/src/components/common/ToggleSwitch.tsx +0 -20
  20. package/src/components/header/ChatHeader.css +0 -285
  21. package/src/components/header/ChatHeader.tsx +0 -376
  22. package/src/components/input/AtFilePicker.css +0 -147
  23. package/src/components/input/AtFilePicker.tsx +0 -519
  24. package/src/components/input/ChatInput.css +0 -283
  25. package/src/components/input/ChatInput.tsx +0 -575
  26. package/src/components/input/DropdownSelector.css +0 -231
  27. package/src/components/input/DropdownSelector.tsx +0 -333
  28. package/src/components/input/ImagePreviewModal.css +0 -124
  29. package/src/components/input/ImagePreviewModal.tsx +0 -118
  30. package/src/components/input/at-views/AtBranchView.tsx +0 -34
  31. package/src/components/input/at-views/AtBrowserView.tsx +0 -34
  32. package/src/components/input/at-views/AtChatsView.tsx +0 -34
  33. package/src/components/input/at-views/AtDocsView.tsx +0 -34
  34. package/src/components/input/at-views/AtFilesView.tsx +0 -168
  35. package/src/components/input/at-views/AtTerminalsView.tsx +0 -34
  36. package/src/components/input/at-views/AtViewStyles.css +0 -143
  37. package/src/components/input/at-views/index.ts +0 -9
  38. package/src/components/message/ContentRenderer.css +0 -9
  39. package/src/components/message/MessageBubble.css +0 -193
  40. package/src/components/message/MessageBubble.tsx +0 -240
  41. package/src/components/message/PartsRenderer.css +0 -12
  42. package/src/components/message/PartsRenderer.tsx +0 -168
  43. package/src/components/message/WelcomeMessage.css +0 -221
  44. package/src/components/message/WelcomeMessage.tsx +0 -93
  45. package/src/components/message/parts/CollapsibleCard.css +0 -80
  46. package/src/components/message/parts/CollapsibleCard.tsx +0 -80
  47. package/src/components/message/parts/ErrorPart.css +0 -9
  48. package/src/components/message/parts/ErrorPart.tsx +0 -40
  49. package/src/components/message/parts/ImagePart.css +0 -49
  50. package/src/components/message/parts/ImagePart.tsx +0 -54
  51. package/src/components/message/parts/SearchPart.css +0 -44
  52. package/src/components/message/parts/SearchPart.tsx +0 -63
  53. package/src/components/message/parts/TextPart.css +0 -579
  54. package/src/components/message/parts/TextPart.tsx +0 -213
  55. package/src/components/message/parts/ThinkingPart.css +0 -9
  56. package/src/components/message/parts/ThinkingPart.tsx +0 -48
  57. package/src/components/message/parts/ToolCallPart.css +0 -246
  58. package/src/components/message/parts/ToolCallPart.tsx +0 -289
  59. package/src/components/message/parts/ToolResultPart.css +0 -67
  60. package/src/components/message/parts/index.ts +0 -13
  61. package/src/components/message/parts/visual-predicate.ts +0 -43
  62. package/src/components/message/parts/visual-render.ts +0 -19
  63. package/src/components/message/parts/visual.ts +0 -12
  64. package/src/components/message/welcome-types.ts +0 -46
  65. package/src/context/AutoRunConfigContext.tsx +0 -13
  66. package/src/context/ChatAdapterContext.tsx +0 -8
  67. package/src/context/ChatInputContext.tsx +0 -40
  68. package/src/context/RenderersContext.tsx +0 -35
  69. package/src/hooks/useChat.ts +0 -1569
  70. package/src/hooks/useImageUpload.ts +0 -345
  71. package/src/hooks/useVoiceInput.ts +0 -454
  72. package/src/hooks/useVoiceToTextInput.ts +0 -87
  73. package/src/index.ts +0 -151
  74. package/src/styles.css +0 -330
  75. package/src/types/index.ts +0 -196
  76. package/src/utils/fileIcon.ts +0 -49
@@ -1,1569 +0,0 @@
1
- /**
2
- * useChat Hook
3
- * 管理聊天状态和与后端的通信
4
- *
5
- * 新架构:
6
- * - 使用 Map 存储每个会话的独立状态
7
- * - 多会话可同时进行,互不干扰
8
- * - 切换 tab 不会停止正在进行的请求
9
- * - ContentPart 数组存储消息内容,支持流式渲染和自定义 UI
10
- */
11
-
12
- import { useState, useCallback, useRef, useMemo, useEffect } from 'react'
13
- import type {
14
- ChatAdapter,
15
- ChatEvent as BridgeChatEvent,
16
- ChatMode,
17
- SessionRecord,
18
- MessageRecord,
19
- AutoRunConfig,
20
- } from '@huyooo/ai-chat-bridge-electron/renderer'
21
- import type {
22
- ChatMessage,
23
- ContentPart,
24
- TextPart,
25
- ThinkingPart,
26
- SearchPart,
27
- ToolCallPart,
28
- ErrorPart,
29
- SearchResult,
30
- } from '../types'
31
-
32
- // 扩展事件类型:允许 bridge 端新增事件时前端不被类型卡死
33
- type ChatEvent = Omit<BridgeChatEvent, 'type'> & {
34
- type: BridgeChatEvent['type'] | 'tool_call_output'
35
- }
36
-
37
- // ==================== @ 上下文(文件引用) ====================
38
- // 约定:AtFilePicker 插入的格式为单行 "@<path>",通常以换行分隔
39
- const AT_LINE_RE = /^\s*@\s*(.+?)\s*$/
40
- const MAX_FILE_CHARS = 20_000
41
-
42
- function stripAtContextLines(text: string): string {
43
- return text
44
- .split('\n')
45
- .filter((line) => !AT_LINE_RE.test(line))
46
- .join('\n')
47
- .trim()
48
- }
49
-
50
- function extractAtContextRefs(text: string): { refs: string[]; cleanedText: string } {
51
- const refs: string[] = []
52
- const kept: string[] = []
53
-
54
- for (const line of text.split('\n')) {
55
- const m = line.match(AT_LINE_RE)
56
- if (m?.[1]) {
57
- refs.push(m[1])
58
- } else {
59
- kept.push(line)
60
- }
61
- }
62
-
63
- return {
64
- refs: Array.from(new Set(refs.map((r) => r.trim()).filter(Boolean))),
65
- cleanedText: kept.join('\n').trim(),
66
- }
67
- }
68
-
69
- async function buildPromptWithAtContext(adapter: ChatAdapter, text: string): Promise<string> {
70
- const { refs, cleanedText } = extractAtContextRefs(text)
71
- if (refs.length === 0) return text
72
-
73
- const blocks: string[] = []
74
-
75
- for (const ref of refs) {
76
- const resolved = (await adapter.resolvePath?.(ref)) || ref
77
-
78
- try {
79
- const stat = await adapter.stat?.(resolved)
80
- if (stat?.isDirectory) {
81
- const items = await adapter.listDir?.(resolved)
82
- const listing = (items || [])
83
- .slice(0, 200)
84
- .map((f) => `${f.isDirectory ? 'DIR ' : 'FILE'} ${f.path}`)
85
- .join('\n')
86
- blocks.push(
87
- `【目录】${resolved}\n` +
88
- '```text\n' +
89
- (listing || '(空目录/无法列出)') +
90
- '\n```'
91
- )
92
- continue
93
- }
94
-
95
- const content = await adapter.readFile?.(resolved)
96
- if (typeof content === 'string') {
97
- const truncated = content.length > MAX_FILE_CHARS
98
- ? content.slice(0, MAX_FILE_CHARS) + `\n\n... (已截断,原始长度 ${content.length})`
99
- : content
100
- blocks.push(
101
- `【文件】${resolved}\n` +
102
- '```text\n' +
103
- truncated +
104
- '\n```'
105
- )
106
- } else {
107
- blocks.push(`【引用】${resolved}\n(无法读取文件内容)`)
108
- }
109
- } catch (error) {
110
- blocks.push(`【引用】${resolved}\n(读取失败:${error instanceof Error ? error.message : String(error)})`)
111
- }
112
- }
113
-
114
- const question = cleanedText || stripAtContextLines(text) || text
115
- return `以下是用户通过 @ 引用提供的上下文:\n\n${blocks.join('\n\n')}\n\n用户问题:\n${question}`
116
- }
117
-
118
- /** 生成唯一 ID */
119
- function generateId(): string {
120
- return Date.now().toString(36) + Math.random().toString(36).substr(2)
121
- }
122
-
123
- /** 获取消息的纯文本内容 */
124
- function extractTextContent(parts: ContentPart[]): string {
125
- return parts
126
- .filter((p): p is TextPart => p.type === 'text')
127
- .map(p => p.text)
128
- .join('')
129
- }
130
-
131
- /** 判断事件是否需要保存到数据库 */
132
- function shouldSaveEvent(event: ChatEvent): boolean {
133
- return (
134
- event.type === 'thinking_end' ||
135
- event.type === 'tool_call_result' ||
136
- event.type === 'text_delta' ||
137
- event.type === 'done' ||
138
- event.type === 'error' ||
139
- event.type === 'abort'
140
- )
141
- }
142
-
143
- /** 解析工具调用结果 */
144
- function parseToolResult(result: unknown): unknown | null {
145
- if (result === undefined || result === null) return null
146
- if (typeof result === 'string') {
147
- try {
148
- return JSON.parse(result)
149
- } catch {
150
- return result
151
- }
152
- }
153
- return result
154
- }
155
-
156
- /** 转换存储的消息为显示格式 */
157
- function convertToMessage(record: MessageRecord): ChatMessage {
158
- // timestamp 统一为 number(毫秒时间戳),不受 JSON 序列化影响
159
- // 如果收到非 number 类型,说明数据异常,直接使用 0
160
- const timestamp = typeof record.timestamp === 'number' && Number.isFinite(record.timestamp)
161
- ? record.timestamp
162
- : 0
163
- let parts: ContentPart[] = []
164
-
165
- if (record.steps) {
166
- try {
167
- const steps = JSON.parse(record.steps)
168
- for (const step of steps) {
169
- if (step.type === 'thinking') {
170
- parts.push({
171
- type: 'thinking',
172
- text: step.text || '',
173
- status: step.status || 'done',
174
- duration: step.duration,
175
- })
176
- } else if (step.type === 'search') {
177
- parts.push({
178
- type: 'search',
179
- query: step.query,
180
- results: step.results,
181
- status: step.status || 'done',
182
- })
183
- } else if (step.type === 'tool_call') {
184
- parts.push({
185
- type: 'tool_call',
186
- id: step.id,
187
- name: step.name,
188
- args: step.args,
189
- result: parseToolResult(step.result),
190
- status: step.status || 'done',
191
- output: step.output,
192
- })
193
- } else if (step.type === 'text') {
194
- parts.push({
195
- type: 'text',
196
- text: step.text || '',
197
- })
198
- } else if (step.type === 'error') {
199
- parts.push({
200
- type: 'error',
201
- message: step.message || '',
202
- category: step.category,
203
- })
204
- }
205
- }
206
- } catch {
207
- // 解析失败,忽略 steps
208
- }
209
- }
210
-
211
- if (record.content && !parts.some(p => p.type === 'text')) {
212
- parts.push({ type: 'text', text: record.content })
213
- }
214
-
215
- if (parts.length === 0) {
216
- parts.push({ type: 'text', text: '' })
217
- }
218
-
219
- return {
220
- id: record.id,
221
- role: record.role,
222
- parts,
223
- images: record.images ?? [],
224
- model: record.model || undefined,
225
- mode: record.mode || undefined,
226
- webSearchEnabled: record.webSearchEnabled ?? undefined,
227
- thinkingEnabled: record.thinkingEnabled ?? undefined,
228
- loading: false,
229
- timestamp,
230
- }
231
- }
232
-
233
- /** 会话状态 */
234
- interface SessionState {
235
- messages: ChatMessage[]
236
- isLoading: boolean
237
- abortController: AbortController | null
238
- /** 当前进行中的 assistant 消息 ID(用于取消/落地状态,避免依赖 message.loading 的不稳定性) */
239
- activeAssistantMessageId: string | null
240
- }
241
-
242
- /** 副作用定义 */
243
- export interface SideEffect {
244
- type: string
245
- success: boolean
246
- data?: unknown
247
- message?: string
248
- }
249
-
250
- /** 工具完成事件数据 */
251
- export interface ToolCompleteEvent {
252
- name: string
253
- result: unknown
254
- /**
255
- * 工具声明的副作用列表
256
- * 前端可根据此字段处理通知、刷新文件列表等
257
- */
258
- sideEffects?: SideEffect[]
259
- }
260
-
261
- export interface UseChatOptions {
262
- adapter: ChatAdapter
263
- defaultModel?: string
264
- defaultMode?: ChatMode
265
- onToolComplete?: (event: ToolCompleteEvent) => void
266
- autoRunConfig?: AutoRunConfig
267
- }
268
-
269
- /**
270
- * 聊天状态管理 Hook
271
- *
272
- * 核心架构:
273
- * - sessionStatesRef: Map<sessionId, SessionState> 存储每个会话的独立状态
274
- * - 多会话可同时进行请求,互不干扰
275
- * - 切换 tab 不会停止任何正在进行的请求
276
- */
277
- export function useChat(options: UseChatOptions) {
278
- const {
279
- adapter,
280
- defaultModel = 'anthropic/claude-opus-4.5',
281
- defaultMode = 'agent',
282
- onToolComplete,
283
- // autoRunConfig 现在从数据库读取,不再从 props 传入
284
- } = options
285
-
286
- // ==================== 会话状态存储 ====================
287
- const sessionStatesRef = useRef(new Map<string, SessionState>())
288
- const [stateVersion, setStateVersion] = useState(0)
289
-
290
- // ==================== 工具开关配置(从数据库读取) ====================
291
- /** 启用的工具名称列表(undefined 表示全部启用) */
292
- const [enabledTools, setEnabledTools] = useState<string[] | undefined>(undefined)
293
- const enabledToolsRef = useRef<string[] | undefined>(enabledTools)
294
- enabledToolsRef.current = enabledTools
295
-
296
- /** 所有可用工具列表(用于设置面板) */
297
- const [allTools, setAllTools] = useState<Array<{ name: string; description: string }>>([])
298
-
299
- // ==================== 自动运行配置(从数据库读取) ====================
300
- // 默认配置
301
- const DEFAULT_AUTO_RUN_CONFIG: AutoRunConfig = {
302
- mode: 'run-everything',
303
- }
304
-
305
- const [autoRunConfig, setAutoRunConfig] = useState<AutoRunConfig>(DEFAULT_AUTO_RUN_CONFIG)
306
- const autoRunConfigRef = useRef(autoRunConfig)
307
- autoRunConfigRef.current = autoRunConfig
308
-
309
- /** 从数据库加载自动运行配置 */
310
- const loadAutoRunConfig = useCallback(async () => {
311
- if (!adapter.getAllSettings) return
312
-
313
- try {
314
- const settings = await adapter.getAllSettings()
315
- const configJson = settings['autoRunConfig']
316
-
317
- if (configJson) {
318
- const config = JSON.parse(configJson) as AutoRunConfig
319
- // 合并配置,确保新增字段有默认值
320
- setAutoRunConfig({ ...DEFAULT_AUTO_RUN_CONFIG, ...config })
321
- }
322
- } catch (error) {
323
- console.error('[useChat] 加载 autoRunConfig 失败:', error)
324
- }
325
- }, [adapter])
326
-
327
- /** 保存自动运行配置到数据库 */
328
- const saveAutoRunConfig = useCallback(async (config: AutoRunConfig) => {
329
- if (!adapter.setSetting) return
330
-
331
- try {
332
- await adapter.setSetting('autoRunConfig', JSON.stringify(config))
333
- setAutoRunConfig(config)
334
- } catch (error) {
335
- console.error('[useChat] 保存 autoRunConfig 失败:', error)
336
- throw error
337
- }
338
- }, [adapter])
339
-
340
- /** 从数据库加载工具开关配置 */
341
- const loadEnabledTools = useCallback(async () => {
342
- if (!adapter.getAllSettings) return
343
-
344
- try {
345
- const settings = await adapter.getAllSettings()
346
- const toolsJson = settings['enabledTools']
347
- if (!toolsJson) {
348
- setEnabledTools(undefined)
349
- return
350
- }
351
-
352
- const parsed = JSON.parse(toolsJson) as unknown
353
- if (Array.isArray(parsed) && parsed.every((x) => typeof x === 'string')) {
354
- setEnabledTools(parsed)
355
- } else {
356
- setEnabledTools(undefined)
357
- }
358
- } catch (error) {
359
- console.error('[useChat] 加载 enabledTools 失败:', error)
360
- }
361
- }, [adapter])
362
-
363
- /** 保存工具开关配置到数据库 */
364
- const saveEnabledTools = useCallback(async (tools: string[] | undefined) => {
365
- if (!adapter.setSetting) return
366
-
367
- try {
368
- if (tools === undefined) {
369
- // 全部启用:删除配置(使用默认行为)
370
- await adapter.deleteSetting?.('enabledTools')
371
- setEnabledTools(undefined)
372
- return
373
- }
374
-
375
- await adapter.setSetting('enabledTools', JSON.stringify(tools))
376
- setEnabledTools(tools)
377
- } catch (error) {
378
- console.error('[useChat] 保存 enabledTools 失败:', error)
379
- throw error
380
- }
381
- }, [adapter])
382
-
383
- /** 加载所有工具列表 */
384
- const loadAllTools = useCallback(async () => {
385
- if (!adapter.getAllTools) {
386
- console.warn('[useChat] adapter.getAllTools 不存在')
387
- return
388
- }
389
-
390
- try {
391
- const tools = await adapter.getAllTools()
392
- setAllTools(tools)
393
- } catch (error) {
394
- console.error('[useChat] 加载工具列表失败:', error)
395
- }
396
- }, [adapter])
397
-
398
- // 初始化时加载配置
399
- useEffect(() => {
400
- loadAutoRunConfig()
401
- loadEnabledTools()
402
- loadAllTools()
403
- }, [loadAutoRunConfig, loadEnabledTools, loadAllTools])
404
-
405
- // 强制重新渲染
406
- const forceUpdate = useCallback(() => setStateVersion(v => v + 1), [])
407
-
408
- // 获取或创建会话状态
409
- const getSessionState = useCallback((sessionId: string): SessionState => {
410
- if (!sessionStatesRef.current.has(sessionId)) {
411
- sessionStatesRef.current.set(sessionId, {
412
- messages: [],
413
- isLoading: false,
414
- abortController: null,
415
- activeAssistantMessageId: null,
416
- })
417
- }
418
- return sessionStatesRef.current.get(sessionId)!
419
- }, [])
420
-
421
- // ==================== 全局状态 ====================
422
- const [sessions, setSessions] = useState<SessionRecord[]>([])
423
- const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)
424
- const currentSessionIdRef = useRef<string | null>(null)
425
-
426
- // 同步 ref
427
- currentSessionIdRef.current = currentSessionId
428
-
429
- // 配置状态
430
- const [modeState, setModeState] = useState<ChatMode>(defaultMode)
431
- const [modelState, setModelState] = useState(defaultModel)
432
- const [webSearchState, setWebSearchState] = useState(true)
433
- const [thinkingState, setThinkingState] = useState(true)
434
-
435
- // refs for async access
436
- const modeRef = useRef(modeState)
437
- const modelRef = useRef(modelState)
438
- const webSearchRef = useRef(webSearchState)
439
- const thinkingRef = useRef(thinkingState)
440
- const sessionsRef = useRef(sessions)
441
-
442
- modeRef.current = modeState
443
- modelRef.current = modelState
444
- webSearchRef.current = webSearchState
445
- thinkingRef.current = thinkingState
446
- sessionsRef.current = sessions
447
-
448
- // ==================== 计算属性 ====================
449
- const messages = useMemo(() => {
450
- // 触发 stateVersion 依赖
451
- void stateVersion
452
- if (!currentSessionId) return []
453
- const state = sessionStatesRef.current.get(currentSessionId)
454
- return state?.messages || []
455
- }, [currentSessionId, stateVersion])
456
-
457
- const isLoading = useMemo(() => {
458
- void stateVersion
459
- if (!currentSessionId) return false
460
- const state = sessionStatesRef.current.get(currentSessionId)
461
- return state?.isLoading || false
462
- }, [currentSessionId, stateVersion])
463
-
464
- // ==================== 会话管理 ====================
465
- const loadSessions = useCallback(async () => {
466
- try {
467
- const list = await adapter.getSessions()
468
- setSessions(list)
469
- if (list.length > 0 && !currentSessionIdRef.current) {
470
- // 选择第一个未隐藏的会话,如果都隐藏了则选第一个
471
- const firstVisible = list.find(s => !s.hidden) || list[0]
472
- setCurrentSessionId(firstVisible.id)
473
- // 加载消息
474
- const state = getSessionState(firstVisible.id)
475
- if (state.messages.length === 0) {
476
- const savedMessages = await adapter.getMessages(firstVisible.id)
477
- state.messages = savedMessages.map(convertToMessage)
478
- forceUpdate()
479
- }
480
- // 同步配置
481
- setModeState(firstVisible.mode)
482
- setModelState(firstVisible.model)
483
- setWebSearchState(firstVisible.webSearchEnabled)
484
- setThinkingState(firstVisible.thinkingEnabled)
485
- }
486
- } catch (error) {
487
- console.error('加载会话失败:', error)
488
- }
489
- }, [adapter, getSessionState, forceUpdate])
490
-
491
- const switchSession = useCallback(async (sessionId: string) => {
492
- if (currentSessionIdRef.current === sessionId) return
493
-
494
- setCurrentSessionId(sessionId)
495
-
496
- // 无状态架构:不需要同步历史到后端
497
- // 发送消息时会传递历史,后端不维护状态
498
-
499
- const state = getSessionState(sessionId)
500
-
501
- if (state.messages.length === 0) {
502
- try {
503
- const savedMessages = await adapter.getMessages(sessionId)
504
- state.messages = savedMessages.map(convertToMessage)
505
- forceUpdate()
506
- } catch (error) {
507
- console.error('加载消息失败:', error)
508
- state.messages = []
509
- forceUpdate()
510
- }
511
- }
512
-
513
- const session = sessionsRef.current.find((s) => s.id === sessionId)
514
- if (session) {
515
- setModeState(session.mode)
516
- setModelState(session.model)
517
- setWebSearchState(session.webSearchEnabled)
518
- setThinkingState(session.thinkingEnabled)
519
- }
520
- }, [adapter, getSessionState, forceUpdate])
521
-
522
- const createNewSession = useCallback(async () => {
523
- try {
524
- const session = await adapter.createSession({
525
- title: '新对话',
526
- model: modelRef.current,
527
- mode: modeRef.current,
528
- webSearchEnabled: webSearchRef.current,
529
- thinkingEnabled: thinkingRef.current,
530
- })
531
- setSessions(prev => [session, ...prev])
532
-
533
- sessionStatesRef.current.set(session.id, {
534
- messages: [],
535
- isLoading: false,
536
- abortController: null,
537
- activeAssistantMessageId: null,
538
- })
539
-
540
- setCurrentSessionId(session.id)
541
- forceUpdate()
542
- } catch (error) {
543
- console.error('创建会话失败:', error)
544
- }
545
- }, [adapter, forceUpdate])
546
-
547
- const deleteSession = useCallback(async (sessionId: string) => {
548
- try {
549
- const state = sessionStatesRef.current.get(sessionId)
550
- if (state?.isLoading && state.abortController) {
551
- state.abortController.abort()
552
- adapter.cancel()
553
- }
554
-
555
- await adapter.deleteSession(sessionId)
556
- setSessions(prev => prev.filter((s) => s.id !== sessionId))
557
- sessionStatesRef.current.delete(sessionId)
558
-
559
- if (currentSessionIdRef.current === sessionId) {
560
- const remainingSessions = sessionsRef.current.filter(s => s.id !== sessionId)
561
- if (remainingSessions.length > 0) {
562
- setCurrentSessionId(remainingSessions[0].id)
563
- } else {
564
- setCurrentSessionId(null)
565
- }
566
- }
567
- forceUpdate()
568
- } catch (error) {
569
- console.error('删除会话失败:', error)
570
- }
571
- }, [adapter, forceUpdate])
572
-
573
- const hideSession = useCallback(async (sessionId: string, hidden: boolean) => {
574
- try {
575
- await adapter.updateSession(sessionId, { hidden })
576
- setSessions(prev => prev.map((s) =>
577
- s.id === sessionId ? { ...s, hidden } : s
578
- ))
579
- } catch (error) {
580
- console.error('更新会话隐藏状态失败:', error)
581
- }
582
- }, [adapter])
583
-
584
- const clearAllSessions = useCallback(async () => {
585
- try {
586
- for (const [, state] of sessionStatesRef.current) {
587
- if (state.isLoading && state.abortController) {
588
- state.abortController.abort()
589
- }
590
- }
591
- adapter.cancel()
592
-
593
- for (const session of sessionsRef.current) {
594
- await adapter.deleteSession(session.id)
595
- }
596
- setSessions([])
597
- sessionStatesRef.current.clear()
598
- await createNewSession()
599
- } catch (error) {
600
- console.error('清空所有会话失败:', error)
601
- }
602
- }, [adapter, createNewSession])
603
-
604
- const hideOtherSessions = useCallback(async () => {
605
- if (!currentSessionIdRef.current) return
606
- try {
607
- for (const session of sessionsRef.current) {
608
- if (session.id !== currentSessionIdRef.current && !session.hidden) {
609
- await adapter.updateSession(session.id, { hidden: true })
610
- }
611
- }
612
- setSessions(prev => prev.map((s) =>
613
- s.id === currentSessionIdRef.current ? s : { ...s, hidden: true }
614
- ))
615
- } catch (error) {
616
- console.error('隐藏其他会话失败:', error)
617
- }
618
- }, [adapter])
619
-
620
- const exportCurrentSession = useCallback((): string | null => {
621
- if (!currentSessionIdRef.current) return null
622
- const session = sessionsRef.current.find((s) => s.id === currentSessionIdRef.current)
623
- if (!session) return null
624
-
625
- const state = sessionStatesRef.current.get(currentSessionIdRef.current)
626
- const msgs = state?.messages || []
627
-
628
- return JSON.stringify({
629
- session: {
630
- id: session.id,
631
- title: session.title,
632
- model: session.model,
633
- mode: session.mode,
634
- createdAt: session.createdAt,
635
- updatedAt: session.updatedAt,
636
- },
637
- messages: msgs.map((msg) => ({
638
- id: msg.id,
639
- role: msg.role,
640
- parts: msg.parts,
641
- model: msg.model,
642
- mode: msg.mode,
643
- timestamp: msg.timestamp,
644
- })),
645
- exportedAt: new Date().toISOString(),
646
- }, null, 2)
647
- }, [])
648
-
649
- const deleteCurrentSession = useCallback(async () => {
650
- if (currentSessionIdRef.current) {
651
- await deleteSession(currentSessionIdRef.current)
652
- }
653
- }, [deleteSession])
654
-
655
- // ==================== 消息更新 ====================
656
- const updateSessionMessage = useCallback((
657
- sessionId: string,
658
- messageIndex: number,
659
- event: ChatEvent
660
- ) => {
661
- const state = sessionStatesRef.current.get(sessionId)
662
- if (!state) return
663
-
664
- const msg = state.messages[messageIndex]
665
- if (!msg) return
666
-
667
- const updatedMsg = { ...msg }
668
- let parts = [...updatedMsg.parts]
669
-
670
- switch (event.type) {
671
- case 'thinking_start': {
672
- parts.push({ type: 'thinking', text: '', status: 'running' })
673
- break
674
- }
675
-
676
- case 'thinking_delta': {
677
- const data = event.data as { content: string }
678
- // 查找最后一个 running 状态的 thinking part
679
- const lastThinkingIndex = parts.findLastIndex(
680
- p => p.type === 'thinking' && p.status === 'running'
681
- )
682
- if (lastThinkingIndex >= 0) {
683
- // 追加到现有的 running thinking part
684
- const part = parts[lastThinkingIndex] as ThinkingPart
685
- parts[lastThinkingIndex] = { ...part, text: part.text + data.content }
686
- }
687
- // ⚠️ 关键修复:如果没有 running 的 thinking part,忽略此事件(不创建新的)
688
- // thinking_start 事件负责创建,thinking_delta 只负责追加
689
- break
690
- }
691
-
692
- case 'thinking_end': {
693
- const data = event.data as { duration: number }
694
- const lastThinkingIndex = parts.findLastIndex(
695
- p => p.type === 'thinking' && p.status === 'running'
696
- )
697
- if (lastThinkingIndex >= 0) {
698
- const part = parts[lastThinkingIndex] as ThinkingPart
699
- parts[lastThinkingIndex] = {
700
- ...part,
701
- status: 'done',
702
- duration: Math.round(data.duration / 1000),
703
- }
704
- }
705
- break
706
- }
707
-
708
- case 'search_start': {
709
- const data = event.data as { query?: string }
710
- const searchPart: SearchPart = { type: 'search', query: data.query, status: 'running' }
711
-
712
- // UX:搜索结果事件可能在 text_delta 之后才到达(Provider 侧以 annotation/结果到达为准)
713
- // 若直接 push,会导致"搜索卡片"显示在回答之后。
714
- // 这里优先插入到第一个 text part 之前(通常在 thinking 后面),保持视觉顺序符合用户预期。
715
- const firstTextIndex = parts.findIndex(p => p.type === 'text')
716
- if (firstTextIndex >= 0) {
717
- parts.splice(firstTextIndex, 0, searchPart)
718
- } else {
719
- parts.push(searchPart)
720
- }
721
- break
722
- }
723
-
724
- case 'search_result': {
725
- const data = event.data as { results: SearchResult[] }
726
- const lastSearchIndex = parts.findLastIndex(p => p.type === 'search')
727
- if (lastSearchIndex >= 0) {
728
- const part = parts[lastSearchIndex] as SearchPart
729
- parts[lastSearchIndex] = { ...part, results: data.results, status: 'done' }
730
- }
731
- break
732
- }
733
-
734
- case 'search_end': {
735
- const lastSearchIndex = parts.findLastIndex(
736
- p => p.type === 'search' && p.status === 'running'
737
- )
738
- if (lastSearchIndex >= 0) {
739
- const part = parts[lastSearchIndex] as SearchPart
740
- parts[lastSearchIndex] = { ...part, status: 'done' }
741
- }
742
- break
743
- }
744
-
745
- case 'tool_approval_request': {
746
- const data = event.data as { id: string; name: string; args: Record<string, unknown> }
747
-
748
- // 如果当前模式是自动执行,直接批准(处理发送消息后切换模式的情况)
749
- if (autoRunConfigRef.current.mode === 'run-everything') {
750
- adapter.respondToolApproval?.(data.id, true)
751
- // 不创建 pending 状态,等待 tool_call_start 事件
752
- break
753
- }
754
-
755
- // manual 模式:创建 pending 状态等待用户确认
756
- const existingIndex = parts.findIndex(
757
- p => p.type === 'tool_call' && (p as ToolCallPart).id === data.id
758
- )
759
- if (existingIndex >= 0) {
760
- // 更新现有 part 为 pending
761
- const part = parts[existingIndex] as ToolCallPart
762
- parts[existingIndex] = { ...part, status: 'pending' }
763
- } else {
764
- // 创建新的 pending part
765
- parts.push({
766
- type: 'tool_call',
767
- id: data.id,
768
- name: data.name,
769
- args: data.args,
770
- status: 'pending',
771
- })
772
- }
773
- break
774
- }
775
-
776
- case 'tool_call_start': {
777
- const data = event.data as { id: string; name: string; args: Record<string, unknown> }
778
- // 检查是否已存在 pending 状态的 part
779
- const existingIndex = parts.findIndex(
780
- p => p.type === 'tool_call' && (p as ToolCallPart).id === data.id
781
- )
782
- if (existingIndex >= 0) {
783
- // 更新现有 part 为 running
784
- const part = parts[existingIndex] as ToolCallPart
785
- parts[existingIndex] = { ...part, status: 'running' }
786
- } else {
787
- // 创建新的 running part
788
- parts.push({
789
- type: 'tool_call',
790
- id: data.id,
791
- name: data.name,
792
- args: data.args,
793
- status: 'running',
794
- })
795
- }
796
- break
797
- }
798
-
799
- case 'tool_call_result': {
800
- const data = event.data as {
801
- id: string
802
- name: string
803
- result: string
804
- success: boolean
805
- resultType?: string // 结果类型(用于生成具体类型的 Part)
806
- }
807
-
808
- let parsedResult: unknown = data.result
809
- try {
810
- parsedResult = JSON.parse(data.result)
811
- } catch {
812
- // 保持原始字符串
813
- }
814
-
815
- // 判断状态:检查是否是跳过或取消操作
816
- const isSkipped = typeof parsedResult === 'object' && parsedResult !== null &&
817
- 'skipped' in parsedResult && (parsedResult as { skipped?: boolean }).skipped === true
818
- const isCancelled = typeof parsedResult === 'object' && parsedResult !== null &&
819
- 'cancelled' in parsedResult && (parsedResult as { cancelled?: boolean }).cancelled === true
820
- const status: 'done' | 'error' | 'cancelled' | 'skipped' =
821
- isSkipped ? 'skipped' : (isCancelled ? 'cancelled' : (data.success ? 'done' : 'error'))
822
-
823
- // 查找对应的 tool_call 并更新状态
824
- const toolCallIndex = parts.findIndex(
825
- p => p.type === 'tool_call' && (p as ToolCallPart).id === data.id
826
- )
827
-
828
- if (toolCallIndex >= 0) {
829
- // 更新 tool_call 状态(不再包含 result 字段)
830
- const toolCall = parts[toolCallIndex] as ToolCallPart
831
- parts[toolCallIndex] = {
832
- ...toolCall,
833
- status,
834
- }
835
- } else {
836
- // 如果没有对应的 tool_call,创建一个新的
837
- parts.push({
838
- type: 'tool_call',
839
- id: data.id,
840
- name: data.name,
841
- args: {},
842
- status,
843
- })
844
- }
845
-
846
- // 如果工具定义了 resultType 且执行成功,生成对应类型的 Part
847
- if (data.resultType && data.success && typeof parsedResult === 'object' && parsedResult !== null) {
848
- // 找到 tool_call 的位置,在其后插入结果 Part
849
- const updatedToolCallIndex = parts.findIndex(
850
- p => p.type === 'tool_call' && (p as ToolCallPart).id === data.id
851
- )
852
-
853
- // 创建具体类型的 Part(如 { type: 'weather', city: '北京', ... })
854
- const resultPart = {
855
- type: data.resultType,
856
- ...parsedResult as Record<string, unknown>,
857
- }
858
-
859
- // 检查是否已存在同类型的结果 Part(避免重复)
860
- const existingResultIndex = parts.findIndex(
861
- (p, i) => i > updatedToolCallIndex && p.type === data.resultType
862
- )
863
-
864
- if (existingResultIndex >= 0) {
865
- // 更新现有的结果 Part
866
- parts[existingResultIndex] = resultPart
867
- } else {
868
- // 插到对应 tool_call 之后
869
- const insertAt = updatedToolCallIndex >= 0 ? updatedToolCallIndex + 1 : parts.length
870
- parts.splice(insertAt, 0, resultPart)
871
- }
872
- }
873
-
874
- if (onToolComplete) {
875
- // 从事件数据中获取副作用声明
876
- const sideEffects = (data as { sideEffects?: SideEffect[] }).sideEffects
877
- onToolComplete({
878
- name: data.name,
879
- result: parsedResult,
880
- sideEffects,
881
- })
882
- }
883
- break
884
- }
885
-
886
- case 'tool_call_output': {
887
- const data = event.data as { id: string; name: string; stream: 'stdout' | 'stderr'; chunk: string }
888
- const toolCallIndex = parts.findIndex(
889
- p => p.type === 'tool_call' && (p as ToolCallPart).id === data.id
890
- )
891
- if (toolCallIndex >= 0) {
892
- const toolCall = parts[toolCallIndex] as ToolCallPart
893
- const prevStdout = toolCall.output?.stdout ?? ''
894
- const prevStderr = toolCall.output?.stderr ?? ''
895
- const MAX = 120_000
896
-
897
- const nextOutput =
898
- data.stream === 'stdout'
899
- ? { stdout: (prevStdout + (data.chunk || '')).slice(-MAX), stderr: prevStderr }
900
- : { stdout: prevStdout, stderr: (prevStderr + (data.chunk || '')).slice(-MAX) }
901
-
902
- parts[toolCallIndex] = {
903
- ...toolCall,
904
- output: nextOutput,
905
- }
906
- }
907
- break
908
- }
909
-
910
- case 'text_delta': {
911
- const data = event.data as { content: string }
912
-
913
- parts = parts.map(p =>
914
- p.type === 'search' && p.status === 'running'
915
- ? { ...p, status: 'done' as const }
916
- : p
917
- )
918
-
919
- // 查找最后一个 text part
920
- const lastTextIndex = parts.findLastIndex(p => p.type === 'text')
921
-
922
- // 检查最后一个 part 是否是已完成的工具调用/思考
923
- // 如果是,说明这是新一轮的文本输出,应该创建新的 text part
924
- //
925
- // 注意:search 结果可能会在文本输出过程中到达(甚至夹在 text_delta 中间),
926
- // 如果把 search(done) 也作为“新一轮文本”的边界,会导致文本被拆成多个 TextPart。
927
- const lastPart = parts[parts.length - 1]
928
- const shouldCreateNew = lastPart && (
929
- (lastPart.type === 'tool_call' && ['done', 'error', 'skipped', 'cancelled'].includes((lastPart as ToolCallPart).status)) ||
930
- (lastPart.type === 'thinking' && (lastPart as ThinkingPart).status === 'done')
931
- )
932
-
933
- if (shouldCreateNew || lastTextIndex < 0) {
934
- // 创建新的 text part
935
- parts.push({ type: 'text', text: data.content })
936
- } else {
937
- // 追加到现有的 text part
938
- const part = parts[lastTextIndex] as TextPart
939
- parts[lastTextIndex] = { ...part, text: part.text + data.content }
940
- }
941
- break
942
- }
943
-
944
- case 'error': {
945
- const data = event.data as { category?: string; message: string; retryable?: boolean }
946
-
947
- parts = parts.map(p => {
948
- if (p.type === 'thinking' && p.status === 'running') return { ...p, status: 'done' as const }
949
- if (p.type === 'search' && p.status === 'running') return { ...p, status: 'done' as const }
950
- if (p.type === 'tool_call' && p.status === 'running') return { ...p, status: 'error' as const }
951
- return p
952
- })
953
-
954
- parts.push({
955
- type: 'error',
956
- message: data.message,
957
- category: data.category,
958
- retryable: data.retryable,
959
- })
960
-
961
- updatedMsg.loading = false
962
- updatedMsg.error = data
963
- break
964
- }
965
-
966
- case 'abort': {
967
- parts = parts.map(p => {
968
- if (p.type === 'thinking' && p.status === 'running') return { ...p, status: 'done' as const }
969
- if (p.type === 'search' && p.status === 'running') return { ...p, status: 'done' as const }
970
- if (p.type === 'tool_call' && p.status === 'running') return { ...p, status: 'cancelled' as const }
971
- return p
972
- })
973
- updatedMsg.loading = false
974
- updatedMsg.aborted = true
975
- break
976
- }
977
-
978
- case 'done':
979
- updatedMsg.loading = false
980
- break
981
- }
982
-
983
- updatedMsg.parts = parts
984
- state.messages = [...state.messages]
985
- state.messages[messageIndex] = updatedMsg
986
- forceUpdate()
987
- }, [onToolComplete, forceUpdate])
988
-
989
- /**
990
- * 保存消息到数据库
991
- */
992
- const saveMessageToDb = useCallback(async (
993
- sessionId: string,
994
- messageIndex: number,
995
- messageId: string
996
- ) => {
997
- const state = sessionStatesRef.current.get(sessionId)
998
- if (!state) return
999
-
1000
- const msg = state.messages[messageIndex]
1001
- if (!msg) return
1002
-
1003
- try {
1004
- await adapter.updateMessage?.({
1005
- id: messageId,
1006
- content: extractTextContent(msg.parts),
1007
- steps: JSON.stringify(msg.parts),
1008
- })
1009
- } catch (error) {
1010
- console.error('[useChat/react] 保存消息失败:', error)
1011
- }
1012
- }, [adapter])
1013
-
1014
- /**
1015
- * 统一的事件处理函数:更新UI并保存到数据库
1016
- * 保证UI更新和DB保存的原子性
1017
- */
1018
- const handleEvent = useCallback(async (
1019
- sessionId: string,
1020
- messageIndex: number,
1021
- event: ChatEvent,
1022
- messageId: string,
1023
- options: { saveToDb: boolean } = { saveToDb: false }
1024
- ) => {
1025
- // 1. 先更新UI
1026
- updateSessionMessage(sessionId, messageIndex, event)
1027
-
1028
- // 2. 如果需要保存,立即保存(保证UI和DB一致)
1029
- if (options.saveToDb && shouldSaveEvent(event)) {
1030
- await saveMessageToDb(sessionId, messageIndex, messageId)
1031
- }
1032
- }, [updateSessionMessage, saveMessageToDb])
1033
-
1034
- // ==================== 发送消息 ====================
1035
- const sendMessage = useCallback(async (text: string, images?: string[]) => {
1036
- // 允许只发送图片(text 为空但有图片)
1037
- const hasContent = text.trim() || (images && images.length > 0)
1038
- if (!hasContent) return
1039
-
1040
- let sessionId = currentSessionIdRef.current
1041
-
1042
- if (sessionId) {
1043
- const currentState = sessionStatesRef.current.get(sessionId)
1044
- if (currentState?.isLoading) {
1045
- console.warn('[useChat] 当前会话正在进行中,请等待完成')
1046
- return
1047
- }
1048
- }
1049
-
1050
- if (!sessionId) {
1051
- try {
1052
- const session = await adapter.createSession({
1053
- title: '新对话',
1054
- model: modelRef.current,
1055
- mode: modeRef.current,
1056
- })
1057
- setSessions(prev => [session, ...prev])
1058
- sessionStatesRef.current.set(session.id, {
1059
- messages: [],
1060
- isLoading: false,
1061
- abortController: null,
1062
- activeAssistantMessageId: null,
1063
- })
1064
- setCurrentSessionId(session.id)
1065
- sessionId = session.id
1066
- } catch (error) {
1067
- console.error('创建会话失败:', error)
1068
- return
1069
- }
1070
- }
1071
-
1072
- const state = getSessionState(sessionId)
1073
-
1074
- const userMsg: ChatMessage = {
1075
- id: generateId(),
1076
- role: 'user',
1077
- parts: [{ type: 'text', text }],
1078
- images,
1079
- timestamp: Date.now(),
1080
- }
1081
- state.messages = [...state.messages, userMsg]
1082
- forceUpdate()
1083
-
1084
- try {
1085
- // 关键:传入 userMsg.id,确保"重新发送/分叉"能用同一个 id 做锚点更新/删除
1086
- await adapter.saveMessage({
1087
- id: userMsg.id,
1088
- sessionId,
1089
- role: 'user',
1090
- content: text,
1091
- images: images || [],
1092
- })
1093
-
1094
- if (state.messages.length === 1) {
1095
- const title = text.trim()
1096
- ? text.slice(0, 20) + (text.length > 20 ? '...' : '')
1097
- : (images && images.length > 0 ? '图片消息' : '新对话')
1098
- await adapter.updateSession(sessionId, { title })
1099
- setSessions(prev => prev.map((s) =>
1100
- s.id === sessionId ? { ...s, title } : s
1101
- ))
1102
- }
1103
- } catch (error) {
1104
- console.error('保存消息失败:', error)
1105
- }
1106
-
1107
- const assistantMsgIndex = state.messages.length
1108
- const assistantMsgId = generateId() // 保存消息 ID 用于后续更新
1109
- const assistantMsg: ChatMessage = {
1110
- id: assistantMsgId,
1111
- role: 'assistant',
1112
- parts: [],
1113
- model: modelRef.current,
1114
- mode: modeRef.current,
1115
- webSearchEnabled: webSearchRef.current,
1116
- thinkingEnabled: thinkingRef.current,
1117
- loading: true,
1118
- timestamp: Date.now(),
1119
- }
1120
- state.messages = [...state.messages, assistantMsg]
1121
-
1122
- // 创建本次请求专用的 abortController(避免多请求竞态条件)
1123
- const requestAbortController = new AbortController()
1124
- state.isLoading = true
1125
- state.abortController = requestAbortController
1126
- state.activeAssistantMessageId = assistantMsgId
1127
- forceUpdate()
1128
-
1129
- const sendModel = modelRef.current
1130
- const sendMode = modeRef.current
1131
- const sendWebSearch = webSearchRef.current
1132
- const sendThinking = thinkingRef.current
1133
- const sendEnabledTools = enabledToolsRef.current
1134
-
1135
- // 【关键】立即保存助手消息到数据库(初始状态)
1136
- try {
1137
- await adapter.saveMessage({
1138
- id: assistantMsgId,
1139
- sessionId,
1140
- role: 'assistant',
1141
- content: '',
1142
- model: sendModel,
1143
- mode: sendMode,
1144
- webSearchEnabled: sendWebSearch,
1145
- thinkingEnabled: sendThinking,
1146
- steps: '[]',
1147
- })
1148
- } catch (error) {
1149
- console.error('创建助手消息失败:', error)
1150
- }
1151
-
1152
- // 增量保存函数
1153
- const saveMessageProgress = async () => {
1154
- const msg = state.messages[assistantMsgIndex]
1155
- if (!msg) return
1156
- try {
1157
- await adapter.updateMessage?.({
1158
- id: assistantMsgId,
1159
- content: extractTextContent(msg.parts),
1160
- steps: JSON.stringify(msg.parts),
1161
- })
1162
- } catch (error) {
1163
- console.error('更新消息失败:', error)
1164
- }
1165
- }
1166
-
1167
- try {
1168
- // 构建历史消息(不包括刚添加的用户消息和助手占位消息)
1169
- const history = state.messages.slice(0, -2).map(msg => ({
1170
- role: msg.role as 'user' | 'assistant' | 'system' | 'tool',
1171
- // history 中去掉 @ 引用行(避免后续轮次出现“空引用”干扰)
1172
- content: stripAtContextLines(extractTextContent(msg.parts)),
1173
- }))
1174
-
1175
- const cleanAutoRunConfig: AutoRunConfig = { ...autoRunConfigRef.current }
1176
- const promptMessage = await buildPromptWithAtContext(adapter, text)
1177
-
1178
- for await (const event of adapter.sendMessage(
1179
- promptMessage,
1180
- {
1181
- mode: sendMode,
1182
- model: sendModel,
1183
- enableWebSearch: sendWebSearch,
1184
- thinkingMode: sendThinking ? 'enabled' : 'disabled',
1185
- enabledTools: sendEnabledTools,
1186
- autoRunConfig: cleanAutoRunConfig,
1187
- history, // 传递历史消息
1188
- },
1189
- images,
1190
- sessionId // 传递 sessionId 用于事件过滤
1191
- )) {
1192
- // 使用本次请求专用的 abortController 检查(避免被新请求覆盖)
1193
- if (requestAbortController.signal.aborted) break
1194
-
1195
- // 统一处理事件:更新UI并保存到数据库
1196
- await handleEvent(sessionId, assistantMsgIndex, event, assistantMsgId, {
1197
- saveToDb: true, // 重要事件需要保存
1198
- })
1199
-
1200
- if (event.type === 'done' || event.type === 'error') {
1201
- break
1202
- }
1203
- }
1204
- } catch (error) {
1205
- console.error('发送消息失败:', error)
1206
- await handleEvent(
1207
- sessionId,
1208
- assistantMsgIndex,
1209
- {
1210
- type: 'error',
1211
- data: { message: error instanceof Error ? error.message : String(error) },
1212
- },
1213
- assistantMsgId,
1214
- { saveToDb: true }
1215
- )
1216
- } finally {
1217
- state.isLoading = false
1218
-
1219
- const finalMsg = state.messages[assistantMsgIndex]
1220
- if (finalMsg) {
1221
- state.messages = [...state.messages]
1222
- state.messages[assistantMsgIndex] = { ...finalMsg, loading: false }
1223
- }
1224
-
1225
- // 【关键】最终保存一次,确保所有内容都被持久化
1226
- await saveMessageToDb(sessionId, assistantMsgIndex, assistantMsgId)
1227
-
1228
- state.abortController = null
1229
- state.activeAssistantMessageId = null
1230
- forceUpdate()
1231
- }
1232
- }, [adapter, getSessionState, handleEvent, saveMessageToDb, forceUpdate])
1233
-
1234
- // ==================== 其他方法 ====================
1235
- const patchPartsAsAborted = useCallback((parts: ContentPart[]): ContentPart[] => {
1236
- return parts.map((p) => {
1237
- if (p.type === 'thinking' && (p as ThinkingPart).status === 'running') {
1238
- return { ...(p as ThinkingPart), status: 'done' as const }
1239
- }
1240
- if (p.type === 'search' && (p as SearchPart).status === 'running') {
1241
- return { ...(p as SearchPart), status: 'done' as const }
1242
- }
1243
- if (p.type === 'tool_call') {
1244
- const tool = p as ToolCallPart
1245
- if (tool.status === 'running' || tool.status === 'pending') {
1246
- return { ...tool, status: 'cancelled' as const }
1247
- }
1248
- }
1249
- return p
1250
- })
1251
- }, [])
1252
-
1253
- const cancelActiveAssistantMessage = useCallback((sessionId: string) => {
1254
- const state = sessionStatesRef.current.get(sessionId)
1255
- if (!state) return
1256
- if (!state.activeAssistantMessageId) return
1257
- const idx = state.messages.findIndex((m) => m.id === state.activeAssistantMessageId)
1258
- if (idx < 0) return
1259
- const msg = state.messages[idx]
1260
- state.messages = [...state.messages]
1261
- state.messages[idx] = {
1262
- ...msg,
1263
- loading: false,
1264
- aborted: true,
1265
- parts: patchPartsAsAborted(msg.parts),
1266
- }
1267
- }, [patchPartsAsAborted])
1268
-
1269
- const cancelRequest = useCallback(() => {
1270
- if (!currentSessionIdRef.current) return
1271
- const sessionId = currentSessionIdRef.current
1272
- const state = sessionStatesRef.current.get(sessionId)
1273
-
1274
- // 先做 UI 即时落地:精准命中本轮 assistant 消息
1275
- cancelActiveAssistantMessage(sessionId)
1276
- if (state) {
1277
- state.abortController?.abort()
1278
- state.isLoading = false
1279
- state.activeAssistantMessageId = null
1280
- forceUpdate()
1281
- }
1282
- adapter.cancel()
1283
- }, [adapter, forceUpdate, cancelActiveAssistantMessage])
1284
-
1285
- const copyMessage = useCallback(async (messageId: string) => {
1286
- if (!currentSessionIdRef.current) return
1287
- const state = sessionStatesRef.current.get(currentSessionIdRef.current)
1288
- if (!state) return
1289
-
1290
- const msg = state.messages.find((m) => m.id === messageId)
1291
- if (!msg) return
1292
-
1293
- try {
1294
- const textContent = extractTextContent(msg.parts)
1295
- await navigator.clipboard.writeText(textContent)
1296
-
1297
- state.messages = state.messages.map((m) =>
1298
- m.id === messageId ? { ...m, copied: true } : m
1299
- )
1300
- forceUpdate()
1301
-
1302
- setTimeout(() => {
1303
- const s = sessionStatesRef.current.get(currentSessionIdRef.current!)
1304
- if (s) {
1305
- s.messages = s.messages.map((m) =>
1306
- m.id === messageId ? { ...m, copied: false } : m
1307
- )
1308
- forceUpdate()
1309
- }
1310
- }, 2000)
1311
- } catch (err) {
1312
- console.error('复制失败:', err)
1313
- }
1314
- }, [forceUpdate])
1315
-
1316
- /** 从指定索引重新发送消息(编辑后重发,分叉:删除其后的所有消息并重新生成) */
1317
- const resendFromIndex = useCallback(async (index: number, text: string) => {
1318
- if (!currentSessionIdRef.current) return
1319
- const sessionId = currentSessionIdRef.current
1320
- const state = sessionStatesRef.current.get(sessionId)
1321
- if (!state) return
1322
-
1323
- const targetMsg = state.messages[index]
1324
- if (!targetMsg || targetMsg.role !== 'user') {
1325
- // 容错:如果传入的不是 user 消息索引,退化为普通发送
1326
- sendMessage(text)
1327
- return
1328
- }
1329
-
1330
- // 如果当前会话正在生成,先取消,避免并发造成状态错乱
1331
- if (state.isLoading) {
1332
- state.abortController?.abort()
1333
- adapter.cancel()
1334
- state.isLoading = false
1335
- state.abortController = null
1336
- }
1337
-
1338
- // 1) UI:保留该条 user 消息,并更新文本;删除其后的所有消息(分叉)
1339
- const updatedUserMsg: ChatMessage = {
1340
- ...targetMsg,
1341
- parts: [{ type: 'text', text }],
1342
- }
1343
- state.messages = [...state.messages.slice(0, index), updatedUserMsg]
1344
- forceUpdate()
1345
-
1346
- // 2) DB:删除该 user 消息之后的所有消息(用于分叉)
1347
- // 新架构:只使用 messageId 作为锚点(更稳定),后端用 messageId 查 timestamp 再删除
1348
- try {
1349
- await adapter.deleteMessagesAfterMessageId(sessionId, updatedUserMsg.id)
1350
- } catch (error) {
1351
- console.error('[useChat/react] deleteMessagesAfterMessageId 失败:', error)
1352
- }
1353
-
1354
- // 3) DB:更新该条 user 消息内容(需要保证 saveMessage 使用同一个 id)
1355
- try {
1356
- await adapter.updateMessage?.({
1357
- id: updatedUserMsg.id,
1358
- content: text,
1359
- })
1360
- } catch (error) {
1361
- console.warn('[useChat/react] updateMessage(user) 失败,已忽略:', error)
1362
- }
1363
-
1364
- // 4) 从该 user 消息继续生成 assistant(不再新建 user 消息)
1365
- const assistantMsgIndex = state.messages.length
1366
- const assistantMsgId = generateId()
1367
- const assistantMsg: ChatMessage = {
1368
- id: assistantMsgId,
1369
- role: 'assistant',
1370
- parts: [],
1371
- model: modelRef.current,
1372
- mode: modeRef.current,
1373
- webSearchEnabled: webSearchRef.current,
1374
- thinkingEnabled: thinkingRef.current,
1375
- loading: true,
1376
- timestamp: Date.now(),
1377
- }
1378
- state.messages = [...state.messages, assistantMsg]
1379
-
1380
- const requestAbortController = new AbortController()
1381
- state.isLoading = true
1382
- state.abortController = requestAbortController
1383
- state.activeAssistantMessageId = assistantMsgId
1384
- forceUpdate()
1385
-
1386
- // 保存当前配置快照
1387
- const sendModel = modelRef.current
1388
- const sendMode = modeRef.current
1389
- const sendWebSearch = webSearchRef.current
1390
- const sendThinking = thinkingRef.current
1391
- const sendEnabledTools = enabledToolsRef.current
1392
-
1393
- // 立即保存助手消息到数据库(初始状态)
1394
- try {
1395
- await adapter.saveMessage({
1396
- id: assistantMsgId,
1397
- sessionId,
1398
- role: 'assistant',
1399
- content: '',
1400
- model: sendModel,
1401
- mode: sendMode,
1402
- webSearchEnabled: sendWebSearch,
1403
- thinkingEnabled: sendThinking,
1404
- steps: '[]',
1405
- })
1406
- } catch (error) {
1407
- console.error('[useChat/react] 创建 assistant 消息失败:', error)
1408
- }
1409
-
1410
- // 使用统一的事件处理函数
1411
-
1412
- try {
1413
- // history:取该 user 消息之前的消息(不包含当前 user)
1414
- const history = state.messages.slice(0, index).map(msg => ({
1415
- role: msg.role as 'user' | 'assistant' | 'system' | 'tool',
1416
- content: stripAtContextLines(extractTextContent(msg.parts)),
1417
- }))
1418
-
1419
- const cleanAutoRunConfig: AutoRunConfig = { ...autoRunConfigRef.current }
1420
- const images = updatedUserMsg.images
1421
- const promptMessage = await buildPromptWithAtContext(adapter, text)
1422
-
1423
- for await (const event of adapter.sendMessage(
1424
- promptMessage,
1425
- {
1426
- mode: sendMode,
1427
- model: sendModel,
1428
- enableWebSearch: sendWebSearch,
1429
- thinkingMode: sendThinking ? 'enabled' : 'disabled',
1430
- enabledTools: sendEnabledTools,
1431
- autoRunConfig: cleanAutoRunConfig,
1432
- history,
1433
- },
1434
- images,
1435
- sessionId
1436
- )) {
1437
- if (requestAbortController.signal.aborted) break
1438
-
1439
- // 统一处理事件:更新UI并保存到数据库
1440
- await handleEvent(sessionId, assistantMsgIndex, event, assistantMsgId, {
1441
- saveToDb: true, // 重要事件需要保存
1442
- })
1443
-
1444
- if (event.type === 'done' || event.type === 'error') break
1445
- }
1446
- } catch (error) {
1447
- console.error('[useChat/react] 分叉重发失败:', error)
1448
- await handleEvent(
1449
- sessionId,
1450
- assistantMsgIndex,
1451
- {
1452
- type: 'error',
1453
- data: { message: error instanceof Error ? error.message : String(error) },
1454
- },
1455
- assistantMsgId,
1456
- { saveToDb: true }
1457
- )
1458
- } finally {
1459
- // 收尾:取消 loading 状态
1460
- const s = sessionStatesRef.current.get(sessionId)
1461
- if (s) {
1462
- s.isLoading = false
1463
- s.abortController = null
1464
- s.activeAssistantMessageId = null
1465
- const finalMsg = s.messages[assistantMsgIndex]
1466
- if (finalMsg) {
1467
- s.messages[assistantMsgIndex] = { ...finalMsg, loading: false }
1468
- }
1469
- forceUpdate()
1470
- }
1471
- // 【关键】最终保存一次,确保所有内容都被持久化
1472
- await saveMessageToDb(sessionId, assistantMsgIndex, assistantMsgId)
1473
- }
1474
- }, [adapter, forceUpdate, sendMessage, handleEvent, saveMessageToDb])
1475
-
1476
- const regenerateMessage = useCallback((messageIndex: number) => {
1477
- if (!currentSessionIdRef.current) return
1478
- const state = sessionStatesRef.current.get(currentSessionIdRef.current)
1479
- if (!state) return
1480
-
1481
- if (messageIndex > 0 && state.messages[messageIndex - 1]?.role === 'user') {
1482
- const userIndex = messageIndex - 1
1483
- const userMsg = state.messages[userIndex]
1484
- const userText = extractTextContent(userMsg.parts)
1485
- void resendFromIndex(userIndex, userText)
1486
- }
1487
- }, [resendFromIndex])
1488
-
1489
- const setWorkingDirectory = useCallback((dir: string) => {
1490
- if (adapter.setCwd) {
1491
- adapter.setCwd(dir)
1492
- }
1493
- }, [adapter])
1494
-
1495
- // ==================== 返回 ====================
1496
- return {
1497
- sessions,
1498
- currentSessionId,
1499
- messages,
1500
- isLoading,
1501
- mode: modeState,
1502
- model: modelState,
1503
- webSearch: webSearchState,
1504
- thinking: thinkingState,
1505
-
1506
- loadSessions,
1507
- switchSession,
1508
- createNewSession,
1509
- deleteSession,
1510
- deleteCurrentSession,
1511
- hideSession,
1512
- clearAllSessions,
1513
- hideOtherSessions,
1514
- exportCurrentSession,
1515
-
1516
- sendMessage,
1517
- cancelRequest,
1518
- copyMessage,
1519
- regenerateMessage,
1520
- resendFromIndex,
1521
-
1522
- setMode: (value: ChatMode) => {
1523
- setModeState(value)
1524
- if (currentSessionIdRef.current) {
1525
- adapter.updateSession(currentSessionIdRef.current, { mode: value }).catch((e: Error) =>
1526
- console.error('更新会话 mode 失败:', e)
1527
- )
1528
- }
1529
- },
1530
- setModel: (value: string) => {
1531
- setModelState(value)
1532
- if (currentSessionIdRef.current) {
1533
- adapter.updateSession(currentSessionIdRef.current, { model: value }).catch((e: Error) =>
1534
- console.error('更新会话 model 失败:', e)
1535
- )
1536
- }
1537
- },
1538
- setWebSearch: (value: boolean) => {
1539
- setWebSearchState(value)
1540
- if (currentSessionIdRef.current) {
1541
- adapter.updateSession(currentSessionIdRef.current, { webSearchEnabled: value }).catch((e: Error) =>
1542
- console.error('更新会话 webSearchEnabled 失败:', e)
1543
- )
1544
- }
1545
- },
1546
- setThinking: (value: boolean) => {
1547
- setThinkingState(value)
1548
- if (currentSessionIdRef.current) {
1549
- adapter.updateSession(currentSessionIdRef.current, { thinkingEnabled: value }).catch((e: Error) =>
1550
- console.error('更新会话 thinkingEnabled 失败:', e)
1551
- )
1552
- }
1553
- },
1554
-
1555
- setWorkingDirectory,
1556
-
1557
- // 工具批准对话框
1558
-
1559
- // 自动运行配置
1560
- autoRunConfig,
1561
- loadAutoRunConfig,
1562
- saveAutoRunConfig,
1563
-
1564
- // 工具管理
1565
- enabledTools,
1566
- allTools,
1567
- saveEnabledTools,
1568
- }
1569
- }