浏览代码

feat: restore graduation project from PR #837

Original PR: https://github.com/datawhalechina/hello-agents/pull/837
Original commits:
819d1c9fbd5d9f49149bcc6c3016755c49dd5efd
fd28a54e62d11c8d13a193eb10aa5920e4f90039
49618ac038f8467fcf0e32ffc7c800ef3774efc9
913b96d86eb3e5cfa050204959403f3bfb5b4564
5c4409865e7184f6eb1e2353fa67dd2ddf6fb46c

senming666 2 周之前
父节点
当前提交
93e32095b8
共有 50 个文件被更改,包括 5796 次插入 和 0 次删除
  1. 31 0
      Co-creation-projects/senming666-api_test_assistant/.dockerignore
  2. 22 0
      Co-creation-projects/senming666-api_test_assistant/.env.example
  3. 31 0
      Co-creation-projects/senming666-api_test_assistant/.gitignore
  4. 58 0
      Co-creation-projects/senming666-api_test_assistant/Dockerfile
  5. 311 0
      Co-creation-projects/senming666-api_test_assistant/README.md
  6. 18 0
      Co-creation-projects/senming666-api_test_assistant/api.yaml
  7. 3 0
      Co-creation-projects/senming666-api_test_assistant/frontend/.gitignore
  8. 15 0
      Co-creation-projects/senming666-api_test_assistant/frontend/index.html
  9. 1574 0
      Co-creation-projects/senming666-api_test_assistant/frontend/package-lock.json
  10. 20 0
      Co-creation-projects/senming666-api_test_assistant/frontend/package.json
  11. 99 0
      Co-creation-projects/senming666-api_test_assistant/frontend/src/App.vue
  12. 36 0
      Co-creation-projects/senming666-api_test_assistant/frontend/src/api/request.js
  13. 11 0
      Co-creation-projects/senming666-api_test_assistant/frontend/src/api/test.js
  14. 186 0
      Co-creation-projects/senming666-api_test_assistant/frontend/src/components/ApiTestForm.vue
  15. 112 0
      Co-creation-projects/senming666-api_test_assistant/frontend/src/components/ResultSummary.vue
  16. 223 0
      Co-creation-projects/senming666-api_test_assistant/frontend/src/components/ResultTable.vue
  17. 49 0
      Co-creation-projects/senming666-api_test_assistant/frontend/src/constants/exampleDoc.js
  18. 13 0
      Co-creation-projects/senming666-api_test_assistant/frontend/src/main.js
  19. 27 0
      Co-creation-projects/senming666-api_test_assistant/frontend/src/styles/index.css
  20. 32 0
      Co-creation-projects/senming666-api_test_assistant/frontend/src/utils/format.js
  21. 25 0
      Co-creation-projects/senming666-api_test_assistant/frontend/vite.config.js
  22. 80 0
      Co-creation-projects/senming666-api_test_assistant/httpbin.json
  23. 255 0
      Co-creation-projects/senming666-api_test_assistant/main.ipynb
  24. 133 0
      Co-creation-projects/senming666-api_test_assistant/main.py
  25. 139 0
      Co-creation-projects/senming666-api_test_assistant/openapi_service.yaml
  26. 4 0
      Co-creation-projects/senming666-api_test_assistant/pytest.ini
  27. 2 0
      Co-creation-projects/senming666-api_test_assistant/requirements-dev.txt
  28. 25 0
      Co-creation-projects/senming666-api_test_assistant/requirements.txt
  29. 150 0
      Co-creation-projects/senming666-api_test_assistant/server.py
  30. 0 0
      Co-creation-projects/senming666-api_test_assistant/src/__init__.py
  31. 0 0
      Co-creation-projects/senming666-api_test_assistant/src/agents/__init__.py
  32. 101 0
      Co-creation-projects/senming666-api_test_assistant/src/agents/executor_agent.py
  33. 242 0
      Co-creation-projects/senming666-api_test_assistant/src/agents/generator_agent.py
  34. 445 0
      Co-creation-projects/senming666-api_test_assistant/src/agents/parser_agent.py
  35. 141 0
      Co-creation-projects/senming666-api_test_assistant/src/agents/reporter_agent.py
  36. 70 0
      Co-creation-projects/senming666-api_test_assistant/src/agents/validator_agent.py
  37. 44 0
      Co-creation-projects/senming666-api_test_assistant/src/config.py
  38. 0 0
      Co-creation-projects/senming666-api_test_assistant/src/tools/__init__.py
  39. 135 0
      Co-creation-projects/senming666-api_test_assistant/src/tools/http_client.py
  40. 95 0
      Co-creation-projects/senming666-api_test_assistant/src/tools/schema_validator.py
  41. 3 0
      Co-creation-projects/senming666-api_test_assistant/tests/.gitignore
  42. 0 0
      Co-creation-projects/senming666-api_test_assistant/tests/fixtures/chat_openapi.json
  43. 82 0
      Co-creation-projects/senming666-api_test_assistant/tests/test_executor_agent.py
  44. 176 0
      Co-creation-projects/senming666-api_test_assistant/tests/test_generator_agent.py
  45. 103 0
      Co-creation-projects/senming666-api_test_assistant/tests/test_http_client.py
  46. 280 0
      Co-creation-projects/senming666-api_test_assistant/tests/test_parser_agent.py
  47. 60 0
      Co-creation-projects/senming666-api_test_assistant/tests/test_reporter_agent.py
  48. 68 0
      Co-creation-projects/senming666-api_test_assistant/tests/test_schema_validator.py
  49. 22 0
      Co-creation-projects/senming666-api_test_assistant/tests/test_server.py
  50. 45 0
      Co-creation-projects/senming666-api_test_assistant/tests/test_validator_agent.py

+ 31 - 0
Co-creation-projects/senming666-api_test_assistant/.dockerignore

@@ -0,0 +1,31 @@
+# ============================================================
+# .dockerignore - "别把这些搬进集装箱现场"的黑名单
+#
+# docker build 时,会把当前目录整个打包发给 Docker 引擎
+# (这个包叫"构建上下文")。你项目里 .venv 有近 2 万个文件、
+# node_modules 也是几百 MB —— 不排除的话,每次构建光传文件就要几分钟。
+#
+# 注意:node_modules 在容器里会重新 npm install,宿主这份完全不用带;
+# .env 里有密钥,绝对不能进镜像(以后用 docker run --env-file 在运行时喂)。
+# ============================================================
+
+# Python
+.venv/
+__pycache__/
+*.pyc
+.pytest_cache/
+
+# Node(阶段一会在容器里自己装,宿主这份不带)
+frontend/node_modules/
+
+# 测试与日志(镜像里不需要)
+tests/
+reports/
+server.log
+httpbin.json
+
+# 密钥!打死也不进镜像
+.env
+
+# Git 元数据
+.git/

+ 22 - 0
Co-creation-projects/senming666-api_test_assistant/.env.example

@@ -0,0 +1,22 @@
+# ============================================================
+# 智能API测试助手 - 环境变量配置模板
+#
+# 使用方法:
+#   1. 复制本文件并重命名为 .env
+#   2. 把下面的占位符替换成你的真实值
+#   3. .env 文件不要提交到 GitHub(里面是密钥)
+# ============================================================
+
+# ========== LLM 配置(必需)==========
+
+# 模型名称(你的中转站支持哪个就填哪个)
+LLM_MODEL_ID=gpt-5.6-luna
+
+# API 密钥
+LLM_API_KEY=your_api_key_here
+
+# API 地址(中转站或官方端点)
+LLM_BASE_URL=https://app.pinaic.com/v1
+
+# 请求超时时间(秒)。生成测试用例耗时较长,建议设大一点
+LLM_TIMEOUT=120

+ 31 - 0
Co-creation-projects/senming666-api_test_assistant/.gitignore

@@ -0,0 +1,31 @@
+# ============================================================
+# Python
+# ============================================================
+.venv/
+__pycache__/
+*.py[cod]
+.pytest_cache/
+.ipynb_checkpoints/
+
+# ============================================================
+# 密钥——绝不能提交到仓库
+# ============================================================
+.env
+
+# ============================================================
+# Node / 前端
+# ============================================================
+frontend/node_modules/
+frontend/dist/
+
+# ============================================================
+# IDE
+# ============================================================
+.idea/
+.vscode/
+
+# ============================================================
+# 日志与生成的测试报告
+# ============================================================
+*.log
+reports/

+ 58 - 0
Co-creation-projects/senming666-api_test_assistant/Dockerfile

@@ -0,0 +1,58 @@
+# ============================================================
+# 智能API测试助手 - Docker 镜像配方(Dockerfile)
+#
+# 思路:一个集装箱装下「能跑起来的完整服务」。
+# 你的服务其实只有一个进程:server.py
+#   - 既当后端(FastAPI,处理 /api/test)
+#   - 又托管前端(把编译好的 frontend/dist 发给浏览器)
+# 所以容器只需要做一件事:让 server.py 跑起来,对外开 8000 端口。
+#
+# 前端要"编译"成静态文件(需要 Node),后端要"运行"(需要 Python),
+# 两个集装箱环境都想要?Docker 的答案是"多阶段构建":
+#   阶段一:用 Node 把 Vue 源码编译出 dist 成品
+#   阶段二:用 Python 环境把 dist 搬进来,再跑 server.py
+# 最终镜像只保留阶段二,阶段一的所有文件都会被丢弃(不占体积)。
+# ============================================================
+
+# ---------- 阶段 1:造前端(Node 集装箱) ----------
+# 用 node:22-alpine(精简版)而非 node:22:功能足够、体积更小,
+# 且本机已缓存该镜像,可避免从 Docker Hub 下载被网络卡住。
+FROM node:22-alpine AS frontend-builder
+
+# 工作目录:进了这个集装箱后的默认落点,后面的命令都在这里执行
+WORKDIR /build
+
+# 先只复制"依赖清单",然后 npm install。
+# 故意拆两步,是为了吃 Docker 缓存:只要 package.json 没变,
+# 下次 build 就直接复用上次装好的依赖,不用重新下载几百 MB。
+COPY frontend/package.json frontend/package-lock.json ./
+RUN npm install
+
+# 再复制全部前端源码,执行编译。Vite 会把成品输出到 /build/dist
+COPY frontend/ ./
+RUN npm run build
+
+# ---------- 阶段 2:跑后端(Python 集装箱) ----------
+FROM python:3.12-slim
+
+# 后端代码统一放在 /app 目录
+WORKDIR /app
+
+# 同样的缓存技巧:先装依赖,再拷代码
+COPY requirements.txt ./
+RUN pip install --no-cache-dir -r requirements.txt
+
+# 复制后端源码(server.py 和 src/ 包)
+COPY server.py ./
+COPY src/ ./src/
+
+# 关键一步:把阶段一编译出的前端成品搬进 Python 集装箱。
+# server.py 里找的是 ./frontend/dist,所以这个目录名不能改。
+COPY --from=frontend-builder /build/dist ./frontend/dist
+
+# 只是"说明书":声明容器对外开 8000 端口(真正开不开由运行时 -p 决定)
+EXPOSE 8000
+
+# 容器一启动就跑这条命令,等价于你本地 python server.py
+# (server.py 内部会调 uvicorn,监听 0.0.0.0:8000)
+CMD ["python", "server.py"]

+ 311 - 0
Co-creation-projects/senming666-api_test_assistant/README.md

@@ -0,0 +1,311 @@
+# 智能API测试助手(API Test Assistant)
+
+> 基于 Hello-Agents 框架的多智能体应用:给它一份 OpenAPI 文档,它自动完成「解析 → 生成用例 → 执行测试 → 验证结果 → 生成报告」的全流程,让接口测试从"手写 Postman 请求"变成"粘贴文档、看报告"。
+
+## 📝 项目简介
+
+接口测试是软件开发的刚需,但传统做法又慢又容易漏:测试人员要对着接口文档,**手写**各种测试用例,再**手动**一个个发请求、**肉眼**盯状态码和返回字段。一个接口磨十几分钟,几十个接口就是一天,还容易漏掉边界情况。
+
+本项目用**多智能体流水线**把这件事自动化:
+
+```
+输入:一份 OpenAPI 文档(.yaml / .json)
+          ↓
+① ParserAgent    解析文档,提取接口清单
+          ↓
+② GeneratorAgent 用 LLM 智能生成测试用例(正常 / 边界 / 异常三类)
+          ↓
+③ ExecutorAgent  真实发 HTTP 请求去调用目标接口
+          ↓
+④ ValidatorAgent 校验状态码和返回结构是否符合预期
+          ↓
+⑤ ReporterAgent  汇总成 HTML 报告 + 通过率统计
+          ↓
+输出:一份漂亮的测试报告
+```
+
+**核心价值**:LLM 只负责"想该测什么"(这是最需要智能的地方),发请求、校验结果这些确定性的活交给工具,既智能又可靠。
+
+## ✨ 核心功能
+
+- ✅ **多智能体流水线**:5 个 Agent 各司其职,上一个的输出是下一个的输入
+- ✅ **LLM 智能生成用例**:自动覆盖正常 / 边界 / 异常三类场景,人想不到的边界值它来补
+- ✅ **真实 HTTP 调用**:带超时控制和自动重试,真实反映目标接口的行为
+- ✅ **自动结果校验**:状态码比对 + JSON Schema 结构校验
+- ✅ **双格式报告**:HTML + Markdown 两种格式 + 通过率统计,一眼定位失败用例
+- ✅ **前端可视化**:FastAPI + Vue3 工程化前端,粘贴文档点按钮即可测试
+- ✅ **命令行入口**:`python main.py` 一键跑完整个流程
+- ✅ **URL 抓取**:`--url` 参数直接抓取网络上的 OpenAPI 文档,无需先下载
+- ✅ **认证请求头**:`--header` 参数传入 Authorization / API Key,能测需要鉴权的接口
+
+## 🛠️ 技术栈
+
+- **Hello-Agents** 框架(`hello-agents>=1.0.0`,多 Agent 架构)
+- **LLM**:OpenAI 兼容接口(支持 DeepSeek / 各类中转站),通过 `HelloAgentsLLM()` 调用
+- **Web 服务**:FastAPI + Uvicorn(把测试能力暴露成 HTTP 接口)
+- **前端**:Vue3 + Vite + Element Plus + axios(`npm run dev` 开发 / `npm run build` 打包)
+- **文档解析**:PyYAML + JSON(解析 OpenAPI 文档)
+- **结构校验**:jsonschema(校验响应体结构)
+- **报告渲染**:Jinja2(HTML 报告模板)
+- **容器化**:Docker(多阶段构建 Dockerfile:Node 编译前端 + Python 运行后端,本地部署已验证)
+
+## 🚀 快速开始
+
+### 环境要求
+
+- Python 3.10+
+- 一个 OpenAI 兼容的 LLM API Key(DeepSeek 或中转站均可)
+
+### 安装依赖
+
+```bash
+pip install -r requirements.txt
+```
+
+### 配置 API 密钥
+
+```bash
+# 复制模板
+cp .env.example .env
+
+# 编辑 .env,填入真实配置
+# LLM_MODEL_ID=deepseek-chat          (你的模型名)
+# LLM_API_KEY=sk-你的真实密钥
+# LLM_BASE_URL=https://api.deepseek.com/v1
+```
+
+### 运行项目
+
+本项目提供三种运行方式,按需选择:
+
+**方式 1:命令行(最直接)**
+
+```bash
+python main.py --file api.yaml --base-url https://jsonplaceholder.typicode.com
+```
+
+**方式 2:Web 前端(可视化)**
+
+```bash
+cd frontend
+npm install                 # 首次运行安装前端依赖
+npm run build               # 构建 Vue 前端,生成 frontend/dist/
+cd ..
+python server.py
+# 浏览器打开 http://localhost:8000
+```
+
+`server.py` 只托管 Vue 构建产物。首次运行或修改前端代码后,需要重新执行 `npm run build`。
+
+**方式 3:Jupyter Notebook(教学演示)**
+
+```bash
+jupyter lab
+# 打开 main.ipynb 并逐格运行
+```
+
+## 📖 使用示例
+
+### 命令行方式
+
+以项目自带的 `api.yaml`(JSONPlaceholder 的 /users、/posts 两个接口)为例:
+
+```bash
+python main.py --file api.yaml --base-url https://jsonplaceholder.typicode.com
+```
+
+运行后自动生成 `reports/report.html`,终端会打印:
+
+```
+[1/5] 解析完成:发现 2 个接口
+[2/5] 生成完成:共 6 个测试用例
+[3/5] 执行完成:已发送 6 个请求
+[4/5] 验证完成
+[5/5] 报告已生成:reports/report.html
+测试结果:总数 6,通过 4,失败 2,通过率 66.7%
+```
+
+> 失败的那 2 个用例是"异常场景":LLM 期望返回 400,但 JSONPlaceholder 这个 mock 服务对非法参数也返回 200。这恰恰证明了工具能**如实发现真实 API 的行为与文档约定不符**。
+
+也可以直接用 `--url` 从网络抓取文档,无需先下载到本地:
+
+```bash
+python main.py --url https://httpbin.org/spec.json --base-url https://httpbin.org
+```
+
+两种方式都会同时生成 `reports/report.html` 和 `reports/report.md`。
+
+需要认证的接口,用 `--header` 传入认证头(可多次使用):
+
+```bash
+python main.py --url https://httpbin.org/spec.json --base-url https://httpbin.org --header "Authorization: Bearer your-token"
+```
+
+### Web 前端方式(Vue3)
+
+前端是 Vue3 工程,支持生产模式和开发模式两种运行方式:
+
+**生产模式(部署)**
+```bash
+cd frontend && npm run build   # 打包 → frontend/dist/
+cd ..
+python server.py               # 启动后端,自动托管 dist
+# 浏览器打开 http://localhost:8000
+```
+
+**开发模式(改代码热更新)**
+```bash
+python server.py               # 终端1:后端在 8000
+cd frontend                    # 终端2
+npm install                     # 首次运行安装前端依赖
+npm run dev                     # Vite 开发服务器在 5173(/api 自动代理到 8000)
+# 浏览器打开 http://localhost:5173
+```
+
+打开后:选择「粘贴文档」并填入 OpenAPI 文档,或选择「URL 抓取」直接填写网络上的 OpenAPI 文档地址;
+再填写目标 API 地址,点「🚀 开始测试」,下方展示统计卡片与用例明细(点行可展开看请求/响应详情)。
+
+## 📂 项目结构
+
+```
+senming666-api_test_assistant/
+├── README.md                       # 项目说明(本文件)
+├── Dockerfile                      # 多阶段构建:Node 编译前端 + Python 运行后端
+├── .dockerignore                   # 构建镜像时排除本地无用文件
+├── .gitignore                      # Git 忽略规则
+├── requirements.txt                # Python 运行依赖
+├── requirements-dev.txt            # Python 开发依赖(pytest)
+├── pytest.ini                      # pytest 配置
+├── main.py                         # 命令行入口,串起 5 个 Agent
+├── main.ipynb                      # Jupyter 演示入口
+├── server.py                       # FastAPI 服务,托管 Vue 构建产物
+├── .env.example                    # LLM 配置模板(不含真实密钥)
+├── .env                            # 本地真实配置,不应提交到代码仓库(仅本机使用)
+├── api.yaml                        # 示例:被测目标文档(JSONPlaceholder)
+├── httpbin.json                    # 示例:被测目标文档(httpbin.org)
+├── openapi_service.yaml            # 本项目自身服务的 OpenAPI 文档
+├── frontend/                       # Vue3 + Vite + Element Plus 前端工程
+│   ├── index.html                  # Vite HTML 入口和 Vue 挂载点
+│   ├── package.json                # 前端依赖和 npm scripts
+│   ├── package-lock.json           # 前端依赖锁定文件
+│   ├── vite.config.js              # Vite 配置、路径别名和 /api 代理
+│   └── src/
+│       ├── main.js                 # Vue 应用入口,注册 Element Plus
+│       ├── App.vue                 # 根组件,编排页面状态
+│       ├── api/
+│       │   ├── request.js          # axios 实例和统一错误处理
+│       │   └── test.js             # 调用后端测试接口
+│       ├── components/
+│       │   ├── ApiTestForm.vue     # 文档粘贴、URL 抓取和测试参数表单
+│       │   ├── ResultSummary.vue   # 测试汇总展示
+│       │   └── ResultTable.vue     # 用例明细和请求响应展示
+│       ├── constants/
+│       │   └── exampleDoc.js       # 示例文档和展示映射
+│       ├── utils/
+│       │   └── format.js            # 数据展示格式化
+│       └── styles/
+│           └── index.css            # 全局样式
+├── reports/                        # HTML 和 Markdown 测试报告输出目录
+├── tests/                          # Python 单元测试
+│   ├── test_parser_agent.py
+│   ├── test_generator_agent.py
+│   ├── test_executor_agent.py
+│   ├── test_validator_agent.py
+│   ├── test_reporter_agent.py
+│   ├── test_schema_validator.py
+│   ├── test_http_client.py
+│   ├── test_server.py
+│   └── fixtures/
+│       └── chat_openapi.json       # 真实业务 OpenAPI 集成夹具
+└── src/                            # Python 核心源代码
+    ├── __init__.py
+    ├── config.py                   # 配置常量(超时/重试/并发等)
+    ├── tools/                      # 工具层:HTTP 请求和结果校验
+    │   ├── __init__.py
+    │   ├── http_client.py           # HTTP 请求工具(超时+重试)
+    │   └── schema_validator.py      # 状态码和 JSON Schema 校验
+    └── agents/                     # Agent 流水线
+        ├── __init__.py
+        ├── parser_agent.py         # ① 解析 OpenAPI 文档
+        ├── generator_agent.py      # ② 用 LLM 生成测试用例
+        ├── executor_agent.py       # ③ 真实调用目标接口
+        ├── validator_agent.py      # ④ 验证接口返回结果
+        └── reporter_agent.py       # ⑤ 生成测试报告
+```
+
+## 🎯 项目亮点
+
+- **分层清晰**:工具层(tools)与智能体层(agents)分离,Agent 决策、Tool 干活
+- **智能与确定性结合**:只有 GeneratorAgent 用 LLM(想"测什么"),其余 4 个 Agent 是确定性逻辑(更可靠、更省 token)
+- **数据穿层设计**:用例在流水线中逐层包裹新字段(case → +result → +passed/errors),每层职责单一
+- **真实可跑**:不依赖 mock,直接对公网 API 发起真实请求,结果可信
+- **全栈完整**:后端(FastAPI)+ Vue3 前端 + 命令行 + Notebook 四种入口
+- **容器化(本地部署)**:已配备多阶段构建 Dockerfile(Node 编译前端 + Python 运行后端),并完成本地 Docker 部署验证
+
+## 📊 性能评估
+
+> 覆盖三套被测对象。后续计划补充:接口覆盖率、各环节耗时占比、不同 LLM 对比。
+
+### 测试对象一:JSONPlaceholder(`api.yaml`,2 个 GET 接口)
+
+结果:**总数 6,通过 4,失败 2,通过率 66.7%**
+
+- ✅ normal / boundary 用例全部通过(GET 接口正常返回 200)
+- ❌ 2 个 error 用例失败:LLM 预期"传未定义参数返回 400",但 JSONPlaceholder 是 mock 服务,对任意参数都返回 200
+
+**分析**:失败并非 bug,而是 LLM 预期与 mock 服务宽松行为的合理摩擦,证明工具如实报告而非"粉饰"。
+
+### 测试对象二:自己测自己(`openapi_service.yaml`,GET / + POST /api/test)
+
+用测试助手测试它自己暴露的接口,三轮迭代:
+
+| 迭代 | 通过率 | 抓到的 bug → 修复 |
+|---|---|---|
+| ① 初始 | 33.3% (2/6) | POST 用例全 422:ParserAgent 未提取 requestBody,LLM 看不到必填字段 |
+| ② 补数据穿层 | 66.7% (4/6) | POST boundary 空字符串 → server 内部 500 崩溃 |
+| ③ 加空输入容错 | 83.3% (5/6) | 只剩 GET / 不校验参数(非 bug,页面接口常态) |
+
+**修复内容**:
+1. `parser_agent.py`:提取 `requestBody` 字段,并对空输入 / 非 dict 加类型容错
+2. `generator_agent.py`:prompt 传入请求体定义,强调 body 字段名必须精确匹配
+
+**关键结论**:这个项目本身就是"测试工具",用它测自己时连续抓到 2 个真实 bug(requestBody 穿层断裂、空输入 500 崩溃),比"全绿"更能体现工具价值——测试工具能反过来驱动被测对象改进。
+
+### 测试对象三:httpbin.org(`--url` 抓取完整 spec,73 个接口)
+
+用 `--url` 抓取 httpbin 官方 spec(`https://httpbin.org/spec.json`),传入认证头 `Authorization: Bearer test-token`,启用路径参数替换,并对请求做 1.5 秒限速以避开 httpbin 对突发流量的限流(返回 503)。
+
+结果:**总数 219,通过 120,失败 99,通过率 54.8%**(干净数据,0 个 503)
+
+失败原因分布(99 个失败,均非工具缺陷):
+- **46 个「期望报错却返回 200」**:LLM 预期 400/404/405 等错误码,但 httpbin 作为测试服务对异常输入宽容,统一返回 200(工具如实报告了被测对象的宽松行为)
+- **20 个「服务端 500」**:重定向到无效目标、`/delay`、`/stream` 等特殊接口的参数组合,httpbin 服务端直接返回 500
+- **17 个「404」**:路径参数为空或缺失时,httpbin 返回 404 而非文档声明的 4xx
+- **7 个「502」**:`/brotli`、`/deflate`、`/gzip` 等压缩编码接口的响应解码问题(httpbin 服务端上游)
+- **5 个「401 认证」**:`/basic-auth`、`/digest-auth` 需要 Basic 认证,本次测试传的是 Bearer 头,认证方式不匹配(测试参数配置问题,非工具缺陷)
+- 其余 4 个为 406(图片内容协商)等边缘情况
+
+**结论**:99 个失败均可归因于被测对象(httpbin 测试服务)的行为特性或测试参数配置,**工具本身无缺陷**——它如实报告了每一个失败,没有误判、没有掩盖。
+
+
+## 🔮 未来计划
+
+- [ ] 并发执行测试用例(config 里已预留 MAX_CONCURRENCY)
+- [ ] 补充更多性能数据(接口覆盖率、各环节耗时占比、多 LLM 对比)
+
+## 🤝 贡献指南
+
+欢迎提出 Issue 和 Pull Request!
+
+## 📄 许可证
+
+MIT License
+
+## 👤 作者
+
+- GitHub: [@senming666](https://github.com/senming666)
+- 项目链接: [senming666-api_test_assistant](https://github.com/datawhalechina/Hello-Agents/tree/main/Co-creation-projects/senming666-api_test_assistant)
+
+## 🙏 致谢
+
+感谢 Datawhale 社区和 Hello-Agents 项目!

+ 18 - 0
Co-creation-projects/senming666-api_test_assistant/api.yaml

@@ -0,0 +1,18 @@
+openapi: 3.0.0
+info:
+  title: JSONPlaceholder 测试API
+  description: 用于测试智能API测试助手的示例文档
+  version: 1.0.0
+paths:
+  /users:
+    get:
+      summary: 获取用户列表
+      responses:
+        '200':
+          description: 成功返回用户列表
+  /posts:
+    get:
+      summary: 获取帖子列表
+      responses:
+        '200':
+          description: 成功返回帖子列表

+ 3 - 0
Co-creation-projects/senming666-api_test_assistant/frontend/.gitignore

@@ -0,0 +1,3 @@
+# 依赖与构建产物不入库
+node_modules/
+dist/

+ 15 - 0
Co-creation-projects/senming666-api_test_assistant/frontend/index.html

@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>智能API测试助手</title>
+  </head>
+  <body>
+    <!-- Vue 的「挂载点」:整个应用会被渲染进这个 div -->
+    <div id="app"></div>
+
+    <!-- 入口 JS:Vite 从这里开始打包整个应用 -->
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>

+ 1574 - 0
Co-creation-projects/senming666-api_test_assistant/frontend/package-lock.json

@@ -0,0 +1,1574 @@
+{
+  "name": "api-test-assistant-frontend",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "api-test-assistant-frontend",
+      "version": "0.1.0",
+      "dependencies": {
+        "axios": "^1.20.0",
+        "element-plus": "^2.14.5",
+        "vue": "^3.5.42"
+      },
+      "devDependencies": {
+        "@vitejs/plugin-vue": "^6.0.8",
+        "vite": "^8.2.2"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+      "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+      "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.29.8",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+      "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.29.8"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.29.8",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+      "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.29.7",
+        "@babel/helper-validator-identifier": "^7.29.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@ctrl/tinycolor": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
+      "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@element-plus/icons-vue": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
+      "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
+      "license": "MIT",
+      "peerDependencies": {
+        "vue": "^3.2.0"
+      }
+    },
+    "node_modules/@floating-ui/core": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
+      "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/utils": "^0.2.12"
+      }
+    },
+    "node_modules/@floating-ui/dom": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
+      "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/core": "^1.8.0",
+        "@floating-ui/utils": "^0.2.12"
+      }
+    },
+    "node_modules/@floating-ui/utils": {
+      "version": "0.2.12",
+      "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
+      "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+      "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+      "license": "MIT"
+    },
+    "node_modules/@oxc-project/types": {
+      "version": "0.147.0",
+      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz",
+      "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/Boshen"
+      }
+    },
+    "node_modules/@popperjs/core": {
+      "name": "@sxzz/popperjs-es",
+      "version": "2.11.8",
+      "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
+      "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/popperjs"
+      }
+    },
+    "node_modules/@rolldown/binding-android-arm-eabi": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz",
+      "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-android-arm64": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz",
+      "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-arm64": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz",
+      "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-x64": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz",
+      "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-freebsd-x64": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz",
+      "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz",
+      "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-gnu": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz",
+      "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-musl": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz",
+      "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz",
+      "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-s390x-gnu": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz",
+      "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-gnu": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz",
+      "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-musl": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz",
+      "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-openharmony-arm64": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz",
+      "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-arm64-msvc": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz",
+      "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-x64-msvc": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz",
+      "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+      "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/lodash": {
+      "version": "4.17.25",
+      "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz",
+      "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==",
+      "license": "MIT"
+    },
+    "node_modules/@types/lodash-es": {
+      "version": "4.17.12",
+      "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+      "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/lodash": "*"
+      }
+    },
+    "node_modules/@types/web-bluetooth": {
+      "version": "0.0.21",
+      "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
+      "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
+      "license": "MIT"
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "6.0.8",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz",
+      "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@rolldown/pluginutils": "^1.0.1"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "peerDependencies": {
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.42",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.42.tgz",
+      "integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.8",
+        "@vue/shared": "3.5.42",
+        "entities": "^7.0.1",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.42",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz",
+      "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.42",
+        "@vue/shared": "3.5.42"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.42",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz",
+      "integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.8",
+        "@vue/compiler-core": "3.5.42",
+        "@vue/compiler-dom": "3.5.42",
+        "@vue/compiler-ssr": "3.5.42",
+        "@vue/shared": "3.5.42",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.21",
+        "postcss": "^8.5.19",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.42",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz",
+      "integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.42",
+        "@vue/shared": "3.5.42"
+      }
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.42",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.42.tgz",
+      "integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/shared": "3.5.42"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.42",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.42.tgz",
+      "integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.42",
+        "@vue/shared": "3.5.42"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.42",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz",
+      "integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.42",
+        "@vue/runtime-core": "3.5.42",
+        "@vue/shared": "3.5.42",
+        "csstype": "^3.2.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.42",
+      "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.42.tgz",
+      "integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.42",
+        "@vue/runtime-dom": "3.5.42",
+        "@vue/shared": "3.5.42"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.42",
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.42.tgz",
+      "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==",
+      "license": "MIT"
+    },
+    "node_modules/@vueuse/core": {
+      "version": "14.4.0",
+      "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.4.0.tgz",
+      "integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/web-bluetooth": "^0.0.21",
+        "@vueuse/metadata": "14.4.0",
+        "@vueuse/shared": "14.4.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
+    "node_modules/@vueuse/metadata": {
+      "version": "14.4.0",
+      "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.4.0.tgz",
+      "integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@vueuse/shared": {
+      "version": "14.4.0",
+      "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.4.0.tgz",
+      "integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
+    "node_modules/agent-base": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
+    "node_modules/async-validator": {
+      "version": "4.2.5",
+      "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
+      "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
+      "license": "MIT"
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "license": "MIT"
+    },
+    "node_modules/axios": {
+      "version": "1.20.0",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
+      "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
+      "license": "MIT",
+      "dependencies": {
+        "follow-redirects": "^1.16.0",
+        "form-data": "^4.0.6",
+        "https-proxy-agent": "^5.0.1",
+        "proxy-from-env": "^2.1.0"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "license": "MIT"
+    },
+    "node_modules/dayjs": {
+      "version": "1.11.23",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
+      "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/element-plus": {
+      "version": "2.14.5",
+      "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.5.tgz",
+      "integrity": "sha512-bghYy/S+qg87enHPXELirhEdDqsVAUGcGpbGIeG8dz0kwpIkGz7gYsifulBshXX74iRtHib85XWQj0uSH2A1Yg==",
+      "license": "MIT",
+      "dependencies": {
+        "@ctrl/tinycolor": "^4.2.0",
+        "@element-plus/icons-vue": "^2.3.2",
+        "@floating-ui/dom": "^1.8.0",
+        "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
+        "@types/lodash": "^4.17.24",
+        "@types/lodash-es": "^4.17.12",
+        "@vueuse/core": "14.4.0",
+        "async-validator": "^4.2.5",
+        "dayjs": "^1.11.20",
+        "lodash": "^4.18.1",
+        "lodash-es": "^4.18.1",
+        "lodash-unified": "^1.0.3",
+        "memoize-one": "^6.0.0",
+        "normalize-wheel-es": "^1.2.0",
+        "vue-component-type-helpers": "^3.3.9"
+      },
+      "peerDependencies": {
+        "vue": "^3.3.7"
+      }
+    },
+    "node_modules/entities": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+      "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+      "license": "MIT"
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.16.0",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+      "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
+      "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.4",
+        "mime-types": "^2.1.35"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/https-proxy-agent": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+      "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "6",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/lightningcss": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+      "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.33.0",
+        "lightningcss-darwin-arm64": "1.33.0",
+        "lightningcss-darwin-x64": "1.33.0",
+        "lightningcss-freebsd-x64": "1.33.0",
+        "lightningcss-linux-arm-gnueabihf": "1.33.0",
+        "lightningcss-linux-arm64-gnu": "1.33.0",
+        "lightningcss-linux-arm64-musl": "1.33.0",
+        "lightningcss-linux-x64-gnu": "1.33.0",
+        "lightningcss-linux-x64-musl": "1.33.0",
+        "lightningcss-win32-arm64-msvc": "1.33.0",
+        "lightningcss-win32-x64-msvc": "1.33.0"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+      "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-arm64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+      "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-x64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+      "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-freebsd-x64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+      "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+      "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+      "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+      "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+      "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+      "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+      "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+      "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+      "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+      "license": "MIT"
+    },
+    "node_modules/lodash-es": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+      "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+      "license": "MIT"
+    },
+    "node_modules/lodash-unified": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz",
+      "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/lodash-es": "*",
+        "lodash": "*",
+        "lodash-es": "*"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/memoize-one": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
+      "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
+      "license": "MIT"
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.18",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+      "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/normalize-wheel-es": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
+      "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+      "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.26",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+      "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.17",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/proxy-from-env": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+      "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/rolldown": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz",
+      "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@oxc-project/types": "=0.147.0",
+        "@rolldown/pluginutils": "^1.0.0"
+      },
+      "bin": {
+        "rolldown": "bin/cli.mjs"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "optionalDependencies": {
+        "@rolldown/binding-android-arm-eabi": "1.2.6",
+        "@rolldown/binding-android-arm64": "1.2.6",
+        "@rolldown/binding-darwin-arm64": "1.2.6",
+        "@rolldown/binding-darwin-x64": "1.2.6",
+        "@rolldown/binding-freebsd-x64": "1.2.6",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.2.6",
+        "@rolldown/binding-linux-arm64-gnu": "1.2.6",
+        "@rolldown/binding-linux-arm64-musl": "1.2.6",
+        "@rolldown/binding-linux-ppc64-gnu": "1.2.6",
+        "@rolldown/binding-linux-s390x-gnu": "1.2.6",
+        "@rolldown/binding-linux-x64-gnu": "1.2.6",
+        "@rolldown/binding-linux-x64-musl": "1.2.6",
+        "@rolldown/binding-openharmony-arm64": "1.2.6",
+        "@rolldown/binding-win32-arm64-msvc": "1.2.6",
+        "@rolldown/binding-win32-x64-msvc": "1.2.6"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.17",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/vite": {
+      "version": "8.2.2",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
+      "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "lightningcss": "^1.33.0",
+        "picomatch": "^4.0.5",
+        "postcss": "^8.5.26",
+        "rolldown": "~1.2.4",
+        "tinyglobby": "^0.2.17"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "@vitejs/devtools": "^0.4.0 || ^0.5.0",
+        "esbuild": "^0.27.0 || ^0.28.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "@vitejs/devtools": {
+          "optional": true
+        },
+        "esbuild": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue": {
+      "version": "3.5.42",
+      "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz",
+      "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.42",
+        "@vue/compiler-sfc": "3.5.42",
+        "@vue/runtime-dom": "3.5.42",
+        "@vue/server-renderer": "3.5.42",
+        "@vue/shared": "3.5.42"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-component-type-helpers": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.11.tgz",
+      "integrity": "sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==",
+      "license": "MIT"
+    }
+  }
+}

+ 20 - 0
Co-creation-projects/senming666-api_test_assistant/frontend/package.json

@@ -0,0 +1,20 @@
+{
+  "name": "api-test-assistant-frontend",
+  "private": true,
+  "version": "0.1.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "axios": "^1.20.0",
+    "element-plus": "^2.14.5",
+    "vue": "^3.5.42"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^6.0.8",
+    "vite": "^8.2.2"
+  }
+}

+ 99 - 0
Co-creation-projects/senming666-api_test_assistant/frontend/src/App.vue

@@ -0,0 +1,99 @@
+<template>
+  <!-- 根元素:页面自上而下的组件树。v-loading 是 Element 的指令式加载遮罩 -->
+  <div class="page" v-loading="loading" element-loading-text="⏳ 正在测试中,请稍候(含 LLM 生成用例)">
+    <!-- 页头 -->
+    <div class="page-header">
+      <h1>🤖 智能API测试助手</h1>
+      <p>粘贴一份 OpenAPI 文档,自动完成「解析 → 生成用例 → 执行测试 → 验证 → 统计」</p>
+    </div>
+
+    <!-- ① 表单:loading 传给子组件做按钮转圈;用 @submit 接住子组件 emit 出来的数据 -->
+    <ApiTestForm :loading="loading" @submit="handleRun" />
+
+    <!-- ② 错误提示:error 非空才渲染;@close 处理右上角关闭按钮 -->
+    <el-alert
+      v-if="error"
+      class="error-alert"
+      :title="error"
+      type="error"
+      show-icon
+      closable
+      @close="error = ''"
+    />
+
+    <!-- ③ 有结果才渲染结果区 -->
+    <template v-if="summary">
+      <ResultSummary :summary="summary" />
+      <ResultTable :results="results" />
+    </template>
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { ElMessage } from 'element-plus'
+
+import ApiTestForm from './components/ApiTestForm.vue'
+import ResultSummary from './components/ResultSummary.vue'
+import ResultTable from './components/ResultTable.vue'
+
+import { runApiTest } from './api/test'
+
+// ===== 页面级状态:所有"跨组件共享的数据"由父组件统一持有 =====
+const loading = ref(false) // 是否正在测试(控制按钮转圈 + 全屏遮罩)
+const error = ref('')      // 错误信息字符串(空串=无错误)
+const summary = ref(null)  // 统计结果 { total, passed, failed, pass_rate }
+const results = ref([])    // 逐条用例结果
+
+/**
+ * 表单组件点了"开始测试"并校验通过后,会把 payload emit 到这里。
+ * 父组件在此真正发起请求,并负责更新页面状态。
+ */
+async function handleRun(payload) {
+  loading.value = true // 开始:开遮罩
+  error.value = ''     // 清掉上次的错误
+  summary.value = null // 收起旧的测试结果,避免"旧结果 + 新请求中"的混乱
+  results.value = []
+
+  try {
+    const data = await runApiTest(payload) // 调 API 层函数(拦截器已剥壳)
+    summary.value = data.summary
+    results.value = data.results ?? []
+
+    // 成功弹一个轻提示
+    ElMessage.success(`测试完成:共 ${summary.value.total} 条,通过率 ${summary.value.pass_rate}%`)
+  } catch (e) {
+    error.value = e.message || '测试执行失败' // 拦截器已经帮我们把后端 detail 放进 e.message
+  } finally {
+    loading.value = false // 无论成败,最后都关遮罩(finally 一定会执行)
+  }
+}
+</script>
+
+<style scoped>
+.page-header {
+  text-align: center;
+  margin-bottom: 20px;
+}
+
+.page-header h1 {
+  margin: 0 0 6px;
+  font-size: 26px;
+  color: #303133;
+}
+
+.page-header p {
+  margin: 0;
+  color: #909399;
+  font-size: 14px;
+}
+
+.error-alert {
+  margin-bottom: 20px;
+}
+
+/* 结果区的卡片之间留点空隙 */
+.page :deep(.el-card) {
+  margin-bottom: 20px;
+}
+</style>

+ 36 - 0
Co-creation-projects/senming666-api_test_assistant/frontend/src/api/request.js

@@ -0,0 +1,36 @@
+import axios from 'axios'
+
+/**
+ * 创建 axios 实例 —— 整个项目所有请求共用的"模板"
+ * 好处:超时时间、请求头、错误处理逻辑只写一遍
+ */
+const request = axios.create({
+  // baseURL 留空 → 请求走相对路径(/api/...)
+  // 开发环境由 Vite 代理转发到后端;生产环境前后端同源,天然可用
+  baseURL: '',
+  // 测试流程要走 LLM 生成用例,可能较久,超时给足 600 秒
+  timeout: 600000,
+})
+
+// 请求拦截器:发请求之前做统一处理(这里暂不需要,保留结构)
+request.interceptors.request.use(
+  (config) => config,
+  (error) => Promise.reject(error),
+)
+
+// 响应拦截器:拿到响应之后做统一处理
+request.interceptors.response.use(
+  // 成功回调:后端返回的数据结构是 { summary, results },
+  // 我们只关心 body,所以直接剥掉 axios 外壳,返回 response.data
+  (response) => response.data,
+
+  // 失败回调:把各种错误统一成一个带 message 的 Error,方便组件里用
+  (error) => {
+    // FastAPI 出错时返回 {"detail": "错误信息"},放在 error.response.data.detail
+    const detail = error.response?.data?.detail
+    const message = detail || error.message || '网络请求失败'
+    return Promise.reject(new Error(message))
+  },
+)
+
+export default request

+ 11 - 0
Co-creation-projects/senming666-api_test_assistant/frontend/src/api/test.js

@@ -0,0 +1,11 @@
+import request from './request'
+
+/**
+ * 调用后端一键测试接口
+ * @param {Object} payload - { openapi_text, base_url } 或 { openapi_url, base_url }
+ * @returns {Promise<{summary: Object, results: Array}>}
+ */
+export function runApiTest(payload) {
+  // 因为拦截器已经剥壳,这里 resolve 出来的直接就是后端返回的 JSON body
+  return request.post('/api/test', payload)
+}

+ 186 - 0
Co-creation-projects/senming666-api_test_assistant/frontend/src/components/ApiTestForm.vue

@@ -0,0 +1,186 @@
+<template>
+  <el-card shadow="never">
+    <template #header>
+      <div class="card-header">
+        <span>① 输入 API 信息</span>
+      </div>
+    </template>
+
+    <!-- ref="formRef":拿到 el-form 的实例,后面才能调用 validate() 做整体校验 -->
+    <!-- :model 绑定表单数据对象;:rules 绑定校验规则;label-position="top" 让标签显示在输入框上方 -->
+    <!-- @submit.prevent:阻止浏览器原生表单提交(否则在单行输入框按回车会刷新页面) -->
+    <el-form ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent>
+      <el-form-item label="OpenAPI 文档来源">
+        <el-radio-group v-model="mode" :disabled="loading">
+          <el-radio-button value="text">粘贴文档</el-radio-button>
+          <el-radio-button value="url">URL 抓取</el-radio-button>
+        </el-radio-group>
+      </el-form-item>
+
+      <el-form-item v-if="mode === 'text'" label="OpenAPI 文档(粘贴 yaml/json)" prop="openapiText">
+        <el-input
+          v-model="form.openapiText"
+          type="textarea"
+          :rows="10"
+          placeholder="在此粘贴 OpenAPI 文档内容..."
+        />
+      </el-form-item>
+
+      <el-form-item v-else label="OpenAPI 文档 URL" prop="openapiUrl">
+        <el-input
+          v-model="form.openapiUrl"
+          type="url"
+          placeholder="例如:https://httpbin.org/spec.json"
+          clearable
+          @keyup.enter="handleSubmit"
+        />
+        <div class="field-tip">后端将直接抓取 URL 内容,无需先下载文档</div>
+      </el-form-item>
+
+      <el-form-item label="目标 API 基础地址(base_url)" prop="baseUrl">
+        <el-input
+          v-model="form.baseUrl"
+          placeholder="例如:https://jsonplaceholder.typicode.com"
+          clearable
+          @keyup.enter="handleSubmit"
+        />
+      </el-form-item>
+
+      <el-form-item label="请求头(选填,JSON 格式)" prop="headers">
+        <el-input
+          v-model="form.headers"
+          type="textarea"
+          :rows="3"
+          placeholder='例如:{"Authorization": "Bearer 你的token"}'
+        />
+        <div class="field-tip">受保护接口需要认证头,按 JSON 对象填写(留空则不发送)</div>
+      </el-form-item>
+
+      <div class="btn-row">
+        <!-- :loading="loading":父组件传进来的状态,请求期间按钮转圈并禁用 -->
+        <el-button type="primary" size="large" :loading="loading" @click="handleSubmit">
+          🚀 开始测试
+        </el-button>
+        <el-button size="large" :disabled="loading" @click="fillExample">📝 填入示例</el-button>
+        <el-button size="large" :disabled="loading" @click="clearForm">🗑️ 清空</el-button>
+      </div>
+    </el-form>
+  </el-card>
+</template>
+
+<script setup>
+import { computed, reactive, ref } from 'vue'
+import { EXAMPLE_DOC, EXAMPLE_BASE_URL } from '@/constants/exampleDoc'
+
+// ===== 父组件传进来的 props(只读)=====
+const props = defineProps({
+  loading: {
+    type: Boolean,
+    default: false,
+  },
+})
+
+// ===== 声明本组件能向外触发的事件 =====
+// 组件里只能 emit 在 defineEmits 里声明过的事件,这层声明让代码自文档化
+const emit = defineEmits(['submit'])
+
+// formRef 绑定到模板里的 el-form;模板中用 ref="formRef",这里用同名 ref 变量接收
+const formRef = ref(null)
+const mode = ref('text')
+
+// reactive 创建响应式对象:输入框用 v-model 双向绑定到它
+// (一个对象里有多个值、会被整体操作时,用 reactive 比多个 ref 更顺)
+const form = reactive({
+  openapiText: '',
+  openapiUrl: '',
+  baseUrl: '',
+  headers: '',
+})
+
+// 请求头可选:留空跳过;非空必须是合法 JSON 对象
+function validateHeaders(rule, value, callback) {
+  if (!value || !value.trim()) return callback()
+  try {
+    const parsed = JSON.parse(value)
+    if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
+      return callback(new Error('请求头必须是 JSON 对象,例如 {"Authorization": "Bearer xxx"}'))
+    }
+    return callback()
+  } catch {
+    return callback(new Error('请求头格式错误:不是合法的 JSON 对象'))
+  }
+}
+
+// 校验规则:required 必填。trigger 表示何时触发校验
+const rules = computed(() => ({
+  openapiText: mode.value === 'text'
+    ? [{ required: true, message: '请粘贴 OpenAPI 文档内容', trigger: 'blur' }]
+    : [],
+  openapiUrl: mode.value === 'url'
+    ? [{ required: true, message: '请输入 OpenAPI 文档 URL', trigger: 'blur' }]
+    : [],
+  baseUrl: [{ required: true, message: '请填写目标 API 基础地址', trigger: 'blur' }],
+  headers: [{ validator: validateHeaders, trigger: 'blur' }],
+}))
+
+// 填入示例:把示例文档和地址写进表单,并清掉红色校验提示
+function fillExample() {
+  mode.value = 'text'
+  form.openapiText = EXAMPLE_DOC
+  form.openapiUrl = ''
+  form.baseUrl = EXAMPLE_BASE_URL
+  form.headers = ''
+  formRef.value?.clearValidate() // ?. 空值保护:ref 还没绑定时不会报错
+}
+
+function clearForm() {
+  form.openapiText = ''
+  form.openapiUrl = ''
+  form.baseUrl = ''
+  form.headers = ''
+  formRef.value?.clearValidate()
+}
+
+async function handleSubmit() {
+  // validate() 校验整张表单;不通过会 reject(并自动标红),直接 return
+  try {
+    await formRef.value.validate()
+  } catch {
+    return
+  }
+  // 通过:把数据整理成后端要的字段名(下划线风格),emit 给父组件
+  const payload = {
+    base_url: form.baseUrl.trim(),
+  }
+  if (mode.value === 'url') {
+    payload.openapi_url = form.openapiUrl.trim()
+  } else {
+    payload.openapi_text = form.openapiText.trim()
+  }
+  // 请求头是选填的 JSON 对象,非空才解析并带上
+  if (form.headers.trim()) {
+    payload.headers = JSON.parse(form.headers)
+  }
+  emit('submit', payload)
+}
+</script>
+
+<style scoped>
+.card-header {
+  display: flex;
+  align-items: center;
+  font-weight: 600;
+  font-size: 16px;
+}
+
+.btn-row {
+  margin-top: 6px;
+}
+
+.field-tip {
+  margin-top: 6px;
+  color: #909399;
+  font-size: 12px;
+  line-height: 1.5;
+}
+</style>

+ 112 - 0
Co-creation-projects/senming666-api_test_assistant/frontend/src/components/ResultSummary.vue

@@ -0,0 +1,112 @@
+<template>
+  <el-card shadow="never">
+    <template #header>
+      <div class="card-header">
+        <span>② 测试结果</span>
+        <!-- 右上角:整体结论标签,颜色由通过率 computed 动态决定 -->
+        <el-tag :type="rateLevel" effect="dark" size="large">
+          {{ rateText }}
+        </el-tag>
+      </div>
+    </template>
+
+    <!-- 统计四宫格:el-row 栅格一行,el-col span=6 表示每块占 1/4 宽 -->
+    <el-row :gutter="16" class="stat-row">
+      <el-col :span="6" v-for="item in statItems" :key="item.label">
+        <div class="stat-item" :class="item.colorClass">
+          <div class="stat-value">{{ item.value }}</div>
+          <div class="stat-label">{{ item.label }}</div>
+        </div>
+      </el-col>
+    </el-row>
+
+    <!-- 通过率进度条:percentage 是 0-100 的数字 -->
+    <el-progress
+      :percentage="percent"
+      :status="progressStatus"
+      :stroke-width="14"
+    />
+  </el-card>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+
+// props:父组件把 summary 传进来,本组件只负责"读"和"算",不修改它
+const props = defineProps({
+  summary: {
+    type: Object,
+    required: true, // required: true → 父组件必须传,漏传会告警
+  },
+})
+
+// 通过率取整成百分比数字(后端给的是保留 1 位小数的数值)
+const percent = computed(() => Math.round(props.summary.pass_rate ?? 0))
+
+// 进度条三种状态色:绿(>=90) 橙(>=60) 红(<60)
+const progressStatus = computed(() => {
+  if (percent.value >= 90) return 'success'
+  if (percent.value >= 60) return 'warning'
+  return 'exception'
+})
+
+// 右上角标签的 Element 颜色类型
+const rateLevel = computed(() => {
+  if (percent.value >= 90) return 'success'
+  if (percent.value >= 60) return 'warning'
+  return 'danger'
+})
+
+// 标签文字
+const rateText = computed(() => `通过率 ${percent.value}%`)
+
+// 四张统计卡的数据(一个数组 = 一份数据源,模板 v-for 遍历生成,避免写四遍)
+const statItems = computed(() => [
+  { label: '总用例', value: props.summary.total ?? 0, colorClass: '' },
+  { label: '通过', value: props.summary.passed ?? 0, colorClass: 'text-success' },
+  { label: '失败', value: props.summary.failed ?? 0, colorClass: 'text-danger' },
+  { label: '通过率', value: `${props.summary.pass_rate ?? 0}%`, colorClass: '' },
+])
+</script>
+
+<style scoped>
+.card-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between; /* 标题靠左、tag 靠右 */
+  font-weight: 600;
+  font-size: 16px;
+}
+
+.stat-row {
+  margin-bottom: 4px;
+}
+
+.stat-item {
+  text-align: center;
+  padding: 14px 0;
+  border-radius: 8px;
+  background: #f7f8fa;
+}
+
+.stat-value {
+  font-size: 26px;
+  font-weight: 700;
+  color: #303133;
+}
+
+.stat-label {
+  margin-top: 4px;
+  font-size: 13px;
+  color: #909399;
+}
+
+/* 通过数用绿色、失败数用红色 */
+.text-success .stat-value {
+  color: #67c23a;
+}
+
+.text-danger .stat-value {
+  color: #f56c6c;
+}
+</style>

+ 223 - 0
Co-creation-projects/senming666-api_test_assistant/frontend/src/components/ResultTable.vue

@@ -0,0 +1,223 @@
+<template>
+  <el-card shadow="never">
+    <template #header>
+      <div class="card-header">
+        <span>③ 用例明细</span>
+        <!-- 筛选器:全部 / 只看通过 / 只看失败,v-model 绑到本地响应式变量 filter -->
+        <el-radio-group v-model="filter" size="small">
+          <el-radio-button value="all">全部</el-radio-button>
+          <el-radio-button value="passed">通过</el-radio-button>
+          <el-radio-button value="failed">失败</el-radio-button>
+        </el-radio-group>
+      </div>
+    </template>
+
+    <!-- :data 是表格数据源;:row-class-name 给每行动态加类(按通过/失败上底色) -->
+    <el-table
+      :data="filteredRows"
+      border
+      :row-class-name="rowClassName"
+      empty-text="暂无用例数据"
+    >
+      <!-- type="expand" 列:点击行首的 + 号展开,default 插槽里收到当前行 row -->
+      <el-table-column type="expand">
+        <template #default="{ row }">
+          <div class="detail">
+            <div class="detail-item">
+              <span class="detail-label">请求</span>
+              <code class="detail-code">{{ row.method }} {{ row.path }}</code>
+            </div>
+
+            <el-row :gutter="12">
+              <el-col :span="12">
+                <div class="detail-item">
+                  <span class="detail-label">查询参数 (params)</span>
+                  <pre class="detail-pre">{{ row.paramsText }}</pre>
+                </div>
+              </el-col>
+              <el-col :span="12">
+                <div class="detail-item">
+                  <span class="detail-label">请求体 (body)</span>
+                  <pre class="detail-pre">{{ row.bodyText }}</pre>
+                </div>
+              </el-col>
+            </el-row>
+
+            <div v-if="row.errors.length" class="detail-item">
+              <span class="detail-label">错误信息</span>
+              <ul class="error-list">
+                <li v-for="(msg, i) in row.errors" :key="i">{{ msg }}</li>
+              </ul>
+            </div>
+
+            <div class="detail-item">
+              <span class="detail-label">响应体 (response)</span>
+              <pre class="detail-pre">{{ row.responseBodyText }}</pre>
+            </div>
+          </div>
+        </template>
+      </el-table-column>
+
+      <el-table-column prop="method" label="方法" width="90" align="center">
+        <template #default="{ row }">
+          <el-tag :type="methodTag(row.method)" size="small" effect="plain">
+            {{ row.method }}
+          </el-tag>
+        </template>
+      </el-table-column>
+
+      <el-table-column prop="name" label="用例名称" min-width="180" show-overflow-tooltip />
+
+      <el-table-column prop="caseType" label="类型" width="90" align="center">
+        <template #default="{ row }">
+          <el-tag :type="caseTypeTag(row.caseType)" size="small">
+            {{ caseTypeText(row.caseType) }}
+          </el-tag>
+        </template>
+      </el-table-column>
+
+      <el-table-column label="期望状态" width="100" align="center">
+        <template #default="{ row }">{{ row.expectedStatus }}</template>
+      </el-table-column>
+
+      <el-table-column label="实际状态" width="100" align="center">
+        <template #default="{ row }">{{ row.statusCode }}</template>
+      </el-table-column>
+
+      <el-table-column prop="elapsed" label="耗时(秒)" width="110" align="center" />
+
+      <el-table-column label="结果" width="110" align="center">
+        <template #default="{ row }">
+          <el-tag :type="row.passed ? 'success' : 'danger'" size="small">
+            {{ row.passed ? '✅ 通过' : '❌ 失败' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+    </el-table>
+  </el-card>
+</template>
+
+<script setup>
+import { computed, ref } from 'vue'
+import { CASE_TYPE_LABELS, CASE_TYPE_TAGS, METHOD_TAGS } from '@/constants/exampleDoc'
+import { display, prettyJson } from '@/utils/format'
+
+// props:父组件把后端返回的 results 数组传进来
+const props = defineProps({
+  results: {
+    type: Array,
+    required: true,
+  },
+})
+
+// 本地状态:表格筛选(全部/通过/失败),只影响本组件展示
+const filter = ref('all')
+
+/**
+ * rows:把后端"嵌套且可能缺失"的原始数据,改造成模板好用的扁平行对象。
+ * 好处:① 模板干净;② 空值兜底只写一次;③ 文案/展示字段集中在这里算好
+ */
+const rows = computed(() => {
+  const results = props.results ?? []
+  return results.map((item, index) => {
+    const caseObj = item.case ?? {}      // 防御:万一 case 缺失
+    const result = item.result ?? {}     // 防御:请求失败时 result 可能字段缺失
+    const errors = item.errors ?? []
+    return {
+      id: index,
+      passed: !!item.passed,
+      name: caseObj.name || '-',
+      method: caseObj.method || '-',
+      path: caseObj.path || '-',
+      caseType: caseObj.case_type || '-',
+      expectedStatus: display(caseObj.expected_status),
+      statusCode: display(result.status_code),
+      elapsed: display(result.elapsed),
+      errors,
+      paramsText: prettyJson(caseObj.params),
+      bodyText: prettyJson(caseObj.body),
+      responseBodyText: prettyJson(result.body),
+    }
+  })
+})
+
+// 按 filter 筛选后的行:computed 依赖 rows 与 filter,任一变化自动重算
+const filteredRows = computed(() => {
+  if (filter.value === 'all') return rows.value
+  const wantPass = filter.value === 'passed'
+  return rows.value.filter((row) => row.passed === wantPass)
+})
+
+// 行底色类名:失败的整行淡红,方便扫一眼定位问题
+function rowClassName({ row }) {
+  return row.passed ? '' : 'row-fail'
+}
+
+// 查表翻译:类型英文 → 中文;METHOD_TAGS / CASE_TYPE_TAGS 查不到就灰色兜底
+function methodTag(method) {
+  return METHOD_TAGS[method] || 'info'
+}
+function caseTypeText(type) {
+  return CASE_TYPE_LABELS[type] || type
+}
+function caseTypeTag(type) {
+  return CASE_TYPE_TAGS[type] || 'info'
+}
+</script>
+
+<style scoped>
+.card-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-weight: 600;
+  font-size: 16px;
+}
+
+.detail {
+  padding: 4px 16px;
+  line-height: 1.6;
+}
+
+.detail-item {
+  margin-bottom: 10px;
+}
+
+.detail-label {
+  display: block;
+  font-size: 12px;
+  color: #909399;
+  margin-bottom: 4px;
+}
+
+.detail-code {
+  background: #f5f7fa;
+  border-radius: 4px;
+  padding: 2px 6px;
+  font-size: 13px;
+}
+
+.detail-pre {
+  margin: 0;
+  background: #282c34;
+  color: #abb2bf;
+  border-radius: 6px;
+  padding: 10px 12px;
+  font-size: 12px;
+  overflow-x: auto;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+
+.error-list {
+  margin: 0;
+  padding-left: 18px;
+  color: #f56c6c;
+}
+
+/* rowClassName 加在 el-table 内部的 <tr> 上,属于 Element 内部组件作用域,
+   本组件 scoped 样式够不着,必须用 :deep() 穿透 */
+:deep(.row-fail td) {
+  background-color: #fdf0f0;
+}
+</style>

+ 49 - 0
Co-creation-projects/senming666-api_test_assistant/frontend/src/constants/exampleDoc.js

@@ -0,0 +1,49 @@
+// 示例 OpenAPI 文档:和仓库根目录 api.yaml 内容一致(JSONPlaceholder 两个 GET 接口)
+export const EXAMPLE_DOC = `openapi: 3.0.0
+info:
+  title: JSONPlaceholder 测试API
+  description: 用于测试智能API测试助手的示例文档
+  version: 1.0.0
+paths:
+  /users:
+    get:
+      summary: 获取用户列表
+      responses:
+        '200':
+          description: 成功返回用户列表
+  /posts:
+    get:
+      summary: 获取帖子列表
+      responses:
+        '200':
+          description: 成功返回帖子列表
+`
+
+// 配套的示例被测地址
+export const EXAMPLE_BASE_URL = 'https://jsonplaceholder.typicode.com'
+
+// 用例类型 → 中文名(后端返回英文 case_type)
+export const CASE_TYPE_LABELS = {
+  normal: '正常',
+  boundary: '边界',
+  error: '异常',
+}
+
+// 用例类型 → Element Plus tag 的颜色
+export const CASE_TYPE_TAGS = {
+  normal: 'success',
+  boundary: 'warning',
+  error: 'danger',
+}
+
+// HTTP 方法 → 标签颜色(模拟常见 API 文档配色:GET绿/POST蓝/PUT橙/DELETE红)
+export const METHOD_TAGS = {
+  GET: 'success',
+  POST: 'primary',
+  PUT: 'warning',
+  DELETE: 'danger',
+  PATCH: 'info',
+}
+
+// 兜底显示
+export const FALLBACK_LABEL = '-'

+ 13 - 0
Co-creation-projects/senming666-api_test_assistant/frontend/src/main.js

@@ -0,0 +1,13 @@
+import { createApp } from 'vue'
+import ElementPlus from 'element-plus'
+import 'element-plus/dist/index.css'
+import zhCn from 'element-plus/es/locale/lang/zh-cn'
+
+import App from './App.vue'
+import './styles/index.css'
+
+const app = createApp(App)
+
+app.use(ElementPlus, { locale: zhCn })
+
+app.mount('#app')

+ 27 - 0
Co-creation-projects/senming666-api_test_assistant/frontend/src/styles/index.css

@@ -0,0 +1,27 @@
+/* ===== 全局样式:先做「基础重置 + 页面氛围」,组件内部样式各自写在 .vue 里 ===== */
+
+* {
+  box-sizing: border-box;
+}
+
+html,
+body {
+  margin: 0;
+  padding: 0;
+}
+
+body {
+  /* 浅灰底,让白色的 el-card 卡片浮起来,更有层次 */
+  background-color: #f0f2f5;
+  /* 匹配企业后台常用的字体族 */
+  font-family: 'Helvetica Neue', Helvetica, 'PingFang SC',
+    'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
+  color: #303133;
+}
+
+#app {
+  /* 应用内容的最大宽度与水平居中,宽屏下不会拉得满屏 */
+  max-width: 1280px;
+  margin: 0 auto;
+  padding: 20px;
+}

+ 32 - 0
Co-creation-projects/senming666-api_test_assistant/frontend/src/utils/format.js

@@ -0,0 +1,32 @@
+/**
+ * 通用的「安全显示」工具。
+ * 后端字段有时是 null / undefined / 空串,直接渲染成空白很丑,
+ * 统一在这里转成占位符或字符串。
+ */
+
+/**
+ * 简单展示:null/undefined/空串 → fallback,对象 → JSON 字符串
+ */
+export function display(value, fallback = '-') {
+  if (value === null || value === undefined || value === '') return fallback
+  if (typeof value === 'object') return JSON.stringify(value)
+  return String(value)
+}
+
+/**
+ * 格式化 JSON 用于「多行查看」:对象或合法 JSON 字符串 → 缩进后的文本;
+ * 非 JSON 的普通字符串原样返回,方便在 <pre> 里展示
+ */
+export function prettyJson(value) {
+  if (value === null || value === undefined || value === '') return '(空)'
+  if (typeof value === 'string') {
+    try {
+      // 字符串里可能存的是 JSON 文本,尝试二次解析再美化
+      return JSON.stringify(JSON.parse(value), null, 2)
+    } catch {
+      return value // 解析失败说明就是普通文本,原样返回
+    }
+  }
+  // 已经是对象/数组,直接美化
+  return JSON.stringify(value, null, 2)
+}

+ 25 - 0
Co-creation-projects/senming666-api_test_assistant/frontend/vite.config.js

@@ -0,0 +1,25 @@
+import { fileURLToPath, URL } from 'node:url'
+
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+// https://vite.dev/config/
+export default defineConfig({
+  plugins: [vue()],
+
+  resolve: {
+    alias: {
+      '@': fileURLToPath(new URL('./src', import.meta.url)),
+    },
+  },
+
+  server: {
+    port: 5173,
+    proxy: {
+      '/api': {
+        target: 'http://localhost:8000',
+        changeOrigin: true,
+      },
+    },
+  },
+})

+ 80 - 0
Co-creation-projects/senming666-api_test_assistant/httpbin.json

@@ -0,0 +1,80 @@
+{
+  "openapi": "3.0.0",
+  "info": {
+    "contact": {
+      "email": "me@kennethreitz.org",
+      "responsibleDeveloper": "Kenneth Reitz",
+      "responsibleOrganization": "Kenneth Reitz",
+      "url": "https://kennethreitz.org"
+    },
+    "description": "A simple HTTP Request & Response Service.<br/> <br/> <b>Run locally: </b> <code>$ docker run -p 80:80 kennethreitz/httpbin</code>",
+    "title": "httpbin.org",
+    "version": "0.9.2"
+  },
+  "paths": {
+    "/get": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "responses": {
+          "200": {
+            "description": "The request's query parameters."
+          }
+        },
+        "summary": "The request's query parameters.",
+        "tags": [
+          "HTTP Methods"
+        ]
+      }
+    },
+    "/post": {
+      "post": {
+        "produces": [
+          "application/json"
+        ],
+        "responses": {
+          "200": {
+            "description": "The request's POST parameters."
+          }
+        },
+        "summary": "The request's POST parameters.",
+        "tags": [
+          "HTTP Methods"
+        ]
+      }
+    },
+    "/json": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "responses": {
+          "200": {
+            "description": "An JSON document."
+          }
+        },
+        "summary": "Returns a simple JSON document.",
+        "tags": [
+          "Response formats"
+        ]
+      }
+    },
+    "/uuid": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "responses": {
+          "200": {
+            "description": "A UUID4."
+          }
+        },
+        "summary": "Return a UUID4.",
+        "tags": [
+          "Dynamic data"
+        ]
+      }
+    }
+  }
+}

+ 255 - 0
Co-creation-projects/senming666-api_test_assistant/main.ipynb

@@ -0,0 +1,255 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# 智能API测试助手(API Test Assistant)\n",
+    "\n",
+    "## 📝 项目简介\n",
+    "\n",
+    "基于 Hello-Agents 框架的多智能体应用,输入一份 OpenAPI 文档,\n",
+    "自动完成「解析 → 生成用例 → 执行测试 → 验证结果 → 生成报告」全流程。\n",
+    "\n",
+    "采用 5 个 Agent 组成流水线:\n",
+    "① ParserAgent(解析)→ ② GeneratorAgent(LLM 生成)→ ③ ExecutorAgent(执行)\n",
+    "→ ④ ValidatorAgent(验证)→ ⑤ ReporterAgent(报告)\n",
+    "\n",
+    "## 👤 作者信息\n",
+    "- GitHub:@senming666\n",
+    "- 日期:2026-08-20\n",
+    "- 框架版本:hello-agents >= 1.0.0\n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## 🔧 第1部分:环境配置"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# 安装依赖(如果需要)\n",
+    "# !pip install -r requirements.txt\n",
+    "\n",
+    "import os\n",
+    "import sys\n",
+    "\n",
+    "# Windows 中文控制台默认 GBK,重配为 UTF-8,否则打印中文会崩\n",
+    "if hasattr(sys.stdout, \"reconfigure\"):\n",
+    "    sys.stdout.reconfigure(encoding=\"utf-8\", errors=\"replace\")\n",
+    "\n",
+    "# 确保能 import src 包(notebook 在项目根目录运行)\n",
+    "sys.path.append(\".\")\n",
+    "\n",
+    "from dotenv import load_dotenv\n",
+    "\n",
+    "# 关键:先加载 .env,再 import Agent(Agent 实例化时要读 LLM 配置)\n",
+    "load_dotenv()\n",
+    "\n",
+    "print(\"✅ 环境配置完成\")\n",
+    "print(f\"📁 当前目录: {os.getcwd()}\")\n",
+    "print(f\"🐍 Python 版本: {sys.version.split()[0]}\")\n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## 🧰 第2部分:工具层(Tools)\n",
+    "\n",
+    "工具层是\"没有大脑、只会干活\"的模块,本项目有两个工具:\n",
+    "- **HttpClient**:发 HTTP 请求(带超时 + 自动重试)\n",
+    "- **SchemaValidator**:校验响应(状态码比对 + JSON Schema 结构校验)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from src.tools.http_client import HttpClient\n",
+    "from src.tools.schema_validator import SchemaValidator\n",
+    "\n",
+    "# 创建工具实例\n",
+    "http_client = HttpClient()\n",
+    "schema_validator = SchemaValidator()\n",
+    "\n",
+    "print(\"📋 工具层就绪:\")\n",
+    "print(f\"1. HttpClient        - 支持方法: {http_client.SUPPORTED_METHODS}\")\n",
+    "print(\"2. SchemaValidator   - 校验状态码 + JSON Schema 结构\")\n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## 🤖 第3部分:智能体构建(5 个 Agent 流水线)\n",
+    "\n",
+    "5 个 Agent 排成流水线,上一个的输出是下一个的输入。\n",
+    "其中只有 **GeneratorAgent** 真正调用 LLM(想\"该测什么\"),其余是确定性逻辑。"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from src.agents.parser_agent import ParserAgent\n",
+    "from src.agents.generator_agent import GeneratorAgent\n",
+    "from src.agents.executor_agent import ExecutorAgent\n",
+    "from src.agents.validator_agent import ValidatorAgent\n",
+    "from src.agents.reporter_agent import ReporterAgent\n",
+    "\n",
+    "# 创建 5 个 Agent 实例\n",
+    "parser = ParserAgent()       # ① 解析:读懂 OpenAPI 文档\n",
+    "generator = GeneratorAgent() # ② 生成:用 LLM 生成测试用例\n",
+    "executor = ExecutorAgent()   # ③ 执行:真实调用目标接口\n",
+    "validator = ValidatorAgent() # ④ 验证:判断结果对错\n",
+    "reporter = ReporterAgent()   # ⑤ 报告:汇总成 HTML 报告\n",
+    "\n",
+    "print(\"🚀 5 个 Agent 创建完成,流水线就绪:\")\n",
+    "print(\"Parser → Generator → Executor → Validator → Reporter\")\n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## 🎯 第4部分:功能演示\n",
+    "\n",
+    "以项目自带的 `api.yaml`(JSONPlaceholder 的 /users、/posts 两个接口)为例,\n",
+    "对公网 API 发起真实测试。"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "BASE_URL = \"https://jsonplaceholder.typicode.com\"\n",
+    "\n",
+    "# ① 解析文档\n",
+    "endpoints = parser.parse_file(\"api.yaml\")\n",
+    "print(f\"[1/5] 解析完成:发现 {len(endpoints)} 个接口\")\n",
+    "for ep in endpoints:\n",
+    "    print(f\"      {ep['method']:<6} {ep['path']}\")\n",
+    "\n",
+    "# ② 生成用例(每个接口都生成,extend 合并成大列表)\n",
+    "all_cases = []\n",
+    "for ep in endpoints:\n",
+    "    all_cases.extend(generator.generate(ep))\n",
+    "print(f\"[2/5] 生成完成:共 {len(all_cases)} 个测试用例\")\n",
+    "\n",
+    "# ③ 执行测试\n",
+    "execution_results = executor.execute(all_cases, BASE_URL)\n",
+    "print(f\"[3/5] 执行完成:已发送 {len(execution_results)} 个请求\")\n",
+    "\n",
+    "# ④ 验证结果\n",
+    "validated_results = validator.validate(execution_results)\n",
+    "print(\"[4/5] 验证完成\")\n",
+    "\n",
+    "# ⑤ 统计汇总\n",
+    "summary = reporter.summarize(validated_results)\n",
+    "print(\"[5/5] 报告汇总完成\")\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "id": "f0881fff",
+   "source": "# ==== 功能扩展:从 URL 抓取 + Markdown 报告 ====\n\n# 扩展1:从 URL 直接抓取 OpenAPI 文档(无需先下载到本地)\nurl_endpoints = parser.parse_url(\"https://httpbin.org/spec.json\")\nprint(f\"从 URL 抓取到 {len(url_endpoints)} 个接口\")\nfor ep in url_endpoints[:3]:\n    print(f\"  {ep['method']} {ep['path']}\")\n\n# 扩展2:生成 Markdown 格式报告(上面已生成 HTML,这里额外生成 .md)\nmd_report = reporter.generate_markdown(validated_results)\nprint(\"\\nMarkdown 报告预览(前 10 行):\")\nprint(\"\\n\".join(md_report.split(\"\\n\")[:10]))",
+   "metadata": {},
+   "execution_count": null,
+   "outputs": []
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# 打印每个用例的验证结果\n",
+    "print(\"=\" * 60)\n",
+    "print(f\"总用例 {summary['total']},通过 {summary['passed']},\"\n",
+    "      f\"失败 {summary['failed']},通过率 {summary['pass_rate']}%\")\n",
+    "print(\"=\" * 60)\n",
+    "\n",
+    "for r in validated_results:\n",
+    "    case = r[\"case\"]\n",
+    "    result = r[\"result\"]\n",
+    "    mark = \"✅ 通过\" if r[\"passed\"] else \"❌ 失败\"\n",
+    "    print(f\"\\n{mark} | {case['method']} {case['path']} | {case['name']}\")\n",
+    "    print(f\"  类型={case.get('case_type')}  \"\n",
+    "          f\"期望状态码={case.get('expected_status')}  \"\n",
+    "          f\"实际状态码={result.get('status_code')}  \"\n",
+    "          f\"耗时={result.get('elapsed')}s\")\n",
+    "    if not r[\"passed\"]:\n",
+    "        print(f\"  失败原因: {'; '.join(r['errors'])}\")\n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": "## 📊 第5部分:性能评估\n\n三套被测对象的测试结果速览(详细失败分析见 README.md):\n\n| 被测对象 | 接口 | 用例数 | 通过率 | 失败原因 |\n|---|---|---|---|---|\n| JSONPlaceholder | 2 个 GET | 6 | 66.7% | error 用例被 mock 服务宽容处理 |\n| 自己测自己 | GET + POST | 6 | 83.3% | 三轮迭代抓到并修复 2 个 bug |\n| httpbin.org | 4 个 GET/POST | 12 | 66.7% | error 用例被测试服务宽容处理 |\n\n**核心结论**:失败几乎都集中在 error 用例——被测的测试/mock 服务对异常输入\n不严格校验,统一返回 200;工具如实报告了这一行为,而非\"粉饰\"结果。"
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": "# 性能评估:用下面的命令可复现任一被测对象的结果\n# python main.py --file api.yaml --base-url https://jsonplaceholder.typicode.com\n# python main.py --file openapi_service.yaml --base-url http://localhost:8000\n# python main.py --file httpbin.json --base-url https://httpbin.org\n\nprint(\"📊 详细测试数据与失败分析见 README.md 的「性能评估」章节\")"
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## 📝 第6部分:总结与展望\n",
+    "\n",
+    "### 项目总结\n",
+    "\n",
+    "#### ✅ 实现的功能\n",
+    "- 5 个 Agent 组成的多智能体流水线\n",
+    "- LLM 智能生成测试用例(正常 / 边界 / 异常三类)\n",
+    "- 真实 HTTP 调用 + 自动结果校验\n",
+    "- HTML 报告 + 通过率统计\n",
+    "- FastAPI 后端 + Vue3 前端 + 命令行入口\n",
+    "\n",
+    "#### 🎯 遇到的挑战\n",
+    "- hello-agents 1.0.0 的 `invoke()` 返回 LLMResponse 对象而非字符串,需用 `.content` 取值\n",
+    "- LLM 生成的用例缺 path/method 字段,改为从 endpoint 注入确定性信息\n",
+    "- Windows GBK 控制台打印中文崩溃,需重配 stdout 为 UTF-8\n",
+    "\n",
+    "#### 🚀 未来改进方向\n",
+    "- [ ] 支持认证请求头(Authorization),可测需鉴权的接口\n",
+    "- [ ] 从 URL 抓取 OpenAPI 文档\n",
+    "- [ ] 并发执行测试用例\n",
+    "- [ ] 补充性能评估数据\n",
+    "\n",
+    "---\n",
+    "\n",
+    "🎓 **恭喜!你已经跑通了智能 API 测试助手的完整流程!**\n"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "name": "python",
+   "version": "3.13"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}

+ 133 - 0
Co-creation-projects/senming666-api_test_assistant/main.py

@@ -0,0 +1,133 @@
+"""
+智能API测试助手 - 命令行入口
+
+把五个 Agent 串成完整流程:
+解析 → 生成 → 执行 → 验证 → 报告
+"""
+
+import sys
+
+# Windows 中文控制台默认是 GBK,先重配为 UTF-8,否则打印中文会崩
+if hasattr(sys.stdout, "reconfigure"):
+    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+    sys.stderr.reconfigure(encoding="utf-8", errors="replace")
+
+import argparse
+from dotenv import load_dotenv
+
+# 关键:先加载 .env,再 import Agent(Agent 实例化时要读环境变量)
+load_dotenv()
+
+from src.agents.parser_agent import ParserAgent
+from src.agents.generator_agent import GeneratorAgent
+from src.agents.executor_agent import ExecutorAgent
+from src.agents.validator_agent import ValidatorAgent
+from src.agents.reporter_agent import ReporterAgent
+from src.config import REPORT_OUTPUT_DIR
+
+
+def run_test(base_url, openapi_file=None, openapi_url=None, headers=None):
+    """执行完整的 API 测试流程
+
+    Args:
+        base_url: 目标 API 的基础地址
+        openapi_file: OpenAPI 文档路径(本地文件)
+        openapi_url: OpenAPI 文档网址(从网络抓取),与 file 二选一
+        headers: 全局请求头(可选),如认证信息
+
+    Returns:
+        报告文件的保存路径
+    """
+    print("=" * 50)
+    print("开始 API 测试流程")
+    print("=" * 50)
+
+    # ① 解析文档(优先用 URL,否则用本地文件)
+    parser = ParserAgent()
+    if openapi_url:
+        endpoints = parser.parse_url(openapi_url)
+    else:
+        endpoints = parser.parse_file(openapi_file)
+
+    # 没有解析出任何接口,说明文档抓取/解析失败,直接退出
+    if not endpoints:
+        print("❌ 未能解析出任何接口,请检查文档地址或内容是否正确")
+        return None
+
+    print(f"[1/5] 解析完成:发现 {len(endpoints)} 个接口")
+
+    # ② 生成用例(每个接口都生成,用 extend 合并成一个大列表)
+    generator = GeneratorAgent()
+    all_cases = []
+    for endpoint in endpoints:
+        cases = generator.generate(endpoint)
+        all_cases.extend(cases)
+    print(f"[2/5] 生成完成:共 {len(all_cases)} 个测试用例")
+
+    # ③ 执行测试(传入认证头)
+    executor = ExecutorAgent()
+    execution_results = executor.execute(all_cases, base_url, headers=headers)
+    print(f"[3/5] 执行完成:已发送 {len(execution_results)} 个请求")
+
+    # ④ 验证结果
+    validator = ValidatorAgent()
+    validated_results = validator.validate(execution_results)
+    print(f"[4/5] 验证完成")
+
+    # ⑤ 生成并保存报告(HTML + Markdown 两种格式)
+    reporter = ReporterAgent()
+    summary = reporter.summarize(validated_results)
+    html = reporter.generate_html(validated_results)
+    markdown = reporter.generate_markdown(validated_results)
+
+    import os
+    os.makedirs(REPORT_OUTPUT_DIR, exist_ok=True)
+    html_path = os.path.join(REPORT_OUTPUT_DIR, "report.html")
+    md_path = os.path.join(REPORT_OUTPUT_DIR, "report.md")
+    with open(html_path, "w", encoding="utf-8") as f:
+        f.write(html)
+    with open(md_path, "w", encoding="utf-8") as f:
+        f.write(markdown)
+
+    print(f"[5/5] 报告已生成:{html_path} 和 {md_path}")
+    print("=" * 50)
+    print(f"测试结果:总数 {summary['total']},"
+          f"通过 {summary['passed']},失败 {summary['failed']},"
+          f"通过率 {summary['pass_rate']}%")
+    print("=" * 50)
+
+    return html_path
+
+
+def main():
+    # 解析命令行参数
+    parser = argparse.ArgumentParser(description="智能API测试助手")
+    parser.add_argument("--file", help="OpenAPI 文档路径(本地文件)")
+    parser.add_argument("--url", help="OpenAPI 文档网址(从网络抓取)")
+    parser.add_argument("--base-url", required=True, help="目标 API 基础地址")
+    parser.add_argument("--header", action="append",
+                        help="自定义请求头,格式 'Key: Value',可多次使用,如 --header 'Authorization: Bearer xxx'")
+    args = parser.parse_args()
+
+    # --file 和 --url 必须二选一
+    if not args.file and not args.url:
+        parser.error("必须提供 --file 或 --url 之一")
+    if args.file and args.url:
+        parser.error("--file 和 --url 只能选一个")
+
+    # 解析请求头 "Key: Value" → dict
+    headers = {}
+    if args.header:
+        for h in args.header:
+            if ":" in h:
+                key, value = h.split(":", 1)
+                headers[key.strip()] = value.strip()
+            else:
+                parser.error(f"请求头格式错误(应为 'Key: Value'):{h}")
+
+    run_test(base_url=args.base_url, openapi_file=args.file,
+             openapi_url=args.url, headers=headers)
+
+
+if __name__ == "__main__":
+    main()

+ 139 - 0
Co-creation-projects/senming666-api_test_assistant/openapi_service.yaml

@@ -0,0 +1,139 @@
+openapi: 3.0.0
+info:
+  title: 智能API测试助手 - 服务接口
+  description: |
+    本项目自身暴露给前端(frontend/index.html)的后端服务接口。
+    前端通过这些接口,把"OpenAPI 文档 + 目标 API 地址"发给后端,
+    后端跑完 5 个 Agent 的流水线后,把测试结果返回给前端。
+
+    这正好演示了本工具的核心用途:拿一份 OpenAPI 文档去测接口——
+    现在这份文档描述的是"测试助手自己"的接口,实现"自己测自己"。
+  version: 1.0.0
+servers:
+  - url: http://localhost:8000
+    description: 本地开发服务器(运行 python server.py 后启动)
+paths:
+  /:
+    get:
+      summary: 获取前端页面
+      description: 返回测试助手的前端可视化页面(frontend/index.html)
+      responses:
+        '200':
+          description: 成功返回前端 HTML 页面
+          content:
+            text/html:
+              schema:
+                type: string
+  /api/test:
+    post:
+      summary: 执行智能 API 测试
+      description: |
+        核心接口。传入一份 OpenAPI 文档和目标 API 的基础地址,
+        后端自动完成「解析文档 → 生成用例 → 执行测试 → 验证结果 → 统计汇总」全流程。
+      requestBody:
+        required: true
+        description: |
+          请求体必须包含 base_url,以及 openapi_text 和 openapi_url 中的一个:
+          - 两个来源字段同时提供或都没提供 → 返回 400
+          - 缺少必填字段(如 base_url)或字段类型错误 → 返回 422
+          headers 可选,用于给被测接口透传认证等请求头。
+        content:
+          application/json:
+            schema:
+              type: object
+              required:
+                - base_url
+              oneOf:
+                - required:
+                    - openapi_text
+                  not:
+                    required:
+                      - openapi_url
+                - required:
+                    - openapi_url
+                  not:
+                    required:
+                      - openapi_text
+              properties:
+                openapi_text:
+                  type: string
+                  description: OpenAPI 文档内容(支持 yaml 或 json 文本)
+                openapi_url:
+                  type: string
+                  format: uri
+                  description: OpenAPI 文档 URL,由后端直接抓取
+                base_url:
+                  type: string
+                  description: 目标 API 的基础地址,如 https://jsonplaceholder.typicode.com
+                headers:
+                  type: object
+                  description: '全局请求头(可选),如 {"Authorization": "Bearer xxx"},会原样透传给被测接口的每个请求'
+                  additionalProperties:
+                    type: string
+            example:
+              openapi_text: |
+                openapi: 3.0.0
+                info:
+                  title: 示例
+                  version: 1.0.0
+                paths:
+                  /users:
+                    get:
+                      summary: 获取用户列表
+                      responses:
+                        '200':
+                          description: 成功
+              base_url: https://jsonplaceholder.typicode.com
+      responses:
+        '200':
+          description: 测试完成,返回汇总统计和每个用例的验证结果
+          content:
+            application/json:
+              schema:
+                type: object
+                required:
+                  - summary
+                  - results
+                properties:
+                  summary:
+                    type: object
+                    description: 汇总统计
+                    properties:
+                      total:
+                        type: integer
+                        description: 用例总数
+                      passed:
+                        type: integer
+                        description: 通过数
+                      failed:
+                        type: integer
+                        description: 失败数
+                      pass_rate:
+                        type: number
+                        description: 通过率(百分比,保留 1 位小数)
+                  results:
+                    type: array
+                    description: 每个用例的验证结果
+                    items:
+                      type: object
+                      properties:
+                        case:
+                          type: object
+                          description: 原始测试用例(含 name / case_type / path / method / expected_status 等)
+                        result:
+                          type: object
+                          description: 实际执行结果(含 status_code / elapsed / body / error 等)
+                        passed:
+                          type: boolean
+                          description: 该用例是否通过
+                        errors:
+                          type: array
+                          description: 失败原因列表(通过时为空)
+                          items:
+                            type: string
+        '422':
+          description: 请求体验证失败:缺少必填字段(如 base_url)或字段类型错误(FastAPI/pydantic 校验)
+        '400':
+          description: openapi_text 与 openapi_url 必须二选一(同时提供或同时缺失),或文档解析不出任何接口
+        '500':
+          description: 测试执行失败(如 LLM 调用失败、被测目标网络异常),服务端返回友好错误提示

+ 4 - 0
Co-creation-projects/senming666-api_test_assistant/pytest.ini

@@ -0,0 +1,4 @@
+[pytest]
+# 把项目根目录加入 sys.path,让测试里能 from src.xxx import ...
+pythonpath = .
+testpaths = tests

+ 2 - 0
Co-creation-projects/senming666-api_test_assistant/requirements-dev.txt

@@ -0,0 +1,2 @@
+# 开发依赖:运行单元测试(python -m pytest)
+pytest>=7.0.0

+ 25 - 0
Co-creation-projects/senming666-api_test_assistant/requirements.txt

@@ -0,0 +1,25 @@
+# ============================================================
+# 智能API测试助手 - 依赖清单
+# ============================================================
+
+# 核心依赖
+hello-agents>=1.0.0
+
+# HTTP 请求:真实调用目标接口
+requests>=2.31.0
+
+# OpenAPI 文档解析(YAML 格式)
+PyYAML>=6.0
+
+# 环境变量加载(读取 .env 里的 LLM 配置)
+python-dotenv>=1.0.0
+
+# JSON Schema 校验:用来验证接口返回的数据结构是否符合文档约定
+jsonschema>=4.19.0
+
+# HTML 报告模板引擎:用来生成漂亮的测试报告网页
+jinja2>=3.1.0
+
+# Web 服务:把测试能力暴露成 HTTP 接口(前端需要)
+fastapi>=0.104.0
+uvicorn>=0.24.0

+ 150 - 0
Co-creation-projects/senming666-api_test_assistant/server.py

@@ -0,0 +1,150 @@
+"""
+FastAPI 服务层 - 把测试能力暴露成 HTTP 接口
+
+让浏览器(前端)能通过网络调用我们的 Agent。
+之前的 src/ 代码一行都不用改,直接复用。
+"""
+
+import sys
+from pathlib import Path
+
+# Windows 中文控制台 GBK 坑
+if hasattr(sys.stdout, "reconfigure"):
+    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+
+from fastapi import FastAPI, HTTPException
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel
+from dotenv import load_dotenv
+
+# 关键:先加载 .env,再 import Agent
+load_dotenv()
+
+from src.agents.parser_agent import ParserAgent
+from src.agents.generator_agent import GeneratorAgent
+from src.agents.executor_agent import ExecutorAgent
+from src.agents.validator_agent import ValidatorAgent
+from src.agents.reporter_agent import ReporterAgent
+
+
+app = FastAPI(title="智能API测试助手")
+
+# CORS 配置:允许前端跨域调用(简单起见先放开所有来源)
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["*"],
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+
+# 请求体模型:前端 POST 过来的数据结构
+class TestRequest(BaseModel):
+    # 保留 openapi_text,兼容原来的前端和外部调用方;两者与 openapi_url 二选一
+    openapi_text: str | None = None  # OpenAPI 文档内容(文本)
+    openapi_url: str | None = None   # OpenAPI 文档地址(由后端抓取)
+    base_url: str                    # 目标 API 基础地址
+    # 全局请求头(可选),如 {"Authorization": "Bearer xxx"},
+    # 用于给受保护接口传认证信息,会原样透传给 ExecutorAgent
+    headers: dict[str, str] | None = None
+
+
+def _json_safe(value):
+    """把执行内部使用的 multipart 二进制转换成可返回给前端的值。
+
+    文件内容必须以 bytes 传给 requests,但不能把原始 bytes 放进 FastAPI
+    响应;PNG 等二进制不是合法 UTF-8,jsonable_encoder 会因此抛异常。
+    """
+    if isinstance(value, (bytes, bytearray, memoryview)):
+        return f"<binary data: {len(value)} bytes>"
+    if isinstance(value, dict):
+        return {key: _json_safe(item) for key, item in value.items()}
+    if isinstance(value, (list, tuple)):
+        return [_json_safe(item) for item in value]
+    return value
+
+
+@app.post("/api/test")
+def test_api(req: TestRequest):
+    """一键执行完整测试流程
+
+    接收前端传来的 {openapi_text, base_url} 或 {openapi_url, base_url},
+    依次调用 5 个 Agent,返回测试结果。
+    """
+    try:
+        source_text = (req.openapi_text or "").strip()
+        source_url = (req.openapi_url or "").strip()
+        if bool(source_text) == bool(source_url):
+            raise HTTPException(
+                status_code=400,
+                detail="请在 OpenAPI 文档内容和文档 URL 中二选一",
+            )
+
+        # ① 解析文档:文本模式沿用原逻辑,URL 模式复用已有 parse_url
+        parser = ParserAgent()
+        if source_url:
+            endpoints = parser.parse_url(source_url)
+        else:
+            endpoints = parser.parse_text(source_text)
+
+        # 文档里没有解析出任何接口,直接返回明确错误
+        if not endpoints:
+            source_hint = "URL" if source_url else "文档内容"
+            raise HTTPException(
+                status_code=400,
+                detail=f"未能从 {source_hint} 解析出任何接口,请检查地址或内容是否正确",
+            )
+
+        # ② 生成用例
+        generator = GeneratorAgent()
+        all_cases = []
+        for endpoint in endpoints:
+            all_cases.extend(generator.generate(endpoint))
+
+        # ③ 执行测试(把前端传来的认证头一并透传)
+        executor = ExecutorAgent()
+        execution_results = executor.execute(all_cases, req.base_url, headers=req.headers)
+
+        # ④ 验证结果
+        validator = ValidatorAgent()
+        validated_results = validator.validate(execution_results)
+
+        # ⑤ 统计汇总
+        reporter = ReporterAgent()
+        summary = reporter.summarize(validated_results)
+
+        return {
+            "summary": summary,
+            # 内部结果可能包含 multipart 的 bytes,响应前统一转成 JSON 安全值。
+            "results": _json_safe(validated_results),
+        }
+    except HTTPException:
+        raise
+    except Exception as e:
+        # LLM 调用失败、网络异常等,返回 500 并给出友好提示,避免无信息崩溃
+        raise HTTPException(status_code=500, detail=f"测试执行失败:{e}")
+
+
+# ===== 前端托管 =====
+# Vue3 工程每次 `npm run build` 会把打包产物生成到 frontend/dist/
+# 只托管 Vue 构建产物,不再回退到旧版单文件页面
+DIST_DIR = Path(__file__).resolve().parent / "frontend" / "dist"
+
+if DIST_DIR.exists():
+    # 注意:挂载必须放在所有 /api 路由定义【之后】,FastAPI 按注册顺序匹配,
+    # 前面的 /api/test 会先命中,静态目录只接管其余请求
+    app.mount("/", StaticFiles(directory=DIST_DIR, html=True), name="frontend")
+else:
+    @app.get("/")
+    def index():
+        """未构建 Vue 前端时返回明确提示"""
+        raise HTTPException(
+            status_code=503,
+            detail="Vue 前端尚未构建,请先执行 cd frontend && npm run build",
+        )
+
+
+if __name__ == "__main__":
+    import uvicorn
+    uvicorn.run(app, host="0.0.0.0", port=8000)

+ 0 - 0
Co-creation-projects/senming666-api_test_assistant/src/__init__.py


+ 0 - 0
Co-creation-projects/senming666-api_test_assistant/src/agents/__init__.py


+ 101 - 0
Co-creation-projects/senming666-api_test_assistant/src/agents/executor_agent.py

@@ -0,0 +1,101 @@
+"""
+执行Agent - 负责真正执行测试用例
+
+这是"智能体层"里第一个"用工具"的 Agent——
+它自己不发请求,而是调用 http_client 工具去发。
+"""
+
+import re
+
+from src.tools.http_client import HttpClient
+
+
+class ExecutorAgent:
+    """执行测试用例,调用目标 API"""
+
+    def __init__(self):
+        # 依赖 HttpClient 工具:发请求的活交给它
+        self.http_client = HttpClient()
+
+    def execute(self, test_cases, base_url, headers=None):
+        """执行一组测试用例
+
+        Args:
+            test_cases: GeneratorAgent 生成的用例列表
+            base_url: 目标 API 的基础地址,如 "https://api.example.com"
+            headers: 全局请求头(可选),如认证信息
+
+        Returns:
+            执行结果列表,每个元素是:
+            {
+                "case": {...},     # 原始用例(含期望的状态码)
+                "result": {...},   # http_client 返回的请求结果
+            }
+        """
+        results = []
+
+        for case in test_cases:
+            result = self.execute_one(case, base_url, headers)
+            results.append(result)
+
+        return results
+
+    def _replace_path_params(self, path, params):
+        """把 path 里的 {xxx} 占位符替换成 params 里的对应值
+
+        Args:
+            path: 接口路径,如 "/status/{codes}"
+            params: 用例的参数字典,如 {"codes": "200"}
+
+        Returns:
+            (替换后的 path, 移除路径参数后的 params)
+        """
+        # 拷贝一份,避免修改原始的 case["params"]
+        params = dict(params or {})
+
+        def replacer(match):
+            key = match.group(1)
+            # 从 params 里取值并移除(路径参数不应再作为查询参数发出)
+            value = params.pop(key, None)
+            if value is None:
+                # params 里没有对应值,保留占位符原样(如 error 用例缺参数)
+                return match.group(0)
+            return str(value)
+
+        new_path = re.sub(r"\{(\w+)\}", replacer, path)
+        return new_path, params
+
+    def execute_one(self, case, base_url, headers=None):
+        """执行单个测试用例
+
+        Args:
+            case: 单个用例字典,含 method/path/params/body
+            base_url: 目标 API 基础地址
+            headers: 全局请求头
+
+        Returns:
+            {"case": ..., "result": ...} 打包结果
+        """
+        # 1. 替换路径参数(如 /status/{codes} → /status/200)
+        path, params = self._replace_path_params(case["path"], case.get("params"))
+
+        # 2. 拼接完整 URL
+        url = base_url.rstrip("/") + "/" + path.lstrip("/")
+
+        # 3. 调用工具发请求(params 已移除路径参数)
+        # 认证头从全局 headers 传入;multipart 上传的文件从 case["files"] 传入
+        response = self.http_client.request(
+            method=case["method"],
+            url=url,
+            headers=headers,
+            params=params,
+            body=case.get("body"),
+            files=case.get("files"),
+            content_type=case.get("content_type", "application/json"),
+        )
+
+        # 3. 把"用例"和"结果"打包在一起返回
+        return {
+            "case": case,
+            "result": response,
+        }

+ 242 - 0
Co-creation-projects/senming666-api_test_assistant/src/agents/generator_agent.py

@@ -0,0 +1,242 @@
+"""
+生成Agent - 负责生成测试用例
+
+这是全项目唯一真正用到 LLM 的 Agent——
+"该传什么值、字段怎么填"这类需要理解和判断的活,交给大模型。
+
+但有几件事不能交给 LLM,因为它们有明确对错:
+- 生成哪些类型的用例(normal/boundary/error),要看接口有没有可测输入;
+- 期望状态码,要按文档的 responses 和用例类型确定;
+- 期望响应 schema,要按状态码从文档里取。
+
+这些都由 ParserAgent 的确定性方法算出,LLM 只负责填充 params/body 的具体值。
+"""
+
+import json
+import re
+import base64
+from hello_agents import HelloAgentsLLM
+from src.config import CASE_TYPES, MINIMAL_PNG_BASE64
+from src.agents.parser_agent import ParserAgent
+
+
+class GeneratorAgent:
+    """用 LLM 为接口智能生成测试用例"""
+
+    def __init__(self):
+        # HelloAgentsLLM() 自动从 .env 读取 LLM_MODEL_ID / LLM_API_KEY / LLM_BASE_URL
+        self.llm = HelloAgentsLLM()
+        # 复用 ParserAgent 的确定性方法(判状态码 / 取响应 schema)
+        self.parser = ParserAgent()
+
+    def generate(self, endpoint):
+        """为单个接口生成一组测试用例
+
+        Args:
+            endpoint: ParserAgent 提取出的接口字典,如
+                {"path": "/users", "method": "GET", "responses": {...}}
+
+        Returns:
+            测试用例列表,每个用例是一个字典:
+            [
+                {
+                    "name": "正常获取用户列表",
+                    "case_type": "normal",
+                    "params": {...},        # 查询参数(含路径参数值)
+                    "body": {...},          # 请求体
+                    "expected_status": 200, # 由文档确定,非 LLM 生成
+                    "expected_schema": {...}, # 由文档确定
+                    "content_type": "...",  # 请求体媒体类型
+                },
+                ...
+            ]
+        """
+        # 先确定要生成哪些类型的用例,不能机械地每个接口都生成 3 条
+        case_types = self._decide_case_types(endpoint)
+        prompt = self._build_prompt(endpoint, case_types)
+        response = self.llm.invoke([{"role": "user", "content": prompt}])
+        # 注意:1.0.0 的 invoke 返回 LLMResponse 对象,用 .content 拿文本
+        test_cases = self._parse_response(response.content)
+
+        # LLM 输出不可靠,可能返回非列表(dict/字符串),直接返回空列表兜底
+        if not isinstance(test_cases, list):
+            return []
+
+        # 逐个规范化:注入确定性字段,覆盖 LLM 不可靠的输出
+        cleaned = []
+        for case in test_cases:
+            if not isinstance(case, dict):
+                continue
+            cleaned.append(self._normalize_case(case, endpoint))
+
+        return cleaned
+
+    def _decide_case_types(self, endpoint):
+        """根据接口结构决定生成哪些类型的用例
+
+        - normal:永远生成
+        - boundary:有可测试输入(query 参数或请求体)才生成
+        - error:有必填输入(必填 query/请求体/路径参数)才生成
+
+        像 GET /api/health 这种没参数没请求体的接口,只生成 normal 一条,
+        避免硬凑"非法参数"用例,结果实际返回 200 却被判成失败。
+        """
+        types = ["normal"]
+        if self.parser.has_testable_inputs(endpoint):
+            types.append("boundary")
+        if (self.parser.has_validation_input(endpoint)
+                or self.parser.has_required_path_param(endpoint)):
+            types.append("error")
+        return types
+
+    def _normalize_case(self, case, endpoint):
+        """规范化单个用例:注入确定性的字段,修正 LLM 的不可靠输出
+
+        Args:
+            case: LLM 生成的单个用例
+            endpoint: 接口字典
+
+        Returns:
+            规范化后的用例
+        """
+        # 用例类型只在固定集合里取值,非法值回退 normal
+        case_type = case.get("case_type")
+        if case_type not in CASE_TYPES:
+            case_type = "normal"
+
+        case["case_type"] = case_type
+        case["path"] = endpoint["path"]
+        case["method"] = endpoint["method"]
+        case["content_type"] = endpoint.get("request_content_type") or "application/json"
+
+        # 期望状态码由文档 + 用例类型确定,不信任 LLM 填的值
+        case["expected_status"] = self.parser.get_expected_status(endpoint, case_type)
+        # 期望响应 schema 同样由文档确定,$ref 已在解析时展开
+        case["expected_schema"] = self.parser.get_response_schema(
+            endpoint, case["expected_status"]
+        )
+
+        # body 统一成 dict,方便后面做 multipart 拆分
+        body = case.get("body")
+        if not isinstance(body, dict):
+            body = {}
+            case["body"] = body
+
+        # multipart 文件上传:把文件字段从 body 拆到 files,其余字段留在 body
+        if "multipart" in case["content_type"].lower():
+            case["files"] = self._build_files(endpoint, body)
+
+        return case
+
+    def _build_files(self, endpoint, body):
+        """为 multipart 上传生成 files 参数
+
+        OpenAPI 里文件字段用 contentMediaType 或 format=binary 标识,
+        这类字段要作为文件上传(files),不能作为普通表单字段(data)。
+        文件内容用最小合法 PNG 占位,保证能通过后端的内容校验。
+
+        Args:
+            endpoint: 接口字典
+            body: 请求体(dict,会被就地修改:文件字段被弹出)
+
+        Returns:
+            files 字典 {"字段名": ("文件名", 字节内容, "MIME")};没有文件字段返回 None
+        """
+        schema = endpoint.get("request_body")
+        if not isinstance(schema, dict):
+            return None
+
+        properties = schema.get("properties") or {}
+        files = {}
+        for field, field_schema in properties.items():
+            if not isinstance(field_schema, dict):
+                continue
+            is_file = (
+                "contentMediaType" in field_schema
+                or field_schema.get("format") == "binary"
+            )
+            if not is_file or field not in body:
+                continue
+            body.pop(field)
+            # 占位内容固定是 PNG,文件名也强制 .png 后缀,
+            # 保证「扩展名 ↔ MIME ↔ 文件头」三者一致,否则后端校验会 415
+            files[field] = (
+                f"{field}.png",
+                base64.b64decode(MINIMAL_PNG_BASE64),
+                "image/png",
+            )
+        return files or None
+
+    def _build_prompt(self, endpoint, case_types):
+        """构造提示词,让 LLM 输出结构化的测试用例"""
+        case_desc = self._describe_case_types(endpoint, case_types)
+        prompt = f"""你是一个专业的 API 测试工程师。请为下面这个接口生成测试用例。
+
+接口信息:
+- 路径:{endpoint['path']}
+- 方法:{endpoint['method']}
+- 路径/查询参数定义:{endpoint.get('parameters', [])}
+- 请求体定义(POST/PUT/PATCH 必须严格参照):{endpoint.get('request_body')}
+- 响应定义:{endpoint.get('responses', {})}
+
+{case_desc}
+
+严格要求:
+- 只输出一个 JSON 数组,不要有任何解释文字
+- 数组里每个元素是一个对象,字段如下:
+  "name"(用例名称)、"case_type"(normal/boundary/error)、
+  "params"(查询参数和路径参数对象)、"body"(请求体对象)、
+  "expected_status"(期望状态码数字,可随便填,后端会按文档覆盖)
+- 若接口有"请求体定义",body 的字段名必须与定义里 required 的字段完全一致,
+  不得臆造或改名(这是最重要的要求)
+- 路径参数(如 path 里的 {{friendship_id}})的值也放在 params 里,用和字段名相同的键
+- 字符串字段取值要满足定义里的 minLength/maxLength/pattern 约束;
+  用户名这类要求唯一的字段,用带随机后缀的值(如 user_12345)
+- 语义上要求互相一致的字段(如 password 和 confirm_password 这种两次输入)要填成相同的值
+
+现在请输出 JSON 数组:"""
+        return prompt
+
+    def _describe_case_types(self, endpoint, case_types):
+        """把要生成的用例类型描述成给 LLM 的自然语言要求"""
+        lines = ["请为以下场景各生成 1 个测试用例:"]
+        for ct in case_types:
+            if ct == "normal":
+                lines.append("- normal(正常):传正确参数和请求体,期待成功(2xx)")
+            elif ct == "boundary":
+                lines.append(
+                    "- boundary(边界):参数/字段取刚好满足约束的边界值"
+                    "(如字符串长度恰好等于 minLength 或 maxLength),仍期待成功(2xx)"
+                )
+            elif ct == "error":
+                if self.parser.has_validation_input(endpoint):
+                    lines.append(
+                        "- error(异常):缺少某个必填的请求体字段,或查询参数传错类型,"
+                        "期待 4xx(校验失败)"
+                    )
+                else:
+                    # 只有必填路径参数时,缺路径参数导致路由不匹配
+                    lines.append(
+                        "- error(异常):params 里不填路径参数(保持缺省),期待 404"
+                    )
+        return "\n".join(lines)
+
+    def _parse_response(self, response):
+        """解析 LLM 返回的 JSON
+
+        LLM 有时会在 JSON 外面加 ```json 标记或多余文字,
+        所以要先清洗再解析。
+        """
+        # 提取第一个 [ 到最后一个 ] 之间的内容(JSON 数组)
+        match = re.search(r"\[.*\]", response, re.DOTALL)
+        if not match:
+            return []
+
+        json_text = match.group(0)
+
+        try:
+            test_cases = json.loads(json_text)
+            return test_cases
+        except json.JSONDecodeError:
+            # 解析失败就返回空列表,不让整个程序崩溃
+            return []

+ 445 - 0
Co-creation-projects/senming666-api_test_assistant/src/agents/parser_agent.py

@@ -0,0 +1,445 @@
+"""
+解析Agent - 负责解析 OpenAPI/Swagger 文档
+
+这是"智能体层"里第一个不需要 LLM 的 Agent——
+它的活是确定性的(读文档、提取结构),用普通 Python 类就够了。
+
+除了提取接口清单,这里还负责两件对后续流程很关键的确定性工作:
+1. 展开本地 $ref 引用(#/components/schemas/...),让生成Agent和验证Agent
+   能直接看到真实字段,而不是一个干巴巴的引用字符串。
+2. 根据文档的 responses 推断"期望状态码"和"期望响应 schema",
+   不把状态码的判断交给 LLM。
+"""
+
+import yaml
+import json
+import requests
+from pathlib import Path
+
+
+class ParserAgent:
+    """解析 OpenAPI/Swagger 文档,提取接口清单"""
+
+    # OpenAPI 里常见的 HTTP 方法
+    HTTP_METHODS = ["get", "post", "put", "delete", "patch"]
+
+    def parse_file(self, file_path):
+        """解析文档文件,返回接口列表
+
+        Args:
+            file_path: OpenAPI 文档路径(支持 .yaml / .yml / .json)
+
+        Returns:
+            接口列表,每个接口是一个字典:
+            [
+                {
+                    "path": "/users",
+                    "method": "GET",
+                    "parameters": [...],          # 参数($ref 已展开)
+                    "request_body": {...},        # 请求体 schema($ref 已展开,无则 None)
+                    "request_content_type": "...",# 请求体媒体类型,如 application/json
+                    "responses": {...},           # 各状态码的响应(schema 已展开)
+                    "response_schema": {...},     # 主成功响应的 schema(无则 None)
+                    "security": [...],            # 安全要求(如 Bearer 认证)
+                },
+                ...
+            ]
+        """
+        text = Path(file_path).read_text(encoding="utf-8")
+        return self.parse_text(text)
+
+    def parse_url(self, url):
+        """从 URL 抓取 OpenAPI 文档并解析
+
+        Args:
+            url: OpenAPI 文档的网址(如 https://httpbin.org/spec.json)
+
+        Returns:
+            接口列表
+        """
+        try:
+            # 用 requests 抓取网络上的文档内容
+            resp = requests.get(url, timeout=30)
+            # 非 200 状态码会抛异常
+            resp.raise_for_status()
+        except requests.RequestException as e:
+            # 抓取失败(如 503 服务不可用、网络超时),不崩溃,返回空列表
+            print(f"[警告] 从 URL 抓取文档失败:{e}")
+            return []
+
+        # 抓到的内容和本地文件一样,复用 parse_text 解析
+        return self.parse_text(resp.text)
+
+    def parse_text(self, text):
+        """解析文档文本,自动判断 JSON 还是 YAML
+
+        Args:
+            text: OpenAPI 文档内容(字符串,前端直接传这个)
+
+        Returns:
+            接口列表
+        """
+        text = text.strip()
+
+        # 空输入直接返回空列表,避免 yaml.safe_load("") 返回 None 导致后续崩溃
+        if not text:
+            return []
+
+        # 先尝试按 JSON 解析,失败则按 YAML 解析
+        # (JSON 也是合法的 YAML,但反过来不成立,所以先试 JSON)
+        try:
+            openapi_dict = json.loads(text)
+        except json.JSONDecodeError:
+            openapi_dict = yaml.safe_load(text)
+
+        return self.extract_endpoints(openapi_dict)
+
+    # ============ $ref 展开 ============
+
+    def _resolve_ref(self, ref, openapi_dict, _stack):
+        """解析单个本地 $ref 引用(如 #/components/schemas/User)
+
+        沿 #/components/schemas/User 逐级取值,再递归展开目标里的嵌套引用。
+        用 _stack 记录当前解析链,避免循环引用(A → B → A)导致无限递归。
+
+        Args:
+            ref: 引用字符串
+            openapi_dict: 完整文档字典
+            _stack: 解析链集合(用于循环检测)
+
+        Returns:
+            展开后的 schema;引用指向不存在的目标或成环时,原样返回引用字符串
+        """
+        if not isinstance(ref, str) or not ref.startswith("#/"):
+            return ref
+        if ref in _stack:
+            # 循环引用:保留引用原样,避免死循环
+            return ref
+
+        parts = ref.lstrip("#/").split("/")
+        node = openapi_dict
+        for part in parts:
+            node = node.get(part) if isinstance(node, dict) else None
+            if node is None:
+                # 引用指向不存在的目标,原样返回
+                return ref
+
+        _stack.add(ref)
+        resolved = self._expand(node, openapi_dict, _stack)
+        _stack.discard(ref)
+        return resolved
+
+    def _expand(self, node, openapi_dict, _stack=None):
+        """递归展开 node 里所有嵌套的 $ref 引用,返回全新结构(不改原文档)
+
+        Args:
+            node: 任意 JSON/YAML 结构
+            openapi_dict: 完整文档字典
+            _stack: 解析链集合
+
+        Returns:
+            展开后的全新结构
+        """
+        if _stack is None:
+            _stack = set()
+
+        if isinstance(node, dict):
+            if "$ref" in node:
+                resolved = self._resolve_ref(node["$ref"], openapi_dict, _stack)
+                # OpenAPI 3.1 允许 $ref 与其它字段共存,兄弟字段覆盖引用结果
+                siblings = {
+                    k: self._expand(v, openapi_dict, _stack)
+                    for k, v in node.items() if k != "$ref"
+                }
+                if isinstance(resolved, dict) and siblings:
+                    merged = dict(resolved)
+                    merged.update(siblings)
+                    return merged
+                return resolved
+            return {
+                k: self._expand(v, openapi_dict, _stack)
+                for k, v in node.items()
+            }
+        if isinstance(node, list):
+            return [self._expand(v, openapi_dict, _stack) for v in node]
+        return node
+
+    # ============ 提取接口 ============
+
+    def extract_endpoints(self, openapi_dict):
+        """从 OpenAPI 结构里提取所有接口(含 $ref 展开)
+
+        Args:
+            openapi_dict: 解析后的 OpenAPI 字典
+
+        Returns:
+            接口列表
+        """
+        endpoints = []
+
+        # 类型检查:解析结果可能是 None 或非 dict(如 yaml 解析出字符串),直接返回空
+        if not isinstance(openapi_dict, dict):
+            return endpoints
+
+        # paths 可能缺失或为 None,统一兜底为空字典
+        paths = openapi_dict.get("paths") or {}
+
+        for path, path_item in paths.items():
+            # path_item 也可能不是 dict(不规范文档),跳过
+            if not isinstance(path_item, dict):
+                continue
+            # 每个 path 下可能有多个方法(get、post 等)
+            for method in self.HTTP_METHODS:
+                if method in path_item:
+                    operation = path_item[method]
+                    # 请求体:解析出 schema 和媒体类型(application/json / multipart/form-data)
+                    request_body, content_type = self._extract_request_body(
+                        operation, openapi_dict
+                    )
+                    # 参数和响应都要展开 $ref,方便后续生成/校验
+                    parameters = self._resolve_parameters(
+                        operation.get("parameters", []), openapi_dict
+                    )
+                    responses = self._resolve_responses(
+                        operation.get("responses", {}), openapi_dict
+                    )
+                    endpoints.append({
+                        "path": path,
+                        "method": method.upper(),
+                        "summary": operation.get("summary", ""),
+                        "parameters": parameters,
+                        "request_body": request_body,
+                        "request_content_type": content_type,
+                        "responses": responses,
+                        "response_schema": self._primary_response_schema(responses),
+                        "security": operation.get("security"),
+                    })
+
+        return endpoints
+
+    def _extract_request_body(self, operation, openapi_dict):
+        """提取请求体的 schema 与媒体类型
+
+        Args:
+            operation: 单个方法对应的 operation 字典
+            openapi_dict: 完整文档字典
+
+        Returns:
+            (schema, content_type) 二元组;没有请求体时返回 (None, None)
+        """
+        request_body = operation.get("requestBody")
+        if not isinstance(request_body, dict):
+            return None, None
+
+        # requestBody 本身也可能是个 $ref
+        if "$ref" in request_body:
+            request_body = self._expand(request_body, openapi_dict)
+
+        content = request_body.get("content") or {}
+        # 优先 JSON,其次 multipart/form-data,最后取第一个可用媒体类型
+        for media_type in ("application/json", "multipart/form-data"):
+            media = content.get(media_type)
+            if isinstance(media, dict) and "schema" in media:
+                return self._expand(media["schema"], openapi_dict), media_type
+        for media_type, media in content.items():
+            if isinstance(media, dict) and "schema" in media:
+                return self._expand(media["schema"], openapi_dict), media_type
+
+        return None, None
+
+    def _resolve_parameters(self, parameters, openapi_dict):
+        """展开参数列表里的 $ref(参数本身和参数里的 schema)
+
+        Args:
+            parameters: operation 的 parameters 列表
+            openapi_dict: 完整文档字典
+
+        Returns:
+            展开后的参数列表
+        """
+        resolved = []
+        for param in parameters or []:
+            if not isinstance(param, dict):
+                continue
+            # 参数本身可能是 $ref(#/components/parameters/...)
+            if "$ref" in param:
+                param = self._expand(param, openapi_dict)
+            param = dict(param)
+            if "schema" in param:
+                param["schema"] = self._expand(param["schema"], openapi_dict)
+            resolved.append(param)
+        return resolved
+
+    def _resolve_responses(self, responses, openapi_dict):
+        """展开 responses 里每个响应体 schema 的 $ref
+
+        Args:
+            responses: operation 的 responses 字典
+            openapi_dict: 完整文档字典
+
+        Returns:
+            展开后的 responses 字典
+        """
+        resolved = {}
+        for code, resp in responses.items():
+            if not isinstance(resp, dict):
+                resolved[code] = resp
+                continue
+            content = resp.get("content") or {}
+            new_content = {}
+            for media_type, media in content.items():
+                if isinstance(media, dict) and "schema" in media:
+                    media = dict(media)
+                    media["schema"] = self._expand(media["schema"], openapi_dict)
+                new_content[media_type] = media
+            new_resp = dict(resp)
+            new_resp["content"] = new_content
+            resolved[code] = new_resp
+        return resolved
+
+    def _primary_response_schema(self, responses):
+        """取第一个 2xx 成功响应的 schema(作为正常用例的校验依据)
+
+        Args:
+            responses: 已展开的 responses 字典
+
+        Returns:
+            成功响应的 schema,没有则 None
+        """
+        for code in responses:
+            if str(code).startswith("2"):
+                schema = self._extract_response_schema(responses, code)
+                if schema is not None:
+                    return schema
+        return None
+
+    @staticmethod
+    def _extract_response_schema(responses, status):
+        """从 responses 里按状态码取响应体 schema
+
+        Args:
+            responses: 已展开的 responses 字典
+            status: 状态码(int 或 str)
+
+        Returns:
+            对应状态码的响应 schema,没有则 None
+        """
+        resp = responses.get(str(status)) or responses.get(status)
+        if not isinstance(resp, dict):
+            return None
+        content = resp.get("content") or {}
+        # 优先 JSON,兼容 */* 和任意媒体类型
+        for media_type in ("application/json", "*/*"):
+            media = content.get(media_type)
+            if isinstance(media, dict) and "schema" in media:
+                return media["schema"]
+        for media in content.values():
+            if isinstance(media, dict) and "schema" in media:
+                return media["schema"]
+        return None
+
+    # ============ 结构判断辅助 ============
+    # 这些是"这个接口有没有可测试的输入"之类的确定性判断,
+    # 生成Agent 用它们决定要不要生成 boundary/error 用例。
+
+    @staticmethod
+    def _query_params(endpoint):
+        return [
+            p for p in endpoint.get("parameters", [])
+            if isinstance(p, dict) and p.get("in") == "query"
+        ]
+
+    @staticmethod
+    def _path_params(endpoint):
+        return [
+            p for p in endpoint.get("parameters", [])
+            if isinstance(p, dict) and p.get("in") == "path"
+        ]
+
+    @staticmethod
+    def _required_body_fields(endpoint):
+        body = endpoint.get("request_body")
+        if isinstance(body, dict) and isinstance(body.get("required"), list):
+            return body["required"]
+        return []
+
+    @staticmethod
+    def has_validation_input(endpoint):
+        """是否有必填的 query 参数或必填请求体字段
+
+        这类接口缺少输入时会触发参数/请求体校验失败(通常 422)。
+        """
+        if any(p.get("required") for p in ParserAgent._query_params(endpoint)):
+            return True
+        return bool(ParserAgent._required_body_fields(endpoint))
+
+    @staticmethod
+    def has_required_path_param(endpoint):
+        """是否有必填的路径参数
+
+        路径参数缺失会导致路由不匹配(通常 404),和校验失败(422)要区分开。
+        """
+        return any(p.get("required") for p in ParserAgent._path_params(endpoint))
+
+    @staticmethod
+    def has_testable_inputs(endpoint):
+        """是否有可测试的输入(query 参数或请求体),决定是否生成 boundary 用例"""
+        return bool(ParserAgent._query_params(endpoint)) or endpoint.get("request_body") is not None
+
+    # ============ 期望状态码 / 期望 schema ============
+
+    def get_expected_status(self, endpoint, case_type="normal"):
+        """根据接口定义和用例类型,确定期望的状态码
+
+        这一步是确定性的,不交给 LLM,避免 LLM 随意生成错误的状态码。
+
+        Args:
+            endpoint: 单个接口字典
+            case_type: 用例类型,normal / boundary / error
+
+        Returns:
+            期望的状态码(int)
+        """
+        responses = endpoint.get("responses", {})
+
+        if case_type in ("normal", "boundary"):
+            # 正常/边界场景:优先 2xx 成功状态码
+            for code in responses:
+                if str(code).startswith("2"):
+                    return int(code)
+
+        elif case_type == "error":
+            # 异常场景要区分两类:
+            #   - 有必填 query/请求体 → 缺字段/传错类型 → 校验失败 4xx(通常 422)
+            #   - 只有必填路径参数 → 缺路径参数 → 路由不匹配 → 404
+            if self.has_validation_input(endpoint):
+                for code in responses:
+                    if str(code).startswith("4"):
+                        return int(code)
+                # 文档没声明 4xx 时,按 FastAPI 校验错误约定默认 422
+                return 422
+            if self.has_required_path_param(endpoint):
+                return 404
+            for code in responses:
+                if str(code).startswith("4"):
+                    return int(code)
+            return 400
+
+        # 兜底:返回第一个声明的状态码
+        for code in responses:
+            return int(code)
+
+        # 文档里什么都没声明,默认 200
+        return 200
+
+    def get_response_schema(self, endpoint, status):
+        """按状态码取接口的响应 schema($ref 已展开)
+
+        Args:
+            endpoint: 单个接口字典
+            status: 状态码(int 或 str)
+
+        Returns:
+            对应状态码的响应 schema,没有则 None
+        """
+        return self._extract_response_schema(endpoint.get("responses", {}), status)

+ 141 - 0
Co-creation-projects/senming666-api_test_assistant/src/agents/reporter_agent.py

@@ -0,0 +1,141 @@
+"""
+报告Agent - 负责生成测试报告
+
+这是流水线的最后一环,把验证结论渲染成 HTML 报告。
+"""
+
+from jinja2 import Template
+
+
+# HTML 报告模板(用 jinja2 语法写占位符)
+HTML_TEMPLATE = """<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <title>API 测试报告</title>
+    <style>
+        body { font-family: sans-serif; margin: 40px; }
+        h1 { color: #333; }
+        .summary { background: #f5f5f5; padding: 15px; border-radius: 8px; margin-bottom: 20px; }
+        .summary span { margin-right: 30px; font-size: 18px; }
+        .passed { color: green; font-weight: bold; }
+        .failed { color: red; font-weight: bold; }
+        table { border-collapse: collapse; width: 100%; }
+        th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
+        th { background: #4CAF50; color: white; }
+    </style>
+</head>
+<body>
+    <h1>API 测试报告</h1>
+
+    <div class="summary">
+        <span>总用例数:<b>{{ summary.total }}</b></span>
+        <span class="passed">通过:{{ summary.passed }}</span>
+        <span class="failed">失败:{{ summary.failed }}</span>
+        <span>通过率:{{ summary.pass_rate }}%</span>
+    </div>
+
+    <table>
+        <tr>
+            <th>用例名称</th>
+            <th>类型</th>
+            <th>状态码</th>
+            <th>耗时(秒)</th>
+            <th>结果</th>
+            <th>错误信息</th>
+        </tr>
+        {% for r in results %}
+        <tr>
+            <td>{{ r.case.name }}</td>
+            <td>{{ r.case.case_type }}</td>
+            <td>{{ r.result.status_code | default('-', true) }}</td>
+            <td>{{ r.result.elapsed | default('-', true) }}</td>
+            <td class="{{ 'passed' if r.passed else 'failed' }}">
+                {{ '通过' if r.passed else '失败' }}
+            </td>
+            <td>{{ r.errors }}</td>
+        </tr>
+        {% endfor %}
+    </table>
+</body>
+</html>
+"""
+
+
+class ReporterAgent:
+    """生成测试报告"""
+
+    def summarize(self, validated_results):
+        """统计测试结果
+
+        Args:
+            validated_results: ValidatorAgent 返回的验证结果列表
+
+        Returns:
+            统计信息字典:总数、通过、失败、跳过、通过率
+        """
+        total = len(validated_results)
+        passed = sum(1 for r in validated_results if r["passed"])
+        failed = total - passed
+        # 通过率 = 通过数 / 总数 * 100%,保留 1 位小数
+        pass_rate = round(passed / total * 100, 1) if total > 0 else 0
+
+        return {
+            "total": total,
+            "passed": passed,
+            "failed": failed,
+            "pass_rate": pass_rate,
+        }
+
+    def generate_html(self, validated_results):
+        """生成 HTML 报告
+
+        Args:
+            validated_results: 验证结果列表
+
+        Returns:
+            HTML 字符串
+        """
+        summary = self.summarize(validated_results)
+        template = Template(HTML_TEMPLATE)
+        return template.render(summary=summary, results=validated_results)
+
+    def generate_markdown(self, validated_results):
+        """生成 Markdown 报告
+
+        Args:
+            validated_results: 验证结果列表
+
+        Returns:
+            Markdown 字符串
+        """
+        summary = self.summarize(validated_results)
+
+        lines = []
+        lines.append("# API 测试报告\n")
+        lines.append("## 汇总\n")
+        lines.append(f"- 总用例数:**{summary['total']}**")
+        lines.append(f"- 通过:**{summary['passed']}**")
+        lines.append(f"- 失败:**{summary['failed']}**")
+        lines.append(f"- 通过率:**{summary['pass_rate']}%**\n")
+        lines.append("## 用例明细\n")
+        lines.append("| 用例名称 | 类型 | 状态码 | 耗时(秒) | 结果 | 错误信息 |")
+        lines.append("|---|---|---|---|---|---|")
+
+        for r in validated_results:
+            case = r["case"]
+            result = r["result"]
+            name = case.get("name", "-")
+            case_type = case.get("case_type", "-")
+            # 请求失败时 status_code / elapsed 是 None,表格里应显示 "-" 而不是 "None"
+            status_code = result.get("status_code")
+            status_code = "-" if status_code is None else status_code
+            elapsed = result.get("elapsed")
+            elapsed = "-" if elapsed is None else elapsed
+            mark = "✅ 通过" if r["passed"] else "❌ 失败"
+            errors = "; ".join(r["errors"]) if r["errors"] else "-"
+            # Markdown 表格里出现 | 或换行会破坏表格结构,做简单转义
+            errors = errors.replace("|", "\\|").replace("\n", " ")
+            lines.append(f"| {name} | {case_type} | {status_code} | {elapsed} | {mark} | {errors} |")
+
+        return "\n".join(lines)

+ 70 - 0
Co-creation-projects/senming666-api_test_assistant/src/agents/validator_agent.py

@@ -0,0 +1,70 @@
+"""
+验证Agent - 负责判断测试结果是否正确
+
+这是"智能体层"里第二个"用工具"的 Agent——
+它自己不判断,而是调用 schema_validator 工具去判断。
+"""
+
+from src.tools.schema_validator import SchemaValidator
+
+
+class ValidatorAgent:
+    """验证测试结果,判断每个用例是否通过"""
+
+    def __init__(self):
+        # 依赖 SchemaValidator 工具:判断对错的活交给它
+        self.validator = SchemaValidator()
+
+    def validate(self, execution_results):
+        """验证一组执行结果
+
+        Args:
+            execution_results: ExecutorAgent 返回的结果列表,
+                每个元素是 {"case": {...}, "result": {...}}
+
+        Returns:
+            验证结果列表,每个元素是:
+            {
+                "case": {...},     # 原始用例
+                "result": {...},   # 执行结果
+                "passed": bool,    # 是否通过
+                "errors": [...],   # 失败原因列表(通过时为空)
+            }
+        """
+        validated = []
+
+        for item in execution_results:
+            validated.append(self.validate_one(item))
+
+        return validated
+
+    def validate_one(self, item):
+        """验证单个执行结果
+
+        Args:
+            item: {"case": ..., "result": ...}
+
+        Returns:
+            加了 passed/errors 字段的完整结果
+        """
+        case = item["case"]
+        result = item["result"]
+
+        # 从用例里取"期望值"(可能没有,所以用 .get 安全取值)
+        expected_status = case.get("expected_status")
+        expected_schema = case.get("expected_schema")
+
+        # 调用工具做实际判断
+        check = self.validator.validate(
+            result,
+            expected_status=expected_status,
+            expected_schema=expected_schema,
+        )
+
+        # 打包返回:用例 + 执行结果 + 验证结论
+        return {
+            "case": case,
+            "result": result,
+            "passed": check["success"],
+            "errors": check["errors"],
+        }

+ 44 - 0
Co-creation-projects/senming666-api_test_assistant/src/config.py

@@ -0,0 +1,44 @@
+"""
+配置常量文件
+集中管理项目中用到的所有固定常量,避免"魔法数字"散落在代码各处。
+
+用法:其他文件里写  from src.config import REQUEST_TIMEOUT
+"""
+
+# ============ HTTP 请求配置 ============
+
+# 单个接口测试的默认超时时间(秒)
+REQUEST_TIMEOUT = 30
+
+# 请求失败后的最大重试次数
+REQUEST_MAX_RETRIES = 2
+
+# 并发执行测试的最大线程数(控制同时发几个请求)
+MAX_CONCURRENCY = 5
+
+# ============ 测试用例生成配置 ============
+
+# 每个接口默认生成多少个测试用例
+TEST_CASES_PER_ENDPOINT = 3
+
+# 用例类型:正常 / 边界 / 异常
+CASE_TYPES = ["normal", "boundary", "error"]
+
+# ============ 测试数据配置 ============
+
+# 1x1 透明 PNG 的最小合法图片(base64 编码)
+# 文件上传接口(multipart/form-data)需要一个真实文件才能通过后端的内容校验,
+# 用这个最小合法图片作为占位测试文件,避免生成器拿不到二进制内容。
+MINIMAL_PNG_BASE64 = (
+    "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
+)
+
+# ============ 报告配置 ============
+
+# 测试报告的输出目录
+REPORT_OUTPUT_DIR = "reports"
+
+# ============ Agent 配置 ============
+
+# 主智能体名称
+AGENT_NAME = "APITestAssistant"

+ 0 - 0
Co-creation-projects/senming666-api_test_assistant/src/tools/__init__.py


+ 135 - 0
Co-creation-projects/senming666-api_test_assistant/src/tools/http_client.py

@@ -0,0 +1,135 @@
+"""
+HTTP 请求工具
+负责真正发送 HTTP 请求,并统一处理超时、重试和错误。
+
+这是"工具层"——没有大脑,只会老老实实发请求、拿结果。
+"""
+
+import time
+import requests
+from src.config import REQUEST_TIMEOUT, REQUEST_MAX_RETRIES
+
+
+class HttpClient:
+    """HTTP 客户端工具
+
+    封装 requests 库,提供:
+    - 统一的超时控制
+    - 失败自动重试
+    - 标准化的返回结果(方便后面的验证Agent使用)
+    """
+
+    # 支持的 HTTP 方法列表
+    SUPPORTED_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"]
+
+    def __init__(self, timeout=REQUEST_TIMEOUT, max_retries=REQUEST_MAX_RETRIES):
+        """
+        初始化工具
+
+        Args:
+            timeout: 单次请求超时时间(秒),默认从 config 读取
+            max_retries: 失败后重试次数,默认从 config 读取
+        """
+        self.timeout = timeout
+        self.max_retries = max_retries
+
+    def request(self, method, url, headers=None, params=None, body=None,
+                files=None, content_type="application/json"):
+        """发送 HTTP 请求(带自动重试)
+
+        Args:
+            method: HTTP 方法,如 "GET"、"POST"
+            url: 完整的请求地址
+            headers: 请求头字典,如 {"Authorization": "Bearer xxx"}
+            params: 查询参数(URL 中 ? 后面的部分)
+            body: 请求体(JSON 时的 dict,或表单字段)
+            files: 上传的文件,格式 {"字段名": ("文件名", 字节内容, "MIME")},传了就走 multipart
+            content_type: 请求体媒体类型,如 "application/json" / "multipart/form-data"
+
+        Returns:
+            标准化结果字典:
+            {
+                "success": bool,        # 是否成功
+                "status_code": int,     # 状态码,如 200、404
+                "body": dict 或 str,    # 解析后的响应体
+                "elapsed": float,       # 耗时(秒)
+                "error": str 或 None    # 错误信息(成功时为 None)
+            }
+        """
+        method = method.upper()
+
+        # 检查方法是否支持
+        if method not in self.SUPPORTED_METHODS:
+            return self._error_result(f"不支持的 HTTP 方法: {method}")
+
+        # 带重试的请求循环:最多试 (max_retries + 1) 次
+        last_error = None
+        for attempt in range(self.max_retries + 1):
+            try:
+                return self._do_request(method, url, headers, params, body, files, content_type)
+            except requests.RequestException as e:
+                last_error = e
+                # 如果不是最后一次,休息 1 秒再重试
+                if attempt < self.max_retries:
+                    time.sleep(1)
+
+        # 所有重试都失败,返回错误结果
+        return self._error_result(f"请求失败(已重试 {self.max_retries} 次): {last_error}")
+
+    def _do_request(self, method, url, headers, params, body, files, content_type):
+        """真正执行一次请求(不重试,被 request 方法调用)
+
+        根据请求体类型选择序列化方式:
+        - 有 files:multipart 文件上传(body 作为表单字段,files 作为文件字段)
+        - content_type 是 multipart:纯表单字段(无文件)
+        - 其它:JSON 请求体
+        """
+        start = time.time()
+
+        kwargs = {
+            "method": method,
+            "url": url,
+            "headers": headers,
+            "params": params,
+            "timeout": self.timeout,
+        }
+
+        if files is not None:
+            # multipart/form-data:文件字段走 files,普通字段走 data
+            kwargs["data"] = body or {}
+            kwargs["files"] = files
+        elif content_type and "multipart" in content_type.lower():
+            # multipart 但没有文件(纯表单字段)
+            kwargs["data"] = body or {}
+        elif body is not None:
+            # 默认 JSON 请求体,自动把 dict 转成 JSON 格式
+            kwargs["json"] = body
+
+        response = requests.request(**kwargs)
+
+        elapsed = time.time() - start
+
+        return {
+            "success": True,
+            "status_code": response.status_code,
+            "body": self._parse_body(response),
+            "elapsed": round(elapsed, 3),
+            "error": None,
+        }
+
+    def _parse_body(self, response):
+        """把响应体解析成 dict(如果是 JSON)或字符串"""
+        try:
+            return response.json()
+        except ValueError:
+            return response.text
+
+    def _error_result(self, message):
+        """构造一个标准的"失败"结果字典"""
+        return {
+            "success": False,
+            "status_code": None,
+            "body": None,
+            "elapsed": 0.0,
+            "error": message,
+        }

+ 95 - 0
Co-creation-projects/senming666-api_test_assistant/src/tools/schema_validator.py

@@ -0,0 +1,95 @@
+"""
+Schema 校验工具
+负责验证接口返回的数据是否符合文档约定。
+
+这是"工具层"——没有大脑,只会严格地对照规则检查。
+"""
+
+import jsonschema
+from jsonschema import ValidationError
+
+
+class SchemaValidator:
+    """结果校验工具
+
+    用 JSON Schema 规则来检查接口响应:
+    - 状态码是否符合预期
+    - 响应体数据结构是否符合约定
+    """
+
+    def validate_status_code(self, actual, expected):
+        """校验状态码是否符合预期
+
+        Args:
+            actual: 实际返回的状态码,如 200
+            expected: 期望的状态码,如 200
+
+        Returns:
+            (是否通过, 错误信息) —— 通过时错误信息为 None
+        """
+        if actual == expected:
+            return True, None
+        return False, f"状态码不符:期望 {expected},实际 {actual}"
+
+    def validate_body(self, body, schema):
+        """校验响应体是否符合 JSON Schema
+
+        Args:
+            body: 实际返回的响应体(dict 或字符串)
+            schema: JSON Schema 说明书(dict)
+
+        Returns:
+            (是否通过, 错误信息)
+        """
+        # 没有给 schema,就不校验(跳过)
+        if schema is None:
+            return True, None
+
+        try:
+            jsonschema.validate(body, schema)
+            return True, None
+        except ValidationError as e:
+            # e.message 是 jsonschema 给的"哪里不符合"的说明
+            return False, f"数据结构不符:{e.message}"
+
+    def validate(self, result, expected_status=None, expected_schema=None):
+        """综合校验一个请求结果
+
+        Args:
+            result: http_client 返回的标准化结果字典
+            expected_status: 期望的状态码(可选)
+            expected_schema: 期望的 JSON Schema(可选)
+
+        Returns:
+            校验结果字典:
+            {
+                "success": bool,        # 是否全部通过
+                "errors": [str, ...]    # 所有不通过的原因列表
+            }
+        """
+        errors = []
+
+        # 1. 请求本身是否成功(网络层)
+        if not result["success"]:
+            errors.append(f"请求失败:{result['error']}")
+            # 请求都失败了,后面的状态码和结构检查没有意义,直接返回
+            return {"success": False, "errors": errors}
+
+        # 2. 状态码检查
+        if expected_status is not None:
+            ok, msg = self.validate_status_code(
+                result["status_code"], expected_status
+            )
+            if not ok:
+                errors.append(msg)
+
+        # 3. 响应体结构检查
+        if expected_schema is not None:
+            ok, msg = self.validate_body(result["body"], expected_schema)
+            if not ok:
+                errors.append(msg)
+
+        return {
+            "success": len(errors) == 0,
+            "errors": errors,
+        }

+ 3 - 0
Co-creation-projects/senming666-api_test_assistant/tests/.gitignore

@@ -0,0 +1,3 @@
+# 仓库根 .gitignore 里有 `test_*.py` 的全局忽略规则,
+# 这里取消忽略,让本项目自己的单元测试能被正常提交
+!test_*.py

文件差异内容过多而无法显示
+ 0 - 0
Co-creation-projects/senming666-api_test_assistant/tests/fixtures/chat_openapi.json


+ 82 - 0
Co-creation-projects/senming666-api_test_assistant/tests/test_executor_agent.py

@@ -0,0 +1,82 @@
+"""ExecutorAgent 路径参数替换与 URL 拼接的单元测试"""
+from src.agents.executor_agent import ExecutorAgent
+
+
+def _executor():
+    return ExecutorAgent()
+
+
+# --- _replace_path_params ---
+
+def test_replace_path_params_basic():
+    path, params = _executor()._replace_path_params("/status/{codes}", {"codes": "200"})
+    assert path == "/status/200"
+    assert params == {}
+
+
+def test_replace_path_params_keeps_query_params():
+    path, params = _executor()._replace_path_params("/users/{id}", {"id": 1, "page": 2})
+    assert path == "/users/1"
+    assert params == {"page": 2}
+
+
+def test_replace_path_params_missing_value_keeps_placeholder():
+    # error 用例缺少参数时,占位符应保留原样
+    path, params = _executor()._replace_path_params("/status/{codes}", {})
+    assert path == "/status/{codes}"
+    assert params == {}
+
+
+def test_replace_path_params_multiple():
+    path, _ = _executor()._replace_path_params("/x/{a}/y/{b}", {"a": 1, "b": 2})
+    assert path == "/x/1/y/2"
+
+
+# --- execute_one:URL 拼接 ---
+
+def test_execute_one_url_join_and_param_removal():
+    class FakeClient:
+        def request(self, method, url, headers=None, params=None, body=None,
+                    files=None, content_type="application/json"):
+            return {"method": method, "url": url, "params": params, "body": body}
+
+    ex = _executor()
+    ex.http_client = FakeClient()
+    case = {"path": "/status/{codes}", "method": "GET", "params": {"codes": "200"}}
+
+    out = ex.execute_one(case, "https://httpbin.org/")
+
+    assert out["case"] is case
+    assert out["result"]["url"] == "https://httpbin.org/status/200"
+    assert out["result"]["params"] == {}
+    assert out["result"]["method"] == "GET"
+
+
+def test_execute_one_passes_headers_and_files():
+    """执行时全局认证头与 multipart 文件要原样透传给工具"""
+    captured = {}
+
+    class FakeClient:
+        def request(self, method, url, headers=None, params=None, body=None,
+                    files=None, content_type="application/json"):
+            captured["headers"] = headers
+            captured["files"] = files
+            captured["content_type"] = content_type
+            return {"method": method, "url": url}
+
+    ex = _executor()
+    ex.http_client = FakeClient()
+    case = {
+        "path": "/api/files/images",
+        "method": "POST",
+        "params": {},
+        "body": {},
+        "files": {"file": ("a.png", b"x", "image/png")},
+        "content_type": "multipart/form-data",
+    }
+
+    ex.execute_one(case, "http://x", headers={"Authorization": "Bearer token"})
+
+    assert captured["headers"] == {"Authorization": "Bearer token"}
+    assert captured["files"] == {"file": ("a.png", b"x", "image/png")}
+    assert captured["content_type"] == "multipart/form-data"

+ 176 - 0
Co-creation-projects/senming666-api_test_assistant/tests/test_generator_agent.py

@@ -0,0 +1,176 @@
+"""GeneratorAgent 纯逻辑部分(prompt 构造 / 响应解析 / 用例规范化)的单元测试
+
+注意:这里只测不依赖 LLM 的方法,用 object.__new__ 跳过 __init__,
+避免真正创建 HelloAgentsLLM 实例。
+"""
+from src.agents.generator_agent import GeneratorAgent
+from src.agents.parser_agent import ParserAgent
+
+
+def _gen():
+    # 不调用 __init__(__init__ 会创建 LLM 实例并读 .env),只测纯函数
+    g = object.__new__(GeneratorAgent)
+    # _normalize_case/_decide_case_types 依赖 parser,手动补上
+    g.parser = ParserAgent()
+    return g
+
+
+# --- _parse_response ---
+
+def test_parse_response_clean_json():
+    cases = _gen()._parse_response('[{"name": "a", "case_type": "normal"}]')
+    assert isinstance(cases, list)
+    assert len(cases) == 1
+    assert cases[0]["name"] == "a"
+
+
+def test_parse_response_with_markdown_fence():
+    resp = '```json\n[{"name": "a", "case_type": "normal"}]\n```'
+    cases = _gen()._parse_response(resp)
+    assert len(cases) == 1
+    assert cases[0]["name"] == "a"
+
+
+def test_parse_response_with_extra_text():
+    resp = '好的,以下是测试用例:\n[{"name": "a"}]\n希望有帮助'
+    cases = _gen()._parse_response(resp)
+    assert len(cases) == 1
+
+
+def test_parse_response_invalid_json():
+    assert _gen()._parse_response("没有 JSON 数组") == []
+
+
+# --- _build_prompt ---
+
+def test_build_prompt_contains_endpoint_fields():
+    ep = {
+        "path": "/users",
+        "method": "GET",
+        "parameters": [],
+        "request_body": None,
+        "responses": {"200": {}},
+    }
+    prompt = _gen()._build_prompt(ep, ["normal"])
+    assert "/users" in prompt
+    assert "GET" in prompt
+
+
+# --- _decide_case_types ---
+
+def test_decide_case_types_no_input_only_normal():
+    ep = {"parameters": [], "request_body": None, "responses": {"200": {}}}
+    assert _gen()._decide_case_types(ep) == ["normal"]
+
+
+def test_decide_case_types_with_body():
+    ep = {
+        "parameters": [],
+        "request_body": {"required": ["name"], "properties": {"name": {"type": "string"}}},
+        "responses": {"201": {}, "422": {}},
+    }
+    # 有请求体 → 有 boundary(可测输入)和 error(必填字段)
+    assert _gen()._decide_case_types(ep) == ["normal", "boundary", "error"]
+
+
+def test_decide_case_types_path_param_only():
+    ep = {
+        "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "string"}}],
+        "request_body": None,
+        "responses": {"200": {}, "422": {}},
+    }
+    # 只有路径参数 → 有 error(缺路径参数→404),但没有 boundary(路径参数不可取边界值)
+    assert _gen()._decide_case_types(ep) == ["normal", "error"]
+
+
+# --- _normalize_case ---
+
+def test_normalize_case_overrides_llm_status_and_injects_schema():
+    ep = {
+        "path": "/register",
+        "method": "POST",
+        "parameters": [],
+        "request_body": {"required": ["name"]},
+        "request_content_type": "application/json",
+        "responses": {
+            "201": {
+                "content": {
+                    "application/json": {
+                        "schema": {"type": "object", "properties": {"id": {"type": "integer"}}}
+                    }
+                }
+            },
+            "422": {},
+        },
+    }
+    # LLM 把 expected_status 写错成 400,规范化后应被覆盖成文档的 201
+    case = {"name": "注册", "case_type": "normal", "body": {"name": "x"}, "expected_status": 400}
+    out = _gen()._normalize_case(case, ep)
+
+    assert out["path"] == "/register"
+    assert out["method"] == "POST"
+    assert out["expected_status"] == 201
+    # 期望响应 schema 被注入
+    assert out["expected_schema"]["properties"]["id"]["type"] == "integer"
+    assert out["content_type"] == "application/json"
+
+
+def test_normalize_case_error_status():
+    ep = {
+        "path": "/x",
+        "method": "POST",
+        "parameters": [],
+        "request_body": {"required": ["name"]},
+        "request_content_type": "application/json",
+        "responses": {"201": {}, "422": {}},
+    }
+    case = {"name": "缺字段", "case_type": "error", "body": {}, "expected_status": 500}
+    out = _gen()._normalize_case(case, ep)
+    assert out["expected_status"] == 422
+
+
+def test_normalize_case_invalid_case_type_falls_back():
+    ep = {
+        "path": "/x", "method": "GET", "parameters": [], "request_body": None,
+        "responses": {"200": {}},
+    }
+    case = {"name": "x", "case_type": "whatever", "body": "not dict"}
+    out = _gen()._normalize_case(case, ep)
+    assert out["case_type"] == "normal"
+    # body 非 dict 被兜底成空 dict,避免后续 multipart 拆分报错
+    assert out["body"] == {}
+
+
+# --- _build_files ---
+
+def test_build_files_extracts_file_field():
+    ep = {
+        "request_body": {
+            "type": "object",
+            "required": ["file"],
+            "properties": {"file": {"type": "string", "contentMediaType": "application/octet-stream"}},
+        }
+    }
+    body = {"file": "test_image.jpg"}  # LLM 给的任意文件名
+    files = _gen()._build_files(ep, body)
+    assert files is not None
+    assert "file" in files
+    filename, content, mime = files["file"]
+    # 占位内容固定是 PNG,文件名强制 .png,避免扩展名和 MIME 不一致导致 415
+    assert filename == "file.png"
+    assert isinstance(content, bytes) and content  # 非空字节
+    assert mime == "image/png"
+    # 文件字段从 body 里弹出,body 不再含它
+    assert "file" not in body
+
+
+def test_build_files_no_file_field_returns_none():
+    ep = {
+        "request_body": {
+            "type": "object",
+            "properties": {"name": {"type": "string"}},
+        }
+    }
+    body = {"name": "x"}
+    assert _gen()._build_files(ep, body) is None
+    assert body == {"name": "x"}

+ 103 - 0
Co-creation-projects/senming666-api_test_assistant/tests/test_http_client.py

@@ -0,0 +1,103 @@
+"""HttpClient 工具层的单元测试"""
+from unittest.mock import Mock, patch
+
+import pytest
+
+from src.tools.http_client import HttpClient
+
+
+# --- _parse_body ---
+
+def test_parse_body_json():
+    resp = Mock()
+    resp.json.return_value = {"a": 1}
+    assert HttpClient()._parse_body(resp) == {"a": 1}
+
+
+def test_parse_body_fallback_to_text():
+    resp = Mock()
+    resp.json.side_effect = ValueError("not json")
+    resp.text = "plain text"
+    assert HttpClient()._parse_body(resp) == "plain text"
+
+
+# --- _error_result ---
+
+def test_error_result_shape():
+    r = HttpClient()._error_result("boom")
+    assert r == {"success": False, "status_code": None, "body": None, "elapsed": 0.0, "error": "boom"}
+
+
+# --- request ---
+
+def test_request_unsupported_method():
+    r = HttpClient().request("TRACE", "http://x")
+    assert r["success"] is False
+    assert "不支持" in r["error"]
+
+
+def test_request_success():
+    resp = Mock()
+    resp.status_code = 200
+    resp.json.return_value = {"ok": True}
+
+    with patch("requests.request", return_value=resp):
+        r = HttpClient(max_retries=0).request("GET", "http://x")
+
+    assert r["success"] is True
+    assert r["status_code"] == 200
+    assert r["body"] == {"ok": True}
+
+
+def test_request_multipart_uses_files_not_json():
+    resp = Mock()
+    resp.status_code = 201
+    resp.json.return_value = {"id": "img1"}
+
+    files = {"file": ("a.png", b"pngbytes", "image/png")}
+    with patch("requests.request", return_value=resp) as mock_req:
+        r = HttpClient(max_retries=0).request(
+            "POST", "http://x", body={"desc": "hi"}, files=files
+        )
+
+    assert r["success"] is True
+    # multipart 场景:走 data + files,而不是 json=
+    kwargs = mock_req.call_args.kwargs
+    assert "json" not in kwargs
+    assert kwargs["files"] == files
+    assert kwargs["data"] == {"desc": "hi"}
+
+
+def test_request_multipart_no_file_uses_form_data():
+    resp = Mock()
+    resp.status_code = 200
+    resp.json.return_value = {}
+
+    with patch("requests.request", return_value=resp) as mock_req:
+        HttpClient(max_retries=0).request(
+            "POST", "http://x", body={"k": "v"}, content_type="multipart/form-data"
+        )
+
+    kwargs = mock_req.call_args.kwargs
+    assert "json" not in kwargs
+    assert kwargs["data"] == {"k": "v"}
+
+
+def test_request_retry_then_success(monkeypatch):
+    import requests.exceptions
+
+    # 去掉重试之间的 1 秒 sleep,加快测试
+    monkeypatch.setattr("src.tools.http_client.time.sleep", lambda s: None)
+
+    resp = Mock()
+    resp.status_code = 200
+    resp.json.return_value = {}
+
+    with patch(
+        "requests.request",
+        side_effect=[requests.exceptions.ConnectionError("网络断开"), resp],
+    ):
+        r = HttpClient(max_retries=1).request("GET", "http://x")
+
+    assert r["success"] is True
+    assert r["status_code"] == 200

+ 280 - 0
Co-creation-projects/senming666-api_test_assistant/tests/test_parser_agent.py

@@ -0,0 +1,280 @@
+"""ParserAgent 解析 OpenAPI 文档的单元测试"""
+import json
+from pathlib import Path
+
+from src.agents.parser_agent import ParserAgent
+
+
+def _parser():
+    return ParserAgent()
+
+
+# --- parse_text:空输入 / JSON / YAML / 非 dict ---
+
+def test_parse_text_empty():
+    assert _parser().parse_text("") == []
+    assert _parser().parse_text("   ") == []
+
+
+def test_parse_text_json():
+    doc = '{"openapi": "3.0.0", "paths": {"/users": {"get": {"summary": "list", "responses": {"200": {"description": "ok"}}}}}}'
+    endpoints = _parser().parse_text(doc)
+    assert len(endpoints) == 1
+    assert endpoints[0]["path"] == "/users"
+    assert endpoints[0]["method"] == "GET"
+
+
+def test_parse_text_yaml():
+    doc = """
+openapi: 3.0.0
+paths:
+  /users:
+    get:
+      summary: list users
+      responses:
+        '200':
+          description: ok
+"""
+    endpoints = _parser().parse_text(doc)
+    assert len(endpoints) == 1
+    assert endpoints[0]["method"] == "GET"
+
+
+def test_parse_text_non_dict():
+    # "null" 会被解析成 None,应返回空列表而不是崩溃
+    assert _parser().parse_text("null") == []
+
+
+# --- extract_endpoints:类型容错 + requestBody ---
+
+def test_extract_endpoints_non_dict_input():
+    p = _parser()
+    assert p.extract_endpoints(None) == []
+    assert p.extract_endpoints("not a dict") == []
+    assert p.extract_endpoints([]) == []
+
+
+def test_extract_endpoints_request_body_and_content_type():
+    doc = {
+        "paths": {
+            "/create": {
+                "post": {
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "type": "object",
+                                    "required": ["name"],
+                                    "properties": {"name": {"type": "string"}},
+                                }
+                            }
+                        }
+                    },
+                    "responses": {"200": {}},
+                }
+            }
+        }
+    }
+    endpoints = _parser().extract_endpoints(doc)
+    assert len(endpoints) == 1
+    ep = endpoints[0]
+    assert ep["method"] == "POST"
+    # 请求体被解析成真正的 schema,而不是原始 requestBody 对象
+    assert ep["request_body"] == {
+        "type": "object",
+        "required": ["name"],
+        "properties": {"name": {"type": "string"}},
+    }
+    assert ep["request_content_type"] == "application/json"
+
+
+# --- $ref 展开 ---
+
+def _ref_doc():
+    """一个带 components/schemas 和 $ref 的最小文档"""
+    return {
+        "components": {
+            "schemas": {
+                "User": {
+                    "type": "object",
+                    "required": ["username", "password"],
+                    "properties": {
+                        "username": {"type": "string"},
+                        "password": {"type": "string"},
+                    },
+                },
+                "Nested": {
+                    "type": "object",
+                    "properties": {"user": {"$ref": "#/components/schemas/User"}},
+                },
+            }
+        },
+        "paths": {
+            "/register": {
+                "post": {
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {"$ref": "#/components/schemas/User"},
+                            }
+                        }
+                    },
+                    "responses": {
+                        "201": {
+                            "content": {
+                                "application/json": {
+                                    "schema": {"$ref": "#/components/schemas/Nested"},
+                                }
+                            }
+                        }
+                    },
+                }
+            }
+        },
+    }
+
+
+def test_request_body_ref_resolved():
+    endpoints = _parser().extract_endpoints(_ref_doc())
+    ep = endpoints[0]
+    # $ref 被展开,字段直接可见
+    assert ep["request_body"]["required"] == ["username", "password"]
+    assert "username" in ep["request_body"]["properties"]
+
+
+def test_response_schema_ref_resolved():
+    endpoints = _parser().extract_endpoints(_ref_doc())
+    ep = endpoints[0]
+    # 主成功响应(201)的 schema 被展开,且嵌套 $ref 也递归展开
+    assert ep["response_schema"]["properties"]["user"]["type"] == "object"
+    assert "username" in ep["response_schema"]["properties"]["user"]["properties"]
+
+
+def test_get_response_schema_by_status():
+    endpoints = _parser().extract_endpoints(_ref_doc())
+    ep = endpoints[0]
+    assert _parser().get_response_schema(ep, 201)["properties"]["user"]["type"] == "object"
+    # 没有声明的状态码返回 None
+    assert _parser().get_response_schema(ep, 404) is None
+
+
+# --- get_expected_status ---
+
+def test_expected_status_normal_prefers_2xx():
+    ep = {"responses": {"200": {}, "400": {}}}
+    assert _parser().get_expected_status(ep, "normal") == 200
+
+
+def test_expected_status_boundary_prefers_2xx():
+    ep = {"responses": {"201": {}, "422": {}}}
+    assert _parser().get_expected_status(ep, "boundary") == 201
+
+
+def test_expected_status_error_prefers_4xx():
+    ep = {"responses": {"200": {}, "400": {}}}
+    assert _parser().get_expected_status(ep, "error") == 400
+
+
+def test_expected_status_error_with_body_validation():
+    # 有必填请求体字段 → 缺字段 → 校验失败 422
+    ep = {
+        "responses": {"201": {}, "422": {}},
+        "request_body": {"required": ["name"], "properties": {"name": {"type": "string"}}},
+        "parameters": [],
+    }
+    assert _parser().get_expected_status(ep, "error") == 422
+
+
+def test_expected_status_error_with_only_path_param():
+    # 只有必填路径参数 → 缺路径参数 → 路由不匹配 404(而非 422)
+    ep = {
+        "responses": {"200": {}, "422": {}},
+        "request_body": None,
+        "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "string"}}],
+    }
+    assert _parser().get_expected_status(ep, "error") == 404
+
+
+def test_expected_status_default_200_when_no_responses():
+    assert _parser().get_expected_status({"responses": {}}, "normal") == 200
+
+
+# --- 结构判断辅助 ---
+
+def test_has_testable_inputs():
+    # 没参数没请求体 → 无可测输入
+    assert _parser().has_testable_inputs({"parameters": [], "request_body": None}) is False
+    # 有请求体 → 可测
+    assert _parser().has_testable_inputs({"parameters": [], "request_body": {"type": "object"}}) is True
+    # 有 query 参数 → 可测
+    assert _parser().has_testable_inputs(
+        {"parameters": [{"in": "query", "name": "limit"}], "request_body": None}
+    ) is True
+
+
+def test_has_validation_input():
+    assert _parser().has_validation_input(
+        {"parameters": [], "request_body": {"required": ["name"]}}
+    ) is True
+    assert _parser().has_validation_input({"parameters": [], "request_body": None}) is False
+
+
+def test_has_required_path_param():
+    assert _parser().has_required_path_param(
+        {"parameters": [{"in": "path", "name": "id", "required": True}]}
+    ) is True
+    assert _parser().has_required_path_param(
+        {"parameters": [{"in": "query", "name": "limit"}]}
+    ) is False
+
+
+# --- parse_file ---
+
+def test_parse_file(tmp_path):
+    f = tmp_path / "api.yaml"
+    f.write_text(
+        "openapi: 3.0.0\npaths:\n  /a:\n    get:\n      responses:\n        '200': {}\n",
+        encoding="utf-8",
+    )
+    endpoints = _parser().parse_file(str(f))
+    assert len(endpoints) == 1
+    assert endpoints[0]["path"] == "/a"
+
+
+# --- 真实 Chat openapi.json 集成解析 ---
+
+def _chat_endpoints():
+    fixture = Path(__file__).parent / "fixtures" / "chat_openapi.json"
+    doc = json.loads(fixture.read_text(encoding="utf-8"))
+    return _parser().extract_endpoints(doc)
+
+
+def test_chat_register_body_ref_resolved():
+    ep = next(e for e in _chat_endpoints() if e["path"] == "/api/auth/register")
+    # 注册请求体应能看到真实字段,而不是 $ref 字符串
+    assert ep["request_body"]["required"] == [
+        "username", "display_name", "password", "confirm_password"
+    ]
+    assert set(ep["request_body"]["properties"]) == {
+        "username", "display_name", "password", "confirm_password"
+    }
+
+
+def test_chat_login_response_schema_resolved():
+    ep = next(e for e in _chat_endpoints() if e["path"] == "/api/auth/login")
+    # 登录成功响应的 schema 展开后应包含 access_token 字段
+    assert "access_token" in ep["response_schema"]["properties"]
+
+
+def test_chat_health_has_no_testable_inputs():
+    ep = next(e for e in _chat_endpoints() if e["path"] == "/api/health")
+    assert _parser().has_testable_inputs(ep) is False
+    assert _parser().has_validation_input(ep) is False
+    assert _parser().has_required_path_param(ep) is False
+
+
+def test_chat_files_images_is_multipart():
+    ep = next(e for e in _chat_endpoints() if e["path"] == "/api/files/images")
+    assert ep["request_content_type"] == "multipart/form-data"
+    # 文件字段用 contentMediaType 标识
+    assert "contentMediaType" in ep["request_body"]["properties"]["file"]

+ 60 - 0
Co-creation-projects/senming666-api_test_assistant/tests/test_reporter_agent.py

@@ -0,0 +1,60 @@
+"""ReporterAgent 统计与报告生成的单元测试"""
+from src.agents.reporter_agent import ReporterAgent
+
+
+def _make_result(passed, name="用例", case_type="normal", status_code=200):
+    return {
+        "case": {"name": name, "case_type": case_type, "expected_status": 200},
+        "result": {"success": True, "status_code": status_code, "body": None, "elapsed": 0.1, "error": None},
+        "passed": passed,
+        "errors": [] if passed else ["状态码不符:期望 200,实际 %s" % status_code],
+    }
+
+
+# --- summarize ---
+
+def test_summarize_basic():
+    results = [_make_result(True), _make_result(True), _make_result(False)]
+    s = ReporterAgent().summarize(results)
+    assert s == {"total": 3, "passed": 2, "failed": 1, "pass_rate": 66.7}
+
+
+def test_summarize_empty():
+    s = ReporterAgent().summarize([])
+    assert s == {"total": 0, "passed": 0, "failed": 0, "pass_rate": 0}
+
+
+# --- generate_markdown ---
+
+def test_generate_markdown_summary():
+    md = ReporterAgent().generate_markdown([_make_result(True), _make_result(False)])
+    assert "通过率:**50.0%**" in md
+    assert "| 用例名称 | 类型 | 状态码 |" in md
+
+
+def test_generate_markdown_failed_request_no_none():
+    # 请求失败时 status_code 是 None,报告里应显示 "-" 而不是 "None"
+    results = [
+        {
+            "case": {"name": "失败用例", "case_type": "error"},
+            "result": {"success": False, "status_code": None, "body": None, "elapsed": 0.0, "error": "请求超时"},
+            "passed": False,
+            "errors": ["请求失败:请求超时"],
+        }
+    ]
+    md = ReporterAgent().generate_markdown(results)
+    assert "None" not in md
+    assert "| 失败用例 | error | - |" in md
+
+
+# --- generate_html ---
+
+def test_generate_html_basic():
+    html = ReporterAgent().generate_html([_make_result(True)])
+    assert "API 测试报告" in html
+    assert "100.0%" in html
+
+
+def test_generate_html_no_crash_on_empty():
+    html = ReporterAgent().generate_html([])
+    assert "API 测试报告" in html

+ 68 - 0
Co-creation-projects/senming666-api_test_assistant/tests/test_schema_validator.py

@@ -0,0 +1,68 @@
+"""SchemaValidator 工具层的单元测试"""
+from src.tools.schema_validator import SchemaValidator
+
+
+def _validator():
+    return SchemaValidator()
+
+
+def _result(success=True, status_code=200, body=None, error=None):
+    return {
+        "success": success,
+        "status_code": status_code,
+        "body": body,
+        "elapsed": 0.1,
+        "error": error,
+    }
+
+
+# --- 状态码校验 ---
+
+def test_status_code_match():
+    ok, msg = _validator().validate_status_code(200, 200)
+    assert ok is True and msg is None
+
+
+def test_status_code_mismatch():
+    ok, msg = _validator().validate_status_code(200, 400)
+    assert ok is False and "400" in msg
+
+
+# --- 响应体结构校验 ---
+
+def test_body_no_schema_skips():
+    ok, msg = _validator().validate_body({"a": 1}, None)
+    assert ok is True
+
+
+def test_body_matches_schema():
+    schema = {"type": "object", "properties": {"id": {"type": "integer"}}, "required": ["id"]}
+    ok, msg = _validator().validate_body({"id": 1}, schema)
+    assert ok is True
+
+
+def test_body_mismatches_schema():
+    schema = {"type": "object", "properties": {"id": {"type": "integer"}}, "required": ["id"]}
+    ok, msg = _validator().validate_body({"name": "x"}, schema)
+    assert ok is False and "id" in msg
+
+
+# --- 综合校验 ---
+
+def test_validate_request_failed():
+    check = _validator().validate(_result(success=False, error="请求超时"), expected_status=200)
+    assert check["success"] is False
+    assert any("请求超时" in e for e in check["errors"])
+
+
+def test_validate_all_pass():
+    check = _validator().validate(_result(status_code=200, body={"id": 1}), expected_status=200)
+    assert check["success"] is True and check["errors"] == []
+
+
+def test_validate_status_and_schema_both_fail():
+    schema = {"type": "object", "required": ["id"]}
+    check = _validator().validate(
+        _result(status_code=500, body={}), expected_status=200, expected_schema=schema
+    )
+    assert check["success"] is False and len(check["errors"]) == 2

+ 22 - 0
Co-creation-projects/senming666-api_test_assistant/tests/test_server.py

@@ -0,0 +1,22 @@
+"""server.py HTTP 响应边界的回归测试"""
+import json
+
+from server import _json_safe
+
+
+def test_json_safe_replaces_binary_multipart_content():
+    result = {
+        "files": {
+            "file": ("file.png", b"\x89PNG\x00\xff", "image/png"),
+        },
+    }
+
+    safe = _json_safe(result)
+
+    assert safe["files"]["file"] == [
+        "file.png",
+        "<binary data: 6 bytes>",
+        "image/png",
+    ]
+    # 关键回归断言:处理后的结果可以被 JSON 序列化。
+    json.dumps(safe)

+ 45 - 0
Co-creation-projects/senming666-api_test_assistant/tests/test_validator_agent.py

@@ -0,0 +1,45 @@
+"""ValidatorAgent 包装验证结论的单元测试"""
+from src.agents.validator_agent import ValidatorAgent
+
+
+def _result(status_code):
+    return {
+        "success": True,
+        "status_code": status_code,
+        "body": None,
+        "elapsed": 0.1,
+        "error": None,
+    }
+
+
+def test_validate_one_pass():
+    item = {"case": {"expected_status": 200}, "result": _result(200)}
+    out = ValidatorAgent().validate_one(item)
+    assert out["passed"] is True
+    assert out["errors"] == []
+
+
+def test_validate_one_fail():
+    item = {"case": {"expected_status": 200}, "result": _result(404)}
+    out = ValidatorAgent().validate_one(item)
+    assert out["passed"] is False
+    assert len(out["errors"]) == 1
+    assert "404" in out["errors"][0]
+
+
+def test_validate_one_missing_expected_status():
+    # 用例没给 expected_status 时,应只做请求成功判断,不应崩溃
+    item = {"case": {}, "result": _result(200)}
+    out = ValidatorAgent().validate_one(item)
+    assert out["passed"] is True
+
+
+def test_validate_batch():
+    items = [
+        {"case": {"expected_status": 200}, "result": _result(200)},
+        {"case": {"expected_status": 200}, "result": _result(500)},
+    ]
+    out = ValidatorAgent().validate(items)
+    assert len(out) == 2
+    assert out[0]["passed"] is True
+    assert out[1]["passed"] is False

部分文件因为文件数量过多而无法显示