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