@huyooo/ai-chat-frontend-react 0.2.12 → 0.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -84
- package/dist/KaTeX_AMS-Regular-CYEKBG2K.woff +0 -0
- package/dist/KaTeX_AMS-Regular-JKX5W2C4.ttf +0 -0
- package/dist/KaTeX_AMS-Regular-U6PRYMIZ.woff2 +0 -0
- package/dist/KaTeX_Caligraphic-Bold-5QL5CMTE.woff2 +0 -0
- package/dist/KaTeX_Caligraphic-Bold-WZ3QSGD3.woff +0 -0
- package/dist/KaTeX_Caligraphic-Bold-ZTS3R3HK.ttf +0 -0
- package/dist/KaTeX_Caligraphic-Regular-3LKEU76G.woff +0 -0
- package/dist/KaTeX_Caligraphic-Regular-A7XRTZ5Q.ttf +0 -0
- package/dist/KaTeX_Caligraphic-Regular-KX5MEWCF.woff2 +0 -0
- package/dist/KaTeX_Fraktur-Bold-2QVFK6NQ.woff2 +0 -0
- package/dist/KaTeX_Fraktur-Bold-T4SWXBMT.woff +0 -0
- package/dist/KaTeX_Fraktur-Bold-WGHVTYOR.ttf +0 -0
- package/dist/KaTeX_Fraktur-Regular-2PEIFJSJ.woff2 +0 -0
- package/dist/KaTeX_Fraktur-Regular-5U4OPH2X.ttf +0 -0
- package/dist/KaTeX_Fraktur-Regular-PQMHCIK6.woff +0 -0
- package/dist/KaTeX_Main-Bold-2GA4IZIN.woff +0 -0
- package/dist/KaTeX_Main-Bold-W5FBVCZM.ttf +0 -0
- package/dist/KaTeX_Main-Bold-YP5VVQRP.woff2 +0 -0
- package/dist/KaTeX_Main-BoldItalic-4P4C7HJH.woff +0 -0
- package/dist/KaTeX_Main-BoldItalic-N4V3DX7S.woff2 +0 -0
- package/dist/KaTeX_Main-BoldItalic-ODMLBJJQ.ttf +0 -0
- package/dist/KaTeX_Main-Italic-I43T2HSR.ttf +0 -0
- package/dist/KaTeX_Main-Italic-RELBIK7M.woff2 +0 -0
- package/dist/KaTeX_Main-Italic-SASNQFN2.woff +0 -0
- package/dist/KaTeX_Main-Regular-ARRPAO67.woff2 +0 -0
- package/dist/KaTeX_Main-Regular-P5I74A2A.woff +0 -0
- package/dist/KaTeX_Main-Regular-W74P5G27.ttf +0 -0
- package/dist/KaTeX_Math-BoldItalic-6EBV3DK5.woff +0 -0
- package/dist/KaTeX_Math-BoldItalic-K4WTGH3J.woff2 +0 -0
- package/dist/KaTeX_Math-BoldItalic-VB447A4D.ttf +0 -0
- package/dist/KaTeX_Math-Italic-6KGCHLFN.woff2 +0 -0
- package/dist/KaTeX_Math-Italic-KKK3USB2.woff +0 -0
- package/dist/KaTeX_Math-Italic-SON4MRCA.ttf +0 -0
- package/dist/KaTeX_SansSerif-Bold-RRNVJFFW.woff2 +0 -0
- package/dist/KaTeX_SansSerif-Bold-STQ6RXC7.ttf +0 -0
- package/dist/KaTeX_SansSerif-Bold-X5M5EMOD.woff +0 -0
- package/dist/KaTeX_SansSerif-Italic-HMPFTM52.woff2 +0 -0
- package/dist/KaTeX_SansSerif-Italic-PSN4QKYX.woff +0 -0
- package/dist/KaTeX_SansSerif-Italic-WTBAZBGY.ttf +0 -0
- package/dist/KaTeX_SansSerif-Regular-2TL3USAE.ttf +0 -0
- package/dist/KaTeX_SansSerif-Regular-OQCII6EP.woff +0 -0
- package/dist/KaTeX_SansSerif-Regular-XIQ62X4E.woff2 +0 -0
- package/dist/KaTeX_Script-Regular-72OLXYNA.ttf +0 -0
- package/dist/KaTeX_Script-Regular-A5IFOEBS.woff +0 -0
- package/dist/KaTeX_Script-Regular-APUWIHLP.woff2 +0 -0
- package/dist/KaTeX_Size1-Regular-4HRHTS65.woff +0 -0
- package/dist/KaTeX_Size1-Regular-5LRUTBFT.woff2 +0 -0
- package/dist/KaTeX_Size1-Regular-7K6AASVL.ttf +0 -0
- package/dist/KaTeX_Size2-Regular-222HN3GT.ttf +0 -0
- package/dist/KaTeX_Size2-Regular-K5ZHAIS6.woff +0 -0
- package/dist/KaTeX_Size2-Regular-LELKET5D.woff2 +0 -0
- package/dist/KaTeX_Size3-Regular-TLFPAHDE.woff +0 -0
- package/dist/KaTeX_Size3-Regular-UFCO6WCA.ttf +0 -0
- package/dist/KaTeX_Size3-Regular-WQRQ47UD.woff2 +0 -0
- package/dist/KaTeX_Size4-Regular-7PGNVPQK.ttf +0 -0
- package/dist/KaTeX_Size4-Regular-CDMV7U5C.woff2 +0 -0
- package/dist/KaTeX_Size4-Regular-PKMWZHNC.woff +0 -0
- package/dist/KaTeX_Typewriter-Regular-3F5K6SQ6.ttf +0 -0
- package/dist/KaTeX_Typewriter-Regular-MJMFSK64.woff +0 -0
- package/dist/KaTeX_Typewriter-Regular-VBYJ4NRC.woff2 +0 -0
- package/dist/index.css +2156 -603
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +126 -92
- package/dist/index.js +1605 -976
- package/dist/index.js.map +1 -1
- package/dist/style.css +130 -0
- package/package.json +3 -3
- package/src/components/ChatPanel.tsx +82 -19
- package/src/components/common/SettingsPanel.css +81 -0
- package/src/components/common/SettingsPanel.tsx +96 -1
- package/src/components/input/ChatInput.css +0 -1
- package/src/components/input/ChatInput.tsx +48 -26
- package/src/components/input/DropdownSelector.css +66 -0
- package/src/components/input/DropdownSelector.tsx +157 -19
- package/src/components/message/MessageBubble.css +5 -2
- package/src/components/message/MessageBubble.tsx +44 -35
- package/src/components/message/PartsRenderer.css +8 -0
- package/src/components/message/PartsRenderer.tsx +137 -83
- package/src/components/message/parts/CollapsibleCard.css +4 -2
- package/src/components/message/parts/CollapsibleCard.tsx +4 -1
- package/src/components/message/parts/ImagePart.css +0 -1
- package/src/components/message/parts/TextPart.css +574 -5
- package/src/components/message/parts/TextPart.tsx +201 -8
- package/src/components/message/parts/ToolCallPart.css +139 -115
- package/src/components/message/parts/ToolCallPart.tsx +138 -134
- package/src/components/message/parts/ToolResultPart.css +0 -1
- package/src/components/message/parts/index.ts +3 -1
- package/src/components/message/parts/visual-predicate.ts +43 -0
- package/src/components/message/parts/visual-render.ts +19 -0
- package/src/components/message/parts/visual.ts +12 -0
- package/src/context/RenderersContext.tsx +19 -25
- package/src/hooks/useChat.ts +567 -79
- package/src/hooks/useImageUpload.ts +104 -12
- package/src/hooks/useVoiceInput.ts +17 -0
- package/src/index.ts +19 -16
- package/src/styles.css +130 -0
- package/src/types/index.ts +52 -68
- package/src/components/message/ContentRenderer.tsx +0 -63
- package/src/components/message/ToolResultRenderer.tsx +0 -21
- package/src/components/message/blocks/CodeBlock.tsx +0 -60
- package/src/components/message/blocks/TextBlock.tsx +0 -15
- package/src/components/message/blocks/blocks.css +0 -141
- package/src/components/message/blocks/index.ts +0 -6
- package/src/components/message/parts/ToolResultPart.tsx +0 -96
- package/src/components/message/tool-results/DefaultToolResult.tsx +0 -26
- package/src/components/message/tool-results/SearchResults.tsx +0 -69
- package/src/components/message/tool-results/WeatherCard.tsx +0 -63
- package/src/components/message/tool-results/index.ts +0 -7
- package/src/components/message/tool-results/tool-results.css +0 -181
package/src/hooks/useChat.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { useState, useCallback, useRef, useMemo, useEffect } from 'react'
|
|
13
13
|
import type {
|
|
14
14
|
ChatAdapter,
|
|
15
|
-
ChatEvent,
|
|
15
|
+
ChatEvent as BridgeChatEvent,
|
|
16
16
|
ChatMode,
|
|
17
17
|
SessionRecord,
|
|
18
18
|
MessageRecord,
|
|
@@ -25,11 +25,96 @@ import type {
|
|
|
25
25
|
ThinkingPart,
|
|
26
26
|
SearchPart,
|
|
27
27
|
ToolCallPart,
|
|
28
|
-
ToolResultPart,
|
|
29
28
|
ErrorPart,
|
|
30
29
|
SearchResult,
|
|
31
30
|
} from '../types'
|
|
32
31
|
|
|
32
|
+
// 扩展事件类型:允许 bridge 端新增事件时前端不被类型卡死
|
|
33
|
+
type ChatEvent = Omit<BridgeChatEvent, 'type'> & {
|
|
34
|
+
type: BridgeChatEvent['type'] | 'tool_call_output'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ==================== @ 上下文(文件引用) ====================
|
|
38
|
+
// 约定:AtFilePicker 插入的格式为单行 "@<path>",通常以换行分隔
|
|
39
|
+
const AT_LINE_RE = /^\s*@\s*(.+?)\s*$/
|
|
40
|
+
const MAX_FILE_CHARS = 20_000
|
|
41
|
+
|
|
42
|
+
function stripAtContextLines(text: string): string {
|
|
43
|
+
return text
|
|
44
|
+
.split('\n')
|
|
45
|
+
.filter((line) => !AT_LINE_RE.test(line))
|
|
46
|
+
.join('\n')
|
|
47
|
+
.trim()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function extractAtContextRefs(text: string): { refs: string[]; cleanedText: string } {
|
|
51
|
+
const refs: string[] = []
|
|
52
|
+
const kept: string[] = []
|
|
53
|
+
|
|
54
|
+
for (const line of text.split('\n')) {
|
|
55
|
+
const m = line.match(AT_LINE_RE)
|
|
56
|
+
if (m?.[1]) {
|
|
57
|
+
refs.push(m[1])
|
|
58
|
+
} else {
|
|
59
|
+
kept.push(line)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
refs: Array.from(new Set(refs.map((r) => r.trim()).filter(Boolean))),
|
|
65
|
+
cleanedText: kept.join('\n').trim(),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function buildPromptWithAtContext(adapter: ChatAdapter, text: string): Promise<string> {
|
|
70
|
+
const { refs, cleanedText } = extractAtContextRefs(text)
|
|
71
|
+
if (refs.length === 0) return text
|
|
72
|
+
|
|
73
|
+
const blocks: string[] = []
|
|
74
|
+
|
|
75
|
+
for (const ref of refs) {
|
|
76
|
+
const resolved = (await adapter.resolvePath?.(ref)) || ref
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const stat = await adapter.stat?.(resolved)
|
|
80
|
+
if (stat?.isDirectory) {
|
|
81
|
+
const items = await adapter.listDir?.(resolved)
|
|
82
|
+
const listing = (items || [])
|
|
83
|
+
.slice(0, 200)
|
|
84
|
+
.map((f) => `${f.isDirectory ? 'DIR ' : 'FILE'} ${f.path}`)
|
|
85
|
+
.join('\n')
|
|
86
|
+
blocks.push(
|
|
87
|
+
`【目录】${resolved}\n` +
|
|
88
|
+
'```text\n' +
|
|
89
|
+
(listing || '(空目录/无法列出)') +
|
|
90
|
+
'\n```'
|
|
91
|
+
)
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const content = await adapter.readFile?.(resolved)
|
|
96
|
+
if (typeof content === 'string') {
|
|
97
|
+
const truncated = content.length > MAX_FILE_CHARS
|
|
98
|
+
? content.slice(0, MAX_FILE_CHARS) + `\n\n... (已截断,原始长度 ${content.length})`
|
|
99
|
+
: content
|
|
100
|
+
blocks.push(
|
|
101
|
+
`【文件】${resolved}\n` +
|
|
102
|
+
'```text\n' +
|
|
103
|
+
truncated +
|
|
104
|
+
'\n```'
|
|
105
|
+
)
|
|
106
|
+
} else {
|
|
107
|
+
blocks.push(`【引用】${resolved}\n(无法读取文件内容)`)
|
|
108
|
+
}
|
|
109
|
+
} catch (error) {
|
|
110
|
+
blocks.push(`【引用】${resolved}\n(读取失败:${error instanceof Error ? error.message : String(error)})`)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const question = cleanedText || stripAtContextLines(text) || text
|
|
115
|
+
return `以下是用户通过 @ 引用提供的上下文:\n\n${blocks.join('\n\n')}\n\n用户问题:\n${question}`
|
|
116
|
+
}
|
|
117
|
+
|
|
33
118
|
/** 生成唯一 ID */
|
|
34
119
|
function generateId(): string {
|
|
35
120
|
return Date.now().toString(36) + Math.random().toString(36).substr(2)
|
|
@@ -43,6 +128,18 @@ function extractTextContent(parts: ContentPart[]): string {
|
|
|
43
128
|
.join('')
|
|
44
129
|
}
|
|
45
130
|
|
|
131
|
+
/** 判断事件是否需要保存到数据库 */
|
|
132
|
+
function shouldSaveEvent(event: ChatEvent): boolean {
|
|
133
|
+
return (
|
|
134
|
+
event.type === 'thinking_end' ||
|
|
135
|
+
event.type === 'tool_call_result' ||
|
|
136
|
+
event.type === 'text_delta' ||
|
|
137
|
+
event.type === 'done' ||
|
|
138
|
+
event.type === 'error' ||
|
|
139
|
+
event.type === 'abort'
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
46
143
|
/** 解析工具调用结果 */
|
|
47
144
|
function parseToolResult(result: unknown): unknown | null {
|
|
48
145
|
if (result === undefined || result === null) return null
|
|
@@ -58,6 +155,11 @@ function parseToolResult(result: unknown): unknown | null {
|
|
|
58
155
|
|
|
59
156
|
/** 转换存储的消息为显示格式 */
|
|
60
157
|
function convertToMessage(record: MessageRecord): ChatMessage {
|
|
158
|
+
// timestamp 统一为 number(毫秒时间戳),不受 JSON 序列化影响
|
|
159
|
+
// 如果收到非 number 类型,说明数据异常,直接使用 0
|
|
160
|
+
const timestamp = typeof record.timestamp === 'number' && Number.isFinite(record.timestamp)
|
|
161
|
+
? record.timestamp
|
|
162
|
+
: 0
|
|
61
163
|
let parts: ContentPart[] = []
|
|
62
164
|
|
|
63
165
|
if (record.steps) {
|
|
@@ -86,6 +188,7 @@ function convertToMessage(record: MessageRecord): ChatMessage {
|
|
|
86
188
|
args: step.args,
|
|
87
189
|
result: parseToolResult(step.result),
|
|
88
190
|
status: step.status || 'done',
|
|
191
|
+
output: step.output,
|
|
89
192
|
})
|
|
90
193
|
} else if (step.type === 'text') {
|
|
91
194
|
parts.push({
|
|
@@ -117,12 +220,13 @@ function convertToMessage(record: MessageRecord): ChatMessage {
|
|
|
117
220
|
id: record.id,
|
|
118
221
|
role: record.role,
|
|
119
222
|
parts,
|
|
223
|
+
images: record.images ?? [],
|
|
120
224
|
model: record.model || undefined,
|
|
121
225
|
mode: record.mode || undefined,
|
|
122
226
|
webSearchEnabled: record.webSearchEnabled ?? undefined,
|
|
123
227
|
thinkingEnabled: record.thinkingEnabled ?? undefined,
|
|
124
228
|
loading: false,
|
|
125
|
-
timestamp
|
|
229
|
+
timestamp,
|
|
126
230
|
}
|
|
127
231
|
}
|
|
128
232
|
|
|
@@ -131,6 +235,8 @@ interface SessionState {
|
|
|
131
235
|
messages: ChatMessage[]
|
|
132
236
|
isLoading: boolean
|
|
133
237
|
abortController: AbortController | null
|
|
238
|
+
/** 当前进行中的 assistant 消息 ID(用于取消/落地状态,避免依赖 message.loading 的不稳定性) */
|
|
239
|
+
activeAssistantMessageId: string | null
|
|
134
240
|
}
|
|
135
241
|
|
|
136
242
|
/** 副作用定义 */
|
|
@@ -181,6 +287,15 @@ export function useChat(options: UseChatOptions) {
|
|
|
181
287
|
const sessionStatesRef = useRef(new Map<string, SessionState>())
|
|
182
288
|
const [stateVersion, setStateVersion] = useState(0)
|
|
183
289
|
|
|
290
|
+
// ==================== 工具开关配置(从数据库读取) ====================
|
|
291
|
+
/** 启用的工具名称列表(undefined 表示全部启用) */
|
|
292
|
+
const [enabledTools, setEnabledTools] = useState<string[] | undefined>(undefined)
|
|
293
|
+
const enabledToolsRef = useRef<string[] | undefined>(enabledTools)
|
|
294
|
+
enabledToolsRef.current = enabledTools
|
|
295
|
+
|
|
296
|
+
/** 所有可用工具列表(用于设置面板) */
|
|
297
|
+
const [allTools, setAllTools] = useState<Array<{ name: string; description: string }>>([])
|
|
298
|
+
|
|
184
299
|
// ==================== 自动运行配置(从数据库读取) ====================
|
|
185
300
|
// 默认配置
|
|
186
301
|
const DEFAULT_AUTO_RUN_CONFIG: AutoRunConfig = {
|
|
@@ -222,10 +337,70 @@ export function useChat(options: UseChatOptions) {
|
|
|
222
337
|
}
|
|
223
338
|
}, [adapter])
|
|
224
339
|
|
|
340
|
+
/** 从数据库加载工具开关配置 */
|
|
341
|
+
const loadEnabledTools = useCallback(async () => {
|
|
342
|
+
if (!adapter.getAllSettings) return
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const settings = await adapter.getAllSettings()
|
|
346
|
+
const toolsJson = settings['enabledTools']
|
|
347
|
+
if (!toolsJson) {
|
|
348
|
+
setEnabledTools(undefined)
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const parsed = JSON.parse(toolsJson) as unknown
|
|
353
|
+
if (Array.isArray(parsed) && parsed.every((x) => typeof x === 'string')) {
|
|
354
|
+
setEnabledTools(parsed)
|
|
355
|
+
} else {
|
|
356
|
+
setEnabledTools(undefined)
|
|
357
|
+
}
|
|
358
|
+
} catch (error) {
|
|
359
|
+
console.error('[useChat] 加载 enabledTools 失败:', error)
|
|
360
|
+
}
|
|
361
|
+
}, [adapter])
|
|
362
|
+
|
|
363
|
+
/** 保存工具开关配置到数据库 */
|
|
364
|
+
const saveEnabledTools = useCallback(async (tools: string[] | undefined) => {
|
|
365
|
+
if (!adapter.setSetting) return
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
if (tools === undefined) {
|
|
369
|
+
// 全部启用:删除配置(使用默认行为)
|
|
370
|
+
await adapter.deleteSetting?.('enabledTools')
|
|
371
|
+
setEnabledTools(undefined)
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
await adapter.setSetting('enabledTools', JSON.stringify(tools))
|
|
376
|
+
setEnabledTools(tools)
|
|
377
|
+
} catch (error) {
|
|
378
|
+
console.error('[useChat] 保存 enabledTools 失败:', error)
|
|
379
|
+
throw error
|
|
380
|
+
}
|
|
381
|
+
}, [adapter])
|
|
382
|
+
|
|
383
|
+
/** 加载所有工具列表 */
|
|
384
|
+
const loadAllTools = useCallback(async () => {
|
|
385
|
+
if (!adapter.getAllTools) {
|
|
386
|
+
console.warn('[useChat] adapter.getAllTools 不存在')
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
const tools = await adapter.getAllTools()
|
|
392
|
+
setAllTools(tools)
|
|
393
|
+
} catch (error) {
|
|
394
|
+
console.error('[useChat] 加载工具列表失败:', error)
|
|
395
|
+
}
|
|
396
|
+
}, [adapter])
|
|
397
|
+
|
|
225
398
|
// 初始化时加载配置
|
|
226
399
|
useEffect(() => {
|
|
227
400
|
loadAutoRunConfig()
|
|
228
|
-
|
|
401
|
+
loadEnabledTools()
|
|
402
|
+
loadAllTools()
|
|
403
|
+
}, [loadAutoRunConfig, loadEnabledTools, loadAllTools])
|
|
229
404
|
|
|
230
405
|
// 强制重新渲染
|
|
231
406
|
const forceUpdate = useCallback(() => setStateVersion(v => v + 1), [])
|
|
@@ -237,6 +412,7 @@ export function useChat(options: UseChatOptions) {
|
|
|
237
412
|
messages: [],
|
|
238
413
|
isLoading: false,
|
|
239
414
|
abortController: null,
|
|
415
|
+
activeAssistantMessageId: null,
|
|
240
416
|
})
|
|
241
417
|
}
|
|
242
418
|
return sessionStatesRef.current.get(sessionId)!
|
|
@@ -291,19 +467,21 @@ export function useChat(options: UseChatOptions) {
|
|
|
291
467
|
const list = await adapter.getSessions()
|
|
292
468
|
setSessions(list)
|
|
293
469
|
if (list.length > 0 && !currentSessionIdRef.current) {
|
|
294
|
-
|
|
470
|
+
// 选择第一个未隐藏的会话,如果都隐藏了则选第一个
|
|
471
|
+
const firstVisible = list.find(s => !s.hidden) || list[0]
|
|
472
|
+
setCurrentSessionId(firstVisible.id)
|
|
295
473
|
// 加载消息
|
|
296
|
-
const state = getSessionState(
|
|
474
|
+
const state = getSessionState(firstVisible.id)
|
|
297
475
|
if (state.messages.length === 0) {
|
|
298
|
-
const savedMessages = await adapter.getMessages(
|
|
476
|
+
const savedMessages = await adapter.getMessages(firstVisible.id)
|
|
299
477
|
state.messages = savedMessages.map(convertToMessage)
|
|
300
478
|
forceUpdate()
|
|
301
479
|
}
|
|
302
480
|
// 同步配置
|
|
303
|
-
setModeState(
|
|
304
|
-
setModelState(
|
|
305
|
-
setWebSearchState(
|
|
306
|
-
setThinkingState(
|
|
481
|
+
setModeState(firstVisible.mode)
|
|
482
|
+
setModelState(firstVisible.model)
|
|
483
|
+
setWebSearchState(firstVisible.webSearchEnabled)
|
|
484
|
+
setThinkingState(firstVisible.thinkingEnabled)
|
|
307
485
|
}
|
|
308
486
|
} catch (error) {
|
|
309
487
|
console.error('加载会话失败:', error)
|
|
@@ -356,6 +534,7 @@ export function useChat(options: UseChatOptions) {
|
|
|
356
534
|
messages: [],
|
|
357
535
|
isLoading: false,
|
|
358
536
|
abortController: null,
|
|
537
|
+
activeAssistantMessageId: null,
|
|
359
538
|
})
|
|
360
539
|
|
|
361
540
|
setCurrentSessionId(session.id)
|
|
@@ -496,15 +675,17 @@ export function useChat(options: UseChatOptions) {
|
|
|
496
675
|
|
|
497
676
|
case 'thinking_delta': {
|
|
498
677
|
const data = event.data as { content: string }
|
|
678
|
+
// 查找最后一个 running 状态的 thinking part
|
|
499
679
|
const lastThinkingIndex = parts.findLastIndex(
|
|
500
680
|
p => p.type === 'thinking' && p.status === 'running'
|
|
501
681
|
)
|
|
502
682
|
if (lastThinkingIndex >= 0) {
|
|
683
|
+
// 追加到现有的 running thinking part
|
|
503
684
|
const part = parts[lastThinkingIndex] as ThinkingPart
|
|
504
685
|
parts[lastThinkingIndex] = { ...part, text: part.text + data.content }
|
|
505
|
-
} else {
|
|
506
|
-
parts.push({ type: 'thinking', text: data.content, status: 'running' })
|
|
507
686
|
}
|
|
687
|
+
// ⚠️ 关键修复:如果没有 running 的 thinking part,忽略此事件(不创建新的)
|
|
688
|
+
// thinking_start 事件负责创建,thinking_delta 只负责追加
|
|
508
689
|
break
|
|
509
690
|
}
|
|
510
691
|
|
|
@@ -526,7 +707,17 @@ export function useChat(options: UseChatOptions) {
|
|
|
526
707
|
|
|
527
708
|
case 'search_start': {
|
|
528
709
|
const data = event.data as { query?: string }
|
|
529
|
-
|
|
710
|
+
const searchPart: SearchPart = { type: 'search', query: data.query, status: 'running' }
|
|
711
|
+
|
|
712
|
+
// UX:搜索结果事件可能在 text_delta 之后才到达(Provider 侧以 annotation/结果到达为准)
|
|
713
|
+
// 若直接 push,会导致"搜索卡片"显示在回答之后。
|
|
714
|
+
// 这里优先插入到第一个 text part 之前(通常在 thinking 后面),保持视觉顺序符合用户预期。
|
|
715
|
+
const firstTextIndex = parts.findIndex(p => p.type === 'text')
|
|
716
|
+
if (firstTextIndex >= 0) {
|
|
717
|
+
parts.splice(firstTextIndex, 0, searchPart)
|
|
718
|
+
} else {
|
|
719
|
+
parts.push(searchPart)
|
|
720
|
+
}
|
|
530
721
|
break
|
|
531
722
|
}
|
|
532
723
|
|
|
@@ -562,13 +753,13 @@ export function useChat(options: UseChatOptions) {
|
|
|
562
753
|
}
|
|
563
754
|
|
|
564
755
|
// manual 模式:创建 pending 状态等待用户确认
|
|
565
|
-
const existingIndex = parts.
|
|
756
|
+
const existingIndex = parts.findIndex(
|
|
566
757
|
p => p.type === 'tool_call' && (p as ToolCallPart).id === data.id
|
|
567
758
|
)
|
|
568
759
|
if (existingIndex >= 0) {
|
|
569
760
|
// 更新现有 part 为 pending
|
|
570
761
|
const part = parts[existingIndex] as ToolCallPart
|
|
571
|
-
parts[existingIndex] = { ...part, status: 'pending'
|
|
762
|
+
parts[existingIndex] = { ...part, status: 'pending' }
|
|
572
763
|
} else {
|
|
573
764
|
// 创建新的 pending part
|
|
574
765
|
parts.push({
|
|
@@ -577,7 +768,6 @@ export function useChat(options: UseChatOptions) {
|
|
|
577
768
|
name: data.name,
|
|
578
769
|
args: data.args,
|
|
579
770
|
status: 'pending',
|
|
580
|
-
result: null,
|
|
581
771
|
})
|
|
582
772
|
}
|
|
583
773
|
break
|
|
@@ -586,13 +776,13 @@ export function useChat(options: UseChatOptions) {
|
|
|
586
776
|
case 'tool_call_start': {
|
|
587
777
|
const data = event.data as { id: string; name: string; args: Record<string, unknown> }
|
|
588
778
|
// 检查是否已存在 pending 状态的 part
|
|
589
|
-
const existingIndex = parts.
|
|
779
|
+
const existingIndex = parts.findIndex(
|
|
590
780
|
p => p.type === 'tool_call' && (p as ToolCallPart).id === data.id
|
|
591
781
|
)
|
|
592
782
|
if (existingIndex >= 0) {
|
|
593
783
|
// 更新现有 part 为 running
|
|
594
784
|
const part = parts[existingIndex] as ToolCallPart
|
|
595
|
-
parts[existingIndex] = { ...part, status: 'running'
|
|
785
|
+
parts[existingIndex] = { ...part, status: 'running' }
|
|
596
786
|
} else {
|
|
597
787
|
// 创建新的 running part
|
|
598
788
|
parts.push({
|
|
@@ -601,14 +791,19 @@ export function useChat(options: UseChatOptions) {
|
|
|
601
791
|
name: data.name,
|
|
602
792
|
args: data.args,
|
|
603
793
|
status: 'running',
|
|
604
|
-
result: null,
|
|
605
794
|
})
|
|
606
795
|
}
|
|
607
796
|
break
|
|
608
797
|
}
|
|
609
798
|
|
|
610
799
|
case 'tool_call_result': {
|
|
611
|
-
const data = event.data as {
|
|
800
|
+
const data = event.data as {
|
|
801
|
+
id: string
|
|
802
|
+
name: string
|
|
803
|
+
result: string
|
|
804
|
+
success: boolean
|
|
805
|
+
resultType?: string // 结果类型(用于生成具体类型的 Part)
|
|
806
|
+
}
|
|
612
807
|
|
|
613
808
|
let parsedResult: unknown = data.result
|
|
614
809
|
try {
|
|
@@ -625,37 +820,55 @@ export function useChat(options: UseChatOptions) {
|
|
|
625
820
|
const status: 'done' | 'error' | 'cancelled' | 'skipped' =
|
|
626
821
|
isSkipped ? 'skipped' : (isCancelled ? 'cancelled' : (data.success ? 'done' : 'error'))
|
|
627
822
|
|
|
628
|
-
// 查找对应的 tool_call
|
|
823
|
+
// 查找对应的 tool_call 并更新状态
|
|
629
824
|
const toolCallIndex = parts.findIndex(
|
|
630
825
|
p => p.type === 'tool_call' && (p as ToolCallPart).id === data.id
|
|
631
826
|
)
|
|
632
827
|
|
|
633
828
|
if (toolCallIndex >= 0) {
|
|
634
|
-
// 更新 tool_call
|
|
829
|
+
// 更新 tool_call 状态(不再包含 result 字段)
|
|
635
830
|
const toolCall = parts[toolCallIndex] as ToolCallPart
|
|
636
831
|
parts[toolCallIndex] = {
|
|
637
832
|
...toolCall,
|
|
638
|
-
result: parsedResult,
|
|
639
833
|
status,
|
|
640
834
|
}
|
|
641
835
|
} else {
|
|
642
|
-
// 如果没有对应的 tool_call,创建一个新的
|
|
836
|
+
// 如果没有对应的 tool_call,创建一个新的
|
|
643
837
|
parts.push({
|
|
644
838
|
type: 'tool_call',
|
|
645
839
|
id: data.id,
|
|
646
840
|
name: data.name,
|
|
647
841
|
args: {},
|
|
648
|
-
result: parsedResult,
|
|
649
842
|
status,
|
|
650
843
|
})
|
|
651
844
|
}
|
|
652
|
-
|
|
653
|
-
//
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
845
|
+
|
|
846
|
+
// 如果工具定义了 resultType 且执行成功,生成对应类型的 Part
|
|
847
|
+
if (data.resultType && data.success && typeof parsedResult === 'object' && parsedResult !== null) {
|
|
848
|
+
// 找到 tool_call 的位置,在其后插入结果 Part
|
|
849
|
+
const updatedToolCallIndex = parts.findIndex(
|
|
850
|
+
p => p.type === 'tool_call' && (p as ToolCallPart).id === data.id
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
// 创建具体类型的 Part(如 { type: 'weather', city: '北京', ... })
|
|
854
|
+
const resultPart = {
|
|
855
|
+
type: data.resultType,
|
|
856
|
+
...parsedResult as Record<string, unknown>,
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// 检查是否已存在同类型的结果 Part(避免重复)
|
|
860
|
+
const existingResultIndex = parts.findIndex(
|
|
861
|
+
(p, i) => i > updatedToolCallIndex && p.type === data.resultType
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
if (existingResultIndex >= 0) {
|
|
865
|
+
// 更新现有的结果 Part
|
|
866
|
+
parts[existingResultIndex] = resultPart
|
|
867
|
+
} else {
|
|
868
|
+
// 插到对应 tool_call 之后
|
|
869
|
+
const insertAt = updatedToolCallIndex >= 0 ? updatedToolCallIndex + 1 : parts.length
|
|
870
|
+
parts.splice(insertAt, 0, resultPart)
|
|
871
|
+
}
|
|
659
872
|
}
|
|
660
873
|
|
|
661
874
|
if (onToolComplete) {
|
|
@@ -670,6 +883,30 @@ export function useChat(options: UseChatOptions) {
|
|
|
670
883
|
break
|
|
671
884
|
}
|
|
672
885
|
|
|
886
|
+
case 'tool_call_output': {
|
|
887
|
+
const data = event.data as { id: string; name: string; stream: 'stdout' | 'stderr'; chunk: string }
|
|
888
|
+
const toolCallIndex = parts.findIndex(
|
|
889
|
+
p => p.type === 'tool_call' && (p as ToolCallPart).id === data.id
|
|
890
|
+
)
|
|
891
|
+
if (toolCallIndex >= 0) {
|
|
892
|
+
const toolCall = parts[toolCallIndex] as ToolCallPart
|
|
893
|
+
const prevStdout = toolCall.output?.stdout ?? ''
|
|
894
|
+
const prevStderr = toolCall.output?.stderr ?? ''
|
|
895
|
+
const MAX = 120_000
|
|
896
|
+
|
|
897
|
+
const nextOutput =
|
|
898
|
+
data.stream === 'stdout'
|
|
899
|
+
? { stdout: (prevStdout + (data.chunk || '')).slice(-MAX), stderr: prevStderr }
|
|
900
|
+
: { stdout: prevStdout, stderr: (prevStderr + (data.chunk || '')).slice(-MAX) }
|
|
901
|
+
|
|
902
|
+
parts[toolCallIndex] = {
|
|
903
|
+
...toolCall,
|
|
904
|
+
output: nextOutput,
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
break
|
|
908
|
+
}
|
|
909
|
+
|
|
673
910
|
case 'text_delta': {
|
|
674
911
|
const data = event.data as { content: string }
|
|
675
912
|
|
|
@@ -682,12 +919,14 @@ export function useChat(options: UseChatOptions) {
|
|
|
682
919
|
// 查找最后一个 text part
|
|
683
920
|
const lastTextIndex = parts.findLastIndex(p => p.type === 'text')
|
|
684
921
|
|
|
685
|
-
// 检查最后一个 part
|
|
922
|
+
// 检查最后一个 part 是否是已完成的工具调用/思考
|
|
686
923
|
// 如果是,说明这是新一轮的文本输出,应该创建新的 text part
|
|
924
|
+
//
|
|
925
|
+
// 注意:search 结果可能会在文本输出过程中到达(甚至夹在 text_delta 中间),
|
|
926
|
+
// 如果把 search(done) 也作为“新一轮文本”的边界,会导致文本被拆成多个 TextPart。
|
|
687
927
|
const lastPart = parts[parts.length - 1]
|
|
688
928
|
const shouldCreateNew = lastPart && (
|
|
689
929
|
(lastPart.type === 'tool_call' && ['done', 'error', 'skipped', 'cancelled'].includes((lastPart as ToolCallPart).status)) ||
|
|
690
|
-
(lastPart.type === 'search' && (lastPart as SearchPart).status === 'done') ||
|
|
691
930
|
(lastPart.type === 'thinking' && (lastPart as ThinkingPart).status === 'done')
|
|
692
931
|
)
|
|
693
932
|
|
|
@@ -747,9 +986,56 @@ export function useChat(options: UseChatOptions) {
|
|
|
747
986
|
forceUpdate()
|
|
748
987
|
}, [onToolComplete, forceUpdate])
|
|
749
988
|
|
|
989
|
+
/**
|
|
990
|
+
* 保存消息到数据库
|
|
991
|
+
*/
|
|
992
|
+
const saveMessageToDb = useCallback(async (
|
|
993
|
+
sessionId: string,
|
|
994
|
+
messageIndex: number,
|
|
995
|
+
messageId: string
|
|
996
|
+
) => {
|
|
997
|
+
const state = sessionStatesRef.current.get(sessionId)
|
|
998
|
+
if (!state) return
|
|
999
|
+
|
|
1000
|
+
const msg = state.messages[messageIndex]
|
|
1001
|
+
if (!msg) return
|
|
1002
|
+
|
|
1003
|
+
try {
|
|
1004
|
+
await adapter.updateMessage?.({
|
|
1005
|
+
id: messageId,
|
|
1006
|
+
content: extractTextContent(msg.parts),
|
|
1007
|
+
steps: JSON.stringify(msg.parts),
|
|
1008
|
+
})
|
|
1009
|
+
} catch (error) {
|
|
1010
|
+
console.error('[useChat/react] 保存消息失败:', error)
|
|
1011
|
+
}
|
|
1012
|
+
}, [adapter])
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* 统一的事件处理函数:更新UI并保存到数据库
|
|
1016
|
+
* 保证UI更新和DB保存的原子性
|
|
1017
|
+
*/
|
|
1018
|
+
const handleEvent = useCallback(async (
|
|
1019
|
+
sessionId: string,
|
|
1020
|
+
messageIndex: number,
|
|
1021
|
+
event: ChatEvent,
|
|
1022
|
+
messageId: string,
|
|
1023
|
+
options: { saveToDb: boolean } = { saveToDb: false }
|
|
1024
|
+
) => {
|
|
1025
|
+
// 1. 先更新UI
|
|
1026
|
+
updateSessionMessage(sessionId, messageIndex, event)
|
|
1027
|
+
|
|
1028
|
+
// 2. 如果需要保存,立即保存(保证UI和DB一致)
|
|
1029
|
+
if (options.saveToDb && shouldSaveEvent(event)) {
|
|
1030
|
+
await saveMessageToDb(sessionId, messageIndex, messageId)
|
|
1031
|
+
}
|
|
1032
|
+
}, [updateSessionMessage, saveMessageToDb])
|
|
1033
|
+
|
|
750
1034
|
// ==================== 发送消息 ====================
|
|
751
1035
|
const sendMessage = useCallback(async (text: string, images?: string[]) => {
|
|
752
|
-
|
|
1036
|
+
// 允许只发送图片(text 为空但有图片)
|
|
1037
|
+
const hasContent = text.trim() || (images && images.length > 0)
|
|
1038
|
+
if (!hasContent) return
|
|
753
1039
|
|
|
754
1040
|
let sessionId = currentSessionIdRef.current
|
|
755
1041
|
|
|
@@ -773,6 +1059,7 @@ export function useChat(options: UseChatOptions) {
|
|
|
773
1059
|
messages: [],
|
|
774
1060
|
isLoading: false,
|
|
775
1061
|
abortController: null,
|
|
1062
|
+
activeAssistantMessageId: null,
|
|
776
1063
|
})
|
|
777
1064
|
setCurrentSessionId(session.id)
|
|
778
1065
|
sessionId = session.id
|
|
@@ -789,20 +1076,25 @@ export function useChat(options: UseChatOptions) {
|
|
|
789
1076
|
role: 'user',
|
|
790
1077
|
parts: [{ type: 'text', text }],
|
|
791
1078
|
images,
|
|
792
|
-
timestamp:
|
|
1079
|
+
timestamp: Date.now(),
|
|
793
1080
|
}
|
|
794
1081
|
state.messages = [...state.messages, userMsg]
|
|
795
1082
|
forceUpdate()
|
|
796
1083
|
|
|
797
1084
|
try {
|
|
1085
|
+
// 关键:传入 userMsg.id,确保"重新发送/分叉"能用同一个 id 做锚点更新/删除
|
|
798
1086
|
await adapter.saveMessage({
|
|
1087
|
+
id: userMsg.id,
|
|
799
1088
|
sessionId,
|
|
800
1089
|
role: 'user',
|
|
801
1090
|
content: text,
|
|
1091
|
+
images: images || [],
|
|
802
1092
|
})
|
|
803
1093
|
|
|
804
1094
|
if (state.messages.length === 1) {
|
|
805
|
-
const title = text.
|
|
1095
|
+
const title = text.trim()
|
|
1096
|
+
? text.slice(0, 20) + (text.length > 20 ? '...' : '')
|
|
1097
|
+
: (images && images.length > 0 ? '图片消息' : '新对话')
|
|
806
1098
|
await adapter.updateSession(sessionId, { title })
|
|
807
1099
|
setSessions(prev => prev.map((s) =>
|
|
808
1100
|
s.id === sessionId ? { ...s, title } : s
|
|
@@ -823,7 +1115,7 @@ export function useChat(options: UseChatOptions) {
|
|
|
823
1115
|
webSearchEnabled: webSearchRef.current,
|
|
824
1116
|
thinkingEnabled: thinkingRef.current,
|
|
825
1117
|
loading: true,
|
|
826
|
-
timestamp:
|
|
1118
|
+
timestamp: Date.now(),
|
|
827
1119
|
}
|
|
828
1120
|
state.messages = [...state.messages, assistantMsg]
|
|
829
1121
|
|
|
@@ -831,12 +1123,14 @@ export function useChat(options: UseChatOptions) {
|
|
|
831
1123
|
const requestAbortController = new AbortController()
|
|
832
1124
|
state.isLoading = true
|
|
833
1125
|
state.abortController = requestAbortController
|
|
1126
|
+
state.activeAssistantMessageId = assistantMsgId
|
|
834
1127
|
forceUpdate()
|
|
835
1128
|
|
|
836
1129
|
const sendModel = modelRef.current
|
|
837
1130
|
const sendMode = modeRef.current
|
|
838
1131
|
const sendWebSearch = webSearchRef.current
|
|
839
1132
|
const sendThinking = thinkingRef.current
|
|
1133
|
+
const sendEnabledTools = enabledToolsRef.current
|
|
840
1134
|
|
|
841
1135
|
// 【关键】立即保存助手消息到数据库(初始状态)
|
|
842
1136
|
try {
|
|
@@ -874,17 +1168,22 @@ export function useChat(options: UseChatOptions) {
|
|
|
874
1168
|
// 构建历史消息(不包括刚添加的用户消息和助手占位消息)
|
|
875
1169
|
const history = state.messages.slice(0, -2).map(msg => ({
|
|
876
1170
|
role: msg.role as 'user' | 'assistant' | 'system' | 'tool',
|
|
877
|
-
|
|
1171
|
+
// history 中去掉 @ 引用行(避免后续轮次出现“空引用”干扰)
|
|
1172
|
+
content: stripAtContextLines(extractTextContent(msg.parts)),
|
|
878
1173
|
}))
|
|
879
1174
|
|
|
1175
|
+
const cleanAutoRunConfig: AutoRunConfig = { ...autoRunConfigRef.current }
|
|
1176
|
+
const promptMessage = await buildPromptWithAtContext(adapter, text)
|
|
1177
|
+
|
|
880
1178
|
for await (const event of adapter.sendMessage(
|
|
881
|
-
|
|
1179
|
+
promptMessage,
|
|
882
1180
|
{
|
|
883
1181
|
mode: sendMode,
|
|
884
1182
|
model: sendModel,
|
|
885
1183
|
enableWebSearch: sendWebSearch,
|
|
886
1184
|
thinkingMode: sendThinking ? 'enabled' : 'disabled',
|
|
887
|
-
|
|
1185
|
+
enabledTools: sendEnabledTools,
|
|
1186
|
+
autoRunConfig: cleanAutoRunConfig,
|
|
888
1187
|
history, // 传递历史消息
|
|
889
1188
|
},
|
|
890
1189
|
images,
|
|
@@ -893,20 +1192,10 @@ export function useChat(options: UseChatOptions) {
|
|
|
893
1192
|
// 使用本次请求专用的 abortController 检查(避免被新请求覆盖)
|
|
894
1193
|
if (requestAbortController.signal.aborted) break
|
|
895
1194
|
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
event.type === 'thinking_end' ||
|
|
901
|
-
event.type === 'tool_call_result' ||
|
|
902
|
-
event.type === 'text_delta' ||
|
|
903
|
-
event.type === 'done' ||
|
|
904
|
-
event.type === 'error' ||
|
|
905
|
-
event.type === 'abort'
|
|
906
|
-
|
|
907
|
-
if (shouldSave) {
|
|
908
|
-
await saveMessageProgress()
|
|
909
|
-
}
|
|
1195
|
+
// 统一处理事件:更新UI并保存到数据库
|
|
1196
|
+
await handleEvent(sessionId, assistantMsgIndex, event, assistantMsgId, {
|
|
1197
|
+
saveToDb: true, // 重要事件需要保存
|
|
1198
|
+
})
|
|
910
1199
|
|
|
911
1200
|
if (event.type === 'done' || event.type === 'error') {
|
|
912
1201
|
break
|
|
@@ -914,10 +1203,16 @@ export function useChat(options: UseChatOptions) {
|
|
|
914
1203
|
}
|
|
915
1204
|
} catch (error) {
|
|
916
1205
|
console.error('发送消息失败:', error)
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1206
|
+
await handleEvent(
|
|
1207
|
+
sessionId,
|
|
1208
|
+
assistantMsgIndex,
|
|
1209
|
+
{
|
|
1210
|
+
type: 'error',
|
|
1211
|
+
data: { message: error instanceof Error ? error.message : String(error) },
|
|
1212
|
+
},
|
|
1213
|
+
assistantMsgId,
|
|
1214
|
+
{ saveToDb: true }
|
|
1215
|
+
)
|
|
921
1216
|
} finally {
|
|
922
1217
|
state.isLoading = false
|
|
923
1218
|
|
|
@@ -928,24 +1223,64 @@ export function useChat(options: UseChatOptions) {
|
|
|
928
1223
|
}
|
|
929
1224
|
|
|
930
1225
|
// 【关键】最终保存一次,确保所有内容都被持久化
|
|
931
|
-
await
|
|
1226
|
+
await saveMessageToDb(sessionId, assistantMsgIndex, assistantMsgId)
|
|
932
1227
|
|
|
933
1228
|
state.abortController = null
|
|
1229
|
+
state.activeAssistantMessageId = null
|
|
934
1230
|
forceUpdate()
|
|
935
1231
|
}
|
|
936
|
-
}, [adapter, getSessionState,
|
|
1232
|
+
}, [adapter, getSessionState, handleEvent, saveMessageToDb, forceUpdate])
|
|
937
1233
|
|
|
938
1234
|
// ==================== 其他方法 ====================
|
|
1235
|
+
const patchPartsAsAborted = useCallback((parts: ContentPart[]): ContentPart[] => {
|
|
1236
|
+
return parts.map((p) => {
|
|
1237
|
+
if (p.type === 'thinking' && (p as ThinkingPart).status === 'running') {
|
|
1238
|
+
return { ...(p as ThinkingPart), status: 'done' as const }
|
|
1239
|
+
}
|
|
1240
|
+
if (p.type === 'search' && (p as SearchPart).status === 'running') {
|
|
1241
|
+
return { ...(p as SearchPart), status: 'done' as const }
|
|
1242
|
+
}
|
|
1243
|
+
if (p.type === 'tool_call') {
|
|
1244
|
+
const tool = p as ToolCallPart
|
|
1245
|
+
if (tool.status === 'running' || tool.status === 'pending') {
|
|
1246
|
+
return { ...tool, status: 'cancelled' as const }
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
return p
|
|
1250
|
+
})
|
|
1251
|
+
}, [])
|
|
1252
|
+
|
|
1253
|
+
const cancelActiveAssistantMessage = useCallback((sessionId: string) => {
|
|
1254
|
+
const state = sessionStatesRef.current.get(sessionId)
|
|
1255
|
+
if (!state) return
|
|
1256
|
+
if (!state.activeAssistantMessageId) return
|
|
1257
|
+
const idx = state.messages.findIndex((m) => m.id === state.activeAssistantMessageId)
|
|
1258
|
+
if (idx < 0) return
|
|
1259
|
+
const msg = state.messages[idx]
|
|
1260
|
+
state.messages = [...state.messages]
|
|
1261
|
+
state.messages[idx] = {
|
|
1262
|
+
...msg,
|
|
1263
|
+
loading: false,
|
|
1264
|
+
aborted: true,
|
|
1265
|
+
parts: patchPartsAsAborted(msg.parts),
|
|
1266
|
+
}
|
|
1267
|
+
}, [patchPartsAsAborted])
|
|
1268
|
+
|
|
939
1269
|
const cancelRequest = useCallback(() => {
|
|
940
1270
|
if (!currentSessionIdRef.current) return
|
|
941
|
-
const
|
|
1271
|
+
const sessionId = currentSessionIdRef.current
|
|
1272
|
+
const state = sessionStatesRef.current.get(sessionId)
|
|
1273
|
+
|
|
1274
|
+
// 先做 UI 即时落地:精准命中本轮 assistant 消息
|
|
1275
|
+
cancelActiveAssistantMessage(sessionId)
|
|
942
1276
|
if (state) {
|
|
943
1277
|
state.abortController?.abort()
|
|
944
1278
|
state.isLoading = false
|
|
1279
|
+
state.activeAssistantMessageId = null
|
|
945
1280
|
forceUpdate()
|
|
946
1281
|
}
|
|
947
1282
|
adapter.cancel()
|
|
948
|
-
}, [adapter, forceUpdate])
|
|
1283
|
+
}, [adapter, forceUpdate, cancelActiveAssistantMessage])
|
|
949
1284
|
|
|
950
1285
|
const copyMessage = useCallback(async (messageId: string) => {
|
|
951
1286
|
if (!currentSessionIdRef.current) return
|
|
@@ -978,30 +1313,178 @@ export function useChat(options: UseChatOptions) {
|
|
|
978
1313
|
}
|
|
979
1314
|
}, [forceUpdate])
|
|
980
1315
|
|
|
981
|
-
|
|
1316
|
+
/** 从指定索引重新发送消息(编辑后重发,分叉:删除其后的所有消息并重新生成) */
|
|
1317
|
+
const resendFromIndex = useCallback(async (index: number, text: string) => {
|
|
982
1318
|
if (!currentSessionIdRef.current) return
|
|
983
|
-
const
|
|
1319
|
+
const sessionId = currentSessionIdRef.current
|
|
1320
|
+
const state = sessionStatesRef.current.get(sessionId)
|
|
984
1321
|
if (!state) return
|
|
985
1322
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1323
|
+
const targetMsg = state.messages[index]
|
|
1324
|
+
if (!targetMsg || targetMsg.role !== 'user') {
|
|
1325
|
+
// 容错:如果传入的不是 user 消息索引,退化为普通发送
|
|
1326
|
+
sendMessage(text)
|
|
1327
|
+
return
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// 如果当前会话正在生成,先取消,避免并发造成状态错乱
|
|
1331
|
+
if (state.isLoading) {
|
|
1332
|
+
state.abortController?.abort()
|
|
1333
|
+
adapter.cancel()
|
|
1334
|
+
state.isLoading = false
|
|
1335
|
+
state.abortController = null
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// 1) UI:保留该条 user 消息,并更新文本;删除其后的所有消息(分叉)
|
|
1339
|
+
const updatedUserMsg: ChatMessage = {
|
|
1340
|
+
...targetMsg,
|
|
1341
|
+
parts: [{ type: 'text', text }],
|
|
1342
|
+
}
|
|
1343
|
+
state.messages = [...state.messages.slice(0, index), updatedUserMsg]
|
|
1344
|
+
forceUpdate()
|
|
1345
|
+
|
|
1346
|
+
// 2) DB:删除该 user 消息之后的所有消息(用于分叉)
|
|
1347
|
+
// 新架构:只使用 messageId 作为锚点(更稳定),后端用 messageId 查 timestamp 再删除
|
|
1348
|
+
try {
|
|
1349
|
+
await adapter.deleteMessagesAfterMessageId(sessionId, updatedUserMsg.id)
|
|
1350
|
+
} catch (error) {
|
|
1351
|
+
console.error('[useChat/react] deleteMessagesAfterMessageId 失败:', error)
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// 3) DB:更新该条 user 消息内容(需要保证 saveMessage 使用同一个 id)
|
|
1355
|
+
try {
|
|
1356
|
+
await adapter.updateMessage?.({
|
|
1357
|
+
id: updatedUserMsg.id,
|
|
1358
|
+
content: text,
|
|
1359
|
+
})
|
|
1360
|
+
} catch (error) {
|
|
1361
|
+
console.warn('[useChat/react] updateMessage(user) 失败,已忽略:', error)
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// 4) 从该 user 消息继续生成 assistant(不再新建 user 消息)
|
|
1365
|
+
const assistantMsgIndex = state.messages.length
|
|
1366
|
+
const assistantMsgId = generateId()
|
|
1367
|
+
const assistantMsg: ChatMessage = {
|
|
1368
|
+
id: assistantMsgId,
|
|
1369
|
+
role: 'assistant',
|
|
1370
|
+
parts: [],
|
|
1371
|
+
model: modelRef.current,
|
|
1372
|
+
mode: modeRef.current,
|
|
1373
|
+
webSearchEnabled: webSearchRef.current,
|
|
1374
|
+
thinkingEnabled: thinkingRef.current,
|
|
1375
|
+
loading: true,
|
|
1376
|
+
timestamp: Date.now(),
|
|
992
1377
|
}
|
|
993
|
-
|
|
1378
|
+
state.messages = [...state.messages, assistantMsg]
|
|
1379
|
+
|
|
1380
|
+
const requestAbortController = new AbortController()
|
|
1381
|
+
state.isLoading = true
|
|
1382
|
+
state.abortController = requestAbortController
|
|
1383
|
+
state.activeAssistantMessageId = assistantMsgId
|
|
1384
|
+
forceUpdate()
|
|
994
1385
|
|
|
995
|
-
|
|
996
|
-
|
|
1386
|
+
// 保存当前配置快照
|
|
1387
|
+
const sendModel = modelRef.current
|
|
1388
|
+
const sendMode = modeRef.current
|
|
1389
|
+
const sendWebSearch = webSearchRef.current
|
|
1390
|
+
const sendThinking = thinkingRef.current
|
|
1391
|
+
const sendEnabledTools = enabledToolsRef.current
|
|
1392
|
+
|
|
1393
|
+
// 立即保存助手消息到数据库(初始状态)
|
|
1394
|
+
try {
|
|
1395
|
+
await adapter.saveMessage({
|
|
1396
|
+
id: assistantMsgId,
|
|
1397
|
+
sessionId,
|
|
1398
|
+
role: 'assistant',
|
|
1399
|
+
content: '',
|
|
1400
|
+
model: sendModel,
|
|
1401
|
+
mode: sendMode,
|
|
1402
|
+
webSearchEnabled: sendWebSearch,
|
|
1403
|
+
thinkingEnabled: sendThinking,
|
|
1404
|
+
steps: '[]',
|
|
1405
|
+
})
|
|
1406
|
+
} catch (error) {
|
|
1407
|
+
console.error('[useChat/react] 创建 assistant 消息失败:', error)
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// 使用统一的事件处理函数
|
|
1411
|
+
|
|
1412
|
+
try {
|
|
1413
|
+
// history:取该 user 消息之前的消息(不包含当前 user)
|
|
1414
|
+
const history = state.messages.slice(0, index).map(msg => ({
|
|
1415
|
+
role: msg.role as 'user' | 'assistant' | 'system' | 'tool',
|
|
1416
|
+
content: stripAtContextLines(extractTextContent(msg.parts)),
|
|
1417
|
+
}))
|
|
1418
|
+
|
|
1419
|
+
const cleanAutoRunConfig: AutoRunConfig = { ...autoRunConfigRef.current }
|
|
1420
|
+
const images = updatedUserMsg.images
|
|
1421
|
+
const promptMessage = await buildPromptWithAtContext(adapter, text)
|
|
1422
|
+
|
|
1423
|
+
for await (const event of adapter.sendMessage(
|
|
1424
|
+
promptMessage,
|
|
1425
|
+
{
|
|
1426
|
+
mode: sendMode,
|
|
1427
|
+
model: sendModel,
|
|
1428
|
+
enableWebSearch: sendWebSearch,
|
|
1429
|
+
thinkingMode: sendThinking ? 'enabled' : 'disabled',
|
|
1430
|
+
enabledTools: sendEnabledTools,
|
|
1431
|
+
autoRunConfig: cleanAutoRunConfig,
|
|
1432
|
+
history,
|
|
1433
|
+
},
|
|
1434
|
+
images,
|
|
1435
|
+
sessionId
|
|
1436
|
+
)) {
|
|
1437
|
+
if (requestAbortController.signal.aborted) break
|
|
1438
|
+
|
|
1439
|
+
// 统一处理事件:更新UI并保存到数据库
|
|
1440
|
+
await handleEvent(sessionId, assistantMsgIndex, event, assistantMsgId, {
|
|
1441
|
+
saveToDb: true, // 重要事件需要保存
|
|
1442
|
+
})
|
|
1443
|
+
|
|
1444
|
+
if (event.type === 'done' || event.type === 'error') break
|
|
1445
|
+
}
|
|
1446
|
+
} catch (error) {
|
|
1447
|
+
console.error('[useChat/react] 分叉重发失败:', error)
|
|
1448
|
+
await handleEvent(
|
|
1449
|
+
sessionId,
|
|
1450
|
+
assistantMsgIndex,
|
|
1451
|
+
{
|
|
1452
|
+
type: 'error',
|
|
1453
|
+
data: { message: error instanceof Error ? error.message : String(error) },
|
|
1454
|
+
},
|
|
1455
|
+
assistantMsgId,
|
|
1456
|
+
{ saveToDb: true }
|
|
1457
|
+
)
|
|
1458
|
+
} finally {
|
|
1459
|
+
// 收尾:取消 loading 状态
|
|
1460
|
+
const s = sessionStatesRef.current.get(sessionId)
|
|
1461
|
+
if (s) {
|
|
1462
|
+
s.isLoading = false
|
|
1463
|
+
s.abortController = null
|
|
1464
|
+
s.activeAssistantMessageId = null
|
|
1465
|
+
const finalMsg = s.messages[assistantMsgIndex]
|
|
1466
|
+
if (finalMsg) {
|
|
1467
|
+
s.messages[assistantMsgIndex] = { ...finalMsg, loading: false }
|
|
1468
|
+
}
|
|
1469
|
+
forceUpdate()
|
|
1470
|
+
}
|
|
1471
|
+
// 【关键】最终保存一次,确保所有内容都被持久化
|
|
1472
|
+
await saveMessageToDb(sessionId, assistantMsgIndex, assistantMsgId)
|
|
1473
|
+
}
|
|
1474
|
+
}, [adapter, forceUpdate, sendMessage, handleEvent, saveMessageToDb])
|
|
1475
|
+
|
|
1476
|
+
const regenerateMessage = useCallback((messageIndex: number) => {
|
|
997
1477
|
if (!currentSessionIdRef.current) return
|
|
998
1478
|
const state = sessionStatesRef.current.get(currentSessionIdRef.current)
|
|
999
1479
|
if (!state) return
|
|
1000
1480
|
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1481
|
+
if (messageIndex > 0 && state.messages[messageIndex - 1]?.role === 'user') {
|
|
1482
|
+
const userIndex = messageIndex - 1
|
|
1483
|
+
const userMsg = state.messages[userIndex]
|
|
1484
|
+
const userText = extractTextContent(userMsg.parts)
|
|
1485
|
+
void resendFromIndex(userIndex, userText)
|
|
1486
|
+
}
|
|
1487
|
+
}, [resendFromIndex])
|
|
1005
1488
|
|
|
1006
1489
|
const setWorkingDirectory = useCallback((dir: string) => {
|
|
1007
1490
|
if (adapter.setCwd) {
|
|
@@ -1077,5 +1560,10 @@ export function useChat(options: UseChatOptions) {
|
|
|
1077
1560
|
autoRunConfig,
|
|
1078
1561
|
loadAutoRunConfig,
|
|
1079
1562
|
saveAutoRunConfig,
|
|
1563
|
+
|
|
1564
|
+
// 工具管理
|
|
1565
|
+
enabledTools,
|
|
1566
|
+
allTools,
|
|
1567
|
+
saveEnabledTools,
|
|
1080
1568
|
}
|
|
1081
1569
|
}
|