@huyooo/ai-chat-frontend-vue 0.1.6 → 0.1.7

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