@liujitcn/kratos-taro-app-system 0.0.1

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,945 @@
1
+ import { Button, ScrollView, Text, View } from '@tarojs/components'
2
+ import Taro, { getCurrentPages, useLoad } from '@tarojs/taro'
3
+ import { useEffect, useMemo, useRef, useState } from 'react'
4
+ import {
5
+ formatSrc,
6
+ navigateAppRoute,
7
+ uploadFile,
8
+ } from '@liujitcn/kratos-taro-app-core'
9
+ import { ArrowLeft, Category } from '@liujitcn/kratos-taro-app-ui'
10
+ import { defAiMessageService, StreamAiMessageByChunkedRequest } from '../../../api/base/ai_message'
11
+ import { defAiSessionService } from '../../../api/base/ai_session'
12
+ import { defAiToolService } from '../../../api/base/ai_tool'
13
+ import type { AiAttachment, AiMessage, AiSession } from '../../../rpc/base/v1/ai_session'
14
+ import type { AiShortcut, AiToolCall } from '../../../rpc/base/v1/ai_tool'
15
+ import { AiMessageStatus, Terminal } from '../../../rpc/common/v1/enum'
16
+ import Composer from './components/Composer'
17
+ import SessionDrawer from './components/SessionDrawer'
18
+ import WelcomePanel from './components/WelcomePanel'
19
+ import {
20
+ type AiStreamEvent,
21
+ type AiStreamPayload,
22
+ createAiEventStreamTextParser,
23
+ parseAiEventStreamText,
24
+ readAiEventStream,
25
+ } from './stream'
26
+ import './index.scss'
27
+
28
+ type ChatRole = 'user' | 'ai'
29
+
30
+ type ChatMessageItem = AiMessage & {
31
+ key: string
32
+ messageID: string
33
+ role: ChatRole
34
+ content: string
35
+ status: AiMessageStatus
36
+ tools: AiToolCall[]
37
+ model: string
38
+ replySource: string
39
+ fallback: boolean
40
+ fallbackReason: string
41
+ tokenTotal: number
42
+ firstTokenMs: number
43
+ durationMs: number
44
+ localOnly?: boolean
45
+ streamKey?: string
46
+ }
47
+
48
+ type AttachmentUpload = { path: string; name: string; size: number }
49
+ type StreamTask = { abort: () => void; aborted: boolean; finished: boolean; success?: boolean }
50
+ type MessageMap = Record<string, ChatMessageItem[]>
51
+
52
+ const AI_TERMINAL = Terminal.TERMINAL_APP
53
+ const THINKING_MESSAGE_CONTENT = '正在回复'
54
+ const LOCAL_USER_MESSAGE_PREFIX = 'ai-user-local'
55
+ const PENDING_MESSAGE_ID = 'pending'
56
+ const MAX_ATTACHMENT_COUNT = 6
57
+ const STARTER_PROMPT_PAGE_SIZE = 4
58
+
59
+ const DEFAULT_SHORTCUTS: AiShortcut[] = [
60
+ { key: 'summarize', title: '帮我总结一段内容', prompt: '请帮我总结以下内容', action: undefined, required_tools: [], sort: 1, group: '文本助手' },
61
+ { key: 'rewrite', title: '帮我优化一段文字', prompt: '请帮我优化以下文字', action: undefined, required_tools: [], sort: 2, group: '文本助手' },
62
+ { key: 'plan', title: '帮我制定一个计划', prompt: '请帮我制定一个清晰的执行计划', action: undefined, required_tools: [], sort: 3, group: '效率助手' },
63
+ { key: 'ideas', title: '给我一些灵感', prompt: '请围绕这个主题给我一些新想法', action: undefined, required_tools: [], sort: 4, group: '效率助手' },
64
+ ]
65
+
66
+ /** AI 助手页面。 */
67
+ export default function AiPage() {
68
+ const windowInfo = Taro.getWindowInfo() as ReturnType<typeof Taro.getWindowInfo> & {
69
+ safeAreaInsets?: { top?: number; bottom?: number }
70
+ }
71
+ let safeAreaTop = windowInfo.safeArea?.top || windowInfo.statusBarHeight || windowInfo.safeAreaInsets?.top || 0
72
+ if (process.env.TARO_ENV === 'weapp') safeAreaTop ||= 44
73
+ const safeAreaBottom = Math.max(windowInfo.safeAreaInsets?.bottom || 0, 9)
74
+ const composerBottom = `${safeAreaBottom}px`
75
+ const drawerTopPadding = `${safeAreaTop + 12}px`
76
+
77
+ const [showSessionDrawer, setShowSessionDrawer] = useState(false)
78
+ const [activeSessionID, setActiveSessionIDState] = useState('')
79
+ const [inputText, setInputText] = useState('')
80
+ const [isRecording, setIsRecording] = useState(false)
81
+ const [starterPromptGroupIndex, setStarterPromptGroupIndex] = useState(0)
82
+ const [sessionKeyword, setSessionKeyword] = useState('')
83
+ const [loadingSessions, setLoadingSessionsState] = useState(false)
84
+ const [loadingShortcuts, setLoadingShortcutsState] = useState(false)
85
+ const [loadingSessionID, setLoadingSessionIDState] = useState('')
86
+ const [uploadingAttachment, setUploadingAttachment] = useState(false)
87
+ const [sendingSessionMap, setSendingSessionMap] = useState<Record<string, boolean>>({})
88
+ const [chatBottomAnchor, setChatBottomAnchor] = useState('')
89
+ const [sessions, setSessionsState] = useState<AiSession[]>([])
90
+ const [messages, setMessagesState] = useState<MessageMap>({})
91
+ const [selectedAttachments, setSelectedAttachments] = useState<AiAttachment[]>([])
92
+ const [starterShortcuts, setStarterShortcuts] = useState<AiShortcut[]>(DEFAULT_SHORTCUTS)
93
+
94
+ const activeSessionIDRef = useRef('')
95
+ const sessionsRef = useRef<AiSession[]>([])
96
+ const messagesRef = useRef<MessageMap>({})
97
+ const sendingSessionMapRef = useRef<Record<string, boolean>>({})
98
+ const loadingSessionsRef = useRef(false)
99
+ const loadingShortcutsRef = useRef(false)
100
+ const loadingSessionIDRef = useRef('')
101
+ const runningStreamTaskMap = useRef(new Map<string, StreamTask>())
102
+ const pendingDeltaMap = useRef(new Map<string, AiStreamPayload>())
103
+ const pendingDeltaTimer = useRef<ReturnType<typeof setTimeout>>()
104
+ const scrollTimer = useRef<ReturnType<typeof setTimeout>>()
105
+
106
+ const filteredSessions = useMemo(() => {
107
+ const keyword = sessionKeyword.trim()
108
+ if (!keyword) return sessions
109
+ return sessions.filter((item) => item.title.includes(keyword) || item.summary.includes(keyword))
110
+ }, [sessionKeyword, sessions])
111
+ const currentMessages = messages[activeSessionID] ?? []
112
+ const hasMessages = currentMessages.length > 0
113
+ const currentSessionSending = Boolean(activeSessionID && sendingSessionMap[activeSessionID])
114
+ const starterPromptPageCount = Math.max(1, Math.ceil(starterShortcuts.length / STARTER_PROMPT_PAGE_SIZE))
115
+ const canRefreshStarterPrompts = starterShortcuts.length > STARTER_PROMPT_PAGE_SIZE
116
+ const starterPromptStart = (starterPromptGroupIndex % starterPromptPageCount) * STARTER_PROMPT_PAGE_SIZE
117
+ const starterPrompts = starterShortcuts.slice(starterPromptStart, starterPromptStart + STARTER_PROMPT_PAGE_SIZE)
118
+ const hour = new Date().getHours()
119
+ const greetingPeriod = hour < 11 ? '上午' : hour < 14 ? '中午' : hour < 18 ? '下午' : '晚上'
120
+ const aiGreetingMessage = `您好,${greetingPeriod}好!今天有什么需要我协助的吗?`
121
+ const composerPlaceholder = isRecording
122
+ ? '正在听...'
123
+ : uploadingAttachment
124
+ ? '附件上传中...'
125
+ : hasMessages
126
+ ? '继续输入问题'
127
+ : '输入你想了解的内容'
128
+ const isSubmitDisabled =
129
+ uploadingAttachment ||
130
+ currentSessionSending ||
131
+ isRecording ||
132
+ (!inputText.trim() && selectedAttachments.length === 0)
133
+
134
+ const setActiveSessionID = (sessionID: string) => {
135
+ activeSessionIDRef.current = sessionID
136
+ setActiveSessionIDState(sessionID)
137
+ }
138
+
139
+ const setSessions = (next: AiSession[] | ((current: AiSession[]) => AiSession[])) => {
140
+ const resolved = typeof next === 'function' ? next(sessionsRef.current) : next
141
+ sessionsRef.current = resolved
142
+ setSessionsState(resolved)
143
+ }
144
+
145
+ const setMessages = (next: MessageMap | ((current: MessageMap) => MessageMap)) => {
146
+ const resolved = typeof next === 'function' ? next(messagesRef.current) : next
147
+ messagesRef.current = resolved
148
+ setMessagesState(resolved)
149
+ }
150
+
151
+ const updateSessionMessages = (
152
+ sessionID: string,
153
+ next: ChatMessageItem[] | ((current: ChatMessageItem[]) => ChatMessageItem[]),
154
+ ) => {
155
+ const current = messagesRef.current[sessionID] ?? []
156
+ const resolved = typeof next === 'function' ? next(current) : next
157
+ setMessages({ ...messagesRef.current, [sessionID]: resolved })
158
+ }
159
+
160
+ const setLoadingSessions = (loading: boolean) => {
161
+ loadingSessionsRef.current = loading
162
+ setLoadingSessionsState(loading)
163
+ }
164
+
165
+ const setLoadingShortcuts = (loading: boolean) => {
166
+ loadingShortcutsRef.current = loading
167
+ setLoadingShortcutsState(loading)
168
+ }
169
+
170
+ const setLoadingSessionID = (sessionID: string) => {
171
+ loadingSessionIDRef.current = sessionID
172
+ setLoadingSessionIDState(sessionID)
173
+ }
174
+
175
+ const setSessionSending = (sessionID: string, sending: boolean) => {
176
+ if (!sessionID) return
177
+ const next = { ...sendingSessionMapRef.current, [sessionID]: sending }
178
+ sendingSessionMapRef.current = next
179
+ setSendingSessionMap(next)
180
+ }
181
+
182
+ const isSessionSending = (sessionID: string) => Boolean(sessionID && sendingSessionMapRef.current[sessionID])
183
+
184
+ const scrollChatToBottom = () => {
185
+ setChatBottomAnchor('')
186
+ if (scrollTimer.current) clearTimeout(scrollTimer.current)
187
+ scrollTimer.current = setTimeout(() => setChatBottomAnchor('chat-bottom'), 0)
188
+ }
189
+
190
+ const upsertSession = (session: AiSession) => {
191
+ setSessions([session, ...sessionsRef.current.filter((item) => item.id !== session.id)])
192
+ }
193
+
194
+ const flushAiDelta = () => {
195
+ if (pendingDeltaTimer.current) clearTimeout(pendingDeltaTimer.current)
196
+ pendingDeltaTimer.current = undefined
197
+ if (!pendingDeltaMap.current.size) return
198
+ const payloadList = Array.from(pendingDeltaMap.current.values())
199
+ pendingDeltaMap.current.clear()
200
+ for (const payload of payloadList) {
201
+ const sessionID = payload.session_id
202
+ if (!sessionID || !messagesRef.current[sessionID]) continue
203
+ updateSessionMessages(sessionID, (current) =>
204
+ appendStreamingDelta(ensureStreamingMessage(current, payload), payload),
205
+ )
206
+ scrollChatToBottom()
207
+ }
208
+ }
209
+
210
+ const queueAiDelta = (payload: AiStreamPayload) => {
211
+ const { session_id: sessionID, message_id: messageID } = payload
212
+ if (!sessionID || !messageID || !messagesRef.current[sessionID]) return
213
+ const key = buildStreamMessageKey(sessionID, messageID)
214
+ const cachedPayload = pendingDeltaMap.current.get(key)
215
+ pendingDeltaMap.current.set(key, {
216
+ ...payload,
217
+ delta: `${cachedPayload?.delta ?? ''}${payload.delta ?? ''}`,
218
+ })
219
+ if (pendingDeltaTimer.current) return
220
+ pendingDeltaTimer.current = setTimeout(flushAiDelta, 32)
221
+ }
222
+
223
+ const handleAiFinish = (payload: AiStreamPayload, task?: StreamTask) => {
224
+ const sessionID = payload.session_id
225
+ if (!sessionID) return
226
+ if (task) task.finished = true
227
+ flushAiDelta()
228
+ const nextMessages = normalizeMessageList(payload.messages)
229
+ if (task) task.success = hasSuccessfulAiMessages(nextMessages)
230
+ const current = messagesRef.current[sessionID] ?? []
231
+ const streamKey = payload.message_id ? buildStreamMessageKey(sessionID, payload.message_id) : ''
232
+ const hasLocalStreamingMessages = current.some((item) => item.localOnly && item.streamKey === streamKey)
233
+ updateSessionMessages(
234
+ sessionID,
235
+ nextMessages.length || !hasLocalStreamingMessages
236
+ ? replacePendingMessages(current, nextMessages, payload)
237
+ : current,
238
+ )
239
+ scrollChatToBottom()
240
+ if (payload.session) upsertSession(normalizeSession(payload.session))
241
+ }
242
+
243
+ const handleAiError = (payload: AiStreamPayload, task?: StreamTask) => {
244
+ const sessionID = payload.session_id
245
+ if (!sessionID) return
246
+ if (task) {
247
+ task.finished = true
248
+ task.success = false
249
+ }
250
+ flushAiDelta()
251
+ const nextMessages = normalizeMessageList(payload.messages)
252
+ updateSessionMessages(sessionID, (current) =>
253
+ nextMessages.length
254
+ ? replacePendingMessages(current, nextMessages, payload)
255
+ : markStreamingError(ensureStreamingMessage(current, payload), payload),
256
+ )
257
+ scrollChatToBottom()
258
+ }
259
+
260
+ const handleAiStreamEvent = (event: AiStreamEvent, task?: StreamTask) => {
261
+ if (event.event === 'delta') {
262
+ if (event.payload.delta) queueAiDelta(event.payload)
263
+ return
264
+ }
265
+ if (event.event === 'finish') {
266
+ handleAiFinish(event.payload, task)
267
+ return
268
+ }
269
+ handleAiError(event.payload, task)
270
+ }
271
+
272
+ const createRemoteSession = async () => {
273
+ const response = await defAiSessionService.CreateAiSession({ title: '新会话', terminal: AI_TERMINAL })
274
+ const session = response.session ? normalizeSession(response.session) : undefined
275
+ if (!session) return ''
276
+ upsertSession(session)
277
+ return session.id
278
+ }
279
+
280
+ const ensureActiveSession = async () => {
281
+ if (activeSessionIDRef.current) return activeSessionIDRef.current
282
+ if (sessionsRef.current.length > 0) {
283
+ setActiveSessionID(sessionsRef.current[0].id)
284
+ return sessionsRef.current[0].id
285
+ }
286
+ const sessionID = await createRemoteSession()
287
+ if (sessionID) {
288
+ setActiveSessionID(sessionID)
289
+ updateSessionMessages(sessionID, [])
290
+ }
291
+ return sessionID
292
+ }
293
+
294
+ const loadMessages = async (sessionID: string) => {
295
+ if (!sessionID) return
296
+ setLoadingSessionID(sessionID)
297
+ try {
298
+ const response = await defAiSessionService.ListAiMessage({ session_id: sessionID })
299
+ if (loadingSessionIDRef.current !== sessionID) return
300
+ updateSessionMessages(sessionID, normalizeMessageList(response.messages))
301
+ if (activeSessionIDRef.current === sessionID) scrollChatToBottom()
302
+ } catch (error) {
303
+ if (loadingSessionIDRef.current === sessionID) updateSessionMessages(sessionID, [])
304
+ showError(error, '加载消息失败')
305
+ } finally {
306
+ if (loadingSessionIDRef.current === sessionID) setLoadingSessionID('')
307
+ }
308
+ }
309
+
310
+ const ensureSessionsLoaded = async () => {
311
+ if (loadingSessionsRef.current || sessionsRef.current.length > 0) return
312
+ setLoadingSessions(true)
313
+ try {
314
+ const response = await defAiSessionService.ListAiSession({ terminal: AI_TERMINAL })
315
+ setSessions(normalizeSessionList(response.sessions))
316
+ const sessionID = await ensureActiveSession()
317
+ if (sessionID) await loadMessages(sessionID)
318
+ } catch (error) {
319
+ showError(error, '加载会话失败')
320
+ } finally {
321
+ setLoadingSessions(false)
322
+ }
323
+ }
324
+
325
+ const loadAiShortcuts = async () => {
326
+ if (loadingShortcutsRef.current) return
327
+ setLoadingShortcuts(true)
328
+ try {
329
+ const response = await defAiToolService.ListAiShortcut({ terminal: AI_TERMINAL })
330
+ const shortcuts = normalizeStarterShortcuts(response.shortcuts).filter((item) => !item.action)
331
+ if (shortcuts.length) {
332
+ setStarterShortcuts(shortcuts)
333
+ setStarterPromptGroupIndex(0)
334
+ }
335
+ } catch (error) {
336
+ showError(error, '加载快捷助手失败')
337
+ } finally {
338
+ setLoadingShortcuts(false)
339
+ }
340
+ }
341
+
342
+ const runAiTask = async (
343
+ sessionID: string,
344
+ payload: { text: string; attachments: AiAttachment[] },
345
+ ) => {
346
+ let task: StreamTask | undefined
347
+ const request = { session_id: sessionID, content: payload.text, attachments: payload.attachments, action: undefined }
348
+ try {
349
+ let handledByStream = false
350
+ if (process.env.TARO_ENV === 'weapp') {
351
+ const parser = createAiEventStreamTextParser((event) => handleAiStreamEvent(event, task))
352
+ const chunkedTask = StreamAiMessageByChunkedRequest(request, { onChunk: (text) => parser.push(text) })
353
+ task = {
354
+ aborted: false,
355
+ finished: false,
356
+ abort() {
357
+ if (task) task.aborted = true
358
+ chunkedTask.abort()
359
+ },
360
+ }
361
+ runningStreamTaskMap.current.set(sessionID, task)
362
+ handledByStream = true
363
+ await chunkedTask.promise
364
+ parser.flush()
365
+ if (!task.finished && !task.aborted) throw new Error('AI 助手流式响应未完整返回')
366
+ }
367
+ if (
368
+ process.env.TARO_ENV === 'h5' &&
369
+ !handledByStream &&
370
+ typeof fetch === 'function' &&
371
+ typeof ReadableStream !== 'undefined' &&
372
+ typeof AbortController !== 'undefined'
373
+ ) {
374
+ const controller = new AbortController()
375
+ task = {
376
+ aborted: false,
377
+ finished: false,
378
+ abort() {
379
+ if (task) task.aborted = true
380
+ controller.abort()
381
+ },
382
+ }
383
+ runningStreamTaskMap.current.set(sessionID, task)
384
+ const response = await defAiMessageService.StreamAiMessage(request, { signal: controller.signal })
385
+ if (!response.body) throw new Error('AI 助手流式响应为空')
386
+ await readAiEventStream(response.body, (event) => handleAiStreamEvent(event, task), controller.signal)
387
+ if (!task.finished && !task.aborted) throw new Error('AI 助手流式响应未完整返回')
388
+ handledByStream = true
389
+ }
390
+ if (!handledByStream) {
391
+ const response = await defAiMessageService.SendAiMessage(request)
392
+ const nextMessages = normalizeNonStreamMessages(response)
393
+ if (!nextMessages.length) throw new Error('AI 助手响应为空')
394
+ updateSessionMessages(sessionID, (current) => replacePendingMessages(current, nextMessages))
395
+ scrollChatToBottom()
396
+ if (response.session) upsertSession(normalizeSession(response.session))
397
+ return hasSuccessfulAiMessages(nextMessages)
398
+ }
399
+ return Boolean(task?.success)
400
+ } catch (error) {
401
+ if (task?.aborted) return false
402
+ updateSessionMessages(sessionID, markThinkingMessageFailed)
403
+ scrollChatToBottom()
404
+ showError(error, 'AI 助手请求失败')
405
+ return false
406
+ } finally {
407
+ if (task && runningStreamTaskMap.current.get(sessionID) === task) {
408
+ runningStreamTaskMap.current.delete(sessionID)
409
+ }
410
+ setSessionSending(sessionID, false)
411
+ }
412
+ }
413
+
414
+ const sendAiPayload = async (payload: { text: string; attachments: AiAttachment[] }) => {
415
+ const sessionID = await ensureActiveSession()
416
+ if (!sessionID || isSessionSending(sessionID)) return false
417
+ updateSessionMessages(sessionID, (current) =>
418
+ sortMessages([...current, createLocalUserMessage(payload), createThinkingMessage({ sessionID })]),
419
+ )
420
+ scrollChatToBottom()
421
+ setSessionSending(sessionID, true)
422
+ return runAiTask(sessionID, payload)
423
+ }
424
+
425
+ useLoad(() => {
426
+ void loadAiShortcuts()
427
+ void ensureSessionsLoaded()
428
+ })
429
+
430
+ useEffect(() => {
431
+ const tasks = runningStreamTaskMap.current
432
+ const deltaMap = pendingDeltaMap.current
433
+ return () => {
434
+ tasks.forEach((task) => {
435
+ task.finished = true
436
+ task.abort()
437
+ })
438
+ tasks.clear()
439
+ deltaMap.clear()
440
+ if (pendingDeltaTimer.current) clearTimeout(pendingDeltaTimer.current)
441
+ if (scrollTimer.current) clearTimeout(scrollTimer.current)
442
+ activeSessionIDRef.current = ''
443
+ }
444
+ }, [])
445
+
446
+ const selectSession = (sessionID: string) => {
447
+ setActiveSessionID(sessionID)
448
+ setShowSessionDrawer(false)
449
+ if (!messagesRef.current[sessionID]?.length || !isSessionSending(sessionID)) {
450
+ void loadMessages(sessionID)
451
+ return
452
+ }
453
+ scrollChatToBottom()
454
+ }
455
+
456
+ const createSession = async () => {
457
+ try {
458
+ const sessionID = await createRemoteSession()
459
+ if (!sessionID) return
460
+ setActiveSessionID(sessionID)
461
+ updateSessionMessages(sessionID, [])
462
+ setSessionKeyword('')
463
+ setShowSessionDrawer(false)
464
+ } catch (error) {
465
+ showError(error, '创建会话失败')
466
+ }
467
+ }
468
+
469
+ const deleteSession = async (sessionID: string) => {
470
+ const session = sessionsRef.current.find((item) => item.id === sessionID)
471
+ const result = await Taro.showModal({
472
+ title: '删除会话',
473
+ content: `是否删除「${session?.title || '当前会话'}」?`,
474
+ confirmText: '删除',
475
+ confirmColor: '#cf4444',
476
+ })
477
+ if (!result.confirm) return
478
+ try {
479
+ await defAiSessionService.DeleteAiSession({ id: sessionID })
480
+ setSessions((current) => current.filter((item) => item.id !== sessionID))
481
+ const nextMessages = { ...messagesRef.current }
482
+ delete nextMessages[sessionID]
483
+ setMessages(nextMessages)
484
+ if (activeSessionIDRef.current === sessionID) {
485
+ setActiveSessionID('')
486
+ await ensureActiveSession()
487
+ }
488
+ } catch (error) {
489
+ showError(error, '删除会话失败')
490
+ }
491
+ }
492
+
493
+ const handleSessionAction = async (session: AiSession) => {
494
+ try {
495
+ const result = await Taro.showActionSheet({ itemList: ['删除会话'] })
496
+ if (result.tapIndex === 0) await deleteSession(session.id)
497
+ } catch {
498
+ // 用户取消操作无需提示。
499
+ }
500
+ }
501
+
502
+ const copyMessage = async (item: ChatMessageItem) => {
503
+ await Taro.setClipboardData({ data: item.content })
504
+ await Taro.showToast({ icon: 'none', title: '消息已复制' })
505
+ }
506
+
507
+ const deleteMessage = async (item: ChatMessageItem) => {
508
+ const sessionID = activeSessionIDRef.current
509
+ if (!sessionID) return
510
+ try {
511
+ if (!item.localOnly) {
512
+ await defAiMessageService.DeleteAiMessage({ session_id: sessionID, message_id: item.messageID })
513
+ }
514
+ updateSessionMessages(sessionID, (current) => current.filter((message) => message.messageID !== item.messageID))
515
+ } catch (error) {
516
+ showError(error, '删除消息失败')
517
+ }
518
+ }
519
+
520
+ const regenerateMessage = async (item: ChatMessageItem) => {
521
+ const sessionID = activeSessionIDRef.current
522
+ if (item.role !== 'ai' || item.localOnly || isSessionSending(sessionID)) return
523
+ setSessionSending(sessionID, true)
524
+ try {
525
+ const response = await defAiMessageService.RegenerateAiMessage({ session_id: sessionID, message_id: item.messageID })
526
+ updateSessionMessages(sessionID, normalizeMessageList(response.messages))
527
+ if (response.session) upsertSession(normalizeSession(response.session))
528
+ } catch (error) {
529
+ showError(error, '重新生成失败')
530
+ } finally {
531
+ setSessionSending(sessionID, false)
532
+ }
533
+ }
534
+
535
+ const handleMessageAction = async (item: ChatMessageItem) => {
536
+ try {
537
+ const itemList = item.role === 'ai' ? ['复制', '删除', '重新生成'] : ['复制', '删除']
538
+ const { tapIndex } = await Taro.showActionSheet({ itemList })
539
+ if (tapIndex === 0) await copyMessage(item)
540
+ else if (tapIndex === 1) await deleteMessage(item)
541
+ else await regenerateMessage(item)
542
+ } catch {
543
+ // 用户取消操作无需提示。
544
+ }
545
+ }
546
+
547
+ const navigateBack = () => {
548
+ if (getCurrentPages().length > 1) {
549
+ void Taro.navigateBack()
550
+ return
551
+ }
552
+ navigateAppRoute('app/home')
553
+ }
554
+
555
+ const handleSend = async () => {
556
+ if (isSubmitDisabled) return
557
+ const text = inputText.trim() || '请结合附件内容回答我的问题'
558
+ const attachments = [...selectedAttachments]
559
+ setInputText('')
560
+ setSelectedAttachments([])
561
+ await sendAiPayload({ text, attachments })
562
+ }
563
+
564
+ const handleAttachment = async () => {
565
+ if (uploadingAttachment || currentSessionSending) return
566
+ if (selectedAttachments.length >= MAX_ATTACHMENT_COUNT) {
567
+ await Taro.showToast({ icon: 'none', title: `最多上传 ${MAX_ATTACHMENT_COUNT} 个附件` })
568
+ return
569
+ }
570
+ try {
571
+ const result = await Taro.chooseImage({
572
+ count: MAX_ATTACHMENT_COUNT - selectedAttachments.length,
573
+ sourceType: ['album', 'camera'],
574
+ })
575
+ const files: AttachmentUpload[] = result.tempFilePaths.map((path, index) => ({
576
+ path,
577
+ name: result.tempFiles[index]?.originalFileObj?.name || `图片${index + 1}`,
578
+ size: Number(result.tempFiles[index]?.size || 0),
579
+ }))
580
+ setUploadingAttachment(true)
581
+ const uploaded = await Promise.all(files.map((file) => uploadFile('ai', file.path)))
582
+ const attachments = uploaded.map<AiAttachment>((file, index) => ({
583
+ id: file.url || `${file.name}-${index}`,
584
+ name: files[index]?.name || file.name,
585
+ size: files[index]?.size || 0,
586
+ url: file.url,
587
+ mime_type: 'image/*',
588
+ }))
589
+ setSelectedAttachments((current) => [...current, ...attachments].slice(0, MAX_ATTACHMENT_COUNT))
590
+ } catch (error) {
591
+ const message = error instanceof Error ? error.message : String(error)
592
+ if (!/cancel/i.test(message)) showError(error, '附件上传失败')
593
+ } finally {
594
+ setUploadingAttachment(false)
595
+ }
596
+ }
597
+
598
+ const previewAttachment = (attachment: AiAttachment, attachments: AiAttachment[]) => {
599
+ const current = formatSrc(attachment.url)
600
+ const urls = attachments.map((item) => formatSrc(item.url)).filter(Boolean)
601
+ if (current) void Taro.previewImage({ current, urls: urls.length ? urls : [current] })
602
+ }
603
+
604
+ return (
605
+ <View className='ai-page'>
606
+ <View className={`ai-navbar${process.env.TARO_ENV === 'weapp' ? ' is-weapp' : ''}`}>
607
+ <Button className='nav-back-button' hoverClass='none' onClick={navigateBack}>
608
+ <ArrowLeft size={24} color='#111' />
609
+ </Button>
610
+ <View className='ai-navbar__title'>AI 助手</View>
611
+ <Button className='nav-menu-button' hoverClass='none' onClick={() => setShowSessionDrawer((open) => !open)}>
612
+ <Category size={24} color='#111' />
613
+ </Button>
614
+ </View>
615
+ <ScrollView
616
+ className='ai-body'
617
+ scrollY
618
+ scrollWithAnimation
619
+ scrollIntoView={chatBottomAnchor}
620
+ showScrollbar={false}
621
+ >
622
+ {!hasMessages ? (
623
+ <WelcomePanel
624
+ greetingMessage={aiGreetingMessage}
625
+ loading={loadingSessions || loadingShortcuts}
626
+ shortcuts={starterPrompts}
627
+ canRefresh={canRefreshStarterPrompts}
628
+ onRefresh={() => setStarterPromptGroupIndex((index) => (index + 1) % starterPromptPageCount)}
629
+ onShortcutTap={(shortcut) => {
630
+ if (!currentSessionSending && !loadingSessions) {
631
+ void sendAiPayload({ text: shortcut.prompt || shortcut.title, attachments: [] })
632
+ }
633
+ }}
634
+ />
635
+ ) : (
636
+ <View className='chat-list'>
637
+ {currentMessages.map((item) => (
638
+ <View
639
+ id={item.key}
640
+ key={item.key}
641
+ className={`message-row ${item.role === 'user' ? 'is-user' : 'is-ai'}`}
642
+ >
643
+ <View
644
+ className={`bubble ${item.role === 'ai' ? 'ai-bubble' : 'user-bubble'}${item.status === AiMessageStatus.GENERATING_AAMS ? ' is-streaming' : ''}`}
645
+ onLongPress={() => void handleMessageAction(item)}
646
+ >
647
+ {item.role === 'ai' && item.model ? (
648
+ <View className='reply-meta'><Text className='reply-tag'>模型回复</Text><Text className='reply-model'>{item.model}</Text></View>
649
+ ) : null}
650
+ <View className='bubble-content'>{item.content}</View>
651
+ {item.attachments.length ? (
652
+ <View className='attachment-list'>
653
+ {item.attachments.map((attachment) => (
654
+ <View
655
+ key={attachment.id || attachment.url || attachment.name}
656
+ className='attachment-card'
657
+ onClick={() => previewAttachment(attachment, item.attachments)}
658
+ >
659
+ <View className='attachment-icon'>{isImageAttachment(attachment) ? '图' : '件'}</View>
660
+ <View className='attachment-info'>
661
+ <View className='attachment-name'>{attachment.name}</View>
662
+ <View className='attachment-meta'>{formatAttachmentMeta(attachment)}</View>
663
+ </View>
664
+ </View>
665
+ ))}
666
+ </View>
667
+ ) : null}
668
+ {item.tools.length ? <View className='tool-row'>已调用:{formatTools(item.tools)}</View> : null}
669
+ </View>
670
+ </View>
671
+ ))}
672
+ <View id='chat-bottom' className='chat-bottom' />
673
+ </View>
674
+ )}
675
+ {loadingSessionID ? <View className='loading-session'>正在加载消息...</View> : null}
676
+ </ScrollView>
677
+ <Composer
678
+ value={inputText}
679
+ attachments={selectedAttachments}
680
+ placeholder={composerPlaceholder}
681
+ bottom={composerBottom}
682
+ recording={isRecording}
683
+ sending={currentSessionSending}
684
+ disabled={isSubmitDisabled}
685
+ onChange={setInputText}
686
+ onAttach={() => void handleAttachment()}
687
+ onRecord={() => {
688
+ setIsRecording((recording) => {
689
+ void Taro.showToast({ icon: 'none', title: !recording ? '正在识别语音' : '已停止语音输入' })
690
+ return !recording
691
+ })
692
+ }}
693
+ onSend={() => void handleSend()}
694
+ onRemoveAttachment={(attachment) => setSelectedAttachments((current) => current.filter((item) => item !== attachment))}
695
+ />
696
+ <SessionDrawer
697
+ open={showSessionDrawer}
698
+ topPadding={drawerTopPadding}
699
+ keyword={sessionKeyword}
700
+ loading={loadingSessions}
701
+ sessions={filteredSessions}
702
+ activeSessionId={activeSessionID}
703
+ onClose={() => setShowSessionDrawer(false)}
704
+ onCreate={() => void createSession()}
705
+ onSelect={selectSession}
706
+ onAction={(session) => void handleSessionAction(session)}
707
+ onKeywordChange={setSessionKeyword}
708
+ />
709
+ </View>
710
+ )
711
+ }
712
+
713
+ function normalizeSession(session?: Partial<AiSession> | null): AiSession {
714
+ return {
715
+ id: String(session?.id ?? ''),
716
+ title: String(session?.title ?? '新会话'),
717
+ summary: String(session?.summary ?? ''),
718
+ updated_at: session?.updated_at,
719
+ terminal: Number(session?.terminal ?? AI_TERMINAL),
720
+ }
721
+ }
722
+
723
+ function normalizeSessionList(list?: AiSession[] | null) {
724
+ return Array.isArray(list) ? list.map(normalizeSession).filter((item) => item.id) : []
725
+ }
726
+
727
+ function normalizeStarterShortcuts(list?: AiShortcut[] | null) {
728
+ return [...(list || [])]
729
+ .filter((item) => Boolean(item?.key && (item.title || item.prompt)))
730
+ .map((item) => ({
731
+ ...item,
732
+ title: item.title || item.prompt,
733
+ prompt: item.prompt || item.title,
734
+ required_tools: Array.isArray(item.required_tools) ? item.required_tools : [],
735
+ sort: Number(item.sort || 0),
736
+ }))
737
+ .sort((left, right) => left.sort - right.sort)
738
+ }
739
+
740
+ function normalizeMessageList(list?: AiMessage[] | null) {
741
+ if (!Array.isArray(list)) return []
742
+ return sortMessages(list.filter(Boolean).flatMap((item) => [mapMessageItem(item, 'user'), mapMessageItem(item, 'ai')]))
743
+ }
744
+
745
+ function hasSuccessfulAiMessages(list: ChatMessageItem[]) {
746
+ return list.some((item) => item.status === AiMessageStatus.SUCCESS_AAMS)
747
+ }
748
+
749
+ function mapMessageItem(message: AiMessage, role: ChatRole): ChatMessageItem {
750
+ const inputContent = { kind: message.input_content?.kind || 'text', content: message.input_content?.content ?? '' }
751
+ const outputContent = {
752
+ kind: message.output_content?.kind || 'text',
753
+ content: message.output_content?.content ?? '',
754
+ reply_source: message.output_content?.reply_source ?? '',
755
+ model: message.output_content?.model ?? '',
756
+ fallback: Boolean(message.output_content?.fallback),
757
+ fallback_reason: message.output_content?.fallback_reason ?? '',
758
+ flow: message.output_content?.flow ?? '',
759
+ step: message.output_content?.step ?? '',
760
+ blocks_json: message.output_content?.blocks_json ?? '',
761
+ }
762
+ const status = Number(message.status ?? AiMessageStatus.SUCCESS_AAMS)
763
+ return {
764
+ ...message,
765
+ key: `${message.id}:${role}`,
766
+ messageID: message.id,
767
+ role,
768
+ content: role === 'user' ? inputContent.content : outputContent.content,
769
+ input_content: inputContent,
770
+ output_content: outputContent,
771
+ attachments: Array.isArray(message.attachments) ? message.attachments : [],
772
+ status,
773
+ token: {
774
+ input: Number(message.token?.input ?? 0),
775
+ output: Number(message.token?.output ?? 0),
776
+ cache: Number(message.token?.cache ?? 0),
777
+ total: Number(message.token?.total ?? 0),
778
+ },
779
+ tools: Array.isArray(message.tools) ? message.tools : [],
780
+ model: role === 'ai' ? outputContent.model : '',
781
+ replySource: role === 'ai' ? outputContent.reply_source : '',
782
+ fallback: role === 'ai' && outputContent.fallback,
783
+ fallbackReason: role === 'ai' ? outputContent.fallback_reason : '',
784
+ tokenTotal: Number(message.token?.total ?? 0),
785
+ firstTokenMs: Number(message.first_token_ms ?? 0),
786
+ durationMs: Number(message.duration_ms ?? 0),
787
+ }
788
+ }
789
+
790
+ function createLocalUserMessage(payload: { text: string; attachments: AiAttachment[] }) {
791
+ const now = Date.now()
792
+ const message = mapMessageItem({
793
+ id: `${LOCAL_USER_MESSAGE_PREFIX}-${now}`,
794
+ input_content: { kind: 'text', content: payload.text },
795
+ output_content: undefined,
796
+ attachments: payload.attachments,
797
+ created_at: { seconds: Math.floor(now / 1000), nanos: (now % 1000) * 1_000_000 },
798
+ status: AiMessageStatus.GENERATING_AAMS,
799
+ token: { input: 0, output: 0, cache: 0, total: 0 },
800
+ tools: [],
801
+ first_token_ms: 0,
802
+ duration_ms: 0,
803
+ }, 'user')
804
+ message.localOnly = true
805
+ message.status = AiMessageStatus.GENERATING_AAMS
806
+ return message
807
+ }
808
+
809
+ function createThinkingMessage(options?: { sessionID?: string; messageID?: string }) {
810
+ const now = Date.now()
811
+ const streamKey = options?.sessionID
812
+ ? buildStreamMessageKey(options.sessionID, options.messageID || PENDING_MESSAGE_ID)
813
+ : undefined
814
+ const message = mapMessageItem({
815
+ id: streamKey || `ai-thinking-${now}`,
816
+ input_content: undefined,
817
+ output_content: {
818
+ kind: 'text', content: THINKING_MESSAGE_CONTENT, reply_source: '', model: '', fallback: false,
819
+ fallback_reason: '', flow: '', step: '', blocks_json: '',
820
+ },
821
+ attachments: [],
822
+ created_at: { seconds: Math.floor(now / 1000), nanos: (now % 1000) * 1_000_000 },
823
+ status: AiMessageStatus.GENERATING_AAMS,
824
+ token: { input: 0, output: 0, cache: 0, total: 0 },
825
+ tools: [],
826
+ first_token_ms: 0,
827
+ duration_ms: 0,
828
+ }, 'ai')
829
+ message.localOnly = true
830
+ message.streamKey = streamKey
831
+ return message
832
+ }
833
+
834
+ function ensureStreamingMessage(current: ChatMessageItem[], payload: AiStreamPayload) {
835
+ const sessionID = payload.session_id
836
+ const messageID = payload.message_id
837
+ if (!sessionID || !messageID) return current
838
+ const streamKey = buildStreamMessageKey(sessionID, messageID)
839
+ if (current.some((item) => item.streamKey === streamKey)) return current
840
+ const pendingStreamKey = buildPendingStreamMessageKey(sessionID)
841
+ const next = current.map((item) =>
842
+ item.streamKey === pendingStreamKey
843
+ ? { ...item, id: messageID, messageID, key: `${messageID}:ai`, streamKey }
844
+ : item,
845
+ )
846
+ return next.some((item) => item.streamKey === streamKey)
847
+ ? next
848
+ : sortMessages([...next, createThinkingMessage({ sessionID, messageID })])
849
+ }
850
+
851
+ function appendStreamingDelta(current: ChatMessageItem[], payload: AiStreamPayload) {
852
+ if (!payload.delta) return current
853
+ const streamKey = buildStreamMessageKey(payload.session_id, payload.message_id)
854
+ return current.map((item) => {
855
+ if (item.streamKey !== streamKey || item.role === 'user') return item
856
+ const baseContent = item.content === THINKING_MESSAGE_CONTENT ? '' : item.content
857
+ return { ...item, content: `${baseContent}${payload.delta}`, status: AiMessageStatus.GENERATING_AAMS }
858
+ })
859
+ }
860
+
861
+ function markThinkingMessageFailed(current: ChatMessageItem[]) {
862
+ return current.map((item) =>
863
+ item.localOnly
864
+ ? {
865
+ ...item,
866
+ status: AiMessageStatus.FAILED_AAMS,
867
+ content: item.role === 'ai' ? '这次回复没有成功返回,你可以直接重试刚才的问题。' : item.content,
868
+ }
869
+ : item,
870
+ )
871
+ }
872
+
873
+ function markStreamingError(current: ChatMessageItem[], payload: AiStreamPayload) {
874
+ const streamKey = buildStreamMessageKey(payload.session_id, payload.message_id)
875
+ return current.map((item) =>
876
+ item.localOnly && item.streamKey === streamKey
877
+ ? { ...item, status: AiMessageStatus.FAILED_AAMS, content: '这次回复没有成功返回,你可以直接重试刚才的问题。' }
878
+ : item,
879
+ )
880
+ }
881
+
882
+ function replacePendingMessages(current: ChatMessageItem[], nextMessages: ChatMessageItem[], payload?: AiStreamPayload) {
883
+ const sessionID = payload?.session_id ?? ''
884
+ const streamKey = payload?.message_id ? buildStreamMessageKey(sessionID, payload.message_id) : ''
885
+ const pendingStreamKey = sessionID ? buildPendingStreamMessageKey(sessionID) : ''
886
+ const stableMessages = current.filter((item) => {
887
+ if (!item.localOnly) return true
888
+ if (payload?.message_id && item.role === 'user') {
889
+ return !nextMessages.some((message) => message.role === 'user' && message.messageID === payload.message_id)
890
+ }
891
+ return streamKey ? item.streamKey !== streamKey && item.streamKey !== pendingStreamKey : false
892
+ })
893
+ const messageMap = new Map<string, ChatMessageItem>()
894
+ for (const item of stableMessages) messageMap.set(item.key, item)
895
+ for (const item of nextMessages) messageMap.set(item.key, item)
896
+ return sortMessages(Array.from(messageMap.values()))
897
+ }
898
+
899
+ function normalizeNonStreamMessages(response: unknown) {
900
+ const jsonResponse = response as { messages?: AiMessage[] }
901
+ if (Array.isArray(jsonResponse?.messages)) return normalizeMessageList(jsonResponse.messages)
902
+ const events = parseAiEventStreamText(response)
903
+ const finishEvent = [...events].reverse().find((item) => item.event === 'finish')
904
+ if (finishEvent) return normalizeMessageList(finishEvent.payload.messages)
905
+ if ([...events].reverse().some((item) => item.event === 'error')) throw new Error('AI 助手请求失败')
906
+ return []
907
+ }
908
+
909
+ function sortMessages(list: ChatMessageItem[]) {
910
+ return [...list].sort((left, right) => {
911
+ const difference = resolveTimestamp(left.created_at) - resolveTimestamp(right.created_at)
912
+ if (difference) return difference
913
+ if (left.role !== right.role) return left.role === 'user' ? -1 : 1
914
+ return left.messageID.localeCompare(right.messageID, 'zh-Hans-CN', { numeric: true })
915
+ })
916
+ }
917
+
918
+ function buildStreamMessageKey(sessionID: string, messageID: string) {
919
+ return `${sessionID}:${messageID}`
920
+ }
921
+
922
+ function buildPendingStreamMessageKey(sessionID: string) {
923
+ return buildStreamMessageKey(sessionID, PENDING_MESSAGE_ID)
924
+ }
925
+
926
+ function resolveTimestamp(timestamp: AiMessage['created_at'] | AiSession['updated_at']) {
927
+ return Number(timestamp?.seconds || 0) * 1000 + Math.floor(Number(timestamp?.nanos || 0) / 1_000_000)
928
+ }
929
+
930
+ function isImageAttachment(attachment: AiAttachment) {
931
+ return attachment.mime_type.startsWith('image/')
932
+ }
933
+
934
+ function formatAttachmentMeta(attachment: AiAttachment) {
935
+ return attachment.size ? `${Math.max(1, Math.round(attachment.size / 1024))} KB` : '附件'
936
+ }
937
+
938
+ function formatTools(tools: AiToolCall[]) {
939
+ return tools.map((item) => item.title || item.name).filter(Boolean).join(' · ')
940
+ }
941
+
942
+ function showError(error: unknown, fallback: string) {
943
+ const message = error instanceof Error ? error.message : fallback
944
+ void Taro.showToast({ icon: 'none', title: message || fallback })
945
+ }