feat: improve chat retrieval, message versions and Markdown rendering
This commit is contained in:
@@ -125,9 +125,19 @@ export async function getCodeTokenizer(theme: 'github-light' | 'github-dark', re
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderMarkdown(source: string, options?: { theme?: 'light' | 'dark'; preferences?: MarkdownPreferences }): Promise<string> {
|
||||
export async function renderMarkdown(source: string, options?: { theme?: 'light' | 'dark'; preferences?: MarkdownPreferences; citationNumbers?: number[]; citationAliases?: Record<string, number> }): Promise<string> {
|
||||
const preferences = options?.preferences ?? defaultMarkdownPreferences
|
||||
const marked = createMarkdownParser(preferences)
|
||||
const citations = new Set(options?.citationNumbers ?? [])
|
||||
if (citations.size) marked.use({ extensions: [{ name: 'citation', level: 'inline',
|
||||
start: text => text.indexOf('['),
|
||||
tokenizer(text) {
|
||||
const match = /^\[([1-9]\d*|cit_[A-Za-z0-9_-]+)\](?!\()/.exec(text)
|
||||
const number = match ? options?.citationAliases?.[match[1]!] ?? Number(match[1]) : 0
|
||||
if (match && citations.has(number)) return { type: 'citation', raw: match[0], number }
|
||||
},
|
||||
renderer: token => `<button type="button" class="inline-citation" data-citation-number="${token.number}" aria-label="查看来源 ${token.number}">[${token.number}]</button>`,
|
||||
}] })
|
||||
const html = marked.parse(source, { async: false }) as string
|
||||
const documentNode = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
|
||||
|
||||
@@ -145,7 +155,17 @@ export async function renderMarkdown(source: string, options?: { theme?: 'light'
|
||||
}
|
||||
const highlighted = await highlightCode(code.textContent ?? '', requestedLanguage)
|
||||
const fragment = document.createRange().createContextualFragment(highlighted)
|
||||
code.parentElement?.replaceWith(fragment)
|
||||
// Shiki separates line spans with newlines. Block layout must not render those
|
||||
// separators as additional blank rows; the untouched source remains available for copy.
|
||||
for (const node of [...(fragment.querySelector('code')?.childNodes ?? [])]) {
|
||||
if (node.nodeType === Node.TEXT_NODE && !node.textContent?.trim()) node.remove()
|
||||
}
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.className = 'markdown-code-block'
|
||||
wrapper.dataset.languageLabel = requestedLanguage
|
||||
appendCodeToolbar(wrapper, requestedLanguage, code.textContent ?? '')
|
||||
wrapper.append(fragment)
|
||||
code.parentElement?.replaceWith(wrapper)
|
||||
}
|
||||
|
||||
for (const { pre, source } of mermaidBlocks) {
|
||||
@@ -154,6 +174,7 @@ export async function renderMarkdown(source: string, options?: { theme?: 'light'
|
||||
const container = document.createElement('div')
|
||||
container.className = 'markdown-mermaid'
|
||||
container.innerHTML = result.svg
|
||||
appendCodeToolbar(container, 'mermaid', source, true)
|
||||
if (!result.warnings.length) appendDiagramControls(container)
|
||||
pre.replaceWith(container)
|
||||
} catch {
|
||||
@@ -166,10 +187,11 @@ export async function renderMarkdown(source: string, options?: { theme?: 'light'
|
||||
|
||||
return DOMPurify.sanitize(documentNode.body.innerHTML, {
|
||||
USE_PROFILES: { html: true },
|
||||
HTML_INTEGRATION_POINTS: { foreignobject: true },
|
||||
ADD_TAGS: ['svg', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon',
|
||||
'text', 'tspan', 'textPath', 'g', 'defs', 'marker', 'style', 'clipPath', 'foreignObject',
|
||||
'title', 'desc', 'use', 'image', 'linearGradient', 'stop', 'radialGradient'],
|
||||
ADD_ATTR: ['viewBox', 'd', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'width', 'height',
|
||||
ADD_ATTR: ['xmlns', 'viewBox', 'd', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'width', 'height',
|
||||
'fill', 'stroke', 'stroke-width', 'stroke-dasharray', 'stroke-linecap', 'stroke-linejoin',
|
||||
'transform', 'points', 'x1', 'y1', 'x2', 'y2', 'class', 'id', 'style', 'text-anchor',
|
||||
'dominant-baseline', 'font-size', 'font-family', 'font-weight', 'opacity', 'orient',
|
||||
@@ -179,4 +201,24 @@ export async function renderMarkdown(source: string, options?: { theme?: 'light'
|
||||
})
|
||||
}
|
||||
|
||||
function appendCodeToolbar(container: HTMLElement, language: string, source: string, diagram = false) {
|
||||
const header = document.createElement('div')
|
||||
header.className = 'markdown-code-toolbar tools'
|
||||
const label = document.createElement('span'); label.textContent = language
|
||||
header.append(label)
|
||||
for (const action of diagram ? ['source', 'copy'] : ['copy']) {
|
||||
const button = document.createElement('button')
|
||||
button.type = 'button'; button.className = 'button-secondary'
|
||||
button.dataset.codeAction = action
|
||||
button.textContent = action === 'source' ? '查看源码' : '复制'
|
||||
button.setAttribute('aria-label', action === 'source' ? '查看源码' : '复制源码')
|
||||
if (action === 'source') button.setAttribute('aria-pressed', 'false')
|
||||
header.append(button)
|
||||
}
|
||||
const raw = document.createElement('pre')
|
||||
raw.className = 'markdown-code-source'; raw.hidden = true; raw.textContent = source
|
||||
container.prepend(header)
|
||||
container.append(raw)
|
||||
}
|
||||
|
||||
// 高亮器首次需要代码高亮时才创建;语法保持按语言加载。Worker 可在性能测量后进一步引入。
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// @vitest-environment jsdom
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import DiagramInteractions from '@/components/common/DiagramInteractions.vue'
|
||||
import { renderMarkdown } from './markdown'
|
||||
vi.mock('@/services/mermaidService', () => ({ renderMermaid: vi.fn(async () => ({ warnings: [], svg: '<svg viewBox="0 0 400 200"><foreignObject width="100" height="30"><div xmlns="http://www.w3.org/1999/xhtml"><span>系统验证</span><img src="x" onerror="alert(1)"></div></foreignObject></svg>' })) }))
|
||||
it('preserves diagram labels, switches preview/source, and copies original Mermaid', async () => {
|
||||
const source = 'graph TD; A-->B'
|
||||
const html = await renderMarkdown('```mermaid\n' + source + '\n```')
|
||||
const wrapper = mount(DiagramInteractions, { slots: { default: '<div></div>' }, attachTo: document.body })
|
||||
// Preserve SVG foreignObject namespace while injecting sanitized rendered HTML.
|
||||
wrapper.element.firstElementChild!.innerHTML = html
|
||||
expect(wrapper.text()).toContain('系统验证')
|
||||
expect(wrapper.find('[onerror]').exists()).toBe(false)
|
||||
const raw = wrapper.get('.markdown-code-source').element as HTMLElement
|
||||
const svg = wrapper.get('.markdown-mermaid > svg').element as SVGSVGElement
|
||||
expect(raw.hidden).toBe(true)
|
||||
await wrapper.get('[data-code-action="source"]').trigger('click')
|
||||
expect(raw.hidden).toBe(false)
|
||||
expect(svg.style.display).toBe('none')
|
||||
await wrapper.get('[data-code-action="source"]').trigger('click')
|
||||
expect(raw.hidden).toBe(true)
|
||||
expect(svg.style.display).toBe('')
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
await wrapper.get('[data-code-action="copy"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(writeText).toHaveBeenCalledWith(source + '\n')
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -25,3 +25,31 @@ it('renders inline, display and editor LaTeX fences while leaving code literals
|
||||
expect(root.querySelector('code')?.textContent).toBe('$literal$')
|
||||
expect(root.querySelector('pre code')?.textContent).toContain('$literal$')
|
||||
})
|
||||
|
||||
it('renders numeric and legacy citations as numbered buttons without altering code', async () => {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = await renderMarkdown('正文 [1][2] [cit_blk_a] `[1]` [3] [1](https://example.com)', { citationNumbers: [1, 2], citationAliases: { cit_blk_a: 2 } })
|
||||
expect([...root.querySelectorAll('.inline-citation')].map(c => c.textContent)).toEqual(['[1]', '[2]', '[2]'])
|
||||
expect(root.querySelector('code')?.textContent).toBe('[1]')
|
||||
expect(root.querySelector('a')?.getAttribute('href')).toBe('https://example.com')
|
||||
})
|
||||
|
||||
it('shows code language and preserves exact source for copying', async () => {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = await renderMarkdown('```python\nprint("hello")\n```')
|
||||
expect(root.querySelector('.markdown-code-toolbar')?.textContent).toContain('python')
|
||||
expect(root.querySelector('[data-code-action="copy"]')).not.toBeNull()
|
||||
expect(root.querySelector('.markdown-code-source')?.textContent).toBe('print("hello")\n')
|
||||
})
|
||||
|
||||
it('keeps code toolbar inside the themed frame and avoids extra rendered newline rows', async () => {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = await renderMarkdown('```markdown\n# First\n\n## Second\n```')
|
||||
const frame = root.querySelector('.markdown-code-block')!
|
||||
expect(frame.getAttribute('data-language-label')).toBe('markdown')
|
||||
expect(frame.querySelector(':scope > .markdown-code-toolbar')).not.toBeNull()
|
||||
const code = frame.querySelector('.shiki code')!
|
||||
expect([...code.childNodes].filter(n => n.nodeType === Node.TEXT_NODE && n.textContent?.includes('\n'))).toHaveLength(0)
|
||||
expect(code.querySelectorAll('.line')).toHaveLength(4)
|
||||
expect(frame.querySelector('.markdown-code-source')?.textContent).toBe('# First\n\n## Second\n')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { usedCitations } from './usedCitations'
|
||||
const candidates = Array.from({ length: 6 }, (_, i) => ({ note_id: 'note', block_id: `${i}`, file_path: 'note.md', heading_path: '', content: 'source' }))
|
||||
|
||||
it('reveals completed references in first-use order without renumbering or duplicates', () => {
|
||||
expect(usedCitations('', candidates)).toEqual([])
|
||||
expect(usedCitations('结论 [3', candidates)).toEqual([])
|
||||
expect(usedCitations('结论 [3] 然后 [2] [3] [99]', candidates).map(item => item.number)).toEqual([3, 2])
|
||||
expect(usedCitations('结论 [3]', [])).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores code examples, escaped markers and links', () => {
|
||||
const content = '`[1]`\n\n```txt\n[2]\n```\n\n\\[3] [4](https://example.com) \n\n正文 **[6]**'
|
||||
expect(usedCitations(content, candidates).map(item => item.number)).toEqual([6])
|
||||
})
|
||||
|
||||
it('restores legacy ID citations with their original numeric card labels', () => {
|
||||
const sources = candidates.map((c, i) => ({ ...c, citation_id: `cit_blk_${i}` }))
|
||||
expect(usedCitations('正文 [cit_blk_2][1][cit_blk_2] `[cit_blk_4]` [cit_blk_unknown]', sources).map(c => c.number)).toEqual([3, 1])
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Marked } from 'marked'
|
||||
import type { Citation } from '@/contracts'
|
||||
|
||||
const parser = new Marked()
|
||||
|
||||
/** Candidate order is the source number sent to the model; never renumber a subset. */
|
||||
export function usedCitations(content: string, candidates: Citation[] = []) {
|
||||
const numbers = new Set<number>()
|
||||
const aliases = new Map(candidates.map((citation, index) => [citation.citation_id, index + 1]))
|
||||
parser.walkTokens(parser.lexer(content), token => {
|
||||
// Ignore code, escaped brackets, HTML and link destinations.
|
||||
if (token.type !== 'text' || ('tokens' in token && token.tokens?.length)) return
|
||||
for (const match of token.text.matchAll(/\[([1-9]\d*|cit_[A-Za-z0-9_-]+)\]/g)) {
|
||||
const number = aliases.get(match[1]) ?? Number(match[1])
|
||||
if (number > 0 && number <= candidates.length) numbers.add(number)
|
||||
}
|
||||
})
|
||||
return [...numbers].map(number => ({ number, citation: candidates[number - 1]! }))
|
||||
}
|
||||
Reference in New Issue
Block a user