Bläddra i källkod

feat: 消息文件卡片与浮层预览

samien 2 månader sedan
förälder
incheckning
fa9e30fff7

+ 105 - 35
src/client/components/messages/MarkdownContent.vue

@@ -1,26 +1,52 @@
 <script setup lang="ts">
 import DOMPurify from 'dompurify'
+import hljs from 'highlight.js'
 import MarkdownIt from 'markdown-it'
 import { computed, onMounted, onUpdated, ref } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import { normalizeAssistantMediaMarkdown } from '@/utils/mediaMarkdown'
+import { repairMarkdownForRender } from '@/utils/markdownRepair'
 
 const props = withDefaults(defineProps<{
   content: string
   streaming?: boolean
   legacyMedia?: boolean
-}>(), { streaming: false, legacyMedia: false })
+  plain?: boolean
+  mentionNames?: string[]
+  fileCards?: boolean
+}>(), { streaming: false, legacyMedia: false, plain: false, fileCards: false })
+
+const emit = defineEmits<{ fileLink: [name: string, url: string] }>()
 
 const root = ref<HTMLElement | null>(null)
 const copied = ref('')
 
+function escapeHtml(value: string): string {
+  return value
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+}
+
 const md = new MarkdownIt({
   html: false,
   breaks: true,
   linkify: true,
   typographer: false,
+  highlight(str: string, lang: string): string {
+    const language = lang.trim().split(/\s+/)[0]?.toLowerCase() || ''
+    const highlighted = language && hljs.getLanguage(language)
+      ? hljs.highlight(str, { language, ignoreIllegals: true }).value
+      : escapeHtml(str)
+    const label = language || 'text'
+    return `<pre class="code-block"><div class="code-header"><span class="code-lang">${escapeHtml(label)}</span><button type="button" class="code-copy" aria-label="复制代码">复制</button></div><code class="hljs language-${escapeHtml(label)}">${highlighted}</code></pre>`
+  },
 })
 
+// Dashboard 规范:保留原文引号,不做智能引号替换。
+md.disable('smartquotes')
+
 const defaultLinkOpen = md.renderer.rules.link_open ?? ((tokens, index, options, _env, self) => self.renderToken(tokens, index, options))
 md.renderer.rules.link_open = (tokens, index, options, env, self) => {
   const token = tokens[index]
@@ -29,35 +55,69 @@ md.renderer.rules.link_open = (tokens, index, options, env, self) => {
   return defaultLinkOpen(tokens, index, options, env, self)
 }
 
-const markdownSource = computed(() => props.legacyMedia
-  ? normalizeAssistantMediaMarkdown(props.content || '', props.streaming)
-  : props.content || '')
+const markdownSource = computed(() => {
+  let source = props.legacyMedia
+    ? normalizeAssistantMediaMarkdown(props.content || '', props.streaming)
+    : props.content || ''
+  source = repairMarkdownForRender(source, props.streaming)
+  return source
+})
 
-const rendered = computed(() => DOMPurify.sanitize(md.render(markdownSource.value), {
-  USE_PROFILES: { html: true },
-  FORBID_TAGS: ['style', 'svg', 'math', 'form', 'input', 'button', 'iframe', 'object', 'embed'],
-  FORBID_ATTR: ['style', 'onerror', 'onclick', 'onload'],
-  ALLOW_UNKNOWN_PROTOCOLS: false,
-}))
+function highlightMentions(html: string): string {
+  const names = props.mentionNames
+  if (!names?.length) return html
+  const escaped = [...new Set(names.filter(Boolean))]
+    .sort((a, b) => b.length - a.length)
+    .map(name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
+  if (!escaped.length) return html
+  const re = new RegExp(`(?<![\\w])@(?:${escaped.join('|')})(?=$|[\\s.,!?;:,。!?;:)\\]}>])`, 'gi')
+  return html.replace(re, match => `<span class="mention-highlight">${match}</span>`)
+}
 
-function decorateCodeBlocks() {
+const rendered = computed(() => {
+  const html = md.render(markdownSource.value)
+  return DOMPurify.sanitize(highlightMentions(html), {
+    USE_PROFILES: { html: true },
+    FORBID_TAGS: ['style', 'svg', 'math', 'form', 'input', 'button', 'iframe', 'object', 'embed'],
+    FORBID_ATTR: ['style', 'onerror', 'onclick', 'onload'],
+    ALLOW_UNKNOWN_PROTOCOLS: false,
+    ADD_ATTR: ['target'],
+  })
+})
+
+function decorateCopyButtons() {
   if (!root.value) return
-  root.value.querySelectorAll('pre').forEach((pre, index) => {
-    if (pre.querySelector(':scope > .code-copy')) return
-    const button = document.createElement('button')
-    button.type = 'button'
-    button.className = 'code-copy'
-    button.textContent = '复制'
-    button.setAttribute('aria-label', '复制代码')
+  root.value.querySelectorAll<HTMLButtonElement>('pre .code-copy').forEach((button, index) => {
+    if (button.dataset.bound) return
+    button.dataset.bound = 'true'
     button.addEventListener('click', async () => {
-      const code = pre.querySelector('code')?.textContent ?? ''
+      const code = button.closest('pre')?.querySelector('code')?.textContent ?? ''
       await navigator.clipboard.writeText(code)
       const key = `${index}:${code.length}`
       copied.value = key
       button.textContent = '已复制'
       window.setTimeout(() => { if (copied.value === key) { copied.value = ''; button.textContent = '复制' } }, 1400)
     })
-    pre.appendChild(button)
+  })
+}
+
+function decorateFileLinks() {
+  if (!props.fileCards || !root.value) return
+  root.value.querySelectorAll<HTMLAnchorElement>('a').forEach(link => {
+    if (link.dataset.fileCard) return
+    let url: URL
+    try { url = new URL(link.href, window.location.href) } catch { return }
+    if (url.origin !== window.location.origin || !/^\/Users\/[^/]+\/Agents\/.+/.test(url.pathname)) return
+    link.dataset.fileCard = 'true'
+    link.classList.add('file-link-card')
+    link.removeAttribute('target')
+    link.removeAttribute('rel')
+    const name = link.textContent?.trim() || decodeURIComponent(url.pathname.split('/').at(-1) || '文件')
+    link.setAttribute('aria-label', `预览文件 ${name}`)
+    link.addEventListener('click', event => {
+      event.preventDefault()
+      emit('fileLink', name, `${url.pathname}${url.search}`)
+    })
   })
 }
 
@@ -68,30 +128,40 @@ function onClick(event: MouseEvent) {
   if (/^(javascript|data|vbscript):/i.test(href)) event.preventDefault()
 }
 
-onMounted(decorateCodeBlocks)
-onUpdated(decorateCodeBlocks)
+onMounted(() => { decorateCopyButtons(); decorateFileLinks() })
+onUpdated(() => { decorateCopyButtons(); decorateFileLinks() })
 </script>
 
 <template>
-  <div ref="root" class="markdown" :class="{ 'markdown--streaming': streaming }" @click="onClick" v-html="rendered" />
+  <div v-if="plain" class="plain-text">{{ content }}</div>
+  <div v-else ref="root" class="markdown" :class="{ 'markdown--streaming': streaming }" @click="onClick" v-html="rendered" />
   <AppIcon v-if="streaming" class="stream-caret" name="arrow-up" :size="0" />
 </template>
 
 <style scoped>
-.markdown { min-width: 0; color: inherit; font-size: 13px; line-height: 1.68; overflow-wrap: anywhere; }
-.markdown :deep(p) { margin: 0 0 .72em; }.markdown :deep(p:last-child) { margin-bottom: 0; }
-.markdown :deep(h1), .markdown :deep(h2), .markdown :deep(h3), .markdown :deep(h4) { margin: 1.2em 0 .5em; color: var(--text-primary); line-height: 1.3; letter-spacing: -.02em; }
-.markdown :deep(h1) { font-size: 1.45em; }.markdown :deep(h2) { font-size: 1.28em; }.markdown :deep(h3) { font-size: 1.13em; }
-.markdown :deep(ul), .markdown :deep(ol) { margin: .6em 0; padding-left: 1.55em; }.markdown :deep(li) { margin: .22em 0; }
-.markdown :deep(blockquote) { margin: .8em 0; padding: .1em 0 .1em 1em; border-left: 2px solid var(--line-strong); color: var(--text-secondary); }
+.plain-text { min-width: 0; color: inherit; font-size: 13px; line-height: 1.68; white-space: pre-wrap; overflow-wrap: anywhere; }
+.markdown { min-width: 0; color: inherit; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; }
+.markdown :deep(p) { margin: 0 0 10px; }.markdown :deep(p:last-child) { margin-bottom: 0; }
+.markdown :deep(h1), .markdown :deep(h2), .markdown :deep(h3), .markdown :deep(h4), .markdown :deep(h5), .markdown :deep(h6) { margin: 1.5em 0 .55em; color: var(--text-primary); line-height: 1.35; letter-spacing: -.01em; }
+.markdown :deep(h1) { font-size: 1.5em; }.markdown :deep(h2) { font-size: 1.28em; }.markdown :deep(h3) { font-size: 1.12em; }.markdown :deep(h4), .markdown :deep(h5), .markdown :deep(h6) { font-size: 1em; }
+.markdown :deep(ul), .markdown :deep(ol) { margin: .45em 0 10px; padding-left: 1.55em; }.markdown :deep(li) { margin: .32em 0; }
+.markdown :deep(strong) { color: var(--text-primary); font-weight: 660; }
+.markdown :deep(blockquote) { margin: 10px 0; padding: .15em 0 .15em 14px; border-left: 3px solid var(--line-strong); color: var(--text-secondary); }
 .markdown :deep(a) { color: var(--text-primary); text-decoration: underline; text-decoration-color: var(--line-strong); text-underline-offset: 3px; }.markdown :deep(a:hover) { text-decoration-color: currentColor; }
-.markdown :deep(code) { padding: .15em .35em; border-radius: 5px; background: var(--surface-soft); color: var(--text-primary); font-family: var(--font-code); font-size: .9em; }
-.markdown :deep(pre) { position: relative; margin: .85em 0; padding: 13px 14px; overflow: auto; border: 1px solid var(--line); border-radius: 11px; background: color-mix(in srgb, var(--surface-soft) 78%, var(--surface)); }
-.markdown :deep(pre code) { padding: 0; background: transparent; font-size: 11px; line-height: 1.6; white-space: pre; }
-.markdown :deep(.code-copy) { position: sticky; float: right; top: 0; margin: -7px -8px 4px 8px; padding: 3px 6px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-raised); color: var(--text-muted); cursor: pointer; font: 9px var(--font-ui); }
-.markdown :deep(table) { display: block; max-width: 100%; margin: .9em 0; overflow-x: auto; border-collapse: collapse; }.markdown :deep(th), .markdown :deep(td) { padding: 6px 9px; border: 1px solid var(--line); text-align: left; }.markdown :deep(th) { background: var(--surface-soft); font-weight: 600; }
-.markdown :deep(hr) { margin: 1.3em 0; border: 0; border-top: 1px solid var(--line); }
+.markdown :deep(code) { padding: .14em .38em; border-radius: 5px; background: var(--surface-soft); color: var(--text-primary); font-family: var(--font-code); font-size: .9em; }
+.markdown :deep(.code-block) { position: relative; margin: 12px 0; border: 1px solid var(--line); border-radius: 11px; background: var(--surface-soft); overflow: hidden; }
+.markdown :deep(.code-header) { display: flex; min-height: 30px; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 6px 4px 12px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--surface-hover) 55%, transparent); }
+.markdown :deep(.code-lang) { color: var(--text-muted); font: 600 9px var(--font-ui); letter-spacing: .06em; text-transform: uppercase; }
+.markdown :deep(.code-copy) { padding: 3px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-raised); color: var(--text-muted); cursor: pointer; font: 500 9px var(--font-ui); }
+.markdown :deep(.code-copy:hover) { color: var(--text-primary); }
+.markdown :deep(.code-block code) { display: block; padding: 12px 14px; overflow: auto; background: transparent; color: var(--text-primary); font-size: 11px; line-height: 1.6; white-space: pre; }
+.markdown :deep(table) { display: block; width: max-content; max-width: 100%; margin: 10px 0; overflow-x: auto; border-collapse: collapse; }
+.markdown :deep(th), .markdown :deep(td) { padding: 6px 10px; border: 1px solid var(--line); text-align: left; }
+.markdown :deep(th) { background: var(--surface-soft); color: var(--text-primary); font-weight: 640; }.markdown :deep(td) { color: var(--text-secondary); }
+.markdown :deep(hr) { margin: 16px 0; border: 0; border-top: 1px solid var(--line); }
 .markdown :deep(img) { display: block; width: auto; max-width: min(100%, 560px); max-height: 360px; margin: .7em 0; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-soft); object-fit: contain; }
+.markdown :deep(.file-link-card) { display: flex; width: min(390px, 100%); min-height: 52px; align-items: center; gap: 9px; margin: 8px 0; padding: 8px 11px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); box-shadow: 0 3px 10px rgba(0,0,0,.035); color: var(--text-primary); text-decoration: none; }.markdown :deep(.file-link-card)::before { display: grid; width: 27px; height: 27px; flex: 0 0 27px; place-items: center; border-radius: 7px; background: var(--surface-soft); color: var(--text-secondary); content: '▤'; font-size: 15px; }.markdown :deep(.file-link-card)::after { margin-left: auto; color: var(--text-muted); content: '预览'; font-size: 9px; }.markdown :deep(.file-link-card:hover) { border-color: var(--line-strong); background: var(--surface-soft); text-decoration: none; }
+.markdown :deep(.mention-highlight) { padding: .08em .34em; border-radius: 6px; background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--text-primary); font-weight: 600; }
 .markdown--streaming :deep(p:last-child)::after { content: ''; display: inline-block; width: 5px; height: 14px; margin-left: 3px; border-radius: 1px; background: currentColor; vertical-align: -2px; animation: caret 1s step-end infinite; }
 .stream-caret { display: none; }
 @keyframes caret { 50% { opacity: 0; } }

+ 4 - 1
src/client/components/messages/MessageTimeline.vue

@@ -5,7 +5,7 @@ import EmptyState from '@/components/common/EmptyState.vue'
 import InteractionCard from './InteractionCard.vue'
 import MarkdownContent from './MarkdownContent.vue'
 import ToolTrace from './ToolTrace.vue'
-import type { UiInteraction, UiMessage } from './types'
+import type { UiInteraction, UiLocalFileLink, UiMessage } from './types'
 
 const props = withDefaults(defineProps<{
   messages: UiMessage[]
@@ -49,6 +49,7 @@ const emit = defineEmits<{
   quote: [message: UiMessage]
   branch: [message: UiMessage]
   preview: [attachment: NonNullable<UiMessage['attachments']>[number]]
+  previewFile: [file: UiLocalFileLink]
   approve: [approved: boolean]
   clarify: [text: string]
 }>()
@@ -194,6 +195,8 @@ defineExpose({ scrollToMessage, scrollToBottom })
                 :legacy-media="message.role === 'assistant'"
                 :plain="message.role === 'user'"
                 :mention-names="mentionNames"
+                file-cards
+                @file-link="(name, url) => emit('previewFile', { name, url })"
               />
             </div>
 

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

@@ -15,6 +15,11 @@ export type UiMessageAttachment = {
   size?: number
 }
 
+export type UiLocalFileLink = {
+  name: string
+  url: string
+}
+
 export type UiMessage = {
   id: string
   role: 'user' | 'assistant' | 'system' | 'tool'

+ 243 - 0
src/client/utils/markdownRepair.ts

@@ -0,0 +1,243 @@
+const MARKDOWN_FENCE_LANGUAGES = new Set(['md', 'markdown', 'mdown', 'mkd'])
+
+type FenceInfo = {
+  indent: string
+  marker: string
+  fence: string
+  length: number
+  info: string
+}
+
+function parseFence(line: string): FenceInfo | null {
+  const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/)
+  if (!match) return null
+
+  const [, indent, fence, rawInfo = ''] = match
+  const marker = fence[0]
+  const info = rawInfo.trim()
+
+  // CommonMark permits backticks in tilde-fence info strings, but not in
+  // backtick-fence info strings. Keeping this distinction prevents inline-ish
+  // malformed backtick text from being promoted into a fence opener.
+  if (marker === '`' && info.includes('`')) return null
+
+  return {
+    indent,
+    marker,
+    fence,
+    length: fence.length,
+    info,
+  }
+}
+
+function serializeFence(fence: FenceInfo, length = fence.length, info = fence.info): string {
+  return `${fence.indent}${fence.marker.repeat(length)}${info ? ` ${info}` : ''}`
+}
+
+function isMarkdownFence(fence: FenceInfo): boolean {
+  const language = fence.info.split(/\s+/)[0]?.toLowerCase()
+  return MARKDOWN_FENCE_LANGUAGES.has(language)
+}
+
+function isClosingFence(line: string, opener: FenceInfo): boolean {
+  const fence = parseFence(line)
+  return Boolean(
+    fence
+    && fence.marker === opener.marker
+    && fence.length >= opener.length
+    && fence.info === '',
+  )
+}
+
+function findLastNonEmptyLine(lines: string[], start = lines.length - 1): number {
+  let index = start
+  while (index >= 0 && lines[index].trim() === '') {
+    index -= 1
+  }
+  return index
+}
+
+function findFinalClosingFence(lines: string[], opener: FenceInfo, start: number): number {
+  for (let i = findLastNonEmptyLine(lines); i > start; i -= 1) {
+    if (isClosingFence(lines[i], opener)) {
+      return i
+    }
+  }
+  return -1
+}
+
+type OpenFence = {
+  marker: string
+  length: number
+}
+
+function canBalanceNestedFences(lines: string[], marker: string): boolean {
+  const stack: OpenFence[] = []
+  let sawFence = false
+
+  for (const line of lines) {
+    const fence = parseFence(line)
+    if (!fence || fence.marker !== marker) continue
+
+    sawFence = true
+    const current = stack[stack.length - 1]
+    if (fence.info === '' && current && fence.length >= current.length) {
+      stack.pop()
+      continue
+    }
+
+    // Inside a Markdown example, an unlabeled fence can be either a closing
+    // fence or a literal nested unlabeled example opener. If there is no nested
+    // opener waiting to close, treat it as the latter while evaluating a later
+    // candidate closing fence for the outer example.
+    stack.push({ marker: fence.marker, length: fence.length })
+  }
+
+  return sawFence && stack.length === 0
+}
+
+function findBalancedClosingFence(lines: string[], opener: FenceInfo, start: number): number {
+  const candidates: number[] = []
+
+  for (let i = start; i < lines.length; i += 1) {
+    const fence = parseFence(lines[i])
+    if (
+      fence
+      && fence.marker === opener.marker
+      && fence.info === ''
+      && fence.length >= opener.length
+    ) {
+      candidates.push(i)
+    }
+  }
+
+  for (let i = candidates.length - 1; i >= 0; i -= 1) {
+    const candidate = candidates[i]
+    if (canBalanceNestedFences(lines.slice(start, candidate), opener.marker)) {
+      return candidate
+    }
+  }
+
+  return candidates[0] ?? -1
+}
+
+function maxFenceLength(lines: string[], marker: string): number {
+  let maxLength = 0
+  for (const line of lines) {
+    const fence = parseFence(line)
+    if (fence?.marker === marker) {
+      maxLength = Math.max(maxLength, fence.length)
+    }
+  }
+  return maxLength
+}
+
+function promoteMarkdownExampleFences(lines: string[]): string[] {
+  const output: string[] = []
+
+  for (let i = 0; i < lines.length; i += 1) {
+    const opener = parseFence(lines[i])
+    if (!opener || !isMarkdownFence(opener)) {
+      output.push(lines[i])
+      continue
+    }
+
+    const balancedClose = findBalancedClosingFence(lines, opener, i + 1)
+    if (balancedClose === -1) {
+      output.push(lines[i])
+      continue
+    }
+
+    const body = lines.slice(i + 1, balancedClose)
+    const innerMaxLength = maxFenceLength(body, opener.marker)
+    if (innerMaxLength >= opener.length) {
+      const promotedLength = innerMaxLength + 1
+      output.push(serializeFence(opener, promotedLength))
+      output.push(...body)
+      output.push(serializeFence(opener, promotedLength, ''))
+    } else {
+      output.push(lines[i])
+      output.push(...body)
+      output.push(lines[balancedClose])
+    }
+
+    i = balancedClose
+  }
+
+  return output
+}
+
+/**
+ * LLMs often wrap a complete Markdown answer in an outer ```md fence. Showing
+ * that wrapper as a code block makes headings and lists render as literal text.
+ * Strip only that outer draft wrapper before handing content to markdown-it,
+ * then normalize Markdown examples that embed shorter literal fences by
+ * lengthening the example's outer fence.
+ */
+export function repairNestedMarkdownFences(content: string): string {
+  if (!content.includes('```') && !content.includes('~~~')) return content
+
+  const lines = content.split('\n')
+  const output: string[] = []
+  let changed = false
+
+  for (let i = 0; i < lines.length; i += 1) {
+    const opener = parseFence(lines[i])
+    if (!opener || !isMarkdownFence(opener)) {
+      output.push(lines[i])
+      continue
+    }
+
+    const finalClose = findFinalClosingFence(lines, opener, i + 1)
+    if (finalClose === -1) {
+      output.push(lines[i])
+      continue
+    }
+
+    const lastNonEmpty = findLastNonEmptyLine(lines)
+    if (finalClose !== lastNonEmpty) {
+      output.push(lines[i])
+      continue
+    }
+
+    output.push(...promoteMarkdownExampleFences(lines.slice(i + 1, finalClose)))
+    output.push(...lines.slice(finalClose + 1))
+    changed = true
+    break
+  }
+
+  return changed ? output.join('\n') : content
+}
+
+function isPlainFenceOpener(line: string): boolean {
+  const fence = parseFence(line)
+  return Boolean(fence && fence.info === '')
+}
+
+function closeDanglingFences(content: string): string {
+  let openMarker = ''
+  let openLength = 0
+  for (const line of content.split('\n')) {
+    const fence = parseFence(line)
+    if (!fence) continue
+    if (!openMarker) {
+      openMarker = fence.marker
+      openLength = fence.length
+    } else if (fence.marker === openMarker && fence.info === '' && fence.length >= openLength) {
+      openMarker = ''
+    }
+  }
+  if (!openMarker) return content
+  // Streamed output can pause mid code block; closing the fence lets the
+  // partial code render as a block instead of leaking ``` into prose.
+  return `${content}\n${openMarker.repeat(openLength)}`
+}
+
+/**
+ * Repair assistant Markdown before rendering: unwrap a whole-answer ```md
+ * wrapper, then close fences left open by streaming or truncation.
+ */
+export function repairMarkdownForRender(content: string, streaming = false): string {
+  const repaired = repairNestedMarkdownFences(content)
+  return streaming ? repaired : closeDanglingFences(repaired)
+}

+ 15 - 0
src/client/views/ChatView.vue

@@ -7,6 +7,7 @@ import type { ChoiceOption } from '@/components/common/types'
 import ComposerShell from '@/components/composer/ComposerShell.vue'
 import type { ComposerOption, ComposerReference, ComposerSubmit } from '@/components/composer/types'
 import PreviewInspector from '@/components/library/PreviewInspector.vue'
+import PreviewModal from '@/components/library/PreviewModal.vue'
 import type { UiLibraryItem } from '@/components/library/types'
 import MessageTimeline from '@/components/messages/MessageTimeline.vue'
 import type { UiMessage } from '@/components/messages/types'
@@ -24,6 +25,7 @@ const router = useRouter()
 const search = ref('')
 const quoted = ref<UiMessage | null>(null)
 const preview = ref<UiLibraryItem | null>(null)
+const filePreview = ref<UiLibraryItem | null>(null)
 const modelDialog = ref(false)
 const showTools = ref(true)
 const queueMode = ref(false)
@@ -106,6 +108,16 @@ 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 }
 }
 
+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 }
+}
+
 async function selectModel(id: string) {
   const [provider, ...rest] = id.split(':')
   const model = chat.models.find(item => item.provider === provider && item.id === rest.join(':'))
@@ -226,6 +238,7 @@ watch(() => chat.activeSessionId, async id => {
         @quote="quoted = $event"
         @branch="branch"
         @preview="openAttachment"
+        @preview-file="openLocalFile"
         @approve="interaction && chat.respondToApproval(interaction.id, $event)"
         @clarify="interaction && chat.respondToClarification(interaction.id, $event)"
       >
@@ -268,6 +281,8 @@ 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" />
+
   <ChoiceDialog :open="modelDialog" title="选择模型" :options="modelOptions" :selected-id="selectedModelId" @close="modelDialog = false" @select="selectModel" />
 
   <Teleport to="body">

+ 17 - 0
src/client/views/GroupsView.vue

@@ -7,6 +7,8 @@ import ComposerShell from '@/components/composer/ComposerShell.vue'
 import type { ComposerOption, ComposerReference, ComposerSubmit } from '@/components/composer/types'
 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 MessageTimeline from '@/components/messages/MessageTimeline.vue'
 import type { UiMessage } from '@/components/messages/types'
 import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
@@ -24,6 +26,7 @@ const createOpen = ref(false)
 const managerOpen = ref(false)
 const showTools = ref(true)
 const quoted = ref<UiMessage | null>(null)
+const preview = ref<UiLibraryItem | null>(null)
 const composer = ref<InstanceType<typeof ComposerShell> | null>(null)
 
 const filteredRooms = computed(() => groups.rooms
@@ -39,6 +42,7 @@ const profiles = computed(() => auth.profiles.map(profile => profile.name))
 const availableProfiles = computed(() => profiles.value.filter(profile => !groups.agents.some(agent => agent.profile === profile)))
 const uploadsEnabled = computed(() => auth.groupUploadsEnabled)
 const reference = computed<ComposerReference | null>(() => quoted.value ? { id: quoted.value.id, author: quoted.value.author, content: quoted.value.content } : null)
+const mentionNames = computed(() => ['所有人', ...groups.agents.map(agent => agent.displayName || agent.profile)])
 const mentionOptions = computed<ComposerOption[]>(() => [
   { id: 'all', label: '所有人', detail: '通知房间内全部 Agent' },
   ...groups.agents.map(agent => ({ id: agent.id, label: agent.displayName || agent.profile, detail: agent.profile, disabled: !agent.enabled })),
@@ -91,6 +95,16 @@ async function archiveRoom() {
   await router.replace(groups.selectedRoomId ? `/groups/${encodeURIComponent(groups.selectedRoomId)}` : '/groups')
 }
 
+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 }
+}
+
 onMounted(async () => {
   try {
     await groups.start()
@@ -144,10 +158,12 @@ watch(() => route.params.roomId, async roomId => {
         :synced="!!groups.selectedRoom"
         :show-tools="showTools"
         :interaction="activeInteraction"
+        :mention-names="mentionNames"
         empty-title="让多个 Agent 一起工作"
         empty-description="使用 @ 提及指定 Agent,或直接发送消息触发已启用自动回复的成员。"
         @load-older="groups.loadOlder"
         @quote="quoted = $event"
+        @preview-file="openLocalFile"
         @approve="activeInteraction && groups.approveInteraction(activeInteraction.id, $event ? 'once' : 'deny')"
         @clarify="activeInteraction && groups.clarifyInteraction(activeInteraction.id, $event)"
       >
@@ -197,6 +213,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" />
 </template>
 
 <style scoped>

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

@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest'
+import { repairMarkdownForRender, repairNestedMarkdownFences } from '@/utils/markdownRepair'
+
+describe('repairNestedMarkdownFences', () => {
+  it('unwraps a whole-answer outer md fence', () => {
+    const input = '```md\n# 标题\n\n- 项目一\n- 项目二\n```'
+    expect(repairNestedMarkdownFences(input)).toBe('# 标题\n\n- 项目一\n- 项目二')
+  })
+
+  it('keeps a real code block untouched', () => {
+    const input = '说明\n\n```ts\nconst a = 1\n```\n\n结尾'
+    expect(repairNestedMarkdownFences(input)).toBe(input)
+  })
+
+  it('promotes literal fences nested inside a markdown example', () => {
+    const input = '```md\n示例\n\n```\nnested\n```\n```'
+    expect(repairNestedMarkdownFences(input)).toBe('示例\n\n```\nnested\n```')
+  })
+
+  it('ignores content without fences', () => {
+    expect(repairNestedMarkdownFences('普通文本')).toBe('普通文本')
+  })
+})
+
+describe('repairMarkdownForRender', () => {
+  it('closes a dangling fence left open by truncation', () => {
+    const result = repairMarkdownForRender('前言\n\n```python\nprint(1)')
+    expect(result).toBe('前言\n\n```python\nprint(1)\n```')
+  })
+
+  it('keeps dangling fences open while streaming', () => {
+    const input = '```python\nprint(1)'
+    expect(repairMarkdownForRender(input, true)).toBe(input)
+  })
+
+  it('does not close an already balanced fence', () => {
+    const input = '```js\nconst a = 1\n```'
+    expect(repairMarkdownForRender(input)).toBe(input)
+  })
+
+  it('keeps a truncated outer md wrapper as a code block while closing it', () => {
+    // 流被截断时外层 md 围栏没有终止行,解包条件不成立;仅补闭合让它按代码块渲染。
+    const result = repairMarkdownForRender('```md\n# 标题\n\n```ts\nconst a = 1')
+    expect(result).toBe('```md\n# 标题\n\n```ts\nconst a = 1\n```')
+  })
+})

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

@@ -90,6 +90,14 @@ test('renders subtask completion as a collapsed timeline event', async ({ page }
   await expect(page.locator('.delegation-event')).not.toHaveAttribute('open', '')
 })
 
+test('opens a local message file link as a floating preview card', async ({ page }) => {
+  await page.goto('/chat/session-demo')
+  const card = page.getByRole('link', { name: '预览文件 方案草稿.md' })
+  await expect(card).toHaveClass(/file-link-card/)
+  await card.click()
+  await expect(page.getByRole('dialog', { name: '预览 方案草稿.md' })).toBeVisible()
+})
+
 test('keeps the canonical logo and yaoyao-webui composer geometry', async ({ page }) => {
   const logo = page.locator('.rail__brand img')
   await expect(logo).toHaveAttribute('src', '/brand/AppIcon-1024.png')

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

@@ -46,6 +46,7 @@ const messages = [
   { 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-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 },
 ]
 const groupAgent = { id: agentId, roomId, profile: 'yaoyao', displayName: '夭夭', description: '主 Agent', enabled: true, replyWithoutMention: true, status: 'idle', createdAt: now() - 2000, updatedAt: now() }