Просмотр исходного кода

修复 Hermes 工作区文件卡识别与预览下载

LSamien 1 месяц назад
Родитель
Сommit
de67e55360

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

@@ -91,6 +91,12 @@ md.disable('smartquotes')
 const defaultLinkOpen = md.renderer.rules.link_open ?? ((tokens, index, options, _env, self) => self.renderToken(tokens, index, options))
 md.renderer.rules.link_open = (tokens, index, options, env, self) => {
   const token = tokens[index]
+  // Some assistants wrap real Hermes workspace paths in a sandbox: URI.
+  // Resolve only this known file route before sanitizing the Markdown.
+  const href = token.attrGet('href') || ''
+  if (props.fileCards && /^sandbox:\/Users\/[^/]+\/\.hermes\/(?:profiles\/[^/]+\/)?workspace\/.+/i.test(href)) {
+    token.attrSet('href', href.slice('sandbox:'.length))
+  }
   token.attrSet('target', '_blank')
   token.attrSet('rel', 'noopener noreferrer')
   return defaultLinkOpen(tokens, index, options, env, self)
@@ -176,8 +182,9 @@ function decorateFileLinks() {
     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)
-    if (url.origin !== window.location.origin || (!isAgentFile && !isRemoteNodeFile)) return
+    if (url.origin !== window.location.origin || (!isAgentFile && !isWorkspaceFile && !isRemoteNodeFile)) return
     link.dataset.fileCard = 'true'
     link.classList.add('file-link-card')
     link.removeAttribute('target')

+ 6 - 5
src/server/routes.ts

@@ -1100,7 +1100,7 @@ function prepareFilePreview(ctx: Koa.Context, name: string): void {
   }
 }
 
-function hermesMediaPath(ctx: Koa.Context): string {
+function hermesMediaPath(ctx: Koa.Context, rootDirectory = 'cache'): string {
   const segment = (value: string): string => {
     if (!value || value === '.' || value === '..' || /[/\\\u0000-\u001f\u007f]/.test(value)) {
       throw new HttpError(400, '媒体路径无效', 'invalid_media_path')
@@ -1109,8 +1109,8 @@ function hermesMediaPath(ctx: Koa.Context): string {
   }
   const owner = segment(ctx.params.owner)
   const profile = ctx.params.profile ? segment(ctx.params.profile) : undefined
-  const directory = profile ? ctx.params.mediaDir : 'cache'
-  if (!['cache', 'images', 'screenshots', 'attachments'].includes(directory)) {
+  const directory = profile ? ctx.params.mediaDir : rootDirectory
+  if (!['cache', 'images', 'screenshots', 'attachments', 'workspace'].includes(directory)) {
     throw new HttpError(404, '媒体目录不存在', 'media_not_found')
   }
   const raw = ctx.params.filePath
@@ -1118,9 +1118,9 @@ function hermesMediaPath(ctx: Koa.Context): string {
   return `/Users/${owner}/.hermes/${profile ? `profiles/${profile}/` : ''}${directory}/${parts.join('/')}`
 }
 
-async function proxyHermesMedia(ctx: Koa.Context, dependencies: RouteDependencies): Promise<void> {
+async function proxyHermesMedia(ctx: Koa.Context, dependencies: RouteDependencies, rootDirectory = 'cache'): Promise<void> {
   const user = dependencies.auth.require(ctx)
-  const path = hermesMediaPath(ctx)
+  const path = hermesMediaPath(ctx, rootDirectory)
   const cached = dependencies.chatCache?.store.attachment(user.id, path)
   if (cached) {
     ctx.set('X-Yaoyao-Data-Source', 'local')
@@ -1580,6 +1580,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   // Keep the original URL usable by inline Markdown, the lightbox and downloads.
   // These root/profile cache paths are not files on the Web server itself.
   router.get('/Users/:owner/.hermes/cache/*filePath', ctx => proxyHermesMedia(ctx, dependencies))
+  router.get('/Users/:owner/.hermes/workspace/*filePath', ctx => proxyHermesMedia(ctx, dependencies, 'workspace'))
   router.get('/Users/:owner/.hermes/profiles/:profile/:mediaDir/*filePath', ctx => proxyHermesMedia(ctx, dependencies))
 
   // Historical messages can contain Markdown links such as

+ 40 - 0
tests/client/markdownContent.test.ts

@@ -42,6 +42,46 @@ 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 => {
+    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')
+    await card.trigger('click')
+    expect(wrapper.emitted('fileLink')).toEqual([['报告.md', encodeURI(path.replace(/^sandbox:/, ''))]])
+    wrapper.unmount()
+  })
+
+  it('preserves ordinary links and keeps unknown protocols sanitized', () => {
+    const wrapper = mount(MarkdownContent, { props: { fileCards: true, content: [
+      '[外部](https://example.com/Users/samien/.hermes/workspace/report.md)',
+      '[普通](https://example.com/report.md)',
+      '[配置](/Users/samien/.hermes/config.yaml)',
+      '[未知](sandbox:/etc/passwd)',
+    ].join('\n\n') } })
+    expect(wrapper.find('.file-link-card').exists()).toBe(false)
+    expect(wrapper.find('a[href^="sandbox:"]').exists()).toBe(false)
+    expect(wrapper.findAll('a[href]')).toHaveLength(3)
+    wrapper.unmount()
+  })
+
+  it('decorates workspace links arriving in a later message update', async () => {
+    const wrapper = mount(MarkdownContent, { props: { content: '附件如下:', fileCards: true } })
+    await wrapper.setProps({ content: '附件如下:\n\n[报告.md](/Users/samien/.hermes/workspace/report.md)' })
+    expect(wrapper.find('a.file-link-card').exists()).toBe(true)
+    wrapper.unmount()
+  })
+})
+
 describe('streaming Markdown', () => {
   afterEach(() => vi.useRealTimers())
 

+ 25 - 2
tests/server/hermesMediaRoutes.test.ts

@@ -73,6 +73,23 @@ function bodyBytes(response: request.Response): Buffer {
 }
 
 describe('historical Hermes media routes', () => {
+  it.each([
+    '/Users/samien/.hermes/workspace/gpt-6-astra-report.md',
+    '/Users/samien/.hermes/profiles/yaoer/workspace/详细 文档.md',
+  ])('serves generated workspace documents for preview and download: %s', async path => {
+    const document = '# GPT-6\n\n文档正文'
+    const fixture = gateway(() => new Response(document, {
+      headers: { 'content-type': 'text/markdown', 'content-disposition': 'attachment; filename="report.md"' },
+    }))
+    const runtime = runtimeFor(fixture)
+    const response = await request(runtime.app.callback()).get(encodeURI(path)).set('Host', HOST).expect(200)
+    expect(response.text).toBe(document)
+    expect(response.headers['content-type']).toMatch(/^text\/markdown/)
+    expect(response.headers['content-disposition']).toBeUndefined()
+    expect(response.headers['cache-control']).toContain('private, no-store')
+    expect([...fixture.downloads()[0]!.url.searchParams]).toEqual([['path', path]])
+  })
+
   it.each([
     `/Users/samien/.hermes/cache/images/${IMAGE_NAME}`,
     `/Users/samien/.hermes/profiles/yaoer/cache/images/${IMAGE_NAME}`,
@@ -138,11 +155,14 @@ describe('historical Hermes media routes', () => {
     expect(response.headers['content-disposition'] ?? '').not.toMatch(/^attachment/i)
   })
 
-  it('requires a local login before contacting Hermes', async () => {
+  it.each([
+    '/Users/samien/.hermes/profiles/yaoer/cache/images/result.png',
+    '/Users/samien/.hermes/workspace/report.md',
+  ])('requires a local login before contacting Hermes: %s', async path => {
     const fixture = gateway()
     const runtime = runtimeFor(fixture, false)
     await request(runtime.app.callback())
-      .get('/Users/samien/.hermes/profiles/yaoer/cache/images/result.png')
+      .get(path)
       .set('Host', HOST).expect(401)
     expect(fixture.calls).toHaveLength(0)
   })
@@ -174,6 +194,9 @@ describe('historical Hermes media routes', () => {
   })
 
   it.each([
+    '/Users/samien/.hermes/workspace/nested%2F..%2Fconfig.yaml',
+    '/Users/samien/.hermes/workspace/result%5C.md',
+    '/Users/samien/.hermes/workspace/result%00.md',
     '/Users/samien/.hermes/profiles/yaoer/config/result.png',
     '/Users/samien/.hermes/profiles/yaoer/cache/images/nested%2F..%2Fresult.png',
     '/Users/samien/.hermes/profiles/yaoer/cache/images/result%5C.png',