فهرست منبع

Merge remote-tracking branch 'refs/remotes/origin/main'

Talegorithm 1 هفته پیش
والد
کامیت
13d780f5d4

+ 18 - 19
frontend/react-template/src/App.tsx

@@ -5,7 +5,6 @@ import { DetailPanel } from "./components/DetailPanel/DetailPanel";
 import type { Goal } from "./types/goal";
 import type { Edge, Message } from "./types/message";
 import { useFlowChartData } from "./components/FlowChart/hooks/useFlowChartData";
-import { useTrace } from "./hooks/useTrace";
 import "./styles/global.css";
 
 function App() {
@@ -20,9 +19,7 @@ function App() {
   const bodyRef = useRef<HTMLDivElement | null>(null);
 
   // 获取数据以传递给 DetailPanel
-  const { trace } = useTrace(selectedTraceId);
-  const initialGoals = useMemo(() => trace?.goal_tree?.goals ?? [], [trace]);
-  const { msgGroups } = useFlowChartData(selectedTraceId, initialGoals, messageRefreshTrigger);
+  const { msgGroups } = useFlowChartData(selectedTraceId, messageRefreshTrigger);
 
   const handleNodeClick = (node: Goal | Message, edge?: Edge) => {
     setSelectedNode(node);
@@ -58,7 +55,7 @@ function App() {
       const rect = bodyRef.current?.getBoundingClientRect();
       if (!rect) return;
       const next = rect.right - event.clientX;
-      const clamped = Math.min(500, Math.max(240, next));
+      const clamped = Math.min(800, Math.max(240, next));
       setRightWidth(clamped);
     };
     const handleUp = () => {
@@ -74,23 +71,25 @@ function App() {
 
   return (
     <div className="app">
+      <div className="app-top">
+        <TopBar
+          selectedTraceId={selectedTraceId}
+          selectedNode={selectedNode}
+          title={selectedTraceTitle}
+          onTraceSelect={(id, title) => {
+            setSelectedTraceId(id);
+            if (title) setSelectedTraceTitle(title);
+          }}
+          onTraceCreated={() => setRefreshTrigger((t) => t + 1)}
+          onMessageInserted={() => setMessageRefreshTrigger((t) => t + 1)}
+        />
+      </div>
       <div
         className="app-body"
         ref={bodyRef}
         style={{ userSelect: isDragging ? "none" : "auto" }}
       >
         <div className="app-main">
-          <TopBar
-            selectedTraceId={selectedTraceId}
-            selectedNode={selectedNode}
-            title={selectedTraceTitle}
-            onTraceSelect={(id, title) => {
-              setSelectedTraceId(id);
-              if (title) setSelectedTraceTitle(title);
-            }}
-            onTraceCreated={() => setRefreshTrigger((t) => t + 1)}
-            onMessageInserted={() => setMessageRefreshTrigger((t) => t + 1)}
-          />
           <MainContent
             traceId={selectedTraceId}
             onNodeClick={handleNodeClick}
@@ -111,16 +110,16 @@ function App() {
               aria-orientation="vertical"
               aria-valuenow={rightWidth}
               aria-valuemin={240}
-              aria-valuemax={500}
+              aria-valuemax={800}
             />
             <div
               className="app-right"
               style={{ width: rightWidth }}
             >
               <DetailPanel
-                node={selectedNode && isGoalNode(selectedNode) ? (selectedNode as Goal) : null}
+                node={selectedNode}
                 edge={selectedEdge}
-                messages={selectedMessages}
+                messages={selectedMessages as Message[]}
                 onClose={handleCloseDetail}
               />
             </div>

+ 67 - 76
frontend/react-template/src/components/DetailPanel/DetailPanel.module.css

@@ -1,141 +1,132 @@
 .panel {
   width: 100%;
   height: 100%;
-  /* border-left: 1px solid var(--border-color, #e0e0e0); */
-  background: #ffffff;
+  background: var(--bg-panel);
   display: flex;
   flex-direction: column;
+  box-shadow: -1px 0 0 0 var(--border-light);
 }
 
 .header {
-  padding: 12px 16px;
-  border-bottom: 1px solid var(--border-color, #e0e0e0);
+  height: var(--topbar-height);
+  padding: 0 var(--space-lg);
+  border-bottom: 1px solid var(--border-light);
   display: flex;
   align-items: center;
   justify-content: space-between;
+  flex-shrink: 0;
 }
 
 .title {
-  font-size: 14px;
+  font-size: 16px;
   font-weight: 600;
-  color: var(--text-primary, #333);
+  color: var(--text-primary);
 }
 
 .close {
   border: none;
   background: transparent;
-  font-size: 16px;
+  width: 28px;
+  height: 28px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 20px;
+  color: var(--text-tertiary);
   cursor: pointer;
+  border-radius: var(--radius-sm);
+  transition: all var(--transition-fast);
+}
+
+.close:hover {
+  background: var(--bg-surface-hover);
+  color: var(--text-secondary);
 }
 
 .content {
-  padding: 16px;
-  overflow: auto;
+  padding: var(--space-lg);
+  overflow-y: auto;
+  overflow-x: hidden;
   flex: 1;
 }
 
 .sectionTitle {
-  font-size: 16px;
+  font-size: 14px;
   font-weight: 600;
-  color: #333;
-  margin-bottom: 12px;
+  color: var(--text-secondary);
+  margin: var(--space-lg) 0 var(--space-md);
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
   display: flex;
   align-items: center;
-  gap: 8px;
 }
 
-.sectionTitle::before {
-  content: "";
-  display: block;
-  width: 4px;
-  height: 16px;
-  background: #2d72d2;
-  border-radius: 2px;
+.sectionTitle:first-child {
+  margin-top: 0;
 }
 
 .section {
-  margin-bottom: 16px;
+  margin-bottom: var(--space-lg);
 }
 
 .label {
   font-size: 12px;
-  color: #666;
-  margin-bottom: 6px;
+  font-weight: 500;
+  color: var(--text-tertiary);
+  margin-bottom: var(--space-xs);
 }
 
 .value {
-  font-size: 13px;
-  color: #333;
-  word-break: break-all;
+  font-size: 14px;
+  line-height: 1.6;
+  color: var(--text-primary);
+  word-break: break-word;
+  background: var(--bg-surface-hover);
+  padding: var(--space-sm) var(--space-md);
+  border-radius: var(--radius-md);
+  border: 1px solid var(--border-light);
 }
 
 .toolCalls {
-  margin-top: 4px;
+  display: flex;
+  flex-direction: column;
+  gap: var(--space-sm);
 }
 
 .toolCall {
-  background: #f5f5f5;
-  border-radius: 4px;
-  padding: 8px;
-  margin-bottom: 8px;
-}
-
-.toolCall:last-child {
-  margin-bottom: 0;
+  background: var(--bg-node-goal);
+  border: 1px solid var(--color-primary);
+  border-radius: var(--radius-md);
+  padding: var(--space-sm);
 }
 
 .toolName {
-  font-size: 12px;
   font-weight: 600;
-  color: #555;
-  margin-bottom: 4px;
+  color: var(--color-primary);
+  margin-bottom: var(--space-xs);
+  font-size: 13px;
 }
 
 .toolArgs {
-  font-family: monospace;
-  font-size: 11px;
-  color: #666;
-  white-space: pre-wrap;
-  word-break: break-all;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+  font-size: 12px;
+  background: rgba(255, 255, 255, 0.5);
+  padding: var(--space-xs) var(--space-sm);
+  border-radius: var(--radius-sm);
   margin: 0;
-  background: rgba(0, 0, 0, 0.03);
-  padding: 4px;
-  border-radius: 2px;
+  white-space: pre-wrap;
+  color: var(--text-secondary);
 }
 
 .messages {
-  margin-top: 24px;
-  border-top: 1px solid #eee;
-  padding-top: 16px;
-}
-
-.messageList {
-  /* max-height removed to allow full content scrolling */
-}
-
-/* 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;
+  display: flex;
+  flex-direction: column;
+  gap: var(--space-md);
 }
 
 .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);
+  border-left: 2px solid var(--border-medium);
+  padding-left: var(--space-md);
 }
 
-.messageItem:last-child {
-  margin-bottom: 0;
-}
+/* Removed old styles */

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

@@ -3,7 +3,7 @@ import type { Edge, Message } from "../../types/message";
 import styles from "./DetailPanel.module.css";
 
 interface DetailPanelProps {
-  node: Goal | null;
+  node: Goal | Message | null;
   edge: Edge | null;
   messages?: Message[];
   onClose: () => void;
@@ -39,6 +39,13 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
     return JSON.stringify(content);
   };
 
+  const isGoal = (node: Goal | Message): node is Goal => {
+    return "status" in node;
+  };
+
+  const isMessageNode = (node: Goal | Message): node is Message =>
+    "message_id" in node || "role" in node || "content" in node || "goal_id" in node || "tokens" in node;
+
   return (
     <aside className={styles.panel}>
       <div className={styles.header}>
@@ -57,28 +64,30 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
             <div className={styles.sectionTitle}>节点</div>
             <div className={styles.section}>
               <div className={styles.label}>ID</div>
-              <div className={styles.value}>{node.id}</div>
+              <div className={styles.value}>{isMessageNode(node) ? node.message_id || node.id : node.id}</div>
             </div>
             <div className={styles.section}>
               <div className={styles.label}>目标描述</div>
               <div className={styles.value}>{node.description}</div>
             </div>
-            {node.reason && (
+            {isGoal(node) && node.reason && (
               <div className={styles.section}>
                 <div className={styles.label}>创建理由</div>
                 <div className={styles.value}>{node.reason}</div>
               </div>
             )}
-            {node.summary && (
+            {isGoal(node) && 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.label}>状态</div>
-              <div className={styles.value}>{node.status}</div>
-            </div>
+            {isGoal(node) && (
+              <div className={styles.section}>
+                <div className={styles.label}>状态</div>
+                <div className={styles.value}>{node.status}</div>
+              </div>
+            )}
           </>
         )}
         {messages && messages.length > 0 && (

+ 119 - 25
frontend/react-template/src/components/FlowChart/FlowChart.tsx

@@ -23,6 +23,7 @@ import { Tooltip } from "@douyinfe/semi-ui";
 interface FlowChartProps {
   goals: Goal[]; // 目标节点列表
   msgGroups?: Record<string, Message[]>; // 消息组,key 是 goal_id
+  invalidBranches?: Message[][]; // 失效分支列表
   onNodeClick?: (node: Goal | Message, edge?: EdgeType) => void; // 节点点击回调
   onSubTraceClick?: (parentGoal: Goal, entry: SubTraceEntry) => void; // 子追踪点击回调
 }
@@ -52,6 +53,7 @@ interface LayoutNode {
   type: "goal" | "subgoal" | "message"; // 节点类型
   level: number; // 嵌套层级(0 表示主链节点,1 表示子节点,2 表示孙节点...)
   parentId?: string; // 父节点 ID
+  isInvalid?: boolean; // 是否为失效节点
 }
 
 /**
@@ -67,13 +69,13 @@ interface LayoutEdge {
   collapsible: boolean; // 是否可折叠
   collapsed: boolean; // 是否已折叠
   children?: LayoutNode[]; // 折叠时隐藏的子节点列表
+  isInvalid?: boolean; // 是否为失效连接线
 }
 
 const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps> = (
-  { goals, msgGroups = {}, onNodeClick, onSubTraceClick },
+  { goals, msgGroups = {}, invalidBranches, onNodeClick, onSubTraceClick },
   ref,
 ) => {
-  console.log("%c [ msgGroups ]-33", "font-size:13px; background:pink; color:#bf2c9f;", msgGroups);
   // 过滤掉有父节点的 goals,只保留主链节点
   goals = goals.filter((g) => !g.parent_id);
 
@@ -276,7 +278,6 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
         allNodes: result.nodes,
       });
     });
-    console.log("%c [ displayGoals ]-261", "font-size:13px; background:pink; color:#bf2c9f;", displayGoals);
 
     /**
      * 生成连接线
@@ -291,7 +292,6 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
      * 3. 递归处理所有层级的节点
      */
 
-    console.log("%c [ mainChainInfo ]-285", "font-size:13px; background:pink; color:#bf2c9f;", mainChainInfo);
     for (let i = 0; i < mainChainInfo.length - 1; i++) {
       const current = mainChainInfo[i];
       const next = mainChainInfo[i + 1];
@@ -495,8 +495,90 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
       }
     }
 
+    // 处理失效分支(invalidBranches)
+    if (invalidBranches && invalidBranches.length > 0) {
+      const validMsgMap = new Map<number, LayoutNode>();
+      nodes.forEach((n) => {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        const seq = (n.data as any).sequence;
+        if (typeof seq === "number") {
+          validMsgMap.set(seq, n);
+        }
+      });
+
+      // Map to store invalid nodes by their anchor parent ID
+      const invalidNodesByAnchor = new Map<string, LayoutNode[]>();
+
+      invalidBranches.forEach((branch) => {
+        if (branch.length === 0) return;
+        const firstMsg = branch[0];
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        const pSeq = (firstMsg as any).parent_sequence;
+
+        if (typeof pSeq === "number") {
+          const parentNode = validMsgMap.get(pSeq);
+          if (parentNode) {
+            let currentParent = parentNode;
+            const X_OFFSET = -200; // 向左偏移
+            const currentBranchNodes: LayoutNode[] = [];
+
+            branch.forEach((msg, idx) => {
+              const nodeId = `invalid-${msg.id || Math.random()}`;
+              const node: LayoutNode = {
+                id: nodeId,
+                x: parentNode.x + X_OFFSET,
+                y: parentNode.y + (idx + 1) * NODE_HEIGHT,
+                data: msg,
+                type: "message",
+                level: parentNode.level,
+                parentId: parentNode.id,
+                isInvalid: true,
+              };
+              nodes.push(node);
+              currentBranchNodes.push(node);
+
+              edges.push({
+                id: `edge-${currentParent.id}-${node.id}`,
+                source: currentParent,
+                target: node,
+                type: "line",
+                level: 0,
+                collapsible: false,
+                collapsed: false,
+                isInvalid: true,
+              });
+
+              currentParent = node;
+            });
+
+            // Store in map
+            if (!invalidNodesByAnchor.has(parentNode.id)) {
+              invalidNodesByAnchor.set(parentNode.id, []);
+            }
+            invalidNodesByAnchor.get(parentNode.id)!.push(...currentBranchNodes);
+          }
+        }
+      });
+
+      // Associate invalid nodes with collapsible edges
+      // If a parent node is hidden (part of a collapsed edge), its invalid children should also be hidden
+      edges.forEach((edge) => {
+        if (edge.collapsible && edge.children) {
+          const extraChildren: LayoutNode[] = [];
+          edge.children.forEach((child) => {
+            if (invalidNodesByAnchor.has(child.id)) {
+              extraChildren.push(...invalidNodesByAnchor.get(child.id)!);
+            }
+          });
+          if (extraChildren.length > 0) {
+            edge.children.push(...extraChildren);
+          }
+        }
+      });
+    }
+
     return { nodes, edges };
-  }, [displayGoals, dimensions, msgGroups, collapsedEdges]);
+  }, [displayGoals, dimensions, msgGroups, collapsedEdges, invalidBranches]);
 
   // 暴露给父组件的方法
   useImperativeHandle(
@@ -844,13 +926,13 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
 
                 if (sourceIsMessage || targetIsMessage) {
                   // msgGroup 相关的连接线用灰色
-                  color = "#9E9E9E";
+                  color = "#94a3b8"; // Slate 400
                 } else if (sourceIsMainGoal && targetIsMainGoal) {
                   // 主节点之间的连接线用绿色
-                  color = "#4CAF50";
+                  color = "#10b981"; // Emerald 500
                 } else {
                   // sub_goals 之间的连接线用蓝色
-                  color = "#2196F3";
+                  color = "#3b82f6"; // Blue 500
                 }
 
                 return (
@@ -858,9 +940,10 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
                     <path
                       d={path}
                       fill="none"
-                      stroke={color}
+                      stroke={edge.isInvalid ? "#cbd5e1" : color} // 失效边使用浅灰色 (Slate 300)
                       strokeWidth={strokeWidth}
-                      markerEnd="url(#arrow-default)" // 箭头
+                      strokeDasharray={edge.isInvalid ? "5,5" : undefined} // 失效边使用虚线
+                      markerEnd={edge.isInvalid ? undefined : "url(#arrow-default)"} // 失效边不显示箭头
                       style={{ cursor: edge.collapsible ? "pointer" : "default" }} // 可折叠的显示手型光标
                       onClick={() => edge.collapsible && toggleCollapse(edge.id)} // 点击切换折叠状态
                     />
@@ -906,11 +989,15 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
                 const data = node.data as Goal;
                 const text = isGoal ? data.description : (node.data as Message).description || "";
 
-                let textColor = "#2196F3"; // 默认蓝色
+                let textColor = "#3b82f6"; // Blue 500
                 if (node.type === "message") {
-                  textColor = "#9E9E9E"; // 消息节点灰色
+                  textColor = "#64748b"; // Slate 500
                 } else if (node.type === "goal" && node.level === 0) {
-                  textColor = "#4CAF50"; // 主节点绿色
+                  textColor = "#10b981"; // Emerald 500
+                }
+
+                if (node.isInvalid) {
+                  textColor = "#94a3b8"; // Slate 400
                 }
 
                 return (
@@ -927,28 +1014,35 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
                       width={150}
                       height={50}
                       rx={8}
-                      fill={isGoal ? "#E3F2FD" : "#F5F5F5"} // 目标节点浅蓝色,消息节点灰色
-                      stroke={selectedNodeId === node.id ? "#2196F3" : "#BDBDBD"} // 选中节点蓝色边框
+                      fill={isGoal ? "#eff6ff" : "#f8fafc"} // Blue 50 / Slate 50
+                      stroke={selectedNodeId === node.id ? "#3b82f6" : node.isInvalid ? "#cbd5e1" : "#e2e8f0"} // Selected: Blue 500, Invalid: Slate 300, Default: Slate 200
                       strokeWidth={selectedNodeId === node.id ? 2 : 1}
+                      strokeDasharray={node.isInvalid ? "5,5" : undefined} // 失效节点虚线边框
+                      style={{
+                        filter:
+                          selectedNodeId === node.id
+                            ? "drop-shadow(0 4px 6px rgb(59 130 246 / 0.3))"
+                            : "drop-shadow(0 1px 2px rgb(0 0 0 / 0.05))",
+                      }}
                     />
                     {/* 节点文本(带 Tooltip) */}
-                    <Tooltip content={text}>
-                      <foreignObject
-                        x={-70}
-                        y={-25}
-                        width={150}
-                        height={50}
-                      >
+                    <foreignObject
+                      x={-70}
+                      y={-25}
+                      width={150}
+                      height={50}
+                    >
+                      <Tooltip content={text}>
                         <div
                           className="w-full h-full overflow-hidden flex items-center justify-center"
                           style={{
                             color: textColor,
                           }}
                         >
-                          <text className="text-xs line-clamp-3 px-1">{text}</text>
+                          <span className="text-xs line-clamp-3 px-1">{text}</span>
                         </div>
-                      </foreignObject>
-                    </Tooltip>
+                      </Tooltip>
+                    </foreignObject>
                   </g>
                 );
               })}

+ 60 - 33
frontend/react-template/src/components/FlowChart/hooks/useFlowChartData.ts

@@ -40,28 +40,26 @@ const buildSubGoals = (flatGoals: Goal[]): Goal[] => {
   });
 };
 
+import { processRetryLogic } from "../utils/retryLogic";
+
 // FlowChart 专用数据 Hook:处理实时事件并聚合消息组
-export const useFlowChartData = (traceId: string | null, initialGoals: Goal[], refreshTrigger?: number) => {
-  const [goals, setGoals] = useState<Goal[]>(initialGoals);
+export const useFlowChartData = (traceId: string | null, refreshTrigger?: number) => {
+  const [goals, setGoals] = useState<Goal[]>([]);
   const [messages, setMessages] = useState<Message[]>([]);
   const [msgGroups, setMsgGroups] = useState<Record<string, Message[]>>({});
   const [sinceEventId, setSinceEventId] = useState(0);
+  const [readyToConnect, setReadyToConnect] = useState(false);
   const currentEventIdRef = useRef(0);
   const restReloadingRef = useRef(false);
   const [reloading, setReloading] = useState(false);
+  const [invalidBranches, setInvalidBranches] = useState<Message[][]>([]);
 
-  const messageSortKey = useCallback((message: Message): number => {
-    const mid =
-      typeof message.message_id === "string"
-        ? message.message_id
-        : typeof message.id === "string"
-          ? message.id
-          : undefined;
-    if (!mid) return 0;
-    if (!mid.includes("-")) return 0;
-    const suffix = mid.slice(mid.lastIndexOf("-") + 1);
-    const num = Number.parseInt(suffix, 10);
-    return Number.isFinite(num) ? num : 0;
+  const messageComparator = useCallback((a: Message, b: Message): number => {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const seqA = typeof (a as any).sequence === "number" ? (a as any).sequence : 0;
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const seqB = typeof (b as any).sequence === "number" ? (b as any).sequence : 0;
+    return seqA - seqB;
   }, []);
 
   const updateMessageGroups = useCallback(
@@ -82,21 +80,19 @@ export const useFlowChartData = (traceId: string | null, initialGoals: Goal[], r
       setMsgGroups((prev) => {
         const existing = prev[groupKey] ? [...prev[groupKey]] : [];
         existing.push(message);
-        existing.sort((a, b) => messageSortKey(a) - messageSortKey(b));
+        existing.sort(messageComparator);
         return { ...prev, [groupKey]: existing };
       });
     },
-    [messageSortKey],
+    [messageComparator],
   );
 
   useEffect(() => {
-    setGoals(initialGoals);
-  }, [initialGoals]);
-
-  useEffect(() => {
+    setGoals([]);
     setMessages([]);
     setMsgGroups({});
     setSinceEventId(0);
+    setReadyToConnect(false);
     currentEventIdRef.current = 0;
     restReloadingRef.current = false;
   }, [traceId]);
@@ -150,17 +146,23 @@ export const useFlowChartData = (traceId: string | null, initialGoals: Goal[], r
         const json = (await messagesRes.json()) as unknown;
         const root = isRecord(json) ? json : {};
         const list = Array.isArray(root.messages) ? (root.messages as Message[]) : [];
+        console.log("%c [ list ]-149", "font-size:13px; background:pink; color:#bf2c9f;", list);
+
         const filtered = list.filter((message) => (message as { status?: string }).status !== "abandoned");
-        const nextMessages = [...filtered].sort((a, b) => messageSortKey(a) - messageSortKey(b));
-        setMessages(nextMessages);
+        const nextMessages = [...filtered].sort(messageComparator);
+
+        const { availableData: finalMessages, invalidBranches: invalidBranchesTemp } = processRetryLogic(nextMessages);
+
+        setMessages(finalMessages);
+        setInvalidBranches(invalidBranchesTemp);
         const grouped: Record<string, Message[]> = {};
-        nextMessages.forEach((message) => {
+        finalMessages.forEach((message) => {
           const groupKey = typeof message.goal_id === "string" && message.goal_id ? message.goal_id : "START";
           if (!grouped[groupKey]) grouped[groupKey] = [];
           grouped[groupKey].push(message);
         });
         Object.keys(grouped).forEach((key) => {
-          grouped[key].sort((a, b) => messageSortKey(a) - messageSortKey(b));
+          grouped[key].sort(messageComparator);
         });
         setMsgGroups(grouped);
 
@@ -177,18 +179,42 @@ export const useFlowChartData = (traceId: string | null, initialGoals: Goal[], r
           });
         }
       }
+
+      // REST 请求完成后,允许建立 WebSocket 连接
+      setReadyToConnect(true);
     } finally {
       restReloadingRef.current = false;
       setReloading(false);
     }
     return nextSinceEventId;
-  }, [messageSortKey, traceId]);
+  }, [messageComparator, traceId]);
+
+  const prevTraceIdRef = useRef<string | null>(null);
+  const prevRefreshTriggerRef = useRef<number | undefined>(undefined);
 
   useEffect(() => {
-    if (!traceId) return;
-    if (!refreshTrigger) return;
-    void reloadViaRest();
-  }, [refreshTrigger, reloadViaRest, traceId]);
+    // 确保 traceId 存在
+    if (!traceId) {
+      prevTraceIdRef.current = null;
+      return;
+    }
+
+    // 检查是否发生了变化
+    const traceChanged = traceId !== prevTraceIdRef.current;
+    const refreshTriggerChanged = refreshTrigger !== prevRefreshTriggerRef.current;
+
+    // 只有当 traceId 真正变化,或者 refreshTrigger 真正变化时,才执行加载
+    if (traceChanged || (typeof refreshTrigger === "number" && refreshTrigger > 0 && refreshTriggerChanged)) {
+      prevTraceIdRef.current = traceId;
+      prevRefreshTriggerRef.current = refreshTrigger;
+
+      // 注意:traceId 变化时,另外一个清理 useEffect 也会执行,这里只负责触发加载
+      // 这里不直接调用,而是通过一个 setTimeout 0 来确保清理操作已经完成
+      // 但实际上清理操作是在副作用执行前发生的(对于同一组件)
+
+      void reloadViaRest();
+    }
+  }, [traceId, refreshTrigger, reloadViaRest]); // 添加 reloadViaRest 到依赖列表,但由于我们用了 ref 来控制,所以不会因为它的变化而重复执行
 
   const handleWebSocketMessage = useCallback(
     (payload: unknown) => {
@@ -312,14 +338,14 @@ export const useFlowChartData = (traceId: string | null, initialGoals: Goal[], r
         if (message) {
           setMessages((prev) => {
             const next = [...prev, message];
-            next.sort((a, b) => messageSortKey(a) - messageSortKey(b));
+            next.sort(messageComparator);
             return next;
           });
           updateMessageGroups(message);
         }
       }
     },
-    [messageSortKey, reloadViaRest, updateMessageGroups],
+    [messageComparator, reloadViaRest, updateMessageGroups],
   );
 
   // 主 Trace 连接
@@ -327,7 +353,8 @@ export const useFlowChartData = (traceId: string | null, initialGoals: Goal[], r
     () => ({ onMessage: handleWebSocketMessage, sinceEventId }),
     [handleWebSocketMessage, sinceEventId],
   );
-  const { connected } = useWebSocket(traceId, wsOptions);
+  // 只有当 traceId 存在且 REST 加载完成 (readyToConnect) 后才连接 WebSocket
+  const { connected } = useWebSocket(readyToConnect ? traceId : null, wsOptions);
 
-  return { goals, messages, msgGroups, connected, reloading };
+  return { goals, messages, msgGroups, connected, reloading, invalidBranches };
 };

+ 35 - 18
frontend/react-template/src/components/FlowChart/styles/FlowChart.module.css

@@ -1,8 +1,9 @@
 .container {
   width: 100%;
   height: 100%;
-  background: #fafafa;
+  background: var(--bg-app);
   position: relative;
+  overflow: hidden;
 }
 
 .scrollContainer {
@@ -13,46 +14,62 @@
 .svg {
   width: 100%;
   height: 100%;
-  background: #fff;
+  background-image: radial-gradient(var(--border-light) 1px, transparent 1px);
+  background-size: 20px 20px;
+  background-color: var(--bg-app);
   cursor: grab;
   overscroll-behavior: contain;
 }
+
 .panning {
   cursor: grabbing;
 }
+
 .links path {
-  transition: all 0.2s ease;
+  transition: all var(--transition-normal);
 }
+
 .nodes text {
   pointer-events: none;
 }
 
 .controls {
   position: absolute;
-  right: 16px;
-  bottom: 16px;
+  right: var(--space-lg);
+  bottom: var(--space-lg);
   display: flex;
-  gap: 8px;
-  background: rgba(255, 255, 255, 0.9);
-  border: 1px solid #e5e7eb;
-  border-radius: 10px;
-  padding: 6px;
-  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
+  gap: var(--space-sm);
+  background: var(--bg-surface);
+  border: 1px solid var(--border-light);
+  border-radius: var(--radius-lg);
+  padding: var(--space-xs);
+  box-shadow: var(--shadow-lg);
   z-index: 10;
 }
 
 .controlButton {
-  min-width: 36px;
+  min-width: 32px;
   height: 32px;
-  border: 1px solid #d0d7de;
-  border-radius: 8px;
-  background: #fff;
-  color: #333;
+  padding: 0 var(--space-sm);
+  border: 1px solid transparent;
+  border-radius: var(--radius-md);
+  background: transparent;
+  color: var(--text-secondary);
   font-size: 14px;
+  font-weight: 500;
   cursor: pointer;
-  position: relative;
+  transition: all var(--transition-fast);
+  display: flex;
+  align-items: center;
+  justify-content: center;
 }
 
 .controlButton:hover {
-  background: #f5f7fb;
+  background: var(--bg-surface-hover);
+  color: var(--text-primary);
+}
+
+.controlButton:active {
+  background: var(--border-light);
 }
+/* Removed old styles */

+ 32 - 0
frontend/react-template/src/components/FlowChart/utils/retryLogic.ts

@@ -0,0 +1,32 @@
+import { Message } from "../../../types/message";
+
+export const processRetryLogic = (
+  messages: Message[],
+): { availableData: Message[]; invalidBranches: Message[][] } => {
+  const invalidBranches: Message[][] = [];
+  const parentLastIndexMap = new Map<number, number>();
+  const invalidIndices = new Set<number>();
+
+  messages.forEach((msg, index) => {
+    const pSeq = msg.parent_sequence;
+    if (typeof pSeq === "number") {
+      if (parentLastIndexMap.has(pSeq)) {
+        const lastIndex = parentLastIndexMap.get(pSeq)!;
+        // 提取失效分支:从上一次出现位置到当前位置之前
+        const invalidBranch = messages.slice(lastIndex, index);
+        if (invalidBranch.length > 0) {
+          invalidBranches.push(invalidBranch);
+          for (let k = lastIndex; k < index; k++) {
+            invalidIndices.add(k);
+          }
+        }
+        parentLastIndexMap.set(pSeq, index);
+      } else {
+        parentLastIndexMap.set(pSeq, index);
+      }
+    }
+  });
+
+  const availableData = messages.filter((_, index) => !invalidIndices.has(index));
+  return { availableData, invalidBranches };
+};

+ 45 - 44
frontend/react-template/src/components/MainContent/MainContent.module.css

@@ -3,86 +3,87 @@
   display: flex;
   flex-direction: column;
   overflow: hidden;
+  background: var(--bg-app);
 }
 
 .header {
-  padding: 12px 16px;
-  border-bottom: 1px solid var(--border-color, #e0e0e0);
+  height: var(--topbar-height);
+  padding: 0 var(--space-lg);
+  border-bottom: 1px solid var(--border-light);
   display: flex;
   align-items: center;
   justify-content: space-between;
-  background: #fff;
+  background: var(--bg-surface);
+  flex-shrink: 0;
+  box-shadow: var(--shadow-sm);
+  z-index: 5;
 }
 
 .title {
   font-size: 14px;
-  color: var(--text-primary, #333);
+  font-weight: 500;
+  color: var(--text-secondary);
 }
 
 .status {
   font-size: 12px;
-  color: #666;
+  color: var(--text-tertiary);
 }
 
 .headerRight {
   display: flex;
   align-items: center;
-  gap: 16px;
+  gap: var(--space-md);
 }
 
-.legend {
-  display: flex;
+.btn {
+  display: inline-flex;
   align-items: center;
-  gap: 12px;
+  justify-content: center;
+  height: 32px;
+  padding: 0 var(--space-md);
+  border-radius: var(--radius-md);
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all var(--transition-fast);
+  border: 1px solid var(--border-medium);
+  background: var(--bg-surface);
+  color: var(--text-secondary);
+  outline: none;
 }
 
-.legendItem {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-  font-size: 12px;
-  color: #666;
+.btn:hover {
+  background: var(--bg-surface-hover);
+  color: var(--text-primary);
+  border-color: var(--border-medium);
 }
 
-.legendDot {
-  width: 10px;
-  height: 10px;
-  border-radius: 50%;
-  display: inline-block;
+.btn:active {
+  background: var(--border-light);
 }
 
 .content {
   flex: 1;
   overflow: hidden;
-  background: #fafafa;
-  display: flex;
-  flex-direction: column;
   position: relative;
+  background: var(--bg-app);
 }
 
-.empty {
-  color: #999;
+.loading {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+  color: var(--text-tertiary);
   font-size: 14px;
-  margin: auto;
 }
 
-.buttons {
+.empty {
   display: flex;
-  gap: 8px;
-}
-
-.btn {
-  padding: 4px 12px;
-  border: 1px solid #d9d9d9;
-  border-radius: 4px;
-  background: #fff;
-  cursor: pointer;
-  font-size: 12px;
-  color: #333;
-  transition: all 0.3s;
-}
-
-.btn:hover {
-  color: #1890ff;
-  border-color: #1890ff;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+  color: var(--text-tertiary);
+  font-size: 14px;
 }

+ 23 - 12
frontend/react-template/src/components/MainContent/MainContent.tsx

@@ -1,9 +1,8 @@
-import { useMemo, useRef, useState, useEffect } from "react";
+import { useRef, useState, useEffect } from "react";
 import type { FC } from "react";
 import { Select } from "@douyinfe/semi-ui";
 import { FlowChart } from "../FlowChart/FlowChart";
 import type { FlowChartRef } from "../FlowChart/FlowChart";
-import { useTrace } from "../../hooks/useTrace";
 import { useFlowChartData } from "../FlowChart/hooks/useFlowChartData";
 import { traceApi } from "../../api/traceApi";
 import type { Goal } from "../../types/goal";
@@ -31,11 +30,13 @@ export const MainContent: FC<MainContentProps> = ({
   const [traceList, setTraceList] = useState<TraceListItem[]>([]);
   const [cachedGoals, setCachedGoals] = useState<Goal[]>([]);
   const [cachedMsgGroups, setCachedMsgGroups] = useState<Record<string, Message[]>>({});
-  const { trace, loading, reload } = useTrace(traceId);
-  const initialGoals = useMemo(() => trace?.goal_tree?.goals ?? [], [trace]);
-  const { goals, connected, msgGroups, reloading } = useFlowChartData(traceId, initialGoals, messageRefreshTrigger);
+  const [cachedInvalidBranches, setCachedInvalidBranches] = useState<Message[][]>([]);
+  const { goals, connected, msgGroups, reloading, invalidBranches } = useFlowChartData(traceId, messageRefreshTrigger);
+  console.log("%c [ msgGroups ]-34", "font-size:13px; background:pink; color:#bf2c9f;", msgGroups);
   const displayGoals = goals.length > 0 ? goals : cachedGoals;
   const displayMsgGroups = Object.keys(msgGroups).length > 0 ? msgGroups : cachedMsgGroups;
+  const displayInvalidBranches =
+    invalidBranches && invalidBranches.length > 0 ? invalidBranches : cachedInvalidBranches;
 
   useEffect(() => {
     const fetchTraces = async () => {
@@ -50,9 +51,8 @@ export const MainContent: FC<MainContentProps> = ({
   }, [refreshTrigger]);
 
   useEffect(() => {
-    if (!messageRefreshTrigger) return;
-    void reload();
-  }, [messageRefreshTrigger, reload]);
+    // 移除 reload 调用,因为 useFlowChartData 内部会监听 messageRefreshTrigger 并重新加载
+  }, [messageRefreshTrigger]);
 
   useEffect(() => {
     if (goals.length > 0) {
@@ -66,12 +66,19 @@ export const MainContent: FC<MainContentProps> = ({
     }
   }, [msgGroups]);
 
+  useEffect(() => {
+    if (invalidBranches && invalidBranches.length > 0) {
+      setCachedInvalidBranches(invalidBranches);
+    }
+  }, [invalidBranches]);
+
   useEffect(() => {
     setCachedGoals([]);
     setCachedMsgGroups({});
+    setCachedInvalidBranches([]);
   }, [traceId]);
 
-  if (!traceId && !loading) {
+  if (!traceId && !reloading) {
     return (
       <div className={styles.main}>
         <div className={styles.header}>
@@ -152,16 +159,20 @@ export const MainContent: FC<MainContentProps> = ({
         </div>
       </div>
       <div className={styles.content}>
-        {loading || reloading ? (
-          <div className={styles.empty}>加载中...</div>
+        {reloading ? (
+          <div className={styles.loading}>加载中...</div>
         ) : displayGoals.length === 0 ? (
-          <div className={styles.empty}>暂无节点</div>
+          <div className={styles.empty}>暂无数据</div>
         ) : (
           <FlowChart
             ref={flowChartRef}
             goals={displayGoals}
             msgGroups={displayMsgGroups}
+            invalidBranches={displayInvalidBranches}
             onNodeClick={onNodeClick}
+            onSubTraceClick={(_parentGoal, entry) => {
+              onTraceChange?.(entry.id, entry.mission || entry.id);
+            }}
           />
         )}
       </div>

+ 99 - 53
frontend/react-template/src/components/TopBar/TopBar.module.css

@@ -1,89 +1,135 @@
 .topbar {
-  height: 60px;
-  background: var(--bg-primary, #ffffff);
-  border-bottom: 1px solid var(--border-color, #e0e0e0);
+  height: var(--topbar-height);
+  background: var(--bg-surface);
+  border-bottom: 1px solid var(--border-light);
   display: flex;
   align-items: center;
   justify-content: space-between;
-  padding: 0 var(--spacing-lg, 24px);
+  padding: 0 var(--space-lg);
+  box-shadow: var(--shadow-sm);
+  z-index: 10;
 }
+
 .title {
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
+  display: flex;
+  align-items: center;
+  gap: var(--space-md);
   flex: 1;
+  min-width: 0;
 }
 
 .title h1 {
+  font-size: 18px;
+  font-weight: 600;
+  color: var(--text-primary);
+  margin: 0;
+  white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
-  white-space: nowrap;
-  font-size: var(--font-size-lg, 16px);
-  font-weight: 600;
-  color: var(--text-primary, #333333);
-  margin: 0 20px 0 0;
 }
 
-.filters {
+.actions {
   display: flex;
-  gap: var(--spacing-md, 16px);
+  gap: var(--space-sm);
+  align-items: center;
 }
 
-.select {
-  padding: 8px 12px;
-  border: 1px solid #ddd;
-  border-radius: 4px;
+.button {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  height: 32px;
+  padding: 0 var(--space-md);
+  border-radius: var(--radius-md);
   font-size: 14px;
-  background: white;
+  font-weight: 500;
   cursor: pointer;
-  min-width: 200px;
-}
-
-.select:disabled {
-  opacity: 0.6;
-  cursor: not-allowed;
+  transition: all var(--transition-fast);
+  border: 1px solid transparent;
+  outline: none;
+  white-space: nowrap;
 }
 
+/* Default Button (Secondary/Ghost) */
 .button {
-  padding: 8px 16px;
-  border: 1px solid #d9d9d9;
-  border-radius: 4px;
-  font-size: 14px;
-  background: #fff;
-  color: #666;
-  cursor: pointer;
-  transition: all 0.2s;
+  background: var(--bg-surface);
+  border-color: var(--border-medium);
+  color: var(--text-secondary);
 }
 
 .button:hover:not(:disabled) {
-  color: #0070f3;
-  border-color: #0070f3;
+  background: var(--bg-surface-hover);
+  color: var(--text-primary);
+  border-color: var(--border-medium);
 }
 
-.button:disabled {
-  opacity: 0.6;
-  cursor: not-allowed;
+.button:active:not(:disabled) {
+  background: var(--border-light);
 }
 
-.buttonPrimary {
-  composes: button;
-  background: #0070f3;
-  color: white;
-  border-color: #0070f3;
+/* Primary Button */
+.button.primary {
+  background: var(--bg-surface);
+  color: var(--color-primary);
+  border-color: var(--color-primary);
 }
 
-.buttonPrimary:hover:not(:disabled) {
-  background: #0051cc;
-  color: white;
+.button.primary:hover:not(:disabled) {
+  background: var(--color-primary-hover);
 }
 
-.buttonDanger {
-  composes: button;
-  color: #f44336;
-  border-color: #f44336;
+.button.primary:active:not(:disabled) {
+  background: var(--color-primary-active);
 }
 
-.buttonDanger:hover:not(:disabled) {
-  background: #fff1f0;
-  color: #f44336;
+/* Danger Button */
+.button.danger {
+  background: var(--bg-surface);
+  color: var(--color-danger);
+  border-color: var(--color-danger);
+}
+
+.button.danger:hover:not(:disabled) {
+  background: #fef2f2; /* Red 50 */
+}
+
+.button.danger:active:not(:disabled) {
+  background: #fee2e2; /* Red 100 */
+}
+
+/* Success Button */
+.button.success {
+  background: var(--bg-surface);
+  color: var(--color-success);
+  border-color: var(--color-success);
+}
+
+.button.success:hover:not(:disabled) {
+  background: #ecfdf5; /* Emerald 50 */
+}
+
+.button.success:active:not(:disabled) {
+  background: #d1fae5; /* Emerald 100 */
+}
+
+/* Warning Button */
+.button.warning {
+  background: var(--bg-surface);
+  color: var(--color-warning);
+  border-color: var(--color-warning);
+}
+
+.button.warning:hover:not(:disabled) {
+  background: #fffbeb; /* Amber 50 */
+}
+
+.button.warning:active:not(:disabled) {
+  background: #fef3c7; /* Amber 100 */
+}
+
+.button:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+  pointer-events: none;
 }
+/* Removed old styles */

+ 5 - 6
frontend/react-template/src/components/TopBar/TopBar.tsx

@@ -227,23 +227,22 @@ export const TopBar: FC<TopBarProps> = ({
         title={title}
       >
         <h1>{title}</h1>
-        {/* <h1>{title.length > 50 ? `${title.slice(0, 50)}...` : title}</h1> */}
       </div>
-      <div className={styles.filters}>
+      <div className={styles.actions}>
         <button
-          className={styles.button}
+          className={`${styles.button} ${styles.success}`}
           onClick={handleNewTask}
         >
           新任务
         </button>
         <button
-          className={styles.buttonPrimary}
+          className={`${styles.button} ${styles.primary}`}
           onClick={handleRun}
         >
           插入
         </button>
         <button
-          className={styles.buttonDanger}
+          className={`${styles.button} ${styles.danger}`}
           onClick={handleStop}
         >
           停止
@@ -255,7 +254,7 @@ export const TopBar: FC<TopBarProps> = ({
           反思
         </button>
         <button
-          className={styles.button}
+          className={`${styles.button} ${styles.warning}`}
           onClick={handleExperience}
         >
           经验

+ 28 - 0
frontend/react-template/src/styles/global.css

@@ -27,12 +27,19 @@ body {
   height: 100%;
   display: flex;
   flex-direction: column;
+  background-color: var(--bg-app);
+}
+
+.app-top {
+  flex: 0 0 auto;
+  z-index: 20;
 }
 
 .app-body {
   flex: 1;
   display: flex;
   overflow: hidden;
+  position: relative;
 }
 
 .app-main {
@@ -40,12 +47,33 @@ body {
   min-width: 0;
   display: flex;
   flex-direction: column;
+  position: relative;
 }
+
 .app-right {
   flex: 0 0 auto;
   min-width: 0;
   display: flex;
   flex-direction: column;
+  background: var(--bg-panel);
+  z-index: 10;
+}
+
+.app-splitter {
+  width: 1px;
+  cursor: col-resize;
+  background-color: var(--border-light);
+  transition: background-color 0.2s;
+  position: relative;
+  z-index: 20;
+}
+
+.app-splitter:hover,
+.app-splitter:active {
+  background-color: var(--color-primary);
+  width: 4px; /* Make it easier to grab visually */
+  margin-left: -1.5px; /* Center alignment adjustment */
+  margin-right: -1.5px;
 }
 
 .app-splitter {

+ 60 - 6
frontend/react-template/src/styles/variables.css

@@ -1,8 +1,62 @@
 :root {
-  --bg-primary: #ffffff;
-  --border-color: #e0e0e0;
-  --text-primary: #333333;
-  --spacing-md: 16px;
-  --spacing-lg: 24px;
-  --font-size-lg: 16px;
+  /* Brand Colors */
+  --color-primary: #3b82f6; /* Blue 500 */
+  --color-primary-hover: #2563eb; /* Blue 600 */
+  --color-primary-active: #1d4ed8; /* Blue 700 */
+  --color-secondary: #64748b; /* Slate 500 */
+
+  /* Semantic Colors */
+  --color-success: #10b981; /* Emerald 500 */
+  --color-warning: #f59e0b; /* Amber 500 */
+  --color-danger: #ef4444; /* Red 500 */
+  --color-info: #3b82f6; /* Blue 500 */
+
+  /* Background Colors */
+  --bg-app: #f8fafc; /* Slate 50 */
+  --bg-surface: #ffffff;
+  --bg-surface-hover: #f1f5f9; /* Slate 100 */
+  --bg-panel: #ffffff;
+  --bg-node: #ffffff;
+  --bg-node-goal: #eff6ff; /* Blue 50 */
+  --bg-node-message: #f8fafc; /* Slate 50 */
+
+  /* Text Colors */
+  --text-primary: #0f172a; /* Slate 900 */
+  --text-secondary: #475569; /* Slate 600 */
+  --text-tertiary: #94a3b8; /* Slate 400 */
+  --text-inverse: #ffffff;
+
+  /* Border Colors */
+  --border-light: #e2e8f0; /* Slate 200 */
+  --border-medium: #cbd5e1; /* Slate 300 */
+  --border-focus: #3b82f6; /* Blue 500 */
+
+  /* Spacing */
+  --space-xs: 4px;
+  --space-sm: 8px;
+  --space-md: 16px;
+  --space-lg: 24px;
+  --space-xl: 32px;
+  --space-2xl: 48px;
+
+  /* Radius */
+  --radius-sm: 4px;
+  --radius-md: 8px;
+  --radius-lg: 12px;
+  --radius-xl: 16px;
+  --radius-full: 9999px;
+
+  /* Shadows */
+  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
+  --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+  --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
+
+  /* Transitions */
+  --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
+  --transition-normal: 300ms cubic-bezier(0.4, 0, 0.2, 1);
+
+  /* Layout */
+  --topbar-height: 64px;
+  --panel-width: 400px;
 }

+ 2 - 0
frontend/react-template/src/types/message.ts

@@ -17,6 +17,8 @@ export interface Message {
   content?: string | MessageContent;
   description?: string;
   tokens?: number | null;
+  parent_sequence?: number | null;
+  sequence?: number | null;
 }
 
 export interface Edge {