Преглед изворни кода

WIP: 兼容 iOS 相对附件标记

[gstack-context]
Decisions: 仅接受 attachments/ 前缀的相对 iOS 持久化标记;映射到受保护的 /attachments 路由,拒绝空段和路径穿越;不启用通用用户 Markdown 解析。
Remaining: 运行最终 Vitest 与完整 Playwright。
Skill: /investigate
[/gstack-context]
samien пре 2 месеци
родитељ
комит
e19ec833f6

+ 10 - 2
src/client/utils/normalize.ts

@@ -172,6 +172,13 @@ 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
+  const segments = path.split('/')
+  if (segments[0] !== 'attachments' || segments.length < 2 || segments.some(segment => !segment || segment === '.' || segment === '..')) return undefined
+  return `/${segments.map(encodeURIComponent).join('/')}`
+}
+
 function extractPersistedAttachments(content: string): { content: string; attachments: ChatAttachment[] } {
   const attachments: ChatAttachment[] = []
   const cleaned = content.replace(
@@ -179,9 +186,10 @@ function extractPersistedAttachments(content: string): { content: string; attach
     (_match, _type: string, rawName: string, rawPath: string) => {
       const name = rawName.trim()
       const path = rawPath.trim().replace(/\\\//g, '/').replace(/^[`'"]|[`'"]$/g, '')
-      if (!path.startsWith('/')) return _match
+      const url = persistedAttachmentUrl(path)
+      if (!url) return _match
       const media = attachmentMime(name)
-      attachments.push({ id: `persisted:${path}`, name, path, url: path, size: 0, ...media })
+      attachments.push({ id: `persisted:${path}`, name, path, url, size: 0, ...media })
       return ''
     },
   ).replace(/\n{3,}/g, '\n\n').trim()

+ 10 - 0
src/server/routes.ts

@@ -484,6 +484,16 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       sendLocalMedia(ctx, localMediaPath(dependencies.config.attachmentsRoot, filePath))
     })
   })
+  router.get('/attachments/*filePath', async (ctx) => {
+    if (!isLoopbackUpstream(dependencies.config.upstream)) {
+      throw new HttpError(409, '本地附件只支持回环 Hermes 上游', 'remote_local_media_disabled')
+    }
+    await withJar(ctx, async (jar) => {
+      await requireGatewayAuthentication(ctx, dependencies, jar)
+      const filePath = Array.isArray(ctx.params.filePath) ? ctx.params.filePath.join('/') : ctx.params.filePath
+      sendLocalMedia(ctx, localMediaPath(dependencies.config.attachmentsRoot, filePath))
+    })
+  })
   router.get('/readyz', async (ctx) => {
     ctx.set('Cache-Control', 'no-store')
     try {

+ 17 - 0
tests/client/messageDisplay.test.ts

@@ -30,6 +30,23 @@ 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`',
+    }, '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',
+        kind: 'pdf',
+      }),
+    ])
+  })
   it('hides expanded attached context while restoring a missing reference', () => {
     const content = [
       '帮我看看这个文件',

+ 1 - 1
tests/fixtures/fake-hermes.mjs

@@ -42,7 +42,7 @@ const sessions = [
 ]
 const messages = [
   { id: 'message-user', role: 'user', content: '请检查今天生成的产物。MEDIA:/brand/AppIcon-1024.png', timestamp: now() - 180 },
-  { id: 'message-user-file', role: 'user', content: '查看附件\n\n[用户附加文件:测试报告.docx]\n@file:`/Users/samien/.hermes/attachments/测试报告.docx`', timestamp: now() - 179 },
+  { id: 'message-user-file', role: 'user', content: '查看附件\n\n[用户附加文件:测试报告.docx]\n@file:`attachments/测试报告.docx`', timestamp: now() - 179 },
   { id: 'message-thinking-tool', role: 'assistant', content: '', reasoning_content: '先检索文件,再归纳结果。', tool_calls: [{ id: 'tool-thinking', type: 'function', function: { name: 'file_search', arguments: '{"query":"产物"}' } }], timestamp: now() - 177 },
   { id: 'message-tool-call', role: 'assistant', content: '', tool_calls: [{ id: 'tool-1', type: 'function', function: { name: 'file_search', arguments: '{"query":"产物"}' } }], timestamp: now() - 176 },
   { id: 'message-tool', role: 'tool', tool_name: 'file_search', tool_call_id: 'tool-1', summary: '扫描产物目录', result: { path: '/tmp/demo-report.pdf', count: 1 }, timestamp: now() - 175 },

+ 6 - 0
tests/server/app.test.ts

@@ -140,6 +140,12 @@ describe('8800 BFF', () => {
       .set('Host', '127.0.0.1:8800').set('Cookie', cookieHeader(bootstrap)).expect(200)
     expect(response.headers['content-length']).toBe(String(Buffer.byteLength('document bytes')))
     expect(response.headers['content-disposition']).toContain('inline')
+    await request(runtime.app.callback())
+      .get('/attachments/报告.docx')
+      .set('Host', '127.0.0.1:8800').set('Cookie', cookieHeader(bootstrap)).expect(200)
+    await request(runtime.app.callback())
+      .get('/attachments/../state.db')
+      .set('Host', '127.0.0.1:8800').set('Cookie', cookieHeader(bootstrap)).expect(404)
   })
   it('bootstraps sequentially without exposing the upstream address', async () => {
     const records: RecordedRequest[] = []