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
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>
|