diff --git a/.dockerignore b/.dockerignore index 4c1d206..0fe6fcd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,6 +19,7 @@ cloud/users/** # Logs and local IDE files logs/ .vscode/ +.devcontainer/ .idea/ # OS files @@ -30,3 +31,6 @@ archive/ # Ignore local environment files .env + +# Ignore Windows solution files +*.sln diff --git a/.gitignore b/.gitignore index 2a78052..f7c9447 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ node_modules/ # Logs logs/ cloud/* + +# Dev Container (not needed for local or Docker-only workflows) +.devcontainer/ # 但是不要忽略 custom_nodes 文件夹 !cloud/custom_nodes/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..1b94d95 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,46 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "FastAPI: Uvicorn (Local Debug)", + "type": "python", + "request": "launch", + "module": "uvicorn", + "args": [ + "server.main:app", + "--host", + "127.0.0.1", + "--port", + "8000", + "--reload" + ], + "env": { + "PYTHONPATH": "${workspaceFolder}" + }, + "cwd": "${workspaceFolder}", + "justMyCode": true, + "console": "integratedTerminal" + }, + { + "name": "FastAPI: Debug Mode (debugpy wait)", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/server/debug_run.py", + "env": { + "PYTHONPATH": "${workspaceFolder}" + }, + "cwd": "${workspaceFolder}", + "justMyCode": true, + "console": "integratedTerminal" + }, + { + "name": "Attach to debugpy (5678)", + "type": "python", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + } + } + ] +} diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 5bb8429..34461ec 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,300 +1,88 @@ -# TraceStudio 部署说明(Windows + Docker Desktop) +# TraceStudio 部署说明 -本文档包含将 TraceStudio 部署到共享 Windows 机器(使用 Docker Desktop)的步骤、建议的卷挂载、端口说明、回滚方法与常见故障排查。 +本文档说明本地开发调试和 Docker 部署的方式。 -## 前提 -- 已安装 Docker Desktop(Windows)并启用 WSL2 或 Hyper-V。确保 Docker Desktop 有权限访问 `D:` 驱动器(Settings → Resources → File Sharing)。 -- 项目位于 `D:/XGame/TraceStudio`。 -- 确认 `archive/`、`dist/` 在 `.gitignore` 中被忽略。 +## 目录 +1. [本地调试模式](#本地调试模式) +2. [Docker 部署](#docker-部署) +3. [故障排查](#故障排查) -## 构建镜像(从源码) -在项目根运行: +--- +## 本地调试模式 + +**前置条件**:虚拟环境已激活,依赖已安装 ```powershell -docker compose build -``` - -开关说明:`docker-compose.yml` 已配置将 `D:/XGame/TraceStudio/cloud` 挂载到容器内 `/opt/tracestudio/cloud`,以便 `server` 访问共享文件。 - -## 启动服务 - -```powershell -docker compose up -d - -# 查看日志 -docker compose logs -f server -docker compose logs -f web -``` - -前端默认通过 `http://` (80) 访问,后端 API 通过 `http://:8000` 访问。根据部署时的 `docker-compose.yml` 端口映射调整。 - -# TraceStudio — 标准作业程序 (SOP) - -此文档为 TraceStudio 的部署与运维标准作业程序(SOP),适用于在共享 Windows 服务器上使用 Docker Desktop 进行部署,以及本地开发和打包流程。它覆盖先决条件、开发指南、构建打包流程(`prepare_dist.py`)、生产部署(Windows + Docker Desktop)、常见故障排查与回滚步骤。 - -重要:请勿将 `dist/` 提交到版本库。`dist/` 是由 `scripts/prepare_dist.py` 生成的构建产物,仅作打包和发布之用。 - -文件速查: -- `scripts/prepare_dist.py` — 生成干净部署包(copy / archive 模式) -- `scripts/build_release.py` — 自动化生成 timestamped release zip -- `docker-compose.yml` — 推荐的本地与生产 compose 配置 -- `server/Dockerfile` — 生产多阶段 Dockerfile(参考) -- `server/requirements.txt`、`server/dev-requirements.txt` -- `archive/` — 被归档的原始配置与脚本备份 - -**目录结构与约定** -- `cloud/`:数据与自定义节点(应作为主机卷挂载到容器) -- `dist/`:临时构建输出(不应加入 Git) -- `archive/`:备份被移除或替换的文件 - -**目录权限与挂载约定(默认)** -- Host path: `D:/XGame/TraceStudio/cloud` → Container path: `/opt/tracestudio/cloud` - -**修订**: 2026-01-12 - --- - -**1. 环境准备 (Prerequisites)** - -- 操作系统:Windows 10/11 或 Windows Server 2019/2022 -- Docker:Docker Desktop(最新稳定版),建议启用 WSL2 后端 -- Docker Compose:v2(随 Docker Desktop 提供) -- Python:3.9+(建议 3.10 / 3.11) -- Node.js:16+(用于前端构建) -- Git:用于源码管理与回退 - -推荐硬件(最小 / 建议): -- CPU:4 cores / 8 cores -- 内存:8 GB / 16 GB -- 磁盘:50 GB 可用(取决于 `cloud/` 数据大小,若存放大量 trace 数据请预留更多) - -权限与网络 -- 在 Docker Desktop 中允许对包含仓库与 `cloud/` 的主机驱动器授权(Settings → Resources → File Sharing)。 -- 有权限运行 Docker Desktop、访问 `D:/XGame/TraceStudio` 路径,并写入 `archive/` 与 `dist/`。 - --- - -**2. 开发指南 (Local Development)** - -快速搭建本地开发环境: - -1) 克隆仓库并创建 Python 虚拟环境 - -```powershell -git clone TraceStudio -cd TraceStudio python -m venv .venv -.\.venv\Scripts\Activate.ps1 # PowerShell -``` - -2) 安装依赖(生产 + 开发) - -```powershell +.\.venv\Scripts\Activate.ps1 pip install -r server/requirements.txt pip install -r server/dev-requirements.txt -cd web -npm install -cd .. +cd web && npm install && cd .. ``` -说明:`server/dev-requirements.txt` 包含开发工具(例如 `debugpy`, `pytest`, `black`, `mypy`, `pre-commit`, `flake8`)。用于本地调试与代码质量检查,不应被包含在生产镜像中。 - -3) 配置本地环境变量 - -- 参考 `web/.env.example` 创建 `web/.env`(不要把 `.env` 提交到 Git)。 -- 根据需要调整 `server/system_config.yaml` 中非敏感设置。 - -4) 本地启动(Debug 模式) - -- 启动后端(带自动重载) - +**启动后端**(终端 1) ```powershell cd server python -m uvicorn main:app --reload --host 127.0.0.1 --port 8000 ``` -- 使用 IDE 调试:`debugpy` 已包含在 `server/dev-requirements.txt`,可在 IDE 中设置远程调试端口并 attach。 - -- 启动前端开发服务器 - +**启动前端**(终端 2) ```powershell cd web npm run dev ``` -调试提示: -- 修改 `cloud/custom_nodes` 中的自定义节点后,若后端未自动热重载,请重启后端服务以加载新节点。 +访问地址: +- 后端 API:`http://localhost:8000` +- 前端:`http://localhost:5173` -4.1) 基于 Docker 的开发(推荐) +**VS Code 调试**(可选) +- Ctrl+Shift+D 打开 Run and Debug +- 选择 **"FastAPI: Uvicorn (Local Debug)"** +- F5 启动,支持打断点 -如果你希望在与生产镜像更接近的环境中开发、避免在主机上直接安装依赖,我们提供 `docker-compose.dev.yml`,它会: -- 把仓库作为卷挂载到容器内,便于热重载与编辑 -- 在容器内安装并运行 `uvicorn --reload`(后端)和 Vite dev server(前端) +**调试配置**(`.vscode/launch.json`) +- **FastAPI: Uvicorn (Local Debug)** ⭐ 推荐 +- **FastAPI: Debug Mode (debugpy wait)** +- **Attach to debugpy (5678)** -在仓库根运行: +--- +## Docker 部署 + +**启动 Docker 服务** ```powershell -docker compose -f docker-compose.dev.yml up --build -``` - -然后: -- 后端 API 在 `http://localhost:8000` -- 前端 dev server 在 `http://localhost:5173`(Vite) - -开发注意事项: -- 第一次运行会在容器内执行 `pip install -r server/requirements.txt` 与 `npm install`,根据网络与依赖大小可能需要几分钟。 -- `docker-compose.dev.yml` 将宿主 `cloud/` 挂载到容器,确保 Docker Desktop 授权访问所在驱动器(如 `D:`)。 - --- - -**3. 构建与发布 (Build & Ship)** - -目标:生成一个只包含运行时所需文件的 `dist/` 包并(可选)创建 release zip,供部署或镜像构建使用。 - -为什么需要 `dist/`? -- `dist/` 是受控的、最小化的部署快照。它避免将开发依赖、缓存、临时文件或本地大数据误打包进生产镜像,并为构建过程提供可复现的输入。 - -生成 `dist/`(推荐流程) - -1. 激活虚拟环境 - -```powershell -.\.venv\Scripts\Activate.ps1 -``` - -2. 运行准备脚本(copy 模式) - -```powershell -python .\scripts\prepare_dist.py --mode copy -``` - -脚本说明: -- `--mode copy`:将需要的文件/目录复制到 `dist/`(不会删除源文件)。 -- `--mode archive`:把标记为过大的或不应纳入 `dist/` 的文件移动到 `archive/`(脚本会记录操作并生成回滚信息)。 -- 日志:脚本会将操作记录到 `scripts/prepare_dist.log`,包括回滚脚本路径。 - -3. (可选)创建 release ZIP - -```powershell -python .\scripts\build_release.py -# 结果示例: release_20260112_202437.zip -``` - -4. 验证 `dist/` 内容 -- `dist/` 应只包含:生产 `server/` 代码、构建后的 `web/dist`(或前端静态文件)、必要的配置(`system_config.yaml` 的非敏感部分)、`cloud/custom_nodes`(如果需要)。 - -安全建议:在运行 `prepare_dist.py --mode archive` 前,请确认 `archive/` 的访问与备份策略,以便在误移除时可恢复。 - --- - -**4. 服务器部署 (Production Deploy) — 共享 Windows 服务器** - -建议使用 Docker Compose 来管理 `server` 与 `web` 服务。以下步骤假定目标机器为共享的 Windows 主机,已安装 Docker Desktop 并允许访问托管驱动器。 - -1) 在目标机器上准备文件 - -- 选项 A(推荐):在开发机上运行 `scripts/build_release.py`,将生成的 `release_*.zip` 复制到目标机器并解压。 -- 选项 B:在目标机器上从 Git 克隆并运行 `scripts/prepare_dist.py --mode copy`。 - -2) 检查 Docker Desktop 文件共享 - -- 打开 Docker Desktop → Settings → Resources → File Sharing,确认包含 `D:`(或项目所在驱动器)的条目。 - -3) 启动服务 - -```powershell -cd docker compose build docker compose up -d ``` -4) 卷挂载说明(重要) - -- Compose 默认将 `D:/XGame/TraceStudio/cloud` 挂载到容器 `/opt/tracestudio/cloud`。若路径不同,请修改 `docker-compose.yml` 中的 volume 映射。 -- 挂载为 read-write(:rw),并确保宿主机上的文件权限允许容器进程读写。 - -5) 运行时检查 +访问地址: +- 后端 API:`http://localhost:8000` +- 前端:`http://localhost:5173` +**查看日志** ```powershell -docker compose ps docker compose logs -f server +docker compose logs -f web ``` --- - -**5. 故障排查 (Troubleshooting)** - -- 容器无法访问主机卷 - - 确认 Docker Desktop 已授予驱动器访问权限并重启 Docker。 - - 检查宿主机目录权限(使用管理员权限查看)。 - -- 应用异常退出或 `uvicorn` 找不到 `main:app` - - 查看 `server/Dockerfile` 是否将代码拷贝到镜像内部正确的路径。 - - 在容器内运行 `python -c "import main; print(main)"` 检查 import 错误。 - -- 端口被占用 - - 执行 `netstat -ano | findstr :8000` 查找占用进程,或修改 `docker-compose.yml` 端口映射。 - -- 构建失败因依赖 - - 检查 `server/requirements.txt` 是否包含生产所需的库。 - - 在本地虚拟环境运行 `pip install -r server/requirements.txt` 验证。 - -- 日志采集 - -```powershell -docker compose logs server --no-log-prefix --tail 200 -docker compose logs web --no-log-prefix --tail 200 -``` - --- - -**6. 回滚 (Rollback)** - -步骤(快速) - -1. 停止服务 - +**停止服务** ```powershell docker compose down ``` -2. 从历史 release 恢复 - -```powershell -Expand-Archive -Path .\release_.zip -DestinationPath .\dist -Force -cd dist -docker compose build -docker compose up -d -``` - -3. 恢复被归档的文件(如适用) - -- 检查 `archive/` 中的备份,按需复制回原位或使用脚本中记录的回滚路径。 - --- - -**附录:常用命令速查** - -- 生成 dist: - -```powershell -python .\scripts\prepare_dist.py --mode copy -``` - -- 生成 release zip: - -```powershell -python .\scripts\build_release.py -``` - -- 运行生产堆栈: - -```powershell -docker compose down -docker compose build -docker compose up -d -``` - --- -若需把 SOP 定制为企业内部流程(例如 CI/CD、私有镜像仓库、自动化回滚或与公司监控系统整合),请告知要接入的工具/平台,我可继续为你扩展自动化脚本与 CI 配置。 +## 故障排查 + +| 问题 | 解决方案 | +|------|--------| +| 端口 8000 被占用 | `netstat -ano \| findstr :8000` 找到进程后关闭 | +| 容器启动失败 | `docker compose logs server` 查看错误日志 | +| 虚拟环境找不到依赖 | 重新激活并运行 `pip install -r server/requirements.txt` | +| 自定义节点未加载 | 本地:重启后端;Docker:`docker compose restart server` | + +--- + +**最后更新**:2026-01-13 + diff --git a/agent/config.yaml b/agent/config.yaml new file mode 100644 index 0000000..5231671 --- /dev/null +++ b/agent/config.yaml @@ -0,0 +1,18 @@ +# Agent 配置示例 +base_workdir: "D:/XGame/TraceStudio" # 作为默认工作目录(可被请求覆盖) +shared_root: "D:/XGame/TraceStudio/cloud" # 文件类输出请写到此目录,供 Docker 挂载访问 + +# 可用工具清单(按名称映射到可执行文件) +tools: + unreal_editor: + path: "C:/Program Files/Epic Games/UE_5.3/Engine/Binaries/Win64/UnrealEditor.exe" + unreal_insights: + path: "D:/XGame/unrealengine/Engine/Binaries/Win64/UnrealInsights.exe" + python: + path: "C:/Python311/python.exe" + +# 运行默认值 +run_defaults: + timeout: 300 + capture_output: true + strip_output: true diff --git a/agent/core/__init__.py b/agent/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/core/models.py b/agent/core/models.py new file mode 100644 index 0000000..89b83b3 --- /dev/null +++ b/agent/core/models.py @@ -0,0 +1,23 @@ +from typing import Dict, List, Optional +from pydantic import BaseModel, Field + + +class RunRequest(BaseModel): + tool: str = Field(..., description="工具名称,对应 config.yaml 的 tools key") + args: List[str] = Field(default_factory=list, description="传给可执行文件的参数") + workdir: Optional[str] = Field(None, description="工作目录,默认使用 config.base_workdir") + timeout: Optional[int] = Field(None, description="超时时间秒,默认取 config.run_defaults") + env: Optional[Dict[str, str]] = Field(None, description="额外环境变量") + capture_output: Optional[bool] = Field(None, description="是否捕获 stdout/stderr") + strip_output: Optional[bool] = Field(None, description="是否 strip 输出换行/空白") + + +class RunResult(BaseModel): + success: bool + return_code: Optional[int] = None + stdout: Optional[str] = None + stderr: Optional[str] = None + elapsed: Optional[float] = None + tool_path: Optional[str] = None + workdir: Optional[str] = None + output_path: Optional[str] = None # 若生成文件,返回落盘路径(由调用方基于共享卷读取) diff --git a/agent/core/runner.py b/agent/core/runner.py new file mode 100644 index 0000000..4fbea78 --- /dev/null +++ b/agent/core/runner.py @@ -0,0 +1,88 @@ +import subprocess +import time +import os +from pathlib import Path +from typing import Dict, Any + +import yaml + +from .models import RunRequest, RunResult + + +def load_config(config_path: str | Path = None) -> Dict[str, Any]: + """加载配置,默认使用 AGENT_CONFIG 或 ./config.yaml。""" + if config_path is None: + env_path = os.environ.get("AGENT_CONFIG") + config_path = Path(env_path) if env_path else Path(__file__).resolve().parent.parent / "config.yaml" + config_path = Path(config_path) + with open(config_path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} + + +def resolve_tool(config: Dict[str, Any], tool_name: str) -> Path: + tools = config.get("tools", {}) + if tool_name not in tools: + raise ValueError(f"未知工具: {tool_name}") + tool_path = Path(tools[tool_name]["path"]) + if not tool_path.exists(): + raise FileNotFoundError(f"工具不存在: {tool_path}") + return tool_path + + +def run_tool(config: Dict[str, Any], req: RunRequest) -> RunResult: + start = time.time() + defaults = config.get("run_defaults", {}) + + tool_path = resolve_tool(config, req.tool) + workdir = Path(req.workdir or config.get("base_workdir", ".")).resolve() + workdir.mkdir(parents=True, exist_ok=True) + + # 处理参数与环境 + args = [str(tool_path)] + req.args + env = os.environ.copy() + if config.get("env"): + env.update({k: str(v) for k, v in config["env"].items()}) + if req.env: + env.update({k: str(v) for k, v in req.env.items()}) + + timeout = req.timeout or defaults.get("timeout") + capture_output = req.capture_output if req.capture_output is not None else defaults.get("capture_output", True) + strip_output = req.strip_output if req.strip_output is not None else defaults.get("strip_output", True) + + try: + completed = subprocess.run( + args, + cwd=str(workdir), + env=env, + timeout=timeout, + capture_output=capture_output, + text=True, + check=False, + ) + stdout = completed.stdout if capture_output else None + stderr = completed.stderr if capture_output else None + if strip_output: + stdout = stdout.strip() if stdout is not None else None + stderr = stderr.strip() if stderr is not None else None + + elapsed = time.time() - start + return RunResult( + success=completed.returncode == 0, + return_code=completed.returncode, + stdout=stdout, + stderr=stderr, + elapsed=elapsed, + tool_path=str(tool_path), + workdir=str(workdir), + ) + except Exception as exc: + elapsed = time.time() - start + return RunResult( + success=False, + return_code=None, + stdout=None, + stderr=str(exc), + elapsed=elapsed, + tool_path=str(tool_path), + workdir=str(workdir), + ) diff --git a/agent/main.py b/agent/main.py new file mode 100644 index 0000000..d6ad153 --- /dev/null +++ b/agent/main.py @@ -0,0 +1,45 @@ +import os +from pathlib import Path +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware + +from core.runner import load_config, run_tool +from core.models import RunRequest, RunResult + +CONFIG = load_config() + +app = FastAPI(title="TraceStudio Agent", version="0.1.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"] , + allow_headers=["*"], +) + + +@app.get("/health") +def health(): + tools = list((CONFIG.get("tools") or {}).keys()) + return {"status": "ok", "tools": tools} + + +@app.post("/run", response_model=RunResult) +def run(req: RunRequest): + try: + return run_tool(CONFIG, req) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +if __name__ == "__main__": + host = os.environ.get("AGENT_HOST", "0.0.0.0") + port = int(os.environ.get("AGENT_PORT", "8100")) + import uvicorn + + uvicorn.run("main:app", host=host, port=port, reload=False) diff --git a/agent/requirements.txt b/agent/requirements.txt new file mode 100644 index 0000000..ba98c88 --- /dev/null +++ b/agent/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +pyyaml==6.0.1 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 5edf264..60c59f4 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -8,8 +8,8 @@ services: volumes: - ./:/app:cached - ./cloud:/opt/tracestudio/cloud:rw - working_dir: /app/server - command: ["/bin/sh", "-c", "pip install -r /app/server/requirements.txt && python -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload"] + working_dir: /app + command: ["/bin/sh", "-c", "pip install -r /app/server/requirements.txt && PYTHONPATH=/app python -m uvicorn server.main:app --host 0.0.0.0 --port 8000 --reload"] ports: - "8000:8000" environment: @@ -26,3 +26,4 @@ services: command: ["/bin/sh", "-c", "npm install --no-audit --no-fund && npm run dev -- --host 0.0.0.0"] ports: - "5173:5173" + diff --git a/scripts/prepare_dist.py b/scripts/prepare_dist.py index 16ac9e3..cb55f8d 100644 --- a/scripts/prepare_dist.py +++ b/scripts/prepare_dist.py @@ -33,6 +33,9 @@ IGNORE_PATTERNS = [ "node_modules", "*.pyc", "*.pyo", + ".vscode", + ".devcontainer", + "*.sln", ] diff --git a/scripts/start_debug_server.ps1 b/scripts/start_debug_server.ps1 new file mode 100644 index 0000000..d7be39e --- /dev/null +++ b/scripts/start_debug_server.ps1 @@ -0,0 +1,13 @@ +# 调试模式启动:只启动前端 + idle 的后端容器,便于手动/VS Code 调试 +# 用法:在仓库根执行 +# ./scripts/start_debug_server.ps1 + +param( + [string]$ComposeDev = "docker-compose.dev.yml", + [string]$ComposeDebug = "docker-compose.debug.yml" +) + +Write-Host "Starting web + server (debug override) ..." -ForegroundColor Cyan +& docker compose -f $ComposeDev -f $ComposeDebug up -d web server + +Write-Host "Done. Server is idle. You can now start uvicorn manually inside the container or via VS Code launch configurations." -ForegroundColor Green diff --git a/server/app/nodes/trace_loader_nodes.py b/server/app/nodes/trace_loader_nodes.py index a82fea8..e702734 100644 --- a/server/app/nodes/trace_loader_nodes.py +++ b/server/app/nodes/trace_loader_nodes.py @@ -19,6 +19,7 @@ from server.app.core.node_base import ( CachePolicy ) from server.app.core.node_registry import register_node +from server.app.services.agent_client import AgentClient import yaml @@ -31,6 +32,10 @@ def load_system_config(): return {} +def _agent_client() -> AgentClient: + return AgentClient.from_env(container_cloud_root=CLOUD_ROOT) + + @register_node class TraceLoader_Metadata(TraceNode): """ @@ -84,47 +89,36 @@ class TraceLoader_Metadata(TraceNode): if not utrace_full_path.exists(): raise FileNotFoundError(f"Utrace 文件不存在:{utrace_full_path}") - # 构建输出 CSV 完整路径 + # 构建输出 CSV 完整路径(容器内路径 + 宿主机路径,宿主机路径给 Agent 用) csv_full_path = CLOUD_ROOT / output_csv - - # 确保输出目录存在 + agent = _agent_client() + host_csv_full_path = agent.map_cloud_to_host(output_csv) + + # 确保输出目录存在(容器内),Agent 写宿主路径时也会自动创建 csv_full_path.parent.mkdir(parents=True, exist_ok=True) - if csv_full_path.exists() and False: - df = pl.read_csv(csv_full_path) - return { - "metadata": df, - "file_path": str(csv_full_path) - } io.safe_delete(csv_full_path) - # 构建命令 - # UnrealInsights.exe -OpenTraceFile="path/to/file.utrace" -ExecOnAnalysisCompleteCmd="TimingInsights.ExportMetadata path/to/output.csv" - cmd = [ - str(insights_path), - f'-OpenTraceFile="{utrace_full_path}"', - f'-ExecOnAnalysisCompleteCmd="TimingInsights.ExportMetadata {csv_full_path} -tokens={tokens}"' + + # 通过 Agent 调用宿主机 UnrealInsights + host_utrace_full_path = agent.map_cloud_to_host(utrace_full_path) + cmd_args = [ + f'-OpenTraceFile="{host_utrace_full_path}"', + f'-ExecOnAnalysisCompleteCmd="TimingInsights.ExportMetadata {host_csv_full_path} -tokens={tokens}"' ] - print("TraceLoader_Metadata: 2.Execute::", " ".join(cmd)) - # 执行命令 - result = subprocess.run( - ' '.join(cmd), - shell=True, - capture_output=True, - text=True, - encoding='utf-8', - errors='replace' - ) - - # 检查执行结果 - if result.returncode != 0: - error_msg = f"UnrealInsights 执行失败 (退出码: {result.returncode})\n" - if result.stderr: - error_msg += f"错误输出:\n{result.stderr}" - raise RuntimeError(error_msg) - + print("TraceLoader_Metadata: call agent with", cmd_args) + resp = agent.run("unreal_insights", args=cmd_args, timeout=None) + + if not resp.get("success", False): + raise RuntimeError( + f"UnrealInsights 通过 Agent 执行失败 (code={resp.get('return_code')})\n" + f"stdout: {resp.get('stdout','')}\n" + f"stderr: {resp.get('stderr','')}" + ) + if not csv_full_path.exists(): raise FileNotFoundError( f"CSV 文件未生成:{csv_full_path}\n" - f"命令输出: {result.stdout}" + f"Agent stdout: {resp.get('stdout','')}\n" + f"Agent stderr: {resp.get('stderr','')}" ) # 读取 CSV 文件 @@ -196,36 +190,31 @@ class TraceLoader_Events(TraceNode): if not utrace_full_path.exists(): raise FileNotFoundError(f"Utrace 文件不存在:{utrace_full_path}") + agent = _agent_client() if Path(output_csv).is_absolute(): csv_full_path = Path(output_csv) + host_csv_full_path = agent.map_cloud_to_host(output_csv) else: csv_full_path = cloud_root / output_csv + host_csv_full_path = agent.map_cloud_to_host(output_csv) csv_full_path.parent.mkdir(parents=True, exist_ok=True) - # 构建命令(导出事件数据) - cmd = [ - str(insights_path), - f'-OpenTraceFile="{utrace_full_path}"', - f'-ExecOnAnalysisCompleteCmd="TimingInsights.ExportTimingEvents {csv_full_path}"' + # 通过 Agent 执行导出 + host_utrace_full_path = agent.map_cloud_to_host(utrace_full_path) + cmd_args = [ + f'-OpenTraceFile="{host_utrace_full_path}"', + f'-ExecOnAnalysisCompleteCmd="TimingInsights.ExportTimingEvents {host_csv_full_path}"' ] - try: - result = subprocess.run( - ' '.join(cmd), - shell=True, - capture_output=True, - text=True, - timeout=timeout, - encoding='utf-8', - errors='replace' - ) + resp = agent.run("unreal_insights", args=cmd_args, timeout=timeout) - if result.returncode != 0: - error_msg = f"UnrealInsights 执行失败 (退出码: {result.returncode})\n" - if result.stderr: - error_msg += f"错误输出:\n{result.stderr}" - raise RuntimeError(error_msg) + if not resp.get("success", False): + raise RuntimeError( + f"UnrealInsights 通过 Agent 执行失败 (code={resp.get('return_code')})\n" + f"stdout: {resp.get('stdout','')}\n" + f"stderr: {resp.get('stderr','')}" + ) # 等待文件生成 wait_time = 0 @@ -234,7 +223,11 @@ class TraceLoader_Events(TraceNode): wait_time += 0.5 if not csv_full_path.exists(): - raise FileNotFoundError(f"CSV 文件未生成:{csv_full_path}") + raise FileNotFoundError( + f"CSV 文件未生成:{csv_full_path}\n" + f"Agent stdout: {resp.get('stdout','')}\n" + f"Agent stderr: {resp.get('stderr','')}" + ) # 读取 CSV(使用 polars,保持后端一致) df = pl.read_csv(csv_full_path) @@ -258,9 +251,7 @@ class TraceLoader_Events(TraceNode): except Exception as e: result_context["cleanup_error"] = str(e) - out = outputs - # 可选地将 context 信息并入 outputs.meta 或单独管理;这里仅返回 outputs - return out + return outputs except subprocess.TimeoutExpired: raise TimeoutError(f"命令执行超时(超过 {timeout} 秒)") diff --git a/server/app/services/agent_client.py b/server/app/services/agent_client.py new file mode 100644 index 0000000..0e2cb91 --- /dev/null +++ b/server/app/services/agent_client.py @@ -0,0 +1,127 @@ +"""Agent 客户端:通过 HTTP 调用宿主上的 Agent 服务,并提供路径映射工具。""" +from __future__ import annotations + +import json +import os +import urllib.request +import urllib.error +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any, Dict, List, Optional, Union, Union + + +class AgentClient: + def __init__( + self, + base_url: str = "http://localhost:8100", + *, + host_cloud_root: Optional[Path | str] = None, + container_cloud_root: Optional[Path | str] = None, + ): + self.base_url = base_url.rstrip("/") + self.host_cloud_root = Path(host_cloud_root) if host_cloud_root else None + self.container_cloud_root = Path(container_cloud_root) if container_cloud_root else None + + @classmethod + def from_env(cls, *, container_cloud_root: Optional[Path | str] = None) -> "AgentClient": + base_url = os.environ.get("AGENT_BASE_URL", "http://host.docker.internal:8100") + host_root = os.environ.get("AGENT_HOST_CLOUD_ROOT") + return cls(base_url=base_url, host_cloud_root=host_root, container_cloud_root=container_cloud_root) + + # ---------------------- 路径映射 (核心修正) ---------------------- + def map_cloud_to_host(self, path: Union[str, Path]) -> str: + """ + 【Docker -> Host】 + 将容器内的路径(/opt/...)转换为宿主机的路径(D:\...)。 + 返回 str 以便直接传给 Agent API。 + """ + # 输入可能是 pathlib.Path (Posix) 或 字符串 + p_obj = PurePosixPath(path) + + # 如果没有配置映射,直接返回字符串 + if not self.container_cloud_root or not self.host_cloud_root: + return str(p_obj) + + try: + # 1. 计算相对路径 (Linux 逻辑) + # 例如: /opt/tracestudio/cloud/traces/test.utrace -> traces/test.utrace + rel = p_obj.relative_to(self.container_cloud_root) + + # 2. 拼接到 Windows 根路径 (Windows 逻辑) + # 例如: D:\XGame\...\cloud + traces/test.utrace -> D:\XGame\...\cloud\traces\test.utrace + return str(self.host_cloud_root / rel) + except ValueError: + # 如果路径不在 container_cloud_root 下,原样返回(可能是绝对路径或其他挂载) + return str(p_obj) + + def map_host_to_container(self, path: Union[str, Path]) -> Path: + """ + 【Host -> Docker】 + 将 Agent 返回的 Windows 路径(D:\...)转换为容器内可读的路径(/opt/...)。 + 返回 Path 对象以便 Python 打开文件。 + """ + # 输入是 Windows 格式字符串 + p_obj = PureWindowsPath(path) + + if not self.host_cloud_root or not self.container_cloud_root: + # 无法映射,尝试直接转换(大概率读不到,但保持类型一致) + return Path(str(p_obj)) + + try: + # 1. 计算相对路径 (Windows 逻辑) + # Windows 不区分大小写,但 relative_to 默认区分。 + # 如果路径来源可靠,通常没问题。 + rel = p_obj.relative_to(self.host_cloud_root) + + # 2. 拼接到 Linux 容器路径 + # 这里的 Path 是具体平台的 Path (在 Docker 里就是 PosixPath) + return Path(self.container_cloud_root / rel) + except ValueError: + return Path(str(p_obj)) + + # ---------------------- 调用 ---------------------- + def run( + self, + tool: str, + args: Optional[List[str]] = None, + *, + workdir: Optional[str] = None, + timeout: Optional[int] = None, + env: Optional[Dict[str, str]] = None, + capture_output: Optional[bool] = None, + strip_output: Optional[bool] = None, + ) -> Dict[str, Any]: + payload = { + "tool": tool, + "args": args or [], + } + if workdir: + payload["workdir"] = workdir + if timeout is not None: + payload["timeout"] = timeout + if env: + payload["env"] = env + if capture_output is not None: + payload["capture_output"] = capture_output + if strip_output is not None: + payload["strip_output"] = strip_output + + url = f"{self.base_url}/run" + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + url, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=timeout or 30) as resp: + body = resp.read().decode("utf-8") + return json.loads(body) + except urllib.error.HTTPError as e: + detail = e.read().decode("utf-8") if e.fp else str(e) + raise RuntimeError(f"Agent HTTP {e.code}: {detail}") from e + except Exception as e: + raise RuntimeError(f"Agent 调用失败: {e}") from e + + +__all__ = ["AgentClient"] diff --git a/web/src/core/api/FileApi.ts b/web/src/core/api/FileApi.ts index fa860c2..d1b7f69 100644 --- a/web/src/core/api/FileApi.ts +++ b/web/src/core/api/FileApi.ts @@ -3,15 +3,7 @@ * 对接后端 /api/files/* 端点,提供文件列表、上传、操作、下载URL。 */ -// 动态获取API基础URL(与 core/api/api.ts 保持一致,但本地定义以降低耦合) -function getApiBaseUrl(): string { - if (import.meta.env.VITE_API_BASE_URL) return import.meta.env.VITE_API_BASE_URL - const { protocol, hostname } = window.location - const apiPort = '8000' - return `${protocol}//${hostname}:${apiPort}` -} - -const API_BASE_URL = getApiBaseUrl() +import { API_BASE_URL } from './base' export type FileInfo = { name: string diff --git a/web/src/core/api/SchemaApi.ts b/web/src/core/api/SchemaApi.ts index eb1a2af..903dd7f 100644 --- a/web/src/core/api/SchemaApi.ts +++ b/web/src/core/api/SchemaApi.ts @@ -1,9 +1,10 @@ +import { API_BASE_URL } from './base' + export class SchemaApi { static async fetchManifest(): Promise { - //const res = await fetch('/api/schema/manifest'); - const res = await fetch('/api/plugins'); - if (!res.ok) throw new Error(`Failed to fetch manifest: ${res.status}`); - return await res.json(); + const res = await fetch(`${API_BASE_URL}/api/plugins`) + if (!res.ok) throw new Error(`Failed to fetch manifest: ${res.status}`) + return await res.json() } } diff --git a/web/src/core/api/api.ts b/web/src/core/api/api.ts index f7c9278..588a85b 100644 --- a/web/src/core/api/api.ts +++ b/web/src/core/api/api.ts @@ -3,29 +3,7 @@ * 统一管理所有后端请求,处理 CORS 和错误 */ -// 动态获取API基础URL -function getApiBaseUrl(): string { - // 1. 优先使用环境变量 - if (import.meta.env.VITE_API_BASE_URL) { - return import.meta.env.VITE_API_BASE_URL - } - // 2. 生产环境使用相对路径(前后端同域) - const { protocol, hostname } = window.location - const apiPort = '8000' - - if (hostname === 'localhost' || hostname === '127.0.0.1') { - return `${protocol}//${hostname}:${apiPort}` - } - - return `${protocol}//${hostname}:${apiPort}` -} - -const API_BASE_URL = getApiBaseUrl() -const WS_BASE_URL = (() => { - const http = new URL(API_BASE_URL) - const isHttps = http.protocol === 'https:' - return `${isHttps ? 'wss' : 'ws'}://${http.host}` -})() +import { API_BASE_URL, WS_BASE_URL } from './base' if (import.meta.env.DEV) { console.log('🔗 API Base URL:', API_BASE_URL) diff --git a/web/src/core/api/base.ts b/web/src/core/api/base.ts new file mode 100644 index 0000000..b9e9688 --- /dev/null +++ b/web/src/core/api/base.ts @@ -0,0 +1,17 @@ +/** + * API 基础地址与协议工具 + */ +export function getApiBaseUrl(): string { + if (import.meta.env.VITE_API_BASE_URL) return import.meta.env.VITE_API_BASE_URL + const { protocol, hostname } = window.location + const apiPort = '8000' + return `${protocol}//${hostname}:${apiPort}` +} + +export const API_BASE_URL = getApiBaseUrl() + +export const WS_BASE_URL = (() => { + const http = new URL(API_BASE_URL) + const isHttps = http.protocol === 'https:' + return `${isHttps ? 'wss' : 'ws'}://${http.host}` +})()