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

修复服务器文件地址切换后的大图预览与图库关联

Samien 1 месяц назад
Родитель
Сommit
1997b134c5

+ 16 - 4
src/client/components/library/mediaSequence.ts

@@ -1,5 +1,6 @@
 import type { UiMessage } from '@/components/messages/types'
 import type { UiMessage } from '@/components/messages/types'
 import { normalizeAssistantMediaMarkdown } from '@/utils/mediaMarkdown'
 import { normalizeAssistantMediaMarkdown } from '@/utils/mediaMarkdown'
+import { serverFileUrl } from '@shared/serverFiles'
 import type { LibraryKind, UiLibraryItem } from './types'
 import type { LibraryKind, UiLibraryItem } from './types'
 
 
 const imageExtensions = new Set(['apng', 'avif', 'bmp', 'gif', 'heic', 'heif', 'ico', 'jfif', 'jpeg', 'jpg', 'jxl', 'png', 'svg', 'tif', 'tiff', 'webp'])
 const imageExtensions = new Set(['apng', 'avif', 'bmp', 'gif', 'heic', 'heif', 'ico', 'jfif', 'jpeg', 'jpg', 'jxl', 'png', 'svg', 'tif', 'tiff', 'webp'])
@@ -17,14 +18,23 @@ export function previewItemFromUrl(name: string, url: string, id = `local:${url}
 
 
 export function mediaUrlIdentity(url: string): string {
 export function mediaUrlIdentity(url: string): string {
   try {
   try {
-    return new URL(url, window.location.origin).href
+    const parsed = new URL(url, window.location.origin)
+    if (parsed.origin === window.location.origin && parsed.pathname === '/api/files/download') {
+      parsed.searchParams.delete('_retry')
+      parsed.searchParams.sort()
+    }
+    return parsed.href
   } catch {
   } catch {
     return url
     return url
   }
   }
 }
 }
 
 
 function nameFromUrl(url: string): string {
 function nameFromUrl(url: string): string {
-  try { return decodeURIComponent(new URL(url, window.location.origin).pathname.split('/').at(-1) || '媒体') } catch { return '媒体' }
+  try {
+    const parsed = new URL(url, window.location.origin)
+    const path = parsed.pathname === '/api/files/download' ? parsed.searchParams.get('path') || parsed.pathname : decodeURIComponent(parsed.pathname)
+    return path.split('/').at(-1) || '媒体'
+  } catch { return '媒体' }
 }
 }
 
 
 /** The ordered image/video sequence visible in one normal or group conversation. */
 /** The ordered image/video sequence visible in one normal or group conversation. */
@@ -46,8 +56,10 @@ export function mediaItemsFromMessages(messages: UiMessage[]): UiLibraryItem[] {
     const content = message.role === 'assistant' ? normalizeAssistantMediaMarkdown(message.content) : message.content
     const content = message.role === 'assistant' ? normalizeAssistantMediaMarkdown(message.content) : message.content
     const markdownMedia = /!\[[^\]]*\]\(([^)\s]+)\)|\[[^\]]+\]\(([^)\s]+)\)/g
     const markdownMedia = /!\[[^\]]*\]\(([^)\s]+)\)|\[[^\]]+\]\(([^)\s]+)\)/g
     for (let match = markdownMedia.exec(content); match; match = markdownMedia.exec(content)) {
     for (let match = markdownMedia.exec(content); match; match = markdownMedia.exec(content)) {
-      const url = match[1] || match[2]
-      if (!url) continue
+      const source = match[1] || match[2]
+      if (!source) continue
+      // Match MarkdownContent's renderer, including the selected server Profile.
+      const url = serverFileUrl(source, message.profile) || source
       const item = previewItemFromUrl(nameFromUrl(url), url, `${message.id}:${url}`)
       const item = previewItemFromUrl(nameFromUrl(url), url, `${message.id}:${url}`)
       append(item)
       append(item)
     }
     }

+ 18 - 0
tests/client/mediaSequence.test.ts

@@ -27,4 +27,22 @@ describe('conversation media sequence', () => {
       mediaUrlIdentity('/Users/samien/Agents/%E7%91%B6%E5%84%BF/%E5%8D%95%E8%A7%86%E8%A7%92%E5%9B%BE%E7%89%87.png'),
       mediaUrlIdentity('/Users/samien/Agents/%E7%91%B6%E5%84%BF/%E5%8D%95%E8%A7%86%E8%A7%92%E5%9B%BE%E7%89%87.png'),
     )
     )
   })
   })
+
+  it('uses the same Profile-scoped URL and filename as rendered server images', () => {
+    const items = mediaItemsFromMessages([
+      { id: 'one', role: 'assistant', profile: 'yaoer', content: 'MEDIA:/Users/test/工作/图片.png\n![第二张](/Users/test/工作/第二张.png)' },
+    ])
+    expect(items.map(item => item.name)).toEqual(['图片.png', '第二张.png'])
+    const url = new URL(items[0]!.previewUrl!, window.location.origin)
+    expect(url.pathname).toBe('/api/files/download')
+    expect(url.searchParams.get('path')).toBe('/Users/test/工作/图片.png')
+    expect(url.searchParams.get('profile')).toBe('yaoer')
+    expect(items.every(item => item.kind === 'image')).toBe(true)
+  })
+
+  it('matches retried media without merging different Profile authorizations', () => {
+    const original='/api/files/download?path=%2Fwork%2Fa.png&preview=1&profile=one'
+    expect(mediaUrlIdentity(original)).toBe(mediaUrlIdentity(original+'&_retry=123'))
+    expect(mediaUrlIdentity(original)).not.toBe(mediaUrlIdentity(original.replace('profile=one','profile=two')))
+  })
 })
 })

+ 31 - 4
tests/e2e/hermesMedia.spec.ts

@@ -33,10 +33,10 @@ test('loads Hermes profile cache images through the Web server and switches thei
   // No image interception: this exercises the real Web route, upstream account
   // No image interception: this exercises the real Web route, upstream account
   // authentication, attachment-to-inline response handling, and browser decode.
   // authentication, attachment-to-inline response handling, and browser decode.
   const responses = mediaPaths.map(path => page.waitForResponse(response => (
   const responses = mediaPaths.map(path => page.waitForResponse(response => (
-    decodeURIComponent(new URL(response.url()).pathname) === path
+    new URL(response.url()).pathname === '/api/files/download' && new URL(response.url()).searchParams.get('path') === path
   )))
   )))
   await page.goto('/chat/session-media?profile=yaoer')
   await page.goto('/chat/session-media?profile=yaoer')
-  await expect(page).toHaveTitle('瑶儿生成图片验收 · 夭夭')
+  await expect(page).toHaveTitle('瑶儿生成图片验收 · 夭夭 AI')
 
 
   for (const [index, name] of mediaNames.entries()) {
   for (const [index, name] of mediaNames.entries()) {
     const image = page.getByRole('button', { name: `预览图片 ${name}`, exact: true })
     const image = page.getByRole('button', { name: `预览图片 ${name}`, exact: true })
@@ -47,6 +47,7 @@ test('loads Hermes profile cache images through the Web server and switches thei
     expect(response.headers()['content-type']).toContain('image/png')
     expect(response.headers()['content-type']).toContain('image/png')
     expect(response.headers()['content-disposition'] ?? '').not.toMatch(/^attachment(?:;|$)/)
     expect(response.headers()['content-disposition'] ?? '').not.toMatch(/^attachment(?:;|$)/)
     expect(new URL(response.url()).origin).toBe(new URL(baseURL!).origin)
     expect(new URL(response.url()).origin).toBe(new URL(baseURL!).origin)
+    expect(new URL(response.url()).searchParams.get('profile')).toBe('yaoer')
   }
   }
 
 
   await page.getByRole('button', { name: `预览图片 ${mediaNames[0]}`, exact: true }).click()
   await page.getByRole('button', { name: `预览图片 ${mediaNames[0]}`, exact: true }).click()
@@ -59,7 +60,7 @@ test('loads Hermes profile cache images through the Web server and switches thei
   await expect(preview).toHaveAccessibleName(`预览 ${mediaNames[1]}`)
   await expect(preview).toHaveAccessibleName(`预览 ${mediaNames[1]}`)
   const secondPreview = preview.getByRole('img', { name: mediaNames[1], exact: true })
   const secondPreview = preview.getByRole('img', { name: mediaNames[1], exact: true })
   await expectDecodedImage(secondPreview)
   await expectDecodedImage(secondPreview)
-  expect(decodeURIComponent(new URL(await secondPreview.getAttribute('src') || '', baseURL).pathname)).toBe(mediaPaths[1])
+  expect(new URL(await secondPreview.getAttribute('src') || '', baseURL).searchParams.get('path')).toBe(mediaPaths[1])
   await expect(preview.locator('.image-preview-counter')).toHaveText('2 / 2')
   await expect(preview.locator('.image-preview-counter')).toHaveText('2 / 2')
 
 
   await page.keyboard.press('ArrowLeft')
   await page.keyboard.press('ArrowLeft')
@@ -86,7 +87,9 @@ test('restores nine user Profile uploads as images and opens the whole batch in
     await expectDecodedImage(image)
     await expectDecodedImage(image)
     const url = new URL(await image.getAttribute('src') || '', baseURL)
     const url = new URL(await image.getAttribute('src') || '', baseURL)
     expect(url.origin).toBe(new URL(baseURL!).origin)
     expect(url.origin).toBe(new URL(baseURL!).origin)
-    expect(url.pathname).toContain('/.hermes/profiles/yaoer/images/')
+    expect(url.pathname).toBe('/api/files/download')
+    expect(url.searchParams.get('path')).toContain('/.hermes/profiles/yaoer/images/')
+    expect(url.searchParams.get('profile')).toBe('yaoer')
   }
   }
   await page.screenshot({ path: testInfo.outputPath('user-profile-images.png') })
   await page.screenshot({ path: testInfo.outputPath('user-profile-images.png') })
   await images.first().click()
   await images.first().click()
@@ -98,3 +101,27 @@ test('restores nine user Profile uploads as images and opens the whole batch in
   await expectDecodedImage(preview.getByRole('img', { name: '照片-2.png', exact: true }))
   await expectDecodedImage(preview.getByRole('img', { name: '照片-2.png', exact: true }))
   await page.screenshot({ path: testInfo.outputPath('user-profile-image-preview.png') })
   await page.screenshot({ path: testInfo.outputPath('user-profile-image-preview.png') })
 })
 })
+
+test('server permissions reject a previously readable image and retry succeeds after authorization', async ({page,baseURL}) => {
+  const origin=new URL(baseURL!).origin
+  await signIn(page.request,origin)
+  const capabilities=await(await page.request.get('/api/realtime/capabilities')).json()
+  const headers={Origin:origin,'X-CSRF-Token':capabilities.csrfToken}
+  const policy=async(mode:'folders'|'all')=>{
+    const response=await page.request.put('/api/app/settings/file-access',{headers,data:{mode,folders:[]}})
+    expect(response.ok(),await response.text()).toBe(true)
+  }
+  await policy('folders')
+  try {
+    await page.goto('/chat/session-media?profile=yaoer')
+    const failure=page.getByRole('alert').filter({hasText:mediaNames[0]})
+    await expect(failure).toContainText('文件访问')
+    await expect(failure.getByRole('button',{name:'重试',exact:true})).toBeVisible()
+    const denied=await page.request.get('/api/files/download',{params:{path:mediaPaths[0]!,profile:'yaoer',preview:'1'}})
+    expect(denied.status()).toBe(403)
+    await policy('all')
+    await failure.getByRole('button',{name:'重试',exact:true}).click()
+    await expectDecodedImage(page.getByRole('button',{name:`预览图片 ${mediaNames[0]}`,exact:true}))
+    await expect(failure).toHaveCount(0)
+  } finally {await policy('all')}
+})