Przeglądaj źródła

fix: 修复会话媒体全览与侧边切换

samien 2 miesięcy temu
rodzic
commit
9eed3dadac

+ 4 - 9
src/client/components/library/PreviewInspector.vue

@@ -6,11 +6,9 @@ import type { UiLibraryItem } from './types'
 
 const props = withDefaults(defineProps<{
   item: UiLibraryItem
-  canPrevious?: boolean
-  canNext?: boolean
   showClose?: boolean
-}>(), { canPrevious: false, canNext: false, showClose: false })
-const emit = defineEmits<{ close: []; previous: []; next: []; addToComposer: [item: UiLibraryItem]; source: [item: UiLibraryItem] }>()
+}>(), { showClose: false })
+const emit = defineEmits<{ close: []; addToComposer: [item: UiLibraryItem]; source: [item: UiLibraryItem] }>()
 
 const canInline = computed(() => !!props.item.previewUrl)
 const isTextual = computed(() => ['text', 'code'].includes(props.item.kind))
@@ -96,8 +94,6 @@ watch(() => props.item.id, loadRichPreview, { immediate: true, flush: 'post' })
     <header>
       <strong :title="item.name">{{ item.title || item.name }}</strong>
       <div class="preview-header-actions">
-        <button v-if="canPrevious" class="icon-button" type="button" aria-label="上一张媒体" title="上一张媒体" @click="emit('previous')"><AppIcon name="chevron-left" :size="16" /></button>
-        <button v-if="canNext" class="icon-button" type="button" aria-label="下一张媒体" title="下一张媒体" @click="emit('next')"><AppIcon class="preview-next-icon" name="chevron-left" :size="16" /></button>
         <button v-if="item.sourceSessionId" class="icon-button" type="button" aria-label="跳转到来源消息" title="跳转到来源消息" @click="emit('source', item)"><AppIcon name="external" :size="15" /></button>
         <a v-if="item.downloadUrl || item.previewUrl" class="icon-button" :href="item.downloadUrl || item.previewUrl" download aria-label="下载" title="下载"><AppIcon name="download" :size="16" /></a>
         <button class="icon-button" type="button" aria-label="加入输入框" title="加入输入框" @click="emit('addToComposer', item)"><AppIcon name="plus" :size="17" /></button>
@@ -122,9 +118,8 @@ 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: 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; }
+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-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: auto; height: auto; max-width: 100%; max-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); }
 .preview-pdf { display: flex; width: 100%; height: 100%; box-sizing: border-box; align-items: center; flex-direction: column; gap: 12px; padding: 12px; overflow: auto; background: #dadad7; }.preview-pdf :deep(.pdf-page) { flex: 0 0 auto; overflow: hidden; background: #fff; box-shadow: 0 3px 14px rgba(0,0,0,.15); }.preview-pdf :deep(canvas) { display: block; }
 .preview-sheet { width: 100%; height: 100%; overflow: auto; background: var(--surface-raised); }.preview-sheet table { min-width: 100%; border-collapse: collapse; font-size: 9px; }.preview-sheet td { min-width: 72px; max-width: 240px; padding: 6px 8px; overflow: hidden; border: 1px solid var(--line); color: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; }.preview-sheet tr:first-child td { position: sticky; top: 0; background: var(--surface-soft); color: var(--text-primary); font-weight: 600; }

+ 17 - 6
src/client/components/library/PreviewModal.vue

@@ -1,5 +1,6 @@
 <script setup lang="ts">
 import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
 import PreviewInspector from './PreviewInspector.vue'
 import type { UiLibraryItem } from './types'
 
@@ -36,9 +37,16 @@ function finishSwipe(event: PointerEvent) {
   else previous()
 }
 function onKeydown(event: KeyboardEvent) {
-  if (event.key !== 'Escape') return
-  event.preventDefault()
-  emit('close')
+  if (event.key === 'Escape') {
+    event.preventDefault()
+    emit('close')
+  } else if (event.key === 'ArrowLeft') {
+    event.preventDefault()
+    previous()
+  } else if (event.key === 'ArrowRight') {
+    event.preventDefault()
+    next()
+  }
 }
 
 watch([() => props.item.id, mediaItems], resetActiveItem, { immediate: true })
@@ -51,7 +59,9 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
     <Transition name="preview-modal">
       <div class="preview-modal-layer" @mousedown.self="emit('close')">
         <section class="preview-modal" role="dialog" aria-modal="true" :aria-label="`预览 ${activeItem.title || activeItem.name}`" @pointerdown="startSwipe" @pointerup="finishSwipe" @pointercancel="swipeStart = undefined">
-          <PreviewInspector :item="activeItem" :can-previous="canPrevious" :can-next="canNext" show-close @close="emit('close')" @previous="previous" @next="next" @add-to-composer="emit('addToComposer', $event)" @source="emit('source', $event)" />
+          <PreviewInspector :item="activeItem" show-close @close="emit('close')" @add-to-composer="emit('addToComposer', $event)" @source="emit('source', $event)" />
+          <button v-if="canPrevious" class="media-nav media-nav--previous" type="button" aria-label="上一张媒体" title="上一张媒体" @click="previous"><AppIcon name="chevron-left" :size="22" /></button>
+          <button v-if="canNext" class="media-nav media-nav--next" type="button" aria-label="下一张媒体" title="下一张媒体" @click="next"><AppIcon class="media-nav__next-icon" name="chevron-left" :size="22" /></button>
         </section>
       </div>
     </Transition>
@@ -60,13 +70,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
 
 <style scoped>
 .preview-modal-layer { position: fixed; z-index: 90; inset: 0; display: grid; place-items: center; padding: 28px; background: var(--scrim); backdrop-filter: blur(4px); }
-.preview-modal { display: flex; width: min(960px, calc(100vw - 56px)); height: min(820px, calc(100vh - 56px)); overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: 0 24px 70px rgba(0,0,0,.28); }
+.preview-modal { position: relative; display: flex; width: min(960px, calc(100vw - 56px)); height: min(820px, calc(100vh - 56px)); overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: 0 24px 70px rgba(0,0,0,.28); }
 .preview-modal :deep(.preview-inspector) { width: 100%; }
 .preview-modal :deep(.preview-stage) { min-height: min(58vh, 560px); }
+.media-nav { position: absolute; z-index: 3; top: 50%; display: grid; width: 42px; height: 58px; place-items: center; padding: 0; border: 0; border-radius: 10px; background: rgba(20,20,20,.45); color: #fff; cursor: pointer; opacity: .72; transform: translateY(-50%); transition: opacity 120ms ease, background-color 120ms ease; }.media-nav:hover { background: rgba(20,20,20,.72); opacity: 1; }.media-nav--previous { left: 12px; }.media-nav--next { right: 12px; }.media-nav__next-icon { transform: rotate(180deg); }
 .preview-modal-enter-active, .preview-modal-leave-active { transition: opacity 160ms ease; }
 .preview-modal-enter-active .preview-modal, .preview-modal-leave-active .preview-modal { transition: transform 180ms var(--ease-out), opacity 150ms ease; }
 .preview-modal-enter-from, .preview-modal-leave-to { opacity: 0; }
 .preview-modal-enter-from .preview-modal, .preview-modal-leave-to .preview-modal { opacity: 0; transform: translateY(8px) scale(.985); }
-@media (max-width: 600px) { .preview-modal-layer { padding: 0; }.preview-modal { width: 100vw; max-height: 100vh; min-height: 100vh; border: 0; border-radius: 0; }.preview-modal :deep(.preview-stage) { min-height: 46vh; } }
+@media (max-width: 600px) { .preview-modal-layer { padding: 0; }.preview-modal { width: 100vw; max-height: 100vh; min-height: 100vh; border: 0; border-radius: 0; }.preview-modal :deep(.preview-stage) { min-height: 46vh; }.media-nav { width: 38px; height: 52px; }.media-nav--previous { left: 8px; }.media-nav--next { right: 8px; } }
 @media (prefers-reduced-motion: reduce) { .preview-modal-enter-active, .preview-modal-leave-active, .preview-modal-enter-active .preview-modal, .preview-modal-leave-active .preview-modal { transition: none; } }
 </style>

+ 3 - 1
src/client/components/library/mediaSequence.ts

@@ -1,4 +1,5 @@
 import type { UiMessage } from '@/components/messages/types'
+import { normalizeAssistantMediaMarkdown } from '@/utils/mediaMarkdown'
 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'])
@@ -32,8 +33,9 @@ export function mediaItemsFromMessages(messages: UiMessage[]): UiLibraryItem[] {
       if (!attachment.url || !['image', 'video'].includes(attachment.kind || 'file')) continue
       append(previewItemFromUrl(attachment.name, attachment.url, `${message.id}:${attachment.id}`, attachment.kind))
     }
+    const content = message.role === 'assistant' ? normalizeAssistantMediaMarkdown(message.content) : message.content
     const markdownMedia = /!\[[^\]]*\]\(([^)\s]+)\)|\[[^\]]+\]\(([^)\s]+)\)/g
-    for (let match = markdownMedia.exec(message.content); match; match = markdownMedia.exec(message.content)) {
+    for (let match = markdownMedia.exec(content); match; match = markdownMedia.exec(content)) {
       const url = match[1] || match[2]
       if (!url) continue
       const item = previewItemFromUrl(nameFromUrl(url), url, `${message.id}:${url}`)

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

@@ -8,11 +8,13 @@ describe('conversation media sequence', () => {
       { 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' }] },
+      { id: 'four', role: 'assistant', content: 'MEDIA:/media/fourth.png' },
     ]
     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'],
+      ['fourth.png', 'image', '/media/fourth.png'],
     ])
   })
 

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

@@ -92,6 +92,8 @@ test('renders historical assistant MEDIA as Markdown in chat and group chat', as
   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('ArrowLeft')
+  await expect(mediaDialog.locator('img')).not.toHaveAttribute('src', /variant=2/)
   await page.keyboard.press('Escape')
   await expect(page.getByRole('dialog', { name: /预览 AppIcon-1024.png/ })).toBeHidden()