agent-Specialization/static/src/components/chat/MarkdownRenderer.vue
JOJO e6d8f194d2 feat(chat): 行内引用胶囊(inline citations)
- 模型输出 【cite:src_xxx】/【file:相对路径】 标记,渲染为可交互来源胶囊(悬停延迟开/点击固定/滚动收起)
- 后端:modules/citations.py 注册表与落库校验(剥离幻觉 id 与不存在的文件);web_search/extract_webpage 注册来源并在工具结果带 citation 信息;assistant 消息落库时 finalize 并挂载 message.metadata.citations
- 前端:marker 输出瞬间即渲染(工具结果查表 + 文件 token 自解析),task_complete 后权威裁决与富化;弹层支持网页摘要/文件内容片段/图片预览,文件头部点击打开右侧预览面板,宿主机模式可在文件管理器中打开
- prompt:三份系统提示加入引用格式与规则(提到文件名不等于引用;图片等二进制文件同样可引用)
2026-09-01 22:31:37 +08:00

161 lines
4.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div ref="containerRef" class="markdown-renderer">
<template v-for="segment in segments" :key="segment.key">
<!-- 文本段按顶层块分片渲染:流式期间只有末尾块字符串变化,
已完成的前缀块 v-html 字符串不变、DOM 不被重设,
保护 show_file 卡片/表格/KaTeX/图片等已增强内容不被反复重建 -->
<div v-if="segment.type === 'text'" class="markdown-text-segment">
<div
v-for="chunk in chunksForSegment(segment)"
:key="chunk.key"
class="md-html-chunk"
v-html="chunk.html"
></div>
</div>
<CodeBlock
v-else
:content="segment.content"
:language="segment.language"
:is-streaming="!segment.closed"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, inject, nextTick, onMounted, onUpdated, ref, watch } from 'vue';
import CodeBlock from './CodeBlock.vue';
import {
parseMarkdownSegments,
renderMarkdownText,
renderMathBlocks,
type MarkdownSegment
} from '@/composables/useMarkdownRenderer';
import { chunkRenderedHtml, type HtmlChunk } from '@/utils/htmlChunks';
import { enhanceCitationChips, type CitationAnnotation } from './citationChips';
defineOptions({ name: 'MarkdownRenderer' });
const props = defineProps<{
content: string;
isStreaming?: boolean;
citations?: CitationAnnotation[];
/** citations 是否为后端裁决后的权威版本message.metadata.citations 已到达 */
citationsFinal?: boolean;
enableCitations?: boolean;
}>();
const containerRef = ref<HTMLElement | null>(null);
const onMathRendered = inject<() => void>('mathRenderedCallback', () => {});
const segments = computed(() => parseMarkdownSegments(props.content || '', props.isStreaming));
function renderText(text: string) {
// 透传流式标志show_html 卡片在流式期间需要 partial 渲染(实时渲染/渲染中占位)
// enableCitations仅 assistant 正文开启引用渲染其他场景【cite:】按原文显示
return renderMarkdownText(text, props.isStreaming, props.enableCitations);
}
// 分段级分块缓存segments 每个 token 都是新对象,按 key 缓存避免对
// 已完成分段重复做 HTML 解析/分块;内容变化时才重新分块。
const segmentChunkCache = new Map<string, { content: string; chunks: HtmlChunk[] }>();
const SEGMENT_CHUNK_CACHE_LIMIT = 40;
function chunksForSegment(segment: MarkdownSegment): HtmlChunk[] {
const cached = segmentChunkCache.get(segment.key);
if (cached && cached.content === segment.content) {
return cached.chunks;
}
const chunks = chunkRenderedHtml(renderText(segment.content));
segmentChunkCache.set(segment.key, { content: segment.content, chunks });
if (segmentChunkCache.size > SEGMENT_CHUNK_CACHE_LIMIT) {
const firstKey = segmentChunkCache.keys().next().value;
if (firstKey !== undefined) {
segmentChunkCache.delete(firstKey);
}
}
return chunks;
}
function renderMath() {
nextTick(() => {
if (containerRef.value) {
renderMathBlocks(containerRef.value);
onMathRendered();
}
});
}
onMounted(renderMath);
onUpdated(renderMath);
watch(() => props.content, renderMath, { immediate: true });
// 行内引用:渲染后扫描 chip 占位 span填充内容并接管交互。
// chip 在输出瞬间即解析(工具结果查表 / file token 自解析);
// citationsFinal 到达时做权威裁决(移除无效 chip与富化。
function enhanceCitations() {
if (!props.enableCitations) return;
nextTick(() => {
if (containerRef.value) {
enhanceCitationChips(containerRef.value, props.citations, { final: props.citationsFinal });
}
});
}
onMounted(enhanceCitations);
onUpdated(enhanceCitations);
watch(() => [props.citations, props.citationsFinal], enhanceCitations);
</script>
<style scoped>
.markdown-renderer {
display: contents;
}
.markdown-text-segment {
display: block;
}
/* 分块容器只作 v-html 载体,不产生任何布局盒,
保证分块不改变原有排版(内外边距/相邻选择器表现与单 v-html 一致) */
.md-html-chunk {
display: contents;
}
.markdown-text-segment > *:first-child {
margin-top: 0;
}
.markdown-text-segment > *:last-child {
margin-bottom: 0;
}
/* 列表基础样式(渲染器自有,不依赖外部容器类名):
全局 reset 清掉了 ul/ol 默认 padding此处置回标准缩进
保证 MarkdownRenderer 在任何容器内(聊天/批准弹窗/预览等)表现一致:
标记在左、文字缩进靠右 */
.markdown-text-segment :deep(ul),
.markdown-text-segment :deep(ol) {
padding-left: 24px;
margin-bottom: 12px;
}
.markdown-text-segment :deep(ul) {
list-style-type: disc;
}
.markdown-text-segment :deep(ul ul) {
list-style-type: circle;
margin-top: 6px;
}
.markdown-text-segment :deep(ol) {
list-style-type: decimal;
}
.markdown-text-segment :deep(li) {
margin-bottom: 6px;
line-height: 1.6;
}
</style>