@liujitcn/kratos-uni-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,1453 @@
1
+ <script setup lang="ts">
2
+ import { onLoad } from '@dcloudio/uni-app'
3
+ import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
4
+ import { defAiMessageService, StreamAiMessageByChunkedRequest } from '../../../api/base/ai_message'
5
+ import { defAiSessionService } from '../../../api/base/ai_session'
6
+ import { defAiToolService } from '../../../api/base/ai_tool'
7
+ import type { AiMessage } from '../../../rpc/base/v1/ai_session'
8
+ import type { AiAttachment, AiSession } from '../../../rpc/base/v1/ai_session'
9
+ import type { AiShortcut, AiToolCall } from '../../../rpc/base/v1/ai_tool'
10
+ import { AiMessageStatus, Terminal } from '../../../rpc/common/v1/enum'
11
+ import { uploadFile } from '@liujitcn/kratos-uni-app-core/utils/file'
12
+ import { formatSrc } from '@liujitcn/kratos-uni-app-core/utils/index'
13
+ import Composer from './components/Composer.vue'
14
+ import SessionDrawer from './components/SessionDrawer.vue'
15
+ import WelcomePanel from './components/WelcomePanel.vue'
16
+ import { navigateAppRoute } from '@liujitcn/kratos-uni-app-core'
17
+ import {
18
+ type AiStreamEvent,
19
+ type AiStreamPayload,
20
+ createAiEventStreamTextParser,
21
+ parseAiEventStreamText,
22
+ readAiEventStream,
23
+ } from './stream'
24
+
25
+ type ChatRole = 'user' | 'ai'
26
+
27
+ type ChatMessageItem = AiMessage & {
28
+ key: string
29
+ messageID: string
30
+ role: ChatRole
31
+ content: string
32
+ status: AiMessageStatus
33
+ tools: AiToolCall[]
34
+ model: string
35
+ replySource: string
36
+ fallback: boolean
37
+ fallbackReason: string
38
+ tokenTotal: number
39
+ firstTokenMs: number
40
+ durationMs: number
41
+ localOnly?: boolean
42
+ streamKey?: string
43
+ }
44
+
45
+ type AttachmentUpload = {
46
+ path: string
47
+ name: string
48
+ size: number
49
+ }
50
+
51
+ type StreamTask = {
52
+ abort: () => void
53
+ aborted: boolean
54
+ finished: boolean
55
+ success?: boolean
56
+ }
57
+
58
+ const AI_TERMINAL = Terminal.TERMINAL_APP
59
+ const THINKING_MESSAGE_CONTENT = '正在回复'
60
+ const LOCAL_USER_MESSAGE_PREFIX = 'ai-user-local'
61
+ const PENDING_MESSAGE_ID = 'pending'
62
+ const MAX_ATTACHMENT_COUNT = 6
63
+ const STARTER_PROMPT_PAGE_SIZE = 4
64
+
65
+ const windowInfo = uni.getWindowInfo()
66
+ let safeAreaTop =
67
+ windowInfo.safeArea?.top || windowInfo.statusBarHeight || windowInfo.safeAreaInsets?.top || 0
68
+ // #ifdef MP-WEIXIN
69
+ safeAreaTop = safeAreaTop || 44
70
+ // #endif
71
+ const safeAreaBottom = Math.max(windowInfo.safeAreaInsets?.bottom || 0, 9)
72
+ const windowHeight = windowInfo.windowHeight || windowInfo.screenHeight || 667
73
+ const composerBottom = `${safeAreaBottom}px`
74
+ const drawerTopPadding = `${safeAreaTop + 12}px`
75
+
76
+ const showSessionDrawer = ref(false)
77
+ const activeSessionID = ref('')
78
+ const inputText = ref('')
79
+ const isRecording = ref(false)
80
+ const starterPromptGroupIndex = ref(0)
81
+ const sessionKeyword = ref('')
82
+ const loadingSessions = ref(false)
83
+ const loadingShortcuts = ref(false)
84
+ const loadingSessionID = ref('')
85
+ const uploadingAttachment = ref(false)
86
+ const sendingSessionMap = ref<Record<string, boolean>>({})
87
+ const chatBottomAnchor = ref('')
88
+ const sessions = ref<AiSession[]>([])
89
+ const messages = ref<Record<string, ChatMessageItem[]>>({})
90
+ const selectedAttachments = ref<AiAttachment[]>([])
91
+ const runningStreamTaskMap = new Map<string, StreamTask>()
92
+ const pendingDeltaMap = new Map<string, AiStreamPayload>()
93
+ let pendingDeltaTimer = 0
94
+
95
+ const starterShortcuts = ref<AiShortcut[]>([
96
+ {
97
+ key: 'summarize',
98
+ title: '帮我总结一段内容',
99
+ prompt: '请帮我总结以下内容',
100
+ action: undefined,
101
+ required_tools: [],
102
+ sort: 1,
103
+ group: '文本助手',
104
+ },
105
+ {
106
+ key: 'rewrite',
107
+ title: '帮我优化一段文字',
108
+ prompt: '请帮我优化以下文字',
109
+ action: undefined,
110
+ required_tools: [],
111
+ sort: 2,
112
+ group: '文本助手',
113
+ },
114
+ {
115
+ key: 'plan',
116
+ title: '帮我制定一个计划',
117
+ prompt: '请帮我制定一个清晰的执行计划',
118
+ action: undefined,
119
+ required_tools: [],
120
+ sort: 3,
121
+ group: '效率助手',
122
+ },
123
+ {
124
+ key: 'ideas',
125
+ title: '给我一些灵感',
126
+ prompt: '请围绕这个主题给我一些新想法',
127
+ action: undefined,
128
+ required_tools: [],
129
+ sort: 4,
130
+ group: '效率助手',
131
+ },
132
+ ])
133
+
134
+ const filteredSessions = computed(() => {
135
+ const keyword = sessionKeyword.value.trim()
136
+ if (!keyword) {
137
+ return sessions.value
138
+ }
139
+ return sessions.value.filter(
140
+ (item) => item.title.includes(keyword) || item.summary.includes(keyword),
141
+ )
142
+ })
143
+
144
+ const currentMessages = computed(() => messages.value[activeSessionID.value] ?? [])
145
+ const hasMessages = computed(() => currentMessages.value.length > 0)
146
+ const currentSessionSending = computed(() => isSessionSending(activeSessionID.value))
147
+ const starterPromptPageCount = computed(() => {
148
+ return Math.max(1, Math.ceil(starterShortcuts.value.length / STARTER_PROMPT_PAGE_SIZE))
149
+ })
150
+ const canRefreshStarterPrompts = computed(
151
+ () => starterShortcuts.value.length > STARTER_PROMPT_PAGE_SIZE,
152
+ )
153
+ const starterPrompts = computed(() => {
154
+ const pageIndex = starterPromptGroupIndex.value % starterPromptPageCount.value
155
+ const start = pageIndex * STARTER_PROMPT_PAGE_SIZE
156
+ return starterShortcuts.value.slice(start, start + STARTER_PROMPT_PAGE_SIZE)
157
+ })
158
+ const aiGreetingPeriod = computed(() => {
159
+ const hour = new Date().getHours()
160
+ if (hour < 11) {
161
+ return '上午'
162
+ }
163
+ if (hour < 14) {
164
+ return '中午'
165
+ }
166
+ if (hour < 18) {
167
+ return '下午'
168
+ }
169
+ return '晚上'
170
+ })
171
+ const aiGreetingMessage = computed(
172
+ () => `您好,${aiGreetingPeriod.value}好!今天有什么需要我协助的吗?`,
173
+ )
174
+ const composerPlaceholder = computed(() => {
175
+ if (isRecording.value) {
176
+ return '正在听...'
177
+ }
178
+ if (uploadingAttachment.value) {
179
+ return '附件上传中...'
180
+ }
181
+ return hasMessages.value ? '继续输入问题' : '输入你想了解的内容'
182
+ })
183
+ const isSubmitDisabled = computed(
184
+ () =>
185
+ uploadingAttachment.value ||
186
+ currentSessionSending.value ||
187
+ isRecording.value ||
188
+ (!inputText.value.trim() && selectedAttachments.value.length === 0),
189
+ )
190
+
191
+ onLoad(() => {
192
+ void loadAiShortcuts()
193
+ void ensureSessionsLoaded()
194
+ })
195
+
196
+ onBeforeUnmount(() => {
197
+ cancelAllStreamTasks()
198
+ clearPendingDelta()
199
+ activeSessionID.value = ''
200
+ })
201
+
202
+ const toggleSessionDrawer = () => {
203
+ showSessionDrawer.value = !showSessionDrawer.value
204
+ }
205
+
206
+ const selectSession = (sessionID: string) => {
207
+ activeSessionID.value = sessionID
208
+ showSessionDrawer.value = false
209
+ if (!messages.value[sessionID]?.length || !isSessionSending(sessionID)) {
210
+ void loadMessages(sessionID)
211
+ return
212
+ }
213
+ scrollChatToBottom()
214
+ }
215
+
216
+ const createSession = async () => {
217
+ try {
218
+ const sessionID = await createRemoteSession()
219
+ if (!sessionID) {
220
+ return
221
+ }
222
+ activeSessionID.value = sessionID
223
+ messages.value[sessionID] = []
224
+ sessionKeyword.value = ''
225
+ showSessionDrawer.value = false
226
+ } catch (error) {
227
+ showError(error, '创建会话失败')
228
+ }
229
+ }
230
+
231
+ const deleteSession = async (sessionID: string) => {
232
+ const session = sessions.value.find((item) => item.id === sessionID)
233
+ const result = await uni.showModal({
234
+ title: '删除会话',
235
+ content: `是否删除「${session?.title || '当前会话'}」?`,
236
+ confirmText: '删除',
237
+ confirmColor: '#cf4444',
238
+ })
239
+ if (!result.confirm) {
240
+ return
241
+ }
242
+
243
+ try {
244
+ await defAiSessionService.DeleteAiSession({ id: sessionID })
245
+ sessions.value = sessions.value.filter((item) => item.id !== sessionID)
246
+ delete messages.value[sessionID]
247
+ if (activeSessionID.value === sessionID) {
248
+ activeSessionID.value = ''
249
+ await ensureActiveSession()
250
+ }
251
+ } catch (error) {
252
+ showError(error, '删除会话失败')
253
+ }
254
+ }
255
+
256
+ const handleSessionAction = (session: AiSession) => {
257
+ uni.showActionSheet({
258
+ itemList: ['删除会话'],
259
+ success: ({ tapIndex }) => {
260
+ if (tapIndex === 0) {
261
+ void deleteSession(session.id)
262
+ }
263
+ },
264
+ })
265
+ }
266
+
267
+ const copyMessage = (item: ChatMessageItem) => {
268
+ uni.setClipboardData({
269
+ data: item.content,
270
+ success: () => uni.showToast({ icon: 'none', title: '消息已复制' }),
271
+ })
272
+ }
273
+
274
+ const deleteMessage = async (item: ChatMessageItem) => {
275
+ const sessionID = activeSessionID.value
276
+ if (!sessionID) {
277
+ return
278
+ }
279
+ try {
280
+ if (!item.localOnly) {
281
+ await defAiMessageService.DeleteAiMessage({
282
+ session_id: sessionID,
283
+ message_id: item.messageID,
284
+ })
285
+ }
286
+ messages.value[sessionID] = (messages.value[sessionID] ?? []).filter(
287
+ (message) => message.messageID !== item.messageID,
288
+ )
289
+ } catch (error) {
290
+ showError(error, '删除消息失败')
291
+ }
292
+ }
293
+
294
+ const regenerateMessage = async (item: ChatMessageItem) => {
295
+ if (item.role !== 'ai' || item.localOnly || currentSessionSending.value) {
296
+ return
297
+ }
298
+ setSessionSending(activeSessionID.value, true)
299
+ try {
300
+ const response = await defAiMessageService.RegenerateAiMessage({
301
+ session_id: activeSessionID.value,
302
+ message_id: item.messageID,
303
+ })
304
+ messages.value[activeSessionID.value] = normalizeMessageList(response.messages)
305
+ if (response.session) {
306
+ upsertSession(normalizeSession(response.session))
307
+ }
308
+ } catch (error) {
309
+ showError(error, '重新生成失败')
310
+ } finally {
311
+ setSessionSending(activeSessionID.value, false)
312
+ }
313
+ }
314
+
315
+ const handleMessageAction = (item: ChatMessageItem) => {
316
+ const itemList = item.role === 'ai' ? ['复制', '删除', '重新生成'] : ['复制', '删除']
317
+ uni.showActionSheet({
318
+ itemList,
319
+ success: ({ tapIndex }) => {
320
+ if (tapIndex === 0) {
321
+ copyMessage(item)
322
+ } else if (tapIndex === 1) {
323
+ void deleteMessage(item)
324
+ } else {
325
+ void regenerateMessage(item)
326
+ }
327
+ },
328
+ })
329
+ }
330
+
331
+ const navigateBack = () => {
332
+ const pages = getCurrentPages()
333
+ if (pages.length > 1) {
334
+ uni.navigateBack()
335
+ return
336
+ }
337
+ navigateAppRoute('app/home')
338
+ }
339
+
340
+ const handleSend = async () => {
341
+ if (isSubmitDisabled.value) {
342
+ return
343
+ }
344
+ const text = inputText.value.trim() || '请结合附件内容回答我的问题'
345
+ inputText.value = ''
346
+ const attachments = [...selectedAttachments.value]
347
+ selectedAttachments.value = []
348
+ await sendAiPayload({ text, attachments })
349
+ }
350
+
351
+ const handleStarterPrompt = async (shortcut: AiShortcut) => {
352
+ if (currentSessionSending.value || loadingSessions.value) {
353
+ return
354
+ }
355
+ await sendAiPayload({
356
+ text: shortcut.prompt || shortcut.title,
357
+ attachments: [],
358
+ })
359
+ }
360
+
361
+ const refreshStarterPrompts = () => {
362
+ if (!canRefreshStarterPrompts.value) {
363
+ return
364
+ }
365
+ starterPromptGroupIndex.value = (starterPromptGroupIndex.value + 1) % starterPromptPageCount.value
366
+ }
367
+
368
+ const handleToggleRecord = () => {
369
+ isRecording.value = !isRecording.value
370
+ uni.showToast({
371
+ icon: 'none',
372
+ title: isRecording.value ? '正在识别语音' : '已停止语音输入',
373
+ })
374
+ }
375
+
376
+ const handleAttachment = () => {
377
+ if (uploadingAttachment.value || currentSessionSending.value) {
378
+ return
379
+ }
380
+ if (selectedAttachments.value.length >= MAX_ATTACHMENT_COUNT) {
381
+ uni.showToast({ icon: 'none', title: `最多上传 ${MAX_ATTACHMENT_COUNT} 个附件` })
382
+ return
383
+ }
384
+
385
+ uni.chooseImage({
386
+ count: MAX_ATTACHMENT_COUNT - selectedAttachments.value.length,
387
+ sourceType: ['album', 'camera'],
388
+ success: async (result) => {
389
+ const paths = Array.isArray(result.tempFilePaths)
390
+ ? result.tempFilePaths
391
+ : [result.tempFilePaths]
392
+ const tempFiles = Array.isArray(result.tempFiles)
393
+ ? result.tempFiles
394
+ : result.tempFiles
395
+ ? [result.tempFiles]
396
+ : []
397
+ const files: AttachmentUpload[] = paths.map((path: string, index: number) => ({
398
+ path,
399
+ name: (tempFiles[index] as { name?: string } | undefined)?.name || `图片${index + 1}`,
400
+ size: Number((tempFiles[index] as { size?: number } | undefined)?.size || 0),
401
+ }))
402
+ uploadingAttachment.value = true
403
+ try {
404
+ const uploaded = await Promise.all(files.map((file) => uploadFile('ai', file.path)))
405
+ const attachments = uploaded.map<AiAttachment>((file, index) => ({
406
+ id: file.url || `${file.name}-${index}`,
407
+ name: files[index]?.name || file.name,
408
+ size: files[index]?.size || 0,
409
+ url: file.url,
410
+ mime_type: 'image/*',
411
+ }))
412
+ selectedAttachments.value = [...selectedAttachments.value, ...attachments].slice(
413
+ 0,
414
+ MAX_ATTACHMENT_COUNT,
415
+ )
416
+ } catch (error) {
417
+ showError(error, '附件上传失败')
418
+ } finally {
419
+ uploadingAttachment.value = false
420
+ }
421
+ },
422
+ })
423
+ }
424
+
425
+ const removeSelectedAttachment = (attachment: AiAttachment) => {
426
+ selectedAttachments.value = selectedAttachments.value.filter((item) => item !== attachment)
427
+ }
428
+
429
+ const previewAttachment = (attachment: AiAttachment, attachments: AiAttachment[]) => {
430
+ const current = formatSrc(attachment.url)
431
+ const urls = attachments.map((item) => formatSrc(item.url)).filter(Boolean)
432
+ if (!current) {
433
+ return
434
+ }
435
+ uni.previewImage({ current, urls: urls.length ? urls : [current] })
436
+ }
437
+
438
+ /** 加载当前终端可用的通用 AI 快捷入口。 */
439
+ async function loadAiShortcuts() {
440
+ if (loadingShortcuts.value) {
441
+ return
442
+ }
443
+ loadingShortcuts.value = true
444
+ try {
445
+ const response = await defAiToolService.ListAiShortcut({
446
+ terminal: AI_TERMINAL,
447
+ })
448
+ const shortcuts = normalizeStarterShortcuts(response.shortcuts).filter((item) => !item.action)
449
+ if (shortcuts.length) {
450
+ starterShortcuts.value = shortcuts
451
+ starterPromptGroupIndex.value = 0
452
+ }
453
+ } catch (error) {
454
+ showError(error, '加载快捷助手失败')
455
+ } finally {
456
+ loadingShortcuts.value = false
457
+ }
458
+ }
459
+
460
+ async function sendAiPayload(payload: { text: string; attachments: AiAttachment[] }) {
461
+ const sessionID = await ensureActiveSession()
462
+ if (!sessionID || isSessionSending(sessionID)) {
463
+ return false
464
+ }
465
+
466
+ const localUserMessage = createLocalUserMessage(payload)
467
+ const thinkingMessage = createThinkingMessage({ sessionID })
468
+ messages.value[sessionID] = sortMessages([
469
+ ...(messages.value[sessionID] ?? []),
470
+ localUserMessage,
471
+ thinkingMessage,
472
+ ])
473
+ scrollChatToBottom()
474
+ setSessionSending(sessionID, true)
475
+ return runAiTask(sessionID, payload)
476
+ }
477
+
478
+ async function runAiTask(
479
+ sessionID: string,
480
+ payload: { text: string; attachments: AiAttachment[] },
481
+ ) {
482
+ let task: StreamTask | undefined
483
+ const request = {
484
+ session_id: sessionID,
485
+ content: payload.text,
486
+ attachments: payload.attachments,
487
+ action: undefined,
488
+ }
489
+ try {
490
+ let handledByStream = false
491
+
492
+ // #ifdef MP-WEIXIN
493
+ const parser = createAiEventStreamTextParser((event) => handleAiStreamEvent(event, task))
494
+ const chunkedTask = StreamAiMessageByChunkedRequest(request, {
495
+ onChunk: (chunkText) => parser.push(chunkText),
496
+ })
497
+ task = {
498
+ aborted: false,
499
+ finished: false,
500
+ abort() {
501
+ task!.aborted = true
502
+ chunkedTask.abort()
503
+ },
504
+ }
505
+ runningStreamTaskMap.set(sessionID, task)
506
+ handledByStream = true
507
+ await chunkedTask.promise
508
+ parser.flush()
509
+ if (!task.finished && !task.aborted) {
510
+ throw new Error('AI 助手流式响应未完整返回')
511
+ }
512
+ // #endif
513
+
514
+ // #ifdef H5
515
+ if (
516
+ !handledByStream &&
517
+ typeof fetch === 'function' &&
518
+ typeof ReadableStream !== 'undefined' &&
519
+ typeof AbortController !== 'undefined'
520
+ ) {
521
+ const controller = new AbortController()
522
+ task = {
523
+ aborted: false,
524
+ finished: false,
525
+ abort() {
526
+ task!.aborted = true
527
+ controller.abort()
528
+ },
529
+ }
530
+ runningStreamTaskMap.set(sessionID, task)
531
+ const response = await defAiMessageService.StreamAiMessage(request, {
532
+ signal: controller.signal,
533
+ })
534
+ if (!response.body) {
535
+ throw new Error('AI 助手流式响应为空')
536
+ }
537
+ await readAiEventStream(
538
+ response.body,
539
+ (event) => handleAiStreamEvent(event, task),
540
+ controller.signal,
541
+ )
542
+ if (!task.finished && !task.aborted) {
543
+ throw new Error('AI 助手流式响应未完整返回')
544
+ }
545
+ handledByStream = true
546
+ }
547
+ // #endif
548
+
549
+ if (!handledByStream) {
550
+ const response = await defAiMessageService.SendAiMessage(request)
551
+ const nextMessages = normalizeNonStreamMessages(response)
552
+ if (!nextMessages.length) {
553
+ throw new Error('AI 助手响应为空')
554
+ }
555
+ const success = hasSuccessfulAiMessages(nextMessages)
556
+ messages.value[sessionID] = replacePendingMessages(
557
+ messages.value[sessionID] ?? [],
558
+ nextMessages,
559
+ )
560
+ scrollChatToBottom()
561
+ if (response.session) {
562
+ upsertSession(normalizeSession(response.session))
563
+ }
564
+ return success
565
+ }
566
+ return Boolean(task?.success)
567
+ } catch (error) {
568
+ if (task?.aborted) {
569
+ return false
570
+ }
571
+ messages.value[sessionID] = markThinkingMessageFailed(messages.value[sessionID] ?? [])
572
+ scrollChatToBottom()
573
+ showError(error, 'AI 助手请求失败')
574
+ } finally {
575
+ if (task && runningStreamTaskMap.get(sessionID) === task) {
576
+ runningStreamTaskMap.delete(sessionID)
577
+ }
578
+ setSessionSending(sessionID, false)
579
+ }
580
+ }
581
+
582
+ function handleAiStreamEvent(event: AiStreamEvent, task?: StreamTask) {
583
+ if (event.event === 'delta') {
584
+ handleAiDelta(event.payload)
585
+ return
586
+ }
587
+ if (event.event === 'finish') {
588
+ handleAiFinish(event.payload, task)
589
+ return
590
+ }
591
+ handleAiError(event.payload, task)
592
+ }
593
+
594
+ function handleAiDelta(payload: AiStreamPayload) {
595
+ if (!payload.delta) {
596
+ return
597
+ }
598
+ queueAiDelta(payload)
599
+ }
600
+
601
+ function handleAiFinish(payload: AiStreamPayload, task?: StreamTask) {
602
+ const sessionID = payload.session_id
603
+ if (!sessionID) {
604
+ return
605
+ }
606
+ if (task) {
607
+ task.finished = true
608
+ }
609
+ flushAiDelta()
610
+ const nextMessages = normalizeMessageList(payload.messages)
611
+ if (task) {
612
+ task.success = hasSuccessfulAiMessages(nextMessages)
613
+ }
614
+ const current = messages.value[sessionID] ?? []
615
+ const streamKey = payload.message_id ? buildStreamMessageKey(sessionID, payload.message_id) : ''
616
+ const hasLocalStreamingMessages = current.some(
617
+ (item) => item.localOnly && item.streamKey === streamKey,
618
+ )
619
+ messages.value[sessionID] =
620
+ nextMessages.length || !hasLocalStreamingMessages
621
+ ? replacePendingMessages(current, nextMessages, payload)
622
+ : current
623
+ scrollChatToBottom()
624
+ if (payload.session) {
625
+ upsertSession(normalizeSession(payload.session))
626
+ }
627
+ }
628
+
629
+ function handleAiError(payload: AiStreamPayload, task?: StreamTask) {
630
+ const sessionID = payload.session_id
631
+ if (!sessionID) {
632
+ return
633
+ }
634
+ if (task) {
635
+ task.finished = true
636
+ task.success = false
637
+ }
638
+ flushAiDelta()
639
+ const nextMessages = normalizeMessageList(payload.messages)
640
+ if (nextMessages.length) {
641
+ messages.value[sessionID] = replacePendingMessages(
642
+ messages.value[sessionID] ?? [],
643
+ nextMessages,
644
+ payload,
645
+ )
646
+ scrollChatToBottom()
647
+ return
648
+ }
649
+ messages.value[sessionID] = markStreamingError(
650
+ ensureStreamingMessage(messages.value[sessionID] ?? [], payload),
651
+ payload,
652
+ )
653
+ scrollChatToBottom()
654
+ }
655
+
656
+ /** 合并同一时刻的流式分片,降低移动端频繁渲染压力。 */
657
+ function queueAiDelta(payload: AiStreamPayload) {
658
+ const sessionID = payload.session_id
659
+ const messageID = payload.message_id
660
+ if (!sessionID || !messageID || !messages.value[sessionID]) {
661
+ return
662
+ }
663
+
664
+ const key = buildStreamMessageKey(sessionID, messageID)
665
+ const cachedPayload = pendingDeltaMap.get(key)
666
+ pendingDeltaMap.set(key, {
667
+ ...payload,
668
+ delta: `${cachedPayload?.delta ?? ''}${payload.delta ?? ''}`,
669
+ })
670
+
671
+ if (pendingDeltaTimer) {
672
+ return
673
+ }
674
+ pendingDeltaTimer = setTimeout(() => {
675
+ pendingDeltaTimer = 0
676
+ flushAiDelta()
677
+ }, 32) as unknown as number
678
+ }
679
+
680
+ function flushAiDelta() {
681
+ if (pendingDeltaTimer) {
682
+ clearTimeout(pendingDeltaTimer)
683
+ pendingDeltaTimer = 0
684
+ }
685
+ if (!pendingDeltaMap.size) {
686
+ return
687
+ }
688
+ const payloadList = Array.from(pendingDeltaMap.values())
689
+ pendingDeltaMap.clear()
690
+ for (const payload of payloadList) {
691
+ const sessionID = payload.session_id
692
+ if (!sessionID || !messages.value[sessionID]) {
693
+ continue
694
+ }
695
+ messages.value[sessionID] = appendStreamingDelta(
696
+ ensureStreamingMessage(messages.value[sessionID] ?? [], payload),
697
+ payload,
698
+ )
699
+ scrollChatToBottom()
700
+ }
701
+ }
702
+
703
+ function clearPendingDelta() {
704
+ if (pendingDeltaTimer) {
705
+ clearTimeout(pendingDeltaTimer)
706
+ pendingDeltaTimer = 0
707
+ }
708
+ pendingDeltaMap.clear()
709
+ }
710
+
711
+ function normalizeNonStreamMessages(response: unknown) {
712
+ const jsonResponse = response as { messages?: AiMessage[] }
713
+ if (Array.isArray(jsonResponse?.messages)) {
714
+ return normalizeMessageList(jsonResponse.messages)
715
+ }
716
+
717
+ const events = parseAiEventStreamText(response)
718
+ const finishEvent = [...events].reverse().find((item) => item.event === 'finish')
719
+ if (finishEvent) {
720
+ return normalizeMessageList(finishEvent.payload.messages)
721
+ }
722
+ const errorEvent = [...events].reverse().find((item) => item.event === 'error')
723
+ if (errorEvent) {
724
+ throw new Error('AI 助手请求失败')
725
+ }
726
+ return []
727
+ }
728
+
729
+ function replacePendingMessages(
730
+ current: ChatMessageItem[],
731
+ nextMessages: ChatMessageItem[],
732
+ payload?: AiStreamPayload,
733
+ ) {
734
+ const sessionID = payload?.session_id ?? ''
735
+ const streamKey = payload?.message_id ? buildStreamMessageKey(sessionID, payload.message_id) : ''
736
+ const pendingStreamKey = sessionID ? buildPendingStreamMessageKey(sessionID) : ''
737
+ const stableMessages = current.filter((item) => {
738
+ if (!item.localOnly) {
739
+ return true
740
+ }
741
+ if (payload?.message_id && item.role === 'user') {
742
+ return !nextMessages.some(
743
+ (message) => message.role === 'user' && message.messageID === payload.message_id,
744
+ )
745
+ }
746
+ if (!streamKey) {
747
+ return false
748
+ }
749
+ return item.streamKey !== streamKey && item.streamKey !== pendingStreamKey
750
+ })
751
+ const messageMap = new Map<string, ChatMessageItem>()
752
+ for (const item of stableMessages) {
753
+ messageMap.set(item.key, item)
754
+ }
755
+ for (const item of nextMessages) {
756
+ messageMap.set(item.key, item)
757
+ }
758
+ return sortMessages(Array.from(messageMap.values()))
759
+ }
760
+
761
+ function buildStreamMessageKey(sessionID: string, messageID: string) {
762
+ return `${sessionID}:${messageID}`
763
+ }
764
+
765
+ function buildPendingStreamMessageKey(sessionID: string) {
766
+ return buildStreamMessageKey(sessionID, PENDING_MESSAGE_ID)
767
+ }
768
+
769
+ async function ensureSessionsLoaded() {
770
+ if (loadingSessions.value || sessions.value.length > 0) {
771
+ return
772
+ }
773
+ loadingSessions.value = true
774
+ try {
775
+ const response = await defAiSessionService.ListAiSession({
776
+ terminal: AI_TERMINAL,
777
+ })
778
+ sessions.value = normalizeSessionList(response.sessions)
779
+ const sessionID = await ensureActiveSession()
780
+ if (sessionID) {
781
+ await loadMessages(sessionID)
782
+ }
783
+ } catch (error) {
784
+ showError(error, '加载会话失败')
785
+ } finally {
786
+ loadingSessions.value = false
787
+ }
788
+ }
789
+
790
+ async function ensureActiveSession() {
791
+ if (activeSessionID.value) {
792
+ return activeSessionID.value
793
+ }
794
+ if (sessions.value.length > 0) {
795
+ activeSessionID.value = sessions.value[0].id
796
+ return activeSessionID.value
797
+ }
798
+ const sessionID = await createRemoteSession()
799
+ if (sessionID) {
800
+ activeSessionID.value = sessionID
801
+ messages.value[sessionID] = []
802
+ }
803
+ return sessionID
804
+ }
805
+
806
+ async function createRemoteSession() {
807
+ const response = await defAiSessionService.CreateAiSession({
808
+ title: '新会话',
809
+ terminal: AI_TERMINAL,
810
+ })
811
+ const session = response.session ? normalizeSession(response.session) : undefined
812
+ if (!session) {
813
+ return ''
814
+ }
815
+ upsertSession(session)
816
+ return session.id
817
+ }
818
+
819
+ async function loadMessages(sessionID: string) {
820
+ if (!sessionID) {
821
+ return
822
+ }
823
+
824
+ loadingSessionID.value = sessionID
825
+ try {
826
+ const response = await defAiSessionService.ListAiMessage({ session_id: sessionID })
827
+ if (loadingSessionID.value !== sessionID) {
828
+ return
829
+ }
830
+ messages.value[sessionID] = normalizeMessageList(response.messages)
831
+ if (activeSessionID.value === sessionID) {
832
+ scrollChatToBottom()
833
+ }
834
+ } catch (error) {
835
+ if (loadingSessionID.value === sessionID) {
836
+ messages.value[sessionID] = []
837
+ }
838
+ showError(error, '加载消息失败')
839
+ } finally {
840
+ if (loadingSessionID.value === sessionID) {
841
+ loadingSessionID.value = ''
842
+ }
843
+ }
844
+ }
845
+
846
+ function normalizeSession(session?: Partial<AiSession> | null): AiSession {
847
+ return {
848
+ id: String(session?.id ?? ''),
849
+ title: String(session?.title ?? '新会话'),
850
+ summary: String(session?.summary ?? ''),
851
+ updated_at: session?.updated_at,
852
+ terminal: Number(session?.terminal ?? AI_TERMINAL),
853
+ }
854
+ }
855
+
856
+ function normalizeSessionList(list?: AiSession[] | null) {
857
+ if (!Array.isArray(list)) {
858
+ return []
859
+ }
860
+ return list.map((item) => normalizeSession(item)).filter((item) => item.id)
861
+ }
862
+
863
+ function normalizeStarterShortcuts(list?: AiShortcut[] | null) {
864
+ return [...(list || [])]
865
+ .filter((item) => Boolean(item?.key && (item.title || item.prompt)))
866
+ .map((item) => ({
867
+ ...item,
868
+ title: item.title || item.prompt,
869
+ prompt: item.prompt || item.title,
870
+ required_tools: Array.isArray(item.required_tools) ? item.required_tools : [],
871
+ sort: Number(item.sort || 0),
872
+ }))
873
+ .sort((left, right) => left.sort - right.sort)
874
+ }
875
+
876
+ function normalizeMessageList(list?: AiMessage[] | null) {
877
+ if (!Array.isArray(list)) {
878
+ return []
879
+ }
880
+
881
+ return sortMessages(
882
+ list
883
+ .filter(Boolean)
884
+ .flatMap((item) => [mapMessageItem(item, 'user'), mapMessageItem(item, 'ai')]),
885
+ )
886
+ }
887
+
888
+ function hasSuccessfulAiMessages(list: ChatMessageItem[]) {
889
+ return list.some((item) => item.status === AiMessageStatus.SUCCESS_AAMS)
890
+ }
891
+
892
+ function mapMessageItem(message: AiMessage, role: ChatRole): ChatMessageItem {
893
+ const inputContent = {
894
+ kind: message.input_content?.kind || 'text',
895
+ content: message.input_content?.content ?? '',
896
+ }
897
+ const outputContent = {
898
+ kind: message.output_content?.kind || 'text',
899
+ content: message.output_content?.content ?? '',
900
+ reply_source: message.output_content?.reply_source ?? '',
901
+ model: message.output_content?.model ?? '',
902
+ fallback: Boolean(message.output_content?.fallback),
903
+ fallback_reason: message.output_content?.fallback_reason ?? '',
904
+ flow: message.output_content?.flow ?? '',
905
+ step: message.output_content?.step ?? '',
906
+ blocks_json: message.output_content?.blocks_json ?? '',
907
+ }
908
+ const status = Number(message.status ?? AiMessageStatus.SUCCESS_AAMS)
909
+ return {
910
+ ...message,
911
+ key: `${message.id}:${role}`,
912
+ messageID: message.id,
913
+ role,
914
+ content: role === 'user' ? inputContent.content : outputContent.content,
915
+ input_content: inputContent,
916
+ output_content: outputContent,
917
+ attachments: Array.isArray(message.attachments) ? message.attachments : [],
918
+ status,
919
+ token: {
920
+ input: Number(message.token?.input ?? 0),
921
+ output: Number(message.token?.output ?? 0),
922
+ cache: Number(message.token?.cache ?? 0),
923
+ total: Number(message.token?.total ?? 0),
924
+ },
925
+ tools: Array.isArray(message.tools) ? message.tools : [],
926
+ model: role === 'ai' ? outputContent.model : '',
927
+ replySource: role === 'ai' ? outputContent.reply_source : '',
928
+ fallback: role === 'ai' && outputContent.fallback,
929
+ fallbackReason: role === 'ai' ? outputContent.fallback_reason : '',
930
+ tokenTotal: Number(message.token?.total ?? 0),
931
+ firstTokenMs: Number(message.first_token_ms ?? 0),
932
+ durationMs: Number(message.duration_ms ?? 0),
933
+ }
934
+ }
935
+
936
+ function createLocalUserMessage(payload: { text: string; attachments: AiAttachment[] }) {
937
+ const now = Date.now()
938
+ const message = mapMessageItem(
939
+ {
940
+ id: `${LOCAL_USER_MESSAGE_PREFIX}-${now}`,
941
+ input_content: { kind: 'text', content: payload.text },
942
+ output_content: undefined,
943
+ attachments: payload.attachments,
944
+ created_at: {
945
+ seconds: Math.floor(now / 1000),
946
+ nanos: (now % 1000) * 1_000_000,
947
+ },
948
+ status: AiMessageStatus.GENERATING_AAMS,
949
+ token: { input: 0, output: 0, cache: 0, total: 0 },
950
+ tools: [],
951
+ first_token_ms: 0,
952
+ duration_ms: 0,
953
+ },
954
+ 'user',
955
+ )
956
+ message.localOnly = true
957
+ message.status = AiMessageStatus.GENERATING_AAMS
958
+ return message
959
+ }
960
+
961
+ function createThinkingMessage(options?: { sessionID?: string; messageID?: string }) {
962
+ const now = Date.now()
963
+ const streamKey = options?.sessionID
964
+ ? buildStreamMessageKey(options.sessionID, options.messageID || PENDING_MESSAGE_ID)
965
+ : undefined
966
+ const message = mapMessageItem(
967
+ {
968
+ id: streamKey || `ai-thinking-${now}`,
969
+ input_content: undefined,
970
+ output_content: {
971
+ kind: 'text',
972
+ content: THINKING_MESSAGE_CONTENT,
973
+ reply_source: '',
974
+ model: '',
975
+ fallback: false,
976
+ fallback_reason: '',
977
+ flow: '',
978
+ step: '',
979
+ blocks_json: '',
980
+ },
981
+ attachments: [],
982
+ created_at: {
983
+ seconds: Math.floor(now / 1000),
984
+ nanos: (now % 1000) * 1_000_000,
985
+ },
986
+ status: AiMessageStatus.GENERATING_AAMS,
987
+ token: { input: 0, output: 0, cache: 0, total: 0 },
988
+ tools: [],
989
+ first_token_ms: 0,
990
+ duration_ms: 0,
991
+ },
992
+ 'ai',
993
+ )
994
+ message.localOnly = true
995
+ message.streamKey = streamKey
996
+ return message
997
+ }
998
+
999
+ function ensureStreamingMessage(current: ChatMessageItem[], payload: AiStreamPayload) {
1000
+ const sessionID = payload.session_id
1001
+ const messageID = payload.message_id
1002
+ if (!sessionID || !messageID) {
1003
+ return current
1004
+ }
1005
+
1006
+ const streamKey = buildStreamMessageKey(sessionID, messageID)
1007
+ if (current.some((item) => item.streamKey === streamKey)) {
1008
+ return current
1009
+ }
1010
+
1011
+ const pendingStreamKey = buildPendingStreamMessageKey(sessionID)
1012
+ const next = current.map((item) =>
1013
+ item.streamKey === pendingStreamKey
1014
+ ? { ...item, id: messageID, messageID, key: `${messageID}:ai`, streamKey }
1015
+ : item,
1016
+ )
1017
+ if (next.some((item) => item.streamKey === streamKey)) {
1018
+ return next
1019
+ }
1020
+
1021
+ return sortMessages([...next, createThinkingMessage({ sessionID, messageID })])
1022
+ }
1023
+
1024
+ function appendStreamingDelta(current: ChatMessageItem[], payload: AiStreamPayload) {
1025
+ if (!payload.delta) {
1026
+ return current
1027
+ }
1028
+ const streamKey = buildStreamMessageKey(payload.session_id, payload.message_id)
1029
+ return current.map((item) => {
1030
+ if (item.streamKey !== streamKey || item.role === 'user') {
1031
+ return item
1032
+ }
1033
+ const baseContent = item.content === THINKING_MESSAGE_CONTENT ? '' : item.content
1034
+ return {
1035
+ ...item,
1036
+ content: `${baseContent}${payload.delta}`,
1037
+ status: AiMessageStatus.GENERATING_AAMS,
1038
+ }
1039
+ })
1040
+ }
1041
+
1042
+ function markThinkingMessageFailed(current: ChatMessageItem[]) {
1043
+ return current.map((item) => {
1044
+ if (!item.localOnly) {
1045
+ return item
1046
+ }
1047
+ return {
1048
+ ...item,
1049
+ status: AiMessageStatus.FAILED_AAMS,
1050
+ content:
1051
+ item.role === 'ai' ? '这次回复没有成功返回,你可以直接重试刚才的问题。' : item.content,
1052
+ }
1053
+ })
1054
+ }
1055
+
1056
+ function markStreamingError(current: ChatMessageItem[], payload: AiStreamPayload) {
1057
+ const streamKey = buildStreamMessageKey(payload.session_id, payload.message_id)
1058
+ return current.map((item) => {
1059
+ if (!item.localOnly || item.streamKey !== streamKey) {
1060
+ return item
1061
+ }
1062
+ return {
1063
+ ...item,
1064
+ status: AiMessageStatus.FAILED_AAMS,
1065
+ content: '这次回复没有成功返回,你可以直接重试刚才的问题。',
1066
+ }
1067
+ })
1068
+ }
1069
+
1070
+ function sortMessages(list: ChatMessageItem[]) {
1071
+ return [...list].sort((left, right) => {
1072
+ const leftTime = resolveTimestamp(left.created_at)
1073
+ const rightTime = resolveTimestamp(right.created_at)
1074
+ if (leftTime === rightTime) {
1075
+ if (left.role !== right.role) {
1076
+ return left.role === 'user' ? -1 : 1
1077
+ }
1078
+ return left.messageID.localeCompare(right.messageID, 'zh-Hans-CN', { numeric: true })
1079
+ }
1080
+ return leftTime - rightTime
1081
+ })
1082
+ }
1083
+
1084
+ function upsertSession(session: AiSession) {
1085
+ const next = sessions.value.filter((item) => item.id !== session.id)
1086
+ next.unshift(session)
1087
+ sessions.value = next
1088
+ }
1089
+
1090
+ function setSessionSending(sessionID: string, sending: boolean) {
1091
+ if (!sessionID) {
1092
+ return
1093
+ }
1094
+ sendingSessionMap.value = { ...sendingSessionMap.value, [sessionID]: sending }
1095
+ }
1096
+
1097
+ function isSessionSending(sessionID: string) {
1098
+ return Boolean(sessionID && sendingSessionMap.value[sessionID])
1099
+ }
1100
+
1101
+ function cancelAllStreamTasks() {
1102
+ runningStreamTaskMap.forEach((task) => {
1103
+ task.finished = true
1104
+ task.abort()
1105
+ })
1106
+ runningStreamTaskMap.clear()
1107
+ }
1108
+
1109
+ function scrollChatToBottom() {
1110
+ void nextTick(() => {
1111
+ chatBottomAnchor.value = ''
1112
+ void nextTick(() => {
1113
+ chatBottomAnchor.value = 'chat-bottom'
1114
+ })
1115
+ })
1116
+ }
1117
+
1118
+ function resolveTimestamp(timestamp: AiMessage['created_at'] | AiSession['updated_at']) {
1119
+ const seconds = Number(timestamp?.seconds || 0)
1120
+ const nanos = Number(timestamp?.nanos || 0)
1121
+ return seconds * 1000 + Math.floor(nanos / 1_000_000)
1122
+ }
1123
+
1124
+ function isImageAttachment(attachment: AiAttachment) {
1125
+ return attachment.mime_type.startsWith('image/')
1126
+ }
1127
+
1128
+ function formatAttachmentMeta(attachment: AiAttachment) {
1129
+ if (!attachment.size) {
1130
+ return '附件'
1131
+ }
1132
+ return `${Math.max(1, Math.round(attachment.size / 1024))} KB`
1133
+ }
1134
+
1135
+ function formatTools(tools: AiToolCall[]) {
1136
+ return tools
1137
+ .map((item) => item.title || item.name)
1138
+ .filter(Boolean)
1139
+ .join(' · ')
1140
+ }
1141
+
1142
+ function showError(error: unknown, fallback: string) {
1143
+ const message = error instanceof Error ? error.message : fallback
1144
+ uni.showToast({ icon: 'none', title: message || fallback })
1145
+ }
1146
+ </script>
1147
+
1148
+ <template>
1149
+ <view class="ai-page">
1150
+ <view class="ai-navbar">
1151
+ <button class="nav-back-button" hover-class="none" @tap="navigateBack">
1152
+ <uni-icons type="left" size="24" color="#111" />
1153
+ </button>
1154
+ <view class="ai-navbar__title">AI 助手</view>
1155
+ <button class="nav-menu-button" hover-class="none" @tap="toggleSessionDrawer">
1156
+ <uni-icons type="bars" size="24" color="#111" />
1157
+ </button>
1158
+ </view>
1159
+
1160
+ <scroll-view
1161
+ class="ai-body"
1162
+ scroll-y
1163
+ scroll-with-animation
1164
+ :scroll-into-view="chatBottomAnchor"
1165
+ :show-scrollbar="false"
1166
+ >
1167
+ <template v-if="!hasMessages">
1168
+ <WelcomePanel
1169
+ :greeting-message="aiGreetingMessage"
1170
+ :loading="loadingSessions || loadingShortcuts"
1171
+ :shortcuts="starterPrompts"
1172
+ :can-refresh="canRefreshStarterPrompts"
1173
+ @refresh="refreshStarterPrompts"
1174
+ @shortcut-tap="handleStarterPrompt"
1175
+ />
1176
+ </template>
1177
+
1178
+ <view v-else class="chat-list">
1179
+ <view
1180
+ v-for="item in currentMessages"
1181
+ :id="item.key"
1182
+ :key="item.key"
1183
+ class="message-row"
1184
+ :class="item.role === 'user' ? 'is-user' : 'is-ai'"
1185
+ >
1186
+ <view
1187
+ class="bubble"
1188
+ :class="[
1189
+ item.role === 'ai' ? 'ai-bubble' : 'user-bubble',
1190
+ item.status === AiMessageStatus.GENERATING_AAMS ? 'is-streaming' : '',
1191
+ ]"
1192
+ @longpress="handleMessageAction(item)"
1193
+ >
1194
+ <view v-if="item.role === 'ai' && item.model" class="reply-meta">
1195
+ <text class="reply-tag">模型回复</text>
1196
+ <text class="reply-model">{{ item.model }}</text>
1197
+ </view>
1198
+ <view class="bubble-content">{{ item.content }}</view>
1199
+ <view v-if="item.attachments.length" class="attachment-list">
1200
+ <view
1201
+ v-for="attachment in item.attachments"
1202
+ :key="attachment.id || attachment.url || attachment.name"
1203
+ class="attachment-card"
1204
+ @tap="previewAttachment(attachment, item.attachments)"
1205
+ >
1206
+ <view class="attachment-icon">{{
1207
+ isImageAttachment(attachment) ? '图' : '件'
1208
+ }}</view>
1209
+ <view class="attachment-info">
1210
+ <view class="attachment-name">{{ attachment.name }}</view>
1211
+ <view class="attachment-meta">{{ formatAttachmentMeta(attachment) }}</view>
1212
+ </view>
1213
+ </view>
1214
+ </view>
1215
+ <view v-if="item.tools.length" class="tool-row"
1216
+ >已调用:{{ formatTools(item.tools) }}</view
1217
+ >
1218
+ </view>
1219
+ </view>
1220
+ <view id="chat-bottom" class="chat-bottom"></view>
1221
+ </view>
1222
+ <view v-if="loadingSessionID" class="loading-session">正在加载消息...</view>
1223
+ </scroll-view>
1224
+
1225
+ <Composer
1226
+ v-model="inputText"
1227
+ :attachments="selectedAttachments"
1228
+ :placeholder="composerPlaceholder"
1229
+ :bottom="composerBottom"
1230
+ :recording="isRecording"
1231
+ :sending="currentSessionSending"
1232
+ :disabled="isSubmitDisabled"
1233
+ @attach="handleAttachment"
1234
+ @record="handleToggleRecord"
1235
+ @send="handleSend"
1236
+ @remove-attachment="removeSelectedAttachment"
1237
+ />
1238
+
1239
+ <SessionDrawer
1240
+ :open="showSessionDrawer"
1241
+ :top-padding="drawerTopPadding"
1242
+ :keyword="sessionKeyword"
1243
+ :loading="loadingSessions"
1244
+ :sessions="filteredSessions"
1245
+ :active-session-id="activeSessionID"
1246
+ @close="showSessionDrawer = false"
1247
+ @create="createSession"
1248
+ @select="selectSession"
1249
+ @action="handleSessionAction"
1250
+ @update:keyword="sessionKeyword = $event"
1251
+ />
1252
+ </view>
1253
+ </template>
1254
+
1255
+ <style lang="scss">
1256
+ page {
1257
+ height: 100%;
1258
+ overflow: hidden;
1259
+ background-color: #f6f6f6;
1260
+ }
1261
+
1262
+ .ai-page {
1263
+ position: relative;
1264
+ display: flex;
1265
+ flex-direction: column;
1266
+ width: 100%;
1267
+ height: 100vh;
1268
+ height: 100dvh;
1269
+ overflow: hidden;
1270
+ color: #333;
1271
+ background-color: #f6f6f6;
1272
+ }
1273
+
1274
+ .ai-navbar {
1275
+ flex-shrink: 0;
1276
+ display: flex;
1277
+ align-items: center;
1278
+ justify-content: space-between;
1279
+ box-sizing: border-box;
1280
+ background-color: #fff;
1281
+ border-bottom: 1rpx solid #eceef1;
1282
+
1283
+ height: 44px;
1284
+
1285
+ /* #ifdef MP-WEIXIN */
1286
+ height: 88px;
1287
+ padding-top: 44px;
1288
+ /* #endif */
1289
+ }
1290
+
1291
+ .nav-back-button,
1292
+ .nav-menu-button {
1293
+ display: flex;
1294
+ align-items: center;
1295
+ justify-content: center;
1296
+ width: 88rpx;
1297
+ height: 44px;
1298
+ padding: 0;
1299
+ border: 0;
1300
+ border-radius: 0;
1301
+ background: transparent;
1302
+ line-height: normal;
1303
+ }
1304
+
1305
+ .nav-back-button::after,
1306
+ .nav-menu-button::after {
1307
+ border: 0;
1308
+ }
1309
+
1310
+ .ai-navbar__title {
1311
+ flex: 1;
1312
+ color: #111;
1313
+ font-size: 32rpx;
1314
+ font-weight: 600;
1315
+ text-align: center;
1316
+ }
1317
+
1318
+ .ai-body {
1319
+ flex: 1;
1320
+ min-height: 0;
1321
+ box-sizing: border-box;
1322
+ padding: 28rpx 24rpx 10rpx;
1323
+ }
1324
+
1325
+ .chat-list {
1326
+ padding-bottom: 24rpx;
1327
+ }
1328
+
1329
+ .message-row {
1330
+ display: flex;
1331
+ width: 100%;
1332
+ margin-bottom: 24rpx;
1333
+ }
1334
+
1335
+ .message-row.is-user {
1336
+ justify-content: flex-end;
1337
+ }
1338
+
1339
+ .bubble {
1340
+ max-width: 86%;
1341
+ padding: 20rpx 24rpx;
1342
+ border-radius: 18rpx;
1343
+ box-sizing: border-box;
1344
+ word-break: break-word;
1345
+ }
1346
+
1347
+ .user-bubble {
1348
+ color: #fff;
1349
+ background-color: #27ba9b;
1350
+ border-bottom-right-radius: 6rpx;
1351
+ }
1352
+
1353
+ .ai-bubble {
1354
+ color: #333;
1355
+ background-color: #fff;
1356
+ border-bottom-left-radius: 6rpx;
1357
+ box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.05);
1358
+ }
1359
+
1360
+ .bubble.is-streaming {
1361
+ opacity: 0.72;
1362
+ }
1363
+
1364
+ .bubble-content {
1365
+ white-space: pre-wrap;
1366
+ font-size: 28rpx;
1367
+ line-height: 1.65;
1368
+ }
1369
+
1370
+ .reply-meta {
1371
+ display: flex;
1372
+ align-items: center;
1373
+ gap: 12rpx;
1374
+ margin-bottom: 8rpx;
1375
+ color: #8a8f99;
1376
+ font-size: 20rpx;
1377
+ }
1378
+
1379
+ .reply-tag {
1380
+ color: #16806d;
1381
+ }
1382
+
1383
+ .attachment-list {
1384
+ margin-top: 16rpx;
1385
+ }
1386
+
1387
+ .attachment-card {
1388
+ display: flex;
1389
+ align-items: center;
1390
+ gap: 12rpx;
1391
+ max-width: 100%;
1392
+ padding: 12rpx;
1393
+ margin-top: 8rpx;
1394
+ border-radius: 10rpx;
1395
+ background-color: rgba(255, 255, 255, 0.72);
1396
+ box-sizing: border-box;
1397
+ }
1398
+
1399
+ .attachment-icon {
1400
+ flex-shrink: 0;
1401
+ width: 48rpx;
1402
+ height: 48rpx;
1403
+ border-radius: 8rpx;
1404
+ color: #16806d;
1405
+ font-size: 22rpx;
1406
+ line-height: 48rpx;
1407
+ text-align: center;
1408
+ background-color: #e8f8f4;
1409
+ }
1410
+
1411
+ .attachment-info {
1412
+ min-width: 0;
1413
+ }
1414
+
1415
+ .attachment-name,
1416
+ .attachment-meta {
1417
+ overflow: hidden;
1418
+ text-overflow: ellipsis;
1419
+ white-space: nowrap;
1420
+ }
1421
+
1422
+ .attachment-name {
1423
+ max-width: 320rpx;
1424
+ font-size: 22rpx;
1425
+ }
1426
+
1427
+ .attachment-meta {
1428
+ margin-top: 4rpx;
1429
+ color: #8a8f99;
1430
+ font-size: 19rpx;
1431
+ }
1432
+
1433
+ .tool-row,
1434
+ .loading-session {
1435
+ color: #8a8f99;
1436
+ font-size: 22rpx;
1437
+ line-height: 34rpx;
1438
+ }
1439
+
1440
+ .tool-row {
1441
+ margin-top: 12rpx;
1442
+ }
1443
+
1444
+ .loading-session {
1445
+ padding: 20rpx 0;
1446
+ text-align: center;
1447
+ }
1448
+
1449
+ .chat-bottom {
1450
+ width: 2rpx;
1451
+ height: 2rpx;
1452
+ }
1453
+ </style>