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

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 (110) hide show
  1. package/README.md +99 -84
  2. package/dist/KaTeX_AMS-Regular-CYEKBG2K.woff +0 -0
  3. package/dist/KaTeX_AMS-Regular-JKX5W2C4.ttf +0 -0
  4. package/dist/KaTeX_AMS-Regular-U6PRYMIZ.woff2 +0 -0
  5. package/dist/KaTeX_Caligraphic-Bold-5QL5CMTE.woff2 +0 -0
  6. package/dist/KaTeX_Caligraphic-Bold-WZ3QSGD3.woff +0 -0
  7. package/dist/KaTeX_Caligraphic-Bold-ZTS3R3HK.ttf +0 -0
  8. package/dist/KaTeX_Caligraphic-Regular-3LKEU76G.woff +0 -0
  9. package/dist/KaTeX_Caligraphic-Regular-A7XRTZ5Q.ttf +0 -0
  10. package/dist/KaTeX_Caligraphic-Regular-KX5MEWCF.woff2 +0 -0
  11. package/dist/KaTeX_Fraktur-Bold-2QVFK6NQ.woff2 +0 -0
  12. package/dist/KaTeX_Fraktur-Bold-T4SWXBMT.woff +0 -0
  13. package/dist/KaTeX_Fraktur-Bold-WGHVTYOR.ttf +0 -0
  14. package/dist/KaTeX_Fraktur-Regular-2PEIFJSJ.woff2 +0 -0
  15. package/dist/KaTeX_Fraktur-Regular-5U4OPH2X.ttf +0 -0
  16. package/dist/KaTeX_Fraktur-Regular-PQMHCIK6.woff +0 -0
  17. package/dist/KaTeX_Main-Bold-2GA4IZIN.woff +0 -0
  18. package/dist/KaTeX_Main-Bold-W5FBVCZM.ttf +0 -0
  19. package/dist/KaTeX_Main-Bold-YP5VVQRP.woff2 +0 -0
  20. package/dist/KaTeX_Main-BoldItalic-4P4C7HJH.woff +0 -0
  21. package/dist/KaTeX_Main-BoldItalic-N4V3DX7S.woff2 +0 -0
  22. package/dist/KaTeX_Main-BoldItalic-ODMLBJJQ.ttf +0 -0
  23. package/dist/KaTeX_Main-Italic-I43T2HSR.ttf +0 -0
  24. package/dist/KaTeX_Main-Italic-RELBIK7M.woff2 +0 -0
  25. package/dist/KaTeX_Main-Italic-SASNQFN2.woff +0 -0
  26. package/dist/KaTeX_Main-Regular-ARRPAO67.woff2 +0 -0
  27. package/dist/KaTeX_Main-Regular-P5I74A2A.woff +0 -0
  28. package/dist/KaTeX_Main-Regular-W74P5G27.ttf +0 -0
  29. package/dist/KaTeX_Math-BoldItalic-6EBV3DK5.woff +0 -0
  30. package/dist/KaTeX_Math-BoldItalic-K4WTGH3J.woff2 +0 -0
  31. package/dist/KaTeX_Math-BoldItalic-VB447A4D.ttf +0 -0
  32. package/dist/KaTeX_Math-Italic-6KGCHLFN.woff2 +0 -0
  33. package/dist/KaTeX_Math-Italic-KKK3USB2.woff +0 -0
  34. package/dist/KaTeX_Math-Italic-SON4MRCA.ttf +0 -0
  35. package/dist/KaTeX_SansSerif-Bold-RRNVJFFW.woff2 +0 -0
  36. package/dist/KaTeX_SansSerif-Bold-STQ6RXC7.ttf +0 -0
  37. package/dist/KaTeX_SansSerif-Bold-X5M5EMOD.woff +0 -0
  38. package/dist/KaTeX_SansSerif-Italic-HMPFTM52.woff2 +0 -0
  39. package/dist/KaTeX_SansSerif-Italic-PSN4QKYX.woff +0 -0
  40. package/dist/KaTeX_SansSerif-Italic-WTBAZBGY.ttf +0 -0
  41. package/dist/KaTeX_SansSerif-Regular-2TL3USAE.ttf +0 -0
  42. package/dist/KaTeX_SansSerif-Regular-OQCII6EP.woff +0 -0
  43. package/dist/KaTeX_SansSerif-Regular-XIQ62X4E.woff2 +0 -0
  44. package/dist/KaTeX_Script-Regular-72OLXYNA.ttf +0 -0
  45. package/dist/KaTeX_Script-Regular-A5IFOEBS.woff +0 -0
  46. package/dist/KaTeX_Script-Regular-APUWIHLP.woff2 +0 -0
  47. package/dist/KaTeX_Size1-Regular-4HRHTS65.woff +0 -0
  48. package/dist/KaTeX_Size1-Regular-5LRUTBFT.woff2 +0 -0
  49. package/dist/KaTeX_Size1-Regular-7K6AASVL.ttf +0 -0
  50. package/dist/KaTeX_Size2-Regular-222HN3GT.ttf +0 -0
  51. package/dist/KaTeX_Size2-Regular-K5ZHAIS6.woff +0 -0
  52. package/dist/KaTeX_Size2-Regular-LELKET5D.woff2 +0 -0
  53. package/dist/KaTeX_Size3-Regular-TLFPAHDE.woff +0 -0
  54. package/dist/KaTeX_Size3-Regular-UFCO6WCA.ttf +0 -0
  55. package/dist/KaTeX_Size3-Regular-WQRQ47UD.woff2 +0 -0
  56. package/dist/KaTeX_Size4-Regular-7PGNVPQK.ttf +0 -0
  57. package/dist/KaTeX_Size4-Regular-CDMV7U5C.woff2 +0 -0
  58. package/dist/KaTeX_Size4-Regular-PKMWZHNC.woff +0 -0
  59. package/dist/KaTeX_Typewriter-Regular-3F5K6SQ6.ttf +0 -0
  60. package/dist/KaTeX_Typewriter-Regular-MJMFSK64.woff +0 -0
  61. package/dist/KaTeX_Typewriter-Regular-VBYJ4NRC.woff2 +0 -0
  62. package/dist/index.css +2156 -603
  63. package/dist/index.css.map +1 -1
  64. package/dist/index.d.ts +126 -92
  65. package/dist/index.js +1605 -976
  66. package/dist/index.js.map +1 -1
  67. package/dist/style.css +130 -0
  68. package/package.json +3 -3
  69. package/src/components/ChatPanel.tsx +82 -19
  70. package/src/components/common/SettingsPanel.css +81 -0
  71. package/src/components/common/SettingsPanel.tsx +96 -1
  72. package/src/components/input/ChatInput.css +0 -1
  73. package/src/components/input/ChatInput.tsx +48 -26
  74. package/src/components/input/DropdownSelector.css +66 -0
  75. package/src/components/input/DropdownSelector.tsx +157 -19
  76. package/src/components/message/MessageBubble.css +5 -2
  77. package/src/components/message/MessageBubble.tsx +44 -35
  78. package/src/components/message/PartsRenderer.css +8 -0
  79. package/src/components/message/PartsRenderer.tsx +137 -83
  80. package/src/components/message/parts/CollapsibleCard.css +4 -2
  81. package/src/components/message/parts/CollapsibleCard.tsx +4 -1
  82. package/src/components/message/parts/ImagePart.css +0 -1
  83. package/src/components/message/parts/TextPart.css +574 -5
  84. package/src/components/message/parts/TextPart.tsx +201 -8
  85. package/src/components/message/parts/ToolCallPart.css +139 -115
  86. package/src/components/message/parts/ToolCallPart.tsx +138 -134
  87. package/src/components/message/parts/ToolResultPart.css +0 -1
  88. package/src/components/message/parts/index.ts +3 -1
  89. package/src/components/message/parts/visual-predicate.ts +43 -0
  90. package/src/components/message/parts/visual-render.ts +19 -0
  91. package/src/components/message/parts/visual.ts +12 -0
  92. package/src/context/RenderersContext.tsx +19 -25
  93. package/src/hooks/useChat.ts +567 -79
  94. package/src/hooks/useImageUpload.ts +104 -12
  95. package/src/hooks/useVoiceInput.ts +17 -0
  96. package/src/index.ts +19 -16
  97. package/src/styles.css +130 -0
  98. package/src/types/index.ts +52 -68
  99. package/src/components/message/ContentRenderer.tsx +0 -63
  100. package/src/components/message/ToolResultRenderer.tsx +0 -21
  101. package/src/components/message/blocks/CodeBlock.tsx +0 -60
  102. package/src/components/message/blocks/TextBlock.tsx +0 -15
  103. package/src/components/message/blocks/blocks.css +0 -141
  104. package/src/components/message/blocks/index.ts +0 -6
  105. package/src/components/message/parts/ToolResultPart.tsx +0 -96
  106. package/src/components/message/tool-results/DefaultToolResult.tsx +0 -26
  107. package/src/components/message/tool-results/SearchResults.tsx +0 -69
  108. package/src/components/message/tool-results/WeatherCard.tsx +0 -63
  109. package/src/components/message/tool-results/index.ts +0 -7
  110. package/src/components/message/tool-results/tool-results.css +0 -181
@@ -12,7 +12,7 @@
12
12
  import { useState, useCallback, useRef, useMemo, useEffect } from 'react'
13
13
  import type {
14
14
  ChatAdapter,
15
- ChatEvent,
15
+ ChatEvent as BridgeChatEvent,
16
16
  ChatMode,
17
17
  SessionRecord,
18
18
  MessageRecord,
@@ -25,11 +25,96 @@ import type {
25
25
  ThinkingPart,
26
26
  SearchPart,
27
27
  ToolCallPart,
28
- ToolResultPart,
29
28
  ErrorPart,
30
29
  SearchResult,
31
30
  } from '../types'
32
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
+
33
118
  /** 生成唯一 ID */
34
119
  function generateId(): string {
35
120
  return Date.now().toString(36) + Math.random().toString(36).substr(2)
@@ -43,6 +128,18 @@ function extractTextContent(parts: ContentPart[]): string {
43
128
  .join('')
44
129
  }
45
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
+
46
143
  /** 解析工具调用结果 */
47
144
  function parseToolResult(result: unknown): unknown | null {
48
145
  if (result === undefined || result === null) return null
@@ -58,6 +155,11 @@ function parseToolResult(result: unknown): unknown | null {
58
155
 
59
156
  /** 转换存储的消息为显示格式 */
60
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
61
163
  let parts: ContentPart[] = []
62
164
 
63
165
  if (record.steps) {
@@ -86,6 +188,7 @@ function convertToMessage(record: MessageRecord): ChatMessage {
86
188
  args: step.args,
87
189
  result: parseToolResult(step.result),
88
190
  status: step.status || 'done',
191
+ output: step.output,
89
192
  })
90
193
  } else if (step.type === 'text') {
91
194
  parts.push({
@@ -117,12 +220,13 @@ function convertToMessage(record: MessageRecord): ChatMessage {
117
220
  id: record.id,
118
221
  role: record.role,
119
222
  parts,
223
+ images: record.images ?? [],
120
224
  model: record.model || undefined,
121
225
  mode: record.mode || undefined,
122
226
  webSearchEnabled: record.webSearchEnabled ?? undefined,
123
227
  thinkingEnabled: record.thinkingEnabled ?? undefined,
124
228
  loading: false,
125
- timestamp: record.timestamp,
229
+ timestamp,
126
230
  }
127
231
  }
128
232
 
@@ -131,6 +235,8 @@ interface SessionState {
131
235
  messages: ChatMessage[]
132
236
  isLoading: boolean
133
237
  abortController: AbortController | null
238
+ /** 当前进行中的 assistant 消息 ID(用于取消/落地状态,避免依赖 message.loading 的不稳定性) */
239
+ activeAssistantMessageId: string | null
134
240
  }
135
241
 
136
242
  /** 副作用定义 */
@@ -181,6 +287,15 @@ export function useChat(options: UseChatOptions) {
181
287
  const sessionStatesRef = useRef(new Map<string, SessionState>())
182
288
  const [stateVersion, setStateVersion] = useState(0)
183
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
+
184
299
  // ==================== 自动运行配置(从数据库读取) ====================
185
300
  // 默认配置
186
301
  const DEFAULT_AUTO_RUN_CONFIG: AutoRunConfig = {
@@ -222,10 +337,70 @@ export function useChat(options: UseChatOptions) {
222
337
  }
223
338
  }, [adapter])
224
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
+
225
398
  // 初始化时加载配置
226
399
  useEffect(() => {
227
400
  loadAutoRunConfig()
228
- }, [loadAutoRunConfig])
401
+ loadEnabledTools()
402
+ loadAllTools()
403
+ }, [loadAutoRunConfig, loadEnabledTools, loadAllTools])
229
404
 
230
405
  // 强制重新渲染
231
406
  const forceUpdate = useCallback(() => setStateVersion(v => v + 1), [])
@@ -237,6 +412,7 @@ export function useChat(options: UseChatOptions) {
237
412
  messages: [],
238
413
  isLoading: false,
239
414
  abortController: null,
415
+ activeAssistantMessageId: null,
240
416
  })
241
417
  }
242
418
  return sessionStatesRef.current.get(sessionId)!
@@ -291,19 +467,21 @@ export function useChat(options: UseChatOptions) {
291
467
  const list = await adapter.getSessions()
292
468
  setSessions(list)
293
469
  if (list.length > 0 && !currentSessionIdRef.current) {
294
- setCurrentSessionId(list[0].id)
470
+ // 选择第一个未隐藏的会话,如果都隐藏了则选第一个
471
+ const firstVisible = list.find(s => !s.hidden) || list[0]
472
+ setCurrentSessionId(firstVisible.id)
295
473
  // 加载消息
296
- const state = getSessionState(list[0].id)
474
+ const state = getSessionState(firstVisible.id)
297
475
  if (state.messages.length === 0) {
298
- const savedMessages = await adapter.getMessages(list[0].id)
476
+ const savedMessages = await adapter.getMessages(firstVisible.id)
299
477
  state.messages = savedMessages.map(convertToMessage)
300
478
  forceUpdate()
301
479
  }
302
480
  // 同步配置
303
- setModeState(list[0].mode)
304
- setModelState(list[0].model)
305
- setWebSearchState(list[0].webSearchEnabled)
306
- setThinkingState(list[0].thinkingEnabled)
481
+ setModeState(firstVisible.mode)
482
+ setModelState(firstVisible.model)
483
+ setWebSearchState(firstVisible.webSearchEnabled)
484
+ setThinkingState(firstVisible.thinkingEnabled)
307
485
  }
308
486
  } catch (error) {
309
487
  console.error('加载会话失败:', error)
@@ -356,6 +534,7 @@ export function useChat(options: UseChatOptions) {
356
534
  messages: [],
357
535
  isLoading: false,
358
536
  abortController: null,
537
+ activeAssistantMessageId: null,
359
538
  })
360
539
 
361
540
  setCurrentSessionId(session.id)
@@ -496,15 +675,17 @@ export function useChat(options: UseChatOptions) {
496
675
 
497
676
  case 'thinking_delta': {
498
677
  const data = event.data as { content: string }
678
+ // 查找最后一个 running 状态的 thinking part
499
679
  const lastThinkingIndex = parts.findLastIndex(
500
680
  p => p.type === 'thinking' && p.status === 'running'
501
681
  )
502
682
  if (lastThinkingIndex >= 0) {
683
+ // 追加到现有的 running thinking part
503
684
  const part = parts[lastThinkingIndex] as ThinkingPart
504
685
  parts[lastThinkingIndex] = { ...part, text: part.text + data.content }
505
- } else {
506
- parts.push({ type: 'thinking', text: data.content, status: 'running' })
507
686
  }
687
+ // ⚠️ 关键修复:如果没有 running 的 thinking part,忽略此事件(不创建新的)
688
+ // thinking_start 事件负责创建,thinking_delta 只负责追加
508
689
  break
509
690
  }
510
691
 
@@ -526,7 +707,17 @@ export function useChat(options: UseChatOptions) {
526
707
 
527
708
  case 'search_start': {
528
709
  const data = event.data as { query?: string }
529
- parts.push({ type: 'search', query: data.query, status: 'running' })
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
+ }
530
721
  break
531
722
  }
532
723
 
@@ -562,13 +753,13 @@ export function useChat(options: UseChatOptions) {
562
753
  }
563
754
 
564
755
  // manual 模式:创建 pending 状态等待用户确认
565
- const existingIndex = parts.findLastIndex(
756
+ const existingIndex = parts.findIndex(
566
757
  p => p.type === 'tool_call' && (p as ToolCallPart).id === data.id
567
758
  )
568
759
  if (existingIndex >= 0) {
569
760
  // 更新现有 part 为 pending
570
761
  const part = parts[existingIndex] as ToolCallPart
571
- parts[existingIndex] = { ...part, status: 'pending', result: null }
762
+ parts[existingIndex] = { ...part, status: 'pending' }
572
763
  } else {
573
764
  // 创建新的 pending part
574
765
  parts.push({
@@ -577,7 +768,6 @@ export function useChat(options: UseChatOptions) {
577
768
  name: data.name,
578
769
  args: data.args,
579
770
  status: 'pending',
580
- result: null,
581
771
  })
582
772
  }
583
773
  break
@@ -586,13 +776,13 @@ export function useChat(options: UseChatOptions) {
586
776
  case 'tool_call_start': {
587
777
  const data = event.data as { id: string; name: string; args: Record<string, unknown> }
588
778
  // 检查是否已存在 pending 状态的 part
589
- const existingIndex = parts.findLastIndex(
779
+ const existingIndex = parts.findIndex(
590
780
  p => p.type === 'tool_call' && (p as ToolCallPart).id === data.id
591
781
  )
592
782
  if (existingIndex >= 0) {
593
783
  // 更新现有 part 为 running
594
784
  const part = parts[existingIndex] as ToolCallPart
595
- parts[existingIndex] = { ...part, status: 'running', result: null }
785
+ parts[existingIndex] = { ...part, status: 'running' }
596
786
  } else {
597
787
  // 创建新的 running part
598
788
  parts.push({
@@ -601,14 +791,19 @@ export function useChat(options: UseChatOptions) {
601
791
  name: data.name,
602
792
  args: data.args,
603
793
  status: 'running',
604
- result: null,
605
794
  })
606
795
  }
607
796
  break
608
797
  }
609
798
 
610
799
  case 'tool_call_result': {
611
- const data = event.data as { id: string; name: string; result: string; success: boolean }
800
+ const data = event.data as {
801
+ id: string
802
+ name: string
803
+ result: string
804
+ success: boolean
805
+ resultType?: string // 结果类型(用于生成具体类型的 Part)
806
+ }
612
807
 
613
808
  let parsedResult: unknown = data.result
614
809
  try {
@@ -625,37 +820,55 @@ export function useChat(options: UseChatOptions) {
625
820
  const status: 'done' | 'error' | 'cancelled' | 'skipped' =
626
821
  isSkipped ? 'skipped' : (isCancelled ? 'cancelled' : (data.success ? 'done' : 'error'))
627
822
 
628
- // 查找对应的 tool_call
823
+ // 查找对应的 tool_call 并更新状态
629
824
  const toolCallIndex = parts.findIndex(
630
825
  p => p.type === 'tool_call' && (p as ToolCallPart).id === data.id
631
826
  )
632
827
 
633
828
  if (toolCallIndex >= 0) {
634
- // 更新 tool_call,添加 result,而不是转换为 tool_result
829
+ // 更新 tool_call 状态(不再包含 result 字段)
635
830
  const toolCall = parts[toolCallIndex] as ToolCallPart
636
831
  parts[toolCallIndex] = {
637
832
  ...toolCall,
638
- result: parsedResult,
639
833
  status,
640
834
  }
641
835
  } else {
642
- // 如果没有对应的 tool_call,创建一个新的 tool_call(而不是 tool_result)
836
+ // 如果没有对应的 tool_call,创建一个新的
643
837
  parts.push({
644
838
  type: 'tool_call',
645
839
  id: data.id,
646
840
  name: data.name,
647
841
  args: {},
648
- result: parsedResult,
649
842
  status,
650
843
  })
651
844
  }
652
-
653
- // 移除可能存在的独立 tool_result(如果有的话)
654
- const existingResultIndex = parts.findIndex(
655
- p => p.type === 'tool_result' && (p as ToolResultPart).id === data.id
656
- )
657
- if (existingResultIndex >= 0) {
658
- parts.splice(existingResultIndex, 1)
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
+ }
659
872
  }
660
873
 
661
874
  if (onToolComplete) {
@@ -670,6 +883,30 @@ export function useChat(options: UseChatOptions) {
670
883
  break
671
884
  }
672
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
+
673
910
  case 'text_delta': {
674
911
  const data = event.data as { content: string }
675
912
 
@@ -682,12 +919,14 @@ export function useChat(options: UseChatOptions) {
682
919
  // 查找最后一个 text part
683
920
  const lastTextIndex = parts.findLastIndex(p => p.type === 'text')
684
921
 
685
- // 检查最后一个 part 是否是已完成的工具调用/搜索/思考
922
+ // 检查最后一个 part 是否是已完成的工具调用/思考
686
923
  // 如果是,说明这是新一轮的文本输出,应该创建新的 text part
924
+ //
925
+ // 注意:search 结果可能会在文本输出过程中到达(甚至夹在 text_delta 中间),
926
+ // 如果把 search(done) 也作为“新一轮文本”的边界,会导致文本被拆成多个 TextPart。
687
927
  const lastPart = parts[parts.length - 1]
688
928
  const shouldCreateNew = lastPart && (
689
929
  (lastPart.type === 'tool_call' && ['done', 'error', 'skipped', 'cancelled'].includes((lastPart as ToolCallPart).status)) ||
690
- (lastPart.type === 'search' && (lastPart as SearchPart).status === 'done') ||
691
930
  (lastPart.type === 'thinking' && (lastPart as ThinkingPart).status === 'done')
692
931
  )
693
932
 
@@ -747,9 +986,56 @@ export function useChat(options: UseChatOptions) {
747
986
  forceUpdate()
748
987
  }, [onToolComplete, forceUpdate])
749
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
+
750
1034
  // ==================== 发送消息 ====================
751
1035
  const sendMessage = useCallback(async (text: string, images?: string[]) => {
752
- if (!text.trim()) return
1036
+ // 允许只发送图片(text 为空但有图片)
1037
+ const hasContent = text.trim() || (images && images.length > 0)
1038
+ if (!hasContent) return
753
1039
 
754
1040
  let sessionId = currentSessionIdRef.current
755
1041
 
@@ -773,6 +1059,7 @@ export function useChat(options: UseChatOptions) {
773
1059
  messages: [],
774
1060
  isLoading: false,
775
1061
  abortController: null,
1062
+ activeAssistantMessageId: null,
776
1063
  })
777
1064
  setCurrentSessionId(session.id)
778
1065
  sessionId = session.id
@@ -789,20 +1076,25 @@ export function useChat(options: UseChatOptions) {
789
1076
  role: 'user',
790
1077
  parts: [{ type: 'text', text }],
791
1078
  images,
792
- timestamp: new Date(),
1079
+ timestamp: Date.now(),
793
1080
  }
794
1081
  state.messages = [...state.messages, userMsg]
795
1082
  forceUpdate()
796
1083
 
797
1084
  try {
1085
+ // 关键:传入 userMsg.id,确保"重新发送/分叉"能用同一个 id 做锚点更新/删除
798
1086
  await adapter.saveMessage({
1087
+ id: userMsg.id,
799
1088
  sessionId,
800
1089
  role: 'user',
801
1090
  content: text,
1091
+ images: images || [],
802
1092
  })
803
1093
 
804
1094
  if (state.messages.length === 1) {
805
- const title = text.slice(0, 20) + (text.length > 20 ? '...' : '')
1095
+ const title = text.trim()
1096
+ ? text.slice(0, 20) + (text.length > 20 ? '...' : '')
1097
+ : (images && images.length > 0 ? '图片消息' : '新对话')
806
1098
  await adapter.updateSession(sessionId, { title })
807
1099
  setSessions(prev => prev.map((s) =>
808
1100
  s.id === sessionId ? { ...s, title } : s
@@ -823,7 +1115,7 @@ export function useChat(options: UseChatOptions) {
823
1115
  webSearchEnabled: webSearchRef.current,
824
1116
  thinkingEnabled: thinkingRef.current,
825
1117
  loading: true,
826
- timestamp: new Date(),
1118
+ timestamp: Date.now(),
827
1119
  }
828
1120
  state.messages = [...state.messages, assistantMsg]
829
1121
 
@@ -831,12 +1123,14 @@ export function useChat(options: UseChatOptions) {
831
1123
  const requestAbortController = new AbortController()
832
1124
  state.isLoading = true
833
1125
  state.abortController = requestAbortController
1126
+ state.activeAssistantMessageId = assistantMsgId
834
1127
  forceUpdate()
835
1128
 
836
1129
  const sendModel = modelRef.current
837
1130
  const sendMode = modeRef.current
838
1131
  const sendWebSearch = webSearchRef.current
839
1132
  const sendThinking = thinkingRef.current
1133
+ const sendEnabledTools = enabledToolsRef.current
840
1134
 
841
1135
  // 【关键】立即保存助手消息到数据库(初始状态)
842
1136
  try {
@@ -874,17 +1168,22 @@ export function useChat(options: UseChatOptions) {
874
1168
  // 构建历史消息(不包括刚添加的用户消息和助手占位消息)
875
1169
  const history = state.messages.slice(0, -2).map(msg => ({
876
1170
  role: msg.role as 'user' | 'assistant' | 'system' | 'tool',
877
- content: extractTextContent(msg.parts),
1171
+ // history 中去掉 @ 引用行(避免后续轮次出现“空引用”干扰)
1172
+ content: stripAtContextLines(extractTextContent(msg.parts)),
878
1173
  }))
879
1174
 
1175
+ const cleanAutoRunConfig: AutoRunConfig = { ...autoRunConfigRef.current }
1176
+ const promptMessage = await buildPromptWithAtContext(adapter, text)
1177
+
880
1178
  for await (const event of adapter.sendMessage(
881
- text,
1179
+ promptMessage,
882
1180
  {
883
1181
  mode: sendMode,
884
1182
  model: sendModel,
885
1183
  enableWebSearch: sendWebSearch,
886
1184
  thinkingMode: sendThinking ? 'enabled' : 'disabled',
887
- autoRunConfig,
1185
+ enabledTools: sendEnabledTools,
1186
+ autoRunConfig: cleanAutoRunConfig,
888
1187
  history, // 传递历史消息
889
1188
  },
890
1189
  images,
@@ -893,20 +1192,10 @@ export function useChat(options: UseChatOptions) {
893
1192
  // 使用本次请求专用的 abortController 检查(避免被新请求覆盖)
894
1193
  if (requestAbortController.signal.aborted) break
895
1194
 
896
- updateSessionMessage(sessionId, assistantMsgIndex, event)
897
-
898
- // 【关键】在重要事件后增量保存到数据库
899
- const shouldSave =
900
- event.type === 'thinking_end' ||
901
- event.type === 'tool_call_result' ||
902
- event.type === 'text_delta' ||
903
- event.type === 'done' ||
904
- event.type === 'error' ||
905
- event.type === 'abort'
906
-
907
- if (shouldSave) {
908
- await saveMessageProgress()
909
- }
1195
+ // 统一处理事件:更新UI并保存到数据库
1196
+ await handleEvent(sessionId, assistantMsgIndex, event, assistantMsgId, {
1197
+ saveToDb: true, // 重要事件需要保存
1198
+ })
910
1199
 
911
1200
  if (event.type === 'done' || event.type === 'error') {
912
1201
  break
@@ -914,10 +1203,16 @@ export function useChat(options: UseChatOptions) {
914
1203
  }
915
1204
  } catch (error) {
916
1205
  console.error('发送消息失败:', error)
917
- updateSessionMessage(sessionId, assistantMsgIndex, {
918
- type: 'error',
919
- data: { message: error instanceof Error ? error.message : String(error) },
920
- })
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
+ )
921
1216
  } finally {
922
1217
  state.isLoading = false
923
1218
 
@@ -928,24 +1223,64 @@ export function useChat(options: UseChatOptions) {
928
1223
  }
929
1224
 
930
1225
  // 【关键】最终保存一次,确保所有内容都被持久化
931
- await saveMessageProgress()
1226
+ await saveMessageToDb(sessionId, assistantMsgIndex, assistantMsgId)
932
1227
 
933
1228
  state.abortController = null
1229
+ state.activeAssistantMessageId = null
934
1230
  forceUpdate()
935
1231
  }
936
- }, [adapter, getSessionState, updateSessionMessage, forceUpdate])
1232
+ }, [adapter, getSessionState, handleEvent, saveMessageToDb, forceUpdate])
937
1233
 
938
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
+
939
1269
  const cancelRequest = useCallback(() => {
940
1270
  if (!currentSessionIdRef.current) return
941
- const state = sessionStatesRef.current.get(currentSessionIdRef.current)
1271
+ const sessionId = currentSessionIdRef.current
1272
+ const state = sessionStatesRef.current.get(sessionId)
1273
+
1274
+ // 先做 UI 即时落地:精准命中本轮 assistant 消息
1275
+ cancelActiveAssistantMessage(sessionId)
942
1276
  if (state) {
943
1277
  state.abortController?.abort()
944
1278
  state.isLoading = false
1279
+ state.activeAssistantMessageId = null
945
1280
  forceUpdate()
946
1281
  }
947
1282
  adapter.cancel()
948
- }, [adapter, forceUpdate])
1283
+ }, [adapter, forceUpdate, cancelActiveAssistantMessage])
949
1284
 
950
1285
  const copyMessage = useCallback(async (messageId: string) => {
951
1286
  if (!currentSessionIdRef.current) return
@@ -978,30 +1313,178 @@ export function useChat(options: UseChatOptions) {
978
1313
  }
979
1314
  }, [forceUpdate])
980
1315
 
981
- const regenerateMessage = useCallback((messageIndex: number) => {
1316
+ /** 从指定索引重新发送消息(编辑后重发,分叉:删除其后的所有消息并重新生成) */
1317
+ const resendFromIndex = useCallback(async (index: number, text: string) => {
982
1318
  if (!currentSessionIdRef.current) return
983
- const state = sessionStatesRef.current.get(currentSessionIdRef.current)
1319
+ const sessionId = currentSessionIdRef.current
1320
+ const state = sessionStatesRef.current.get(sessionId)
984
1321
  if (!state) return
985
1322
 
986
- if (messageIndex > 0 && state.messages[messageIndex - 1]?.role === 'user') {
987
- const userMsg = state.messages[messageIndex - 1]
988
- const userText = extractTextContent(userMsg.parts)
989
- state.messages = state.messages.slice(0, messageIndex - 1)
990
- forceUpdate()
991
- sendMessage(userText, userMsg.images)
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(),
992
1377
  }
993
- }, [sendMessage, forceUpdate])
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()
994
1385
 
995
- /** 从指定索引重新发送消息(编辑后重发) */
996
- const resendFromIndex = useCallback((index: number, text: string) => {
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) => {
997
1477
  if (!currentSessionIdRef.current) return
998
1478
  const state = sessionStatesRef.current.get(currentSessionIdRef.current)
999
1479
  if (!state) return
1000
1480
 
1001
- state.messages = state.messages.slice(0, index)
1002
- forceUpdate()
1003
- sendMessage(text)
1004
- }, [sendMessage, forceUpdate])
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])
1005
1488
 
1006
1489
  const setWorkingDirectory = useCallback((dir: string) => {
1007
1490
  if (adapter.setCwd) {
@@ -1077,5 +1560,10 @@ export function useChat(options: UseChatOptions) {
1077
1560
  autoRunConfig,
1078
1561
  loadAutoRunConfig,
1079
1562
  saveAutoRunConfig,
1563
+
1564
+ // 工具管理
1565
+ enabledTools,
1566
+ allTools,
1567
+ saveEnabledTools,
1080
1568
  }
1081
1569
  }