@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.
- package/README.md +31 -0
- package/dist/index.mjs +93 -0
- package/package.json +38 -0
- package/src/api/base/ai_message.ts +373 -0
- package/src/api/base/ai_session.ts +85 -0
- package/src/api/base/ai_tool.ts +23 -0
- package/src/index.ts +16 -0
- package/src/pages.ts +26 -0
- package/src/rpc/base/v1/ai_message.ts +98 -0
- package/src/rpc/base/v1/ai_session.ts +214 -0
- package/src/rpc/base/v1/ai_tool.ts +74 -0
- package/src/rpc/common/v1/enum.ts +71 -0
- package/src/rpc/google/protobuf/timestamp.ts +115 -0
- package/src/views/pages/my/my.vue +238 -0
- package/src/views/pagesMember/ai/components/Composer.vue +219 -0
- package/src/views/pagesMember/ai/components/SessionDrawer.vue +308 -0
- package/src/views/pagesMember/ai/components/WelcomePanel.vue +446 -0
- package/src/views/pagesMember/ai/index.vue +1453 -0
- package/src/views/pagesMember/ai/stream.ts +190 -0
- package/src/views/pagesMember/profile/profile.vue +393 -0
- package/src/views/pagesMember/settings/settings.vue +129 -0
|
@@ -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>
|