Przeglądaj źródła

feat: 兼容助手历史媒体 Markdown

samien 2 miesięcy temu
rodzic
commit
ad2f903b36

+ 8 - 2
src/client/components/messages/MarkdownContent.vue

@@ -3,11 +3,13 @@ import DOMPurify from 'dompurify'
 import MarkdownIt from 'markdown-it'
 import { computed, onMounted, onUpdated, ref } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
+import { normalizeAssistantMediaMarkdown } from '@/utils/mediaMarkdown'
 
 const props = withDefaults(defineProps<{
   content: string
   streaming?: boolean
-}>(), { streaming: false })
+  legacyMedia?: boolean
+}>(), { streaming: false, legacyMedia: false })
 
 const root = ref<HTMLElement | null>(null)
 const copied = ref('')
@@ -27,7 +29,11 @@ md.renderer.rules.link_open = (tokens, index, options, env, self) => {
   return defaultLinkOpen(tokens, index, options, env, self)
 }
 
-const rendered = computed(() => DOMPurify.sanitize(md.render(props.content || ''), {
+const markdownSource = computed(() => props.legacyMedia
+  ? normalizeAssistantMediaMarkdown(props.content || '', props.streaming)
+  : props.content || '')
+
+const rendered = computed(() => DOMPurify.sanitize(md.render(markdownSource.value), {
   USE_PROFILES: { html: true },
   FORBID_TAGS: ['style', 'svg', 'math', 'form', 'input', 'button', 'iframe', 'object', 'embed'],
   FORBID_ATTR: ['style', 'onerror', 'onclick', 'onload'],

+ 5 - 1
src/client/components/messages/MessageTimeline.vue

@@ -164,7 +164,11 @@ defineExpose({ scrollToMessage, scrollToBottom })
             </div>
 
             <div class="message__content">
-              <MarkdownContent :content="message.content" :streaming="message.status === 'streaming'" />
+              <MarkdownContent
+                :content="message.content"
+                :streaming="message.status === 'streaming'"
+                :legacy-media="message.role === 'assistant'"
+              />
             </div>
 
             <div v-if="message.role !== 'user' && message.attachments?.length" class="message__attachments">

+ 114 - 0
src/client/utils/mediaMarkdown.ts

@@ -0,0 +1,114 @@
+const IMAGE_EXTENSIONS = new Set([
+  'apng', 'avif', 'bmp', 'gif', 'heic', 'heif', 'ico', 'jfi', 'jfif', 'jif',
+  'jpe', 'jpeg', 'jpg', 'jxl', 'png', 'svg', 'tif', 'tiff', 'webp',
+])
+const MEDIA_MARKER = /MEDIA:\s*/g
+const FENCE = /^\s{0,3}(`{3,}|~{3,})/
+const HEADING = /^\s{0,3}#{1,6}(?:\s|$)/
+
+type MediaMatch = { path: string; end: number }
+
+function inlineCodeRanges(line: string): Array<[number, number]> {
+  const ranges: Array<[number, number]> = []
+  let index = 0
+  while (index < line.length) {
+    if (line[index] !== '`' || line[index - 1] === '\\') {
+      index += 1
+      continue
+    }
+    const start = index
+    while (line[index] === '`') index += 1
+    const fence = line.slice(start, index)
+    const end = line.indexOf(fence, index)
+    if (end < 0) {
+      ranges.push([start, line.length])
+      break
+    }
+    ranges.push([start, end + fence.length])
+    index = end + fence.length
+  }
+  return ranges
+}
+
+function isInRanges(index: number, ranges: Array<[number, number]>): boolean {
+  return ranges.some(([start, end]) => index >= start && index < end)
+}
+
+function isSafeAbsolutePath(path: string): boolean {
+  if (!path.startsWith('/') || path.startsWith('//') || /\0/.test(path)) return false
+  return !path.split('/').some(segment => segment === '..' || /^%2e%2e$/i.test(segment))
+}
+
+function unquotedPath(line: string, start: number, streaming: boolean): MediaMatch | undefined {
+  if (line[start] !== '/') return undefined
+  const tail = line.slice(start)
+  const extension = /\.[A-Za-z0-9]{1,12}(?=$|[\s,,。;;::!?!?\])}])/g
+  const match = extension.exec(tail)
+  if (!match) return undefined
+  const end = start + match.index + match[0].length
+  // A streamed unquoted path is ambiguous until the producer has emitted a
+  // line boundary. Rendering early can turn a later path suffix into prose.
+  if (streaming && end === line.length) return undefined
+  return { path: line.slice(start, end), end }
+}
+
+function readMediaPath(line: string, start: number, streaming: boolean): MediaMatch | undefined {
+  let cursor = start
+  while (/\s/.test(line[cursor] ?? '')) cursor += 1
+  const quote = line[cursor]
+  if (quote === '"' || quote === "'" || quote === '`') {
+    const close = line.indexOf(quote, cursor + 1)
+    if (close < 0) return undefined
+    return { path: line.slice(cursor + 1, close), end: close + 1 }
+  }
+  return unquotedPath(line, cursor, streaming)
+}
+
+function escapeLabel(value: string): string {
+  return value.replace(/([\\\[\]])/g, '\\$1')
+}
+
+function asMarkdown(path: string): string {
+  const fileName = path.split('/').filter(Boolean).at(-1) || '文件'
+  const extension = fileName.split('.').at(-1)?.toLocaleLowerCase() || ''
+  const label = escapeLabel(fileName)
+  return IMAGE_EXTENSIONS.has(extension) ? `![${label}](${path})` : `[${label}](${path})`
+}
+
+function transformLine(line: string, streaming: boolean, terminated: boolean): string {
+  if (HEADING.test(line) || line.includes('|')) return line
+  const codeRanges = inlineCodeRanges(line)
+  let output = ''
+  let cursor = 0
+  MEDIA_MARKER.lastIndex = 0
+  for (let marker = MEDIA_MARKER.exec(line); marker; marker = MEDIA_MARKER.exec(line)) {
+    const markerStart = marker.index
+    if (isInRanges(markerStart, codeRanges)) continue
+    const media = readMediaPath(line, markerStart + marker[0].length, streaming && !terminated)
+    if (!media || !isSafeAbsolutePath(media.path)) continue
+    output += line.slice(cursor, markerStart)
+    output += asMarkdown(media.path)
+    cursor = media.end
+    MEDIA_MARKER.lastIndex = media.end
+  }
+  return cursor ? output + line.slice(cursor) : line
+}
+
+/**
+ * Compatibility for historical assistant output. New agent output is already
+ * Markdown and deliberately bypasses any message mutation at send time.
+ */
+export function normalizeAssistantMediaMarkdown(content: string, streaming = false): string {
+  let inFence = false
+  const parts = content.split(/(\r?\n)/)
+  return parts.map((part, index) => {
+    if (part === '\n' || part === '\r\n') return part
+    const fence = FENCE.exec(part)
+    if (fence) {
+      inFence = !inFence
+      return part
+    }
+    const terminated = parts[index + 1] === '\n' || parts[index + 1] === '\r\n'
+    return inFence ? part : transformLine(part, streaming, terminated)
+  }).join('')
+}

+ 36 - 0
tests/client/mediaMarkdown.test.ts

@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest'
+import { normalizeAssistantMediaMarkdown } from '@/utils/mediaMarkdown'
+
+describe('historical assistant MEDIA compatibility', () => {
+  it('converts image and non-image absolute paths to standard Markdown', () => {
+    expect(normalizeAssistantMediaMarkdown('MEDIA:/Users/samien/图片 文件.jpg\nMEDIA:/tmp/报告.docx')).toBe(
+      '![图片 文件.jpg](/Users/samien/图片 文件.jpg)\n[报告.docx](/tmp/报告.docx)',
+    )
+  })
+
+  it('accepts quoted paths with Chinese, spaces, quotes, and backticks', () => {
+    expect(normalizeAssistantMediaMarkdown("MEDIA:' /not-a-path'\nMEDIA:\"/Users/瑶儿/带 空格 '引号'.png\"\nMEDIA:\"/tmp/含`反引号`.pdf\"\nMEDIA:`/tmp/反引号.pdf`")).toBe(
+      "MEDIA:' /not-a-path'\n![带 空格 '引号'.png](/Users/瑶儿/带 空格 '引号'.png)\n[含`反引号`.pdf](/tmp/含`反引号`.pdf)\n[反引号.pdf](/tmp/反引号.pdf)",
+    )
+  })
+
+  it('waits for an unquoted streaming path to reach a line boundary', () => {
+    expect(normalizeAssistantMediaMarkdown('MEDIA:/tmp/正在生成.png', true)).toBe('MEDIA:/tmp/正在生成.png')
+    expect(normalizeAssistantMediaMarkdown('MEDIA:/tmp/正在生成.png\n下一行', true)).toBe('![正在生成.png](/tmp/正在生成.png)\n下一行')
+    expect(normalizeAssistantMediaMarkdown('MEDIA:`/tmp/已完整.png`', true)).toBe('![已完整.png](/tmp/已完整.png)')
+  })
+
+  it('does not transform code, headings, tables, URLs, or traversal paths', () => {
+    const input = [
+      '```text',
+      'MEDIA:/tmp/inside-code.png',
+      '```',
+      '`MEDIA:/tmp/inline.png`',
+      '# MEDIA:/tmp/title.png',
+      '| MEDIA:/tmp/table.png | value |',
+      'MEDIA:https://example.com/remote.png',
+      'MEDIA:/tmp/../secret.png',
+    ].join('\n')
+    expect(normalizeAssistantMediaMarkdown(input)).toBe(input)
+  })
+})

+ 10 - 0
tests/e2e/workspace.spec.ts

@@ -50,6 +50,16 @@ test('restores a routed conversation even when unread-count refresh fails', asyn
   await expect(page.getByText('已整理完成。下面是', { exact: false })).toBeVisible()
 })
 
+test('renders historical assistant MEDIA as Markdown in chat and group chat', async ({ page }) => {
+  await page.goto('/chat/session-demo')
+  await expect(page.locator('.markdown img[alt="AppIcon-1024.png"]')).toBeVisible()
+  await expect(page.locator('.message--user .markdown')).toContainText('MEDIA:/brand/AppIcon-1024.png')
+  await expect(page.locator('.message--user .markdown img')).toHaveCount(0)
+
+  await page.getByRole('button', { name: '群聊' }).click()
+  await expect(page.locator('.markdown img[alt="AppIcon-1024.png"]')).toBeVisible()
+})
+
 test('keeps the canonical logo and yaoyao-webui composer geometry', async ({ page }) => {
   const logo = page.locator('.rail__brand img')
   await expect(logo).toHaveAttribute('src', '/brand/AppIcon-1024.png')

+ 6 - 3
tests/fixtures/fake-hermes.mjs

@@ -39,14 +39,17 @@ const sessions = [
   { id: 'session-demo', profile: 'yaoyao', source: 'web', title: '夭夭 Web 验收会话', preview: '文件库与群聊已经就绪', message_count: 4, tool_call_count: 1, started_at: now() - 3600, last_active_at: now(), pinned: true, model: 'gpt-5.6', provider: 'openai' },
 ]
 const messages = [
-  { id: 'message-user', role: 'user', content: '请检查今天生成的产物。', timestamp: now() - 180 },
+  { id: 'message-user', role: 'user', content: '请检查今天生成的产物。MEDIA:/brand/AppIcon-1024.png', timestamp: now() - 180 },
   { id: 'message-assistant', role: 'assistant', content: '已整理完成。下面是 **验收摘要**:\n\n- 普通聊天已连接\n- 群聊 v2 已就绪\n- [打开报告](https://example.com/report)', reasoning_content: '先核对会话与文件索引。', timestamp: now() - 170 },
   { id: 'message-tool', role: 'tool', tool_name: 'file_search', tool_call_id: 'tool-1', summary: '扫描产物目录', result: { path: '/tmp/demo-report.pdf', count: 1 }, timestamp: now() - 175 },
   { id: 'message-image', role: 'assistant', content: '预览图:![夭夭 Logo](/brand/AppIcon-1024.png)', timestamp: now() - 160 },
+  { id: 'message-legacy-media', role: 'assistant', content: '历史兼容:MEDIA:/brand/AppIcon-1024.png', timestamp: now() - 150 },
 ]
 const groupAgent = { id: agentId, roomId, profile: 'yaoyao', displayName: '夭夭', description: '主 Agent', enabled: true, replyWithoutMention: true, status: 'idle', createdAt: now() - 2000, updatedAt: now() }
 const groupMessage = { seq: 1, id: '44444444-4444-4444-8444-444444444444', roomId, senderKind: 'human', senderId: 'demo-user', senderName: '验收用户', content: '大家好,检查一下群聊输入框。', status: 'completed', createdAt: now() - 120, updatedAt: now() - 120 }
-const room = { id: roomId, name: '设计与工程协作', cwd: '/tmp', createdAt: now() - 2000, updatedAt: now(), archived: false, agentCount: 1, maxReplyRounds: 3, lastMessage: groupMessage, unreadCount: 0 }
+const groupAssistantMessage = { seq: 2, id: '55555555-5555-4555-8555-555555555555', roomId, senderKind: 'agent', senderId: agentId, senderName: '夭夭', content: '历史兼容:MEDIA:/brand/AppIcon-1024.png', status: 'completed', createdAt: now() - 100, updatedAt: now() - 100 }
+const groupMessages = [groupMessage, groupAssistantMessage]
+const room = { id: roomId, name: '设计与工程协作', cwd: '/tmp', createdAt: now() - 2000, updatedAt: now(), archived: false, agentCount: 1, maxReplyRounds: 3, lastMessage: groupAssistantMessage, unreadCount: 0 }
 const previewPdf = Buffer.from('JVBERi0xLjQKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCAyMDAgMjAwXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMTAgMDAwMDAgbiAKMDAwMDAwMDA1MyAwMDAwMCBuIAowMDAwMDAwMTA1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMTY2CiUlRU9G', 'base64')
 
 const server = createServer(async (request, response) => {
@@ -75,7 +78,7 @@ const server = createServer(async (request, response) => {
   if (url.pathname === '/api/plugins/yaoyao/v1/capabilities') return json(response, 200, { protocolVersion: 2, journalEpoch: epoch, latestCursor: 0, limits: { maxAgentsPerRoom: 8, maxMessageBytes: 65536, maxMessagePageSize: 100, defaultMaxReplyRounds: 3, unlimitedReplyRoundsValue: -1, maxAgentDisplayNameLength: 100 }, eventTypes: ['message.upsert', 'agent.status'] })
   if (url.pathname === '/api/plugins/yaoyao/v1/rooms') return json(response, 200, { items: [room], nextCursor: null })
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}`) return json(response, 200, { ...room, agents: [groupAgent], runs: [], pendingInteractions: [], latestCursor: 0 })
-  if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/messages`) return json(response, 200, { items: [groupMessage] })
+  if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/messages`) return json(response, 200, { items: groupMessages })
   if (url.pathname === '/api/plugins/yaoyao/files') return json(response, 200, { items: [{ id: 1, path: '/tmp/demo-report.pdf', name: 'demo-report.pdf', extension: 'pdf', mimeType: 'application/pdf', size: 204800, modifiedAt: now(), exists: true, origins: [{ profile: 'yaoyao', sessionId: 'session-demo', sessionTitle: '夭夭 Web 验收会话', messageId: 'message-assistant', authorKind: 'assistant', authorName: '夭夭', observedAt: now() }] }], nextCursor: null, total: 1 })
   if (url.pathname === '/api/plugins/yaoyao/1/download') {
     response.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Length': previewPdf.length })