Explorar el Código

feat: 媒体预览支持添加到聊天

samien hace 2 meses
padre
commit
037a7f984d

+ 18 - 9
src/client/components/library/ImagePreviewLightbox.vue

@@ -1,17 +1,19 @@
 <script setup lang="ts">
 import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
-import { ChevronBackOutline, ChevronForwardOutline, CloseOutline } from '@vicons/ionicons5'
+import { AddOutline, ChevronBackOutline, ChevronForwardOutline, CloseOutline, DownloadOutline } from '@vicons/ionicons5'
 
 export type PreviewMedia = { url: string; name: string; type?: 'image' | 'video' }
 
-const props = defineProps<{
+const props = withDefaults(defineProps<{
   images: PreviewMedia[]
   modelValue: number | null
   closeLabel?: string
+  addLabel?: string
+  canAdd?: boolean
   previousLabel?: string
   nextLabel?: string
-}>()
-const emit = defineEmits<{ 'update:modelValue': [value: number | null]; close: [] }>()
+}>(), { canAdd: true })
+const emit = defineEmits<{ 'update:modelValue': [value: number | null]; close: []; add: [media: PreviewMedia] }>()
 
 const dragStart = ref<{ pointerId: number; x: number } | null>(null)
 const previewMedia = computed(() => props.modelValue === null ? null : props.images[props.modelValue] || null)
@@ -58,7 +60,11 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
       @pointerup="onPointerUp"
       @pointercancel="dragStart = null"
     >
-      <button class="image-preview-close" type="button" :aria-label="closeLabel || '关闭预览'" @click="close"><CloseOutline aria-hidden="true" /></button>
+      <div class="image-preview-actions">
+        <a :href="previewMedia.url" download :aria-label="`下载 ${previewMedia.name}`" :title="`下载 ${previewMedia.name}`"><DownloadOutline aria-hidden="true" /></a>
+        <button type="button" :disabled="canAdd === false" :aria-label="addLabel || '添加到聊天'" :title="canAdd === false ? '当前群聊不支持添加附件' : addLabel || '添加到聊天'" @click.stop="emit('add', previewMedia)"><AddOutline aria-hidden="true" /></button>
+        <button type="button" :aria-label="closeLabel || '关闭预览'" :title="closeLabel || '关闭预览'" @click="close"><CloseOutline aria-hidden="true" /></button>
+      </div>
       <button v-if="canPrevious" class="image-preview-nav image-preview-nav--previous" type="button" :aria-label="previousLabel || '上一张媒体'" @click.stop="move(-1)"><ChevronBackOutline aria-hidden="true" /></button>
       <Transition name="image-preview-change" mode="out-in">
         <video v-if="previewMedia.type === 'video'" :key="previewMedia.url" :src="previewMedia.url" :aria-label="previewMedia.name" class="image-preview-video" controls playsinline preload="metadata" @click.stop @pointerdown.stop @pointerup.stop />
@@ -74,11 +80,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
 .image-preview-overlay { position: fixed; z-index: 9999; inset: 0; display: flex; align-items: center; justify-content: center; isolation: isolate; background: rgba(0,0,0,.85); touch-action: none; user-select: none; }
 .image-preview-img, .image-preview-video { position: relative; z-index: 1; max-width: 90vw; max-height: 84vh; border-radius: 8px; object-fit: contain; box-shadow: 0 22px 64px rgba(0,0,0,.42); }
 .image-preview-video { width: min(90vw, 1200px); background: #000; }
-.image-preview-close, .image-preview-nav { position: absolute; z-index: 2; display: inline-flex; align-items: center; justify-content: center; border: 0; background: rgba(255,255,255,.12); color: #fff; cursor: pointer; backdrop-filter: blur(12px); transition: background .15s ease, transform .15s ease; }
-.image-preview-close:hover, .image-preview-close:focus-visible, .image-preview-nav:hover, .image-preview-nav:focus-visible { outline: none; background: rgba(255,255,255,.22); }
-.image-preview-close { top: 20px; right: 20px; width: 40px; height: 40px; border-radius: 50%; }.image-preview-close svg { width: 20px; height: 20px; }
+.image-preview-actions { position: absolute; z-index: 3; top: 20px; right: 20px; display: flex; align-items: center; gap: 8px; }
+.image-preview-actions > a, .image-preview-actions > button, .image-preview-nav { display: inline-flex; align-items: center; justify-content: center; border: 0; background: rgba(255,255,255,.12); color: #fff; cursor: pointer; backdrop-filter: blur(12px); transition: background .15s ease, transform .15s ease, opacity .15s ease; }
+.image-preview-actions > a, .image-preview-actions > button { width: 40px; height: 40px; padding: 0; border-radius: 50%; text-decoration: none; }
+.image-preview-actions svg { width: 20px; height: 20px; }
+.image-preview-actions > a:hover, .image-preview-actions > a:focus-visible, .image-preview-actions > button:hover, .image-preview-actions > button:focus-visible, .image-preview-nav:hover, .image-preview-nav:focus-visible { outline: none; background: rgba(255,255,255,.22); }
+.image-preview-actions > button:disabled { cursor: not-allowed; opacity: .38; }
 .image-preview-nav { top: 50%; width: 46px; height: 58px; border-radius: 14px; transform: translateY(-50%); }.image-preview-nav svg { width: 24px; height: 24px; }.image-preview-nav:hover, .image-preview-nav:focus-visible { transform: translateY(-50%) scale(1.04); }.image-preview-nav--previous { left: 24px; }.image-preview-nav--next { right: 24px; }
 .image-preview-counter { position: absolute; z-index: 2; bottom: 22px; left: 50%; padding: 6px 10px; border-radius: 999px; background: rgba(0,0,0,.46); color: rgba(255,255,255,.9); font-size: 12px; font-variant-numeric: tabular-nums; transform: translateX(-50%); }
 .image-preview-change-enter-active, .image-preview-change-leave-active { transition: opacity .16s ease, transform .16s ease; }.image-preview-change-enter-from { opacity: 0; transform: translateX(14px); }.image-preview-change-leave-to { opacity: 0; transform: translateX(-14px); }
-@media (max-width: 600px) { .image-preview-img, .image-preview-video { max-width: calc(100vw - 32px); max-height: 78vh; }.image-preview-video { width: calc(100vw - 32px); }.image-preview-close { top: max(14px, env(safe-area-inset-top)); right: 14px; }.image-preview-nav { width: 38px; height: 50px; border-radius: 12px; }.image-preview-nav--previous { left: 10px; }.image-preview-nav--next { right: 10px; } }
+@media (max-width: 600px) { .image-preview-img, .image-preview-video { max-width: calc(100vw - 32px); max-height: 78vh; }.image-preview-video { width: calc(100vw - 32px); }.image-preview-actions { top: max(14px, env(safe-area-inset-top)); right: 14px; gap: 6px; }.image-preview-actions > a, .image-preview-actions > button { width: 40px; height: 40px; }.image-preview-nav { width: 38px; height: 50px; border-radius: 12px; }.image-preview-nav--previous { left: 10px; }.image-preview-nav--next { right: 10px; } }
 </style>

+ 16 - 5
src/client/components/workspace/pendingComposer.ts

@@ -4,6 +4,8 @@ const KEY = 'hermes-yaoyao:pending-composer-item'
 
 type PendingItem = { name: string; url: string; mimeType?: string }
 
+type ComposerFileSource = Pick<UiLibraryItem, 'name' | 'previewUrl' | 'downloadUrl' | 'mimeType'>
+
 export function queueLibraryItemForComposer(item: UiLibraryItem): boolean {
   const url = item.downloadUrl || item.previewUrl
   if (!url) return false
@@ -12,17 +14,26 @@ export function queueLibraryItemForComposer(item: UiLibraryItem): boolean {
   return true
 }
 
+export async function loadComposerFile(item: ComposerFileSource): Promise<File | null> {
+  const url = item.downloadUrl || item.previewUrl
+  if (!url || !item.name) return null
+  try {
+    const response = await fetch(url, { credentials: 'same-origin' })
+    if (!response.ok) throw new Error(`下载文件失败(${response.status})`)
+    const blob = await response.blob()
+    return new File([blob], item.name, { type: item.mimeType || blob.type || 'application/octet-stream' })
+  } catch {
+    return null
+  }
+}
+
 export async function consumeLibraryItemForComposer(): Promise<File | null> {
   const raw = sessionStorage.getItem(KEY)
   if (!raw) return null
   sessionStorage.removeItem(KEY)
   try {
     const pending = JSON.parse(raw) as PendingItem
-    if (!pending.url || !pending.name) return null
-    const response = await fetch(pending.url, { credentials: 'same-origin' })
-    if (!response.ok) throw new Error(`下载文件失败(${response.status})`)
-    const blob = await response.blob()
-    return new File([blob], pending.name, { type: pending.mimeType || blob.type || 'application/octet-stream' })
+    return await loadComposerFile({ name: pending.name, downloadUrl: pending.url, mimeType: pending.mimeType })
   } catch {
     return null
   }

+ 20 - 4
src/client/views/ChatView.vue

@@ -10,6 +10,7 @@ import type { ComposerOption, ComposerReference, ComposerSubmit } from '@/compon
 import PreviewInspector from '@/components/library/PreviewInspector.vue'
 import PreviewModal from '@/components/library/PreviewModal.vue'
 import ImagePreviewLightbox from '@/components/library/ImagePreviewLightbox.vue'
+import type { PreviewMedia } from '@/components/library/ImagePreviewLightbox.vue'
 import type { UiLibraryItem } from '@/components/library/types'
 import { mediaItemsFromMessages, previewItemFromUrl } from '@/components/library/mediaSequence'
 import MessageTimeline from '@/components/messages/MessageTimeline.vue'
@@ -17,7 +18,7 @@ import type { UiMessage } from '@/components/messages/types'
 import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
 import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
 import { chatInteraction, chatMessagesToUi, sessionSidebarItem } from '@/components/workspace/viewModels'
-import { consumeLibraryItemForComposer } from '@/components/workspace/pendingComposer'
+import { consumeLibraryItemForComposer, loadComposerFile } from '@/components/workspace/pendingComposer'
 import { useAuthStore } from '@/stores/auth'
 import { useChatStore } from '@/stores/chat'
 import { getSession } from '@/api/sessions'
@@ -165,6 +166,21 @@ function openLocalFile({ name, url }: { name: string; url: string }) {
   else filePreview.value = item
 }
 
+async function addPreviewToComposer(item: UiLibraryItem) {
+  const file = await loadComposerFile(item)
+  if (!file || !composer.value) return
+  composer.value.attachFiles([file])
+  preview.value = null
+  filePreview.value = null
+  mediaPreviewIndex.value = null
+  await nextTick()
+  composer.value?.focus()
+}
+
+async function addMediaToComposer(media: PreviewMedia) {
+  await addPreviewToComposer(previewItemFromUrl(media.name, media.url, `preview:${media.url}`, media.type))
+}
+
 async function selectModel(id: string) {
   const [provider, ...rest] = id.split(':')
   const model = chat.models.find(item => item.provider === provider && item.id === rest.join(':'))
@@ -337,11 +353,11 @@ watch(() => chat.activeSessionId, async id => {
       />
     </div>
 
-    <template #inspector><PreviewInspector v-if="preview" :item="preview" @close="preview = null" @add-to-composer="preview = null" /></template>
+    <template #inspector><PreviewInspector v-if="preview" :item="preview" @close="preview = null" @add-to-composer="addPreviewToComposer" /></template>
   </WorkspaceView>
 
-  <PreviewModal v-if="filePreview" :item="filePreview" :items="conversationMediaItems" @close="filePreview = null" @add-to-composer="filePreview = null" @source="filePreview = null" />
-  <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" />
+  <PreviewModal v-if="filePreview" :item="filePreview" :items="conversationMediaItems" @close="filePreview = null" @add-to-composer="addPreviewToComposer" @source="filePreview = null" />
+  <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" @add="addMediaToComposer" />
 
   <ChoiceDialog :open="modelDialog" title="选择模型" :options="modelOptions" :selected-id="selectedModelId" @close="modelDialog = false" @select="selectModel" />
 

+ 7 - 1
src/client/views/FilesView.vue

@@ -6,6 +6,7 @@ import AppIcon from '@/components/common/AppIcon.vue'
 import LibraryGrid from '@/components/library/LibraryGrid.vue'
 import PreviewModal from '@/components/library/PreviewModal.vue'
 import ImagePreviewLightbox from '@/components/library/ImagePreviewLightbox.vue'
+import type { PreviewMedia } from '@/components/library/ImagePreviewLightbox.vue'
 import type { LibraryFilterOption, UiLibraryItem } from '@/components/library/types'
 import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
 import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
@@ -59,6 +60,11 @@ async function addToComposer(item: UiLibraryItem) {
   await router.push('/chat')
 }
 
+async function addMediaToComposer(media: PreviewMedia) {
+  const item = items.value.find(candidate => (candidate.previewUrl || candidate.downloadUrl) === media.url)
+  if (item) await addToComposer(item)
+}
+
 async function openSource(item: UiLibraryItem) {
   if (!item.sourceSessionId) return
   if (item.sourceProfile && auth.profiles.some(profile => profile.name === item.sourceProfile)) {
@@ -146,7 +152,7 @@ watch(() => auth.activeProfile?.name, profile => {
     </section>
   </WorkspaceView>
   <PreviewModal v-if="selected" :item="selected" :items="items" @close="selected = null" @add-to-composer="addToComposer" @source="openSource" />
-  <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" />
+  <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" @add="addMediaToComposer" />
 </template>
 
 <style scoped>

+ 20 - 3
src/client/views/GroupsView.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import AppIcon from '@/components/common/AppIcon.vue'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
@@ -10,12 +10,14 @@ import CreateGroupDialog from '@/components/groups/CreateGroupDialog.vue'
 import GroupManager from '@/components/groups/GroupManager.vue'
 import PreviewModal from '@/components/library/PreviewModal.vue'
 import ImagePreviewLightbox from '@/components/library/ImagePreviewLightbox.vue'
+import type { PreviewMedia } from '@/components/library/ImagePreviewLightbox.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'
 import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
+import { loadComposerFile } from '@/components/workspace/pendingComposer'
 import { agentToUi, groupInteraction, groupMessageToUi, roomSidebarItem, roomToUi } from '@/components/workspace/viewModels'
 import { useAuthStore } from '@/stores/auth'
 import { useGroupsStore } from '@/stores/groups'
@@ -119,6 +121,21 @@ function openAttachment(attachment: NonNullable<UiMessage['attachments']>[number
   else preview.value = item
 }
 
+async function addPreviewToComposer(item: UiLibraryItem) {
+  if (!uploadsEnabled.value) return
+  const file = await loadComposerFile(item)
+  if (!file || !composer.value) return
+  composer.value.attachFiles([file])
+  preview.value = null
+  mediaPreviewIndex.value = null
+  await nextTick()
+  composer.value?.focus()
+}
+
+async function addMediaToComposer(media: PreviewMedia) {
+  await addPreviewToComposer(previewItemFromUrl(media.name, media.url, `preview:${media.url}`, media.type))
+}
+
 onMounted(async () => {
   try {
     await groups.start()
@@ -228,8 +245,8 @@ 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" :items="conversationMediaItems" @close="preview = null" @add-to-composer="preview = null" @source="preview = null" />
-  <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" />
+  <PreviewModal v-if="preview" :item="preview" :items="conversationMediaItems" @close="preview = null" @add-to-composer="addPreviewToComposer" @source="preview = null" />
+  <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" :can-add="uploadsEnabled" @add="addMediaToComposer" />
 </template>
 
 <style scoped>

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

@@ -102,9 +102,16 @@ test('renders historical assistant MEDIA as Markdown in chat and group chat', as
   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 expect(mediaDialog.getByRole('link', { name: /下载 AppIcon-1024\.png/ })).toBeVisible()
   await page.keyboard.press('Escape')
   await expect(page.getByRole('dialog', { name: /预览 AppIcon-1024.png/ })).toBeHidden()
 
+  await image.click()
+  const reopenedMediaDialog = page.getByRole('dialog', { name: /预览 AppIcon-1024.png/ })
+  await reopenedMediaDialog.getByRole('button', { name: '添加到聊天' }).click()
+  await expect(reopenedMediaDialog).toBeHidden()
+  await expect(page.locator('.composer-attachments img[alt="AppIcon-1024.png"]')).toBeVisible()
+
   await page.getByRole('button', { name: '群聊' }).click()
   await expect(page.locator('.markdown img[alt="AppIcon-1024.png"]')).toBeVisible()
 })