@huyooo/ai-chat-frontend-react 0.1.4 → 0.1.8
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.
- package/README.md +368 -0
- package/dist/index.css +2575 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +378 -135
- package/dist/index.js +3954 -1044
- package/dist/index.js.map +1 -1
- package/dist/style.css +48 -987
- package/package.json +7 -4
- package/src/adapter.ts +10 -70
- package/src/components/ChatPanel.tsx +373 -117
- package/src/components/common/ConfirmDialog.css +136 -0
- package/src/components/common/ConfirmDialog.tsx +91 -0
- package/src/components/common/CopyButton.css +22 -0
- package/src/components/common/CopyButton.tsx +46 -0
- package/src/components/common/IndexingSettings.css +207 -0
- package/src/components/common/IndexingSettings.tsx +398 -0
- package/src/components/common/SettingsPanel.css +256 -0
- package/src/components/common/SettingsPanel.tsx +120 -0
- package/src/components/common/Toast.css +50 -0
- package/src/components/common/Toast.tsx +38 -0
- package/src/components/common/ToggleSwitch.css +52 -0
- package/src/components/common/ToggleSwitch.tsx +20 -0
- package/src/components/header/ChatHeader.css +285 -0
- package/src/components/header/ChatHeader.tsx +376 -0
- package/src/components/input/AtFilePicker.css +147 -0
- package/src/components/input/AtFilePicker.tsx +519 -0
- package/src/components/input/ChatInput.css +204 -0
- package/src/components/input/ChatInput.tsx +506 -0
- package/src/components/input/DropdownSelector.css +159 -0
- package/src/components/input/DropdownSelector.tsx +195 -0
- package/src/components/input/ImagePreviewModal.css +124 -0
- package/src/components/input/ImagePreviewModal.tsx +118 -0
- package/src/components/input/at-views/AtBranchView.tsx +34 -0
- package/src/components/input/at-views/AtBrowserView.tsx +34 -0
- package/src/components/input/at-views/AtChatsView.tsx +34 -0
- package/src/components/input/at-views/AtDocsView.tsx +34 -0
- package/src/components/input/at-views/AtFilesView.tsx +168 -0
- package/src/components/input/at-views/AtTerminalsView.tsx +34 -0
- package/src/components/input/at-views/AtViewStyles.css +143 -0
- package/src/components/input/at-views/index.ts +9 -0
- package/src/components/message/ContentRenderer.css +9 -0
- package/src/components/message/ContentRenderer.tsx +63 -0
- package/src/components/message/MessageBubble.css +190 -0
- package/src/components/message/MessageBubble.tsx +231 -0
- package/src/components/message/PartsRenderer.css +4 -0
- package/src/components/message/PartsRenderer.tsx +114 -0
- package/src/components/message/ToolResultRenderer.tsx +21 -0
- package/src/components/message/WelcomeMessage.css +221 -0
- package/src/components/message/WelcomeMessage.tsx +93 -0
- package/src/components/message/blocks/CodeBlock.tsx +60 -0
- package/src/components/message/blocks/TextBlock.tsx +15 -0
- package/src/components/message/blocks/blocks.css +141 -0
- package/src/components/message/blocks/index.ts +6 -0
- package/src/components/message/parts/CollapsibleCard.css +78 -0
- package/src/components/message/parts/CollapsibleCard.tsx +77 -0
- package/src/components/message/parts/ErrorPart.css +9 -0
- package/src/components/message/parts/ErrorPart.tsx +40 -0
- package/src/components/message/parts/ImagePart.css +50 -0
- package/src/components/message/parts/ImagePart.tsx +54 -0
- package/src/components/message/parts/SearchPart.css +44 -0
- package/src/components/message/parts/SearchPart.tsx +63 -0
- package/src/components/message/parts/TextPart.css +10 -0
- package/src/components/message/parts/TextPart.tsx +20 -0
- package/src/components/message/parts/ThinkingPart.css +9 -0
- package/src/components/message/parts/ThinkingPart.tsx +48 -0
- package/src/components/message/parts/ToolCallPart.css +220 -0
- package/src/components/message/parts/ToolCallPart.tsx +285 -0
- package/src/components/message/parts/ToolResultPart.css +68 -0
- package/src/components/message/parts/ToolResultPart.tsx +96 -0
- package/src/components/message/parts/index.ts +11 -0
- package/src/components/message/tool-results/DefaultToolResult.tsx +26 -0
- package/src/components/message/tool-results/SearchResults.tsx +69 -0
- package/src/components/message/tool-results/WeatherCard.tsx +63 -0
- package/src/components/message/tool-results/index.ts +7 -0
- package/src/components/message/tool-results/tool-results.css +179 -0
- package/src/components/message/welcome-types.ts +46 -0
- package/src/context/AutoRunConfigContext.tsx +13 -0
- package/src/context/ChatAdapterContext.tsx +8 -0
- package/src/context/ChatInputContext.tsx +40 -0
- package/src/context/RenderersContext.tsx +41 -0
- package/src/hooks/useChat.ts +855 -237
- package/src/hooks/useImageUpload.ts +253 -0
- package/src/index.ts +99 -42
- package/src/styles.css +48 -987
- package/src/types/index.ts +172 -103
- package/src/utils/fileIcon.ts +49 -0
- package/src/components/ChatInput.tsx +0 -368
- package/src/components/chat/messages/ExecutionSteps.tsx +0 -234
- package/src/components/chat/messages/MessageBubble.tsx +0 -130
- package/src/components/chat/ui/ChatHeader.tsx +0 -301
- package/src/components/chat/ui/WelcomeMessage.tsx +0 -107
package/src/hooks/useChat.ts
CHANGED
|
@@ -1,259 +1,766 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* useChat Hook
|
|
3
3
|
* 管理聊天状态和与后端的通信
|
|
4
|
-
* 使用 Adapter 模式解耦后端通信
|
|
5
4
|
*
|
|
6
|
-
*
|
|
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
|
-
|
|
15
|
+
ChatEvent,
|
|
13
16
|
ChatMode,
|
|
14
17
|
SessionRecord,
|
|
15
18
|
MessageRecord,
|
|
19
|
+
AutoRunConfig,
|
|
16
20
|
} from '@huyooo/ai-chat-bridge-electron/renderer'
|
|
17
|
-
import {
|
|
18
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
248
|
+
const currentSessionIdRef = useRef<string | null>(null)
|
|
249
|
+
|
|
250
|
+
// 同步 ref
|
|
251
|
+
currentSessionIdRef.current = currentSessionId
|
|
63
252
|
|
|
64
253
|
// 配置状态
|
|
65
|
-
const [
|
|
66
|
-
const [
|
|
67
|
-
const [
|
|
68
|
-
const [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
|
160
|
-
if (
|
|
161
|
-
|
|
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,
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
790
|
+
parts: [{ type: 'text', text }],
|
|
278
791
|
images,
|
|
279
792
|
timestamp: new Date(),
|
|
280
793
|
}
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
815
|
+
const assistantMsgIndex = state.messages.length
|
|
816
|
+
const assistantMsgId = generateId() // 保存消息 ID 用于后续更新
|
|
307
817
|
const assistantMsg: ChatMessage = {
|
|
308
|
-
id:
|
|
818
|
+
id: assistantMsgId,
|
|
309
819
|
role: 'assistant',
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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:
|
|
328
|
-
model:
|
|
329
|
-
enableWebSearch:
|
|
330
|
-
thinkingMode:
|
|
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 (
|
|
893
|
+
// 使用本次请求专用的 abortController 检查(避免被新请求覆盖)
|
|
894
|
+
if (requestAbortController.signal.aborted) break
|
|
336
895
|
|
|
337
|
-
|
|
896
|
+
updateSessionMessage(sessionId, assistantMsgIndex, event)
|
|
338
897
|
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
930
|
+
// 【关键】最终保存一次,确保所有内容都被持久化
|
|
931
|
+
await saveMessageProgress()
|
|
932
|
+
|
|
933
|
+
state.abortController = null
|
|
934
|
+
forceUpdate()
|
|
377
935
|
}
|
|
378
|
-
}, [adapter,
|
|
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
|
-
|
|
384
|
-
setIsLoading(false)
|
|
385
|
-
}, [adapter])
|
|
948
|
+
}, [adapter, forceUpdate])
|
|
386
949
|
|
|
387
|
-
/** 复制消息 */
|
|
388
950
|
const copyMessage = useCallback(async (messageId: string) => {
|
|
389
|
-
|
|
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
|
-
|
|
394
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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.
|
|
420
|
-
adapter.
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
}
|