Просмотр исходного кода

update: support trace upload on frontend

guantao 3 часов назад
Родитель
Сommit
858560c9bb

+ 197 - 0
agent/trace/upload_api.py

@@ -0,0 +1,197 @@
+"""
+Trace Upload API
+
+提供 Trace 压缩包上传和导入功能
+"""
+
+import os
+import shutil
+import tempfile
+import zipfile
+from typing import List, Dict, Any
+from fastapi import APIRouter, UploadFile, File, HTTPException
+from pydantic import BaseModel
+
+from .protocols import TraceStore
+
+
+router = APIRouter(prefix="/api/traces", tags=["traces"])
+
+
+# ===== Response 模型 =====
+
+
+class UploadResponse(BaseModel):
+    """上传响应"""
+    success: bool
+    message: str
+    imported_traces: List[str]
+    failed_traces: List[Dict[str, str]]
+
+
+# ===== 全局 TraceStore =====
+
+
+_trace_store: TraceStore | None = None
+
+
+def set_trace_store(store: TraceStore):
+    """设置 TraceStore 实例"""
+    global _trace_store
+    _trace_store = store
+
+
+def get_trace_store() -> TraceStore:
+    """获取 TraceStore 实例"""
+    if _trace_store is None:
+        raise RuntimeError("TraceStore not initialized")
+    return _trace_store
+
+
+# ===== 辅助函数 =====
+
+
+def is_valid_trace_folder(folder_path: str) -> bool:
+    """
+    验证是否是有效的 trace 文件夹
+
+    有效的 trace 文件夹应该包含:
+    - meta.json 文件
+    """
+    return os.path.isfile(os.path.join(folder_path, "meta.json"))
+
+
+def extract_and_import_traces(zip_path: str, base_trace_path: str) -> tuple[List[str], List[Dict[str, str]]]:
+    """
+    解压并导入 traces
+
+    Returns:
+        (imported_traces, failed_traces)
+    """
+    import logging
+    logger = logging.getLogger(__name__)
+
+    imported = []
+    failed = []
+
+    # 创建临时目录
+    with tempfile.TemporaryDirectory() as temp_dir:
+        try:
+            # 解压文件
+            with zipfile.ZipFile(zip_path, 'r') as zip_ref:
+                zip_ref.extractall(temp_dir)
+
+            logger.info(f"Extracted to temp dir: {temp_dir}")
+
+            # 收集所有有效的 trace 文件夹
+            valid_traces = []
+
+            # 遍历解压后的内容
+            for root, dirs, files in os.walk(temp_dir):
+                # 检查当前目录是否是 trace 文件夹
+                if is_valid_trace_folder(root):
+                    valid_traces.append(root)
+                    logger.info(f"Found valid trace folder: {root}")
+
+            if not valid_traces:
+                logger.warning(f"No valid traces found in {temp_dir}")
+                # 列出临时目录的内容用于调试
+                for root, dirs, files in os.walk(temp_dir):
+                    logger.info(f"Dir: {root}, Files: {files[:5]}")  # 只显示前5个文件
+
+            # 导入找到的 trace 文件夹
+            for trace_folder in valid_traces:
+                trace_folder_name = os.path.basename(trace_folder)
+                target_path = os.path.join(base_trace_path, trace_folder_name)
+
+                try:
+                    # 如果目标已存在,跳过
+                    if os.path.exists(target_path):
+                        failed.append({
+                            "trace_id": trace_folder_name,
+                            "reason": "Trace already exists"
+                        })
+                        logger.warning(f"Trace already exists: {trace_folder_name}")
+                        continue
+
+                    # 复制到目标目录
+                    shutil.copytree(trace_folder, target_path)
+                    imported.append(trace_folder_name)
+                    logger.info(f"Imported trace: {trace_folder_name}")
+
+                except Exception as e:
+                    failed.append({
+                        "trace_id": trace_folder_name,
+                        "reason": str(e)
+                    })
+                    logger.error(f"Failed to import {trace_folder_name}: {e}")
+
+        except zipfile.BadZipFile:
+            raise HTTPException(status_code=400, detail="Invalid zip file")
+        except Exception as e:
+            logger.error(f"Failed to extract zip: {e}")
+            raise HTTPException(status_code=500, detail=f"Failed to extract zip: {str(e)}")
+
+    return imported, failed
+
+
+# ===== 路由 =====
+
+
+@router.post("/upload", response_model=UploadResponse)
+async def upload_traces(file: UploadFile = File(...)):
+    """
+    上传 trace 压缩包并导入
+
+    支持的格式:.zip
+    压缩包可以包含:
+    - 单个 trace 文件夹
+    - 多个 trace 文件夹
+    - 嵌套的 trace 文件夹
+
+    Args:
+        file: 上传的压缩包文件
+    """
+    # 验证文件类型
+    if not file.filename or not file.filename.endswith('.zip'):
+        raise HTTPException(status_code=400, detail="Only .zip files are supported")
+
+    # 获取 trace 存储路径
+    store = get_trace_store()
+    # 假设 FileSystemTraceStore 有 base_path 属性
+    if not hasattr(store, 'base_path'):
+        raise HTTPException(status_code=500, detail="TraceStore does not support file system operations")
+
+    base_trace_path = store.base_path
+
+    # 保存上传的文件到临时位置
+    with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as temp_file:
+        temp_file_path = temp_file.name
+        content = await file.read()
+        temp_file.write(content)
+
+    try:
+        # 解压并导入
+        imported, failed = extract_and_import_traces(temp_file_path, base_trace_path)
+
+        # 构建响应消息
+        if imported and not failed:
+            message = f"Successfully imported {len(imported)} trace(s)"
+        elif imported and failed:
+            message = f"Imported {len(imported)} trace(s), {len(failed)} failed"
+        elif not imported and failed:
+            message = f"Failed to import all traces"
+        else:
+            message = "No valid traces found in the zip file"
+
+        return UploadResponse(
+            success=len(imported) > 0,
+            message=message,
+            imported_traces=imported,
+            failed_traces=failed
+        )
+
+    finally:
+        # 清理临时文件
+        if os.path.exists(temp_file_path):
+            os.unlink(temp_file_path)

+ 5 - 0
api_server.py

@@ -21,6 +21,7 @@ from agent.trace.run_api import router as run_router, experiences_router, set_ru
 from agent.trace.websocket import router as ws_router, set_trace_store as set_ws_trace_store
 from agent.trace.examples_api import router as examples_router
 from agent.trace.logs_websocket import router as logs_router, setup_websocket_logging
+from agent.trace.upload_api import router as upload_router, set_trace_store as set_upload_trace_store
 
 
 # ===== 日志配置 =====
@@ -61,6 +62,7 @@ trace_store = FileSystemTraceStore(base_path=".trace")
 # 注入到 step_tree 模块
 set_api_trace_store(trace_store)
 set_ws_trace_store(trace_store)
+set_upload_trace_store(trace_store)
 
 
 # ===== 可选:配置 Runner(启用执行 API)=====
@@ -82,6 +84,9 @@ set_runner(runner)
 # Examples API(GET /api/examples)
 app.include_router(examples_router)
 
+# Trace 上传 API(POST /api/traces/upload)
+app.include_router(upload_router)
+
 # Trace 执行 API(POST + GET /running,需配置 Runner)
 # 注意:run_router 必须在 api_router 之前注册,否则 GET /running 会被 /{trace_id} 捕获
 app.include_router(run_router)

+ 94 - 1
frontend/react-template/package-lock.json

@@ -12,6 +12,7 @@
         "@douyinfe/semi-ui": "^2.56.0",
         "axios": "^1.6.0",
         "d3": "^7.8.5",
+        "jszip": "^3.10.1",
         "react": "^18.2.0",
         "react-dom": "^18.2.0",
         "react-error-boundary": "^6.1.1",
@@ -4071,6 +4072,12 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+      "license": "MIT"
+    },
     "node_modules/crelt": {
       "version": "1.0.6",
       "resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.6.tgz",
@@ -5801,6 +5808,12 @@
         "node": ">= 4"
       }
     },
+    "node_modules/immediate": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+      "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+      "license": "MIT"
+    },
     "node_modules/import-fresh": {
       "version": "3.3.1",
       "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -5844,7 +5857,6 @@
       "version": "2.0.4",
       "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
       "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-      "dev": true,
       "license": "ISC"
     },
     "node_modules/inline-style-parser": {
@@ -5968,6 +5980,12 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+      "license": "MIT"
+    },
     "node_modules/isexe": {
       "version": "2.0.0",
       "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
@@ -6098,6 +6116,18 @@
       "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
       "license": "MIT"
     },
+    "node_modules/jszip": {
+      "version": "3.10.1",
+      "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+      "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+      "license": "(MIT OR GPL-3.0-or-later)",
+      "dependencies": {
+        "lie": "~3.3.0",
+        "pako": "~1.0.2",
+        "readable-stream": "~2.3.6",
+        "setimmediate": "^1.0.5"
+      }
+    },
     "node_modules/keyv": {
       "version": "4.5.4",
       "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz",
@@ -6122,6 +6152,15 @@
         "node": ">= 0.8.0"
       }
     },
+    "node_modules/lie": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+      "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "immediate": "~3.0.5"
+      }
+    },
     "node_modules/lightningcss": {
       "version": "1.30.2",
       "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.2.tgz",
@@ -7767,6 +7806,12 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/pako": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+      "license": "(MIT AND Zlib)"
+    },
     "node_modules/parent-module": {
       "version": "1.0.1",
       "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz",
@@ -7988,6 +8033,12 @@
         "node": ">=6"
       }
     },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "license": "MIT"
+    },
     "node_modules/prop-types": {
       "version": "15.8.1",
       "resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz",
@@ -8372,6 +8423,21 @@
         "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
       }
     },
+    "node_modules/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
     "node_modules/recma-build-jsx": {
       "version": "1.0.0",
       "resolved": "https://registry.npmmirror.com/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz",
@@ -8669,6 +8735,12 @@
       "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
       "license": "BSD-3-Clause"
     },
+    "node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
     "node_modules/safer-buffer": {
       "version": "2.1.2",
       "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -8719,6 +8791,12 @@
         "node": ">=10"
       }
     },
+    "node_modules/setimmediate": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+      "license": "MIT"
+    },
     "node_modules/shebang-command": {
       "version": "2.0.0",
       "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -8802,6 +8880,15 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
     "node_modules/stringify-entities": {
       "version": "4.0.4",
       "resolved": "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -9263,6 +9350,12 @@
         "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
       }
     },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
     "node_modules/utility-types": {
       "version": "3.11.0",
       "resolved": "https://registry.npmmirror.com/utility-types/-/utility-types-3.11.0.tgz",

+ 2 - 1
frontend/react-template/package.json

@@ -14,6 +14,7 @@
     "@douyinfe/semi-ui": "^2.56.0",
     "axios": "^1.6.0",
     "d3": "^7.8.5",
+    "jszip": "^3.10.1",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-error-boundary": "^6.1.1",
@@ -42,4 +43,4 @@
     "vite": "^5.0.8",
     "vitest": "^4.0.18"
   }
-}
+}

+ 16 - 0
frontend/react-template/src/api/traceApi.ts

@@ -70,4 +70,20 @@ export const traceApi = {
       temperature?: number;
     }>(`/api/examples/${projectName}/prompt`);
   },
+  uploadTraces(file: File) {
+    const formData = new FormData();
+    formData.append("file", file);
+    return request<{
+      success: boolean;
+      message: string;
+      imported_traces: string[];
+      failed_traces: Array<{ trace_id: string; reason: string }>;
+    }>("/api/traces/upload", {
+      method: "POST",
+      data: formData,
+      headers: {
+        "Content-Type": "multipart/form-data",
+      },
+    });
+  },
 };

+ 24 - 26
frontend/react-template/src/components/FlowChart/FlowChart.tsx

@@ -1050,38 +1050,36 @@ const FlowChartComponent: ForwardRefRenderFunction<FlowChartRef, FlowChartProps>
                             : "drop-shadow(0 1px 2px rgb(0 0 0 / 0.05))",
                       }}
                     />
-                    {/* 节点文本(带 Tooltip) */}
+                    {/* 节点文本 */}
                     <foreignObject
                       x={-70}
                       y={-25}
                       width={150}
                       height={50}
                     >
-                      <Tooltip content={text}>
-                        <div
-                          className="w-full h-full overflow-hidden flex items-center px-2 gap-2"
-                          style={{
-                            color: textColor,
-                            justifyContent: thumbnail ? "space-between" : "center",
-                          }}
-                        >
-                          <span className={`text-xs line-clamp-3 ${thumbnail ? "flex-1 text-left" : "text-center"}`}>
-                            {text}
-                          </span>
-                          {thumbnail && (
-                            <img
-                              src={thumbnail}
-                              alt="thumb"
-                              className="w-8 h-8 object-cover rounded border border-gray-200 bg-white flex-shrink-0 hover:scale-110 transition-transform"
-                              loading="lazy"
-                              onClick={(e) => {
-                                e.stopPropagation();
-                                setPreviewImage(thumbnail);
-                              }}
-                            />
-                          )}
-                        </div>
-                      </Tooltip>
+                      <div
+                        className="w-full h-full overflow-hidden flex items-center px-2 gap-2"
+                        style={{
+                          color: textColor,
+                          justifyContent: thumbnail ? "space-between" : "center",
+                        }}
+                      >
+                        <span className={`text-xs line-clamp-3 ${thumbnail ? "flex-1 text-left" : "text-center"}`}>
+                          {text}
+                        </span>
+                        {thumbnail && (
+                          <img
+                            src={thumbnail}
+                            alt="thumb"
+                            className="w-8 h-8 object-cover rounded border border-gray-200 bg-white flex-shrink-0 hover:scale-110 transition-transform"
+                            loading="lazy"
+                            onClick={(e) => {
+                              e.stopPropagation();
+                              setPreviewImage(thumbnail);
+                            }}
+                          />
+                        )}
+                      </div>
                     </foreignObject>
                   </g>
                 );

+ 0 - 1
frontend/react-template/src/components/FlowChart/components/Edge.tsx

@@ -94,7 +94,6 @@ export const Edge: FC<EdgeProps> = ({ link, label, highlighted, dimmed, onClick,
           onClick();
         }}
       >
-        <title>{label}</title>
         <rect
           x={-30}
           y={-10}

+ 1 - 2
frontend/react-template/src/components/FlowChart/components/Node.tsx

@@ -30,7 +30,6 @@ export const Node: FC<NodeProps> = ({ node, selected, highlighted, dimmed, onCli
       onClick={onClick}
       style={{ cursor: "pointer" }}
     >
-      <title>{data.description}</title>
       <rect
         x={-70}
         y={-26}
@@ -49,7 +48,7 @@ export const Node: FC<NodeProps> = ({ node, selected, highlighted, dimmed, onCli
         fontWeight={selected || highlighted ? 600 : 400}
         style={{ opacity: dimmed ? 0.35 : 1, pointerEvents: "none" }}
       >
-        {truncateMiddle(data.description || data.id, 10)}
+        {truncateMiddle(data.description || data.id, 20)}
       </text>
       {data.status === "running" && (
         <circle

+ 8 - 5
frontend/react-template/src/components/MainContent/MainContent.tsx

@@ -130,12 +130,15 @@ export const MainContent: FC<MainContentProps> = ({
               const trace = traceList.find((t) => t.trace_id === value);
               onTraceChange?.(value as string, trace?.task || trace?.trace_id);
             }}
-            style={{ width: 200 }}
+            style={{ width: 400 }}
             placeholder="选择 Trace"
-            optionList={traceList.map((t) => ({
-              label: t.task?.length > 15 ? `${t.task.slice(0, 15)}...` : t.task || t.trace_id,
-              value: t.trace_id,
-            }))}
+            optionList={traceList.map((t) => {
+              const taskDesc = t.task && t.task.length > 20 ? `${t.task.slice(0, 20)}...` : t.task;
+              return {
+                label: taskDesc ? `${t.trace_id} - ${taskDesc}` : t.trace_id,
+                value: t.trace_id,
+              };
+            })}
           />
           {/* <div className={styles.status}>{connected ? "WebSocket 已连接" : "WebSocket 未连接"}</div> */}
           {/* <div className={styles.legend}>

+ 15 - 0
frontend/react-template/src/components/TopBar/TopBar.module.css

@@ -127,6 +127,21 @@
   background: #fef3c7; /* Amber 100 */
 }
 
+/* Info Button */
+.button.info {
+  background: var(--bg-surface);
+  color: #3b82f6; /* Blue 500 */
+  border-color: #3b82f6;
+}
+
+.button.info:hover:not(:disabled) {
+  background: #eff6ff; /* Blue 50 */
+}
+
+.button.info:active:not(:disabled) {
+  background: #dbeafe; /* Blue 100 */
+}
+
 .button:disabled {
   opacity: 0.5;
   cursor: not-allowed;

+ 67 - 0
frontend/react-template/src/components/TopBar/TopBar.tsx

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, useRef } from "react";
 import type { FC } from "react";
 import ReactMarkdown from "react-markdown";
 import { Modal, Form, Toast } from "@douyinfe/semi-ui";
+import JSZip from "jszip";
 import { traceApi } from "../../api/traceApi";
 import type { Goal } from "../../types/goal";
 import type { Message } from "../../types/message";
@@ -33,6 +34,7 @@ export const TopBar: FC<TopBarProps> = ({
   const [exampleProjects, setExampleProjects] = useState<Array<{ name: string; path: string; has_prompt: boolean }>>(
     [],
   );
+  const [isUploading, setIsUploading] = useState(false);
   // 控制中心面板
   const [isControlPanelVisible, setIsControlPanelVisible] = useState(false);
 
@@ -248,6 +250,59 @@ export const TopBar: FC<TopBarProps> = ({
     }
   };
 
+  const handleUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
+    const files = event.target.files;
+    if (!files || files.length === 0) return;
+
+    setIsUploading(true);
+    try {
+      // 创建 ZIP 文件
+      const zip = new JSZip();
+
+      // 将所有文件添加到 ZIP
+      for (let i = 0; i < files.length; i++) {
+        const file = files[i];
+        // 使用 webkitRelativePath 保持文件夹结构
+        const path = file.webkitRelativePath || file.name;
+        zip.file(path, file);
+      }
+
+      // 生成 ZIP blob
+      Toast.info("正在压缩文件...");
+      const zipBlob = await zip.generateAsync({ type: "blob" });
+
+      // 创建 File 对象
+      const zipFile = new File([zipBlob], "traces.zip", { type: "application/zip" });
+
+      // 上传
+      Toast.info("正在上传...");
+      const result = await traceApi.uploadTraces(zipFile);
+
+      if (result.success) {
+        Toast.success(result.message);
+        // 刷新 trace 列表
+        loadTraces();
+        if (onTraceCreated) {
+          onTraceCreated();
+        }
+      } else {
+        Toast.warning(result.message);
+      }
+
+      // 显示详细信息
+      if (result.failed_traces.length > 0) {
+        console.warn("Failed traces:", result.failed_traces);
+      }
+    } catch (error) {
+      console.error("Failed to upload traces:", error);
+      Toast.error("上传失败");
+    } finally {
+      setIsUploading(false);
+      // 清空 input,允许重复上传同一文件
+      event.target.value = "";
+    }
+  };
+
   return (
     <>
       <header className={styles.topbar}>
@@ -291,6 +346,18 @@ export const TopBar: FC<TopBarProps> = ({
           >
             经验
           </button>
+          <label className={`${styles.button} ${styles.info}`} style={{ cursor: 'pointer' }}>
+            {isUploading ? "上传中..." : "📤 导入"}
+            <input
+              type="file"
+              webkitdirectory=""
+              directory=""
+              multiple
+              onChange={handleUpload}
+              disabled={isUploading}
+              style={{ display: 'none' }}
+            />
+          </label>
         </div>
         <Modal
           title={<div className="w-full text-center">新建任务</div>}

+ 8 - 0
frontend/react-template/src/global.d.ts

@@ -2,3 +2,11 @@ declare module "*.module.css" {
   const classes: { [key: string]: string };
   export default classes;
 }
+
+// 扩展 HTMLInputElement 以支持 webkitdirectory 属性
+declare module "react" {
+  interface InputHTMLAttributes<T> extends HTMLAttributes<T> {
+    webkitdirectory?: string;
+    directory?: string;
+  }
+}