Przeglądaj źródła

fix: 将历史用户文件标记恢复为附件卡片

samien 2 miesięcy temu
rodzic
commit
0fd03ee63c

+ 31 - 0
src/client/utils/normalize.ts

@@ -163,6 +163,31 @@ export function normalizeAttachment(value: unknown, index = 0): ChatAttachment {
   }
 }
 
+function attachmentMime(name: string): { mimeType: string; kind: ChatAttachment['kind'] } {
+  const extension = name.split('.').at(-1)?.toLocaleLowerCase() || ''
+  if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif', 'avif', 'bmp', 'tif', 'tiff'].includes(extension)) {
+    return { mimeType: `image/${extension === 'jpg' ? 'jpeg' : extension}`, kind: 'image' }
+  }
+  if (extension === 'pdf') return { mimeType: 'application/pdf', kind: 'pdf' }
+  return { mimeType: 'application/octet-stream', kind: 'file' }
+}
+
+function extractPersistedAttachments(content: string): { content: string; attachments: ChatAttachment[] } {
+  const attachments: ChatAttachment[] = []
+  const cleaned = content.replace(
+    /\[用户附加\s*(文件|图片|PDF)\s*:\s*([^\]\r\n]+)\]\s*(?:\r?\n)?@file:\s*((?:\\?\/)[^\r\n]+)/g,
+    (_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 media = attachmentMime(name)
+      attachments.push({ id: `persisted:${path}`, name, path, url: path, size: 0, ...media })
+      return ''
+    },
+  ).replace(/\n{3,}/g, '\n\n').trim()
+  return { content: cleaned, attachments }
+}
+
 export function normalizeChatMessage(value: unknown, sessionId: string, fallbackProfile?: string): ChatMessage {
   const source = record(value)
   const contentValue = pick(source, 'content', 'text', 'output')
@@ -182,6 +207,12 @@ export function normalizeChatMessage(value: unknown, sessionId: string, fallback
   }
   const roleValue = string(source.role, 'assistant')
   const role = ['user', 'assistant', 'tool', 'system'].includes(roleValue) ? roleValue as ChatMessage['role'] : 'system'
+  if (role === 'user' && content.includes('@file:')) {
+    const persisted = extractPersistedAttachments(content)
+    content = persisted.content
+    const known = new Set(attachments.map(item => item.path || item.url || item.name))
+    attachments.push(...persisted.attachments.filter(item => !known.has(item.path || item.url || item.name)))
+  }
   const id = string(pick(source, 'id', 'message_id', 'messageId')) || `history-${sessionId}-${number(source.timestamp)}-${Math.random()}`
   const status = string(pick(source, 'status', 'finish_reason', 'finishReason'))
   return {

+ 3 - 0
src/server/config.ts

@@ -9,6 +9,7 @@ export interface ServerConfig {
   allowedHosts: ReadonlySet<string>
   home: string
   mediaRoot: string
+  attachmentsRoot: string
   mediaOwner: string
   tlsCert?: string
   tlsKey?: string
@@ -86,6 +87,7 @@ export function loadServerConfig(
   const upstream = parseUpstream(env.HERMES_YAOYAO_UPSTREAM)
   const home = resolve(env.HERMES_YAOYAO_HOME?.trim() || `${homedir()}/.hermes-yaoyao`)
   const mediaRoot = resolve(env.HERMES_YAOYAO_MEDIA_ROOT?.trim() || `${homedir()}/Agents`)
+  const attachmentsRoot = resolve(`${homedir()}/.hermes/attachments`)
   const tlsCert = env.HERMES_YAOYAO_TLS_CERT?.trim() || undefined
   const tlsKey = env.HERMES_YAOYAO_TLS_KEY?.trim() || undefined
   if (Boolean(tlsCert) !== Boolean(tlsKey)) {
@@ -112,6 +114,7 @@ export function loadServerConfig(
     allowedHosts,
     home,
     mediaRoot,
+    attachmentsRoot,
     mediaOwner: basename(homedir()),
     tlsCert,
     tlsKey,

+ 13 - 0
src/server/routes.ts

@@ -471,6 +471,19 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       sendLocalMedia(ctx, localMediaPath(dependencies.config.mediaRoot, filePath))
     })
   })
+  router.get('/Users/:owner/.hermes/attachments/*filePath', async (ctx) => {
+    if (!isLoopbackUpstream(dependencies.config.upstream)) {
+      throw new HttpError(409, '本地附件只支持回环 Hermes 上游', 'remote_local_media_disabled')
+    }
+    if (ctx.params.owner !== dependencies.config.mediaOwner) {
+      throw new HttpError(404, '本地附件不存在', 'local_media_not_found')
+    }
+    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 {

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

@@ -1,7 +1,20 @@
 import { describe, expect, it } from 'vitest'
 import { displayContentForMessage } from '@/utils/messageDisplay'
+import { normalizeChatMessage } from '@/utils/normalize'
 
 describe('displayContentForMessage', () => {
+  it('turns persisted user @file markers into attachment cards and removes marker text', () => {
+    const message = normalizeChatMessage({
+      id: 'user-file', role: 'user', timestamp: 1,
+      content: '刘博士的技术介绍。\n\n[用户附加文件:大模型推理优化报告.docx]\n@file:\\/Users/samien/.hermes/attachments/大模型推理优化报告.docx',
+    }, 'session-1', 'default')
+    expect(message.content).toBe('刘博士的技术介绍。')
+    expect(message.attachments).toEqual([expect.objectContaining({
+      name: '大模型推理优化报告.docx',
+      path: '/Users/samien/.hermes/attachments/大模型推理优化报告.docx',
+      kind: 'file',
+    })])
+  })
   it('hides expanded attached context while restoring a missing reference', () => {
     const content = [
       '帮我看看这个文件',

+ 9 - 0
tests/e2e/workspace.spec.ts

@@ -222,6 +222,15 @@ test('renders historical assistant MEDIA as Markdown in chat and group chat', as
   await expect(page.locator('.markdown img[alt="AppIcon-1024.png"]')).toBeVisible()
 })
 
+test('renders persisted user file markers as attachment cards', async ({ page }) => {
+  await page.goto('/chat/session-demo?profile=yaoyao')
+  const userMessage = page.locator('[data-message-id="message-user-file"]')
+  await expect(userMessage).toContainText('查看附件')
+  await expect(userMessage.getByRole('button', { name: /测试报告\.docx/ })).toBeVisible()
+  await expect(userMessage).not.toContainText('@file:')
+  await expect(userMessage).not.toContainText('用户附加文件')
+})
+
 test('folds tool result rows into their expandable tool call', async ({ page }) => {
   await page.goto('/chat/session-demo')
   const trace = page.locator('.turn-trace').first()

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

@@ -42,6 +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-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 },

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

@@ -25,7 +25,9 @@ afterEach(() => {
 function makeConfig(upstream = 'http://127.0.0.1:9119'): ServerConfig {
   const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-server-'))
   const mediaRoot = join(home, 'media')
+  const attachmentsRoot = join(home, 'attachments')
   mkdirSync(mediaRoot)
+  mkdirSync(attachmentsRoot)
   homes.push(home)
   return {
     host: '127.0.0.1',
@@ -34,6 +36,7 @@ function makeConfig(upstream = 'http://127.0.0.1:9119'): ServerConfig {
     allowedHosts: new Set(),
     home,
     mediaRoot,
+    attachmentsRoot,
     mediaOwner: 'samien',
     allowInsecureLan: false,
     insecureLan: false,
@@ -125,6 +128,19 @@ describe('8800 BFF', () => {
     await request(runtime.app.callback())
       .get('/Users/samien/Agents/../state.db').set('Host', '127.0.0.1:8800').set('Cookie', cookies).expect(404)
   })
+  it('serves persisted Hermes attachment references from the restricted attachment root', async () => {
+    const config = makeConfig()
+    writeFileSync(join(config.attachmentsRoot, '报告.docx'), 'document bytes')
+    const runtime = createApplication({ config, fetchImpl: fakeGateway([]) })
+    runtimes.push(runtime)
+    const bootstrap = await request(runtime.app.callback())
+      .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    const response = await request(runtime.app.callback())
+      .get('/Users/samien/.hermes/attachments/报告.docx')
+      .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')
+  })
   it('bootstraps sequentially without exposing the upstream address', async () => {
     const records: RecordedRequest[] = []
     const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway(records) })