Переглянути джерело

fix: 展示 iOS 用户图片附件

samien 2 місяців тому
батько
коміт
fa70466df6

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

@@ -182,6 +182,17 @@ 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 '' }
+  })
+  if (segments.length < 5 || segments[0] !== 'Users' || normalizedSegments[2] !== '.hermes' || normalizedSegments[3] !== 'images'
+    || 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 extracted = content.replace(
@@ -203,6 +214,53 @@ function extractPersistedAttachments(content: string): { content: string; attach
   return { content: cleaned, attachments }
 }
 
+function extractPersistedImages(content: string): { content: string; attachments: ChatAttachment[] } {
+  const lines = content.split(/\r?\n/)
+  const attachments: ChatAttachment[] = []
+  const imageMarker = /^\[用户附加图片\s*:\s*([^\]\r\n]+)\]\s*$/
+  const imageReference = /^@image:\s*(.+?)\s*$/
+  const screenshot = /^\[screenshot\]\s*$/
+
+  for (let index = 0; index < lines.length;) {
+    const firstMarker = lines[index]?.match(imageMarker)
+    if (!firstMarker) {
+      index += 1
+      continue
+    }
+    const start = index
+    const names: string[] = []
+    let cursor = index
+    while (true) {
+      const marker = lines[cursor]?.match(imageMarker)
+      if (!marker) break
+      names.push(marker[1]!.trim())
+      cursor += 1
+      while (lines[cursor]?.trim() === '') cursor += 1
+    }
+    const paths: string[] = []
+    while (true) {
+      const reference = lines[cursor]?.match(imageReference)
+      if (!reference) break
+      paths.push(reference[1]!.trim().replace(/^[`'"]|[`'"]$/g, ''))
+      cursor += 1
+    }
+    const references = paths.map(persistedImageUrl)
+    if (!paths.length || names.length !== paths.length || references.some(reference => !reference)) {
+      index = start + 1
+      continue
+    }
+    for (let position = 0; position < names.length; position += 1) {
+      const reference = references[position]!
+      const name = names[position]!
+      attachments.push({ id: `persisted:${reference.path}`, name, path: reference.path, url: reference.url, size: 0, ...attachmentMime(name) })
+    }
+    while (screenshot.test(lines[cursor] || '')) cursor += 1
+    lines.splice(start, cursor - start, ...Array(cursor - start).fill(''))
+    index = cursor
+  }
+  return { content: lines.join('\n').replace(/\n{3,}/g, '\n\n').trim(), attachments }
+}
+
 export function normalizeChatMessage(value: unknown, sessionId: string, fallbackProfile?: string): ChatMessage {
   const source = record(value)
   const contentValue = pick(source, 'content', 'text', 'output')
@@ -222,11 +280,13 @@ 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
+  if (role === 'user' && (content.includes('@file:') || content.includes('@image:'))) {
+    const persistedFiles = extractPersistedAttachments(content)
+    const persistedImages = extractPersistedImages(persistedFiles.content)
+    content = persistedImages.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)))
+    attachments.push(...[...persistedFiles.attachments, ...persistedImages.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'))

+ 3 - 0
src/server/config.ts

@@ -10,6 +10,7 @@ export interface ServerConfig {
   home: string
   mediaRoot: string
   attachmentsRoot: string
+  imagesRoot: string
   mediaOwner: string
   tlsCert?: string
   tlsKey?: string
@@ -88,6 +89,7 @@ export function loadServerConfig(
   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 imagesRoot = resolve(`${homedir()}/.hermes/images`)
   const tlsCert = env.HERMES_YAOYAO_TLS_CERT?.trim() || undefined
   const tlsKey = env.HERMES_YAOYAO_TLS_KEY?.trim() || undefined
   if (Boolean(tlsCert) !== Boolean(tlsKey)) {
@@ -115,6 +117,7 @@ export function loadServerConfig(
     home,
     mediaRoot,
     attachmentsRoot,
+    imagesRoot,
     mediaOwner: basename(homedir()),
     tlsCert,
     tlsKey,

+ 13 - 0
src/server/routes.ts

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

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

@@ -54,6 +54,50 @@ describe('displayContentForMessage', () => {
       }),
     ])
   })
+
+  it('turns iOS image markers into image attachments without keeping model screenshots', () => {
+    const message = normalizeChatMessage({
+      id: 'ios-user-image', role: 'user', timestamp: 1,
+      content: [
+        '测试',
+        '',
+        '[用户附加图片:照片-9270F5CF-2FE2-487F-8948-FA583F4C83B1.png]',
+        '@image:/Users/samien/.hermes/images/upload_20260820_130901_1.png',
+        '[screenshot]',
+      ].join('\n'),
+    }, 'session-1', 'default')
+
+    expect(message.content).toBe('测试')
+    expect(message.attachments).toEqual([
+      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',
+      }),
+    ])
+  })
+
+  it('keeps multiple iOS image uploads together as image attachments', () => {
+    const message = normalizeChatMessage({
+      id: 'ios-user-images', role: 'user', timestamp: 1,
+      content: [
+        '[用户附加图片:照片-1.jpeg]',
+        '',
+        '[用户附加图片:照片-2.jpeg]',
+        '@image:/Users/samien/.hermes/images/upload_1.jpeg',
+        '@image:/Users/samien/.hermes/images/upload_2.jpeg',
+        '[screenshot]',
+        '[screenshot]',
+      ].join('\n'),
+    }, 'session-1', 'default')
+
+    expect(message.content).toBe('')
+    expect(message.attachments?.map(attachment => attachment.url)).toEqual([
+      '/Users/samien/.hermes/images/upload_1.jpeg',
+      '/Users/samien/.hermes/images/upload_2.jpeg',
+    ])
+  })
   it('hides expanded attached context while restoring a missing reference', () => {
     const content = [
       '帮我看看这个文件',

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

@@ -26,8 +26,10 @@ 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')
+  const imagesRoot = join(home, 'images')
   mkdirSync(mediaRoot)
   mkdirSync(attachmentsRoot)
+  mkdirSync(imagesRoot)
   homes.push(home)
   return {
     host: '127.0.0.1',
@@ -37,6 +39,7 @@ function makeConfig(upstream = 'http://127.0.0.1:9119'): ServerConfig {
     home,
     mediaRoot,
     attachmentsRoot,
+    imagesRoot,
     mediaOwner: 'samien',
     allowInsecureLan: false,
     insecureLan: false,
@@ -147,6 +150,21 @@ describe('8800 BFF', () => {
       .get('/attachments/../state.db')
       .set('Host', '127.0.0.1:8800').set('Cookie', cookieHeader(bootstrap)).expect(404)
   })
+  it('serves persisted Hermes image references from the restricted images root', async () => {
+    const config = makeConfig()
+    writeFileSync(join(config.imagesRoot, '照片.png'), 'image 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/images/照片.png')
+      .set('Host', '127.0.0.1:8800').set('Cookie', cookieHeader(bootstrap)).expect(200)
+    expect(response.body.toString()).toBe('image bytes')
+    await request(runtime.app.callback())
+      .get('/Users/samien/.hermes/images/../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[] = []
     const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway(records) })