messageMedia.test.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
  1. import { mount } from '@vue/test-utils'
  2. import { describe, expect, it } from 'vitest'
  3. import MarkdownContent from '@/components/messages/MarkdownContent.vue'
  4. import { normalizeChatMessage } from '@/utils/normalize'
  5. import { chatMessageToUi, workspaceMessagesToUi } from '@/components/workspace/viewModels'
  6. import type { WorkspaceMessage } from '@shared/workspace'
  7. const attachments = [
  8. { id: 'doc', name: 'bamboo-sample.txt', size: 134, kind: 'file' as const, url: '/api/app/files/doc/download' },
  9. { id: 'pic', name: 'bamboo.jpg', kind: 'image' as const, url: '/api/app/files/pic/preview' },
  10. ]
  11. describe('ordered message media', () => {
  12. it('keeps media between prose bubbles and deduplicates trailing attachments', async () => {
  13. const wrapper = mount(MarkdownContent, { props: {
  14. separateMedia: true, bubble: true, fileCards: true, attachments,
  15. content: '先看图片。\n\n![竹叶](/api/app/files/pic/preview)\n\n再看文件。\n\n[bamboo-sample.txt](/api/app/files/doc/download)\n\n最后一段。',
  16. } })
  17. expect([...wrapper.get('.message-parts').element.children].map(el => el.className)).toEqual(['message-prose chat-bubble', 'message-media', 'message-prose chat-bubble', 'message-file', 'message-prose chat-bubble'])
  18. expect(wrapper.findAll('.message-file')).toHaveLength(1)
  19. expect(wrapper.find('.chat-bubble img').exists()).toBe(false)
  20. expect(wrapper.get('.message-file__size').text()).toBe('134 B')
  21. await wrapper.get('.message-file').trigger('click')
  22. expect(wrapper.emitted('preview')?.[0]?.[0]).toEqual(attachments[0])
  23. })
  24. it('does not turn code examples or inline prose links into separate attachments', () => {
  25. const wrapper = mount(MarkdownContent, { props: { separateMedia: true, bubble: true, fileCards: true,
  26. content: '```md\n![示例](/api/app/files/pic/preview)\n```\n\n请阅读[文档](/api/app/files/doc/download),然后继续。',
  27. } })
  28. expect(wrapper.findAll('.message-media, .message-file')).toHaveLength(0)
  29. expect(wrapper.get('.chat-bubble').text()).toContain('请阅读文档,然后继续。')
  30. })
  31. it('preserves typed content order through normalization without parsing user Markdown', () => {
  32. const message = chatMessageToUi(normalizeChatMessage({ id: 'ordered', role: 'user', content: [
  33. { type: 'text', text: '**原样文字**' },
  34. { type: 'file', id: 'doc', name: 'bamboo-sample.txt', url: '/api/app/files/doc/download', size: 134 },
  35. { type: 'text', text: '结尾' },
  36. ] }, 'session'))
  37. const wrapper = mount(MarkdownContent, { props: { content: message.content, attachments: message.attachments, contentParts: message.contentParts, separateMedia: true, bubble: true, plain: true } })
  38. expect([...wrapper.get('.message-parts').element.children].map(el => el.textContent)).toEqual(['**原样文字**', 'bamboo-sample.txt134 B', '结尾'])
  39. })
  40. it('retains workspace metadata after resolving an inline path', () => {
  41. const message = workspaceMessagesToUi([{ id: 'w', role: 'assistant', content: '[文件](/tmp/bamboo-sample.txt)', attachments: [{ id: 'doc', name: 'bamboo-sample.txt', size: 134, mimeType: 'text/plain', sourcePath: '/tmp/bamboo-sample.txt' }], tools: [], status: 'complete' } as any])[0]!
  42. const wrapper = mount(MarkdownContent, { props: { content: message.content, attachments: message.attachments, separateMedia: true, fileCards: true } })
  43. expect(wrapper.findAll('.message-file')).toHaveLength(1)
  44. expect(wrapper.text()).toBe('bamboo-sample.txt134 B')
  45. })
  46. it.each([
  47. '/Users/samien/Agents/zhuer/2026-09-22/dingtalk-current-page.png',
  48. '/tmp/截图 文件(1).png',
  49. '/tmp/screenshot%20copy.png',
  50. ])('renders a workspace MEDIA reference and its archived attachment once: %s', async sourcePath => {
  51. const original: WorkspaceMessage = {
  52. id: 'screenshot', conversationId: 'chat', seq: 1, role: 'assistant', agentId: 'bot',
  53. content: `当前画面:\n\nMEDIA:${sourcePath}\n\n截图完成。`, reasoning: '',
  54. status: 'complete', createdAt: 1, tools: [],
  55. attachments: [{ id: 'screenshot-file', name: sourcePath.split('/').at(-1)!, sourcePath, mimeType: 'image/png', size: 3590111, sender: 'agent', createdAt: 1 }],
  56. }
  57. const message = workspaceMessagesToUi([original])[0]!
  58. const wrapper = mount(MarkdownContent, { props: {
  59. content: message.content, attachments: message.attachments, separateMedia: true, fileCards: true, legacyMedia: true,
  60. } })
  61. expect(wrapper.findAll('.message-media')).toHaveLength(1)
  62. expect(wrapper.get('img').attributes('src')).toBe('/api/app/files/screenshot-file/preview')
  63. expect([...wrapper.get('.message-parts').element.children].map(el => el.className)).toEqual(['message-prose', 'message-media', 'message-prose'])
  64. await wrapper.get('.message-media__image').trigger('click')
  65. expect(wrapper.emitted('preview')?.[0]?.[0]).toEqual(message.attachments![0])
  66. expect(original.content).toContain(`MEDIA:${sourcePath}`)
  67. wrapper.unmount()
  68. })
  69. it('reconciles a late workspace attachment without duplicating the streamed image or hiding a distinct same-name file', async () => {
  70. const original: WorkspaceMessage = {
  71. id: 'stream', conversationId: 'chat', seq: 1, role: 'assistant', agentId: 'bot',
  72. content: 'MEDIA:/tmp/first/screenshot.png\n', reasoning: '', status: 'streaming', createdAt: 1, tools: [], attachments: [],
  73. }
  74. const props = () => {
  75. const message = workspaceMessagesToUi([original])[0]!
  76. return { content: message.content, attachments: message.attachments, streaming: message.status === 'streaming' }
  77. }
  78. const wrapper = mount(MarkdownContent, { props: { ...props(), separateMedia: true, fileCards: true, legacyMedia: true } })
  79. expect(wrapper.findAll('img')).toHaveLength(1)
  80. original.status = 'complete'
  81. original.attachments = ['first', 'second'].map(id => ({ id, name: 'screenshot.png', sourcePath: `/tmp/${id}/screenshot.png`, mimeType: 'image/png', size: 123, sender: 'agent', createdAt: 1 }))
  82. await wrapper.setProps(props())
  83. expect(wrapper.findAll('img').map(image => image.attributes('src'))).toEqual(['/api/app/files/first/preview', '/api/app/files/second/preview'])
  84. wrapper.unmount()
  85. })
  86. })