|
|
|
@ -1,11 +1,19 @@ |
|
|
|
<template> |
|
|
|
<t-card :bordered="false"> |
|
|
|
<template #header> |
|
|
|
<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 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> |
|
|
|
|
|
|
|
<!-- 图例 --> |
|
|
|
@ -32,6 +40,8 @@ |
|
|
|
|
|
|
|
<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' |
|
|
|
|
|
|
|
@ -161,12 +171,128 @@ 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; |
|
|
|
|