Sfoglia il codice sorgente

统一文件卡片识别规则

Samien 1 mese fa
parent
commit
c195e59bc7

+ 31 - 22
src/client/components/messages/MarkdownContent.vue

@@ -7,7 +7,7 @@ import AppIcon from '@/components/common/AppIcon.vue'
 import { copyTextToClipboard } from '@/utils/clipboard'
 import { normalizeAssistantMediaMarkdown } from '@/utils/mediaMarkdown'
 import { repairMarkdownForRender } from '@/utils/markdownRepair'
-import { serverFilePath, serverFileUrl } from '@shared/serverFiles'
+import { isSupportedFilePath, serverFilePath, serverFileUrl } from '@shared/serverFiles'
 
 const props = withDefaults(defineProps<{
   content: string
@@ -194,23 +194,34 @@ function decorateCopyButtons() {
   })
 }
 
-function isFilePathLabel(link: HTMLAnchorElement, url: URL): boolean {
-  const label = link.textContent?.trim() || ''
-  if (!label || /[||\r\n]/.test(label)) return false
-
-  // An explicit absolute path is unambiguous even for extensionless files.
-  if (serverFilePath(label)) return true
+function isStandaloneFileLink(link: HTMLAnchorElement): boolean {
+  if (link.closest('blockquote, table, h1, h2, h3, h4, h5, h6, pre, code, strong, em, del')) return false
+  const parent = link.parentElement
+  if (!parent || (parent.tagName !== 'P' && parent.tagName !== 'LI')) return false
+  for (let node = link.previousSibling; node && node.nodeName !== 'BR'; node = node.previousSibling) {
+    if (node.nodeType !== Node.TEXT_NODE || node.textContent?.trim()) return false
+  }
+  for (let node = link.nextSibling; node && node.nodeName !== 'BR'; node = node.nextSibling) {
+    if (node.nodeType !== Node.TEXT_NODE || node.textContent?.trim()) return false
+  }
+  return true
+}
 
-  const targetPath = url.pathname === '/api/files/download'
-    ? serverFilePath(url.searchParams.get('path') || '')
-    : serverFilePath(url.pathname)
-  const targetName = targetPath?.split('/').at(-1)
-  if (targetName && label === targetName) return true
+function fileTarget(url: URL): { path?: string; remote: boolean } | undefined {
+  if (url.origin !== window.location.origin) return
+  if (/^\/api\/app\/files\/[^/]+\/(?:download|preview)$/i.test(url.pathname)) {
+    return { remote: true }
+  }
+  if (url.pathname !== '/api/files/download') return
+  const path = serverFilePath(url.searchParams.get('path') || '')
+  if (!path || !isSupportedFilePath(path)) return
+  return { path, remote: false }
+}
 
-  // A bare filename remains useful when a remote-node route hides the original
-  // path or the assistant translated the basename. Descriptive titles without
-  // a file extension deliberately stay ordinary links.
-  return /^[^/\\]+\.[A-Za-z0-9]{1,16}$/.test(label)
+function fileCardName(link: HTMLAnchorElement, target: { path?: string; remote: boolean }): string {
+  const basename = target.path?.split('/').at(-1)
+  if (basename) return basename
+  return link.textContent?.trim() || '文件'
 }
 
 function decorateFileLinks() {
@@ -219,16 +230,14 @@ function decorateFileLinks() {
     if (link.dataset.fileCard) return
     let url: URL
     try { url = new URL(link.href, window.location.href) } catch { return }
-    const isAgentFile = /^\/Users\/[^/]+\/Agents\/.+/.test(url.pathname)
-    const isWorkspaceFile = /^\/Users\/[^/]+\/\.hermes\/(?:profiles\/[^/]+\/)?workspace\/.+/.test(url.pathname)
-    const isRemoteNodeFile = /^\/api\/app\/files\/(?:[0-9a-f-]{36}|[0-9]+)\/(?:download|preview)$/i.test(url.pathname)
-    const isServerFile = url.pathname === '/api/files/download' && Boolean(url.searchParams.get('path'))
-    if (url.origin !== window.location.origin || (!isAgentFile && !isWorkspaceFile && !isRemoteNodeFile && !isServerFile) || !isFilePathLabel(link, url)) return
+    const target = fileTarget(url)
+    if (!target || !isStandaloneFileLink(link)) return
     link.dataset.fileCard = 'true'
     link.classList.add('file-link-card')
     link.removeAttribute('target')
     link.removeAttribute('rel')
-    const name = link.textContent?.trim() || decodeURIComponent(url.pathname.split('/').at(-1) || '文件')
+    const name = fileCardName(link, target)
+    link.textContent = name
     link.setAttribute('aria-label', `预览文件 ${name}`)
     link.addEventListener('click', event => {
       event.preventDefault()

+ 14 - 1
src/client/utils/mediaMarkdown.ts

@@ -1,3 +1,5 @@
+import { isSupportedFilePath, serverFilePath, serverFileUrl } from '@shared/serverFiles'
+
 const IMAGE_EXTENSIONS = new Set([
   'apng', 'avif', 'bmp', 'gif', 'heic', 'heif', 'ico', 'jfi', 'jfif', 'jif',
   'jpe', 'jpeg', 'jpg', 'jxl', 'png', 'svg', 'tif', 'tiff', 'webp',
@@ -95,6 +97,16 @@ function transformLine(line: string, streaming: boolean, terminated: boolean): s
   return cursor ? output + line.slice(cursor) : line
 }
 
+function transformStandaloneFilePath(line: string, streaming: boolean): string {
+  if (streaming || HEADING.test(line) || line.includes('|') || line.includes('](')) return line
+  const match = /^(\s*)((?:[-+*]|\d+[.)])\s+)?(.+?)\s*$/.exec(line)
+  if (!match) return line
+  const candidate = match[3]
+  const path = serverFilePath(candidate)
+  if (!path || !serverFileUrl(candidate) || !isSupportedFilePath(path)) return line
+  return `${match[1]}${match[2] || ''}${asMarkdown(path)}`
+}
+
 /**
  * Compatibility for historical assistant output. New agent output is already
  * Markdown and deliberately bypasses any message mutation at send time.
@@ -110,6 +122,7 @@ export function normalizeAssistantMediaMarkdown(content: string, streaming = fal
       return part
     }
     const terminated = parts[index + 1] === '\n' || parts[index + 1] === '\r\n'
-    return inFence ? part : transformLine(part, streaming, terminated)
+    if (inFence) return part
+    return transformStandaloneFilePath(transformLine(part, streaming, terminated), streaming)
   }).join('')
 }

+ 23 - 1
src/shared/serverFiles.ts

@@ -10,8 +10,30 @@ export function serverFilePath(raw: string, decode = true): string | undefined {
   return value.replace(/\/{2,}/g, '/')
 }
 
+const FILE_CARD_EXTENSIONS = new Set([
+  'apng', 'avif', 'bmp', 'gif', 'heic', 'heif', 'ico', 'jfi', 'jfif', 'jif',
+  'jpe', 'jpeg', 'jpg', 'jxl', 'png', 'svg', 'tif', 'tiff', 'webp',
+  'mp4', 'mov', 'm4v', 'webm', 'mkv', 'avi',
+  'mp3', 'wav', 'm4a', 'aac', 'ogg', 'flac',
+  'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'csv', 'tsv', 'rtf',
+  'txt', 'md', 'json', 'jsonl', 'xml', 'yaml', 'yml', 'toml', 'ini', 'log',
+  'html', 'htm', 'css', 'scss', 'less', 'js', 'mjs', 'cjs', 'ts', 'tsx',
+  'jsx', 'vue', 'svelte', 'swift', 'kt', 'kts', 'java', 'py', 'rb', 'go',
+  'rs', 'c', 'cc', 'cpp', 'h', 'hpp', 'cs', 'php', 'sh', 'zsh', 'bash',
+  'fish', 'sql', 'zip', 'rar', '7z', 'tar', 'gz', 'tgz', 'bz2', 'xz',
+])
+
+/** Only promote file types that the preview/download experience understands. */
+export function isSupportedFilePath(raw: string): boolean {
+  const path = serverFilePath(raw)
+  if (!path) return false
+  const name = path.split('/').at(-1) || ''
+  const match = /\.([A-Za-z0-9]{1,16})$/.exec(name)
+  return Boolean(match && FILE_CARD_EXTENSIONS.has(match[1].toLowerCase()))
+}
+
 export function serverFileUrl(raw: string, profile?: string): string | undefined {
   const path = serverFilePath(raw)
-  if (!path || !/^(?:file:|sandbox:)/i.test(raw) && /^\/(?:api|assets|icons|brand|attachments|uploads|media|chat|history|conversations|kanban)(?:\/|$)/.test(path)) return
+  if (!path || !/^(?:file:|sandbox:)/i.test(raw) && /^\/(?:api|assets|icons|brand|attachments|uploads|media|chat|history|conversations|kanban|files|artifacts|settings)(?:\/|$)/.test(path)) return
   return `/api/files/download?${new URLSearchParams({ path, preview: '1', ...(profile ? { profile } : {}) })}`
 }

+ 61 - 13
tests/client/markdownContent.test.ts

@@ -44,23 +44,24 @@ describe('MarkdownContent code copy', () => {
 
 describe('Markdown file cards', () => {
   it.each([
-    '/Users/samien/.hermes/workspace/gpt-6-astra-report.md',
-    '/Users/samien/.hermes/profiles/yaoer/workspace/详细 文档.md',
-    'sandbox:/Users/samien/.hermes/workspace/gpt-6-astra-report.md',
-    '/Users/samien/Agents/report.md',
-    '/api/app/files/12/download',
-  ])('renders a preview card for %s', async path => {
+    ['/Users/samien/.hermes/workspace/gpt-6-astra-report.md', 'gpt-6-astra-report.md'],
+    ['/Users/samien/.hermes/profiles/yaoer/workspace/详细 文档.md', '详细 文档.md'],
+    ['sandbox:/Users/samien/.hermes/workspace/gpt-6-astra-report.md', 'gpt-6-astra-report.md'],
+    ['/Users/samien/Agents/report.md', 'report.md'],
+    ['/api/app/files/12/download', '报告.md'],
+    ['/api/app/files/file-ref/download', '报告.md'],
+  ])('renders a preview card for %s', async (path, expectedName) => {
     const wrapper = mount(MarkdownContent, {
       props: { content: `[报告.md](<${path}>)`, fileCards: true },
     })
     const card = wrapper.get('a.file-link-card')
     expect(card.attributes('target')).toBeUndefined()
-    expect(card.attributes('aria-label')).toBe('预览文件 报告.md')
+    expect(card.attributes('aria-label')).toBe(`预览文件 ${expectedName}`)
     await card.trigger('click')
     const href = card.attributes('href')!
     if (!path.startsWith('/api/')) expect(new URL(href, 'http://localhost').searchParams.get('path')).toBe(path.replace(/^sandbox:/, ''))
     else expect(href).toBe(path)
-    expect(wrapper.emitted('fileLink')).toEqual([['报告.md', href]])
+    expect(wrapper.emitted('fileLink')).toEqual([[expectedName, href]])
     wrapper.unmount()
   })
 
@@ -78,7 +79,7 @@ describe('Markdown file cards', () => {
     wrapper.unmount()
   })
 
-  it('keeps descriptive file links as ordinary links instead of file cards', () => {
+  it('uses the verified target instead of descriptive labels to identify and name file cards', () => {
     const wrapper = mount(MarkdownContent, { props: { fileCards: true, content: [
       '[源程序鉴别材料|Word](/Users/samien/.hermes/workspace/源程序鉴别材料.docx)',
       '[源程序鉴别材料|PDF](/Users/samien/.hermes/workspace/源程序鉴别材料.pdf)',
@@ -86,9 +87,56 @@ describe('Markdown file cards', () => {
       '[修改说明与验证结果](/Users/samien/.hermes/workspace/修改说明.md)',
     ].join('\n\n') } })
 
-    expect(wrapper.findAll('.file-link-card')).toHaveLength(0)
-    expect(wrapper.findAll('a')).toHaveLength(4)
-    expect(wrapper.findAll('a').every(link => link.attributes('target') === '_blank')).toBe(true)
+    const cards = wrapper.findAll('.file-link-card')
+    expect(cards.map(card => card.text())).toEqual([
+      '源程序鉴别材料.docx',
+      '源程序鉴别材料.pdf',
+      '信息采集表.xlsx',
+      '修改说明.md',
+    ])
+    expect(cards.map(card => card.attributes('aria-label'))).toEqual([
+      '预览文件 源程序鉴别材料.docx',
+      '预览文件 源程序鉴别材料.pdf',
+      '预览文件 信息采集表.xlsx',
+      '预览文件 修改说明.md',
+    ])
+    wrapper.unmount()
+  })
+
+  it('only promotes standalone paragraph or list-item file links', () => {
+    const wrapper = mount(MarkdownContent, { props: { fileCards: true, content: [
+      '正文中的[报告](/Users/samien/.hermes/workspace/report.pdf)保持行内链接。',
+      '> [引用报告](/Users/samien/.hermes/workspace/quote.pdf)',
+      '# [标题报告](/Users/samien/.hermes/workspace/heading.pdf)',
+      '**[强调报告](/Users/samien/.hermes/workspace/strong.pdf)**',
+      '| 文件 |\n| --- |\n| [表格报告](/Users/samien/.hermes/workspace/table.pdf) |',
+      '- [列表报告](/Users/samien/.hermes/workspace/list.pdf)',
+    ].join('\n\n') } })
+
+    expect(wrapper.findAll('.file-link-card')).toHaveLength(1)
+    expect(wrapper.get('.file-link-card').attributes('aria-label')).toBe('预览文件 list.pdf')
+    expect(wrapper.findAll('a')).toHaveLength(6)
+    wrapper.unmount()
+  })
+
+  it('promotes supported standalone output paths only after completion', async () => {
+    const wrapper = mount(MarkdownContent, {
+      props: { content: '/Users/samien/.hermes/workspace/final report.pdf', fileCards: true, legacyMedia: true, streaming: true },
+    })
+    expect(wrapper.find('.file-link-card').exists()).toBe(false)
+    await wrapper.setProps({ streaming: false })
+    expect(wrapper.get('.file-link-card').attributes('aria-label')).toBe('预览文件 final report.pdf')
+    await wrapper.setProps({ content: '文件在 `/Users/samien/.hermes/workspace/inline.pdf`。' })
+    expect(wrapper.find('.file-link-card').exists()).toBe(false)
+    wrapper.unmount()
+  })
+
+  it('promotes a file link that occupies its own soft-break line', () => {
+    const wrapper = mount(MarkdownContent, { props: {
+      fileCards: true,
+      content: '说明文字\n[下载报告](/Users/samien/.hermes/workspace/report.pdf)\n补充文字',
+    } })
+    expect(wrapper.get('.file-link-card').attributes('aria-label')).toBe('预览文件 report.pdf')
     wrapper.unmount()
   })
 
@@ -185,7 +233,7 @@ describe('streaming Markdown', () => {
 })
 
 it('renders process media as text while retaining message media previews', async () => {
-  const content = '![过程图](/tmp/process.png) [process.pdf](/Users/test/Agents/process.pdf)'
+  const content = '![过程图](/tmp/process.png)\n\n[process.pdf](/Users/test/Agents/process.pdf)'
   const process = mount(MarkdownContent, { props: { content, processContent: true, fileCards: true } })
   await nextTick()
   expect(process.find('img').exists()).toBe(false)

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

@@ -41,4 +41,14 @@ describe('historical assistant MEDIA compatibility', () => {
     ].join('\n')
     expect(normalizeAssistantMediaMarkdown(input)).toBe(input)
   })
+
+  it('converts only completed standalone output paths', () => {
+    const path = '/Users/samien/.hermes/workspace/final report.pdf'
+    expect(destinations(normalizeAssistantMediaMarkdown(path))).toEqual([path])
+    expect(normalizeAssistantMediaMarkdown(path, true)).toBe(path)
+    expect(normalizeAssistantMediaMarkdown(`文件在 \`${path}\`。`)).toBe(`文件在 \`${path}\`。`)
+    expect(normalizeAssistantMediaMarkdown('relative/report.pdf')).toBe('relative/report.pdf')
+    expect(normalizeAssistantMediaMarkdown('/history/report.pdf')).toBe('/history/report.pdf')
+    expect(normalizeAssistantMediaMarkdown('/files/report.pdf')).toBe('/files/report.pdf')
+  })
 })

+ 6 - 1
tests/server/fileAccess.test.ts

@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { authorizeFileRead, readFileAccess, saveFileAccess } from '../../src/server/fileAccess'
-import { serverFilePath, serverFileUrl } from '../../src/shared/serverFiles'
+import { isSupportedFilePath, serverFilePath, serverFileUrl } from '../../src/shared/serverFiles'
 import type { ServerConfig } from '../../src/server/config'
 import type { UpstreamServiceSession } from '../../src/server/localAuth'
 
@@ -72,7 +72,12 @@ describe('server-owned file permissions', () => {
       expect(url.searchParams.get('profile')).toBe('beta')
     }
     expect(serverFileUrl('/api/app/files/id/preview')).toBeUndefined()
+    expect(serverFileUrl('/files/report.pdf')).toBeUndefined()
+    expect(serverFileUrl('/history/report.pdf')).toBeUndefined()
     expect(serverFileUrl('https://example.com/a.png')).toBeUndefined()
     expect(serverFilePath('/tmp/100%25.png', false)).toBe('/tmp/100%25.png')
+    expect(isSupportedFilePath('/tmp/report.docx')).toBe(true)
+    expect(isSupportedFilePath('/tmp/no-extension')).toBe(false)
+    expect(isSupportedFilePath('/tmp/archive.exe')).toBe(false)
   })
 })