返回 AiToEarn
convertMessages.ts
根目录 / project / aitoearn-web / src / components / Chat / utils / convertMessages.ts
1 import type { TaskMessage } from '@/api/ai/ai.types'
2 import type { IUploadedMedia } from '@/components/Chat/MediaUpload'
3 import type { IDisplayMessage, IMessageStep, IWorkflowStep } from '@/store/agent'
4 import { parseUserMessageContent } from './parseMessageContent'
5
6 /**
7 * 判断文本是否为占位符(不应该显示给用户)
8 */
9 function isPlaceholderText(text: string | undefined): boolean {
10 if (!text)
11 return true
12 const trimmed = text.trim()
13 if (!trimmed)
14 return true
15
16 // 过滤常见的占位符文本
17 const placeholders = ['(no content)', 'no content', '(empty)', 'empty', '无内容', '(无内容)']
18
19 return placeholders.some(p => trimmed.toLowerCase() === p.toLowerCase())
20 }
21
22 /**
23 * 将后端消息转换为显示格式
24 * 数据结构说明:
25 * - user: { type: 'user', content: [{ type: 'text', text: '...' }] }
26 * - assistant: { type: 'assistant', uuid: '...', message: { content: [{ type: 'text', text: '...' }] } }
27 * - stream_event: 流式事件,用于提取工具调用信息
28 * - result: { type: 'result', message: '...', result: { action: '...', platform: '...', ... } }
29 * - message 字段包含文本内容
30 * - result 字段(根级别)包含 action 数据(如 createChannel、updateChannel 等)
31 *
32 * 改进:解析多步骤和工作流步骤
33 * - message_start 事件标识新步骤开始
34 * - tool_use 事件标识工具调用
35 * - tool_result 事件标识工具结果
36 */
37 export function convertMessages(messages: TaskMessage[]): IDisplayMessage[] {
38 const displayMessages: IDisplayMessage[] = []
39
40 // 临时存储当前 assistant 消息的步骤
41 let currentSteps: IMessageStep[] = []
42 let currentStepContent = ''
43 let currentStepWorkflow: IWorkflowStep[] = []
44 let stepIndex = 0
45 let lastAssistantMsgIndex = -1
46 // 用于在 medias 处理后保留原始 content 供去重检查
47 let contentBeforeMediasProcessing = ''
48
49 // 用于追踪工具调用的 Map
50 const toolCallMap = new Map<string, string>()
51
52 // 用于追踪当前消息轮次(通过 message.id 判断)
53 // 当 message.id 变化时,表示新的一轮消息开始,需要分割步骤
54 let currentMessageId = ''
55
56 /** 保存当前步骤到步骤列表 */
57 const saveCurrentStep = () => {
58 if (currentStepContent.trim() || currentStepWorkflow.length > 0) {
59 currentSteps.push({
60 id: `step-${stepIndex}`,
61 content: currentStepContent.trim(),
62 workflowSteps: [...currentStepWorkflow],
63 isActive: false,
64 timestamp: Date.now(),
65 })
66 stepIndex++
67 }
68 currentStepContent = ''
69 currentStepWorkflow = []
70 }
71
72 /** 将步骤保存到最后一个 assistant 消息 */
73 const saveStepsToMessage = () => {
74 saveCurrentStep()
75 if (currentSteps.length > 0 && lastAssistantMsgIndex >= 0) {
76 const lastMsg = displayMessages[lastAssistantMsgIndex]
77 if (lastMsg && lastMsg.role === 'assistant') {
78 // 合并已有的 steps(保留之前可能由 result 附加的 media-only steps),避免覆盖
79 lastMsg.steps = [...(lastMsg.steps || []), ...currentSteps]
80 }
81 }
82 currentSteps = []
83 stepIndex = 0
84 }
85
86 messages.forEach((msg, index) => {
87 // 如果任意消息体里直接包含根级别的 result(有些 SSE 使用 stream_event 包裹 result),优先处理 medias 并按步骤位置插入
88 const msgAnyCheck = msg as any
89 if (msgAnyCheck && msgAnyCheck.result) {
90 const resultData = msgAnyCheck.result
91 const resultArray = Array.isArray(resultData) ? resultData : [resultData]
92
93 // 保存处理 medias 前的 currentStepContent,用于后续去重检查
94 contentBeforeMediasProcessing = currentStepContent
95
96 resultArray.forEach((item: any, arrIndex: number) => {
97 if (item && item.medias && Array.isArray(item.medias) && item.medias.length > 0) {
98 const convertedMedias = item.medias.map((m: any) => ({
99 url: m.url || m.thumbUrl || '',
100 type: m.type === 'video' ? 'video' : 'image',
101 name: m.name,
102 }))
103
104 // 如果当前有未保存的步骤内容,先保存该步骤,然后把 media step 放到 currentSteps(以便后续合并到最后 assistant 消息中,保证 media 出现在该步骤之后)
105 if (currentStepContent && currentStepContent.trim()) {
106 saveCurrentStep()
107 currentSteps.push({
108 id: `media-step-${Date.now()}-${arrIndex}`,
109 content: '',
110 workflowSteps: [],
111 isActive: false,
112 timestamp: Date.now(),
113 medias: convertedMedias,
114 } as any)
115 }
116 else {
117 // 否则直接附加到最后一条 assistant 消息的 steps(如果存在),或新建一条 assistant 消息
118 const lastMsg = displayMessages[displayMessages.length - 1]
119 const mediaStep = {
120 id: `media-step-${Date.now()}-${arrIndex}`,
121 content: '',
122 workflowSteps: [],
123 isActive: false,
124 timestamp: Date.now(),
125 medias: convertedMedias,
126 }
127 if (lastMsg && lastMsg.role === 'assistant') {
128 if (!lastMsg.steps)
129 lastMsg.steps = []
130 // 尝试将 media 插入到最后一个有文本内容的 step 之后
131 let inserted = false
132 for (let i = lastMsg.steps.length - 1; i >= 0; i--) {
133 const s = lastMsg.steps[i] as any
134 if (s && s.content && String(s.content).trim()) {
135 lastMsg.steps.splice(i + 1, 0, mediaStep as any)
136 inserted = true
137 break
138 }
139 }
140 if (!inserted) {
141 // 如果没有文本 step,但 message 层有 content(未拆分为 step),把 message.content 转为 step,放在前面
142 if (lastMsg.content && String(lastMsg.content).trim()) {
143 const contentStep = {
144 id: `legacy-content-${Date.now()}`,
145 content: lastMsg.content,
146 workflowSteps: [],
147 isActive: false,
148 timestamp: Date.now(),
149 }
150 // 清空 message.content 并保留在 steps 中
151 lastMsg.content = ''
152 lastMsg.steps.push(contentStep as any)
153 }
154 // 最后添加 mediaStep
155 lastMsg.steps.push(mediaStep as any)
156 }
157 }
158 else {
159 displayMessages.push({
160 id: msgAnyCheck.uuid || `result-${index}-${arrIndex}`,
161 role: 'assistant',
162 content: '',
163 status: 'done',
164 steps: [mediaStep as any],
165 })
166 }
167 }
168 }
169 })
170 // 继续后续的 result 内容处理(不返回,仍需执行 processResultMessage 对文本/actions 解析)
171 }
172 if (msg.type === 'user') {
173 // 用户消息处理
174 processUserMessage(
175 msg,
176 index,
177 displayMessages,
178 currentStepWorkflow,
179 toolCallMap,
180 saveStepsToMessage,
181 )
182 }
183 else if (msg.type === 'stream_event') {
184 // 流式事件处理
185 processStreamEvent(msg, currentStepWorkflow, toolCallMap, saveCurrentStep)
186 }
187 else if (msg.type === 'assistant') {
188 // AI 回复消息处理
189 // 检测消息轮次变化:通过 message.id 判断是否是新的一轮
190 // 在详情数据中,同一轮的多条 assistant 消息会有相同的 message.id
191 const messageData = (msg as any).message as any
192 const messageId = messageData?.id || ''
193
194 // 检查是否包含 text 内容(非空文本)
195 const hasText = messageData?.content?.some(
196 (item: any) => item.type === 'text' && item.text?.trim(),
197 )
198
199 // 只在遇到包含 text 的新 message.id 时开始新步骤
200 // 纯 tool_use 消息不触发新步骤,其工作流会合并到当前步骤
201 if (hasText && messageId && currentMessageId && messageId !== currentMessageId) {
202 // 新的消息轮次开始(且有文本内容),保存当前步骤
203 saveCurrentStep()
204 }
205 // 更新当前消息 ID
206 if (messageId) {
207 currentMessageId = messageId
208 }
209
210 const result = processAssistantMessage(
211 msg,
212 index,
213 displayMessages,
214 currentStepWorkflow,
215 toolCallMap,
216 )
217 if (result.contentToAdd) {
218 currentStepContent += (currentStepContent ? '\n\n' : '') + result.contentToAdd
219 }
220 if (result.newAssistantMsgIndex !== undefined) {
221 lastAssistantMsgIndex = result.newAssistantMsgIndex
222 }
223 }
224 else if (msg.type === 'result') {
225 // 结果消息处理
226 const result = processResultMessage(msg, index, displayMessages)
227 // 更严格的去重:比较 trim 后的内容,避免因空白字符导致重复
228 // 注意:如果 medias 处理时调用了 saveCurrentStep(),currentStepContent 可能被重置
229 // 使用 contentBeforeMediasProcessing 作为备选来源进行去重检查
230 const contentToAdd = result.contentToAdd?.trim()
231 const existingContent = currentStepContent.trim()
232 const previousContent = contentBeforeMediasProcessing.trim()
233 // 检查内容是否已存在于当前步骤或之前保存的步骤中
234 const isDuplicate
235 = contentToAdd
236 && (existingContent.includes(contentToAdd)
237 || contentToAdd === existingContent
238 || previousContent.includes(contentToAdd)
239 || contentToAdd === previousContent)
240 if (contentToAdd && !isDuplicate) {
241 currentStepContent += (currentStepContent ? '\n\n' : '') + result.contentToAdd
242 }
243 // 重置 contentBeforeMediasProcessing,避免影响后续消息
244 contentBeforeMediasProcessing = ''
245 if (result.newAssistantMsgIndex !== undefined) {
246 lastAssistantMsgIndex = result.newAssistantMsgIndex
247 }
248 }
249 else if (msg.type === 'error') {
250 if (msg.code === 12001) {
251 // 积分不足:创建 insufficientCredits action 卡片
252 saveStepsToMessage()
253 displayMessages.push({
254 id: `error-${index}`,
255 role: 'assistant',
256 content: '',
257 status: 'done',
258 actions: [{ type: 'insufficientCredits' }],
259 })
260 lastAssistantMsgIndex = displayMessages.length - 1
261 }
262 }
263 })
264
265 // 保存最后的步骤
266 saveStepsToMessage()
267
268 // 后处理:确保每条 assistant 消息都有正确的 content
269 displayMessages.forEach((msg) => {
270 if (msg.role === 'assistant' && msg.steps && msg.steps.length > 0) {
271 const totalContent = msg.steps
272 .map(s => s.content)
273 .filter(Boolean)
274 .join('\n\n')
275 if (totalContent && !msg.content) {
276 msg.content = totalContent
277 }
278 }
279 })
280
281 return displayMessages
282 }
283
284 /** 处理用户消息 */
285 function processUserMessage(
286 msg: TaskMessage,
287 index: number,
288 displayMessages: IDisplayMessage[],
289 currentStepWorkflow: IWorkflowStep[],
290 toolCallMap: Map<string, string>,
291 saveStepsToMessage: () => void,
292 ) {
293 let content = ''
294 const medias: IUploadedMedia[] = []
295 let isToolResult = false
296
297 if (Array.isArray(msg.content)) {
298 // 尝试使用新的解析器解析数组格式
299 const parsed = parseUserMessageContent(msg.content)
300 if (parsed.hasSpecialFormat || parsed.medias.length > 0) {
301 content = parsed.text
302 medias.push(...parsed.medias)
303 }
304 else {
305 // 使用原有逻辑
306 msg.content.forEach((item: any) => {
307 if (item.type === 'text') {
308 content = item.text || ''
309 }
310 else if (item.type === 'image') {
311 medias.push({
312 url: item.source?.url || '',
313 type: 'image',
314 })
315 }
316 else if (item.type === 'video') {
317 medias.push({
318 url: item.source?.url || '',
319 type: 'video',
320 })
321 }
322 else if (item.type === 'document') {
323 medias.push({
324 url: item.source?.url || '',
325 type: 'document',
326 })
327 }
328 else if (item.type === 'tool_result') {
329 isToolResult = true
330 }
331 })
332 }
333 }
334 else if (typeof msg.content === 'string') {
335 // 尝试使用新的解析器解析字符串格式
336 const parsed = parseUserMessageContent(msg.content)
337 content = parsed.text
338 if (parsed.medias.length > 0) {
339 medias.push(...parsed.medias)
340 }
341 }
342
343 // 只有非工具结果的用户消息才显示(包含文本或媒体)
344 if ((content || medias.length > 0) && !isToolResult) {
345 saveStepsToMessage()
346
347 displayMessages.push({
348 id: msg.uuid || `user-${index}`,
349 role: 'user',
350 content,
351 medias: medias.length > 0 ? medias : undefined,
352 status: 'done',
353 })
354 }
355
356 // 处理工具结果(合并到对应的 tool_call 的 result 字段)
357 // 这样 UI 渲染时可以通过 result 字段判断工具是否已完成
358 if ((msg as any).message) {
359 const userMsg = (msg as any).message as any
360 const contentArray = userMsg?.content || userMsg?.message?.content
361 if (contentArray && Array.isArray(contentArray)) {
362 contentArray.forEach((item: any) => {
363 if (item.type === 'tool_result' && item.tool_use_id) {
364 // 提取结果文本
365 let resultText = ''
366 if (Array.isArray(item.content)) {
367 item.content.forEach((rc: any) => {
368 if (rc.type === 'text') {
369 resultText = rc.text || ''
370 }
371 })
372 }
373 else if (typeof item.content === 'string') {
374 resultText = item.content
375 }
376
377 // 查找对应的 tool_call 并设置 result 字段
378 if (resultText) {
379 const toolCall = currentStepWorkflow.find(
380 s => s.type === 'tool_call' && s.id === item.tool_use_id,
381 )
382 if (toolCall) {
383 toolCall.result = resultText
384 }
385 }
386 }
387 })
388 }
389 }
390
391 // 从 tool_use_result 字段获取工具结果
392 if ((msg as any).tool_use_result) {
393 const results = (msg as any).tool_use_result
394 if (Array.isArray(results)) {
395 results.forEach((result: any) => {
396 if (result.type === 'text' && result.text) {
397 // 查找最后一个没有 result 的 tool_call
398 const lastToolCall = [...currentStepWorkflow]
399 .reverse()
400 .find(s => s.type === 'tool_call' && !s.result)
401 if (lastToolCall) {
402 lastToolCall.result = result.text
403 }
404 }
405 })
406 }
407 }
408 }
409
410 /** 处理流式事件 */
411 function processStreamEvent(
412 msg: TaskMessage,
413 currentStepWorkflow: IWorkflowStep[],
414 toolCallMap: Map<string, string>,
415 saveCurrentStep: () => void,
416 ) {
417 const streamEvent = msg as any
418 const event = streamEvent.event
419
420 // message_start 表示新的一轮消息开始(新步骤)
421 if (event?.type === 'message_start') {
422 saveCurrentStep()
423 }
424
425 // 工具调用开始
426 if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
427 const toolName = event.content_block.name || 'Unknown Tool'
428 const toolId = event.content_block.id || `tool-${Date.now()}`
429
430 toolCallMap.set(toolId, toolName)
431
432 currentStepWorkflow.push({
433 id: toolId,
434 type: 'tool_call',
435 toolName,
436 content: '',
437 isActive: false,
438 timestamp: Date.now(),
439 })
440 }
441
442 // 工具调用参数
443 if (event?.type === 'content_block_delta' && event.delta?.type === 'input_json_delta') {
444 const lastToolCall = currentStepWorkflow.findLast(s => s.type === 'tool_call')
445 if (lastToolCall) {
446 lastToolCall.content = (lastToolCall.content || '') + (event.delta.partial_json || '')
447 }
448 }
449 }
450
451 /** 处理 assistant 消息 */
452 function processAssistantMessage(
453 msg: TaskMessage,
454 index: number,
455 displayMessages: IDisplayMessage[],
456 currentStepWorkflow: IWorkflowStep[],
457 toolCallMap: Map<string, string>,
458 ): { contentToAdd?: string, newAssistantMsgIndex?: number } {
459 let content = ''
460 const messageData = (msg as any).message as any
461
462 if (messageData?.content && Array.isArray(messageData.content)) {
463 messageData.content.forEach((item: any) => {
464 if (item.type === 'text') {
465 content += item.text || ''
466 }
467 else if (item.type === 'tool_use') {
468 const toolName = item.name || 'Unknown Tool'
469 const toolId = item.id || `tool-${Date.now()}`
470 const toolInput = item.input ? JSON.stringify(item.input, null, 2) : ''
471
472 toolCallMap.set(toolId, toolName)
473
474 const existingCall = currentStepWorkflow.find(s => s.id === toolId)
475 if (existingCall) {
476 existingCall.content = toolInput
477 existingCall.isActive = false
478 }
479 else {
480 currentStepWorkflow.push({
481 id: toolId,
482 type: 'tool_call',
483 toolName,
484 content: toolInput,
485 isActive: false,
486 timestamp: Date.now(),
487 })
488 }
489 }
490 })
491 }
492
493 // 检查是否需要创建新的 assistant 消息
494 const lastMsg = displayMessages[displayMessages.length - 1]
495 if (!lastMsg || lastMsg.role !== 'assistant') {
496 displayMessages.push({
497 id: (msg as any).uuid || `assistant-${index}`,
498 role: 'assistant',
499 content: '',
500 status: 'done',
501 steps: [],
502 })
503 return {
504 contentToAdd: content || undefined,
505 newAssistantMsgIndex: displayMessages.length - 1,
506 }
507 }
508
509 return { contentToAdd: content || undefined }
510 }
511
512 /** 处理结果消息 */
513 function processResultMessage(
514 msg: TaskMessage,
515 index: number,
516 displayMessages: IDisplayMessage[],
517 ): { contentToAdd?: string, newAssistantMsgIndex?: number, actions?: any[], publishFlows?: any[] } {
518 const msgAny = msg as any
519 const msgData = msgAny.message
520 let content = ''
521 let actions: any[] = []
522 let publishFlows: any[] = []
523
524 // 处理 result 消息格式
525 // 格式1: message 是字符串
526 if (msgData && typeof msgData === 'string') {
527 content = msgData
528 }
529 // 格式2: message 是对象,包含 message 文本和 result 数组/对象
530 else if (msgData && typeof msgData === 'object') {
531 // 获取文本消息
532 if (msgData.message && typeof msgData.message === 'string') {
533 content = msgData.message
534 }
535 }
536
537 // 过滤占位符文本
538 if (isPlaceholderText(content)) {
539 content = ''
540 }
541
542 // 解析 result 数据中的 action(支持数组和单个对象)
543 // 注意:后端保存的结构是 msg.result(根级别),不是 msg.message.result
544 // 同时兼容两种格式以防万一
545 const resultData
546 = msgAny.result || (msgData && typeof msgData === 'object' ? msgData.result : null)
547
548 if (resultData) {
549 // 统一转换为数组处理
550 const resultArray = Array.isArray(resultData) ? resultData : [resultData]
551
552 // Map actions (but do NOT attach medias to action cards to avoid duplicate rendering)
553 // Exception: navigateToPublish needs medias to pass to the publish page
554 // 过滤掉 action 为 "none" 的项,因为它们不需要显示为 action 卡片
555 actions = resultArray
556 .filter((item: any) => item && item.action && item.action !== 'none') // 只处理有实际 action 的项
557 .map((item: any) => ({
558 type: item.action, // 映射 action -> type
559 platform: item.platform,
560 accountId: item.accountId,
561 title: item.title,
562 description: item.description,
563 // navigateToPublish 需要 medias 来传递到发布页面,其他类型不需要(避免重复渲染)
564 medias: item.action === 'navigateToPublish' ? item.medias : undefined,
565 tags: item.tags,
566 }))
567
568 // 提取包含 flowId 的发布流程数据
569 publishFlows = resultArray
570 .filter((item: any) => item && item.flowId) // 只处理有 flowId 的项
571 .map((item: any) => ({
572 flowId: item.flowId,
573 platform: item.platform,
574 initialData: {
575 title: item.title,
576 description: item.description,
577 medias: item.medias,
578 },
579 }))
580
581 // medias 的插入逻辑已在外层 convertMessages 的循环中处理,以保证插入顺序正确(避免覆盖或顺序错误)
582 }
583
584 if (content || actions.length > 0 || publishFlows.length > 0) {
585 const lastMsg = displayMessages[displayMessages.length - 1]
586 if (lastMsg && lastMsg.role === 'assistant') {
587 // 将 actions 附加到最后一条 assistant 消息
588 if (actions.length > 0) {
589 lastMsg.actions = [...(lastMsg.actions || []), ...actions]
590 }
591 // 将 publishFlows 附加到最后一条 assistant 消息
592 if (publishFlows.length > 0) {
593 lastMsg.publishFlows = [...(lastMsg.publishFlows || []), ...publishFlows]
594 }
595 return { contentToAdd: content || undefined }
596 }
597 else {
598 displayMessages.push({
599 id: msgAny.uuid || `result-${index}`,
600 role: 'assistant',
601 content: content || '',
602 status: 'done',
603 actions: actions.length > 0 ? actions : undefined,
604 publishFlows: publishFlows.length > 0 ? publishFlows : undefined,
605 })
606 return { newAssistantMsgIndex: displayMessages.length - 1 }
607 }
608 }
609
610 return {}
611 }
612
612 lines TYPESCRIPT