|
@@ -0,0 +1,822 @@
|
|
|
|
|
+<!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>
|