本地 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.
 
 
 
 
 
 

329 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 回复的完整处理流程,包含 FAQ 与本地路由、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;">
<t-alert v-if="error" theme="error">
<template #default>图表渲染失败:{{ error }}</template>
<template #operation><t-button variant="text" size="small" @click="retry">重试</t-button></template>
</t-alert>
<t-loading v-else-if="!svg" text="正在生成流程图..." style="text-align:center;padding:40px 0;" />
<div v-else class="pipeline-diagram" v-html="svg" />
</div>
</t-card>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { toast } from '@/utils/toast'
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-09-14", basedOn: "ChatPipeline v5 direct retrieval, RagPipeline v3, AssistantApp sources metadata", 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 --> CB{"<b>AssistantApp 熔断检查</b><br/>SimpleCircuitBreaker<br/>阈值: 连续 3 次失败 / 恢复: 5 分钟"}
CB -- "正常" --> C["<b>ChatPipeline.buildRequest</b><br/>编排决策入口"]
C --> D{"enableRag ?"}
D -- "❌ false" --> E["<b>模式: 纯对话</b><br/>systemPrompt(角色人设 + 全局配置)<br/>不检索知识库"]
D -- "true" --> H["<b>FAQ 优先匹配</b><br/>FaqMatchEngine 完整三级匹配<br/>精确 → 关键词 → 向量语义<br/>沿用角色分类隔离"]
H -- "命中标准答案" --> T
H -- "未命中 / 异常" --> G{"<b>本地 isChitchat</b><br/>精确寒暄词匹配<br/>无意图分类 LLM"}
G -- "寒暄命中" --> I["<b>模式: 纯对话</b><br/>跳过知识库检索<br/>不注入资料块"]
G -- "其余问题" --> J["<b>RagPipeline.retrieve</b><br/>默认零 LLM 预处理"]
subgraph RAG["📚 RAG 检索流水线(当前: 纯向量检索)"]
J --> K{"前置 FAQ 匹配<br/>completedCleanly ?"}
K -- "true: 跳过重复 FAQ" --> L{"<b>2. rewriteStrategy</b><br/>默认 NONE"}
K -- "false: 异常后重试" --> KR["<b>1. FAQ 匹配重试</b><br/>FaqMatchEngine 完整三级匹配"]
KR -- "命中标准答案" --> T
KR -- "未命中 / 异常" --> L
L -- "NONE 原文直检索" --> M["<b>3. 向量检索</b><br/>PGVector similaritySearch<br/>topK=4 + 分类过滤"]
L -- "显式选择" --> LR["<b>查询重写 LLM</b><br/>REWRITE / TRANSLATION / MULTI_QUERY<br/>COMPRESSION: 最近 10 条历史补全指代"]
LR --> M
M --> S["<b>4. 构建资料块</b><br/>命中/未命中日志仅此层记录一次<br/>注入 system prompt + 保留命中文档"]
end
I --> T
S --> T
E --> T
T["<b>组装 ChatRequest</b><br/>finalMessage + finalSystemPrompt<br/>+ faqAnswer / 当次命中文档"]
T -- "FAQ 标准答案" --> FAQ["<b>FAQ 直接回复</b><br/>内容安全检查 + 写入会话记忆<br/>不调用答案 LLM"]
CB -- "熔断中" --> FALLBACK["<b>返回降级提示</b><br/>「AI 服务暂时不可用<br/>请稍后重试」"]
T -- "需要生成" --> 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/>同步 JSON 或 SSE 正文 → sources metadata → stop / DONE<br/>引用不进入正文,不二次检索"]
FAQ --> AB
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) {
toast('流程图尚未渲染完成,请稍后再试', '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)
toast('PNG 导出受限,已降级为 SVG 矢量图下载', 'warning')
}
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)
toast('Canvas 上下文创建失败', 'error')
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()
toast('图片导出成功', '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)
toast(e.message || '导出失败', 'error')
}
}
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;
}
</style>