| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822 |
- <!doctype html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8" />
- <meta
- name="viewport"
- content="width=device-width, initial-scale=1.0"
- />
- <title>Trace 执行流程可视化</title>
- <script src="https://d3js.org/d3.v7.min.js"></script>
- <style>
- body {
- margin: 0;
- padding: 20px;
- font-family: "Microsoft YaHei", Arial, sans-serif;
- background: #f5f5f5;
- }
- .main-container {
- display: flex;
- gap: 20px;
- height: calc(100vh - 40px);
- }
- .container {
- flex: 1;
- background: white;
- border-radius: 8px;
- padding: 20px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
- overflow: hidden;
- display: flex;
- flex-direction: column;
- }
- /* 顶部导航栏 */
- .top-nav {
- margin-bottom: 20px;
- padding: 15px;
- background: #f8f9fa;
- border-radius: 8px;
- border: 1px solid #dee2e6;
- }
- .top-nav h1 {
- margin: 0 0 15px 0;
- color: #333;
- font-size: 24px;
- }
- .filter-section {
- display: flex;
- gap: 15px;
- align-items: center;
- flex-wrap: wrap;
- }
- .filter-item {
- display: flex;
- align-items: center;
- gap: 8px;
- }
- .filter-item label {
- font-weight: bold;
- color: #333;
- font-size: 14px;
- }
- .filter-item select,
- .filter-item button {
- padding: 8px 12px;
- font-size: 14px;
- border: 1px solid #ced4da;
- border-radius: 4px;
- background: white;
- cursor: pointer;
- }
- .filter-item select:hover,
- .filter-item button:hover {
- border-color: #4e79a7;
- }
- .filter-item button {
- background: #4e79a7;
- color: white;
- border-color: #4e79a7;
- }
- .filter-item button:hover {
- background: #356391;
- }
- /* 主体内容区域 */
- #chart {
- flex: 1;
- min-height: 0;
- position: relative;
- }
- svg {
- width: 100%;
- height: 100%;
- border: 1px solid #ddd;
- border-radius: 4px;
- background: #fff;
- }
- /* 节点样式 */
- .node {
- cursor: pointer;
- }
- .node rect {
- fill: transparent;
- stroke: none;
- }
- .node.selected rect {
- stroke: #ff6b6b;
- stroke-width: 2px;
- stroke-dasharray: 5, 5;
- }
- .node text {
- font-size: 14px;
- fill: #000;
- text-anchor: middle;
- dominant-baseline: middle;
- pointer-events: none;
- }
- .node.trace_root text {
- font-size: 16px;
- font-weight: bold;
- }
- /* 连线样式 */
- .link {
- fill: none;
- stroke: #5ba85f;
- stroke-width: 2px;
- cursor: pointer;
- }
- .link.highlighted {
- stroke: #ff6b6b;
- stroke-width: 3px;
- }
- .link-text {
- font-size: 12px;
- fill: #5ba85f;
- font-weight: bold;
- text-anchor: middle;
- pointer-events: auto;
- cursor: pointer;
- text-shadow:
- -2px -2px 0 #fff,
- 2px -2px 0 #fff,
- -2px 2px 0 #fff,
- 2px 2px 0 #fff;
- }
- /* 右侧详情面板 */
- .resizer {
- width: 8px;
- background: #e0e0e0;
- cursor: col-resize;
- flex-shrink: 0;
- position: relative;
- transition: background 0.2s;
- }
- .resizer:hover {
- background: #4e79a7;
- }
- .resizer::before {
- content: "";
- position: absolute;
- left: 50%;
- top: 0;
- bottom: 0;
- width: 2px;
- background: #999;
- transform: translateX(-50%);
- }
- .resizer:hover::before {
- background: #4e79a7;
- }
- .detail-panel {
- width: 400px;
- min-width: 250px;
- max-width: 60%;
- background: white;
- border-radius: 8px;
- padding: 20px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
- overflow-y: auto;
- max-height: calc(100vh - 40px);
- flex-shrink: 0;
- transition:
- width 0.2s ease,
- padding 0.2s ease;
- }
- .detail-panel.collapsed {
- width: 32px;
- min-width: 32px;
- max-width: 32px;
- padding: 8px 4px;
- overflow: hidden;
- }
- .detail-panel.collapsed #detail-content {
- display: none;
- }
- .detail-panel.collapsed h2 {
- margin: 0;
- padding: 0;
- border-bottom: none;
- writing-mode: vertical-rl;
- text-orientation: mixed;
- font-size: 12px;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .detail-panel h2 {
- margin-top: 0;
- margin-bottom: 15px;
- color: #333;
- font-size: 18px;
- border-bottom: 2px solid #4e79a7;
- padding-bottom: 10px;
- display: flex;
- align-items: center;
- gap: 8px;
- }
- .detail-toggle {
- margin-left: auto;
- padding: 2px 8px;
- font-size: 12px;
- border: 1px solid #4e79a7;
- border-radius: 4px;
- background: #f0f4f8;
- color: #4e79a7;
- cursor: pointer;
- }
- .detail-toggle:hover {
- background: #e1e9f0;
- }
- .detail-content {
- color: #666;
- line-height: 1.6;
- }
- .detail-item {
- margin-bottom: 20px;
- }
- .detail-item label {
- font-weight: bold;
- color: #333;
- display: block;
- margin-bottom: 8px;
- font-size: 14px;
- }
- .detail-item-value {
- color: #666;
- word-break: break-word;
- font-size: 14px;
- line-height: 1.6;
- padding: 10px;
- background: #f8f9fa;
- border-radius: 4px;
- }
- .detail-empty {
- color: #999;
- font-style: italic;
- text-align: center;
- padding: 40px 20px;
- }
- /* 统计信息样式 */
- .stats-grid {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: 10px;
- margin-top: 10px;
- }
- .stat-item {
- padding: 8px;
- background: #f0f4f8;
- border-radius: 4px;
- text-align: center;
- }
- .stat-label {
- font-size: 12px;
- color: #666;
- margin-bottom: 4px;
- }
- .stat-value {
- font-size: 16px;
- font-weight: bold;
- color: #4e79a7;
- }
- /* 消息列表样式 */
- .message-list {
- margin-top: 10px;
- }
- .message-item {
- padding: 12px;
- margin-bottom: 10px;
- background: #f8f9fa;
- border-radius: 4px;
- border-left: 3px solid #4e79a7;
- }
- .message-item.assistant {
- border-left-color: #4caf50;
- }
- .message-item.tool {
- border-left-color: #ff9800;
- }
- .message-header {
- display: flex;
- justify-content: space-between;
- margin-bottom: 8px;
- font-size: 12px;
- color: #666;
- }
- .message-role {
- font-weight: bold;
- color: #333;
- }
- .message-content {
- font-size: 13px;
- color: #666;
- line-height: 1.5;
- }
- .tool-calls {
- margin-top: 8px;
- padding: 8px;
- background: #fff;
- border-radius: 4px;
- font-size: 12px;
- }
- .tool-call-item {
- margin-bottom: 4px;
- }
- .tool-call-name {
- font-weight: bold;
- color: #ff9800;
- }
- </style>
- </head>
- <body>
- <div class="main-container">
- <div class="container">
- <!-- 顶部导航栏 -->
- <div class="top-nav">
- <h1 id="task-title">Trace 执行流程可视化</h1>
- <div class="filter-section">
- <div class="filter-item">
- <label for="status-filter">状态筛选:</label>
- <select id="status-filter">
- <option value="">全部</option>
- <option value="running">运行中</option>
- <option value="completed">已完成</option>
- <option value="failed">失败</option>
- </select>
- </div>
- <div class="filter-item">
- <label for="trace-select">Trace 选择:</label>
- <select id="trace-select">
- <!-- 动态填充 -->
- </select>
- </div>
- <div class="filter-item">
- <button id="refresh-btn">刷新</button>
- </div>
- </div>
- </div>
- <!-- 主体内容区域 -->
- <div id="chart"></div>
- </div>
- <!-- 拖拽调整器 -->
- <div
- class="resizer"
- id="resizer"
- ></div>
- <!-- 右侧详情面板 -->
- <div
- class="detail-panel"
- id="detail-panel"
- >
- <h2>
- <span>详情信息</span>
- <button
- id="detail-toggle"
- class="detail-toggle"
- >
- 收起
- </button>
- </h2>
- <div
- id="detail-content"
- class="detail-content"
- >
- <div class="detail-empty">点击节点或边查看详情</div>
- </div>
- </div>
- </div>
- <script>
- const rawTraceListData = "__TRACE_LIST_DATA__";
- const traceListData =
- typeof rawTraceListData === "string" && rawTraceListData === "__TRACE_LIST_DATA__"
- ? { traces: [] }
- : rawTraceListData;
- const rawTraceDetailData = "__TRACE_DETAIL_DATA__";
- const traceDetailData =
- typeof rawTraceDetailData === "string" && rawTraceDetailData === "__TRACE_DETAIL_DATA__"
- ? { trace_id: "mock", goal_tree: { goals: [] } }
- : rawTraceDetailData;
- let currentTraceId = traceDetailData.trace_id;
- let selectedNode = null;
- let svg, g, zoom;
- // 初始化
- function init() {
- // 更新标题
- updateTitle();
- // 渲染图表
- renderGraph();
- // 绑定事件
- bindEvents();
- }
- // 更新标题
- function updateTitle() {
- const traces = Array.isArray(traceListData)
- ? traceListData
- : Array.isArray(traceListData.traces)
- ? traceListData.traces
- : traceListData.trace_id
- ? [traceListData]
- : [];
- const trace = traces.find((t) => t.trace_id === currentTraceId);
- if (trace) {
- document.getElementById("task-title").textContent = trace.task;
- }
- }
- // 渲染图表
- function renderGraph() {
- const chartDiv = document.getElementById("chart");
- const width = chartDiv.offsetWidth || 1000;
- const height = chartDiv.offsetHeight || 700;
- // 清除之前的内容
- d3.select("#chart").selectAll("svg").remove();
- // 创建 SVG
- svg = d3
- .select("#chart")
- .append("svg")
- .attr("viewBox", `0 0 ${width} ${height}`)
- .attr("preserveAspectRatio", "xMidYMid meet");
- // 定义箭头
- svg
- .append("defs")
- .append("marker")
- .attr("id", "arrowhead")
- .attr("viewBox", "0 -5 10 10")
- .attr("refX", 10)
- .attr("refY", 0)
- .attr("markerWidth", 6)
- .attr("markerHeight", 6)
- .attr("orient", "auto")
- .append("path")
- .attr("d", "M0,-5L10,0L0,5")
- .attr("fill", "#4e79a7");
- g = svg.append("g");
- const goals =
- traceDetailData.goal_tree && Array.isArray(traceDetailData.goal_tree.goals)
- ? traceDetailData.goal_tree.goals
- : Array.isArray(traceDetailData.goals)
- ? traceDetailData.goals
- : [];
- const nodeMap = new Map();
- // 1. 创建虚拟根节点
- const virtualRoot = {
- id: "VIRTUAL_ROOT",
- children: [],
- };
- // 2. 构建节点映射
- goals.forEach((goal) => {
- nodeMap.set(goal.id, {
- ...goal,
- children: [],
- });
- });
- // 3. 构建树结构 - 还原 parent_id 关系,多根节点挂载到虚拟根节点
- goals.forEach((goal) => {
- const node = nodeMap.get(goal.id);
- if (goal.parent_id === null) {
- // 根节点(parent_id 为 null),挂载到虚拟根节点
- virtualRoot.children.push(node);
- } else {
- // 子节点,挂载到对应的父节点
- const parent = nodeMap.get(goal.parent_id);
- if (parent) {
- parent.children.push(node);
- } else {
- // 如果找不到父节点,作为根节点处理(容错)
- console.warn(`Parent node ${goal.parent_id} not found for goal ${goal.id}`);
- virtualRoot.children.push(node);
- }
- }
- });
- // 创建层次结构 - 从虚拟根节点开始
- const root = d3.hierarchy(virtualRoot);
- // 创建树布局
- // 注意:由于隐藏了第一层(虚拟根节点),实际显示的节点从第二层开始
- // 我们需要调整布局大小,或者在绘制时进行坐标偏移
- const treeLayout = d3
- .tree()
- .size([height - 100, width - 200])
- .separation((a, b) => (a.parent == b.parent ? 1.2 : 1.5)); // 增加节点间距
- treeLayout(root);
- // 过滤掉虚拟根节点及其连接的边
- const nodesData = root.descendants().filter((d) => d.depth > 0);
- const linksData = root.links().filter((d) => d.source.depth > 0);
- // 绘制连线
- const linkGroups = g.selectAll(".link-group").data(linksData).enter().append("g").attr("class", "link-group");
- linkGroups
- .append("path")
- .attr("class", "link")
- .attr("d", (d) => {
- // 调整坐标:减去虚拟根节点带来的层级偏移
- // 假设每一层级大约占用的宽度,这里我们简单地减去一定偏移量让其靠左
- // 但更好的方式是依赖 fitToView 自动调整
- const sourceX = d.source.y + 100 + 80;
- const sourceY = d.source.x + 50;
- const targetX = d.target.y + 100 - 80;
- const targetY = d.target.x + 50;
- return `M${sourceX},${sourceY} C${(sourceX + targetX) / 2},${sourceY} ${(sourceX + targetX) / 2},${targetY} ${targetX},${targetY}`;
- })
- .attr("marker-end", "url(#arrowhead)")
- .on("click", function (event, d) {
- event.stopPropagation();
- showEdgeDetail(d);
- });
- // 添加连线文字
- linkGroups
- .append("text")
- .attr("class", "link-text")
- .attr("x", (d) => {
- const sourceX = d.source.y + 100 + 80;
- const targetX = d.target.y + 100 - 80;
- return (sourceX + targetX) / 2;
- })
- .attr("y", (d) => {
- const sourceY = d.source.x + 50;
- const targetY = d.target.x + 50;
- return (sourceY + targetY) / 2 - 5;
- })
- .text((d) => d.target.data.edgeLabel || "");
- // 绘制节点
- const nodes = g
- .selectAll(".node")
- .data(nodesData)
- .enter()
- .append("g")
- .attr("class", (d) => `node ${d.data.status} ${d.data.type}`)
- .attr("transform", (d) => `translate(${d.y + 100},${d.x + 50})`)
- .on("click", function (event, d) {
- event.stopPropagation();
- selectNode(d);
- });
- nodes.append("rect").attr("x", -80).attr("y", -30).attr("width", 160).attr("height", 60);
- nodes
- .append("text")
- .attr("dy", 5)
- .text((d) => d.data.description.substring(0, 10) + (d.data.description.length > 10 ? "..." : ""));
- // 添加缩放功能
- zoom = d3
- .zoom()
- .scaleExtent([0.1, 5])
- .on("zoom", function (event) {
- g.attr("transform", event.transform);
- });
- svg.call(zoom);
- // 自动缩放以适应屏幕
- setTimeout(() => {
- fitToView();
- }, 0);
- }
- // 自动缩放以适应屏幕
- function fitToView() {
- if (!g || !svg) return;
- const bounds = g.node().getBBox();
- if (bounds.width === 0 || bounds.height === 0) return;
- const chartDiv = document.getElementById("chart");
- const width = chartDiv.offsetWidth;
- const height = chartDiv.offsetHeight;
- const scale = 0.85 / Math.max(bounds.width / width, bounds.height / height);
- const clampedScale = Math.max(0.1, Math.min(3, scale));
- const centerX = bounds.x + bounds.width / 2;
- const centerY = bounds.y + bounds.height / 2;
- svg.call(
- zoom.transform,
- d3.zoomIdentity
- .translate(width / 2 - clampedScale * centerX, height / 2 - clampedScale * centerY)
- .scale(clampedScale),
- );
- }
- // 选择节点
- function selectNode(d) {
- // 移除之前的选中状态
- d3.selectAll(".node").classed("selected", false);
- // 添加选中状态
- d3.select(event.currentTarget).classed("selected", true);
- selectedNode = d;
- // 高亮路径
- highlightPath(d);
- // 显示详情
- showNodeDetail(d);
- }
- // 高亮路径
- function highlightPath(d) {
- // 移除之前的高亮
- d3.selectAll(".link").classed("highlighted", false);
- // 找到从根节点到当前节点的路径
- const path = d.ancestors().reverse();
- // 高亮路径上的连线
- d3.selectAll(".link").each(function (linkData) {
- const sourceInPath = path.includes(linkData.source);
- const targetInPath = path.includes(linkData.target);
- if (sourceInPath && targetInPath) {
- d3.select(this).classed("highlighted", true);
- }
- });
- }
- // 显示节点详情
- function showNodeDetail(d) {
- const detailContent = document.getElementById("detail-content");
- const goal = d.data;
- let html = `
- <div class="detail-item">
- <label>节点 ID:</label>
- <div class="detail-item-value">${goal.id}</div>
- </div>
- <div class="detail-item">
- <label>描述:</label>
- <div class="detail-item-value">${goal.description}</div>
- </div>
- <div class="detail-item">
- <label>创建理由:</label>
- <div class="detail-item-value">${goal.reason}</div>
- </div>
- <div class="detail-item">
- <label>状态:</label>
- <div class="detail-item-value">${goal.status}</div>
- </div>
- `;
- if (goal.summary) {
- html += `
- <div class="detail-item">
- <label>总结:</label>
- <div class="detail-item-value">${goal.summary}</div>
- </div>
- `;
- }
- // 显示统计信息
- if (goal.self_stats) {
- html += `
- <div class="detail-item">
- <label>当前节点统计:</label>
- <div class="stats-grid">
- <div class="stat-item">
- <div class="stat-label">消息数</div>
- <div class="stat-value">${goal.self_stats.message_count}</div>
- </div>
- <div class="stat-item">
- <div class="stat-label">Token 数</div>
- <div class="stat-value">${goal.self_stats.total_tokens}</div>
- </div>
- <div class="stat-item">
- <div class="stat-label">成本</div>
- <div class="stat-value">$${goal.self_stats.total_cost.toFixed(3)}</div>
- </div>
- <div class="stat-item">
- <div class="stat-label">预览</div>
- <div class="stat-value" style="font-size: 12px;">${goal.self_stats.preview || "-"}</div>
- </div>
- </div>
- </div>
- `;
- }
- if (goal.cumulative_stats) {
- html += `
- <div class="detail-item">
- <label>累计统计:</label>
- <div class="stats-grid">
- <div class="stat-item">
- <div class="stat-label">消息数</div>
- <div class="stat-value">${goal.cumulative_stats.message_count}</div>
- </div>
- <div class="stat-item">
- <div class="stat-label">Token 数</div>
- <div class="stat-value">${goal.cumulative_stats.total_tokens}</div>
- </div>
- <div class="stat-item">
- <div class="stat-label">成本</div>
- <div class="stat-value">$${goal.cumulative_stats.total_cost.toFixed(3)}</div>
- </div>
- <div class="stat-item">
- <div class="stat-label">预览</div>
- <div class="stat-value" style="font-size: 12px;">${goal.cumulative_stats.preview || "-"}</div>
- </div>
- </div>
- </div>
- `;
- }
- detailContent.innerHTML = html;
- }
- // 显示边详情
- function showEdgeDetail(d) {
- const detailContent = document.getElementById("detail-content");
- const html = `
- <div class="detail-item">
- <label>连线:</label>
- <div class="detail-item-value">${d.source.data.description} → ${d.target.data.description}</div>
- </div>
- <div class="detail-item">
- <label>目标节点:</label>
- <div class="detail-item-value">${d.target.data.description}</div>
- </div>
- `;
- detailContent.innerHTML = html;
- }
- // 绑定事件
- function bindEvents() {
- // Trace 选择器
- document.getElementById("trace-select").addEventListener("change", function () {
- currentTraceId = this.value;
- updateTitle();
- renderGraph();
- });
- // 刷新按钮
- document.getElementById("refresh-btn").addEventListener("click", function () {
- renderGraph();
- });
- // 详情面板收起/展开
- const detailToggle = document.getElementById("detail-toggle");
- const detailPanel = document.getElementById("detail-panel");
- let isDetailCollapsed = false;
- detailToggle.addEventListener("click", function () {
- isDetailCollapsed = !isDetailCollapsed;
- if (isDetailCollapsed) {
- detailPanel.classList.add("collapsed");
- detailToggle.textContent = "展开";
- } else {
- detailPanel.classList.remove("collapsed");
- detailToggle.textContent = "收起";
- }
- setTimeout(() => {
- renderGraph();
- }, 0);
- });
- // 窗口大小变化
- let resizeTimer;
- window.addEventListener("resize", function () {
- clearTimeout(resizeTimer);
- resizeTimer = setTimeout(function () {
- renderGraph();
- }, 250);
- });
- }
- // 启动
- init();
- </script>
- </body>
- </html>
|