Просмотр исходного кода

feat: 会话媒体全览滑动与预览关闭

samien 2 месяцев назад
Родитель
Сommit
59e4d4fcb1

+ 1 - 1
src/client/components/library/PreviewInspector.vue

@@ -122,7 +122,7 @@ watch(() => props.item.id, loadRichPreview, { immediate: true, flush: 'post' })
 
 <style scoped>
 .preview-inspector { display: flex; height: 100%; min-height: 0; flex-direction: column; overflow: hidden; }
-header { display: flex; min-height: 46px; align-items: center; gap: 8px; padding: 5px 10px; border-bottom: 1px solid var(--line); } .preview-back { display: none; } header > strong { min-width: 0; flex: 1; overflow: hidden; font-size: 12px; font-weight: 630; text-overflow: ellipsis; white-space: nowrap; }.preview-header-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 2px; }.preview-header-actions .icon-button { display: grid; width: 30px; height: 30px; place-items: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; text-decoration: none; }.preview-header-actions .icon-button:hover { background: var(--surface-soft); color: var(--text-primary); }
+header { display: flex; min-height: 56px; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); } header > strong { min-width: 0; flex: 1; overflow: hidden; font-size: 12px; font-weight: 630; text-overflow: ellipsis; white-space: nowrap; }.preview-header-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 3px; }.preview-header-actions .icon-button { display: grid; width: 32px; height: 32px; place-items: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; text-decoration: none; }.preview-header-actions .icon-button:hover { background: var(--surface-soft); color: var(--text-primary); }.preview-next-icon { transform: rotate(180deg); }
 .preview-next-icon { transform: rotate(180deg); }
 .preview-stage { display: grid; min-height: 0; flex: 1 1 auto; place-items: center; overflow: hidden; background: var(--surface-soft); touch-action: pan-y; }.preview-stage img, .preview-stage video { display: block; width: 100%; height: 100%; object-fit: contain; }.preview-stage audio { width: calc(100% - 34px); }.preview-stage object { width: 100%; height: 100%; border: 0; }.preview-text { width: 100%; height: 100%; box-sizing: border-box; padding: 16px; overflow: auto; background: var(--surface-raised); }.preview-text pre { margin: 0; color: var(--text-secondary); font: 10px/1.65 var(--font-code); white-space: pre-wrap; overflow-wrap: anywhere; }
 .preview-office { width: 100%; height: 100%; overflow: auto; background: #e6e6e4; color: #111; }.preview-office :deep(.docx-wrapper) { padding: 18px 0; background: #e6e6e4; }.preview-office :deep(.docx) { margin-bottom: 14px; box-shadow: 0 4px 18px rgba(0,0,0,.12); }

+ 2 - 2
src/client/components/library/PreviewModal.vue

@@ -8,7 +8,7 @@ const emit = defineEmits<{ close: []; addToComposer: [item: UiLibraryItem]; sour
 
 const mediaItems = computed(() => {
   const candidates = props.items.filter(item => item.kind === 'image' || item.kind === 'video')
-  return candidates.some(item => item.id === props.item.id) ? candidates : [props.item]
+  return candidates.some(item => item.id === props.item.id || item.previewUrl === props.item.previewUrl) ? candidates : [props.item]
 })
 const activeIndex = ref(0)
 const activeItem = computed(() => mediaItems.value[activeIndex.value] ?? props.item)
@@ -17,7 +17,7 @@ const canNext = computed(() => activeIndex.value < mediaItems.value.length - 1)
 let swipeStart: number | undefined
 
 function resetActiveItem() {
-  const index = mediaItems.value.findIndex(item => item.id === props.item.id)
+  const index = mediaItems.value.findIndex(item => item.id === props.item.id || item.previewUrl === props.item.previewUrl)
   activeIndex.value = index >= 0 ? index : 0
 }
 function previous() { if (canPrevious.value) activeIndex.value -= 1 }

+ 44 - 0
src/client/components/library/mediaSequence.ts

@@ -0,0 +1,44 @@
+import type { UiMessage } from '@/components/messages/types'
+import type { LibraryKind, UiLibraryItem } from './types'
+
+const imageExtensions = new Set(['apng', 'avif', 'bmp', 'gif', 'heic', 'heif', 'ico', 'jfif', 'jpeg', 'jpg', 'jxl', 'png', 'svg', 'tif', 'tiff', 'webp'])
+const videoExtensions = new Set(['avi', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'webm'])
+
+export function previewItemFromUrl(name: string, url: string, id = `local:${url}`, kind?: LibraryKind): UiLibraryItem {
+  const extension = name.split('.').at(-1)?.toLocaleLowerCase() || ''
+  const inferred = imageExtensions.has(extension) ? 'image'
+    : videoExtensions.has(extension) ? 'video'
+      : ['mp3', 'wav', 'm4a', 'aac', 'ogg'].includes(extension) ? 'audio'
+        : extension === 'pdf' ? 'pdf'
+          : ['md', 'markdown', 'txt', 'json', 'yaml', 'yml', 'csv', 'js', 'ts', 'py', 'sh'].includes(extension) ? 'text' : 'file'
+  return { id, name, kind: kind || inferred, previewUrl: url, downloadUrl: url }
+}
+
+function nameFromUrl(url: string): string {
+  try { return decodeURIComponent(new URL(url, window.location.origin).pathname.split('/').at(-1) || '媒体') } catch { return '媒体' }
+}
+
+/** The ordered image/video sequence visible in one normal or group conversation. */
+export function mediaItemsFromMessages(messages: UiMessage[]): UiLibraryItem[] {
+  const result: UiLibraryItem[] = []
+  const seen = new Set<string>()
+  const append = (item: UiLibraryItem) => {
+    if (!item.previewUrl || !['image', 'video'].includes(item.kind) || seen.has(item.previewUrl)) return
+    seen.add(item.previewUrl)
+    result.push(item)
+  }
+  for (const message of messages) {
+    for (const attachment of message.attachments ?? []) {
+      if (!attachment.url || !['image', 'video'].includes(attachment.kind || 'file')) continue
+      append(previewItemFromUrl(attachment.name, attachment.url, `${message.id}:${attachment.id}`, attachment.kind))
+    }
+    const markdownMedia = /!\[[^\]]*\]\(([^)\s]+)\)|\[[^\]]+\]\(([^)\s]+)\)/g
+    for (let match = markdownMedia.exec(message.content); match; match = markdownMedia.exec(message.content)) {
+      const url = match[1] || match[2]
+      if (!url) continue
+      const item = previewItemFromUrl(nameFromUrl(url), url, `${message.id}:${url}`)
+      append(item)
+    }
+  }
+  return result
+}

+ 8 - 9
src/client/views/ChatView.vue

@@ -9,6 +9,7 @@ import type { ComposerOption, ComposerReference, ComposerSubmit } from '@/compon
 import PreviewInspector from '@/components/library/PreviewInspector.vue'
 import PreviewModal from '@/components/library/PreviewModal.vue'
 import type { UiLibraryItem } from '@/components/library/types'
+import { mediaItemsFromMessages, previewItemFromUrl } from '@/components/library/mediaSequence'
 import MessageTimeline from '@/components/messages/MessageTimeline.vue'
 import type { UiMessage } from '@/components/messages/types'
 import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
@@ -50,6 +51,7 @@ const sidebarItems = computed(() => sessions.value.map(session => sessionSidebar
   agentNames.value.get(session.profile || auth.activeProfile?.name || ''),
 )))
 const messages = computed(() => chatMessagesToUi(chat.messages, profile => profile ? agentNames.value.get(profile) : undefined))
+const conversationMediaItems = computed(() => mediaItemsFromMessages(messages.value))
 const activeSession = computed(() => chat.activeSession)
 const connected = computed(() => ['connected', 'ready'].includes(chat.connectionState))
 const interaction = computed(() => chatInteraction(chat.pendingApproval, chat.pendingClarification))
@@ -142,17 +144,14 @@ async function send(payload: ComposerSubmit) {
 }
 
 function openAttachment(attachment: NonNullable<UiMessage['attachments']>[number]) {
-  preview.value = { id: attachment.id, name: attachment.name, kind: attachment.kind || 'file', size: attachment.size, previewUrl: attachment.url, downloadUrl: attachment.url }
+  const item = previewItemFromUrl(attachment.name, attachment.url || '', attachment.id, attachment.kind)
+  item.size = attachment.size
+  if (item.kind === 'image' || item.kind === 'video') filePreview.value = item
+  else preview.value = item
 }
 
 function openLocalFile({ name, url }: { name: string; url: string }) {
-  const extension = name.split('.').at(-1)?.toLowerCase() || ''
-  const kind: UiLibraryItem['kind'] = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'heic', 'avif'].includes(extension) ? 'image'
-    : ['mp4', 'mov', 'webm', 'mkv'].includes(extension) ? 'video'
-      : ['mp3', 'wav', 'm4a', 'aac', 'ogg'].includes(extension) ? 'audio'
-        : extension === 'pdf' ? 'pdf'
-          : ['md', 'markdown', 'txt', 'json', 'yaml', 'yml', 'csv', 'js', 'ts', 'py', 'sh'].includes(extension) ? 'text' : 'file'
-  filePreview.value = { id: `local:${url}`, name, kind, previewUrl: url, downloadUrl: url }
+  filePreview.value = previewItemFromUrl(name, url)
 }
 
 async function selectModel(id: string) {
@@ -330,7 +329,7 @@ watch(() => chat.activeSessionId, async id => {
     <template #inspector><PreviewInspector v-if="preview" :item="preview" @close="preview = null" @add-to-composer="preview = null" /></template>
   </WorkspaceView>
 
-  <PreviewModal v-if="filePreview" :item="filePreview" @close="filePreview = null" @add-to-composer="filePreview = null" @source="filePreview = null" />
+  <PreviewModal v-if="filePreview" :item="filePreview" :items="conversationMediaItems" @close="filePreview = null" @add-to-composer="filePreview = null" @source="filePreview = null" />
 
   <ChoiceDialog :open="modelDialog" title="选择模型" :options="modelOptions" :selected-id="selectedModelId" @close="modelDialog = false" @select="selectModel" />
 

+ 11 - 8
src/client/views/GroupsView.vue

@@ -9,6 +9,7 @@ import CreateGroupDialog from '@/components/groups/CreateGroupDialog.vue'
 import GroupManager from '@/components/groups/GroupManager.vue'
 import PreviewModal from '@/components/library/PreviewModal.vue'
 import type { UiLibraryItem } from '@/components/library/types'
+import { mediaItemsFromMessages, previewItemFromUrl } from '@/components/library/mediaSequence'
 import MessageTimeline from '@/components/messages/MessageTimeline.vue'
 import type { UiMessage } from '@/components/messages/types'
 import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
@@ -34,6 +35,7 @@ const filteredRooms = computed(() => groups.rooms
   .filter(room => !search.value.trim() || `${room.name} ${room.lastMessage?.content || ''}`.toLocaleLowerCase().includes(search.value.trim().toLocaleLowerCase())))
 const sidebarItems = computed(() => filteredRooms.value.map(roomSidebarItem))
 const messages = computed(() => groups.messages.map(groupMessageToUi))
+const conversationMediaItems = computed(() => mediaItemsFromMessages(messages.value))
 const agents = computed(() => groups.agents.map(agentToUi))
 const connected = computed(() => ['connected', 'ready'].includes(groups.connectionState))
 const activeInteraction = computed(() => groupInteraction(groups.pendingInteractions[0]))
@@ -96,13 +98,13 @@ async function archiveRoom() {
 }
 
 function openLocalFile({ name, url }: { name: string; url: string }) {
-  const extension = name.split('.').at(-1)?.toLowerCase() || ''
-  const kind: UiLibraryItem['kind'] = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'heic', 'avif'].includes(extension) ? 'image'
-    : ['mp4', 'mov', 'webm', 'mkv'].includes(extension) ? 'video'
-      : ['mp3', 'wav', 'm4a', 'aac', 'ogg'].includes(extension) ? 'audio'
-        : extension === 'pdf' ? 'pdf'
-          : ['md', 'markdown', 'txt', 'json', 'yaml', 'yml', 'csv', 'js', 'ts', 'py', 'sh'].includes(extension) ? 'text' : 'file'
-  preview.value = { id: `local:${url}`, name, kind, previewUrl: url, downloadUrl: url }
+  preview.value = previewItemFromUrl(name, url)
+}
+
+function openAttachment(attachment: NonNullable<UiMessage['attachments']>[number]) {
+  const item = previewItemFromUrl(attachment.name, attachment.url || '', attachment.id, attachment.kind)
+  item.size = attachment.size
+  preview.value = item
 }
 
 onMounted(async () => {
@@ -163,6 +165,7 @@ watch(() => route.params.roomId, async roomId => {
         empty-description="使用 @ 提及指定 Agent,或直接发送消息触发已启用自动回复的成员。"
         @load-older="groups.loadOlder"
         @quote="quoted = $event"
+        @preview="openAttachment"
         @preview-file="openLocalFile"
         @approve="activeInteraction && groups.approveInteraction(activeInteraction.id, $event ? 'once' : 'deny')"
         @clarify="activeInteraction && groups.clarifyInteraction(activeInteraction.id, $event)"
@@ -213,7 +216,7 @@ watch(() => route.params.roomId, async roomId => {
   </WorkspaceView>
 
   <CreateGroupDialog :open="createOpen" :profiles="profiles" :busy="groups.isLoading" @close="createOpen = false" @create="createRoom" />
-  <PreviewModal v-if="preview" :item="preview" @close="preview = null" @add-to-composer="preview = null" @source="preview = null" />
+  <PreviewModal v-if="preview" :item="preview" :items="conversationMediaItems" @close="preview = null" @add-to-composer="preview = null" @source="preview = null" />
 </template>
 
 <style scoped>

+ 22 - 0
tests/client/mediaSequence.test.ts

@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest'
+import { mediaItemsFromMessages, previewItemFromUrl } from '@/components/library/mediaSequence'
+import type { UiMessage } from '@/components/messages/types'
+
+describe('conversation media sequence', () => {
+  it('keeps visible image and video order while deduplicating the same URL', () => {
+    const messages: UiMessage[] = [
+      { id: 'one', role: 'assistant', content: '![第一张](/media/first.png)' },
+      { id: 'two', role: 'assistant', content: '[视频](/media/second.mp4)', attachments: [{ id: 'a', name: '第一张.png', kind: 'image', url: '/media/first.png' }] },
+      { id: 'three', role: 'assistant', content: '普通文本', attachments: [{ id: 'b', name: '第三张.webp', kind: 'image', url: '/media/third.webp' }] },
+    ]
+    expect(mediaItemsFromMessages(messages).map(item => [item.name, item.kind, item.previewUrl])).toEqual([
+      ['first.png', 'image', '/media/first.png'],
+      ['second.mp4', 'video', '/media/second.mp4'],
+      ['第三张.webp', 'image', '/media/third.webp'],
+    ])
+  })
+
+  it('infers full-view media kind from a clicked conversation link', () => {
+    expect(previewItemFromUrl('片段.webm', '/media/clip.webm')).toMatchObject({ kind: 'video', previewUrl: '/media/clip.webm' })
+  })
+})

+ 5 - 1
tests/e2e/workspace.spec.ts

@@ -86,7 +86,11 @@ test('renders historical assistant MEDIA as Markdown in chat and group chat', as
   const image = page.locator('.markdown img[alt="夭夭 Logo"]')
   await expect(image).toBeVisible()
   await image.click()
-  await expect(page.getByRole('dialog', { name: /预览 AppIcon-1024.png/ })).toBeVisible()
+  const mediaDialog = page.getByRole('dialog', { name: /预览 AppIcon-1024.png/ })
+  await expect(mediaDialog).toBeVisible()
+  await expect(mediaDialog.getByRole('button', { name: '下一张媒体' })).toBeVisible()
+  await mediaDialog.getByRole('button', { name: '下一张媒体' }).click()
+  await expect(mediaDialog.locator('img')).toHaveAttribute('src', /variant=2/)
   await page.keyboard.press('Escape')
   await expect(page.getByRole('dialog', { name: /预览 AppIcon-1024.png/ })).toBeHidden()
 

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

@@ -47,6 +47,7 @@ const messages = [
   { 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 },
   { id: 'message-assistant', role: 'assistant', content: '已整理完成。下面是 **验收摘要**:\n\n- 普通聊天已连接\n- 群聊 v2 已就绪\n- [打开报告](https://example.com/report)', reasoning_content: '先核对会话与文件索引。', timestamp: now() - 170 },
   { id: 'message-image', role: 'assistant', content: '预览图:![夭夭 Logo](/brand/AppIcon-1024.png)', timestamp: now() - 160 },
+  { id: 'message-image-second', role: 'assistant', content: '第二张:![夭夭 Logo 2](/brand/AppIcon-1024.png?variant=2)', timestamp: now() - 155 },
   { id: 'message-legacy-media', role: 'assistant', content: '历史兼容:MEDIA:/brand/AppIcon-1024.png', timestamp: now() - 150 },
   { id: 'message-local-link', role: 'assistant', content: '[方案草稿.md](/Users/samien/Agents/方案草稿.md)', timestamp: now() - 145 },
   { id: 'message-delegation', role: 'user', content: '[ASYNC DELEGATION BATCH COMPLETE]\n后台子任务已经完成。', display_kind: 'async_delegation_complete', display_metadata: { task_count: 2, completed_count: 2, failed_count: 0, duration_seconds: 71 }, timestamp: now() - 140 },