Browse Source

修复中文附件名乱码并将待发送附件放入输入框

Samien 1 tháng trước cách đây
mục cha
commit
7b98662cfe

+ 12 - 11
src/client/components/composer/ComposerShell.vue

@@ -497,15 +497,6 @@ defineExpose({
 
 <template>
   <div class="composer-area">
-    <div v-if="attachments.length" class="composer-attachments" aria-label="附件">
-      <div v-for="attachment in attachments" :key="attachment.id" class="composer-attachment" :class="{ image: isImage(attachment.type) }">
-        <img v-if="attachment.previewUrl" :src="attachment.previewUrl" :alt="attachment.name" />
-        <AppIcon v-else name="file" :size="19" />
-        <span><strong>{{ attachment.name }}</strong><small>{{ formatSize(attachment.size) }}</small></span>
-        <button type="button" :aria-label="`移除 ${attachment.name}`" @click="removeAttachment(attachment.id)"><AppIcon name="close" :size="12" /></button>
-      </div>
-    </div>
-
     <div v-if="reference" class="composer-reference">
       <AppIcon name="quote" :size="14" />
       <span><small>{{ reference.author ? `回复 ${reference.author}` : '回复消息' }}</small><strong>{{ reference.content.replace(/\s+/g, ' ').trim() }}</strong></span>
@@ -542,6 +533,15 @@ defineExpose({
         <i v-if="hasContextUsage && contextLimit > 0"><b :style="{ width: `${contextPercent}%` }" /></i>
       </div>
 
+      <div v-if="attachments.length" class="composer-attachments" aria-label="附件">
+        <div v-for="attachment in attachments" :key="attachment.id" class="composer-attachment" :class="{ image: isImage(attachment.type) }">
+          <img v-if="attachment.previewUrl" :src="attachment.previewUrl" :alt="attachment.name" />
+          <AppIcon v-else name="file" :size="19" />
+          <span><strong>{{ attachment.name }}</strong><small>{{ formatSize(attachment.size) }}</small></span>
+          <button type="button" :aria-label="`移除 ${attachment.name}`" @click="removeAttachment(attachment.id)"><AppIcon name="close" :size="12" /></button>
+        </div>
+      </div>
+
       <textarea
         ref="textarea"
         v-model="text"
@@ -766,8 +766,9 @@ defineExpose({
 .composer-stop:hover { color: var(--danger); border-color: var(--danger); }
 .composer-sending { display: inline-flex; align-items: center; gap: 5px; color: var(--text-muted); font-size: 9px; white-space: nowrap; }.composer-sending i { width: 5px; height: 5px; border-radius: 50%; background: var(--warning); animation: composer-pulse 1s ease-in-out infinite; }
 @keyframes composer-pulse { 50% { opacity: .25; transform: scale(.7); } }
-.composer-attachments { display: flex; width: 100%; max-width: 760px; margin: 0 auto 9px; flex-wrap: wrap; gap: 7px; }
-.composer-attachment { position: relative; display: flex; max-width: 210px; min-height: 48px; align-items: center; gap: 8px; padding: 6px 28px 6px 9px; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-raised); color: var(--text-secondary); }
+.composer-attachments { display: flex; width: 100%; min-width: 0; flex-wrap: wrap; gap: 8px; }
+.composer-context + .composer-attachments { padding-top: 16px; }
+.composer-attachment { position: relative; display: flex; max-width: min(210px, 100%); min-height: 48px; align-items: center; gap: 8px; padding: 6px 28px 6px 9px; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-raised); color: var(--text-secondary); }
 .composer-attachment.image { width: 64px; height: 64px; padding: 0; }
 .composer-attachment img { width: 100%; height: 100%; object-fit: cover; }
 .composer-attachment > span { display: flex; min-width: 0; flex-direction: column; }

+ 4 - 1
src/server/uploads.ts

@@ -6,6 +6,7 @@ import { basename, extname, join, resolve, sep } from 'node:path'
 import { DatabaseSync } from 'node:sqlite'
 import Busboy from 'busboy'
 import { HttpError } from './errors.js'
+import { normalizeUploadName } from '../shared/uploadNames.js'
 
 export const MAX_UPLOAD_FILE_BYTES = 25 * 1_024 * 1_024
 export const MAX_UPLOAD_REQUEST_BYTES = 50 * 1_024 * 1_024
@@ -161,7 +162,7 @@ export class UploadStore {
       return {
         id: row.id,
         accountKey: row.account_key,
-        name: row.name,
+        name: normalizeUploadName(row.name),
         mimeType: row.mime_type,
         size: Number(row.size),
         path: row.path,
@@ -230,6 +231,8 @@ export async function receiveGroupUploads(
 
   const busboy = Busboy({
     headers: request.headers,
+    // Browser FormData sends ordinary filename parameters as UTF-8 bytes.
+    defParamCharset: 'utf8',
     limits: {
       files: MAX_UPLOAD_FILES,
       fileSize: MAX_UPLOAD_FILE_BYTES,

+ 2 - 1
src/server/workspaceAssets.ts

@@ -5,6 +5,7 @@ import {HttpError} from './errors.js'
 import { authorizeFileRead } from './fileAccess.js'
 import { lookup } from 'mime-types'
 import { messageFileReferences, type MessageFileOrigin } from '../shared/messageFiles.js'
+import { normalizeUploadName } from '../shared/uploadNames.js'
 import { WorkspaceStore } from './workspaceStore.js'
 import { WorkspaceNodes, type WorkspaceNode } from './workspaceGateway.js'
 import type { WorkspaceAgent, WorkspaceFile, WorkspaceMessage } from '../shared/workspace.js'
@@ -17,7 +18,7 @@ const archiveKey = (nodeId: string, profile: string, messageId: string, path: st
   createHash('sha256').update(JSON.stringify([nodeId, profile, messageId, path])).digest('hex')
 export function publicFile(f: StoredWorkspaceFile): WorkspaceFile {
   const { path: _path, digest: _digest, messageFileSource: _source, ...result } = f
-  return result
+  return result.sender === 'user' ? { ...result, name: normalizeUploadName(result.name) } : result
 }
 export function libraryFile(
   f: WorkspaceFile,

+ 1 - 1
src/server/workspaceRoutes.ts

@@ -527,7 +527,7 @@ export function workspaceRouter(
       ctx.type = activeContent ? 'application/octet-stream' : file.mimeType
       ctx.set(
         'Content-Disposition',
-        `${action === 'preview' && !activeContent ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(file.name)}`,
+        `${action === 'preview' && !activeContent ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(publicFile(file).name)}`,
       )
       ctx.set('Accept-Ranges', 'bytes')
       ctx.set('Cache-Control', 'private, no-store')

+ 4 - 1
src/server/workspaceStore.ts

@@ -7,6 +7,7 @@ import { messageAppendPatch } from '../shared/workspaceMessagePatch.js'
 import { z } from 'zod'
 import { HttpError } from './errors.js'
 import { isVisibleMessageFile } from '../shared/messageFiles.js'
+import { normalizeUploadName } from '../shared/uploadNames.js'
 import type { StoredWorkspaceFile } from './workspaceAssets.js'
 import type { SharedComputer } from './sharedComputers.js'
 import { notificationPlainText } from './notificationText.js'
@@ -1028,6 +1029,7 @@ export class WorkspaceStore {
     const messages = new Map(this.list<Message>(owner, 'message').map(message => [message.id, message]))
     return this.list<StoredWorkspaceFile>(owner, 'file').filter(file =>
       isVisibleMessageFile(file, messages.get(file.messageId ?? '') ?? this.nativeMessageForFile?.(owner, file)))
+      .map(file => file.sender === 'user' ? { ...file, name: normalizeUploadName(file.name) } : file)
   }
   messageForDisplay(owner: string, message: Message): Message {
     const peer = message.peerMessageId && message.role === 'system'
@@ -1042,7 +1044,7 @@ export class WorkspaceStore {
       const peerAvatar = entity?.avatar || (incoming ? peer.fromAvatar : peer.targetAvatar) || ''
       const files = peer.fileIds.flatMap(id => {
         const file = this.get<StoredWorkspaceFile>(owner, 'file', id) ?? message.attachments.find(file => file.id === id)
-        return file ? [{ id: file.id, name: file.name, mimeType: file.mimeType, size: file.size, sender: file.sender, createdAt: file.createdAt }] : []
+        return file ? [{ id: file.id, name: file.sender === 'user' ? normalizeUploadName(file.name) : file.name, mimeType: file.mimeType, size: file.size, sender: file.sender, createdAt: file.createdAt }] : []
       })
       return { ...message, reasoning: '', tools: [], attachments: message.visible === false ? [] : files,
         communication: { direction: incoming ? 'incoming' : 'outgoing', peerId, peerName, peerAvatar, peerKind, content: peer.content } }
@@ -1050,6 +1052,7 @@ export class WorkspaceStore {
     const attachments = message.role === 'user' || message.role === 'assistant'
       ? message.attachments.filter(file => isVisibleMessageFile(
         this.get<StoredWorkspaceFile>(owner, 'file', file.id) ?? file, message))
+        .map(file => file.sender === 'user' ? { ...file, name: normalizeUploadName(file.name) } : file)
       : []
     return { ...message, attachments }
   }

+ 12 - 0
src/shared/uploadNames.ts

@@ -0,0 +1,12 @@
+/** Recover UTF-8 upload names that older multipart parsing decoded as Latin-1. */
+export function normalizeUploadName(name: string): string {
+  // Leave Unicode names and ordinary Latin-1 names alone unless every byte
+  // forms valid UTF-8. Fatal decoding avoids replacing data we cannot recover.
+  if (!/[\u0080-\u00ff]/.test(name) || /[^\u0000-\u00ff]/.test(name)) return name
+  try {
+    return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
+      .decode(Uint8Array.from(name, character => character.charCodeAt(0)))
+  } catch {
+    return name
+  }
+}

+ 52 - 0
tests/e2e/workspace-chat.spec.ts

@@ -23,6 +23,58 @@ async function createAgent(page: Page, name: string) {
   await expect(page.getByRole('heading', { name, exact: true })).toBeVisible()
 }
 
+test('UTF-8 attachments stay inside the composer and retain their names after sending and reload', async ({ page }, testInfo) => {
+  await login(page)
+  const capabilities = await (await page.request.get('/api/realtime/capabilities')).json()
+  const headers = { 'X-CSRF-Token': capabilities.csrfToken, Origin: new URL(page.url()).origin }
+  const created = await page.request.post('/api/app/agents', { headers, data: { name: '附件验收', profile: 'default' } })
+  expect(created.ok(), await created.text()).toBe(true)
+  const agent = (await created.json()).agent
+  const conversations = (await (await page.request.get('/api/app/conversations')).json()).conversations
+  const conversation = conversations.find((c: any) => c.kind === 'direct' && c.memberIds[0] === agent.id)
+  await page.goto(`/conversations/${conversation.id}`)
+  const name = '人工智能合同综合质量评估系统V2.0-信息采集表-待确认.docx'
+  const certificate = 'CertificateSigningRequest.certSigningRequest'
+  await page.locator('.composer-file-input').setInputFiles([
+    { name, mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', buffer: Buffer.from('附件内容保持不变') },
+    { name: certificate, mimeType: 'application/octet-stream', buffer: Buffer.from('certificate') },
+    { name: '图片.png', mimeType: 'image/png', buffer: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') },
+  ])
+  await page.locator('.composer-textarea').fill('请按格式填写附件')
+  for (const width of [1280, 655, 375]) {
+    await page.setViewportSize({ width, height: 863 })
+    const attachments = page.locator('.composer-shell > .composer-attachments')
+    await expect(attachments.locator('.composer-attachment')).toHaveCount(3)
+    await expect(attachments).toContainText(name)
+    await expect(attachments.getByRole('img', { name: '图片.png' })).toBeVisible()
+    const shell = (await page.locator('.composer-shell').boundingBox())!
+    const tray = (await attachments.boundingBox())!
+    const input = (await page.locator('.composer-textarea').boundingBox())!
+    expect(tray.x).toBeGreaterThan(shell.x)
+    expect(tray.y).toBeGreaterThan(shell.y)
+    expect(tray.x + tray.width).toBeLessThan(shell.x + shell.width)
+    expect(input.y - (tray.y + tray.height)).toBeGreaterThanOrEqual(0)
+    expect(input.y - (tray.y + tray.height)).toBeLessThanOrEqual(12)
+    expect(input.y + input.height).toBeLessThan(shell.y + shell.height)
+    expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
+    await page.screenshot({ path: testInfo.outputPath(`composer-attachments-${width}.png`) })
+  }
+  await page.getByRole('button', { name: '移除 图片.png', exact: true }).click()
+  await page.getByRole('button', { name: `移除 ${certificate}`, exact: true }).click()
+  await expect(page.locator('.composer-attachment')).toHaveCount(1)
+  await expect(page.locator('.composer-textarea')).toHaveValue('请按格式填写附件')
+  const uploaded = page.waitForResponse(response => response.url().endsWith('/api/app/uploads') && response.request().method() === 'POST')
+  await page.getByRole('button', { name: '发送消息', exact: true }).click()
+  expect((await (await uploaded).json()).files[0].name).toBe(name)
+  const file = page.locator('.message-file').filter({ hasText: name })
+  await expect(file).toBeVisible()
+  await expect(file).toHaveAccessibleName(new RegExp(`预览文件 ${name}`))
+  await expect(page.locator('.composer-attachment')).toHaveCount(0)
+  await page.reload()
+  await expect(file).toBeVisible()
+  await page.screenshot({ path: testInfo.outputPath('utf8-attachment-message.png') })
+})
+
 test('Bot unread dots sync across viewers and neither detail nor read requests delay navigation', async ({ page, context }, testInfo) => {
   await login(page)
   const capabilities = await (await page.request.get('/api/realtime/capabilities')).json()

+ 17 - 0
tests/server/uploadNames.test.ts

@@ -0,0 +1,17 @@
+// @vitest-environment node
+import { expect, it } from 'vitest'
+import { normalizeUploadName } from '../../src/shared/uploadNames'
+
+it.each([
+  '人工智能合同综合质量评估系统V2.0-信息采集表-待确认.docx',
+  'café-附件-📄.txt',
+])('recovers a legacy Latin-1 decoding of %s exactly once', name => {
+  const corrupted = Buffer.from(name, 'utf8').toString('latin1')
+  expect(normalizeUploadName(corrupted)).toBe(name)
+  expect(normalizeUploadName(normalizeUploadName(corrupted))).toBe(name)
+})
+
+it.each(['report.txt', '中文附件.docx', 'café.txt', 'ÿ.txt', '坏数据-äº.txt', 'äº.txt'])
+  ('preserves names that cannot be safely recovered: %s', name => {
+    expect(normalizeUploadName(name)).toBe(name)
+  })

+ 45 - 0
tests/server/workspaceRoutes.test.ts

@@ -4,12 +4,14 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { randomUUID } from 'node:crypto'
+import { DatabaseSync } from 'node:sqlite'
 import request from 'supertest'
 import type Koa from 'koa'
 import { createApplication, type ApplicationRuntime } from '../../src/server/app'
 import { loadServerConfig } from '../../src/server/config'
 import { LocalAuthStore, type LocalUser } from '../../src/server/localAuth'
 import { WorkspaceAssets } from '../../src/server/workspaceAssets'
+import type { StoredWorkspaceFile } from '../../src/server/workspaceAssets'
 import { saveFileAccess } from '../../src/server/fileAccess'
 import { WorkspaceTranscriptStore } from '../../src/client/components/workspace/transcriptStore'
 import { SharedComputers, type SharedComputer } from '../../src/server/sharedComputers'
@@ -657,6 +659,49 @@ describe('application workspace HTTP contract', () => {
     expect(response.headers['content-type']).toContain('application/octet-stream')
     expect(response.headers['content-disposition']).toContain('attachment')
   })
+  it.each([
+    '人工智能合同综合质量评估系统V2.0-信息采集表-待确认.docx',
+    'café-附件-📄.txt',
+  ])('preserves the UTF-8 upload name %s in metadata, library search and downloads', async name => {
+    const file = (await req('post', '/api/app/uploads')
+      .attach('files', Buffer.from('file contents'), { filename: name, contentType: 'application/octet-stream' })
+      .expect(201)).body.files[0]
+    expect(file.name).toBe(name)
+    expect(runtime.uploads.records([file.id], 'first')[0]?.name).toBe(name)
+    const library = await req('get', `/api/app/files?search=${encodeURIComponent(name)}`).expect(200)
+    expect(library.body.items[0]?.name).toBe(name)
+    const download = await req('get', `/api/app/files/${file.id}/download`).expect(200)
+    expect(download.headers['content-disposition']).toBe(`attachment; filename*=UTF-8''${encodeURIComponent(name)}`)
+  })
+  it('recovers legacy upload names in history, events, search and downloads without changing stored files', async () => {
+    const name = '人工智能合同综合质量评估系统V2.0-信息采集表-待确认.docx'
+    const corrupted = Buffer.from(name, 'utf8').toString('latin1')
+    const uploaded = (await req('post', '/api/app/uploads')
+      .attach('files', Buffer.from('preserved file contents'), { filename: 'legacy.docx', contentType: 'application/octet-stream' })
+      .expect(201)).body.files[0]
+    const database = new DatabaseSync(join(home, 'uploads.sqlite3'))
+    try { database.prepare('UPDATE uploads SET name=? WHERE id=?').run(corrupted, uploaded.id) }
+    finally { database.close() }
+    const file = { ...runtime.workspace.require<StoredWorkspaceFile>('first', 'file', uploaded.id), name: corrupted }
+    runtime.workspace.put('first', 'file', file.id, file)
+    const agent = runtime.workspace.createAgent('first', { name: '附件测试', profile: 'default' })
+    const conversation = runtime.workspace.list<WorkspaceConversation>('first', 'conversation').find(c => c.memberIds[0] === agent.id)!
+    runtime.workspace.saveMessage('first', {
+      id: 'legacy-upload', conversationId: conversation.id, seq: 0, role: 'user', content: '请检查附件',
+      reasoning: '', tools: [], attachments: [file], status: 'complete', createdAt: 1,
+    })
+    expect(runtime.uploads.records([file.id], 'first')[0]?.name).toBe(name)
+    const history = (await req('get', `/api/app/conversations/${conversation.id}`).expect(200)).body
+    expect(history.messages[0]?.attachments[0]?.name).toBe(name)
+    const event = runtime.workspace.events('first', 0).find(event => event.type === 'message.changed' && (event.data as { id: string }).id === 'legacy-upload')!
+    expect((event.data as { attachments: { name: string }[] }).attachments[0]?.name).toBe(name)
+    const library = await req('get', `/api/app/files?search=${encodeURIComponent('信息采集表')}`).expect(200)
+    expect(library.body.items[0]?.name).toBe(name)
+    const download = await req('get', `/api/app/files/${file.id}/download`).expect(200)
+    expect(download.headers['content-disposition']).toBe(`attachment; filename*=UTF-8''${encodeURIComponent(name)}`)
+    expect(readFileSync(file.path, 'utf8')).toBe('preserved file contents')
+    expect(runtime.workspace.require<StoredWorkspaceFile>('first', 'file', file.id).name).toBe(corrupted)
+  })
   it('persists encrypted voice credentials while giving each user their own selected voice', async () => {
     await req('put', '/api/app/admin/duplex-voice')
       .send({