feat: improve chat retrieval, message versions and Markdown rendering

This commit is contained in:
2026-09-06 21:39:40 +08:00
parent 174e723545
commit 637ddbb9bf
34 changed files with 1072 additions and 62 deletions
+45 -3
View File
@@ -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')
})
+20
View File
@@ -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) ![5](image.png)\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])
})
+19
View File
@@ -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]! }))
}