Procházet zdrojové kódy

fix: 以弹窗预览 iOS 附件卡片

samien před 2 měsíci
rodič
revize
7e69ede3ac

+ 2 - 1
src/client/utils/messageReducer.ts

@@ -12,13 +12,14 @@ function sameMessage(left: ChatMessage, right: ChatMessage): boolean {
 }
 
 function mergeOne(previous: ChatMessage, incoming: ChatMessage): ChatMessage {
+  const replacesContent = Boolean(incoming.content) || Boolean(incoming.attachments?.length)
   return {
     ...previous,
     ...incoming,
     id: incoming.serverMessageId || incoming.stage === 'settled' ? incoming.id : previous.id,
     clientMessageId: incoming.clientMessageId ?? previous.clientMessageId,
     serverMessageId: incoming.serverMessageId ?? previous.serverMessageId,
-    content: incoming.content || previous.content,
+    content: replacesContent ? incoming.content : previous.content,
     reasoning: incoming.reasoning ?? previous.reasoning,
     attachments: incoming.attachments?.length ? incoming.attachments : previous.attachments,
     toolCalls: incoming.toolCalls?.length ? incoming.toolCalls : previous.toolCalls,

+ 15 - 8
src/client/utils/normalize.ts

@@ -172,26 +172,33 @@ function attachmentMime(name: string): { mimeType: string; kind: ChatAttachment[
   return { mimeType: 'application/octet-stream', kind: 'file' }
 }
 
-function persistedAttachmentUrl(path: string): string | undefined {
-  if (path.startsWith('/')) return path
+function persistedAttachmentUrl(path: string): { path: string; url: string } | undefined {
+  if (path.startsWith('/')) return { path, url: path }
   const segments = path.split('/')
-  if (segments[0] !== 'attachments' || segments.length < 2 || segments.some(segment => !segment || segment === '.' || segment === '..')) return undefined
-  return `/${segments.map(encodeURIComponent).join('/')}`
+  const normalizedSegments = segments.map(segment => {
+    try { return decodeURIComponent(segment) } catch { return '' }
+  })
+  if (segments[0] !== 'attachments' || segments.length < 2 || normalizedSegments.some(segment => !segment || segment === '.' || segment === '..')) return undefined
+  return { path, url: `/${segments.map(encodeURIComponent).join('/')}` }
 }
 
 function extractPersistedAttachments(content: string): { content: string; attachments: ChatAttachment[] } {
   const attachments: ChatAttachment[] = []
-  const cleaned = content.replace(
+  const extracted = content.replace(
     /\[用户附加\s*(文件|图片|PDF)\s*:\s*([^\]\r\n]+)\]\s*(?:\r?\n)?@file:\s*(`[^`\r\n]+`|'[^'\r\n]+'|"[^"\r\n]+"|(?:\\?\/)[^\r\n]+)/g,
     (_match, _type: string, rawName: string, rawPath: string) => {
       const name = rawName.trim()
       const path = rawPath.trim().replace(/\\\//g, '/').replace(/^[`'"]|[`'"]$/g, '')
-      const url = persistedAttachmentUrl(path)
-      if (!url) return _match
+      const reference = persistedAttachmentUrl(path)
+      if (!reference) return _match
       const media = attachmentMime(name)
-      attachments.push({ id: `persisted:${path}`, name, path, url, size: 0, ...media })
+      attachments.push({ id: `persisted:${reference.path}`, name, path: reference.path, url: reference.url, size: 0, ...media })
       return ''
     },
+  )
+  const cleaned = (attachments.length
+    ? extracted.replace(/(?:^|\n)--- Attached Context ---[\s\S]*$/, '')
+    : extracted
   ).replace(/\n{3,}/g, '\n\n').trim()
   return { content: cleaned, attachments }
 }

+ 1 - 1
src/client/views/ChatView.vue

@@ -163,7 +163,7 @@ function openAttachment(attachment: NonNullable<UiMessage['attachments']>[number
   const item = previewItemFromUrl(attachment.name, attachment.url || '', attachment.id, attachment.kind)
   item.size = attachment.size
   if (item.kind === 'image' || item.kind === 'video') openMedia(item)
-  else { outlineOpen.value = false; preview.value = item }
+  else { outlineOpen.value = false; preview.value = null; filePreview.value = item }
 }
 
 function openMedia(item: UiLibraryItem) {

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

@@ -37,6 +37,16 @@ describe('chat message reducer', () => {
     expect(merged[0].stage).toBe('settled')
   })
 
+  it('replaces a cached iOS marker with an attachment-only normalized message', () => {
+    const cached = message('ios-file', '[用户附加文件:车位.pdf]\n@file:`attachments/车位.pdf`')
+    const normalized = {
+      ...message('ios-file', ''),
+      attachments: [{ id: 'ios-file:0', name: '车位.pdf', mimeType: 'application/pdf', size: 0, kind: 'pdf' as const, url: '/attachments/车位.pdf' }],
+    }
+    const merged = mergeChatMessages([cached], [normalized])
+    expect(merged[0]).toMatchObject({ content: '', attachments: [expect.objectContaining({ name: '车位.pdf' })] })
+  })
+
   it('ignores events attributed to another route', () => {
     const original = state()
     const reduced = applyChatEvent(original, event('message.delta', { delta: 'wrong' }, 'session-2'))

+ 10 - 3
tests/client/messageDisplay.test.ts

@@ -34,15 +34,22 @@ describe('displayContentForMessage', () => {
   it('turns iOS relative attachment markers into attachment cards', () => {
     const message = normalizeChatMessage({
       id: 'ios-user-file', role: 'user', timestamp: 1,
-      content: '[用户附加文件:车位 四号车库-B2307 罗益民 33600元.pdf]\n@file:`attachments/车位 四号车库-B2307 罗益民 33600元.pdf`',
+      content: [
+        '[用户附加文件:车位 四号车库-B2307 罗益民 33600元.pdf]',
+        '@file:`attachments/车位 四号车库-B2307 罗益民 33600元-2.pdf`',
+        '',
+        '--- Attached Context ---',
+        '',
+        '📎 @file:`attachments/车位 四号车库-B2307 罗益民 33600元-2.pdf` (application/pdf, 139.2 KB) — binary file.',
+      ].join('\n'),
     }, 'session-1', 'default')
 
     expect(message.content).toBe('')
     expect(message.attachments).toEqual([
       expect.objectContaining({
         name: '车位 四号车库-B2307 罗益民 33600元.pdf',
-        path: 'attachments/车位 四号车库-B2307 罗益民 33600元.pdf',
-        url: '/attachments/%E8%BD%A6%E4%BD%8D%20%E5%9B%9B%E5%8F%B7%E8%BD%A6%E5%BA%93-B2307%20%E7%BD%97%E7%9B%8A%E6%B0%91%2033600%E5%85%83.pdf',
+        path: 'attachments/车位 四号车库-B2307 罗益民 33600元-2.pdf',
+        url: '/attachments/%E8%BD%A6%E4%BD%8D%20%E5%9B%9B%E5%8F%B7%E8%BD%A6%E5%BA%93-B2307%20%E7%BD%97%E7%9B%8A%E6%B0%91%2033600%E5%85%83-2.pdf',
         kind: 'pdf',
       }),
     ])