本地 RAG 知识库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

342 lines
12 KiB

<template>
<t-card :bordered="false">
<template #header>
<div class="pipeline-header">
<div>
<span style="font-size:16px;font-weight:600;">🔀 AI 执行链</span>
<p style="font-size:12px;color:var(--td-text-color-placeholder);margin:4px 0 0;">
下图展示从用户请求到 AI 回复的完整处理流程包含意图路由RAG 检索熔断保护和 Advisor
菱形节点 = 决策分支 · 虚线框 = 独立子系统 · 虚线箭头 = 降级/异步路径
</p>
</div>
<t-button variant="outline" size="small" :disabled="!svg" @click="exportImage">
<template #icon><DownloadIcon /></template>
导出图片
</t-button>
</div>
</template>
<!-- 图例 -->
<div class="pipeline-legend">
<div v-for="item in legendItems" :key="item.label" class="legend-item">
<span class="legend-dot" :class="{ 'legend-diamond': item.diamond }" :style="{ background: item.bg, border: `1px solid ${item.border}` }" />
{{ item.label }}
</div>
</div>
<!-- 流程图 -->
<div style="margin-top:16px;">
<div v-if="error" class="pipeline-error">
<p>⚠️ 图表渲染失败: {{ error }}</p>
<t-button variant="outline" size="small" @click="retry">重试</t-button>
</div>
<div v-else-if="!svg" class="pipeline-loading">
<p>⏳ 正在生成流程图...</p>
</div>
<div v-else class="pipeline-diagram" v-html="svg" />
</div>
</t-card>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
import { DownloadIcon } from 'tdesign-icons-vue-next'
import mermaid from 'mermaid'
import { palette, paletteBg, paletteBorder } from '@/utils/palette'
// 图例配置颜色统一走 palette避免散落 hex
const legendItems = [
{ label: '用户请求 / 入口', bg: paletteBg.blue, border: paletteBorder.blue },
{ label: '决策分支', bg: paletteBg.orange, border: paletteBorder.orange, diamond: true },
{ label: '处理步骤', bg: paletteBg.gray, border: paletteBorder.gray },
{ label: 'RAG 检索', bg: paletteBg.indigo, border: paletteBorder.indigo },
{ label: 'Advisor ', bg: paletteBg.pink, border: paletteBorder.pink },
{ label: '最终输出', bg: paletteBg.green, border: paletteBorder.green },
{ label: 'LLM 调用', bg: paletteBg.orange, border: paletteBorder.orange },
]
// 初始化 Mermaid 主题匹配后台管理 UI 风格
mermaid.initialize({
startOnLoad: false,
theme: 'base',
themeVariables: {
primaryColor: paletteBg.gray,
primaryTextColor: palette.neutralText,
primaryBorderColor: palette.neutralBorder,
lineColor: palette.neutralLine,
secondaryColor: paletteBg.blue,
tertiaryColor: paletteBg.orange,
fontSize: '13px',
},
flowchart: {
useMaxWidth: true,
htmlLabels: true,
curve: 'basis',
},
})
// Mermaid 流程图 DSL 定义
// 节点类型: [矩形]=处理步骤, {菱形}=决策分支, subgraph=子系统
// %%graph-meta: { updated: "2026-08-27", basedOn: "ChatPipeline v3, RagPipeline v2, AssistantApp v2", mermaidVersion: "flowchart-v2" }
const GRAPH_DEFINITION = `
flowchart TD
A["<b>用户请求</b><br/>message + roleId + accountId + chatId"]
A --> B{"<b>Controller</b><br/>鉴权 / 角色解析 / KB 隔离判断<br/>构建 ChatContext"}
B --> C["<b>ChatPipeline.buildRequest</b><br/>编排决策入口"]
C --> D{"enableRag ?"}
D -- " false" --> E["<b>模式: 纯对话</b><br/>systemPrompt(角色人设 + 全局配置)<br/>不检索知识库"]
D -- " true" --> F["<b>IntentRouter</b><br/>🔹 寒暄词快速路径: 本地列表精确匹配 LLM<br/>🔹 未命中则 LLM 意图分类<br/>FAQ / RAG / CHITCHAT"]
F --> G{"意图分类结果"}
G -- "FAQ<br/>confidence 0.8" --> H["<b>FaqMatchEngine</b><br/>三级匹配策略<br/>精确 关键词 向量语义"]
G -- "CHITCHAT<br/>confidence 0.6" --> CHK["<b>闲聊前 FAQ 精准匹配</b><br/>先试 FaqMatchEngine<br/>命中则短路返回"]
G -- "RAG / 降级<br/>其余情况" --> J["<b>RagPipeline.retrieve</b><br/>RAG 检索流水线入口"]
H -- " 命中标准答案" --> T
H -. " 未命中 降级 RAG" .-> J
subgraph RAG["📚 RAG 检索流水线当前: 纯向量检索"]
J --> K["<b>1. FAQ 优先匹配二次兜底</b><br/>FaqMatchEngine 三级匹配<br/>命中则短路返回"]
K --> L["<b>2. 查询重写</b><br/>REWRITE / TRANSLATION<br/>COMPRESSION / MULTI_QUERY"]
L --> M["<b>3. 向量检索</b><br/>PGVector similaritySearch<br/>topK=4 + 分类过滤"]
M --> S["<b>4. 构建资料块</b><br/>拼接检索文档<br/>注入 system prompt 末尾"]
end
CHK -. " 未命中 纯对话" .-> I["<b>模式: 纯对话</b><br/>跳过知识库检索<br/>不注入资料块"]
CHK -- " 命中标准答案" --> T
I --> T
S --> T
E --> T
T["<b>组装 ChatRequest</b><br/>finalMessage + finalSystemPrompt<br/>+ faqAnswer (可选)"]
T --> CB{"<b>🔌 AI 熔断检查</b><br/>SimpleCircuitBreaker<br/>阈值: 连续 3 次失败 / 恢复: 5 分钟"}
CB -- "熔断中" --> FALLBACK["<b>返回降级提示</b><br/>AI 服务暂时不可用<br/>请稍后重试"]
CB -- "正常" --> U["<b>AssistantApp</b><br/>chat / chatStream<br/>构建 ChatClient + MCP 工具"]
subgraph ADVISOR["🛡 Advisor 环绕 LLM 调用"]
U --> V["<b>ContentSafetyAdvisor</b><br/>🔽 before: DFA 敏感词检测<br/>用户输入 BLOCK/MASK"]
V --> W["<b>MessageChatMemoryAdvisor</b><br/>注入历史对话记忆<br/>DatabaseChatMemory 持久化"]
W --> X["<b>MyLoggerAdvisor</b><br/>🔽 before: 请求日志<br/>INFO: AI Request: ..."]
X --> Y["<b>🤖 ChatClient.call / stream</b><br/>LLM 大模型调用<br/>通义千问 / 其他提供商"]
Y --> Z["<b>MyLoggerAdvisor</b><br/>🔼 after: 响应日志<br/>INFO: AI Response: ..."]
Z --> AA["<b>ContentSafetyAdvisor</b><br/>🔼 after: AI 输出检测<br/>BLOCK/MASK 违规内容"]
end
AA --> AB["<b>返回 AI 回复</b><br/>SSE 流式输出<br/>+ MCP 工具调用事件"]
FALLBACK --> AB
AB -. "异步按需触发" .-> SG
subgraph SUGGEST["💡 推荐问题异步"]
SG["<b>SuggestionGenerator</b><br/>独立 ChatClient MCP 工具<br/>基于最近 10 条历史<br/>生成 3 条推荐问题<br/>超时 15s · 结果缓存"]
end
style A fill:${paletteBg.blue},stroke:${paletteBorder.blue},stroke-width:2px
style AB fill:${paletteBg.green},stroke:${paletteBorder.green},stroke-width:2px
style Y fill:${paletteBg.orange},stroke:${paletteBorder.orange},stroke-width:2px
style CB fill:${paletteBg.orange},stroke:${paletteBorder.orange},stroke-width:2px
style FALLBACK fill:${paletteBg.red},stroke:${paletteBorder.red},stroke-width:2px,stroke-dasharray:5
style M fill:${paletteBg.indigo},stroke:${paletteBorder.indigo},stroke-width:2px
style S fill:${paletteBg.indigo},stroke:${paletteBorder.indigo},stroke-width:2px
`
const svg = ref('')
const error = ref('')
async function render() {
error.value = ''
svg.value = ''
await nextTick()
try {
const { svg: result } = await mermaid.render('pipeline-graph', GRAPH_DEFINITION)
svg.value = result
} catch (e: any) {
console.error('Mermaid 渲染失败:', e)
error.value = e.message || '未知渲染错误'
}
}
function retry() {
render()
}
/**
* 将当前渲染的流程图导出为 PNG 图片。
* 实现思路:克隆 SVG → 序列化为 Blob → 绘制到 Canvas → 触发下载。
* 注意:SVG 中若引用跨域字体/图片会污染 Canvas,因此强制使用系统字体并捕获异常降级为 SVG 下载。
*/
async function exportImage() {
const svgEl = document.querySelector('.pipeline-diagram svg') as SVGSVGElement | null
if (!svgEl) {
MessagePlugin.warning('流程图尚未渲染完成,请稍后再试')
return
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, '')
let url = ''
try {
const clonedSvg = svgEl.cloneNode(true) as SVGSVGElement
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink')
// 强制使用系统字体,避免 SVG 独立渲染时请求跨域自定义字体导致 Canvas 被污染
const systemFont = 'sans-serif, Arial, "Microsoft YaHei", "PingFang SC"'
clonedSvg.style.fontFamily = systemFont
clonedSvg.querySelectorAll('*').forEach(node => {
if (node instanceof SVGElement || node instanceof HTMLElement) {
node.style.fontFamily = systemFont
}
})
// 优先使用 viewBox 原始尺寸,保证导出图片清晰且不受页面缩放影响
const viewBox = svgEl.viewBox?.baseVal
let width = viewBox?.width || 0
let height = viewBox?.height || 0
if (!width || !height) {
width = parseFloat(svgEl.getAttribute('width') || '0')
height = parseFloat(svgEl.getAttribute('height') || '0')
}
if (!width || !height) {
const rect = svgEl.getBoundingClientRect()
width = rect.width
height = rect.height
}
clonedSvg.setAttribute('width', String(width))
clonedSvg.setAttribute('height', String(height))
const serializer = new XMLSerializer()
const svgString = serializer.serializeToString(clonedSvg)
// 先提供 SVG 降级下载能力
const downloadSvg = () => {
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' })
const svgUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = svgUrl
link.download = `ai-执行链-${timestamp}.svg`
link.click()
URL.revokeObjectURL(svgUrl)
MessagePlugin.warning('PNG 导出受限已降级为 SVG 矢量图下载')
}
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' })
url = URL.createObjectURL(svgBlob)
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
try {
const canvas = document.createElement('canvas')
const scale = 2
canvas.width = Math.ceil(width * scale)
canvas.height = Math.ceil(height * scale)
const ctx = canvas.getContext('2d')
if (!ctx) {
URL.revokeObjectURL(url)
MessagePlugin.error('Canvas 上下文创建失败')
return
}
// 填充白色背景,避免透明背景导致文字/线条在某些查看器下不清晰
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
URL.revokeObjectURL(url)
const pngUrl = canvas.toDataURL('image/png')
const link = document.createElement('a')
link.href = pngUrl
link.download = `ai-执行链-${timestamp}.png`
link.click()
MessagePlugin.success('图片导出成功')
} catch (e: any) {
URL.revokeObjectURL(url)
console.error('Canvas 导出失败降级为 SVG:', e)
downloadSvg()
}
}
img.onerror = () => {
URL.revokeObjectURL(url)
console.error('SVG 图片加载失败降级为 SVG 下载')
downloadSvg()
}
img.src = url
} catch (e: any) {
if (url) URL.revokeObjectURL(url)
console.error('导出图片失败:', e)
MessagePlugin.error(e.message || '导出失败')
}
}
onMounted(() => {
render()
})
</script>
<style scoped>
.pipeline-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.pipeline-legend {
display: flex;
flex-wrap: wrap;
gap: 10px 20px;
margin-bottom: 8px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--color-text-tertiary);
}
.legend-dot {
width: 14px;
height: 14px;
border-radius: 3px;
flex: none;
}
.legend-diamond { transform: rotate(45deg); border-radius: 2px; }
.pipeline-diagram {
width: 100%;
overflow-x: auto;
padding: 8px 0;
}
.pipeline-diagram :deep(svg) {
max-width: 100%;
height: auto;
display: block;
margin: 0 auto;
}
.pipeline-loading {
text-align: center;
padding: 40px;
color: var(--color-text-tertiary);
font-size: 14px;
}
.pipeline-error {
text-align: center;
padding: 24px;
color: var(--color-error);
font-size: 14px;
}
</style>