|
@@ -1,26 +1,52 @@
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
import DOMPurify from 'dompurify'
|
|
import DOMPurify from 'dompurify'
|
|
|
|
|
+import hljs from 'highlight.js'
|
|
|
import MarkdownIt from 'markdown-it'
|
|
import MarkdownIt from 'markdown-it'
|
|
|
import { computed, onMounted, onUpdated, ref } from 'vue'
|
|
import { computed, onMounted, onUpdated, ref } from 'vue'
|
|
|
import AppIcon from '@/components/common/AppIcon.vue'
|
|
import AppIcon from '@/components/common/AppIcon.vue'
|
|
|
import { normalizeAssistantMediaMarkdown } from '@/utils/mediaMarkdown'
|
|
import { normalizeAssistantMediaMarkdown } from '@/utils/mediaMarkdown'
|
|
|
|
|
+import { repairMarkdownForRender } from '@/utils/markdownRepair'
|
|
|
|
|
|
|
|
const props = withDefaults(defineProps<{
|
|
const props = withDefaults(defineProps<{
|
|
|
content: string
|
|
content: string
|
|
|
streaming?: boolean
|
|
streaming?: boolean
|
|
|
legacyMedia?: 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 root = ref<HTMLElement | null>(null)
|
|
|
const copied = ref('')
|
|
const copied = ref('')
|
|
|
|
|
|
|
|
|
|
+function escapeHtml(value: string): string {
|
|
|
|
|
+ return value
|
|
|
|
|
+ .replace(/&/g, '&')
|
|
|
|
|
+ .replace(/</g, '<')
|
|
|
|
|
+ .replace(/>/g, '>')
|
|
|
|
|
+ .replace(/"/g, '"')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
const md = new MarkdownIt({
|
|
const md = new MarkdownIt({
|
|
|
html: false,
|
|
html: false,
|
|
|
breaks: true,
|
|
breaks: true,
|
|
|
linkify: true,
|
|
linkify: true,
|
|
|
typographer: false,
|
|
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))
|
|
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) => {
|
|
md.renderer.rules.link_open = (tokens, index, options, env, self) => {
|
|
|
const token = tokens[index]
|
|
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)
|
|
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
|
|
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 () => {
|
|
button.addEventListener('click', async () => {
|
|
|
- const code = pre.querySelector('code')?.textContent ?? ''
|
|
|
|
|
|
|
+ const code = button.closest('pre')?.querySelector('code')?.textContent ?? ''
|
|
|
await navigator.clipboard.writeText(code)
|
|
await navigator.clipboard.writeText(code)
|
|
|
const key = `${index}:${code.length}`
|
|
const key = `${index}:${code.length}`
|
|
|
copied.value = key
|
|
copied.value = key
|
|
|
button.textContent = '已复制'
|
|
button.textContent = '已复制'
|
|
|
window.setTimeout(() => { if (copied.value === key) { copied.value = ''; button.textContent = '复制' } }, 1400)
|
|
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()
|
|
if (/^(javascript|data|vbscript):/i.test(href)) event.preventDefault()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-onMounted(decorateCodeBlocks)
|
|
|
|
|
-onUpdated(decorateCodeBlocks)
|
|
|
|
|
|
|
+onMounted(() => { decorateCopyButtons(); decorateFileLinks() })
|
|
|
|
|
+onUpdated(() => { decorateCopyButtons(); decorateFileLinks() })
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<template>
|
|
<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" />
|
|
<AppIcon v-if="streaming" class="stream-caret" name="arrow-up" :size="0" />
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<style scoped>
|
|
<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(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(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; }
|
|
.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; }
|
|
.stream-caret { display: none; }
|
|
|
@keyframes caret { 50% { opacity: 0; } }
|
|
@keyframes caret { 50% { opacity: 0; } }
|