feat: improve chat retrieval, message versions and Markdown rendering
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
theme_id: paper-moments
|
||||
name: 纸间时光 · Paper Moments
|
||||
version: 1.9.0
|
||||
version: 1.9.2
|
||||
author: NotesAgent
|
||||
description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。
|
||||
min_app_version: 0.2.0
|
||||
@@ -201,14 +201,16 @@ license: MIT
|
||||
--color-code-muted: #bdb19f;
|
||||
--color-code-border: #786b59;
|
||||
}
|
||||
[data-theme="paper-moments"] .milkdown-host .milkdown-code-block {
|
||||
[data-theme="paper-moments"] .milkdown-host .milkdown-code-block,
|
||||
[data-theme="paper-moments"] .markdown-content .markdown-code-block {
|
||||
position: relative;
|
||||
padding-top: 34px;
|
||||
padding-bottom: 30px;
|
||||
border-color: var(--color-code-border);
|
||||
box-shadow: 3px 4px 0 #d8cebd;
|
||||
}
|
||||
[data-theme="paper-moments"] .milkdown-code-block::before {
|
||||
[data-theme="paper-moments"] .milkdown-code-block::before,
|
||||
[data-theme="paper-moments"] .markdown-content .markdown-code-block::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
@@ -220,7 +222,8 @@ license: MIT
|
||||
box-shadow: 18px 0 0 #c9a65d, 36px 0 0 #819b75;
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-theme="paper-moments"] .milkdown-code-block::after {
|
||||
[data-theme="paper-moments"] .milkdown-code-block::after,
|
||||
[data-theme="paper-moments"] .markdown-content .markdown-code-block::after {
|
||||
content: attr(data-language-label);
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
@@ -233,6 +236,7 @@ license: MIT
|
||||
font: 600 12px/1.4 var(--font-ui-mono);
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-theme="paper-moments"] .markdown-code-block .tools,
|
||||
[data-theme="paper-moments"] .milkdown-code-block .tools { margin-left: 72px; }
|
||||
[data-theme="paper-moments"] .milkdown-code-block .cm-activeLine,
|
||||
[data-theme="paper-moments"] .milkdown-code-block .cm-activeLineGutter { background: color-mix(in srgb, var(--color-code-text) 7%, transparent); }
|
||||
|
||||
@@ -103,6 +103,26 @@ function widthOf(svg: SVGSVGElement) {
|
||||
}
|
||||
async function interact(event: MouseEvent) {
|
||||
if (!(event.target instanceof Element)) return
|
||||
const codeButton = event.target.closest<HTMLButtonElement>('[data-code-action]')
|
||||
if (codeButton) {
|
||||
const block = codeButton.closest<HTMLElement>('.markdown-code-block, .markdown-mermaid')
|
||||
const source = block?.querySelector<HTMLElement>('.markdown-code-source')
|
||||
if (!block || !source) return
|
||||
event.preventDefault(); event.stopPropagation()
|
||||
if (codeButton.dataset.codeAction === 'copy') {
|
||||
try { await navigator.clipboard.writeText(source.textContent ?? ''); codeButton.textContent = '已复制' }
|
||||
catch { codeButton.textContent = '复制失败,请选择源码复制' }
|
||||
} else {
|
||||
disarm()
|
||||
source.hidden = !source.hidden
|
||||
const svg = block.querySelector<SVGSVGElement>(':scope > svg')
|
||||
if (svg) svg.style.display = source.hidden ? '' : 'none'
|
||||
block.dataset.sourceView = String(!source.hidden)
|
||||
codeButton.setAttribute('aria-pressed', String(!source.hidden))
|
||||
codeButton.textContent = source.hidden ? '查看源码' : '查看预览'
|
||||
}
|
||||
return
|
||||
}
|
||||
const button = event.target.closest<HTMLElement>('[data-diagram-action]')
|
||||
const diagram = button?.closest<HTMLElement>('.editor-mermaid-preview, .markdown-mermaid')
|
||||
const svg = diagram?.querySelector<SVGSVGElement>('svg')
|
||||
@@ -167,6 +187,12 @@ function close() { disarm(); viewer.value?.close(); svgHtml.value = ''; opener?.
|
||||
|
||||
<style>
|
||||
.diagram-interactions { min-width: 0; }
|
||||
.markdown-code-toolbar { display: flex; align-items: center; gap: var(--space-sm); padding: var(--space-sm); color: var(--color-code-muted); font: 12px/1.4 var(--font-editor-mono); }
|
||||
.markdown-code-toolbar > span { margin-right: auto; }
|
||||
.markdown-code-toolbar button { font: inherit; }
|
||||
.markdown-code-source { text-align: left; white-space: pre; overflow: auto; padding: var(--space-md); background: var(--color-code-background); color: var(--color-code-text); font-family: var(--font-editor-mono); }
|
||||
.markdown-code-source[hidden] { display: none !important; }
|
||||
.markdown-mermaid[data-source-view='true'] > .diagram-controls { display: none; }
|
||||
.diagram-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
|
||||
.diagram-controls button { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); color: var(--color-text-primary); background: var(--color-surface-primary); cursor: pointer; font: inherit; font-size: 12px; }
|
||||
.diagram-controls button:hover { border-color: var(--color-accent-primary); }
|
||||
|
||||
@@ -8,7 +8,13 @@ const headingAppearance = useHeadingAppearanceStore()
|
||||
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
|
||||
const markdownPreferences = useMarkdownPreferencesStore()
|
||||
|
||||
const props = defineProps<{ source: string }>()
|
||||
const props = defineProps<{ source: string; citationNumbers?: number[]; citationAliases?: Record<string, number> }>()
|
||||
const emit = defineEmits<{ citation: [number: number] }>()
|
||||
function citationClick(event: MouseEvent) {
|
||||
if (!(event.target instanceof Element)) return
|
||||
const number = Number(event.target.closest('[data-citation-number]')?.getAttribute('data-citation-number'))
|
||||
if (props.citationNumbers?.includes(number)) { event.preventDefault(); emit('citation', number) }
|
||||
}
|
||||
const themeStore = useThemeStore()
|
||||
const html = ref('')
|
||||
let renderVersion = 0
|
||||
@@ -16,19 +22,21 @@ let renderVersion = 0
|
||||
const diagramTheme = computed<'light' | 'dark'>(() => (themeStore.isDark ? 'dark' : 'light'))
|
||||
|
||||
// 主题切换需要重渲染:Mermaid SVG 的配色在渲染时烘焙,无法靠 CSS 变量事后调整。
|
||||
watch([() => props.source, diagramTheme, () => themeStore.currentThemeId, () => JSON.stringify(markdownPreferences.normalized)], async ([source, theme]) => {
|
||||
watch([() => props.source, diagramTheme, () => themeStore.currentThemeId, () => JSON.stringify(markdownPreferences.normalized), () => JSON.stringify([props.citationNumbers, props.citationAliases])], async ([source, theme]) => {
|
||||
const version = ++renderVersion
|
||||
const result = await renderMarkdown(source, { theme, preferences: markdownPreferences.normalized })
|
||||
const result = await renderMarkdown(source, { theme, preferences: markdownPreferences.normalized, citationNumbers: props.citationNumbers, citationAliases: props.citationAliases })
|
||||
if (version === renderVersion) html.value = result
|
||||
}, { immediate: true, flush: 'post' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DiagramInteractions :data-heading-style="headingAppearance.preferences.custom ? 'custom' : undefined" :style="headingAppearance.cssVariables"><div class="markdown-content" :data-code-wrap="markdownPreferences.normalized.wrapCode" :data-line-numbers="markdownPreferences.normalized.lineNumbers" :style="{ '--markdown-code-indent': markdownPreferences.normalized.indent }" v-html="html" /></DiagramInteractions>
|
||||
<DiagramInteractions :data-heading-style="headingAppearance.preferences.custom ? 'custom' : undefined" :style="headingAppearance.cssVariables"><div class="markdown-content" @click="citationClick" :data-code-wrap="markdownPreferences.normalized.wrapCode" :data-line-numbers="markdownPreferences.normalized.lineNumbers" :style="{ '--markdown-code-indent': markdownPreferences.normalized.indent }" v-html="html" /></DiagramInteractions>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.markdown-content { white-space: normal; user-select: text; }
|
||||
.inline-citation { display: inline; padding: 0 .15em; border: 0; background: var(--color-accent-soft); color: var(--color-text-link); border-radius: var(--radius-sm); cursor: pointer; font: inherit; }
|
||||
.inline-citation:focus-visible { outline: 2px solid var(--color-border-focus); }
|
||||
.markdown-content p, .markdown-content ul, .markdown-content ol, .markdown-content pre, .markdown-content blockquote { margin: .65em 0; }
|
||||
.markdown-content h1, .markdown-content h2, .markdown-content h3 { margin: 1em 0 .5em; line-height: var(--line-height-tight); }
|
||||
.markdown-content ul { padding-left: 1.5em; list-style: disc; }
|
||||
@@ -36,6 +44,7 @@ watch([() => props.source, diagramTheme, () => themeStore.currentThemeId, () =>
|
||||
.markdown-content li::marker { color: var(--color-markdown-marker); font-weight: 700; }
|
||||
.markdown-content .shiki { overflow: auto; margin: .85em 0; padding: 16px; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background) !important; color: var(--color-code-text); font-family: var(--font-ui-mono); font-size: .875em; line-height: 1.45; tab-size: 4; }
|
||||
.markdown-content code { padding: .1em .3em; border-radius: var(--radius-sm); background: var(--color-background-tertiary); font-family: var(--font-ui-mono); }
|
||||
.markdown-content .shiki { font-family: var(--font-editor-mono); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); }
|
||||
.markdown-content :not(pre) > code { background: var(--color-code-background); color: var(--color-code-text); border: 1px solid var(--color-code-border); }
|
||||
.markdown-content div.markdown-math { overflow-x: auto; padding-block: .5em; }
|
||||
.markdown-content h4, .markdown-content h5, .markdown-content h6 { margin: 1em 0 .5em; font-weight: 600; }
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface Conversation {
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
activity?: Array<{ type: 'thinking'; text: string } | { type: 'tool'; tool_call_id: string }>
|
||||
versions?: string[]
|
||||
message_id: string
|
||||
conversation_id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
@@ -81,6 +83,7 @@ export interface ChatMessage {
|
||||
}
|
||||
|
||||
export interface Citation {
|
||||
citation_id?: string
|
||||
note_id: string
|
||||
block_id: string
|
||||
file_path: string
|
||||
|
||||
@@ -43,6 +43,9 @@ const eventLabelsEn: Record<AgentEventType, string> = {
|
||||
}
|
||||
|
||||
const toolLabels: Record<string, string> = {
|
||||
'markdown.catalog': 'Markdown 格式目录',
|
||||
'markdown.compose': '生成 Markdown 片段',
|
||||
'notes.patch_markdown': '局部修改 Markdown',
|
||||
'system.echo': '回显测试',
|
||||
'math.add': '数值相加',
|
||||
'notes.search': '搜索笔记',
|
||||
@@ -61,6 +64,9 @@ const toolLabels: Record<string, string> = {
|
||||
}
|
||||
|
||||
const toolDescriptions: Record<string, string> = {
|
||||
'markdown.catalog': '查询支持的 Markdown 格式、警告框类型及渲染限制。',
|
||||
'markdown.compose': '生成标题、列表、表格、警告框、公式、Mermaid 和元数据等片段,不直接写入笔记。',
|
||||
'notes.patch_markdown': '根据内容版本精确替换唯一片段,避免误改重复内容或覆盖并发编辑。',
|
||||
'system.echo': '回显文本,用于本地智能体集成测试。',
|
||||
'math.add': '计算两个数的和,不产生外部副作用。',
|
||||
'notes.search': '搜索已建立索引的笔记,并返回摘要和引用。',
|
||||
|
||||
@@ -29,6 +29,49 @@ beforeEach(() => {
|
||||
vi.spyOn(useSkillStore(), 'loadSkills').mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('reveals only cited sources as the streamed answer reaches complete markers', async () => {
|
||||
const wrapper = mount(ChatView)
|
||||
await flushPromises()
|
||||
const chat = useChatStore()
|
||||
chat.messages = [{ message_id: 'answer', conversation_id: 'test', role: 'assistant', content: '', created_at: new Date().toISOString(),
|
||||
citations: [1, 2, 3].map(number => ({ note_id: 'note', block_id: String(number), file_path: 'note.md', heading_path: '', content: `source ${number}` })),
|
||||
}]
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.citation-card')).toHaveLength(0)
|
||||
chat.messages[0]!.content = '结论 [3'
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.citation-card')).toHaveLength(0)
|
||||
chat.messages[0]!.content += '],补充 [1],再次 [3]'
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.citation-card .badge').map(item => item.text())).toEqual(['3', '1'])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('animates only the active reply and keeps tools inside the reasoning disclosure', async () => {
|
||||
const wrapper = mount(ChatView)
|
||||
await flushPromises()
|
||||
const chat = useChatStore()
|
||||
const base = { conversation_id: 'test', role: 'assistant' as const, content: '', created_at: new Date().toISOString() }
|
||||
chat.messages = [{ ...base, message_id: 'old' }, { ...base, message_id: 'active', tool_calls: [{ tool_call_id: 'search', name: 'rag.search', parameters: { query: 'Python' }, status: 'running' }] }]
|
||||
chat.isStreaming = true
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.thinking-typewriter')).toHaveLength(1)
|
||||
expect(wrapper.findAll('.message')[0]!.find('.thinking').exists()).toBe(false)
|
||||
expect(wrapper.get('details.thinking .tool-calls').text()).toContain('rag.search')
|
||||
expect(wrapper.get('details.thinking summary').text()).toContain('正在思考')
|
||||
chat.messages[1]!.thinking = 'beforeafter'
|
||||
chat.messages[1]!.activity = [{ type: 'thinking', text: 'before' }, { type: 'tool', tool_call_id: 'search' }, { type: 'thinking', text: 'after' }]
|
||||
await flushPromises()
|
||||
expect(wrapper.get('details.thinking').element.textContent).toMatch(/before[\s\S]*rag.search[\s\S]*after/)
|
||||
chat.messages[1]!.content = 'Answer'
|
||||
chat.isStreaming = false
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.thinking-typewriter').exists()).toBe(false)
|
||||
expect(wrapper.get('details.thinking summary').text()).toBe('思考过程')
|
||||
expect(wrapper.find('details.thinking .tool-calls').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('reuses the settings model cache and renders the shared select style', async () => {
|
||||
const providers = useProviderStore()
|
||||
providers.modelsByProvider.a = [{model_id:'a-default',name:'A model',capabilities:{chat:true}}]
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useCitationNavigation } from '@/composables/useCitationNavigation'
|
||||
import { t } from '@/i18n'
|
||||
import ChatPersonaDialog from './ChatPersonaDialog.vue'
|
||||
import { useChatPreferences } from '@/stores/chatPreferences'
|
||||
import { usedCitations } from '@/utils/usedCitations'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const preferences = useChatPreferences()
|
||||
@@ -21,6 +22,29 @@ let disposed = false
|
||||
onBeforeUnmount(() => { disposed = true })
|
||||
|
||||
const availableModels = computed(() => providerStore.modelsByProvider[chatStore.selectedProviderId] ?? [])
|
||||
const streamingMessageId = computed(() => chatStore.isStreaming ? chatStore.messages.at(-1)?.message_id : undefined)
|
||||
const thinkingLabel = computed(() => t('正在思考…', 'Thinking…'))
|
||||
const editingMessage = ref<string | null>(null)
|
||||
const editedText = ref('')
|
||||
watch(() => chatStore.activeConversationId, () => { editingMessage.value = null })
|
||||
const activities = computed(() => Object.fromEntries(chatStore.messages.map(message => {
|
||||
const entries = message.activity?.length ? message.activity : [
|
||||
...(message.thinking ? [{ type: 'thinking' as const, text: message.thinking }] : []),
|
||||
...(message.tool_calls ?? []).map(call => ({ type: 'tool' as const, tool_call_id: call.tool_call_id })),
|
||||
]
|
||||
return [message.message_id, entries.map(entry => entry.type === 'thinking'
|
||||
? { text: entry.text, call: undefined }
|
||||
: { text: undefined, call: message.tool_calls?.find(call => call.tool_call_id === entry.tool_call_id) })]
|
||||
})))
|
||||
async function saveEdit() {
|
||||
const id = editingMessage.value
|
||||
if (!id || !editedText.value.trim()) return
|
||||
await chatStore.retryMessage(id, editedText.value)
|
||||
editingMessage.value = null
|
||||
}
|
||||
const visibleCitations = computed(() => Object.fromEntries(chatStore.messages.map(message => [
|
||||
message.message_id, message.role === 'assistant' ? usedCitations(message.content, message.citations) : [],
|
||||
])))
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -82,7 +106,7 @@ async function openCitationCard(citation: Citation) {
|
||||
</div>
|
||||
<button type="button" class="button-secondary" @click="showPersona = true">{{ t('人设与头像', 'Persona and avatars') }}</button>
|
||||
<label class="rag-toggle"><input v-model="chatStore.useRag" type="checkbox" :disabled="chatStore.isStreaming" />{{ t('检索知识库', 'Search knowledge base') }}</label>
|
||||
<span class="subtle">{{ t('开启后,将相关笔记片段发送给所选模型,并显示来源。技能调用请使用智能体。', 'When enabled, relevant note excerpts are sent to the selected model and citations are shown. Use Agent for skills.') }}</span>
|
||||
<span class="subtle">{{ t('模型先回复,按需调用知识库检索;需要提供商支持工具调用,仅显示正文引用的来源。笔记修改和技能调用请使用智能体。', 'The model responds first and can search the knowledge base as needed. Requires tool calling; only cited sources are shown. Use Agent for note edits and skills.') }}</span>
|
||||
</header>
|
||||
<div v-if="chatStore.contextNotice" class="notice-banner" role="status">{{ chatStore.contextNotice }}</div>
|
||||
<div v-if="loadError || providerStore.error || chatStore.historyError" class="error-banner chat-error">{{ loadError || providerStore.error || chatStore.historyError }}</div>
|
||||
@@ -91,16 +115,38 @@ async function openCitationCard(citation: Citation) {
|
||||
<article v-for="message in chatStore.messages" :key="message.message_id" class="message" :class="message.role">
|
||||
<div class="avatar"><img v-if="message.role === 'user' ? preferences.settings.userAvatar : preferences.settings.aiAvatar" :src="message.role === 'user' ? preferences.settings.userAvatar : preferences.settings.aiAvatar" :alt="message.role === 'user' ? t('我', 'Me') : 'AI'" /><span v-else>{{ message.role === 'user' ? t('你', 'You') : 'AI' }}</span></div>
|
||||
<div class="message-body">
|
||||
<details v-if="message.thinking" class="thinking ui-disclosure"><summary>{{ t('思考过程', 'Reasoning') }}</summary><p>{{ message.thinking }}</p></details>
|
||||
<MarkdownContent v-if="message.content" class="message-content" :source="message.content" />
|
||||
<div v-else-if="chatStore.isStreaming" class="message-content">{{ t('正在思考…', 'Thinking…') }}</div>
|
||||
<div v-if="message.tool_calls?.length" class="tool-calls"><div v-for="call in message.tool_calls" :key="call.tool_call_id" class="item-card"><span class="badge info">{{ call.status }}</span><strong>{{ call.name }}</strong><pre>{{ JSON.stringify(call.parameters, null, 2) }}</pre></div></div>
|
||||
<div v-if="message.citations?.length" class="citations">
|
||||
<button v-for="(citation, index) in message.citations" :key="citation.block_id" class="citation-card" @click="openCitationCard(citation)">
|
||||
<span class="badge info">{{ index + 1 }}</span><span><strong>{{ citation.heading_path || citation.file_path }}</strong><small>{{ citation.content }}</small></span>
|
||||
<details v-if="message.thinking || message.tool_calls?.length || (message.role === 'assistant' && message.message_id === streamingMessageId)" class="thinking ui-disclosure">
|
||||
<summary>
|
||||
<span v-if="message.message_id === streamingMessageId && !message.content" class="thinking-indicator" :aria-label="thinkingLabel">
|
||||
<span class="thinking-typewriter" aria-hidden="true" :style="{ '--typing-steps': Array.from(thinkingLabel).length }">{{ thinkingLabel }}</span>
|
||||
</span>
|
||||
<span v-else>{{ t('思考过程', 'Reasoning') }}</span>
|
||||
</summary>
|
||||
<template v-for="(entry, index) in activities[message.message_id]" :key="index">
|
||||
<p v-if="entry.text !== undefined">{{ entry.text }}</p>
|
||||
<div v-else-if="entry.call" class="tool-calls"><div class="item-card"><span class="badge info">{{ entry.call.status }}</span><strong>{{ entry.call.name }}</strong><pre>{{ JSON.stringify(entry.call.parameters, null, 2) }}</pre></div></div>
|
||||
</template>
|
||||
</details>
|
||||
<div v-if="editingMessage === message.message_id" class="message-edit">
|
||||
<textarea v-model="editedText" class="textarea" :aria-label="t('编辑消息', 'Edit message')" :disabled="!chatStore.canSend" />
|
||||
<div class="inline-actions"><button class="button-primary" :disabled="!chatStore.canSend || !editedText.trim()" @click="saveEdit">{{ t('保存并重新生成', 'Save and regenerate') }}</button><button class="button-secondary" @click="editingMessage = null">{{ t('取消', 'Cancel') }}</button></div>
|
||||
</div>
|
||||
<MarkdownContent v-else-if="message.content" class="message-content" :source="message.content" :citation-aliases="Object.fromEntries((message.citations ?? []).filter(c => c.citation_id).map(c => [c.citation_id!, (message.citations ?? []).indexOf(c) + 1]))" :citation-numbers="visibleCitations[message.message_id]?.map(item => item.number)" @citation="number => message.citations?.[number - 1] && openCitationCard(message.citations[number - 1]!)" />
|
||||
<div v-if="visibleCitations[message.message_id]?.length" class="citations">
|
||||
<button v-for="{ citation, number } in visibleCitations[message.message_id]" :key="number" class="citation-card" @click="openCitationCard(citation)">
|
||||
<span class="badge info">{{ number }}</span><span><strong>{{ citation.heading_path || citation.file_path }}</strong><small>{{ citation.content }}</small></span>
|
||||
</button>
|
||||
</div>
|
||||
<time>{{ new Date(message.created_at).toLocaleTimeString() }}</time>
|
||||
<div class="message-actions inline-actions">
|
||||
<button v-if="message.role === 'assistant'" class="button-secondary" :disabled="!chatStore.canSend" @click="chatStore.retryMessage(message.message_id)">{{ t('重新生成', 'Regenerate') }}</button>
|
||||
<button v-if="message.role === 'user' && editingMessage !== message.message_id" class="button-secondary" :disabled="!chatStore.canSend" @click="editingMessage = message.message_id; editedText = message.content">{{ t('编辑', 'Edit') }}</button>
|
||||
<template v-if="message.versions && message.versions.length > 1">
|
||||
<button class="button-secondary" :aria-label="t('上一版本', 'Previous version')" :disabled="!chatStore.canSend || message.versions.indexOf(message.message_id) <= 0" @click="chatStore.switchVersion(message.versions[message.versions.indexOf(message.message_id) - 1]!)">‹</button>
|
||||
<span>{{ message.versions.indexOf(message.message_id) + 1 }} / {{ message.versions.length }}</span>
|
||||
<button class="button-secondary" :aria-label="t('下一版本', 'Next version')" :disabled="!chatStore.canSend || message.versions.indexOf(message.message_id) >= message.versions.length - 1" @click="chatStore.switchVersion(message.versions[message.versions.indexOf(message.message_id) + 1]!)">›</button>
|
||||
</template>
|
||||
</div>
|
||||
<small v-if="message.usage" class="usage">Token {{ message.usage.total_tokens }}<span v-if="message.usage.input_tokens !== undefined && message.usage.output_tokens !== undefined"> ({{ t('输入', 'input') }} {{ message.usage.input_tokens }} / {{ t('输出', 'output') }} {{ message.usage.output_tokens }})</span></small>
|
||||
</div>
|
||||
</article>
|
||||
@@ -132,6 +178,12 @@ async function openCitationCard(citation: Citation) {
|
||||
.user .message-body { background: var(--color-accent-soft); border-color: color-mix(in srgb, var(--color-accent-primary) 14%, transparent); }
|
||||
.message-content { white-space: pre-wrap; line-height: var(--line-height-relaxed); }
|
||||
.thinking { margin-bottom: var(--space-sm); color: var(--color-text-secondary); }.thinking p { margin-top: var(--space-sm); white-space: pre-wrap; }
|
||||
.thinking-indicator { display: inline-block; }
|
||||
.message-actions { margin-top: var(--space-sm); }
|
||||
.message-edit .textarea { width: 100%; min-height: 100px; }
|
||||
.thinking-typewriter { display: inline-block; white-space: nowrap; padding-inline-end: 3px; border-inline-end: 2px solid var(--color-accent-primary); animation: thinking-type 2s steps(var(--typing-steps), end) infinite; }
|
||||
@keyframes thinking-type { 0% { clip-path: inset(0 100% 0 0); } 65%, 100% { clip-path: inset(0 0 0 0); } }
|
||||
@media (prefers-reduced-motion: reduce) { .thinking-typewriter { animation: none; border-inline-end: 0; } }
|
||||
.tool-calls { display: grid; gap: var(--space-sm); margin-top: var(--space-md); }.tool-calls .item-card { display: grid; gap: var(--space-xs); }.tool-calls pre { overflow: auto; font-size: var(--font-size-xs); }
|
||||
.usage { display: block; margin-top: var(--space-xs); color: var(--color-text-tertiary); }
|
||||
.message time { display: block; margin-top: var(--space-sm); color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
||||
|
||||
@@ -105,6 +105,6 @@ it('offers and applies the paper theme update without discarding the active them
|
||||
const card = wrapper.findAll('article.theme-card').find(item => item.text().includes('Paper Moments'))!
|
||||
await card.findAll('button').find(button => button.text() === '更新')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(store.allThemes.find(theme => theme.theme_id === 'paper-moments')?.version).toBe('1.9.0')
|
||||
expect(store.allThemes.find(theme => theme.theme_id === 'paper-moments')?.version).toBe('1.9.2')
|
||||
expect(document.getElementById('theme-style-paper-moments')!.textContent).toContain('.surface-nested')
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { apiClient } from './apiClient'
|
||||
import type { ChatMessage, Conversation, ModelEvent, PageMeta } from '@/contracts'
|
||||
|
||||
export interface ChatRequest {
|
||||
retry_message_id?: string
|
||||
provider_id: string
|
||||
model: string
|
||||
conversation_id?: string
|
||||
@@ -41,6 +42,10 @@ export function removeConversation(conversationId: string) {
|
||||
return apiClient.delete(`/api/chat/conversations/${encodeURIComponent(conversationId)}`)
|
||||
}
|
||||
|
||||
export function selectMessageVersion(conversationId: string, messageId: string) {
|
||||
return apiClient.post(`/api/chat/conversations/${encodeURIComponent(conversationId)}/messages/${encodeURIComponent(messageId)}/select`, {})
|
||||
}
|
||||
|
||||
export function streamChat(
|
||||
request: ChatRequest,
|
||||
handlers: {
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock('@/services/chatService', () => ({
|
||||
listConversations: vi.fn(),
|
||||
removeConversation: vi.fn(),
|
||||
streamChat: vi.fn(),
|
||||
selectMessageVersion: vi.fn().mockResolvedValue({ status: 'completed' }),
|
||||
}))
|
||||
|
||||
const page = { total: 0, limit: 100, offset: 0 }
|
||||
@@ -40,6 +41,34 @@ beforeEach(() => {
|
||||
vi.mocked(removeConversation).mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('keeps reasoning and tools ordered and retries only the selected branch prefix', async () => {
|
||||
const store = useChatStore()
|
||||
store.selectedProviderId = 'real'
|
||||
store.selectedModel = 'model'
|
||||
await store.sendMessage('original')
|
||||
const first = vi.mocked(streamChat).mock.calls[0]![1]
|
||||
const event = (name: string, data: Record<string, unknown>) => first.onEvent?.({ event: name as 'ThinkingDelta', sequence: 0, data, timestamp: new Date().toISOString() })
|
||||
event('ThinkingDelta', { text: 'before' })
|
||||
event('ToolCallStart', { tool_call_id: 'tool', name: 'rag.search' })
|
||||
event('ThinkingDelta', { text: 'after' })
|
||||
event('TextDelta', { text: 'answer' })
|
||||
expect(store.messages[1]!.activity).toEqual([{ type: 'thinking', text: 'before' }, { type: 'tool', tool_call_id: 'tool' }, { type: 'thinking', text: 'after' }])
|
||||
first.onDone?.()
|
||||
const originalUser = store.messages[0]!.message_id
|
||||
const originalAnswer = store.messages[1]!.message_id
|
||||
await store.retryMessage(originalAnswer)
|
||||
const second = vi.mocked(streamChat).mock.calls[1]!
|
||||
expect(second[0].retry_message_id).toBe(originalAnswer)
|
||||
expect(second[0].user_message_id).toBe(originalUser)
|
||||
expect(second[0].messages).toEqual([{ role: 'user', content: 'original' }])
|
||||
expect(store.messages[1]!.versions).toContain(originalAnswer)
|
||||
second[1].onDone?.()
|
||||
await store.retryMessage(originalUser, 'edited')
|
||||
expect(vi.mocked(streamChat).mock.calls[2]![0].messages).toEqual([{ role: 'user', content: 'edited' }])
|
||||
expect(store.messages[0]!.versions).toContain(originalUser)
|
||||
expect(store.messages[0]!.message_id).not.toBe(originalUser)
|
||||
})
|
||||
|
||||
it('sends persistent message ids and restores messages from the backend', async () => {
|
||||
const store = useChatStore()
|
||||
store.selectedProviderId = 'real'
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
listConversations as listConversationsApi,
|
||||
removeConversation,
|
||||
streamChat,
|
||||
selectMessageVersion,
|
||||
} from '@/services/chatService'
|
||||
import type { SseClient } from '@/services/sseClient'
|
||||
import { t } from '@/i18n'
|
||||
@@ -156,7 +157,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
try { await persistConversation(conversation) } catch { /* exposed through historyError */ }
|
||||
}
|
||||
|
||||
async function sendMessage(text: string) {
|
||||
async function sendMessage(text: string, retryMessageId?: string) {
|
||||
const content = text.trim()
|
||||
if (!content || !canSend.value || !selectedProviderId.value || !selectedModel.value) return
|
||||
const version = ++streamVersion
|
||||
@@ -180,15 +181,26 @@ export const useChatStore = defineStore('chat', () => {
|
||||
|
||||
const conversationId = conversation.conversation_id
|
||||
if (conversation.message_count === 0) conversation.title = content.slice(0, 30)
|
||||
const userMsg: ChatMessage = {
|
||||
const retryIndex = retryMessageId ? messages.value.findIndex(m => m.message_id === retryMessageId) : -1
|
||||
const retryTarget = retryIndex >= 0 ? messages.value[retryIndex] : undefined
|
||||
if (retryMessageId && !retryTarget) return
|
||||
const originalMessages = retryTarget ? [...messages.value] : null
|
||||
const regenerate = retryTarget?.role === 'assistant'
|
||||
const userMsg: ChatMessage = regenerate ? messages.value[retryIndex - 1]! : {
|
||||
message_id: crypto.randomUUID(), conversation_id: conversationId, role: 'user', content,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
const aiMsg = reactive<ChatMessage>({
|
||||
message_id: crypto.randomUUID(), conversation_id: conversationId, role: 'assistant', content: '',
|
||||
created_at: new Date().toISOString(), citations: [], tool_calls: [],
|
||||
created_at: new Date().toISOString(), citations: [], tool_calls: [], activity: [],
|
||||
})
|
||||
messages.value.push(userMsg, aiMsg)
|
||||
if (retryTarget) {
|
||||
messages.value = messages.value.slice(0, retryIndex)
|
||||
const newVersion = regenerate ? aiMsg : userMsg
|
||||
newVersion.versions = [...(retryTarget.versions?.length ? retryTarget.versions : [retryTarget.message_id]), newVersion.message_id]
|
||||
}
|
||||
if (!regenerate) messages.value.push(userMsg)
|
||||
messages.value.push(aiMsg)
|
||||
inputText.value = ''
|
||||
isStreaming.value = true
|
||||
conversation.updated_at = new Date().toISOString()
|
||||
@@ -197,6 +209,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const argumentBuffers = new Map<string, string>()
|
||||
sseClient = streamChat({
|
||||
provider_id: selectedProviderId.value,
|
||||
...(retryMessageId ? { retry_message_id: retryMessageId } : {}),
|
||||
model: selectedModel.value,
|
||||
conversation_id: conversationId,
|
||||
user_message_id: userMsg.message_id,
|
||||
@@ -205,13 +218,22 @@ export const useChatStore = defineStore('chat', () => {
|
||||
use_rag: useRag.value,
|
||||
messages: messages.value
|
||||
.filter(message => message.message_id !== aiMsg.message_id)
|
||||
.map(message => ({ role: message.role, content: message.content })),
|
||||
.map(message => ({ role: message.role, content: message.content,
|
||||
...(message.role === 'assistant' && message.thinking != null ? { reasoning_content: message.thinking } : {}),
|
||||
})),
|
||||
}, {
|
||||
onEvent(event) {
|
||||
if (version !== streamVersion) return
|
||||
if (event.event === 'TextDelta') aiMsg.content += String(event.data.text ?? '')
|
||||
if (event.event === 'ThinkingDelta') aiMsg.thinking = `${aiMsg.thinking ?? ''}${String(event.data.text ?? '')}`
|
||||
if (event.event === 'ThinkingDelta') {
|
||||
const text = String(event.data.text ?? '')
|
||||
aiMsg.thinking = `${aiMsg.thinking ?? ''}${text}`
|
||||
const last = aiMsg.activity?.at(-1)
|
||||
if (last?.type === 'thinking') last.text += text
|
||||
else aiMsg.activity?.push({ type: 'thinking', text })
|
||||
}
|
||||
if (event.event === 'ToolCallStart') {
|
||||
aiMsg.activity?.push({ type: 'tool', tool_call_id: String(event.data.tool_call_id ?? '') })
|
||||
aiMsg.tool_calls?.push({
|
||||
tool_call_id: String(event.data.tool_call_id ?? ''), name: String(event.data.name ?? 'unknown'),
|
||||
parameters: (event.data.arguments ?? {}) as Record<string, unknown>, status: 'running',
|
||||
@@ -228,7 +250,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
if (event.event === 'ToolCallEnd') {
|
||||
const call = aiMsg.tool_calls?.find(item => item.tool_call_id === event.data.tool_call_id)
|
||||
if (call) call.status = 'completed'
|
||||
if (call) call.status = event.data.status === 'failed' ? 'error' : 'completed'
|
||||
}
|
||||
if (event.event === 'Usage') {
|
||||
const input = Number(event.data.input_tokens ?? 0)
|
||||
@@ -249,6 +271,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
onError(error) {
|
||||
if (version !== streamVersion) return
|
||||
aiMsg.content += `\n\n${t('连接失败:', 'Connection failed: ')}${error.message}`
|
||||
if (originalMessages) historyError.value = t('重试连接失败,可切换版本恢复原回复。', 'Retry connection failed. Switch versions to return to the original reply.')
|
||||
isStreaming.value = false
|
||||
sseClient = null
|
||||
},
|
||||
@@ -262,6 +285,27 @@ export const useChatStore = defineStore('chat', () => {
|
||||
})
|
||||
}
|
||||
|
||||
async function retryMessage(messageId: string, editedText?: string) {
|
||||
if (!canSend.value) return
|
||||
const index = messages.value.findIndex(m => m.message_id === messageId)
|
||||
const message = messages.value[index]
|
||||
if (!message) return
|
||||
const text = message.role === 'user' ? editedText : messages.value[index - 1]?.content
|
||||
if (text?.trim()) await sendMessage(text, messageId)
|
||||
}
|
||||
|
||||
async function switchVersion(messageId: string) {
|
||||
const id = activeConversationId.value
|
||||
if (!canSend.value || !id) return
|
||||
const version = loadVersion
|
||||
isPreparing.value = true
|
||||
try {
|
||||
await selectMessageVersion(id, messageId)
|
||||
if (activeConversationId.value === id && loadVersion === version) await setActiveConversation(id)
|
||||
} catch (error) { historyError.value = error instanceof Error ? error.message : 'Version switch failed' }
|
||||
finally { isPreparing.value = false }
|
||||
}
|
||||
|
||||
function stopGeneration() {
|
||||
streamVersion++
|
||||
isPreparing.value = false
|
||||
@@ -294,6 +338,6 @@ export const useChatStore = defineStore('chat', () => {
|
||||
return {
|
||||
conversations, activeConversationId, activeConversation, sortedConversations, messages,
|
||||
isStreaming, isPreparing, canSend, inputText, useRag, selectedSkillId, selectedProviderId, selectedModel, historyError, contextNotice,
|
||||
loadConversations, setActiveConversation, sendMessage, stopGeneration, createNewConversation, deleteConversation,
|
||||
loadConversations, setActiveConversation, sendMessage, stopGeneration, createNewConversation, deleteConversation, retryMessage, switchVersion,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
.markdown-content details.markdown-callout:not([open]) { border-style: dashed; border-inline-start-style: solid; }
|
||||
.editor-pane.source { caret-color: var(--color-accent-primary); }
|
||||
.markdown-content .shiki code { display: block; min-width: max-content; padding: 0; background: transparent; font: inherit; }
|
||||
.markdown-content .shiki .line { display: block; min-height: 1.45em; }
|
||||
.markdown-content .shiki .line { display: block; min-height: 1lh; }
|
||||
.markdown-content[data-code-wrap] .shiki { tab-size: var(--markdown-code-indent, 4); }
|
||||
.markdown-content[data-code-wrap='true'] .shiki code { min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.markdown-content[data-line-numbers='true'] .shiki code { counter-reset: code-line; }
|
||||
@@ -32,3 +32,11 @@
|
||||
.editor-scroll-buttons button:hover { background: var(--color-background-hover); border-color: var(--color-accent-primary); }
|
||||
.editor-scroll-buttons button:active { background: var(--color-accent-soft); }
|
||||
.editor-scroll-buttons button:focus-visible { outline: 2px solid var(--color-border-focus); outline-offset: 2px; }
|
||||
|
||||
/* The read-only renderer uses the same framed code surface as the workspace. */
|
||||
.markdown-content .markdown-code-block { position: relative; margin: .85em 0; padding: 8px 20px 16px; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background); color: var(--color-code-text); }
|
||||
.markdown-content .markdown-code-block > .markdown-code-toolbar { display: flex; align-items: center; gap: 8px; padding: 0 0 8px; min-height: 28px; font: 12px/1.4 var(--font-ui-mono); color: var(--color-code-muted); }
|
||||
.markdown-content .markdown-code-block > .markdown-code-toolbar button { min-height: 24px; padding: 3px 10px; border: 0; border-radius: var(--radius-sm); box-shadow: none; background: var(--color-accent-soft); color: var(--color-code-muted); font: inherit; }
|
||||
.markdown-content .markdown-code-block > .shiki { margin: 0; padding: 0; border: 0; border-radius: 0; box-shadow: none; font-family: var(--font-editor-mono); font-size: var(--font-editor-size); line-height: 1.4; }
|
||||
.markdown-content .markdown-code-block > .shiki::before,
|
||||
.markdown-content .markdown-code-block > .shiki::after { content: none; }
|
||||
|
||||
@@ -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