@huyooo/ai-chat-frontend-react 0.1.2

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.
@@ -0,0 +1,464 @@
1
+ /**
2
+ * useChat Hook
3
+ * 管理聊天状态和与后端的通信
4
+ * 使用 Adapter 模式解耦后端通信
5
+ *
6
+ * 与 Vue 版本 useChat composable 保持一致
7
+ */
8
+
9
+ import { useState, useCallback, useRef } from 'react'
10
+ import type { ChatAdapter, ChatProgress } from '../adapter'
11
+ import { createNullAdapter } from '../adapter'
12
+ import type {
13
+ ChatMessage,
14
+ ChatMode,
15
+ SessionRecord,
16
+ MessageRecord,
17
+ SearchResult,
18
+ ToolCall,
19
+ } from '../types'
20
+
21
+ /** 生成唯一 ID */
22
+ function generateId(): string {
23
+ return Date.now().toString(36) + Math.random().toString(36).substr(2)
24
+ }
25
+
26
+ /** 转换存储的消息为显示格式 */
27
+ function convertToMessage(record: MessageRecord): ChatMessage {
28
+ return {
29
+ id: record.id,
30
+ role: record.role,
31
+ content: record.content,
32
+ thinking: record.thinking || undefined,
33
+ thinkingComplete: true,
34
+ toolCalls: record.toolCalls ? JSON.parse(record.toolCalls) : undefined,
35
+ searchResults: record.searchResults ? JSON.parse(record.searchResults) : undefined,
36
+ searching: false,
37
+ timestamp: record.timestamp,
38
+ }
39
+ }
40
+
41
+ export interface UseChatOptions {
42
+ /** Adapter 实例 */
43
+ adapter?: ChatAdapter
44
+ /** 默认模型 */
45
+ defaultModel?: string
46
+ /** 默认模式 */
47
+ defaultMode?: ChatMode
48
+ }
49
+
50
+ /**
51
+ * 聊天状态管理 Hook
52
+ */
53
+ export function useChat(options: UseChatOptions = {}) {
54
+ const {
55
+ adapter = createNullAdapter(),
56
+ defaultModel = 'anthropic/claude-opus-4.5',
57
+ defaultMode = 'agent',
58
+ } = options
59
+
60
+ // 会话状态
61
+ const [sessions, setSessions] = useState<SessionRecord[]>([])
62
+ const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)
63
+ const [messages, setMessages] = useState<ChatMessage[]>([])
64
+
65
+ // 配置状态
66
+ const [mode, setModeState] = useState<ChatMode>(defaultMode)
67
+ const [model, setModelState] = useState(defaultModel)
68
+ const [webSearch, setWebSearchState] = useState(true)
69
+ const [thinking, setThinkingState] = useState(true)
70
+
71
+ // 加载状态
72
+ const [isLoading, setIsLoading] = useState(false)
73
+
74
+ // 取消控制器
75
+ const abortControllerRef = useRef<AbortController | null>(null)
76
+
77
+ // 用于在回调中访问最新状态
78
+ const sessionsRef = useRef(sessions)
79
+ const messagesRef = useRef(messages)
80
+ const currentSessionIdRef = useRef(currentSessionId)
81
+ const modeRef = useRef(mode)
82
+ const modelRef = useRef(model)
83
+ const webSearchRef = useRef(webSearch)
84
+ const thinkingRef = useRef(thinking)
85
+
86
+ // 同步 ref
87
+ sessionsRef.current = sessions
88
+ messagesRef.current = messages
89
+ currentSessionIdRef.current = currentSessionId
90
+ modeRef.current = mode
91
+ modelRef.current = model
92
+ webSearchRef.current = webSearch
93
+ thinkingRef.current = thinking
94
+
95
+ /** 加载会话列表 */
96
+ const loadSessions = useCallback(async () => {
97
+ try {
98
+ const list = await adapter.getSessions()
99
+ setSessions(list)
100
+ // 如果有会话且没有当前会话,选择最新的
101
+ if (list.length > 0 && !currentSessionIdRef.current) {
102
+ const firstSession = list[0]
103
+ setCurrentSessionId(firstSession.id)
104
+ const savedMessages = await adapter.getMessages(firstSession.id)
105
+ setMessages(savedMessages.map(convertToMessage))
106
+ setModeState(firstSession.mode)
107
+ setModelState(firstSession.model)
108
+ }
109
+ } catch (error) {
110
+ console.error('加载会话失败:', error)
111
+ }
112
+ }, [adapter])
113
+
114
+ /** 切换会话 */
115
+ const switchSession = useCallback(async (sessionId: string) => {
116
+ if (currentSessionIdRef.current === sessionId) return
117
+
118
+ setCurrentSessionId(sessionId)
119
+
120
+ try {
121
+ const savedMessages = await adapter.getMessages(sessionId)
122
+ setMessages(savedMessages.map(convertToMessage))
123
+
124
+ // 更新配置
125
+ const session = sessionsRef.current.find((s) => s.id === sessionId)
126
+ if (session) {
127
+ setModeState(session.mode)
128
+ setModelState(session.model)
129
+ }
130
+ } catch (error) {
131
+ console.error('加载消息失败:', error)
132
+ setMessages([])
133
+ }
134
+ }, [adapter])
135
+
136
+ /** 创建新会话 */
137
+ const createNewSession = useCallback(async () => {
138
+ try {
139
+ const session = await adapter.createSession({
140
+ title: '新对话',
141
+ model: modelRef.current,
142
+ mode: modeRef.current,
143
+ })
144
+ setSessions(prev => [session, ...prev])
145
+ setCurrentSessionId(session.id)
146
+ setMessages([])
147
+ } catch (error) {
148
+ console.error('创建会话失败:', error)
149
+ }
150
+ }, [adapter])
151
+
152
+ /** 删除会话 */
153
+ const deleteSession = useCallback(async (sessionId: string) => {
154
+ try {
155
+ await adapter.deleteSession(sessionId)
156
+ setSessions(prev => prev.filter((s) => s.id !== sessionId))
157
+
158
+ // 如果删除的是当前会话,切换到下一个
159
+ if (currentSessionIdRef.current === sessionId) {
160
+ const remaining = sessionsRef.current.filter((s) => s.id !== sessionId)
161
+ if (remaining.length > 0) {
162
+ await switchSession(remaining[0].id)
163
+ } else {
164
+ setCurrentSessionId(null)
165
+ setMessages([])
166
+ }
167
+ }
168
+ } catch (error) {
169
+ console.error('删除会话失败:', error)
170
+ }
171
+ }, [adapter, switchSession])
172
+
173
+ /** 删除当前会话 */
174
+ const deleteCurrentSession = useCallback(async () => {
175
+ if (currentSessionIdRef.current) {
176
+ await deleteSession(currentSessionIdRef.current)
177
+ }
178
+ }, [deleteSession])
179
+
180
+ /** 更新消息 */
181
+ const updateMessage = useCallback((index: number, progress: ChatProgress) => {
182
+ setMessages(prev => {
183
+ const newMessages = [...prev]
184
+ const msg = { ...newMessages[index] }
185
+ if (!msg) return prev
186
+
187
+ switch (progress.type) {
188
+ case 'thinking': {
189
+ const thinkingData = progress.data as { content: string; isComplete: boolean }
190
+ if (thinkingData.content) {
191
+ msg.thinking = (msg.thinking || '') + thinkingData.content
192
+ }
193
+ msg.thinkingComplete = thinkingData.isComplete
194
+ break
195
+ }
196
+
197
+ case 'search_start':
198
+ msg.searching = true
199
+ break
200
+
201
+ case 'search_result': {
202
+ msg.searching = false
203
+ const searchData = progress.data as { results: SearchResult[] }
204
+ msg.searchResults = searchData.results || []
205
+ break
206
+ }
207
+
208
+ case 'tool_call': {
209
+ const toolData = progress.data as { name: string; args: Record<string, unknown> }
210
+ if (!msg.toolCalls) msg.toolCalls = []
211
+ msg.toolCalls = [...msg.toolCalls, {
212
+ name: toolData.name,
213
+ args: toolData.args,
214
+ status: 'running' as const,
215
+ }]
216
+ break
217
+ }
218
+
219
+ case 'tool_result': {
220
+ const resultData = progress.data as { name: string; result: string }
221
+ if (msg.toolCalls) {
222
+ msg.toolCalls = msg.toolCalls.map((t: ToolCall) => {
223
+ if (t.name === resultData.name && t.status === 'running') {
224
+ return { ...t, result: resultData.result, status: 'success' as const }
225
+ }
226
+ return t
227
+ })
228
+ }
229
+ break
230
+ }
231
+
232
+ case 'text_delta':
233
+ msg.content = (msg.content || '') + (progress.data as string)
234
+ break
235
+
236
+ case 'text':
237
+ if (!msg.content) {
238
+ msg.content = progress.data as string
239
+ }
240
+ break
241
+
242
+ case 'error':
243
+ msg.content = (msg.content || '') + `\n\n❌ 错误: ${progress.data}`
244
+ break
245
+ }
246
+
247
+ newMessages[index] = msg
248
+ return newMessages
249
+ })
250
+ }, [])
251
+
252
+ /** 发送消息 */
253
+ const sendMessage = useCallback(async (text: string, images?: string[]) => {
254
+ if (!text.trim() || isLoading) return
255
+
256
+ // 如果没有当前会话,先创建
257
+ let sessionId = currentSessionIdRef.current
258
+ if (!sessionId) {
259
+ try {
260
+ const session = await adapter.createSession({
261
+ title: '新对话',
262
+ model: modelRef.current,
263
+ mode: modeRef.current,
264
+ })
265
+ setSessions(prev => [session, ...prev])
266
+ setCurrentSessionId(session.id)
267
+ sessionId = session.id
268
+ } catch (error) {
269
+ console.error('创建会话失败:', error)
270
+ return
271
+ }
272
+ }
273
+
274
+ // 添加用户消息
275
+ const userMsg: ChatMessage = {
276
+ id: generateId(),
277
+ role: 'user',
278
+ content: text,
279
+ images,
280
+ timestamp: new Date(),
281
+ }
282
+
283
+ const currentMessages = messagesRef.current
284
+ setMessages([...currentMessages, userMsg])
285
+
286
+ // 保存用户消息
287
+ try {
288
+ await adapter.saveMessage({
289
+ sessionId,
290
+ role: 'user',
291
+ content: text,
292
+ })
293
+
294
+ // 更新会话标题(如果是第一条消息)
295
+ if (currentMessages.length === 0) {
296
+ const title = text.slice(0, 20) + (text.length > 20 ? '...' : '')
297
+ await adapter.updateSession(sessionId, { title })
298
+ setSessions(prev => prev.map((s) =>
299
+ s.id === sessionId ? { ...s, title } : s
300
+ ))
301
+ }
302
+ } catch (error) {
303
+ console.error('保存消息失败:', error)
304
+ }
305
+
306
+ // 创建助手消息
307
+ const assistantMsgIndex = currentMessages.length + 1
308
+ const assistantMsg: ChatMessage = {
309
+ id: generateId(),
310
+ role: 'assistant',
311
+ content: '',
312
+ toolCalls: [],
313
+ thinkingComplete: false,
314
+ searching: false,
315
+ loading: true,
316
+ timestamp: new Date(),
317
+ }
318
+ setMessages(prev => [...prev, assistantMsg])
319
+
320
+ setIsLoading(true)
321
+ abortControllerRef.current = new AbortController()
322
+
323
+ try {
324
+ // 使用异步迭代器接收消息流
325
+ for await (const progress of adapter.sendMessage(
326
+ text,
327
+ {
328
+ mode: modeRef.current,
329
+ model: modelRef.current,
330
+ enableWebSearch: webSearchRef.current,
331
+ thinkingMode: thinkingRef.current ? 'enabled' : 'disabled',
332
+ },
333
+ images
334
+ )) {
335
+ // 检查是否被取消
336
+ if (abortControllerRef.current?.signal.aborted) break
337
+
338
+ updateMessage(assistantMsgIndex, progress)
339
+
340
+ if (progress.type === 'done' || progress.type === 'error') {
341
+ break
342
+ }
343
+ }
344
+ } catch (error) {
345
+ console.error('发送消息失败:', error)
346
+ updateMessage(assistantMsgIndex, {
347
+ type: 'error',
348
+ data: error instanceof Error ? error.message : String(error),
349
+ })
350
+ } finally {
351
+ setIsLoading(false)
352
+
353
+ // 标记加载完成并保存
354
+ setMessages(prev => {
355
+ const newMessages = [...prev]
356
+ const finalMsg = newMessages[assistantMsgIndex]
357
+ if (finalMsg) {
358
+ newMessages[assistantMsgIndex] = { ...finalMsg, loading: false }
359
+
360
+ // 保存助手消息
361
+ if (sessionId) {
362
+ adapter.saveMessage({
363
+ sessionId,
364
+ role: 'assistant',
365
+ content: finalMsg.content,
366
+ thinking: finalMsg.thinking,
367
+ toolCalls: finalMsg.toolCalls ? JSON.stringify(finalMsg.toolCalls) : undefined,
368
+ searchResults: finalMsg.searchResults
369
+ ? JSON.stringify(finalMsg.searchResults)
370
+ : undefined,
371
+ }).catch((e: Error) => console.error('保存助手消息失败:', e))
372
+ }
373
+ }
374
+ return newMessages
375
+ })
376
+
377
+ abortControllerRef.current = null
378
+ }
379
+ }, [adapter, isLoading, updateMessage])
380
+
381
+ /** 取消请求 */
382
+ const cancelRequest = useCallback(() => {
383
+ adapter.cancel()
384
+ abortControllerRef.current?.abort()
385
+ setIsLoading(false)
386
+ }, [adapter])
387
+
388
+ /** 复制消息 */
389
+ const copyMessage = useCallback(async (messageId: string) => {
390
+ const msg = messagesRef.current.find((m) => m.id === messageId)
391
+ if (!msg) return
392
+
393
+ try {
394
+ await navigator.clipboard.writeText(msg.content)
395
+ setMessages(prev => prev.map((m) =>
396
+ m.id === messageId ? { ...m, copied: true } : m
397
+ ))
398
+ setTimeout(() => {
399
+ setMessages(prev => prev.map((m) =>
400
+ m.id === messageId ? { ...m, copied: false } : m
401
+ ))
402
+ }, 2000)
403
+ } catch (err) {
404
+ console.error('复制失败:', err)
405
+ }
406
+ }, [])
407
+
408
+ /** 重新生成消息 */
409
+ const regenerateMessage = useCallback((messageIndex: number) => {
410
+ const currentMsgs = messagesRef.current
411
+ if (messageIndex > 0 && currentMsgs[messageIndex - 1]?.role === 'user') {
412
+ const userMsg = currentMsgs[messageIndex - 1]
413
+ setMessages(prev => prev.slice(0, messageIndex - 1))
414
+ sendMessage(userMsg.content, userMsg.images)
415
+ }
416
+ }, [sendMessage])
417
+
418
+ /** 设置工作目录 */
419
+ const setWorkingDirectory = useCallback((dir: string) => {
420
+ if (adapter.setWorkingDir) {
421
+ adapter.setWorkingDir(dir)
422
+ }
423
+ }, [adapter])
424
+
425
+ // 配置方法
426
+ const setMode = useCallback((value: ChatMode) => setModeState(value), [])
427
+ const setModel = useCallback((value: string) => setModelState(value), [])
428
+ const setWebSearch = useCallback((value: boolean) => setWebSearchState(value), [])
429
+ const setThinking = useCallback((value: boolean) => setThinkingState(value), [])
430
+
431
+ return {
432
+ // 状态
433
+ sessions,
434
+ currentSessionId,
435
+ messages,
436
+ isLoading,
437
+ mode,
438
+ model,
439
+ webSearch,
440
+ thinking,
441
+
442
+ // 会话方法
443
+ loadSessions,
444
+ switchSession,
445
+ createNewSession,
446
+ deleteSession,
447
+ deleteCurrentSession,
448
+
449
+ // 消息方法
450
+ sendMessage,
451
+ cancelRequest,
452
+ copyMessage,
453
+ regenerateMessage,
454
+
455
+ // 配置方法
456
+ setMode,
457
+ setModel,
458
+ setWebSearch,
459
+ setThinking,
460
+
461
+ // 工具方法
462
+ setWorkingDirectory,
463
+ }
464
+ }
package/src/index.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @huyooo/ai-chat-frontend-react
3
+ *
4
+ * AI Chat 前端组件库 - React 版本
5
+ *
6
+ * 使用 adapter 模式,与后端通信方式解耦
7
+ * 与 Vue 版本 (@huyooo/ai-chat-frontend-vue) 保持一致的 API
8
+ */
9
+
10
+ // 导出 Adapter 接口和类型
11
+ export type {
12
+ ChatAdapter,
13
+ ChatProgress,
14
+ ChatProgressType,
15
+ ThinkingData,
16
+ ToolCallData,
17
+ ToolResultData,
18
+ ImageData,
19
+ SendMessageOptions,
20
+ CreateSessionOptions,
21
+ UpdateSessionOptions,
22
+ SaveMessageOptions,
23
+ } from './adapter'
24
+ export { createNullAdapter } from './adapter'
25
+
26
+ // 导出 hooks
27
+ export { useChat } from './hooks/useChat'
28
+ export type { UseChatOptions } from './hooks/useChat'
29
+
30
+ // 导出主组件
31
+ export { ChatPanel } from './components/ChatPanel'
32
+
33
+ // 导出输入组件
34
+ export { ChatInput } from './components/ChatInput'
35
+
36
+ // 导出 Header 组件
37
+ export { ChatHeader } from './components/chat/ui/ChatHeader'
38
+
39
+ // 导出欢迎消息组件
40
+ export { WelcomeMessage } from './components/chat/ui/WelcomeMessage'
41
+
42
+ // 导出消息组件
43
+ export { MessageBubble } from './components/chat/messages/MessageBubble'
44
+ export { ExecutionSteps } from './components/chat/messages/ExecutionSteps'
45
+
46
+ // 导出类型
47
+ export type {
48
+ ChatMessage,
49
+ ChatMode,
50
+ ModelConfig,
51
+ ModelProvider,
52
+ ThinkingMode,
53
+ SessionRecord,
54
+ MessageRecord,
55
+ SearchResult,
56
+ ToolCall,
57
+ // 向后兼容
58
+ ChatSession,
59
+ MediaOperation,
60
+ AiModel,
61
+ DiffStat,
62
+ } from './types'
63
+ export { DEFAULT_MODELS, FileType } from './types'
64
+
65
+ /**
66
+ * 使用说明:
67
+ *
68
+ * 1. 导入样式:
69
+ * import '@huyooo/ai-chat-frontend-react/style.css'
70
+ *
71
+ * 2. 创建 adapter(使用桥接包):
72
+ * import { createElectronAdapter } from '@huyooo/ai-chat-bridge-electron/renderer'
73
+ * const adapter = createElectronAdapter()
74
+ *
75
+ * 3. 在 React 组件中使用:
76
+ * <ChatPanel adapter={adapter} />
77
+ *
78
+ * 4. 或使用 useChat hook:
79
+ * const chat = useChat({ adapter })
80
+ * // 然后自定义 UI
81
+ */