Selaa lähdekoodia

按原顺序独立展示消息正文、图片和文件

Samien 1 kuukausi sitten
vanhempi
sitoutus
b844ee1

+ 34 - 0
design-qa.md

@@ -268,3 +268,37 @@ final result: passed
 - 未部署到服务器或安装到真机。
 
 final result: passed
+
+
+---
+
+# 2026-09-21:正文、图片、文件按原顺序独立展示
+
+## Findings / scope
+
+- 无未解决的 P0/P1/P2 视觉差异。文件按照用户手机截图:浅灰、贴合内容的圆角块,左侧文件图标,文件名与灰色扩展名,下方仅显示大小。图片独立显示,不套正文气泡。
+- 普通聊天与 Bot 时间线均接入。保留原有外观设置、头像、复制/引用操作、系统提示和预览入口;夹具中的 Grok 配色只是演示,不强制更改产品默认。
+- 内容流保留文字 → 图片 → 文字 → 文件 → 文字的顺序;结构化附件与 Markdown 引用去重。正文中的普通链接和代码示例保留原样。
+
+## Source / normalization / evidence
+
+- 源图:`docs/media-qa/approved-design.png`,1577 × 997。依据用户提供的手机文件块照片修正后的确认稿。
+- 运行截图:`desktop.png` 1040 × 900、`mobile.png` 390 × 844、`mobile-dark-order.png` 390 × 844、`mobile-light-order.png` 与 `mobile-ordinary.png` 375 × 812。CSS viewport 与截图同尺寸,1x。
+- 对照源图及运行截图的消息内容区域,忽略概念图设备栏、画板边距和现有产品操作行;不宣称整页逐像素复刻。文件块在原尺寸截图中可直接判读。
+- 本地入口:`http://127.0.0.1:15301/tests/fixtures/message-media/index.html`;`?ordinary` 普通聊天,`?order` 顺序验证,`?dark` 深色。正式 Vue 组件、本地数据,预览回调用夹具弹窗验证,不访问生产附件。
+
+## Fidelity / comparison history
+
+- 字体:现有产品字体,文件名16px中等字重,扩展名和12px大小使用次要灰色;未知大小不伪造。
+- 布局:文件最小高度64px、圆角20px、内容间隔8px;图片圆角12px,按原比例显示,桌面最大430px宽,手机受可用宽度约束。
+- 颜色:文件底色浅色 #F1F1F1、深色 #262626。首轮发现 scoped 深色选择器失效导致深色页面文件块发白,已改为主题 token,截图复核通过。
+- 图像:复用现有线性图标;竹叶图是根据用户照片生成的测试素材,仅用于夹具。图片自身显示,不额外加标题框、边框或阴影。
+- 文案:`bamboo-sample.txt`、`134 B`;移除文件块中多余的「预览/下载」文字,保留按钮的完整可访问名称。
+
+## Verification
+
+- 完整 `npm run build`(含类型检查)通过;9个相关测试文件58项通过,覆盖外观、Markdown、文件顺序、去重、普通/Bot 时间线以及系统提示回归。
+- 浏览器实测375px无横向溢出:scrollWidth=375;文件块218 × 64,图片约337 × 190且加载完成。文件与图片点击均触发对应预览回调,控制台 error 为空。
+- 未发布到服务器;浏览器预览和源代码已完成。
+
+final result: passed

BIN
docs/media-qa/approved-design.png


BIN
docs/media-qa/desktop.png


BIN
docs/media-qa/mobile-dark-order.png


BIN
docs/media-qa/mobile-light-order.png


BIN
docs/media-qa/mobile-ordinary.png


BIN
docs/media-qa/mobile.png


+ 30 - 12
src/client/components/library/mediaSequence.ts

@@ -1,3 +1,4 @@
+import MarkdownIt from 'markdown-it'
 import type { UiMessage } from '@/components/messages/types'
 import { normalizeAssistantMediaMarkdown } from '@/utils/mediaMarkdown'
 import { serverFileUrl } from '@shared/serverFiles'
@@ -23,6 +24,7 @@ export function mediaUrlIdentity(url: string): string {
       parsed.searchParams.delete('_retry')
       parsed.searchParams.sort()
     }
+    if (parsed.origin === window.location.origin) parsed.pathname = parsed.pathname.replace(/(\/api\/app\/files\/[^/]+)\/(?:preview|download)$/, '$1')
     return parsed.href
   } catch {
     return url
@@ -48,21 +50,37 @@ export function mediaItemsFromMessages(messages: UiMessage[]): UiLibraryItem[] {
     seen.add(identity)
     result.push(item)
   }
+  const md = new MarkdownIt({ html: false })
+  const validate = md.validateLink
+  md.validateLink = source => Boolean(serverFileUrl(source)) || validate(source)
   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))
+    if (!['user', 'assistant'].includes(message.role) && !message.communication) continue
+    const appendAttachment = (id: string) => {
+      const attachment = message.attachments?.find(item => item.id === id)
+      if (attachment?.url && ['image', 'video'].includes(attachment.kind || 'file')) {
+        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(content); match; match = markdownMedia.exec(content)) {
-      const source = match[1] || match[2]
-      if (!source) continue
-      // Match MarkdownContent's renderer, including the selected server Profile.
-      const url = serverFileUrl(source, message.profile) || source
-      const item = previewItemFromUrl(nameFromUrl(url), url, `${message.id}:${url}`)
-      append(item)
+    const appendMarkdown = (source: string) => {
+      if (message.role === 'user') return
+      const content = normalizeAssistantMediaMarkdown(source, message.status === 'streaming')
+      for (const token of md.parse(content, {})) {
+        for (const child of token.children ?? []) {
+          const source = child.type === 'image' ? child.attrGet('src') : child.type === 'link_open' ? child.attrGet('href') : null
+          if (!source) continue
+          const url = serverFileUrl(source, message.profile) || source
+          const attached = message.attachments?.find(item => item.url && mediaUrlIdentity(item.url) === mediaUrlIdentity(url))
+          append(previewItemFromUrl(attached?.name || nameFromUrl(url), url, `${message.id}:${url}`, attached?.kind))
+        }
+      }
     }
+    if (message.contentParts?.length) {
+      for (const part of message.contentParts) {
+        if ('text' in part) appendMarkdown(part.text)
+        else appendAttachment(part.attachmentId)
+      }
+    } else appendMarkdown(message.content)
+    for (const attachment of message.attachments ?? []) appendAttachment(attachment.id)
   }
   return result
 }

+ 97 - 5
src/client/components/messages/MarkdownContent.vue

@@ -4,6 +4,8 @@ import hljs from 'highlight.js'
 import MarkdownIt from 'markdown-it'
 import { computed, nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
+import MessageAttachment from './MessageAttachment.vue'
+import type { UiMessageAttachment } from './types'
 import { copyTextToClipboard } from '@/utils/clipboard'
 import { normalizeAssistantMediaMarkdown } from '@/utils/mediaMarkdown'
 import { repairMarkdownForRender } from '@/utils/markdownRepair'
@@ -20,9 +22,13 @@ const props = withDefaults(defineProps<{
   processContent?: boolean
   outlinePrefix?: string
   fileProfile?: string
+  separateMedia?: boolean
+  bubble?: boolean
+  attachments?: UiMessageAttachment[]
+  contentParts?: ({ text: string } | { attachmentId: string })[]
 }>(), { streaming: false, streamIntervalMs: 80, legacyMedia: false, plain: false, fileCards: false, processContent: false, outlinePrefix: '' })
 
-const emit = defineEmits<{ fileLink: [name: string, url: string]; rendered: [] }>()
+const emit = defineEmits<{ fileLink: [name: string, url: string]; preview: [attachment: UiMessageAttachment]; rendered: [] }>()
 
 const root = ref<HTMLElement | null>(null)
 const copied = ref('')
@@ -153,7 +159,7 @@ function sanitize(html: string): string {
 }
 
 const renderedBlocks = computed(() => {
-  if (props.plain) return []
+  if (props.plain || props.separateMedia) return []
   const env = {}
   const tokens = md.parse(markdownSource.value, env)
   const blocks: string[] = []
@@ -167,7 +173,77 @@ const renderedBlocks = computed(() => {
   }
   return blocks
 })
-watch(renderedBlocks, async () => {
+// Split only actual Markdown media nodes, never fenced code or prose URLs.
+// Contiguous prose keeps one bubble; media occupies its own position in the stream.
+type MessagePart = { html: string; text?: string } | { attachment: UiMessageAttachment }
+function attachmentIdentity(value: string): string {
+  try {
+    const url = new URL(value, window.location.href)
+    url.pathname = url.pathname.replace(/(\/api\/app\/files\/[^/]+)\/(?:preview|download)$/, '$1')
+    url.searchParams.delete('_retry')
+    url.searchParams.sort()
+    return url.href
+  } catch { return value }
+}
+const messageParts = computed<MessagePart[]>(() => {
+  if (!props.separateMedia) return []
+  const parts: MessagePart[] = []
+  const used = new Set<string>()
+  const attachments = props.attachments ?? []
+  function appendAttachment(item: UiMessageAttachment) {
+    used.add(item.id)
+    parts.push({ attachment: item })
+  }
+  function appendHtml(html: string) {
+    if (!html.replace(/<[^>]+>/g, '').trim() && !/<(?:img|hr|pre|table)\b/.test(html)) return
+    const last = parts.at(-1)
+    if (last && 'html' in last && !last.text) last.html += html
+    else parts.push({ html })
+  }
+  function appendSource(source: string) {
+    if (props.plain) {
+      if (source.trim()) parts.push({ html: '', text: source })
+      return
+    }
+    const container = document.createElement('template')
+    container.innerHTML = sanitize(md.render(source))
+    for (const element of [...container.content.children]) {
+      if (element.tagName !== 'P') { appendHtml(element.outerHTML); continue }
+      let prose = ''
+      for (const node of [...element.childNodes]) {
+        const child = node instanceof HTMLElement ? node : undefined
+        const image = child?.tagName === 'IMG' ? child as HTMLImageElement : undefined
+        const link = child?.tagName === 'A' ? child as HTMLAnchorElement : undefined
+        let target: ReturnType<typeof fileTarget>
+        if (link && isStandaloneFileLink(link)) {
+          try { target = fileTarget(new URL(link.href, window.location.href)) } catch { /* inert link */ }
+        }
+        if (image || target) {
+          appendHtml(`<p>${prose}</p>`); prose = ''
+          const url = image?.getAttribute('src') || link!.getAttribute('href')!
+          const match = attachments.find(item => item.url && attachmentIdentity(item.url) === attachmentIdentity(url))
+          const name = match?.name || (image ? image.alt || '图片' : fileCardName(link!, target!))
+          appendAttachment(match ?? { id: url, url, name, kind: image ? 'image' : 'file' })
+        } else {
+          prose += child ? child.outerHTML : escapeHtml(node.textContent || '')
+        }
+      }
+      appendHtml(`<p>${prose}</p>`)
+    }
+  }
+  if (props.contentParts?.length) {
+    for (const part of props.contentParts) {
+      if ('text' in part) appendSource(props.legacyMedia ? normalizeAssistantMediaMarkdown(part.text, props.streaming) : part.text)
+      else {
+        const item = attachments.find(item => item.id === part.attachmentId)
+        if (item) appendAttachment(item)
+      }
+    }
+  } else appendSource(markdownSource.value)
+  for (const item of attachments) if (!used.has(item.id)) appendAttachment(item)
+  return parts
+})
+watch([renderedBlocks, messageParts], async () => {
   await nextTick()
   emit('rendered')
 }, { flush: 'post' })
@@ -249,7 +325,7 @@ function decorateFileLinks() {
 function decorateMediaPreviews() {
   if (props.processContent || !props.fileCards || !root.value) return
   root.value.querySelectorAll<HTMLImageElement>('img').forEach(image => {
-    if (image.dataset.mediaPreview) return
+    if (image.dataset.mediaPreview || image.closest('.message-media')) return
     let url: URL
     try { url = new URL(image.currentSrc || image.src, window.location.href) } catch { return }
     if (url.origin !== window.location.origin) return
@@ -318,7 +394,16 @@ onUpdated(() => { decorateCopyButtons(); decorateFileLinks(); decorateMediaPrevi
 </script>
 
 <template>
-  <div v-if="plain" class="plain-text">{{ content }}</div>
+  <div v-if="separateMedia" ref="root" class="markdown message-parts" :class="{ 'message-parts--streaming': streaming }" @click="onClick">
+    <template v-for="(part, index) in messageParts" :key="index">
+      <MessageAttachment v-if="'attachment' in part" :attachment="part.attachment" @open="item => emit('preview', item)" @rendered="emit('rendered')" />
+      <div v-else class="message-prose" :class="{ 'chat-bubble': bubble }">
+        <div v-if="part.text !== undefined" class="plain-text">{{ part.text }}</div>
+        <div v-else class="markdown-block" v-html="part.html" />
+      </div>
+    </template>
+  </div>
+  <div v-else-if="plain" class="plain-text">{{ content }}</div>
   <div v-else ref="root" class="markdown" :class="{ 'markdown--streaming': streaming }" @click="onClick">
     <div v-for="(html, index) in renderedBlocks" :key="index" class="markdown-block" v-html="html" />
   </div>
@@ -326,6 +411,13 @@ onUpdated(() => { decorateCopyButtons(); decorateFileLinks(); decorateMediaPrevi
 </template>
 
 <style scoped>
+.message-parts { display:flex; flex-direction:column; align-items:flex-start; gap:8px; }
+.message-prose { width:fit-content; min-width:0; max-width:100%; }
+.message-prose.chat-bubble { --text-primary:var(--bubble-ink); --text-secondary:var(--bubble-ink); --text-muted:var(--bubble-ink); }
+.message-parts--streaming > .message-prose:last-child .markdown-block::after { content: ''; display: inline-block; width: 5px; height: 14px; margin-left: 3px; background: currentColor; animation: caret 1s step-end infinite; }
+.message-prose :deep(p:last-child) { margin-bottom:0; }
+.message-parts :deep(.message-media__image img) { max-width:min(100%, 430px); margin:0; border:0; background:transparent; border-radius:12px; }
+
 .markdown :deep(img[hidden]){display:none!important}
 .markdown :deep(.media-load-error){display:flex;align-items:center;gap:12px;padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-soft);font-size:12px}.markdown :deep(.media-load-error span){flex:1;overflow-wrap:anywhere}.markdown :deep(.media-load-error button){min-width:48px;min-height:44px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary);cursor:pointer}.markdown :deep(.media-load-error button:focus-visible){outline:2px solid var(--accent);outline-offset:2px}
 .plain-text { min-width: 0; color: inherit; font-size: 13px; line-height: 1.68; white-space: pre-wrap; overflow-wrap: anywhere; }

+ 63 - 0
src/client/components/messages/MessageAttachment.vue

@@ -0,0 +1,63 @@
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import type { UiMessageAttachment } from './types'
+
+const props = defineProps<{ attachment: UiMessageAttachment }>()
+const emit = defineEmits<{ open: [attachment: UiMessageAttachment]; rendered: [] }>()
+const failed = ref(false)
+const retry = ref(0)
+watch(() => props.attachment.url, () => { failed.value = false; retry.value = 0 })
+const url = computed(() => {
+  if (!retry.value || !props.attachment.url) return props.attachment.url
+  const url = new URL(props.attachment.url, window.location.href)
+  url.searchParams.set('_retry', String(retry.value))
+  return url.href
+})
+const filename = computed(() => {
+  const name = props.attachment.name
+  const dot = name.lastIndexOf('.')
+  return dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, '']
+})
+const size = computed(() => {
+  const bytes = props.attachment.size
+  if (bytes === undefined || bytes < 0) return ''
+  if (bytes < 1024) return `${bytes} B`
+  if (bytes < 1024 ** 2) return `${+(bytes / 1024).toFixed(1)} KB`
+  return `${+(bytes / 1024 ** 2).toFixed(1)} MB`
+})
+</script>
+
+<template>
+  <div v-if="attachment.kind === 'image' && attachment.url" class="message-media">
+    <button v-if="!failed" class="message-media__image" type="button" :aria-label="`预览图片 ${attachment.name}`" @click="emit('open', attachment)">
+      <img :src="url" :alt="attachment.name" @load="emit('rendered')" @error="failed = true; emit('rendered')" />
+    </button>
+    <button v-else class="message-file" type="button" @click="failed = false; retry = Date.now()">
+      <AppIcon name="image" :size="22" /><span>{{ attachment.name }} · 加载失败,点击重试</span>
+    </button>
+  </div>
+  <button v-else class="message-file" type="button" :aria-label="`预览文件 ${attachment.name}${size ? `,${size}` : ''}`" @click="emit('open', attachment)">
+    <span class="message-file__icon"><AppIcon :name="attachment.kind || 'file'" :size="23" /></span>
+    <span class="message-file__info">
+      <span class="message-file__name"><strong>{{ filename[0] }}</strong><span>{{ filename[1] }}</span></span>
+      <span v-if="size" class="message-file__size">{{ size }}</span>
+    </span>
+  </button>
+</template>
+
+<style scoped>
+.message-file { display:flex; align-items:center; gap:11px; width:fit-content; max-width:100%; min-height:64px; padding:10px 15px 10px 11px; border:0; border-radius:20px; background:var(--message-file-surface, #f1f1f1); color:var(--text-primary); text-align:left; cursor:pointer; }
+.message-file__icon { display:grid; place-items:center; flex:0 0 32px; width:32px; height:36px; border-radius:10px; color:var(--text-secondary); background:color-mix(in srgb, var(--text-primary) 5%, transparent); }
+.message-file__info { min-width:0; display:flex; flex-direction:column; gap:3px; }
+.message-file__name { display:flex; min-width:0; max-width:300px; font-size:16px; line-height:1.4; }
+.message-file__name strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:560; }
+.message-file__name > span { flex-shrink:0; color:var(--text-muted); }
+.message-file__size { font-size:12px; line-height:1.4; color:var(--text-muted); }
+.message-file:hover { filter:brightness(.97); }
+.message-file:focus-visible, .message-media__image:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
+.message-media { max-width:100%; }
+.message-media__image { display:block; width:fit-content; max-width:100%; padding:0; border:0; border-radius:12px; overflow:hidden; background:transparent; cursor:zoom-in; }
+.message-media__image img { display:block; width:auto; height:auto; max-width:min(100%, 430px); max-height:420px; object-fit:contain; }
+@media(max-width:600px) { .message-media__image img { max-height:360px; } }
+</style>

+ 7 - 19
src/client/components/messages/MessageTimeline.vue

@@ -266,16 +266,13 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
               <MarkdownContent process-content :content="message.reasoning" :streaming="message.status === 'streaming'" @rendered="onMarkdownRendered" />
             </details>
 
-            <div v-if="message.role === 'user' && message.attachments?.length" class="message__attachments">
-              <button v-for="attachment in message.attachments" :key="attachment.id" type="button" :class="{ 'message__attachment--image': attachment.kind === 'image' && attachment.url }" @click="emit('preview', attachment)">
-                <img v-if="attachment.kind === 'image' && attachment.url" :src="attachment.url" :alt="attachment.name" />
-                <span v-else><AppIcon :name="attachment.kind || 'file'" :size="17" /></span>
-                <strong v-if="attachment.kind !== 'image' || !attachment.url">{{ attachment.name }}</strong>
-              </button>
-            </div>
-
-            <div v-if="!messageFailure(message)?.replacesContent" class="message__content" :class="{ 'chat-bubble': (message.role === 'user' || message.role === 'assistant') && !!message.content.trim() }" :style="(message.role === 'user' || message.role === 'assistant') ? bubbleVariables(appearance[message.role], message.role) : undefined">
+            <div v-if="!messageFailure(message)?.replacesContent" class="message__content"  :style="(message.role === 'user' || message.role === 'assistant') ? bubbleVariables(appearance[message.role], message.role) : undefined">
               <MarkdownContent
+                :separate-media="message.role === 'user' || message.role === 'assistant' || !!message.communication"
+                :bubble="(message.role === 'user' || message.role === 'assistant') && !!message.content.trim()"
+                :attachments="message.attachments"
+                :content-parts="message.contentParts"
+                @preview="item => item.id && message.attachments?.some(a => a.id === item.id) ? emit('preview', item) : emit('previewFile', { name: item.name, url: item.url! })"
                 :file-profile="message.profile"
                 :content="displayContentForMessage(message.role, message.content)"
                 :streaming="message.status === 'streaming'"
@@ -301,14 +298,6 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
               {{ deliveryLabel(message.status) }}
             </div>
 
-            <div v-if="message.role === 'assistant' && message.attachments?.length" class="message__attachments">
-              <button v-for="attachment in message.attachments" :key="attachment.id" type="button" @click="emit('preview', attachment)">
-                <img v-if="attachment.kind === 'image' && attachment.url" :src="attachment.url" :alt="attachment.name" />
-                <span v-else><AppIcon :name="attachment.kind || 'file'" :size="17" /></span>
-                <strong>{{ attachment.name }}</strong>
-              </button>
-            </div>
-
             <div v-if="showTools && message.tools?.length" class="message__tools">
               <ToolTrace v-for="tool in message.tools" :key="tool.id" :tool="tool" />
             </div>
@@ -389,6 +378,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 .message--user { justify-content: flex-end; margin-top: 27px; }
 .message--user .message__body { max-width: min(610px, 76%); padding: 0; background: transparent; }
 
+.message--user :deep(.message-parts) { align-items: flex-end; }
 .message--user .message__meta { display: none; }
 .message__delivery { display: flex; min-height: 18px; align-items: center; justify-content: flex-end; gap: 4px; margin: 5px 1px -4px; color: var(--text-muted); font-size: 10px; }.message__delivery--accepted { color: var(--success); }.message__delivery--failed, .message__delivery--unknown-receipt { color: var(--danger); }
 .message--tool-only { margin-block: 4px; }.message--tool-only .message__avatar, .message--tool-only .message__meta, .message--tool-only .message__actions { display: none; }.message--tool-only .message__body { max-width: 100%; }
@@ -396,8 +386,6 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 .message--assistant-anonymous .message__body { max-width: min(680px, 100%); }
 .message__reasoning { max-width: 420px; margin: 3px 0 10px; padding: 0 0 8px; border: 0; border-bottom: 1px dashed var(--line-strong); color: var(--text-secondary); font-size: 12px; }.message__reasoning summary { display: flex; align-items: center; gap: 5px; list-style: none; color: var(--text-muted); cursor: pointer; font-size: 10px; }.message__reasoning summary::-webkit-details-marker { display: none; }.message__reasoning summary::before { content: '›'; color: var(--text-muted); font-size: 14px; line-height: 1; transition: transform 120ms ease; }.message__reasoning[open] summary::before { transform: rotate(90deg); }.message__reasoning :deep(.markdown) { margin-top: 8px; }
 .fork-divider { display: flex; align-items: center; gap: 12px; margin: 8px 0 28px; color: var(--text-muted); font-size: 11px; }.fork-divider::before, .fork-divider::after { height: 1px; flex: 1; background: var(--line); content: ''; }.fork-divider > span { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface-raised); white-space: nowrap; }.fork-divider strong { max-width: 180px; overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; }
-.message__attachments { display: flex; margin-top: 8px; flex-wrap: wrap; gap: 7px; }.message__attachments button { display: flex; min-width: 90px; max-width: 210px; min-height: 42px; align-items: center; gap: 7px; padding: 5px 8px; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); color: var(--text-secondary); cursor: pointer; }.message__attachments button:hover { border-color: var(--line-strong); }.message__attachments img { width: 48px; height: 48px; margin: -5px 0 -5px -8px; object-fit: cover; }.message__attachments button > span { display: grid; place-items: center; width: 25px; height: 25px; border-radius: 7px; background: var(--surface-soft); }.message__attachments strong { min-width: 0; overflow: hidden; font-size: 11px; font-weight: 520; text-overflow: ellipsis; white-space: nowrap; }
-.message--user .message__attachments { margin: 0 0 9px; }.message--user .message__attachments button { width: 100%; max-width: none; min-height: 50px; padding: 7px 9px; border: 0; border-radius: 11px; background: rgba(255,255,255,.48); color: var(--text-primary); }.message--user .message__attachments button > span { width: 28px; height: 28px; background: rgba(255,255,255,.65); }.message--user .message__attachments strong { font-size: 12px; font-weight: 560; }.dark .message--user .message__attachments button { background: rgba(255,255,255,.08); }.message--user .message__attachments button.message__attachment--image { display: block; width: auto; min-width: 0; max-width: min(360px, 100%); min-height: 0; padding: 0; border: 0; border-radius: 8px; background: transparent; }.message--user .message__attachments button.message__attachment--image img { display: block; width: auto; height: auto; max-width: min(360px, 100%); max-height: 420px; margin: 0; border-radius: inherit; object-fit: contain; }
 .message__tools { margin-top: 9px; }
 .message__actions { display: flex; position: absolute; left: -3px; top: 100%; gap: 2px; padding-top: 2px; opacity: 0; transition: opacity 120ms ease; }.message:hover .message__actions, .message:focus-within .message__actions { opacity: 1; }.message--user .message__actions { right: 0; left: auto; }.message__actions button { display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; }.message__actions button:hover { background: var(--surface-soft); color: var(--text-primary); }.message__actions .message-action--copied { color: var(--success); }.message__actions .message-action--copy-failed { color: var(--danger); }
 .message--assistant-anonymous .message__actions { align-items: center; }.message__action-time { min-width: 36px; padding: 0 4px 0 3px; color: var(--text-muted); font-size: 10px; font-variant-numeric: tabular-nums; }

+ 1 - 0
src/client/components/messages/types.ts

@@ -33,6 +33,7 @@ export type UiMessage = {
   status?: 'preparing' | 'attached' | 'pending' | 'accepted' | 'streaming' | 'settled' | 'failed' | 'unknown-receipt'
   error?: string
   runId?: string
+  contentParts?: ({ text: string } | { attachmentId: string })[]
   attachments?: UiMessageAttachment[]
   tools?: UiToolCall[]
   profile?: string

+ 7 - 19
src/client/components/workspace/WorkspaceMessageTimeline.vue

@@ -365,16 +365,13 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
               <MarkdownContent process-content :content="message.reasoning" :streaming="message.status === 'streaming'" :stream-interval-ms="0" @rendered="onMarkdownRendered" />
             </details>
 
-            <div v-if="message.role === 'user' && message.attachments?.length" class="message__attachments">
-              <button v-for="attachment in message.attachments" :key="attachment.id" type="button" :class="{ 'message__attachment--image': attachment.kind === 'image' && attachment.url }" @click="emit('preview', attachment)">
-                <img v-if="attachment.kind === 'image' && attachment.url" :src="attachment.url" :alt="attachment.name" />
-                <span v-else><AppIcon :name="attachment.kind || 'file'" :size="17" /></span>
-                <strong v-if="attachment.kind !== 'image' || !attachment.url">{{ attachment.name }}</strong>
-              </button>
-            </div>
-
-            <div v-if="!messageFailure(message)?.replacesContent" class="message__content" :class="{ 'chat-bubble': !message.communication && (message.role === 'user' || message.role === 'assistant') && !!message.content.trim() }" :style="!message.communication && (message.role === 'user' || message.role === 'assistant') ? bubbleVariables(appearance[message.role], message.role) : undefined">
+            <div v-if="!messageFailure(message)?.replacesContent" class="message__content"  :style="!message.communication && (message.role === 'user' || message.role === 'assistant') ? bubbleVariables(appearance[message.role], message.role) : undefined">
               <MarkdownContent
+                :separate-media="message.role === 'user' || message.role === 'assistant' || !!message.communication"
+                :bubble="!message.communication && (message.role === 'user' || message.role === 'assistant') && !!message.content.trim()"
+                :attachments="message.attachments"
+                :content-parts="message.contentParts"
+                @preview="item => item.id && message.attachments?.some(a => a.id === item.id) ? emit('preview', item) : emit('previewFile', { name: item.name, url: item.url! })"
                 :stream-interval-ms="0"
                 :file-profile="message.profile"
                 :content="displayContentForMessage(message.role, message.content)"
@@ -401,14 +398,6 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
               {{ deliveryLabel(message.status) }}
             </div>
 
-            <div v-if="(message.role === 'assistant' || message.communication) && message.attachments?.length" class="message__attachments">
-              <button v-for="attachment in message.attachments" :key="attachment.id" type="button" @click="emit('preview', attachment)">
-                <img v-if="attachment.kind === 'image' && attachment.url" :src="attachment.url" :alt="attachment.name" />
-                <span v-else><AppIcon :name="attachment.kind || 'file'" :size="17" /></span>
-                <strong>{{ attachment.name }}</strong>
-              </button>
-            </div>
-
             <div v-if="showTools && message.tools?.length" class="message__tools">
               <ToolTrace v-for="tool in message.tools" :key="tool.id" :tool="tool" />
             </div>
@@ -496,6 +485,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 .message--user { justify-content: flex-end; margin-top: 27px; }
 .message--user .message__body { max-width: min(610px, 76%); padding: 0; background: transparent; }
 
+.message--user :deep(.message-parts) { align-items: flex-end; }
 .message--user .message__meta { display: none; }
 .message__delivery { display: flex; min-height: 18px; align-items: center; justify-content: flex-end; gap: 4px; margin: 5px 1px -4px; color: var(--text-muted); font-size: 10px; }.message__delivery--accepted { color: var(--success); }.message__delivery--failed, .message__delivery--unknown-receipt { color: var(--danger); }
 .message--tool-only { margin-block: 4px; }.message--tool-only .message__avatar, .message--tool-only .message__meta, .message--tool-only .message__actions { display: none; }.message--tool-only .message__body { max-width: 100%; }
@@ -503,8 +493,6 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 .message--assistant-anonymous .message__body { max-width: min(680px, 100%); }
 .message__reasoning { max-width: 420px; margin: 3px 0 10px; padding: 0 0 8px; border: 0; border-bottom: 1px dashed var(--line-strong); color: var(--text-secondary); font-size: 12px; }.message__reasoning summary { display: flex; align-items: center; gap: 5px; list-style: none; color: var(--text-muted); cursor: pointer; font-size: 10px; }.message__reasoning summary::-webkit-details-marker { display: none; }.message__reasoning summary::before { content: '›'; color: var(--text-muted); font-size: 14px; line-height: 1; transition: transform 120ms ease; }.message__reasoning[open] summary::before { transform: rotate(90deg); }.message__reasoning :deep(.markdown) { margin-top: 8px; }
 .fork-divider { display: flex; align-items: center; gap: 12px; margin: 8px 0 28px; color: var(--text-muted); font-size: 11px; }.fork-divider::before, .fork-divider::after { height: 1px; flex: 1; background: var(--line); content: ''; }.fork-divider > span { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface-raised); white-space: nowrap; }.fork-divider strong { max-width: 180px; overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; }
-.message__attachments { display: flex; margin-top: 8px; flex-wrap: wrap; gap: 7px; }.message__attachments button { display: flex; min-width: 90px; max-width: 210px; min-height: 42px; align-items: center; gap: 7px; padding: 5px 8px; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); color: var(--text-secondary); cursor: pointer; }.message__attachments button:hover { border-color: var(--line-strong); }.message__attachments img { width: 48px; height: 48px; margin: -5px 0 -5px -8px; object-fit: cover; }.message__attachments button > span { display: grid; place-items: center; width: 25px; height: 25px; border-radius: 7px; background: var(--surface-soft); }.message__attachments strong { min-width: 0; overflow: hidden; font-size: 11px; font-weight: 520; text-overflow: ellipsis; white-space: nowrap; }
-.message--user .message__attachments { margin: 0 0 9px; }.message--user .message__attachments button { width: 100%; max-width: none; min-height: 50px; padding: 7px 9px; border: 0; border-radius: 11px; background: rgba(255,255,255,.48); color: var(--text-primary); }.message--user .message__attachments button > span { width: 28px; height: 28px; background: rgba(255,255,255,.65); }.message--user .message__attachments strong { font-size: 12px; font-weight: 560; }.dark .message--user .message__attachments button { background: rgba(255,255,255,.08); }.message--user .message__attachments button.message__attachment--image { display: block; width: auto; min-width: 0; max-width: min(360px, 100%); min-height: 0; padding: 0; border: 0; border-radius: 8px; background: transparent; }.message--user .message__attachments button.message__attachment--image img { display: block; width: auto; height: auto; max-width: min(360px, 100%); max-height: 420px; margin: 0; border-radius: inherit; object-fit: contain; }
 .message__tools { margin-top: 9px; }
 .message__actions { display: flex; position: absolute; left: -3px; top: 100%; gap: 2px; padding-top: 2px; opacity: 0; transition: opacity 120ms ease; }.message:hover .message__actions, .message:focus-within .message__actions { opacity: 1; }.message--user .message__actions { right: 0; left: auto; }.message__actions button { display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; }.message__actions button:hover { background: var(--surface-soft); color: var(--text-primary); }.message__actions .message-action--copied { color: var(--success); }.message__actions .message-action--copy-failed { color: var(--danger); }
 .message--assistant-anonymous .message__actions { align-items: center; }.message__action-time { min-width: 36px; padding: 0 4px 0 3px; color: var(--text-muted); font-size: 10px; font-variant-numeric: tabular-nums; }

+ 4 - 0
src/client/components/workspace/viewModels.ts

@@ -132,6 +132,10 @@ export function chatMessageToUi(message: ChatMessage, agentNameFor?: (profile?:
     status: message.isStreaming ? 'streaming' : message.stage,
     error: message.error,
     attachments,
+    // A later streaming string may replace an older structured body.
+    contentParts: message.contentParts?.filter(part => 'text' in part).map(part => (part as { text: string }).text).filter(Boolean).join('\n\n') === message.content
+      ? message.contentParts?.map(part => 'text' in part && message.role === 'assistant' ? { text: visibleMessageText(part.text) } : part)
+      : undefined,
     tools: message.toolCalls?.filter(tool => tool.id).map(tool => toolToUi(!message.isStreaming && ['pending', 'running'].includes(tool.status)
       ? { ...tool, status: 'interrupted' } : tool)),
     profile: message.profile,

+ 2 - 0
src/client/styles/tokens.css

@@ -4,6 +4,7 @@
   --font-code: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
   --canvas: #ffffff;
   --conversation-canvas: #ffffff;
+  --message-file-surface: #f1f1f1;
   --surface: #ffffff;
   --surface-raised: #ffffff;
   --surface-soft: #f2f2ef;
@@ -46,6 +47,7 @@
   color-scheme: dark;
   --canvas: #181817;
   --conversation-canvas: #171a20;
+  --message-file-surface: #262626;
   --surface: #1d1d1c;
   --surface-raised: #242423;
   --surface-soft: #282827;

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

@@ -288,15 +288,21 @@ export function normalizeChatMessage(value: unknown, sessionId: string, fallback
   const source = record(value)
   const contentValue = pick(source, 'content', 'text', 'output')
   let content = typeof contentValue === 'string' ? contentValue : ''
+  let contentParts: ChatMessage['contentParts']
   let attachments: ChatAttachment[] = values(pick(source, 'attachments', 'files')).map(normalizeAttachment)
   if (Array.isArray(contentValue)) {
+    contentParts = []
     const textBlocks: string[] = []
     const attachmentBlocks: ChatAttachment[] = []
     contentValue.forEach((blockValue, index) => {
       const block = record(blockValue)
       const type = string(block.type)
-      if (type === 'text') textBlocks.push(string(block.text))
-      if (type === 'image' || type === 'file') attachmentBlocks.push(normalizeAttachment(block, index))
+      if (type === 'text') { textBlocks.push(string(block.text)); contentParts!.push({ text: string(block.text) }) }
+      if (type === 'image' || type === 'file') {
+        const attachment = normalizeAttachment(block, index)
+        attachmentBlocks.push(attachment)
+        contentParts!.push({ attachmentId: attachment.id })
+      }
     })
     content = textBlocks.filter(Boolean).join('\n\n')
     attachments = [...attachments, ...attachmentBlocks]
@@ -321,6 +327,7 @@ export function normalizeChatMessage(value: unknown, sessionId: string, fallback
     profile: string(source.profile, fallbackProfile) || undefined,
     role,
     content,
+    contentParts,
     reasoning: string(pick(source, 'reasoning', 'thinking', 'reasoning_content', 'reasoningContent')) || undefined,
     timestamp: number(pick(source, 'timestamp', 'created_at', 'createdAt', 'updated_at', 'updatedAt'), Date.now() / 1000),
     sequence: number(pick(source, 'seq', 'sequence')) || undefined,

+ 1 - 0
src/shared/types.ts

@@ -144,6 +144,7 @@ export interface ChatMessage {
   stage: DeliveryStage
   isStreaming?: boolean
   error?: string
+  contentParts?: ({ text: string } | { attachmentId: string })[]
   attachments?: ChatAttachment[]
   toolCalls?: ToolCall[]
   toolCallId?: string

+ 4 - 4
tests/client/chatAppearance.test.ts

@@ -24,13 +24,13 @@ describe('chat bubble appearance', () => {
     const store = useChatAppearance()
     const wrapper = mount(component as typeof MessageTimeline, {
       props: {identity: 'appearance', messages: [{id:'u',role:'user',content:'问题'}, {id:'a',role:'assistant',content:'长回复'}, {id:'s',role:'system',content:'系统信息'}]},
-      global: {stubs: {AgentAvatar:true, AppIcon:true, MarkdownContent:true}},
+      global: {stubs: {AgentAvatar:true, AppIcon:true}},
     })
     store.selectPreset('codex'); await nextTick()
-    expect(wrapper.findAll('.message__content.chat-bubble')).toHaveLength(2)
-    expect(wrapper.get('.message--user .chat-bubble').attributes('style')).toContain('#E4F3FC')
+    expect(wrapper.findAll('.message__content .chat-bubble')).toHaveLength(2)
+    expect(wrapper.get('.message--user .message__content').attributes('style')).toContain('#E4F3FC')
     store.selectPreset('grok'); await nextTick()
-    expect(wrapper.get('.message--user .chat-bubble').attributes('style')).toContain('#090909')
+    expect(wrapper.get('.message--user .message__content').attributes('style')).toContain('#090909')
     expect(wrapper.find('.message--system .chat-bubble').exists()).toBe(false)
     wrapper.unmount()
   })

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

@@ -3,6 +3,12 @@ import { mediaItemsFromMessages, mediaUrlIdentity, previewItemFromUrl } from '@/
 import type { UiMessage } from '@/components/messages/types'
 
 describe('conversation media sequence', () => {
+  it('follows inline and typed positions instead of moving direct attachments first', () => {
+    const attachments = [{ id: 'last', name: 'last.png', kind: 'image' as const, url: '/media/last.png' }]
+    expect(mediaItemsFromMessages([{ id: 'ordered', role: 'assistant', content: '![首图](/media/first.png)', attachments }]).map(item => item.name)).toEqual(['first.png', 'last.png'])
+    expect(mediaItemsFromMessages([{ id: 'typed', role: 'assistant', content: '', attachments, contentParts: [{attachmentId:'last'}, {text:'![首图](/media/first.png)'}] }]).map(item => item.name)).toEqual(['last.png', 'first.png'])
+    expect(mediaItemsFromMessages([{id:'code',role:'assistant',content:'```md\n![示例](/media/code.png)\n```'}])).toEqual([])
+  })
   it('keeps visible image and video order while deduplicating the same URL', () => {
     const messages: UiMessage[] = [
       { id: 'one', role: 'assistant', content: '![第一张](/media/first.png)' },

+ 46 - 0
tests/client/messageMedia.test.ts

@@ -0,0 +1,46 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import MarkdownContent from '@/components/messages/MarkdownContent.vue'
+import { normalizeChatMessage } from '@/utils/normalize'
+import { chatMessageToUi, workspaceMessagesToUi } from '@/components/workspace/viewModels'
+
+const attachments = [
+  { id: 'doc', name: 'bamboo-sample.txt', size: 134, kind: 'file' as const, url: '/api/app/files/doc/download' },
+  { id: 'pic', name: 'bamboo.jpg', kind: 'image' as const, url: '/api/app/files/pic/preview' },
+]
+describe('ordered message media', () => {
+  it('keeps media between prose bubbles and deduplicates trailing attachments', async () => {
+    const wrapper = mount(MarkdownContent, { props: {
+      separateMedia: true, bubble: true, fileCards: true, attachments,
+      content: '先看图片。\n\n![竹叶](/api/app/files/pic/preview)\n\n再看文件。\n\n[bamboo-sample.txt](/api/app/files/doc/download)\n\n最后一段。',
+    } })
+    expect([...wrapper.get('.message-parts').element.children].map(el => el.className)).toEqual(['message-prose chat-bubble', 'message-media', 'message-prose chat-bubble', 'message-file', 'message-prose chat-bubble'])
+    expect(wrapper.findAll('.message-file')).toHaveLength(1)
+    expect(wrapper.find('.chat-bubble img').exists()).toBe(false)
+    expect(wrapper.get('.message-file__size').text()).toBe('134 B')
+    await wrapper.get('.message-file').trigger('click')
+    expect(wrapper.emitted('preview')?.[0]?.[0]).toEqual(attachments[0])
+  })
+  it('does not turn code examples or inline prose links into separate attachments', () => {
+    const wrapper = mount(MarkdownContent, { props: { separateMedia: true, bubble: true, fileCards: true,
+      content: '```md\n![示例](/api/app/files/pic/preview)\n```\n\n请阅读[文档](/api/app/files/doc/download),然后继续。',
+    } })
+    expect(wrapper.findAll('.message-media, .message-file')).toHaveLength(0)
+    expect(wrapper.get('.chat-bubble').text()).toContain('请阅读文档,然后继续。')
+  })
+  it('preserves typed content order through normalization without parsing user Markdown', () => {
+    const message = chatMessageToUi(normalizeChatMessage({ id: 'ordered', role: 'user', content: [
+      { type: 'text', text: '**原样文字**' },
+      { type: 'file', id: 'doc', name: 'bamboo-sample.txt', url: '/api/app/files/doc/download', size: 134 },
+      { type: 'text', text: '结尾' },
+    ] }, 'session'))
+    const wrapper = mount(MarkdownContent, { props: { content: message.content, attachments: message.attachments, contentParts: message.contentParts, separateMedia: true, bubble: true, plain: true } })
+    expect([...wrapper.get('.message-parts').element.children].map(el => el.textContent)).toEqual(['**原样文字**', 'bamboo-sample.txt134 B', '结尾'])
+  })
+  it('retains workspace metadata after resolving an inline path', () => {
+    const message = workspaceMessagesToUi([{ id: 'w', role: 'assistant', content: '[文件](/tmp/bamboo-sample.txt)', attachments: [{ id: 'doc', name: 'bamboo-sample.txt', size: 134, mimeType: 'text/plain', sourcePath: '/tmp/bamboo-sample.txt' }], tools: [], status: 'complete' } as any])[0]!
+    const wrapper = mount(MarkdownContent, { props: { content: message.content, attachments: message.attachments, separateMedia: true, fileCards: true } })
+    expect(wrapper.findAll('.message-file')).toHaveLength(1)
+    expect(wrapper.text()).toBe('bamboo-sample.txt134 B')
+  })
+})

+ 1 - 0
tests/fixtures/message-media/bamboo-sample.txt

@@ -0,0 +1 @@
+这是一份用于验证文件预览的文本。

BIN
tests/fixtures/message-media/bamboo.png


+ 1 - 0
tests/fixtures/message-media/index.html

@@ -0,0 +1 @@
+<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>消息图片与附件预览</title></head><body><div id="app"></div><script type="module" src="./main.ts"></script></body></html>

+ 31 - 0
tests/fixtures/message-media/main.ts

@@ -0,0 +1,31 @@
+import { createApp, defineComponent, h, ref } from 'vue'
+import WorkspaceMessageTimeline from '@/components/workspace/WorkspaceMessageTimeline.vue'
+import MessageTimeline from '@/components/messages/MessageTimeline.vue'
+import type { UiMessage } from '@/components/messages/types'
+import { useChatAppearance } from '@/stores/chatAppearance'
+import '@/styles/tokens.css'
+import '@/styles/global.css'
+const query = new URLSearchParams(location.search)
+document.documentElement.classList.toggle('dark', query.has('dark'))
+// Isolated fixture only: the product keeps the user's saved appearance.
+useChatAppearance().selectPreset('grok')
+const mobile = query.has('mobile')
+const imageUrl = location.origin + '/tests/fixtures/message-media/bamboo.png'
+const messages: UiMessage[] = [
+  { id: 'u1', role: 'user', content: '随便发个文件给我看看' },
+  { id: 'a1', role: 'assistant', content: '随便拿了一个小文本给你试打开。', attachments: [{ id: 'file', name: 'bamboo-sample.txt', size: 134, kind: 'file', url: '/tests/fixtures/message-media/bamboo-sample.txt' }] },
+  { id: 'u2', role: 'user', content: '图片发一个' },
+  { id: 'a2', role: 'assistant', content: `发一张试传。\n\n![竹叶](${imageUrl})` },
+  { id: 'u3', role: 'user', content: '提示信息是什么样的?' },
+  { id: 'a3', role: 'assistant', content: '提示会居中显示,点开可看详情。' },
+  { id: 'notice', role: 'system', content: '任务已有新结果', taskReference: { conversationId: 'team', taskId: 'task' } },
+]
+if (query.has('order')) messages.splice(0, messages.length, { id:'order',role:'assistant',content:`第一段。\n\n![竹叶](${imageUrl})\n\n图片下面的文字。\n\n[bamboo-sample.txt](/api/app/files/file/download)\n\n最后一段。`,attachments:[{id:'file',name:'bamboo-sample.txt',size:134,kind:'file',url:'/api/app/files/file/download'}]})
+createApp(defineComponent({ setup() {
+  const opened = ref('')
+  return () => h('main', { style: `height:100dvh;display:flex;flex-direction:column;${mobile ? 'max-width:390px;margin:auto;border-inline:1px solid var(--line)' : ''}` }, [
+    h(query.has('ordinary') ? MessageTimeline : WorkspaceMessageTimeline, { identity:'media-preview',title:'竹儿',showAssistantIdentity:false,messages,onPreview:(file:any)=>opened.value=file.name,onPreviewFile:(file:any)=>opened.value=file.name }),
+    h('footer', {style:'padding:12px 24px;border-top:1px solid var(--line);color:var(--text-muted)'}, '向竹儿提问'),
+    opened.value ? h('dialog',{open:true,style:'position:fixed;inset:20% auto auto 50%;transform:translateX(-50%);border:1px solid var(--line);border-radius:20px;padding:24px;background:var(--surface);color:var(--text-primary)'},[h('p',opened.value), h('button',{onClick:()=>opened.value=''},'关闭预览')]) : null,
+  ])
+} })).mount('#app')