Sfoglia il codice sorgente

统一普通聊天图片上传与历史显示路径

Samien 1 mese fa
parent
commit
3a62fea6f6

+ 11 - 5
src/client/stores/chat.ts

@@ -11,7 +11,7 @@ import { ChatRpcSocket, RpcError } from '@/api/realtime'
 import { ChatTranscriptClient } from '@/api/chatTranscript'
 import type { TranscriptMessage, TranscriptSnapshot } from '@shared/chatTranscript'
 import { ApiError } from '@/api/client'
-import { encodeAttachment } from '@/utils/attachments'
+import { encodeAttachment, imageAttachmentReference } from '@/utils/attachments'
 import { ScopedCache } from '@/utils/cache'
 import { createId, routeKey } from '@/utils/id'
 import { applyChatEvent, chatHasUnfinishedOutput, mergeChatMessages, settleChatMessages } from '@/utils/messageReducer'
@@ -808,13 +808,19 @@ export const useChatStore = defineStore('chat', () => {
         persistFastMode(state)
       }
       const parts = trimmed ? [trimmed] : []
-      for (const file of files) {
+      for (const [index, file] of files.entries()) {
         const encoded = await encodeAttachment(file)
         if (encoded.kind === 'image') {
-          await socket.request('image.attach_bytes', {
+          const result = resultRecord(await socket.request('image.attach_bytes', {
             session_id: runtimeId, content_base64: encoded.base64, filename: encoded.name, ext: encoded.extension,
-          })
-          parts.push(`[用户附加图片:${encoded.name}]`)
+          }))
+          const path = string(result.image_path) || string(result.path) || string(result.ref_path)
+          const reference = imageAttachmentReference(path, state.route.profile)
+          if (reference) {
+            attachments[index] = { ...attachments[index]!, ...reference }
+            updateDelivery(state, clientMessageId, { attachments: [...attachments] })
+          }
+          parts.push([`[用户附加图片:${encoded.name}]`, path ? `@image:${path}` : '', '[screenshot]'].filter(Boolean).join('\n'))
         } else if (encoded.kind === 'pdf') {
           await socket.request('pdf.attach', { session_id: runtimeId, content_base64: encoded.base64, filename: encoded.name })
           parts.push(`[用户附加 PDF:${encoded.name}]`)

+ 20 - 0
src/client/utils/attachments.ts

@@ -1,5 +1,25 @@
+import { serverFilePath } from '@shared/serverFiles'
+
 export const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
 
+/** Use the authenticated file route for uploads from any Hermes home. */
+export function imageAttachmentReference(raw: string, profile?: string): { path: string; url: string } | undefined {
+  let source = raw.trim().replace(/^[`'"]|[`'"]$/g, '')
+  let decode = true
+  if (/^\/api\/(?:files\/(?:download|stream)|hermes\/download|media)\?/.test(source)) {
+    source = new URL(source, 'https://local.invalid').searchParams.get('path') || ''
+    decode = false // URLSearchParams already decoded the path.
+  }
+  // Encoded separators must not change the meaning of a path segment.
+  if (/%2f|%5c|[\\\u0000-\u001f\u007f]/i.test(source)
+    || source.split('/').some(segment => /^(?:\.|%2e){1,2}$/i.test(segment))) return
+  const path = serverFilePath(source, decode)
+  if (!path || path.split('/').some(segment => segment === '.')) return
+  if (decode && !/^(?:file:|sandbox:)/i.test(source)
+    && /^\/(?:api|assets|icons|brand|attachments|uploads|media|chat|history|conversations|kanban|files|artifacts|settings)(?:\/|$)/.test(path)) return
+  return { path, url: `/api/files/download?${new URLSearchParams({ path, preview: '1', ...(profile ? { profile } : {}) })}` }
+}
+
 export interface EncodedAttachment {
   name: string
   mimeType: string

+ 4 - 20
src/client/utils/normalize.ts

@@ -1,4 +1,5 @@
 import { toolStatus } from '@shared/chatTools'
+import { imageAttachmentReference } from './attachments'
 import type {
   ChatAttachment,
   ChatMessage,
@@ -200,23 +201,6 @@ function persistedAttachmentUrl(path: string): { path: string; url: string } | u
   return { path, url: `/${segments.map(encodeURIComponent).join('/')}` }
 }
 
-function persistedImageUrl(path: string): { path: string; url: string } | undefined {
-  if (!path.startsWith('/')) return undefined
-  const segments = path.slice(1).split('/')
-  const normalizedSegments = segments.map(segment => {
-    try { return decodeURIComponent(segment) } catch { return '' }
-  })
-  const rootImages = normalizedSegments[3] === 'images' && segments.length >= 5
-  const profileImages = normalizedSegments[3] === 'profiles'
-    && normalizedSegments[5] === 'images' && segments.length >= 7
-  // User uploads belong to either the default Hermes home or the selected
-  // Profile. Keep the full path so the Web media route reaches that Profile.
-  if (normalizedSegments[0] !== 'Users' || normalizedSegments[2] !== '.hermes'
-    || (!rootImages && !profileImages)
-    || normalizedSegments.some(segment => !segment || segment === '.' || segment === '..' || /[/\\\u0000-\u001f\u007f]/.test(segment))) return undefined
-  return { path, url: `/${segments.map(encodeURIComponent).join('/')}` }
-}
-
 function extractPersistedAttachments(content: string): { content: string; attachments: ChatAttachment[] } {
   const attachments: ChatAttachment[] = []
   const extracted = content.replace(
@@ -238,7 +222,7 @@ function extractPersistedAttachments(content: string): { content: string; attach
   return { content: cleaned, attachments }
 }
 
-function extractPersistedImages(content: string): { content: string; attachments: ChatAttachment[] } {
+function extractPersistedImages(content: string, profile?: string): { content: string; attachments: ChatAttachment[] } {
   const lines = content.split(/\r?\n/)
   const attachments: ChatAttachment[] = []
   const imageMarker = /^\[用户附加图片\s*:\s*([^\]\r\n]+)\]\s*$/
@@ -268,7 +252,7 @@ function extractPersistedImages(content: string): { content: string; attachments
       paths.push(reference[1]!.trim().replace(/^[`'"]|[`'"]$/g, ''))
       cursor += 1
     }
-    const references = paths.map(persistedImageUrl)
+    const references = paths.map(path => imageAttachmentReference(path, profile))
     if (!paths.length || names.length !== paths.length || references.some(reference => !reference)) {
       index = start + 1
       continue
@@ -312,7 +296,7 @@ export function normalizeChatMessage(value: unknown, sessionId: string, fallback
   const role = ['user', 'assistant', 'tool', 'system'].includes(roleValue) ? roleValue as ChatMessage['role'] : 'system'
   if (role === 'user' && (content.includes('@file:') || content.includes('@image:'))) {
     const persistedFiles = extractPersistedAttachments(content)
-    const persistedImages = extractPersistedImages(persistedFiles.content)
+    const persistedImages = extractPersistedImages(persistedFiles.content, string(source.profile, fallbackProfile) || undefined)
     content = persistedImages.content
     const known = new Set(attachments.map(item => item.path || item.url || item.name))
     attachments.push(...[...persistedFiles.attachments, ...persistedImages.attachments]

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

@@ -137,6 +137,46 @@ describe('ordinary v2 cross-client store', () => {
     await vi.waitFor(() => expect(wire.clients.some((c) => c.id === id)).toBe(true))
     return wire.clients.filter((c) => c.id === id).at(-1)!
   }
+  it.each(['image_path', 'path', 'ref_path'])('preserves uploaded image %s in pending and canonical messages', async field => {
+    const client = await open()
+    const paths = ['/home/user/.hermes/images/照片 one.png', '/tmp/photo-two.png']
+    let uploaded = 0
+    wire.request.mockImplementation((method: string) => Promise.resolve(
+      method === 'image.attach_bytes' ? { [field]: paths[uploaded++] } : {},
+    ))
+    await chat.send('查看图片', paths.map((_, index) => new File(['image'], `photo-${index}.png`, { type: 'image/png' })))
+    const submission = wire.request.mock.calls.find(([method]) => method === 'prompt.submit')![1]
+    expect(submission.text).toBe([
+      '查看图片',
+      `[用户附加图片:photo-0.png]\n@image:${paths[0]}\n[screenshot]`,
+      `[用户附加图片:photo-1.png]\n@image:${paths[1]}\n[screenshot]`,
+    ].join('\n\n'))
+    const pending = chat.messages.find(message => message.role === 'user')!
+    expect(pending.attachments?.map(file => file.path)).toEqual(paths)
+    const checkDownloads = () => chat.messages[0]!.attachments?.forEach((file, index) => {
+      const url = new URL(file.url!, 'https://yaoyao.test')
+      expect(url.pathname).toBe('/api/files/download')
+      expect(url.searchParams.get('path')).toBe(paths[index])
+      expect(url.searchParams.get('profile')).toBe('p')
+    })
+    checkDownloads()
+    await client.changed(snapshot([{
+      id: 'canonical-image', seq: 1, revision: 1, role: 'user', status: 'complete',
+      client_message_id: pending.clientMessageId, content: submission.text,
+    }]))
+    expect(chat.messages).toHaveLength(1)
+    expect(chat.messages[0]!.content).toBe('查看图片')
+    expect(chat.messages[0]!.attachments?.map(file => file.path)).toEqual(paths)
+    checkDownloads()
+  })
+  it('does not fabricate an image path when an older upload response omits it', async () => {
+    await open()
+    wire.request.mockResolvedValue({})
+    await chat.send('', [new File(['image'], 'photo.png', { type: 'image/png' })])
+    const submission = wire.request.mock.calls.find(([method]) => method === 'prompt.submit')![1]
+    expect(submission.text).toBe('[用户附加图片:photo.png]\n[screenshot]')
+    expect(chat.messages[0]!.attachments?.[0]?.url).toBeUndefined()
+  })
   it('renders only canonical messages and ignores raw upstream bodies and completions', async () => {
     const client = await open()
     await client.changed(snapshot([answer('前半', { status: 'streaming' })], 's', { running: true }))

+ 34 - 5
tests/client/messageDisplay.test.ts

@@ -2,6 +2,14 @@ import { describe, expect, it } from 'vitest'
 import { displayContentForMessage } from '@/utils/messageDisplay'
 import { normalizeChatMessage } from '@/utils/normalize'
 
+function expectImageDownload(url: string | undefined, path: string, profile = 'default') {
+  const parsed = new URL(url!, 'https://yaoyao.test')
+  expect(parsed.pathname).toBe('/api/files/download')
+  expect(parsed.searchParams.get('path')).toBe(path)
+  expect(parsed.searchParams.get('profile')).toBe(profile)
+  expect(parsed.searchParams.get('preview')).toBe('1')
+}
+
 describe('displayContentForMessage', () => {
   it('turns persisted user @file markers into attachment cards and removes marker text', () => {
     const message = normalizeChatMessage({
@@ -72,10 +80,10 @@ describe('displayContentForMessage', () => {
       expect.objectContaining({
         name: '照片-9270F5CF-2FE2-487F-8948-FA583F4C83B1.png',
         path: '/Users/samien/.hermes/images/upload_20260820_130901_1.png',
-        url: '/Users/samien/.hermes/images/upload_20260820_130901_1.png',
         kind: 'image',
       }),
     ])
+    expectImageDownload(message.attachments?.[0]?.url, '/Users/samien/.hermes/images/upload_20260820_130901_1.png')
   })
 
   it('keeps multiple iOS image uploads together as image attachments', () => {
@@ -93,7 +101,7 @@ describe('displayContentForMessage', () => {
     }, 'session-1', 'default')
 
     expect(message.content).toBe('')
-    expect(message.attachments?.map(attachment => attachment.url)).toEqual([
+    expect(message.attachments?.map(attachment => attachment.path)).toEqual([
       '/Users/samien/.hermes/images/upload_1.jpeg',
       '/Users/samien/.hermes/images/upload_2.jpeg',
     ])
@@ -113,12 +121,33 @@ describe('displayContentForMessage', () => {
     }, 'session-1', 'yaoer')
 
     expect(message.content).toBe('读取图片中的提示词')
-    expect(message.attachments?.map(({ name, path, url, kind }) => ({ name, path, url, kind })))
-      .toEqual(names.map((name, index) => ({ name, path: paths[index], url: paths[index], kind: 'image' })))
+    expect(message.attachments?.map(({ name, path, kind }) => ({ name, path, kind })))
+      .toEqual(names.map((name, index) => ({ name, path: paths[index], kind: 'image' })))
+    message.attachments?.forEach((attachment, index) => expectImageDownload(attachment.url, paths[index]!, 'yaoer'))
+  })
+
+  it.each([
+    ['/home/user/.hermes/images/demo.png', '/home/user/.hermes/images/demo.png'],
+    ['/tmp/中文 照片.png', '/tmp/中文 照片.png'],
+    ['/Users/samien/.hermes/profiles/yaoer/config/image.png', '/Users/samien/.hermes/profiles/yaoer/config/image.png'],
+    ['file:///tmp/photo%20one.png', '/tmp/photo one.png'],
+    ['/api/files/download?path=%2Ftmp%2Fphoto%20one.png', '/tmp/photo one.png'],
+    ['/api/files/download?path=%2Ftmp%2F100%25.png', '/tmp/100%.png'],
+  ])('restores cross-client image reference %s through the authenticated download route', (reference, path) => {
+    const message = normalizeChatMessage({ role: 'user', content: `说明\n[用户附加图片:照片.png]\n@image:${reference}\n[screenshot]` }, 's', 'yaoer')
+    expect(message.content).toBe('说明')
+    expect(message.attachments).toHaveLength(1)
+    expect(message.attachments?.[0]?.path).toBe(path)
+    expectImageDownload(message.attachments?.[0]?.url, path, 'yaoer')
   })
 
   it.each([
-    '/Users/samien/.hermes/profiles/yaoer/config/image.png',
+    '/api/app/files/id/preview',
+    'file://remote/tmp/image.png',
+    'file:///tmp/../secret.png',
+    'file:///tmp/%2e%2e/secret.png',
+    'https://other.test/image.png',
+    '/api/files/download?path=%2Ftmp%2F..%2Fsecret.png',
     '/Users/samien/.hermes/profiles/../images/image.png',
     '/Users/samien/.hermes/profiles/yaoer/images/%2e%2e/image.png',
     '/Users/samien/.hermes/profiles/yaoer%2Fother/images/image.png',