MarkdownContent.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. <script setup lang="ts">
  2. import DOMPurify from 'dompurify'
  3. import hljs from 'highlight.js'
  4. import MarkdownIt from 'markdown-it'
  5. import { computed, nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue'
  6. import AppIcon from '@/components/common/AppIcon.vue'
  7. import { copyTextToClipboard } from '@/utils/clipboard'
  8. import { normalizeAssistantMediaMarkdown } from '@/utils/mediaMarkdown'
  9. import { repairMarkdownForRender } from '@/utils/markdownRepair'
  10. import { serverFilePath, serverFileUrl } from '@shared/serverFiles'
  11. const props = withDefaults(defineProps<{
  12. content: string
  13. streaming?: boolean
  14. streamIntervalMs?: number
  15. legacyMedia?: boolean
  16. plain?: boolean
  17. mentionNames?: string[]
  18. fileCards?: boolean
  19. processContent?: boolean
  20. outlinePrefix?: string
  21. fileProfile?: string
  22. }>(), { streaming: false, streamIntervalMs: 80, legacyMedia: false, plain: false, fileCards: false, processContent: false, outlinePrefix: '' })
  23. const emit = defineEmits<{ fileLink: [name: string, url: string]; rendered: [] }>()
  24. const root = ref<HTMLElement | null>(null)
  25. const copied = ref('')
  26. const displayedContent = ref(props.content)
  27. let renderTimer: ReturnType<typeof setTimeout> | undefined
  28. function cancelRenderTimer() {
  29. if (renderTimer !== undefined) clearTimeout(renderTimer)
  30. renderTimer = undefined
  31. }
  32. // Throttle, not debounce: a continuous token stream must keep making progress.
  33. watch(() => [props.content, props.streaming, props.plain] as const, ([content, streaming, plain]) => {
  34. if (!streaming || plain || props.streamIntervalMs === 0 || !content.startsWith(displayedContent.value) || !displayedContent.value) {
  35. cancelRenderTimer()
  36. displayedContent.value = content
  37. } else if (renderTimer === undefined) {
  38. renderTimer = setTimeout(() => {
  39. renderTimer = undefined
  40. displayedContent.value = props.content
  41. }, props.streamIntervalMs)
  42. }
  43. })
  44. onBeforeUnmount(cancelRenderTimer)
  45. // Completed code blocks retain their HTML as the trailing block grows.
  46. const highlightedCode = new Map<string, string>()
  47. let highlightCost = 0
  48. function highlightCode(source: string, language: string): string {
  49. const key = `${language}\0${source}`
  50. const cached = highlightedCode.get(key)
  51. if (cached !== undefined) return cached
  52. const html = language && hljs.getLanguage(language)
  53. ? hljs.highlight(source, { language, ignoreIllegals: true }).value
  54. : escapeHtml(source)
  55. if (key.length + html.length <= 256_000) {
  56. highlightedCode.set(key, html)
  57. highlightCost += key.length + html.length
  58. while (highlightedCode.size > 32 || highlightCost > 512_000) {
  59. const oldest = highlightedCode.keys().next().value!
  60. highlightCost -= oldest.length + highlightedCode.get(oldest)!.length
  61. highlightedCode.delete(oldest)
  62. }
  63. }
  64. return html
  65. }
  66. function escapeHtml(value: string): string {
  67. return value
  68. .replace(/&/g, '&amp;')
  69. .replace(/</g, '&lt;')
  70. .replace(/>/g, '&gt;')
  71. .replace(/"/g, '&quot;')
  72. }
  73. const md = new MarkdownIt({
  74. html: false,
  75. breaks: true,
  76. linkify: true,
  77. typographer: false,
  78. highlight(str: string, lang: string): string {
  79. const language = lang.trim().split(/\s+/)[0]?.toLowerCase() || ''
  80. const highlighted = highlightCode(str, language)
  81. const label = language || 'text'
  82. 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>`
  83. },
  84. })
  85. // Dashboard 规范:保留原文引号,不做智能引号替换。
  86. md.disable('smartquotes')
  87. const defaultValidateLink = md.validateLink
  88. md.validateLink = value => Boolean(serverFileUrl(value)) || defaultValidateLink(value)
  89. const defaultImage = md.renderer.rules.image!
  90. md.renderer.rules.image = (tokens, index, options, env, self) => {
  91. if (props.processContent) return escapeHtml(tokens[index].content || tokens[index].attrGet('src') || '图片')
  92. const source = tokens[index].attrGet('src') || ''
  93. const file = serverFileUrl(source, props.fileProfile)
  94. if (file) tokens[index].attrSet('src', file)
  95. return defaultImage(tokens, index, options, env, self)
  96. }
  97. const defaultLinkOpen = md.renderer.rules.link_open ?? ((tokens, index, options, _env, self) => self.renderToken(tokens, index, options))
  98. md.renderer.rules.link_open = (tokens, index, options, env, self) => {
  99. const token = tokens[index]
  100. // Some assistants wrap real Hermes workspace paths in a sandbox: URI.
  101. // Resolve only this known file route before sanitizing the Markdown.
  102. const href = token.attrGet('href') || ''
  103. const file = !props.processContent && props.fileCards ? serverFileUrl(href, props.fileProfile) : undefined
  104. if (file && /\.[^/]+$/.test(serverFilePath(href) ?? '')) token.attrSet('href', file)
  105. token.attrSet('target', '_blank')
  106. token.attrSet('rel', 'noopener noreferrer')
  107. return defaultLinkOpen(tokens, index, options, env, self)
  108. }
  109. const markdownSource = computed(() => {
  110. let source = props.legacyMedia
  111. ? normalizeAssistantMediaMarkdown(displayedContent.value || '', props.streaming)
  112. : displayedContent.value || ''
  113. source = repairMarkdownForRender(source, props.streaming)
  114. return source
  115. })
  116. function highlightMentions(html: string): string {
  117. const names = props.mentionNames
  118. if (!names?.length) return html
  119. const escaped = [...new Set(names.filter(Boolean))]
  120. .sort((a, b) => b.length - a.length)
  121. .map(name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
  122. if (!escaped.length) return html
  123. const re = new RegExp(`(?<![\\w])@(?:${escaped.join('|')})(?=$|[\\s.,!?;:,。!?;:)\\]}>])`, 'gi')
  124. return html.replace(re, match => `<span class="mention-highlight">${match}</span>`)
  125. }
  126. // Reuse the identical policy for every block instead of rebuilding DOMPurify's
  127. // allowlists hundreds of times for a long answer. Every block is still sanitized.
  128. const sanitizeOptions = {
  129. USE_PROFILES: { html: true },
  130. // MarkdownIt generates the inert code-copy button above. User-provided HTML
  131. // remains disabled, and DOMPurify still strips event-handler attributes.
  132. FORBID_TAGS: ['style', 'svg', 'math', 'form', 'input', 'iframe', 'object', 'embed'],
  133. FORBID_ATTR: ['style', 'onerror', 'onclick', 'onload'],
  134. ALLOW_UNKNOWN_PROTOCOLS: false,
  135. ADD_ATTR: ['target'],
  136. }
  137. const sanitizer = DOMPurify(window)
  138. sanitizer.setConfig(sanitizeOptions)
  139. function sanitize(html: string): string {
  140. return sanitizer.sanitize(highlightMentions(html))
  141. }
  142. const renderedBlocks = computed(() => {
  143. if (props.plain) return []
  144. const env = {}
  145. const tokens = md.parse(markdownSource.value, env)
  146. const blocks: string[] = []
  147. let start = 0
  148. let depth = 0
  149. for (let index = 0; index < tokens.length; index++) {
  150. depth += tokens[index].nesting
  151. if (depth !== 0) continue
  152. blocks.push(sanitize(md.renderer.render(tokens.slice(start, index + 1), md.options, env)))
  153. start = index + 1
  154. }
  155. return blocks
  156. })
  157. watch(renderedBlocks, async () => {
  158. await nextTick()
  159. emit('rendered')
  160. }, { flush: 'post' })
  161. function decorateCopyButtons() {
  162. if (!root.value) return
  163. root.value.querySelectorAll<HTMLButtonElement>('pre .code-copy').forEach((button, index) => {
  164. if (button.dataset.bound) return
  165. button.dataset.bound = 'true'
  166. button.addEventListener('click', async () => {
  167. const code = button.closest('pre')?.querySelector('code')?.textContent ?? ''
  168. const key = `${index}:${code.length}`
  169. copied.value = key
  170. const copiedSuccessfully = await copyTextToClipboard(code)
  171. button.textContent = copiedSuccessfully ? '已复制' : '复制失败'
  172. button.setAttribute('aria-label', copiedSuccessfully ? '代码已复制' : '复制代码失败')
  173. window.setTimeout(() => {
  174. if (copied.value !== key || !button.isConnected) return
  175. copied.value = ''
  176. button.textContent = '复制'
  177. button.setAttribute('aria-label', '复制代码')
  178. }, 1400)
  179. })
  180. })
  181. }
  182. function decorateFileLinks() {
  183. if (props.processContent || !props.fileCards || !root.value) return
  184. root.value.querySelectorAll<HTMLAnchorElement>('a').forEach(link => {
  185. if (link.dataset.fileCard) return
  186. let url: URL
  187. try { url = new URL(link.href, window.location.href) } catch { return }
  188. const isAgentFile = /^\/Users\/[^/]+\/Agents\/.+/.test(url.pathname)
  189. const isWorkspaceFile = /^\/Users\/[^/]+\/\.hermes\/(?:profiles\/[^/]+\/)?workspace\/.+/.test(url.pathname)
  190. const isRemoteNodeFile = /^\/api\/app\/files\/(?:[0-9a-f-]{36}|[0-9]+)\/(?:download|preview)$/i.test(url.pathname)
  191. const isServerFile = url.pathname === '/api/files/download' && Boolean(url.searchParams.get('path'))
  192. if (url.origin !== window.location.origin || (!isAgentFile && !isWorkspaceFile && !isRemoteNodeFile && !isServerFile)) return
  193. link.dataset.fileCard = 'true'
  194. link.classList.add('file-link-card')
  195. link.removeAttribute('target')
  196. link.removeAttribute('rel')
  197. const name = link.textContent?.trim() || decodeURIComponent(url.pathname.split('/').at(-1) || '文件')
  198. link.setAttribute('aria-label', `预览文件 ${name}`)
  199. link.addEventListener('click', event => {
  200. event.preventDefault()
  201. emit('fileLink', name, `${url.pathname}${url.search}`)
  202. })
  203. })
  204. }
  205. function decorateMediaPreviews() {
  206. if (props.processContent || !props.fileCards || !root.value) return
  207. root.value.querySelectorAll<HTMLImageElement>('img').forEach(image => {
  208. if (image.dataset.mediaPreview) return
  209. let url: URL
  210. try { url = new URL(image.currentSrc || image.src, window.location.href) } catch { return }
  211. if (url.origin !== window.location.origin) return
  212. image.dataset.mediaPreview = 'true'
  213. const name = (url.searchParams.get('path') || decodeURIComponent(url.pathname)).split('/').at(-1) || '图片'
  214. image.setAttribute('role', 'button')
  215. image.setAttribute('tabindex', '0')
  216. image.setAttribute('aria-label', `预览图片 ${name}`)
  217. const preview = (event: Event) => {
  218. event.preventDefault()
  219. event.stopPropagation()
  220. emit('fileLink', name, `${url.pathname}${url.search}`)
  221. }
  222. image.addEventListener('click', preview)
  223. image.addEventListener('keydown', event => {
  224. if ((event as KeyboardEvent).key === 'Enter' || (event as KeyboardEvent).key === ' ') preview(event)
  225. })
  226. let failure: HTMLElement | undefined
  227. const failed = async () => {
  228. if (!image.isConnected || failure) return
  229. failure = document.createElement('span')
  230. failure.className = 'media-load-error'
  231. failure.setAttribute('role', 'alert')
  232. const label = document.createElement('span')
  233. label.textContent = `${name}:图片加载失败。`
  234. const retry = document.createElement('button')
  235. retry.type = 'button'; retry.textContent = '重试'
  236. retry.addEventListener('click', () => {
  237. failure?.remove(); failure = undefined
  238. image.hidden = false
  239. const next = new URL(url); next.searchParams.set('_retry', String(Date.now())); image.src = next.toString()
  240. })
  241. failure.append(label, retry); image.after(failure); image.hidden = true
  242. if (url.pathname !== '/api/files/download') return
  243. try {
  244. const response = await fetch(url, { credentials: 'same-origin' })
  245. if (!response.ok) {
  246. const value = await response.json().catch(() => ({}))
  247. const reason = value.error?.message || (typeof value.error === 'string' ? value.error : undefined) || value.message || value.detail
  248. label.textContent = `${name}:${typeof reason === 'string' ? reason : response.status === 404 ? '服务器文件不存在或已被移动。' : response.status === 403 ? '此目录未获授权,请检查设置中的文件访问权限。' : '服务器暂时无法读取此文件。'}`
  249. } else { await response.body?.cancel(); label.textContent = `${name}:文件内容不是可解码的图片。` }
  250. } catch { label.textContent = `${name}:连接失败,请重试。` }
  251. }
  252. image.addEventListener('error', () => { void failed() })
  253. image.addEventListener('load', () => { image.hidden = false; failure?.remove(); failure = undefined })
  254. if (image.complete && image.naturalWidth === 0) void failed()
  255. })
  256. }
  257. function decorateOutlineHeadings() {
  258. if (!props.outlinePrefix || !root.value) return
  259. root.value.querySelectorAll<HTMLElement>('h1, h2, h3').forEach((heading, index) => {
  260. heading.id = `${props.outlinePrefix}-heading-${index + 1}`
  261. })
  262. }
  263. function onClick(event: MouseEvent) {
  264. const link = (event.target as HTMLElement).closest('a')
  265. if (!link) return
  266. const href = link.getAttribute('href') || ''
  267. if (/^(javascript|data|vbscript):/i.test(href)) event.preventDefault()
  268. }
  269. onMounted(() => { decorateCopyButtons(); decorateFileLinks(); decorateMediaPreviews(); decorateOutlineHeadings() })
  270. onUpdated(() => { decorateCopyButtons(); decorateFileLinks(); decorateMediaPreviews(); decorateOutlineHeadings() })
  271. </script>
  272. <template>
  273. <div v-if="plain" class="plain-text">{{ content }}</div>
  274. <div v-else ref="root" class="markdown" :class="{ 'markdown--streaming': streaming }" @click="onClick">
  275. <div v-for="(html, index) in renderedBlocks" :key="index" class="markdown-block" v-html="html" />
  276. </div>
  277. <AppIcon v-if="streaming" class="stream-caret" name="arrow-up" :size="0" />
  278. </template>
  279. <style scoped>
  280. .markdown :deep(img[hidden]){display:none!important}
  281. .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}
  282. .plain-text { min-width: 0; color: inherit; font-size: 13px; line-height: 1.68; white-space: pre-wrap; overflow-wrap: anywhere; }
  283. .markdown-block { display: flow-root; }
  284. .markdown { min-width: 0; color: inherit; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; }
  285. .markdown :deep(p) { margin: 0 0 10px; }.markdown-block:last-child :deep(> p:last-child) { margin-bottom: 0; }
  286. .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; }
  287. .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; }
  288. .markdown :deep(ul), .markdown :deep(ol) { margin: .45em 0 10px; padding-left: 1.55em; }.markdown :deep(li) { margin: .32em 0; }
  289. .markdown :deep(strong) { color: var(--text-primary); font-weight: 660; }
  290. .markdown :deep(blockquote) { margin: 10px 0; padding: .15em 0 .15em 14px; border-left: 3px solid var(--line-strong); color: var(--text-secondary); }
  291. .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; }
  292. .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; }
  293. .markdown :deep(.code-block) { position: relative; margin: 12px 0; border: 1px solid var(--line); border-radius: 11px; background: var(--surface-soft); overflow: hidden; }
  294. .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); }
  295. .markdown :deep(.code-lang) { color: var(--text-muted); font: 600 9px var(--font-ui); letter-spacing: .06em; text-transform: uppercase; }
  296. .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); }
  297. .markdown :deep(.code-copy:hover) { color: var(--text-primary); }
  298. .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; }
  299. .markdown :deep(table) { display: block; width: max-content; max-width: 100%; margin: 10px 0; overflow-x: auto; border-collapse: collapse; }
  300. .markdown :deep(th), .markdown :deep(td) { padding: 6px 10px; border: 1px solid var(--line); text-align: left; }
  301. .markdown :deep(th) { background: var(--surface-soft); color: var(--text-primary); font-weight: 640; }.markdown :deep(td) { color: var(--text-secondary); }
  302. .markdown :deep(hr) { margin: 16px 0; border: 0; border-top: 1px solid var(--line); }
  303. .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(img[data-media-preview]) { cursor: zoom-in; }
  304. .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; }
  305. .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; }
  306. .markdown--streaming > .markdown-block: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; }
  307. .stream-caret { display: none; }
  308. @keyframes caret { 50% { opacity: 0; } }
  309. @media (max-width: 600px) { .markdown :deep(img) { max-height: 280px; } }
  310. </style>