@huyooo/ai-chat-frontend-react 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +368 -0
  2. package/dist/index.css +2575 -0
  3. package/dist/index.css.map +1 -0
  4. package/dist/index.d.ts +378 -135
  5. package/dist/index.js +3956 -1042
  6. package/dist/index.js.map +1 -1
  7. package/dist/style.css +48 -987
  8. package/package.json +7 -4
  9. package/src/adapter.ts +10 -70
  10. package/src/components/ChatPanel.tsx +373 -117
  11. package/src/components/common/ConfirmDialog.css +136 -0
  12. package/src/components/common/ConfirmDialog.tsx +91 -0
  13. package/src/components/common/CopyButton.css +22 -0
  14. package/src/components/common/CopyButton.tsx +46 -0
  15. package/src/components/common/IndexingSettings.css +207 -0
  16. package/src/components/common/IndexingSettings.tsx +398 -0
  17. package/src/components/common/SettingsPanel.css +256 -0
  18. package/src/components/common/SettingsPanel.tsx +120 -0
  19. package/src/components/common/Toast.css +50 -0
  20. package/src/components/common/Toast.tsx +38 -0
  21. package/src/components/common/ToggleSwitch.css +52 -0
  22. package/src/components/common/ToggleSwitch.tsx +20 -0
  23. package/src/components/header/ChatHeader.css +285 -0
  24. package/src/components/header/ChatHeader.tsx +376 -0
  25. package/src/components/input/AtFilePicker.css +147 -0
  26. package/src/components/input/AtFilePicker.tsx +519 -0
  27. package/src/components/input/ChatInput.css +204 -0
  28. package/src/components/input/ChatInput.tsx +506 -0
  29. package/src/components/input/DropdownSelector.css +159 -0
  30. package/src/components/input/DropdownSelector.tsx +195 -0
  31. package/src/components/input/ImagePreviewModal.css +124 -0
  32. package/src/components/input/ImagePreviewModal.tsx +118 -0
  33. package/src/components/input/at-views/AtBranchView.tsx +34 -0
  34. package/src/components/input/at-views/AtBrowserView.tsx +34 -0
  35. package/src/components/input/at-views/AtChatsView.tsx +34 -0
  36. package/src/components/input/at-views/AtDocsView.tsx +34 -0
  37. package/src/components/input/at-views/AtFilesView.tsx +168 -0
  38. package/src/components/input/at-views/AtTerminalsView.tsx +34 -0
  39. package/src/components/input/at-views/AtViewStyles.css +143 -0
  40. package/src/components/input/at-views/index.ts +9 -0
  41. package/src/components/message/ContentRenderer.css +9 -0
  42. package/src/components/message/ContentRenderer.tsx +63 -0
  43. package/src/components/message/MessageBubble.css +190 -0
  44. package/src/components/message/MessageBubble.tsx +231 -0
  45. package/src/components/message/PartsRenderer.css +4 -0
  46. package/src/components/message/PartsRenderer.tsx +114 -0
  47. package/src/components/message/ToolResultRenderer.tsx +21 -0
  48. package/src/components/message/WelcomeMessage.css +221 -0
  49. package/src/components/message/WelcomeMessage.tsx +93 -0
  50. package/src/components/message/blocks/CodeBlock.tsx +60 -0
  51. package/src/components/message/blocks/TextBlock.tsx +15 -0
  52. package/src/components/message/blocks/blocks.css +141 -0
  53. package/src/components/message/blocks/index.ts +6 -0
  54. package/src/components/message/parts/CollapsibleCard.css +78 -0
  55. package/src/components/message/parts/CollapsibleCard.tsx +77 -0
  56. package/src/components/message/parts/ErrorPart.css +9 -0
  57. package/src/components/message/parts/ErrorPart.tsx +40 -0
  58. package/src/components/message/parts/ImagePart.css +50 -0
  59. package/src/components/message/parts/ImagePart.tsx +54 -0
  60. package/src/components/message/parts/SearchPart.css +44 -0
  61. package/src/components/message/parts/SearchPart.tsx +63 -0
  62. package/src/components/message/parts/TextPart.css +10 -0
  63. package/src/components/message/parts/TextPart.tsx +20 -0
  64. package/src/components/message/parts/ThinkingPart.css +9 -0
  65. package/src/components/message/parts/ThinkingPart.tsx +48 -0
  66. package/src/components/message/parts/ToolCallPart.css +220 -0
  67. package/src/components/message/parts/ToolCallPart.tsx +285 -0
  68. package/src/components/message/parts/ToolResultPart.css +68 -0
  69. package/src/components/message/parts/ToolResultPart.tsx +96 -0
  70. package/src/components/message/parts/index.ts +11 -0
  71. package/src/components/message/tool-results/DefaultToolResult.tsx +26 -0
  72. package/src/components/message/tool-results/SearchResults.tsx +69 -0
  73. package/src/components/message/tool-results/WeatherCard.tsx +63 -0
  74. package/src/components/message/tool-results/index.ts +7 -0
  75. package/src/components/message/tool-results/tool-results.css +179 -0
  76. package/src/components/message/welcome-types.ts +46 -0
  77. package/src/context/AutoRunConfigContext.tsx +13 -0
  78. package/src/context/ChatAdapterContext.tsx +8 -0
  79. package/src/context/ChatInputContext.tsx +40 -0
  80. package/src/context/RenderersContext.tsx +41 -0
  81. package/src/hooks/useChat.ts +855 -237
  82. package/src/hooks/useImageUpload.ts +253 -0
  83. package/src/index.ts +96 -39
  84. package/src/styles.css +48 -987
  85. package/src/types/index.ts +172 -103
  86. package/src/utils/fileIcon.ts +49 -0
  87. package/src/components/ChatInput.tsx +0 -368
  88. package/src/components/chat/messages/ExecutionSteps.tsx +0 -234
  89. package/src/components/chat/messages/MessageBubble.tsx +0 -130
  90. package/src/components/chat/ui/ChatHeader.tsx +0 -301
  91. package/src/components/chat/ui/WelcomeMessage.tsx +0 -107
@@ -1,259 +1,766 @@
1
1
  /**
2
2
  * useChat Hook
3
3
  * 管理聊天状态和与后端的通信
4
- * 使用 Adapter 模式解耦后端通信
5
4
  *
6
- * 与 Vue 版本 useChat composable 保持一致
5
+ * 新架构:
6
+ * - 使用 Map 存储每个会话的独立状态
7
+ * - 多会话可同时进行,互不干扰
8
+ * - 切换 tab 不会停止正在进行的请求
9
+ * - ContentPart 数组存储消息内容,支持流式渲染和自定义 UI
7
10
  */
8
11
 
9
- import { useState, useCallback, useRef } from 'react'
12
+ import { useState, useCallback, useRef, useMemo, useEffect } from 'react'
10
13
  import type {
11
14
  ChatAdapter,
12
- ChatProgress,
15
+ ChatEvent,
13
16
  ChatMode,
14
17
  SessionRecord,
15
18
  MessageRecord,
19
+ AutoRunConfig,
16
20
  } from '@huyooo/ai-chat-bridge-electron/renderer'
17
- import { createNullAdapter } from '../adapter'
18
- import type { ChatMessage, SearchResult, ToolCall } from '../types'
21
+ import type {
22
+ ChatMessage,
23
+ ContentPart,
24
+ TextPart,
25
+ ThinkingPart,
26
+ SearchPart,
27
+ ToolCallPart,
28
+ ToolResultPart,
29
+ ErrorPart,
30
+ SearchResult,
31
+ } from '../types'
19
32
 
20
33
  /** 生成唯一 ID */
21
34
  function generateId(): string {
22
35
  return Date.now().toString(36) + Math.random().toString(36).substr(2)
23
36
  }
24
37
 
38
+ /** 获取消息的纯文本内容 */
39
+ function extractTextContent(parts: ContentPart[]): string {
40
+ return parts
41
+ .filter((p): p is TextPart => p.type === 'text')
42
+ .map(p => p.text)
43
+ .join('')
44
+ }
45
+
46
+ /** 解析工具调用结果 */
47
+ function parseToolResult(result: unknown): unknown | null {
48
+ if (result === undefined || result === null) return null
49
+ if (typeof result === 'string') {
50
+ try {
51
+ return JSON.parse(result)
52
+ } catch {
53
+ return result
54
+ }
55
+ }
56
+ return result
57
+ }
58
+
25
59
  /** 转换存储的消息为显示格式 */
26
60
  function convertToMessage(record: MessageRecord): ChatMessage {
61
+ let parts: ContentPart[] = []
62
+
63
+ if (record.steps) {
64
+ try {
65
+ const steps = JSON.parse(record.steps)
66
+ for (const step of steps) {
67
+ if (step.type === 'thinking') {
68
+ parts.push({
69
+ type: 'thinking',
70
+ text: step.text || '',
71
+ status: step.status || 'done',
72
+ duration: step.duration,
73
+ })
74
+ } else if (step.type === 'search') {
75
+ parts.push({
76
+ type: 'search',
77
+ query: step.query,
78
+ results: step.results,
79
+ status: step.status || 'done',
80
+ })
81
+ } else if (step.type === 'tool_call') {
82
+ parts.push({
83
+ type: 'tool_call',
84
+ id: step.id,
85
+ name: step.name,
86
+ args: step.args,
87
+ result: parseToolResult(step.result),
88
+ status: step.status || 'done',
89
+ })
90
+ } else if (step.type === 'text') {
91
+ parts.push({
92
+ type: 'text',
93
+ text: step.text || '',
94
+ })
95
+ } else if (step.type === 'error') {
96
+ parts.push({
97
+ type: 'error',
98
+ message: step.message || '',
99
+ category: step.category,
100
+ })
101
+ }
102
+ }
103
+ } catch {
104
+ // 解析失败,忽略 steps
105
+ }
106
+ }
107
+
108
+ if (record.content && !parts.some(p => p.type === 'text')) {
109
+ parts.push({ type: 'text', text: record.content })
110
+ }
111
+
112
+ if (parts.length === 0) {
113
+ parts.push({ type: 'text', text: '' })
114
+ }
115
+
27
116
  return {
28
117
  id: record.id,
29
118
  role: record.role,
30
- content: record.content,
31
- thinking: record.thinking || undefined,
32
- thinkingComplete: true,
33
- toolCalls: record.toolCalls ? JSON.parse(record.toolCalls) : undefined,
34
- searchResults: record.searchResults ? JSON.parse(record.searchResults) : undefined,
35
- searching: false,
119
+ parts,
120
+ model: record.model || undefined,
121
+ mode: record.mode || undefined,
122
+ webSearchEnabled: record.webSearchEnabled ?? undefined,
123
+ thinkingEnabled: record.thinkingEnabled ?? undefined,
124
+ loading: false,
36
125
  timestamp: record.timestamp,
37
126
  }
38
127
  }
39
128
 
129
+ /** 会话状态 */
130
+ interface SessionState {
131
+ messages: ChatMessage[]
132
+ isLoading: boolean
133
+ abortController: AbortController | null
134
+ }
135
+
136
+ /** 副作用定义 */
137
+ export interface SideEffect {
138
+ type: string
139
+ success: boolean
140
+ data?: unknown
141
+ message?: string
142
+ }
143
+
144
+ /** 工具完成事件数据 */
145
+ export interface ToolCompleteEvent {
146
+ name: string
147
+ result: unknown
148
+ /**
149
+ * 工具声明的副作用列表
150
+ * 前端可根据此字段处理通知、刷新文件列表等
151
+ */
152
+ sideEffects?: SideEffect[]
153
+ }
154
+
40
155
  export interface UseChatOptions {
41
- /** Adapter 实例 */
42
- adapter?: ChatAdapter
43
- /** 默认模型 */
156
+ adapter: ChatAdapter
44
157
  defaultModel?: string
45
- /** 默认模式 */
46
158
  defaultMode?: ChatMode
159
+ onToolComplete?: (event: ToolCompleteEvent) => void
160
+ autoRunConfig?: AutoRunConfig
47
161
  }
48
162
 
49
163
  /**
50
164
  * 聊天状态管理 Hook
165
+ *
166
+ * 核心架构:
167
+ * - sessionStatesRef: Map<sessionId, SessionState> 存储每个会话的独立状态
168
+ * - 多会话可同时进行请求,互不干扰
169
+ * - 切换 tab 不会停止任何正在进行的请求
51
170
  */
52
- export function useChat(options: UseChatOptions = {}) {
171
+ export function useChat(options: UseChatOptions) {
53
172
  const {
54
- adapter = createNullAdapter(),
173
+ adapter,
55
174
  defaultModel = 'anthropic/claude-opus-4.5',
56
175
  defaultMode = 'agent',
176
+ onToolComplete,
177
+ // autoRunConfig 现在从数据库读取,不再从 props 传入
57
178
  } = options
58
179
 
59
- // 会话状态
180
+ // ==================== 会话状态存储 ====================
181
+ const sessionStatesRef = useRef(new Map<string, SessionState>())
182
+ const [stateVersion, setStateVersion] = useState(0)
183
+
184
+ // ==================== 自动运行配置(从数据库读取) ====================
185
+ // 默认配置
186
+ const DEFAULT_AUTO_RUN_CONFIG: AutoRunConfig = {
187
+ mode: 'run-everything',
188
+ }
189
+
190
+ const [autoRunConfig, setAutoRunConfig] = useState<AutoRunConfig>(DEFAULT_AUTO_RUN_CONFIG)
191
+ const autoRunConfigRef = useRef(autoRunConfig)
192
+ autoRunConfigRef.current = autoRunConfig
193
+
194
+ /** 从数据库加载自动运行配置 */
195
+ const loadAutoRunConfig = useCallback(async () => {
196
+ if (!adapter.getAllSettings) return
197
+
198
+ try {
199
+ const settings = await adapter.getAllSettings()
200
+ const configJson = settings['autoRunConfig']
201
+
202
+ if (configJson) {
203
+ const config = JSON.parse(configJson) as AutoRunConfig
204
+ // 合并配置,确保新增字段有默认值
205
+ setAutoRunConfig({ ...DEFAULT_AUTO_RUN_CONFIG, ...config })
206
+ }
207
+ } catch (error) {
208
+ console.error('[useChat] 加载 autoRunConfig 失败:', error)
209
+ }
210
+ }, [adapter])
211
+
212
+ /** 保存自动运行配置到数据库 */
213
+ const saveAutoRunConfig = useCallback(async (config: AutoRunConfig) => {
214
+ if (!adapter.setSetting) return
215
+
216
+ try {
217
+ await adapter.setSetting('autoRunConfig', JSON.stringify(config))
218
+ setAutoRunConfig(config)
219
+ } catch (error) {
220
+ console.error('[useChat] 保存 autoRunConfig 失败:', error)
221
+ throw error
222
+ }
223
+ }, [adapter])
224
+
225
+ // 初始化时加载配置
226
+ useEffect(() => {
227
+ loadAutoRunConfig()
228
+ }, [loadAutoRunConfig])
229
+
230
+ // 强制重新渲染
231
+ const forceUpdate = useCallback(() => setStateVersion(v => v + 1), [])
232
+
233
+ // 获取或创建会话状态
234
+ const getSessionState = useCallback((sessionId: string): SessionState => {
235
+ if (!sessionStatesRef.current.has(sessionId)) {
236
+ sessionStatesRef.current.set(sessionId, {
237
+ messages: [],
238
+ isLoading: false,
239
+ abortController: null,
240
+ })
241
+ }
242
+ return sessionStatesRef.current.get(sessionId)!
243
+ }, [])
244
+
245
+ // ==================== 全局状态 ====================
60
246
  const [sessions, setSessions] = useState<SessionRecord[]>([])
61
247
  const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)
62
- const [messages, setMessages] = useState<ChatMessage[]>([])
248
+ const currentSessionIdRef = useRef<string | null>(null)
249
+
250
+ // 同步 ref
251
+ currentSessionIdRef.current = currentSessionId
63
252
 
64
253
  // 配置状态
65
- const [mode, setModeState] = useState<ChatMode>(defaultMode)
66
- const [model, setModelState] = useState(defaultModel)
67
- const [webSearch, setWebSearchState] = useState(true)
68
- const [thinking, setThinkingState] = useState(true)
69
-
70
- // 加载状态
71
- const [isLoading, setIsLoading] = useState(false)
254
+ const [modeState, setModeState] = useState<ChatMode>(defaultMode)
255
+ const [modelState, setModelState] = useState(defaultModel)
256
+ const [webSearchState, setWebSearchState] = useState(true)
257
+ const [thinkingState, setThinkingState] = useState(true)
72
258
 
73
- // 取消控制器
74
- const abortControllerRef = useRef<AbortController | null>(null)
75
-
76
- // 用于在回调中访问最新状态
259
+ // refs for async access
260
+ const modeRef = useRef(modeState)
261
+ const modelRef = useRef(modelState)
262
+ const webSearchRef = useRef(webSearchState)
263
+ const thinkingRef = useRef(thinkingState)
77
264
  const sessionsRef = useRef(sessions)
78
- const messagesRef = useRef(messages)
79
- const currentSessionIdRef = useRef(currentSessionId)
80
- const modeRef = useRef(mode)
81
- const modelRef = useRef(model)
82
- const webSearchRef = useRef(webSearch)
83
- const thinkingRef = useRef(thinking)
84
265
 
85
- // 同步 ref
266
+ modeRef.current = modeState
267
+ modelRef.current = modelState
268
+ webSearchRef.current = webSearchState
269
+ thinkingRef.current = thinkingState
86
270
  sessionsRef.current = sessions
87
- messagesRef.current = messages
88
- currentSessionIdRef.current = currentSessionId
89
- modeRef.current = mode
90
- modelRef.current = model
91
- webSearchRef.current = webSearch
92
- thinkingRef.current = thinking
93
271
 
94
- /** 加载会话列表 */
272
+ // ==================== 计算属性 ====================
273
+ const messages = useMemo(() => {
274
+ // 触发 stateVersion 依赖
275
+ void stateVersion
276
+ if (!currentSessionId) return []
277
+ const state = sessionStatesRef.current.get(currentSessionId)
278
+ return state?.messages || []
279
+ }, [currentSessionId, stateVersion])
280
+
281
+ const isLoading = useMemo(() => {
282
+ void stateVersion
283
+ if (!currentSessionId) return false
284
+ const state = sessionStatesRef.current.get(currentSessionId)
285
+ return state?.isLoading || false
286
+ }, [currentSessionId, stateVersion])
287
+
288
+ // ==================== 会话管理 ====================
95
289
  const loadSessions = useCallback(async () => {
96
290
  try {
97
291
  const list = await adapter.getSessions()
98
292
  setSessions(list)
99
- // 如果有会话且没有当前会话,选择最新的
100
293
  if (list.length > 0 && !currentSessionIdRef.current) {
101
- const firstSession = list[0]
102
- setCurrentSessionId(firstSession.id)
103
- const savedMessages = await adapter.getMessages(firstSession.id)
104
- setMessages(savedMessages.map(convertToMessage))
105
- setModeState(firstSession.mode)
106
- setModelState(firstSession.model)
294
+ setCurrentSessionId(list[0].id)
295
+ // 加载消息
296
+ const state = getSessionState(list[0].id)
297
+ if (state.messages.length === 0) {
298
+ const savedMessages = await adapter.getMessages(list[0].id)
299
+ state.messages = savedMessages.map(convertToMessage)
300
+ forceUpdate()
301
+ }
302
+ // 同步配置
303
+ setModeState(list[0].mode)
304
+ setModelState(list[0].model)
305
+ setWebSearchState(list[0].webSearchEnabled)
306
+ setThinkingState(list[0].thinkingEnabled)
107
307
  }
108
308
  } catch (error) {
109
309
  console.error('加载会话失败:', error)
110
310
  }
111
- }, [adapter])
311
+ }, [adapter, getSessionState, forceUpdate])
112
312
 
113
- /** 切换会话 */
114
313
  const switchSession = useCallback(async (sessionId: string) => {
115
314
  if (currentSessionIdRef.current === sessionId) return
116
315
 
117
316
  setCurrentSessionId(sessionId)
317
+
318
+ // 无状态架构:不需要同步历史到后端
319
+ // 发送消息时会传递历史,后端不维护状态
118
320
 
119
- try {
120
- const savedMessages = await adapter.getMessages(sessionId)
121
- setMessages(savedMessages.map(convertToMessage))
122
-
123
- // 更新配置
124
- const session = sessionsRef.current.find((s) => s.id === sessionId)
125
- if (session) {
126
- setModeState(session.mode)
127
- setModelState(session.model)
321
+ const state = getSessionState(sessionId)
322
+
323
+ if (state.messages.length === 0) {
324
+ try {
325
+ const savedMessages = await adapter.getMessages(sessionId)
326
+ state.messages = savedMessages.map(convertToMessage)
327
+ forceUpdate()
328
+ } catch (error) {
329
+ console.error('加载消息失败:', error)
330
+ state.messages = []
331
+ forceUpdate()
128
332
  }
129
- } catch (error) {
130
- console.error('加载消息失败:', error)
131
- setMessages([])
132
333
  }
133
- }, [adapter])
134
334
 
135
- /** 创建新会话 */
335
+ const session = sessionsRef.current.find((s) => s.id === sessionId)
336
+ if (session) {
337
+ setModeState(session.mode)
338
+ setModelState(session.model)
339
+ setWebSearchState(session.webSearchEnabled)
340
+ setThinkingState(session.thinkingEnabled)
341
+ }
342
+ }, [adapter, getSessionState, forceUpdate])
343
+
136
344
  const createNewSession = useCallback(async () => {
137
345
  try {
138
346
  const session = await adapter.createSession({
139
347
  title: '新对话',
140
348
  model: modelRef.current,
141
349
  mode: modeRef.current,
350
+ webSearchEnabled: webSearchRef.current,
351
+ thinkingEnabled: thinkingRef.current,
142
352
  })
143
353
  setSessions(prev => [session, ...prev])
354
+
355
+ sessionStatesRef.current.set(session.id, {
356
+ messages: [],
357
+ isLoading: false,
358
+ abortController: null,
359
+ })
360
+
144
361
  setCurrentSessionId(session.id)
145
- setMessages([])
362
+ forceUpdate()
146
363
  } catch (error) {
147
364
  console.error('创建会话失败:', error)
148
365
  }
149
- }, [adapter])
366
+ }, [adapter, forceUpdate])
150
367
 
151
- /** 删除会话 */
152
368
  const deleteSession = useCallback(async (sessionId: string) => {
153
369
  try {
370
+ const state = sessionStatesRef.current.get(sessionId)
371
+ if (state?.isLoading && state.abortController) {
372
+ state.abortController.abort()
373
+ adapter.cancel()
374
+ }
375
+
154
376
  await adapter.deleteSession(sessionId)
155
377
  setSessions(prev => prev.filter((s) => s.id !== sessionId))
378
+ sessionStatesRef.current.delete(sessionId)
156
379
 
157
- // 如果删除的是当前会话,切换到下一个
158
380
  if (currentSessionIdRef.current === sessionId) {
159
- const remaining = sessionsRef.current.filter((s) => s.id !== sessionId)
160
- if (remaining.length > 0) {
161
- await switchSession(remaining[0].id)
381
+ const remainingSessions = sessionsRef.current.filter(s => s.id !== sessionId)
382
+ if (remainingSessions.length > 0) {
383
+ setCurrentSessionId(remainingSessions[0].id)
162
384
  } else {
163
385
  setCurrentSessionId(null)
164
- setMessages([])
165
386
  }
166
387
  }
388
+ forceUpdate()
167
389
  } catch (error) {
168
390
  console.error('删除会话失败:', error)
169
391
  }
170
- }, [adapter, switchSession])
392
+ }, [adapter, forceUpdate])
393
+
394
+ const hideSession = useCallback(async (sessionId: string, hidden: boolean) => {
395
+ try {
396
+ await adapter.updateSession(sessionId, { hidden })
397
+ setSessions(prev => prev.map((s) =>
398
+ s.id === sessionId ? { ...s, hidden } : s
399
+ ))
400
+ } catch (error) {
401
+ console.error('更新会话隐藏状态失败:', error)
402
+ }
403
+ }, [adapter])
404
+
405
+ const clearAllSessions = useCallback(async () => {
406
+ try {
407
+ for (const [, state] of sessionStatesRef.current) {
408
+ if (state.isLoading && state.abortController) {
409
+ state.abortController.abort()
410
+ }
411
+ }
412
+ adapter.cancel()
413
+
414
+ for (const session of sessionsRef.current) {
415
+ await adapter.deleteSession(session.id)
416
+ }
417
+ setSessions([])
418
+ sessionStatesRef.current.clear()
419
+ await createNewSession()
420
+ } catch (error) {
421
+ console.error('清空所有会话失败:', error)
422
+ }
423
+ }, [adapter, createNewSession])
424
+
425
+ const hideOtherSessions = useCallback(async () => {
426
+ if (!currentSessionIdRef.current) return
427
+ try {
428
+ for (const session of sessionsRef.current) {
429
+ if (session.id !== currentSessionIdRef.current && !session.hidden) {
430
+ await adapter.updateSession(session.id, { hidden: true })
431
+ }
432
+ }
433
+ setSessions(prev => prev.map((s) =>
434
+ s.id === currentSessionIdRef.current ? s : { ...s, hidden: true }
435
+ ))
436
+ } catch (error) {
437
+ console.error('隐藏其他会话失败:', error)
438
+ }
439
+ }, [adapter])
440
+
441
+ const exportCurrentSession = useCallback((): string | null => {
442
+ if (!currentSessionIdRef.current) return null
443
+ const session = sessionsRef.current.find((s) => s.id === currentSessionIdRef.current)
444
+ if (!session) return null
445
+
446
+ const state = sessionStatesRef.current.get(currentSessionIdRef.current)
447
+ const msgs = state?.messages || []
448
+
449
+ return JSON.stringify({
450
+ session: {
451
+ id: session.id,
452
+ title: session.title,
453
+ model: session.model,
454
+ mode: session.mode,
455
+ createdAt: session.createdAt,
456
+ updatedAt: session.updatedAt,
457
+ },
458
+ messages: msgs.map((msg) => ({
459
+ id: msg.id,
460
+ role: msg.role,
461
+ parts: msg.parts,
462
+ model: msg.model,
463
+ mode: msg.mode,
464
+ timestamp: msg.timestamp,
465
+ })),
466
+ exportedAt: new Date().toISOString(),
467
+ }, null, 2)
468
+ }, [])
171
469
 
172
- /** 删除当前会话 */
173
470
  const deleteCurrentSession = useCallback(async () => {
174
471
  if (currentSessionIdRef.current) {
175
472
  await deleteSession(currentSessionIdRef.current)
176
473
  }
177
474
  }, [deleteSession])
178
475
 
179
- /** 更新消息 */
180
- const updateMessage = useCallback((index: number, progress: ChatProgress) => {
181
- setMessages(prev => {
182
- const newMessages = [...prev]
183
- const msg = { ...newMessages[index] }
184
- if (!msg) return prev
185
-
186
- switch (progress.type) {
187
- case 'thinking': {
188
- const thinkingData = progress.data as { content: string; isComplete: boolean }
189
- if (thinkingData.content) {
190
- msg.thinking = (msg.thinking || '') + thinkingData.content
476
+ // ==================== 消息更新 ====================
477
+ const updateSessionMessage = useCallback((
478
+ sessionId: string,
479
+ messageIndex: number,
480
+ event: ChatEvent
481
+ ) => {
482
+ const state = sessionStatesRef.current.get(sessionId)
483
+ if (!state) return
484
+
485
+ const msg = state.messages[messageIndex]
486
+ if (!msg) return
487
+
488
+ const updatedMsg = { ...msg }
489
+ let parts = [...updatedMsg.parts]
490
+
491
+ switch (event.type) {
492
+ case 'thinking_start': {
493
+ parts.push({ type: 'thinking', text: '', status: 'running' })
494
+ break
495
+ }
496
+
497
+ case 'thinking_delta': {
498
+ const data = event.data as { content: string }
499
+ const lastThinkingIndex = parts.findLastIndex(
500
+ p => p.type === 'thinking' && p.status === 'running'
501
+ )
502
+ if (lastThinkingIndex >= 0) {
503
+ const part = parts[lastThinkingIndex] as ThinkingPart
504
+ parts[lastThinkingIndex] = { ...part, text: part.text + data.content }
505
+ } else {
506
+ parts.push({ type: 'thinking', text: data.content, status: 'running' })
507
+ }
508
+ break
509
+ }
510
+
511
+ case 'thinking_end': {
512
+ const data = event.data as { duration: number }
513
+ const lastThinkingIndex = parts.findLastIndex(
514
+ p => p.type === 'thinking' && p.status === 'running'
515
+ )
516
+ if (lastThinkingIndex >= 0) {
517
+ const part = parts[lastThinkingIndex] as ThinkingPart
518
+ parts[lastThinkingIndex] = {
519
+ ...part,
520
+ status: 'done',
521
+ duration: Math.round(data.duration / 1000),
191
522
  }
192
- msg.thinkingComplete = thinkingData.isComplete
193
- break
194
523
  }
524
+ break
525
+ }
195
526
 
196
- case 'search_start':
197
- msg.searching = true
198
- break
527
+ case 'search_start': {
528
+ const data = event.data as { query?: string }
529
+ parts.push({ type: 'search', query: data.query, status: 'running' })
530
+ break
531
+ }
199
532
 
200
- case 'search_result': {
201
- msg.searching = false
202
- const searchData = progress.data as { results: SearchResult[] }
203
- msg.searchResults = searchData.results || []
204
- break
533
+ case 'search_result': {
534
+ const data = event.data as { results: SearchResult[] }
535
+ const lastSearchIndex = parts.findLastIndex(p => p.type === 'search')
536
+ if (lastSearchIndex >= 0) {
537
+ const part = parts[lastSearchIndex] as SearchPart
538
+ parts[lastSearchIndex] = { ...part, results: data.results, status: 'done' }
539
+ }
540
+ break
541
+ }
542
+
543
+ case 'search_end': {
544
+ const lastSearchIndex = parts.findLastIndex(
545
+ p => p.type === 'search' && p.status === 'running'
546
+ )
547
+ if (lastSearchIndex >= 0) {
548
+ const part = parts[lastSearchIndex] as SearchPart
549
+ parts[lastSearchIndex] = { ...part, status: 'done' }
205
550
  }
551
+ break
552
+ }
206
553
 
207
- case 'tool_call': {
208
- const toolData = progress.data as { name: string; args: Record<string, unknown> }
209
- if (!msg.toolCalls) msg.toolCalls = []
210
- msg.toolCalls = [...msg.toolCalls, {
211
- name: toolData.name,
212
- args: toolData.args,
213
- status: 'running' as const,
214
- }]
554
+ case 'tool_approval_request': {
555
+ const data = event.data as { id: string; name: string; args: Record<string, unknown> }
556
+
557
+ // 如果当前模式是自动执行,直接批准(处理发送消息后切换模式的情况)
558
+ if (autoRunConfigRef.current.mode === 'run-everything') {
559
+ adapter.respondToolApproval?.(data.id, true)
560
+ // 不创建 pending 状态,等待 tool_call_start 事件
215
561
  break
216
562
  }
563
+
564
+ // manual 模式:创建 pending 状态等待用户确认
565
+ const existingIndex = parts.findLastIndex(
566
+ p => p.type === 'tool_call' && (p as ToolCallPart).id === data.id
567
+ )
568
+ if (existingIndex >= 0) {
569
+ // 更新现有 part 为 pending
570
+ const part = parts[existingIndex] as ToolCallPart
571
+ parts[existingIndex] = { ...part, status: 'pending', result: null }
572
+ } else {
573
+ // 创建新的 pending part
574
+ parts.push({
575
+ type: 'tool_call',
576
+ id: data.id,
577
+ name: data.name,
578
+ args: data.args,
579
+ status: 'pending',
580
+ result: null,
581
+ })
582
+ }
583
+ break
584
+ }
217
585
 
218
- case 'tool_result': {
219
- const resultData = progress.data as { name: string; result: string }
220
- if (msg.toolCalls) {
221
- msg.toolCalls = msg.toolCalls.map((t: ToolCall) => {
222
- if (t.name === resultData.name && t.status === 'running') {
223
- return { ...t, result: resultData.result, status: 'success' as const }
224
- }
225
- return t
226
- })
227
- }
228
- break
586
+ case 'tool_call_start': {
587
+ const data = event.data as { id: string; name: string; args: Record<string, unknown> }
588
+ // 检查是否已存在 pending 状态的 part
589
+ const existingIndex = parts.findLastIndex(
590
+ p => p.type === 'tool_call' && (p as ToolCallPart).id === data.id
591
+ )
592
+ if (existingIndex >= 0) {
593
+ // 更新现有 part 为 running
594
+ const part = parts[existingIndex] as ToolCallPart
595
+ parts[existingIndex] = { ...part, status: 'running', result: null }
596
+ } else {
597
+ // 创建新的 running part
598
+ parts.push({
599
+ type: 'tool_call',
600
+ id: data.id,
601
+ name: data.name,
602
+ args: data.args,
603
+ status: 'running',
604
+ result: null,
605
+ })
229
606
  }
607
+ break
608
+ }
230
609
 
231
- case 'text_delta':
232
- msg.content = (msg.content || '') + (progress.data as string)
233
- break
610
+ case 'tool_call_result': {
611
+ const data = event.data as { id: string; name: string; result: string; success: boolean }
612
+
613
+ let parsedResult: unknown = data.result
614
+ try {
615
+ parsedResult = JSON.parse(data.result)
616
+ } catch {
617
+ // 保持原始字符串
618
+ }
234
619
 
235
- case 'text':
236
- if (!msg.content) {
237
- msg.content = progress.data as string
620
+ // 判断状态:检查是否是跳过或取消操作
621
+ const isSkipped = typeof parsedResult === 'object' && parsedResult !== null &&
622
+ 'skipped' in parsedResult && (parsedResult as { skipped?: boolean }).skipped === true
623
+ const isCancelled = typeof parsedResult === 'object' && parsedResult !== null &&
624
+ 'cancelled' in parsedResult && (parsedResult as { cancelled?: boolean }).cancelled === true
625
+ const status: 'done' | 'error' | 'cancelled' | 'skipped' =
626
+ isSkipped ? 'skipped' : (isCancelled ? 'cancelled' : (data.success ? 'done' : 'error'))
627
+
628
+ // 查找对应的 tool_call
629
+ const toolCallIndex = parts.findIndex(
630
+ p => p.type === 'tool_call' && (p as ToolCallPart).id === data.id
631
+ )
632
+
633
+ if (toolCallIndex >= 0) {
634
+ // 更新 tool_call,添加 result,而不是转换为 tool_result
635
+ const toolCall = parts[toolCallIndex] as ToolCallPart
636
+ parts[toolCallIndex] = {
637
+ ...toolCall,
638
+ result: parsedResult,
639
+ status,
238
640
  }
239
- break
641
+ } else {
642
+ // 如果没有对应的 tool_call,创建一个新的 tool_call(而不是 tool_result)
643
+ parts.push({
644
+ type: 'tool_call',
645
+ id: data.id,
646
+ name: data.name,
647
+ args: {},
648
+ result: parsedResult,
649
+ status,
650
+ })
651
+ }
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)
659
+ }
240
660
 
241
- case 'error':
242
- msg.content = (msg.content || '') + `\n\n❌ 错误: ${progress.data}`
243
- break
661
+ if (onToolComplete) {
662
+ // 从事件数据中获取副作用声明
663
+ const sideEffects = (data as { sideEffects?: SideEffect[] }).sideEffects
664
+ onToolComplete({
665
+ name: data.name,
666
+ result: parsedResult,
667
+ sideEffects,
668
+ })
669
+ }
670
+ break
244
671
  }
245
672
 
246
- newMessages[index] = msg
247
- return newMessages
248
- })
249
- }, [])
673
+ case 'text_delta': {
674
+ const data = event.data as { content: string }
675
+
676
+ parts = parts.map(p =>
677
+ p.type === 'search' && p.status === 'running'
678
+ ? { ...p, status: 'done' as const }
679
+ : p
680
+ )
681
+
682
+ // 查找最后一个 text part
683
+ const lastTextIndex = parts.findLastIndex(p => p.type === 'text')
684
+
685
+ // 检查最后一个 part 是否是已完成的工具调用/搜索/思考
686
+ // 如果是,说明这是新一轮的文本输出,应该创建新的 text part
687
+ const lastPart = parts[parts.length - 1]
688
+ const shouldCreateNew = lastPart && (
689
+ (lastPart.type === 'tool_call' && ['done', 'error', 'skipped', 'cancelled'].includes((lastPart as ToolCallPart).status)) ||
690
+ (lastPart.type === 'search' && (lastPart as SearchPart).status === 'done') ||
691
+ (lastPart.type === 'thinking' && (lastPart as ThinkingPart).status === 'done')
692
+ )
693
+
694
+ if (shouldCreateNew || lastTextIndex < 0) {
695
+ // 创建新的 text part
696
+ parts.push({ type: 'text', text: data.content })
697
+ } else {
698
+ // 追加到现有的 text part
699
+ const part = parts[lastTextIndex] as TextPart
700
+ parts[lastTextIndex] = { ...part, text: part.text + data.content }
701
+ }
702
+ break
703
+ }
250
704
 
251
- /** 发送消息 */
252
- const sendMessage = useCallback(async (text: string, images?: string[]) => {
253
- if (!text.trim() || isLoading) return
705
+ case 'error': {
706
+ const data = event.data as { category?: string; message: string; retryable?: boolean }
707
+
708
+ parts = parts.map(p => {
709
+ if (p.type === 'thinking' && p.status === 'running') return { ...p, status: 'done' as const }
710
+ if (p.type === 'search' && p.status === 'running') return { ...p, status: 'done' as const }
711
+ if (p.type === 'tool_call' && p.status === 'running') return { ...p, status: 'error' as const }
712
+ return p
713
+ })
714
+
715
+ parts.push({
716
+ type: 'error',
717
+ message: data.message,
718
+ category: data.category,
719
+ retryable: data.retryable,
720
+ })
721
+
722
+ updatedMsg.loading = false
723
+ updatedMsg.error = data
724
+ break
725
+ }
726
+
727
+ case 'abort': {
728
+ parts = parts.map(p => {
729
+ if (p.type === 'thinking' && p.status === 'running') return { ...p, status: 'done' as const }
730
+ if (p.type === 'search' && p.status === 'running') return { ...p, status: 'done' as const }
731
+ if (p.type === 'tool_call' && p.status === 'running') return { ...p, status: 'cancelled' as const }
732
+ return p
733
+ })
734
+ updatedMsg.loading = false
735
+ updatedMsg.aborted = true
736
+ break
737
+ }
254
738
 
255
- // 如果没有当前会话,先创建
739
+ case 'done':
740
+ updatedMsg.loading = false
741
+ break
742
+ }
743
+
744
+ updatedMsg.parts = parts
745
+ state.messages = [...state.messages]
746
+ state.messages[messageIndex] = updatedMsg
747
+ forceUpdate()
748
+ }, [onToolComplete, forceUpdate])
749
+
750
+ // ==================== 发送消息 ====================
751
+ const sendMessage = useCallback(async (text: string, images?: string[]) => {
752
+ if (!text.trim()) return
753
+
256
754
  let sessionId = currentSessionIdRef.current
755
+
756
+ if (sessionId) {
757
+ const currentState = sessionStatesRef.current.get(sessionId)
758
+ if (currentState?.isLoading) {
759
+ console.warn('[useChat] 当前会话正在进行中,请等待完成')
760
+ return
761
+ }
762
+ }
763
+
257
764
  if (!sessionId) {
258
765
  try {
259
766
  const session = await adapter.createSession({
@@ -262,6 +769,11 @@ export function useChat(options: UseChatOptions = {}) {
262
769
  mode: modeRef.current,
263
770
  })
264
771
  setSessions(prev => [session, ...prev])
772
+ sessionStatesRef.current.set(session.id, {
773
+ messages: [],
774
+ isLoading: false,
775
+ abortController: null,
776
+ })
265
777
  setCurrentSessionId(session.id)
266
778
  sessionId = session.id
267
779
  } catch (error) {
@@ -270,19 +782,18 @@ export function useChat(options: UseChatOptions = {}) {
270
782
  }
271
783
  }
272
784
 
273
- // 添加用户消息
785
+ const state = getSessionState(sessionId)
786
+
274
787
  const userMsg: ChatMessage = {
275
788
  id: generateId(),
276
789
  role: 'user',
277
- content: text,
790
+ parts: [{ type: 'text', text }],
278
791
  images,
279
792
  timestamp: new Date(),
280
793
  }
281
-
282
- const currentMessages = messagesRef.current
283
- setMessages([...currentMessages, userMsg])
794
+ state.messages = [...state.messages, userMsg]
795
+ forceUpdate()
284
796
 
285
- // 保存用户消息
286
797
  try {
287
798
  await adapter.saveMessage({
288
799
  sessionId,
@@ -290,8 +801,7 @@ export function useChat(options: UseChatOptions = {}) {
290
801
  content: text,
291
802
  })
292
803
 
293
- // 更新会话标题(如果是第一条消息)
294
- if (currentMessages.length === 0) {
804
+ if (state.messages.length === 1) {
295
805
  const title = text.slice(0, 20) + (text.length > 20 ? '...' : '')
296
806
  await adapter.updateSession(sessionId, { title })
297
807
  setSessions(prev => prev.map((s) =>
@@ -302,162 +812,270 @@ export function useChat(options: UseChatOptions = {}) {
302
812
  console.error('保存消息失败:', error)
303
813
  }
304
814
 
305
- // 创建助手消息
306
- const assistantMsgIndex = currentMessages.length + 1
815
+ const assistantMsgIndex = state.messages.length
816
+ const assistantMsgId = generateId() // 保存消息 ID 用于后续更新
307
817
  const assistantMsg: ChatMessage = {
308
- id: generateId(),
818
+ id: assistantMsgId,
309
819
  role: 'assistant',
310
- content: '',
311
- toolCalls: [],
312
- thinkingComplete: false,
313
- searching: false,
820
+ parts: [],
821
+ model: modelRef.current,
822
+ mode: modeRef.current,
823
+ webSearchEnabled: webSearchRef.current,
824
+ thinkingEnabled: thinkingRef.current,
314
825
  loading: true,
315
826
  timestamp: new Date(),
316
827
  }
317
- setMessages(prev => [...prev, assistantMsg])
828
+ state.messages = [...state.messages, assistantMsg]
829
+
830
+ // 创建本次请求专用的 abortController(避免多请求竞态条件)
831
+ const requestAbortController = new AbortController()
832
+ state.isLoading = true
833
+ state.abortController = requestAbortController
834
+ forceUpdate()
835
+
836
+ const sendModel = modelRef.current
837
+ const sendMode = modeRef.current
838
+ const sendWebSearch = webSearchRef.current
839
+ const sendThinking = thinkingRef.current
840
+
841
+ // 【关键】立即保存助手消息到数据库(初始状态)
842
+ try {
843
+ await adapter.saveMessage({
844
+ id: assistantMsgId,
845
+ sessionId,
846
+ role: 'assistant',
847
+ content: '',
848
+ model: sendModel,
849
+ mode: sendMode,
850
+ webSearchEnabled: sendWebSearch,
851
+ thinkingEnabled: sendThinking,
852
+ steps: '[]',
853
+ })
854
+ } catch (error) {
855
+ console.error('创建助手消息失败:', error)
856
+ }
318
857
 
319
- setIsLoading(true)
320
- abortControllerRef.current = new AbortController()
858
+ // 增量保存函数
859
+ const saveMessageProgress = async () => {
860
+ const msg = state.messages[assistantMsgIndex]
861
+ if (!msg) return
862
+ try {
863
+ await adapter.updateMessage?.({
864
+ id: assistantMsgId,
865
+ content: extractTextContent(msg.parts),
866
+ steps: JSON.stringify(msg.parts),
867
+ })
868
+ } catch (error) {
869
+ console.error('更新消息失败:', error)
870
+ }
871
+ }
321
872
 
322
873
  try {
323
- // 使用异步迭代器接收消息流
324
- for await (const progress of adapter.sendMessage(
874
+ // 构建历史消息(不包括刚添加的用户消息和助手占位消息)
875
+ const history = state.messages.slice(0, -2).map(msg => ({
876
+ role: msg.role as 'user' | 'assistant' | 'system' | 'tool',
877
+ content: extractTextContent(msg.parts),
878
+ }))
879
+
880
+ for await (const event of adapter.sendMessage(
325
881
  text,
326
882
  {
327
- mode: modeRef.current,
328
- model: modelRef.current,
329
- enableWebSearch: webSearchRef.current,
330
- thinkingMode: thinkingRef.current ? 'enabled' : 'disabled',
883
+ mode: sendMode,
884
+ model: sendModel,
885
+ enableWebSearch: sendWebSearch,
886
+ thinkingMode: sendThinking ? 'enabled' : 'disabled',
887
+ autoRunConfig,
888
+ history, // 传递历史消息
331
889
  },
332
- images
890
+ images,
891
+ sessionId // 传递 sessionId 用于事件过滤
333
892
  )) {
334
- // 检查是否被取消
335
- if (abortControllerRef.current?.signal.aborted) break
893
+ // 使用本次请求专用的 abortController 检查(避免被新请求覆盖)
894
+ if (requestAbortController.signal.aborted) break
336
895
 
337
- updateMessage(assistantMsgIndex, progress)
896
+ updateSessionMessage(sessionId, assistantMsgIndex, event)
338
897
 
339
- if (progress.type === 'done' || progress.type === 'error') {
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
+ }
910
+
911
+ if (event.type === 'done' || event.type === 'error') {
340
912
  break
341
913
  }
342
914
  }
343
915
  } catch (error) {
344
916
  console.error('发送消息失败:', error)
345
- updateMessage(assistantMsgIndex, {
917
+ updateSessionMessage(sessionId, assistantMsgIndex, {
346
918
  type: 'error',
347
- data: error instanceof Error ? error.message : String(error),
919
+ data: { message: error instanceof Error ? error.message : String(error) },
348
920
  })
349
921
  } finally {
350
- setIsLoading(false)
351
-
352
- // 标记加载完成并保存
353
- setMessages(prev => {
354
- const newMessages = [...prev]
355
- const finalMsg = newMessages[assistantMsgIndex]
356
- if (finalMsg) {
357
- newMessages[assistantMsgIndex] = { ...finalMsg, loading: false }
358
-
359
- // 保存助手消息
360
- if (sessionId) {
361
- adapter.saveMessage({
362
- sessionId,
363
- role: 'assistant',
364
- content: finalMsg.content,
365
- thinking: finalMsg.thinking,
366
- toolCalls: finalMsg.toolCalls ? JSON.stringify(finalMsg.toolCalls) : undefined,
367
- searchResults: finalMsg.searchResults
368
- ? JSON.stringify(finalMsg.searchResults)
369
- : undefined,
370
- }).catch((e: Error) => console.error('保存助手消息失败:', e))
371
- }
372
- }
373
- return newMessages
374
- })
922
+ state.isLoading = false
923
+
924
+ const finalMsg = state.messages[assistantMsgIndex]
925
+ if (finalMsg) {
926
+ state.messages = [...state.messages]
927
+ state.messages[assistantMsgIndex] = { ...finalMsg, loading: false }
928
+ }
375
929
 
376
- abortControllerRef.current = null
930
+ // 【关键】最终保存一次,确保所有内容都被持久化
931
+ await saveMessageProgress()
932
+
933
+ state.abortController = null
934
+ forceUpdate()
377
935
  }
378
- }, [adapter, isLoading, updateMessage])
936
+ }, [adapter, getSessionState, updateSessionMessage, forceUpdate])
379
937
 
380
- /** 取消请求 */
938
+ // ==================== 其他方法 ====================
381
939
  const cancelRequest = useCallback(() => {
940
+ if (!currentSessionIdRef.current) return
941
+ const state = sessionStatesRef.current.get(currentSessionIdRef.current)
942
+ if (state) {
943
+ state.abortController?.abort()
944
+ state.isLoading = false
945
+ forceUpdate()
946
+ }
382
947
  adapter.cancel()
383
- abortControllerRef.current?.abort()
384
- setIsLoading(false)
385
- }, [adapter])
948
+ }, [adapter, forceUpdate])
386
949
 
387
- /** 复制消息 */
388
950
  const copyMessage = useCallback(async (messageId: string) => {
389
- const msg = messagesRef.current.find((m) => m.id === messageId)
951
+ if (!currentSessionIdRef.current) return
952
+ const state = sessionStatesRef.current.get(currentSessionIdRef.current)
953
+ if (!state) return
954
+
955
+ const msg = state.messages.find((m) => m.id === messageId)
390
956
  if (!msg) return
391
957
 
392
958
  try {
393
- await navigator.clipboard.writeText(msg.content)
394
- setMessages(prev => prev.map((m) =>
959
+ const textContent = extractTextContent(msg.parts)
960
+ await navigator.clipboard.writeText(textContent)
961
+
962
+ state.messages = state.messages.map((m) =>
395
963
  m.id === messageId ? { ...m, copied: true } : m
396
- ))
964
+ )
965
+ forceUpdate()
966
+
397
967
  setTimeout(() => {
398
- setMessages(prev => prev.map((m) =>
399
- m.id === messageId ? { ...m, copied: false } : m
400
- ))
968
+ const s = sessionStatesRef.current.get(currentSessionIdRef.current!)
969
+ if (s) {
970
+ s.messages = s.messages.map((m) =>
971
+ m.id === messageId ? { ...m, copied: false } : m
972
+ )
973
+ forceUpdate()
974
+ }
401
975
  }, 2000)
402
976
  } catch (err) {
403
977
  console.error('复制失败:', err)
404
978
  }
405
- }, [])
979
+ }, [forceUpdate])
406
980
 
407
- /** 重新生成消息 */
408
981
  const regenerateMessage = useCallback((messageIndex: number) => {
409
- const currentMsgs = messagesRef.current
410
- if (messageIndex > 0 && currentMsgs[messageIndex - 1]?.role === 'user') {
411
- const userMsg = currentMsgs[messageIndex - 1]
412
- setMessages(prev => prev.slice(0, messageIndex - 1))
413
- sendMessage(userMsg.content, userMsg.images)
982
+ if (!currentSessionIdRef.current) return
983
+ const state = sessionStatesRef.current.get(currentSessionIdRef.current)
984
+ if (!state) return
985
+
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)
414
992
  }
415
- }, [sendMessage])
993
+ }, [sendMessage, forceUpdate])
994
+
995
+ /** 从指定索引重新发送消息(编辑后重发) */
996
+ const resendFromIndex = useCallback((index: number, text: string) => {
997
+ if (!currentSessionIdRef.current) return
998
+ const state = sessionStatesRef.current.get(currentSessionIdRef.current)
999
+ if (!state) return
1000
+
1001
+ state.messages = state.messages.slice(0, index)
1002
+ forceUpdate()
1003
+ sendMessage(text)
1004
+ }, [sendMessage, forceUpdate])
416
1005
 
417
- /** 设置工作目录 */
418
1006
  const setWorkingDirectory = useCallback((dir: string) => {
419
- if (adapter.setWorkingDir) {
420
- adapter.setWorkingDir(dir)
1007
+ if (adapter.setCwd) {
1008
+ adapter.setCwd(dir)
421
1009
  }
422
1010
  }, [adapter])
423
1011
 
424
- // 配置方法
425
- const setMode = useCallback((value: ChatMode) => setModeState(value), [])
426
- const setModel = useCallback((value: string) => setModelState(value), [])
427
- const setWebSearch = useCallback((value: boolean) => setWebSearchState(value), [])
428
- const setThinking = useCallback((value: boolean) => setThinkingState(value), [])
429
-
1012
+ // ==================== 返回 ====================
430
1013
  return {
431
- // 状态
432
1014
  sessions,
433
1015
  currentSessionId,
434
1016
  messages,
435
1017
  isLoading,
436
- mode,
437
- model,
438
- webSearch,
439
- thinking,
1018
+ mode: modeState,
1019
+ model: modelState,
1020
+ webSearch: webSearchState,
1021
+ thinking: thinkingState,
440
1022
 
441
- // 会话方法
442
1023
  loadSessions,
443
1024
  switchSession,
444
1025
  createNewSession,
445
1026
  deleteSession,
446
1027
  deleteCurrentSession,
1028
+ hideSession,
1029
+ clearAllSessions,
1030
+ hideOtherSessions,
1031
+ exportCurrentSession,
447
1032
 
448
- // 消息方法
449
1033
  sendMessage,
450
1034
  cancelRequest,
451
1035
  copyMessage,
452
1036
  regenerateMessage,
1037
+ resendFromIndex,
453
1038
 
454
- // 配置方法
455
- setMode,
456
- setModel,
457
- setWebSearch,
458
- setThinking,
1039
+ setMode: (value: ChatMode) => {
1040
+ setModeState(value)
1041
+ if (currentSessionIdRef.current) {
1042
+ adapter.updateSession(currentSessionIdRef.current, { mode: value }).catch((e: Error) =>
1043
+ console.error('更新会话 mode 失败:', e)
1044
+ )
1045
+ }
1046
+ },
1047
+ setModel: (value: string) => {
1048
+ setModelState(value)
1049
+ if (currentSessionIdRef.current) {
1050
+ adapter.updateSession(currentSessionIdRef.current, { model: value }).catch((e: Error) =>
1051
+ console.error('更新会话 model 失败:', e)
1052
+ )
1053
+ }
1054
+ },
1055
+ setWebSearch: (value: boolean) => {
1056
+ setWebSearchState(value)
1057
+ if (currentSessionIdRef.current) {
1058
+ adapter.updateSession(currentSessionIdRef.current, { webSearchEnabled: value }).catch((e: Error) =>
1059
+ console.error('更新会话 webSearchEnabled 失败:', e)
1060
+ )
1061
+ }
1062
+ },
1063
+ setThinking: (value: boolean) => {
1064
+ setThinkingState(value)
1065
+ if (currentSessionIdRef.current) {
1066
+ adapter.updateSession(currentSessionIdRef.current, { thinkingEnabled: value }).catch((e: Error) =>
1067
+ console.error('更新会话 thinkingEnabled 失败:', e)
1068
+ )
1069
+ }
1070
+ },
459
1071
 
460
- // 工具方法
461
1072
  setWorkingDirectory,
1073
+
1074
+ // 工具批准对话框
1075
+
1076
+ // 自动运行配置
1077
+ autoRunConfig,
1078
+ loadAutoRunConfig,
1079
+ saveAutoRunConfig,
462
1080
  }
463
1081
  }