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