فهرست منبع

发布 v0.3.10:修复用户 Profile 图片在 Web 历史消息中无法显示

LSamien 1 ماه پیش
والد
کامیت
d26f88cd55
9فایلهای تغییر یافته به همراه104 افزوده شده و 10 حذف شده
  1. 1 1
      README.md
  2. 14 0
      docs/releases/v0.3.10.md
  3. 2 2
      package-lock.json
  4. 1 1
      package.json
  5. 3 3
      release.json
  6. 8 2
      src/client/utils/normalize.ts
  7. 33 0
      tests/client/messageDisplay.test.ts
  8. 28 0
      tests/e2e/hermesMedia.spec.ts
  9. 14 1
      tests/fixtures/fake-hermes.mjs

+ 1 - 1
README.md

@@ -1,6 +1,6 @@
 # 夭夭 Web
 
-当前发布版本:**v0.3.9**,配套 iOS **1.3(173)**。详见 [发布说明](docs/releases/v0.3.9.md)。
+当前发布版本:**v0.3.10**,配套 iOS **1.3(173)**。详见 [发布说明](docs/releases/v0.3.10.md)。
 
 夭夭 Web 为网页和 iOS 提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务;无需安装 Yaoyao 插件。
 

+ 14 - 0
docs/releases/v0.3.10.md

@@ -0,0 +1,14 @@
+# v0.3.10
+
+修复 Web 历史对话中,用户从 iOS 发送的 Profile 图片被显示成文字的问题。
+
+- 用户图片解析同时识别默认目录和 `.hermes/profiles/<profile>/images/`,保留原始 Profile 路径。
+- 批量图片恢复为可预览附件,隐藏对应的图片说明、`@image:` 和截图占位标记;正文与原始文件名保留。
+- 继续使用现有的鉴权图片读取接口;拒绝目录越界、编码分隔符、反斜杠及控制字符。
+- 本次仅更新 Web,继续配套 iOS 1.3(173)。
+
+## 验证
+
+- 85 个测试文件、498 项测试通过,覆盖用户消息解析和媒体代理回归。
+- 浏览器通过实际 Web 与上游接口加载九张用户图片,验证点开与切换预览;原有生成图片预览通过。
+- 类型检查、生产构建通过。

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.9",
+  "version": "0.3.10",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "hermes-yaoyao",
-      "version": "0.3.9",
+      "version": "0.3.10",
       "license": "BSL-1.1",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.9",
+  "version": "0.3.10",
   "private": true,
   "license": "BSL-1.1",
   "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.3.9",
-  "webVersion": "0.3.9",
-  "gitTag": "v0.3.9"
+  "releaseVersion": "0.3.10",
+  "webVersion": "0.3.10",
+  "gitTag": "v0.3.10"
 }

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

@@ -201,8 +201,14 @@ function persistedImageUrl(path: string): { path: string; url: string } | undefi
   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
+  const rootImages = normalizedSegments[3] === 'images' && segments.length >= 5
+  const profileImages = normalizedSegments[3] === 'profiles'
+    && normalizedSegments[5] === 'images' && segments.length >= 7
+  // User uploads belong to either the default Hermes home or the selected
+  // Profile. Keep the full path so the Web media route reaches that Profile.
+  if (normalizedSegments[0] !== 'Users' || normalizedSegments[2] !== '.hermes'
+    || (!rootImages && !profileImages)
+    || normalizedSegments.some(segment => !segment || segment === '.' || segment === '..' || /[/\\\u0000-\u001f\u007f]/.test(segment))) return undefined
   return { path, url: `/${segments.map(encodeURIComponent).join('/')}` }
 }
 

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

@@ -98,6 +98,39 @@ describe('displayContentForMessage', () => {
       '/Users/samien/.hermes/images/upload_2.jpeg',
     ])
   })
+
+  it('restores an entire batch of iOS Profile images with their original names', () => {
+    const names = ['照片-D2BE1E0F.png', '照片-8717030E.png', '中文 照片.heic']
+    const paths = names.map((_, index) => `/Users/samien/.hermes/profiles/yaoer/images/upload_20260815_233834_${index + 1}.png`)
+    const message = normalizeChatMessage({
+      id: 'ios-profile-images', role: 'user', timestamp: 1,
+      content: [
+        '读取图片中的提示词',
+        ...names.map(name => `[用户附加图片:${name}]`),
+        ...paths.map(path => `@image:${path}`),
+        ...paths.map(() => '[screenshot]'),
+      ].join('\n'),
+    }, 'session-1', 'yaoer')
+
+    expect(message.content).toBe('读取图片中的提示词')
+    expect(message.attachments?.map(({ name, path, url, kind }) => ({ name, path, url, kind })))
+      .toEqual(names.map((name, index) => ({ name, path: paths[index], url: paths[index], kind: 'image' })))
+  })
+
+  it.each([
+    '/Users/samien/.hermes/profiles/yaoer/config/image.png',
+    '/Users/samien/.hermes/profiles/../images/image.png',
+    '/Users/samien/.hermes/profiles/yaoer/images/%2e%2e/image.png',
+    '/Users/samien/.hermes/profiles/yaoer%2Fother/images/image.png',
+    '/Users/samien/.hermes/profiles/yaoer/images/nested%2F..%2Fimage.png',
+    '/Users/samien/.hermes/profiles/yaoer/images/image%5C.png',
+    '/Users/samien/.hermes/profiles/yaoer/images/image%00.png',
+  ])('keeps unsupported or unsafe image references as text: %s', path => {
+    const content = `[用户附加图片:照片.png]\n@image:${path}\n[screenshot]`
+    const message = normalizeChatMessage({ id: 'unsafe', role: 'user', content }, 'session-1', 'yaoer')
+    expect(message.content).toBe(content)
+    expect(message.attachments).toBeUndefined()
+  })
   it('hides expanded attached context while restoring a missing reference', () => {
     const content = [
       '帮我看看这个文件',

+ 28 - 0
tests/e2e/hermesMedia.spec.ts

@@ -74,3 +74,31 @@ test('loads Hermes profile cache images through the Web server and switches thei
   await page.keyboard.press('Escape')
   await expect(preview).toBeHidden()
 })
+
+test('restores nine user Profile uploads as images and opens the whole batch in the preview', async ({ page, baseURL }, testInfo) => {
+  await signIn(page.request, new URL(baseURL!).origin)
+  await page.goto('/chat/session-user-media?profile=yaoer')
+  const message = page.locator('.message--user')
+  await expect(message).toContainText('读取图片中的提示词')
+  await expect(message).not.toContainText('@image:')
+  await expect(message).not.toContainText('[用户附加图片')
+  await expect(message).not.toContainText('[screenshot]')
+  const images = message.locator('img')
+  await expect(images).toHaveCount(9)
+  for (const image of await images.all()) {
+    await image.scrollIntoViewIfNeeded()
+    await expectDecodedImage(image)
+    const url = new URL(await image.getAttribute('src') || '', baseURL)
+    expect(url.origin).toBe(new URL(baseURL!).origin)
+    expect(url.pathname).toContain('/.hermes/profiles/yaoer/images/')
+  }
+  await page.screenshot({ path: testInfo.outputPath('user-profile-images.png') })
+  await images.first().click()
+  const preview = page.locator('.image-preview-overlay')
+  await expect(preview.locator('.image-preview-counter')).toHaveText('1 / 9')
+  await expectDecodedImage(preview.getByRole('img', { name: '照片-1.png', exact: true }))
+  await preview.getByRole('button', { name: '下一张媒体' }).click()
+  await expect(preview.locator('.image-preview-counter')).toHaveText('2 / 9')
+  await expectDecodedImage(preview.getByRole('img', { name: '照片-2.png', exact: true }))
+  await page.screenshot({ path: testInfo.outputPath('user-profile-image-preview.png') })
+})

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

@@ -107,6 +107,7 @@ const sessions = [
   { id: 'session-demo', profile: 'yaoyao', source: 'web', title: '夭夭 Web 验收会话', preview: '文件库与群聊已经就绪', message_count: 4, tool_call_count: 1, started_at: now() - 3600, last_active_at: now(), pinned: true, model: 'gpt-5.6', provider: 'openai' },
   { id: 'session-yaoer', profile: 'yaoer', source: 'web', title: '瑶儿专属会话', message_count: 1, tool_call_count: 0, started_at: now() - 3500, last_active_at: now() - 20, model: 'gpt-5.6', provider: 'openai' },
   { id: 'session-media', profile: 'yaoer', source: 'web', title: '瑶儿生成图片验收', message_count: 1, tool_call_count: 0, started_at: now() - 3400, last_active_at: now() - 30, model: 'gpt-5.6', provider: 'openai' },
+  { id: 'session-user-media', profile: 'yaoer', source: 'web', title: '用户多图验收', message_count: 1, tool_call_count: 0, started_at: now() - 3300, last_active_at: now() - 40, model: 'gpt-5.6', provider: 'openai' },
   ...Array.from({ length: 101 }, (_, index) => ({ id: `session-page-${index + 1}`, profile: 'yaoyao', source: 'web', title: `分页会话 ${index + 1}`, message_count: 0, tool_call_count: 0, started_at: now() - 10_000 - index, last_active_at: now() - 10_000 - index })),
 ]
 const messages = [
@@ -132,6 +133,17 @@ const profileMediaPaths = [
   '/Users/samien/.hermes/profiles/yaoer/cache/images/openai_codex_gpt-image-2-high_20260902_194820_1d225f08.png',
   '/Users/samien/.hermes/profiles/yaoer/cache/images/国漫三视图.png',
 ]
+const userProfileMediaPaths = Array.from({ length: 9 }, (_, index) =>
+  `/Users/samien/.hermes/profiles/yaoer/images/upload_20260815_233834_${index + 1}.png`)
+const userProfileMediaMessages = [{
+  id: 'message-user-profile-media', role: 'user', timestamp: now() - 40,
+  content: [
+    '读取图片中的提示词',
+    ...userProfileMediaPaths.map((_, index) => `[用户附加图片:照片-${index + 1}.png]`),
+    ...userProfileMediaPaths.map(path => `@image:${path}`),
+    ...userProfileMediaPaths.map(() => '[screenshot]'),
+  ].join('\n'),
+}]
 const profileMediaPng = readFileSync(new URL('../../public/brand/AppIcon-1024.png', import.meta.url))
 const profileMediaMessages = [{
   id: 'message-profile-media', role: 'assistant', timestamp: now() - 30,
@@ -186,7 +198,7 @@ const server = createServer(async (request, response) => {
   if (!authenticated(request)) return json(response, 401, { detail: 'Unauthorized' })
   if (url.pathname === '/api/files/download' && request.method === 'GET') {
     const path = url.searchParams.get('path')
-    if (!profileMediaPaths.includes(path)) return json(response, 404, { detail: 'File not found' })
+    if (![...profileMediaPaths, ...userProfileMediaPaths].includes(path)) return json(response, 404, { detail: 'File not found' })
     response.writeHead(200, {
       'Content-Type': 'image/png',
       'Content-Length': profileMediaPng.length,
@@ -329,6 +341,7 @@ const server = createServer(async (request, response) => {
   if (/^\/api\/sessions\/[^/]+\/messages$/.test(url.pathname)) {
     const id = decodeURIComponent(url.pathname.split('/')[3])
     const scopedMessages = id === 'session-media' ? profileMediaMessages
+      : id === 'session-user-media' ? userProfileMediaMessages
       : id === 'session-yaoer' ? [{ id: 'yaoer-history', role: 'assistant', content: '瑶儿历史消息', timestamp: now() - 10 }] : messages
     return json(response, 200, { messages: scopedMessages, pagination: { total: scopedMessages.length, returned: scopedMessages.length, limit: 150, hasMore: false } })
   }