Explorar el Código

修复 MEDIA 图片引用与归档附件重复展示

samien hace 1 mes
padre
commit
53b1218ad3

+ 8 - 2
src/client/components/workspace/viewModels.ts

@@ -392,8 +392,14 @@ export function workspaceMessagesToUi(messages: import('@shared/workspace').Work
     taskReference: message.taskReference,
     taskReference: message.taskReference,
     error: message.error, runId: message.runId,
     error: message.error, runId: message.runId,
     profile: message.agentId, createdAt: message.createdAt,
     profile: message.agentId, createdAt: message.createdAt,
-    content: (message.communication?.content ?? (message.role === 'assistant' ? visibleMessageText(message.content) : message.content)).replace(/(!?\[[^\]]*\])\(<?([^)>]+)>?\)/g, (whole, label: string, path: string) => {
-      const file = message.attachments.find(file => file.sourcePath === path)
+    // Resolve legacy MEDIA references before mapping paths to archived file URLs,
+    // so the renderer can identify body media and trailing attachments as one file.
+    content: (message.communication?.content ?? (message.role === 'assistant'
+      ? normalizeAssistantMediaMarkdown(visibleMessageText(message.content), message.status === 'streaming')
+      : message.content)).replace(/(!?\[[^\]]*\])\(<?([^)>]+)>?\)/g, (whole, label: string, path: string) => {
+      const sourcePath = serverFilePath(path)
+      const file = message.attachments.find(file => file.sourcePath === path
+        || (sourcePath && file.sourcePath && serverFilePath(file.sourcePath, false) === sourcePath))
       return file ? `${label}(/api/app/files/${file.id}/${label.startsWith('!') ? 'preview' : 'download'})` : whole
       return file ? `${label}(/api/app/files/${file.id}/${label.startsWith('!') ? 'preview' : 'download'})` : whole
     }),
     }),
     reasoning: message.role === 'assistant' ? messageReasoningText(message.content, message.reasoning) : message.reasoning,
     reasoning: message.role === 'assistant' ? messageReasoningText(message.content, message.reasoning) : message.reasoning,

+ 41 - 0
tests/client/messageMedia.test.ts

@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
 import MarkdownContent from '@/components/messages/MarkdownContent.vue'
 import MarkdownContent from '@/components/messages/MarkdownContent.vue'
 import { normalizeChatMessage } from '@/utils/normalize'
 import { normalizeChatMessage } from '@/utils/normalize'
 import { chatMessageToUi, workspaceMessagesToUi } from '@/components/workspace/viewModels'
 import { chatMessageToUi, workspaceMessagesToUi } from '@/components/workspace/viewModels'
+import type { WorkspaceMessage } from '@shared/workspace'
 
 
 const attachments = [
 const attachments = [
   { id: 'doc', name: 'bamboo-sample.txt', size: 134, kind: 'file' as const, url: '/api/app/files/doc/download' },
   { id: 'doc', name: 'bamboo-sample.txt', size: 134, kind: 'file' as const, url: '/api/app/files/doc/download' },
@@ -43,4 +44,44 @@ describe('ordered message media', () => {
     expect(wrapper.findAll('.message-file')).toHaveLength(1)
     expect(wrapper.findAll('.message-file')).toHaveLength(1)
     expect(wrapper.text()).toBe('bamboo-sample.txt134 B')
     expect(wrapper.text()).toBe('bamboo-sample.txt134 B')
   })
   })
+  it.each([
+    '/Users/samien/Agents/zhuer/2026-09-22/dingtalk-current-page.png',
+    '/tmp/截图 文件(1).png',
+    '/tmp/screenshot%20copy.png',
+  ])('renders a workspace MEDIA reference and its archived attachment once: %s', async sourcePath => {
+    const original: WorkspaceMessage = {
+      id: 'screenshot', conversationId: 'chat', seq: 1, role: 'assistant', agentId: 'bot',
+      content: `当前画面:\n\nMEDIA:${sourcePath}\n\n截图完成。`, reasoning: '',
+      status: 'complete', createdAt: 1, tools: [],
+      attachments: [{ id: 'screenshot-file', name: sourcePath.split('/').at(-1)!, sourcePath, mimeType: 'image/png', size: 3590111, sender: 'agent', createdAt: 1 }],
+    }
+    const message = workspaceMessagesToUi([original])[0]!
+    const wrapper = mount(MarkdownContent, { props: {
+      content: message.content, attachments: message.attachments, separateMedia: true, fileCards: true, legacyMedia: true,
+    } })
+    expect(wrapper.findAll('.message-media')).toHaveLength(1)
+    expect(wrapper.get('img').attributes('src')).toBe('/api/app/files/screenshot-file/preview')
+    expect([...wrapper.get('.message-parts').element.children].map(el => el.className)).toEqual(['message-prose', 'message-media', 'message-prose'])
+    await wrapper.get('.message-media__image').trigger('click')
+    expect(wrapper.emitted('preview')?.[0]?.[0]).toEqual(message.attachments![0])
+    expect(original.content).toContain(`MEDIA:${sourcePath}`)
+    wrapper.unmount()
+  })
+  it('reconciles a late workspace attachment without duplicating the streamed image or hiding a distinct same-name file', async () => {
+    const original: WorkspaceMessage = {
+      id: 'stream', conversationId: 'chat', seq: 1, role: 'assistant', agentId: 'bot',
+      content: 'MEDIA:/tmp/first/screenshot.png\n', reasoning: '', status: 'streaming', createdAt: 1, tools: [], attachments: [],
+    }
+    const props = () => {
+      const message = workspaceMessagesToUi([original])[0]!
+      return { content: message.content, attachments: message.attachments, streaming: message.status === 'streaming' }
+    }
+    const wrapper = mount(MarkdownContent, { props: { ...props(), separateMedia: true, fileCards: true, legacyMedia: true } })
+    expect(wrapper.findAll('img')).toHaveLength(1)
+    original.status = 'complete'
+    original.attachments = ['first', 'second'].map(id => ({ id, name: 'screenshot.png', sourcePath: `/tmp/${id}/screenshot.png`, mimeType: 'image/png', size: 123, sender: 'agent', createdAt: 1 }))
+    await wrapper.setProps(props())
+    expect(wrapper.findAll('img').map(image => image.attributes('src'))).toEqual(['/api/app/files/first/preview', '/api/app/files/second/preview'])
+    wrapper.unmount()
+  })
 })
 })