Ver Fonte

Merge branch 'main' of https://git.yishihui.com/howard/Agent

elksmmx há 4 semanas atrás
pai
commit
a260bfd220

+ 1 - 4
agent/tools/builtin/__init__.py

@@ -7,14 +7,11 @@
 参考版本:opencode main branch (2025-01)
 参考版本:opencode main branch (2025-01)
 """
 """
 
 
-# 文件操作工具
 from agent.tools.builtin.file.read import read_file
 from agent.tools.builtin.file.read import read_file
 from agent.tools.builtin.file.edit import edit_file
 from agent.tools.builtin.file.edit import edit_file
 from agent.tools.builtin.file.write import write_file
 from agent.tools.builtin.file.write import write_file
-from agent.tools.builtin.file.glob import glob_files
+from agent.tools.builtin.glob_tool import glob_files
 from agent.tools.builtin.file.grep import grep_content
 from agent.tools.builtin.file.grep import grep_content
-
-# 系统工具
 from agent.tools.builtin.bash import bash_command
 from agent.tools.builtin.bash import bash_command
 from agent.tools.builtin.skill import skill, list_skills
 from agent.tools.builtin.skill import skill, list_skills
 from agent.tools.builtin.subagent import subagent
 from agent.tools.builtin.subagent import subagent

+ 108 - 0
agent/tools/builtin/glob_tool.py

@@ -0,0 +1,108 @@
+"""
+Glob Tool - 文件模式匹配工具
+
+参考:vendor/opencode/packages/opencode/src/tool/glob.ts
+
+核心功能:
+- 使用 glob 模式匹配文件
+- 按修改时间排序
+- 限制返回数量
+"""
+
+import glob as glob_module
+from pathlib import Path
+from typing import Optional
+
+from agent.tools import tool, ToolResult, ToolContext
+
+# 常量
+LIMIT = 100  # 最大返回数量(参考 opencode glob.ts:35)
+
+
+@tool(description="使用 glob 模式匹配文件")
+async def glob_files(
+    pattern: str,
+    path: Optional[str] = None,
+    context: Optional[ToolContext] = None
+) -> ToolResult:
+    """
+    使用 glob 模式匹配文件
+
+    参考 OpenCode 实现
+
+    Args:
+        pattern: glob 模式(如 "*.py", "src/**/*.ts")
+        path: 搜索目录(默认当前目录)
+        context: 工具上下文
+
+    Returns:
+        ToolResult: 匹配的文件列表
+    """
+    # 确定搜索路径
+    search_path = Path(path) if path else Path.cwd()
+    if not search_path.is_absolute():
+        search_path = Path.cwd() / search_path
+
+    if not search_path.exists():
+        return ToolResult(
+            title="目录不存在",
+            output=f"搜索目录不存在: {path}",
+            error="Directory not found"
+        )
+
+    # 执行 glob 搜索
+    try:
+        # 使用 pathlib 的 glob(支持 ** 递归)
+        if "**" in pattern:
+            matches = list(search_path.glob(pattern))
+        else:
+            # 使用标准 glob(更快)
+            pattern_path = search_path / pattern
+            matches = [Path(p) for p in glob_module.glob(str(pattern_path))]
+
+        # 过滤掉目录,只保留文件
+        file_matches = [m for m in matches if m.is_file()]
+
+        # 按修改时间排序(参考 opencode:47-56)
+        file_matches_with_mtime = []
+        for file_path in file_matches:
+            try:
+                mtime = file_path.stat().st_mtime
+                file_matches_with_mtime.append((file_path, mtime))
+            except Exception:
+                file_matches_with_mtime.append((file_path, 0))
+
+        # 按修改时间降序排序(最新的在前)
+        file_matches_with_mtime.sort(key=lambda x: x[1], reverse=True)
+
+        # 限制数量
+        truncated = len(file_matches_with_mtime) > LIMIT
+        file_matches_with_mtime = file_matches_with_mtime[:LIMIT]
+
+        # 格式化输出
+        if not file_matches_with_mtime:
+            output = "未找到匹配的文件"
+        else:
+            file_paths = [str(f[0]) for f in file_matches_with_mtime]
+            output = "\n".join(file_paths)
+
+            if truncated:
+                output += f"\n\n(结果已截断。考虑使用更具体的路径或模式。)"
+
+        return ToolResult(
+            title=f"匹配: {pattern}",
+            output=output,
+            metadata={
+                "count": len(file_matches_with_mtime),
+                "truncated": truncated,
+                "pattern": pattern,
+                "search_path": str(search_path)
+            }
+        )
+
+    except Exception as e:
+        return ToolResult(
+            title="Glob 错误",
+            output=f"glob 匹配失败: {str(e)}",
+            error=str(e)
+        )

+ 24 - 9
frontend/react-template/src/App.tsx

@@ -1,9 +1,11 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
 import { TopBar } from "./components/TopBar/TopBar";
 import { TopBar } from "./components/TopBar/TopBar";
 import { MainContent } from "./components/MainContent/MainContent";
 import { MainContent } from "./components/MainContent/MainContent";
 import { DetailPanel } from "./components/DetailPanel/DetailPanel";
 import { DetailPanel } from "./components/DetailPanel/DetailPanel";
 import type { Goal } from "./types/goal";
 import type { Goal } from "./types/goal";
 import type { Edge } from "./types/message";
 import type { Edge } from "./types/message";
+import { useFlowChartData } from "./components/FlowChart/hooks/useFlowChartData";
+import { useTrace } from "./hooks/useTrace";
 import "./styles/global.css";
 import "./styles/global.css";
 
 
 function App() {
 function App() {
@@ -14,14 +16,14 @@ function App() {
   const [isDragging, setIsDragging] = useState(false);
   const [isDragging, setIsDragging] = useState(false);
   const bodyRef = useRef<HTMLDivElement | null>(null);
   const bodyRef = useRef<HTMLDivElement | null>(null);
 
 
-  const handleNodeClick = (node: Goal) => {
-    setSelectedNode(node);
-    setSelectedEdge(null);
-  };
+  // 获取数据以传递给 DetailPanel
+  const { trace } = useTrace(selectedTraceId);
+  const initialGoals = useMemo(() => trace?.goal_tree?.goals ?? [], [trace]);
+  const { msgGroups } = useFlowChartData(selectedTraceId, initialGoals);
 
 
-  const handleEdgeClick = (edge: Edge) => {
-    setSelectedEdge(edge);
-    setSelectedNode(null);
+  const handleNodeClick = (node: Goal, edge?: Edge) => {
+    setSelectedNode(node);
+    setSelectedEdge(edge || null);
   };
   };
 
 
   const handleCloseDetail = () => {
   const handleCloseDetail = () => {
@@ -29,6 +31,19 @@ function App() {
     setSelectedEdge(null);
     setSelectedEdge(null);
   };
   };
 
 
+  // 根据选中的节点获取对应的消息
+  const selectedMessages = useMemo(() => {
+    if (selectedNode) {
+      return msgGroups[selectedNode.id] || [];
+    }
+    // 如果点击的是边,且该边是主链上的边,通常我们显示源节点的消息(如果需要)
+    // 但根据用户需求 "边里面的信息,显示对应msgGroup里面的所有的子集的描述"
+    // 如果 selectedEdge 存在且 selectedNode 为空(虽然目前逻辑是联动),这里可以做处理
+    // 目前 handleNodeClick 会设置 selectedNode 为 link.source
+    // 所以 selectedNode.id 就是边的源节点 ID,直接取 msgGroups 即可
+    return [];
+  }, [selectedNode, msgGroups]);
+
   useEffect(() => {
   useEffect(() => {
     if (!isDragging) return;
     if (!isDragging) return;
     const handleMove = (event: MouseEvent) => {
     const handleMove = (event: MouseEvent) => {
@@ -61,7 +76,6 @@ function App() {
           <MainContent
           <MainContent
             traceId={selectedTraceId}
             traceId={selectedTraceId}
             onNodeClick={handleNodeClick}
             onNodeClick={handleNodeClick}
-            onEdgeClick={handleEdgeClick}
           />
           />
         </div>
         </div>
         {(selectedNode || selectedEdge) && (
         {(selectedNode || selectedEdge) && (
@@ -82,6 +96,7 @@ function App() {
               <DetailPanel
               <DetailPanel
                 node={selectedNode}
                 node={selectedNode}
                 edge={selectedEdge}
                 edge={selectedEdge}
+                messages={selectedMessages}
                 onClose={handleCloseDetail}
                 onClose={handleCloseDetail}
               />
               />
             </div>
             </div>

+ 92 - 0
frontend/react-template/src/components/DetailPanel/DetailPanel.module.css

@@ -33,6 +33,25 @@
   flex: 1;
   flex: 1;
 }
 }
 
 
+.sectionTitle {
+  font-size: 16px;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 12px;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.sectionTitle::before {
+  content: "";
+  display: block;
+  width: 4px;
+  height: 16px;
+  background: #2d72d2;
+  border-radius: 2px;
+}
+
 .section {
 .section {
   margin-bottom: 16px;
   margin-bottom: 16px;
 }
 }
@@ -48,3 +67,76 @@
   color: #333;
   color: #333;
   word-break: break-all;
   word-break: break-all;
 }
 }
+
+.toolCalls {
+  margin-top: 4px;
+}
+
+.toolCall {
+  background: #f5f5f5;
+  border-radius: 4px;
+  padding: 8px;
+  margin-bottom: 8px;
+}
+
+.toolCall:last-child {
+  margin-bottom: 0;
+}
+
+.toolName {
+  font-size: 12px;
+  font-weight: 600;
+  color: #555;
+  margin-bottom: 4px;
+}
+
+.toolArgs {
+  font-family: monospace;
+  font-size: 11px;
+  color: #666;
+  white-space: pre-wrap;
+  word-break: break-all;
+  margin: 0;
+  background: rgba(0, 0, 0, 0.03);
+  padding: 4px;
+  border-radius: 2px;
+}
+
+.messages {
+  margin-top: 24px;
+  border-top: 1px solid #eee;
+  padding-top: 16px;
+}
+
+.messageList {
+  max-height: 400px;
+  overflow-y: auto;
+  padding-right: 4px; /* Space for scrollbar */
+}
+
+/* Custom scrollbar for message list */
+.messageList::-webkit-scrollbar {
+  width: 4px;
+}
+
+.messageList::-webkit-scrollbar-thumb {
+  background: #ccc;
+  border-radius: 2px;
+}
+
+.messageList::-webkit-scrollbar-track {
+  background: transparent;
+}
+
+.messageItem {
+  background: #fff;
+  border: 1px solid #eee;
+  border-radius: 8px;
+  padding: 12px;
+  margin-bottom: 12px;
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+}
+
+.messageItem:last-child {
+  margin-bottom: 0;
+}

+ 65 - 17
frontend/react-template/src/components/DetailPanel/DetailPanel.tsx

@@ -1,15 +1,44 @@
 import type { Goal } from "../../types/goal";
 import type { Goal } from "../../types/goal";
-import type { Edge } from "../../types/message";
+import type { Edge, Message } from "../../types/message";
 import styles from "./DetailPanel.module.css";
 import styles from "./DetailPanel.module.css";
 
 
 interface DetailPanelProps {
 interface DetailPanelProps {
   node: Goal | null;
   node: Goal | null;
   edge: Edge | null;
   edge: Edge | null;
+  messages?: Message[];
   onClose: () => void;
   onClose: () => void;
 }
 }
 
 
-export const DetailPanel = ({ node, edge, onClose }: DetailPanelProps) => {
+export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelProps) => {
   const title = node ? "节点详情" : edge ? "连线详情" : "详情";
   const title = node ? "节点详情" : edge ? "连线详情" : "详情";
+
+  const renderMessageContent = (content: Message["content"]) => {
+    if (!content) return "";
+    if (typeof content === "string") return content;
+
+    // 如果有 text,优先显示 text
+    if (content.text) return content.text;
+
+    // 如果有 tool_calls,展示 tool_calls 信息
+    if (content.tool_calls && content.tool_calls.length > 0) {
+      return (
+        <div className={styles.toolCalls}>
+          {content.tool_calls.map((call) => (
+            <div
+              key={call.id}
+              className={styles.toolCall}
+            >
+              <div className={styles.toolName}>工具调用: {call.name}</div>
+              <pre className={styles.toolArgs}>{JSON.stringify(call.arguments, null, 2)}</pre>
+            </div>
+          ))}
+        </div>
+      );
+    }
+
+    return JSON.stringify(content);
+  };
+
   return (
   return (
     <aside className={styles.panel}>
     <aside className={styles.panel}>
       <div className={styles.header}>
       <div className={styles.header}>
@@ -25,35 +54,54 @@ export const DetailPanel = ({ node, edge, onClose }: DetailPanelProps) => {
       <div className={styles.content}>
       <div className={styles.content}>
         {node && (
         {node && (
           <>
           <>
+            <div className={styles.sectionTitle}>节点</div>
             <div className={styles.section}>
             <div className={styles.section}>
               <div className={styles.label}>ID</div>
               <div className={styles.label}>ID</div>
               <div className={styles.value}>{node.id}</div>
               <div className={styles.value}>{node.id}</div>
             </div>
             </div>
             <div className={styles.section}>
             <div className={styles.section}>
-              <div className={styles.label}>描述</div>
+              <div className={styles.label}>目标描述</div>
               <div className={styles.value}>{node.description}</div>
               <div className={styles.value}>{node.description}</div>
             </div>
             </div>
+            {node.reason && (
+              <div className={styles.section}>
+                <div className={styles.label}>创建理由</div>
+                <div className={styles.value}>{node.reason}</div>
+              </div>
+            )}
+            {node.summary && (
+              <div className={styles.section}>
+                <div className={styles.label}>总结</div>
+                <div className={styles.value}>{node.summary}</div>
+              </div>
+            )}
             <div className={styles.section}>
             <div className={styles.section}>
               <div className={styles.label}>状态</div>
               <div className={styles.label}>状态</div>
               <div className={styles.value}>{node.status}</div>
               <div className={styles.value}>{node.status}</div>
             </div>
             </div>
           </>
           </>
         )}
         )}
-        {edge && (
-          <>
-            <div className={styles.section}>
-              <div className={styles.label}>ID</div>
-              <div className={styles.value}>{edge.id}</div>
-            </div>
-            <div className={styles.section}>
-              <div className={styles.label}>起点</div>
-              <div className={styles.value}>{edge.source}</div>
+        {messages && messages.length > 0 && (
+          <div className={styles.messages}>
+            <div className={styles.sectionTitle}>边</div>
+            <div className={styles.messageList}>
+              {messages.map((msg, idx) => (
+                <div
+                  key={msg.id || idx}
+                  className={styles.messageItem}
+                >
+                  <div className={styles.section}>
+                    <div className={styles.label}>描述</div>
+                    <div className={styles.value}>{msg.description || "-"}</div>
+                  </div>
+                  <div className={styles.section}>
+                    <div className={styles.label}>内容</div>
+                    <div className={styles.value}>{renderMessageContent(msg.content)}</div>
+                  </div>
+                </div>
+              ))}
             </div>
             </div>
-            <div className={styles.section}>
-              <div className={styles.label}>终点</div>
-              <div className={styles.value}>{edge.target}</div>
-            </div>
-          </>
+          </div>
         )}
         )}
       </div>
       </div>
     </aside>
     </aside>

+ 143 - 90
frontend/react-template/src/components/FlowChart/FlowChart.tsx

@@ -14,7 +14,6 @@ interface FlowChartProps {
   goals: Goal[];
   goals: Goal[];
   msgGroups?: Record<string, Message[]>;
   msgGroups?: Record<string, Message[]>;
   onNodeClick?: (node: Goal, edge?: EdgeType) => void;
   onNodeClick?: (node: Goal, edge?: EdgeType) => void;
-  onEdgeClick?: (edge: EdgeType) => void;
 }
 }
 
 
 interface LayoutNode extends d3.HierarchyPointNode<Goal> {
 interface LayoutNode extends d3.HierarchyPointNode<Goal> {
@@ -25,8 +24,23 @@ interface LayoutNode extends d3.HierarchyPointNode<Goal> {
 type TreeGoal = Goal & { children?: TreeGoal[] };
 type TreeGoal = Goal & { children?: TreeGoal[] };
 const VIRTUAL_ROOT_ID = "__VIRTUAL_ROOT__";
 const VIRTUAL_ROOT_ID = "__VIRTUAL_ROOT__";
 
 
-export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeClick, onEdgeClick }) => {
-  console.log("%c [ msgGroups ]-29", "font-size:13px; background:pink; color:#bf2c9f;", msgGroups);
+export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeClick }) => {
+  // 确保 goals 中包含 END 节点
+  const displayGoals = useMemo(() => {
+    if (!goals) return [];
+    const hasEnd = goals.some((g) => g.id === "END");
+    if (hasEnd) return goals;
+    const endGoal: Goal = {
+      id: "END",
+      description: "终止",
+      status: "completed",
+      created_at: new Date().toISOString(),
+      reason: "",
+    };
+    return [...goals, endGoal];
+  }, [goals]);
+
+  console.log("%c [ goals ]-28", "font-size:13px; background:pink; color:#bf2c9f;", displayGoals);
   const svgRef = useRef<SVGSVGElement>(null);
   const svgRef = useRef<SVGSVGElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
   const [dimensions, setDimensions] = useState({ width: 1200, height: 800 });
   const [dimensions, setDimensions] = useState({ width: 1200, height: 800 });
@@ -82,8 +96,8 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
 
 
   // 计算树布局坐标(横向)
   // 计算树布局坐标(横向)
   const layoutData = useMemo(() => {
   const layoutData = useMemo(() => {
-    if (!goals || goals.length === 0) return null;
-    const root = buildHierarchy(goals);
+    if (!displayGoals || displayGoals.length === 0) return null;
+    const root = buildHierarchy(displayGoals);
     const margin = { top: 60, right: 140, bottom: 60, left: 140 };
     const margin = { top: 60, right: 140, bottom: 60, left: 140 };
     const treeLayout = d3
     const treeLayout = d3
       .tree<Goal>()
       .tree<Goal>()
@@ -95,7 +109,7 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
       links: treeData.links(),
       links: treeData.links(),
       margin,
       margin,
     };
     };
-  }, [goals, dimensions]);
+  }, [displayGoals, dimensions]);
 
 
   // 选中节点到根的路径,用于高亮与弱化
   // 选中节点到根的路径,用于高亮与弱化
   const pathNodeIds = useMemo(() => {
   const pathNodeIds = useMemo(() => {
@@ -115,18 +129,52 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
     const nonVirtualLinks = layoutData.links.filter(
     const nonVirtualLinks = layoutData.links.filter(
       (link) => link.source.data.id !== VIRTUAL_ROOT_ID && link.target.data.id !== VIRTUAL_ROOT_ID,
       (link) => link.source.data.id !== VIRTUAL_ROOT_ID && link.target.data.id !== VIRTUAL_ROOT_ID,
     );
     );
-    if (nonVirtualLinks.length > 0) return nonVirtualLinks;
+    // Remove the early return so we can merge nonVirtualLinks with manual sibling links
+    // if (nonVirtualLinks.length > 0) return nonVirtualLinks;
+
     const nodeMap = new Map(layoutData.nodes.map((node) => [node.data.id, node]));
     const nodeMap = new Map(layoutData.nodes.map((node) => [node.data.id, node]));
-    const orderedNodes = goals.map((goal) => nodeMap.get(goal.id)).filter((node): node is LayoutNode => Boolean(node));
+    const orderedNodes = displayGoals
+      .map((goal) => nodeMap.get(goal.id))
+      .filter((node): node is LayoutNode => Boolean(node));
     const links: d3.HierarchyPointLink<Goal>[] = [];
     const links: d3.HierarchyPointLink<Goal>[] = [];
+
+    // 处理主链(Sibling 顺序连接)
     for (let i = 0; i < orderedNodes.length - 1; i += 1) {
     for (let i = 0; i < orderedNodes.length - 1; i += 1) {
-      links.push({
-        source: orderedNodes[i],
-        target: orderedNodes[i + 1],
-      });
+      const current = orderedNodes[i];
+      const next = orderedNodes[i + 1];
+
+      // 如果是 END 节点前的节点,且该节点有子节点(非 END 的子节点),则不直接连接到 END
+      // 而是将子节点连接到 END
+      if (next.data.id === "END" && current.children && current.children.length > 0) {
+        // 过滤掉已经在 mainLinks 中的连接(理论上 current -> children 已经在 nonVirtualLinks 中)
+        // 这里添加 children -> END 的连接
+        current.children.forEach((child) => {
+          // 确保 child 不是 END(虽然 END 此时是 sibling)
+          if (child.data.id !== "END") {
+            links.push({
+              source: child as LayoutNode,
+              target: next,
+            });
+          }
+        });
+        // 不添加 current -> END
+        continue;
+      }
+
+      // 避免重复:如果 nonVirtualLinks 中已经存在 current -> next(即父子关系),则跳过
+      const exists = nonVirtualLinks.some(
+        (l) => l.source.data.id === current.data.id && l.target.data.id === next.data.id,
+      );
+      if (!exists) {
+        links.push({
+          source: current,
+          target: next,
+        });
+      }
     }
     }
-    return links;
-  }, [layoutData, goals]);
+
+    return [...nonVirtualLinks, ...links];
+  }, [layoutData, displayGoals]);
 
 
   // 节点点击:记录选中状态并回传对应边
   // 节点点击:记录选中状态并回传对应边
   const handleNodeClick = (node: LayoutNode) => {
   const handleNodeClick = (node: LayoutNode) => {
@@ -143,17 +191,6 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
     onNodeClick?.(node.data, edge);
     onNodeClick?.(node.data, edge);
   };
   };
 
 
-  // 边点击:构造 Edge 类型回传
-  const handleEdgeClick = (link: d3.HierarchyPointLink<Goal>) => {
-    const edge: EdgeType = {
-      id: `${link.source.data.id}-${link.target.data.id}`,
-      source: link.source.data.id,
-      target: link.target.data.id,
-      label: "",
-    };
-    onEdgeClick?.(edge);
-  };
-
   // 当前选中节点的消息链
   // 当前选中节点的消息链
   const selectedMessages = useMemo(() => {
   const selectedMessages = useMemo(() => {
     if (!selectedNodeId) return [];
     if (!selectedNodeId) return [];
@@ -165,60 +202,54 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
     if (!layoutData || !selectedNodeId || selectedMessages.length === 0) return null;
     if (!layoutData || !selectedNodeId || selectedMessages.length === 0) return null;
     const anchorNode = layoutData.nodes.find((n) => n.data.id === selectedNodeId);
     const anchorNode = layoutData.nodes.find((n) => n.data.id === selectedNodeId);
     if (!anchorNode) return null;
     if (!anchorNode) return null;
-
-    const startPt = { x: anchorNode.x, y: anchorNode.y };
+    const anchorX = anchorNode.x;
+    const anchorY = anchorNode.y;
     const count = selectedMessages.length;
     const count = selectedMessages.length;
+    const sides = count + 2;
+    const radius = Math.max(90, 44 + count * 16 + Math.max(0, count - 4) * 6);
+    const centerX = anchorX;
+    const centerY = anchorY + radius;
+    const angleStep = (Math.PI * 2) / sides;
 
 
-    const nextGoalLink = mainLinks.find((l) => l.source.data.id === selectedNodeId);
-    // 如果没有下一个节点,则虚拟一个终点在右侧,形成自然的抛物线
-    const endPt = nextGoalLink
-      ? { x: nextGoalLink.target.x, y: nextGoalLink.target.y }
-      : { x: startPt.x + 400 + Math.max(0, count - 4) * 80, y: startPt.y };
-
-    // 计算悬链线控制点 (Quadratic Bezier)
-    // 深度随节点数增加,确保不拥挤
-    // 当节点数 > 4 时,额外增加深度以拉开间距
-    const depth = 120 + count * 35 + Math.max(0, count - 4) * 40;
-    const midX = (startPt.x + endPt.x) / 2;
-    const bottomY = Math.max(startPt.y, endPt.y);
-    const controlPt = { x: midX, y: bottomY + depth };
-
-    const getQuadraticBezierPoint = (
-      t: number,
-      p0: { x: number; y: number },
-      p1: { x: number; y: number },
-      p2: { x: number; y: number },
-    ) => {
-      const oneMinusT = 1 - t;
+    const msgNodes = selectedMessages.map((message, index) => {
+      const angle = -Math.PI / 2 - angleStep * (index + 1);
       return {
       return {
-        x: oneMinusT * oneMinusT * p0.x + 2 * oneMinusT * t * p1.x + t * t * p2.x,
-        y: oneMinusT * oneMinusT * p0.y + 2 * oneMinusT * t * p1.y + t * t * p2.y,
+        x: centerX + Math.cos(angle) * radius,
+        y: centerY + Math.sin(angle) * radius,
+        message,
       };
       };
+    });
+
+    const nextGoalLink = mainLinks.find((l) => l.source.data.id === selectedNodeId);
+    const nextGoalTarget = nextGoalLink?.target;
+
+    // 节点半高度,用于计算下边界连接点
+    const nodeHalfH = 5;
+
+    const paths: { d: string; dashed?: boolean; terminal?: boolean }[] = [];
+    const buildSegment = (sx: number, sy: number, tx: number, ty: number) => {
+      const controlX = (sx + tx) / 2 - 40;
+      const controlY = Math.max(sy, ty) - 30;
+      return `M${sx},${sy} Q${controlX},${controlY} ${tx},${ty}`;
     };
     };
 
 
-    // 采样点:起点 -> 消息点... -> 终点
-    const totalSegments = count + 1;
-    const points = [startPt];
-    const msgNodes = [];
-
-    for (let i = 1; i <= count; i += 1) {
-      const t = i / totalSegments;
-      const pos = getQuadraticBezierPoint(t, startPt, controlPt, endPt);
-      msgNodes.push({
-        x: pos.x,
-        y: pos.y,
-        message: selectedMessages[i - 1],
-      });
-      points.push(pos);
+    for (let i = -1; i < msgNodes.length - 1; i += 1) {
+      // 起始点:如果是第一个点(i < 0),则从 anchorNode 的下边界开始 (anchorY + nodeHalfH)
+      // 否则从上一个 msgNode 开始
+      const from = i < 0 ? { x: anchorX, y: anchorY + nodeHalfH } : msgNodes[i];
+      const to = msgNodes[i + 1];
+      const d = buildSegment(from.x, from.y, to.x, to.y);
+      paths.push({ d, dashed: true, terminal: true });
     }
     }
-    points.push(endPt);
 
 
-    const paths: { d: string; dashed?: boolean; terminal?: boolean }[] = [];
-    for (let i = 0; i < points.length - 1; i += 1) {
-      const p1 = points[i];
-      const p2 = points[i + 1];
-      // 使用直线段连接采样点,形成带箭头的折线链,整体逼近曲线
-      const d = `M${p1.x},${p1.y} L${p2.x},${p2.y}`;
+    if (nextGoalTarget && msgNodes.length > 0) {
+      const last = msgNodes[msgNodes.length - 1];
+      const tx = nextGoalTarget.x;
+      // 结束点:连接到 nextGoalTarget 的下边界 (ty + nodeHalfH)
+      const ty = nextGoalTarget.y + nodeHalfH;
+      const controlX = (last.x + tx) / 2;
+      const controlY = Math.max(last.y, ty) - 2;
+      const d = `M${last.x},${last.y} Q${controlX},${controlY} ${tx},${ty}`;
       paths.push({ d, dashed: true, terminal: true });
       paths.push({ d, dashed: true, terminal: true });
     }
     }
 
 
@@ -303,11 +334,13 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
                         const dimmed = false;
                         const dimmed = false;
                         return (
                         return (
                           <Edge
                           <Edge
-                            key={index}
+                            key={`edge-line-${index}`}
                             link={link}
                             link={link}
+                            label={link.target.data.reason}
                             highlighted={isInPath}
                             highlighted={isInPath}
                             dimmed={dimmed}
                             dimmed={dimmed}
-                            onClick={() => handleEdgeClick(link)}
+                            onClick={() => handleNodeClick(link.source)}
+                            mode="line"
                           />
                           />
                         );
                         );
                       })}
                       })}
@@ -330,6 +363,30 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
                         );
                         );
                       })}
                       })}
                   </g>
                   </g>
+                  <g className={styles.links}>
+                    {mainLinks
+                      .filter(
+                        (link) => link.source.data.id !== VIRTUAL_ROOT_ID && link.target.data.id !== VIRTUAL_ROOT_ID,
+                      )
+                      .map((link, index) => {
+                        const isInPath =
+                          pathNodeIds.size > 0 &&
+                          pathNodeIds.has(link.source.data.id) &&
+                          pathNodeIds.has(link.target.data.id);
+                        const dimmed = false;
+                        return (
+                          <Edge
+                            key={`edge-label-${index}`}
+                            link={link}
+                            label={link.target.data.reason}
+                            highlighted={isInPath}
+                            dimmed={dimmed}
+                            onClick={() => handleNodeClick(link.source)}
+                            mode="label"
+                          />
+                        );
+                      })}
+                  </g>
                   {messageOverlay && (
                   {messageOverlay && (
                     <g>
                     <g>
                       {messageOverlay.paths.map((p, idx) => (
                       {messageOverlay.paths.map((p, idx) => (
@@ -347,28 +404,24 @@ export const FlowChart: FC<FlowChartProps> = ({ goals, msgGroups = {}, onNodeCli
                       ))}
                       ))}
                       {messageOverlay.msgNodes.map((mn, idx) =>
                       {messageOverlay.msgNodes.map((mn, idx) =>
                         (() => {
                         (() => {
-                          const fullText = mn.message.description || mn.message.content || "";
-                          const shortText = fullText.length > 6 ? `${fullText.slice(0, 6)}...` : fullText;
-                          const isLast = idx === messageOverlay.msgNodes.length - 1;
+                          const content = mn.message.content;
+                          let contentStr = "";
+                          if (typeof content === "string") {
+                            contentStr = content;
+                          } else if (content) {
+                            contentStr = content.text || JSON.stringify(content);
+                          }
+                          const fullText = mn.message.description || contentStr || "";
+                          const truncateMiddle = (text: string, limit: number) => {
+                            if (text.length <= limit) return text;
+                            return `${text.slice(0, 2)}...${text.slice(-2)}`;
+                          };
+                          const shortText = truncateMiddle(fullText, 6);
                           return (
                           return (
                             <g
                             <g
                               key={`msg-node-${idx}`}
                               key={`msg-node-${idx}`}
                               transform={`translate(${mn.x},${mn.y})`}
                               transform={`translate(${mn.x},${mn.y})`}
                             >
                             >
-                              {isLast ? (
-                                <path
-                                  d="M-2,-4 L6,0 L-2,4 Z"
-                                  fill="#7aa0d6"
-                                />
-                              ) : (
-                                <circle
-                                  r={4.5}
-                                  fill="#7aa0d6"
-                                  stroke="#ffffff"
-                                  strokeWidth={2}
-                                />
-                              )}
-
                               <Tooltip content={fullText}>
                               <Tooltip content={fullText}>
                                 <text
                                 <text
                                   x={0}
                                   x={0}

+ 85 - 17
frontend/react-template/src/components/FlowChart/components/Edge.tsx

@@ -2,17 +2,25 @@ import type { FC } from "react";
 import * as d3 from "d3";
 import * as d3 from "d3";
 import type { Goal } from "../../../types/goal";
 import type { Goal } from "../../../types/goal";
 import styles from "../styles/Edge.module.css";
 import styles from "../styles/Edge.module.css";
+import { Tooltip } from "@douyinfe/semi-ui";
 
 
 // 主链连接线:根据目标状态决定颜色与箭头,支持选中高亮
 // 主链连接线:根据目标状态决定颜色与箭头,支持选中高亮
 interface EdgeProps {
 interface EdgeProps {
   link: d3.HierarchyPointLink<Goal>;
   link: d3.HierarchyPointLink<Goal>;
+  label?: string;
   highlighted: boolean;
   highlighted: boolean;
   dimmed: boolean;
   dimmed: boolean;
   onClick: () => void;
   onClick: () => void;
+  mode?: "line" | "label";
 }
 }
 
 
-export const Edge: FC<EdgeProps> = ({ link, highlighted, dimmed, onClick }) => {
+export const Edge: FC<EdgeProps> = ({ link, label, highlighted, dimmed, onClick, mode = "line" }) => {
   const { source, target } = link;
   const { source, target } = link;
+  const truncateMiddle = (text: string, limit: number) => {
+    console.log("%c [ text ]-20", "font-size:13px; background:pink; color:#bf2c9f;", text);
+    if (text.length <= limit) return text;
+    return `${text.slice(0, 2)}...${text.slice(-2)}`;
+  };
 
 
   const createPath = () => {
   const createPath = () => {
     const sourceX = source.x;
     const sourceX = source.x;
@@ -57,23 +65,83 @@ export const Edge: FC<EdgeProps> = ({ link, highlighted, dimmed, onClick }) => {
     }
     }
   };
   };
 
 
-  return (
-    <g className={`${styles.edge} ${highlighted ? styles.selected : ""} ${dimmed ? styles.dimmed : ""}`}>
-      <path
-        d={createPath()}
-        fill="none"
-        stroke="transparent"
-        strokeWidth={24}
-        onClick={onClick}
+  const renderLabel = () => {
+    if (!label) return null;
+    const sourceX = source.x;
+    const sourceY = source.y;
+    const targetX = target.x;
+    const targetY = target.y;
+    const nodeHalfW = 80;
+    const direction = targetX >= sourceX ? 1 : -1;
+    const startX = sourceX + direction * nodeHalfW;
+    const endX = targetX - direction * nodeHalfW;
+    const midX = (startX + endX) / 2;
+    const midY = (sourceY + targetY) / 2;
+
+    const truncated = truncateMiddle(label, 6);
+
+    return (
+      <g
+        transform={`translate(${midX},${midY})`}
         style={{ cursor: "pointer" }}
         style={{ cursor: "pointer" }}
-      />
-      <path
-        d={createPath()}
-        fill="none"
-        stroke={getStrokeColor()}
-        strokeWidth={3}
-        markerEnd={getMarkerUrl()}
-      />
+      >
+        <title>{label}</title>
+        <rect
+          x={-30}
+          y={-10}
+          width={60}
+          height={20}
+          rx={4}
+          fill="#fff"
+          fillOpacity={1}
+          stroke="#fff"
+          strokeWidth={1}
+        />
+        <Tooltip
+          content={label}
+          position="top"
+        >
+          <text
+            textAnchor="middle"
+            dy="0.35em"
+            fontSize={11}
+            fill="#1f2937"
+            style={{ userSelect: "none" }}
+          >
+            {truncated}
+          </text>
+        </Tooltip>
+      </g>
+    );
+  };
+
+  return (
+    <g className={styles.edge}>
+      {mode === "line" && (
+        <>
+          <path
+            d={createPath()}
+            fill="none"
+            stroke="transparent"
+            strokeWidth={15}
+            style={{ cursor: "pointer" }}
+            onClick={(e) => {
+              e.stopPropagation();
+              onClick();
+            }}
+          />
+          <path
+            d={createPath()}
+            fill="none"
+            stroke={getStrokeColor()}
+            strokeWidth={highlighted ? 3 : 2}
+            markerEnd={getMarkerUrl()}
+            opacity={dimmed ? 0.2 : 1}
+            style={{ pointerEvents: "none" }}
+          />
+        </>
+      )}
+      {mode === "label" && renderLabel()}
     </g>
     </g>
   );
   );
 };
 };

+ 13 - 7
frontend/react-template/src/components/FlowChart/components/Node.tsx

@@ -15,25 +15,31 @@ interface NodeProps {
 export const Node: FC<NodeProps> = ({ node, selected, highlighted, dimmed, onClick }) => {
 export const Node: FC<NodeProps> = ({ node, selected, highlighted, dimmed, onClick }) => {
   const { x, y, data } = node;
   const { x, y, data } = node;
 
 
-  const truncateText = (text: string, maxLength: number): string => {
-    return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
+  const truncateMiddle = (text: string, limit: number) => {
+    if (text.length <= limit) return text;
+    const half = Math.floor((limit - 3) / 2);
+    return `${text.slice(0, half)}...${text.slice(-half)}`;
   };
   };
 
 
+  const isEndNode = data.id === "END";
+
   return (
   return (
     <g
     <g
-      className={`${styles.node} ${selected ? styles.selected : ""} ${dimmed ? styles.dimmed : ""}`}
+      className={`${styles.node} ${selected ? styles.selected : ""} ${dimmed ? styles.dimmed : ""} ${isEndNode ? styles.endNode : ""}`}
       transform={`translate(${x},${y})`}
       transform={`translate(${x},${y})`}
       onClick={onClick}
       onClick={onClick}
       style={{ cursor: "pointer" }}
       style={{ cursor: "pointer" }}
     >
     >
+      <title>{data.description}</title>
       <rect
       <rect
         x={-70}
         x={-70}
         y={-26}
         y={-26}
         width={140}
         width={140}
         height={52}
         height={52}
         rx={8}
         rx={8}
-        fill="transparent"
-        stroke="none"
+        fill={isEndNode ? "#fff" : "transparent"}
+        stroke={isEndNode ? "#ff4d4f" : "none"}
+        strokeDasharray={isEndNode ? "4 4" : "none"}
       />
       />
       <text
       <text
         textAnchor="middle"
         textAnchor="middle"
@@ -41,9 +47,9 @@ export const Node: FC<NodeProps> = ({ node, selected, highlighted, dimmed, onCli
         fontSize={16}
         fontSize={16}
         fill={dimmed ? "#999" : "#333"}
         fill={dimmed ? "#999" : "#333"}
         fontWeight={selected || highlighted ? 600 : 400}
         fontWeight={selected || highlighted ? 600 : 400}
-        style={{ opacity: dimmed ? 0.35 : 1 }}
+        style={{ opacity: dimmed ? 0.35 : 1, pointerEvents: "none" }}
       >
       >
-        {truncateText(data.description || data.id, 16)}
+        {truncateMiddle(data.description || data.id, 10)}
       </text>
       </text>
       {data.status === "running" && (
       {data.status === "running" && (
         <circle
         <circle

+ 2 - 11
frontend/react-template/src/components/MainContent/MainContent.tsx

@@ -10,10 +10,9 @@ import styles from "./MainContent.module.css";
 interface MainContentProps {
 interface MainContentProps {
   traceId: string | null;
   traceId: string | null;
   onNodeClick?: (node: Goal, edge?: Edge) => void;
   onNodeClick?: (node: Goal, edge?: Edge) => void;
-  onEdgeClick?: (edge: Edge) => void;
 }
 }
 
 
-export const MainContent: FC<MainContentProps> = ({ traceId, onNodeClick, onEdgeClick }) => {
+export const MainContent: FC<MainContentProps> = ({ traceId, onNodeClick }) => {
   const { trace, loading } = useTrace(traceId);
   const { trace, loading } = useTrace(traceId);
   const initialGoals = useMemo(() => trace?.goal_tree?.goals ?? [], [trace]);
   const initialGoals = useMemo(() => trace?.goal_tree?.goals ?? [], [trace]);
   const { goals, connected, msgGroups } = useFlowChartData(traceId, initialGoals);
   const { goals, connected, msgGroups } = useFlowChartData(traceId, initialGoals);
@@ -35,17 +34,10 @@ export const MainContent: FC<MainContentProps> = ({ traceId, onNodeClick, onEdge
   return (
   return (
     <div className={styles.main}>
     <div className={styles.main}>
       <div className={styles.header}>
       <div className={styles.header}>
-        <div className={styles.title}>{trace?.task || "流程图"}</div>
+        <div className={styles.title}></div>
         <div className={styles.headerRight}>
         <div className={styles.headerRight}>
           <div className={styles.status}>{connected ? "WebSocket 已连接" : "WebSocket 未连接"}</div>
           <div className={styles.status}>{connected ? "WebSocket 已连接" : "WebSocket 未连接"}</div>
           <div className={styles.legend}>
           <div className={styles.legend}>
-            <div className={styles.legendItem}>
-              <span
-                className={styles.legendDot}
-                style={{ background: "#ff6b6b" }}
-              />
-              选中
-            </div>
             <div className={styles.legendItem}>
             <div className={styles.legendItem}>
               <span
               <span
                 className={styles.legendDot}
                 className={styles.legendDot}
@@ -87,7 +79,6 @@ export const MainContent: FC<MainContentProps> = ({ traceId, onNodeClick, onEdge
             goals={goals}
             goals={goals}
             msgGroups={msgGroups}
             msgGroups={msgGroups}
             onNodeClick={onNodeClick}
             onNodeClick={onNodeClick}
-            onEdgeClick={onEdgeClick}
           />
           />
         )}
         )}
       </div>
       </div>

+ 2 - 2
frontend/react-template/src/components/TopBar/TopBar.tsx

@@ -64,13 +64,13 @@ export const TopBar: FC<TopBarProps> = ({ onTraceSelect }) => {
           <option value="completed">已完成</option>
           <option value="completed">已完成</option>
           <option value="failed">失败</option>
           <option value="failed">失败</option>
         </select>
         </select>
-        <button
+        {/* <button
           onClick={() => loadTraces(statusFilter)}
           onClick={() => loadTraces(statusFilter)}
           className={styles.button}
           className={styles.button}
           disabled={loading}
           disabled={loading}
         >
         >
           {loading ? "加载中..." : "刷新"}
           {loading ? "加载中..." : "刷新"}
-        </button>
+        </button> */}
       </div>
       </div>
     </header>
     </header>
   );
   );

+ 1 - 0
frontend/react-template/src/types/goal.ts

@@ -1,6 +1,7 @@
 export interface Goal {
 export interface Goal {
   id: string;
   id: string;
   description: string;
   description: string;
+  reason?: string;
   status: "pending" | "running" | "completed" | "failed";
   status: "pending" | "running" | "completed" | "failed";
   summary?: string;
   summary?: string;
   parent_id?: string;
   parent_id?: string;

+ 12 - 1
frontend/react-template/src/types/message.ts

@@ -1,9 +1,20 @@
+export interface ToolCall {
+  id: string;
+  name: string;
+  arguments: Record<string, unknown>;
+}
+
+export interface MessageContent {
+  text?: string;
+  tool_calls?: ToolCall[];
+}
+
 export interface Message {
 export interface Message {
   id?: string;
   id?: string;
   message_id?: string;
   message_id?: string;
   goal_id?: string;
   goal_id?: string;
   role?: string;
   role?: string;
-  content?: string;
+  content?: string | MessageContent;
   description?: string;
   description?: string;
   tokens?: number | null;
   tokens?: number | null;
 }
 }