From 2c1f06c84c9798c8e50835a3ab2f7407cc44dbab Mon Sep 17 00:00:00 2001 From: handsomeAq Date: Tue, 4 Aug 2026 17:20:08 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=88=9D=E5=A7=8B=E5=8C=96-?= =?UTF-8?q?=E9=A6=96=E6=AC=A1=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 27 + .gitignore | 71 + A2A需求分析-网关-K8s全链路架构详细设计方案.md | 231 ++ README.md | 139 + _test_sse.py | 81 + backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/admin.py | 103 + backend/app/api/agent.py | 53 + backend/app/api/cli.py | 88 + backend/app/config.py | 52 + backend/app/constants.py | 38 + backend/app/main.py | 62 + backend/app/middleware.py | 33 + backend/app/models/__init__.py | 0 backend/app/models/schemas.py | 92 + backend/app/repository/__init__.py | 0 backend/app/repository/agent_repo.py | 146 + backend/app/repository/log_repo.py | 50 + backend/app/repository/redis_client.py | 14 + backend/app/repository/task_repo.py | 166 + backend/app/scheduler_loop.py | 70 + backend/app/services/__init__.py | 0 backend/app/services/agent_service.py | 72 + backend/app/services/heartbeat.py | 24 + backend/app/services/lock.py | 35 + backend/app/services/notifier.py | 78 + backend/app/services/relay.py | 101 + backend/app/services/scheduler.py | 76 + backend/app/services/security.py | 10 + backend/app/services/task_service.py | 106 + backend/app/services/timeout.py | 19 + backend/tests/__init__.py | 0 backend/tests/conftest.py | 25 + backend/tests/test_agent_repo.py | 55 + backend/tests/test_api.py | 90 + backend/tests/test_scheduler.py | 67 + backend/tests/test_task_repo.py | 51 + docker-compose.yml | 15 + frontend/index.html | 12 + frontend/package-lock.json | 2751 +++++++++++++++++ frontend/package.json | 27 + frontend/postcss.config.js | 6 + frontend/src/App.vue | 160 + frontend/src/api/index.ts | 78 + frontend/src/env.d.ts | 7 + frontend/src/main.ts | 15 + frontend/src/router/index.ts | 18 + frontend/src/style.css | 93 + frontend/src/views/AgentView.vue | 92 + frontend/src/views/ControlView.vue | 115 + frontend/src/views/LogView.vue | 71 + frontend/src/views/TaskView.vue | 175 ++ frontend/tailwind.config.js | 8 + frontend/tsconfig.json | 20 + frontend/vite.config.ts | 16 + requirements.txt | 12 + 网关架构流程图.md | 236 ++ 58 files changed, 6252 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 A2A需求分析-网关-K8s全链路架构详细设计方案.md create mode 100644 README.md create mode 100644 _test_sse.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/admin.py create mode 100644 backend/app/api/agent.py create mode 100644 backend/app/api/cli.py create mode 100644 backend/app/config.py create mode 100644 backend/app/constants.py create mode 100644 backend/app/main.py create mode 100644 backend/app/middleware.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/schemas.py create mode 100644 backend/app/repository/__init__.py create mode 100644 backend/app/repository/agent_repo.py create mode 100644 backend/app/repository/log_repo.py create mode 100644 backend/app/repository/redis_client.py create mode 100644 backend/app/repository/task_repo.py create mode 100644 backend/app/scheduler_loop.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/agent_service.py create mode 100644 backend/app/services/heartbeat.py create mode 100644 backend/app/services/lock.py create mode 100644 backend/app/services/notifier.py create mode 100644 backend/app/services/relay.py create mode 100644 backend/app/services/scheduler.py create mode 100644 backend/app/services/security.py create mode 100644 backend/app/services/task_service.py create mode 100644 backend/app/services/timeout.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_agent_repo.py create mode 100644 backend/tests/test_api.py create mode 100644 backend/tests/test_scheduler.py create mode 100644 backend/tests/test_task_repo.py create mode 100644 docker-compose.yml create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/index.ts create mode 100644 frontend/src/env.d.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/style.css create mode 100644 frontend/src/views/AgentView.vue create mode 100644 frontend/src/views/ControlView.vue create mode 100644 frontend/src/views/LogView.vue create mode 100644 frontend/src/views/TaskView.vue create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 requirements.txt create mode 100644 网关架构流程图.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b25108c --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Redis 连接 +REDIS_URL=redis://localhost:6379/0 + +# 网关服务 +HOST=0.0.0.0 +PORT=8000 + +# 后端运行目录(用于前端代理,预留) +BACKEND_ROOT=backend + +# 心跳保活参数(秒) +HEARTBEAT_SCAN_INTERVAL=60 +AGENT_HEARTBEAT_TIMEOUT=120 +AGENT_HEARTBEAT_EXPECTED=10 + +# 调度参数(秒) +DISPATCH_INTERVAL=2 +TASK_TIMEOUT_CHECK_INTERVAL=5 + +# 任务默认超时(秒) +DEFAULT_TASK_TIMEOUT=3600 + +# 任务数据保留 TTL(秒),默认 24 小时 +TASK_TTL=86400 + +# 认证:CLI 提交任务 / Agent 注册时必须携带的密码 +GATEWAY_AUTH=change-me \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d3fd80e --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +# ===== Environment / Secrets ===== +# 环境变量(含密钥,切勿提交) +.env +.env.* +!.env.example + +# ===== Python (backend) ===== +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg-info/ +.eggs/ +build/ +dist/ +htmlcov/ +.venv/ +venv/ +env/ +ENV/ +.python-version +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.coverage +.tox/ +*.egg + +# ===== Node (frontend) ===== +node_modules/ +frontend/node_modules/ +frontend/dist/ +*.tsbuildinfo +frontend/tsconfig.tsbuildinfo + +# ===== Logs ===== +*.log +logs/ +*.log.* +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# ===== 运行时 / 生成文件 ===== +gateway_restart.log +gateway_restart_err.log +frontend/frontend.err.log +frontend/frontend.log +frontend/vite.log +backend/backend.err.log +backend/backend.log +backend/gateway_restart.err.log +backend/gateway_restart.log + +# ===== IDE / 编辑器 ===== +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# ===== 项目工具数据 ===== +.codebuddy/ + +# ===== 构建产物与压缩包 ===== +*.tar.gz +*.zip +*.tgz \ No newline at end of file diff --git a/A2A需求分析-网关-K8s全链路架构详细设计方案.md b/A2A需求分析-网关-K8s全链路架构详细设计方案.md new file mode 100644 index 0000000..7b94e93 --- /dev/null +++ b/A2A需求分析-网关-K8s全链路架构详细设计方案.md @@ -0,0 +1,231 @@ +# A2A需求分析\-网关\-K8s全链路架构详细设计方案 + +## 一、架构整体概述 + +### 1\.1 核心架构分层 + +整体采用**三层解耦架构:需求分析层 → 智能网关层 → K8s Agent算力层**,完全贴合你的设计思路,兼顾本地IDE/CLI异步任务、云端Agent弹性调度、全链路任务管控。 + +- **需求分析层(前端调度端)**:CLI终端、VSCode IDE、Claude Code/CodeBuddy等AI终端,负责自然语言需求解析、复杂任务拆解、本地轻量任务异步执行、结构化任务上报网关。 + +- **智能网关层(核心中枢)**:基于FastAPI\+ElementUI\+Redis搭建,核心能力为任务池管理、Agent池管理、心跳保活、任务\-Agent规则匹配、全链路通信中转、任务生命周期管控、可视化运维。 + +- **K8s算力层(执行端)**:全权负责Agent容器的生命周期管理(创建、扩容、销毁、重启),Agent启动/销毁主动通知网关,实现集群弹性伸缩。 + +### 1\.2 核心设计理念 + +- 分层解耦:需求拆解、任务调度、算力执行完全隔离,支持独立迭代扩容; + +- 异步协同:本地CLI可自主处理轻量编程任务,复杂任务上云调度K8s Agent,轻重任务分流; + +- 动态感知:网关实时维护在线Agent池,通过定时心跳剔除异常节点,适配K8s动态扩缩容; + +- 精准匹配:基于自定义规则实现任务与Agent能力精准绑定,支持负载均衡、优先级调度; + +- 统一通信:网关作为唯一通信中枢,承接CLI与云端Agent的双向交互,通过AgentID精准关联任务链路。 + +## 二、各层级详细能力设计 + +### 2\.1 需求分析层(CLI/IDE/AI终端) + +#### 2\.1\.1 核心角色 + +包含自研CLI、VSCode插件、Claude Code、CodeBuddy等所有前端需求入口,作为**顶层任务调度者**,具备LLM自主拆解能力。 + +#### 2\.1\.2 核心能力 + +1. **需求智能拆解**:接收用户自然语言需求,自主拆分出可独立执行的原子任务,区分轻量本地任务、重型云端任务; + +2. **本地异步执行**:简单代码补全、语法校验、本地文件修改等轻量任务,由CLI/IDE本地异步处理,无需上云; + +3. **云端任务上报**:编译构建、批量测试、数据分析、流媒体处理等复杂任务,结构化封装后提交至网关; + +4. **结果接收与聚合**:接收网关转发的Agent执行结果,LLM聚合输出最终答案,反馈给用户; + +5. **全链路追踪**:每个任务生成全局唯一RequestID,贯穿网关、Agent全链路,用于结果精准匹配、日志溯源。 + +#### 2\.1\.3 任务上报协议 + +采用标准HTTP JSON协议适配所有AI终端工具调用规范,请求体核心字段:RequestID、任务类型、任务标签、任务载荷、超时时间、本地工作目录信息。 + +### 2\.2 智能网关层(核心中枢) + +技术栈:**FastAPI(高性能接口服务)\+ ElementUI(可视化后台)\+ Redis(内存数据存储)**,无状态服务设计,支持水平扩容,核心维护两大核心资源池:Task任务池、Agent算力池。 + +#### 2\.2\.1 核心模块拆分 + +- **API接入模块**:接收CLI/IDE任务提交、Agent注册/注销、心跳上报、任务结果回传; + +- **任务池管理模块**:缓存所有待执行/执行中/已完成任务,维护任务全生命周期状态; + +- **Agent池管理模块**:维护所有K8s在线Agent信息,基于定时心跳保活、剔除异常节点; + +- **规则调度模块**:实现任务与Agent的标签匹配、负载均衡、优先级调度; + +- **通信中转模块**:基于AgentID关联CLI与Agent,双向转发任务指令、执行日志、最终结果; + +- **可视化运维模块**:ElementUI实现任务列表、Agent在线状态、执行日志、异常告警可视化展示。 + +#### 2\.2\.2 Task任务池设计(Redis存储) + +网关接收CLI任务后,立即写入Redis任务池,同步返回「任务受理成功」响应,不阻塞CLI,实现异步调度。 + +**任务数据结构**:RequestID、TaskType、TaskTags、Payload、Status(pending/running/success/failed)、AgentID、CliSessionID、CreateTime、Timeout、Progress、Result、ErrorInfo。 + +**任务状态流转**:待受理 → 待调度 → 执行中 → 执行成功/执行失败 → 任务归档(TTL自动清理)。 + +#### 2\.2\.3 Agent算力池设计(Redis存储) + +全权管理K8s集群所有Agent实例,适配K8s动态生命周期,实现动态注册、失效剔除。 + +**Agent数据结构**:AgentID、Endpoint(K8s内部访问地址)、AgentTags(能力标签)、MaxConcurrent(最大并发)、CurrentLoad(当前负载)、LastHeartbeat(最后心跳时间)、Status(online/offline)、CreateTime。 + +#### 2\.2\.4 心跳保活机制(核心设计) + +严格遵循你的设计:**网关定时每分钟轮询维持心跳**,补充工程化容错逻辑: + +1. 网关后台启动定时任务,每60s扫描一次Redis Agent池; + +2. 判定规则:连续2个周期(120s)未上报心跳 → 标记Agent为offline,从可用算力池剔除; + +3. 被动更新:Agent启动主动注册、销毁主动注销,实时更新Agent池状态,优先覆盖定时轮询结果; + +4. 负载同步:心跳同步上报当前任务并发数,为调度算法提供依据。 + +#### 2\.2\.5 任务\-Agent匹配调度规则 + +网关核心调度逻辑,实现精准任务分发,优先级从高到低: + +1. **标签精准匹配**:根据任务TaskTags筛选具备对应能力标签的Agent(如代码编译、日志分析、流媒体处理); + +2. **负载过滤**:剔除当前负载已满(CurrentLoad ≥ MaxConcurrent)的Agent; + +3. **最优算力选择**:优先选择负载最低、在线时间最稳定的Agent; + +4. **任务绑定**:匹配成功后,将AgentID写入任务池对应任务,标记任务为running,下发任务至目标Agent。 + +#### 2\.2\.6 双向通信机制(核心闭环) + +完全贴合你的设计:**网关作为唯一中转,携带AgentID完成CLI与Agent双向通信** + +1. 正向链路(CLI → Agent):CLI提交任务 → 网关入库匹配Agent → 携带AgentID下发任务指令; + +2. 反向链路(Agent → CLI):Agent执行完成/产生日志 → 结果回传网关 → 网关通过RequestID\+AgentID关联对应CLI会话 → 精准返回结果; + +3. 链路隔离:多任务、多Agent、多CLI会话通过RequestID\+AgentID双维度隔离,杜绝消息错乱。 + +### 2\.3 K8s算力层 + +#### 2\.3\.1 核心职责 + +专注Agent容器生命周期管理,不参与业务调度,职责单一:Agent镜像部署、弹性扩缩容、故障重启、资源配额管控、节点销毁。 + +#### 2\.3\.2 Agent生命周期联动网关机制 + +1. **Agent创建启动**:Pod启动后自动读取K8s环境变量(网关地址、Pod信息),主动调用网关`/agent/register`接口完成注册,上报能力标签、并发上限、访问地址;网关更新Agent池,纳入算力调度。 + +2. **Agent运行中**:每10s主动上报心跳,同步当前负载,网关定时轮询兜底校验; + +3. **Agent销毁/缩容**:Pod收到SIGTERM信号后,主动调用网关`/agent/unregister`接口优雅注销;网关立即剔除该Agent,终止其未完成任务,避免任务调度失效; + +4. **Agent异常崩溃**:无主动注销时,依赖网关120s心跳超时机制自动剔除,实现故障自愈。 + +## 三、全链路完整执行流程 + +整合所有模块,端到端闭环流程如下: + +1. **需求拆解上报**:用户在CLI/IDE输入需求,AI终端自主拆解任务,轻量任务本地异步执行,复杂任务结构化后提交网关; + +2. **网关任务受理**:网关接收任务,生成/复用RequestID,写入Redis任务池,立即返回受理响应,避免CLI阻塞; + +3. **算力匹配调度**:网关遍历在线Agent池,通过标签\+负载规则匹配最优Agent,绑定AgentID,下发任务; + +4. **Agent执行任务**:K8s Agent接收任务,开始执行,定时上报执行进度、日志至网关; + +5. **结果回传中转**:Agent执行完成后,将结果/错误信息回传网关;网关通过AgentID\+RequestID匹配原CLI会话; + +6. **结果聚合反馈**:网关推送结果至CLI,AI终端聚合结果、整理答案,最终反馈给用户; + +7. **状态归档清理**:网关更新任务为成功/失败状态,留存日志,到期自动清理冗余数据。 + +## 四、核心数据存储设计(Redis) + +### 4\.1 任务池存储结构 + +- Hash结构:`task:info:{RequestID}`存储任务全量信息; + +- Set结构:`task:pending` 待调度任务集合、`task:running` 执行中任务集合; + +- TTL策略:任务数据默认保留24小时,自动过期清理,释放资源。 + +### 4\.2 Agent池存储结构 + +- Hash结构:`agent:info:{AgentID}` 存储Agent全量信息; + +- ZSet结构:`agent:heartbeat` score=最后心跳时间,用于网关定时清理失效节点; + +- Set索引:`agent:tag:{tag}` 按能力标签索引Agent,加速任务匹配效率。 + +## 五、容错与异常处理机制 + +### 5\.1 任务异常容错 + +- 任务超时:网关自定义任务超时时间,超时自动标记失败,释放Agent算力,反馈CLI; + +- Agent离线:执行中Agent失联,网关立即终止任务,标记异常,支持CLI重提任务; + +- 幂等性:基于RequestID实现任务幂等,避免CLI重试导致重复调度。 + +### 5\.2 Agent容错 + +- 心跳兜底:主动心跳\+网关定时轮询双机制,杜绝僵尸节点; + +- 重启自愈:K8s负责Agent故障重启,重启后自动重新注册,纳入算力池。 + +### 5\.3 通信容错 + +- 断连不丢任务:任务数据持久化Redis,网关重启、网络波动不丢失任务状态; + +- 精准重连匹配:重连后通过RequestID\+AgentID恢复任务链路,继续推送结果。 + +## 六、可视化运维能力(ElementUI) + +基于ElementUI搭建后台,提供全维度可视化管控: + +- 任务管理:查看所有任务状态、进度、关联AgentID、执行日志、结果详情; + +- Agent管理:在线Agent列表、能力标签、实时负载、心跳状态、异常节点告警; + +- 日志审计:全链路任务操作日志、Agent注册注销日志、通信记录溯源; + +- 手动管控:支持手动取消任务、下线异常Agent、重置任务状态。 + +## 七、架构核心优势 + +1. **极简解耦**:三层架构职责清晰,K8s只管生命周期,网关只管调度通信,CLI只管需求拆解; + +2. **弹性适配**:完美适配K8s扩缩容,Agent动态上下线自动感知,无需人工干预; + +3. **高低速任务分流**:本地轻量异步处理,云端重型算力调度,资源利用率最大化; + +4. **精准链路管控**:AgentID\+RequestID双维度绑定,通信不错乱、任务可溯源; + +5. **轻量易落地**:FastAPI\+Redis技术栈轻量化,部署简单、开发成本低,配套可视化运维; + +6. **可扩展性强**:后续可扩展多租户、权限管控、负载均衡优化、大文件中转、本地文件远程读写等能力。 + +## 八、落地实施步骤 + +1. 搭建网关基础服务:FastAPI接口、Redis数据结构、ElementUI可视化基础页面; + +2. 实现Agent注册、注销、心跳上报、网关定时轮询保活能力; + +3. 开发任务池管理、任务\-Agent匹配调度核心逻辑; + +4. 打通CLI任务上报、网关调度、Agent执行、结果回传全链路; + +5. 完善异常容错、日志审计、可视化运维功能; + +6. 压测优化,适配K8s大规模Agent集群调度。 + +> (注:部分内容可能由 AI 生成) diff --git a/README.md b/README.md new file mode 100644 index 0000000..6aed276 --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +# A2A 智能网关(gateway) + +基于设计文档《A2A需求分析-网关-K8s全链路架构详细设计方案》实现的智能网关核心服务。 + +本期范围:**网关核心 + 可视化运维后台**。Agent/CLI 为预留接口,K8s 算力层不实际部署。 + +## 技术栈 + +- 后端:Python + FastAPI + Uvicorn + redis-py(asyncio) +- 存储:Redis(任务池 / Agent 池 / 调度索引 / 心跳 ZSet / 审计日志) +- 前端:Vue 3 + Vite + Element Plus(完整运维后台) +- 后台任务:asyncio 循环(心跳扫描 / 调度 / 超时检查),配 Redis 分布式锁防多实例重复执行 + +## 目录结构 + +``` +gateway/ +├── docker-compose.yml # 本地一键启动 Redis +├── requirements.txt # Python 依赖 +├── .env.example # 环境变量示例 +├── backend/ +│ ├── app/ +│ │ ├── main.py # FastAPI 入口 +│ │ ├── config.py # 配置 +│ │ ├── constants.py # Redis 键名/TTL/状态常量 +│ │ ├── models/schemas.py # Pydantic 模型 +│ │ ├── repository/ # Redis 存储层(task/agent/log) +│ │ ├── services/ # 业务服务(任务/Agent/调度/心跳/通信/超时/锁) +│ │ ├── api/ # 路由(cli/agent/admin) +│ │ ├── middleware.py # 日志中间件 +│ │ └── scheduler_loop.py # 后台任务循环 +│ └── tests/ # 单元与集成测试 +└── frontend/ # Vue3 + Element Plus 运维后台 +``` + +## 快速启动 + +### 1. 启动 Redis + +```bash +docker compose up -d +``` + +无 Docker 时需本机提供 Redis 实例,并设置 `REDIS_URL`。 + +### 2. 启动后端 + +```bash +cd backend +pip install -r ../requirements.txt +# 复制 .env.example 为 .env 并按需修改 +uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +接口文档(Swagger):http://localhost:8000/docs + +### 3. 启动前端运维后台 + +```bash +cd frontend +# 使用 Node >= 18(推荐 18/20/22) +npm install +npm run dev +``` + +访问 http://localhost:5173 ,Vite 已将 `/api` 代理到后端 `:8000`。 + +### 4. 运行后端测试 + +```bash +cd backend +python -m pytest -q +``` + +## 核心机制 + +### 任务池(Redis) + +- `task:info:{request_id}`:任务全量信息(Hash) +- `task:pending` / `task:running`:调度状态索引(Set) +- 状态流转:pending → running → success/failed +- TTL 默认 24h 自动归档;RequestID 幂等去重 + +### Agent 池(Redis) + +- `agent:info:{agent_id}`:Agent 信息(Hash) +- `agent:heartbeat`:心跳时间戳(ZSet,score=最后心跳) +- `agent:tag:{tag}`:能力标签索引(Set) +- 心跳保活:网关每 60s 扫描,连续 120s 未心跳标记 offline + +### 规则调度 + +标签精准匹配 → 负载过滤(当前负载 < 并发上限)→ 最低负载(停留时间久者优先)→ 绑定 Agent 并下发。 + +### 认证 + +CLI 提交任务与 Agent 注册时,请求体必须携带 `auth` 字段,值须与网关配置的 `GATEWAY_AUTH`(`.env` 中设置)一致,否则返回 `401`。示例: + +```bash +curl -X POST http://localhost:8000/api/cli/tasks \ + -H "Content-Type: application/json" \ + -d '{"auth": "", "task_type": "compile", "task_tags": ["build"], "payload": {"cmd": "python -m build"}}' +``` + +### 预留 Agent 协议接口 + +> 所有接口中 `register` 必须携带 `auth` 字段,其余接口以已注册的 `agent_id` 关联身份。 + +| 接口 | 说明 | +| --- | --- | +| `POST /api/agent/register` | Agent 启动注册(`auth` + 能力标签、并发上限、地址) | +| `POST /api/agent/unregister` | 优雅注销 | +| `POST /api/agent/heartbeat` | 心跳(约每 10s,同步负载) | +| `POST /api/agent/result` | 任务结果回传 | + +### 通信中转 + +以 `RequestID + AgentID` 双维度关联 CLI 会话与 Agent,正向下发任务指令、反向回传进度与结果。本期为状态机闭环 + 日志记录,实际网络下发由 Agent 接入时扩展。 + +## 运维后台 + +- 任务管理:列表 / 筛选 / 详情 / 进度 / 取消 +- Agent 管理:卡片网格 / 标签 / 负载 / 心跳 / 离线高亮 +- 日志审计:全链路时间线,按 RequestID / AgentID 筛选 +- 手动管控:取消任务 / 重置任务 / 下线 Agent + +## 环境变量 + +见 `.env.example`,关键参数: + +| 变量 | 默认 | 说明 | +| --- | --- | --- | +| `REDIS_URL` | `redis://localhost:6379/0` | Redis 连接 | +| `HEARTBEAT_SCAN_INTERVAL` | `60` | 心跳扫描间隔(秒) | +| `AGENT_HEARTBEAT_TIMEOUT` | `120` | 心跳超时剔除阈值(秒) | +| `DISPATCH_INTERVAL` | `2` | 调度循环间隔(秒) | +| `TASK_TIMEOUT_CHECK_INTERVAL` | `5` | 任务超时检查间隔(秒) | +| `DEFAULT_TASK_TIMEOUT` | `3600` | 任务默认超时(秒) | +| `TASK_TTL` | `86400` | 任务数据保留 TTL(秒) | \ No newline at end of file diff --git a/_test_sse.py b/_test_sse.py new file mode 100644 index 0000000..a59973b --- /dev/null +++ b/_test_sse.py @@ -0,0 +1,81 @@ +"""端到端验证:SSE 订阅 + 任务提交,确认网关主动推送 task_done。""" +import asyncio +import json +import uuid + +import httpx + +BASE = "http://127.0.0.1:8000" +AUTH = "gw_Hz8Qp3Km9f" +SESSION = f"e2e-{uuid.uuid4().hex[:8]}" + + +async def main() -> None: + async with httpx.AsyncClient(timeout=30) as client: + # 1) 提交任务(携带 cli_session_id),任务类型用 agent 已注册的能力 + resp = await client.post( + f"{BASE}/api/cli/tasks", + json={ + "auth": AUTH, + "task_type": "dev", + "task_tags": ["code", "dev"], + "description": "SSE 通知验证:创建临时文件", + "cli_session_id": SESSION, + "payload": { + "type": "create_files", + "files": [{"path": "_sse_probe.txt", "content": "hello-sse"}], + }, + "timeout": 120, + }, + ) + print("submit status:", resp.status_code) + task = resp.json() + request_id = task["request_id"] + print("request_id:", request_id, "initial status:", task["status"]) + + # 2) 订阅 SSE,等待 task_done + got = [] + + async def subscribe(): + try: + async with client.stream( + "GET", + f"{BASE}/api/cli/events", + params={"cli_session_id": SESSION, "auth": AUTH}, + ) as stream: + async for line in stream.aiter_lines(): + if line.startswith("data: "): + evt = json.loads(line[6:]) + got.append(evt) + print("SSE EVENT:", json.dumps(evt, ensure_ascii=False)) + if evt.get("request_id") == request_id: + return + except Exception as exc: # noqa: BLE001 + print("SSE error:", exc) + + # 3) 并行:订阅 + 轮询兜底打印(仅观察,不作为结论) + await asyncio.gather( + subscribe(), + _poll_until_done(client, request_id), + ) + if any(e.get("request_id") == request_id for e in got): + print("\nRESULT: PASS - SSE task_done received without polling") + else: + print("\nRESULT: FAIL - no SSE event for this request_id") + + +async def _poll_until_done(client, request_id: str) -> None: + for _ in range(60): + r = await client.get(f"{BASE}/api/cli/tasks/{request_id}") + if r.status_code == 404: + await asyncio.sleep(2) + continue + t = r.json() + print("poll status:", t["status"]) + if t["status"] in ("success", "failed"): + return + await asyncio.sleep(2) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 0000000..bc823d8 --- /dev/null +++ b/backend/app/api/admin.py @@ -0,0 +1,103 @@ +"""后台管理接口:任务/Agent/日志/手动管控。""" +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Request + +from app.constants import TaskStatus +from app.models.schemas import AgentInfo, LogEntry, TaskInfo +from app.repository.log_repo import LogRepo +from app.services.agent_service import AgentService +from app.services.task_service import TaskService + +router = APIRouter() + + +def get_task_service(request: Request) -> TaskService: + return TaskService(request.app.state.redis) + + +def get_agent_service(request: Request) -> AgentService: + return AgentService(request.app.state.redis) + + +def get_log_repo(request: Request) -> LogRepo: + return LogRepo(request.app.state.redis) + + +# ---------- 任务管理 ---------- +@router.get("/tasks", response_model=list[TaskInfo], summary="任务列表") +async def list_tasks( + status: Optional[TaskStatus] = None, + limit: int = 100, + svc: TaskService = Depends(get_task_service), +): + return await svc.list(status=status, limit=limit) + + +@router.get("/tasks/{request_id}", response_model=TaskInfo, summary="任务详情") +async def task_detail(request_id: str, svc: TaskService = Depends(get_task_service)): + task = await svc.get(request_id) + if not task: + raise HTTPException(status_code=404, detail="task not found") + return task + + +@router.post("/tasks/{request_id}/cancel", summary="取消任务") +async def admin_cancel(request_id: str, svc: TaskService = Depends(get_task_service)): + if not await svc.cancel(request_id): + raise HTTPException(status_code=400, detail="cannot cancel task") + return {"ok": True} + + +@router.post("/tasks/{request_id}/reset", summary="重置任务状态") +async def admin_reset(request_id: str, svc: TaskService = Depends(get_task_service)): + if not await svc.reset(request_id): + raise HTTPException(status_code=400, detail="cannot reset task") + return {"ok": True} + + +# ---------- Agent 管理 ---------- +@router.get("/agents", response_model=list[AgentInfo], summary="Agent 列表") +async def list_agents(svc: AgentService = Depends(get_agent_service)): + return await svc.all() + + +@router.post("/agents/{agent_id}/offline", summary="下线 Agent") +async def agent_offline(agent_id: str, svc: AgentService = Depends(get_agent_service)): + if not await svc.take_offline(agent_id): + raise HTTPException(status_code=404, detail="agent not found") + return {"ok": True} + + +# ---------- 日志审计 ---------- +@router.get("/logs", response_model=list[LogEntry], summary="日志审计") +async def logs( + limit: int = 200, + request_id: Optional[str] = None, + agent_id: Optional[str] = None, + repo: LogRepo = Depends(get_log_repo), +): + return await repo.list(limit=limit, request_id=request_id, agent_id=agent_id) + + +# ---------- 概览 ---------- +@router.get("/overview", summary="概览统计") +async def overview( + task_svc: TaskService = Depends(get_task_service), + agent_svc: AgentService = Depends(get_agent_service), +): + tasks = await task_svc.list(limit=1000) + agents = await agent_svc.all() + from collections import Counter + + status_count = Counter(t.status.value for t in tasks) + return { + "task_total": len(tasks), + "task_pending": status_count.get(TaskStatus.PENDING.value, 0), + "task_running": status_count.get(TaskStatus.RUNNING.value, 0), + "task_success": status_count.get(TaskStatus.SUCCESS.value, 0), + "task_failed": status_count.get(TaskStatus.FAILED.value, 0), + "agent_total": len(agents), + "agent_online": sum(1 for a in agents if a.status.value == "online"), + "agent_offline": sum(1 for a in agents if a.status.value == "offline"), + } \ No newline at end of file diff --git a/backend/app/api/agent.py b/backend/app/api/agent.py new file mode 100644 index 0000000..b0d6be7 --- /dev/null +++ b/backend/app/api/agent.py @@ -0,0 +1,53 @@ +"""预留 Agent 协议接口:register / unregister / heartbeat / result。""" +from fastapi import APIRouter, Depends, Request + +from app.models.schemas import AgentHeartbeat, AgentInfo, AgentRegister, TaskResult +from app.services.agent_service import AgentService +from app.services.relay import RelayService +from app.services.security import require_auth + +router = APIRouter() + + +def get_agent_service(request: Request) -> AgentService: + return AgentService(request.app.state.redis) + + +def get_relay_service(request: Request) -> RelayService: + return RelayService(request.app.state.redis) + + +@router.post("/register", response_model=AgentInfo, summary="Agent 注册") +async def register(body: AgentRegister, svc: AgentService = Depends(get_agent_service)): + require_auth(body.auth) + return await svc.register(body) + + +@router.post("/unregister", summary="Agent 注销") +async def unregister(body: dict, svc: AgentService = Depends(get_agent_service)): + from fastapi import HTTPException + + agent_id = body.get("agent_id") + if not agent_id: + raise HTTPException(status_code=400, detail="agent_id required") + if not await svc.unregister(agent_id): + raise HTTPException(status_code=404, detail="agent not found") + return {"ok": True} + + +@router.post("/heartbeat", summary="Agent 心跳") +async def heartbeat(body: AgentHeartbeat, svc: AgentService = Depends(get_agent_service)): + from fastapi import HTTPException + + if not await svc.heartbeat(body): + raise HTTPException(status_code=404, detail="agent not registered") + return {"ok": True} + + +@router.post("/result", summary="Agent 结果回传") +async def result(body: TaskResult, relay: RelayService = Depends(get_relay_service)): + from fastapi import HTTPException + + if not await relay.on_result(body): + raise HTTPException(status_code=400, detail="result rejected") + return {"ok": True} \ No newline at end of file diff --git a/backend/app/api/cli.py b/backend/app/api/cli.py new file mode 100644 index 0000000..969fa5c --- /dev/null +++ b/backend/app/api/cli.py @@ -0,0 +1,88 @@ +"""CLI 接口:任务提交/查询/取消/完成事件订阅。""" +from fastapi import APIRouter, Depends, Request +from fastapi.responses import StreamingResponse + +from app.models.schemas import TaskInfo, TaskSubmit +from app.services.notifier import TaskNotifier +from app.services.security import require_auth +from app.services.task_service import TaskService + +router = APIRouter() + + +def get_task_service(request: Request) -> TaskService: + return TaskService(request.app.state.redis) + + +def get_notifier(request: Request) -> TaskNotifier: + return TaskNotifier(request.app.state.redis) + + +@router.get("/events", summary="订阅任务完成事件(SSE 长连接)") +async def subscribe_events( + request: Request, + cli_session_id: str, + auth: str = "", + notifier: TaskNotifier = Depends(get_notifier), +): + """任务到达终态时网关主动推送完成事件,无需轮询。 + + 返回 SSE 流(text/event-stream),每帧形如: + data: {"event": "task_done", "request_id": "...", "status": "success", "result": {...}} + """ + require_auth(auth) + + channel = notifier.channel(cli_session_id) + pubsub = request.app.state.redis.pubsub() + await pubsub.subscribe(channel) + replay = await notifier.replay(cli_session_id) + + async def event_gen(): + try: + # 先回放已完成的(连接晚于任务完成的场景) + for evt in replay: + yield f"data: {evt}\n\n" + # 再实时监听 + async for message in pubsub.listen(): + if await request.is_disconnected(): + break + if message["type"] != "message": + continue + data = message["data"] + if isinstance(data, bytes): + data = data.decode() + yield f"data: {data}\n\n" + finally: + await pubsub.unsubscribe(channel) + await pubsub.aclose() + + return StreamingResponse( + event_gen(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@router.post("/tasks", response_model=TaskInfo, summary="提交任务") +async def submit_task(body: TaskSubmit, svc: TaskService = Depends(get_task_service)): + require_auth(body.auth) + return await svc.submit(body) + + +@router.get("/tasks/{request_id}", response_model=TaskInfo, summary="查询任务") +async def get_task(request_id: str, svc: TaskService = Depends(get_task_service)): + from fastapi import HTTPException + + task = await svc.get(request_id) + if not task: + raise HTTPException(status_code=404, detail="task not found") + return task + + +@router.post("/tasks/{request_id}/cancel", summary="取消任务") +async def cancel_task(request_id: str, svc: TaskService = Depends(get_task_service)): + from fastapi import HTTPException + + if not await svc.cancel(request_id): + raise HTTPException(status_code=400, detail="cannot cancel task") + return {"ok": True, "request_id": request_id} \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..033ce07 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,52 @@ +"""应用配置:通过环境变量 / .env 读取。""" +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + +# 项目根目录(backend/ 的上一级) +PROJECT_ROOT = Path(__file__).resolve().parents[2] + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=str(PROJECT_ROOT / ".env"), + env_file_encoding="utf-8", + extra="ignore", + ) + + # Redis + redis_url: str = "redis://localhost:6379/0" + + # 网关服务 + host: str = "0.0.0.0" + port: int = 8000 + + # 心跳保活参数(秒) + heartbeat_scan_interval: int = 60 + agent_heartbeat_timeout: int = 120 + agent_heartbeat_expected: int = 10 + + # 调度参数(秒) + dispatch_interval: int = 2 + task_timeout_check_interval: int = 5 + + # 任务默认超时(秒) + default_task_timeout: int = 3600 + + # 任务数据保留 TTL(秒) + task_ttl: int = 86400 + + # 日志级别 + log_level: str = "INFO" + + # 认证:CLI 提交任务 / Agent 注册时必须携带的密码 + gateway_auth: str = "dev-gateway-auth" + + # 分布式锁相关 + lock_timeout: int = 30 + + # 最大并发调度轮次 + dispatch_batch_size: int = 20 + + +settings = Settings() \ No newline at end of file diff --git a/backend/app/constants.py b/backend/app/constants.py new file mode 100644 index 0000000..2a3fc8c --- /dev/null +++ b/backend/app/constants.py @@ -0,0 +1,38 @@ +"""Redis 键名与 TTL 常量、状态枚举。""" +from enum import Enum + + +class TaskStatus(str, Enum): + PENDING = "pending" + RUNNING = "running" + SUCCESS = "success" + FAILED = "failed" + + +class AgentStatus(str, Enum): + ONLINE = "online" + OFFLINE = "offline" + + +# ---- 任务池键 ---- +# 任务全量信息:hash task:info:{request_id} +TASK_INFO_KEY = "task:info:{request_id}" +# 待调度任务集合 +TASK_PENDING_SET = "task:pending" +# 执行中任务集合 +TASK_RUNNING_SET = "task:running" + +# ---- Agent 池键 ---- +# Agent 全量信息:hash agent:info:{agent_id} +AGENT_INFO_KEY = "agent:info:{agent_id}" +# 心跳时间戳 ZSet:score = 最后心跳时间 +AGENT_HEARTBEAT_ZSET = "agent:heartbeat" +# 按能力标签索引 Agent:set agent:tag:{tag} +AGENT_TAG_KEY = "agent:tag:{tag}" +# 全部在线 Agent 索引 +AGENT_ALL_SET = "agent:all" + +# ---- 分布式锁键 ---- +LOCK_SCHEDULER = "lock:scheduler" +LOCK_HEARTBEAT = "lock:heartbeat" +LOCK_TIMEOUT = "lock:timeout" \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..a2e91f7 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,62 @@ +"""FastAPI 应用入口:注册路由、CORS、启动后台任务循环。""" +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import settings +from app.api import admin, agent, cli +from app.middleware import RequestLogMiddleware +from app.scheduler_loop import SchedulerLoop +from app.repository.redis_client import get_redis_pool + +logging.basicConfig( + level=getattr(logging, settings.log_level.upper(), logging.INFO), + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", +) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # 启动时会话 + redis = await get_redis_pool() + loop = SchedulerLoop(redis) + app.state.redis = redis + app.state.scheduler_loop = loop + await loop.start() + logger.info("Gateway started, host=%s port=%s", settings.host, settings.port) + try: + yield + finally: + await loop.stop() + await redis.aclose() + logger.info("Gateway stopped") + + +app = FastAPI( + title="A2A Gateway", + description="A2A 智能网关:任务池 / Agent 池 / 规则调度 / 通信中转 / 可视化运维", + version="1.0.0", + lifespan=lifespan, +) + +# CORS:允许前端开发服务器访问 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +app.add_middleware(RequestLogMiddleware) + +app.include_router(cli.router, prefix="/api/cli", tags=["CLI"]) +app.include_router(agent.router, prefix="/api/agent", tags=["Agent"]) +app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) + + +@app.get("/health", tags=["health"]) +async def health(): + return {"status": "ok"} \ No newline at end of file diff --git a/backend/app/middleware.py b/backend/app/middleware.py new file mode 100644 index 0000000..c201e9d --- /dev/null +++ b/backend/app/middleware.py @@ -0,0 +1,33 @@ +"""日志中间件:记录 RequestID/AgentID 链路与访问日志,避免敏感载荷与日志刷屏。""" +import logging +import time + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request + +logger = logging.getLogger("access") + + +class RequestLogMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + start = time.perf_counter() + # 心跳类接口降级为 debug,避免刷屏 + is_heartbeat = request.url.path.endswith("/heartbeat") + if logger.isEnabledFor(logging.DEBUG) or not is_heartbeat: + logger.info( + "%s %s path=%s", + request.method, + request.client.host if request.client else "-", + request.url.path, + ) + response = await call_next(request) + cost_ms = (time.perf_counter() - start) * 1000 + if not is_heartbeat or logger.isEnabledFor(logging.DEBUG): + logger.info( + "%s %s -> %s (%dms)", + request.method, + request.url.path, + response.status_code, + round(cost_ms, 2), + ) + return response \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py new file mode 100644 index 0000000..d41ee31 --- /dev/null +++ b/backend/app/models/schemas.py @@ -0,0 +1,92 @@ +"""Pydantic 数据模型。""" +import time +import uuid +from typing import Any + +from pydantic import BaseModel, Field + +from app.constants import AgentStatus, TaskStatus + + +def new_request_id() -> str: + return uuid.uuid4().hex + + +# ---------- 任务 ---------- +class TaskSubmit(BaseModel): + """CLI 任务上报请求体。""" + + auth: str = Field(..., description="网关认证密码,需与 GATEWAY_AUTH 一致") + request_id: str | None = Field(default=None, description="幂等键,不传则自动生成") + task_type: str = Field(..., description="任务类型,如 compile/build/test") + task_tags: list[str] = Field(default_factory=list, description="任务能力标签") + description: str | None = Field(default=None, description="任务描述") + payload: dict[str, Any] = Field(default_factory=dict, description="任务载荷") + cli_session_id: str | None = Field(default=None, description="CLI 会话标识") + timeout: int = Field(default=0, description="超时秒数,0 使用默认值") + local_workdir: str | None = Field(default=None, description="本地工作目录信息") + + +class TaskInfo(BaseModel): + request_id: str + task_type: str + task_tags: list[str] = Field(default_factory=list) + description: str | None = None + payload: dict[str, Any] = Field(default_factory=dict) + status: TaskStatus = TaskStatus.PENDING + agent_id: str | None = None + cli_session_id: str | None = None + create_time: float = Field(default_factory=time.time) + timeout: int = 0 + progress: int = 0 + result: dict[str, Any] | None = None + error_info: str | None = None + + +# ---------- Agent ---------- +class AgentRegister(BaseModel): + """Agent 启动注册请求体。""" + + auth: str = Field(..., description="网关认证密码,需与 GATEWAY_AUTH 一致") + agent_id: str + endpoint: str + agent_tags: list[str] = Field(default_factory=list) + max_concurrent: int = Field(default=1, ge=1) + current_load: int = Field(default=0, ge=0) + + +class AgentHeartbeat(BaseModel): + agent_id: str + current_load: int = Field(default=0, ge=0) + + +class AgentInfo(BaseModel): + agent_id: str + endpoint: str + agent_tags: list[str] = Field(default_factory=list) + max_concurrent: int = 1 + current_load: int = 0 + last_heartbeat: float = Field(default_factory=time.time) + status: AgentStatus = AgentStatus.ONLINE + create_time: float = Field(default_factory=time.time) + + +# ---------- 结果回传 ---------- +class TaskResult(BaseModel): + request_id: str + agent_id: str + status: TaskStatus = TaskStatus.SUCCESS + progress: int = Field(default=100, ge=0, le=100) + result: dict[str, Any] | None = None + error_info: str | None = None + + +# ---------- 日志 ---------- +class LogEntry(BaseModel): + ts: float = Field(default_factory=time.time) + level: str = "info" + source: str = "gateway" # gateway / agent / cli / admin + scope: str = "task" # task / agent / system + request_id: str | None = None + agent_id: str | None = None + message: str \ No newline at end of file diff --git a/backend/app/repository/__init__.py b/backend/app/repository/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/repository/agent_repo.py b/backend/app/repository/agent_repo.py new file mode 100644 index 0000000..4df38e4 --- /dev/null +++ b/backend/app/repository/agent_repo.py @@ -0,0 +1,146 @@ +"""Agent 池 Redis 存储层。""" +import time +from typing import Any + +import redis.asyncio as aioredis + +from app import constants as C +from app.constants import AgentStatus +from app.models.schemas import AgentInfo + + +class AgentRepo: + def __init__(self, redis: aioredis.Redis): + self.redis = redis + + @staticmethod + def _info_key(agent_id: str) -> str: + return C.AGENT_INFO_KEY.format(agent_id=agent_id) + + @staticmethod + def _tag_key(tag: str) -> str: + return C.AGENT_TAG_KEY.format(tag=tag) + + # ---------- 写入 ---------- + async def upsert(self, agent: AgentInfo, *, heartbeat: bool = False) -> bool: + """写入 Agent 信息,返回是否新建。非心跳时为全量注册。""" + key = self._info_key(agent.agent_id) + existed = await self.redis.exists(key) + if not existed: + await self.redis.sadd(C.AGENT_ALL_SET, agent.agent_id) + # 建立标签索引 + for tag in agent.agent_tags: + await self.redis.sadd(self._tag_key(tag), agent.agent_id) + mapping: dict[str, Any] = { + "agent_id": agent.agent_id, + "endpoint": agent.endpoint, + "agent_tags": ",".join(agent.agent_tags), + "max_concurrent": str(agent.max_concurrent), + "current_load": str(agent.current_load), + "last_heartbeat": str(agent.last_heartbeat), + "status": agent.status.value, + "create_time": str(agent.create_time), + } + await self.redis.hset(key, mapping=mapping) + # 心跳 ZSet 记录时间戳 + await self.redis.zadd(C.AGENT_HEARTBEAT_ZSET, {agent.agent_id: agent.last_heartbeat}) + return not existed + + async def update_status(self, agent_id: str, status: AgentStatus) -> bool: + key = self._info_key(agent_id) + if not await self.redis.exists(key): + return False + await self.redis.hset(key, "status", status.value) + if status == AgentStatus.OFFLINE: + await self.redis.zrem(C.AGENT_HEARTBEAT_ZSET, agent_id) + return True + + async def beat(self, agent_id: str, current_load: int) -> bool: + """更新心跳时间与负载。""" + key = self._info_key(agent_id) + if not await self.redis.exists(key): + return False + now = time.time() + await self.redis.hset( + key, + mapping={"last_heartbeat": str(now), "current_load": str(current_load), "status": AgentStatus.ONLINE.value}, + ) + await self.redis.zadd(C.AGENT_HEARTBEAT_ZSET, {agent_id: now}) + return True + + async def adjust_load(self, agent_id: str, delta: int) -> bool: + """原子调整 Agent 负载(delta 可为正/负),负载下限为 0。""" + key = self._info_key(agent_id) + if not await self.redis.exists(key): + return False + # hincrby 为原子增量操作,兼容 fakeredis(不支持 Lua eval) + new = await self.redis.hincrby(key, "current_load", delta) + if new < 0: + await self.redis.hset(key, "current_load", "0") + return True + + # ---------- 读取 ---------- + async def get(self, agent_id: str) -> AgentInfo | None: + raw = await self.redis.hgetall(self._info_key(agent_id)) + if not raw: + return None + return AgentInfo( + agent_id=raw.get("agent_id", ""), + endpoint=raw.get("endpoint", ""), + agent_tags=[t for t in raw.get("agent_tags", "").split(",") if t], + max_concurrent=int(raw.get("max_concurrent", 1) or 1), + current_load=int(raw.get("current_load", 0) or 0), + last_heartbeat=float(raw.get("last_heartbeat", 0) or 0), + status=AgentStatus(raw.get("status", AgentStatus.ONLINE.value)), + create_time=float(raw.get("create_time", 0) or 0), + ) + + async def online(self) -> list[AgentInfo]: + return [a for a in await self.all() if a.status == AgentStatus.ONLINE] + + async def all(self) -> list[AgentInfo]: + ids = list(await self.redis.smembers(C.AGENT_ALL_SET)) + out = [] + for aid in ids: + a = await self.get(aid) + if a: + out.append(a) + return out + + async def by_tags(self, tags: list[str]) -> list[AgentInfo]: + """按标签索引取 Agent 池(取所有标签的交集,无标签则返回全部在线)。""" + if not tags: + return await self.online() + keys = [self._tag_key(t) for t in tags] + if len(keys) == 1: + ids = list(await self.redis.smembers(keys[0])) + else: + await self.redis.sinterstore("agent:tmp:intersect", keys) + ids = list(await self.redis.smembers("agent:tmp:intersect")) + await self.redis.delete("agent:tmp:intersect") + out = [] + for aid in ids: + a = await self.get(aid) + if a and a.status == AgentStatus.ONLINE: + out.append(a) + return out + + async def remove(self, agent_id: str) -> None: + """注销:删除信息、心跳 ZSet、标签索引、全量索引。""" + a = await self.get(agent_id) + if a: + for tag in a.agent_tags: + await self.redis.srem(self._tag_key(tag), agent_id) + await self.redis.delete(self._info_key(agent_id)) + await self.redis.zrem(C.AGENT_HEARTBEAT_ZSET, agent_id) + await self.redis.srem(C.AGENT_ALL_SET, agent_id) + + async def stale_agents(self, timeout: float) -> list[str]: + """返回超过 timeout 秒未心跳的 Agent ID(基于 ZSet score)。""" + cutoff = time.time() - timeout + scored = await self.redis.zrangebyscore(C.AGENT_HEARTBEAT_ZSET, 0, cutoff) + return list(scored) + + async def heartbeat_map(self) -> dict[str, float]: + """返回 {agent_id: last_heartbeat}。""" + return {k: float(v) for k, v in await self.redis.zrange(C.AGENT_HEARTBEAT_ZSET, 0, -1, withscores=True)} \ No newline at end of file diff --git a/backend/app/repository/log_repo.py b/backend/app/repository/log_repo.py new file mode 100644 index 0000000..2b36d0b --- /dev/null +++ b/backend/app/repository/log_repo.py @@ -0,0 +1,50 @@ +"""日志审计存储层:全链路操作/注册注销/通信记录。""" +import time + +import redis.asyncio as aioredis + +from app.models.schemas import LogEntry + +LOG_KEY = "log:audit" +LOG_MAX = 2000 + + +class LogRepo: + def __init__(self, redis: aioredis.Redis): + self.redis = redis + + async def append( + self, + message: str, + *, + level: str = "info", + source: str = "gateway", + scope: str = "system", + request_id: str | None = None, + agent_id: str | None = None, + ) -> None: + entry = LogEntry( + level=level, + source=source, + scope=scope, + request_id=request_id, + agent_id=agent_id, + message=message, + ) + await self.redis.lpush(LOG_KEY, entry.model_dump_json()) + await self.redis.ltrim(LOG_KEY, 0, LOG_MAX - 1) + + async def list(self, limit: int = 200, request_id: str | None = None, agent_id: str | None = None) -> list[LogEntry]: + raw = await self.redis.lrange(LOG_KEY, 0, limit - 1) + out = [] + for item in raw: + try: + entry = LogEntry.model_validate_json(item) + except Exception: + continue + if request_id and entry.request_id != request_id: + continue + if agent_id and entry.agent_id != agent_id: + continue + out.append(entry) + return out \ No newline at end of file diff --git a/backend/app/repository/redis_client.py b/backend/app/repository/redis_client.py new file mode 100644 index 0000000..43e80a3 --- /dev/null +++ b/backend/app/repository/redis_client.py @@ -0,0 +1,14 @@ +"""Redis 连接池与共享访问封装。""" +import redis.asyncio as aioredis + +from app.config import settings + +_pool: aioredis.Redis | None = None + + +async def get_redis_pool() -> aioredis.Redis: + """返回全局共享的 Redis 客户端(懒初始化)。""" + global _pool + if _pool is None: + _pool = aioredis.from_url(settings.redis_url, decode_responses=True) + return _pool \ No newline at end of file diff --git a/backend/app/repository/task_repo.py b/backend/app/repository/task_repo.py new file mode 100644 index 0000000..ce9d304 --- /dev/null +++ b/backend/app/repository/task_repo.py @@ -0,0 +1,166 @@ +"""任务池 Redis 存储层。""" +import time +from typing import Any + +import redis.asyncio as aioredis + +from app import constants as C +from app.config import settings +from app.constants import TaskStatus +from app.models.schemas import TaskInfo + + +class TaskRepo: + def __init__(self, redis: aioredis.Redis): + self.redis = redis + + @staticmethod + def _info_key(request_id: str) -> str: + return C.TASK_INFO_KEY.format(request_id=request_id) + + # ---------- 写入 ---------- + async def create(self, task: TaskInfo) -> bool: + """录入任务(幂等),返回是否新建。""" + key = self._info_key(task.request_id) + existed = await self.redis.exists(key) + if existed: + return False + await self.redis.hset( + key, + mapping={ + "request_id": task.request_id, + "task_type": task.task_type, + "task_tags": ",".join(task.task_tags), + "description": task.description or "", + "payload": _json(task.payload), + "status": task.status.value, + "agent_id": task.agent_id or "", + "cli_session_id": task.cli_session_id or "", + "create_time": str(task.create_time), + "timeout": str(task.timeout), + "progress": str(task.progress), + "result": _json(task.result), + "error_info": task.error_info or "", + }, + ) + await self.redis.expire(key, settings.task_ttl) + await self.redis.sadd(C.TASK_PENDING_SET, task.request_id) + return True + + async def update( + self, + request_id: str, + *, + status: TaskStatus | None = None, + agent_id: str | None = None, + progress: int | None = None, + result: dict | None = None, + error_info: str | None = None, + ) -> bool: + key = self._info_key(request_id) + if not await self.redis.exists(key): + return False + mapping: dict[str, Any] = {} + if status is not None: + mapping["status"] = status.value + if agent_id is not None: + mapping["agent_id"] = agent_id + if progress is not None: + mapping["progress"] = str(progress) + if result is not None: + mapping["result"] = _json(result) + if error_info is not None: + mapping["error_info"] = error_info + if mapping: + await self.redis.hset(key, mapping=mapping) + return True + + # ---------- 状态集合维护 ---------- + async def mark_pending(self, request_id: str) -> None: + await self.redis.sadd(C.TASK_PENDING_SET, request_id) + await self.redis.srem(C.TASK_RUNNING_SET, request_id) + + async def mark_running(self, request_id: str) -> None: + await self.redis.sadd(C.TASK_RUNNING_SET, request_id) + await self.redis.srem(C.TASK_PENDING_SET, request_id) + + async def mark_finished(self, request_id: str) -> None: + await self.redis.srem(C.TASK_PENDING_SET, request_id) + await self.redis.srem(C.TASK_RUNNING_SET, request_id) + + async def pending_tasks(self) -> list[str]: + return list(await self.redis.smembers(C.TASK_PENDING_SET)) + + async def running_tasks(self) -> list[str]: + return list(await self.redis.smembers(C.TASK_RUNNING_SET)) + + async def remove_pending(self, request_id: str) -> None: + await self.redis.srem(C.TASK_PENDING_SET, request_id) + + # ---------- 读取 ---------- + async def get(self, request_id: str) -> TaskInfo | None: + raw = await self.redis.hgetall(self._info_key(request_id)) + if not raw: + return None + return await self._to_task(raw) + + async def _to_task(self, raw: dict) -> TaskInfo: + return TaskInfo( + request_id=raw.get("request_id", ""), + task_type=raw.get("task_type", ""), + task_tags=[t for t in raw.get("task_tags", "").split(",") if t], + description=raw.get("description") or None, + payload=_unjson(raw.get("payload")), + status=TaskStatus(raw.get("status", TaskStatus.PENDING.value)), + agent_id=raw.get("agent_id") or None, + cli_session_id=raw.get("cli_session_id") or None, + create_time=float(raw.get("create_time", 0) or 0), + timeout=int(raw.get("timeout", 0) or 0), + progress=int(raw.get("progress", 0) or 0), + result=_unjson(raw.get("result")), + error_info=raw.get("error_info") or None, + ) + + async def list(self, status: TaskStatus | None = None, limit: int = 100) -> list[TaskInfo]: + """按状态过滤返回任务列表(含终态任务,通过 scan 全量扫描 task:info:*)。""" + ids = await self.scan_ids() + out = [] + for rid in ids: + t = await self.get(rid) + if not t: + continue + if status is not None and t.status != status: + continue + out.append(t) + # 按创建时间倒序 + out.sort(key=lambda x: x.create_time, reverse=True) + return out[:limit] + + async def scan_ids(self) -> list[str]: + """扫描所有 task:info:* 键,返回 RequestID 列表。""" + prefix = C.TASK_INFO_KEY.replace("{request_id}", "") + ids = [] + async for key in self.redis.scan_iter(match=f"{prefix}*", count=1000): + ids.append(key[len(prefix):]) + return ids + + async def delete(self, request_id: str) -> None: + await self.redis.delete(self._info_key(request_id)) + await self.mark_finished(request_id) + + +def _json(obj: Any) -> str: + import json + + return json.dumps(obj, ensure_ascii=False) if obj is not None else "" + + +def _unjson(s: str | None) -> Any: + import json + + if not s: + return None + try: + return json.loads(s) + except json.JSONDecodeError: + return None \ No newline at end of file diff --git a/backend/app/scheduler_loop.py b/backend/app/scheduler_loop.py new file mode 100644 index 0000000..9dbf8e7 --- /dev/null +++ b/backend/app/scheduler_loop.py @@ -0,0 +1,70 @@ +"""后台任务循环启动器:心跳扫描 / 任务调度 / 任务超时,带分布式锁防多实例重复执行。""" +import asyncio +import logging + +import redis.asyncio as aioredis + +from app.config import settings +from app.constants import LOCK_HEARTBEAT, LOCK_SCHEDULER, LOCK_TIMEOUT +from app.services.heartbeat import HeartbeatService +from app.services.lock import Lock +from app.services.relay import RelayService +from app.services.scheduler import Scheduler +from app.services.timeout import TimeoutService + +logger = logging.getLogger(__name__) + + +class SchedulerLoop: + def __init__(self, redis: aioredis.Redis): + self.redis = redis + self._tasks: list[asyncio.Task] = [] + + async def start(self) -> None: + self._tasks.append(asyncio.create_task(self._heartbeat_loop())) + self._tasks.append(asyncio.create_task(self._dispatch_loop())) + self._tasks.append(asyncio.create_task(self._timeout_loop())) + logger.info("scheduler loops started") + + async def stop(self) -> None: + for t in self._tasks: + t.cancel() + for t in self._tasks: + try: + await t + except asyncio.CancelledError: + pass + self._tasks.clear() + + async def _heartbeat_loop(self) -> None: + hb = HeartbeatService(self.redis) + while True: + await asyncio.sleep(settings.heartbeat_scan_interval) + lock = Lock(self.redis, LOCK_HEARTBEAT) + if await lock.acquire(): + try: + await hb.scan() + finally: + await lock.release() + + async def _dispatch_loop(self) -> None: + sched = Scheduler(self.redis, relay=RelayService(self.redis)) + while True: + await asyncio.sleep(settings.dispatch_interval) + lock = Lock(self.redis, LOCK_SCHEDULER) + if await lock.acquire(): + try: + await sched.dispatch_pending(settings.dispatch_batch_size) + finally: + await lock.release() + + async def _timeout_loop(self) -> None: + to = TimeoutService(self.redis) + while True: + await asyncio.sleep(settings.task_timeout_check_interval) + lock = Lock(self.redis, LOCK_TIMEOUT) + if await lock.acquire(): + try: + await to.check() + finally: + await lock.release() \ No newline at end of file diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/agent_service.py b/backend/app/services/agent_service.py new file mode 100644 index 0000000..81d4783 --- /dev/null +++ b/backend/app/services/agent_service.py @@ -0,0 +1,72 @@ +"""Agent 池服务:注册/注销/心跳/负载/离线剔除。""" +import logging +import time + +import redis.asyncio as aioredis + +from app.constants import AgentStatus +from app.models.schemas import AgentHeartbeat, AgentInfo, AgentRegister +from app.repository.agent_repo import AgentRepo + +logger = logging.getLogger(__name__) + + +class AgentService: + def __init__(self, redis: aioredis.Redis, repo: AgentRepo | None = None): + self.redis = redis + self.repo = repo or AgentRepo(redis) + + async def register(self, reg: AgentRegister) -> AgentInfo: + agent = AgentInfo( + agent_id=reg.agent_id, + endpoint=reg.endpoint, + agent_tags=reg.agent_tags, + max_concurrent=reg.max_concurrent, + current_load=reg.current_load, + status=AgentStatus.ONLINE, + create_time=time.time(), + last_heartbeat=time.time(), + ) + created = await self.repo.upsert(agent) + logger.info("agent registered agent_id=%s created=%s", agent.agent_id, created) + return agent + + async def unregister(self, agent_id: str) -> bool: + existed = await self.repo.get(agent_id) + if not existed: + return False + await self.repo.remove(agent_id) + logger.info("agent unregistered agent_id=%s", agent_id) + return True + + async def heartbeat(self, hb: AgentHeartbeat) -> bool: + ok = await self.repo.beat(hb.agent_id, hb.current_load) + if not ok: + logger.warning("heartbeat from unknown agent agent_id=%s", hb.agent_id) + return ok + + async def online(self) -> list[AgentInfo]: + return await self.repo.online() + + async def all(self) -> list[AgentInfo]: + return await self.repo.all() + + async def get(self, agent_id: str) -> AgentInfo | None: + return await self.repo.get(agent_id) + + async def take_offline(self, agent_id: str) -> bool: + """手动下线 Agent。""" + a = await self.repo.get(agent_id) + if not a: + return False + await self.repo.update_status(agent_id, AgentStatus.OFFLINE) + logger.info("agent taken offline agent_id=%s", agent_id) + return True + + async def purge_stale(self, timeout: float) -> list[str]: + """剔除超时未心跳的 Agent。""" + stale = await self.repo.stale_agents(timeout) + for aid in stale: + await self.repo.update_status(aid, AgentStatus.OFFLINE) + logger.info("agent purged (stale) agent_id=%s", aid) + return stale \ No newline at end of file diff --git a/backend/app/services/heartbeat.py b/backend/app/services/heartbeat.py new file mode 100644 index 0000000..c43c091 --- /dev/null +++ b/backend/app/services/heartbeat.py @@ -0,0 +1,24 @@ +"""心跳保活服务:扫描 Agent 池,剔除超时未心跳节点。""" +import logging + +import redis.asyncio as aioredis + +from app.config import settings +from app.constants import AgentStatus +from app.repository.agent_repo import AgentRepo + +logger = logging.getLogger(__name__) + + +class HeartbeatService: + def __init__(self, redis: aioredis.Redis, agent_repo: AgentRepo | None = None): + self.redis = redis + self.agent_repo = agent_repo or AgentRepo(redis) + + async def scan(self) -> int: + """扫描并剔除超时 Agent,返回剔除数量。""" + stale = await self.agent_repo.stale_agents(settings.agent_heartbeat_timeout) + for aid in stale: + await self.agent_repo.update_status(aid, AgentStatus.OFFLINE) + logger.info("heartbeat: agent offline (stale) agent_id=%s", aid) + return len(stale) \ No newline at end of file diff --git a/backend/app/services/lock.py b/backend/app/services/lock.py new file mode 100644 index 0000000..0605b3b --- /dev/null +++ b/backend/app/services/lock.py @@ -0,0 +1,35 @@ +"""Redis 分布式锁(SET NX EX)。""" +import secrets +import time + +import redis.asyncio as aioredis + +from app.config import settings + + +class Lock: + def __init__(self, redis: aioredis.Redis, name: str, timeout: int = settings.lock_timeout): + self.redis = redis + self.name = name + self.timeout = timeout + self._token = secrets.token_hex(8) + + async def acquire(self) -> bool: + ok = await self.redis.set(self.name, self._token, nx=True, ex=self.timeout) + return bool(ok) + + async def release(self) -> None: + # 仅在持有相同 token 时释放,避免误删他人锁 + val = await self.redis.get(self.name) + if val == self._token: + await self.redis.delete(self.name) + + async def guarded(self, fn): + """上下文封装:获取锁执行,释放锁。""" + if not await self.acquire(): + return False + try: + await fn() + return True + finally: + await self.release() \ No newline at end of file diff --git a/backend/app/services/notifier.py b/backend/app/services/notifier.py new file mode 100644 index 0000000..b676e38 --- /dev/null +++ b/backend/app/services/notifier.py @@ -0,0 +1,78 @@ +"""任务完成事件通知服务。 + +设计:CLI 提交任务时可携带 cli_session_id,并通过 SSE 长连接订阅 +GET /api/cli/events?cli_session_id=xxx 事件流。任务到达终态 +(success / failed,含取消、超时)时,网关通过 Redis Pub/Sub 向 +对应会话推送事件,避免 CLI 轮询。 + +为保证订阅晚于任务完成也不丢事件,publish 时同时写入一份 +回放缓存(Redis List,保留最近 N 条),SSE 连接建立后先回放再实时推送。 +""" +import json +import logging +from typing import Any + +import redis.asyncio as aioredis + +from app.models.schemas import TaskInfo + +logger = logging.getLogger(__name__) + +CHANNEL_PREFIX = "task:event:cli:" # Redis Pub/Sub channel +REPLAY_KEY_PREFIX = "task:event:replay:" # 回放缓存 List key +REPLAY_TTL = 600 +REPLAY_MAX = 200 + + +class TaskNotifier: + def __init__(self, redis: aioredis.Redis): + self.redis = redis + + @staticmethod + def channel(cli_session_id: str) -> str: + return f"{CHANNEL_PREFIX}{cli_session_id}" + + @staticmethod + def _replay_key(cli_session_id: str) -> str: + return f"{REPLAY_KEY_PREFIX}{cli_session_id}" + + # ---------- 发布 ---------- + async def publish_task_done(self, task: TaskInfo) -> None: + """任务到达终态时调用:写回放缓存并广播到该 CLI 会话。""" + if not task.cli_session_id: + return + payload = json.dumps( + { + "event": "task_done", + "request_id": task.request_id, + "status": task.status.value, + "result": task.result, + "error_info": task.error_info, + }, + ensure_ascii=False, + ) + key = self._replay_key(task.cli_session_id) + channel = self.channel(task.cli_session_id) + await self.redis.lpush(key, payload) + await self.redis.ltrim(key, 0, REPLAY_MAX - 1) + await self.redis.expire(key, REPLAY_TTL) + await self.redis.publish(channel, payload) + logger.info("notify task done session=%s request=%s status=%s", + task.cli_session_id, task.request_id, task.status.value) + + # ---------- 消费 ---------- + async def replay(self, cli_session_id: str) -> list[str]: + """返回该会话的历史完成事件(新 → 旧),供 SSE 连接回放。""" + items = await self.redis.lrange(self._replay_key(cli_session_id), 0, -1) + return [s.decode() if isinstance(s, bytes) else s for s in items] + + async def drain_replay(self, cli_session_id: str) -> None: + """SSE 连接回放结束后清空回放缓存(已消费)。""" + await self.redis.delete(self._replay_key(cli_session_id)) + + +def format_sse(data: str | dict[str, Any]) -> str: + """格式化为 SSE 帧。""" + if isinstance(data, dict): + data = json.dumps(data, ensure_ascii=False) + return f"data: {data}\n\n" diff --git a/backend/app/services/relay.py b/backend/app/services/relay.py new file mode 100644 index 0000000..23bcf0a --- /dev/null +++ b/backend/app/services/relay.py @@ -0,0 +1,101 @@ +"""通信中转服务:正向下发任务指令、反向回传结果/日志,RequestID+AgentID 关联。""" +import logging + +import httpx +import redis.asyncio as aioredis + +from app.config import settings +from app.constants import TaskStatus +from app.models.schemas import TaskResult +from app.repository.agent_repo import AgentRepo +from app.repository.task_repo import TaskRepo +from app.services.notifier import TaskNotifier + +logger = logging.getLogger(__name__) + + +class RelayService: + """网关作为唯一通信中枢:正向把任务指令推送到 Agent 端点,反向接收结果回传。""" + + def __init__(self, redis: aioredis.Redis, task_repo: TaskRepo | None = None): + self.redis = redis + self.task_repo = task_repo or TaskRepo(redis) + self.agent_repo = AgentRepo(redis) + + async def dispatch_command(self, agent_id: str, request_id: str, payload: dict) -> bool: + """正向:向 Agent 真实 HTTP 推送任务指令(POST {agent.endpoint}/tasks/{request_id})。 + + 成功返回 True;Agent 不存在 / 端点缺失 / 网络失败 / 非 202 均返回 False, + 由调度器回退任务状态并释放负载。 + """ + agent = await self.agent_repo.get(agent_id) + if not agent: + logger.warning("relay dispatch failed: agent not found agent=%s request=%s", agent_id, request_id) + return False + if not agent.endpoint: + logger.warning("relay dispatch failed: endpoint empty agent=%s request=%s", agent_id, request_id) + return False + url = f"{agent.endpoint.rstrip('/')}/tasks/{request_id}" + body = { + "auth": settings.gateway_auth, + "request_id": request_id, + "payload": payload, + } + try: + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.post(url, json=body) + except httpx.HTTPError as e: + logger.error("relay dispatch network error agent=%s request=%s err=%s", agent_id, request_id, e) + await self._log(agent_id, request_id, "dispatch", f"push failed: {e}") + return False + if resp.status_code != 202: + logger.warning("relay dispatch http %s agent=%s request=%s body=%s", + resp.status_code, agent_id, request_id, resp.text[:200]) + await self._log(agent_id, request_id, "dispatch", f"push failed http {resp.status_code}") + return False + await self._log(agent_id, request_id, "dispatch", f"command pushed to {agent.endpoint}") + logger.info("relay dispatch ok agent=%s request=%s url=%s", agent_id, request_id, url) + return True + + async def on_result(self, result: TaskResult) -> bool: + """反向:Agent 回传结果,更新任务状态并记录日志。""" + task = await self.task_repo.get(result.request_id) + if not task: + logger.warning("result for unknown task request=%s", result.request_id) + return False + if task.agent_id and task.agent_id != result.agent_id: + logger.warning("result agent mismatch request=%s expected=%s got=%s", + result.request_id, task.agent_id, result.agent_id) + return False + await self.task_repo.update( + result.request_id, + status=result.status, + progress=result.progress, + result=result.result, + error_info=result.error_info, + ) + await self.task_repo.mark_finished(result.request_id) + # 释放 Agent 算力(任务确实绑定在该 Agent 时才释放) + if task.agent_id == result.agent_id: + await self.agent_repo.adjust_load(result.agent_id, -1) + await self._log(result.agent_id, result.request_id, "result", f"result received status={result.status.value}") + # 任务到达终态,通知对应 CLI 会话(若携带了 cli_session_id) + if task.cli_session_id: + finished = await self.task_repo.get(result.request_id) + if finished: + await TaskNotifier(self.redis).publish_task_done(finished) + logger.info("task result request=%s agent=%s status=%s", result.request_id, result.agent_id, result.status.value) + return True + + async def on_progress(self, request_id: str, agent_id: str, progress: int) -> bool: + await self.task_repo.update(request_id, progress=progress) + await self._log(agent_id, request_id, "progress", f"progress {progress}%") + return True + + async def _log(self, agent_id: str, request_id: str, action: str, message: str) -> None: + from app.repository.log_repo import LogRepo + + await LogRepo(self.redis).append( + source="gateway", scope="task", message=message, + request_id=request_id, agent_id=agent_id, + ) \ No newline at end of file diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py new file mode 100644 index 0000000..c365d3a --- /dev/null +++ b/backend/app/services/scheduler.py @@ -0,0 +1,76 @@ +"""规则调度服务:标签匹配→负载过滤→最优选择→任务绑定→推送下发。""" +import logging + +import redis.asyncio as aioredis + +from app.constants import TaskStatus +from app.models.schemas import AgentInfo +from app.repository.agent_repo import AgentRepo +from app.repository.task_repo import TaskRepo +from app.services.relay import RelayService + +logger = logging.getLogger(__name__) + + +class Scheduler: + def __init__( + self, + redis: aioredis.Redis, + task_repo: TaskRepo | None = None, + agent_repo: AgentRepo | None = None, + relay: RelayService | None = None, + ): + self.redis = redis + self.task_repo = task_repo or TaskRepo(redis) + self.agent_repo = agent_repo or AgentRepo(redis) + self.relay = relay or RelayService(redis) + + async def pick_candidate(self, task_tags: list[str]) -> AgentInfo | None: + """按规则挑选最优 Agent:标签匹配→负载过滤→最低负载。""" + candidates = await self.agent_repo.by_tags(task_tags) + # 负载过滤:剔除满载 + candidates = [a for a in candidates if a.current_load < a.max_concurrent] + if not candidates: + return None + # 最优:负载最低、在线时间最长(稳定) + candidates.sort(key=lambda a: (a.current_load, -a.create_time)) + return candidates[0] + + async def dispatch(self, request_id: str) -> bool: + """将单个 pending 任务下发给匹配 Agent(绑定 → 推送 payload → 失败回退)。""" + task = await self.task_repo.get(request_id) + if not task or task.status != TaskStatus.PENDING: + return False + agent = await self.pick_candidate(task.task_tags) + if not agent: + return False # 暂无可用 Agent,保持 pending,等待下次调度 + # 绑定 Agent,标记 running + await self.task_repo.update( + request_id, + status=TaskStatus.RUNNING, + agent_id=agent.agent_id, + progress=0, + ) + await self.task_repo.mark_running(request_id) + # 更新 Agent 负载 + await self.agent_repo.adjust_load(agent.agent_id, 1) + # 真实推送 payload 到 Agent 端点 + pushed = await self.relay.dispatch_command(agent.agent_id, request_id, task.payload or {}) + if not pushed: + # 推送失败:回退 pending 并释放负载,等待下次调度(避免重复扣负载) + await self.task_repo.update(request_id, status=TaskStatus.PENDING, agent_id="", progress=0) + await self.task_repo.mark_pending(request_id) + await self.agent_repo.adjust_load(agent.agent_id, -1) + logger.warning("task dispatch push failed, reverted request_id=%s agent=%s", request_id, agent.agent_id) + return False + logger.info("task dispatched request_id=%s -> agent=%s", request_id, agent.agent_id) + return True + + async def dispatch_pending(self, batch_size: int = 20) -> int: + """调度所有可调度的 pending 任务,返回成功下发数。""" + pending = await self.task_repo.pending_tasks() + dispatched = 0 + for rid in pending[:batch_size]: + if await self.dispatch(rid): + dispatched += 1 + return dispatched \ No newline at end of file diff --git a/backend/app/services/security.py b/backend/app/services/security.py new file mode 100644 index 0000000..816299b --- /dev/null +++ b/backend/app/services/security.py @@ -0,0 +1,10 @@ +"""认证校验:CLI / Agent 请求必须携带与 GATEWAY_AUTH 一致的密码。""" +from fastapi import HTTPException + +from app.config import settings + + +def require_auth(auth: str) -> None: + """auth 不匹配时抛出 401。""" + if auth != settings.gateway_auth: + raise HTTPException(status_code=401, detail="invalid auth") diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py new file mode 100644 index 0000000..12de5f6 --- /dev/null +++ b/backend/app/services/task_service.py @@ -0,0 +1,106 @@ +"""任务池服务:受理、状态流转、幂等、超时失败。""" +import logging +import time + +import redis.asyncio as aioredis + +from app.config import settings +from app.constants import TaskStatus +from app.models.schemas import TaskInfo, TaskSubmit +from app.repository.agent_repo import AgentRepo +from app.repository.task_repo import TaskRepo +from app.services.notifier import TaskNotifier + +logger = logging.getLogger(__name__) + + +class TaskService: + def __init__(self, redis: aioredis.Redis, repo: TaskRepo | None = None): + self.redis = redis + self.repo = repo or TaskRepo(redis) + self.agent_repo = AgentRepo(redis) + + async def _release_if_running(self, task: TaskInfo) -> None: + """若任务处于 running 且绑定 Agent,释放其算力。""" + if task.status == TaskStatus.RUNNING and task.agent_id: + await self.agent_repo.adjust_load(task.agent_id, -1) + + async def submit(self, submit: TaskSubmit) -> TaskInfo: + """受理任务:生成/复用 RequestID,幂等写入任务池。""" + task = TaskInfo( + request_id=submit.request_id or submit.request_id or _new_id(), + task_type=submit.task_type, + task_tags=submit.task_tags, + description=submit.description, + payload=submit.payload, + cli_session_id=submit.cli_session_id, + timeout=submit.timeout or settings.default_task_timeout, + status=TaskStatus.PENDING, + ) + created = await self.repo.create(task) + if not created: + # 幂等:返回已有任务 + existing = await self.repo.get(task.request_id) + if existing: + return existing + logger.info("task submitted request_id=%s type=%s", task.request_id, task.task_type) + return task + + async def get(self, request_id: str) -> TaskInfo | None: + return await self.repo.get(request_id) + + async def list(self, status: TaskStatus | None = None, limit: int = 100) -> list[TaskInfo]: + return await self.repo.list(status=status, limit=limit) + + async def cancel(self, request_id: str) -> bool: + """取消任务(仅 pending/running 可取消)。""" + task = await self.repo.get(request_id) + if not task: + return False + if task.status in (TaskStatus.SUCCESS, TaskStatus.FAILED): + return False + await self._release_if_running(task) + await self.repo.update(request_id, status=TaskStatus.FAILED, error_info="cancelled by user") + await self.repo.mark_finished(request_id) + if task.cli_session_id: + finished = await self.repo.get(request_id) + if finished: + await TaskNotifier(self.redis).publish_task_done(finished) + logger.info("task cancelled request_id=%s", request_id) + return True + + async def reset(self, request_id: str) -> bool: + """重置任务状态为 pending(重新调度)。""" + task = await self.repo.get(request_id) + if not task: + return False + await self.repo.update(request_id, status=TaskStatus.PENDING, agent_id=None, error_info=None, progress=0) + await self.repo.mark_pending(request_id) + logger.info("task reset request_id=%s", request_id) + return True + + async def check_timeouts(self) -> int: + """扫描 running 任务,超时自动标记失败。""" + now = time.time() + failed = 0 + for rid in await self.repo.running_tasks(): + task = await self.repo.get(rid) + if not task: + continue + if task.timeout and (now - task.create_time) > task.timeout: + await self._release_if_running(task) + await self.repo.update(rid, status=TaskStatus.FAILED, error_info="timeout") + await self.repo.mark_finished(rid) + if task.cli_session_id: + finished = await self.repo.get(rid) + if finished: + await TaskNotifier(self.redis).publish_task_done(finished) + failed += 1 + logger.info("task timeout request_id=%s", rid) + return failed + + +def _new_id() -> str: + import uuid + + return uuid.uuid4().hex \ No newline at end of file diff --git a/backend/app/services/timeout.py b/backend/app/services/timeout.py new file mode 100644 index 0000000..77ccd88 --- /dev/null +++ b/backend/app/services/timeout.py @@ -0,0 +1,19 @@ +"""任务超时检查服务。""" +import logging + +import redis.asyncio as aioredis + +from app.repository.task_repo import TaskRepo +from app.services.task_service import TaskService + +logger = logging.getLogger(__name__) + + +class TimeoutService: + def __init__(self, redis: aioredis.Redis, task_repo: TaskRepo | None = None): + self.redis = redis + self.task_repo = task_repo or TaskRepo(redis) + + async def check(self) -> int: + """扫描 running 任务,超时自动标记失败,返回失败数。""" + return await TaskService(self.redis, self.task_repo).check_timeouts() \ No newline at end of file diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..5487393 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,25 @@ +"""pytest 共享 fixture。""" +import fakeredis.aioredis +import pytest +import pytest_asyncio + +from app.repository.agent_repo import AgentRepo +from app.repository.task_repo import TaskRepo + + +@pytest_asyncio.fixture +async def redis(): + server = fakeredis.FakeServer() + client = fakeredis.aioredis.FakeRedis(server=server, decode_responses=True) + yield client + await client.aclose() + + +@pytest_asyncio.fixture +def task_repo(redis): + return TaskRepo(redis) + + +@pytest_asyncio.fixture +def agent_repo(redis): + return AgentRepo(redis) \ No newline at end of file diff --git a/backend/tests/test_agent_repo.py b/backend/tests/test_agent_repo.py new file mode 100644 index 0000000..4a30af9 --- /dev/null +++ b/backend/tests/test_agent_repo.py @@ -0,0 +1,55 @@ +"""Agent 池存储层单元测试。""" +import time + +import pytest + +from app.constants import AgentStatus +from app.models.schemas import AgentInfo + + +@pytest.mark.asyncio +async def test_upsert_and_get(agent_repo): + a = AgentInfo(agent_id="a1", endpoint="agent-1:5000", agent_tags=["compile", "test"], max_concurrent=2) + created = await agent_repo.upsert(a) + assert created is True + + got = await agent_repo.get("a1") + assert got is not None + assert got.agent_tags == ["compile", "test"] + assert got.status == AgentStatus.ONLINE + + +@pytest.mark.asyncio +async def test_beat_and_stale(agent_repo): + a = AgentInfo(agent_id="a1", endpoint="agent-1:5000", agent_tags=["compile"]) + await agent_repo.upsert(a) + assert await agent_repo.beat("a1", 1) is True + + # 心跳为最新,不应 stale + stale = await agent_repo.stale_agents(1) + assert "a1" not in stale + + +@pytest.mark.asyncio +async def test_by_tags(agent_repo): + a1 = AgentInfo(agent_id="a1", endpoint="e1", agent_tags=["compile"], max_concurrent=2) + a2 = AgentInfo(agent_id="a2", endpoint="e2", agent_tags=["compile", "test"], max_concurrent=2) + a3 = AgentInfo(agent_id="a3", endpoint="e3", agent_tags=["test"], max_concurrent=2) + await agent_repo.upsert(a1) + await agent_repo.upsert(a2) + await agent_repo.upsert(a3) + + ids = {a.agent_id for a in await agent_repo.by_tags(["compile"])} + assert ids == {"a1", "a2"} + + ids = {a.agent_id for a in await agent_repo.by_tags(["compile", "test"])} + assert ids == {"a2"} + + +@pytest.mark.asyncio +async def test_remove(agent_repo): + a1 = AgentInfo(agent_id="a1", endpoint="e1", agent_tags=["compile"]) + await agent_repo.upsert(a1) + await agent_repo.remove("a1") + assert await agent_repo.get("a1") is None + assert await agent_repo.by_tags(["compile"]) == [] \ No newline at end of file diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..a085d98 --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,90 @@ +"""接口集成测试。""" +import fakeredis.aioredis +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from app.config import settings +from app.main import app + +AUTH = settings.gateway_auth + + +@pytest_asyncio.fixture +async def client(): + server = fakeredis.FakeServer() + redis = fakeredis.aioredis.FakeRedis(server=server, decode_responses=True) + app.state.redis = redis + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + await redis.aclose() + + +@pytest.mark.asyncio +async def test_health(client): + r = await client.get("/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" + + +@pytest.mark.asyncio +async def test_submit_and_query_task(client): + r = await client.post("/api/cli/tasks", json={"auth": AUTH, "task_type": "compile", "task_tags": ["build"]}) + assert r.status_code == 200 + data = r.json() + assert data["status"] == "pending" + rid = data["request_id"] + + r2 = await client.get(f"/api/cli/tasks/{rid}") + assert r2.status_code == 200 + assert r2.json()["request_id"] == rid + + +@pytest.mark.asyncio +async def test_agent_register_heartbeat_result(client): + # 注册 + r = await client.post("/api/agent/register", json={"auth": AUTH, "agent_id": "a1", "endpoint": "e1", "agent_tags": ["compile"], "max_concurrent": 2}) + assert r.status_code == 200 + + # 心跳 + r = await client.post("/api/agent/heartbeat", json={"agent_id": "a1", "current_load": 0}) + assert r.status_code == 200 + + # 提交任务并等待调度(手动触发一次调度) + r = await client.post("/api/cli/tasks", json={"auth": AUTH, "task_type": "compile", "task_tags": ["compile"]}) + rid = r.json()["request_id"] + from app.services.scheduler import Scheduler + await Scheduler(app.state.redis).dispatch_pending(10) + + # 回传结果 + r = await client.post("/api/agent/result", json={"request_id": rid, "agent_id": "a1", "status": "success", "progress": 100, "result": {"ok": True}}) + assert r.status_code == 200 + + detail = await client.get(f"/api/cli/tasks/{rid}") + assert detail.json()["status"] == "success" + + +@pytest.mark.asyncio +async def test_admin_overview(client): + r = await client.get("/api/admin/overview") + assert r.status_code == 200 + assert "task_total" in r.json() + + +@pytest.mark.asyncio +async def test_submit_task_rejects_bad_auth(client): + r = await client.post("/api/cli/tasks", json={"auth": "wrong-password", "task_type": "compile"}) + assert r.status_code == 401 + + +@pytest.mark.asyncio +async def test_submit_task_requires_auth(client): + r = await client.post("/api/cli/tasks", json={"task_type": "compile"}) + assert r.status_code == 422 # 缺 auth 字段 + + +@pytest.mark.asyncio +async def test_agent_register_rejects_bad_auth(client): + r = await client.post("/api/agent/register", json={"auth": "wrong-password", "agent_id": "a9", "endpoint": "e9"}) + assert r.status_code == 401 \ No newline at end of file diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py new file mode 100644 index 0000000..182eb79 --- /dev/null +++ b/backend/tests/test_scheduler.py @@ -0,0 +1,67 @@ +"""调度服务单元测试。""" +import pytest + +from app.constants import TaskStatus +from app.models.schemas import AgentInfo, TaskInfo +from app.repository.agent_repo import AgentRepo +from app.repository.task_repo import TaskRepo +from app.services.scheduler import Scheduler + + +@pytest.mark.asyncio +async def test_dispatch_binds_min_load_agent(redis): + task_repo = TaskRepo(redis) + agent_repo = AgentRepo(redis) + + # 两个 compile Agent,b2 负载更低 + await agent_repo.upsert(AgentInfo(agent_id="b1", endpoint="e1", agent_tags=["compile"], max_concurrent=2, current_load=1)) + await agent_repo.upsert(AgentInfo(agent_id="b2", endpoint="e2", agent_tags=["compile"], max_concurrent=2, current_load=0)) + + await task_repo.create(TaskInfo(request_id="t1", task_type="compile", task_tags=["compile"])) + + sched = Scheduler(redis, task_repo, agent_repo) + ok = await sched.dispatch("t1") + assert ok is True + + task = await task_repo.get("t1") + assert task.agent_id == "b2" + assert task.status == TaskStatus.RUNNING + assert "t1" in await task_repo.running_tasks() + + +@pytest.mark.asyncio +async def test_dispatch_no_agent_stays_pending(redis): + task_repo = TaskRepo(redis) + agent_repo = AgentRepo(redis) + await task_repo.create(TaskInfo(request_id="t1", task_type="compile", task_tags=["compile"])) + + sched = Scheduler(redis, task_repo, agent_repo) + ok = await sched.dispatch("t1") + assert ok is False + task = await task_repo.get("t1") + assert task.status == TaskStatus.PENDING + + +@pytest.mark.asyncio +async def test_dispatch_skips_full_agent(redis): + task_repo = TaskRepo(redis) + agent_repo = AgentRepo(redis) + # 唯一 agent 已满载 + await agent_repo.upsert(AgentInfo(agent_id="b1", endpoint="e1", agent_tags=["compile"], max_concurrent=1, current_load=1)) + await task_repo.create(TaskInfo(request_id="t1", task_type="compile", task_tags=["compile"])) + + sched = Scheduler(redis, task_repo, agent_repo) + assert await sched.dispatch("t1") is False + + +@pytest.mark.asyncio +async def test_dispatch_pending(redis): + task_repo = TaskRepo(redis) + agent_repo = AgentRepo(redis) + await agent_repo.upsert(AgentInfo(agent_id="b1", endpoint="e1", agent_tags=["compile"], max_concurrent=2)) + await task_repo.create(TaskInfo(request_id="t1", task_type="compile", task_tags=["compile"])) + await task_repo.create(TaskInfo(request_id="t2", task_type="compile", task_tags=["compile"])) + + sched = Scheduler(redis, task_repo, agent_repo) + n = await sched.dispatch_pending(10) + assert n == 2 \ No newline at end of file diff --git a/backend/tests/test_task_repo.py b/backend/tests/test_task_repo.py new file mode 100644 index 0000000..3a94022 --- /dev/null +++ b/backend/tests/test_task_repo.py @@ -0,0 +1,51 @@ +"""任务池存储层单元测试。""" +import pytest + +from app.constants import TaskStatus +from app.models.schemas import TaskInfo + + +@pytest.mark.asyncio +async def test_create_and_get(task_repo): + t = TaskInfo(request_id="r1", task_type="compile", task_tags=["build"], payload={"cmd": "go build"}) + created = await task_repo.create(t) + assert created is True + + got = await task_repo.get("r1") + assert got is not None + assert got.task_type == "compile" + assert got.task_tags == ["build"] + assert got.payload == {"cmd": "go build"} + assert got.status == TaskStatus.PENDING + + +@pytest.mark.asyncio +async def test_idempotent_create(task_repo): + t = TaskInfo(request_id="r1", task_type="compile") + assert await task_repo.create(t) is True + assert await task_repo.create(t) is False # 幂等 + + +@pytest.mark.asyncio +async def test_state_sets(task_repo): + t = TaskInfo(request_id="r1", task_type="compile") + await task_repo.create(t) + assert "r1" in await task_repo.pending_tasks() + + await task_repo.mark_running("r1") + assert "r1" not in await task_repo.pending_tasks() + assert "r1" in await task_repo.running_tasks() + + await task_repo.mark_finished("r1") + assert "r1" not in await task_repo.running_tasks() + + +@pytest.mark.asyncio +async def test_update(task_repo): + t = TaskInfo(request_id="r1", task_type="compile") + await task_repo.create(t) + await task_repo.update("r1", status=TaskStatus.RUNNING, agent_id="a1", progress=50) + got = await task_repo.get("r1") + assert got.status == TaskStatus.RUNNING + assert got.agent_id == "a1" + assert got.progress == 50 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4e0c70e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +version: "3.8" + +services: + redis: + image: redis:7-alpine + container_name: gateway-redis + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis-data:/data + command: ["redis-server", "--appendonly", "yes"] + +volumes: + redis-data: \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8792cb1 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + A2A 智能网关运维台 + + +
+ + + \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..1d365ce --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2751 @@ +{ + "name": "gateway-admin", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gateway-admin", + "version": "1.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "element-plus": "^2.9.1", + "lucide-vue-next": "^0.469.0", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "autoprefixer": "^10.5.4", + "postcss": "^8.5.25", + "tailwindcss": "^3.4.0", + "typescript": "~5.6.2", + "vite": "^5.4.11", + "vue-tsc": "^2.1.10" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "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/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "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/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "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": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", + "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.40", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", + "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", + "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.40", + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-ssr": "3.5.40", + "@vue/shared": "3.5.40", + "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.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", + "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz", + "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz", + "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", + "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/runtime-core": "3.5.40", + "@vue/shared": "3.5.40", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz", + "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz", + "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "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/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "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.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.400", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.400.tgz", + "integrity": "sha512-96EWDNjM59SYflgeV5Ylsf4EMiq1a25YjCnJH7cxn/AF2H3pILRweaUnoLax0yKHWdpOzY6JKEu45e8irqZIHA==", + "dev": true, + "license": "ISC" + }, + "node_modules/element-plus": { + "version": "2.14.3", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.3.tgz", + "integrity": "sha512-pJcvxcpZjYruNzuJhAeVwnbYjfNgzBKnWHwSVEhwzM2/kcLI3brzmtIBxtPqd4hQWJfD1PRnjoc1WipLw2eBGg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.7.6", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.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.5" + }, + "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-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "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==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "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/lucide-vue-next": { + "version": "0.469.0", + "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.469.0.tgz", + "integrity": "sha512-EjOap+vY3xEzCMrnaccDHO4BH3k3Lr+sOyvzRQCaayYxkxKla0w6Jr4h3cHAzA4vMSp63Dcy7vDiGeCPcCY+Gg==", + "deprecated": "Package deprecated. Please use @lucide/vue instead.", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, + "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/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/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "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/node-releases": { + "version": "2.0.52", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.52.tgz", + "integrity": "sha512-MRlTqhAfoMx/4mhEbPo3Hi02g9LJZaJkka69V6h67Cb1gjrAG0jsTE4CZX1eptNx+VCAwJmfpnDIF4P0Nh1A7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "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": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "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.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "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/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.0.tgz", + "integrity": "sha512-VigzymniH77knD1dryXbyxR+ePHihHociZbXnLZHUyzf2MMs2ZVqlUrZ3FvpXP8pno9JzmILt1sZPD19M3IxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.19.1", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "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/tinyglobby/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/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz", + "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-sfc": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/server-renderer": "3.5.40", + "@vue/shared": "3.5.40" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.9.tgz", + "integrity": "sha512-3c/UfMe0SqyEfcGTyH7mfshHagJ9QTCbppCb0/uGpHZpFug7+If3GeGZN7I0YheKEExemx3xldQPoO7PQSOLQg==", + "license": "MIT" + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c5ad5b7 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "gateway-admin", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "element-plus": "^2.9.1", + "lucide-vue-next": "^0.469.0", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "autoprefixer": "^10.5.4", + "postcss": "^8.5.25", + "tailwindcss": "^3.4.0", + "typescript": "~5.6.2", + "vite": "^5.4.11", + "vue-tsc": "^2.1.10" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..358e74b --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..1ca0ad5 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,78 @@ +const BASE = '/api/admin' + +async function request(path: string, options: RequestInit = {}): Promise { + const res = await fetch(`${BASE}${path}`, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }) + if (!res.ok) { + const text = await res.text() + throw new Error(text || `HTTP ${res.status}`) + } + return res.json() as Promise +} + +export interface TaskInfo { + request_id: string + task_type: string + task_tags: string[] + description: string | null + status: 'pending' | 'running' | 'success' | 'failed' + agent_id: string | null + create_time: number + progress: number + result: unknown + error_info: string | null +} + +export interface AgentInfo { + agent_id: string + endpoint: string + agent_tags: string[] + max_concurrent: number + current_load: number + last_heartbeat: number + status: 'online' | 'offline' + create_time: number +} + +export interface LogEntry { + ts: number + level: string + source: string + scope: string + request_id: string | null + agent_id: string | null + message: string +} + +export interface Overview { + task_total: number + task_pending: number + task_running: number + task_success: number + task_failed: number + agent_total: number + agent_online: number + agent_offline: number +} + +export const api = { + listTasks: (status?: string) => { + const q = status ? `?status=${status}` : '' + return request(`/tasks${q}`) + }, + taskDetail: (id: string) => request(`/tasks/${id}`), + cancelTask: (id: string) => request<{ ok: boolean }>(`/tasks/${id}/cancel`, { method: 'POST' }), + resetTask: (id: string) => request<{ ok: boolean }>(`/tasks/${id}/reset`, { method: 'POST' }), + listAgents: () => request('/agents'), + offlineAgent: (id: string) => request<{ ok: boolean }>(`/agents/${id}/offline`, { method: 'POST' }), + listLogs: (params?: { request_id?: string; agent_id?: string }) => { + const q = new URLSearchParams() + if (params?.request_id) q.set('request_id', params.request_id) + if (params?.agent_id) q.set('agent_id', params.agent_id) + const s = q.toString() + return request(`/logs${s ? `?${s}` : ''}`) + }, + overview: () => request('/overview'), +} \ No newline at end of file diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..37dae94 --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} \ No newline at end of file diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..ce1514b --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,15 @@ +import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import App from './App.vue' +import router from './router' +import './style.css' + +const app = createApp(App) +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} +app.use(ElementPlus) +app.use(router) +app.mount('#app') \ No newline at end of file diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..207a736 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,18 @@ +import { createRouter, createWebHistory } from 'vue-router' +import TaskView from '../views/TaskView.vue' +import AgentView from '../views/AgentView.vue' +import LogView from '../views/LogView.vue' +import ControlView from '../views/ControlView.vue' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', redirect: '/tasks' }, + { path: '/tasks', name: 'tasks', component: TaskView, meta: { title: '任务管理' } }, + { path: '/agents', name: 'agents', component: AgentView, meta: { title: 'Agent 管理' } }, + { path: '/logs', name: 'logs', component: LogView, meta: { title: '日志审计' } }, + { path: '/control', name: 'control', component: ControlView, meta: { title: '手动管控' } }, + ], +}) + +export default router \ No newline at end of file diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..cb5b61c --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,93 @@ +@tailwind components; +@tailwind utilities; + +:root { + --gw-primary: #1e63e8; + --gw-primary-light: #3b82f6; + --gw-dark: #0f172a; + --gw-bg: #f5f7fa; + --gw-success: #10b981; + --gw-warning: #f59e0b; + --gw-danger: #ef4444; + --gw-info: #3b82f6; + --gw-text: #1e293b; + --gw-text-sub: #64748b; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + margin: 0; + padding: 0; + height: 100%; + font-family: 'PingFang SC', 'Microsoft YaHei', -apple-system, sans-serif; + background: var(--gw-bg); + color: var(--gw-text); +} + +.stat-card { + border-radius: 12px; + background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%); + border: 1px solid #e2e8f0; + padding: 18px 20px; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.stat-card:hover { + transform: translateY(-3px); + box-shadow: 0 8px 24px rgba(30, 99, 232, 0.10); +} + +.stat-value { + font-size: 28px; + font-weight: 700; + line-height: 1.2; +} + +.badge-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; +} + +.pulse { + animation: pulse 1.6s ease-in-out infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} + +.page-card { + background: #fff; + border-radius: 12px; + border: 1px solid #e2e8f0; + padding: 20px; +} + +.fade-in { + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} \ No newline at end of file diff --git a/frontend/src/views/AgentView.vue b/frontend/src/views/AgentView.vue new file mode 100644 index 0000000..8fd5bf3 --- /dev/null +++ b/frontend/src/views/AgentView.vue @@ -0,0 +1,92 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/ControlView.vue b/frontend/src/views/ControlView.vue new file mode 100644 index 0000000..d1790fe --- /dev/null +++ b/frontend/src/views/ControlView.vue @@ -0,0 +1,115 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/LogView.vue b/frontend/src/views/LogView.vue new file mode 100644 index 0000000..ee94979 --- /dev/null +++ b/frontend/src/views/LogView.vue @@ -0,0 +1,71 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/TaskView.vue b/frontend/src/views/TaskView.vue new file mode 100644 index 0000000..34e68a7 --- /dev/null +++ b/frontend/src/views/TaskView.vue @@ -0,0 +1,175 @@ + + + \ No newline at end of file diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..c1da0e5 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..6b5eba8 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "verbatimModuleSyntax": false + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"] +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..60fa158 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + host: '0.0.0.0', + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, +}) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2dd70f4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.115.12 +uvicorn[standard]==0.34.0 +redis==5.2.1 +pydantic==2.12.4 +pydantic-settings==2.10.1 +python-multipart==0.0.20 + +# 测试 +pytest==8.3.4 +pytest-asyncio==0.25.3 +httpx==0.28.1 +fakeredis==2.26.2 \ No newline at end of file diff --git a/网关架构流程图.md b/网关架构流程图.md new file mode 100644 index 0000000..ff11536 --- /dev/null +++ b/网关架构流程图.md @@ -0,0 +1,236 @@ +# 网关(Gateway)项目架构流程图 + +> 基于代码现状绘制的架构图(backend + frontend + agent 联动)。 +> 端口:后端 `:8000`、前端 `:5173`、Agent `:8001`、Redis 远程实例 `45.207.192.237:56987/0`。 + +--- + +## 一、系统总体架构图(分层) + +```mermaid +flowchart TB + subgraph OUT["外部调用方"] + CLI["CLI 客户端 / 模型对话
提交任务(带 auth)"] + end + + subgraph FE["前端运维后台(Vue3 + Vite + Element Plus):5173"] + TV["TaskView 任务管理
列表/筛选/详情/取消/重置"] + AV["AgentView Agent 管理
卡片/标签/负载/下线"] + LV["LogView 日志审计
按 request_id/agent_id 筛选"] + CV["ControlView 总览
任务/Agent 统计"] + API["api/index.ts
fetch 封装(/api/admin/*)"] + end + + subgraph GW["网关后端(FastAPI + Uvicorn):8000"] + direction TB + subgraph API_L["API 层(app/api/)"] + A1["/api/cli/*
任务提交"] + A2["/api/agent/*
注册/心跳/结果回传"] + A3["/api/admin/*
运维后台查询与管控"] + end + + subgraph SVC["服务层(app/services/)"] + S1["TaskService 任务池
受理/取消/重置/超时"] + S2["AgentService Agent 池
注册/心跳/下线/剔除"] + S3["Scheduler 规则调度
选人→绑定→推送→回退"] + S4["RelayService 通信中转
正向推送/反向收结果"] + S5["HeartbeatService
心跳扫描(60s)"] + S6["TimeoutService
任务超时检查(5s)"] + end + + subgraph REPO["存储层(app/repository/)"] + R1["TaskRepo"] + R2["AgentRepo"] + R3["LogRepo"] + end + + subgraph LOOP["后台循环(scheduler_loop.py)"] + L1["heartbeat_loop
Lock:lock:heartbeat"] + L2["dispatch_loop(2s)
Lock:lock:scheduler"] + L3["timeout_loop
Lock:lock:timeout"] + end + end + + subgraph RD["Redis 存储(DB0)"] + K1["task:info:{id} Hash
task:pending / task:running Set"] + K2["agent:info:{id} Hash
agent:all / agent:tag:{tag} Set"] + K3["agent:heartbeat ZSet"] + K4["lock:* 分布式锁"] + K5["log:audit List"] + end + + subgraph AG["Agent 服务(ADK ApiServer):8001"] + direction TB + AG0["api_server.py
REST /run /run_sse
SQLite 会话 / InMemory 记忆"] + AG1["task_receiver.py
POST /tasks/{id}
校验 auth → 202 → 后台线程"] + AG2["gateway_client.py
注册 / 心跳 / 回传结果"] + AG3["ADK InMemoryRunner
执行 dev_app(my_agent)"] + end + + CLI -->|"POST /api/cli/tasks"| A1 + FE --> API + API -->|"/api/admin/*"| A3 + + A1 --> S1 + A2 --> S2 + A3 --> S1 & S2 + + S1 & S2 & S3 & S4 & S5 & S6 --> R1 & R2 & R3 + L1 -->|"调用"| S5 + L2 -->|"调用"| S3 + L3 -->|"调用"| S6 + S5 -->|"Lock"| K4 + S3 -->|"Lock"| K4 + + R1 & R2 & R3 --> K1 & K2 & K3 & K5 + + S3 -->|"Scheduler 选中 Agent"| S4 + S4 -->|"POST {endpoint}/tasks/{request_id}
期望 202"| AG1 + AG1 -->|"校验通过后后台线程"| AG3 + AG3 -->|"执行完成"| AG2 + AG2 -->|"POST /api/agent/result"| A2 + AG2 -->|"心跳 /api/agent/heartbeat(10s)"| A2 +``` + +--- + +## 二、任务全生命周期时序图(完整闭环) + +```mermaid +sequenceDiagram + autonumber + participant CLI as CLI / 对话模型 + participant GW as 网关后端(FastAPI :8000) + participant RD as Redis + participant LOOP as SchedulerLoop 后台循环 + participant AG as Agent 服务(:8001) + participant ADK as ADK InMemoryRunner + + Note over AG,ADK: Agent 启动:register → 每 10s heartbeat + + CLI->>GW: POST /api/cli/tasks {auth, task_type, tags, payload} + GW->>GW: TaskService.submit(幂等:RequestID 复用) + GW->>RD: hset task:info:{id} + sadd task:pending + GW-->>CLI: 200 {request_id} + + loop dispatch_loop(每 2s,持 lock:scheduler) + LOOP->>RD: 取 task:pending 成员 + LOOP->>GW: Scheduler.dispatch_pending(batch) + GW->>RD: AgentRepo.by_tags(tags) 标签匹配 + GW->>GW: 负载过滤(load < max_concurrent) + GW->>GW: 最低负载 + 在线最久者优先 + GW->>RD: 任务置 running + 绑定 agent_id
task:pending→task:running,agent 负载 +1 + GW->>AG: RelayService.dispatch_command
POST {endpoint}/tasks/{id} {auth, payload} + alt 推送成功(202) + AG->>AG: 校验 auth → 后台线程接收任务 + AG-->>GW: 202 {ok, request_id, status:"accepted"} + AG->>ADK: 运行 agent(InMemoryRunner.run_async) + ADK-->>AG: 最终输出文本 + AG->>GW: POST /api/agent/result {success, progress:100, result} + GW->>RD: 更新任务 success + 释放 agent 负载
task:running 移除 + GW-->>CLI: (前端可查) + else 推送失败(网络错误 / 非 202) + AG-->>GW: 4xx/5xx 或无响应 + GW->>RD: 回退:任务置 pending + 解绑 + 释放负载
task:running→task:pending + end + end + + Note over LOOP,RD: heartbeat_loop:60s 扫 agent:heartbeat ZSet,
超 120s 未心跳 → 标记 offline
timeout_loop:5s 扫 running,超时 → failed +``` + +--- + +## 三、规则调度决策流程(Scheduler) + +```mermaid +flowchart LR + START(["dispatch_pending
取 task:pending 批量"]) --> T1{"任务存在且为 pending?"} + T1 -- 否 --> END(["跳过"]) + T1 -- 是 --> T2{"按 task_tags 标签匹配
AgentRepo.by_tags(tags)"} + T2 -- 无候选 --> END2(["保持 pending
等待下次调度"]) + T2 -- 有候选 --> T3["负载过滤
current_load < max_concurrent"] + T3 -- 空 --> END2 + T3 -- 非空 --> T4["排序:最低负载优先
负载相同时在线最久优先"] + T4 --> T5["绑定 Agent
task:info 置 running + agent_id
task:pending→task:running"] + T5 --> T6["agent 负载 +1
adjust_load(agent, +1)"] + T6 --> T7["RelayService.dispatch_command
POST {endpoint}/tasks/{id}"] + T7 --> T8{"HTTP 202?"} + T8 -- 是 --> OK(["下发成功
记录 dispatch 日志"]) + T8 -- 否 --> T9["回退:task 置 pending + 解绑
task:running→task:pending
agent 负载 -1"] + T9 --> END2 +``` + +--- + +## 四、Agent 端任务处理流程 + +```mermaid +flowchart TB + RECV["POST {endpoint}/tasks/{request_id}"] --> AUTH{"auth == GATEWAY_AUTH?"} + AUTH -- 否 --> 401["401 invalid auth"] + AUTH -- 是 --> THREAD["启动 daemon 后台线程
(立即返回 202 accepted)"] + THREAD --> PROMPT["payload → prompt
(prompt / cmd / 兜底序列化)"] + PROMPT --> RUN["InMemoryRunner.run_async
user_id=gateway, session=task-{id}"] + RUN --> OUT["收集最终文本输出"] + OUT --> REP["gateway_client.report_result
POST /api/agent/result"] + RUN --> ERR["异常捕获"] + ERR --> REPF["report_result(status=failed, error_info)"] +``` + +--- + +## 五、Redis 数据结构一览 + +| 前缀 / 键 | 类型 | 用途 | +| --- | --- | --- | +| `task:info:{request_id}` | Hash | 任务全量信息(状态/绑定 Agent/进度/结果),TTL 24h | +| `task:pending` | Set | 待调度任务索引 | +| `task:running` | Set | 执行中任务索引 | +| `agent:info:{agent_id}` | Hash | Agent 信息(endpoint/标签/并发上限/负载/心跳时间) | +| `agent:all` | Set | 全部 Agent 索引 | +| `agent:tag:{tag}` | Set | 能力标签 → Agent 索引(调度匹配用) | +| `agent:heartbeat` | ZSet | score=最后心跳时间戳(剔除离线用) | +| `lock:heartbeat` / `lock:scheduler` / `lock:timeout` | String | 分布式锁,防多实例重复执行后台循环 | +| `log:audit` | List | 全链路审计日志(dispatch/progress/result) | + +--- + +## 六、技术栈与端口汇总 + +```mermaid +flowchart LR + subgraph 后端 + B["Python + FastAPI + Uvicorn
redis-py(asyncio) + httpx
端口 8000"] + end + subgraph 前端 + F["Vue 3 + Vite + Element Plus
端口 5173(/api 代理到 8000)"] + end + subgraph Agent + A["Python + Google ADK v2.5
FastAPI ApiServer + InMemoryRunner
端口 8001"] + end + subgraph 存储 + R["Redis(远程 45.207.192.237:56987/0)
任务池 + Agent 池 + 索引 + 日志 + 锁"] + end + B <--> R + B <-->|"HTTP 推送 / 结果回传"| A + F -->|"/api/admin/*"| B +``` + +--- + +## 七、任务状态机 + +```mermaid +stateDiagram-v2 + [*] --> pending: submit 受理(幂等) + pending --> running: 调度器选 Agent 并推送成功 + pending --> pending: 无可用 Agent / 推送失败回退 + running --> success: Agent 回传 result(success) + running --> failed: Agent 回传 failed + running --> failed: 超时 / 用户取消 + pending --> failed: 用户取消 + success --> pending: 手动重置(重新调度) + failed --> pending: 手动重置(重新调度) + success --> [*]: TTL 归档 + failed --> [*]: TTL 归档 +```