Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdc75d0c65 | ||
|
|
bdd1aa2c14 | ||
|
|
53c5680260 | ||
|
|
df0a7dc37a | ||
|
|
ec2c1265d3 | ||
|
|
3fbcdfefea | ||
|
|
4d6560dbd4 | ||
|
|
df66897ff4 | ||
|
|
a6a3ee2eb0 | ||
|
|
49873e7f05 | ||
|
|
f826bdf3b0 | ||
|
|
c96935b47b | ||
|
|
18d17da966 | ||
|
|
0355af7833 | ||
|
|
079d7b1e28 | ||
|
|
1fdf7c0d1d | ||
|
|
606ed94160 | ||
|
|
8dbe054a8c | ||
|
|
868f611c36 | ||
|
|
d31eb2a19d | ||
|
|
ab066733b8 | ||
|
|
59f458205d | ||
|
|
4465a2e199 | ||
|
|
c818a09a49 | ||
|
|
e1e26772d9 | ||
|
|
1dc2b0c686 | ||
|
|
84e183d28d | ||
|
|
0e13bb5c29 | ||
|
|
8522fa3ca3 | ||
|
|
bfcd828fa8 | ||
|
|
057f0c3050 | ||
|
|
cec9898e4c | ||
|
|
60ac066859 | ||
|
|
9d6833f092 |
No files matched your search
+32
-2
@@ -2,20 +2,44 @@
|
|||||||
# 所有模型调用仅使用 OpenAI Responses API;环境变量优先于 .env。
|
# 所有模型调用仅使用 OpenAI Responses API;环境变量优先于 .env。
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
# 接口根地址:通常以 /v1 结尾,不要添加 /responses 或 /chat/completions。
|
# 接口根地址:通常以 /v1 结尾,不要添加 /responses 或 /chat/completions。
|
||||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
OPENAI_BASE_URL=https://api-n-cd.playones.com/v1
|
||||||
OPENAI_MODEL=mimo-v2.6-flash
|
OPENAI_MODEL=mimo-v2.6-flash
|
||||||
# 单次模型 HTTP 请求超时(秒)
|
# 单次模型 HTTP 请求超时(秒)
|
||||||
OPENAI_TIMEOUT_SECONDS=300
|
OPENAI_TIMEOUT_SECONDS=300
|
||||||
|
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
|
||||||
|
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
|
||||||
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
||||||
OPENAI_MAX_RETRIES=5
|
OPENAI_MAX_RETRIES=5
|
||||||
OPENAI_RETRY_INTERVAL_SECONDS=10
|
OPENAI_RETRY_INTERVAL_SECONDS=10
|
||||||
|
OPENAI_RATE_LIMIT_RETRY_SECONDS=60
|
||||||
|
OPENAI_TOOL_FALLBACK_MODEL=gpt-6-luna
|
||||||
|
OPENAI_TOOL_FALLBACK_REASONING_EFFORT=high
|
||||||
|
OPENAI_TOOL_FALLBACK_MAX_RETRIES=3
|
||||||
|
ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS=3
|
||||||
# default 表示不发送思考强度参数;接口不支持所选值时明确报错。
|
# default 表示不发送思考强度参数;接口不支持所选值时明确报错。
|
||||||
OPENAI_REASONING_EFFORT=high
|
OPENAI_REASONING_EFFORT=high
|
||||||
|
|
||||||
# 功能开关:保存后对新任务生效
|
# 功能开关:保存后对新任务生效
|
||||||
ARTEMIS_VIDEO_ENABLED=false
|
ARTEMIS_VIDEO_ENABLED=false
|
||||||
|
ARTEMIS_RECORDING_ENABLED=false
|
||||||
ARTEMIS_OUTPUTTER_ENABLED=true
|
ARTEMIS_OUTPUTTER_ENABLED=true
|
||||||
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
||||||
|
# 默认将任务截图长边压至 2048;false 保留设备原始截图。长边值可在 config/artemis.jsonc 的 agent.screenshot 中覆盖。
|
||||||
|
ARTEMIS_SCREENSHOT_LIMIT_ENABLED=true
|
||||||
|
# Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。
|
||||||
|
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3
|
||||||
|
# Flash 连续无工具调用的最大次数;无效回复不会重复送回模型。
|
||||||
|
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS=3
|
||||||
|
# 所有网页/MCP 任务的总执行时限;排队、用户暂停不计入。任务 JSON 的 timeout_seconds 可覆盖。
|
||||||
|
ARTEMIS_TASK_TIMEOUT_SECONDS=1800
|
||||||
|
# 单次屏幕观测预算、最多恢复次数和备用 ADB 命令时限。
|
||||||
|
ARTEMIS_OBSERVATION_TIMEOUT_SECONDS=60
|
||||||
|
ARTEMIS_OBSERVATION_MAX_ATTEMPTS=3
|
||||||
|
ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS=10
|
||||||
|
# Flash 连续设备动作失败后,重新观察并切换兜底模型;兜底仍失败时结束。
|
||||||
|
ARTEMIS_ACTION_FAILURE_ATTEMPTS=3
|
||||||
|
# 已写出任务结果后,后台线程/录屏清理最多等待多久;超时回收进程释放队列。
|
||||||
|
ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS=30
|
||||||
|
|
||||||
# 视频分段、重试与单次分析超时(秒)
|
# 视频分段、重试与单次分析超时(秒)
|
||||||
ARTEMIS_VIDEO_CHUNK_SECONDS=60
|
ARTEMIS_VIDEO_CHUNK_SECONDS=60
|
||||||
@@ -26,6 +50,8 @@ ARTEMIS_CHECKPOINT_TIMEOUT_SECONDS=180
|
|||||||
ARTEMIS_SETTLEMENT_TIMEOUT_SECONDS=120
|
ARTEMIS_SETTLEMENT_TIMEOUT_SECONDS=120
|
||||||
ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS=12
|
ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS=12
|
||||||
ARTEMIS_MODEL_CACHE_TTL_SECONDS=10
|
ARTEMIS_MODEL_CACHE_TTL_SECONDS=10
|
||||||
|
# 守护服务轻量探测超时;探测超时且端口仍占用时,不重复启动服务。
|
||||||
|
ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS=2
|
||||||
# 模型重试耗尽后的暂停等待上限(秒);0 表示无限等待
|
# 模型重试耗尽后的暂停等待上限(秒);0 表示无限等待
|
||||||
LLM_PAUSE_TIMEOUT_SECONDS=900
|
LLM_PAUSE_TIMEOUT_SECONDS=900
|
||||||
|
|
||||||
@@ -36,12 +62,16 @@ OCR_API_KEY=
|
|||||||
ADB_HOST=127.0.0.1
|
ADB_HOST=127.0.0.1
|
||||||
ADB_PORT=5037
|
ADB_PORT=5037
|
||||||
# ADB_DEVICE_SERIAL=
|
# ADB_DEVICE_SERIAL=
|
||||||
|
# 自动发现本机模拟器,并重连通过网页连接过的 TCP 设备;不扫描局域网。
|
||||||
|
ARTEMIS_ADB_AUTO_CONNECT=true
|
||||||
|
ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS=10
|
||||||
|
ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS=5
|
||||||
ARTEMIS_KEEP_DEVICE_AWAKE=true
|
ARTEMIS_KEEP_DEVICE_AWAKE=true
|
||||||
ARTEMIS_HIERARCHY_BACKEND=auto
|
ARTEMIS_HIERARCHY_BACKEND=auto
|
||||||
ARTEMIS_HELPER_AUTO_INSTALL=true
|
ARTEMIS_HELPER_AUTO_INSTALL=true
|
||||||
ARTEMIS_DEFAULT_PROFILE=flash
|
ARTEMIS_DEFAULT_PROFILE=flash
|
||||||
|
|
||||||
# 批量任务与 Excel 导入限制
|
# 批量任务与 Excel 导入限制
|
||||||
ARTEMIS_BATCH_TASK_TIMEOUT=1800
|
|
||||||
ARTEMIS_IMPORT_MAX_ROWS=1000
|
ARTEMIS_IMPORT_MAX_ROWS=1000
|
||||||
|
ARTEMIS_BATCH_MAX_EXECUTIONS=10000
|
||||||
ARTEMIS_IMPORT_MAX_BYTES=10485760
|
ARTEMIS_IMPORT_MAX_BYTES=10485760
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# cmd.exe requires CRLF for batch scripts, especially UTF-8 Chinese text.
|
||||||
|
*.bat text eol=crlf
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
| 核心类型检查 | `uv run pyright --project pyright-core.json` |
|
| 核心类型检查 | `uv run pyright --project pyright-core.json` |
|
||||||
| 源码打包 | `uv run --no-sync python scripts/package_source.py` |
|
| 源码打包 | `uv run --no-sync python scripts/package_source.py` |
|
||||||
|
|
||||||
Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。开发中需要保留当前源码时传入 `-SkipUpdate`,避免启动脚本拉取远端更新。
|
Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。启动只提示源码更新;升级使用 `update.bat` / `update-cn.bat`,会终止本项目运行进程。开发中可传入 `-SkipUpdate` 跳过更新提示。
|
||||||
|
|
||||||
## Key Paths
|
## Key Paths
|
||||||
|
|
||||||
@@ -58,6 +58,7 @@ Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。开
|
|||||||
- 新增可调参数集中进入配置层,并更新环境示例、网页/MCP 入口和必要文档,避免散落硬编码。
|
- 新增可调参数集中进入配置层,并更新环境示例、网页/MCP 入口和必要文档,避免散落硬编码。
|
||||||
- MCP 工具增删时同步工具注册、CLI 安装白名单、测试、Skill 和能力文档。
|
- MCP 工具增删时同步工具注册、CLI 安装白名单、测试、Skill 和能力文档。
|
||||||
- Windows PowerShell 启动脚本保持兼容 Windows PowerShell 5.1,避免编码导致解析失败。升级只做 Git 快进,保护已有配置和本地修改。
|
- Windows PowerShell 启动脚本保持兼容 Windows PowerShell 5.1,避免编码导致解析失败。升级只做 Git 快进,保护已有配置和本地修改。
|
||||||
|
- `update*.bat` 保持精简的 ASCII 入口;升级程序及其辅助脚本先复制到项目外执行。更新后通过新版 `start.ps1 -PrepareOnly -SkipUpdate` 同步环境,保留此内部调用约定,避免继续运行旧版安装函数。
|
||||||
- 前端遵循现有简体中文文案与视觉样式;不直接编辑构建产物。
|
- 前端遵循现有简体中文文案与视觉样式;不直接编辑构建产物。
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
|
|
||||||
- **自然语言任务**:Flash 适合明确的操作流程,Pro 适合需要计划、分支探索和验证的任务。
|
- **自然语言任务**:Flash 适合明确的操作流程,Pro 适合需要计划、分支探索和验证的任务。
|
||||||
- **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。
|
- **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。
|
||||||
- **批量测试**:下载 Excel 模板、校验导入、顺序执行,支持暂停、继续和复制失败项。
|
- **批量测试**:按新版 Excel 模板导入提示词,每项可独立指定模型、模式和参数;任务可暂停、补充指导并继续。
|
||||||
- **报告与历史**:导出 Word、Excel 和批次 ZIP;历史按日期分组,支持搜索和筛选。
|
- **报告与历史**:导出 Word、Excel、步骤截图、录屏和任务资料 ZIP;历史按日期分组,支持搜索和筛选。
|
||||||
- **AI 接入**:随源码提供 MCP 与 Skill,让 AI 完成设备诊断、任务执行、历史分析和报告交付。
|
- **AI 接入**:随源码提供 MCP 与 Skill,让 AI 完成设备诊断、任务执行、历史分析和报告交付。
|
||||||
|
|
||||||
## 安装与启动
|
## 安装与启动
|
||||||
|
|
||||||
准备一台开启 USB 调试并完成授权的 Android 手机,或已启动的 Android 模拟器。首次安装需要联网,建议使用 Git 克隆以支持后续更新。
|
准备一台开启 USB 调试并完成授权的 Android 手机,或已启动的 Android 模拟器。首次安装需要联网。可使用 Git 克隆或下载 1.1.1 及更新版本的发布源码包;新版 ZIP 解压后也保留 Git 更新能力。
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
@@ -24,21 +24,22 @@ cd Artemis-Eapil
|
|||||||
.\start.bat
|
.\start.bat
|
||||||
```
|
```
|
||||||
|
|
||||||
国内网络使用 `.\start-cn.bat`。启动脚本会检测并安装缺少的 uv、Python 和所需工具,创建或复用 `.venv`,同步依赖并构建网页。启动后访问 **http://127.0.0.1:8000**。
|
国内网络使用 `.\start-cn.bat`,支持 `-Help` 查看中文选项。启动脚本会检测并安装缺少的 uv、Python 和所需工具,创建或复用 `.venv`,同步依赖并构建网页。启动后访问 **http://127.0.0.1:8000**。
|
||||||
|
|
||||||
常用选项:
|
常用选项:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\start-cn.bat -SetupOnly # 仅准备 Python 环境并检查已有 MCP 配置
|
.\start-cn.bat -SetupOnly # 准备环境并安装/更新 Codex MCP
|
||||||
.\start.bat -Port 8001 # 指定端口
|
.\start.bat -Port 8001 # 指定端口
|
||||||
.\start.bat -SkipUpdate # 跳过 Git 源码更新检查
|
.\start.bat -SkipUpdate # 跳过源码更新提示
|
||||||
|
.\start.bat -SkipMcpSync # MCP 配置异常时,仅启动网页
|
||||||
```
|
```
|
||||||
|
|
||||||
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。scrcpy 默认从 GitHub 下载,无法访问时可配置可信的 scrcpy_url 或预先安装。
|
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。scrcpy 国内下载源失败时自动尝试备用源,每个安装包均校验官方 SHA256;也可预先安装,或通过 `overrides.scrcpy_url` 和 `scrcpy_fallback_urls` 自定义 HTTPS 地址。
|
||||||
|
|
||||||
### macOS / Linux
|
### macOS / Linux
|
||||||
|
|
||||||
克隆仓库后在项目目录执行 `./start.sh`。国内镜像入口和启动时自动更新目前由 Windows 脚本提供。
|
克隆仓库后在项目目录执行 `./start.sh`。国内镜像入口和独立升级脚本目前由 Windows 提供。
|
||||||
|
|
||||||
## 配置与使用
|
## 配置与使用
|
||||||
|
|
||||||
@@ -47,21 +48,23 @@ cd Artemis-Eapil
|
|||||||
3. 输入任务目标,选择 Flash 或 Pro 后执行;在工作区查看步骤、截图和结果。
|
3. 输入任务目标,选择 Flash 或 Pro 后执行;在工作区查看步骤、截图和结果。
|
||||||
4. 通过“报告与批量”导出报告或导入 Excel 用例;任务队列支持历史搜索。
|
4. 通过“报告与批量”导出报告或导入 Excel 用例;任务队列支持历史搜索。
|
||||||
|
|
||||||
预设模型为 `gpt-5.6-luna`、`mimo-v2.6-flash`,也可填写自定义名称。默认模型是 `mimo-v2.6-flash`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**;视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。
|
预设模型为 `gpt-5.6-luna`、`gpt-6-luna`、`mimo-v2.6-flash`、`mimo-v2.6-pro`,也可填写自定义名称。默认模型是 `mimo-v2.6-flash`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**。遇到 429 请求过多时改为每次等待 **60 秒**。连续 3 次没有有效工具调用时,本步骤改用 `gpt-6-luna`(high),兜底模型最多再尝试 3 次;下一步恢复首选模型。视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。
|
||||||
|
|
||||||
也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
|
也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
|
||||||
|
|
||||||
|
“功能开关”默认将任务截图长边限制为 2048 像素;关闭后保留设备原始分辨率。可在 [config/artemis.jsonc](config/artemis.jsonc) 的 `agent.screenshot` 中覆盖 `enabled` 和 `max_long_edge`,重启后对新任务生效。
|
||||||
|
|
||||||
报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。
|
报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。
|
||||||
|
|
||||||
## 让 AI 使用
|
## 让 AI 使用
|
||||||
|
|
||||||
环境与模型配置完成后,例如为 Codex 接入 MCP:
|
目前仅支持为 Codex 配置 MCP。Windows 启动脚本检测 Codex 配置目录并安装或更新 ARTEMIS;也可手动安装:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
uv run artemis mcp --install codex
|
uv run artemis mcp --install codex
|
||||||
```
|
```
|
||||||
|
|
||||||
在客户端重新加载 MCP,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。其他客户端可通过 `uv run artemis mcp --help` 查看接入方式。
|
安装或更新后,在 Codex 中重新加载 MCP 或重启 Codex,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。可用 `uv run artemis mcp --generate-config codex` 查看配置模板。
|
||||||
|
|
||||||
示例指令:
|
示例指令:
|
||||||
|
|
||||||
@@ -69,9 +72,11 @@ uv run artemis mcp --install codex
|
|||||||
|
|
||||||
## 更新与排错
|
## 更新与排错
|
||||||
|
|
||||||
Windows 用户再次运行一键脚本时,会检查 Git 上游、项目依赖、前端构建和已有 MCP 配置,仅对源码做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。更新后需在 AI 客户端重新加载 MCP。
|
启动脚本只检查并提示源码更新,不自动拉取。升级运行 **`.\update.bat`**,国内网络运行 **`.\update-cn.bat`**;支持 `-Help`。升级会终止当前项目的 ARTEMIS 服务、MCP 和任务进程,然后快进更新源码,同步依赖、网页构建及 Codex MCP。升级程序先复制到项目外运行,避免更新正在执行的脚本;其他项目、Codex 本身、模拟器和共享 ADB 服务不会被终止。完成后重新启动 ARTEMIS,并在 Codex 中重载 MCP。
|
||||||
|
|
||||||
ZIP 安装可继续运行;需要自动拉取源码时,请改用 Git 克隆安装。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。
|
本地修改、分支分叉或网络认证失败会停止升级,保留现有文件;`.env`、密钥和历史资料不会清空。启动/升级中的依赖或 MCP 同步失败会明确报错,修复后可重新执行;仅启动网页时可用 `-SkipMcpSync`。
|
||||||
|
|
||||||
|
1.1.1 及更新版本的发布 ZIP 含浅层 Git 工作树;包含独立升级入口的新版源码包可直接用上述脚本升级。1.1.0 及更早 ZIP 不含 Git 信息,需改用 Git 克隆或新版源码包。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。
|
||||||
|
|
||||||
更多说明:[批量任务与报告](docs/automation-workspace.md) · [AI 接入](docs/ai-automation.md) · [开发协作指南](AGENTS.md)
|
更多说明:[批量任务与报告](docs/automation-workspace.md) · [AI 接入](docs/ai-automation.md) · [开发协作指南](AGENTS.md)
|
||||||
|
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ class ServerState:
|
|||||||
return {
|
return {
|
||||||
str(run.get("lock_key") or run["device_id"])
|
str(run.get("lock_key") or run["device_id"])
|
||||||
for run in self.active_runs.values()
|
for run in self.active_runs.values()
|
||||||
if run.get("device_id")
|
if run.get("device_id") and not run.get("parked")
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -422,7 +422,7 @@ class SessionRepository:
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
def reconcile_orphaned_sessions(self) -> int:
|
def reconcile_orphaned_sessions(self) -> int:
|
||||||
"""Mark running sessions with no live worker process as failed.
|
"""Mark running sessions with no live worker process as interrupted.
|
||||||
|
|
||||||
This is safe to call both during startup and immediately after a
|
This is safe to call both during startup and immediately after a
|
||||||
forced server stop: live workers, including workers owned by another
|
forced server stop: live workers, including workers owned by another
|
||||||
@@ -442,14 +442,14 @@ class SessionRepository:
|
|||||||
cursor.execute(
|
cursor.execute(
|
||||||
"UPDATE sessions SET status = ?, end_time = ? "
|
"UPDATE sessions SET status = ?, end_time = ? "
|
||||||
"WHERE session_id = ? AND status = ?",
|
"WHERE session_id = ? AND status = ?",
|
||||||
("failed", now, row["session_id"], "running"),
|
("interrupted", now, row["session_id"], "running"),
|
||||||
)
|
)
|
||||||
count += cursor.rowcount
|
count += cursor.rowcount
|
||||||
try:
|
try:
|
||||||
if trace_store.read_status(str(row["session_id"])):
|
if trace_store.read_status(str(row["session_id"])):
|
||||||
trace_store.update_trace_status(
|
trace_store.update_trace_status(
|
||||||
str(row["session_id"]),
|
str(row["session_id"]),
|
||||||
"failed",
|
"interrupted",
|
||||||
error="Process terminated prior to server startup.",
|
error="Process terminated prior to server startup.",
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from apps.admin_console.services.automation_store import identifier, store
|
from apps.admin_console.services.automation_store import identifier, store
|
||||||
from apps.admin_console.services import automation_service as service
|
from apps.admin_console.services import automation_service as service
|
||||||
|
from apps.admin_console.services import batch_archive as archive_service
|
||||||
from apps.admin_console.services import report_export, task_import
|
from apps.admin_console.services import report_export, task_import
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
from apps.admin_console.services.case_review import read_reviewed_result
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/automation", tags=["automation"])
|
router = APIRouter(prefix="/api/automation", tags=["automation"])
|
||||||
|
|
||||||
@@ -21,6 +24,12 @@ class ReportPreferences(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseReviewRequest(BaseModel):
|
||||||
|
model_config = {"extra": "forbid"}
|
||||||
|
status: Literal["passed", "failed"]
|
||||||
|
reason: str = Field(default="", max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
class BatchRequest(ReportPreferences):
|
class BatchRequest(ReportPreferences):
|
||||||
request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,100}$")
|
request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,100}$")
|
||||||
name: str = Field(min_length=1, max_length=200)
|
name: str = Field(min_length=1, max_length=200)
|
||||||
@@ -28,8 +37,9 @@ class BatchRequest(ReportPreferences):
|
|||||||
device_serial: str = Field(min_length=1, max_length=200)
|
device_serial: str = Field(min_length=1, max_length=200)
|
||||||
profile: Literal["flash", "pro"] = "flash"
|
profile: Literal["flash", "pro"] = "flash"
|
||||||
stop_on_failure: bool = False
|
stop_on_failure: bool = False
|
||||||
|
repeat_count: int = Field(default=1, ge=1, strict=True)
|
||||||
timeout_seconds: int = Field(
|
timeout_seconds: int = Field(
|
||||||
default_factory=lambda: int(os.getenv("ARTEMIS_BATCH_TASK_TIMEOUT", "1800")),
|
default_factory=lambda: settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||||
ge=30,
|
ge=30,
|
||||||
le=86400,
|
le=86400,
|
||||||
)
|
)
|
||||||
@@ -49,7 +59,8 @@ def require(kind, key):
|
|||||||
def preferences():
|
def preferences():
|
||||||
return {
|
return {
|
||||||
**store.preferences(),
|
**store.preferences(),
|
||||||
"batch_timeout_seconds": int(os.getenv("ARTEMIS_BATCH_TASK_TIMEOUT", "1800")),
|
"batch_timeout_seconds": settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||||
|
"batch_max_executions": settings.ARTEMIS_BATCH_MAX_EXECUTIONS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -118,7 +129,29 @@ def batch_by_request(request_id: str):
|
|||||||
|
|
||||||
@router.get("/batches/{batch_id}")
|
@router.get("/batches/{batch_id}")
|
||||||
def batch_detail(batch_id: str):
|
def batch_detail(batch_id: str):
|
||||||
return {**require("batch", batch_id), "jobs": store.jobs(batch_id)}
|
jobs = store.jobs(batch_id)
|
||||||
|
for job in jobs:
|
||||||
|
job["test_result"] = read_reviewed_result(
|
||||||
|
job["session_id"], job["status"], job.get("error") or "", storage=store
|
||||||
|
)
|
||||||
|
return {**require("batch", batch_id), "jobs": jobs}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/batches/{batch_id}")
|
||||||
|
def delete_batch(batch_id: str):
|
||||||
|
try:
|
||||||
|
key = identifier(batch_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
try:
|
||||||
|
if archive_service.status(key, require("batch", key))["status"] == "preparing":
|
||||||
|
raise HTTPException(409, "报告正在打包,请完成后再删除批次")
|
||||||
|
if not service.delete_batch(key):
|
||||||
|
raise HTTPException(404, "批次不存在")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(409, str(exc)) from exc
|
||||||
|
archive_service.forget(key)
|
||||||
|
return {"batch_id": key, "status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/batches/{batch_id}/{action}")
|
@router.post("/batches/{batch_id}/{action}")
|
||||||
@@ -130,10 +163,30 @@ def batch_action(batch_id: str, action: Literal["pause", "resume", "retry_failed
|
|||||||
raise HTTPException(409, str(exc)) from exc
|
raise HTTPException(409, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batches/{batch_id}/archive/prepare")
|
||||||
|
def prepare_batch_archive(batch_id: str):
|
||||||
|
batch = require("batch", batch_id)
|
||||||
|
return archive_service.prepare(batch_id, batch)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/batches/{batch_id}/archive/status")
|
||||||
|
def batch_archive_status(batch_id: str):
|
||||||
|
batch = require("batch", batch_id)
|
||||||
|
return archive_service.status(batch_id, batch)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/batches/{batch_id}/archive")
|
@router.get("/batches/{batch_id}/archive")
|
||||||
async def batch_archive(batch_id: str):
|
async def batch_archive(batch_id: str):
|
||||||
require("batch", batch_id)
|
batch = require("batch", batch_id)
|
||||||
path = await asyncio.to_thread(report_export.export_batch, batch_id)
|
if archive_service.status(batch_id, batch)["status"] == "preparing":
|
||||||
|
raise HTTPException(409, "报告正在打包,请等待准备完成")
|
||||||
|
path = archive_service.archive_path(batch_id)
|
||||||
|
if (
|
||||||
|
not path.is_file()
|
||||||
|
or not batch.get("report_ready")
|
||||||
|
and archive_service.status(batch_id, batch)["status"] != "ready"
|
||||||
|
):
|
||||||
|
path = await asyncio.to_thread(report_export.export_batch, batch_id)
|
||||||
return FileResponse(path, filename=f"批次报告-{batch_id[:8]}.zip")
|
return FileResponse(path, filename=f"批次报告-{batch_id[:8]}.zip")
|
||||||
|
|
||||||
|
|
||||||
@@ -146,14 +199,56 @@ def report(session_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/reports/{session_id}/{fmt}")
|
@router.get("/reports/{session_id}/{fmt}")
|
||||||
async def download_report(session_id: str, fmt: Literal["docx", "xlsx"]):
|
async def download_report(
|
||||||
|
session_id: str, fmt: Literal["docx", "xlsx", "screenshots", "video", "all"]
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
|
if fmt in {"screenshots", "video", "all"}:
|
||||||
|
path = await asyncio.to_thread(report_export.export_session_media, session_id, fmt)
|
||||||
|
label = report_export.archive_case_name(report_export.session_report(session_id))
|
||||||
|
return FileResponse(path, filename=f"{label}-{fmt}.zip")
|
||||||
paths = await asyncio.to_thread(report_export.export_session, session_id, [fmt])
|
paths = await asyncio.to_thread(report_export.export_session, session_id, [fmt])
|
||||||
return FileResponse(paths[0], filename=f"任务报告-{session_id[:8]}.{fmt}")
|
return FileResponse(paths[0], filename=f"任务报告-{session_id[:8]}.{fmt}")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(404, str(exc)) from exc
|
raise HTTPException(404, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/reports/{session_id}/review")
|
||||||
|
def review_case(session_id: str, value: CaseReviewRequest):
|
||||||
|
report(session_id)
|
||||||
|
job = store.job(session_id) or {}
|
||||||
|
batch_id = job.get("batch_id")
|
||||||
|
if (
|
||||||
|
batch_id
|
||||||
|
and archive_service.status(batch_id, require("batch", batch_id))["status"] == "preparing"
|
||||||
|
):
|
||||||
|
raise HTTPException(409, "批次报告正在打包,请完成后再保存人工复验")
|
||||||
|
with report_export.EXPORT_LOCK:
|
||||||
|
current = report(session_id)
|
||||||
|
if current["status"] not in {
|
||||||
|
"completed",
|
||||||
|
"success",
|
||||||
|
"failed",
|
||||||
|
"cancelled",
|
||||||
|
"interrupted",
|
||||||
|
"skipped",
|
||||||
|
}:
|
||||||
|
raise HTTPException(409, "请在任务结束后进行人工复验")
|
||||||
|
try:
|
||||||
|
store.save_case_review(session_id, value.status, value.reason)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(409, str(exc)) from exc
|
||||||
|
if batch_id:
|
||||||
|
archive_service.forget(batch_id)
|
||||||
|
# A finishing worker can publish report_ready just after export;
|
||||||
|
# removing only the generated ZIP also prevents that stale cache hit.
|
||||||
|
try:
|
||||||
|
archive_service.archive_path(batch_id).unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass # An open download may hold the file; report_ready is already invalidated.
|
||||||
|
return report(session_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/history")
|
@router.get("/history")
|
||||||
def history(
|
def history(
|
||||||
q: str = Query(default="", max_length=200),
|
q: str = Query(default="", max_length=200),
|
||||||
@@ -162,26 +257,49 @@ def history(
|
|||||||
end: float | None = None,
|
end: float | None = None,
|
||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
size: int = Query(default=30, ge=1, le=100),
|
size: int = Query(default=30, ge=1, le=100),
|
||||||
|
batch_id: str = Query(default="", max_length=100),
|
||||||
):
|
):
|
||||||
# SQL filtering/pagination searches the entire persisted history, not a UI cache.
|
# SQL filtering/pagination searches the entire persisted history, not a UI cache.
|
||||||
query = """WITH history AS (
|
query = """WITH history AS (
|
||||||
SELECT s.session_id, s.initial_goal, s.start_time, s.end_time,
|
SELECT s.session_id, s.initial_goal, s.start_time,
|
||||||
|
COALESCE(json_extract(j.payload,'$.ended_at'),s.end_time) end_time,
|
||||||
COALESCE(j.status,s.status) status, COALESCE(json_extract(j.payload,'$.case.name'),s.initial_goal) name,
|
COALESCE(j.status,s.status) status, COALESCE(json_extract(j.payload,'$.case.name'),s.initial_goal) name,
|
||||||
COALESCE(json_extract(j.payload,'$.case.tags'),'') tags
|
COALESCE(json_extract(j.payload,'$.case.tags'),'') tags,
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.case_id'),'') case_id,
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.category'),'') category,
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory,
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions,
|
||||||
|
COALESCE(json_extract(j.payload,'$.error'),'') error, j.batch_id
|
||||||
FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id
|
FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT j.session_id,json_extract(j.payload,'$.case.goal'),
|
SELECT j.session_id,json_extract(j.payload,'$.case.goal'),
|
||||||
COALESCE(json_extract(j.payload,'$.started_at'),json_extract(j.payload,'$.created_at')),
|
COALESCE(json_extract(j.payload,'$.started_at'),json_extract(j.payload,'$.created_at')),
|
||||||
json_extract(j.payload,'$.ended_at'),j.status,json_extract(j.payload,'$.case.name'),
|
json_extract(j.payload,'$.ended_at'),j.status,json_extract(j.payload,'$.case.name'),
|
||||||
COALESCE(json_extract(j.payload,'$.case.tags'),'')
|
COALESCE(json_extract(j.payload,'$.case.tags'),''),
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.case_id'),''),
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.category'),''),
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.subcategory'),''),
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.preconditions'),''),
|
||||||
|
COALESCE(json_extract(j.payload,'$.error'),''), j.batch_id
|
||||||
FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id)
|
FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id)
|
||||||
) """
|
) """
|
||||||
where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')"
|
where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')"
|
||||||
args = []
|
args = []
|
||||||
|
if batch_id == "__batch__":
|
||||||
|
where += " AND batch_id IS NOT NULL"
|
||||||
|
elif batch_id == "__single__":
|
||||||
|
where += " AND batch_id IS NULL"
|
||||||
|
elif batch_id:
|
||||||
|
try:
|
||||||
|
identifier(batch_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
where += " AND batch_id = ?"
|
||||||
|
args.append(batch_id)
|
||||||
if q:
|
if q:
|
||||||
where += " AND (name LIKE ? ESCAPE '\\' OR initial_goal LIKE ? ESCAPE '\\' OR session_id LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')"
|
where += " AND (name LIKE ? ESCAPE '\\' OR initial_goal LIKE ? ESCAPE '\\' OR session_id LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\' OR case_id LIKE ? ESCAPE '\\' OR category LIKE ? ESCAPE '\\' OR subcategory LIKE ? ESCAPE '\\' OR preconditions LIKE ? ESCAPE '\\')"
|
||||||
escaped = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
escaped = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
||||||
args.extend([escaped] * 4)
|
args.extend([escaped] * 8)
|
||||||
for condition, value in [
|
for condition, value in [
|
||||||
("status = ?", status or None),
|
("status = ?", status or None),
|
||||||
("start_time >= ?", start),
|
("start_time >= ?", start),
|
||||||
@@ -199,4 +317,16 @@ def history(
|
|||||||
+ " ORDER BY start_time DESC,session_id LIMIT ? OFFSET ?",
|
+ " ORDER BY start_time DESC,session_id LIMIT ? OFFSET ?",
|
||||||
[*args, size, (page - 1) * size],
|
[*args, size, (page - 1) * size],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return {"items": [dict(r) for r in rows], "total": total, "page": page, "size": size}
|
items = [dict(row) for row in rows]
|
||||||
|
jobs = store.jobs_for_sessions([item["session_id"] for item in items])
|
||||||
|
for item in items:
|
||||||
|
item["test_result"] = read_reviewed_result(
|
||||||
|
item["session_id"], item["status"], item.get("error") or "", storage=store
|
||||||
|
)
|
||||||
|
if job := jobs.get(item["session_id"]):
|
||||||
|
item["name"] = task_import.execution_title(job)
|
||||||
|
item["iteration"] = job.get("iteration", 1)
|
||||||
|
item["repeat_count"] = job.get("repeat_count", 1)
|
||||||
|
elif item["case_id"]:
|
||||||
|
item["name"] = task_import.case_title(item)
|
||||||
|
return {"items": items, "total": total, "page": page, "size": size}
|
||||||
@@ -20,6 +20,7 @@ from fastapi import APIRouter, HTTPException
|
|||||||
|
|
||||||
from artemis.config import DB_PATH, TRACES_PATH
|
from artemis.config import DB_PATH, TRACES_PATH
|
||||||
from artemis.runtime import trace_store
|
from artemis.runtime import trace_store
|
||||||
|
from apps.admin_console.services.case_review import read_reviewed_result
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from admin_console.core.state import state
|
from admin_console.core.state import state
|
||||||
@@ -27,12 +28,16 @@ try:
|
|||||||
from admin_console.database.repositories.trace_repository import trace_repo
|
from admin_console.database.repositories.trace_repository import trace_repo
|
||||||
from admin_console.services.media_service import media_service
|
from admin_console.services.media_service import media_service
|
||||||
from admin_console.services.model_service import model_service
|
from admin_console.services.model_service import model_service
|
||||||
|
from admin_console.services.automation_store import store
|
||||||
|
from admin_console.services.task_import import execution_title
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from apps.admin_console.core.state import state
|
from apps.admin_console.core.state import state
|
||||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||||
from apps.admin_console.database.repositories.trace_repository import trace_repo
|
from apps.admin_console.database.repositories.trace_repository import trace_repo
|
||||||
from apps.admin_console.services.media_service import media_service
|
from apps.admin_console.services.media_service import media_service
|
||||||
from apps.admin_console.services.model_service import model_service
|
from apps.admin_console.services.model_service import model_service
|
||||||
|
from apps.admin_console.services.automation_store import store
|
||||||
|
from apps.admin_console.services.task_import import execution_title
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(tags=["sessions"])
|
router = APIRouter(tags=["sessions"])
|
||||||
@@ -52,6 +57,7 @@ async def list_sessions():
|
|||||||
|
|
||||||
def _list_sessions_sync():
|
def _list_sessions_sync():
|
||||||
rows = session_repo.get_all_sessions()
|
rows = session_repo.get_all_sessions()
|
||||||
|
jobs_by_id = store.jobs_for_sessions([str(row.get("session_id")) for row in rows])
|
||||||
video_rec_map = session_repo.get_video_recordings_map()
|
video_rec_map = session_repo.get_video_recordings_map()
|
||||||
latest_recordings = session_repo.get_latest_video_recordings_map()
|
latest_recordings = session_repo.get_latest_video_recordings_map()
|
||||||
agent_names_by_session = session_repo.get_agent_trace_names_map()
|
agent_names_by_session = session_repo.get_agent_trace_names_map()
|
||||||
@@ -74,6 +80,18 @@ def _list_sessions_sync():
|
|||||||
|
|
||||||
for row_dict in rows:
|
for row_dict in rows:
|
||||||
s_id = str(row_dict.get("session_id"))
|
s_id = str(row_dict.get("session_id"))
|
||||||
|
job = jobs_by_id.get(s_id)
|
||||||
|
if job:
|
||||||
|
case = job.get("case") or {}
|
||||||
|
row_dict["batch_id"] = job.get("batch_id")
|
||||||
|
row_dict["case_id"] = case.get("case_id") or ""
|
||||||
|
row_dict["display_name"] = execution_title(job) or row_dict.get("initial_goal")
|
||||||
|
row_dict["iteration"] = job.get("iteration", 1)
|
||||||
|
row_dict["repeat_count"] = job.get("repeat_count", 1)
|
||||||
|
row_dict["error"] = job.get("error") or ""
|
||||||
|
if job.get("status") in {"completed", "failed", "cancelled", "interrupted", "skipped"}:
|
||||||
|
row_dict["status"] = job["status"]
|
||||||
|
row_dict["end_time"] = job.get("ended_at") or row_dict.get("end_time")
|
||||||
recording = latest_recordings.get(s_id)
|
recording = latest_recordings.get(s_id)
|
||||||
d_info_raw = row_dict.get("device_info")
|
d_info_raw = row_dict.get("device_info")
|
||||||
device_id = None
|
device_id = None
|
||||||
@@ -114,6 +132,13 @@ def _list_sessions_sync():
|
|||||||
else:
|
else:
|
||||||
row_dict["video_url"] = None
|
row_dict["video_url"] = None
|
||||||
row_dict["recording_status"] = recording_status
|
row_dict["recording_status"] = recording_status
|
||||||
|
row_dict["test_result"] = read_reviewed_result(
|
||||||
|
s_id,
|
||||||
|
row_dict.get("status") or "",
|
||||||
|
row_dict.get("error") or "",
|
||||||
|
root=TRACES_PATH,
|
||||||
|
storage=store,
|
||||||
|
)
|
||||||
|
|
||||||
agent_names = agent_names_by_session.get(s_id, [])
|
agent_names = agent_names_by_session.get(s_id, [])
|
||||||
sess_profile = model_service.resolve_session_profile(
|
sess_profile = model_service.resolve_session_profile(
|
||||||
@@ -173,7 +198,13 @@ async def get_session_details(session_id: str):
|
|||||||
row = session_repo.get_session_by_id(session_id)
|
row = session_repo.get_session_by_id(session_id)
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||||
return dict(row)
|
result = dict(row)
|
||||||
|
job = store.job(session_id) or {}
|
||||||
|
result["batch_id"] = job.get("batch_id")
|
||||||
|
result["test_result"] = read_reviewed_result(
|
||||||
|
session_id, result.get("status") or "", root=TRACES_PATH, storage=store
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/sessions/{session_id}/usage")
|
@router.get("/api/sessions/{session_id}/usage")
|
||||||
|
|||||||
@@ -517,9 +517,12 @@ async def get_model_config_and_env():
|
|||||||
|
|
||||||
@router.get("/server-status")
|
@router.get("/server-status")
|
||||||
async def get_server_runtime_status():
|
async def get_server_runtime_status():
|
||||||
"""Retrieve runtime status, PID, port, and uptime of the Artemis server."""
|
"""Return this server's status without scanning listening ports."""
|
||||||
import os
|
import os
|
||||||
from artemis.runtime.server_lifecycle import get_server_status
|
import time
|
||||||
|
|
||||||
|
from artemis.runtime.process_probe import pid_is_alive
|
||||||
|
from artemis.runtime.server_lifecycle import read_server_info
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from apps.admin_console.core.state import state
|
from apps.admin_console.core.state import state
|
||||||
@@ -527,18 +530,34 @@ async def get_server_runtime_status():
|
|||||||
from admin_console.core.state import state
|
from admin_console.core.state import state
|
||||||
|
|
||||||
port = getattr(state, "port", 8000)
|
port = getattr(state, "port", 8000)
|
||||||
status = get_server_status(port=port)
|
current_pid = os.getpid()
|
||||||
|
pids = {current_pid}
|
||||||
|
started_at = None
|
||||||
|
info = read_server_info()
|
||||||
|
if info and info.get("port") == port:
|
||||||
|
saved_pid = info.get("pid")
|
||||||
|
if isinstance(saved_pid, int) and saved_pid != current_pid and pid_is_alive(saved_pid):
|
||||||
|
pids.add(saved_pid)
|
||||||
|
if saved_pid == current_pid and isinstance(info.get("started_at"), (int, float)):
|
||||||
|
started_at = float(info["started_at"])
|
||||||
|
if started_at is None:
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
started_at = psutil.Process(current_pid).create_time()
|
||||||
|
except Exception:
|
||||||
|
started_at = None
|
||||||
# Explicit DTO: the raw metadata file additionally holds the lifecycle
|
# Explicit DTO: the raw metadata file additionally holds the lifecycle
|
||||||
# token, cmdline, and filesystem paths, none of which belong on the wire.
|
# token, cmdline, and filesystem paths, none of which belong on the wire.
|
||||||
return {
|
return {
|
||||||
"running": status["running"],
|
"running": True,
|
||||||
"port": status["port"],
|
"port": port,
|
||||||
"pids": status["pids"],
|
"pids": sorted(pids),
|
||||||
"active_pid": status["active_pid"],
|
"active_pid": current_pid,
|
||||||
"uptime_seconds": status["uptime_seconds"],
|
"uptime_seconds": max(0.0, time.time() - started_at) if started_at else None,
|
||||||
"url": status["url"],
|
"url": f"http://localhost:{port}",
|
||||||
"admin_url": status["admin_url"],
|
"admin_url": f"http://localhost:{port}/admin",
|
||||||
"current_pid": os.getpid(),
|
"current_pid": current_pid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,19 @@ import json
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from artemis.core.diagnostics import readiness_engine
|
from artemis.core.diagnostics import readiness_engine
|
||||||
|
from apps.admin_console.services.automation_store import store
|
||||||
|
from apps.admin_console.services.task_import import execution_title
|
||||||
from artemis.runtime import DeviceExecutionLock, device_pool
|
from artemis.runtime import DeviceExecutionLock, device_pool
|
||||||
|
from artemis.runtime.task_control import (
|
||||||
|
add_instruction,
|
||||||
|
control_dir,
|
||||||
|
list_instructions,
|
||||||
|
read_state,
|
||||||
|
request_pause,
|
||||||
|
request_resume,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from admin_console.core.state import state
|
from admin_console.core.state import state
|
||||||
@@ -42,6 +53,59 @@ except ImportError:
|
|||||||
router = APIRouter(tags=["tasks"])
|
router = APIRouter(tags=["tasks"])
|
||||||
|
|
||||||
|
|
||||||
|
class GuidanceRequest(BaseModel):
|
||||||
|
instruction: str = Field(min_length=1, max_length=10000)
|
||||||
|
|
||||||
|
|
||||||
|
def _live_task(session_id: str) -> dict:
|
||||||
|
from apps.admin_console.services.automation_store import identifier
|
||||||
|
|
||||||
|
identifier(session_id)
|
||||||
|
run = state.active_runs.get(session_id)
|
||||||
|
if not run or run["process"].returncode is not None:
|
||||||
|
raise HTTPException(409, "任务未在执行,无法暂停或指导")
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/tasks/{session_id}/pause")
|
||||||
|
def pause_running_task(session_id: str):
|
||||||
|
_live_task(session_id)
|
||||||
|
request_pause(session_id)
|
||||||
|
return {"session_id": session_id, "status": "pausing"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/tasks/{session_id}/guide")
|
||||||
|
def guide_running_task(session_id: str, value: GuidanceRequest):
|
||||||
|
_live_task(session_id)
|
||||||
|
guidance = add_instruction(session_id, value.instruction, source="web")
|
||||||
|
if (
|
||||||
|
read_state(session_id).get("status") == "task_paused"
|
||||||
|
or (control_dir(session_id) / "pause.request").exists()
|
||||||
|
):
|
||||||
|
request_resume(session_id)
|
||||||
|
return {"session_id": session_id, "status": "guidance_submitted", "guidance": guidance}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tasks/{session_id}/guidance")
|
||||||
|
def task_guidance_history(session_id: str):
|
||||||
|
from apps.admin_console.services.automation_store import identifier
|
||||||
|
|
||||||
|
try:
|
||||||
|
identifier(session_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
return list_instructions(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/tasks/{session_id}/continue")
|
||||||
|
def continue_running_task(session_id: str):
|
||||||
|
_live_task(session_id)
|
||||||
|
if read_state(session_id).get("status") != "task_paused":
|
||||||
|
raise HTTPException(409, "任务尚未暂停")
|
||||||
|
request_resume(session_id)
|
||||||
|
return {"session_id": session_id, "status": "resuming"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/tasks/presets")
|
@router.get("/api/tasks/presets")
|
||||||
async def get_task_presets(
|
async def get_task_presets(
|
||||||
category: str = "recommended",
|
category: str = "recommended",
|
||||||
@@ -140,7 +204,9 @@ async def run_task(request: RunRequest):
|
|||||||
# With no explicit serial the probe itself resolves a live target (it
|
# With no explicit serial the probe itself resolves a live target (it
|
||||||
# prefers the diagnostics target preference, then any unlocked ready
|
# prefers the diagnostics target preference, then any unlocked ready
|
||||||
# device); the verified serial is bound below.
|
# device); the verified serial is bound below.
|
||||||
target_serial = request.device_serial
|
target_serial = (
|
||||||
|
request.runtime_overrides.device_serial if request.runtime_overrides else None
|
||||||
|
) or request.device_serial
|
||||||
device_probe = await readiness_engine.run_device_submission_probe(target_serial=target_serial)
|
device_probe = await readiness_engine.run_device_submission_probe(target_serial=target_serial)
|
||||||
if device_probe and device_probe.summary in {"Device Locked", "Lock State Unknown"}:
|
if device_probe and device_probe.summary in {"Device Locked", "Lock State Unknown"}:
|
||||||
locked_serial = (
|
locked_serial = (
|
||||||
@@ -162,7 +228,7 @@ async def run_task(request: RunRequest):
|
|||||||
# Only auto-selected targets may be re-bound to the probed device. An
|
# Only auto-selected targets may be re-bound to the probed device. An
|
||||||
# explicitly requested serial is never silently replaced -- if it is
|
# explicitly requested serial is never silently replaced -- if it is
|
||||||
# invalid, enqueue_tasks rejects the submission with a clear error.
|
# invalid, enqueue_tasks rejects the submission with a clear error.
|
||||||
if verified_serial and not request.device_serial:
|
if verified_serial and not target_serial:
|
||||||
target_serial = verified_serial
|
target_serial = verified_serial
|
||||||
|
|
||||||
response = await task_queue_service.enqueue_tasks(
|
response = await task_queue_service.enqueue_tasks(
|
||||||
@@ -178,6 +244,9 @@ async def run_task(request: RunRequest):
|
|||||||
ingress=request.ingress or "frontend",
|
ingress=request.ingress or "frontend",
|
||||||
session_id=request.session_id,
|
session_id=request.session_id,
|
||||||
conversation_id=request.conversation_id,
|
conversation_id=request.conversation_id,
|
||||||
|
runtime_overrides=request.runtime_overrides.model_dump(exclude_none=True)
|
||||||
|
if request.runtime_overrides
|
||||||
|
else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
from apps.admin_console.services.automation_service import register_submission
|
from apps.admin_console.services.automation_service import register_submission
|
||||||
@@ -207,7 +276,12 @@ async def get_run_defaults():
|
|||||||
async def list_devices():
|
async def list_devices():
|
||||||
"""List all connected Android devices with their busy / idle status."""
|
"""List all connected Android devices with their busy / idle status."""
|
||||||
devices = await device_pool.list_devices_async()
|
devices = await device_pool.list_devices_async()
|
||||||
return {"devices": [d.to_dict() for d in devices]}
|
from artemis.runtime.adb_discovery import adb_device_discovery
|
||||||
|
|
||||||
|
return {
|
||||||
|
"devices": [d.to_dict() for d in devices],
|
||||||
|
"automatic_connections": adb_device_discovery.results.copy(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/stop")
|
@router.post("/api/stop")
|
||||||
@@ -285,6 +359,17 @@ async def get_status():
|
|||||||
}
|
}
|
||||||
for owner in active_owners.values()
|
for owner in active_owners.values()
|
||||||
]
|
]
|
||||||
|
for sid, run in state.active_runs.items():
|
||||||
|
if run.get("parked"):
|
||||||
|
active_tasks.append(
|
||||||
|
{
|
||||||
|
"session_id": sid,
|
||||||
|
"device_id": run.get("device_id"),
|
||||||
|
"goal": run.get("goal"),
|
||||||
|
"pid": getattr(run.get("process"), "pid", None),
|
||||||
|
"status": read_state(sid).get("status") or "task_paused",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
running_sid = (
|
running_sid = (
|
||||||
(global_owner.session_id if global_owner else None)
|
(global_owner.session_id if global_owner else None)
|
||||||
@@ -340,6 +425,15 @@ async def get_status():
|
|||||||
seen_ids.add(sid)
|
seen_ids.add(sid)
|
||||||
queue_data.append(g_item)
|
queue_data.append(g_item)
|
||||||
|
|
||||||
|
batch_jobs = store.jobs_for_sessions(
|
||||||
|
[str(item["session_id"]) for item in [*queue_data, *active_tasks] if item.get("session_id")]
|
||||||
|
)
|
||||||
|
queue_data = [{**item} for item in queue_data]
|
||||||
|
for item in [*queue_data, *active_tasks]:
|
||||||
|
if job := batch_jobs.get(str(item.get("session_id"))):
|
||||||
|
item["batch_id"] = job.get("batch_id")
|
||||||
|
item["display_name"] = execution_title(job)
|
||||||
|
|
||||||
if is_running:
|
if is_running:
|
||||||
is_paused = state.is_paused
|
is_paused = state.is_paused
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from apps.admin_console.services.task_import import TaskOptions
|
||||||
|
|
||||||
|
|
||||||
class RunRequest(BaseModel):
|
class RunRequest(BaseModel):
|
||||||
@@ -32,6 +33,7 @@ class RunRequest(BaseModel):
|
|||||||
ingress: str | None = "frontend"
|
ingress: str | None = "frontend"
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
conversation_id: str | None = None
|
conversation_id: str | None = None
|
||||||
|
runtime_overrides: TaskOptions | None = None
|
||||||
|
|
||||||
|
|
||||||
class ReplayRequest(BaseModel):
|
class ReplayRequest(BaseModel):
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import secrets
|
import secrets
|
||||||
import signal
|
import signal
|
||||||
|
import socket
|
||||||
import sys
|
import sys
|
||||||
from types import FrameType
|
from types import FrameType
|
||||||
|
|
||||||
@@ -152,7 +153,7 @@ async def on_startup():
|
|||||||
|
|
||||||
cleaned = session_repo.cleanup_orphans_on_startup()
|
cleaned = session_repo.cleanup_orphans_on_startup()
|
||||||
if cleaned > 0:
|
if cleaned > 0:
|
||||||
print(f"[ServerStartup] Marked {cleaned} orphaned running session(s) as failed.")
|
print(f"[ServerStartup] Marked {cleaned} orphaned running session(s) as interrupted.")
|
||||||
# Workers killed together with a previous daemon never remuxed their
|
# Workers killed together with a previous daemon never remuxed their
|
||||||
# recordings; publish whatever raw files they left behind.
|
# recordings; publish whatever raw files they left behind.
|
||||||
asyncio.create_task(asyncio.to_thread(task_queue_service.recover_orphaned_recordings_on_launch))
|
asyncio.create_task(asyncio.to_thread(task_queue_service.recover_orphaned_recordings_on_launch))
|
||||||
@@ -176,7 +177,9 @@ async def on_shutdown():
|
|||||||
owned_session_ids = {
|
owned_session_ids = {
|
||||||
str(item["session_id"])
|
str(item["session_id"])
|
||||||
for item in state.queue_items
|
for item in state.queue_items
|
||||||
if isinstance(item, dict) and item.get("status") == "running" and item.get("session_id")
|
if isinstance(item, dict)
|
||||||
|
and item.get("status") in {"running", "task_paused", "resuming"}
|
||||||
|
and item.get("session_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
worker = state.worker_task
|
worker = state.worker_task
|
||||||
@@ -212,7 +215,7 @@ async def on_shutdown():
|
|||||||
state.current_process = None
|
state.current_process = None
|
||||||
state.queue_items.clear()
|
state.queue_items.clear()
|
||||||
for session_id in owned_session_ids:
|
for session_id in owned_session_ids:
|
||||||
session_repo.update_session_status(session_id, "cancelled")
|
session_repo.update_session_status(session_id, "interrupted")
|
||||||
|
|
||||||
from apps.admin_console.services.automation_service import background_tasks
|
from apps.admin_console.services.automation_service import background_tasks
|
||||||
|
|
||||||
@@ -419,6 +422,14 @@ queue_goals = state.queue_goals
|
|||||||
class ArtemisUvicornServer(uvicorn.Server):
|
class ArtemisUvicornServer(uvicorn.Server):
|
||||||
"""Notify application streams before Uvicorn waits for them to close."""
|
"""Notify application streams before Uvicorn waits for them to close."""
|
||||||
|
|
||||||
|
async def startup(self, sockets: list[socket.socket] | None = None) -> None:
|
||||||
|
# Reload workers start in a fresh interpreter; restore the actual bind
|
||||||
|
# address there before lifespan publishes runtime metadata.
|
||||||
|
state.host = self.config.host
|
||||||
|
state.port = self.config.port
|
||||||
|
app.state.uvicorn_server = self
|
||||||
|
await super().startup(sockets=sockets)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _task_is_active() -> bool:
|
def _task_is_active() -> bool:
|
||||||
"""Check task state without doing process I/O from a signal handler."""
|
"""Check task state without doing process I/O from a signal handler."""
|
||||||
@@ -468,31 +479,40 @@ class ArtemisUvicornServer(uvicorn.Server):
|
|||||||
|
|
||||||
def run_ui_server(host: str, port: int, reload: bool = False) -> None:
|
def run_ui_server(host: str, port: int, reload: bool = False) -> None:
|
||||||
"""Run the UI server with bounded, signal-aware graceful shutdown."""
|
"""Run the UI server with bounded, signal-aware graceful shutdown."""
|
||||||
|
# Reserve a listening socket before publishing metadata or running lifespan.
|
||||||
|
# Uvicorn normally runs application startup *before* binding its port; a
|
||||||
|
# duplicate daemon would otherwise recover jobs owned by the real server.
|
||||||
|
family = socket.AF_INET6 if ":" in host else socket.AF_INET
|
||||||
|
try:
|
||||||
|
listener = socket.create_server((host, port), family=family)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"无法启动服务:{host}:{port} 端口不可用({exc})。现有服务和任务保持不变。")
|
||||||
|
raise SystemExit(1) from exc
|
||||||
|
listener.set_inheritable(True)
|
||||||
|
port = listener.getsockname()[1]
|
||||||
state.host = host
|
state.host = host
|
||||||
state.port = port
|
state.port = port
|
||||||
write_server_info(port=port, host=host, lifecycle_token=LIFECYCLE_TOKEN)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if reload:
|
|
||||||
uvicorn.run(
|
|
||||||
"apps.admin_console.server:app",
|
|
||||||
host=host,
|
|
||||||
port=port,
|
|
||||||
reload=True,
|
|
||||||
timeout_graceful_shutdown=5,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
config = uvicorn.Config(
|
config = uvicorn.Config(
|
||||||
app,
|
"apps.admin_console.server:app" if reload else app,
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
|
reload=reload,
|
||||||
timeout_graceful_shutdown=5,
|
timeout_graceful_shutdown=5,
|
||||||
)
|
)
|
||||||
|
if reload:
|
||||||
|
from uvicorn.supervisors import ChangeReload
|
||||||
|
|
||||||
|
server = ArtemisUvicornServer(config)
|
||||||
|
ChangeReload(config, target=server.run, sockets=[listener]).run()
|
||||||
|
return
|
||||||
|
|
||||||
server = ArtemisUvicornServer(config)
|
server = ArtemisUvicornServer(config)
|
||||||
app.state.uvicorn_server = server
|
app.state.uvicorn_server = server
|
||||||
server.run()
|
server.run(sockets=[listener])
|
||||||
finally:
|
finally:
|
||||||
|
listener.close()
|
||||||
app.state.uvicorn_server = None
|
app.state.uvicorn_server = None
|
||||||
clear_server_info(port=port, lifecycle_token=LIFECYCLE_TOKEN)
|
clear_server_info(port=port, lifecycle_token=LIFECYCLE_TOKEN)
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,15 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import time
|
import time
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from apps.admin_console.core.state import state
|
from apps.admin_console.core.state import state
|
||||||
from apps.admin_console.services.automation_store import store, new_id
|
from apps.admin_console.services.automation_store import RECOVERY_ERRORS, store, new_id
|
||||||
from apps.admin_console.services.task_import import case_goal
|
from apps.admin_console.services.task_import import case_goal, execution_title
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
from artemis.runtime.task_limits import task_timeout
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"}
|
TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"}
|
||||||
@@ -38,16 +42,56 @@ def register_submission(item: dict, case: dict | None = None, formats=None, **ex
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_recovery_error(job: dict) -> bool:
|
||||||
|
"""Archive a superseded recovery warning without erasing execution failures."""
|
||||||
|
if job.get("error") not in RECOVERY_ERRORS:
|
||||||
|
return False
|
||||||
|
job.setdefault("recovery_events", []).append(
|
||||||
|
{"message": job.pop("error"), "resolved_at": time.time()}
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def recover_batches():
|
def recover_batches():
|
||||||
"""Recovery never silently repeats a potentially side-effecting interrupted task."""
|
"""Recovery never silently repeats a potentially side-effecting interrupted task."""
|
||||||
|
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||||
|
from artemis.runtime import DeviceExecutionLock
|
||||||
|
|
||||||
|
repository = SessionRepository(store.path)
|
||||||
|
live_sessions = {
|
||||||
|
str(owner.session_id)
|
||||||
|
for owner in DeviceExecutionLock.get_active_owners().values()
|
||||||
|
if owner.session_id
|
||||||
|
}
|
||||||
|
live_sessions.update(
|
||||||
|
str(row["session_id"])
|
||||||
|
for row in repository.get_all_sessions()
|
||||||
|
if row.get("status") not in TERMINAL and repository.process_is_alive(row.get("pid"))
|
||||||
|
)
|
||||||
|
live_sessions.update(
|
||||||
|
str(item["session_id"])
|
||||||
|
for item in state.queue_items
|
||||||
|
if isinstance(item, dict) and item.get("session_id") and item.get("status") not in TERMINAL
|
||||||
|
)
|
||||||
for job in store.jobs():
|
for job in store.jobs():
|
||||||
if job["status"] in {"running", "queued", "finalizing"}:
|
if job["status"] == "completed" and resolve_recovery_error(job):
|
||||||
|
store.save_job(job)
|
||||||
|
if str(job["session_id"]) in live_sessions:
|
||||||
|
if job["status"] not in TERMINAL and str(job["session_id"]) not in state.active_runs:
|
||||||
|
job["recovery_waiting"] = True
|
||||||
|
store.save_job(job)
|
||||||
|
logger.info("Recovery preserved live session %s", job["session_id"])
|
||||||
|
continue
|
||||||
|
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
|
||||||
job.update(
|
job.update(
|
||||||
status=job.get("terminal_status", "interrupted"),
|
status=job.get("terminal_status", "interrupted"),
|
||||||
error=job.get("error")
|
error=job.get("error")
|
||||||
or ("" if job.get("terminal_status") else "服务中断;未完成任务需手动重跑"),
|
or ("" if job.get("terminal_status") else "服务中断;未完成任务需手动重跑"),
|
||||||
ended_at=job.get("ended_at") or time.time(),
|
ended_at=job.get("ended_at") or time.time(),
|
||||||
)
|
)
|
||||||
|
if job["status"] == "completed":
|
||||||
|
resolve_recovery_error(job)
|
||||||
|
logger.info("Recovery resolved session %s to %s", job["session_id"], job["status"])
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
for batch in store.records("batch"):
|
for batch in store.records("batch"):
|
||||||
if has_legacy_replay(batch["id"]):
|
if has_legacy_replay(batch["id"]):
|
||||||
@@ -60,11 +104,13 @@ def recover_batches():
|
|||||||
store.put("batch", batch["id"], batch)
|
store.put("batch", batch["id"], batch)
|
||||||
continue
|
continue
|
||||||
if batch["status"] in {"running", "pausing", "paused"}:
|
if batch["status"] in {"running", "pausing", "paused"}:
|
||||||
|
if any(str(job["session_id"]) in live_sessions for job in store.jobs(batch["id"])):
|
||||||
|
continue
|
||||||
batch.update(
|
batch.update(
|
||||||
status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务"
|
status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务"
|
||||||
)
|
)
|
||||||
for job in store.jobs(batch["id"]):
|
for job in store.jobs(batch["id"]):
|
||||||
if job["status"] in {"running", "queued", "finalizing"}:
|
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
|
||||||
job.update(
|
job.update(
|
||||||
status="interrupted", error="服务中断,需手动重跑", ended_at=time.time()
|
status="interrupted", error="服务中断,需手动重跑", ended_at=time.time()
|
||||||
)
|
)
|
||||||
@@ -82,20 +128,29 @@ async def on_terminal(item: dict, status: str):
|
|||||||
job = store.job(sid)
|
job = store.job(sid)
|
||||||
if job is None:
|
if job is None:
|
||||||
raise RuntimeError("任务元数据写入失败")
|
raise RuntimeError("任务元数据写入失败")
|
||||||
job.update(status="finalizing", ended_at=time.time())
|
|
||||||
if job.get("timed_out"):
|
if job.get("timed_out"):
|
||||||
status = "failed"
|
status = "failed"
|
||||||
|
job.pop("recovery_waiting", None)
|
||||||
|
if status == "completed":
|
||||||
|
resolve_recovery_error(job)
|
||||||
|
job.update(status="finalizing", terminal_status=status, ended_at=time.time())
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
try:
|
try:
|
||||||
job["status"] = status
|
job["status"] = status
|
||||||
report = await asyncio.to_thread(session_report, sid)
|
report = await asyncio.to_thread(session_report, sid)
|
||||||
if report["verdict"] == "验证失败" or report["outcome"].get("task_status") in {
|
if status != "interrupted" and (
|
||||||
"blocked",
|
report["verdict"] == "验证失败"
|
||||||
"partial",
|
or report["outcome"].get("task_status")
|
||||||
"failed",
|
in {
|
||||||
}:
|
"blocked",
|
||||||
|
"partial",
|
||||||
|
"failed",
|
||||||
|
}
|
||||||
|
):
|
||||||
job["status"] = "failed"
|
job["status"] = "failed"
|
||||||
job["error"] = "任务执行结束,但业务检查未通过"
|
job["error"] = job.get("error") or "任务执行结束,但业务检查未通过"
|
||||||
|
elif job["status"] == "failed" and report.get("error") and not job.get("error"):
|
||||||
|
job["error"] = report["error"]
|
||||||
# Persist terminal classification before export, but the coordinator waits for finalization.
|
# Persist terminal classification before export, but the coordinator waits for finalization.
|
||||||
store.save_job({**job, "status": "finalizing", "terminal_status": job["status"]})
|
store.save_job({**job, "status": "finalizing", "terminal_status": job["status"]})
|
||||||
await asyncio.to_thread(export_session, sid, job["formats"])
|
await asyncio.to_thread(export_session, sid, job["formats"])
|
||||||
@@ -103,14 +158,41 @@ async def on_terminal(item: dict, status: str):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Report finalization failed for %s", sid)
|
logger.exception("Report finalization failed for %s", sid)
|
||||||
job["report_error"] = str(exc)[:500]
|
job["report_error"] = str(exc)[:500]
|
||||||
|
job.pop("terminal_status", None)
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
|
|
||||||
|
|
||||||
|
async def reconcile_recovered_jobs():
|
||||||
|
"""Finalize surviving workers once they exit; never resubmit their goals."""
|
||||||
|
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||||
|
from artemis.runtime import DeviceExecutionLock
|
||||||
|
from artemis.runtime.task_result import read_worker_outcome
|
||||||
|
|
||||||
|
waiting = [job for job in store.jobs() if job.get("recovery_waiting")]
|
||||||
|
if not waiting:
|
||||||
|
return
|
||||||
|
repository = SessionRepository(store.path)
|
||||||
|
owners = {str(owner.session_id) for owner in DeviceExecutionLock.get_active_owners().values()}
|
||||||
|
for job in waiting:
|
||||||
|
sid = str(job["session_id"])
|
||||||
|
row = repository.get_session_by_id(sid) or {}
|
||||||
|
if sid in owners or repository.process_is_alive(row.get("pid")):
|
||||||
|
continue
|
||||||
|
outcome = read_worker_outcome(sid)
|
||||||
|
status = job.get("terminal_status") or outcome.get("task_status") or row.get("status")
|
||||||
|
if status not in TERMINAL:
|
||||||
|
status = "interrupted"
|
||||||
|
job["error"] = job.get("error") or "服务中断;未完成任务需手动重跑"
|
||||||
|
store.save_job(job)
|
||||||
|
await on_terminal({"session_id": sid}, status)
|
||||||
|
|
||||||
|
|
||||||
async def tick_batches():
|
async def tick_batches():
|
||||||
from apps.admin_console.server import task_queue_service
|
from apps.admin_console.server import task_queue_service
|
||||||
from artemis.runtime import device_pool
|
from artemis.runtime import device_pool
|
||||||
from apps.admin_console.services.report_export import export_batch
|
from apps.admin_console.services.report_export import export_batch
|
||||||
|
|
||||||
|
await reconcile_recovered_jobs()
|
||||||
for batch in store.records("batch"):
|
for batch in store.records("batch"):
|
||||||
if batch["status"] not in {"running", "paused"}:
|
if batch["status"] not in {"running", "paused"}:
|
||||||
continue
|
continue
|
||||||
@@ -121,17 +203,41 @@ async def tick_batches():
|
|||||||
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
|
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
|
||||||
store.put("batch", batch["id"], batch)
|
store.put("batch", batch["id"], batch)
|
||||||
for job in active:
|
for job in active:
|
||||||
limit = int(batch.get("timeout_seconds", 1800))
|
# The queue records started_at at submission, then replaces it
|
||||||
|
# when the worker actually starts. Waiting for a device must
|
||||||
|
# not consume this case's execution budget.
|
||||||
|
if job["status"] != "running":
|
||||||
|
continue
|
||||||
|
from artemis.runtime.task_result import read_worker_outcome
|
||||||
|
|
||||||
|
if read_worker_outcome(str(job["session_id"])):
|
||||||
|
continue # Execution has ended; the worker supervisor bounds cleanup.
|
||||||
|
limit = task_timeout({}, job, batch)
|
||||||
|
task_state = getattr(state, "active_runs", {}).get(str(job["session_id"])) or {}
|
||||||
|
excluded = float(task_state.get("non_execution_seconds") or 0)
|
||||||
|
if task_state.get("non_execution_started_at"):
|
||||||
|
excluded += time.time() - float(task_state["non_execution_started_at"])
|
||||||
if (
|
if (
|
||||||
job["status"] != "finalizing"
|
not job.get("timed_out")
|
||||||
and not job.get("timed_out")
|
and time.time() - job.get("started_at", time.time()) - excluded > limit
|
||||||
and time.time() - job.get("started_at", time.time()) > limit
|
|
||||||
):
|
):
|
||||||
job.update(error="任务达到批次配置的执行时限", timed_out=True)
|
job.update(
|
||||||
|
error=f"任务执行超时({limit} 秒,不含暂停/排队时间)", timed_out=True
|
||||||
|
)
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
task_queue_service.stop_tasks(session_id=job["session_id"])
|
from artemis.runtime.task_result import save_failure_result
|
||||||
batch.update(status="paused", reason="任务超时,已暂停剩余任务;请确认设备状态")
|
|
||||||
store.put("batch", batch["id"], batch)
|
try:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
save_failure_result,
|
||||||
|
str(job["session_id"]),
|
||||||
|
job["error"],
|
||||||
|
code="task_timeout",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
task_queue_service.stop_tasks(session_id=job["session_id"])
|
||||||
|
# on_terminal records the failed case. The next pending
|
||||||
|
# case then runs normally unless stop_on_failure is set.
|
||||||
continue
|
continue
|
||||||
if batch["status"] != "running":
|
if batch["status"] != "running":
|
||||||
continue
|
continue
|
||||||
@@ -142,6 +248,11 @@ async def tick_batches():
|
|||||||
job.update(status="skipped", error="本批次设置为失败即停止", ended_at=time.time())
|
job.update(status="skipped", error="本批次设置为失败即停止", ended_at=time.time())
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
pending = []
|
pending = []
|
||||||
|
if not pending and any(
|
||||||
|
j["status"] in {"task_paused", "resuming", "running", "queued", "finalizing"}
|
||||||
|
for j in jobs
|
||||||
|
):
|
||||||
|
continue
|
||||||
if not pending:
|
if not pending:
|
||||||
batch.update(status="completed", ended_at=time.time())
|
batch.update(status="completed", ended_at=time.time())
|
||||||
try:
|
try:
|
||||||
@@ -156,7 +267,9 @@ async def tick_batches():
|
|||||||
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
|
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
|
||||||
store.put("batch", batch["id"], batch)
|
store.put("batch", batch["id"], batch)
|
||||||
continue
|
continue
|
||||||
serial = batch.get("device_serial")
|
job = pending[0]
|
||||||
|
options = job["case"].get("task_config") or {}
|
||||||
|
serial = options.get("device_serial") or batch.get("device_serial")
|
||||||
devices = await device_pool.try_list_devices_async()
|
devices = await device_pool.try_list_devices_async()
|
||||||
rejection = (
|
rejection = (
|
||||||
None
|
None
|
||||||
@@ -169,26 +282,29 @@ async def tick_batches():
|
|||||||
continue
|
continue
|
||||||
if (store.get("batch", batch["id"]) or {}).get("status") != "running":
|
if (store.get("batch", batch["id"]) or {}).get("status") != "running":
|
||||||
continue
|
continue
|
||||||
job = pending[0]
|
|
||||||
job.update(status="queued", started_at=time.time())
|
job.update(status="queued", started_at=time.time())
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
try:
|
try:
|
||||||
response = await task_queue_service.enqueue_tasks(
|
response = await task_queue_service.enqueue_tasks(
|
||||||
[case_goal(job["case"])],
|
[case_goal(job["case"])],
|
||||||
profile=batch.get("profile", "flash"),
|
profile=options.get("profile") or batch.get("profile", "flash"),
|
||||||
expected_output="用简体中文报告实际结果与证据。明确区分执行完成、验证通过、验证失败和前置条件未满足。"
|
expected_output="用简体中文报告实际结果与证据。明确区分执行完成、验证通过、验证失败和前置条件未满足。",
|
||||||
+ job["case"].get("expected", ""),
|
|
||||||
enable_outputter=True,
|
enable_outputter=True,
|
||||||
device_serial=serial,
|
device_serial=serial,
|
||||||
|
verification_level=options.get("verification_level"),
|
||||||
|
explorer_mode=options.get("explorer_mode"),
|
||||||
|
locked_app_package=options.get("locked_app_package"),
|
||||||
|
runtime_overrides=options,
|
||||||
session_id=job["session_id"],
|
session_id=job["session_id"],
|
||||||
ingress="batch",
|
ingress="batch",
|
||||||
|
display_name=execution_title(job),
|
||||||
)
|
)
|
||||||
if response.get("status") == "rejected" or not response.get("tasks"):
|
if response.get("status") == "rejected" or not response.get("tasks"):
|
||||||
raise ValueError(response.get("error") or "队列拒绝接收任务")
|
raise ValueError(response.get("error") or "队列拒绝接收任务")
|
||||||
bound = response["tasks"][0].get("device_serial")
|
bound = response["tasks"][0].get("device_serial")
|
||||||
if bound and not serial:
|
if bound:
|
||||||
batch["device_serial"] = bound
|
job["device_serial"] = bound
|
||||||
store.put("batch", batch["id"], batch)
|
store.save_job(job)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
job.update(status="pending", error=str(exc)[:500])
|
job.update(status="pending", error=str(exc)[:500])
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
@@ -214,6 +330,15 @@ def has_legacy_replay(batch_id: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def create_batch(payload: dict) -> dict:
|
def create_batch(payload: dict) -> dict:
|
||||||
|
repeat_count = payload.get("repeat_count", 1)
|
||||||
|
if type(repeat_count) is not int or repeat_count < 1:
|
||||||
|
raise ValueError("循环次数必须为正整数")
|
||||||
|
cases = payload["cases"]
|
||||||
|
total = len(cases) * repeat_count
|
||||||
|
if not cases or total > settings.ARTEMIS_BATCH_MAX_EXECUTIONS:
|
||||||
|
raise ValueError(
|
||||||
|
f"请选择用例;用例数 × 循环次数不能超过 {settings.ARTEMIS_BATCH_MAX_EXECUTIONS} 次执行"
|
||||||
|
)
|
||||||
bid = new_id()
|
bid = new_id()
|
||||||
batch = {
|
batch = {
|
||||||
"id": bid,
|
"id": bid,
|
||||||
@@ -223,28 +348,63 @@ def create_batch(payload: dict) -> dict:
|
|||||||
"formats": payload.get("formats") or store.preferences()["formats"],
|
"formats": payload.get("formats") or store.preferences()["formats"],
|
||||||
"profile": payload.get("profile", "flash"),
|
"profile": payload.get("profile", "flash"),
|
||||||
"device_serial": payload.get("device_serial"),
|
"device_serial": payload.get("device_serial"),
|
||||||
"timeout_seconds": payload.get("timeout_seconds")
|
"timeout_seconds": payload.get("timeout_seconds") or settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||||
or int(os.getenv("ARTEMIS_BATCH_TASK_TIMEOUT", "1800")),
|
|
||||||
"stop_on_failure": payload.get("stop_on_failure", False),
|
"stop_on_failure": payload.get("stop_on_failure", False),
|
||||||
"request_id": payload.get("request_id"),
|
"request_id": payload.get("request_id"),
|
||||||
|
"repeat_count": repeat_count,
|
||||||
|
"case_count": len(cases),
|
||||||
|
"total_executions": total,
|
||||||
}
|
}
|
||||||
jobs = []
|
jobs = []
|
||||||
for number, case in enumerate(payload["cases"], 1):
|
for iteration in range(1, repeat_count + 1):
|
||||||
jobs.append(
|
for number, case in enumerate(cases, 1):
|
||||||
{
|
jobs.append(
|
||||||
"session_id": new_id(),
|
{
|
||||||
"batch_id": bid,
|
"session_id": new_id(),
|
||||||
"position": number,
|
"batch_id": bid,
|
||||||
"case": case,
|
"position": len(jobs) + 1,
|
||||||
"status": "pending",
|
"iteration": iteration,
|
||||||
"formats": batch["formats"],
|
"repeat_count": repeat_count,
|
||||||
"created_at": time.time(),
|
"case_position": number,
|
||||||
"device_serial": batch["device_serial"],
|
"case": case,
|
||||||
}
|
"status": "pending",
|
||||||
)
|
"formats": batch["formats"],
|
||||||
|
"created_at": batch["created_at"],
|
||||||
|
"device_serial": batch["device_serial"],
|
||||||
|
}
|
||||||
|
)
|
||||||
return store.create_batch(batch, jobs, payload)
|
return store.create_batch(batch, jobs, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_batch(batch_id: str) -> bool:
|
||||||
|
"""Delete an inactive batch, its sessions and its generated reports."""
|
||||||
|
from artemis.data_engine.storage import StorageManager
|
||||||
|
from apps.admin_console.services import report_export
|
||||||
|
|
||||||
|
jobs = store.begin_delete_batch(batch_id)
|
||||||
|
if jobs is None:
|
||||||
|
return False
|
||||||
|
storage = StorageManager(store.path, report_export.TRACES_PATH)
|
||||||
|
mcp_exports = report_export.EXPORT_ROOT.parent / "mcp_exports"
|
||||||
|
with report_export.EXPORT_LOCK:
|
||||||
|
for job in jobs:
|
||||||
|
session_id = str(UUID(job["session_id"]))
|
||||||
|
storage.delete_session(UUID(session_id))
|
||||||
|
report_dir = report_export.EXPORT_ROOT / "sessions" / session_id
|
||||||
|
if report_dir.is_dir():
|
||||||
|
shutil.rmtree(report_dir)
|
||||||
|
mcp_session_dir = mcp_exports / "sessions" / session_id
|
||||||
|
if mcp_session_dir.is_dir():
|
||||||
|
shutil.rmtree(mcp_session_dir)
|
||||||
|
batch_dir = report_export.EXPORT_ROOT / "batches" / batch_id
|
||||||
|
if batch_dir.is_dir():
|
||||||
|
shutil.rmtree(batch_dir)
|
||||||
|
mcp_batch_dir = mcp_exports / "batches" / batch_id
|
||||||
|
if mcp_batch_dir.is_dir():
|
||||||
|
shutil.rmtree(mcp_batch_dir)
|
||||||
|
return store.delete_batch(batch_id)
|
||||||
|
|
||||||
|
|
||||||
def batch_action(batch_id: str, action: str) -> dict:
|
def batch_action(batch_id: str, action: str) -> dict:
|
||||||
batch = store.get("batch", batch_id)
|
batch = store.get("batch", batch_id)
|
||||||
if not batch:
|
if not batch:
|
||||||
@@ -268,7 +428,13 @@ def batch_action(batch_id: str, action: str) -> dict:
|
|||||||
if not failed:
|
if not failed:
|
||||||
raise ValueError("没有可重跑的失败或中断任务")
|
raise ValueError("没有可重跑的失败或中断任务")
|
||||||
revised = create_batch(
|
revised = create_batch(
|
||||||
{**batch, "name": batch["name"] + " · 失败项重跑", "cases": [j["case"] for j in failed]}
|
{
|
||||||
|
**batch,
|
||||||
|
"name": batch["name"] + " · 失败项重跑",
|
||||||
|
"cases": [j["case"] for j in failed],
|
||||||
|
"repeat_count": 1,
|
||||||
|
"request_id": None,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
return revised
|
return revised
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ import uuid
|
|||||||
from artemis.config import DB_PATH
|
from artemis.config import DB_PATH
|
||||||
|
|
||||||
|
|
||||||
|
RECOVERY_ERRORS = {
|
||||||
|
"服务中断;未完成任务需手动重跑",
|
||||||
|
"服务中断,需手动重跑",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def identifier(value: str) -> str:
|
def identifier(value: str) -> str:
|
||||||
if not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", value):
|
if not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", value):
|
||||||
raise ValueError("无效的记录编号")
|
raise ValueError("无效的记录编号")
|
||||||
@@ -54,6 +60,13 @@ class AutomationStore:
|
|||||||
|
|
||||||
def put(self, kind: str, key: str, payload: dict) -> dict:
|
def put(self, kind: str, key: str, payload: dict) -> dict:
|
||||||
with self.connection() as conn:
|
with self.connection() as conn:
|
||||||
|
if kind == "batch":
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?", (key,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None or json.loads(row[0]).get("status") == "deleting":
|
||||||
|
raise ValueError("批次正在删除或已删除,不能继续更新")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO automation_records VALUES (?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at",
|
"INSERT INTO automation_records VALUES (?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at",
|
||||||
(kind, key, json.dumps(payload, ensure_ascii=False), time.time()),
|
(kind, key, json.dumps(payload, ensure_ascii=False), time.time()),
|
||||||
@@ -78,6 +91,14 @@ class AutomationStore:
|
|||||||
|
|
||||||
def save_job(self, payload: dict) -> dict:
|
def save_job(self, payload: dict) -> dict:
|
||||||
with self.connection() as conn:
|
with self.connection() as conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
if batch_id := payload.get("batch_id"):
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?",
|
||||||
|
(batch_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None or json.loads(row[0]).get("status") == "deleting":
|
||||||
|
raise ValueError("批次正在删除,不能继续派发任务")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO automation_jobs VALUES (?,?,?,?,?,?) ON CONFLICT(session_id) DO UPDATE SET payload=excluded.payload,status=excluded.status,updated_at=excluded.updated_at",
|
"INSERT INTO automation_jobs VALUES (?,?,?,?,?,?) ON CONFLICT(session_id) DO UPDATE SET payload=excluded.payload,status=excluded.status,updated_at=excluded.updated_at",
|
||||||
(
|
(
|
||||||
@@ -106,9 +127,74 @@ class AutomationStore:
|
|||||||
)
|
)
|
||||||
return [{**json.loads(row[0]), "status": row[1]} for row in rows]
|
return [{**json.loads(row[0]), "status": row[1]} for row in rows]
|
||||||
|
|
||||||
|
def jobs_for_sessions(self, session_ids: list[str]) -> dict[str, dict]:
|
||||||
|
"""Load only metadata belonging to the sessions shown in history."""
|
||||||
|
result = {}
|
||||||
|
with self.connection() as conn:
|
||||||
|
for offset in range(0, len(session_ids), 400):
|
||||||
|
chunk = session_ids[offset : offset + 400]
|
||||||
|
markers = ",".join("?" for _ in chunk)
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT session_id,payload,status FROM automation_jobs WHERE session_id IN ({markers})",
|
||||||
|
chunk,
|
||||||
|
).fetchall()
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
row["session_id"]: {**json.loads(row["payload"]), "status": row["status"]}
|
||||||
|
for row in rows
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
def preferences(self) -> dict:
|
def preferences(self) -> dict:
|
||||||
return self.get("settings", "reports") or {"formats": ["docx", "xlsx"]}
|
return self.get("settings", "reports") or {"formats": ["docx", "xlsx"]}
|
||||||
|
|
||||||
|
def save_case_review(self, session_id: str, status: str, reason: str) -> dict:
|
||||||
|
"""Append reviews atomically; invalidate the batch's generated report."""
|
||||||
|
identifier(session_id)
|
||||||
|
if status not in {"passed", "failed"}:
|
||||||
|
raise ValueError("人工复验结果只能为通过或不通过")
|
||||||
|
review = {
|
||||||
|
"status": status,
|
||||||
|
"reason": reason.strip()
|
||||||
|
or ("人工复验确认通过。" if status == "passed" else "人工复验确认不通过。"),
|
||||||
|
"reviewed_at": time.time(),
|
||||||
|
}
|
||||||
|
with self.connection() as conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='case_review' AND id=?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
record = json.loads(row[0]) if row else {"reviews": []}
|
||||||
|
record["reviews"].append(review)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO automation_records VALUES (?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at",
|
||||||
|
(
|
||||||
|
"case_review",
|
||||||
|
session_id,
|
||||||
|
json.dumps(record, ensure_ascii=False),
|
||||||
|
review["reviewed_at"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
job = conn.execute(
|
||||||
|
"SELECT batch_id FROM automation_jobs WHERE session_id=?", (session_id,)
|
||||||
|
).fetchone()
|
||||||
|
if job and job[0]:
|
||||||
|
batch = conn.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?", (job[0],)
|
||||||
|
).fetchone()
|
||||||
|
if not batch or json.loads(batch[0]).get("status") == "deleting":
|
||||||
|
raise ValueError("批次正在删除,不能保存复验")
|
||||||
|
payload = json.loads(batch[0])
|
||||||
|
payload["report_ready"] = False
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE automation_records SET payload=?,updated_at=? WHERE kind='batch' AND id=?",
|
||||||
|
(json.dumps(payload, ensure_ascii=False), review["reviewed_at"], job[0]),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return review
|
||||||
|
|
||||||
def create_batch(self, batch: dict, jobs: list[dict], payload: dict) -> dict:
|
def create_batch(self, batch: dict, jobs: list[dict], payload: dict) -> dict:
|
||||||
"""Commit a batch and all its jobs atomically; repeated request IDs never duplicate work."""
|
"""Commit a batch and all its jobs atomically; repeated request IDs never duplicate work."""
|
||||||
request_id = payload.get("request_id")
|
request_id = payload.get("request_id")
|
||||||
@@ -164,13 +250,75 @@ class AutomationStore:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
return batch
|
return batch
|
||||||
|
|
||||||
|
def begin_delete_batch(self, batch_id: str) -> list[dict] | None:
|
||||||
|
"""Reserve an inactive batch for cleanup before any more jobs can start."""
|
||||||
|
with self.connection() as conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?",
|
||||||
|
(batch_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
batch = json.loads(row[0])
|
||||||
|
active = conn.execute(
|
||||||
|
"SELECT 1 FROM automation_jobs WHERE batch_id=? AND status IN "
|
||||||
|
"('queued','running','task_paused','resuming','finalizing') LIMIT 1",
|
||||||
|
(batch_id,),
|
||||||
|
).fetchone()
|
||||||
|
if batch["status"] == "running" or active:
|
||||||
|
raise ValueError("批次仍有任务执行中或排队中,请等待完成后删除")
|
||||||
|
batch["status"] = "deleting"
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE automation_records SET payload=?,updated_at=? WHERE kind='batch' AND id=?",
|
||||||
|
(json.dumps(batch, ensure_ascii=False), time.time(), batch_id),
|
||||||
|
)
|
||||||
|
jobs = conn.execute(
|
||||||
|
"SELECT payload,status FROM automation_jobs WHERE batch_id=? ORDER BY position",
|
||||||
|
(batch_id,),
|
||||||
|
).fetchall()
|
||||||
|
conn.commit()
|
||||||
|
return [{**json.loads(job[0]), "status": job[1]} for job in jobs]
|
||||||
|
|
||||||
|
def delete_batch(self, batch_id: str) -> bool:
|
||||||
|
"""Remove a batch record and all of its jobs after cleanup."""
|
||||||
|
with self.connection() as conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?",
|
||||||
|
(batch_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
batch = json.loads(row[0])
|
||||||
|
if batch.get("status") != "deleting":
|
||||||
|
raise ValueError("批次尚未准备删除")
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM automation_records WHERE kind='case_review' AND id IN "
|
||||||
|
"(SELECT session_id FROM automation_jobs WHERE batch_id=?)",
|
||||||
|
(batch_id,),
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM automation_jobs WHERE batch_id=?", (batch_id,))
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM automation_records WHERE kind='batch' AND id=?",
|
||||||
|
(batch_id,),
|
||||||
|
)
|
||||||
|
# Keep the request-id tombstone so deletion cannot replay an old batch.
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
def delete_history(self, session_id: str | None = None):
|
def delete_history(self, session_id: str | None = None):
|
||||||
with self.connection() as conn:
|
with self.connection() as conn:
|
||||||
if session_id:
|
if session_id:
|
||||||
conn.execute("DELETE FROM automation_jobs WHERE session_id=?", (session_id,))
|
conn.execute("DELETE FROM automation_jobs WHERE session_id=?", (session_id,))
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM automation_records WHERE kind='case_review' AND id=?",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
conn.execute("DELETE FROM automation_jobs")
|
conn.execute("DELETE FROM automation_jobs")
|
||||||
conn.execute("DELETE FROM automation_records WHERE kind='batch'")
|
conn.execute("DELETE FROM automation_records WHERE kind='batch'")
|
||||||
|
conn.execute("DELETE FROM automation_records WHERE kind='case_review'")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Background preparation and observable progress for batch report downloads."""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from apps.admin_console.services import report_export
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_states: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def archive_path(batch_id: str):
|
||||||
|
return report_export.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip"
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot(state: dict) -> dict:
|
||||||
|
elapsed = max(0, (state.get("finished_at") or time.monotonic()) - state["started_at"])
|
||||||
|
completed, total = state["completed"], state["total"]
|
||||||
|
eta = (
|
||||||
|
max(0, round(elapsed / completed * (total - completed)))
|
||||||
|
if completed and total > completed
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": state["status"],
|
||||||
|
"stage": state["stage"],
|
||||||
|
"completed": completed,
|
||||||
|
"total": total,
|
||||||
|
"elapsed_seconds": round(elapsed),
|
||||||
|
"eta_seconds": eta,
|
||||||
|
"error": state.get("error"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def status(batch_id: str, batch: dict) -> dict:
|
||||||
|
with _lock:
|
||||||
|
state = _states.get(batch_id)
|
||||||
|
if state:
|
||||||
|
return _snapshot(state)
|
||||||
|
if batch.get("report_ready") and archive_path(batch_id).is_file():
|
||||||
|
return {
|
||||||
|
"status": "ready",
|
||||||
|
"stage": "报告包已就绪",
|
||||||
|
"completed": 0,
|
||||||
|
"total": 0,
|
||||||
|
"elapsed_seconds": 0,
|
||||||
|
"eta_seconds": None,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": "idle",
|
||||||
|
"stage": "尚未开始打包",
|
||||||
|
"completed": 0,
|
||||||
|
"total": 0,
|
||||||
|
"elapsed_seconds": 0,
|
||||||
|
"eta_seconds": None,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(batch_id: str, batch: dict) -> dict:
|
||||||
|
with _lock:
|
||||||
|
current = _states.get(batch_id)
|
||||||
|
if current and current["status"] == "preparing":
|
||||||
|
return _snapshot(current)
|
||||||
|
if batch.get("report_ready") and archive_path(batch_id).is_file():
|
||||||
|
_states.pop(batch_id, None)
|
||||||
|
return status_from_cache()
|
||||||
|
state = {
|
||||||
|
"status": "preparing",
|
||||||
|
"stage": "等待打包",
|
||||||
|
"completed": 0,
|
||||||
|
"total": len(report_export.store.jobs(batch_id)),
|
||||||
|
"started_at": time.monotonic(),
|
||||||
|
}
|
||||||
|
_states[batch_id] = state
|
||||||
|
|
||||||
|
def update(completed: int, total: int, stage: str) -> None:
|
||||||
|
with _lock:
|
||||||
|
state.update(completed=completed, total=total, stage=stage)
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
try:
|
||||||
|
with report_export.EXPORT_LOCK:
|
||||||
|
latest = report_export.store.get("batch", batch_id)
|
||||||
|
if not (latest and latest.get("report_ready") and archive_path(batch_id).is_file()):
|
||||||
|
report_export.export_batch(batch_id, progress=update)
|
||||||
|
with _lock:
|
||||||
|
state.update(status="ready", stage="报告包已就绪", finished_at=time.monotonic())
|
||||||
|
except Exception as exc:
|
||||||
|
with _lock:
|
||||||
|
state.update(
|
||||||
|
status="failed",
|
||||||
|
stage="打包失败",
|
||||||
|
error=str(exc)[:500],
|
||||||
|
finished_at=time.monotonic(),
|
||||||
|
)
|
||||||
|
|
||||||
|
threading.Thread(target=run, name=f"batch-archive-{batch_id[:8]}", daemon=True).start()
|
||||||
|
return status(batch_id, batch)
|
||||||
|
|
||||||
|
|
||||||
|
def status_from_cache() -> dict:
|
||||||
|
return {
|
||||||
|
"status": "ready",
|
||||||
|
"stage": "报告包已就绪",
|
||||||
|
"completed": 0,
|
||||||
|
"total": 0,
|
||||||
|
"elapsed_seconds": 0,
|
||||||
|
"eta_seconds": None,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def forget(batch_id: str) -> None:
|
||||||
|
with _lock:
|
||||||
|
_states.pop(batch_id, None)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Human review overlays the AI verdict without changing execution evidence."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from apps.admin_console.services.automation_store import AutomationStore
|
||||||
|
from artemis.runtime.case_result import read_case_result
|
||||||
|
|
||||||
|
|
||||||
|
def reviewed_result(
|
||||||
|
session_id: str, ai_result: dict | None, storage: AutomationStore
|
||||||
|
) -> dict | None:
|
||||||
|
original = {**ai_result, "source": "ai"} if ai_result else None
|
||||||
|
record = storage.get("case_review", session_id)
|
||||||
|
reviews = (record or {}).get("reviews") or []
|
||||||
|
if not reviews:
|
||||||
|
return original
|
||||||
|
latest = reviews[-1]
|
||||||
|
return {
|
||||||
|
"status": latest["status"],
|
||||||
|
"reason": latest["reason"],
|
||||||
|
"evidence": [],
|
||||||
|
"source": "human",
|
||||||
|
"reviewed_at": latest["reviewed_at"],
|
||||||
|
"ai_result": original,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_reviewed_result(
|
||||||
|
session_id: str,
|
||||||
|
execution_status: str,
|
||||||
|
error: str = "",
|
||||||
|
*,
|
||||||
|
storage: AutomationStore,
|
||||||
|
root: Path | None = None,
|
||||||
|
) -> dict | None:
|
||||||
|
return reviewed_result(
|
||||||
|
session_id,
|
||||||
|
read_case_result(session_id, execution_status, error, root=root),
|
||||||
|
storage,
|
||||||
|
)
|
||||||
@@ -9,6 +9,7 @@ from urllib.parse import urlsplit
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field, SecretStr, field_validator
|
from pydantic import BaseModel, Field, SecretStr, field_validator
|
||||||
|
|
||||||
|
from artemis.config.agent import load_agent_config
|
||||||
from artemis.config.paths import get_env_file
|
from artemis.config.paths import get_env_file
|
||||||
from artemis.config.settings import settings
|
from artemis.config.settings import settings
|
||||||
|
|
||||||
@@ -16,12 +17,19 @@ _write_lock = threading.Lock()
|
|||||||
|
|
||||||
|
|
||||||
class ModelSettingsUpdate(BaseModel):
|
class ModelSettingsUpdate(BaseModel):
|
||||||
base_url: str = "https://api.openai.com/v1"
|
base_url: str = "https://api-n-cd.playones.com/v1"
|
||||||
model: str = Field(min_length=1, max_length=200)
|
model: str = Field(min_length=1, max_length=200)
|
||||||
api_key: SecretStr | None = None # Omitted/blank keeps the existing key.
|
api_key: SecretStr | None = None # Omitted/blank keeps the existing key.
|
||||||
timeout_seconds: float = Field(default=300, gt=0, le=3600)
|
timeout_seconds: float = Field(default=300, gt=0, le=3600)
|
||||||
max_retries: int = Field(default=5, ge=0, le=10)
|
max_retries: int = Field(default=5, ge=0, le=10)
|
||||||
retry_interval_seconds: float = Field(default=10, ge=0, le=300)
|
retry_interval_seconds: float = Field(default=10, ge=0, le=300)
|
||||||
|
rate_limit_retry_seconds: float | None = Field(default=None, ge=0, le=3600)
|
||||||
|
tool_fallback_model: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
tool_fallback_reasoning_effort: (
|
||||||
|
Literal["default", "none", "minimal", "low", "medium", "high", "xhigh", "max"] | None
|
||||||
|
) = None
|
||||||
|
tool_fallback_max_retries: int | None = Field(default=None, ge=0, le=10)
|
||||||
|
tool_fallback_no_progress_attempts: int | None = Field(default=None, ge=1, le=10)
|
||||||
reasoning_effort: Literal[
|
reasoning_effort: Literal[
|
||||||
"default", "none", "minimal", "low", "medium", "high", "xhigh", "max"
|
"default", "none", "minimal", "low", "medium", "high", "xhigh", "max"
|
||||||
] = "high"
|
] = "high"
|
||||||
@@ -41,12 +49,14 @@ class ModelSettingsUpdate(BaseModel):
|
|||||||
):
|
):
|
||||||
raise ValueError("请输入有效的 HTTP(S) 接口根地址,不要包含密钥、查询参数或片段")
|
raise ValueError("请输入有效的 HTTP(S) 接口根地址,不要包含密钥、查询参数或片段")
|
||||||
if value.endswith(("/responses", "/chat/completions")):
|
if value.endswith(("/responses", "/chat/completions")):
|
||||||
raise ValueError("请输入接口根地址,例如 https://api.openai.com/v1")
|
raise ValueError("请输入接口根地址,例如 https://api-n-cd.playones.com/v1")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@field_validator("model")
|
@field_validator("model", "tool_fallback_model")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_model(cls, value: str) -> str:
|
def validate_model(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
value = value.strip()
|
value = value.strip()
|
||||||
if not value or any(c in value for c in "\r\n\x00"):
|
if not value or any(c in value for c in "\r\n\x00"):
|
||||||
raise ValueError("模型名称不能为空或包含换行")
|
raise ValueError("模型名称不能为空或包含换行")
|
||||||
@@ -57,14 +67,24 @@ def get_model_settings() -> dict:
|
|||||||
return {
|
return {
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"api_mode": "responses",
|
"api_mode": "responses",
|
||||||
"base_url": settings.OPENAI_BASE_URL or "https://api.openai.com/v1",
|
"base_url": settings.OPENAI_BASE_URL or "https://api-n-cd.playones.com/v1",
|
||||||
"model": settings.OPENAI_MODEL,
|
"model": settings.OPENAI_MODEL,
|
||||||
"api_key_configured": bool(settings.OPENAI_API_KEY),
|
"api_key_configured": bool(settings.OPENAI_API_KEY),
|
||||||
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
|
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
|
||||||
"max_retries": settings.OPENAI_MAX_RETRIES,
|
"max_retries": settings.OPENAI_MAX_RETRIES,
|
||||||
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
|
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
|
||||||
|
"rate_limit_retry_seconds": settings.OPENAI_RATE_LIMIT_RETRY_SECONDS,
|
||||||
|
"tool_fallback_model": settings.OPENAI_TOOL_FALLBACK_MODEL,
|
||||||
|
"tool_fallback_reasoning_effort": settings.OPENAI_TOOL_FALLBACK_REASONING_EFFORT,
|
||||||
|
"tool_fallback_max_retries": settings.OPENAI_TOOL_FALLBACK_MAX_RETRIES,
|
||||||
|
"tool_fallback_no_progress_attempts": settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS,
|
||||||
"reasoning_effort": settings.OPENAI_REASONING_EFFORT,
|
"reasoning_effort": settings.OPENAI_REASONING_EFFORT,
|
||||||
"model_presets": ["gpt-5.6-luna", "mimo-v2.6-flash"],
|
"model_presets": [
|
||||||
|
"gpt-5.6-luna",
|
||||||
|
"gpt-6-luna",
|
||||||
|
"mimo-v2.6-flash",
|
||||||
|
"mimo-v2.6-pro",
|
||||||
|
],
|
||||||
"env_path": str(get_env_file()),
|
"env_path": str(get_env_file()),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +100,20 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
|
|||||||
"OPENAI_RETRY_INTERVAL_SECONDS": str(update.retry_interval_seconds),
|
"OPENAI_RETRY_INTERVAL_SECONDS": str(update.retry_interval_seconds),
|
||||||
"OPENAI_REASONING_EFFORT": update.reasoning_effort,
|
"OPENAI_REASONING_EFFORT": update.reasoning_effort,
|
||||||
}
|
}
|
||||||
|
optional_settings = {
|
||||||
|
"rate_limit_retry_seconds": ("OPENAI_RATE_LIMIT_RETRY_SECONDS", float),
|
||||||
|
"tool_fallback_model": ("OPENAI_TOOL_FALLBACK_MODEL", str),
|
||||||
|
"tool_fallback_reasoning_effort": ("OPENAI_TOOL_FALLBACK_REASONING_EFFORT", str),
|
||||||
|
"tool_fallback_max_retries": ("OPENAI_TOOL_FALLBACK_MAX_RETRIES", int),
|
||||||
|
"tool_fallback_no_progress_attempts": (
|
||||||
|
"ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS",
|
||||||
|
int,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for field_name, (env_name, expected_type) in optional_settings.items():
|
||||||
|
value = getattr(update, field_name)
|
||||||
|
if value is not None:
|
||||||
|
values[env_name] = str(value)
|
||||||
if update.api_key and update.api_key.get_secret_value().strip():
|
if update.api_key and update.api_key.get_secret_value().strip():
|
||||||
values["OPENAI_API_KEY"] = update.api_key.get_secret_value().strip()
|
values["OPENAI_API_KEY"] = update.api_key.get_secret_value().strip()
|
||||||
persist_environment(values)
|
persist_environment(values)
|
||||||
@@ -89,6 +123,10 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
|
|||||||
settings.OPENAI_MAX_RETRIES = update.max_retries
|
settings.OPENAI_MAX_RETRIES = update.max_retries
|
||||||
settings.OPENAI_RETRY_INTERVAL_SECONDS = update.retry_interval_seconds
|
settings.OPENAI_RETRY_INTERVAL_SECONDS = update.retry_interval_seconds
|
||||||
settings.OPENAI_REASONING_EFFORT = update.reasoning_effort
|
settings.OPENAI_REASONING_EFFORT = update.reasoning_effort
|
||||||
|
for field_name, (env_name, expected_type) in optional_settings.items():
|
||||||
|
value = getattr(update, field_name)
|
||||||
|
if value is not None:
|
||||||
|
setattr(settings, env_name, expected_type(value))
|
||||||
if "OPENAI_API_KEY" in values:
|
if "OPENAI_API_KEY" in values:
|
||||||
settings.OPENAI_API_KEY = SecretStr(values["OPENAI_API_KEY"])
|
settings.OPENAI_API_KEY = SecretStr(values["OPENAI_API_KEY"])
|
||||||
from artemis.llm.router import ModelFactory
|
from artemis.llm.router import ModelFactory
|
||||||
@@ -128,24 +166,67 @@ def persist_environment(values: dict[str, str]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
class CapabilitySettings(BaseModel):
|
class CapabilitySettings(BaseModel):
|
||||||
|
adb_auto_connect: bool = Field(default_factory=lambda: settings.ARTEMIS_ADB_AUTO_CONNECT)
|
||||||
|
adb_discovery_interval_seconds: float = Field(
|
||||||
|
default_factory=lambda: settings.ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS, ge=1, le=300
|
||||||
|
)
|
||||||
|
adb_discovery_timeout_seconds: float = Field(
|
||||||
|
default_factory=lambda: settings.ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS, gt=0, le=30
|
||||||
|
)
|
||||||
video_enabled: bool = False
|
video_enabled: bool = False
|
||||||
|
recording_enabled: bool = False
|
||||||
outputter_enabled: bool = True
|
outputter_enabled: bool = True
|
||||||
step_summarizer_enabled: bool = True
|
step_summarizer_enabled: bool = True
|
||||||
|
screenshot_limit_enabled: bool = True
|
||||||
|
screenshot_max_long_edge: int = 2048
|
||||||
|
task_timeout_seconds: int = Field(
|
||||||
|
default_factory=lambda: settings.ARTEMIS_TASK_TIMEOUT_SECONDS, ge=30, le=86400
|
||||||
|
)
|
||||||
|
observation_timeout_seconds: float = Field(
|
||||||
|
default_factory=lambda: settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS, gt=0, le=300
|
||||||
|
)
|
||||||
|
observation_max_attempts: int = Field(
|
||||||
|
default_factory=lambda: settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS, ge=1, le=5
|
||||||
|
)
|
||||||
|
device_command_timeout_seconds: float = Field(
|
||||||
|
default_factory=lambda: settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS, gt=0, le=60
|
||||||
|
)
|
||||||
|
action_failure_attempts: int = Field(
|
||||||
|
default_factory=lambda: settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS, ge=1, le=10
|
||||||
|
)
|
||||||
|
worker_cleanup_timeout_seconds: float = Field(
|
||||||
|
default_factory=lambda: settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS, gt=0, le=300
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_capability_settings() -> CapabilitySettings:
|
def get_capability_settings() -> CapabilitySettings:
|
||||||
|
screenshot = load_agent_config().screenshot
|
||||||
return CapabilitySettings(
|
return CapabilitySettings(
|
||||||
video_enabled=settings.ARTEMIS_VIDEO_ENABLED,
|
video_enabled=settings.ARTEMIS_VIDEO_ENABLED,
|
||||||
|
recording_enabled=settings.ARTEMIS_RECORDING_ENABLED,
|
||||||
outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED,
|
outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED,
|
||||||
step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED,
|
step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED,
|
||||||
|
screenshot_limit_enabled=screenshot.enabled,
|
||||||
|
screenshot_max_long_edge=screenshot.max_long_edge,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
|
def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
|
||||||
values = {
|
values = {
|
||||||
|
"ARTEMIS_ADB_AUTO_CONNECT": update.adb_auto_connect,
|
||||||
|
"ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS": update.adb_discovery_interval_seconds,
|
||||||
|
"ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS": update.adb_discovery_timeout_seconds,
|
||||||
"ARTEMIS_VIDEO_ENABLED": update.video_enabled,
|
"ARTEMIS_VIDEO_ENABLED": update.video_enabled,
|
||||||
|
"ARTEMIS_RECORDING_ENABLED": update.recording_enabled,
|
||||||
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
|
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
|
||||||
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
|
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
|
||||||
|
"ARTEMIS_SCREENSHOT_LIMIT_ENABLED": update.screenshot_limit_enabled,
|
||||||
|
"ARTEMIS_TASK_TIMEOUT_SECONDS": update.task_timeout_seconds,
|
||||||
|
"ARTEMIS_OBSERVATION_TIMEOUT_SECONDS": update.observation_timeout_seconds,
|
||||||
|
"ARTEMIS_OBSERVATION_MAX_ATTEMPTS": update.observation_max_attempts,
|
||||||
|
"ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS": update.device_command_timeout_seconds,
|
||||||
|
"ARTEMIS_ACTION_FAILURE_ATTEMPTS": update.action_failure_attempts,
|
||||||
|
"ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS": update.worker_cleanup_timeout_seconds,
|
||||||
}
|
}
|
||||||
persist_environment({key: str(value).lower() for key, value in values.items()})
|
persist_environment({key: str(value).lower() for key, value in values.items()})
|
||||||
for key, value in values.items():
|
for key, value in values.items():
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Evidence-backed Word/Excel artifacts, independent of the language model runtime."""
|
"""Evidence-backed Word/Excel artifacts, independent of the language model runtime."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import cast
|
from typing import Callable, cast
|
||||||
from docx.styles.style import ParagraphStyle
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
@@ -10,15 +9,22 @@ import threading
|
|||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
from docx import Document
|
from docx import Document
|
||||||
from docx.shared import Inches, Pt
|
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
|
||||||
|
from docx.oxml import OxmlElement
|
||||||
from docx.oxml.ns import qn
|
from docx.oxml.ns import qn
|
||||||
|
from docx.shared import Cm, Pt, RGBColor
|
||||||
|
from docx.styles.style import ParagraphStyle
|
||||||
from openpyxl import Workbook
|
from openpyxl import Workbook
|
||||||
from openpyxl.styles import Font, PatternFill, Alignment
|
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
|
from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
|
||||||
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||||
from apps.admin_console.database.repositories.step_repository import StepRepository
|
from apps.admin_console.database.repositories.step_repository import StepRepository
|
||||||
from apps.admin_console.services.automation_store import store, identifier
|
from apps.admin_console.services.automation_store import store, identifier
|
||||||
|
from apps.admin_console.services.task_import import execution_title
|
||||||
|
from artemis.runtime.case_result import RESULT_LABELS, resolve_case_result
|
||||||
|
from apps.admin_console.services.case_review import reviewed_result
|
||||||
|
|
||||||
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
|
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
|
||||||
EXPORT_LOCK = threading.RLock()
|
EXPORT_LOCK = threading.RLock()
|
||||||
@@ -32,9 +38,19 @@ STATUS = {
|
|||||||
"pending": "待执行",
|
"pending": "待执行",
|
||||||
"queued": "已排队",
|
"queued": "已排队",
|
||||||
"running": "执行中",
|
"running": "执行中",
|
||||||
|
"task_paused": "等待指导",
|
||||||
|
"resuming": "等待设备恢复",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def archive_case_name(report: dict) -> str:
|
||||||
|
"""Keep case identifiers readable without allowing ZIP paths or Windows-invalid names."""
|
||||||
|
fallback = str(report.get("id") or "task")[:8]
|
||||||
|
candidate = str(report.get("case_id") or fallback).strip()
|
||||||
|
candidate = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", candidate).strip(" .")
|
||||||
|
return candidate[:80] or fallback
|
||||||
|
|
||||||
|
|
||||||
def read_json(path: Path, fallback=None):
|
def read_json(path: Path, fallback=None):
|
||||||
try:
|
try:
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
@@ -48,6 +64,12 @@ def text(value) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def verdict(outcome: dict | None) -> str:
|
def verdict(outcome: dict | None) -> str:
|
||||||
|
result = (outcome or {}).get("test_result")
|
||||||
|
if isinstance(result, dict) and result.get("status") in RESULT_LABELS:
|
||||||
|
label = RESULT_LABELS[result["status"]]
|
||||||
|
if result.get("source"):
|
||||||
|
label += "(人工确认)" if result["source"] == "human" else "(AI确认)"
|
||||||
|
return label
|
||||||
tests = (outcome or {}).get("tests") or {}
|
tests = (outcome or {}).get("tests") or {}
|
||||||
if tests.get("failed", 0):
|
if tests.get("failed", 0):
|
||||||
return "验证失败"
|
return "验证失败"
|
||||||
@@ -58,6 +80,81 @@ def verdict(outcome: dict | None) -> str:
|
|||||||
return "未验证(执行完成不等于测试通过)"
|
return "未验证(执行完成不等于测试通过)"
|
||||||
|
|
||||||
|
|
||||||
|
def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
|
||||||
|
def redact(value: str) -> str:
|
||||||
|
safe = text(value).strip()
|
||||||
|
safe = re.sub(r"sk-[A-Za-z0-9_-]{12,}", "[已隐藏密钥]", safe)
|
||||||
|
safe = re.sub(
|
||||||
|
r"(?i)(\b(?:authorization\s*:\s*bearer|bearer)\s+)[^\s,;]+",
|
||||||
|
r"\1[已隐藏密钥]",
|
||||||
|
safe,
|
||||||
|
)
|
||||||
|
safe = re.sub(
|
||||||
|
r"(?i)(\b(?:OPENAI_API_KEY|api[_-]?key)\s*[:=]\s*['\"]?)[^\s,'\";]+",
|
||||||
|
r"\1[已隐藏密钥]",
|
||||||
|
safe,
|
||||||
|
)
|
||||||
|
return safe[:500]
|
||||||
|
|
||||||
|
from apps.admin_console.services.automation_store import RECOVERY_ERRORS
|
||||||
|
|
||||||
|
if status == "completed" and recorded in RECOVERY_ERRORS:
|
||||||
|
return ""
|
||||||
|
if recorded:
|
||||||
|
return redact(recorded)
|
||||||
|
if status not in {"failed", "interrupted"}:
|
||||||
|
return ""
|
||||||
|
state = read_json(directory / "status.json", {})
|
||||||
|
if isinstance(state, dict) and state.get("error"):
|
||||||
|
return redact(state["error"])
|
||||||
|
result = read_json(directory / "result.json", {})
|
||||||
|
if isinstance(result, dict) and result.get("status") == "failed":
|
||||||
|
reason = result.get("error") or result.get("explanation")
|
||||||
|
if reason:
|
||||||
|
return redact(reason)
|
||||||
|
log_lines: list[str] = []
|
||||||
|
for name in ("stdout.log", "stderr.log"):
|
||||||
|
path = directory / name
|
||||||
|
try:
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
stream.seek(0, 2)
|
||||||
|
stream.seek(max(0, stream.tell() - 131072))
|
||||||
|
log_lines.extend(stream.read().decode("utf-8", errors="replace").splitlines())
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for line in reversed(log_lines):
|
||||||
|
match = re.search(r"Error running automation:\s*(.+)", line)
|
||||||
|
if match:
|
||||||
|
return redact(match.group(1))
|
||||||
|
for line in reversed(log_lines):
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith(("⚠", "ℹ", "WARNING", "INFO", "DEBUG")):
|
||||||
|
continue
|
||||||
|
if any(
|
||||||
|
name in line
|
||||||
|
for name in (
|
||||||
|
"VisualStepSummarizer:",
|
||||||
|
"StepMemoryService:",
|
||||||
|
"Could not clean unowned screen wake locks",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
exception_line = re.fullmatch(
|
||||||
|
r"(?:[A-Za-z_]\w*\.)*[A-Za-z_]\w*(?:Error|Exception|Timeout):\s*.*",
|
||||||
|
stripped,
|
||||||
|
)
|
||||||
|
if exception_line and not re.search(r"(?i)(?:timeout|timed out|超时)", stripped):
|
||||||
|
return redact(stripped)
|
||||||
|
if re.search(
|
||||||
|
r"(?i)(?:\b(?:TimeoutError|APITimeoutError|ReadTimeout|ConnectTimeout)\s*:|"
|
||||||
|
r"(?:模型请求|任务执行|model request|task execution).{0,100}"
|
||||||
|
r"(?:超时|timed out|deadline exceeded))",
|
||||||
|
stripped,
|
||||||
|
):
|
||||||
|
return redact("模型请求或任务执行超时:" + stripped)
|
||||||
|
return "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
|
||||||
|
|
||||||
|
|
||||||
def session_report(session_id: str, db_path=None) -> dict:
|
def session_report(session_id: str, db_path=None) -> dict:
|
||||||
identifier(session_id)
|
identifier(session_id)
|
||||||
meta = store.job(session_id) or {}
|
meta = store.job(session_id) or {}
|
||||||
@@ -87,32 +184,51 @@ def session_report(session_id: str, db_path=None) -> dict:
|
|||||||
if not outcome:
|
if not outcome:
|
||||||
outcome = {"task_status": args.get("status"), "tests": {}}
|
outcome = {"task_status": args.get("status"), "tests": {}}
|
||||||
break
|
break
|
||||||
if not content:
|
|
||||||
content = "本任务没有保存最终结论。以下内容仅来自已记录的执行状态和证据。"
|
|
||||||
case = meta.get("case") or {}
|
case = meta.get("case") or {}
|
||||||
|
status = meta.get("terminal_status") or meta.get("status") or session.get("status", "unknown")
|
||||||
|
error = failure_reason(directory, status, meta.get("error"))
|
||||||
|
ai_result = resolve_case_result(outcome, status, error)
|
||||||
|
test_result = reviewed_result(session_id, ai_result.model_dump() if ai_result else None, store)
|
||||||
|
if test_result:
|
||||||
|
outcome = {**outcome, "test_result": test_result}
|
||||||
|
verification = verdict(outcome)
|
||||||
|
if status in {"failed", "interrupted"} and verification.startswith("未验证"):
|
||||||
|
verification = "未验证(任务异常终止)"
|
||||||
|
if not content:
|
||||||
|
content = (
|
||||||
|
"本任务未生成最终结论。请查看异常原因和已记录的执行步骤。"
|
||||||
|
if status in {"failed", "interrupted"}
|
||||||
|
else "本任务没有保存最终结论。以下内容仅来自已记录的执行状态和证据。"
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"id": session_id,
|
"id": session_id,
|
||||||
"name": case.get("name") or session.get("initial_goal", session_id)[:120],
|
"name": execution_title(meta) or session.get("initial_goal", session_id)[:120],
|
||||||
|
"iteration": meta.get("iteration", 1),
|
||||||
|
"repeat_count": meta.get("repeat_count", 1),
|
||||||
"goal": case.get("goal") or session.get("initial_goal", ""),
|
"goal": case.get("goal") or session.get("initial_goal", ""),
|
||||||
"expected": case.get("expected", ""),
|
"expected": case.get("expected", ""),
|
||||||
"status": meta.get("terminal_status")
|
"status": status,
|
||||||
or meta.get("status")
|
"verdict": verification,
|
||||||
or session.get("status", "unknown"),
|
"test_result": test_result,
|
||||||
"verdict": verdict(outcome),
|
|
||||||
"outcome": outcome,
|
"outcome": outcome,
|
||||||
"content": content,
|
"content": content,
|
||||||
"steps": steps,
|
"steps": steps,
|
||||||
"start": session.get("start_time") or meta.get("started_at"),
|
"start": session.get("start_time") or meta.get("started_at"),
|
||||||
"end": session.get("end_time") or meta.get("ended_at"),
|
"end": session.get("end_time") or meta.get("ended_at"),
|
||||||
"error": meta.get("error", ""),
|
"error": error,
|
||||||
"device": meta.get("device_serial") or text(session.get("device_info") or ""),
|
"device": meta.get("device_serial") or text(session.get("device_info") or ""),
|
||||||
"tags": case.get("tags", ""),
|
"tags": case.get("tags", ""),
|
||||||
|
"case_id": case.get("case_id", ""),
|
||||||
|
"category": case.get("category", ""),
|
||||||
|
"subcategory": case.get("subcategory", ""),
|
||||||
|
"preconditions": case.get("preconditions", ""),
|
||||||
|
"task_config": case.get("task_config") or {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def time_text(value) -> str:
|
def time_text(value) -> str:
|
||||||
return (
|
return (
|
||||||
datetime.fromtimestamp(float(value)).astimezone().isoformat(timespec="seconds")
|
datetime.fromtimestamp(float(value)).astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
if value
|
if value
|
||||||
else "—"
|
else "—"
|
||||||
)
|
)
|
||||||
@@ -121,10 +237,27 @@ def time_text(value) -> str:
|
|||||||
def metadata(report: dict) -> list[tuple]:
|
def metadata(report: dict) -> list[tuple]:
|
||||||
return [
|
return [
|
||||||
("任务编号", report["id"]),
|
("任务编号", report["id"]),
|
||||||
|
("用例编号", report.get("case_id") or "—"),
|
||||||
|
("检测项目", report.get("category") or "—"),
|
||||||
|
("测试子项", report.get("subcategory") or "—"),
|
||||||
|
("前置约束提取", report.get("preconditions") or "—"),
|
||||||
|
("任务配置", config_text(report.get("task_config"))),
|
||||||
("任务目标", report["goal"]),
|
("任务目标", report["goal"]),
|
||||||
("预期结果", report["expected"] or "未指定"),
|
("预期结果", report["expected"] or "未指定"),
|
||||||
("执行状态", STATUS.get(report["status"], report["status"])),
|
("执行状态", STATUS.get(report["status"], report["status"])),
|
||||||
("验证结论", report["verdict"]),
|
("验证结论", report["verdict"]),
|
||||||
|
("人工复验时间", time_text((report.get("test_result") or {}).get("reviewed_at"))),
|
||||||
|
(
|
||||||
|
"原 AI 结论",
|
||||||
|
verdict({"test_result": (report.get("test_result") or {}).get("ai_result")})
|
||||||
|
if (report.get("test_result") or {}).get("source") == "human"
|
||||||
|
else "—",
|
||||||
|
),
|
||||||
|
("判定依据", (report.get("test_result") or {}).get("reason") or "未记录"),
|
||||||
|
(
|
||||||
|
"验收证据",
|
||||||
|
"\n".join((report.get("test_result") or {}).get("evidence") or []) or "未记录",
|
||||||
|
),
|
||||||
("开始时间", time_text(report["start"])),
|
("开始时间", time_text(report["start"])),
|
||||||
("结束时间", time_text(report["end"])),
|
("结束时间", time_text(report["end"])),
|
||||||
("设备", report["device"]),
|
("设备", report["device"]),
|
||||||
@@ -132,6 +265,16 @@ def metadata(report: dict) -> list[tuple]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def config_text(config) -> str:
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
return text(config or {})
|
||||||
|
return json.dumps(
|
||||||
|
{key: value for key, value in config.items() if value is not None},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def image_path(name: str | None) -> Path | None:
|
def image_path(name: str | None) -> Path | None:
|
||||||
if not name:
|
if not name:
|
||||||
return None
|
return None
|
||||||
@@ -149,10 +292,14 @@ def image_path(name: str | None) -> Path | None:
|
|||||||
def markdown_blocks(content: str):
|
def markdown_blocks(content: str):
|
||||||
lines = content.splitlines()
|
lines = content.splitlines()
|
||||||
index = 0
|
index = 0
|
||||||
|
in_code = False
|
||||||
while index < len(lines):
|
while index < len(lines):
|
||||||
line = lines[index]
|
line = lines[index]
|
||||||
|
if line.strip().startswith("```"):
|
||||||
|
in_code = not in_code
|
||||||
if (
|
if (
|
||||||
"|" in line
|
not in_code
|
||||||
|
and "|" in line
|
||||||
and index + 1 < len(lines)
|
and index + 1 < len(lines)
|
||||||
and re.fullmatch(r"[\s|:\-]+", lines[index + 1])
|
and re.fullmatch(r"[\s|:\-]+", lines[index + 1])
|
||||||
and "-" in lines[index + 1]
|
and "-" in lines[index + 1]
|
||||||
@@ -168,81 +315,302 @@ def markdown_blocks(content: str):
|
|||||||
index += 1
|
index += 1
|
||||||
|
|
||||||
|
|
||||||
def word_report(report: dict, path: Path):
|
def word_styles(doc, *, landscape: bool = False):
|
||||||
doc = Document()
|
section = doc.sections[0]
|
||||||
style = cast(ParagraphStyle, doc.styles["Normal"])
|
section.page_width, section.page_height = (
|
||||||
style.font.name = "Microsoft YaHei"
|
(Cm(29.7), Cm(21)) if landscape else (Cm(21), Cm(29.7))
|
||||||
style.font.size = Pt(10)
|
)
|
||||||
style.element.rPr.rFonts.set(qn("w:eastAsia"), "微软雅黑")
|
section.left_margin = section.right_margin = Cm(2)
|
||||||
doc.add_heading(report["name"], 0)
|
section.top_margin = section.bottom_margin = Cm(1.9)
|
||||||
table = doc.add_table(rows=0, cols=2)
|
for name, size, color in (
|
||||||
table.columns[0].width = Inches(1.3)
|
("Normal", 10.5, "263445"),
|
||||||
table.columns[1].width = Inches(5.2)
|
("Title", 20, "0F172A"),
|
||||||
table.style = "Light Shading Accent 1"
|
("Heading 1", 14, "0F172A"),
|
||||||
for label, value in metadata(report):
|
("Heading 2", 11.5, "0F172A"),
|
||||||
|
("Heading 3", 10.5, "0F172A"),
|
||||||
|
("List Bullet", 10.5, "263445"),
|
||||||
|
("List Number", 10.5, "263445"),
|
||||||
|
("Quote", 10.5, "263445"),
|
||||||
|
):
|
||||||
|
style = cast(ParagraphStyle, doc.styles[name])
|
||||||
|
style.font.name = "Microsoft YaHei"
|
||||||
|
style.font.size = Pt(size)
|
||||||
|
style.font.color.rgb = RGBColor.from_string(color)
|
||||||
|
style.element.get_or_add_rPr().get_or_add_rFonts().set(qn("w:eastAsia"), "微软雅黑")
|
||||||
|
style.paragraph_format.space_after = Pt(7 if name == "Normal" else 9)
|
||||||
|
if name == "Normal":
|
||||||
|
style.paragraph_format.line_spacing = 1.3
|
||||||
|
if name == "Title":
|
||||||
|
ppr = style.element.get_or_add_pPr()
|
||||||
|
border = ppr.find(qn("w:pBdr"))
|
||||||
|
if border is not None:
|
||||||
|
ppr.remove(border)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def shade_cell(cell, fill: str):
|
||||||
|
shading = OxmlElement("w:shd")
|
||||||
|
shading.set(qn("w:fill"), fill)
|
||||||
|
cell._tc.get_or_add_tcPr().append(shading)
|
||||||
|
|
||||||
|
|
||||||
|
def word_table(doc, rows, widths: tuple[float, ...], *, header: bool = False):
|
||||||
|
table = doc.add_table(rows=0, cols=len(widths))
|
||||||
|
table.autofit = False
|
||||||
|
table.style = "Table Grid"
|
||||||
|
borders = OxmlElement("w:tblBorders")
|
||||||
|
for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
|
||||||
|
line = OxmlElement(f"w:{edge}")
|
||||||
|
line.set(qn("w:val"), "single")
|
||||||
|
line.set(qn("w:sz"), "4")
|
||||||
|
line.set(qn("w:color"), "D9D9D9")
|
||||||
|
borders.append(line)
|
||||||
|
table._tbl.tblPr.append(borders)
|
||||||
|
for column, width in zip(table.columns, widths):
|
||||||
|
column.width = Cm(width)
|
||||||
|
for index, row in enumerate(rows):
|
||||||
cells = table.add_row().cells
|
cells = table.add_row().cells
|
||||||
cells[0].text, cells[1].text = label, text(value)
|
if header and index == 0:
|
||||||
cells[0].width, cells[1].width = Inches(1.3), Inches(5.2)
|
table.rows[-1]._tr.get_or_add_trPr().append(OxmlElement("w:tblHeader"))
|
||||||
doc.add_heading("结论与任务专属结果", 1)
|
for position, cell in enumerate(cells):
|
||||||
for kind, value in markdown_blocks(text(report["content"])):
|
cell.width = Cm(widths[position])
|
||||||
|
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
|
||||||
|
cell.text = text(row[position]) if position < len(row) else ""
|
||||||
|
margins = OxmlElement("w:tcMar")
|
||||||
|
for edge, size in (("top", "85"), ("bottom", "85"), ("left", "110"), ("right", "110")):
|
||||||
|
margin = OxmlElement(f"w:{edge}")
|
||||||
|
margin.set(qn("w:w"), size)
|
||||||
|
margin.set(qn("w:type"), "dxa")
|
||||||
|
margins.append(margin)
|
||||||
|
cell._tc.get_or_add_tcPr().append(margins)
|
||||||
|
if (header and index == 0) or (not header and position == 0):
|
||||||
|
shade_cell(cell, "DDEBF5")
|
||||||
|
for run in cell.paragraphs[0].runs:
|
||||||
|
run.bold = True
|
||||||
|
elif index % 2 == 0:
|
||||||
|
shade_cell(cell, "F6F9FC")
|
||||||
|
for paragraph in cell.paragraphs:
|
||||||
|
paragraph.paragraph_format.space_after = Pt(3)
|
||||||
|
for run in paragraph.runs:
|
||||||
|
run.font.size = Pt(8.5 if len(widths) > 2 else 9.5)
|
||||||
|
doc.add_paragraph().paragraph_format.space_after = Pt(2)
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def add_markdown_inline(paragraph, value: str):
|
||||||
|
"""Keep emphasis readable in Word without importing untrusted HTML."""
|
||||||
|
pieces = re.split(r"(\*\*[^*]+\*\*|`[^`]+`|\*[^*]+\*)", value)
|
||||||
|
for piece in pieces:
|
||||||
|
if not piece:
|
||||||
|
continue
|
||||||
|
run = paragraph.add_run(
|
||||||
|
piece.strip("*")
|
||||||
|
if piece.startswith("*")
|
||||||
|
else piece.strip("`")
|
||||||
|
if piece.startswith("`")
|
||||||
|
else piece
|
||||||
|
)
|
||||||
|
run.bold = piece.startswith("**")
|
||||||
|
run.italic = piece.startswith("*") and not piece.startswith("**")
|
||||||
|
if piece.startswith("`"):
|
||||||
|
run.font.name = "Consolas"
|
||||||
|
|
||||||
|
|
||||||
|
def add_markdown_content(doc, content: str):
|
||||||
|
in_code = False
|
||||||
|
for kind, value in markdown_blocks(text(content)):
|
||||||
if kind == "table":
|
if kind == "table":
|
||||||
table = doc.add_table(rows=0, cols=max(map(len, value)))
|
columns = max(map(len, value))
|
||||||
table.style = "Light Shading Accent 1"
|
if columns:
|
||||||
for row in value:
|
word_table(doc, value, tuple(16 / columns for _ in range(columns)), header=True)
|
||||||
cells = table.add_row().cells
|
continue
|
||||||
for cell, item in zip(cells, row):
|
line = str(value)
|
||||||
cell.text = item
|
if line.strip().startswith("```"):
|
||||||
elif isinstance(value, str) and value.startswith("#"):
|
in_code = not in_code
|
||||||
doc.add_heading(value.lstrip("# "), min(len(value) - len(value.lstrip("#")), 3))
|
continue
|
||||||
|
if in_code:
|
||||||
|
paragraph = doc.add_paragraph()
|
||||||
|
shade = OxmlElement("w:shd")
|
||||||
|
shade.set(qn("w:fill"), "F1F5F9")
|
||||||
|
paragraph._p.get_or_add_pPr().append(shade)
|
||||||
|
paragraph.add_run(line).font.name = "Consolas"
|
||||||
|
elif not line.strip():
|
||||||
|
continue
|
||||||
|
elif match := re.match(r"^(#{1,3})\s+(.+)$", line):
|
||||||
|
doc.add_heading(match.group(2), level=min(len(match.group(1)) + 1, 3))
|
||||||
|
elif match := re.match(r"^\s*([-*+] |\d+\. )(.+)$", line):
|
||||||
|
paragraph = doc.add_paragraph(
|
||||||
|
style="List Number" if match.group(1)[0].isdigit() else "List Bullet"
|
||||||
|
)
|
||||||
|
add_markdown_inline(paragraph, match.group(2))
|
||||||
else:
|
else:
|
||||||
doc.add_paragraph(str(value))
|
paragraph = doc.add_paragraph(style="Quote" if line.lstrip().startswith("> ") else None)
|
||||||
|
add_markdown_inline(
|
||||||
|
paragraph, line.lstrip("> ") if line.lstrip().startswith("> ") else line
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def word_report(report: dict, path: Path):
|
||||||
|
doc = word_styles(Document())
|
||||||
|
doc.add_paragraph(report["name"], style="Title")
|
||||||
|
doc.add_paragraph(
|
||||||
|
f"执行状态:{STATUS.get(report['status'], report['status'])} 验证结论:{report['verdict']}"
|
||||||
|
)
|
||||||
|
if report["error"]:
|
||||||
|
doc.add_heading("异常原因", 1)
|
||||||
|
doc.add_paragraph(text(report["error"]))
|
||||||
|
if result := report.get("test_result"):
|
||||||
|
doc.add_heading("测试结论与依据", 1)
|
||||||
|
doc.add_paragraph(text(result.get("reason") or "未记录"))
|
||||||
|
for evidence in result.get("evidence") or []:
|
||||||
|
doc.add_paragraph(text(evidence), style="List Bullet")
|
||||||
|
doc.add_heading("任务信息", 1)
|
||||||
|
details = [
|
||||||
|
(label, value)
|
||||||
|
for label, value in metadata(report)
|
||||||
|
if label in {"任务编号", "用例编号", "检测项目", "测试子项", "开始时间", "结束时间", "设备"}
|
||||||
|
]
|
||||||
|
word_table(doc, details, (3.2, 13.3))
|
||||||
|
doc.add_heading("任务目标与预期" if not report["expected"] else "任务目标", 2)
|
||||||
|
doc.add_paragraph(report["goal"] or "未记录")
|
||||||
|
if report["expected"]:
|
||||||
|
doc.add_heading("预期结果", 2)
|
||||||
|
doc.add_paragraph(report["expected"])
|
||||||
|
if report.get("preconditions"):
|
||||||
|
doc.add_paragraph("前置约束:" + text(report["preconditions"]))
|
||||||
|
if report.get("task_config"):
|
||||||
|
doc.add_paragraph("任务配置:" + config_text(report["task_config"]))
|
||||||
|
doc.add_heading("结论与发现", 1)
|
||||||
|
add_markdown_content(doc, report["content"])
|
||||||
doc.add_heading("执行步骤与证据", 1)
|
doc.add_heading("执行步骤与证据", 1)
|
||||||
for step in report["steps"]:
|
for step in report["steps"]:
|
||||||
doc.add_paragraph(
|
paragraph = doc.add_paragraph()
|
||||||
f"步骤 {step['step_number']} · {time_text(step.get('timestamp'))} · {step.get('summary') or '已记录操作'}"
|
paragraph.paragraph_format.keep_together = True
|
||||||
)
|
paragraph.add_run(
|
||||||
evidence = report["steps"][:1] + report["steps"][-3:]
|
f"步骤 {step['step_number']} · {time_text(step.get('timestamp'))}\n"
|
||||||
seen = set()
|
).bold = True
|
||||||
|
paragraph.add_run(step.get("summary") or "已记录操作")
|
||||||
|
evidence = report["steps"][:1] + report["steps"][-1:]
|
||||||
|
seen: set[str] = set()
|
||||||
|
images: list[tuple[Path, int]] = []
|
||||||
for step in evidence:
|
for step in evidence:
|
||||||
name = step.get("post_image_name") or step.get("pre_image_name")
|
name = step.get("post_image_name") or step.get("pre_image_name")
|
||||||
photo = image_path(name)
|
photo = image_path(name)
|
||||||
if photo and name not in seen:
|
if photo and name not in seen:
|
||||||
seen.add(name)
|
seen.add(name)
|
||||||
doc.add_paragraph(f"证据:步骤 {step['step_number']} / {name}")
|
images.append((photo, step["step_number"]))
|
||||||
doc.add_picture(str(photo), width=Inches(2.6))
|
if images:
|
||||||
|
doc.add_page_break()
|
||||||
|
doc.add_heading("关键截图", 2)
|
||||||
|
table = doc.add_table(rows=1, cols=len(images))
|
||||||
|
table.autofit = False
|
||||||
|
for cell, (photo, step_number) in zip(table.rows[0].cells, images):
|
||||||
|
cell.width = Cm(8 if len(images) == 2 else 12)
|
||||||
|
cell.paragraphs[0].add_run(f"步骤 {step_number}").bold = True
|
||||||
|
cell.add_paragraph().add_run().add_picture(
|
||||||
|
str(photo), width=Cm(7.5 if len(images) == 2 else 8.5)
|
||||||
|
)
|
||||||
|
doc.add_paragraph("其余步骤截图可从任务资料包单独下载。")
|
||||||
doc.add_paragraph(f"追溯编号:{report['id']}。未观察、未检查的项目不计为验证通过。")
|
doc.add_paragraph(f"追溯编号:{report['id']}。未观察、未检查的项目不计为验证通过。")
|
||||||
doc.save(str(path))
|
doc.save(str(path))
|
||||||
|
|
||||||
|
|
||||||
def sheet_rows(ws, rows):
|
def sheet_rows(ws, rows, widths: tuple[int, ...] = ()):
|
||||||
for row in rows:
|
ws.sheet_view.showGridLines = False
|
||||||
|
ws.freeze_panes = "A2"
|
||||||
|
ws.sheet_properties.pageSetUpPr.fitToPage = True
|
||||||
|
ws.page_setup.fitToWidth = 1
|
||||||
|
if len(widths) > 3:
|
||||||
|
ws.page_setup.orientation = "landscape"
|
||||||
|
ws.page_setup.paperSize = ws.PAPERSIZE_A3
|
||||||
|
ws.print_options.horizontalCentered = True
|
||||||
|
for index, row in enumerate(rows, 1):
|
||||||
ws.append([text(value)[:32700] if value is not None else "" for value in row])
|
ws.append([text(value)[:32700] if value is not None else "" for value in row])
|
||||||
for cell in ws[ws.max_row]:
|
longest = 1
|
||||||
|
for column, cell in enumerate(ws[index], 1):
|
||||||
# Always store text, including leading '=': never export executable formulas.
|
# Always store text, including leading '=': never export executable formulas.
|
||||||
cell.data_type = "s"
|
cell.data_type = "s"
|
||||||
cell.alignment = Alignment(wrap_text=True, vertical="top")
|
cell.alignment = Alignment(wrap_text=True, vertical="center" if index == 1 else "top")
|
||||||
ws.freeze_panes = "A2"
|
cell.font = Font(
|
||||||
if ws.max_row:
|
name="微软雅黑",
|
||||||
|
size=10,
|
||||||
|
bold=index == 1,
|
||||||
|
color="FFFFFF" if index == 1 else "253244",
|
||||||
|
)
|
||||||
|
cell.border = Border(bottom=Side(style="hair", color="E2E8F0"))
|
||||||
|
if index == 1:
|
||||||
|
cell.fill = PatternFill("solid", fgColor="245C78")
|
||||||
|
elif index % 2 == 1 or column == 1:
|
||||||
|
cell.fill = PatternFill("solid", fgColor="F3F7FA" if column == 1 else "F8FAFC")
|
||||||
|
width = widths[column - 1] if column <= len(widths) else 32
|
||||||
|
ws.column_dimensions[get_column_letter(column)].width = width
|
||||||
|
lines = str(cell.value or "").splitlines() or [""]
|
||||||
|
height_lines = sum(
|
||||||
|
max(1, (sum(2 if ord(char) > 255 else 1 for char in line) + width - 1) // width)
|
||||||
|
for line in lines
|
||||||
|
)
|
||||||
|
longest = max(longest, height_lines)
|
||||||
|
ws.row_dimensions[index].height = 30 if index == 1 else min(300, max(27, longest * 18 + 8))
|
||||||
|
if ws.max_row > 1:
|
||||||
ws.auto_filter.ref = ws.dimensions
|
ws.auto_filter.ref = ws.dimensions
|
||||||
for cell in ws[1]:
|
|
||||||
cell.font = Font(bold=True, color="FFFFFF")
|
|
||||||
cell.fill = PatternFill("solid", fgColor="245C78")
|
def conclusion_rows(content: str):
|
||||||
for column in ws.columns:
|
rows = [("类型", "结论与发现")]
|
||||||
ws.column_dimensions[column[0].column_letter].width = 32
|
table_number = 0
|
||||||
|
in_code = False
|
||||||
|
for kind, value in markdown_blocks(content):
|
||||||
|
if kind == "table":
|
||||||
|
table_number += 1
|
||||||
|
rows.append(("表格", f"详见「结果表{table_number}」工作表"))
|
||||||
|
elif line := str(value).strip():
|
||||||
|
if line.startswith("```"):
|
||||||
|
in_code = not in_code
|
||||||
|
continue
|
||||||
|
if in_code:
|
||||||
|
rows.append(("代码", line))
|
||||||
|
continue
|
||||||
|
if match := re.match(r"^#{1,6}\s+(.*)$", line):
|
||||||
|
label, body = "小节", match.group(1)
|
||||||
|
elif match := re.match(r"^(?:[-*+] |\d+\. )(.*)$", line):
|
||||||
|
label, body = "要点", match.group(1)
|
||||||
|
else:
|
||||||
|
label, body = "正文", line.lstrip("> ")
|
||||||
|
rows.append(
|
||||||
|
(
|
||||||
|
label,
|
||||||
|
re.sub(r"\*\*([^*]+)\*\*|`([^`]+)`", lambda m: m.group(1) or m.group(2), body),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def execution_result_label(result) -> str:
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return text(result or "未记录")[:120]
|
||||||
|
status = result.get("status")
|
||||||
|
labels = {
|
||||||
|
"dispatched": "动作已发出(需核对截图)",
|
||||||
|
"success": "动作返回成功",
|
||||||
|
"failed": "动作失败",
|
||||||
|
}
|
||||||
|
label = labels.get(status, text(status or "未记录"))
|
||||||
|
if result.get("error"):
|
||||||
|
label += "\n" + text(result["error"])[:180]
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
def excel_report(report: dict, path: Path):
|
def excel_report(report: dict, path: Path):
|
||||||
wb = Workbook()
|
wb = Workbook()
|
||||||
assert wb.active is not None
|
assert wb.active is not None
|
||||||
wb.active.title = "任务结果"
|
wb.active.title = "任务结果"
|
||||||
sheet_rows(wb.active, [("项目", "内容"), *metadata(report)])
|
sheet_rows(wb.active, [("项目", "内容"), *metadata(report)], (22, 82))
|
||||||
sheet_rows(
|
sheet_rows(wb.create_sheet("结论"), conclusion_rows(report["content"]), (15, 96))
|
||||||
wb.create_sheet("结论"),
|
table_number = 0
|
||||||
[("最终结论",), *[(line,) for line in report["content"].splitlines()]],
|
for kind, rows in markdown_blocks(report["content"]):
|
||||||
)
|
|
||||||
for number, (kind, rows) in enumerate(markdown_blocks(report["content"])):
|
|
||||||
if kind == "table":
|
if kind == "table":
|
||||||
sheet_rows(wb.create_sheet(f"结果表{number + 1}"), rows)
|
table_number += 1
|
||||||
|
sheet_rows(wb.create_sheet(f"结果表{table_number}"), rows)
|
||||||
sheet_rows(
|
sheet_rows(
|
||||||
wb.create_sheet("执行证据"),
|
wb.create_sheet("执行证据"),
|
||||||
[
|
[
|
||||||
@@ -252,12 +620,15 @@ def excel_report(report: dict, path: Path):
|
|||||||
s["step_number"],
|
s["step_number"],
|
||||||
time_text(s.get("timestamp")),
|
time_text(s.get("timestamp")),
|
||||||
s.get("summary"),
|
s.get("summary"),
|
||||||
text(s.get("last_execution_result")),
|
execution_result_label(s.get("last_execution_result")),
|
||||||
s.get("post_image_name") or s.get("pre_image_name"),
|
"见步骤截图资料包"
|
||||||
|
if s.get("post_image_name") or s.get("pre_image_name")
|
||||||
|
else "无截图",
|
||||||
)
|
)
|
||||||
for s in report["steps"]
|
for s in report["steps"]
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
(10, 22, 68, 26, 24),
|
||||||
)
|
)
|
||||||
wb.save(path)
|
wb.save(path)
|
||||||
|
|
||||||
@@ -279,7 +650,94 @@ def export_session(session_id: str, formats: list[str] | None = None) -> list[Pa
|
|||||||
return outputs
|
return outputs
|
||||||
|
|
||||||
|
|
||||||
def export_batch(batch_id: str) -> Path:
|
def session_screenshots(report: dict) -> list[tuple[Path, str]]:
|
||||||
|
"""Ordered, path-checked pre/post observations for each recorded step."""
|
||||||
|
screenshots: list[tuple[Path, str]] = []
|
||||||
|
for index, step in enumerate(report["steps"], 1):
|
||||||
|
for phase, key in (("操作前", "pre_image_name"), ("操作后", "post_image_name")):
|
||||||
|
source = image_path(step.get(key))
|
||||||
|
if source:
|
||||||
|
screenshots.append(
|
||||||
|
(
|
||||||
|
source,
|
||||||
|
f"{index:04d}_步骤{int(step.get('step_number') or index):04d}_{phase}{source.suffix.lower()}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return screenshots
|
||||||
|
|
||||||
|
|
||||||
|
def session_videos(report: dict) -> list[tuple[Path, str]]:
|
||||||
|
"""Find only verified files attached to this session's recording directory."""
|
||||||
|
session = SessionRepository().get_session_by_id(report["id"]) or {}
|
||||||
|
recording = SessionRepository().get_video_recording_for_session(report["id"]) or {}
|
||||||
|
candidate = recording.get("local_video_path") or session.get("video_filepath")
|
||||||
|
if not candidate:
|
||||||
|
return []
|
||||||
|
source = Path(str(candidate)).resolve()
|
||||||
|
trace_root = TRACES_PATH.resolve()
|
||||||
|
# Workers save recordings in their finalized web_<time>_<id>_PASS/FAIL
|
||||||
|
# folder. The recording row already ties that path to this session.
|
||||||
|
if not source.is_relative_to(trace_root):
|
||||||
|
return []
|
||||||
|
# A resumed recording lives under resume_NNNN; the initial recording is
|
||||||
|
# in its parent, with any later segments in sibling resume folders.
|
||||||
|
directory = source.parent
|
||||||
|
if directory.name.startswith("resume_"):
|
||||||
|
directory = directory.parent
|
||||||
|
if not directory.is_dir() or not directory.is_relative_to(trace_root):
|
||||||
|
return []
|
||||||
|
paths = [
|
||||||
|
p
|
||||||
|
for p in directory.rglob("*.mp4")
|
||||||
|
if p.is_file()
|
||||||
|
and p.resolve().is_relative_to(directory.resolve())
|
||||||
|
and (p.parent == directory or p.parent.name.startswith("resume_"))
|
||||||
|
]
|
||||||
|
paths.sort(key=lambda p: (0 if p.parent == directory else 1, p.parent.name, p.name))
|
||||||
|
return [(path, f"{i:04d}_{path.parent.name}_{path.name}") for i, path in enumerate(paths, 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def export_session_media(session_id: str, kind: str) -> Path:
|
||||||
|
if kind not in {"screenshots", "video", "all"}:
|
||||||
|
raise ValueError("不支持的下载类型")
|
||||||
|
with EXPORT_LOCK:
|
||||||
|
report = session_report(session_id)
|
||||||
|
target_dir = EXPORT_ROOT / "sessions" / identifier(session_id)
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
images = session_screenshots(report) if kind in {"screenshots", "all"} else []
|
||||||
|
videos = session_videos(report) if kind in {"video", "all"} else []
|
||||||
|
if kind == "video" and not videos:
|
||||||
|
raise ValueError("此任务没有已保存的录屏")
|
||||||
|
if kind == "screenshots" and not images:
|
||||||
|
raise ValueError("此任务没有可下载的步骤截图")
|
||||||
|
target = target_dir / f"{kind}.zip"
|
||||||
|
temporary = target_dir / f".{kind}.tmp"
|
||||||
|
manifest = {
|
||||||
|
"session_id": session_id,
|
||||||
|
"name": report["name"],
|
||||||
|
"status": report["status"],
|
||||||
|
"verdict": report["verdict"],
|
||||||
|
"screenshots": [name for _, name in images],
|
||||||
|
"videos": [name for _, name in videos],
|
||||||
|
"note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
|
||||||
|
}
|
||||||
|
prefix = archive_case_name(report) + "/" if kind == "all" else ""
|
||||||
|
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
if kind == "all":
|
||||||
|
for path in export_session(session_id, ["docx", "xlsx"]):
|
||||||
|
archive.write(path, prefix + path.name)
|
||||||
|
for source, name in images:
|
||||||
|
archive.write(source, f"{prefix}步骤截图/{name}")
|
||||||
|
for source, name in videos:
|
||||||
|
archive.write(source, f"{prefix}任务录屏/{name}")
|
||||||
|
archive.writestr(
|
||||||
|
prefix + "文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2)
|
||||||
|
)
|
||||||
|
temporary.replace(target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None = None) -> Path:
|
||||||
with EXPORT_LOCK:
|
with EXPORT_LOCK:
|
||||||
batch = store.get("batch", identifier(batch_id))
|
batch = store.get("batch", identifier(batch_id))
|
||||||
if not batch:
|
if not batch:
|
||||||
@@ -288,6 +746,8 @@ def export_batch(batch_id: str) -> Path:
|
|||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
formats = batch["formats"]
|
formats = batch["formats"]
|
||||||
records = [session_report(job["session_id"]) for job in store.jobs(batch_id)]
|
records = [session_report(job["session_id"]) for job in store.jobs(batch_id)]
|
||||||
|
if progress:
|
||||||
|
progress(0, len(records), "正在生成批次汇总")
|
||||||
rows = [("任务编号", "任务名称", "执行状态", "验证结论", "异常原因")]
|
rows = [("任务编号", "任务名称", "执行状态", "验证结论", "异常原因")]
|
||||||
rows += [
|
rows += [
|
||||||
(r["id"], r["name"], STATUS.get(r["status"], r["status"]), r["verdict"], r["error"])
|
(r["id"], r["name"], STATUS.get(r["status"], r["status"]), r["verdict"], r["error"])
|
||||||
@@ -298,21 +758,17 @@ def export_batch(batch_id: str) -> Path:
|
|||||||
wb = Workbook()
|
wb = Workbook()
|
||||||
assert wb.active is not None
|
assert wb.active is not None
|
||||||
wb.active.title = "批次结果"
|
wb.active.title = "批次结果"
|
||||||
sheet_rows(wb.active, rows)
|
sheet_rows(wb.active, rows, (40, 48, 16, 28, 70))
|
||||||
path = directory / "批次结果.xlsx"
|
path = directory / "批次结果.xlsx"
|
||||||
wb.save(path)
|
wb.save(path)
|
||||||
outputs.append(path)
|
outputs.append(path)
|
||||||
if "docx" in formats:
|
if "docx" in formats:
|
||||||
doc = Document()
|
doc = word_styles(Document(), landscape=True)
|
||||||
doc.add_heading(batch["name"], 0)
|
doc.add_paragraph(batch["name"], style="Title")
|
||||||
doc.add_paragraph(
|
doc.add_paragraph(
|
||||||
f"批次编号:{batch_id};共 {len(records)} 个任务。验证通过与执行完成分别统计。"
|
f"批次编号:{batch_id};共 {len(records)} 个任务。验证通过与执行完成分别统计。"
|
||||||
)
|
)
|
||||||
table = doc.add_table(rows=0, cols=5)
|
word_table(doc, rows, (3.5, 5.4, 2.5, 3.5, 7.3), header=True)
|
||||||
table.style = "Light Shading Accent 1"
|
|
||||||
for row in rows:
|
|
||||||
for cell, value in zip(table.add_row().cells, row):
|
|
||||||
cell.text = str(value)
|
|
||||||
path = directory / "批次汇总.docx"
|
path = directory / "批次汇总.docx"
|
||||||
doc.save(str(path))
|
doc.save(str(path))
|
||||||
outputs.append(path)
|
outputs.append(path)
|
||||||
@@ -322,7 +778,18 @@ def export_batch(batch_id: str) -> Path:
|
|||||||
for path in outputs:
|
for path in outputs:
|
||||||
archive.write(path, path.name)
|
archive.write(path, path.name)
|
||||||
for number, report in enumerate(records, 1):
|
for number, report in enumerate(records, 1):
|
||||||
|
if progress:
|
||||||
|
progress(number - 1, len(records), f"正在打包任务 {number}/{len(records)}")
|
||||||
|
prefix = f"任务报告/{number:04d}_{archive_case_name(report)}"
|
||||||
for path in export_session(report["id"], formats):
|
for path in export_session(report["id"], formats):
|
||||||
archive.write(path, f"任务报告/{number:04d}_{report['id']}/{path.name}")
|
archive.write(path, f"{prefix}/{path.name}")
|
||||||
|
for source, name in session_screenshots(report):
|
||||||
|
archive.write(source, f"{prefix}/步骤截图/{name}")
|
||||||
|
for source, name in session_videos(report):
|
||||||
|
archive.write(source, f"{prefix}/任务录屏/{name}")
|
||||||
|
if progress:
|
||||||
|
progress(number, len(records), f"已打包任务 {number}/{len(records)}")
|
||||||
|
if progress:
|
||||||
|
progress(len(records), len(records), "正在完成压缩包")
|
||||||
temporary.replace(target)
|
temporary.replace(target)
|
||||||
return target
|
return target
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Versioned Excel task template and bounded, non-executing import validation."""
|
"""Validate the prompt-first batch workbook without executing its contents."""
|
||||||
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import json
|
import json
|
||||||
@@ -6,33 +6,58 @@ import os
|
|||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
from openpyxl import Workbook, load_workbook
|
from openpyxl import Workbook, load_workbook
|
||||||
|
from openpyxl.styles import Alignment, Font, PatternFill
|
||||||
from openpyxl.utils import get_column_letter
|
from openpyxl.utils import get_column_letter
|
||||||
from openpyxl.styles import Font, PatternFill, Alignment
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from pydantic import BaseModel, Field, ConfigDict
|
|
||||||
|
|
||||||
|
HEADERS = (
|
||||||
HEADERS = {
|
"用例编号",
|
||||||
"任务名称": "name",
|
"检测项目",
|
||||||
"任务目标": "goal",
|
"测试子项",
|
||||||
"预期结果": "expected",
|
"测试用例",
|
||||||
"目标应用": "app",
|
"前置约束提取",
|
||||||
"前置条件": "preconditions",
|
"Artemis提示词",
|
||||||
"输入参数(JSON)": "parameters",
|
"任务配置(JSON)",
|
||||||
"标签": "tags",
|
)
|
||||||
}
|
|
||||||
MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000"))
|
MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000"))
|
||||||
MAX_BYTES = int(os.getenv("ARTEMIS_IMPORT_MAX_BYTES", "10485760"))
|
MAX_BYTES = int(os.getenv("ARTEMIS_IMPORT_MAX_BYTES", "10485760"))
|
||||||
|
|
||||||
|
|
||||||
|
class TaskOptions(BaseModel):
|
||||||
|
"""Allowlisted per-case overrides; credentials are never accepted in a workbook."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
profile: str | None = Field(default=None, pattern="^(flash|pro)$")
|
||||||
|
model: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
reasoning_effort: str | None = Field(
|
||||||
|
default=None, pattern="^(default|none|minimal|low|medium|high|xhigh|max)$"
|
||||||
|
)
|
||||||
|
device_serial: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
verification_level: str | None = Field(default=None, pattern="^(off|final|checkpoints|strict)$")
|
||||||
|
explorer_mode: str | None = Field(default=None, pattern="^(flash|pro|ultra)$")
|
||||||
|
locked_app_package: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
record_video: bool | None = None
|
||||||
|
analyze_video: bool | None = None
|
||||||
|
request_timeout_seconds: float | None = Field(default=None, gt=0, le=3600)
|
||||||
|
max_retries: int | None = Field(default=None, ge=0, le=10)
|
||||||
|
retry_interval_seconds: float | None = Field(default=None, ge=0, le=300)
|
||||||
|
timeout_seconds: int | None = Field(default=None, ge=30, le=86400)
|
||||||
|
observation_timeout_seconds: float | None = Field(default=None, gt=0, le=300)
|
||||||
|
observation_max_attempts: int | None = Field(default=None, ge=1, le=5)
|
||||||
|
device_command_timeout_seconds: float | None = Field(default=None, gt=0, le=60)
|
||||||
|
action_failure_attempts: int | None = Field(default=None, ge=1, le=10)
|
||||||
|
worker_cleanup_timeout_seconds: float | None = Field(default=None, gt=0, le=300)
|
||||||
|
|
||||||
|
|
||||||
class TaskCase(BaseModel):
|
class TaskCase(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
case_id: str = Field(default="", max_length=200)
|
||||||
|
category: str = Field(default="", max_length=200)
|
||||||
|
subcategory: str = Field(default="", max_length=200)
|
||||||
name: str = Field(min_length=1, max_length=200)
|
name: str = Field(min_length=1, max_length=200)
|
||||||
goal: str = Field(min_length=1, max_length=10000)
|
|
||||||
expected: str = Field(default="", max_length=10000)
|
|
||||||
app: str = Field(default="", max_length=200)
|
|
||||||
preconditions: str = Field(default="", max_length=10000)
|
preconditions: str = Field(default="", max_length=10000)
|
||||||
parameters: dict[str, str | int | float | bool] = Field(default_factory=dict)
|
goal: str = Field(min_length=1, max_length=10000)
|
||||||
tags: str = Field(default="", max_length=500)
|
task_config: TaskOptions = Field(default_factory=TaskOptions)
|
||||||
|
|
||||||
|
|
||||||
def template() -> bytes:
|
def template() -> bytes:
|
||||||
@@ -40,27 +65,54 @@ def template() -> bytes:
|
|||||||
ws = wb.active
|
ws = wb.active
|
||||||
assert ws is not None
|
assert ws is not None
|
||||||
ws.title = "任务"
|
ws.title = "任务"
|
||||||
ws.append(list(HEADERS))
|
ws.append(HEADERS)
|
||||||
ws.freeze_panes = "A2"
|
ws.freeze_panes = "A2"
|
||||||
ws.auto_filter.ref = "A1:G1"
|
ws.auto_filter.ref = "A1:G1"
|
||||||
for cell in ws[1]:
|
for cell in ws[1]:
|
||||||
cell.font = Font(bold=True, color="FFFFFF")
|
cell.font = Font(bold=True, color="FFFFFF")
|
||||||
cell.fill = PatternFill("solid", fgColor="245C78")
|
cell.fill = PatternFill("solid", fgColor="245C78")
|
||||||
ws.column_dimensions[get_column_letter(int(cell.column or 1))].width = 32
|
ws.column_dimensions[get_column_letter(int(cell.column or 1))].width = (
|
||||||
|
65 if cell.column in {6, 7} else 28
|
||||||
|
)
|
||||||
help_sheet = wb.create_sheet("填写说明")
|
help_sheet = wb.create_sheet("填写说明")
|
||||||
for line in [
|
help_sheet.append(
|
||||||
"模板版本:1",
|
["模板版本:2。任务表每行一项,Artemis提示词必填;只把这一列作为 AI 任务正文。"]
|
||||||
"每行一个独立任务;任务名称、任务目标必填。",
|
)
|
||||||
"预期结果决定要验证的业务条件。未填写时只报告任务完成情况。",
|
help_sheet.append(["前置约束提取及其他描述列仅作查看、搜索和报告元数据,不追加到提示词。"])
|
||||||
"前置条件描述实际用例起点;不会统一重启或清空应用数据。",
|
help_sheet.append(["任务配置(JSON)可留空,空字段继承批次或全局配置;禁止密钥与未知字段。"])
|
||||||
'输入参数使用 JSON 对象,例如 {"关键词":"天气"};目标可引用 {{关键词}}。',
|
help_sheet.append(["示例仅供参考,不会导入执行。请在“任务”页填写自己的用例。"])
|
||||||
"目标应用可填名称或包名;空白时由任务决定。",
|
help_sheet.append(HEADERS)
|
||||||
"不要填写 Excel 公式、宏或外部链接。导入仅预览,点击开始后执行。",
|
help_sheet.append(
|
||||||
]:
|
[
|
||||||
help_sheet.append([line])
|
"示例-001",
|
||||||
help_sheet.column_dimensions["A"].width = 105
|
"示例模块",
|
||||||
|
"示例检查",
|
||||||
|
"检查示例页面",
|
||||||
|
"设备已经打开目标应用",
|
||||||
|
"观察当前页面并报告实际结果,不要假设检查已通过。",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"profile": "pro",
|
||||||
|
"model": "mimo-v2.6-flash",
|
||||||
|
"reasoning_effort": "high",
|
||||||
|
"verification_level": "final",
|
||||||
|
"record_video": True,
|
||||||
|
"analyze_video": False,
|
||||||
|
"request_timeout_seconds": 300,
|
||||||
|
"max_retries": 5,
|
||||||
|
"retry_interval_seconds": 10,
|
||||||
|
"timeout_seconds": 1800,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for column in range(1, 8):
|
||||||
|
help_sheet.column_dimensions[get_column_letter(column)].width = 70 if column > 5 else 26
|
||||||
for row in help_sheet:
|
for row in help_sheet:
|
||||||
row[0].alignment = Alignment(wrap_text=True)
|
for cell in row:
|
||||||
|
cell.alignment = Alignment(wrap_text=True, vertical="top")
|
||||||
|
help_sheet.row_dimensions[6].height = 110
|
||||||
stream = BytesIO()
|
stream = BytesIO()
|
||||||
wb.save(stream)
|
wb.save(stream)
|
||||||
return stream.getvalue()
|
return stream.getvalue()
|
||||||
@@ -90,7 +142,7 @@ def parse_workbook(data: bytes) -> dict:
|
|||||||
rows = iter(wb["任务"].iter_rows())
|
rows = iter(wb["任务"].iter_rows())
|
||||||
headers = [str(cell.value or "").strip() for cell in next(rows, [])]
|
headers = [str(cell.value or "").strip() for cell in next(rows, [])]
|
||||||
if headers != list(HEADERS):
|
if headers != list(HEADERS):
|
||||||
raise ValueError("列名或顺序与模板不一致,请重新下载模板")
|
raise ValueError("列名或顺序与新版模板不一致,请重新下载模板")
|
||||||
tasks, errors = [], []
|
tasks, errors = [], []
|
||||||
for number, row in enumerate(rows, 2):
|
for number, row in enumerate(rows, 2):
|
||||||
if number > MAX_ROWS + 1:
|
if number > MAX_ROWS + 1:
|
||||||
@@ -100,28 +152,48 @@ def parse_workbook(data: bytes) -> dict:
|
|||||||
try:
|
try:
|
||||||
if any(cell.data_type == "f" for cell in row):
|
if any(cell.data_type == "f" for cell in row):
|
||||||
raise ValueError("不允许公式,请粘贴为纯文本")
|
raise ValueError("不允许公式,请粘贴为纯文本")
|
||||||
values = [str(cell.value or "").strip() for cell in row[: len(HEADERS)]]
|
values = [
|
||||||
item = dict(zip(HEADERS.values(), values))
|
str(cell.value if cell.value is not None else "").strip() for cell in row[:7]
|
||||||
item["parameters"] = json.loads(item.get("parameters") or "{}")
|
]
|
||||||
case = TaskCase.model_validate(item)
|
config = json.loads(values[6] or "{}")
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
raise ValueError("任务配置必须为 JSON 对象")
|
||||||
|
case = TaskCase.model_validate(
|
||||||
|
{
|
||||||
|
"case_id": values[0],
|
||||||
|
"category": values[1],
|
||||||
|
"subcategory": values[2],
|
||||||
|
"name": values[3] or values[2] or values[0] or values[5][:120],
|
||||||
|
"preconditions": values[4],
|
||||||
|
"goal": values[5],
|
||||||
|
"task_config": config,
|
||||||
|
}
|
||||||
|
)
|
||||||
tasks.append({"row": number, **case.model_dump()})
|
tasks.append({"row": number, **case.model_dump()})
|
||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError) as exc:
|
||||||
errors.append({"row": number, "message": str(exc)[:500]})
|
errors.append({"row": number, "message": str(exc)[:500]})
|
||||||
if not tasks and not errors:
|
if not tasks and not errors:
|
||||||
errors.append({"row": 2, "message": "没有任务,请填写模板后重新导入"})
|
errors.append({"row": 2, "message": "没有任务,请填写模板后重新导入"})
|
||||||
return {"version": 1, "tasks": tasks, "errors": errors, "valid": bool(tasks) and not errors}
|
return {"version": 2, "tasks": tasks, "errors": errors, "valid": bool(tasks) and not errors}
|
||||||
finally:
|
finally:
|
||||||
wb.close()
|
wb.close()
|
||||||
|
|
||||||
|
|
||||||
def case_goal(case: dict) -> str:
|
def case_goal(case: dict) -> str:
|
||||||
goal = case["goal"]
|
"""Execution gets the prompt verbatim; metadata stays outside model input."""
|
||||||
for key, value in case.get("parameters", {}).items():
|
return case["goal"]
|
||||||
goal = goal.replace("{{" + key + "}}", str(value))
|
|
||||||
return (
|
|
||||||
f"任务名称:{case['name']}\n任务目标:{goal}\n目标应用:{case.get('app') or '按目标判断'}\n"
|
def case_title(case: dict) -> str:
|
||||||
f"前置条件:{case.get('preconditions') or '根据实际用例判断合适起点'}\n"
|
"""Human-facing title; never prepend metadata to the model prompt."""
|
||||||
"先观察手机并恢复到本用例需要的起始位置,记录准备过程。不要固定重启或清空数据。"
|
case_id = str(case.get("case_id") or "").strip()
|
||||||
"如果无法建立前置条件,明确报告前置条件未满足,不继续正文操作。\n"
|
name = str(case.get("name") or "").strip()
|
||||||
f"预期结果:{case.get('expected') or '仅确认任务完成情况,不宣称测试通过'}"
|
return " ".join(part for part in (case_id, name) if part)
|
||||||
)
|
|
||||||
|
|
||||||
|
def execution_title(job: dict) -> str:
|
||||||
|
"""Distinguish repeated executions without altering the original case or prompt."""
|
||||||
|
title = case_title(job.get("case") or {})
|
||||||
|
if job.get("repeat_count", 1) > 1:
|
||||||
|
title += f" · 第 {job['iteration']} / {job['repeat_count']} 轮"
|
||||||
|
return title
|
||||||
@@ -302,7 +302,7 @@ class TaskQueueService:
|
|||||||
normalized = current_status.lower().strip() if isinstance(current_status, str) else None
|
normalized = current_status.lower().strip() if isinstance(current_status, str) else None
|
||||||
if normalized == "success":
|
if normalized == "success":
|
||||||
return "completed", True
|
return "completed", True
|
||||||
if normalized in {"completed", "failed", "cancelled"}:
|
if normalized in {"completed", "failed", "cancelled", "interrupted"}:
|
||||||
return normalized, False
|
return normalized, False
|
||||||
return ("completed" if returncode == 0 else "failed"), True
|
return ("completed" if returncode == 0 else "failed"), True
|
||||||
|
|
||||||
@@ -388,10 +388,9 @@ class TaskQueueService:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _dispatch_pending_tasks(cls) -> None:
|
def _dispatch_pending_tasks(cls) -> None:
|
||||||
"""Launch every pending task admissible under the current concurrency limit."""
|
"""Launch every pending task admissible under the current concurrency limit."""
|
||||||
|
cls._sync_control_states()
|
||||||
limit = cls._concurrency_limit()
|
limit = cls._concurrency_limit()
|
||||||
state.prune_finished_runs()
|
state.prune_finished_runs()
|
||||||
if limit == 1 and state.is_running:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Dispatched-but-not-yet-spawned runs are only visible as queue items in
|
# Dispatched-but-not-yet-spawned runs are only visible as queue items in
|
||||||
# "running" state, so admission must count those too -- active_runs alone
|
# "running" state, so admission must count those too -- active_runs alone
|
||||||
@@ -410,7 +409,9 @@ class TaskQueueService:
|
|||||||
and item.get("pid") not in active_pids
|
and item.get("pid") not in active_pids
|
||||||
for item in in_flight
|
for item in in_flight
|
||||||
)
|
)
|
||||||
capacity = limit - len(state.active_runs) - starting
|
capacity = (
|
||||||
|
limit - sum(not r.get("parked") for r in state.active_runs.values()) - starting
|
||||||
|
)
|
||||||
if capacity <= 0:
|
if capacity <= 0:
|
||||||
return
|
return
|
||||||
busy_devices = state.busy_device_ids | {
|
busy_devices = state.busy_device_ids | {
|
||||||
@@ -432,11 +433,15 @@ class TaskQueueService:
|
|||||||
# A task without a resolved device may bind to any serial, so it
|
# A task without a resolved device may bind to any serial, so it
|
||||||
# only launches on an otherwise idle scheduler; the device lock
|
# only launches on an otherwise idle scheduler; the device lock
|
||||||
# then allocates freely without contending against active runs.
|
# then allocates freely without contending against active runs.
|
||||||
if device is None and (state.active_runs or in_flight or dispatched_any):
|
if device is None and (
|
||||||
|
any(not r.get("parked") for r in state.active_runs.values())
|
||||||
|
or in_flight
|
||||||
|
or dispatched_any
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
if device is not None and target.lock_key in busy_devices:
|
if device is not None and target.lock_key in busy_devices:
|
||||||
continue
|
continue
|
||||||
elif limit > 1 and device is not None and target.lock_key in busy_devices:
|
elif limit >= 1 and device is not None and target.lock_key in busy_devices:
|
||||||
# A second worker for this device would wait on its lock.
|
# A second worker for this device would wait on its lock.
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -454,6 +459,42 @@ class TaskQueueService:
|
|||||||
if capacity <= 0:
|
if capacity <= 0:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _sync_control_states(cls) -> None:
|
||||||
|
from artemis.runtime.task_control import read_state
|
||||||
|
from apps.admin_console.services.automation_store import store
|
||||||
|
|
||||||
|
for item in state.queue_items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
sid = str(item.get("session_id") or "")
|
||||||
|
run = state.active_runs.get(sid)
|
||||||
|
if not run:
|
||||||
|
continue
|
||||||
|
status = read_state(sid).get("status")
|
||||||
|
if status not in {"running", "task_paused", "resuming"}:
|
||||||
|
continue
|
||||||
|
old = item.get("status")
|
||||||
|
if status == old:
|
||||||
|
continue
|
||||||
|
parked = status in {"task_paused", "resuming"}
|
||||||
|
now = time.time()
|
||||||
|
if parked and not run.get("parked"):
|
||||||
|
run["non_execution_started_at"] = now
|
||||||
|
elif not parked and run.get("parked"):
|
||||||
|
run["non_execution_seconds"] = (
|
||||||
|
float(run.get("non_execution_seconds") or 0)
|
||||||
|
+ now
|
||||||
|
- float(run.pop("non_execution_started_at", now))
|
||||||
|
)
|
||||||
|
run["parked"] = parked
|
||||||
|
item["status"] = status
|
||||||
|
job = store.job(sid)
|
||||||
|
if job and job.get("status") != "finalizing":
|
||||||
|
job["status"] = status
|
||||||
|
store.save_job(job)
|
||||||
|
cls._broadcast_event("task_status", {"session_id": sid, "status": status})
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _begin_task_run(
|
def _begin_task_run(
|
||||||
cls,
|
cls,
|
||||||
@@ -531,6 +572,26 @@ class TaskQueueService:
|
|||||||
env["ARTEMIS_SESSION_ID"] = str(sess_id)
|
env["ARTEMIS_SESSION_ID"] = str(sess_id)
|
||||||
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
|
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
|
||||||
env["ARTEMIS_TASK_WORKER"] = "1"
|
env["ARTEMIS_TASK_WORKER"] = "1"
|
||||||
|
overrides = task_item.get("runtime_overrides") or {}
|
||||||
|
for option, variable in {
|
||||||
|
"model": "OPENAI_MODEL",
|
||||||
|
"reasoning_effort": "OPENAI_REASONING_EFFORT",
|
||||||
|
"request_timeout_seconds": "OPENAI_TIMEOUT_SECONDS",
|
||||||
|
"max_retries": "OPENAI_MAX_RETRIES",
|
||||||
|
"retry_interval_seconds": "OPENAI_RETRY_INTERVAL_SECONDS",
|
||||||
|
"record_video": "ARTEMIS_RECORDING_ENABLED",
|
||||||
|
"analyze_video": "ARTEMIS_VIDEO_ENABLED",
|
||||||
|
"observation_timeout_seconds": "ARTEMIS_OBSERVATION_TIMEOUT_SECONDS",
|
||||||
|
"observation_max_attempts": "ARTEMIS_OBSERVATION_MAX_ATTEMPTS",
|
||||||
|
"device_command_timeout_seconds": "ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS",
|
||||||
|
"action_failure_attempts": "ARTEMIS_ACTION_FAILURE_ATTEMPTS",
|
||||||
|
}.items():
|
||||||
|
if overrides.get(option) is not None:
|
||||||
|
env[variable] = (
|
||||||
|
str(overrides[option]).lower()
|
||||||
|
if isinstance(overrides[option], bool)
|
||||||
|
else str(overrides[option])
|
||||||
|
)
|
||||||
target.endpoint.apply_to_environment(env)
|
target.endpoint.apply_to_environment(env)
|
||||||
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
|
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
|
||||||
queue_ticket = task_item.get("queue_ticket")
|
queue_ticket = task_item.get("queue_ticket")
|
||||||
@@ -573,6 +634,72 @@ class TaskQueueService:
|
|||||||
env["RESULTS_OUTPUT_PATH"] = str(TRACES_PATH / str(sess_id) / "result.json")
|
env["RESULTS_OUTPUT_PATH"] = str(TRACES_PATH / str(sess_id) / "result.json")
|
||||||
return cmd, env
|
return cmd, env
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _await_worker_with_budget(cls, proc, task_item: dict, run_key: str) -> int:
|
||||||
|
from artemis.runtime.task_limits import ExecutionBudget, task_timeout
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
from artemis.runtime.task_result import save_failure_result, read_worker_outcome
|
||||||
|
from artemis.runtime.task_control import read_state
|
||||||
|
from apps.admin_console.services.automation_store import store
|
||||||
|
|
||||||
|
sid = str(task_item.get("session_id") or run_key)
|
||||||
|
job = store.job(sid)
|
||||||
|
batch = store.get("batch", job["batch_id"]) if job and job.get("batch_id") else None
|
||||||
|
limit = task_timeout(task_item, job, batch)
|
||||||
|
budget = ExecutionBudget(limit)
|
||||||
|
cleanup_limit = (task_item.get("runtime_overrides") or {}).get(
|
||||||
|
"worker_cleanup_timeout_seconds"
|
||||||
|
) or settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS
|
||||||
|
finalized_at = None
|
||||||
|
waiter = asyncio.create_task(cls._wait_for_worker_process(proc))
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
done, _ = await asyncio.wait({waiter}, timeout=0.25)
|
||||||
|
if waiter in done:
|
||||||
|
return waiter.result()
|
||||||
|
outcome = read_worker_outcome(sid)
|
||||||
|
if outcome:
|
||||||
|
finalized_at = finalized_at if finalized_at is not None else time.monotonic()
|
||||||
|
if time.monotonic() - finalized_at >= cleanup_limit:
|
||||||
|
logger.warning(
|
||||||
|
"%s: task finalized but worker cleanup exceeded %s seconds; reclaiming process",
|
||||||
|
sid,
|
||||||
|
cleanup_limit,
|
||||||
|
)
|
||||||
|
await cls._terminate_worker_process(proc)
|
||||||
|
return await waiter
|
||||||
|
continue # Final result is authoritative; only bound resource cleanup now.
|
||||||
|
cls._sync_control_states()
|
||||||
|
run = state.active_runs.get(run_key, {})
|
||||||
|
remaining = budget.update(
|
||||||
|
excluded=bool(run.get("parked")) or read_state(sid).get("status") == "queued"
|
||||||
|
)
|
||||||
|
if remaining > 0:
|
||||||
|
continue
|
||||||
|
error = f"任务执行超时({limit} 秒,不含暂停/排队时间)"
|
||||||
|
task_item.update(timed_out=True, error=error)
|
||||||
|
logger.error("%s: %s", sid, error)
|
||||||
|
try:
|
||||||
|
latest = store.job(sid)
|
||||||
|
if latest:
|
||||||
|
latest.update(timed_out=True, error=error)
|
||||||
|
store.save_job(latest)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Cannot persist timeout for %s; terminating worker anyway", sid
|
||||||
|
)
|
||||||
|
await cls._terminate_worker_process(proc)
|
||||||
|
returncode = await waiter
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(save_failure_result, sid, error, code="task_timeout")
|
||||||
|
except OSError:
|
||||||
|
logger.exception("Cannot save timeout report for %s", sid)
|
||||||
|
return returncode
|
||||||
|
finally:
|
||||||
|
if not waiter.done():
|
||||||
|
waiter.cancel()
|
||||||
|
await asyncio.gather(waiter, return_exceptions=True)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _register_worker_run(
|
def _register_worker_run(
|
||||||
cls,
|
cls,
|
||||||
@@ -664,6 +791,16 @@ class TaskQueueService:
|
|||||||
returncode,
|
returncode,
|
||||||
manual_stop,
|
manual_stop,
|
||||||
)
|
)
|
||||||
|
from apps.admin_console.services.automation_store import store
|
||||||
|
|
||||||
|
if (store.job(str(sess_id)) or {}).get("timed_out"):
|
||||||
|
new_status, should_persist = "failed", True
|
||||||
|
if (
|
||||||
|
state.is_shutting_down
|
||||||
|
and not manual_stop
|
||||||
|
and current_status not in {"completed", "success", "failed", "interrupted"}
|
||||||
|
):
|
||||||
|
new_status, should_persist = "interrupted", True
|
||||||
if should_persist:
|
if should_persist:
|
||||||
if session_repo.update_session_status(sess_id, new_status, time.time()):
|
if session_repo.update_session_status(sess_id, new_status, time.time()):
|
||||||
print(f"[QueueWorker] Updated session {sess_id} status to '{new_status}'")
|
print(f"[QueueWorker] Updated session {sess_id} status to '{new_status}'")
|
||||||
@@ -880,7 +1017,7 @@ class TaskQueueService:
|
|||||||
await cls._terminate_if_cancelled_during_launch(run_key, sess_id, proc)
|
await cls._terminate_if_cancelled_during_launch(run_key, sess_id, proc)
|
||||||
|
|
||||||
# 3. Await subprocess completion
|
# 3. Await subprocess completion
|
||||||
returncode = await cls._wait_for_worker_process(proc)
|
returncode = await cls._await_worker_with_budget(proc, task_item, run_key)
|
||||||
print(f"[QueueWorker] Task [{sess_id}] exited with returncode {returncode}")
|
print(f"[QueueWorker] Task [{sess_id}] exited with returncode {returncode}")
|
||||||
|
|
||||||
manual_stop = run_key in state.manually_stopped_run_ids or bool(
|
manual_stop = run_key in state.manually_stopped_run_ids or bool(
|
||||||
@@ -1069,6 +1206,8 @@ class TaskQueueService:
|
|||||||
conversation_id: str | None = None,
|
conversation_id: str | None = None,
|
||||||
verification_level: str | None = None,
|
verification_level: str | None = None,
|
||||||
explorer_mode: str | None = None,
|
explorer_mode: str | None = None,
|
||||||
|
runtime_overrides: dict[str, Any] | None = None,
|
||||||
|
display_name: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Enqueues one or more goals and wakes up the background worker.
|
"""Enqueues one or more goals and wakes up the background worker.
|
||||||
|
|
||||||
@@ -1076,6 +1215,12 @@ class TaskQueueService:
|
|||||||
forwarded to the worker as ``--verification-level`` / ``--explorer-pro-mode``;
|
forwarded to the worker as ``--verification-level`` / ``--explorer-pro-mode``;
|
||||||
they are normalised here so the queue item and the CLI see one spelling.
|
they are normalised here so the queue item and the CLI see one spelling.
|
||||||
"""
|
"""
|
||||||
|
if runtime_overrides:
|
||||||
|
profile = runtime_overrides.get("profile") or profile
|
||||||
|
device_serial = runtime_overrides.get("device_serial") or device_serial
|
||||||
|
locked_app_package = runtime_overrides.get("locked_app_package") or locked_app_package
|
||||||
|
verification_level = runtime_overrides.get("verification_level") or verification_level
|
||||||
|
explorer_mode = runtime_overrides.get("explorer_mode") or explorer_mode
|
||||||
verification_level = (
|
verification_level = (
|
||||||
str(verification_level).strip().lower() or None if verification_level else None
|
str(verification_level).strip().lower() or None if verification_level else None
|
||||||
)
|
)
|
||||||
@@ -1123,6 +1268,10 @@ class TaskQueueService:
|
|||||||
verification_level=verification_level,
|
verification_level=verification_level,
|
||||||
explorer_mode=explorer_mode,
|
explorer_mode=explorer_mode,
|
||||||
)
|
)
|
||||||
|
if runtime_overrides:
|
||||||
|
task_item["runtime_overrides"] = runtime_overrides
|
||||||
|
if display_name:
|
||||||
|
task_item["display_name"] = display_name
|
||||||
state.queue_items.append(task_item)
|
state.queue_items.append(task_item)
|
||||||
enqueued_tasks.append(task_item)
|
enqueued_tasks.append(task_item)
|
||||||
cls._broadcast_startup_progress(
|
cls._broadcast_startup_progress(
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.0.0",
|
"version": "1.5.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.0.0",
|
"version": "1.5.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.1.5",
|
"@angular/cdk": "^22.1.5",
|
||||||
"@angular/common": "^22.1.4",
|
"@angular/common": "^22.1.4",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.0.0",
|
"version": "1.5.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve --proxy-config proxy.conf.json",
|
"start": "ng serve --proxy-config proxy.conf.json",
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ limitations under the License.
|
|||||||
</button>
|
</button>
|
||||||
<button #automationOrigin="cdkOverlayOrigin" cdkOverlayOrigin class="vscode-tab-btn" [class.active]="isAutomationOpen()" (click)="isAutomationOpen.set(!isAutomationOpen())" title="报告与批量" aria-label="报告与批量"><span class="material-symbols-outlined tab-icon">assignment</span><span>报告与批量</span></button>
|
<button #automationOrigin="cdkOverlayOrigin" cdkOverlayOrigin class="vscode-tab-btn" [class.active]="isAutomationOpen()" (click)="isAutomationOpen.set(!isAutomationOpen())" title="报告与批量" aria-label="报告与批量"><span class="material-symbols-outlined tab-icon">assignment</span><span>报告与批量</span></button>
|
||||||
</div>
|
</div>
|
||||||
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="automationOrigin" [cdkConnectedOverlayOpen]="isAutomationOpen()" [cdkConnectedOverlayHasBackdrop]="true" cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop" (backdropClick)="isAutomationOpen.set(false)" [cdkConnectedOverlayPositions]="dropdownPositions"><div class="vscode-dropdown-menu" style="width:min(560px,calc(100vw - 24px));max-height:85vh;overflow:auto;padding:20px"><div class="popover-title-header" style="justify-content:space-between;margin-bottom:6px"><span>报告与自动化</span><button type="button" (click)="isAutomationOpen.set(false)" aria-label="关闭报告与自动化" style="border:0;background:transparent;color:#64748b;cursor:pointer;padding:4px"><span class="material-symbols-outlined">close</span></button></div><app-automation-panel /></div></ng-template>
|
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="automationOrigin" [cdkConnectedOverlayOpen]="isAutomationOpen()" [cdkConnectedOverlayHasBackdrop]="true" cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop" (backdropClick)="isAutomationOpen.set(false)" [cdkConnectedOverlayPositions]="dropdownPositions"><div class="vscode-dropdown-menu" style="width:min(560px,calc(100vw - 24px));max-height:85vh;overflow:auto;padding:20px"><div class="popover-title-header" style="justify-content:space-between;margin-bottom:6px"><span>报告与自动化</span><button type="button" (click)="isAutomationOpen.set(false)" aria-label="关闭报告与自动化" style="border:0;background:transparent;color:#64748b;cursor:pointer;padding:4px"><span class="material-symbols-outlined">close</span></button></div><app-automation-panel (queueRequested)="openBatchQueue()" /></div></ng-template>
|
||||||
|
|
||||||
<!-- Dropdown Popover Panel for Task Queue -->
|
<!-- Dropdown Popover Panel for Task Queue -->
|
||||||
<ng-template
|
<ng-template
|
||||||
@@ -61,6 +61,7 @@ limitations under the License.
|
|||||||
<span class="material-symbols-outlined">list_alt</span>
|
<span class="material-symbols-outlined">list_alt</span>
|
||||||
<span>任务队列</span>
|
<span>任务队列</span>
|
||||||
</div>
|
</div>
|
||||||
|
<app-task-batch-filter />
|
||||||
<div class="dropdown-section-label">
|
<div class="dropdown-section-label">
|
||||||
<span class="material-symbols-outlined icon-bolt">bolt</span>
|
<span class="material-symbols-outlined icon-bolt">bolt</span>
|
||||||
<span>当前队列 ({{ activeQueue().length }})</span>
|
<span>当前队列 ({{ activeQueue().length }})</span>
|
||||||
@@ -76,6 +77,7 @@ limitations under the License.
|
|||||||
class="dropdown-item-card"
|
class="dropdown-item-card"
|
||||||
[class.selected]="agentService.currentSessionId() === session.session_id"
|
[class.selected]="agentService.currentSessionId() === session.session_id"
|
||||||
[class.running]="getTaskStatus(session) === 'running'"
|
[class.running]="getTaskStatus(session) === 'running'"
|
||||||
|
[ngClass]="getTaskStatus(session)"
|
||||||
(click)="selectTask(session.session_id, $event)"
|
(click)="selectTask(session.session_id, $event)"
|
||||||
>
|
>
|
||||||
<div class="item-header">
|
<div class="item-header">
|
||||||
@@ -85,6 +87,7 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
{{ getTaskStatus(session) | uiLabel }}
|
{{ getTaskStatus(session) | uiLabel }}
|
||||||
</span>
|
</span>
|
||||||
|
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
|
||||||
<div class="item-header-actions">
|
<div class="item-header-actions">
|
||||||
@if (getDeviceSerial(session)) {
|
@if (getDeviceSerial(session)) {
|
||||||
<span class="item-device" [title]="'Device: ' + getDeviceSerial(session)">
|
<span class="item-device" [title]="'Device: ' + getDeviceSerial(session)">
|
||||||
@@ -96,7 +99,7 @@ limitations under the License.
|
|||||||
<span class="item-time-divider">·</span>
|
<span class="item-time-divider">·</span>
|
||||||
}
|
}
|
||||||
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
||||||
@if (getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused') {
|
@if (['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-stop-card"
|
class="btn-stop-card"
|
||||||
@@ -107,7 +110,7 @@ limitations under the License.
|
|||||||
<span class="material-symbols-outlined">stop_circle</span>
|
<span class="material-symbols-outlined">stop_circle</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') {
|
@if (!['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-delete-card"
|
class="btn-delete-card"
|
||||||
@@ -120,12 +123,15 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-goal" title="{{ session.initial_goal }}">
|
<div class="item-goal" [title]="session.initial_goal || ''">
|
||||||
{{ session.initial_goal || '未命名任务' }}
|
{{ session.display_name || session.initial_goal || '未命名任务' }}
|
||||||
</div>
|
</div>
|
||||||
|
@if (session.error && getTaskStatus(session) === 'failed') {
|
||||||
|
<div class="item-error" [title]="session.error">{{session.error}}</div>
|
||||||
|
}
|
||||||
<div class="item-footer">
|
<div class="item-footer">
|
||||||
<span class="item-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
|
<app-task-id-copy [taskId]="session.session_id" />
|
||||||
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
|
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }} · 已用 {{ formatTaskDuration(session.start_time, session.end_time) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -331,7 +337,7 @@ limitations under the License.
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Floating Top-Right Badges & Toolbar (Pinned overlay, hidden in empty/initial state) -->
|
<!-- Floating Top-Right Badges & Toolbar (Pinned overlay, hidden in empty/initial state) -->
|
||||||
@if (consolidatedBlocks().length > 0 || showPlanningLoader()) {
|
@if (consolidatedBlocks().length > 0 || showPlanningLoader() || agentService.currentSession()) {
|
||||||
<div class="stream-floating-toolbar">
|
<div class="stream-floating-toolbar">
|
||||||
<!-- Active Architecture Chip: click opens the run info popover -->
|
<!-- Active Architecture Chip: click opens the run info popover -->
|
||||||
@if (agentService.viewedModel(); as model) {
|
@if (agentService.viewedModel(); as model) {
|
||||||
@@ -492,6 +498,7 @@ limitations under the License.
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
<ng-content select="[task-guidance]"></ng-content>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,7 +551,7 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
@for (phase of phases(); track trackPhase($index, phase); let first = $first) {
|
@for (phase of phases(); track trackPhase($index, phase); let first = $first) {
|
||||||
<div class="phase-container">
|
<div class="phase-container">
|
||||||
@if (hasVisibleBlocks(phase)) {
|
@if (hasVisibleBlocks(phase) && phase.blocks[0]?.data?.phase !== 'outcome') {
|
||||||
<div
|
<div
|
||||||
class="phase-header"
|
class="phase-header"
|
||||||
[class.first-phase]="first && startupWorkItems().length === 0"
|
[class.first-phase]="first && startupWorkItems().length === 0"
|
||||||
@@ -568,10 +575,20 @@ limitations under the License.
|
|||||||
>
|
>
|
||||||
<!-- Unified Step / Stream Body -->
|
<!-- Unified Step / Stream Body -->
|
||||||
<div class="step-body">
|
<div class="step-body">
|
||||||
|
@if (block.data?.extra_metadata?.injected_instruction; as guidance) {
|
||||||
|
<div class="guidance-step-note">
|
||||||
|
<div class="guidance-step-title"><span class="material-symbols-outlined">forum</span>用户指导 · 已进入第 {{block.data.step_number}} 步</div>
|
||||||
|
<p>{{ guidance }}</p>
|
||||||
|
<small>{{ hasRecordedDeviceAction(block.data.action_taken) ? '本步设备动作见下方;是否完成目标需以实际屏幕为准。' : '本步没有记录设备动作。' }}</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<!-- Check block (verifier lane): header, then the declared check items
|
<!-- Check block (verifier lane): header, then the declared check items
|
||||||
and how each is judged; the chronological stream follows, and the
|
and how each is judged; the chronological stream follows, and the
|
||||||
result rows close the block (see check-result below). -->
|
result rows close the block (see check-result below). -->
|
||||||
@if (isCheckerBlock(block)) {
|
@if (isCheckerBlock(block)) {
|
||||||
|
@if (block.data.phase === 'outcome' && block.data.test_result) {
|
||||||
|
<app-test-result-card [result]="agentService.currentSession()?.test_result || block.data.test_result" [sessionId]="agentService.currentSessionId()" [executionStatus]="agentService.currentSession()?.status || ''" (reviewSaved)="agentService.applyCaseReview($event)" />
|
||||||
|
} @else {
|
||||||
<div class="check-lane" [ngClass]="'tone-' + getCheckerTone(block)">
|
<div class="check-lane" [ngClass]="'tone-' + getCheckerTone(block)">
|
||||||
<div class="check-header">
|
<div class="check-header">
|
||||||
<span class="stream-status-dot" [class.streaming]="isBlockRunning(block)"></span>
|
<span class="stream-status-dot" [class.streaming]="isBlockRunning(block)"></span>
|
||||||
@@ -593,6 +610,7 @@ limitations under the License.
|
|||||||
</ol>
|
</ol>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
<!-- Text, tool calls, and actions share one chronological timeline. -->
|
<!-- Text, tool calls, and actions share one chronological timeline. -->
|
||||||
@if (getSortedStepEvents(block.data).length > 0) {
|
@if (getSortedStepEvents(block.data).length > 0) {
|
||||||
@@ -746,7 +764,7 @@ limitations under the License.
|
|||||||
@if (getReportStatusExplanation(item.data); as expText) {
|
@if (getReportStatusExplanation(item.data); as expText) {
|
||||||
<div class="grid-item full-width">
|
<div class="grid-item full-width">
|
||||||
<span class="grid-label">报告与发现</span>
|
<span class="grid-label">报告与发现</span>
|
||||||
<span class="grid-value report-markdown-body" [innerHTML]="renderMarkdown(expText, 'report-' + block.id + '-' + itemIdx)"></span>
|
<div class="grid-value report-markdown-body" [innerHTML]="renderMarkdown(expText, 'report-' + block.id + '-' + itemIdx)"></div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -1256,7 +1274,7 @@ limitations under the License.
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Check block result: one row per check item, keyed by its number above -->
|
<!-- Check block result: one row per check item, keyed by its number above -->
|
||||||
@if (hasCheckerResult(block)) {
|
@if (hasCheckerResult(block) && !(block.data.phase === 'outcome' && block.data.test_result)) {
|
||||||
<div class="check-result" [ngClass]="'tone-' + getCheckerTone(block)">
|
<div class="check-result" [ngClass]="'tone-' + getCheckerTone(block)">
|
||||||
@for (verdict of getCheckerVerdicts(block); track $index) {
|
@for (verdict of getCheckerVerdicts(block); track $index) {
|
||||||
<div class="check-verdict" [ngClass]="verdict.status">
|
<div class="check-verdict" [ngClass]="verdict.status">
|
||||||
@@ -1283,6 +1301,23 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (awaitingGuidance().length) {
|
||||||
|
<div class="guidance-queue" aria-label="待处理用户指导">
|
||||||
|
@for (item of awaitingGuidance(); track item.id) {
|
||||||
|
<div class="guidance-queue-item">
|
||||||
|
<strong>用户指导 · {{ guidanceStatusLabel(item) }}</strong>
|
||||||
|
<p>{{ item.instruction }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (terminalFailureReason(); as reason) {
|
||||||
|
<div class="session-failure-summary" role="status">
|
||||||
|
<strong>任务执行失败 · 原因</strong>
|
||||||
|
<p>{{ reason }}</p>
|
||||||
|
<button type="button" (click)="isAutomationOpen.set(true)">查看完整报告</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<!-- Outputter Post-Execution Synthesis Card (Rendered ONCE at the end of stream) -->
|
<!-- Outputter Post-Execution Synthesis Card (Rendered ONCE at the end of stream) -->
|
||||||
@if (outputterReport(); as outReport) {
|
@if (outputterReport(); as outReport) {
|
||||||
<div class="outputter-synthesis-stream-card">
|
<div class="outputter-synthesis-stream-card">
|
||||||
|
|||||||
@@ -1921,6 +1921,8 @@ $stream-row-height: 25px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.task-report-card {
|
&.task-report-card {
|
||||||
|
width: min(680px, 100%);
|
||||||
|
box-sizing: border-box;
|
||||||
background: #f0fdf4;
|
background: #f0fdf4;
|
||||||
border: 1px solid #bbf7d0;
|
border: 1px solid #bbf7d0;
|
||||||
|
|
||||||
@@ -1967,12 +1969,13 @@ $stream-row-height: 25px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.report-markdown-body {
|
.report-markdown-body {
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.7;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
user-select: text;
|
user-select: text;
|
||||||
word-break: break-word;
|
word-break: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 3px 0;
|
margin: 3px 0;
|
||||||
@@ -2227,11 +2230,19 @@ $stream-row-height: 25px;
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-value {
|
.grid-value {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
|
||||||
|
&.report-markdown-body {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.7;
|
||||||
|
word-break: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
&.code-pill {
|
&.code-pill {
|
||||||
font-family: 'Roboto Mono', 'Fira Code', Consolas, monospace;
|
font-family: 'Roboto Mono', 'Fira Code', Consolas, monospace;
|
||||||
@@ -3673,6 +3684,12 @@ $stream-row-height: 25px;
|
|||||||
&.running {
|
&.running {
|
||||||
border-left: 3.5px solid #2563eb;
|
border-left: 3.5px solid #2563eb;
|
||||||
}
|
}
|
||||||
|
&.paused, &.task_paused { border-left: 3.5px solid #d97706; background: #fffbeb; }
|
||||||
|
&.resuming, &.queued { border-left: 3.5px solid #7c3aed; }
|
||||||
|
&.failed { border-left: 3.5px solid #dc2626; background: #fff7f7; }
|
||||||
|
&.completed { border-left: 3.5px solid #16a34a; }
|
||||||
|
&.cancelled, &.interrupted { border-left: 3.5px solid #ea580c; }
|
||||||
|
.item-error { color: #b91c1c; font-size: 11px; line-height: 1.4; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
.item-header {
|
.item-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3685,9 +3702,12 @@ $stream-row-height: 25px;
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 10.5px;
|
box-sizing: border-box;
|
||||||
|
height: 20px;
|
||||||
|
line-height: 14px;
|
||||||
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 2px 5px;
|
padding: 0 5px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
@@ -3718,12 +3738,24 @@ $stream-row-height: 25px;
|
|||||||
border-color: #fdba74;
|
border-color: #fdba74;
|
||||||
&::before { background-color: #f97316; }
|
&::before { background-color: #f97316; }
|
||||||
}
|
}
|
||||||
|
&.task_paused {
|
||||||
|
background: #ffedd5;
|
||||||
|
color: #9a3412;
|
||||||
|
border-color: #fb923c;
|
||||||
|
&::before { background-color: #ea580c; }
|
||||||
|
}
|
||||||
|
&.resuming, &.queued {
|
||||||
|
background: #f3e8ff;
|
||||||
|
color: #6b21a8;
|
||||||
|
border-color: #c4b5fd;
|
||||||
|
&::before { background-color: #7c3aed; }
|
||||||
|
}
|
||||||
&.completed {
|
&.completed {
|
||||||
background: #fafafa;
|
background: #dcfce7;
|
||||||
color: #1f1f1f;
|
color: #166534;
|
||||||
border-color: #e2e8f0;
|
border-color: #86efac;
|
||||||
&::before {
|
&::before {
|
||||||
background-color: #1f1f1f;
|
background-color: #16a34a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.pending {
|
&.pending {
|
||||||
@@ -3735,21 +3767,22 @@ $stream-row-height: 25px;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.failed {
|
&.failed {
|
||||||
background: #fafafa;
|
background: #fee2e2;
|
||||||
color: #ef4444;
|
color: #991b1b;
|
||||||
border-color: #fee2e2;
|
border-color: #fca5a5;
|
||||||
&::before {
|
&::before {
|
||||||
background-color: #ef4444;
|
background-color: #ef4444;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.cancelled {
|
&.cancelled {
|
||||||
background: #fafafa;
|
background: #ffedd5;
|
||||||
color: #d97706;
|
color: #9a3412;
|
||||||
border-color: #fef3c7;
|
border-color: #fdba74;
|
||||||
&::before {
|
&::before {
|
||||||
background-color: #f59e0b;
|
background-color: #ea580c;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
&.interrupted { background: #fef3c7; color: #92400e; border-color: #fbbf24; &::before { background-color: #d97706; } }
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -3971,3 +4004,48 @@ $stream-row-height: 25px;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.guidance-step-note, .guidance-queue-item {
|
||||||
|
padding: 10px 13px;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1e3a8a;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
.guidance-step-title { display: flex; align-items: center; gap: 6px; font-weight: 650; }
|
||||||
|
.material-symbols-outlined { font-size: 17px; }
|
||||||
|
p { margin: 5px 0; white-space: pre-wrap; color: #1e293b; }
|
||||||
|
small { color: #475569; }
|
||||||
|
}
|
||||||
|
.guidance-queue { display: grid; gap: 8px; padding: 12px 16px; }
|
||||||
|
.guidance-queue-item strong { font-size: 12px; }
|
||||||
|
.session-failure-summary {
|
||||||
|
margin: 12px 16px 28px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #fecdd3;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff7f8;
|
||||||
|
color: #881337;
|
||||||
|
strong { font-size: 13px; }
|
||||||
|
p { margin: 6px 0 10px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
button { border: 0; background: transparent; padding: 0; color: #be123c; font: inherit; font-weight: 600; cursor: pointer; }
|
||||||
|
}
|
||||||
|
:host ::ng-deep .report-markdown-body {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.7;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: normal;
|
||||||
|
> div { margin: 3px 0; }
|
||||||
|
h1, h2, h3 { margin: 12px 0 6px; font-size: 14px; color: #0f172a; }
|
||||||
|
ul, ol { padding-left: 22px; margin: 8px 0; }
|
||||||
|
li { margin: 4px 0; }
|
||||||
|
pre { overflow: auto; white-space: pre-wrap; padding: 10px; border-radius: 7px; background: #e2e8f0; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { padding: 7px 9px; border: 1px solid #cbd5e1; text-align: left; vertical-align: top; }
|
||||||
|
th { background: #dbeafe; }
|
||||||
|
.md-para-gap { height: 8px; }
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||||
import { TaskHistoryComponent } from '../task-history/task-history.component';
|
import { TaskHistoryComponent } from '../task-history/task-history.component';
|
||||||
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
|
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
|
||||||
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
||||||
@@ -22,8 +23,11 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { AgentService, StartupProgressEvent } from '../../services/agent.service';
|
import { AgentService, GuidanceEvent, StartupProgressEvent } from '../../services/agent.service';
|
||||||
import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model';
|
import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model';
|
||||||
|
import { TestResultBadgeComponent, TEST_RESULT_LABELS } from '../test-result-badge/test-result-badge.component';
|
||||||
|
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
|
||||||
|
import { TaskBatchFilterComponent } from '../task-batch-filter/task-batch-filter.component';
|
||||||
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
|
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
|
||||||
import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay';
|
import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay';
|
||||||
import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../../core/models/stream.model';
|
import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../../core/models/stream.model';
|
||||||
@@ -236,7 +240,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-agent-stream',
|
selector: 'app-agent-stream',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent],
|
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
|
||||||
templateUrl: './agent-stream.component.html',
|
templateUrl: './agent-stream.component.html',
|
||||||
styleUrl: './agent-stream.component.scss',
|
styleUrl: './agent-stream.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -246,6 +250,11 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
private zone = inject(NgZone);
|
private zone = inject(NgZone);
|
||||||
|
public terminalFailureReason = signal('');
|
||||||
|
private selectedTerminalStatus = computed(() => {
|
||||||
|
const id = this.agentService.currentSessionId();
|
||||||
|
return this.agentService.sessions().find(session => session.session_id === id)?.status || '';
|
||||||
|
});
|
||||||
|
|
||||||
// Auto-scroll state tracking
|
// Auto-scroll state tracking
|
||||||
public isUserAtBottom = true;
|
public isUserAtBottom = true;
|
||||||
@@ -382,13 +391,13 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
public activeQueue = computed(() => {
|
public activeQueue = computed(() => {
|
||||||
const list = this.agentService.sessions().filter((s) => {
|
const list = this.agentService.sessions().filter((s) => {
|
||||||
const status = this.getTaskStatus(s);
|
const status = this.getTaskStatus(s);
|
||||||
return status === 'running' || status === 'paused' || status === 'pending';
|
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s);
|
||||||
});
|
});
|
||||||
return list.sort((a, b) => {
|
return list.sort((a, b) => {
|
||||||
const statusA = this.getTaskStatus(a);
|
const statusA = this.getTaskStatus(a);
|
||||||
const statusB = this.getTaskStatus(b);
|
const statusB = this.getTaskStatus(b);
|
||||||
const isRunA = statusA === 'running' || statusA === 'paused';
|
const isRunA = ['running', 'paused', 'task_paused', 'resuming'].includes(statusA);
|
||||||
const isRunB = statusB === 'running' || statusB === 'paused';
|
const isRunB = ['running', 'paused', 'task_paused', 'resuming'].includes(statusB);
|
||||||
if (isRunA && !isRunB) return -1;
|
if (isRunA && !isRunB) return -1;
|
||||||
if (!isRunA && isRunB) return 1;
|
if (!isRunA && isRunB) return 1;
|
||||||
return (a.start_time || 0) - (b.start_time || 0);
|
return (a.start_time || 0) - (b.start_time || 0);
|
||||||
@@ -398,7 +407,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
public historyTasks = computed(() => {
|
public historyTasks = computed(() => {
|
||||||
return this.agentService.sessions().filter((s) => {
|
return this.agentService.sessions().filter((s) => {
|
||||||
const status = this.getTaskStatus(s);
|
const status = this.getTaskStatus(s);
|
||||||
return status === 'completed' || status === 'failed' || status === 'cancelled';
|
return ['completed', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(status);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -473,6 +482,23 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
return consolidateLogsToBlocks(this.filteredLogs());
|
return consolidateLogsToBlocks(this.filteredLogs());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
public awaitingGuidance = computed<GuidanceEvent[]>(() => {
|
||||||
|
const steps = this.consolidatedBlocks().filter(block => block.type === 'step');
|
||||||
|
return (this.agentService.guidanceEvents?.() || []).filter(event => !steps.some(block =>
|
||||||
|
new Date(block.timestamp).getTime() >= event.timestamp * 1000
|
||||||
|
&& String(block.data?.extra_metadata?.injected_instruction || '').includes(event.instruction)
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
public guidanceStatusLabel(event: GuidanceEvent): string {
|
||||||
|
const terminal = ['completed', 'failed', 'cancelled', 'interrupted']
|
||||||
|
.includes(this.agentService.currentSession()?.status || '');
|
||||||
|
if (event.status === 'pending') {
|
||||||
|
return terminal ? '任务结束前未进入模型' : '已接收,正在打断当前模型步骤';
|
||||||
|
}
|
||||||
|
return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录';
|
||||||
|
}
|
||||||
|
|
||||||
public phases = computed<PhaseBlock[]>(() => {
|
public phases = computed<PhaseBlock[]>(() => {
|
||||||
const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId());
|
const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId());
|
||||||
return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0);
|
return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0);
|
||||||
@@ -540,6 +566,17 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
effect((onCleanup) => {
|
||||||
|
const id = this.agentService.currentSessionId();
|
||||||
|
const status = this.selectedTerminalStatus();
|
||||||
|
this.terminalFailureReason.set('');
|
||||||
|
if (!id || !['failed', 'interrupted'].includes(status || '')) return;
|
||||||
|
const request = this.http.get<{error?: string}>(`/api/automation/reports/${encodeURIComponent(id)}`).subscribe({
|
||||||
|
next: report => this.terminalFailureReason.set(report.error || '任务未完成;请查看已记录的执行步骤。'),
|
||||||
|
error: () => this.terminalFailureReason.set('任务未完成,暂时无法读取异常详情。')
|
||||||
|
});
|
||||||
|
onCleanup(() => request.unsubscribe());
|
||||||
|
});
|
||||||
// Run info popover: fetch usage when opened or when the viewed session
|
// Run info popover: fetch usage when opened or when the viewed session
|
||||||
// changes, and keep refreshing while that session is still running.
|
// changes, and keep refreshing while that session is still running.
|
||||||
effect((onCleanup) => {
|
effect((onCleanup) => {
|
||||||
@@ -818,6 +855,12 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Top Nav Action Methods
|
// Top Nav Action Methods
|
||||||
|
public openBatchQueue(): void {
|
||||||
|
this.isAutomationOpen.set(false);
|
||||||
|
this.isNotesDropdownOpen.set(false);
|
||||||
|
this.isTaskDropdownOpen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
public toggleTaskDropdown(event: Event): void {
|
public toggleTaskDropdown(event: Event): void {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.isTaskDropdownOpen.update(v => !v);
|
this.isTaskDropdownOpen.update(v => !v);
|
||||||
@@ -878,14 +921,14 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
return tuningLabel(kind, id);
|
return tuningLabel(kind, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
|
public getTaskStatus(session: Session): string {
|
||||||
if (session.status) {
|
if (session.status) {
|
||||||
const s = session.status.toLowerCase();
|
const s = session.status.toLowerCase();
|
||||||
if (s === 'completed' || s === 'success' || s === 'failed' || s === 'cancelled') {
|
if (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(s)) {
|
||||||
return (s === 'success' ? 'completed' : s) as any;
|
return s === 'success' ? 'completed' : s;
|
||||||
}
|
}
|
||||||
if (s === 'running' || s === 'paused' || s === 'pending') {
|
if (['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(s)) {
|
||||||
return s as any;
|
return s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
|
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
|
||||||
@@ -1326,6 +1369,19 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
return isAndroidAction(action);
|
return isAndroidAction(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public formatTaskDuration(start?: number, end?: number): string {
|
||||||
|
if (!start || !Number.isFinite(start)) return '—';
|
||||||
|
const seconds = Math.max(0, Math.floor((end || Date.now() / 1000) - start));
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
return hours ? `${hours}时${minutes}分` : minutes ? `${minutes}分${seconds % 60}秒` : `${seconds}秒`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasRecordedDeviceAction(action: any): boolean {
|
||||||
|
const actions = Array.isArray(action) ? action : action ? [action] : [];
|
||||||
|
return actions.some(item => this.isAndroidAction(item) && !this.isReportStatusAction(item));
|
||||||
|
}
|
||||||
|
|
||||||
public getActionIcon(action: any): string {
|
public getActionIcon(action: any): string {
|
||||||
return getActionIcon(action);
|
return getActionIcon(action);
|
||||||
}
|
}
|
||||||
@@ -1707,7 +1763,9 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
/** Follow-ups not already visible in the verdict rows (the findings text repeats them). */
|
/** Follow-ups not already visible in the verdict rows (the findings text repeats them). */
|
||||||
public getCheckerNotes(block: any): string[] {
|
public getCheckerNotes(block: any): string[] {
|
||||||
const d = block?.data || {};
|
const d = block?.data || {};
|
||||||
if (d.phase === 'outcome') return Array.isArray(d.last_findings) ? d.last_findings : [];
|
if (d.phase === 'outcome') {
|
||||||
|
return [d.test_result?.reason, ...(d.test_result?.evidence || []), ...(Array.isArray(d.last_findings) ? d.last_findings : [])].filter(Boolean);
|
||||||
|
}
|
||||||
const notes: string[] = [];
|
const notes: string[] = [];
|
||||||
if (Array.isArray(d.unmet_subgoals)) {
|
if (Array.isArray(d.unmet_subgoals)) {
|
||||||
for (const text of d.unmet_subgoals) notes.push(`尚未完成:${text}`);
|
for (const text of d.unmet_subgoals) notes.push(`尚未完成:${text}`);
|
||||||
@@ -1749,7 +1807,9 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
public getCheckerTone(block: any): string {
|
public getCheckerTone(block: any): string {
|
||||||
const d = block?.data || {};
|
const d = block?.data || {};
|
||||||
if (d.phase === 'outcome') {
|
if (d.phase === 'outcome') {
|
||||||
return d.task_status === 'completed' ? 'passed' : (d.task_status === 'blocked' ? 'failed' : 'inconclusive');
|
if (d.test_result) return d.test_result.status === 'blocked' ? 'inconclusive' : d.test_result.status;
|
||||||
|
if (d.tests?.failed) return 'failed';
|
||||||
|
return d.tests?.passed && !d.tests?.unchecked && !d.tests?.inconclusive ? 'passed' : 'inconclusive';
|
||||||
}
|
}
|
||||||
if (d.isCompleted === false) return 'running';
|
if (d.isCompleted === false) return 'running';
|
||||||
if (d.status === 'superseded' || d.status === 'unchecked') return 'muted';
|
if (d.status === 'superseded' || d.status === 'unchecked') return 'muted';
|
||||||
@@ -1764,12 +1824,12 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
const d = block?.data || {};
|
const d = block?.data || {};
|
||||||
if (d.phase === 'outcome') {
|
if (d.phase === 'outcome') {
|
||||||
const t = d.tests || {};
|
const t = d.tests || {};
|
||||||
const label = d.task_status === 'completed' ? "目标已完成"
|
const label = d.test_result ? TEST_RESULT_LABELS[d.test_result.status as keyof typeof TEST_RESULT_LABELS]
|
||||||
: (d.task_status === 'blocked' ? "受阻" : "部分完成");
|
: (d.task_status === 'completed' ? "执行完成" : (d.task_status === 'blocked' ? "执行受阻" : "部分完成"));
|
||||||
const counts = [
|
const counts = [
|
||||||
[t.passed, 'passed'],
|
[t.passed, '项通过'],
|
||||||
[t.failed, 'failed'],
|
[t.failed, '项不通过'],
|
||||||
[t.inconclusive, 'inconclusive'],
|
[t.inconclusive, '项无法确认'],
|
||||||
[t.unchecked, "未检查"]
|
[t.unchecked, "未检查"]
|
||||||
]
|
]
|
||||||
.filter(([n]) => Number(n) > 0)
|
.filter(([n]) => Number(n) > 0)
|
||||||
@@ -1812,6 +1872,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
public hasVisibleContent(block: any): boolean {
|
public hasVisibleContent(block: any): boolean {
|
||||||
if (!block) return false;
|
if (!block) return false;
|
||||||
if (block.type === 'checker') return true;
|
if (block.type === 'checker') return true;
|
||||||
|
if (block.data?.extra_metadata?.injected_instruction) return true;
|
||||||
const hasNative = Boolean(this.getNativeThinking(block));
|
const hasNative = Boolean(this.getNativeThinking(block));
|
||||||
const hasRaw = Boolean(this.getRawThinking(block));
|
const hasRaw = Boolean(this.getRawThinking(block));
|
||||||
const hasReset = this.hasBlockResets(block, false) || this.hasBlockResets(block, true);
|
const hasReset = this.hasBlockResets(block, false) || this.hasBlockResets(block, true);
|
||||||
|
|||||||
+71
-13
@@ -5,6 +5,7 @@
|
|||||||
@if (error()) { <p class="error" role="alert">{{error()}}</p> }
|
@if (error()) { <p class="error" role="alert">{{error()}}</p> }
|
||||||
@if (notice()) { <p class="notice" role="status">{{notice()}}</p> }
|
@if (notice()) { <p class="notice" role="status">{{notice()}}</p> }
|
||||||
@if (tab() === 'reports') {
|
@if (tab() === 'reports') {
|
||||||
|
@if(batch(); as b){<button type="button" class="back-to-batch" (click)="tab.set('batches')">返回批次:{{b.name}}</button>}
|
||||||
<section><h3>默认报告格式</h3><p>任务结束后自动生成。批次报告包同时包含汇总和每个任务的报告。</p>
|
<section><h3>默认报告格式</h3><p>任务结束后自动生成。批次报告包同时包含汇总和每个任务的报告。</p>
|
||||||
<div class="format-options"><label><input type="checkbox" [(ngModel)]="word"> Word 文档</label>
|
<div class="format-options"><label><input type="checkbox" [(ngModel)]="word"> Word 文档</label>
|
||||||
<label><input type="checkbox" [(ngModel)]="excel"> Excel 表格</label></div>
|
<label><input type="checkbox" [(ngModel)]="excel"> Excel 表格</label></div>
|
||||||
@@ -12,42 +13,99 @@
|
|||||||
</section>
|
</section>
|
||||||
<section><h3>当前任务报告</h3>
|
<section><h3>当前任务报告</h3>
|
||||||
@if (report(); as r) {
|
@if (report(); as r) {
|
||||||
<strong>{{r.name}}</strong><p>{{label(r.status)}} · {{r.verdict}}</p>
|
<div class="report-overview">
|
||||||
<div class="actions"><a [href]="'/api/automation/reports/'+r.id+'/docx'">下载 Word</a><a [href]="'/api/automation/reports/'+r.id+'/xlsx'">下载 Excel</a></div>
|
<strong class="report-title">{{r.name}}</strong>
|
||||||
|
<div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></div>
|
||||||
|
@if(r.test_result; as result){<app-test-result-card [result]="result" [sessionId]="r.id" [executionStatus]="r.status" (reviewSaved)="reviewSaved($event)" />}
|
||||||
|
@if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> }
|
||||||
|
</div>
|
||||||
|
<div class="report-meta"><div><strong>任务目标与预期</strong><p>{{r.goal || '未记录'}}</p></div>@if(r.expected){<div><strong>预期结果</strong><p>{{r.expected}}</p></div>}</div>
|
||||||
|
<div class="actions"><a [href]="'/api/automation/reports/'+r.id+'/docx'">下载 Word</a><a [href]="'/api/automation/reports/'+r.id+'/xlsx'">下载 Excel</a>
|
||||||
|
<a [href]="'/api/automation/reports/'+r.id+'/screenshots'">步骤截图 ZIP</a>
|
||||||
|
<a [href]="'/api/automation/reports/'+r.id+'/video'">分段录屏 ZIP</a>
|
||||||
|
<a [href]="'/api/automation/reports/'+r.id+'/all'">下载全部资料</a></div>
|
||||||
<p class="muted">报告包含目标、预期、实际结论、步骤与截图证据。执行中的下载为当前快照。</p>
|
<p class="muted">报告包含目标、预期、实际结论、步骤与截图证据。执行中的下载为当前快照。</p>
|
||||||
<pre class="preview">{{r.content}}</pre>
|
<div class="report-content"><h3>结论与发现</h3><div class="report-body" [innerHTML]="renderReport(r.content)"></div></div>
|
||||||
} @else { <p>从任务队列或历史记录中选择任务,即可下载报告。</p> }
|
} @else { <p>从任务队列或历史记录中选择任务,即可下载报告。</p> }
|
||||||
|
@if (liveTask() && agent.currentSessionId()) {
|
||||||
|
<div class="task-guidance">
|
||||||
|
<h3>任务指导 · {{label(taskStatus())}}</h3>
|
||||||
|
<p>{{ taskStatus() === 'paused' ? '模型请求已暂停;提交指导后请在执行日志中恢复模型调用。' : '运行中提交会立即打断模型思考并重新观察设备;正在执行的设备动作会先安全结束。' }}</p>
|
||||||
|
<label for="task-guidance-text">补充信息或调整方向</label>
|
||||||
|
<textarea id="task-guidance-text" [(ngModel)]="guidance" maxlength="10000" rows="3" placeholder="描述已观察到的情况和下一步方向"></textarea>
|
||||||
|
<div class="actions">
|
||||||
|
@if(taskStatus() === 'running'){<button [disabled]="busy()" (click)="pauseTask(agent.currentSessionId()!)">暂停当前任务</button>}
|
||||||
|
<button [disabled]="busy() || !guidance.trim()" (click)="guideTask(agent.currentSessionId()!)">提交指导{{taskStatus() === 'task_paused' ? '并恢复' : ''}}</button>
|
||||||
|
@if(taskStatus() === 'task_paused'){<button [disabled]="busy()" (click)="continueTask(agent.currentSessionId()!)">直接继续</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
@if (tab() !== 'reports') {
|
@if (tab() !== 'reports') {
|
||||||
<section class="device"><label>执行设备<select [(ngModel)]="device"><option value="">请选择设备</option>@for(d of system.connectedDevices(); track d.serial){<option [value]="d.serial">{{d.serial}}</option>}</select></label></section>
|
<section class="device"><label>执行设备<select [(ngModel)]="device"><option value="">请选择设备</option>@for(d of system.connectedDevices(); track d.serial){<option [value]="d.serial">{{d.serial}}</option>}</select></label></section>
|
||||||
}
|
}
|
||||||
@if (tab() === 'batches') {
|
@if (tab() === 'batches') {
|
||||||
<section><h3>导入任务</h3><p>每行一个用例,填写名称与目标。预期结果、应用、前置条件、参数和标签可选。</p>
|
<section><h3>导入任务</h3><p>每行一个用例,仅「Artemis提示词」作为任务正文;「任务配置(JSON)」可设置每项模型、模式、超时和录屏。「前置约束提取」仅供查看与搜索。示例在模板的「填写说明」页。</p>
|
||||||
<div class="actions"><a href="/api/automation/template">下载 Excel 模板</a><label class="upload">导入 Excel<input type="file" accept=".xlsx" [disabled]="busy()" (change)="upload($event)"></label></div>
|
<div class="actions"><a href="/api/automation/template">下载 Excel 模板</a><label class="upload">导入 Excel<input type="file" accept=".xlsx" [disabled]="busy()" (change)="upload($event)"></label></div>
|
||||||
@if(preview(); as p) {
|
@if(preview(); as p) {
|
||||||
<p>有效任务 {{p.tasks.length}} 条 · 错误 {{p.errors.length}} 条</p>
|
<div class="selection-toolbar"><span>已选 <strong>{{selectedCount()}}</strong> / {{p.tasks.length}} 个用例 · 错误 {{p.errors.length}} 条</span><button type="button" [disabled]="busy() || !p.tasks.length" (click)="toggleAllRows()">{{selectedCount() === p.tasks.length ? '全不选' : '全选'}}</button></div>
|
||||||
@for(e of p.errors; track e.row){<p class="error">第 {{e.row}} 行:{{e.message}}</p>}
|
@for(e of p.errors; track e.row){<p class="error">第 {{e.row}} 行:{{e.message}}</p>}
|
||||||
<div class="table-wrap"><table><thead><tr><th>行</th><th>名称</th><th>目标</th></tr></thead><tbody>@for(t of p.tasks; track t.row){<tr><td>{{t.row}}</td><td>{{t.name}}</td><td>{{t.goal}}</td></tr>}</tbody></table></div>
|
<div class="case-preview-list" aria-label="选择导入用例">
|
||||||
|
@for(t of p.tasks; track t.row){
|
||||||
|
<div class="case-preview" [class.unselected]="!selectedRows().has(t.row)">
|
||||||
|
<span class="selection-column"><input type="checkbox" [checked]="selectedRows().has(t.row)" [disabled]="busy()" (change)="toggleRow(t.row)" [attr.aria-label]="'导入用例:' + (t.case_id ? t.case_id + ' ' : '') + t.name"></span>
|
||||||
|
<div class="case-preview-content"><strong>{{t.case_id ? t.case_id + ' ' : ''}}{{t.name}}</strong><small>第 {{t.row}} 行</small>
|
||||||
|
<p class="case-goal" [title]="t.goal">{{t.goal}}</p>
|
||||||
|
@if(caseConfig(t.task_config); as config){<details><summary>自定义参数</summary><pre>{{config}}</pre></details>}@else{<small>使用批次 / 全局参数</small>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
<label>批次名称<input [(ngModel)]="name" maxlength="200"></label>
|
<label>批次名称<input [(ngModel)]="name" maxlength="200"></label>
|
||||||
<div class="fields"><label>执行模式<select [(ngModel)]="profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label><label>单任务时限(秒)<input type="number" min="30" max="86400" [(ngModel)]="timeout"></label></div>
|
<div class="fields"><label>执行模式<select [(ngModel)]="profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label><label>单任务时限(秒)<input type="number" min="30" max="86400" [(ngModel)]="timeout"></label></div>
|
||||||
|
<label for="batch-repeat-count">整批循环次数<input id="batch-repeat-count" type="number" min="1" [max]="maxExecutions" step="1" [(ngModel)]="repeatCount"></label>
|
||||||
|
<p class="execution-summary">{{selectedCount()}} 个用例 × {{repeatCount || 0}} 轮,共 {{selectedCount() * (repeatCount || 0)}} 次执行</p>
|
||||||
|
<p class="muted">每轮按表格顺序执行所选用例,分别保存结果。未勾选用例不会导入。</p>
|
||||||
|
@if(!validRepetitions()){<p class="error">循环次数须为正整数,执行总数不超过 {{maxExecutions}}。</p>}
|
||||||
<label><input type="checkbox" [(ngModel)]="stopOnFailure"> 失败即停止后续任务(默认继续)</label>
|
<label><input type="checkbox" [(ngModel)]="stopOnFailure"> 失败即停止后续任务(默认继续)</label>
|
||||||
<label><input type="checkbox" [(ngModel)]="word"> Word</label><label><input type="checkbox" [(ngModel)]="excel"> Excel</label>
|
<label><input type="checkbox" [(ngModel)]="word"> Word</label><label><input type="checkbox" [(ngModel)]="excel"> Excel</label>
|
||||||
<button class="primary" [disabled]="busy() || !p.valid || !device || !name.trim() || !formats().length" (click)="startBatch()">确认导入并开始执行</button>
|
<button class="primary" [disabled]="busy() || !p.valid || !selectedCount() || !validRepetitions() || !device || !name.trim() || !formats().length" (click)="startBatch()">确认导入并开始执行</button>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
<section><h3>批次记录</h3>
|
<section><h3>批次记录</h3>
|
||||||
@for(b of batches(); track b.id){<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}}</span></button>}
|
@for(b of batches(); track b.id){
|
||||||
|
<div class="batch-record">
|
||||||
|
<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}} · {{b.repeat_count || 1}} 轮@if(b.total_executions){ · {{b.total_executions}} 次执行}</span></button>
|
||||||
|
<button class="delete-batch" type="button" [disabled]="busy() || b.status === 'running'" (click)="deleteBatch(b)" [attr.aria-label]="'删除批次:' + b.name" [title]="b.status === 'running' ? '执行中的批次不能删除' : '删除批次及其任务、报告和文件'"><span class="material-symbols-outlined">delete_outline</span></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if(!batches().length){<p>暂无批次。</p>}
|
@if(!batches().length){<p>暂无批次。</p>}
|
||||||
</section>
|
</section>
|
||||||
@if(batch(); as b){<section><h3>{{b.name}}</h3><p>{{label(b.status)}} · {{b.reason}}</p>
|
@if(batch(); as b){<section><h3>{{b.name}}</h3><p>{{label(b.status)}} · {{b.reason}}</p>
|
||||||
<div class="actions">
|
<p class="execution-summary">{{b.case_count || b.jobs?.length || 0}} 个用例 · {{b.repeat_count || 1}} 轮 · 共 {{b.total_executions || b.jobs?.length || 0}} 次执行</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" (click)="showBatchQueue(b.id)">在任务队列查看此批次</button>
|
||||||
|
@if(agent.currentSessionId()){<button type="button" (click)="tab.set('reports')">查看当前任务报告</button>}
|
||||||
@if(b.status === 'running'){<button [disabled]="busy()" (click)="batchAction('pause')">暂停后续任务</button>}
|
@if(b.status === 'running'){<button [disabled]="busy()" (click)="batchAction('pause')">暂停后续任务</button>}
|
||||||
@if(b.status === 'draft' || b.status === 'paused'){<button [disabled]="busy()" (click)="batchAction('resume')">{{b.status === 'draft' ? '开始执行' : '继续未开始任务'}}</button>}
|
@if(b.status === 'draft' || b.status === 'paused'){<button [disabled]="busy()" (click)="batchAction('resume')">{{b.status === 'draft' ? '开始执行' : '继续未开始任务'}}</button>}
|
||||||
@if(b.status !== 'running'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
|
@if(b.status !== 'running' && b.status !== 'deleting'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
|
||||||
<a [href]="'/api/automation/batches/'+b.id+'/archive'">下载全部报告 ZIP</a>
|
<button type="button" [disabled]="archiveBusy()" (click)="downloadBatchArchive(b.id)">{{archiveBusy() && archiveBatchId() === b.id ? '正在准备报告包…' : '下载批次报告 ZIP'}}</button>
|
||||||
</div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p>
|
</div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p>
|
||||||
|
@if(archiveBatchId() === b.id && archiveProgress(); as progress){
|
||||||
|
<div class="archive-progress" role="status" aria-live="polite" [class.archive-failed]="progress.status === 'failed'">
|
||||||
|
<div class="archive-progress-heading">
|
||||||
|
@if(progress.status === 'preparing'){<span class="archive-spinner" aria-hidden="true"></span>}
|
||||||
|
<strong>{{progress.stage}}</strong>
|
||||||
|
</div>
|
||||||
|
@if(progress.status === 'preparing'){
|
||||||
|
<div class="archive-track" role="progressbar" [attr.aria-valuenow]="progress.total ? progress.completed : null" [attr.aria-valuemin]="progress.total ? 0 : null" [attr.aria-valuemax]="progress.total || null" [attr.aria-label]="'批次报告打包进度:' + progress.stage"><span [style.width.%]="progress.total ? progress.completed / progress.total * 100 : 8"></span></div>
|
||||||
|
<p>已处理 {{progress.completed}} / {{progress.total}} 个任务 · 已用 {{duration(progress.elapsed_seconds)}}@if(progress.eta_seconds !== null){ · 预计还需约 {{duration(progress.eta_seconds)}}}</p>
|
||||||
|
} @else if(progress.status === 'failed'){<p>{{progress.error}}。可点击下载按钮重试。</p>}
|
||||||
|
@else if(progress.status === 'ready'){<p>打包完成,下载已开始。</p>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
|
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
|
||||||
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true)">{{j.position}}. {{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
|
@for(j of b.jobs; track j.session_id){<div class="job" [class.selected]="agent.currentSessionId() === j.session_id"><button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button><span>{{label(j.status)}}</span><app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />@if(j.error){<small>{{j.error}}</small>}</div>}
|
||||||
</section>}
|
</section>}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
:host { display:block;min-width:0;color:#1e293b;font-size:13px;line-height:1.65; }
|
:host { display:block;min-width:0;color:#1e293b;font-size:13px;line-height:1.65; }
|
||||||
|
.back-to-batch{margin-bottom:12px;max-width:100%;text-align:left;overflow-wrap:anywhere}
|
||||||
|
.job.selected{background:#eff6ff;border-radius:8px;padding-left:8px;padding-right:8px}.job.selected button{color:#1d4ed8;border-color:#93c5fd;background:#fff}
|
||||||
.tabs {display:flex;gap:4px;padding:4px;margin-bottom:18px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:20px;
|
.tabs {display:flex;gap:4px;padding:4px;margin-bottom:18px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:20px;
|
||||||
button {flex:1;padding:7px 8px;border:0;border-radius:16px;color:#475569;background:transparent;white-space:nowrap;
|
button {flex:1;padding:7px 8px;border:0;border-radius:16px;color:#475569;background:transparent;white-space:nowrap;
|
||||||
&.selected {color:#2563eb;background:#fff;box-shadow:0 1px 4px #0f172a12;} }
|
&.selected {color:#2563eb;background:#fff;box-shadow:0 1px 4px #0f172a12;} }
|
||||||
@@ -16,7 +18,42 @@ input:not([type=checkbox]):not([type=file]),select,textarea{box-sizing:border-bo
|
|||||||
input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:-3px;margin-right:6px;}
|
input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:-3px;margin-right:6px;}
|
||||||
.upload{position:relative;margin:0;color:#fff;background:#2563eb;border-color:#2563eb;}.upload:hover{background:#1d4ed8;border-color:#1d4ed8;}.upload input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;}
|
.upload{position:relative;margin:0;color:#fff;background:#2563eb;border-color:#2563eb;}.upload:hover{background:#1d4ed8;border-color:#1d4ed8;}.upload input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;}
|
||||||
.device{background:#f8fafc;padding:12px 16px;}.device label{margin:0;}
|
.device{background:#f8fafc;padding:12px 16px;}.device label{margin:0;}
|
||||||
|
.selection-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0;color:#475569;}
|
||||||
|
.selection-column{flex:0 0 18px;text-align:center;}.selection-column input{margin:0;}
|
||||||
|
.case-preview-list{max-height:320px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}
|
||||||
|
.case-preview{display:flex;align-items:flex-start;gap:10px;padding:12px;border-bottom:1px solid #e2e8f0;&:last-child{border-bottom:0;}&.unselected{background:#f8fafc;color:#64748b;}}
|
||||||
|
.case-preview-content{min-width:0;flex:1;strong{display:block;overflow-wrap:anywhere;}small{display:block;}details{margin:5px 0 0;}summary{padding:0;font-size:12px;color:#2563eb;}pre{max-height:160px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;background:#f8fafc;border-radius:6px;padding:8px;font-size:11px;}}
|
||||||
|
.case-goal{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;white-space:pre-wrap;margin:5px 0;font-size:12px;}
|
||||||
|
.execution-summary{padding:9px 12px;border-radius:8px;background:#eff6ff;color:#1d4ed8;font-weight:500;}
|
||||||
.record{display:flex;width:100%;text-align:left;align-items:flex-start;flex-direction:column;gap:4px;margin:8px 0 0;padding:12px;overflow-wrap:anywhere;}.record span{color:#64748b;font-size:12px;font-weight:400;}.record.selected{border-color:#93c5fd;background:#eff6ff;}.record.selected strong{color:#1d4ed8;}
|
.record{display:flex;width:100%;text-align:left;align-items:flex-start;flex-direction:column;gap:4px;margin:8px 0 0;padding:12px;overflow-wrap:anywhere;}.record span{color:#64748b;font-size:12px;font-weight:400;}.record.selected{border-color:#93c5fd;background:#eff6ff;}.record.selected strong{color:#1d4ed8;}
|
||||||
|
.batch-record{display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record .record{flex:1;min-width:0;width:auto;margin:0;}.batch-record .delete-batch{flex:0 0 40px;min-width:40px;padding:0;color:#b91c1c;}.batch-record .delete-batch:hover:not(:disabled){background:#fff1f2;border-color:#fda4af;}.batch-record .delete-batch .material-symbols-outlined{font-size:19px;}
|
||||||
|
.archive-progress{margin:12px 0;padding:11px 13px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;color:#1e40af;}
|
||||||
|
.archive-progress-heading{display:flex;align-items:center;gap:8px;}
|
||||||
|
.archive-progress p{margin:7px 0 0;font-size:12px;color:#475569;}
|
||||||
|
.archive-progress.archive-failed{border-color:#fecdd3;background:#fff1f2;color:#be123c;}
|
||||||
|
.archive-track{height:7px;margin-top:10px;border-radius:999px;background:#dbeafe;overflow:hidden;}
|
||||||
|
.archive-track span{display:block;height:100%;border-radius:inherit;background:#3b82f6;transition:width .3s ease;}
|
||||||
|
.archive-spinner{width:13px;height:13px;border:2px solid #bfdbfe;border-top-color:#2563eb;border-radius:50%;animation:archive-spin .8s linear infinite;}
|
||||||
|
@keyframes archive-spin{to{transform:rotate(360deg);}}
|
||||||
.code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:#f8fafc;}.table-wrap{max-height:260px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid #e2e8f0;text-align:left;overflow-wrap:anywhere;}th{background:#f8fafc;color:#475569;font-weight:500;}
|
.code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:#f8fafc;}.table-wrap{max-height:260px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid #e2e8f0;text-align:left;overflow-wrap:anywhere;}th{background:#f8fafc;color:#475569;font-weight:500;}
|
||||||
.preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:#334155;background:#f8fafc;border-radius:8px;padding:12px;border:1px solid #e2e8f0;}
|
.preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:#334155;background:#f8fafc;border-radius:8px;padding:12px;border:1px solid #e2e8f0;}
|
||||||
.job{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 0;border-bottom:1px solid #f1f5f9;}.job:last-child{border-bottom:0;}.job button{flex:1;justify-content:flex-start;text-align:left;min-width:150px;}.job span{font-size:12px;background:#f1f5f9;color:#475569;border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:#475569;cursor:pointer;padding:8px 0;}
|
.job{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 0;border-bottom:1px solid #f1f5f9;}.job:last-child{border-bottom:0;}.job button{flex:1;justify-content:flex-start;text-align:left;min-width:150px;}.job span{font-size:12px;background:#f1f5f9;color:#475569;border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:#475569;cursor:pointer;padding:8px 0;}
|
||||||
|
.task-guidance{margin-top:16px;padding:14px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;}
|
||||||
|
.task-guidance label{margin:8px 0 4px;}
|
||||||
|
.report-overview{padding:14px;border:1px solid #dbeafe;border-radius:10px;background:#f8fbff;}
|
||||||
|
.report-title{display:block;font-size:14px;line-height:1.5;color:#0f172a;overflow-wrap:anywhere;}
|
||||||
|
.report-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px;span{padding:3px 8px;border:1px solid #bfdbfe;border-radius:20px;background:#eff6ff;color:#1e40af;font-size:12px;&.failed{background:#fff1f2;border-color:#fecdd3;color:#be123c;}}}
|
||||||
|
.report-error{margin-top:11px;padding:10px 12px;background:#fff1f2;border-left:3px solid #e11d48;border-radius:6px;color:#9f1239;strong{font-size:12px;}p{margin:4px 0 0;color:#9f1239;white-space:pre-wrap;}}
|
||||||
|
.report-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin:12px 0;div{min-width:0;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;background:#fff;}strong{font-size:12px;color:#475569;}p{margin:5px 0 0;white-space:pre-wrap;color:#1e293b;}}
|
||||||
|
.report-content{margin-top:14px;padding:14px;border:1px solid #e2e8f0;border-radius:10px;background:#fff;h3{margin-bottom:12px;}}
|
||||||
|
.report-body{overflow-x:auto;overflow-wrap:anywhere;color:#1e293b;line-height:1.75;}
|
||||||
|
:host ::ng-deep .report-body, :host ::ng-deep .report-markdown-body {
|
||||||
|
h1,h2,h3,h4{margin:18px 0 8px;color:#0f172a;line-height:1.4;}h1{font-size:18px;}h2{font-size:16px;}h3,h4{font-size:14px;}
|
||||||
|
> div{margin:3px 0;}
|
||||||
|
ul,ol{margin:8px 0;padding-left:24px;}li{margin:4px 0;}
|
||||||
|
blockquote{margin:10px 0;padding:8px 12px;border-left:3px solid #93c5fd;background:#f8fafc;}
|
||||||
|
pre{max-width:100%;overflow:auto;padding:10px 12px;background:#f1f5f9;border-radius:6px;white-space:pre-wrap;}
|
||||||
|
code{font-family:Consolas,monospace;background:#f1f5f9;padding:1px 3px;border-radius:3px;}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:12px;}th,td{padding:7px 9px;border:1px solid #e2e8f0;vertical-align:top;}th{background:#eff6ff;color:#1e3a8a;}
|
||||||
|
.md-para-gap{height:8px;}
|
||||||
|
}
|
||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
import { SystemService } from '../../services/system.service';
|
||||||
|
import { AutomationPanelComponent } from './automation-panel.component';
|
||||||
|
|
||||||
|
describe('AutomationPanelComponent batch deletion', () => {
|
||||||
|
const agent = {
|
||||||
|
currentSessionId: signal<string | null>(null),
|
||||||
|
runningSessionId: signal<string | null>(null),
|
||||||
|
agentStatus: signal('idle'),
|
||||||
|
sessions: signal<any[]>([]),
|
||||||
|
resultRevision: signal(0), batchFilter: signal(''), activeTab: signal('automation'),
|
||||||
|
selectSession: jasmine.createSpy('selectSession'), applyCaseReview: jasmine.createSpy('applyCaseReview')
|
||||||
|
};
|
||||||
|
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'post', 'delete']);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
(http.get.and as any).callFake((url: string) => of(url.endsWith('/preferences')
|
||||||
|
? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : []));
|
||||||
|
http.delete.and.returnValue(of({ status: 'deleted' }));
|
||||||
|
http.post.and.returnValue(of({ status: 'ready', stage: '报告包已就绪', completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null, error: null }));
|
||||||
|
http.delete.calls.reset();
|
||||||
|
http.post.calls.reset();
|
||||||
|
agent.currentSessionId.set(null); agent.activeTab.set('automation'); agent.batchFilter.set(''); agent.selectSession.calls.reset();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AutomationPanelComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: AgentService, useValue: agent },
|
||||||
|
{ provide: SystemService, useValue: { connectedDevices: signal([]) } },
|
||||||
|
{ provide: HttpClient, useValue: http }
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects all imported cases by default, toggles all and submits only checked cases with rounds', async () => {
|
||||||
|
const tasks = [
|
||||||
|
{ row: 2, case_id: '1.1', name: '打开设置', goal: '观察设置', task_config: { model: 'mimo-v2.6-pro' } },
|
||||||
|
{ row: 3, case_id: '1.2', name: '观察首页', goal: '观察首页', task_config: {} }
|
||||||
|
];
|
||||||
|
(http.post.and as any).callFake((url: string) => of(url.endsWith('/preview')
|
||||||
|
? { valid: true, tasks, errors: [] } : url.endsWith('/batches')
|
||||||
|
? { id: 'selected-batch', status: 'draft' } : { id: 'selected-batch', status: 'running' }));
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.tab.set('batches');
|
||||||
|
component.upload({ target: { files: [new File(['test'], '用例.xlsx')] } } as unknown as Event);
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const checkboxes = () => [...fixture.nativeElement.querySelectorAll('.selection-column input')] as HTMLInputElement[];
|
||||||
|
expect(checkboxes().every(c => c.checked)).toBeTrue();
|
||||||
|
const toggle = fixture.nativeElement.querySelector('.selection-toolbar button') as HTMLButtonElement;
|
||||||
|
expect(toggle.textContent).toContain('全不选');
|
||||||
|
toggle.click(); fixture.detectChanges();
|
||||||
|
expect(component.selectedCount()).toBe(0);
|
||||||
|
expect((fixture.nativeElement.querySelector('.primary') as HTMLButtonElement).disabled).toBeTrue();
|
||||||
|
expect(toggle.textContent).toContain('全选');
|
||||||
|
toggle.click(); fixture.detectChanges();
|
||||||
|
expect(component.selectedCount()).toBe(2);
|
||||||
|
checkboxes()[1].click();
|
||||||
|
component.repeatCount = 3; component.device = 'test-device';
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.execution-summary').textContent).toContain('共 3 次执行');
|
||||||
|
component.startBatch();
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
const request = http.post.calls.allArgs().find(args => args[0] === '/api/automation/batches');
|
||||||
|
expect(request?.[1]).toEqual(jasmine.objectContaining({
|
||||||
|
cases: [{ case_id: '1.1', name: '打开设置', goal: '观察设置', task_config: { model: 'mimo-v2.6-pro' } }],
|
||||||
|
repeat_count: 3, device_serial: 'test-device'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty selections and invalid or excessive loop counts without creating a batch', async () => {
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.preview.set({ valid: true, tasks: [{ row: 2, name: '检查', goal: '观察' }], errors: [] });
|
||||||
|
component.device = 'test-device';
|
||||||
|
component.startBatch();
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
expect(component.error()).toContain('至少勾选');
|
||||||
|
component.selectedRows.set(new Set([2]));
|
||||||
|
for (const count of [0, 1.5, 10001]) {
|
||||||
|
component.repeatCount = count; component.startBatch();
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
expect(component.error()).toContain('循环次数');
|
||||||
|
}
|
||||||
|
expect(http.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirms deletion and removes the batch from the visible list', async () => {
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
fixture.componentInstance.tab.set('batches');
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
fixture.componentInstance.batches.set([
|
||||||
|
{ id: 'batch-1', name: '批量冒烟', status: 'completed', created_at: 100 }
|
||||||
|
]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('[aria-label="删除批次:批量冒烟"]')).not.toBeNull();
|
||||||
|
|
||||||
|
spyOn(window, 'confirm').and.returnValue(true);
|
||||||
|
const button = fixture.nativeElement.querySelector('.delete-batch') as HTMLButtonElement;
|
||||||
|
button.click();
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(http.delete).toHaveBeenCalledWith('/api/automation/batches/batch-1');
|
||||||
|
expect(fixture.componentInstance.batches()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a running batch protected from the delete control', async () => {
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
fixture.componentInstance.tab.set('batches');
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
fixture.componentInstance.batches.set([
|
||||||
|
{ id: 'batch-2', name: '进行中', status: 'running', created_at: 100 }
|
||||||
|
]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect((fixture.nativeElement.querySelector('.delete-batch') as HTMLButtonElement).disabled).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('查看同批次用例时保留批次列表,可直接筛选该批次队列', () => {
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.tab.set('batches');
|
||||||
|
component.batch.set({id:'batch-1',name:'冒烟批次',status:'completed',jobs:[
|
||||||
|
{session_id:'task-a',status:'completed',case:{name:'用例 A',goal:'观察设置'}},
|
||||||
|
{session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}}
|
||||||
|
]});
|
||||||
|
fixture.detectChanges();
|
||||||
|
const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job button')];
|
||||||
|
jobs[0].click(); jobs[1].click();
|
||||||
|
expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]);
|
||||||
|
expect(component.tab()).toBe('batches');
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('.job').length).toBe(2);
|
||||||
|
const queueRequested = jasmine.createSpy('queueRequested');
|
||||||
|
component.queueRequested.subscribe(queueRequested);
|
||||||
|
component.showBatchQueue('batch-1');
|
||||||
|
expect(agent.batchFilter()).toBe('batch-1'); expect(agent.activeTab()).toBe('tasks');
|
||||||
|
expect(queueRequested).toHaveBeenCalled();
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the failure reason and renders Chinese report structure with line breaks', () => {
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.componentInstance.report.set({
|
||||||
|
id: 'task-1', name: '系统设置检查', status: 'failed', verdict: '未验证',
|
||||||
|
error: '模型连续 3 次未提供可执行工具调用', goal: '查看设置', expected: '',
|
||||||
|
content: '## 结果\n第一行\n第二行\n\n| 项目 | 状态 |\n| --- | --- |\n| 设置 | 未验证 |'
|
||||||
|
});
|
||||||
|
fixture.detectChanges();
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(root.querySelector('.report-error')?.textContent).toContain('连续 3 次');
|
||||||
|
expect(root.querySelector('.report-body h2')?.textContent).toBe('结果');
|
||||||
|
expect(root.querySelector('.report-body')?.textContent).toContain('第一行');
|
||||||
|
expect(root.querySelector('.report-body')?.textContent).toContain('第二行');
|
||||||
|
expect(root.querySelector('.report-body table td')?.textContent).toBe('设置');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prepares a batch archive before starting the browser download', async () => {
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.tab.set('batches');
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
component.batch.set({ id: 'batch-3', name: '批量测试', status: 'completed', jobs: [] });
|
||||||
|
const click = spyOn(HTMLAnchorElement.prototype, 'click');
|
||||||
|
fixture.detectChanges();
|
||||||
|
(fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).click();
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(http.post).toHaveBeenCalledWith('/api/automation/batches/batch-3/archive/prepare', {});
|
||||||
|
expect(click).toHaveBeenCalled();
|
||||||
|
expect(fixture.nativeElement.querySelector('.archive-progress')?.textContent).toContain('下载已开始');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows archive errors and lets the user retry', async () => {
|
||||||
|
http.post.and.returnValue(of({ status: 'failed', stage: '打包失败', completed: 1, total: 2, elapsed_seconds: 3, eta_seconds: null, error: '截图文件无法读取' }));
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.tab.set('batches');
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
component.batch.set({ id: 'batch-4', name: '批量测试', status: 'completed', jobs: [] });
|
||||||
|
fixture.detectChanges();
|
||||||
|
(fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).click();
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.archive-progress')?.textContent).toContain('截图文件无法读取');
|
||||||
|
expect((fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).disabled).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows live archive progress and estimated remaining time while preparing', async () => {
|
||||||
|
http.post.and.returnValue(of({ status: 'preparing', stage: '正在打包任务 2/4', completed: 1, total: 4, elapsed_seconds: 12, eta_seconds: 36, error: null }));
|
||||||
|
(http.get.and as any).callFake((url: string) => of(url.endsWith('/status')
|
||||||
|
? { status: 'ready', stage: '报告包已就绪', completed: 4, total: 4, elapsed_seconds: 13, eta_seconds: null, error: null }
|
||||||
|
: url.endsWith('/preferences') ? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : []));
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.tab.set('batches');
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
component.batch.set({ id: 'batch-5', name: '批量测试', status: 'completed', jobs: [] });
|
||||||
|
spyOn(HTMLAnchorElement.prototype, 'click');
|
||||||
|
fixture.detectChanges();
|
||||||
|
(fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).click();
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const progress = fixture.nativeElement.querySelector('.archive-progress') as HTMLElement;
|
||||||
|
expect(progress.textContent).toContain('已处理 1 / 4 个任务');
|
||||||
|
expect(progress.textContent).toContain('预计还需约 36 秒');
|
||||||
|
expect(progress.querySelector('.archive-spinner')).not.toBeNull();
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
+116
-8
@@ -1,35 +1,100 @@
|
|||||||
import { Component, DestroyRef, effect, inject, signal } from '@angular/core';
|
import { Component, DestroyRef, computed, effect, inject, output, signal } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { AgentService } from '../../services/agent.service';
|
import { AgentService } from '../../services/agent.service';
|
||||||
import { SystemService } from '../../services/system.service';
|
import { SystemService } from '../../services/system.service';
|
||||||
|
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
|
||||||
|
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
||||||
|
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
|
||||||
|
import { CaseReviewResponse } from '../../core/models/session.model';
|
||||||
|
|
||||||
|
interface ArchiveProgress {
|
||||||
|
status: 'idle' | 'preparing' | 'ready' | 'failed';
|
||||||
|
stage: string;
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
elapsed_seconds: number;
|
||||||
|
eta_seconds: number | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule],
|
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent],
|
||||||
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
|
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
|
||||||
})
|
})
|
||||||
export class AutomationPanelComponent {
|
export class AutomationPanelComponent {
|
||||||
|
queueRequested = output<void>();
|
||||||
agent = inject(AgentService); system = inject(SystemService); http = inject(HttpClient);
|
agent = inject(AgentService); system = inject(SystemService); http = inject(HttpClient);
|
||||||
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
|
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
|
||||||
batches = signal<any[]>([]); preview = signal<any>(null);
|
batches = signal<any[]>([]); preview = signal<any>(null);
|
||||||
|
selectedRows = signal<Set<number>>(new Set());
|
||||||
|
selectedCount = computed(() => this.selectedRows().size);
|
||||||
batch = signal<any>(null); report = signal<any>(null);
|
batch = signal<any>(null); report = signal<any>(null);
|
||||||
|
archiveProgress = signal<ArchiveProgress | null>(null);
|
||||||
|
archiveBatchId = signal(''); archiveBusy = signal(false);
|
||||||
|
guidance = '';
|
||||||
|
taskStatus = computed(() => {
|
||||||
|
const sid = this.agent.currentSessionId();
|
||||||
|
if (!sid) return '';
|
||||||
|
return this.agent.sessions().find(s => s.session_id === sid)?.status
|
||||||
|
|| (this.agent.runningSessionId() === sid ? this.agent.agentStatus() : '') || '';
|
||||||
|
});
|
||||||
|
liveTask = computed(() => ['running', 'paused', 'task_paused', 'resuming'].includes(this.taskStatus()));
|
||||||
word = true; excel = true; name = ''; device = ''; profile = 'flash'; stopOnFailure = false; timeout = 1800;
|
word = true; excel = true; name = ''; device = ''; profile = 'flash'; stopOnFailure = false; timeout = 1800;
|
||||||
|
repeatCount = 1; maxExecutions = 10000;
|
||||||
private destroyed = false;
|
private destroyed = false;
|
||||||
constructor() {
|
constructor() {
|
||||||
const destroy = inject(DestroyRef);
|
const destroy = inject(DestroyRef);
|
||||||
this.http.get<any>('/api/automation/preferences').subscribe(p => { this.word = p.formats.includes('docx'); this.excel = p.formats.includes('xlsx'); this.timeout = p.batch_timeout_seconds || 1800; });
|
this.http.get<any>('/api/automation/preferences').subscribe(p => { this.word = p.formats.includes('docx'); this.excel = p.formats.includes('xlsx'); this.timeout = p.batch_timeout_seconds || 1800; this.maxExecutions = p.batch_max_executions || 10000; });
|
||||||
void this.refresh();
|
void this.refresh();
|
||||||
const timer = setInterval(() => void this.refresh(), 5000);
|
const timer = setInterval(() => void this.refresh(), 5000);
|
||||||
destroy.onDestroy(() => { this.destroyed = true; clearInterval(timer); });
|
destroy.onDestroy(() => { this.destroyed = true; clearInterval(timer); });
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const sid = this.agent.currentSessionId(); this.report.set(null);
|
this.agent.resultRevision();
|
||||||
|
const sid = this.agent.currentSessionId(); this.report.set(null); this.guidance = '';
|
||||||
if (sid) this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`).subscribe({ next: r => { if (this.agent.currentSessionId() === sid) this.report.set(r); }, error: () => {} });
|
if (sid) this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`).subscribe({ next: r => { if (this.agent.currentSessionId() === sid) this.report.set(r); }, error: () => {} });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
|
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
|
||||||
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告'} as Record<string,string>)[s] || s; }
|
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告',deleting:'删除中,可重试清理'} as Record<string,string>)[s] || s; }
|
||||||
|
renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
|
||||||
|
reviewSaved(review: CaseReviewResponse) { this.agent.applyCaseReview(review); if (this.report()?.id === review.id) this.report.update(value => ({...value, ...review})); }
|
||||||
|
openBatchTask(id: string) { this.agent.selectSession(id, true); }
|
||||||
|
showBatchQueue(id: string) { this.agent.batchFilter.set(id); this.agent.activeTab.set('tasks'); this.queueRequested.emit(); }
|
||||||
|
duration(seconds: number) { const minutes = Math.floor(seconds / 60); return minutes ? `${minutes} 分 ${seconds % 60} 秒` : `${seconds} 秒`; }
|
||||||
|
downloadBatchArchive(id: string) { void this.prepareBatchArchive(id); }
|
||||||
|
private async prepareBatchArchive(id: string) {
|
||||||
|
if (this.archiveBusy()) return;
|
||||||
|
this.archiveBusy.set(true); this.archiveBatchId.set(id);
|
||||||
|
this.archiveProgress.set({ status: 'preparing', stage: '正在请求打包', completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null, error: null });
|
||||||
|
const base = `/api/automation/batches/${encodeURIComponent(id)}/archive`;
|
||||||
|
try {
|
||||||
|
let progress = await firstValueFrom(this.http.post<ArchiveProgress>(`${base}/prepare`, {}));
|
||||||
|
while (!this.destroyed && progress.status === 'preparing') {
|
||||||
|
this.archiveProgress.set(progress);
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 1000));
|
||||||
|
if (this.destroyed) return;
|
||||||
|
progress = await firstValueFrom(this.http.get<ArchiveProgress>(`${base}/status`));
|
||||||
|
}
|
||||||
|
if (this.destroyed) return;
|
||||||
|
this.archiveProgress.set(progress);
|
||||||
|
if (progress.status === 'failed') throw Error(progress.error || '批次报告打包失败');
|
||||||
|
if (progress.status !== 'ready') throw Error('打包任务已中断,请重试');
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = base;
|
||||||
|
link.download = `批次报告-${id.slice(0, 8)}.zip`;
|
||||||
|
document.body.appendChild(link); link.click(); link.remove();
|
||||||
|
this.notice.set('报告包已就绪,正在开始下载');
|
||||||
|
} catch (e: any) {
|
||||||
|
const detail = e.error?.detail;
|
||||||
|
this.archiveProgress.set({
|
||||||
|
...(this.archiveProgress() || { completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null }),
|
||||||
|
status: 'failed', stage: '打包失败', error: typeof detail === 'string' ? detail : e.message || '下载失败'
|
||||||
|
});
|
||||||
|
} finally { this.archiveBusy.set(false); }
|
||||||
|
}
|
||||||
async action(fn: () => Promise<void>) {
|
async action(fn: () => Promise<void>) {
|
||||||
if (this.busy()) return;
|
if (this.busy()) return;
|
||||||
this.busy.set(true); this.error.set(''); this.notice.set('');
|
this.busy.set(true); this.error.set(''); this.notice.set('');
|
||||||
@@ -50,17 +115,60 @@ export class AutomationPanelComponent {
|
|||||||
savePreferences() { void this.action(async () => { if (!this.formats().length) throw Error('请至少选择一种报告格式'); await firstValueFrom(this.http.put('/api/automation/preferences',{formats:this.formats()})); this.notice.set('已保存,后续任务和批次使用此默认格式'); }); }
|
savePreferences() { void this.action(async () => { if (!this.formats().length) throw Error('请至少选择一种报告格式'); await firstValueFrom(this.http.put('/api/automation/preferences',{formats:this.formats()})); this.notice.set('已保存,后续任务和批次使用此默认格式'); }); }
|
||||||
upload(event: Event) {
|
upload(event: Event) {
|
||||||
const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return;
|
const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return;
|
||||||
void this.action(async () => { this.preview.set(null); const data = new FormData(); data.append('file',file); this.preview.set(await firstValueFrom(this.http.post('/api/automation/import/preview',data))); this.name = file.name.replace(/\.xlsx$/i,''); });
|
void this.action(async () => { this.preview.set(null); this.selectedRows.set(new Set()); const data = new FormData(); data.append('file',file); const preview: any = await firstValueFrom(this.http.post('/api/automation/import/preview',data)); this.preview.set(preview); this.selectedRows.set(new Set<number>(preview.tasks.map((t: any) => t.row))); this.repeatCount = 1; this.name = file.name.replace(/\.xlsx$/i,''); });
|
||||||
|
}
|
||||||
|
toggleRow(row: number) {
|
||||||
|
const selected = new Set(this.selectedRows());
|
||||||
|
if (selected.has(row)) selected.delete(row); else selected.add(row);
|
||||||
|
this.selectedRows.set(selected);
|
||||||
|
}
|
||||||
|
toggleAllRows() {
|
||||||
|
const rows: number[] = (this.preview()?.tasks || []).map((t: any) => t.row);
|
||||||
|
this.selectedRows.set(this.selectedCount() === rows.length ? new Set() : new Set(rows));
|
||||||
|
}
|
||||||
|
validRepetitions() {
|
||||||
|
return Number.isInteger(this.repeatCount) && this.repeatCount >= 1
|
||||||
|
&& this.selectedCount() * this.repeatCount <= this.maxExecutions;
|
||||||
|
}
|
||||||
|
caseConfig(config: Record<string, unknown> | undefined) {
|
||||||
|
const overrides = Object.fromEntries(Object.entries(config || {}).filter(([, value]) => value !== null && value !== undefined));
|
||||||
|
return Object.keys(overrides).length ? JSON.stringify(overrides, null, 2) : '';
|
||||||
}
|
}
|
||||||
startBatch() { void this.action(async () => {
|
startBatch() { void this.action(async () => {
|
||||||
if (!this.preview()?.valid) throw Error('请先导入并修正所有错误');
|
if (!this.preview()?.valid) throw Error('请先导入并修正所有错误');
|
||||||
|
if (!this.selectedCount()) throw Error('请至少勾选一个用例');
|
||||||
|
if (!this.validRepetitions()) throw Error(`循环次数必须为正整数,执行总数不能超过 ${this.maxExecutions}`);
|
||||||
if (!this.device) throw Error('请选择执行设备');
|
if (!this.device) throw Error('请选择执行设备');
|
||||||
const cases = this.preview().tasks.map(({row, ...c}: any) => c);
|
const cases = this.preview().tasks.filter((t: any) => this.selectedRows().has(t.row)).map(({row, ...c}: any) => c);
|
||||||
const created: any = await firstValueFrom(this.http.post('/api/automation/batches',{name:this.name.trim(),cases,device_serial:this.device,profile:this.profile,stop_on_failure:this.stopOnFailure,timeout_seconds:this.timeout,formats:this.formats()}));
|
const created: any = await firstValueFrom(this.http.post('/api/automation/batches',{name:this.name.trim(),cases,repeat_count:this.repeatCount,device_serial:this.device,profile:this.profile,stop_on_failure:this.stopOnFailure,timeout_seconds:this.timeout,formats:this.formats()}));
|
||||||
this.batch.set(created);
|
this.batch.set(created);
|
||||||
await firstValueFrom(this.http.post(`/api/automation/batches/${created.id}/resume`,{}));
|
await firstValueFrom(this.http.post(`/api/automation/batches/${created.id}/resume`,{}));
|
||||||
this.preview.set(null); this.notice.set('批次已开始,可暂停或查看每项结果'); await this.refresh();
|
this.preview.set(null); this.notice.set('批次已开始,可暂停或查看每项结果'); await this.refresh();
|
||||||
}); }
|
}); }
|
||||||
selectBatch(id: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.get(`/api/automation/batches/${id}`))); }); }
|
selectBatch(id: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.get(`/api/automation/batches/${id}`))); }); }
|
||||||
|
deleteBatch(item: { id: string; name: string }) {
|
||||||
|
if (!window.confirm(`删除批次“${item.name}”及其全部任务记录、报告和文件?此操作无法撤销。`)) return;
|
||||||
|
void this.action(async () => {
|
||||||
|
await firstValueFrom(this.http.delete(`/api/automation/batches/${encodeURIComponent(item.id)}`));
|
||||||
|
if (this.batch()?.id === item.id) this.batch.set(null);
|
||||||
|
await this.refresh();
|
||||||
|
this.agent.fetchSessions();
|
||||||
|
this.notice.set('批次及关联任务、报告和文件已删除');
|
||||||
|
});
|
||||||
|
}
|
||||||
batchAction(action: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.post(`/api/automation/batches/${this.batch().id}/${action}`,{}))); await this.refresh(); }); }
|
batchAction(action: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.post(`/api/automation/batches/${this.batch().id}/${action}`,{}))); await this.refresh(); }); }
|
||||||
|
pauseTask(id: string) { void this.action(async () => { await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/pause`,{})); this.notice.set('已请求暂停:当前步骤结束后释放设备'); await this.refresh(); }); }
|
||||||
|
guideTask(id: string) { void this.action(async () => {
|
||||||
|
if (!this.guidance.trim()) throw Error('请先填写指导内容');
|
||||||
|
const status = this.taskStatus();
|
||||||
|
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/guide`,{instruction:this.guidance.trim()}));
|
||||||
|
this.guidance = '';
|
||||||
|
this.agent.fetchGuidance(id);
|
||||||
|
if (status === 'paused') this.notice.set('模型请求暂停,请在执行日志中恢复模型调用');
|
||||||
|
await this.refresh();
|
||||||
|
}); }
|
||||||
|
continueTask(id: string) { void this.action(async () => {
|
||||||
|
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/continue`,{}));
|
||||||
|
this.notice.set('任务已申请恢复;设备忙时等待排队'); await this.refresh();
|
||||||
|
}); }
|
||||||
}
|
}
|
||||||
@@ -58,6 +58,7 @@ limitations under the License.
|
|||||||
@if (agentService.activeTab() === 'automation') { <app-automation-panel /> }
|
@if (agentService.activeTab() === 'automation') { <app-automation-panel /> }
|
||||||
<!-- Tasks Tab Content -->
|
<!-- Tasks Tab Content -->
|
||||||
@if (agentService.activeTab() === 'tasks') {
|
@if (agentService.activeTab() === 'tasks') {
|
||||||
|
<app-task-batch-filter />
|
||||||
<!-- Active Queue Section -->
|
<!-- Active Queue Section -->
|
||||||
<div class="queue-section-header">
|
<div class="queue-section-header">
|
||||||
<span class="material-symbols-outlined section-icon">bolt</span> 当前队列 ({{ activeQueue().length }})
|
<span class="material-symbols-outlined section-icon">bolt</span> 当前队列 ({{ activeQueue().length }})
|
||||||
@@ -82,6 +83,7 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
{{ getTaskStatus(session) | uiLabel }}
|
{{ getTaskStatus(session) | uiLabel }}
|
||||||
</span>
|
</span>
|
||||||
|
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
|
||||||
<div class="task-card-header-actions">
|
<div class="task-card-header-actions">
|
||||||
@if (getDeviceSerial(session)) {
|
@if (getDeviceSerial(session)) {
|
||||||
<span class="task-device" [title]="'Device: ' + getDeviceSerial(session)">
|
<span class="task-device" [title]="'Device: ' + getDeviceSerial(session)">
|
||||||
@@ -119,10 +121,10 @@ limitations under the License.
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-goal" title="{{ session.initial_goal }}">
|
<div class="task-goal" title="{{ session.initial_goal }}">
|
||||||
{{ session.initial_goal || '未命名任务' }}
|
{{ session.display_name || session.initial_goal || '未命名任务' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="task-card-footer">
|
<div class="task-card-footer">
|
||||||
<span class="task-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
|
<app-task-id-copy [taskId]="session.session_id" />
|
||||||
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
|
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -338,9 +338,12 @@ $text-muted: #a1a1aa;
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 10.5px;
|
box-sizing: border-box;
|
||||||
|
height: 20px;
|
||||||
|
line-height: 14px;
|
||||||
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 2px 5px;
|
padding: 0 5px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||||
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
|
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
|
||||||
import { TaskHistoryComponent } from '../task-history/task-history.component';
|
import { TaskHistoryComponent } from '../task-history/task-history.component';
|
||||||
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
||||||
@@ -22,6 +23,8 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { AgentService } from '../../services/agent.service';
|
import { AgentService } from '../../services/agent.service';
|
||||||
import { Session } from '../../core/models/session.model';
|
import { Session } from '../../core/models/session.model';
|
||||||
|
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
||||||
|
import { TaskBatchFilterComponent } from '../task-batch-filter/task-batch-filter.component';
|
||||||
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
|
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
|
||||||
import { parseNote, parseNoteLines } from '../../utils/markdown-parser.util';
|
import { parseNote, parseNoteLines } from '../../utils/markdown-parser.util';
|
||||||
|
|
||||||
@@ -30,7 +33,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-interface',
|
selector: 'app-chat-interface',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent],
|
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent, TestResultBadgeComponent, TaskBatchFilterComponent],
|
||||||
templateUrl: './chat-interface.component.html',
|
templateUrl: './chat-interface.component.html',
|
||||||
styleUrl: './chat-interface.component.scss',
|
styleUrl: './chat-interface.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -53,7 +56,7 @@ export class ChatInterfaceComponent {
|
|||||||
public activeQueue = computed(() => {
|
public activeQueue = computed(() => {
|
||||||
const list = this.agentService.sessions().filter((s) => {
|
const list = this.agentService.sessions().filter((s) => {
|
||||||
const status = this.getTaskStatus(s);
|
const status = this.getTaskStatus(s);
|
||||||
return status === 'running' || status === 'paused' || status === 'pending';
|
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s);
|
||||||
});
|
});
|
||||||
return list.sort((a, b) => {
|
return list.sort((a, b) => {
|
||||||
const statusA = this.getTaskStatus(a);
|
const statusA = this.getTaskStatus(a);
|
||||||
|
|||||||
+19
@@ -338,6 +338,8 @@ limitations under the License.
|
|||||||
playsinline
|
playsinline
|
||||||
(loadedmetadata)="onLoadedMetadata()"
|
(loadedmetadata)="onLoadedMetadata()"
|
||||||
(timeupdate)="onTimeUpdate()"
|
(timeupdate)="onTimeUpdate()"
|
||||||
|
(seeked)="onSeeked()"
|
||||||
|
(emptied)="onPause()"
|
||||||
(play)="onPlay()"
|
(play)="onPlay()"
|
||||||
(pause)="onPause()"
|
(pause)="onPause()"
|
||||||
(ended)="onEnded()"
|
(ended)="onEnded()"
|
||||||
@@ -354,6 +356,7 @@ limitations under the License.
|
|||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
class="scrubber-range"
|
class="scrubber-range"
|
||||||
|
aria-label="录像进度"
|
||||||
[min]="0"
|
[min]="0"
|
||||||
[max]="duration() || 100"
|
[max]="duration() || 100"
|
||||||
[step]="0.1"
|
[step]="0.1"
|
||||||
@@ -361,6 +364,22 @@ limitations under the License.
|
|||||||
(input)="onScrub($event)"
|
(input)="onScrub($event)"
|
||||||
/>
|
/>
|
||||||
<div class="scrubber-progress-fill" [style.width.%]="progressPercent()"></div>
|
<div class="scrubber-progress-fill" [style.width.%]="progressPercent()"></div>
|
||||||
|
@if (videoStepMarkers().length > 0) {
|
||||||
|
<div class="video-markers" aria-label="录像关键步骤">
|
||||||
|
@for (marker of videoStepMarkers(); track marker.time) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="video-marker"
|
||||||
|
[class.passed]="currentTime() >= marker.time"
|
||||||
|
[class.failed]="marker.failed"
|
||||||
|
[style.left.%]="marker.percent"
|
||||||
|
[title]="videoMarkerLabel(marker)"
|
||||||
|
[attr.aria-label]="'跳转到' + videoMarkerLabel(marker)"
|
||||||
|
(click)="seekToVideoMarker(marker)"
|
||||||
|
></button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<!-- Bottom Controls Row -->
|
<!-- Bottom Controls Row -->
|
||||||
<div class="controls-row">
|
<div class="controls-row">
|
||||||
|
|||||||
+32
@@ -724,6 +724,38 @@
|
|||||||
box-shadow: 0 0 8px rgba(2, 132, 199, 0.4);
|
box-shadow: 0 0 8px rgba(2, 132, 199, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-markers {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 6;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.video-marker {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
padding: 0;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #64748b;
|
||||||
|
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.25);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: transform 0.15s ease, background 0.15s ease;
|
||||||
|
|
||||||
|
&.passed { background: #0284c7; }
|
||||||
|
&.failed { background: #ef4444; }
|
||||||
|
&:hover, &:focus-visible {
|
||||||
|
z-index: 1;
|
||||||
|
transform: translate(-50%, -50%) scale(1.35);
|
||||||
|
outline: 2px solid #38bdf8;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.step-ticks-wrapper {
|
.step-ticks-wrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
+243
@@ -0,0 +1,243 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
import { StepReplayFrame } from '../../core/models/stream.model';
|
||||||
|
import { FloatingVideoPlayerComponent } from './floating-video-player.component';
|
||||||
|
|
||||||
|
describe('FloatingVideoPlayerComponent video markers', () => {
|
||||||
|
let fixture: ComponentFixture<FloatingVideoPlayerComponent>;
|
||||||
|
let component: FloatingVideoPlayerComponent;
|
||||||
|
let video: HTMLVideoElement;
|
||||||
|
let paused: boolean;
|
||||||
|
let seeking: boolean;
|
||||||
|
let time: number;
|
||||||
|
let play: jasmine.Spy;
|
||||||
|
let agentService: ReturnType<typeof createAgentService>;
|
||||||
|
|
||||||
|
function createAgentService() {
|
||||||
|
const start = 1_800_000_000;
|
||||||
|
const frame: StepReplayFrame = {
|
||||||
|
index: 0, stepNumber: 1, title: '步骤 1: 点击设置', imageUrl: 'step.png',
|
||||||
|
actionText: '点击设置', timestamp: start + 5
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
isVideoWindowOpen: signal(true),
|
||||||
|
isVideoMinimized: signal(false),
|
||||||
|
recordingPlaybackStatus: signal('ready'),
|
||||||
|
playerMode: signal('video'),
|
||||||
|
hasCurrentSessionStepFrames: signal(true),
|
||||||
|
activeVideoTitle: signal('测试录像'),
|
||||||
|
isVideoLoading: signal(false),
|
||||||
|
activeVideoUrl: signal('/test.mp4'),
|
||||||
|
activeVideoSegments: signal([{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 }]),
|
||||||
|
currentSessionStepFrames: signal([frame]),
|
||||||
|
currentSession: signal({ session_id: 'test', start_time: start }),
|
||||||
|
currentSessionId: signal('test'),
|
||||||
|
videoSeekRequest: signal(null),
|
||||||
|
stepSeekRequest: signal(null),
|
||||||
|
consumeVideoAutoplay: jasmine.createSpy('consumeVideoAutoplay').and.returnValue(false)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
agentService = createAgentService();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [FloatingVideoPlayerComponent],
|
||||||
|
providers: [{ provide: AgentService, useValue: agentService }]
|
||||||
|
});
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(FloatingVideoPlayerComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
video = component.videoRef!.nativeElement;
|
||||||
|
paused = true;
|
||||||
|
seeking = false;
|
||||||
|
time = 0;
|
||||||
|
Object.defineProperties(video, {
|
||||||
|
paused: { configurable: true, get: () => paused },
|
||||||
|
seeking: { configurable: true, get: () => seeking },
|
||||||
|
ended: { configurable: true, get: () => false },
|
||||||
|
duration: { configurable: true, get: () => 20 },
|
||||||
|
currentTime: {
|
||||||
|
configurable: true, get: () => time,
|
||||||
|
set: (value: number) => { time = value; seeking = true; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
spyOn(video, 'pause').and.callFake(() => {
|
||||||
|
paused = true;
|
||||||
|
video.dispatchEvent(new Event('pause'));
|
||||||
|
});
|
||||||
|
play = spyOn(video, 'play').and.callFake(() => {
|
||||||
|
paused = false;
|
||||||
|
video.dispatchEvent(new Event('play'));
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function finishSeek(): void {
|
||||||
|
seeking = false;
|
||||||
|
video.dispatchEvent(new Event('seeked'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
function playButton(): HTMLButtonElement {
|
||||||
|
return fixture.nativeElement.querySelector('.play-btn');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows a recorded step on the video timeline and seeks when clicked', () => {
|
||||||
|
const marker = fixture.nativeElement.querySelector('.video-marker') as HTMLButtonElement;
|
||||||
|
expect(marker).not.toBeNull();
|
||||||
|
expect(marker.getAttribute('aria-label')).toContain('步骤 1');
|
||||||
|
expect(marker.style.left).toBe('25%');
|
||||||
|
|
||||||
|
const seek = spyOn(fixture.componentInstance, 'seek');
|
||||||
|
marker.click();
|
||||||
|
expect(seek).toHaveBeenCalledOnceWith(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resumes a playing video after a marker seek finishes and synchronizes the button', async () => {
|
||||||
|
component.togglePlay();
|
||||||
|
await Promise.resolve();
|
||||||
|
play.calls.reset();
|
||||||
|
fixture.nativeElement.querySelector('.video-marker').click();
|
||||||
|
expect(time).toBe(5);
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
finishSeek();
|
||||||
|
await Promise.resolve();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(paused).toBeFalse();
|
||||||
|
expect(play).toHaveBeenCalledTimes(1);
|
||||||
|
expect(playButton().title).toBe('暂停');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a paused video paused after a marker seek and resumes with one click', async () => {
|
||||||
|
fixture.nativeElement.querySelector('.video-marker').click();
|
||||||
|
finishSeek();
|
||||||
|
expect(paused).toBeTrue();
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
expect(playButton().title).toBe('播放');
|
||||||
|
playButton().click();
|
||||||
|
await Promise.resolve();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(paused).toBeFalse();
|
||||||
|
expect(playButton().title).toBe('暂停');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves playing intent across rapid consecutive marker seeks', () => {
|
||||||
|
component.togglePlay();
|
||||||
|
component.seek(5);
|
||||||
|
component.seek(8);
|
||||||
|
play.calls.reset();
|
||||||
|
finishSeek();
|
||||||
|
expect(time).toBe(8);
|
||||||
|
expect(play).toHaveBeenCalledTimes(1);
|
||||||
|
expect(paused).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors play and pause clicks while the destination is still seeking', () => {
|
||||||
|
component.togglePlay();
|
||||||
|
component.seek(5);
|
||||||
|
play.calls.reset();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(playButton().title).toBe('播放');
|
||||||
|
playButton().click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(playButton().title).toBe('暂停');
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
playButton().click();
|
||||||
|
finishSeek();
|
||||||
|
expect(paused).toBeTrue();
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
expect(playButton().title).toBe('播放');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for the destination segment to seek before resuming playback', () => {
|
||||||
|
agentService.activeVideoSegments.set([
|
||||||
|
{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 },
|
||||||
|
{ url: '/second.mp4', start: 20, duration: 20, width: 400, height: 800 }
|
||||||
|
]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
component.togglePlay();
|
||||||
|
component.seek(25);
|
||||||
|
play.calls.reset();
|
||||||
|
fixture.detectChanges();
|
||||||
|
video.dispatchEvent(new Event('loadedmetadata'));
|
||||||
|
expect(component.activeSegmentIndex()).toBe(1);
|
||||||
|
expect(time).toBe(5);
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
finishSeek();
|
||||||
|
expect(play).toHaveBeenCalledTimes(1);
|
||||||
|
expect(paused).toBeFalse();
|
||||||
|
expect(component.currentTime()).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a paused video paused when jumping to another segment', () => {
|
||||||
|
agentService.activeVideoSegments.set([
|
||||||
|
{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 },
|
||||||
|
{ url: '/second.mp4', start: 20, duration: 20, width: 400, height: 800 }
|
||||||
|
]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
component.seek(25);
|
||||||
|
fixture.detectChanges();
|
||||||
|
video.dispatchEvent(new Event('loadedmetadata'));
|
||||||
|
finishSeek();
|
||||||
|
expect(time).toBe(5);
|
||||||
|
expect(paused).toBeTrue();
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
expect(playButton().title).toBe('播放');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resumes after seeking a recording without a segment manifest', () => {
|
||||||
|
agentService.activeVideoSegments.set([]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
video.dispatchEvent(new Event('loadedmetadata'));
|
||||||
|
component.togglePlay();
|
||||||
|
component.seek(5);
|
||||||
|
play.calls.reset();
|
||||||
|
finishSeek();
|
||||||
|
expect(time).toBe(5);
|
||||||
|
expect(component.currentTime()).toBe(5);
|
||||||
|
expect(paused).toBeFalse();
|
||||||
|
expect(play).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets a rejected play request so a single subsequent click can resume', async () => {
|
||||||
|
play.and.callFake(() => {
|
||||||
|
paused = false;
|
||||||
|
video.dispatchEvent(new Event('play'));
|
||||||
|
return Promise.reject(new DOMException('Interrupted', 'AbortError'));
|
||||||
|
});
|
||||||
|
component.seek(5, true);
|
||||||
|
finishSeek();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(paused).toBeTrue();
|
||||||
|
expect(playButton().title).toBe('播放');
|
||||||
|
play.and.callFake(() => {
|
||||||
|
paused = false;
|
||||||
|
video.dispatchEvent(new Event('play'));
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
playButton().click();
|
||||||
|
await Promise.resolve();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(paused).toBeFalse();
|
||||||
|
expect(playButton().title).toBe('暂停');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores an older aborted play request after a newer seek resumes', async () => {
|
||||||
|
let rejectOldPlay!: (reason: unknown) => void;
|
||||||
|
play.and.returnValue(new Promise<void>((_, reject) => { rejectOldPlay = reject; }));
|
||||||
|
component.togglePlay();
|
||||||
|
paused = false;
|
||||||
|
component.seek(5);
|
||||||
|
play.and.callFake(() => { paused = false; return Promise.resolve(); });
|
||||||
|
finishSeek();
|
||||||
|
rejectOldPlay(new DOMException('Interrupted', 'AbortError'));
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(paused).toBeFalse();
|
||||||
|
expect(component.isPlaying()).toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
+113
-28
@@ -34,6 +34,7 @@ import { StepReplayFrame } from '../../core/models/stream.model';
|
|||||||
import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util';
|
import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util';
|
||||||
import { getActionIcon } from '../../utils/action-formatter.util';
|
import { getActionIcon } from '../../utils/action-formatter.util';
|
||||||
import { locateTimelineTime, sessionTimeToTimelineTime } from '../../utils/recording-timeline.util';
|
import { locateTimelineTime, sessionTimeToTimelineTime } from '../../utils/recording-timeline.util';
|
||||||
|
import { buildVideoStepMarkers, VideoStepMarker } from '../../utils/video-step-markers.util';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-floating-video-player',
|
selector: 'app-floating-video-player',
|
||||||
@@ -70,6 +71,8 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
private pendingLocalTime: number | null = null;
|
private pendingLocalTime: number | null = null;
|
||||||
private pendingAutoplay = false;
|
private pendingAutoplay = false;
|
||||||
private pendingAbsoluteSeek: number | null = null;
|
private pendingAbsoluteSeek: number | null = null;
|
||||||
|
private seekPlaybackIntent: boolean | null = null;
|
||||||
|
private playRequestId = 0;
|
||||||
|
|
||||||
// Step Replay state signals
|
// Step Replay state signals
|
||||||
public activeStepIndex = signal<number>(0);
|
public activeStepIndex = signal<number>(0);
|
||||||
@@ -83,6 +86,12 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
private stepTimer: any = null;
|
private stepTimer: any = null;
|
||||||
|
|
||||||
public stepFrames = computed(() => this.agentService.currentSessionStepFrames());
|
public stepFrames = computed(() => this.agentService.currentSessionStepFrames());
|
||||||
|
public videoStepMarkers = computed(() => buildVideoStepMarkers(
|
||||||
|
this.stepFrames(),
|
||||||
|
this.agentService.currentSession()?.start_time,
|
||||||
|
this.agentService.activeVideoSegments(),
|
||||||
|
this.duration()
|
||||||
|
));
|
||||||
public totalStepFrames = computed(() => this.stepFrames().length);
|
public totalStepFrames = computed(() => this.stepFrames().length);
|
||||||
public currentStepFrame = computed<StepReplayFrame | null>(() => {
|
public currentStepFrame = computed<StepReplayFrame | null>(() => {
|
||||||
const frames = this.stepFrames();
|
const frames = this.stepFrames();
|
||||||
@@ -131,6 +140,10 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
this.currentTime.set(0);
|
this.currentTime.set(0);
|
||||||
this.duration.set(segments.reduce((sum, segment) => sum + segment.duration, 0));
|
this.duration.set(segments.reduce((sum, segment) => sum + segment.duration, 0));
|
||||||
this.isPlaying.set(false);
|
this.isPlaying.set(false);
|
||||||
|
this.pendingLocalTime = null;
|
||||||
|
this.pendingAutoplay = false;
|
||||||
|
this.seekPlaybackIntent = null;
|
||||||
|
this.playRequestId++;
|
||||||
},
|
},
|
||||||
{ allowSignalWrites: true }
|
{ allowSignalWrites: true }
|
||||||
);
|
);
|
||||||
@@ -308,20 +321,19 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
// that segment's metadata event apply pendingLocalTime.
|
// that segment's metadata event apply pendingLocalTime.
|
||||||
if (this.pendingLocalTime !== null) return;
|
if (this.pendingLocalTime !== null) return;
|
||||||
}
|
}
|
||||||
if (this.pendingLocalTime !== null) {
|
const autoplay = this.agentService.consumeVideoAutoplay();
|
||||||
v.currentTime = Math.max(0, Math.min(v.duration || this.pendingLocalTime, this.pendingLocalTime));
|
if (autoplay) {
|
||||||
this.pendingLocalTime = null;
|
|
||||||
}
|
|
||||||
if (this.pendingAutoplay) {
|
|
||||||
this.pendingAutoplay = false;
|
|
||||||
v.play().catch(() => {});
|
|
||||||
}
|
|
||||||
if (this.agentService.consumeVideoAutoplay()) {
|
|
||||||
this.isMuted.set(true);
|
this.isMuted.set(true);
|
||||||
v.muted = true;
|
v.muted = true;
|
||||||
v.play().catch(() => {
|
}
|
||||||
this.isPlaying.set(false);
|
const shouldPlay = this.pendingAutoplay || autoplay;
|
||||||
});
|
this.pendingAutoplay = false;
|
||||||
|
if (this.pendingLocalTime !== null) {
|
||||||
|
const localTime = Math.max(0, Math.min(v.duration || this.pendingLocalTime, this.pendingLocalTime));
|
||||||
|
this.pendingLocalTime = null;
|
||||||
|
this.seekWithinVideo(localTime, shouldPlay);
|
||||||
|
} else if (shouldPlay) {
|
||||||
|
this.playVideo();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,14 +351,28 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onPlay(): void {
|
public onPlay(): void {
|
||||||
this.isPlaying.set(true);
|
this.isPlaying.set(!this.videoRef?.nativeElement.paused);
|
||||||
}
|
}
|
||||||
|
|
||||||
public onPause(): void {
|
public onPause(): void {
|
||||||
this.isPlaying.set(false);
|
this.isPlaying.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onSeeked(): void {
|
||||||
|
const v = this.videoRef?.nativeElement;
|
||||||
|
if (!v || v.seeking || this.pendingLocalTime !== null) return;
|
||||||
|
const shouldPlay = this.seekPlaybackIntent;
|
||||||
|
this.seekPlaybackIntent = null;
|
||||||
|
this.onTimeUpdate();
|
||||||
|
if (shouldPlay) {
|
||||||
|
this.playVideo();
|
||||||
|
} else {
|
||||||
|
this.isPlaying.set(!v.paused && !v.ended);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public onEnded(): void {
|
public onEnded(): void {
|
||||||
|
this.isPlaying.set(false);
|
||||||
const segments = this.agentService.activeVideoSegments();
|
const segments = this.agentService.activeVideoSegments();
|
||||||
if (segments.length && this.activeSegmentIndex() < segments.length - 1) {
|
if (segments.length && this.activeSegmentIndex() < segments.length - 1) {
|
||||||
this.pendingLocalTime = 0;
|
this.pendingLocalTime = 0;
|
||||||
@@ -362,6 +388,10 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onError(): void {
|
public onError(): void {
|
||||||
|
this.playRequestId++;
|
||||||
|
this.pendingLocalTime = null;
|
||||||
|
this.pendingAutoplay = false;
|
||||||
|
this.seekPlaybackIntent = null;
|
||||||
this.videoLoadError.set(true);
|
this.videoLoadError.set(true);
|
||||||
this.isPlaying.set(false);
|
this.isPlaying.set(false);
|
||||||
}
|
}
|
||||||
@@ -372,11 +402,52 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
public togglePlay(): void {
|
public togglePlay(): void {
|
||||||
const v = this.videoRef?.nativeElement;
|
const v = this.videoRef?.nativeElement;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
if (v.paused) {
|
if (this.pendingLocalTime !== null) {
|
||||||
v.play().catch(() => {});
|
this.pendingAutoplay = !this.isPlaying();
|
||||||
} else {
|
this.isPlaying.set(this.pendingAutoplay);
|
||||||
v.pause();
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.seekPlaybackIntent !== null) {
|
||||||
|
this.seekPlaybackIntent = !this.isPlaying();
|
||||||
|
this.isPlaying.set(this.seekPlaybackIntent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (v.paused) {
|
||||||
|
this.playVideo();
|
||||||
|
} else {
|
||||||
|
this.playRequestId++;
|
||||||
|
v.pause();
|
||||||
|
this.isPlaying.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private playVideo(): void {
|
||||||
|
const v = this.videoRef?.nativeElement;
|
||||||
|
if (!v) return;
|
||||||
|
const requestId = ++this.playRequestId;
|
||||||
|
v.play().then(() => {
|
||||||
|
if (requestId === this.playRequestId) this.isPlaying.set(!v.paused && !v.ended);
|
||||||
|
}).catch(() => {
|
||||||
|
// A rejected play request can leave paused=false without advancing frames.
|
||||||
|
// Reset the native state too, so the next click really starts playback.
|
||||||
|
if (requestId === this.playRequestId) {
|
||||||
|
v.pause();
|
||||||
|
this.isPlaying.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private seekWithinVideo(seconds: number, shouldPlay: boolean): void {
|
||||||
|
const v = this.videoRef?.nativeElement;
|
||||||
|
if (!v) return;
|
||||||
|
this.playRequestId++;
|
||||||
|
this.seekPlaybackIntent = shouldPlay;
|
||||||
|
// Cancel any unfinished play request before seeking; resume only once the
|
||||||
|
// browser has decoded the destination, including after a source switch.
|
||||||
|
v.pause();
|
||||||
|
this.isPlaying.set(false);
|
||||||
|
v.currentTime = seconds;
|
||||||
|
if (!v.seeking) this.onSeeked();
|
||||||
}
|
}
|
||||||
|
|
||||||
public restart(): void {
|
public restart(): void {
|
||||||
@@ -394,6 +465,19 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
this.seek(sessionTimeToTimelineTime(segments, sessionSeconds), autoplay);
|
this.seek(sessionTimeToTimelineTime(segments, sessionSeconds), autoplay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public seekToVideoMarker(marker: VideoStepMarker): void {
|
||||||
|
this.seek(marker.time);
|
||||||
|
}
|
||||||
|
|
||||||
|
public videoMarkerLabel(marker: VideoStepMarker): string {
|
||||||
|
const first = marker.steps[0];
|
||||||
|
const count = marker.steps.length;
|
||||||
|
const action = first.actionText || first.title;
|
||||||
|
return count > 1
|
||||||
|
? `步骤 ${first.stepNumber} 等 ${count} 步 · ${this.formatTime(marker.time)} · ${action}`
|
||||||
|
: `步骤 ${first.stepNumber} · ${this.formatTime(marker.time)} · ${action}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Seek on the back-to-back playlist axis used by the scrubber. */
|
/** Seek on the back-to-back playlist axis used by the scrubber. */
|
||||||
public seek(seconds: number, autoplay = false): void {
|
public seek(seconds: number, autoplay = false): void {
|
||||||
const v = this.videoRef?.nativeElement;
|
const v = this.videoRef?.nativeElement;
|
||||||
@@ -401,19 +485,19 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
const target = Math.max(0, Math.min(this.duration(), seconds));
|
const target = Math.max(0, Math.min(this.duration(), seconds));
|
||||||
const segments = this.agentService.activeVideoSegments();
|
const segments = this.agentService.activeVideoSegments();
|
||||||
const location = locateTimelineTime(segments, target);
|
const location = locateTimelineTime(segments, target);
|
||||||
|
const shouldPlay = autoplay || this.pendingAutoplay || (this.seekPlaybackIntent ?? !v.paused);
|
||||||
if (!location) {
|
if (!location) {
|
||||||
v.currentTime = target;
|
this.seekWithinVideo(target, shouldPlay);
|
||||||
if (autoplay) v.play().catch(() => {});
|
} else if (location.index === this.activeSegmentIndex() && this.pendingLocalTime === null) {
|
||||||
return;
|
this.seekWithinVideo(location.localTime, shouldPlay);
|
||||||
}
|
|
||||||
const { index, localTime } = location;
|
|
||||||
if (index === this.activeSegmentIndex()) {
|
|
||||||
v.currentTime = localTime;
|
|
||||||
if (autoplay) v.play().catch(() => {});
|
|
||||||
} else {
|
} else {
|
||||||
this.pendingLocalTime = localTime;
|
this.playRequestId++;
|
||||||
this.pendingAutoplay = autoplay || !v.paused;
|
this.seekPlaybackIntent = null;
|
||||||
this.activeSegmentIndex.set(index);
|
v.pause();
|
||||||
|
this.isPlaying.set(false);
|
||||||
|
this.pendingLocalTime = location.localTime;
|
||||||
|
this.pendingAutoplay = shouldPlay;
|
||||||
|
this.activeSegmentIndex.set(location.index);
|
||||||
}
|
}
|
||||||
this.currentTime.set(target);
|
this.currentTime.set(target);
|
||||||
}
|
}
|
||||||
@@ -621,6 +705,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ngOnDestroy(): void {
|
public ngOnDestroy(): void {
|
||||||
|
this.playRequestId++;
|
||||||
if (this.stepTimer) {
|
if (this.stepTimer) {
|
||||||
clearTimeout(this.stepTimer);
|
clearTimeout(this.stepTimer);
|
||||||
this.stepTimer = null;
|
this.stepTimer = null;
|
||||||
|
|||||||
+54
-5
@@ -4,9 +4,21 @@ import { HttpClient } from '@angular/common/http';
|
|||||||
import { finalize } from 'rxjs';
|
import { finalize } from 'rxjs';
|
||||||
|
|
||||||
interface Capabilities {
|
interface Capabilities {
|
||||||
|
adb_auto_connect: boolean;
|
||||||
|
adb_discovery_interval_seconds: number;
|
||||||
|
adb_discovery_timeout_seconds: number;
|
||||||
video_enabled: boolean;
|
video_enabled: boolean;
|
||||||
|
recording_enabled: boolean;
|
||||||
outputter_enabled: boolean;
|
outputter_enabled: boolean;
|
||||||
step_summarizer_enabled: boolean;
|
step_summarizer_enabled: boolean;
|
||||||
|
screenshot_limit_enabled: boolean;
|
||||||
|
screenshot_max_long_edge: number;
|
||||||
|
task_timeout_seconds: number;
|
||||||
|
observation_timeout_seconds: number;
|
||||||
|
observation_max_attempts: number;
|
||||||
|
device_command_timeout_seconds: number;
|
||||||
|
action_failure_attempts: number;
|
||||||
|
worker_cleanup_timeout_seconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -15,13 +27,33 @@ interface Capabilities {
|
|||||||
<section aria-label="功能开关">
|
<section aria-label="功能开关">
|
||||||
<h3>功能开关</h3>
|
<h3>功能开关</h3>
|
||||||
<p>按需启用,保存后对新任务生效。</p>
|
<p>按需启用,保存后对新任务生效。</p>
|
||||||
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.adb_auto_connect" [disabled]="busy() || loading() || !loaded()" />
|
||||||
|
<span>自动连接设备<small>刷新设备时发现本机常见模拟器,并重连手动连接过的 TCP 设备;重启 ADB 后也会重连。不扫描局域网,不自动选择任务设备。保存后立即生效。</small></span></label>
|
||||||
<label><input type="checkbox" role="switch" [(ngModel)]="config.video_enabled" [disabled]="busy() || loading() || !loaded()" />
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.video_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
<span>视频分析与录屏<small>录制设备画面,并允许智能体分析录像;关闭可减少资源占用。</small></span></label>
|
<span>视频分析<small>允许智能体分析任务画面。运行期间会临时录制,完成后不保留录像(除非开启录屏)。</small></span></label>
|
||||||
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.recording_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
|
<span>保存录屏<small>保留任务录像以便复盘,可单独开启,不要求启用视频分析。</small></span></label>
|
||||||
<label><input type="checkbox" role="switch" [(ngModel)]="config.outputter_enabled" [disabled]="busy() || loading() || !loaded()" />
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.outputter_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
|
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
|
||||||
<label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" />
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
<span>步骤摘要压缩<small>Flash 模式下在后台生成摘要,减少长任务的上下文占用。</small></span></label>
|
<span>步骤摘要压缩<small>Flash 模式下在后台生成摘要,减少长任务的上下文占用。</small></span></label>
|
||||||
<button type="button" (click)="save()" [disabled]="busy() || loading() || !loaded()">{{ busy() ? '正在保存…' : '保存功能设置' }}</button>
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.screenshot_limit_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
|
<span>限制截图分辨率<small>开启时长边最多 {{ config.screenshot_max_long_edge }} 像素;关闭后使用设备原始分辨率。JSON 配置可覆盖开关和上限。</small></span></label>
|
||||||
|
<details class="execution-settings">
|
||||||
|
<summary>执行保护</summary>
|
||||||
|
<p>限制故障等待与反复失败;保存后对新任务生效,任务 JSON 可单独覆盖。</p>
|
||||||
|
<div class="number-fields">
|
||||||
|
<label>任务总超时(秒)<input type="number" min="30" max="86400" step="1" [(ngModel)]="config.task_timeout_seconds" [disabled]="busy() || !loaded()" /><small>不包含排队和用户暂停时间。</small></label>
|
||||||
|
<label>屏幕观测超时(秒)<input type="number" min="1" max="300" [(ngModel)]="config.observation_timeout_seconds" [disabled]="busy() || !loaded()" /></label>
|
||||||
|
<label>观测恢复最多尝试次数<input type="number" min="1" max="5" step="1" [(ngModel)]="config.observation_max_attempts" [disabled]="busy() || !loaded()" /></label>
|
||||||
|
<label>ADB 命令超时(秒)<input type="number" min="1" max="60" [(ngModel)]="config.device_command_timeout_seconds" [disabled]="busy() || !loaded()" /></label>
|
||||||
|
<label>连续动作失败次数<input type="number" min="1" max="10" step="1" [(ngModel)]="config.action_failure_attempts" [disabled]="busy() || !loaded()" /><small>先重新观察,再让兜底模型处理当前步骤。</small></label>
|
||||||
|
<label>任务收尾超时(秒)<input type="number" min="1" max="300" [(ngModel)]="config.worker_cleanup_timeout_seconds" [disabled]="busy() || !loaded()" /><small>已有最终结果但进程未退出时,回收进程以释放队列。</small></label>
|
||||||
|
<label>设备自动发现间隔(秒)<input type="number" min="1" max="300" [(ngModel)]="config.adb_discovery_interval_seconds" [disabled]="busy() || !loaded()" /></label>
|
||||||
|
<label>每轮自动连接预算(秒)<input type="number" min="1" max="30" [(ngModel)]="config.adb_discovery_timeout_seconds" [disabled]="busy() || !loaded()" /></label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<button type="button" (click)="save()" [disabled]="busy() || loading() || !loaded() || !valid()">{{ busy() ? '正在保存…' : '保存功能设置' }}</button>
|
||||||
@if (message()) { <p role="status">{{ message() }}</p> }
|
@if (message()) { <p role="status">{{ message() }}</p> }
|
||||||
@if (error()) { <p role="alert" class="error">{{ error() }}</p> }
|
@if (error()) { <p role="alert" class="error">{{ error() }}</p> }
|
||||||
</section>
|
</section>
|
||||||
@@ -31,12 +63,18 @@ interface Capabilities {
|
|||||||
label { display: flex; gap: 12px; align-items: flex-start; padding: 14px 0; cursor: pointer; }
|
label { display: flex; gap: 12px; align-items: flex-start; padding: 14px 0; cursor: pointer; }
|
||||||
input { width: 20px; height: 20px; accent-color: #0284c7; flex-shrink: 0; }
|
input { width: 20px; height: 20px; accent-color: #0284c7; flex-shrink: 0; }
|
||||||
small { display: block; margin-top: 4px; } button { padding: 10px 18px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; font: inherit; cursor: pointer; }
|
small { display: block; margin-top: 4px; } button { padding: 10px 18px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; font: inherit; cursor: pointer; }
|
||||||
button:disabled { opacity: .5; cursor: default; } .error { color: #be123c; }`]
|
button:disabled { opacity: .5; cursor: default; } .error { color: #be123c; }
|
||||||
|
.execution-settings { margin: 12px 0 20px; padding: 12px 14px; border: 1px solid #cbd5e1; border-radius: 10px; background: #f8fafc; }
|
||||||
|
summary { cursor: pointer; font-weight: 600; color: #334155; }
|
||||||
|
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; }
|
||||||
|
.number-fields label { display: block; padding: 0; color: #334155; }
|
||||||
|
.number-fields input { box-sizing: border-box; display: block; width: 100%; height: auto; margin-top: 8px; padding: 9px; border: 1px solid #cbd5e1; border-radius: 8px; font: inherit; }
|
||||||
|
@media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }`]
|
||||||
})
|
})
|
||||||
export class CapabilitySettingsComponent implements OnInit {
|
export class CapabilitySettingsComponent implements OnInit {
|
||||||
changed = output<Capabilities>();
|
changed = output<Capabilities>();
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
config: Capabilities = { video_enabled: false, outputter_enabled: true, step_summarizer_enabled: true };
|
config: Capabilities = { adb_auto_connect: true, adb_discovery_interval_seconds: 10, adb_discovery_timeout_seconds: 5, video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
|
||||||
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
|
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.http.get<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
|
this.http.get<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
|
||||||
@@ -44,11 +82,22 @@ export class CapabilitySettingsComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
save(): void {
|
save(): void {
|
||||||
if (this.busy() || this.loading() || !this.loaded()) return;
|
if (this.busy() || this.loading() || !this.loaded() || !this.valid()) return;
|
||||||
this.busy.set(true); this.message.set(''); this.error.set('');
|
this.busy.set(true); this.message.set(''); this.error.set('');
|
||||||
this.http.post<Capabilities>('/api/system/capability-settings', this.config).pipe(finalize(() => this.busy.set(false))).subscribe({
|
this.http.post<Capabilities>('/api/system/capability-settings', this.config).pipe(finalize(() => this.busy.set(false))).subscribe({
|
||||||
next: config => { this.config = config; this.changed.emit(config); this.message.set('功能设置已保存,新任务将使用此配置。'); },
|
next: config => { this.config = config; this.changed.emit(config); this.message.set('功能设置已保存,新任务将使用此配置。'); },
|
||||||
error: () => this.error.set('保存失败,请检查服务器连接和 .env 文件权限。')
|
error: () => this.error.set('保存失败,请检查服务器连接和 .env 文件权限。')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
valid(): boolean {
|
||||||
|
const c = this.config;
|
||||||
|
return Number.isInteger(c.task_timeout_seconds) && c.task_timeout_seconds >= 30 && c.task_timeout_seconds <= 86400
|
||||||
|
&& c.observation_timeout_seconds > 0 && c.observation_timeout_seconds <= 300
|
||||||
|
&& Number.isInteger(c.observation_max_attempts) && c.observation_max_attempts >= 1 && c.observation_max_attempts <= 5
|
||||||
|
&& c.device_command_timeout_seconds > 0 && c.device_command_timeout_seconds <= 60
|
||||||
|
&& Number.isInteger(c.action_failure_attempts) && c.action_failure_attempts >= 1 && c.action_failure_attempts <= 10
|
||||||
|
&& c.worker_cleanup_timeout_seconds > 0 && c.worker_cleanup_timeout_seconds <= 300
|
||||||
|
&& c.adb_discovery_interval_seconds >= 1 && c.adb_discovery_interval_seconds <= 300
|
||||||
|
&& c.adb_discovery_timeout_seconds > 0 && c.adb_discovery_timeout_seconds <= 30;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+102
-5
@@ -7,7 +7,10 @@ import { CapabilitySettingsComponent } from './capability-settings.component';
|
|||||||
describe('持久化模型与功能设置', () => {
|
describe('持久化模型与功能设置', () => {
|
||||||
let http: HttpTestingController;
|
let http: HttpTestingController;
|
||||||
const config = { base_url: 'https://example.test/v1', model: 'mimo-v2.6-flash',
|
const config = { base_url: 'https://example.test/v1', model: 'mimo-v2.6-flash',
|
||||||
api_key_configured: true, timeout_seconds: 300, max_retries: 5, retry_interval_seconds: 10, reasoning_effort: 'high', env_path: '/app/.env' };
|
api_key_configured: true, timeout_seconds: 300, max_retries: 5, retry_interval_seconds: 10, reasoning_effort: 'high',
|
||||||
|
rate_limit_retry_seconds: 60, tool_fallback_model: 'gpt-6-luna', tool_fallback_reasoning_effort: 'high',
|
||||||
|
tool_fallback_max_retries: 3, tool_fallback_no_progress_attempts: 3,
|
||||||
|
model_presets: ['gpt-5.6-luna', 'gpt-6-luna', 'mimo-v2.6-flash', 'mimo-v2.6-pro'], env_path: '/app/.env' };
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||||
http = TestBed.inject(HttpTestingController);
|
http = TestBed.inject(HttpTestingController);
|
||||||
@@ -30,6 +33,9 @@ describe('持久化模型与功能设置', () => {
|
|||||||
expect(request.request.body.timeout_seconds).toBe(300);
|
expect(request.request.body.timeout_seconds).toBe(300);
|
||||||
expect(request.request.body.max_retries).toBe(5);
|
expect(request.request.body.max_retries).toBe(5);
|
||||||
expect(request.request.body.retry_interval_seconds).toBe(10);
|
expect(request.request.body.retry_interval_seconds).toBe(10);
|
||||||
|
expect(request.request.body.rate_limit_retry_seconds).toBe(60);
|
||||||
|
expect(request.request.body.tool_fallback_model).toBe('gpt-6-luna');
|
||||||
|
expect(request.request.body.tool_fallback_no_progress_attempts).toBe(3);
|
||||||
expect(request.request.body.api_key).toBeUndefined();
|
expect(request.request.body.api_key).toBeUndefined();
|
||||||
request.flush({ ...config, model: 'another-model' });
|
request.flush({ ...config, model: 'another-model' });
|
||||||
expect(component.busy()).toBeNull();
|
expect(component.busy()).toBeNull();
|
||||||
@@ -44,13 +50,23 @@ describe('持久化模型与功能设置', () => {
|
|||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
const root: HTMLElement = fixture.nativeElement;
|
const root: HTMLElement = fixture.nativeElement;
|
||||||
const presets = root.querySelectorAll<HTMLButtonElement>('.model-presets button');
|
const presets = root.querySelectorAll<HTMLButtonElement>('.model-presets button');
|
||||||
expect(presets.length).toBe(2);
|
expect(Array.from(presets, button => button.textContent?.trim())).toEqual([
|
||||||
|
'gpt-5.6-luna', 'gpt-6-luna', 'mimo-v2.6-flash', 'mimo-v2.6-pro'
|
||||||
|
]);
|
||||||
presets[0].click();
|
presets[0].click();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
const input = root.querySelector<HTMLInputElement>('#model-name')!;
|
const input = root.querySelector<HTMLInputElement>('#model-name')!;
|
||||||
expect(input.value).toBe('gpt-5.6-luna');
|
expect(input.value).toBe('gpt-5.6-luna');
|
||||||
expect(presets[0].getAttribute('aria-pressed')).toBe('true');
|
expect(presets[0].getAttribute('aria-pressed')).toBe('true');
|
||||||
|
presets[1].click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(input.value).toBe('gpt-6-luna');
|
||||||
|
presets[3].click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(input.value).toBe('mimo-v2.6-pro');
|
||||||
expect(input.getAttribute('list')).toBeNull();
|
expect(input.getAttribute('list')).toBeNull();
|
||||||
expect(input.autocomplete).toBe('off');
|
expect(input.autocomplete).toBe('off');
|
||||||
input.value = 'custom/model-v2';
|
input.value = 'custom/model-v2';
|
||||||
@@ -77,6 +93,29 @@ describe('持久化模型与功能设置', () => {
|
|||||||
expect(reopened.nativeElement.querySelector('#model-reasoning').value).toBe('max');
|
expect(reopened.nativeElement.querySelector('#model-reasoning').value).toBe('max');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('高级稳定性设置可编辑并保存', async () => {
|
||||||
|
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
http.expectOne('/api/system/model-settings').flush(config);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
const root: HTMLElement = fixture.nativeElement;
|
||||||
|
root.querySelector<HTMLDetailsElement>('.recovery-settings')!.open = true;
|
||||||
|
const fallback = root.querySelector<HTMLInputElement>('input[name="toolFallbackModel"]')!;
|
||||||
|
fallback.value = 'custom/fallback';
|
||||||
|
fallback.dispatchEvent(new Event('input'));
|
||||||
|
const wait = root.querySelector<HTMLInputElement>('input[name="rateLimitRetry"]')!;
|
||||||
|
wait.value = '90';
|
||||||
|
wait.dispatchEvent(new Event('input'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
root.querySelector<HTMLFormElement>('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
|
||||||
|
const request = http.expectOne('/api/system/model-settings');
|
||||||
|
expect(request.request.body.tool_fallback_model).toBe('custom/fallback');
|
||||||
|
expect(request.request.body.rate_limit_retry_seconds).toBe(90);
|
||||||
|
request.flush({ ...config, tool_fallback_model: 'custom/fallback', rate_limit_retry_seconds: 90 });
|
||||||
|
});
|
||||||
|
|
||||||
it('连接测试使用当前输入而不写入保存接口', () => {
|
it('连接测试使用当前输入而不写入保存接口', () => {
|
||||||
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -98,21 +137,79 @@ describe('持久化模型与功能设置', () => {
|
|||||||
http.expectNone('/api/system/capability-settings');
|
http.expectNone('/api/system/capability-settings');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('保存关闭视频分析的配置,重新打开时恢复关闭状态', () => {
|
it('录屏与视频分析独立保存,重新打开后保留各自状态', () => {
|
||||||
const capabilities = { video_enabled: true, outputter_enabled: true, step_summarizer_enabled: false };
|
const capabilities = { adb_auto_connect: true, adb_discovery_interval_seconds: 10, adb_discovery_timeout_seconds: 5, video_enabled: true, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: false, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
|
||||||
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
http.expectOne('/api/system/capability-settings').flush(capabilities);
|
http.expectOne('/api/system/capability-settings').flush(capabilities);
|
||||||
fixture.componentInstance.config.video_enabled = false;
|
fixture.componentInstance.config.video_enabled = false;
|
||||||
|
fixture.componentInstance.config.recording_enabled = true;
|
||||||
|
fixture.componentInstance.config.screenshot_limit_enabled = false;
|
||||||
fixture.componentInstance.save();
|
fixture.componentInstance.save();
|
||||||
const request = http.expectOne('/api/system/capability-settings');
|
const request = http.expectOne('/api/system/capability-settings');
|
||||||
expect(request.request.body.video_enabled).toBeFalse();
|
expect(request.request.body.video_enabled).toBeFalse();
|
||||||
const saved = { ...capabilities, video_enabled: false };
|
expect(request.request.body.recording_enabled).toBeTrue();
|
||||||
|
expect(request.request.body.screenshot_limit_enabled).toBeFalse();
|
||||||
|
const saved = { ...capabilities, video_enabled: false, recording_enabled: true, screenshot_limit_enabled: false };
|
||||||
request.flush(saved);
|
request.flush(saved);
|
||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
|
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
|
||||||
reopened.detectChanges();
|
reopened.detectChanges();
|
||||||
http.expectOne('/api/system/capability-settings').flush(saved);
|
http.expectOne('/api/system/capability-settings').flush(saved);
|
||||||
expect(reopened.componentInstance.config.video_enabled).toBeFalse();
|
expect(reopened.componentInstance.config.video_enabled).toBeFalse();
|
||||||
|
expect(reopened.componentInstance.config.recording_enabled).toBeTrue();
|
||||||
|
expect(reopened.componentInstance.config.screenshot_limit_enabled).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('执行保护默认折叠,修改时限可保存且拒绝无效数值', async () => {
|
||||||
|
const capabilities = { adb_auto_connect: true, adb_discovery_interval_seconds: 10, adb_discovery_timeout_seconds: 5, video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
|
||||||
|
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
http.expectOne('/api/system/capability-settings').flush(capabilities);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
const root: HTMLElement = fixture.nativeElement;
|
||||||
|
expect(root.querySelector<HTMLDetailsElement>('.execution-settings')!.open).toBeFalse();
|
||||||
|
root.querySelector<HTMLElement>('summary')!.click();
|
||||||
|
const timeout = root.querySelector<HTMLInputElement>('input[type="number"]')!;
|
||||||
|
timeout.value = '3600';
|
||||||
|
timeout.dispatchEvent(new Event('input'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
root.querySelector<HTMLButtonElement>('button')!.click();
|
||||||
|
const request = http.expectOne('/api/system/capability-settings');
|
||||||
|
expect(request.request.body.task_timeout_seconds).toBe(3600);
|
||||||
|
expect(request.request.body.observation_timeout_seconds).toBe(60);
|
||||||
|
request.flush({ ...capabilities, task_timeout_seconds: 3600 });
|
||||||
|
expect(fixture.componentInstance.message()).toContain('已保存');
|
||||||
|
timeout.value = '0';
|
||||||
|
timeout.dispatchEvent(new Event('input'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(root.querySelector<HTMLButtonElement>('button')!.disabled).toBeTrue();
|
||||||
|
fixture.componentInstance.save();
|
||||||
|
http.expectNone('/api/system/capability-settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('自动连接默认开启,可独立关闭并保存发现预算', () => {
|
||||||
|
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
||||||
|
const capabilities = { ...fixture.componentInstance.config };
|
||||||
|
fixture.detectChanges();
|
||||||
|
http.expectOne('/api/system/capability-settings').flush(capabilities);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const root: HTMLElement = fixture.nativeElement;
|
||||||
|
expect(root.textContent).toContain('自动连接设备');
|
||||||
|
expect(fixture.componentInstance.config.adb_auto_connect).toBeTrue();
|
||||||
|
fixture.componentInstance.config.adb_auto_connect = false;
|
||||||
|
fixture.componentInstance.config.adb_discovery_timeout_seconds = 8;
|
||||||
|
fixture.componentInstance.save();
|
||||||
|
const request = http.expectOne('/api/system/capability-settings');
|
||||||
|
expect(request.request.body.adb_auto_connect).toBeFalse();
|
||||||
|
expect(request.request.body.adb_discovery_timeout_seconds).toBe(8);
|
||||||
|
expect(request.request.body.recording_enabled).toBeFalse();
|
||||||
|
request.flush(request.request.body);
|
||||||
|
fixture.componentInstance.config.adb_discovery_timeout_seconds = 0;
|
||||||
|
fixture.componentInstance.save();
|
||||||
|
http.expectNone('/api/system/capability-settings');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -10,7 +10,13 @@ interface ModelSettings {
|
|||||||
timeout_seconds: number;
|
timeout_seconds: number;
|
||||||
max_retries: number;
|
max_retries: number;
|
||||||
retry_interval_seconds: number;
|
retry_interval_seconds: number;
|
||||||
|
rate_limit_retry_seconds: number;
|
||||||
|
tool_fallback_model: string;
|
||||||
|
tool_fallback_reasoning_effort: string;
|
||||||
|
tool_fallback_max_retries: number;
|
||||||
|
tool_fallback_no_progress_attempts: number;
|
||||||
reasoning_effort: string;
|
reasoning_effort: string;
|
||||||
|
model_presets: string[];
|
||||||
env_path: string;
|
env_path: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
@@ -24,7 +30,7 @@ interface ModelSettings {
|
|||||||
<p>统一使用 OpenAI Responses。保存后新任务立即生效,重启后自动加载。</p>
|
<p>统一使用 OpenAI Responses。保存后新任务立即生效,重启后自动加载。</p>
|
||||||
<label for="model-base-url">接口地址</label>
|
<label for="model-base-url">接口地址</label>
|
||||||
<input id="model-base-url" name="baseUrl" type="url" [(ngModel)]="baseUrl" required
|
<input id="model-base-url" name="baseUrl" type="url" [(ngModel)]="baseUrl" required
|
||||||
placeholder="https://api.openai.com/v1" [disabled]="busy() || loading()" />
|
placeholder="https://api-n-cd.playones.com/v1" [disabled]="busy() || loading()" />
|
||||||
<small>填写接口根地址,通常包含 /v1,无需添加 /responses。</small>
|
<small>填写接口根地址,通常包含 /v1,无需添加 /responses。</small>
|
||||||
<label for="model-name">模型名称</label>
|
<label for="model-name">模型名称</label>
|
||||||
<div class="model-presets" role="group" aria-label="预设模型">
|
<div class="model-presets" role="group" aria-label="预设模型">
|
||||||
@@ -55,6 +61,30 @@ interface ModelSettings {
|
|||||||
[(ngModel)]="retries" [disabled]="busy() || loading()" /></label>
|
[(ngModel)]="retries" [disabled]="busy() || loading()" /></label>
|
||||||
<label>重试间隔(秒)<input name="retryInterval" type="number" min="0" max="300" required [(ngModel)]="retryInterval" [disabled]="busy() || loading()" /></label>
|
<label>重试间隔(秒)<input name="retryInterval" type="number" min="0" max="300" required [(ngModel)]="retryInterval" [disabled]="busy() || loading()" /></label>
|
||||||
</div>
|
</div>
|
||||||
|
<details class="recovery-settings">
|
||||||
|
<summary>稳定性与兜底模型</summary>
|
||||||
|
<div class="recovery-content">
|
||||||
|
<label>429 等待重试(秒)<input name="rateLimitRetry" type="number" min="0" max="3600" required
|
||||||
|
[(ngModel)]="rateLimitRetry" [disabled]="busy() || loading()" /></label>
|
||||||
|
<label>兜底模型名称<input name="toolFallbackModel" type="text" required autocomplete="off"
|
||||||
|
autocapitalize="off" spellcheck="false" [(ngModel)]="toolFallbackModel"
|
||||||
|
[disabled]="busy() || loading()" /></label>
|
||||||
|
<label>兜底思考强度<select name="toolFallbackReasoning" [(ngModel)]="toolFallbackReasoning"
|
||||||
|
[disabled]="busy() || loading()">
|
||||||
|
<option value="default">由模型默认</option><option value="none">关闭(none)</option>
|
||||||
|
<option value="minimal">最低(minimal)</option><option value="low">低(low)</option>
|
||||||
|
<option value="medium">中(medium)</option><option value="high">高(high)</option>
|
||||||
|
<option value="xhigh">极高(xhigh)</option><option value="max">最高(max)</option>
|
||||||
|
</select></label>
|
||||||
|
<div class="number-fields">
|
||||||
|
<label>兜底请求重试次数<input name="toolFallbackRetries" type="number" min="0" max="10" required
|
||||||
|
[(ngModel)]="toolFallbackRetries" [disabled]="busy() || loading()" /></label>
|
||||||
|
<label>兜底无效调用上限<input name="toolFallbackNoProgress" type="number" min="1" max="10" required
|
||||||
|
[(ngModel)]="toolFallbackNoProgress" [disabled]="busy() || loading()" /></label>
|
||||||
|
</div>
|
||||||
|
<small>首选模型连续 3 次未形成有效工具调用后,本步骤使用兜底模型;下一步恢复首选模型。</small>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit" [disabled]="busy() || loading() || !valid()">{{ busy() === 'save' ? '正在保存…' : '保存配置' }}</button>
|
<button type="submit" [disabled]="busy() || loading() || !valid()">{{ busy() === 'save' ? '正在保存…' : '保存配置' }}</button>
|
||||||
<button type="button" (click)="test()" [disabled]="busy() || loading() || !valid()">
|
<button type="button" (click)="test()" [disabled]="busy() || loading() || !valid()">
|
||||||
@@ -76,6 +106,10 @@ interface ModelSettings {
|
|||||||
input:focus, select:focus { outline: 2px solid #38bdf8; }
|
input:focus, select:focus { outline: 2px solid #38bdf8; }
|
||||||
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 8px; }
|
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 8px; }
|
||||||
.number-fields input { margin-top: 8px; }
|
.number-fields input { margin-top: 8px; }
|
||||||
|
.recovery-settings { border: 1px solid #cbd5e1; border-radius: 10px; background: #f8fafc; padding: 10px 14px; }
|
||||||
|
.recovery-settings summary { cursor: pointer; font-weight: 600; color: #334155; }
|
||||||
|
.recovery-content { display: grid; gap: 10px; padding-top: 12px; }
|
||||||
|
.recovery-content label input, .recovery-content label select { margin-top: 8px; }
|
||||||
.actions { display: flex; gap: 12px; margin-top: 8px; }
|
.actions { display: flex; gap: 12px; margin-top: 8px; }
|
||||||
button { padding: 11px 20px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; cursor: pointer; font: inherit; }
|
button { padding: 11px 20px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; cursor: pointer; font: inherit; }
|
||||||
button:disabled { opacity: .5; cursor: default; } .success { color: #047857; } .error { color: #be123c; }
|
button:disabled { opacity: .5; cursor: default; } .success { color: #047857; } .error { color: #be123c; }
|
||||||
@@ -90,8 +124,10 @@ interface ModelSettings {
|
|||||||
export class ModelSettingsComponent implements OnInit {
|
export class ModelSettingsComponent implements OnInit {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
saved = output<void>();
|
saved = output<void>();
|
||||||
readonly modelPresets = ['gpt-5.6-luna', 'mimo-v2.6-flash'];
|
modelPresets: string[] = [];
|
||||||
baseUrl = ''; model = ''; apiKey = ''; timeout = 300; retries = 5; retryInterval = 10; reasoning = 'high';
|
baseUrl = ''; model = ''; apiKey = ''; timeout = 300; retries = 5; retryInterval = 10; reasoning = 'high';
|
||||||
|
rateLimitRetry = 60; toolFallbackModel = 'gpt-6-luna'; toolFallbackReasoning = 'high';
|
||||||
|
toolFallbackRetries = 3; toolFallbackNoProgress = 3;
|
||||||
busy = signal<'save' | 'test' | null>(null);
|
busy = signal<'save' | 'test' | null>(null);
|
||||||
loading = signal(true); keyConfigured = signal(false); envPath = signal('');
|
loading = signal(true); keyConfigured = signal(false); envPath = signal('');
|
||||||
message = signal(''); error = signal('');
|
message = signal(''); error = signal('');
|
||||||
@@ -104,15 +140,27 @@ export class ModelSettingsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
valid(): boolean {
|
valid(): boolean {
|
||||||
return !!this.baseUrl.trim() && !!this.model.trim() && (!!this.apiKey.trim() || this.keyConfigured())
|
return !!this.baseUrl.trim() && !!this.model.trim() && (!!this.apiKey.trim() || this.keyConfigured())
|
||||||
&& this.timeout > 0 && this.timeout <= 3600 && Number.isInteger(this.retries) && this.retries >= 0 && this.retries <= 10 && this.retryInterval >= 0 && this.retryInterval <= 300;
|
&& this.timeout > 0 && this.timeout <= 3600 && Number.isInteger(this.retries) && this.retries >= 0 && this.retries <= 10 && this.retryInterval >= 0 && this.retryInterval <= 300
|
||||||
|
&& this.rateLimitRetry >= 0 && this.rateLimitRetry <= 3600 && !!this.toolFallbackModel.trim()
|
||||||
|
&& Number.isInteger(this.toolFallbackRetries) && this.toolFallbackRetries >= 0 && this.toolFallbackRetries <= 10
|
||||||
|
&& Number.isInteger(this.toolFallbackNoProgress) && this.toolFallbackNoProgress >= 1 && this.toolFallbackNoProgress <= 10;
|
||||||
}
|
}
|
||||||
private payload() {
|
private payload() {
|
||||||
return { base_url: this.baseUrl.trim(), model: this.model.trim(), api_key: this.apiKey.trim() || undefined,
|
return { base_url: this.baseUrl.trim(), model: this.model.trim(), api_key: this.apiKey.trim() || undefined,
|
||||||
timeout_seconds: this.timeout, max_retries: this.retries, retry_interval_seconds: this.retryInterval, reasoning_effort: this.reasoning };
|
timeout_seconds: this.timeout, max_retries: this.retries, retry_interval_seconds: this.retryInterval, reasoning_effort: this.reasoning,
|
||||||
|
rate_limit_retry_seconds: this.rateLimitRetry, tool_fallback_model: this.toolFallbackModel.trim(),
|
||||||
|
tool_fallback_reasoning_effort: this.toolFallbackReasoning, tool_fallback_max_retries: this.toolFallbackRetries,
|
||||||
|
tool_fallback_no_progress_attempts: this.toolFallbackNoProgress };
|
||||||
}
|
}
|
||||||
private apply(config: ModelSettings): void {
|
private apply(config: ModelSettings): void {
|
||||||
this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds;
|
this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds;
|
||||||
|
this.modelPresets = config.model_presets;
|
||||||
this.retries = config.max_retries; this.retryInterval = config.retry_interval_seconds ?? 10; this.reasoning = config.reasoning_effort ?? 'high'; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path);
|
this.retries = config.max_retries; this.retryInterval = config.retry_interval_seconds ?? 10; this.reasoning = config.reasoning_effort ?? 'high'; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path);
|
||||||
|
this.rateLimitRetry = config.rate_limit_retry_seconds ?? 60;
|
||||||
|
this.toolFallbackModel = config.tool_fallback_model ?? 'gpt-6-luna';
|
||||||
|
this.toolFallbackReasoning = config.tool_fallback_reasoning_effort ?? 'high';
|
||||||
|
this.toolFallbackRetries = config.tool_fallback_max_retries ?? 3;
|
||||||
|
this.toolFallbackNoProgress = config.tool_fallback_no_progress_attempts ?? 3;
|
||||||
}
|
}
|
||||||
save(): void {
|
save(): void {
|
||||||
if (this.busy() || !this.valid()) return;
|
if (this.busy() || !this.valid()) return;
|
||||||
|
|||||||
@@ -43,6 +43,17 @@
|
|||||||
border-color: rgba(203, 213, 225, 0.9);
|
border-color: rgba(203, 213, 225, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-version {
|
||||||
|
padding: 0 10px 0 8px;
|
||||||
|
border-left: 1px solid #e2e8f0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-tab-btn {
|
.nav-tab-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||||
|
|
||||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
|
import { version } from '../../../../package.json';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-nav-switcher',
|
selector: 'app-nav-switcher',
|
||||||
@@ -43,9 +44,12 @@ import { RouterLink, RouterLinkActive } from '@angular/router';
|
|||||||
<span class="material-symbols-outlined tab-icon">space_dashboard</span>
|
<span class="material-symbols-outlined tab-icon">space_dashboard</span>
|
||||||
<span class="tab-label">工作区</span>
|
<span class="tab-label">工作区</span>
|
||||||
</a>
|
</a>
|
||||||
|
<span class="app-version" [title]="'ARTEMIS 版本 ' + version" [attr.aria-label]="'ARTEMIS 版本 ' + version">v{{version}}</span>
|
||||||
</nav>
|
</nav>
|
||||||
`,
|
`,
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
changeDetection: ChangeDetectionStrategy.Eager,
|
||||||
styleUrls: ['./nav-switcher.component.scss']
|
styleUrls: ['./nav-switcher.component.scss']
|
||||||
})
|
})
|
||||||
export class NavSwitcherComponent {}
|
export class NavSwitcherComponent {
|
||||||
|
readonly version = version;
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
import { TaskBatchFilterComponent } from './task-batch-filter.component';
|
||||||
|
|
||||||
|
describe('队列批次筛选', () => {
|
||||||
|
it('支持全部批量、单独任务及具体批次,可清除筛选', fakeAsync(() => {
|
||||||
|
const agent = {batchFilter:signal('batch-1')};
|
||||||
|
TestBed.configureTestingModule({providers:[provideHttpClient(),provideHttpClientTesting(),{provide:AgentService,useValue:agent}]});
|
||||||
|
const fixture = TestBed.createComponent(TaskBatchFilterComponent);
|
||||||
|
const http = TestBed.inject(HttpTestingController);
|
||||||
|
http.expectOne('/api/automation/batches').flush([{id:'batch-1',name:'第一轮冒烟',created_at:100},{id:'batch-2',name:'第二轮冒烟',created_at:200}]);
|
||||||
|
fixture.detectChanges(); tick(); fixture.detectChanges();
|
||||||
|
const select: HTMLSelectElement = fixture.nativeElement.querySelector('select');
|
||||||
|
expect(select.value).toBe('batch-1'); expect(select.options.length).toBe(5);
|
||||||
|
for(const value of ['__batch__','__single__','batch-2']){
|
||||||
|
select.value=value; select.dispatchEvent(new Event('change')); tick(); fixture.detectChanges();
|
||||||
|
expect(agent.batchFilter()).toBe(value);
|
||||||
|
}
|
||||||
|
fixture.nativeElement.querySelector('button').click(); fixture.detectChanges();
|
||||||
|
expect(agent.batchFilter()).toBe('');
|
||||||
|
fixture.destroy(); http.verify();
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Component, DestroyRef, inject, signal } from '@angular/core';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-task-batch-filter', standalone: true, imports: [FormsModule, DatePipe],
|
||||||
|
template: `<div class="batch-filter"><label>任务范围<select aria-label="按批次筛选任务" [ngModel]="agent.batchFilter()" (ngModelChange)="agent.batchFilter.set($event)">
|
||||||
|
<option value="">全部任务</option><option value="__batch__">全部批量任务</option><option value="__single__">单独任务</option>
|
||||||
|
@for(batch of batches(); track batch.id){<option [value]="batch.id">{{batch.name}} · {{batch.created_at * 1000 | date:'MM-dd HH:mm'}}</option>}
|
||||||
|
@if(agent.batchFilter() && !['__batch__','__single__'].includes(agent.batchFilter()) && !hasSelectedBatch()){<option [value]="agent.batchFilter()">当前选定批次</option>}
|
||||||
|
</select></label>@if(agent.batchFilter()){<button type="button" (click)="agent.batchFilter.set('')">清除筛选</button>}</div>
|
||||||
|
@if(error()){<div class="filter-error" role="alert">{{error()}} <button type="button" (click)="load()">重试</button></div>}`,
|
||||||
|
styles: [`:host{display:block;margin:0 0 12px}.batch-filter{display:flex;align-items:flex-end;gap:8px;color:#475569;font-size:12px}label{flex:1;min-width:0}select{display:block;box-sizing:border-box;width:100%;height:34px;margin-top:5px;border:1px solid #cbd5e1;border-radius:7px;padding:0 9px;background:#fff;color:#1e293b;font:inherit}option{background:#fff;color:#1e293b}button{height:34px;white-space:nowrap;border:1px solid #cbd5e1;border-radius:7px;padding:0 9px;background:#fff;color:#475569;font:inherit;cursor:pointer}select:focus-visible,button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}.filter-error{font-size:12px;color:#b91c1c;margin-top:5px}.filter-error button{height:auto;padding:2px 6px}`]
|
||||||
|
})
|
||||||
|
export class TaskBatchFilterComponent {
|
||||||
|
agent = inject(AgentService); private http = inject(HttpClient);
|
||||||
|
batches = signal<{id: string; name: string; created_at: number}[]>([]); error = signal('');
|
||||||
|
private destroyed = false;
|
||||||
|
constructor() {
|
||||||
|
this.load();
|
||||||
|
const timer = setInterval(() => this.load(), 15000);
|
||||||
|
inject(DestroyRef).onDestroy(() => { this.destroyed = true; clearInterval(timer); });
|
||||||
|
}
|
||||||
|
hasSelectedBatch() { return this.batches().some(batch => batch.id === this.agent.batchFilter()); }
|
||||||
|
load() {
|
||||||
|
this.http.get<{id: string; name: string; created_at: number}[]>('/api/automation/batches').subscribe({
|
||||||
|
next: batches => { if (!this.destroyed) { this.batches.set(batches); this.error.set(''); } },
|
||||||
|
error: () => { if (!this.destroyed) this.error.set('批次列表加载失败'); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { signal } from '@angular/core';
|
import { signal } from '@angular/core';
|
||||||
|
import { Clipboard } from '@angular/cdk/clipboard';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
@@ -9,11 +10,63 @@ describe('历史任务检索', () => {
|
|||||||
let http: HttpTestingController;
|
let http: HttpTestingController;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting(),
|
TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting(),
|
||||||
{provide: AgentService, useValue: {currentSessionId: signal(null), selectSession: jasmine.createSpy()}}]});
|
{provide: AgentService, useValue: {currentSessionId: signal(null), batchFilter: signal(''), resultRevision: signal(0), selectSession: jasmine.createSpy()}}]});
|
||||||
http = TestBed.inject(HttpTestingController);
|
http = TestBed.inject(HttpTestingController);
|
||||||
});
|
});
|
||||||
afterEach(() => http.verify());
|
afterEach(() => http.verify());
|
||||||
|
|
||||||
|
it('更换批次时保留搜索条件并回到第一页,复验后立即刷新', () => {
|
||||||
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
|
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.componentInstance.query = '设置'; fixture.componentInstance.page = 3;
|
||||||
|
const agent = TestBed.inject(AgentService);
|
||||||
|
agent.batchFilter.set('batch-2'); fixture.detectChanges();
|
||||||
|
const filtered = http.expectOne(r => r.params.get('batch_id') === 'batch-2');
|
||||||
|
expect(filtered.request.params.get('page')).toBe('1');
|
||||||
|
expect(filtered.request.params.get('q')).toBe('设置');
|
||||||
|
filtered.flush({total:0,items:[]});
|
||||||
|
agent.resultRevision.update(value => value + 1); fixture.detectChanges();
|
||||||
|
http.expectOne(r => r.params.get('batch_id') === 'batch-2').flush({total:0,items:[]});
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('执行完成的任务仍能显示测试不通过,旧任务不会自动显示通过', () => {
|
||||||
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
|
http.expectOne(r => r.url === '/api/automation/history').flush({total: 3, items: [
|
||||||
|
{session_id:'failed-test', name:'预期校验', status:'completed', start_time:100, test_result:{status:'failed', reason:'预期出现成功,实际出现错误', evidence:['步骤 3 显示错误']}},
|
||||||
|
{session_id:'passed-test', name:'已验收', status:'completed', start_time:100, test_result:{status:'passed', reason:'实际与预期一致', evidence:['步骤 3 显示成功']}},
|
||||||
|
{session_id:'old-task', name:'旧任务', status:'completed', start_time:100}
|
||||||
|
]});
|
||||||
|
fixture.detectChanges();
|
||||||
|
const rows = fixture.nativeElement.querySelectorAll('button.row');
|
||||||
|
expect(rows[0].textContent).toContain('执行完成');
|
||||||
|
expect(rows[0].querySelector('app-test-result-badge').textContent).toContain('测试不通过');
|
||||||
|
expect(rows[0].querySelector('.test-result-badge').title).toContain('实际出现错误');
|
||||||
|
expect(rows[1].querySelector('app-test-result-badge').textContent).toContain('测试通过');
|
||||||
|
expect(rows[2].querySelector('app-test-result-badge').textContent).toContain('未验证');
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('历史 ID 独立于打开任务按钮,点击复制不会打开任务', async () => {
|
||||||
|
spyOn(navigator.clipboard, 'writeText').and.rejectWith(new Error('blocked'));
|
||||||
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
|
http.expectOne(r => r.url === '/api/automation/history').flush({total: 1, items: [
|
||||||
|
{session_id:'completed-task', name:'已完成任务', status:'completed', start_time:Date.now()/1000}
|
||||||
|
]});
|
||||||
|
const copy = spyOn(TestBed.inject(Clipboard), 'copy').and.returnValue(true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const id: HTMLElement = fixture.nativeElement.querySelector('app-task-id-copy [role="button"]');
|
||||||
|
expect(id.closest('button.row')).toBeNull();
|
||||||
|
id.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(copy).toHaveBeenCalledOnceWith('completed-task');
|
||||||
|
expect(TestBed.inject(AgentService).selectSession).not.toHaveBeenCalled();
|
||||||
|
fixture.nativeElement.querySelector('button.row').click();
|
||||||
|
expect(TestBed.inject(AgentService).selectSession).toHaveBeenCalledWith('completed-task', true);
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
it('迟到的旧查询不会覆盖最新搜索结果', () => {
|
it('迟到的旧查询不会覆盖最新搜索结果', () => {
|
||||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
const first = http.expectOne(r => r.url === '/api/automation/history');
|
const first = http.expectOne(r => r.url === '/api/automation/history');
|
||||||
@@ -29,6 +82,21 @@ describe('历史任务检索', () => {
|
|||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('显示明确的结果、完成时间与总耗时,标题悬停显示完整提示词', () => {
|
||||||
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
|
http.expectOne(r => r.url === '/api/automation/history').flush({total: 1, items: [
|
||||||
|
{session_id:'case-1', name:'1.1 打开并核对设置', initial_goal:'请打开设置首页并核对两个真实分类', status:'failed', start_time:100, end_time:165, error:'任务执行超时(60 秒)'}
|
||||||
|
]});
|
||||||
|
fixture.detectChanges();
|
||||||
|
const row: HTMLButtonElement = fixture.nativeElement.querySelector('button.row');
|
||||||
|
expect(row.getAttribute('title')).toBe('请打开设置首页并核对两个真实分类');
|
||||||
|
expect(row.textContent).toContain('1.1 打开并核对设置');
|
||||||
|
expect(row.textContent).toContain('总耗时 1分5秒');
|
||||||
|
expect(row.textContent).toContain('任务执行超时');
|
||||||
|
expect(fixture.nativeElement.querySelector('.status-failed .history-status').textContent).toContain('失败');
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
it('日期筛选用本地零点到次日零点,后端分页参数独立传递', () => {
|
it('日期筛选用本地零点到次日零点,后端分页参数独立传递', () => {
|
||||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
|
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
|
||||||
|
|||||||
@@ -1,37 +1,52 @@
|
|||||||
import { Component, DestroyRef, inject, signal } from '@angular/core';
|
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||||
|
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
||||||
|
import { Component, DestroyRef, effect, inject, signal, untracked } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { AgentService } from '../../services/agent.service';
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule],
|
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent],
|
||||||
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small></h3>
|
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small></h3>
|
||||||
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
|
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
|
||||||
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
|
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
|
||||||
@if(error()){<p role="alert">{{error()}}</p>}
|
@if(error()){<p role="alert">{{error()}}</p>}
|
||||||
@if(loading()){<small>正在查询…</small>}
|
@if(loading()){<small>正在查询…</small>}
|
||||||
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
|
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
|
||||||
@for(row of group.rows; track row.session_id){<div class="history-item"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)"><strong>{{row.name || row.initial_goal}}</strong><span>{{label(row.status)}} · {{row.start_time*1000 | date:'HH:mm:ss'}} · {{row.tags}}</span><small>{{row.session_id}}</small></button><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
|
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
|
||||||
</details>}
|
</details>}
|
||||||
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
|
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
|
||||||
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
|
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
|
||||||
</section>`,
|
</section>`,
|
||||||
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:4px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:500}.row span{opacity:.75}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative}.history-item .row{padding-right:35px}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
|
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;line-height:14px;border-radius:5px;padding:0 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
|
||||||
})
|
})
|
||||||
export class TaskHistoryComponent {
|
export class TaskHistoryComponent {
|
||||||
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
|
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
|
||||||
total=signal(0);groups=signal<any[]>([]);loading=signal(false);error=signal('');collapsed=new Set<string>();
|
total=signal(0);groups=signal<any[]>([]);loading=signal(false);error=signal('');collapsed=new Set<string>();
|
||||||
private revision=0;private debounce: ReturnType<typeof setTimeout>|undefined;
|
private revision=0;private debounce: ReturnType<typeof setTimeout>|undefined;
|
||||||
constructor(){this.load();const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});}
|
constructor(){
|
||||||
|
let filter=this.agent.batchFilter(), resultRevision=this.agent.resultRevision();
|
||||||
|
this.load();
|
||||||
|
effect(()=>{
|
||||||
|
const nextFilter=this.agent.batchFilter(), nextRevision=this.agent.resultRevision();
|
||||||
|
if(nextFilter!==filter || nextRevision!==resultRevision){
|
||||||
|
if(nextFilter!==filter){this.page=1;clearTimeout(this.debounce);}
|
||||||
|
filter=nextFilter;resultRevision=nextRevision;untracked(()=>this.load());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});
|
||||||
|
}
|
||||||
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});}
|
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});}
|
||||||
pages(){return Math.max(1,Math.ceil(this.total()/30));}
|
pages(){return Math.max(1,Math.ceil(this.total()/30));}
|
||||||
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已取消',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
|
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已终止',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
|
||||||
|
duration(start:number,end?:number){if(!start||!end)return '—';const seconds=Math.max(0,Math.floor(end-start));const hours=Math.floor(seconds/3600);const minutes=Math.floor((seconds%3600)/60);return hours?`${hours}时${minutes}分`:minutes?`${minutes}分${seconds%60}秒`:`${seconds}秒`;}
|
||||||
toggle(key:string,event:Event){if((event.target as HTMLDetailsElement).open)this.collapsed.delete(key);else this.collapsed.add(key);}
|
toggle(key:string,event:Event){if((event.target as HTMLDetailsElement).open)this.collapsed.delete(key);else this.collapsed.add(key);}
|
||||||
search(){this.page=1;this.revision++;clearTimeout(this.debounce);this.debounce=setTimeout(()=>this.load(),250);}
|
search(){this.page=1;this.revision++;clearTimeout(this.debounce);this.debounce=setTimeout(()=>this.load(),250);}
|
||||||
load(){
|
load(){
|
||||||
const revision=++this.revision;this.loading.set(true);this.error.set('');
|
const revision=++this.revision;this.loading.set(true);this.error.set('');
|
||||||
let params=new HttpParams().set('q',this.query).set('page',this.page).set('status',this.status);
|
let params=new HttpParams().set('q',this.query).set('page',this.page).set('status',this.status);
|
||||||
|
if(this.agent.batchFilter())params=params.set('batch_id',this.agent.batchFilter());
|
||||||
if(this.start)params=params.set('start',new Date(this.start+'T00:00:00').getTime()/1000);
|
if(this.start)params=params.set('start',new Date(this.start+'T00:00:00').getTime()/1000);
|
||||||
if(this.end){const d=new Date(this.end+'T00:00:00');d.setDate(d.getDate()+1);params=params.set('end',d.getTime()/1000);}
|
if(this.end){const d=new Date(this.end+'T00:00:00');d.setDate(d.getDate()+1);params=params.set('end',d.getTime()/1000);}
|
||||||
this.http.get<any>('/api/automation/history',{params}).subscribe({next:r=>{
|
this.http.get<any>('/api/automation/history',{params}).subscribe({next:r=>{
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Clipboard } from '@angular/cdk/clipboard';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { TaskIdCopyComponent } from './task-id-copy.component';
|
||||||
|
|
||||||
|
describe('任务 ID 复制', () => {
|
||||||
|
let clipboard: jasmine.SpyObj<Clipboard>;
|
||||||
|
beforeEach(() => {
|
||||||
|
spyOn(navigator.clipboard, 'writeText').and.rejectWith(new Error('blocked'));
|
||||||
|
clipboard = jasmine.createSpyObj('Clipboard', ['copy']);
|
||||||
|
TestBed.configureTestingModule({ providers: [{ provide: Clipboard, useValue: clipboard }] });
|
||||||
|
});
|
||||||
|
afterEach(() => window.getSelection()?.removeAllRanges());
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const fixture = TestBed.createComponent(TaskIdCopyComponent);
|
||||||
|
fixture.componentRef.setInput('taskId', 'task-123');
|
||||||
|
fixture.detectChanges();
|
||||||
|
const target: HTMLElement = fixture.nativeElement.querySelector('[role="button"]');
|
||||||
|
return { fixture, target };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('点击复制完整 ID,并提示成功,不触发父卡片', async () => {
|
||||||
|
clipboard.copy.and.returnValue(true);
|
||||||
|
const { fixture, target } = setup();
|
||||||
|
const parentClick = jasmine.createSpy('parentClick');
|
||||||
|
fixture.nativeElement.addEventListener('click', parentClick);
|
||||||
|
target.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(clipboard.copy).toHaveBeenCalledOnceWith('task-123');
|
||||||
|
expect(fixture.nativeElement.querySelector('[role="status"]').textContent).toContain('已复制');
|
||||||
|
expect(parentClick).not.toHaveBeenCalled();
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('优先使用浏览器剪贴板,并在写入完成后提示成功', async () => {
|
||||||
|
const write = navigator.clipboard.writeText as jasmine.Spy;
|
||||||
|
let complete!: () => void;
|
||||||
|
write.and.returnValue(new Promise<void>(resolve => complete = resolve));
|
||||||
|
const { fixture, target } = setup();
|
||||||
|
target.click();
|
||||||
|
expect(fixture.componentInstance.feedback()).toBe('');
|
||||||
|
complete();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(write).toHaveBeenCalledOnceWith('task-123');
|
||||||
|
expect(clipboard.copy).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.componentInstance.feedback()).toBe('任务 ID 已复制');
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制失败不会提示成功,ID 仍可手动选择', async () => {
|
||||||
|
clipboard.copy.and.returnValue(false);
|
||||||
|
const { fixture, target } = setup();
|
||||||
|
target.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('复制失败,请选中任务 ID 手动复制');
|
||||||
|
expect(getComputedStyle(target.querySelector('.value')!).userSelect).toBe('text');
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拖选 ID 后的点击不会覆盖剪贴板或清空选区', () => {
|
||||||
|
const { fixture, target } = setup();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(target.querySelector('.value')!);
|
||||||
|
const selection = window.getSelection()!;
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
target.click();
|
||||||
|
expect(clipboard.copy).not.toHaveBeenCalled();
|
||||||
|
expect(selection.toString()).toContain('task-123');
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('支持键盘 Enter 复制', async () => {
|
||||||
|
clipboard.copy.and.returnValue(true);
|
||||||
|
const { fixture, target } = setup();
|
||||||
|
target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(clipboard.copy).toHaveBeenCalledOnceWith('task-123');
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Clipboard } from '@angular/cdk/clipboard';
|
||||||
|
import { Component, DestroyRef, Input, inject, signal } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-task-id-copy',
|
||||||
|
standalone: true,
|
||||||
|
template: `
|
||||||
|
<span class="copy-target" role="button" tabindex="0"
|
||||||
|
[attr.aria-label]="'复制任务 ID:' + taskId" title="点击复制任务 ID,也可选中文字复制"
|
||||||
|
(click)="copy($event)" (keydown.enter)="copy($event)" (keydown.space)="copy($event)">
|
||||||
|
<span class="value">ID: {{ taskId }}</span>
|
||||||
|
<span class="material-symbols-outlined" aria-hidden="true">content_copy</span>
|
||||||
|
</span>
|
||||||
|
<span class="feedback" role="status" aria-live="polite">{{ feedback() }}</span>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
:host { display: block; min-width: 0; font-size: 11px; color: inherit; user-select: text; }
|
||||||
|
.copy-target { display: inline-flex; align-items: center; gap: 5px; max-width: 100%;
|
||||||
|
padding: 3px 0; cursor: pointer; user-select: text; border-radius: 3px; }
|
||||||
|
.value { min-width: 0; overflow-wrap: anywhere; user-select: text; }
|
||||||
|
.material-symbols-outlined { flex-shrink: 0; font-size: 14px; user-select: none; }
|
||||||
|
.copy-target:hover { color: #2563eb; }
|
||||||
|
.copy-target:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; }
|
||||||
|
.feedback { display: block; line-height: 1.6; user-select: text; }
|
||||||
|
.feedback:empty { display: none; }
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class TaskIdCopyComponent {
|
||||||
|
@Input({ required: true }) taskId = '';
|
||||||
|
readonly feedback = signal('');
|
||||||
|
private readonly clipboard = inject(Clipboard);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.destroyRef.onDestroy(() => clearTimeout(this.timer));
|
||||||
|
}
|
||||||
|
|
||||||
|
async copy(event: Event): Promise<void> {
|
||||||
|
event.stopPropagation();
|
||||||
|
// A drag selection must remain available for the browser's normal copy action.
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (event.type === 'click' && selection && !selection.isCollapsed
|
||||||
|
&& (event.currentTarget as HTMLElement).contains(selection.anchorNode)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
let copied = false;
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(this.taskId);
|
||||||
|
copied = true;
|
||||||
|
} else {
|
||||||
|
copied = this.clipboard.copy(this.taskId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
copied = this.clipboard.copy(this.taskId);
|
||||||
|
} catch {
|
||||||
|
// Keep manual selection available when the browser blocks both clipboard APIs.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.destroyRef.destroyed) return;
|
||||||
|
this.feedback.set(copied ? '任务 ID 已复制' : '复制失败,请选中任务 ID 手动复制');
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = setTimeout(() => this.feedback.set(''), 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Component, computed, input } from '@angular/core';
|
||||||
|
import { CaseTestResult } from '../../core/models/session.model';
|
||||||
|
|
||||||
|
export const TEST_RESULT_LABELS: Record<CaseTestResult['status'], string> = {
|
||||||
|
passed: '测试通过', failed: '测试不通过', inconclusive: '未验证', blocked: '测试阻塞'
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-test-result-badge',
|
||||||
|
standalone: true,
|
||||||
|
template: `<span [class]="'test-result-badge ' + status()" [class.compact]="compact()" [class.prominent]="prominent()" [title]="(result()?.source === 'human' ? '人工确认:' : 'AI确认:') + (result()?.reason || '执行状态与测试结论分别记录')"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
|
||||||
|
styles: [`
|
||||||
|
:host{display:inline-flex;vertical-align:middle;max-width:100%}
|
||||||
|
.test-result-badge{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;border-radius:6px;border:1px solid #cbd5e1;padding:3px 8px;font-size:12px;font-weight:700;line-height:1.5;background:#f1f5f9;color:#475569}
|
||||||
|
.material-symbols-outlined{font-size:16px;line-height:1}
|
||||||
|
.passed{background:#15803d;color:#fff;border-color:#15803d}
|
||||||
|
.failed{background:#b91c1c;color:#fff;border-color:#b91c1c}
|
||||||
|
.inconclusive{background:#fff7ed;color:#9a3412;border-color:#fdba74}
|
||||||
|
.blocked{background:#fef3c7;color:#92400e;border-color:#fbbf24}
|
||||||
|
.compact{box-sizing:border-box;height:20px;padding:0 6px;font-size:11px;line-height:14px;gap:3px;border-radius:5px}
|
||||||
|
.compact .material-symbols-outlined{font-size:13px}
|
||||||
|
.prominent{padding:4px 9px;font-size:14px;gap:5px}
|
||||||
|
.prominent .material-symbols-outlined{font-size:18px}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class TestResultBadgeComponent {
|
||||||
|
result = input<CaseTestResult | null | undefined>();
|
||||||
|
executionStatus = input('');
|
||||||
|
compact = input(false);
|
||||||
|
prominent = input(false);
|
||||||
|
status = computed(() => this.result()?.status || (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()) ? 'inconclusive' : 'pending'));
|
||||||
|
label = computed(() => this.result()?.source === 'human'
|
||||||
|
? (this.status() === 'passed' ? '人工通过' : '人工不通过')
|
||||||
|
: this.status() === 'pending' ? '待判定' : TEST_RESULT_LABELS[this.status() as CaseTestResult['status']]);
|
||||||
|
icon = computed(() => ({passed: 'check_circle', failed: 'cancel', blocked: 'block', inconclusive: 'help', pending: 'hourglass_empty'})[this.status()]);
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestResultCardComponent } from './test-result-card.component';
|
||||||
|
|
||||||
|
describe('用例测试结果卡片', () => {
|
||||||
|
beforeEach(() => TestBed.configureTestingModule({providers:[provideHttpClient(), provideHttpClientTesting()]}));
|
||||||
|
afterEach(() => TestBed.inject(HttpTestingController).verify());
|
||||||
|
it('显式展示不通过及原因,允许展开证据', () => {
|
||||||
|
const fixture = TestBed.createComponent(TestResultCardComponent);
|
||||||
|
fixture.componentRef.setInput('result', {status: 'failed', reason: '预期英文,实际为中文。', evidence: ['截图显示搜索设置']});
|
||||||
|
fixture.detectChanges();
|
||||||
|
const view: HTMLElement = fixture.nativeElement;
|
||||||
|
expect(view.querySelector('[aria-label="用例测试结果"]')).not.toBeNull();
|
||||||
|
expect(view.querySelector('.prominent')?.textContent).toContain('测试不通过');
|
||||||
|
expect(view.querySelector('.reason')?.textContent).toContain('预期英文,实际为中文。');
|
||||||
|
const details = view.querySelector('details')!;
|
||||||
|
expect(details.open).toBeFalse();
|
||||||
|
details.querySelector('summary')!.click();
|
||||||
|
expect(details.open).toBeTrue();
|
||||||
|
expect(details.textContent).toContain('截图显示搜索设置');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('人工复验可覆盖 AI 判定,保留原始结论并提交备注', () => {
|
||||||
|
const fixture = TestBed.createComponent(TestResultCardComponent);
|
||||||
|
const ai = {status:'failed',reason:'AI发现不一致',evidence:['步骤 3 截图'],source:'ai'};
|
||||||
|
fixture.componentRef.setInput('result', ai);
|
||||||
|
fixture.componentRef.setInput('sessionId', 'task-1'); fixture.componentRef.setInput('executionStatus','completed');
|
||||||
|
const saved = jasmine.createSpy(); fixture.componentInstance.reviewSaved.subscribe(saved);
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.nativeElement.querySelector('.review-toolbar button').click(); fixture.detectChanges();
|
||||||
|
fixture.componentInstance.note = '实际符合预期,已人工核对';
|
||||||
|
fixture.nativeElement.querySelector('.confirm-pass').click(); fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.confirm-fail').disabled).toBeTrue();
|
||||||
|
const request = TestBed.inject(HttpTestingController).expectOne('/api/automation/reports/task-1/review');
|
||||||
|
expect(request.request.method).toBe('PUT');
|
||||||
|
expect(request.request.body).toEqual({status:'passed',reason:'实际符合预期,已人工核对'});
|
||||||
|
const manual = {status:'passed',reason:'实际符合预期,已人工核对',evidence:[],source:'human',reviewed_at:100,ai_result:ai};
|
||||||
|
request.flush({id:'task-1',test_result:manual,verdict:'测试通过(人工确认)'});
|
||||||
|
fixture.componentRef.setInput('result',manual); fixture.detectChanges();
|
||||||
|
expect(saved).toHaveBeenCalled();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('人工通过');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('原 AI 结论');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('AI发现不一致');
|
||||||
|
expect(fixture.nativeElement.querySelector('.review-form')).toBeNull();
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('保存失败时保留备注并允许重试,执行中不显示人工复验', () => {
|
||||||
|
const fixture = TestBed.createComponent(TestResultCardComponent);
|
||||||
|
fixture.componentRef.setInput('result',{status:'inconclusive',reason:'尚未验证',evidence:[]});
|
||||||
|
fixture.componentRef.setInput('sessionId','task-2'); fixture.componentRef.setInput('executionStatus','running');
|
||||||
|
fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.review-toolbar')).toBeNull();
|
||||||
|
fixture.componentRef.setInput('executionStatus','failed'); fixture.detectChanges();
|
||||||
|
fixture.nativeElement.querySelector('.review-toolbar button').click(); fixture.detectChanges();
|
||||||
|
fixture.componentInstance.note = '人工发现异常';
|
||||||
|
fixture.nativeElement.querySelector('.confirm-fail').click();
|
||||||
|
TestBed.inject(HttpTestingController).expectOne('/api/automation/reports/task-2/review').flush({detail:'报告正在打包'}, {status:409,statusText:'Conflict'});
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.componentInstance.note).toBe('人工发现异常');
|
||||||
|
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('报告正在打包');
|
||||||
|
expect(fixture.nativeElement.querySelector('.confirm-fail').disabled).toBeFalse();
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Component, computed, effect, inject, input, output, signal } from '@angular/core';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { CaseReviewResponse, CaseTestResult } from '../../core/models/session.model';
|
||||||
|
import { TestResultBadgeComponent } from './test-result-badge.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-test-result-card',
|
||||||
|
standalone: true,
|
||||||
|
imports: [TestResultBadgeComponent, DatePipe, FormsModule],
|
||||||
|
template: `<section [class]="'test-result-card ' + result().status" aria-label="用例测试结果" role="status">
|
||||||
|
<header><span class="caption">用例测试结果 <small>{{result().source === 'human' ? '人工确认' : 'AI确认'}}</small></span><app-test-result-badge [result]="result()" [prominent]="true" /></header>
|
||||||
|
<p class="reason">{{result().reason}}</p>
|
||||||
|
@if(result().reviewed_at){<p class="review-time">复验于 {{result().reviewed_at! * 1000 | date:'yyyy-MM-dd HH:mm:ss'}}</p>}
|
||||||
|
@if(result().ai_result; as original){<details><summary>原 AI 结论 · {{original.status === 'passed' ? '测试通过' : original.status === 'failed' ? '测试不通过' : original.status === 'blocked' ? '测试阻塞' : '未验证'}}</summary><p class="reason">{{original.reason}}</p>@if(original.evidence.length){<ol>@for(item of original.evidence; track $index){<li>{{item}}</li>}</ol>}</details>}
|
||||||
|
@if(result().evidence.length){<details><summary>查看验收证据 · {{result().evidence.length}} 条</summary><ol>@for(item of result().evidence; track $index){<li>{{item}}</li>}</ol></details>}
|
||||||
|
@if(canReview()){
|
||||||
|
<div class="review-toolbar"><button type="button" [disabled]="saving()" (click)="toggleReview()" [attr.aria-expanded]="reviewing()">{{result().source === 'human' ? '修改人工复验' : '人工复验'}}</button>@if(notice()){<span role="status">{{notice()}}</span>}</div>
|
||||||
|
@if(reviewing()){
|
||||||
|
<div class="review-form" aria-label="人工复验">
|
||||||
|
<label>复验备注(选填)<textarea [(ngModel)]="note" [disabled]="saving()" rows="2" maxlength="2000" placeholder="填写人工检查依据或发现的问题"></textarea></label>
|
||||||
|
<div class="review-actions"><button type="button" class="confirm-pass" [disabled]="saving()" (click)="saveReview('passed')">确认通过</button><button type="button" class="confirm-fail" [disabled]="saving()" (click)="saveReview('failed')">确认不通过</button><button type="button" [disabled]="saving()" (click)="reviewing.set(false)">取消</button>@if(saving()){<span role="status">正在保存…</span>}</div>
|
||||||
|
@if(error()){<p class="review-error" role="alert">{{error()}}</p>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</section>`,
|
||||||
|
styles: [`
|
||||||
|
:host{display:block;margin:8px 0}
|
||||||
|
.test-result-card{border:1px solid #cbd5e1;border-left:3px solid #64748b;border-radius:10px;padding:12px 16px;background:#f8fafc;color:#334155}
|
||||||
|
.passed{border-color:#bbf7d0;border-left-color:#15803d;background:#f0fdf4}
|
||||||
|
.failed{border-color:#fecaca;border-left-color:#b91c1c;background:#fef2f2}
|
||||||
|
.inconclusive,.blocked{border-color:#fde68a;border-left-color:#b45309;background:#fffbeb}
|
||||||
|
header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}
|
||||||
|
.caption{font-size:12px;font-weight:600;color:#475569}
|
||||||
|
.caption small{font-size:11px;font-weight:400;margin-left:7px;color:#64748b}
|
||||||
|
.reason{font-size:13px;line-height:1.7;margin:8px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;color:#1e293b}
|
||||||
|
details{margin-top:6px;font-size:12px;line-height:1.7;color:#475569}
|
||||||
|
summary{cursor:pointer;width:fit-content;padding:2px 0;font-weight:600}
|
||||||
|
ol{padding-left:22px;margin:8px 0 0}li{padding:3px 0;white-space:pre-wrap;overflow-wrap:anywhere}
|
||||||
|
.review-time{margin:5px 0 0;color:#64748b;font-size:11px}
|
||||||
|
.review-toolbar,.review-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;font-size:12px}
|
||||||
|
button{border:1px solid #cbd5e1;border-radius:6px;padding:4px 9px;background:#fff;color:#475569;font:inherit;cursor:pointer;line-height:1.5}
|
||||||
|
button:hover{border-color:#94a3b8;background:#f8fafc}button:disabled{opacity:.55;cursor:wait}button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}
|
||||||
|
.review-toolbar span{color:#15803d}.review-form{border-top:1px solid #cbd5e1;margin-top:10px;padding-top:10px;font-size:12px}
|
||||||
|
label{display:block;color:#475569}textarea{display:block;box-sizing:border-box;width:100%;resize:vertical;border:1px solid #cbd5e1;border-radius:6px;margin-top:5px;padding:7px 9px;color:#1e293b;background:#fff;font:inherit;line-height:1.6}
|
||||||
|
.confirm-pass{color:#166534;border-color:#86efac;background:#f0fdf4}.confirm-fail{color:#991b1b;border-color:#fca5a5;background:#fef2f2}.review-error{color:#b91c1c;margin:8px 0 0}
|
||||||
|
@media(max-width:480px){.test-result-card{padding:10px 12px}}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class TestResultCardComponent {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
result = input.required<CaseTestResult>();
|
||||||
|
sessionId = input<string | null>(null);
|
||||||
|
executionStatus = input('');
|
||||||
|
reviewSaved = output<CaseReviewResponse>();
|
||||||
|
reviewing = signal(false); saving = signal(false); error = signal(''); notice = signal(''); note = '';
|
||||||
|
canReview = computed(() => !!this.sessionId() && ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()));
|
||||||
|
constructor() {
|
||||||
|
effect(() => { this.sessionId(); this.reviewing.set(false); this.error.set(''); this.notice.set(''); this.note = ''; });
|
||||||
|
}
|
||||||
|
toggleReview() { this.reviewing.update(value => !value); this.error.set(''); this.notice.set(''); this.note = this.result().source === 'human' ? this.result().reason : ''; }
|
||||||
|
saveReview(status: 'passed' | 'failed') {
|
||||||
|
const id = this.sessionId();
|
||||||
|
if (!id || !this.canReview() || this.saving()) return;
|
||||||
|
this.saving.set(true); this.error.set('');
|
||||||
|
this.http.put<CaseReviewResponse>(`/api/automation/reports/${encodeURIComponent(id)}/review`, {status, reason: this.note.trim()}).subscribe({
|
||||||
|
next: response => {
|
||||||
|
this.saving.set(false); this.reviewSaved.emit(response);
|
||||||
|
if (this.sessionId() === id) { this.reviewing.set(false); this.note = ''; this.notice.set('人工复验已保存,报告已更新'); }
|
||||||
|
},
|
||||||
|
error: error => { this.saving.set(false); if (this.sessionId() === id) this.error.set(error.error?.detail || '复验保存失败,请重试'); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,8 +21,24 @@ export interface ModelInfo {
|
|||||||
architecture?: string;
|
architecture?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CaseTestResult {
|
||||||
|
status: 'passed' | 'failed' | 'inconclusive' | 'blocked';
|
||||||
|
reason: string;
|
||||||
|
evidence: string[];
|
||||||
|
source?: 'ai' | 'human';
|
||||||
|
reviewed_at?: number;
|
||||||
|
ai_result?: CaseTestResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaseReviewResponse {
|
||||||
|
id: string;
|
||||||
|
test_result: CaseTestResult;
|
||||||
|
verdict: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskQueueItem {
|
export interface TaskQueueItem {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
|
batch_id?: string | null;
|
||||||
goal: string;
|
goal: string;
|
||||||
profile?: string;
|
profile?: string;
|
||||||
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
|
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
|
||||||
@@ -34,10 +50,15 @@ export interface TaskQueueItem {
|
|||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
|
batch_id?: string | null;
|
||||||
initial_goal: string;
|
initial_goal: string;
|
||||||
start_time: number;
|
start_time: number;
|
||||||
end_time?: number;
|
end_time?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
display_name?: string;
|
||||||
|
case_id?: string;
|
||||||
|
error?: string;
|
||||||
|
test_result?: CaseTestResult | null;
|
||||||
video_url?: string;
|
video_url?: string;
|
||||||
recording_status?: 'recording' | 'finalizing' | 'processing' | 'ready' | 'failed' | 'unavailable';
|
recording_status?: 'recording' | 'finalizing' | 'processing' | 'ready' | 'failed' | 'unavailable';
|
||||||
model_info?: ModelInfo;
|
model_info?: ModelInfo;
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ export interface CheckerBlockData {
|
|||||||
route?: string;
|
route?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
task_status?: 'completed' | 'partial' | 'blocked' | string;
|
task_status?: 'completed' | 'partial' | 'blocked' | string;
|
||||||
|
test_result?: import('./session.model').CaseTestResult | null;
|
||||||
tests?: { passed: number; failed: number; inconclusive: number; unchecked: number };
|
tests?: { passed: number; failed: number; inconclusive: number; unchecked: number };
|
||||||
last_findings?: string[];
|
last_findings?: string[];
|
||||||
started_at?: number;
|
started_at?: number;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { Component, signal, computed, inject, OnInit, OnDestroy, ChangeDetection
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { AgentService } from '../../services/agent.service';
|
import { AgentService } from '../../services/agent.service';
|
||||||
import { SystemService } from '../../services/system.service';
|
import { SystemService, formatAdbRestartFeedback } from '../../services/system.service';
|
||||||
import {
|
import {
|
||||||
AdbServerConnectionResult,
|
AdbServerConnectionResult,
|
||||||
AdbServerDevice,
|
AdbServerDevice,
|
||||||
@@ -548,10 +548,8 @@ export class HomeComponent implements OnInit, OnDestroy {
|
|||||||
this.adbRestartFeedback.set(null);
|
this.adbRestartFeedback.set(null);
|
||||||
this.systemService.restartAdb().subscribe({
|
this.systemService.restartAdb().subscribe({
|
||||||
next: (res) => {
|
next: (res) => {
|
||||||
this.adbRestartFeedback.set(
|
this.adbRestartFeedback.set(formatAdbRestartFeedback(res?.restart_result));
|
||||||
res?.restart_result?.skipped ? "设备已刷新 ✓" : "ADB 已刷新 ✓"
|
setTimeout(() => this.adbRestartFeedback.set(null), 6000);
|
||||||
);
|
|
||||||
setTimeout(() => this.adbRestartFeedback.set(null), 2500);
|
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.adbRestartFeedback.set("重启失败");
|
this.adbRestartFeedback.set("重启失败");
|
||||||
|
|||||||
@@ -17,7 +17,43 @@ limitations under the License.
|
|||||||
<div class="workspace-container" [class.is-resizing]="isDragging()">
|
<div class="workspace-container" [class.is-resizing]="isDragging()">
|
||||||
<!-- Left Main Panel: Raw Agent Log Stream -->
|
<!-- Left Main Panel: Raw Agent Log Stream -->
|
||||||
<main class="left-panel glass-panel">
|
<main class="left-panel glass-panel">
|
||||||
<app-agent-stream></app-agent-stream>
|
<app-agent-stream>
|
||||||
|
@if (guidanceStatus(); as status) {
|
||||||
|
<div task-guidance class="task-guidance-control">
|
||||||
|
<button type="button" class="guidance-trigger" (click)="toggleGuidance()"
|
||||||
|
[attr.aria-expanded]="guidanceOpenId() === agentService.currentSessionId()"
|
||||||
|
aria-controls="workspace-guidance-panel" aria-label="指导当前任务" title="暂停或指导当前任务">
|
||||||
|
<span class="material-symbols-outlined">edit_note</span>
|
||||||
|
指导任务
|
||||||
|
</button>
|
||||||
|
@if (guidanceOpenId() === agentService.currentSessionId()) {
|
||||||
|
<section id="workspace-guidance-panel" class="task-guidance-panel" aria-label="当前任务指导">
|
||||||
|
<div class="guidance-heading">
|
||||||
|
<strong>指导当前任务 · {{ status === 'running' ? '执行中' : status === 'resuming' ? '等待设备恢复' : status === 'paused' ? '模型调用暂停' : '等待指导' }}</strong>
|
||||||
|
<span [title]="agentService.currentSession()?.initial_goal || ''">{{ agentService.currentSession()?.initial_goal || agentService.currentSessionId() }}</span>
|
||||||
|
</div>
|
||||||
|
<label for="workspace-task-guidance">补充信息或调整方向</label>
|
||||||
|
<textarea id="workspace-task-guidance" [(ngModel)]="taskGuidance" rows="2" maxlength="10000"
|
||||||
|
[disabled]="guidanceBusy()" placeholder="输入指导;将中断当前模型思考并立即重新规划"></textarea>
|
||||||
|
<div class="guidance-actions">
|
||||||
|
<span>{{ status === 'paused' ? '提交指导后请在执行日志中恢复模型调用' : '下方输入框用于开始新任务' }}</span>
|
||||||
|
@if (status === 'running') {
|
||||||
|
<button type="button" (click)="pauseCurrentTask()"
|
||||||
|
[disabled]="guidanceBusy() || pauseRequestId() === agentService.currentSessionId()">
|
||||||
|
{{ pauseRequestId() === agentService.currentSessionId() ? '等待暂停…' : '暂停当前任务' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (status === 'task_paused') {
|
||||||
|
<button type="button" (click)="continueCurrentTask()" [disabled]="guidanceBusy()">直接继续</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="guidance-submit" (click)="submitGuidance()"
|
||||||
|
[disabled]="guidanceBusy() || !taskGuidance.trim()">{{ status === 'task_paused' ? '提交并恢复' : '提交指导' }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</app-agent-stream>
|
||||||
|
|
||||||
<!-- Bottom Floating Command Bar Dock -->
|
<!-- Bottom Floating Command Bar Dock -->
|
||||||
<div class="workspace-floating-bar-wrapper" [class.is-expanded]="isBarExpanded()" [class.is-dormant]="!isBarExpanded()"
|
<div class="workspace-floating-bar-wrapper" [class.is-expanded]="isBarExpanded()" [class.is-dormant]="!isBarExpanded()"
|
||||||
|
|||||||
@@ -108,6 +108,110 @@ $rose-dark: #dc2626;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-guidance-control {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
.guidance-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border: 1px solid rgba(226, 232, 240, .85);
|
||||||
|
border-radius: 15px;
|
||||||
|
background: rgba(255, 255, 255, .72);
|
||||||
|
box-shadow: 0 2px 6px -1px rgba(15, 23, 42, .05);
|
||||||
|
color: #334155;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
.material-symbols-outlined { font-size: 16px; }
|
||||||
|
&:hover { background: rgba(255, 255, 255, .92); border-color: #cbd5e1; }
|
||||||
|
&[aria-expanded='true'] { background: #eff6ff; border-color: #bfdbfe; color: #1d4ed8; }
|
||||||
|
&:focus-visible { outline: 2px solid #93c5fd; outline-offset: 2px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.guidance-trigger {
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0;
|
||||||
|
.material-symbols-outlined { font-size: 16px; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-guidance-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 9px);
|
||||||
|
right: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: min(420px, calc(100vw - 40px));
|
||||||
|
padding: 15px;
|
||||||
|
border: 1px solid #dbe4ef;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 18px 48px rgba(15, 23, 42, .16);
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
.guidance-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
strong { flex-shrink: 0; color: #1d4ed8; font-size: 13px; }
|
||||||
|
span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #475569; }
|
||||||
|
}
|
||||||
|
|
||||||
|
label { display: block; margin-bottom: 6px; font-weight: 600; }
|
||||||
|
textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 62px;
|
||||||
|
max-height: 160px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0f172a;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
&:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.guidance-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
span { flex: 1 0 100%; color: #64748b; }
|
||||||
|
button {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 5px 11px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover:not(:disabled) { background: #eff6ff; border-color: #93c5fd; }
|
||||||
|
&:disabled { opacity: .55; cursor: not-allowed; }
|
||||||
|
&.guidance-submit { background: #2563eb; border-color: #2563eb; color: #fff; }
|
||||||
|
&.guidance-submit:hover:not(:disabled) { background: #1d4ed8; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ========================================================================= */
|
/* ========================================================================= */
|
||||||
/* BOTTOM FLOATING COMMAND BAR (Proximity Reveal & Minimalist Glassmorphism) */
|
/* BOTTOM FLOATING COMMAND BAR (Proximity Reveal & Minimalist Glassmorphism) */
|
||||||
/* ========================================================================= */
|
/* ========================================================================= */
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { Component, computed, signal } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
import { AgentStreamComponent } from '../../components/agent-stream/agent-stream.component';
|
||||||
|
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
|
||||||
|
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
|
||||||
|
import { WorkspaceComponent } from './workspace.component';
|
||||||
|
|
||||||
|
@Component({ selector: 'app-agent-stream', standalone: true,
|
||||||
|
template: '<div class="stream-floating-toolbar"><ng-content select="[task-guidance]"></ng-content></div>' })
|
||||||
|
class StreamStub {}
|
||||||
|
@Component({ selector: 'app-chat-interface', standalone: true, template: '' })
|
||||||
|
class ChatStub {}
|
||||||
|
@Component({ selector: 'app-floating-video-player', standalone: true, template: '' })
|
||||||
|
class VideoStub {}
|
||||||
|
|
||||||
|
describe('Workspace task guidance', () => {
|
||||||
|
const currentId = signal<string | null>('running-task');
|
||||||
|
const session = signal({ session_id: 'running-task', status: 'running', initial_goal: '观察设置' });
|
||||||
|
const agent = {
|
||||||
|
currentSessionId: currentId,
|
||||||
|
currentSession: computed(() => currentId() === session().session_id ? session() : null),
|
||||||
|
runningSessionId: signal<string | null>('running-task'),
|
||||||
|
agentStatus: signal('running'),
|
||||||
|
isCurrentSessionRunning: computed(() => ['running', 'paused'].includes(session().status)),
|
||||||
|
fetchStatus: jasmine.createSpy('fetchStatus'),
|
||||||
|
fetchSessions: jasmine.createSpy('fetchSessions'),
|
||||||
|
fetchGuidance: jasmine.createSpy('fetchGuidance')
|
||||||
|
};
|
||||||
|
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['post']);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
currentId.set('running-task');
|
||||||
|
session.set({ session_id: 'running-task', status: 'running', initial_goal: '观察设置' });
|
||||||
|
agent.runningSessionId.set('running-task');
|
||||||
|
http.post.and.returnValue(of({}));
|
||||||
|
http.post.calls.reset();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [WorkspaceComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: AgentService, useValue: agent },
|
||||||
|
{ provide: HttpClient, useValue: http }
|
||||||
|
]
|
||||||
|
}).overrideComponent(WorkspaceComponent, {
|
||||||
|
remove: { imports: [AgentStreamComponent, ChatInterfaceComponent, FloatingVideoPlayerComponent] },
|
||||||
|
add: { imports: [StreamStub, ChatStub, VideoStub] }
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows pause and guidance for the selected running task, then hides them on cancellation', async () => {
|
||||||
|
const fixture = TestBed.createComponent(WorkspaceComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
|
||||||
|
expect(fixture.nativeElement.querySelector('.stream-floating-toolbar .guidance-trigger')).not.toBeNull();
|
||||||
|
expect(fixture.nativeElement.querySelector('.workspace-floating-bar-wrapper .guidance-trigger')).toBeNull();
|
||||||
|
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const panel = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
|
||||||
|
expect(panel.textContent).toContain('指导当前任务');
|
||||||
|
expect(panel.textContent).toContain('下方输入框用于开始新任务');
|
||||||
|
|
||||||
|
const pause = Array.from(panel.querySelectorAll('button')).find(b => b.textContent?.includes('暂停当前任务'))!;
|
||||||
|
pause.click();
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/pause', {});
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const reopened = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
|
||||||
|
const input = reopened.querySelector('textarea')!;
|
||||||
|
input.value = '先确认设置首页';
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const submit = Array.from(reopened.querySelectorAll('button')).find(b => b.textContent?.includes('提交指导'))!;
|
||||||
|
submit.click();
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/guide', { instruction: '先确认设置首页' });
|
||||||
|
expect(agent.fetchGuidance).toHaveBeenCalledWith('running-task');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('指导已提交');
|
||||||
|
|
||||||
|
session.set({ session_id: 'running-task', status: 'cancelled', initial_goal: '观察设置' });
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
|
||||||
|
expect(fixture.nativeElement.querySelector('.guidance-trigger')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues a paused task without creating a new task', async () => {
|
||||||
|
session.set({ session_id: 'running-task', status: 'task_paused', initial_goal: '观察设置' });
|
||||||
|
const fixture = TestBed.createComponent(WorkspaceComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const button = Array.from<HTMLElement>(fixture.nativeElement.querySelectorAll('.task-guidance-panel button'))
|
||||||
|
.find(b => b.textContent?.includes('直接继续'))!;
|
||||||
|
button.click();
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/continue', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer task resume for a paused model request', () => {
|
||||||
|
session.set({ session_id: 'running-task', status: 'paused', initial_goal: '观察设置' });
|
||||||
|
const fixture = TestBed.createComponent(WorkspaceComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const panel = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
|
||||||
|
expect(panel.textContent).toContain('模型调用暂停');
|
||||||
|
expect(panel.textContent).toContain('请在执行日志中恢复模型调用');
|
||||||
|
expect(panel.textContent).not.toContain('直接继续');
|
||||||
|
expect(panel.textContent).not.toContain('提交并恢复');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,8 @@ import { AgentStreamComponent } from '../../components/agent-stream/agent-stream
|
|||||||
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
|
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
|
||||||
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
|
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
|
||||||
import { AgentService } from '../../services/agent.service';
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-workspace',
|
selector: 'app-workspace',
|
||||||
@@ -37,6 +39,7 @@ import { AgentService } from '../../services/agent.service';
|
|||||||
})
|
})
|
||||||
export class WorkspaceComponent implements OnInit {
|
export class WorkspaceComponent implements OnInit {
|
||||||
public agentService = inject(AgentService);
|
public agentService = inject(AgentService);
|
||||||
|
private http = inject(HttpClient);
|
||||||
private zone = inject(NgZone);
|
private zone = inject(NgZone);
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
@@ -56,6 +59,33 @@ export class WorkspaceComponent implements OnInit {
|
|||||||
public isSubmitting = signal<boolean>(false);
|
public isSubmitting = signal<boolean>(false);
|
||||||
public errorMessage = signal<string | null>(null);
|
public errorMessage = signal<string | null>(null);
|
||||||
public selectedProfile = signal<'flash' | 'pro'>('flash');
|
public selectedProfile = signal<'flash' | 'pro'>('flash');
|
||||||
|
private guidanceDrafts = new Map<string, string>();
|
||||||
|
public guidanceBusy = signal(false);
|
||||||
|
public guidanceOpenId = signal<string | null>(null);
|
||||||
|
public pauseRequestId = signal<string | null>(null);
|
||||||
|
|
||||||
|
public get taskGuidance(): string {
|
||||||
|
const id = this.agentService.currentSessionId();
|
||||||
|
return id ? this.guidanceDrafts.get(id) || '' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public set taskGuidance(value: string) {
|
||||||
|
const id = this.agentService.currentSessionId();
|
||||||
|
if (id) this.guidanceDrafts.set(id, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public guidanceStatus = computed(() => {
|
||||||
|
const id = this.agentService.currentSessionId();
|
||||||
|
if (!id) return null;
|
||||||
|
const status = this.agentService.currentSession()?.status
|
||||||
|
|| (this.agentService.runningSessionId() === id ? this.agentService.agentStatus() : '');
|
||||||
|
return ['running', 'paused', 'task_paused', 'resuming'].includes(status || '') ? status : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
public toggleGuidance(): void {
|
||||||
|
const id = this.agentService.currentSessionId();
|
||||||
|
this.guidanceOpenId.set(id && this.guidanceOpenId() !== id ? id : null);
|
||||||
|
}
|
||||||
|
|
||||||
// Expand States (Signals for 0-latency reactivity)
|
// Expand States (Signals for 0-latency reactivity)
|
||||||
public isHoveringCard = signal<boolean>(false);
|
public isHoveringCard = signal<boolean>(false);
|
||||||
@@ -103,6 +133,69 @@ export class WorkspaceComponent implements OnInit {
|
|||||||
return this.agentService.isCurrentSessionRunning();
|
return this.agentService.isCurrentSessionRunning();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
public async pauseCurrentTask(): Promise<void> {
|
||||||
|
const id = this.agentService.currentSessionId();
|
||||||
|
if (!id || this.guidanceStatus() !== 'running' || this.guidanceBusy()) return;
|
||||||
|
this.guidanceBusy.set(true);
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
try {
|
||||||
|
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/pause`, {}));
|
||||||
|
this.pauseRequestId.set(id);
|
||||||
|
this.guidanceOpenId.set(null);
|
||||||
|
this.agentService.fetchStatus();
|
||||||
|
this.agentService.fetchSessions();
|
||||||
|
} catch (error) {
|
||||||
|
this.showGuidanceError(error);
|
||||||
|
} finally {
|
||||||
|
this.guidanceBusy.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async submitGuidance(): Promise<void> {
|
||||||
|
const id = this.agentService.currentSessionId();
|
||||||
|
const instruction = this.taskGuidance.trim();
|
||||||
|
const status = this.guidanceStatus();
|
||||||
|
if (!id || !status || !instruction || this.guidanceBusy()) return;
|
||||||
|
this.guidanceBusy.set(true);
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
try {
|
||||||
|
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/guide`, { instruction }));
|
||||||
|
this.guidanceDrafts.delete(id);
|
||||||
|
this.pauseRequestId.set(null);
|
||||||
|
this.guidanceOpenId.set(null);
|
||||||
|
this.agentService.fetchGuidance(id);
|
||||||
|
this.agentService.fetchStatus();
|
||||||
|
this.agentService.fetchSessions();
|
||||||
|
} catch (error) {
|
||||||
|
this.showGuidanceError(error);
|
||||||
|
} finally {
|
||||||
|
this.guidanceBusy.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async continueCurrentTask(): Promise<void> {
|
||||||
|
const id = this.agentService.currentSessionId();
|
||||||
|
if (!id || this.guidanceStatus() !== 'task_paused' || this.guidanceBusy()) return;
|
||||||
|
this.guidanceBusy.set(true);
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
try {
|
||||||
|
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/continue`, {}));
|
||||||
|
this.pauseRequestId.set(null);
|
||||||
|
this.guidanceOpenId.set(null);
|
||||||
|
this.agentService.fetchStatus();
|
||||||
|
this.agentService.fetchSessions();
|
||||||
|
} catch (error) {
|
||||||
|
this.showGuidanceError(error);
|
||||||
|
} finally {
|
||||||
|
this.guidanceBusy.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private showGuidanceError(error: unknown): void {
|
||||||
|
const response = error as { error?: { detail?: string }; message?: string };
|
||||||
|
this.errorMessage.set(response.error?.detail || response.message || '操作失败,请刷新任务状态后重试');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dynamic tooltip and label indicating which task will be stopped
|
* Dynamic tooltip and label indicating which task will be stopped
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { Injectable, signal, inject, computed, DestroyRef, NgZone } from '@angul
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage } from '../core/models/session.model';
|
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage, CaseReviewResponse } from '../core/models/session.model';
|
||||||
import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
|
import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
|
||||||
import { StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE, PersistedCheckerStream, StreamSegment } from '../core/models/stream.model';
|
import { StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE, PersistedCheckerStream, StreamSegment } from '../core/models/stream.model';
|
||||||
import { extractStepReplayFrames } from '../utils/action-formatter.util';
|
import { extractStepReplayFrames } from '../utils/action-formatter.util';
|
||||||
@@ -54,6 +54,15 @@ export interface StartupProgressEvent {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GuidanceEvent {
|
||||||
|
id: string;
|
||||||
|
instruction: string;
|
||||||
|
source: string;
|
||||||
|
timestamp: number;
|
||||||
|
status: 'pending' | 'consumed';
|
||||||
|
consumed_at?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface SessionVideoResponse {
|
interface SessionVideoResponse {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
status?: 'processing' | 'ready' | 'failed' | 'unavailable';
|
status?: 'processing' | 'ready' | 'failed' | 'unavailable';
|
||||||
@@ -76,6 +85,23 @@ export class AgentService {
|
|||||||
private rawSessions = signal<Session[]>([]);
|
private rawSessions = signal<Session[]>([]);
|
||||||
private pendingQueue = signal<Session[]>([]);
|
private pendingQueue = signal<Session[]>([]);
|
||||||
public activeTasks = signal<any[]>([]);
|
public activeTasks = signal<any[]>([]);
|
||||||
|
public batchFilter = signal('');
|
||||||
|
public resultRevision = signal(0);
|
||||||
|
|
||||||
|
public matchesBatchFilter(session: Session): boolean {
|
||||||
|
const filter = this.batchFilter();
|
||||||
|
if (!filter) return true;
|
||||||
|
if (filter === '__batch__') return !!session.batch_id;
|
||||||
|
if (filter === '__single__') return !session.batch_id;
|
||||||
|
return session.batch_id === filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public applyCaseReview(review: CaseReviewResponse): void {
|
||||||
|
this.rawSessions.update(items => items.map(session => session.session_id === review.id
|
||||||
|
? {...session, test_result: review.test_result} : session));
|
||||||
|
this.resultRevision.update(value => value + 1);
|
||||||
|
this.fetchSessions();
|
||||||
|
}
|
||||||
// Persistent tracking of active/pending sessions across polling boundaries
|
// Persistent tracking of active/pending sessions across polling boundaries
|
||||||
private activeSessionTracking = new Map<string, Session>();
|
private activeSessionTracking = new Map<string, Session>();
|
||||||
|
|
||||||
@@ -99,11 +125,13 @@ export class AgentService {
|
|||||||
const pendingMatch = pendingById.get(s.session_id);
|
const pendingMatch = pendingById.get(s.session_id);
|
||||||
const isCurrentActive = ((status === 'running' || status === 'paused') && runId === s.session_id)
|
const isCurrentActive = ((status === 'running' || status === 'paused') && runId === s.session_id)
|
||||||
|| !!activeMatch;
|
|| !!activeMatch;
|
||||||
const isTerminal = s.status === 'completed' || s.status === 'success' || s.status === 'failed' || s.status === 'cancelled';
|
const isTerminal = ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(s.status || '');
|
||||||
let sStatus = s.status;
|
let sStatus = s.status;
|
||||||
if (!isTerminal) {
|
if (!isTerminal) {
|
||||||
const isPending = !!pendingMatch && !isCurrentActive;
|
const isPending = !!pendingMatch && !isCurrentActive;
|
||||||
sStatus = isCurrentActive ? (status === 'paused' ? 'paused' : 'running') : (isPending ? 'pending' : s.status);
|
sStatus = isCurrentActive
|
||||||
|
? (activeMatch?.status || (status === 'paused' ? 'paused' : 'running'))
|
||||||
|
: (isPending ? 'pending' : s.status);
|
||||||
} else {
|
} else {
|
||||||
sStatus = (s.status === 'success') ? 'completed' : s.status;
|
sStatus = (s.status === 'success') ? 'completed' : s.status;
|
||||||
}
|
}
|
||||||
@@ -118,6 +146,7 @@ export class AgentService {
|
|||||||
}
|
}
|
||||||
const finalSession: Session = {
|
const finalSession: Session = {
|
||||||
...s,
|
...s,
|
||||||
|
batch_id: s.batch_id || activeMatch?.batch_id || pendingMatch?.batch_id || null,
|
||||||
status: sStatus,
|
status: sStatus,
|
||||||
device_serial: serial,
|
device_serial: serial,
|
||||||
model_info: isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info
|
model_info: isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info
|
||||||
@@ -138,7 +167,8 @@ export class AgentService {
|
|||||||
|| !!activeMatch;
|
|| !!activeMatch;
|
||||||
const finalPending: Session = {
|
const finalPending: Session = {
|
||||||
...p,
|
...p,
|
||||||
status: isCurrentRunning ? (status === 'paused' ? 'paused' : 'running') : (p.status || 'pending'),
|
batch_id: p.batch_id || activeMatch?.batch_id || null,
|
||||||
|
status: isCurrentRunning ? (activeMatch?.status || (status === 'paused' ? 'paused' : 'running')) : (p.status || 'pending'),
|
||||||
device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null
|
device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null
|
||||||
};
|
};
|
||||||
sessionMap.set(p.session_id, finalPending);
|
sessionMap.set(p.session_id, finalPending);
|
||||||
@@ -154,9 +184,11 @@ export class AgentService {
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
const newSession: Session = {
|
const newSession: Session = {
|
||||||
session_id: sid,
|
session_id: sid,
|
||||||
|
batch_id: at.batch_id || null,
|
||||||
|
display_name: at.display_name,
|
||||||
initial_goal: at.goal || goal || '',
|
initial_goal: at.goal || goal || '',
|
||||||
start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
|
start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
|
||||||
status: 'running',
|
status: at.status || 'running',
|
||||||
model_info: this.activeModel() || undefined,
|
model_info: this.activeModel() || undefined,
|
||||||
device_serial: at.device_id || null
|
device_serial: at.device_id || null
|
||||||
};
|
};
|
||||||
@@ -199,6 +231,8 @@ export class AgentService {
|
|||||||
|
|
||||||
public currentSessionId = signal<string | null>(null);
|
public currentSessionId = signal<string | null>(null);
|
||||||
public sessionLogs = signal<any[]>([]); // Dynamic array of all raw events received
|
public sessionLogs = signal<any[]>([]); // Dynamic array of all raw events received
|
||||||
|
public guidanceEvents = signal<GuidanceEvent[]>([]);
|
||||||
|
private guidanceSignature = '';
|
||||||
public isSessionContentLoading = signal<boolean>(false);
|
public isSessionContentLoading = signal<boolean>(false);
|
||||||
public agentStatus = signal<string>('idle'); // Status of the agent runner process
|
public agentStatus = signal<string>('idle'); // Status of the agent runner process
|
||||||
public runningSessionId = signal<string | null>(null);
|
public runningSessionId = signal<string | null>(null);
|
||||||
@@ -723,6 +757,8 @@ export class AgentService {
|
|||||||
this.isSessionContentLoading.set(false);
|
this.isSessionContentLoading.set(false);
|
||||||
this.currentSessionId.set(null);
|
this.currentSessionId.set(null);
|
||||||
this.sessionLogs.set([]);
|
this.sessionLogs.set([]);
|
||||||
|
this.guidanceEvents.set([]);
|
||||||
|
this.guidanceSignature = '';
|
||||||
this.currentNotes.set({});
|
this.currentNotes.set({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -738,6 +774,9 @@ export class AgentService {
|
|||||||
this.isSessionContentLoading.set(true);
|
this.isSessionContentLoading.set(true);
|
||||||
this.currentSessionId.set(sessionId);
|
this.currentSessionId.set(sessionId);
|
||||||
this.sessionLogs.set([]); // Reset logs for new session selection
|
this.sessionLogs.set([]); // Reset logs for new session selection
|
||||||
|
this.guidanceEvents.set([]);
|
||||||
|
this.guidanceSignature = '';
|
||||||
|
this.fetchGuidance(sessionId);
|
||||||
this.activePauseCardKey = null;
|
this.activePauseCardKey = null;
|
||||||
this.isPaused.set(false);
|
this.isPaused.set(false);
|
||||||
this.pausedError.set(null);
|
this.pausedError.set(null);
|
||||||
@@ -1277,6 +1316,21 @@ export class AgentService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public fetchGuidance(sessionId: string): void {
|
||||||
|
if (this.currentSessionId() !== sessionId) return;
|
||||||
|
this.http.get<GuidanceEvent[]>(`/api/tasks/${encodeURIComponent(sessionId)}/guidance`).subscribe({
|
||||||
|
next: (items) => {
|
||||||
|
if (this.currentSessionId() !== sessionId) return;
|
||||||
|
const signature = JSON.stringify(items);
|
||||||
|
if (signature !== this.guidanceSignature) {
|
||||||
|
this.guidanceSignature = signature;
|
||||||
|
this.guidanceEvents.set(items);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => { /* Older runners have no guidance history; steps remain authoritative. */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private restoreSessionsCache(): void {
|
private restoreSessionsCache(): void {
|
||||||
try {
|
try {
|
||||||
const cached = localStorage.getItem(SESSION_CACHE_KEY);
|
const cached = localStorage.getItem(SESSION_CACHE_KEY);
|
||||||
@@ -1482,6 +1536,8 @@ export class AgentService {
|
|||||||
return; // Paused while the tab is in the background; resumed by visibilitychange.
|
return; // Paused while the tab is in the background; resumed by visibilitychange.
|
||||||
}
|
}
|
||||||
this.fetchStatus();
|
this.fetchStatus();
|
||||||
|
const selected = this.currentSessionId();
|
||||||
|
if (selected) this.fetchGuidance(selected);
|
||||||
this.pollCounter++;
|
this.pollCounter++;
|
||||||
// Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications
|
// Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications
|
||||||
if (this.pollCounter % 3 === 0) {
|
if (this.pollCounter % 3 === 0) {
|
||||||
@@ -1543,6 +1599,8 @@ export class AgentService {
|
|||||||
if (typeof item === 'object' && item !== null) {
|
if (typeof item === 'object' && item !== null) {
|
||||||
return {
|
return {
|
||||||
session_id: item.session_id || `pending-task-${index}`,
|
session_id: item.session_id || `pending-task-${index}`,
|
||||||
|
batch_id: item.batch_id || null,
|
||||||
|
display_name: item.display_name,
|
||||||
initial_goal: item.goal || '',
|
initial_goal: item.goal || '',
|
||||||
start_time: item.start_time || item.created_at || (Date.now() / 1000 + index),
|
start_time: item.start_time || item.created_at || (Date.now() / 1000 + index),
|
||||||
status: item.status || 'pending',
|
status: item.status || 'pending',
|
||||||
|
|||||||
@@ -3,7 +3,23 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
import { SystemReadinessReport } from '../core/models/system.model';
|
import { SystemReadinessReport } from '../core/models/system.model';
|
||||||
import { SystemService } from './system.service';
|
import { SystemService, formatAdbRestartFeedback } from './system.service';
|
||||||
|
|
||||||
|
describe('ADB restart feedback', () => {
|
||||||
|
it('区分已连接与正在连接,不把等待就绪显示为失败', () => {
|
||||||
|
const message = formatAdbRestartFeedback({ success: true, automatic_connections: [{ success: true }, { success: false, pending: true }] });
|
||||||
|
expect(message).toContain('已连接 1 台设备');
|
||||||
|
expect(message).toContain('1 台设备连接中');
|
||||||
|
expect(message).not.toContain('失败');
|
||||||
|
});
|
||||||
|
it('显示连接失败数量,提示查看诊断', () => {
|
||||||
|
expect(formatAdbRestartFeedback({ success: true, automatic_connections: [{ success: false }] })).toContain('1 项连接失败,请查看诊断');
|
||||||
|
});
|
||||||
|
it('服务器重启失败不会显示成功,远程端点明确显示仅刷新', () => {
|
||||||
|
expect(formatAdbRestartFeedback({ success: false, message: '端口被占用' })).toBe('端口被占用');
|
||||||
|
expect(formatAdbRestartFeedback({ success: true, skipped: true })).toBe('设备已刷新 ✓');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('SystemService readiness polling', () => {
|
describe('SystemService readiness polling', () => {
|
||||||
let service: SystemService;
|
let service: SystemService;
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ import {
|
|||||||
EmulatorLaunchStage
|
EmulatorLaunchStage
|
||||||
} from '../core/models/system.model';
|
} from '../core/models/system.model';
|
||||||
|
|
||||||
|
export function formatAdbRestartFeedback(result?: {
|
||||||
|
success?: boolean; skipped?: boolean; message?: string;
|
||||||
|
automatic_connections?: { success?: boolean; pending?: boolean }[];
|
||||||
|
}): string {
|
||||||
|
if (!result?.success) return result?.message || '重启失败';
|
||||||
|
if (result.skipped) return '设备已刷新 ✓';
|
||||||
|
const connections = result.automatic_connections ?? [];
|
||||||
|
const connected = connections.filter(item => item.success).length;
|
||||||
|
const pending = connections.filter(item => item.pending).length;
|
||||||
|
const failed = connections.filter(item => !item.success && !item.pending).length;
|
||||||
|
return `ADB 已重启${connected ? `,已连接 ${connected} 台设备` : ''}${pending ? `,${pending} 台设备连接中,将自动重试` : ''}${failed ? `,${failed} 项连接失败,请查看诊断` : ' ✓'}`;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -67,4 +67,12 @@ describe('markdown-parser.util verification & check lines', () => {
|
|||||||
expect(html).toContain('<span class="verify-badge">verify</span>');
|
expect(html).toContain('<span class="verify-badge">verify</span>');
|
||||||
expect(html).toContain('status is ok');
|
expect(html).toContain('status is ok');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders Chinese report tables and preserves separate lines safely', () => {
|
||||||
|
const html = renderMarkdownToHtml('## 结论\n第一行\n第二行\n\n| 分类 | 结果 |\n| --- | --- |\n| 网络 | **通过** |');
|
||||||
|
expect(html).toContain('<h2>结论</h2>');
|
||||||
|
expect(html).toContain('<div>第一行</div>\n<div>第二行</div>');
|
||||||
|
expect(html).toContain('<th>分类</th>');
|
||||||
|
expect(html).toContain('<td><strong>通过</strong></td>');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -272,7 +272,7 @@ export function renderMarkdownToHtml(text: string): string {
|
|||||||
if (inCodeBlock) {
|
if (inCodeBlock) {
|
||||||
// End code block
|
// End code block
|
||||||
const codeContent = codeBlockLines.join('\n');
|
const codeContent = codeBlockLines.join('\n');
|
||||||
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang}">${codeContent}</code></pre>`);
|
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang.replace(/[^a-zA-Z0-9_-]/g, '')}">${codeContent}</code></pre>`);
|
||||||
inCodeBlock = false;
|
inCodeBlock = false;
|
||||||
codeBlockLines = [];
|
codeBlockLines = [];
|
||||||
codeBlockLang = '';
|
codeBlockLang = '';
|
||||||
@@ -290,6 +290,26 @@ export function renderMarkdownToHtml(text: string): string {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Markdown result tables need real cells to remain readable in reports.
|
||||||
|
if (trimmed.includes('|') && i + 1 < rawLines.length
|
||||||
|
&& /^\s*\|?\s*:?-{3,}:?(?:\s*\|\s*:?-{3,}:?)*\s*\|?\s*$/.test(rawLines[i + 1])) {
|
||||||
|
closeList();
|
||||||
|
const cells = (line: string) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
||||||
|
const headings = cells(rawLine);
|
||||||
|
result.push('<div class="md-table-scroll"><table><thead><tr>'
|
||||||
|
+ headings.map(cell => `<th>${formatInlineMarkdown(cell.trim())}</th>`).join('')
|
||||||
|
+ '</tr></thead><tbody>');
|
||||||
|
i += 2;
|
||||||
|
while (i < rawLines.length && rawLines[i].trim() && rawLines[i].includes('|')) {
|
||||||
|
const row = cells(rawLines[i]);
|
||||||
|
result.push('<tr>' + headings.map((_, index) => `<td>${formatInlineMarkdown((row[index] || '').trim())}</td>`).join('') + '</tr>');
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
result.push('</tbody></table></div>');
|
||||||
|
i--;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Headings: # to ######
|
// 2. Headings: # to ######
|
||||||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
||||||
if (headingMatch) {
|
if (headingMatch) {
|
||||||
@@ -375,7 +395,7 @@ export function renderMarkdownToHtml(text: string): string {
|
|||||||
|
|
||||||
if (inCodeBlock) {
|
if (inCodeBlock) {
|
||||||
const codeContent = codeBlockLines.join('\n');
|
const codeContent = codeBlockLines.join('\n');
|
||||||
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang}">${codeContent}</code></pre>`);
|
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang.replace(/[^a-zA-Z0-9_-]/g, '')}">${codeContent}</code></pre>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
closeList();
|
closeList();
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core';
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
|
|
||||||
const LABELS: Record<string, string> = {
|
const LABELS: Record<string, string> = {
|
||||||
running: '执行中', queued: '排队中', pending: '等待中', completed: '已完成',
|
running: '执行中', queued: '排队中', pending: '等待中', completed: '执行完成',
|
||||||
success: '成功', succeeded: '成功', failed: '失败', error: '错误', paused: '已暂停',
|
success: '成功', succeeded: '成功', failed: '失败', error: '错误', paused: '已暂停',
|
||||||
cancelled: '已取消', canceled: '已取消', stopped: '已停止', unknown: '未知',
|
cancelled: '已终止', canceled: '已终止', stopped: '已终止', interrupted: '中断',
|
||||||
|
task_paused: '等待指导', resuming: '恢复排队', skipped: '已跳过', unknown: '未知',
|
||||||
idle: '空闲', starting: '启动中', launching: '启动中', ready: '就绪',
|
idle: '空闲', starting: '启动中', launching: '启动中', ready: '就绪',
|
||||||
pass: '通过', fail: '失败', warn: '警告', warning: '警告', blocked: '受阻',
|
pass: '通过', fail: '失败', warn: '警告', warning: '警告', blocked: '受阻',
|
||||||
done: '完成', active: '当前', waiting: '等待中', processing: '处理中',
|
done: '完成', active: '当前', waiting: '等待中', processing: '处理中',
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { StepReplayFrame } from '../core/models/stream.model';
|
||||||
|
import { TimelineSegment } from './recording-timeline.util';
|
||||||
|
import { buildVideoStepMarkers } from './video-step-markers.util';
|
||||||
|
|
||||||
|
describe('buildVideoStepMarkers', () => {
|
||||||
|
const start = 1_800_000_000;
|
||||||
|
const segments: TimelineSegment[] = [
|
||||||
|
{ start: 0, duration: 10, offset_ms: 1000, duration_ms: 10000 },
|
||||||
|
{ start: 10, duration: 10, offset_ms: 13000, duration_ms: 10000 }
|
||||||
|
];
|
||||||
|
const frame = (stepNumber: number, seconds: number, status = 'dispatched'): StepReplayFrame => ({
|
||||||
|
index: stepNumber - 1,
|
||||||
|
stepNumber,
|
||||||
|
title: `步骤 ${stepNumber}`,
|
||||||
|
imageUrl: 'step.png',
|
||||||
|
timestamp: start + seconds,
|
||||||
|
status
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places markers on the playable timeline across recording restart gaps', () => {
|
||||||
|
const markers = buildVideoStepMarkers([frame(1, 4), frame(2, 15)], start, segments, 20);
|
||||||
|
expect(markers.map((marker) => marker.time)).toEqual([3, 12]);
|
||||||
|
expect(markers.map((marker) => marker.percent)).toEqual([15, 60]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('snaps a step in a recording gap to the following segment', () => {
|
||||||
|
const markers = buildVideoStepMarkers([frame(1, 12)], start, segments, 20);
|
||||||
|
expect(markers[0].time).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits steps outside the actual recording and without timestamps', () => {
|
||||||
|
const missing = { ...frame(4, 5), timestamp: undefined };
|
||||||
|
const markers = buildVideoStepMarkers(
|
||||||
|
[frame(1, 0), frame(2, 2), frame(3, 24), missing], start, segments, 20
|
||||||
|
);
|
||||||
|
expect(markers.map((marker) => marker.steps[0].stepNumber)).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups crowded steps and keeps failure information', () => {
|
||||||
|
const markers = buildVideoStepMarkers(
|
||||||
|
[frame(1, 4), frame(2, 4.1, 'failed'), frame(3, 8)], start, segments, 20
|
||||||
|
);
|
||||||
|
expect(markers.length).toBe(2);
|
||||||
|
expect(markers[0].steps.map((step) => step.stepNumber)).toEqual([1, 2]);
|
||||||
|
expect(markers[0].failed).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses session seconds directly for a legacy playlist or single video', () => {
|
||||||
|
expect(buildVideoStepMarkers([frame(1, 6)], start, [{ start: 0, duration: 20 }], 20)[0].time).toBe(6);
|
||||||
|
expect(buildVideoStepMarkers([frame(1, 6)], start, [], 20)[0].time).toBe(6);
|
||||||
|
expect(buildVideoStepMarkers([frame(1, 6)], undefined, [], 20)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { StepReplayFrame } from '../core/models/stream.model';
|
||||||
|
import { hasSessionOffsets, sessionTimeToTimelineTime, TimelineSegment } from './recording-timeline.util';
|
||||||
|
|
||||||
|
export interface VideoStepMarker {
|
||||||
|
time: number;
|
||||||
|
percent: number;
|
||||||
|
steps: StepReplayFrame[];
|
||||||
|
failed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Place recorded action steps on the video's gap-free playback timeline. */
|
||||||
|
export function buildVideoStepMarkers(
|
||||||
|
frames: readonly StepReplayFrame[],
|
||||||
|
sessionStart: number | undefined,
|
||||||
|
segments: readonly TimelineSegment[],
|
||||||
|
duration: number
|
||||||
|
): VideoStepMarker[] {
|
||||||
|
const start = Number(sessionStart);
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(duration) || duration <= 0) return [];
|
||||||
|
|
||||||
|
const hasOffsets = hasSessionOffsets(segments);
|
||||||
|
const firstOffset = hasOffsets ? (segments[0].offset_ms as number) / 1000 : 0;
|
||||||
|
const lastSegment = segments[segments.length - 1];
|
||||||
|
const lastOffset = hasOffsets
|
||||||
|
? ((lastSegment.offset_ms as number) + (lastSegment.duration_ms as number)) / 1000
|
||||||
|
: duration;
|
||||||
|
|
||||||
|
const positioned = frames.flatMap((frame) => {
|
||||||
|
const timestamp = Number(frame.timestamp);
|
||||||
|
if (frame.timestamp == null || !Number.isFinite(timestamp)) return [];
|
||||||
|
const sessionTime = timestamp - start;
|
||||||
|
// Do not create misleading ticks at the start/end for steps outside the recording.
|
||||||
|
if (sessionTime < firstOffset || sessionTime > lastOffset) return [];
|
||||||
|
const time = sessionTimeToTimelineTime(segments, sessionTime);
|
||||||
|
if (time < 0 || time > duration) return [];
|
||||||
|
return [{ time, frame }];
|
||||||
|
}).sort((a, b) => a.time - b.time);
|
||||||
|
|
||||||
|
const markers: VideoStepMarker[] = [];
|
||||||
|
// Nearby actions share one visible tick on a narrow player timeline.
|
||||||
|
const minGap = duration * 0.02;
|
||||||
|
for (const { time, frame } of positioned) {
|
||||||
|
const previous = markers[markers.length - 1];
|
||||||
|
if (previous && time - previous.time < minGap) {
|
||||||
|
previous.steps.push(frame);
|
||||||
|
previous.failed ||= frame.status === 'failed';
|
||||||
|
} else {
|
||||||
|
markers.push({ time, percent: (time / duration) * 100, steps: [frame], failed: frame.status === 'failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return markers;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "ES2022"
|
"module": "ES2022"
|
||||||
|
|||||||
@@ -5,5 +5,6 @@
|
|||||||
"assert_semantics": "# `assert` items (test assertions)\n- An `assert` item is a test expectation, not an instruction. A failed assertion is a legitimate, valuable test result: record it truthfully with evidence. Nothing will (or should) try to repair it.\n- Never mark an assert `failed` merely because evidence is absent — absence of captured evidence is `inconclusive` with an explicit note that the evidence is missing.\n- Precondition first: before judging an assert, confirm from the execution history (step replay, notes, anchor evidence) that the action(s) meant to trigger the asserted behavior were actually and correctly performed by the executor (the toggle was really tapped, the intended screen was really reached).\n- If that precondition is not established — the executor never performed the trigger, performed the wrong action, or the evidence cannot show it — output `inconclusive` and state explicitly that the precondition failed / the triggering action is not confirmed as executed. Never output `failed` here: a failed assert is attributed to the device under test, and an execution problem must not be reported as a device defect.\n- Output `failed` only when the precondition is established AND the device state, probe output, or recorded evidence still contradicts the expectation. The evidence must cite the concrete step(s) that performed the trigger plus the probe output / observation that contradicts the expectation, so the verdict reads as a device-defect diagnosis.\n- `suggestion` is not needed for assert verdicts.",
|
"assert_semantics": "# `assert` items (test assertions)\n- An `assert` item is a test expectation, not an instruction. A failed assertion is a legitimate, valuable test result: record it truthfully with evidence. Nothing will (or should) try to repair it.\n- Never mark an assert `failed` merely because evidence is absent — absence of captured evidence is `inconclusive` with an explicit note that the evidence is missing.\n- Precondition first: before judging an assert, confirm from the execution history (step replay, notes, anchor evidence) that the action(s) meant to trigger the asserted behavior were actually and correctly performed by the executor (the toggle was really tapped, the intended screen was really reached).\n- If that precondition is not established — the executor never performed the trigger, performed the wrong action, or the evidence cannot show it — output `inconclusive` and state explicitly that the precondition failed / the triggering action is not confirmed as executed. Never output `failed` here: a failed assert is attributed to the device under test, and an execution problem must not be reported as a device defect.\n- Output `failed` only when the precondition is established AND the device state, probe output, or recorded evidence still contradicts the expectation. The evidence must cite the concrete step(s) that performed the trigger plus the probe output / observation that contradicts the expectation, so the verdict reads as a device-defect diagnosis.\n- `suggestion` is not needed for assert verdicts.",
|
||||||
"anchor_guide": "# Evidence anchoring (checkpoint audit)\n- You are auditing a subgoal at its completion moment. The authoritative evidence is the execution history around the anchor step (the anchor step's pre-action screenshot and the preceding step's post-action state) plus persistently probeable device state.\n- For transient expectations (toasts, dialogs, momentary states) and ordering expectations, you MUST inspect the recorded history near the anchor step — the live device has already moved on and is NOT valid evidence for them.\n- Prefer persistent state probes and recorded notes over inference from summaries.",
|
"anchor_guide": "# Evidence anchoring (checkpoint audit)\n- You are auditing a subgoal at its completion moment. The authoritative evidence is the execution history around the anchor step (the anchor step's pre-action screenshot and the preceding step's post-action state) plus persistently probeable device state.\n- For transient expectations (toasts, dialogs, momentary states) and ordering expectations, you MUST inspect the recorded history near the anchor step — the live device has already moved on and is NOT valid evidence for them.\n- Prefer persistent state probes and recorded notes over inference from summaries.",
|
||||||
"final_guide": "# Final review (task exit audit)\n- Audit against the user's original goal as amended by the user guidance received during the run (listed in the prompt), not merely the plan's checkboxes. Guidance outranks the plan and its check lines: judge a check line the guidance retired or changed by the guidance, not by its original wording.\n- `@end` items: judge them using the FINAL device state provided to you.\n- `on_complete` items: cite the verdict ledger — an already-recorded PASS/FAIL from the item's checkpoint attempt is authoritative (an assert that ever failed stays failed). For items never checked, judge from the historical evidence near their subgoal's completion; if that evidence was never captured, output `inconclusive` stating the evidence is missing. NEVER re-judge an `on_complete` item from the current screen: the current screen only proves the present, not the past moment the item was declared for.\n- `unmet_subgoals`: list, verbatim, any plan subgoal marked complete whose verify criteria are demonstrably not met. Leave it empty otherwise; failed asserts alone do NOT make a subgoal unmet.",
|
"final_guide": "# Final review (task exit audit)\n- Audit against the user's original goal as amended by the user guidance received during the run (listed in the prompt), not merely the plan's checkboxes. Guidance outranks the plan and its check lines: judge a check line the guidance retired or changed by the guidance, not by its original wording.\n- `@end` items: judge them using the FINAL device state provided to you.\n- `on_complete` items: cite the verdict ledger — an already-recorded PASS/FAIL from the item's checkpoint attempt is authoritative (an assert that ever failed stays failed). For items never checked, judge from the historical evidence near their subgoal's completion; if that evidence was never captured, output `inconclusive` stating the evidence is missing. NEVER re-judge an `on_complete` item from the current screen: the current screen only proves the present, not the past moment the item was declared for.\n- `unmet_subgoals`: list, verbatim, any plan subgoal marked complete whose verify criteria are demonstrably not met. Leave it empty otherwise; failed asserts alone do NOT make a subgoal unmet.",
|
||||||
"probe_guide": "# Device probes (read-only)\n- `probe_device` runs a fixed, enumerated set of read-only state probes. Prefer probing persistent state over inferring it: alarms (`alarms`), battery status (`battery`), foreground activities (`foreground`), notifications (`notifications`), a settings value (`setting` with `namespace` + `key`), a content provider query (`content` with `uri`), installed packages (`packages`), a system property (`prop` with `key`).\n- Probes outside this table do not exist; you cannot run arbitrary commands."
|
"probe_guide": "# Device probes (read-only)\n- `probe_device` runs a fixed, enumerated set of read-only state probes. Prefer probing persistent state over inferring it: alarms (`alarms`), battery status (`battery`), foreground activities (`foreground`), notifications (`notifications`), a settings value (`setting` with `namespace` + `key`), a content provider query (`content` with `uri`), installed packages (`packages`), a system property (`prop` with `key`).\n- Probes outside this table do not exist; you cannot run arbitrary commands.",
|
||||||
|
"case_result_guide": "# Overall test result (final review, for Flash and Pro)\nAlways produce `test_result`, even when no plan/check items exist. Infer acceptance criteria directly from the user's original task prompt and subsequent guidance; do not ask for an extra expected-results form. Evaluate explicit expectations and the observable outcome of requested actions. Do not invent requirements, turn examples into expectations, or relax an expectation because the executor says it completed the task.\nCompare the executor's final explanation with actual screenshots, UI elements, step history, notes, probes and the verdict ledger. The explanation is a claim to verify, never proof by itself. A final screen only proves the final state: use recorded history for intermediate/transient results and required sequences. Use read-only tools only. Do not repair a failed test or repeat device actions.\n- passed: all requirements in the prompt/guidance are supported by concrete evidence, and no active assertion failed. Cite the specific step/screenshot/probe observations.\n- failed: the triggering actions and prerequisites are confirmed as correctly executed, but concrete evidence contradicts at least one stated expectation. Cite both the trigger and the contradictory result. A confirmed failure stays failed even when other requirements are unverified.\n- inconclusive: evidence is missing/ambiguous, the task has no discernible acceptance criterion, or the conclusion cannot be checked. Explain which requirement/evidence is missing; never claim passed or failed merely from the report or an absent exception.\n- blocked: an execution or environment obstacle prevented the required actions/validation. This is not evidence of a product defect.\nWrite `reason` in concise Simplified Chinese, explaining the expected and actual result. `evidence` contains concrete observations with step numbers when available. Preserve user-supplied values, UI text and logs exactly.\nThe test result evaluates the system under test, NOT whether the testing agent obeyed the instruction to inspect/report. Explicit expectations (预期, 应当, 必须, should, expected) are test assertions even without plan checkboxes. Reporting a mismatch correctly does not satisfy that expectation. Example: 'Check the search box; expected Search settings; if mismatched report the actual text and finish.' If the box visibly says 搜索设置, output test_result.status=failed, not passed: the inspection completed but the expected product result was not met. 'Report any failures and stop' never converts a detected failure into a pass. Likewise, confirming a missing required entry is a failed test when the correct page was reached. First list the expected product behavior in reason, then compare the actual evidence; judge execution/reporting separately. If no product expectation is given, judge the observable result of the requested actions."
|
||||||
}
|
}
|
||||||
@@ -46,6 +46,7 @@ from pydantic import BaseModel, Field, ValidationError
|
|||||||
|
|
||||||
from artemis.core.tool_failure import ToolFailure, is_tool_failure
|
from artemis.core.tool_failure import ToolFailure, is_tool_failure
|
||||||
from artemis.constants import CHECKER_MAX_ITERATIONS
|
from artemis.constants import CHECKER_MAX_ITERATIONS
|
||||||
|
from artemis.runtime.case_result import CaseTestResult, normalize_case_result
|
||||||
from artemis.context import ArtemisContext
|
from artemis.context import ArtemisContext
|
||||||
from artemis.data_engine.context_vars import CURRENT_TRACE_ID
|
from artemis.data_engine.context_vars import CURRENT_TRACE_ID
|
||||||
from artemis.data_engine.trace import trace, trace_langchain_tool
|
from artemis.data_engine.trace import trace, trace_langchain_tool
|
||||||
@@ -98,6 +99,10 @@ class CheckVerdict(BaseModel):
|
|||||||
|
|
||||||
class CheckReport(BaseModel):
|
class CheckReport(BaseModel):
|
||||||
verdicts: list[CheckVerdict] = Field(default_factory=list)
|
verdicts: list[CheckVerdict] = Field(default_factory=list)
|
||||||
|
test_result: CaseTestResult | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Final review only: overall test verdict against the user's prompt and guidance, independent of execution completion.",
|
||||||
|
)
|
||||||
unmet_subgoals: list[str] = Field(
|
unmet_subgoals: list[str] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
description=(
|
description=(
|
||||||
@@ -287,6 +292,7 @@ def assemble_checker_prompt_segments(
|
|||||||
segments.append(prompts.get("anchor_guide", ""))
|
segments.append(prompts.get("anchor_guide", ""))
|
||||||
else:
|
else:
|
||||||
segments.append(prompts.get("final_guide", ""))
|
segments.append(prompts.get("final_guide", ""))
|
||||||
|
segments.append(prompts.get("case_result_guide", ""))
|
||||||
if probe_tool_registered:
|
if probe_tool_registered:
|
||||||
segments.append(prompts.get("probe_guide", ""))
|
segments.append(prompts.get("probe_guide", ""))
|
||||||
return "\n\n".join(s for s in segments if s)
|
return "\n\n".join(s for s in segments if s)
|
||||||
@@ -458,7 +464,11 @@ def _normalize_report(report: CheckReport, check_items: list) -> CheckReport:
|
|||||||
evidence="no verdict produced for this item",
|
evidence="no verdict produced for this item",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return CheckReport(verdicts=verdicts, unmet_subgoals=list(report.unmet_subgoals))
|
return CheckReport(
|
||||||
|
verdicts=verdicts,
|
||||||
|
unmet_subgoals=list(report.unmet_subgoals),
|
||||||
|
test_result=normalize_case_result(report.test_result) if report.test_result else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _run_check_loop(
|
async def _run_check_loop(
|
||||||
@@ -796,6 +806,7 @@ async def run_final_check(
|
|||||||
ledger: list[dict],
|
ledger: list[dict],
|
||||||
check_items,
|
check_items,
|
||||||
attempt_id: str | None = None,
|
attempt_id: str | None = None,
|
||||||
|
execution_report: dict | str | None = None,
|
||||||
) -> CheckReport:
|
) -> CheckReport:
|
||||||
"""Final review at task exit: audits the user's original goal and all
|
"""Final review at task exit: audits the user's original goal and all
|
||||||
declared check items, with the final screen state available."""
|
declared check items, with the final screen state available."""
|
||||||
@@ -828,6 +839,8 @@ async def run_final_check(
|
|||||||
human_text = (
|
human_text = (
|
||||||
f"# User's Original Goal\n{goal}\n\n"
|
f"# User's Original Goal\n{goal}\n\n"
|
||||||
f"# User guidance received during the run\n{_format_user_guidance(ctx, steps)}\n\n"
|
f"# User guidance received during the run\n{_format_user_guidance(ctx, steps)}\n\n"
|
||||||
|
f"# Executor's Final Report (claims to verify, not independent evidence)\n"
|
||||||
|
f"{json.dumps(execution_report, ensure_ascii=False, default=str) if execution_report is not None else '(not provided)'}\n\n"
|
||||||
f"# Final Task Plan\n{plan_text or '(no plan)'}\n\n"
|
f"# Final Task Plan\n{plan_text or '(no plan)'}\n\n"
|
||||||
f"# Declared Check Items\n{_format_check_items(items)}\n\n"
|
f"# Declared Check Items\n{_format_check_items(items)}\n\n"
|
||||||
f"# Verdict Ledger (checkpoint results, authoritative for on_complete"
|
f"# Verdict Ledger (checkpoint results, authoritative for on_complete"
|
||||||
@@ -852,7 +865,15 @@ async def run_final_check(
|
|||||||
]
|
]
|
||||||
with CheckerStreamCapture(ctx) as capture:
|
with CheckerStreamCapture(ctx) as capture:
|
||||||
try:
|
try:
|
||||||
return await _run_check_loop(ctx, messages, tools, items)
|
report = await _run_check_loop(ctx, messages, tools, items)
|
||||||
|
report.test_result = (
|
||||||
|
normalize_case_result(report.test_result)
|
||||||
|
if report.test_result
|
||||||
|
else CaseTestResult(
|
||||||
|
status="inconclusive", reason="验收未生成测试结论,不能确认用例是否通过。"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return report
|
||||||
finally:
|
finally:
|
||||||
_persist_stream(
|
_persist_stream(
|
||||||
ctx, capture, attempt_id=attempt_id, phase="final", checkpoint_id="final"
|
ctx, capture, attempt_id=attempt_id, phase="final", checkpoint_id="final"
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Flash's exit-only acceptance check uses the same read-only Checker as Pro."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from artemis.agents.checker.checker import run_final_check
|
||||||
|
from artemis.context import ArtemisContext
|
||||||
|
from artemis.graph.checkpoints import (
|
||||||
|
RunOutcome,
|
||||||
|
TestSummary,
|
||||||
|
final_check_enabled,
|
||||||
|
publish_checker_event,
|
||||||
|
verdict_payloads,
|
||||||
|
write_run_outcome,
|
||||||
|
)
|
||||||
|
from artemis.runtime.case_result import CaseTestResult, resolve_case_result
|
||||||
|
from artemis.utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def settle_flash_outcome(
|
||||||
|
ctx: ArtemisContext,
|
||||||
|
goal: str,
|
||||||
|
execution_report: dict,
|
||||||
|
*,
|
||||||
|
final_report_received: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Check once at exit, without operating the device or rerunning the task."""
|
||||||
|
status = str(execution_report.get("status") or "failed")
|
||||||
|
result = None
|
||||||
|
if status == "completed" or final_report_received:
|
||||||
|
if final_check_enabled(ctx):
|
||||||
|
timeout = float(getattr(ctx.execution_setup, "checkpoint_timeout", 180.0))
|
||||||
|
publish_checker_event(
|
||||||
|
ctx,
|
||||||
|
{
|
||||||
|
"event": "attempt_started",
|
||||||
|
"phase": "final",
|
||||||
|
"attempt_id": "final#1",
|
||||||
|
"checkpoint_id": "final",
|
||||||
|
"check_items": [],
|
||||||
|
"status": "running",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
report = await asyncio.wait_for(
|
||||||
|
run_final_check(
|
||||||
|
ctx,
|
||||||
|
goal=goal,
|
||||||
|
plan_text="",
|
||||||
|
ledger=[],
|
||||||
|
check_items=[],
|
||||||
|
attempt_id="final#1",
|
||||||
|
execution_report=execution_report,
|
||||||
|
),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
result = report.test_result
|
||||||
|
publish_checker_event(
|
||||||
|
ctx,
|
||||||
|
{
|
||||||
|
"event": "attempt_finished",
|
||||||
|
"phase": "final",
|
||||||
|
"attempt_id": "final#1",
|
||||||
|
"checkpoint_id": "final",
|
||||||
|
"status": "done",
|
||||||
|
"verdicts": verdict_payloads(report.verdicts),
|
||||||
|
"test_result": result.model_dump() if result else None,
|
||||||
|
"route": "end",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
detail = (
|
||||||
|
f"超过 {timeout:g} 秒"
|
||||||
|
if isinstance(exc, TimeoutError)
|
||||||
|
else f"{type(exc).__name__}: {exc}"
|
||||||
|
)
|
||||||
|
logger.warning(f"Flash 测试验收未完成:{detail}")
|
||||||
|
result = CaseTestResult(
|
||||||
|
status="inconclusive", reason=f"操作已结束,但测试验收未完成:{detail}。"
|
||||||
|
)
|
||||||
|
publish_checker_event(
|
||||||
|
ctx,
|
||||||
|
{
|
||||||
|
"event": "attempt_finished",
|
||||||
|
"phase": "final",
|
||||||
|
"attempt_id": "final#1",
|
||||||
|
"checkpoint_id": "final",
|
||||||
|
"status": "error",
|
||||||
|
"error": result.reason,
|
||||||
|
"route": "end",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = CaseTestResult(
|
||||||
|
status="inconclusive", reason="已关闭最终检查,未进行测试结果验收。"
|
||||||
|
)
|
||||||
|
|
||||||
|
outcome = RunOutcome(
|
||||||
|
task_status="completed" if status == "completed" else "blocked", tests=TestSummary()
|
||||||
|
)
|
||||||
|
payload = outcome.model_dump()
|
||||||
|
if result:
|
||||||
|
payload["test_result"] = result.model_dump()
|
||||||
|
resolved = resolve_case_result(payload, status, str(execution_report.get("explanation") or ""))
|
||||||
|
payload["test_result"] = resolved.model_dump() if resolved else None
|
||||||
|
if ctx.data_engine:
|
||||||
|
write_run_outcome(
|
||||||
|
ctx.data_engine.base_dir, outcome, {"test_result": payload["test_result"]}
|
||||||
|
)
|
||||||
|
publish_checker_event(ctx, {"event": "run_outcome", "phase": "outcome", **payload})
|
||||||
|
ctx.run_outcome = payload
|
||||||
|
return payload
|
||||||
@@ -37,6 +37,7 @@ from artemis.agents.explorer.tiers import SUBMIT_TOOL
|
|||||||
from artemis.agents.explorer.tool_declarations import UNIVERSAL_EXPLORER_TOOLS
|
from artemis.agents.explorer.tool_declarations import UNIVERSAL_EXPLORER_TOOLS
|
||||||
from artemis.graph.state import State
|
from artemis.graph.state import State
|
||||||
from artemis.services.llm import get_llm
|
from artemis.services.llm import get_llm
|
||||||
|
from artemis.config.settings import settings
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -120,7 +121,9 @@ class UniversalRunnerMixin:
|
|||||||
else:
|
else:
|
||||||
current_llm = bound_llm
|
current_llm = bound_llm
|
||||||
|
|
||||||
response = await asyncio.wait_for(current_llm.ainvoke(messages), timeout=180)
|
response = await asyncio.wait_for(
|
||||||
|
current_llm.ainvoke(messages), timeout=settings.OPENAI_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
messages.append(response)
|
messages.append(response)
|
||||||
|
|
||||||
tool_calls = getattr(response, "tool_calls", None) or []
|
tool_calls = getattr(response, "tool_calls", None) or []
|
||||||
|
|||||||
@@ -55,5 +55,5 @@ Keep in mind that the mobile device is an asynchronous, constantly evolving envi
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 6. TERMINATION PROTOCOL (`report_task_status`)
|
# 6. TERMINATION PROTOCOL (`report_task_status`)
|
||||||
- **Completion (`status="completed"`)**: ONLY call `report_task_status(status="completed", ...)` after you have **visually verified** from the latest screen that the objective is fully achieved.
|
- **Completion (`status="completed"`)**: ONLY call `report_task_status(status="completed", ...)` after the latest screenshot and UI elements support the claimed result. If those sources disagree during a transition, wait and observe again before concluding. Tie each claimed page title or value to an observed screenshot or UI element; mark any missing evidence as unverified.
|
||||||
- **Failure (`status="failed"`)**: If the task is genuinely impossible due to paywalls, unrecoverable crashes, or hitting an absolute dead end after sensible fallbacks, call `report_task_status(status="failed", ...)` with the exact blocking reason.
|
- **Failure (`status="failed"`)**: If the task is genuinely impossible due to paywalls, unrecoverable crashes, or hitting an absolute dead end after sensible fallbacks, call `report_task_status(status="failed", ...)` with the exact blocking reason.
|
||||||
@@ -24,6 +24,7 @@ You are the Step Summarizer for an Android UI automation agent. Your task is to
|
|||||||
- **Target & Action**: Identify what control/button was targeted (referencing the red visual indicator on the BEFORE screen) and the physical action performed.
|
- **Target & Action**: Identify what control/button was targeted (referencing the red visual indicator on the BEFORE screen) and the physical action performed.
|
||||||
- **Objective Transition**: Describe the physical screen change (e.g., page transitioned, modal dialog opened, list scrolled revealing new items, checkbox/toggle toggled, keyboard appeared).
|
- **Objective Transition**: Describe the physical screen change (e.g., page transitioned, modal dialog opened, list scrolled revealing new items, checkbox/toggle toggled, keyboard appeared).
|
||||||
- **Preserve Critical Data & Verifications**: Faithfully transcribe visible text, toast alerts, error banners, verification codes, prices, account names, or tracking numbers that appeared on the AFTER screen.
|
- **Preserve Critical Data & Verifications**: Faithfully transcribe visible text, toast alerts, error banners, verification codes, prices, account names, or tracking numbers that appeared on the AFTER screen.
|
||||||
|
3. **Reconcile both observation sources**: The optional AFTER ACTION UI ELEMENTS block is device hierarchy evidence from the same observation request. During a page animation its contents may be newer than the screenshot. If the image still shows the old page but the hierarchy names the new page, state both observations and that the transition is not yet visually settled; never conclude that the tap did nothing. Do not invent details absent from both sources. A controller's "Tapped" outcome only confirms input dispatch.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+199
-21
@@ -37,7 +37,6 @@ active window until its turn is chunked, whose user lines then carry it on.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from artemis.config.settings import settings
|
|
||||||
import base64
|
import base64
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
import json
|
import json
|
||||||
@@ -45,6 +44,9 @@ from pathlib import Path
|
|||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
from artemis.runtime.task_control import TaskControl, has_pending_instruction
|
||||||
|
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
from langchain_core.messages import (
|
from langchain_core.messages import (
|
||||||
BaseMessage,
|
BaseMessage,
|
||||||
@@ -76,9 +78,10 @@ from artemis.controllers.unified_controller import UnifiedMobileController
|
|||||||
from artemis.data_engine.trace import trace
|
from artemis.data_engine.trace import trace
|
||||||
from artemis.graph.perception import _check_injected_instruction_file
|
from artemis.graph.perception import _check_injected_instruction_file
|
||||||
from artemis.graph.state import State
|
from artemis.graph.state import State
|
||||||
|
from artemis.llm.router import ModelEndpoint
|
||||||
from artemis.llm.structured import ParseFailure, parse_structured
|
from artemis.llm.structured import ParseFailure, parse_structured
|
||||||
from artemis.mcp.action_executor import McpActionExecutor
|
from artemis.mcp.action_executor import McpActionExecutor
|
||||||
from artemis.mcp.observation import observe
|
from artemis.mcp.observation import DeviceObservationError, require_observation
|
||||||
from artemis.memory.transcript import PRO_UI_LIST_MARKER, TranscriptLedger, mark_ephemeral
|
from artemis.memory.transcript import PRO_UI_LIST_MARKER, TranscriptLedger, mark_ephemeral
|
||||||
from artemis.services.llm import (
|
from artemis.services.llm import (
|
||||||
RobustChatModelWrapper,
|
RobustChatModelWrapper,
|
||||||
@@ -142,6 +145,7 @@ class FlashRunner:
|
|||||||
def __init__(self, ctx: ArtemisContext, goal: str, max_turns: int | None = None):
|
def __init__(self, ctx: ArtemisContext, goal: str, max_turns: int | None = None):
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
self.goal = goal
|
self.goal = goal
|
||||||
|
self.final_report_received = False
|
||||||
try:
|
try:
|
||||||
cfg = load_agent_config()
|
cfg = load_agent_config()
|
||||||
self.max_turns = max_turns if max_turns is not None else cfg.flash.max_turns
|
self.max_turns = max_turns if max_turns is not None else cfg.flash.max_turns
|
||||||
@@ -165,6 +169,7 @@ class FlashRunner:
|
|||||||
ctx,
|
ctx,
|
||||||
model_name=self.step_summarizer_cfg.model,
|
model_name=self.step_summarizer_cfg.model,
|
||||||
retry_limit=self.memory_runtime_cfg.retry_limit,
|
retry_limit=self.memory_runtime_cfg.retry_limit,
|
||||||
|
timeout_seconds=self.step_summarizer_cfg.timeout_seconds,
|
||||||
max_concurrency=self.memory_runtime_cfg.max_concurrency,
|
max_concurrency=self.memory_runtime_cfg.max_concurrency,
|
||||||
flush_timeout_s=self.memory_runtime_cfg.flush_timeout_s,
|
flush_timeout_s=self.memory_runtime_cfg.flush_timeout_s,
|
||||||
)
|
)
|
||||||
@@ -285,7 +290,9 @@ class FlashRunner:
|
|||||||
logger.warning(f"Failed to get operator LLM from config, using default: {e}")
|
logger.warning(f"Failed to get operator LLM from config, using default: {e}")
|
||||||
|
|
||||||
return RobustChatModelWrapper(
|
return RobustChatModelWrapper(
|
||||||
get_openai_llm(model_name=settings.OPENAI_MODEL), self.ctx
|
get_openai_llm(model_name=settings.OPENAI_MODEL),
|
||||||
|
self.ctx,
|
||||||
|
endpoint=ModelEndpoint(model_name=settings.OPENAI_MODEL),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _render_system_prompt(self, tools_declaration: list) -> str:
|
def _render_system_prompt(self, tools_declaration: list) -> str:
|
||||||
@@ -374,8 +381,16 @@ class FlashRunner:
|
|||||||
blocks.append(
|
blocks.append(
|
||||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}
|
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}
|
||||||
)
|
)
|
||||||
if xml_list:
|
blocks.append(
|
||||||
blocks.append({"type": "text", "text": f"{PRO_UI_LIST_MARKER}\n{xml_list}"})
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": f"{PRO_UI_LIST_MARKER}\n"
|
||||||
|
+ (
|
||||||
|
xml_list
|
||||||
|
or "当前没有可用的元素索引。仅根据当前截图定位坐标,禁止使用历史画面的元素编号。"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
ephemeral: list[int] = []
|
ephemeral: list[int] = []
|
||||||
# (text, ephemeral) in tail order.
|
# (text, ephemeral) in tail order.
|
||||||
per_turn: list[tuple[str, bool]] = [(text, True) for text in (notices or [])]
|
per_turn: list[tuple[str, bool]] = [(text, True) for text in (notices or [])]
|
||||||
@@ -556,6 +571,7 @@ class FlashRunner:
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Records the final step, acknowledges the tool call, and flushes."""
|
"""Records the final step, acknowledges the tool call, and flushes."""
|
||||||
final_report = args
|
final_report = args
|
||||||
|
self.final_report_received = True
|
||||||
if self.ctx.data_engine:
|
if self.ctx.data_engine:
|
||||||
try:
|
try:
|
||||||
if self.ctx.data_engine.current_step_id is None:
|
if self.ctx.data_engine.current_step_id is None:
|
||||||
@@ -598,19 +614,14 @@ class FlashRunner:
|
|||||||
post_img_bytes = exec_result.screenshot_bytes
|
post_img_bytes = exec_result.screenshot_bytes
|
||||||
if post_img_bytes or name not in action_names:
|
if post_img_bytes or name not in action_names:
|
||||||
return post_img_bytes
|
return post_img_bytes
|
||||||
try:
|
if state is not None:
|
||||||
obs, screenshot_bytes = await observe(self.ctx, self.controller, settle_ms=0)
|
state.indexed_points = []
|
||||||
except Exception as shot_err:
|
state.indexed_elements = []
|
||||||
logger.warning(f"Failed to capture fallback screenshot in FlashRunner: {shot_err}")
|
state.latest_screenshot = None
|
||||||
return post_img_bytes
|
obs, screenshot_bytes = await require_observation(self.ctx, self.controller, settle_ms=0)
|
||||||
if not obs.ok:
|
|
||||||
logger.warning(f"Failed to capture fallback screenshot in FlashRunner: {obs.message}")
|
|
||||||
return post_img_bytes
|
|
||||||
post_img_bytes = screenshot_bytes
|
post_img_bytes = screenshot_bytes
|
||||||
if not exec_result.ui_elements_text:
|
exec_result.ui_elements_text = obs.elements_text
|
||||||
exec_result.ui_elements_text = obs.elements_text
|
exec_result.screenshot_path = obs.screenshot_path
|
||||||
if not exec_result.screenshot_path and obs.screenshot_path:
|
|
||||||
exec_result.screenshot_path = obs.screenshot_path
|
|
||||||
if state is not None:
|
if state is not None:
|
||||||
if obs.hierarchy_ok:
|
if obs.hierarchy_ok:
|
||||||
state.indexed_points = [el["center"] for el in obs.elements]
|
state.indexed_points = [el["center"] for el in obs.elements]
|
||||||
@@ -825,7 +836,7 @@ class FlashRunner:
|
|||||||
turn.actions.append((name, exec_result.status, exec_result.text_summary))
|
turn.actions.append((name, exec_result.status, exec_result.text_summary))
|
||||||
|
|
||||||
# ⚡ Non-blocking dispatch of objective visual transition summarizer
|
# ⚡ Non-blocking dispatch of objective visual transition summarizer
|
||||||
if self.summarizer and name in action_names:
|
if self.summarizer and name in action_names and post_img_bytes:
|
||||||
action_sequence += 1
|
action_sequence += 1
|
||||||
self.summarizer.dispatch(
|
self.summarizer.dispatch(
|
||||||
step_number=action_sequence,
|
step_number=action_sequence,
|
||||||
@@ -836,6 +847,8 @@ class FlashRunner:
|
|||||||
pre_img_bytes=pre_screenshot_bytes,
|
pre_img_bytes=pre_screenshot_bytes,
|
||||||
post_img_bytes=post_img_bytes,
|
post_img_bytes=post_img_bytes,
|
||||||
exec_outcome=exec_result.text_summary,
|
exec_outcome=exec_result.text_summary,
|
||||||
|
post_ui_elements_text=exec_result.ui_elements_text,
|
||||||
|
observation_settled=(exec_result.metadata or {}).get("observation_settled"),
|
||||||
action_key=str(tc_id),
|
action_key=str(tc_id),
|
||||||
data_engine_step_id=recorded_step_id,
|
data_engine_step_id=recorded_step_id,
|
||||||
# The model's own reasoning for this turn steers the lens's
|
# The model's own reasoning for this turn steers the lens's
|
||||||
@@ -849,7 +862,7 @@ class FlashRunner:
|
|||||||
|
|
||||||
if post_img_bytes:
|
if post_img_bytes:
|
||||||
pre_screenshot_bytes = post_img_bytes
|
pre_screenshot_bytes = post_img_bytes
|
||||||
if exec_result.ui_elements_text:
|
if exec_result.ui_elements_text is not None:
|
||||||
xml_list = exec_result.ui_elements_text
|
xml_list = exec_result.ui_elements_text
|
||||||
|
|
||||||
# Helper tools may return multimodal blocks (a step screenshot);
|
# Helper tools may return multimodal blocks (a step screenshot);
|
||||||
@@ -868,6 +881,8 @@ class FlashRunner:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except DeviceObservationError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error executing tool {name}: {e}")
|
logger.error(f"Error executing tool {name}: {e}")
|
||||||
if name in action_names:
|
if name in action_names:
|
||||||
@@ -935,6 +950,9 @@ class FlashRunner:
|
|||||||
action_names = self.executor.action_tool_names
|
action_names = self.executor.action_tool_names
|
||||||
index_elements = self._turn_index_snapshot(state)
|
index_elements = self._turn_index_snapshot(state)
|
||||||
for tc in tool_calls:
|
for tc in tool_calls:
|
||||||
|
control = getattr(self.ctx, "task_control", None)
|
||||||
|
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
|
||||||
|
return None, pre_screenshot_bytes, xml_list, action_sequence
|
||||||
name = tc["name"].split(":")[-1] if ":" in tc["name"] else tc["name"]
|
name = tc["name"].split(":")[-1] if ":" in tc["name"] else tc["name"]
|
||||||
args = tc.get("args") or {}
|
args = tc.get("args") or {}
|
||||||
tc_id = tc.get("id") or str(uuid.uuid4())
|
tc_id = tc.get("id") or str(uuid.uuid4())
|
||||||
@@ -1036,6 +1054,21 @@ class FlashRunner:
|
|||||||
|
|
||||||
@trace(type="agent", name="FlashRunner")
|
@trace(type="agent", name="FlashRunner")
|
||||||
async def run(self, state: State) -> dict:
|
async def run(self, state: State) -> dict:
|
||||||
|
try:
|
||||||
|
return await self._run(state)
|
||||||
|
except DeviceObservationError as exc:
|
||||||
|
state.indexed_points = []
|
||||||
|
state.indexed_elements = []
|
||||||
|
state.latest_screenshot = None
|
||||||
|
logger.error(f"Flash 因设备观测失败停止:{exc}")
|
||||||
|
return {
|
||||||
|
"status": "failed",
|
||||||
|
"explanation": str(exc),
|
||||||
|
"error": str(exc),
|
||||||
|
"code": "device_observation_failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _run(self, state: State) -> dict:
|
||||||
limit = self.turn_limit
|
limit = self.turn_limit
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Starting Artemis Flash reactive loop for goal: {self.goal}"
|
f"Starting Artemis Flash reactive loop for goal: {self.goal}"
|
||||||
@@ -1058,8 +1091,36 @@ class FlashRunner:
|
|||||||
current_xml_list = xml_list
|
current_xml_list = xml_list
|
||||||
previous_turn: _TurnRecord | None = None
|
previous_turn: _TurnRecord | None = None
|
||||||
pending_notices: list[str] = []
|
pending_notices: list[str] = []
|
||||||
|
no_tool_attempts = 0
|
||||||
|
no_tool_limit = settings.ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS
|
||||||
|
fallback_llm = None
|
||||||
|
using_tool_fallback = False
|
||||||
|
action_failure_attempts = 0
|
||||||
|
|
||||||
while limit is None or turns < limit:
|
while limit is None or turns < limit:
|
||||||
|
control = getattr(self.ctx, "task_control", None)
|
||||||
|
if isinstance(control, TaskControl):
|
||||||
|
resumed = await control.checkpoint(
|
||||||
|
on_pause=getattr(self.ctx, "on_task_pause", None),
|
||||||
|
on_resume=getattr(self.ctx, "on_task_resume", None),
|
||||||
|
)
|
||||||
|
if resumed:
|
||||||
|
using_tool_fallback = False
|
||||||
|
no_tool_attempts = 0
|
||||||
|
action_failure_attempts = 0
|
||||||
|
self._llm = llm
|
||||||
|
(
|
||||||
|
shot_path,
|
||||||
|
current_pre_screenshot_bytes,
|
||||||
|
current_xml_list,
|
||||||
|
) = await capture_screenshot_and_parse_ui(
|
||||||
|
self.ctx, state, self.controller, skip_settling=False
|
||||||
|
)
|
||||||
|
state.latest_screenshot = shot_path
|
||||||
|
pending_notices.append(
|
||||||
|
"设备在暂停期间可能被其他任务操作过。已重新观察画面;"
|
||||||
|
"请根据当前状态和新指导重新判断操作,勿沿用旧坐标。"
|
||||||
|
)
|
||||||
turns += 1
|
turns += 1
|
||||||
logger.info(f"--- Artemis Flash Turn {turns}{f'/{limit}' if limit else ''} ---")
|
logger.info(f"--- Artemis Flash Turn {turns}{f'/{limit}' if limit else ''} ---")
|
||||||
|
|
||||||
@@ -1097,7 +1158,44 @@ class FlashRunner:
|
|||||||
turn_base = len(messages) - 1
|
turn_base = len(messages) - 1
|
||||||
current_tools = report_only_tools if is_final else tools_declaration
|
current_tools = report_only_tools if is_final else tools_declaration
|
||||||
|
|
||||||
response = await self._invoke_model(llm, current_tools, messages)
|
if isinstance(control, TaskControl):
|
||||||
|
interrupted, response = await control.run_interruptibly(
|
||||||
|
self._invoke_model(
|
||||||
|
fallback_llm if using_tool_fallback else llm, current_tools, messages
|
||||||
|
),
|
||||||
|
on_pause=getattr(self.ctx, "on_task_pause", None),
|
||||||
|
on_resume=getattr(self.ctx, "on_task_resume", None),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
interrupted, response = (
|
||||||
|
False,
|
||||||
|
await self._invoke_model(
|
||||||
|
fallback_llm if using_tool_fallback else llm, current_tools, messages
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if interrupted or (
|
||||||
|
isinstance(control, TaskControl) and has_pending_instruction(control.directory)
|
||||||
|
):
|
||||||
|
# Do not dispatch a stale action or final answer from a prompt
|
||||||
|
# assembled before the instruction arrived.
|
||||||
|
using_tool_fallback = False
|
||||||
|
no_tool_attempts = 0
|
||||||
|
action_failure_attempts = 0
|
||||||
|
self._llm = llm
|
||||||
|
(
|
||||||
|
shot_path,
|
||||||
|
current_pre_screenshot_bytes,
|
||||||
|
current_xml_list,
|
||||||
|
) = await capture_screenshot_and_parse_ui(
|
||||||
|
self.ctx, state, self.controller, skip_settling=False
|
||||||
|
)
|
||||||
|
state.latest_screenshot = shot_path
|
||||||
|
pending_notices.append(
|
||||||
|
"新指导已打断旧模型步骤。请重新观察画面并按最新指导决定下一步。"
|
||||||
|
)
|
||||||
|
turns -= 1
|
||||||
|
continue
|
||||||
|
|
||||||
if response is None:
|
if response is None:
|
||||||
break
|
break
|
||||||
@@ -1124,6 +1222,21 @@ class FlashRunner:
|
|||||||
self._record_llm_trace(step_token_usage, raw_text)
|
self._record_llm_trace(step_token_usage, raw_text)
|
||||||
|
|
||||||
tool_calls = self._resolve_tool_calls(response, raw_text)
|
tool_calls = self._resolve_tool_calls(response, raw_text)
|
||||||
|
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
|
||||||
|
using_tool_fallback = False
|
||||||
|
no_tool_attempts = 0
|
||||||
|
self._llm = llm
|
||||||
|
(
|
||||||
|
shot_path,
|
||||||
|
current_pre_screenshot_bytes,
|
||||||
|
current_xml_list,
|
||||||
|
) = await capture_screenshot_and_parse_ui(
|
||||||
|
self.ctx, state, self.controller, skip_settling=False
|
||||||
|
)
|
||||||
|
state.latest_screenshot = shot_path
|
||||||
|
pending_notices.append("新指导已收到;忽略旧模型动作,重新观察画面。")
|
||||||
|
turns -= 1
|
||||||
|
continue
|
||||||
|
|
||||||
if not tool_calls:
|
if not tool_calls:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1133,11 +1246,41 @@ class FlashRunner:
|
|||||||
if is_final:
|
if is_final:
|
||||||
final_report = {"status": "failed", "explanation": raw_text}
|
final_report = {"status": "failed", "explanation": raw_text}
|
||||||
break
|
break
|
||||||
|
no_tool_attempts += 1
|
||||||
|
active_limit = (
|
||||||
|
settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS
|
||||||
|
if using_tool_fallback
|
||||||
|
else no_tool_limit
|
||||||
|
)
|
||||||
|
if no_tool_attempts >= active_limit:
|
||||||
|
if using_tool_fallback:
|
||||||
|
final_report = {
|
||||||
|
"status": "failed",
|
||||||
|
"explanation": (
|
||||||
|
f"首选模型连续 {no_tool_limit} 次、兜底模型连续 "
|
||||||
|
f"{no_tool_attempts} 次未调用工具,已停止任务;"
|
||||||
|
"未执行新的设备动作。"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
break
|
||||||
|
fallback_llm = get_llm(self.ctx, name="operator", use_fallback=True)
|
||||||
|
using_tool_fallback = True
|
||||||
|
no_tool_attempts = 0
|
||||||
|
action_failure_attempts = 0
|
||||||
|
logger.warning("Flash 首选模型未调用工具,当前步骤切换到兜底模型")
|
||||||
|
pending_notices.append("首选模型连续未调用工具;本步骤改用兜底模型。")
|
||||||
pending_notices.append(_NO_TOOL_CALL_NOTICE)
|
pending_notices.append(_NO_TOOL_CALL_NOTICE)
|
||||||
ledger.stage_turn(messages[turn_base:])
|
# Keep the observation and any injected user instruction, but
|
||||||
|
# do not feed the model its own unproductive prose on retry.
|
||||||
|
ledger.stage_turn(messages[turn_base:-1])
|
||||||
previous_turn = _TurnRecord()
|
previous_turn = _TurnRecord()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
no_tool_attempts = 0
|
||||||
|
if using_tool_fallback:
|
||||||
|
# A tool-producing turn completes the fallback's scope. The next
|
||||||
|
# observation goes back to the configured primary model.
|
||||||
|
self._llm = fallback_llm
|
||||||
# Process tool calls
|
# Process tool calls
|
||||||
turn = _TurnRecord()
|
turn = _TurnRecord()
|
||||||
(
|
(
|
||||||
@@ -1158,6 +1301,41 @@ class FlashRunner:
|
|||||||
injected=injected,
|
injected=injected,
|
||||||
native_text=native_text,
|
native_text=native_text,
|
||||||
)
|
)
|
||||||
|
if using_tool_fallback and any(status == "success" for _, status, _ in turn.actions):
|
||||||
|
using_tool_fallback = False
|
||||||
|
self._llm = llm
|
||||||
|
pending_notices.append("兜底模型已完成本步骤;后续步骤恢复首选模型。")
|
||||||
|
for _, status, _ in turn.actions:
|
||||||
|
action_failure_attempts = 0 if status == "success" else action_failure_attempts + 1
|
||||||
|
failure_limit = (
|
||||||
|
settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS
|
||||||
|
if using_tool_fallback
|
||||||
|
else settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS
|
||||||
|
)
|
||||||
|
if action_failure_attempts >= failure_limit:
|
||||||
|
if using_tool_fallback:
|
||||||
|
final_report = {
|
||||||
|
"status": "failed",
|
||||||
|
"explanation": f"兜底模型连续 {action_failure_attempts} 次设备动作失败,已停止任务。最后原因:{turn.result()}",
|
||||||
|
}
|
||||||
|
break
|
||||||
|
# A fresh, valid observation is required before spending any
|
||||||
|
# fallback model calls. Hardware recovery is not model retry.
|
||||||
|
(
|
||||||
|
shot_path,
|
||||||
|
current_pre_screenshot_bytes,
|
||||||
|
current_xml_list,
|
||||||
|
) = await capture_screenshot_and_parse_ui(self.ctx, state, self.controller)
|
||||||
|
state.latest_screenshot = shot_path
|
||||||
|
fallback_llm = get_llm(self.ctx, name="operator", use_fallback=True)
|
||||||
|
using_tool_fallback = True
|
||||||
|
self._llm = fallback_llm
|
||||||
|
action_failure_attempts = 0
|
||||||
|
no_tool_attempts = 0
|
||||||
|
pending_notices.append(
|
||||||
|
f"首选模型连续 {settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS} 次设备动作失败。"
|
||||||
|
f"最后原因:{turn.result()}。已重新观察;本步骤由兜底模型重新定位,勿重复旧动作。"
|
||||||
|
)
|
||||||
ledger.stage_turn(messages[turn_base:])
|
ledger.stage_turn(messages[turn_base:])
|
||||||
previous_turn = turn
|
previous_turn = turn
|
||||||
if final_report_from_calls is not None:
|
if final_report_from_calls is not None:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from jinja2 import Template
|
|||||||
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
||||||
|
|
||||||
from artemis.context import ArtemisContext
|
from artemis.context import ArtemisContext
|
||||||
|
from artemis.llm.messages import adapt_responses_messages
|
||||||
from artemis.memory.step_memory import JobKey, StepMemoryService
|
from artemis.memory.step_memory import JobKey, StepMemoryService
|
||||||
from artemis.services.llm import RobustChatModelWrapper, get_openai_llm, get_llm
|
from artemis.services.llm import RobustChatModelWrapper, get_openai_llm, get_llm
|
||||||
from artemis.services.token_meter import record_llm_usage
|
from artemis.services.token_meter import record_llm_usage
|
||||||
@@ -151,6 +152,7 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
ctx: ArtemisContext,
|
ctx: ArtemisContext,
|
||||||
model_name: str | None = None,
|
model_name: str | None = None,
|
||||||
retry_limit: int = 3,
|
retry_limit: int = 3,
|
||||||
|
timeout_seconds: float = 120.0,
|
||||||
*,
|
*,
|
||||||
max_concurrency: int = 1,
|
max_concurrency: int = 1,
|
||||||
flush_timeout_s: float = 30.0,
|
flush_timeout_s: float = 30.0,
|
||||||
@@ -159,8 +161,11 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
ctx,
|
ctx,
|
||||||
max_concurrency=max_concurrency,
|
max_concurrency=max_concurrency,
|
||||||
retry_limit=retry_limit,
|
retry_limit=retry_limit,
|
||||||
flush_timeout_s=flush_timeout_s,
|
# A pending request must be allowed to reach its own timeout when
|
||||||
|
# the task finishes; otherwise the shorter flush cancels it first.
|
||||||
|
flush_timeout_s=max(flush_timeout_s, timeout_seconds + 1.0),
|
||||||
)
|
)
|
||||||
|
self._timeout_seconds = timeout_seconds
|
||||||
|
|
||||||
# Initialize lightweight VLM: prioritize explicit model_name
|
# Initialize lightweight VLM: prioritize explicit model_name
|
||||||
target_model = model_name or settings.OPENAI_MODEL
|
target_model = model_name or settings.OPENAI_MODEL
|
||||||
@@ -224,6 +229,8 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
action_key: str | None = None,
|
action_key: str | None = None,
|
||||||
data_engine_step_id: UUID | str | None = None,
|
data_engine_step_id: UUID | str | None = None,
|
||||||
focus: dict[str, str] | None = None,
|
focus: dict[str, str] | None = None,
|
||||||
|
post_ui_elements_text: str | None = None,
|
||||||
|
observation_settled: bool | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Dispatches an asynchronous summarization task without blocking the caller.
|
"""Dispatches an asynchronous summarization task without blocking the caller.
|
||||||
|
|
||||||
@@ -254,6 +261,8 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
"exec_outcome": exec_outcome,
|
"exec_outcome": exec_outcome,
|
||||||
"data_engine_step_id": data_engine_step_id,
|
"data_engine_step_id": data_engine_step_id,
|
||||||
"focus": focus,
|
"focus": focus,
|
||||||
|
"post_ui_elements_text": post_ui_elements_text,
|
||||||
|
"observation_settled": observation_settled,
|
||||||
}
|
}
|
||||||
self.submit(key, payload, aliases=aliases)
|
self.submit(key, payload, aliases=aliases)
|
||||||
|
|
||||||
@@ -406,6 +415,10 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
if verbatim and not self._phrase_carries(phrase, verbatim):
|
if verbatim and not self._phrase_carries(phrase, verbatim):
|
||||||
lead_lines.append(f"Action arguments (verbatim): {action_name}({verbatim})")
|
lead_lines.append(f"Action arguments (verbatim): {action_name}({verbatim})")
|
||||||
lead_lines.append(f"Controller Outcome: {exec_outcome}")
|
lead_lines.append(f"Controller Outcome: {exec_outcome}")
|
||||||
|
if input_data.get("observation_settled") is False:
|
||||||
|
lead_lines.append(
|
||||||
|
"Observation status: screen still changing; page state is not confirmed."
|
||||||
|
)
|
||||||
focus = input_data.get("focus")
|
focus = input_data.get("focus")
|
||||||
if isinstance(focus, dict) and focus:
|
if isinstance(focus, dict) and focus:
|
||||||
lead_lines.extend(["", render_focus_block(focus, dual=dual)])
|
lead_lines.extend(["", render_focus_block(focus, dual=dual)])
|
||||||
@@ -454,12 +467,24 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
post_ui = input_data.get("post_ui_elements_text")
|
||||||
|
if post_bytes and isinstance(post_ui, str) and post_ui.strip():
|
||||||
|
content_blocks.append(
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "--- [3] AFTER ACTION UI ELEMENTS ---\n" + post_ui[:8000],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
messages: list[BaseMessage] = [
|
messages: list[BaseMessage] = [
|
||||||
SystemMessage(content=rendered_prompt),
|
SystemMessage(content=rendered_prompt),
|
||||||
HumanMessage(content=content_blocks),
|
HumanMessage(content=content_blocks),
|
||||||
]
|
]
|
||||||
|
|
||||||
response = await asyncio.wait_for(self._llm.ainvoke(messages), timeout=25.0)
|
response = await asyncio.wait_for(
|
||||||
|
self._llm.ainvoke(adapt_responses_messages(messages, self._model_name)),
|
||||||
|
timeout=self._timeout_seconds,
|
||||||
|
)
|
||||||
self._meter_lens_call(response)
|
self._meter_lens_call(response)
|
||||||
summary_raw = response.content if isinstance(response.content, str) else ""
|
summary_raw = response.content if isinstance(response.content, str) else ""
|
||||||
if isinstance(response.content, list):
|
if isinstance(response.content, list):
|
||||||
@@ -505,8 +530,14 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
logger.debug(f"DataEngine step summary update skipped: {de_err}")
|
logger.debug(f"DataEngine step summary update skipped: {de_err}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
f"VisualStepSummarizer: Step {step_number} summary request timed out "
|
||||||
|
f"(limit {self._timeout_seconds:.1f}s)"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"VisualStepSummarizer: Error generating summary for step {step_number}: {e}"
|
f"VisualStepSummarizer: Error generating summary for step {step_number}: "
|
||||||
|
f"{type(e).__name__}: {e}"
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
@@ -18,7 +18,7 @@ from pathlib import Path
|
|||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
|
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
|
||||||
from langchain_core.tools import BaseTool, StructuredTool
|
from langchain_core.tools import BaseTool, StructuredTool
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
|
|
||||||
|
|
||||||
class _CyFunctionDetectorMeta(type):
|
class _CyFunctionDetectorMeta(type):
|
||||||
@@ -89,6 +89,20 @@ class LogAnalyzerNode:
|
|||||||
original_coroutine = tool.coroutine
|
original_coroutine = tool.coroutine
|
||||||
|
|
||||||
async def cached_coroutine(**kwargs):
|
async def cached_coroutine(**kwargs):
|
||||||
|
# The injection runner calls the coroutine directly, bypassing
|
||||||
|
# StructuredTool's normal Pydantic argument coercion. Normalize
|
||||||
|
# model-supplied numeric strings before caching or searching logs.
|
||||||
|
if tool.name in ("read_logs", "search_logs") and tool.args_schema:
|
||||||
|
try:
|
||||||
|
validated = tool.args_schema.model_validate(kwargs)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return ToolFailure(f"Invalid {tool.name} arguments: {exc}")
|
||||||
|
kwargs.update(validated.model_dump(exclude_unset=True))
|
||||||
|
if kwargs.get("lines") is None:
|
||||||
|
kwargs["lines"] = 200 if tool.name == "read_logs" else 10000
|
||||||
|
if tool.name == "search_logs" and kwargs.get("context_lines") is None:
|
||||||
|
kwargs["context_lines"] = 0
|
||||||
|
|
||||||
if tool.name == "read_logs":
|
if tool.name == "read_logs":
|
||||||
# Intercept read_logs to cache raw logs
|
# Intercept read_logs to cache raw logs
|
||||||
lines = kwargs.get("lines", 200)
|
lines = kwargs.get("lines", 200)
|
||||||
|
|||||||
@@ -13,14 +13,17 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from collections.abc import Awaitable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from langchain_core.messages import SystemMessage, ToolMessage
|
from langchain_core.messages import SystemMessage, ToolMessage
|
||||||
from langchain_core.tools import BaseTool
|
from langchain_core.tools import BaseTool
|
||||||
|
|
||||||
from artemis.core.tool_failure import is_tool_failure
|
from artemis.agents.validator.tool_declarations import normalize_coordinate_target
|
||||||
|
from artemis.config.settings import settings
|
||||||
from artemis.context import ArtemisContext
|
from artemis.context import ArtemisContext
|
||||||
|
from artemis.core.tool_failure import is_tool_failure
|
||||||
from artemis.data_engine.trace import (
|
from artemis.data_engine.trace import (
|
||||||
TraceSpan,
|
TraceSpan,
|
||||||
trace,
|
trace,
|
||||||
@@ -30,6 +33,7 @@ from artemis.graph.state import State
|
|||||||
from artemis.graph.visibility import strict_state
|
from artemis.graph.visibility import strict_state
|
||||||
from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool
|
from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool
|
||||||
from artemis.llm.usage import usage_from_message
|
from artemis.llm.usage import usage_from_message
|
||||||
|
from artemis.runtime.task_control import TaskControl, has_pending_instruction
|
||||||
from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message
|
from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message
|
||||||
from artemis.tools.command_tool import (
|
from artemis.tools.command_tool import (
|
||||||
analyze_task_output_wrapper,
|
analyze_task_output_wrapper,
|
||||||
@@ -57,6 +61,11 @@ from artemis.utils.visualization import format_minimal_list_with_elements
|
|||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OperatorNoProgressError(RuntimeError):
|
||||||
|
"""The model repeatedly returned no usable tool call; stop without device actions."""
|
||||||
|
|
||||||
|
|
||||||
DEFERRING_TOOLS = {
|
DEFERRING_TOOLS = {
|
||||||
"ask_diagnoser",
|
"ask_diagnoser",
|
||||||
"video_analyzer",
|
"video_analyzer",
|
||||||
@@ -71,6 +80,8 @@ DEFERRING_TOOLS = {
|
|||||||
"ask_explorer",
|
"ask_explorer",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
INTERRUPTIBLE_HELPERS = {"ask_diagnoser", "ask_explorer", "video_analyzer", "analyze_task_output"}
|
||||||
|
|
||||||
# Bare key names press_key accepts (case-insensitive, optional KEYCODE_ prefix).
|
# Bare key names press_key accepts (case-insensitive, optional KEYCODE_ prefix).
|
||||||
SUPPORTED_PRESS_KEYS = ("ENTER", "BACK", "HOME", "APP_SWITCH")
|
SUPPORTED_PRESS_KEYS = ("ENTER", "BACK", "HOME", "APP_SWITCH")
|
||||||
|
|
||||||
@@ -471,6 +482,16 @@ class OperatorNode:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to stage transcript turn: {e}")
|
logger.error(f"Failed to stage transcript turn: {e}")
|
||||||
|
|
||||||
|
async def _await_or_pause[T](self, operation: Awaitable[T]) -> tuple[bool, T | None]:
|
||||||
|
control = getattr(self.ctx, "task_control", None)
|
||||||
|
if isinstance(control, TaskControl):
|
||||||
|
return await control.run_interruptibly(
|
||||||
|
operation,
|
||||||
|
on_pause=getattr(self.ctx, "on_task_pause", None),
|
||||||
|
on_resume=getattr(self.ctx, "on_task_resume", None),
|
||||||
|
)
|
||||||
|
return False, await operation
|
||||||
|
|
||||||
async def _invoke_llm_loop(
|
async def _invoke_llm_loop(
|
||||||
self,
|
self,
|
||||||
base_llm,
|
base_llm,
|
||||||
@@ -485,8 +506,20 @@ class OperatorNode:
|
|||||||
native_thoughts = []
|
native_thoughts = []
|
||||||
tool_limit_exceeded = False
|
tool_limit_exceeded = False
|
||||||
plan_gate_bounced = False
|
plan_gate_bounced = False
|
||||||
|
no_progress_attempts = 0
|
||||||
|
no_progress_limit = settings.ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS
|
||||||
|
fallback_limit = settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS
|
||||||
|
active_llm = base_llm
|
||||||
|
using_tool_fallback = False
|
||||||
|
|
||||||
for iteration in range(max_iterations):
|
for iteration in range(max_iterations):
|
||||||
|
control = getattr(self.ctx, "task_control", None)
|
||||||
|
if isinstance(control, TaskControl) and await control.checkpoint(
|
||||||
|
on_pause=getattr(self.ctx, "on_task_pause", None),
|
||||||
|
on_resume=getattr(self.ctx, "on_task_resume", None),
|
||||||
|
):
|
||||||
|
# The device may have changed while another task held its lease.
|
||||||
|
return None, None, None, False
|
||||||
if iteration == max_iterations - 1:
|
if iteration == max_iterations - 1:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Operator has reached the maximum number of tool calls. Adding reminder."
|
"Operator has reached the maximum number of tool calls. Adding reminder."
|
||||||
@@ -502,8 +535,16 @@ class OperatorNode:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
bound_llm = base_llm.bind_tools(tools=traced_tools)
|
bound_llm = active_llm.bind_tools(tools=traced_tools)
|
||||||
response = await invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages))
|
interrupted, response = await self._await_or_pause(
|
||||||
|
invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages))
|
||||||
|
)
|
||||||
|
if interrupted:
|
||||||
|
return None, None, None, False
|
||||||
|
assert response is not None
|
||||||
|
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
|
||||||
|
# The model answered a prompt that predates the user's new direction.
|
||||||
|
return None, None, None, False
|
||||||
|
|
||||||
usage = usage_from_message(response)
|
usage = usage_from_message(response)
|
||||||
# The ledger's compaction thresholds read the operator's own
|
# The ledger's compaction thresholds read the operator's own
|
||||||
@@ -568,8 +609,41 @@ class OperatorNode:
|
|||||||
logger.error(f"Failed to parse function_call fallback: {e}")
|
logger.error(f"Failed to parse function_call fallback: {e}")
|
||||||
|
|
||||||
if not response.tool_calls:
|
if not response.tool_calls:
|
||||||
logger.warning("LLM stopped without calling any tool. Encouraging action.")
|
no_progress_attempts += 1
|
||||||
break
|
if no_progress_attempts >= (
|
||||||
|
fallback_limit if using_tool_fallback else no_progress_limit
|
||||||
|
):
|
||||||
|
if using_tool_fallback:
|
||||||
|
raise OperatorNoProgressError(
|
||||||
|
f"首选模型连续 {no_progress_limit} 次、兜底模型连续 "
|
||||||
|
f"{no_progress_attempts} 次未提供可执行的工具调用;"
|
||||||
|
"已停止任务以避免空转。"
|
||||||
|
)
|
||||||
|
active_llm = get_llm(ctx=self.ctx, name="operator", use_fallback=True)
|
||||||
|
using_tool_fallback = True
|
||||||
|
no_progress_attempts = 0
|
||||||
|
logger.warning("Operator 首选模型未调用工具,当前步骤切换到兜底模型")
|
||||||
|
current_messages.append(
|
||||||
|
SystemMessage(
|
||||||
|
content="本步骤已切换兜底模型。请使用工具完成操作或明确报告阻碍。"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
logger.warning("LLM returned no tool call; requesting a concrete tool call.")
|
||||||
|
current_messages.append(response)
|
||||||
|
current_messages.append(
|
||||||
|
SystemMessage(
|
||||||
|
content=(
|
||||||
|
"No tool call was returned, so no device action occurred. "
|
||||||
|
"Use the available tool interface now, not a prose promise or JSON "
|
||||||
|
"inside text. Use an index from the current element list or "
|
||||||
|
"normalized coordinates with target_description. If blocked, "
|
||||||
|
"use an observation/diagnostic tool; do not invent a click or "
|
||||||
|
"claim success. This recovery has a limited retry budget."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
current_messages.append(response)
|
current_messages.append(response)
|
||||||
|
|
||||||
@@ -627,12 +701,42 @@ class OperatorNode:
|
|||||||
"\n".join(native_thoughts) if native_thoughts else None
|
"\n".join(native_thoughts) if native_thoughts else None
|
||||||
)
|
)
|
||||||
|
|
||||||
result_obj = await invoke_tool_with_injection(
|
tool_call = invoke_tool_with_injection(
|
||||||
tool=tool_to_run,
|
tool=tool_to_run,
|
||||||
args=args,
|
args=args,
|
||||||
tool_call_id=tc["id"],
|
tool_call_id=tc["id"],
|
||||||
state=state,
|
state=state,
|
||||||
)
|
)
|
||||||
|
if normalize_name(tool_name) in INTERRUPTIBLE_HELPERS:
|
||||||
|
interrupted, result_obj = await self._await_or_pause(tool_call)
|
||||||
|
if interrupted:
|
||||||
|
span.result = "任务被暂停或指导打断;重新观察屏幕后继续"
|
||||||
|
results.append((span.result, "error"))
|
||||||
|
for remaining in other_calls[len(results) :]:
|
||||||
|
results.append(("任务已打断,未执行该工具", "error"))
|
||||||
|
for completed, (content, status) in zip(
|
||||||
|
other_calls, results
|
||||||
|
):
|
||||||
|
current_messages.extend(
|
||||||
|
tool_result_messages(
|
||||||
|
completed["id"],
|
||||||
|
content,
|
||||||
|
name=normalize_name(completed["name"]),
|
||||||
|
status=status,
|
||||||
|
llm=base_llm,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for action in action_calls:
|
||||||
|
current_messages.append(
|
||||||
|
ToolMessage(
|
||||||
|
tool_call_id=action["id"],
|
||||||
|
content="任务已打断,未执行屏幕操作",
|
||||||
|
status="error",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return None, None, None, False
|
||||||
|
else:
|
||||||
|
result_obj = await tool_call
|
||||||
|
|
||||||
content = get_tool_result_content(result_obj)
|
content = get_tool_result_content(result_obj)
|
||||||
# Failure is structural (a ToolMessage status or a
|
# Failure is structural (a ToolMessage status or a
|
||||||
@@ -743,6 +847,9 @@ class OperatorNode:
|
|||||||
|
|
||||||
action_calls = []
|
action_calls = []
|
||||||
|
|
||||||
|
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
|
||||||
|
return None, None, None, False
|
||||||
|
|
||||||
if action_calls:
|
if action_calls:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Operator requested {len(action_calls)} action(s)."
|
f"Operator requested {len(action_calls)} action(s)."
|
||||||
@@ -840,9 +947,34 @@ class OperatorNode:
|
|||||||
break
|
break
|
||||||
|
|
||||||
if validation_errors:
|
if validation_errors:
|
||||||
|
no_progress_attempts += 1
|
||||||
|
if no_progress_attempts >= (
|
||||||
|
fallback_limit if using_tool_fallback else no_progress_limit
|
||||||
|
):
|
||||||
|
if using_tool_fallback:
|
||||||
|
raise OperatorNoProgressError(
|
||||||
|
f"首选模型连续 {no_progress_limit} 次、兜底模型连续 "
|
||||||
|
f"{no_progress_attempts} 次未提供可执行的工具调用,"
|
||||||
|
"工具参数校验仍失败;已停止任务。"
|
||||||
|
)
|
||||||
|
active_llm = get_llm(ctx=self.ctx, name="operator", use_fallback=True)
|
||||||
|
using_tool_fallback = True
|
||||||
|
no_progress_attempts = 0
|
||||||
|
logger.warning("Operator 首选模型工具调用校验失败,当前步骤切换到兜底模型")
|
||||||
|
current_messages.append(
|
||||||
|
SystemMessage(
|
||||||
|
content="本步骤已切换兜底模型。请修正上面的工具参数后再调用。"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
logger.warning("Some action calls failed validation. Feeding back to LLM.")
|
logger.warning("Some action calls failed validation. Feeding back to LLM.")
|
||||||
continue
|
continue
|
||||||
|
no_progress_attempts = 0
|
||||||
else:
|
else:
|
||||||
|
if no_progress_attempts:
|
||||||
|
raise OperatorNoProgressError(
|
||||||
|
"模型在本轮工具调用预算内未恢复有效调用,已停止任务以避免空转。"
|
||||||
|
)
|
||||||
tool_limit_exceeded = True
|
tool_limit_exceeded = True
|
||||||
|
|
||||||
raw_thinking = "\n".join(raw_thoughts) if raw_thoughts else None
|
raw_thinking = "\n".join(raw_thoughts) if raw_thoughts else None
|
||||||
@@ -1122,6 +1254,9 @@ class OperatorNode:
|
|||||||
straight from the indexed list); a coordinate pair carries only the
|
straight from the indexed list); a coordinate pair carries only the
|
||||||
model's own ``target_description``. The two never mix.
|
model's own ``target_description``. The two never mix.
|
||||||
"""
|
"""
|
||||||
|
target = normalize_coordinate_target(target)
|
||||||
|
if isinstance(target, bool):
|
||||||
|
return None, "Error: Boolean values are not valid target indices."
|
||||||
if isinstance(target, (int, float)):
|
if isinstance(target, (int, float)):
|
||||||
try:
|
try:
|
||||||
target_int = int(target)
|
target_int = int(target)
|
||||||
|
|||||||
@@ -136,9 +136,14 @@ async def capture_screenshot_and_parse_ui(
|
|||||||
Thin adapter over :func:`artemis.mcp.observation.observe` that adds the LangGraph
|
Thin adapter over :func:`artemis.mcp.observation.observe` that adds the LangGraph
|
||||||
``State`` write-back (indexed points/elements) the agents rely on.
|
``State`` write-back (indexed points/elements) the agents rely on.
|
||||||
"""
|
"""
|
||||||
obs, screenshot_bytes = await observe(ctx, controller, settle_ms=0 if skip_settling else 400)
|
from artemis.mcp.observation import require_observation
|
||||||
if not obs.ok:
|
|
||||||
return None, None, None
|
state.indexed_points = []
|
||||||
|
state.indexed_elements = []
|
||||||
|
state.latest_screenshot = None
|
||||||
|
obs, screenshot_bytes = await require_observation(
|
||||||
|
ctx, controller, settle_ms=0 if skip_settling else 400
|
||||||
|
)
|
||||||
|
|
||||||
if obs.hierarchy_ok:
|
if obs.hierarchy_ok:
|
||||||
state.indexed_points = [el["center"] for el in obs.elements]
|
state.indexed_points = [el["center"] for el in obs.elements]
|
||||||
|
|||||||
@@ -247,6 +247,48 @@ def _ensure_maestro_not_installed(device_id: str) -> None:
|
|||||||
_uninstall_package(device_id, MAESTRO_PACKAGE)
|
_uninstall_package(device_id, MAESTRO_PACKAGE)
|
||||||
|
|
||||||
|
|
||||||
|
class UIAutomatorConnectionError(RuntimeError):
|
||||||
|
"""Terminal connection failure with device context and the original cause."""
|
||||||
|
|
||||||
|
def __init__(self, device_id: str, attempts: int, cause: Exception) -> None:
|
||||||
|
# The library appends its whole output buffer on every readiness poll.
|
||||||
|
# Keep the original exception as __cause__, but do not repeat those
|
||||||
|
# identical startup lines dozens of times in the user-facing failure.
|
||||||
|
lines = [line for arg in cause.args for line in str(arg).splitlines()]
|
||||||
|
detail = "\n".join(dict.fromkeys(lines)) or str(cause)
|
||||||
|
super().__init__(
|
||||||
|
f"UIAutomator2 连接失败(设备:{device_id};已尝试 {attempts} 次)。"
|
||||||
|
f"\n底层原因:{type(cause).__name__}: {detail}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _ManagedUIAutomatorDevice(u2.Device):
|
||||||
|
"""Release a failed launch before the constructor can lose its handle.
|
||||||
|
|
||||||
|
UIAutomator2 registers exit cleanup only after startup succeeds. Its
|
||||||
|
partially constructed Device is not returned when readiness fails, so
|
||||||
|
the caller cannot stop that launch. Intercept the startup boundary here
|
||||||
|
instead of modifying the installed library or killing device processes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def start_uiautomator(self) -> None:
|
||||||
|
try:
|
||||||
|
super().start_uiautomator()
|
||||||
|
except BaseException:
|
||||||
|
# _process is set only for a server launched by this instance;
|
||||||
|
# attaching to a server already on the device leaves it None.
|
||||||
|
if self._process is not None:
|
||||||
|
try:
|
||||||
|
super().stop_uiautomator(wait=False)
|
||||||
|
except Exception as cleanup_error:
|
||||||
|
# Cleanup must never hide the startup error or interrupt.
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to stop this UIAutomator2 launch on {self._dev.serial}: "
|
||||||
|
f"{cleanup_error}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
class UIAutomatorClient:
|
class UIAutomatorClient:
|
||||||
"""UIAutomator2 client for Android screen data retrieval.
|
"""UIAutomator2 client for Android screen data retrieval.
|
||||||
|
|
||||||
@@ -297,7 +339,7 @@ class UIAutomatorClient:
|
|||||||
if attempt:
|
if attempt:
|
||||||
time.sleep(1.0 * attempt)
|
time.sleep(1.0 * attempt)
|
||||||
try:
|
try:
|
||||||
self._device = u2.connect(self._device_id)
|
self._device = _ManagedUIAutomatorDevice(self._device_id)
|
||||||
break
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_error = exc
|
last_error = exc
|
||||||
@@ -307,7 +349,9 @@ class UIAutomatorClient:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._awake_strategy = None
|
self._awake_strategy = None
|
||||||
raise last_error
|
raise UIAutomatorConnectionError(
|
||||||
|
self._device_id, attempt + 1, last_error
|
||||||
|
) from last_error
|
||||||
logger.info("UIAutomator2 connected successfully")
|
logger.info("UIAutomator2 connected successfully")
|
||||||
return self._device
|
return self._device
|
||||||
|
|
||||||
|
|||||||
@@ -370,6 +370,11 @@ class StepSummarizerConfig(BaseModel):
|
|||||||
default_factory=lambda: settings.OPENAI_MODEL,
|
default_factory=lambda: settings.OPENAI_MODEL,
|
||||||
description="Lightweight model used for background step state summarization.",
|
description="Lightweight model used for background step state summarization.",
|
||||||
)
|
)
|
||||||
|
timeout_seconds: float = Field(
|
||||||
|
default=120.0,
|
||||||
|
gt=0,
|
||||||
|
description="Timeout for one optional background visual-summary model request.",
|
||||||
|
)
|
||||||
prune_history_xml: bool = Field(
|
prune_history_xml: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
description="Whether to prune outdated heavy UI XML trees from historical turns.",
|
description="Whether to prune outdated heavy UI XML trees from historical turns.",
|
||||||
@@ -830,6 +835,13 @@ class ProProfileConfig(BaseModel):
|
|||||||
model_config = {"extra": "allow"}
|
model_config = {"extra": "allow"}
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenshotConfig(BaseModel):
|
||||||
|
"""Limit screenshots sent to agents while retaining physical device coordinates."""
|
||||||
|
|
||||||
|
enabled: bool = Field(default_factory=lambda: settings.ARTEMIS_SCREENSHOT_LIMIT_ENABLED)
|
||||||
|
max_long_edge: int = Field(default=2048, ge=256, le=8192)
|
||||||
|
|
||||||
|
|
||||||
class AgentGlobalConfig(BaseModel):
|
class AgentGlobalConfig(BaseModel):
|
||||||
"""Global configuration parsed from artemis.jsonc / agent_config.json."""
|
"""Global configuration parsed from artemis.jsonc / agent_config.json."""
|
||||||
|
|
||||||
@@ -884,6 +896,10 @@ class AgentGlobalConfig(BaseModel):
|
|||||||
default_factory=MemoryConfig,
|
default_factory=MemoryConfig,
|
||||||
description="Unified step-memory runtime and transcript scrub options.",
|
description="Unified step-memory runtime and transcript scrub options.",
|
||||||
)
|
)
|
||||||
|
screenshot: ScreenshotConfig = Field(
|
||||||
|
default_factory=ScreenshotConfig,
|
||||||
|
description="Agent screenshot size limit; device/UI coordinates remain native.",
|
||||||
|
)
|
||||||
|
|
||||||
model_config = {"extra": "allow"}
|
model_config = {"extra": "allow"}
|
||||||
|
|
||||||
|
|||||||
@@ -95,27 +95,49 @@ class Settings(BaseSettings):
|
|||||||
API_KEY: SecretStr | None = None
|
API_KEY: SecretStr | None = None
|
||||||
|
|
||||||
# Custom Provider Endpoints
|
# Custom Provider Endpoints
|
||||||
OPENAI_BASE_URL: str | None = None
|
OPENAI_BASE_URL: str | None = "https://api-n-cd.playones.com/v1"
|
||||||
OPENAI_MODEL: str = Field(default="mimo-v2.6-flash", min_length=1)
|
OPENAI_MODEL: str = Field(default="mimo-v2.6-flash", min_length=1)
|
||||||
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
|
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
|
||||||
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
|
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
|
||||||
OPENAI_RETRY_INTERVAL_SECONDS: float = Field(default=10.0, ge=0, le=300)
|
OPENAI_RETRY_INTERVAL_SECONDS: float = Field(default=10.0, ge=0, le=300)
|
||||||
|
OPENAI_RATE_LIMIT_RETRY_SECONDS: float = Field(default=60.0, ge=0, le=3600)
|
||||||
|
OPENAI_TOOL_FALLBACK_MODEL: str = Field(default="gpt-6-luna", min_length=1)
|
||||||
|
OPENAI_TOOL_FALLBACK_REASONING_EFFORT: str = "high"
|
||||||
|
OPENAI_TOOL_FALLBACK_MAX_RETRIES: int = Field(default=3, ge=0, le=10)
|
||||||
|
ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=10)
|
||||||
OPENAI_REASONING_EFFORT: str = "high"
|
OPENAI_REASONING_EFFORT: str = "high"
|
||||||
ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS: float = Field(default=12.0, gt=0)
|
ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS: float = Field(default=12.0, gt=0)
|
||||||
ARTEMIS_MODEL_CACHE_TTL_SECONDS: float = Field(default=10.0, ge=0)
|
ARTEMIS_MODEL_CACHE_TTL_SECONDS: float = Field(default=10.0, ge=0)
|
||||||
|
ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS: float = Field(default=2.0, gt=0)
|
||||||
ARTEMIS_VIDEO_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0)
|
ARTEMIS_VIDEO_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0)
|
||||||
ARTEMIS_VIDEO_MAX_RETRIES: int = Field(default=1, ge=0)
|
ARTEMIS_VIDEO_MAX_RETRIES: int = Field(default=1, ge=0)
|
||||||
ARTEMIS_VIDEO_CHUNK_SECONDS: float = Field(default=60.0, gt=0)
|
ARTEMIS_VIDEO_CHUNK_SECONDS: float = Field(default=60.0, gt=0)
|
||||||
ARTEMIS_CHECKPOINT_TIMEOUT_SECONDS: float = Field(default=180.0, gt=0)
|
ARTEMIS_CHECKPOINT_TIMEOUT_SECONDS: float = Field(default=180.0, gt=0)
|
||||||
ARTEMIS_SETTLEMENT_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0)
|
ARTEMIS_SETTLEMENT_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0)
|
||||||
ARTEMIS_VIDEO_ENABLED: bool = False
|
ARTEMIS_VIDEO_ENABLED: bool = False
|
||||||
|
ARTEMIS_RECORDING_ENABLED: bool = False
|
||||||
ARTEMIS_OUTPUTTER_ENABLED: bool = True
|
ARTEMIS_OUTPUTTER_ENABLED: bool = True
|
||||||
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
|
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
|
||||||
|
ARTEMIS_SCREENSHOT_LIMIT_ENABLED: bool = True
|
||||||
|
# Consecutive Pro responses without a valid tool call before failing the task.
|
||||||
|
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
|
||||||
|
# Consecutive Flash responses without any tool call before failing the task.
|
||||||
|
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
|
||||||
|
ARTEMIS_TASK_TIMEOUT_SECONDS: int = Field(default=1800, ge=30, le=86400)
|
||||||
|
ARTEMIS_BATCH_MAX_EXECUTIONS: int = Field(default=10000, ge=1)
|
||||||
|
ARTEMIS_OBSERVATION_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=300)
|
||||||
|
ARTEMIS_OBSERVATION_MAX_ATTEMPTS: int = Field(default=3, ge=1, le=5)
|
||||||
|
ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS: float = Field(default=10, gt=0, le=60)
|
||||||
|
ARTEMIS_ACTION_FAILURE_ATTEMPTS: int = Field(default=3, ge=1, le=10)
|
||||||
|
ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS: float = Field(default=30, gt=0, le=300)
|
||||||
|
|
||||||
# Android ADB Connectivity
|
# Android ADB Connectivity
|
||||||
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
|
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
|
||||||
ADB_PORT: int | None = Field(default=DEFAULT_ADB_PORT)
|
ADB_PORT: int | None = Field(default=DEFAULT_ADB_PORT)
|
||||||
ADB_DEVICE_SERIAL: str | None = None
|
ADB_DEVICE_SERIAL: str | None = None
|
||||||
|
ARTEMIS_ADB_AUTO_CONNECT: bool = True
|
||||||
|
ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS: float = Field(default=10, ge=1, le=300)
|
||||||
|
ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS: float = Field(default=5, gt=0, le=30)
|
||||||
# UI hierarchy backend: "auto" = Accessibility Helper with UIAutomator2
|
# UI hierarchy backend: "auto" = Accessibility Helper with UIAutomator2
|
||||||
# fallback, "helper" = helper only, "uiautomator" = UIAutomator2 only.
|
# fallback, "helper" = helper only, "uiautomator" = UIAutomator2 only.
|
||||||
ARTEMIS_HIERARCHY_BACKEND: str = Field(default="auto")
|
ARTEMIS_HIERARCHY_BACKEND: str = Field(default="auto")
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ except ImportError:
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
|
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
|
||||||
from artemis.utils.video import detect_video_tools_enabled
|
from artemis.utils.video import detect_video_tools_enabled
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
from artemis.config import ExplorerConfig, LLMConfig, OutputterConfig
|
from artemis.config import ExplorerConfig, LLMConfig, OutputterConfig
|
||||||
@@ -108,6 +109,7 @@ class ExecutionSetup(BaseModel):
|
|||||||
enable_remote_tracing: bool = False
|
enable_remote_tracing: bool = False
|
||||||
app_lock_status: AppLaunchResult | None = None
|
app_lock_status: AppLaunchResult | None = None
|
||||||
video_recording_tools_enabled: bool = Field(default_factory=detect_video_tools_enabled)
|
video_recording_tools_enabled: bool = Field(default_factory=detect_video_tools_enabled)
|
||||||
|
record_video_enabled: bool = Field(default_factory=lambda: settings.ARTEMIS_RECORDING_ENABLED)
|
||||||
disable_checker: bool = False
|
disable_checker: bool = False
|
||||||
"""Legacy master switch (compat alias): ``True`` disables BOTH the midway
|
"""Legacy master switch (compat alias): ``True`` disables BOTH the midway
|
||||||
checkpoints and the final check, regardless of the individual gates below.
|
checkpoints and the final check, regardless of the individual gates below.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import time
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from artemis.core.diagnostics.adb_server_connection import adb_server_connection
|
from artemis.core.diagnostics.adb_server_connection import adb_server_connection
|
||||||
|
from artemis.runtime.adb_discovery import adb_device_discovery
|
||||||
from artemis.core.diagnostics.probes.adb_probe import AdbDeviceProbe
|
from artemis.core.diagnostics.probes.adb_probe import AdbDeviceProbe
|
||||||
from artemis.core.diagnostics.probes.base import BaseProbe
|
from artemis.core.diagnostics.probes.base import BaseProbe
|
||||||
from artemis.core.diagnostics.probes.credentials_probe import (
|
from artemis.core.diagnostics.probes.credentials_probe import (
|
||||||
@@ -353,7 +354,12 @@ class ReadinessEngine:
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info("[ReadinessEngine] Restarting ADB server...")
|
logger.info("[ReadinessEngine] Restarting ADB server...")
|
||||||
return await asyncio.to_thread(_restart_sync)
|
result = await asyncio.to_thread(_restart_sync)
|
||||||
|
if result.get("success"):
|
||||||
|
result["automatic_connections"] = await asyncio.to_thread(
|
||||||
|
adb_device_discovery.refresh, adb_path, force=True
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
async def launch_emulator(self, avd_name: str) -> dict[str, Any]:
|
async def launch_emulator(self, avd_name: str) -> dict[str, Any]:
|
||||||
"""Launch an Android emulator by AVD name in the background and track its lifecycle."""
|
"""Launch an Android emulator by AVD name in the background and track its lifecycle."""
|
||||||
@@ -417,8 +423,10 @@ class ReadinessEngine:
|
|||||||
)
|
)
|
||||||
output = (res.stdout + "\n" + res.stderr).strip()
|
output = (res.stdout + "\n" + res.stderr).strip()
|
||||||
success = "connected to" in output.lower() and "failed" not in output.lower()
|
success = "connected to" in output.lower() and "failed" not in output.lower()
|
||||||
|
remembered = adb_device_discovery.remember(target) if success else False
|
||||||
return {
|
return {
|
||||||
"success": success,
|
"success": success,
|
||||||
|
"remembered": remembered,
|
||||||
"target": target,
|
"target": target,
|
||||||
"output": output,
|
"output": output,
|
||||||
"message": f"Connected to {target}" if success else output,
|
"message": f"Connected to {target}" if success else output,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from artemis.core.diagnostics.schema import (
|
|||||||
)
|
)
|
||||||
from artemis.platform import OSType, platform
|
from artemis.platform import OSType, platform
|
||||||
from artemis.toolchain import toolchain
|
from artemis.toolchain import toolchain
|
||||||
|
from artemis.runtime.adb_discovery import adb_device_discovery
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -477,6 +478,7 @@ class AdbDeviceProbe(BaseProbe):
|
|||||||
async def _parse_adb_devices(self, adb_path: str) -> list[DeviceInfo]:
|
async def _parse_adb_devices(self, adb_path: str) -> list[DeviceInfo]:
|
||||||
"""Execute `adb devices -l` and extract structured device metadata."""
|
"""Execute `adb devices -l` and extract structured device metadata."""
|
||||||
devices: list[DeviceInfo] = []
|
devices: list[DeviceInfo] = []
|
||||||
|
await asyncio.to_thread(adb_device_discovery.refresh, adb_path)
|
||||||
try:
|
try:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
adb_path,
|
adb_path,
|
||||||
@@ -668,6 +670,7 @@ class AdbDeviceProbe(BaseProbe):
|
|||||||
"installed": True,
|
"installed": True,
|
||||||
"adb_path": adb_path,
|
"adb_path": adb_path,
|
||||||
"adb_version": adb_ver,
|
"adb_version": adb_ver,
|
||||||
|
"automatic_connections": adb_device_discovery.results.copy(),
|
||||||
"adb_keys": key_status.to_dict(),
|
"adb_keys": key_status.to_dict(),
|
||||||
"emulator_path": emulator_path,
|
"emulator_path": emulator_path,
|
||||||
"is_emulator_in_path": is_emu_in_path,
|
"is_emulator_in_path": is_emu_in_path,
|
||||||
|
|||||||
@@ -372,6 +372,20 @@ class StorageManager:
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
def discard_session_recordings(self, session_id: str) -> None:
|
||||||
|
"""Remove references to temporary analysis-only captures after the run."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE video_recordings SET local_video_path = NULL, status = 'unavailable' "
|
||||||
|
"WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sessions SET video_filepath = NULL WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
def update_session_video_path(self, session_id: UUID, video_path: str):
|
def update_session_video_path(self, session_id: UUID, video_path: str):
|
||||||
"""Update video filepath across session and video_recordings tables."""
|
"""Update video filepath across session and video_recordings tables."""
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
@@ -1144,11 +1158,21 @@ class StorageManager:
|
|||||||
"""Delete all data associated with a session, including files on disk."""
|
"""Delete all data associated with a session, including files on disk."""
|
||||||
session_id_str = str(session_id)
|
session_id_str = str(session_id)
|
||||||
|
|
||||||
# 1. Get video paths before deleting from DB
|
# 1. Get files referenced by this session before deleting from DB.
|
||||||
video_paths = []
|
video_paths = []
|
||||||
video_ids: list[str] = []
|
video_ids: list[str] = []
|
||||||
|
image_names: set[str] = set()
|
||||||
try:
|
try:
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
|
image_names = {
|
||||||
|
str(name)
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT pre_image_name, post_image_name FROM steps WHERE session_id = ?",
|
||||||
|
(session_id_str,),
|
||||||
|
)
|
||||||
|
for name in row
|
||||||
|
if name
|
||||||
|
}
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"SELECT video_id, local_video_path FROM video_recordings WHERE session_id = ?",
|
"SELECT video_id, local_video_path FROM video_recordings WHERE session_id = ?",
|
||||||
(session_id_str,),
|
(session_id_str,),
|
||||||
@@ -1159,9 +1183,11 @@ class StorageManager:
|
|||||||
if row["local_video_path"]:
|
if row["local_video_path"]:
|
||||||
video_paths.append(Path(row["local_video_path"]))
|
video_paths.append(Path(row["local_video_path"]))
|
||||||
except sqlite3.OperationalError as e:
|
except sqlite3.OperationalError as e:
|
||||||
logger.warning(f"Could not query video_recordings: {e}")
|
logger.warning(f"Could not query session media: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
# 2. Delete from DB tables
|
# 2. Delete from DB tables
|
||||||
|
orphaned_images: list[str] = []
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
conn.execute("PRAGMA foreign_keys = OFF")
|
conn.execute("PRAGMA foreign_keys = OFF")
|
||||||
board_keys = [f"session:{session_id_str}"] + [
|
board_keys = [f"session:{session_id_str}"] + [
|
||||||
@@ -1193,13 +1219,26 @@ class StorageManager:
|
|||||||
(session_id_str,),
|
(session_id_str,),
|
||||||
)
|
)
|
||||||
except sqlite3.OperationalError as e:
|
except sqlite3.OperationalError as e:
|
||||||
logger.warning(f"Failed to delete from table {table}: {e}")
|
logger.error(f"Failed to delete from table {table}: {e}")
|
||||||
conn.commit()
|
raise
|
||||||
conn.execute("VACUUM")
|
for name in image_names:
|
||||||
|
if not conn.execute(
|
||||||
|
"SELECT 1 FROM steps WHERE pre_image_name=? OR post_image_name=? LIMIT 1",
|
||||||
|
(name, name),
|
||||||
|
).fetchone():
|
||||||
|
conn.execute("DELETE FROM images WHERE image_name=?", (name,))
|
||||||
|
orphaned_images.append(name)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
logger.info(f"Database records for session {session_id} cleared.")
|
logger.info(f"Database records for session {session_id} cleared.")
|
||||||
|
|
||||||
|
images_root = (self.base_trace_dir / "images").resolve()
|
||||||
|
for name in orphaned_images:
|
||||||
|
if len(name) == 64 and all(c in "0123456789abcdef" for c in name):
|
||||||
|
image_path = (images_root / f"{name}.jpg").resolve()
|
||||||
|
if image_path.is_relative_to(images_root):
|
||||||
|
image_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
# 3. Delete session directory (notes, etc.)
|
# 3. Delete session directory (notes, etc.)
|
||||||
session_dir = self.base_trace_dir / session_id_str
|
session_dir = self.base_trace_dir / session_id_str
|
||||||
if session_dir.exists() and session_dir.is_dir():
|
if session_dir.exists() and session_dir.is_dir():
|
||||||
@@ -1208,38 +1247,15 @@ class StorageManager:
|
|||||||
logger.info(f"Deleted session directory: {session_dir}")
|
logger.info(f"Deleted session directory: {session_dir}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete session directory {session_dir}: {e}")
|
logger.error(f"Failed to delete session directory {session_dir}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
# 4. Delete video files and their containing folders (which might have been renamed)
|
# 4. Never let a persisted recording path remove a sibling session.
|
||||||
|
trace_root = self.base_trace_dir.resolve()
|
||||||
for video_path in video_paths:
|
for video_path in video_paths:
|
||||||
# video_path is likely: /path/to/traces/task_name/recording.mp4
|
resolved = video_path.resolve()
|
||||||
# If it was renamed, it might be: /path/to/traces/task_name_PASS_timestamp/recording.mp4
|
if not resolved.is_relative_to(trace_root):
|
||||||
|
continue
|
||||||
task_name = video_path.parent.name
|
relative = resolved.relative_to(trace_root)
|
||||||
|
if not relative.parts or relative.parts[0] != session_id_str:
|
||||||
if task_name and task_name != "traces" and task_name != "..":
|
continue
|
||||||
# Search for directories starting with task_name in base_trace_dir
|
resolved.unlink(missing_ok=True)
|
||||||
try:
|
|
||||||
for path in self.base_trace_dir.iterdir():
|
|
||||||
if path.is_dir() and path.name.startswith(task_name):
|
|
||||||
try:
|
|
||||||
shutil.rmtree(path)
|
|
||||||
logger.info(f"Deleted trace/video directory: {path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete directory {path}: {e}")
|
|
||||||
elif path.is_file() and path.name.startswith(task_name):
|
|
||||||
try:
|
|
||||||
path.unlink()
|
|
||||||
logger.info(f"Deleted trace/video file: {path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete file {path}: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error iterating base_trace_dir for cleanup: {e}")
|
|
||||||
|
|
||||||
if video_path.exists():
|
|
||||||
try:
|
|
||||||
video_path.unlink()
|
|
||||||
logger.info(f"Deleted video file directly: {video_path}")
|
|
||||||
if video_path.parent.exists() and not any(video_path.parent.iterdir()):
|
|
||||||
video_path.parent.rmdir()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete video file directly {video_path}: {e}")
|
|
||||||
@@ -19,18 +19,22 @@ import base64
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from adbutils import AdbClient, AdbDevice
|
from adbutils import AdbClient, AdbDevice
|
||||||
from artemis.clients.ui_automator_client import (
|
from artemis.clients.ui_automator_client import (
|
||||||
UIAutomatorClient,
|
UIAutomatorClient,
|
||||||
_parse_hierarchy_xml_to_elements,
|
_parse_hierarchy_xml_to_elements,
|
||||||
)
|
)
|
||||||
|
from artemis.config.agent import load_agent_config
|
||||||
|
from artemis.config.settings import settings
|
||||||
from artemis.config.paths import get_temp_dir
|
from artemis.config.paths import get_temp_dir
|
||||||
from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection
|
from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection
|
||||||
from artemis.toolchain import find_ffmpeg, find_scrcpy
|
from artemis.toolchain import find_ffmpeg, find_scrcpy
|
||||||
from artemis.utils.video import build_scrcpy_record_command
|
from artemis.utils.video import build_scrcpy_record_command
|
||||||
from artemis.utils.ui_filter import filter_ui_hierarchy
|
from artemis.utils.ui_filter import filter_ui_hierarchy
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
|
from artemis.utils.screenshot_limit import limit_screenshot_long_edge
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -88,6 +92,8 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
self._device: AdbDevice | None = None
|
self._device: AdbDevice | None = None
|
||||||
self._recording_process: asyncio.subprocess.Process | None = None
|
self._recording_process: asyncio.subprocess.Process | None = None
|
||||||
self._recording_output_path: Path | None = None
|
self._recording_output_path: Path | None = None
|
||||||
|
self._screenshot_config = load_agent_config().screenshot
|
||||||
|
self._screen_capture_task: asyncio.Task[ScreenData] | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_id(self) -> str:
|
def device_id(self) -> str:
|
||||||
@@ -119,6 +125,24 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
await asyncio.to_thread(self._ui_adb_client.disconnect)
|
await asyncio.to_thread(self._ui_adb_client.disconnect)
|
||||||
|
|
||||||
async def get_screen_data(self, skip_settling: bool = False) -> ScreenData:
|
async def get_screen_data(self, skip_settling: bool = False) -> ScreenData:
|
||||||
|
"""Bound observation latency and share unfinished capture work across retries."""
|
||||||
|
task = self._screen_capture_task
|
||||||
|
if task is None or task.done():
|
||||||
|
task = asyncio.create_task(self._get_screen_data(skip_settling))
|
||||||
|
# A timed-out caller does not cancel a blocking SDK thread or start
|
||||||
|
# another concurrent read on the same device. Retrieve late errors.
|
||||||
|
task.add_done_callback(lambda done: None if done.cancelled() else done.exception())
|
||||||
|
self._screen_capture_task = task
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(
|
||||||
|
asyncio.shield(task), timeout=settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
|
except TimeoutError as exc:
|
||||||
|
raise TimeoutError(
|
||||||
|
f"设备屏幕观测超时({settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS:g} 秒)"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def _get_screen_data(self, skip_settling: bool = False) -> ScreenData:
|
||||||
"""Captures screenshot and XML hierarchy concurrently."""
|
"""Captures screenshot and XML hierarchy concurrently."""
|
||||||
if not skip_settling:
|
if not skip_settling:
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
@@ -128,11 +152,14 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
screenshot_base64: str = ""
|
screenshot_base64: str = ""
|
||||||
ui_hierarchy_xml: str | None = None
|
ui_hierarchy_xml: str | None = None
|
||||||
ui_elements: list[dict[str, Any]] = []
|
ui_elements: list[dict[str, Any]] = []
|
||||||
|
capture_errors: list[str] = []
|
||||||
|
|
||||||
# Check if ui_adb_client has direct screen data support
|
# Check if ui_adb_client has direct screen data support
|
||||||
if self._ui_adb_client and hasattr(self._ui_adb_client, "get_screen_data"):
|
if self._ui_adb_client and hasattr(self._ui_adb_client, "get_screen_data"):
|
||||||
try:
|
try:
|
||||||
raw_ui_data = self._ui_adb_client.get_screen_data()
|
raw_ui_data = await asyncio.to_thread(self._ui_adb_client.get_screen_data)
|
||||||
|
if asyncio.iscoroutine(raw_ui_data):
|
||||||
|
raw_ui_data = await raw_ui_data
|
||||||
if (
|
if (
|
||||||
hasattr(raw_ui_data, "base64")
|
hasattr(raw_ui_data, "base64")
|
||||||
and isinstance(raw_ui_data.base64, str)
|
and isinstance(raw_ui_data.base64, str)
|
||||||
@@ -155,11 +182,18 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
elif isinstance(raw_elements, list):
|
elif isinstance(raw_elements, list):
|
||||||
ui_elements = raw_elements
|
ui_elements = raw_elements
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
capture_errors.append(str(e))
|
||||||
logger.debug(f"Direct ui_adb_client.get_screen_data error: {e}")
|
logger.debug(f"Direct ui_adb_client.get_screen_data error: {e}")
|
||||||
|
|
||||||
if not screenshot_base64 or not isinstance(screenshot_base64, str):
|
if not screenshot_base64 or not isinstance(screenshot_base64, str):
|
||||||
try:
|
try:
|
||||||
pil_img = await asyncio.to_thread(self.device.screenshot)
|
raw_image = await asyncio.to_thread(
|
||||||
|
self.device.shell,
|
||||||
|
["screencap", "-p"],
|
||||||
|
encoding=None,
|
||||||
|
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
pil_img = Image.open(BytesIO(raw_image))
|
||||||
if hasattr(pil_img, "save"):
|
if hasattr(pil_img, "save"):
|
||||||
buf = BytesIO()
|
buf = BytesIO()
|
||||||
pil_img.save(buf, format="PNG")
|
pil_img.save(buf, format="PNG")
|
||||||
@@ -167,18 +201,24 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
self._width, self._height = pil_img.size
|
self._width, self._height = pil_img.size
|
||||||
screenshot_base64 = base64.b64encode(screenshot_bytes).decode("utf-8")
|
screenshot_base64 = base64.b64encode(screenshot_bytes).decode("utf-8")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
capture_errors.append(str(e))
|
||||||
logger.warning(f"Device screenshot capture failed on {self._device_id}: {e}")
|
logger.warning(f"Device screenshot capture failed on {self._device_id}: {e}")
|
||||||
|
|
||||||
if not screenshot_base64 or not isinstance(screenshot_base64, str):
|
if not screenshot_base64 or not isinstance(screenshot_base64, str):
|
||||||
# Fallback for headless testing environments
|
raise RuntimeError(
|
||||||
screenshot_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
"设备截图失败:ADB 与屏幕辅助服务均未返回有效截图;" + "; ".join(capture_errors)
|
||||||
screenshot_bytes = base64.b64decode(screenshot_base64)
|
)
|
||||||
|
|
||||||
|
with Image.open(BytesIO(screenshot_bytes)) as image:
|
||||||
|
if min(image.size) <= 1:
|
||||||
|
raise RuntimeError("设备截图无效:收到占位图,禁止基于旧画面继续操作")
|
||||||
|
image.verify()
|
||||||
|
|
||||||
# 2. Capture UI Hierarchy via UIAutomator
|
# 2. Capture UI Hierarchy via UIAutomator
|
||||||
if self._ui_adb_client and not ui_elements:
|
if self._ui_adb_client and not ui_elements:
|
||||||
try:
|
try:
|
||||||
if hasattr(self._ui_adb_client, "get_hierarchy"):
|
if hasattr(self._ui_adb_client, "get_hierarchy"):
|
||||||
res = self._ui_adb_client.get_hierarchy()
|
res = await asyncio.to_thread(self._ui_adb_client.get_hierarchy)
|
||||||
hierarchy = await res if asyncio.iscoroutine(res) else res
|
hierarchy = await res if asyncio.iscoroutine(res) else res
|
||||||
if isinstance(hierarchy, str):
|
if isinstance(hierarchy, str):
|
||||||
ui_hierarchy_xml = hierarchy
|
ui_hierarchy_xml = hierarchy
|
||||||
@@ -186,7 +226,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
else:
|
else:
|
||||||
ui_elements = hierarchy
|
ui_elements = hierarchy
|
||||||
elif hasattr(self._ui_adb_client, "get_ui_elements"):
|
elif hasattr(self._ui_adb_client, "get_ui_elements"):
|
||||||
res = self._ui_adb_client.get_ui_elements()
|
res = await asyncio.to_thread(self._ui_adb_client.get_ui_elements)
|
||||||
ui_elements = await res if asyncio.iscoroutine(res) else res
|
ui_elements = await res if asyncio.iscoroutine(res) else res
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"UI hierarchy extraction failed: {e}")
|
logger.debug(f"UI hierarchy extraction failed: {e}")
|
||||||
@@ -205,16 +245,15 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
screen_width=w,
|
screen_width=w,
|
||||||
screen_height=h,
|
screen_height=h,
|
||||||
)
|
)
|
||||||
clean_b64 = (
|
clean_b64 = screenshot_base64
|
||||||
screenshot_base64
|
clean_bytes = screenshot_bytes
|
||||||
if isinstance(screenshot_base64, str) and screenshot_base64
|
if self._screenshot_config.enabled:
|
||||||
else "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
limited_bytes = limit_screenshot_long_edge(
|
||||||
)
|
clean_bytes, self._screenshot_config.max_long_edge
|
||||||
clean_bytes = (
|
)
|
||||||
screenshot_bytes
|
if limited_bytes is not clean_bytes:
|
||||||
if isinstance(screenshot_bytes, bytes) and screenshot_bytes
|
clean_bytes = limited_bytes
|
||||||
else base64.b64decode(clean_b64)
|
clean_b64 = base64.b64encode(clean_bytes).decode("utf-8")
|
||||||
)
|
|
||||||
|
|
||||||
return ScreenData(
|
return ScreenData(
|
||||||
screenshot_bytes=clean_bytes,
|
screenshot_bytes=clean_bytes,
|
||||||
@@ -245,7 +284,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
cmd = f" && sleep {delay_ms / 1000.0:.3f} && ".join(taps)
|
cmd = f" && sleep {delay_ms / 1000.0:.3f} && ".join(taps)
|
||||||
|
|
||||||
logger.info(f"[ADB] {cmd}")
|
logger.info(f"[ADB] {cmd}")
|
||||||
await asyncio.to_thread(self.device.shell, cmd)
|
await asyncio.to_thread(
|
||||||
|
self.device.shell, cmd, timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Tap failed at ({x}, {y}): {e}")
|
logger.error(f"Tap failed at ({x}, {y}): {e}")
|
||||||
@@ -265,7 +306,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
try:
|
try:
|
||||||
cmd = f"input swipe {start_x} {start_y} {end_x} {end_y} {duration_ms}"
|
cmd = f"input swipe {start_x} {start_y} {end_x} {end_y} {duration_ms}"
|
||||||
logger.info(f"[ADB] {cmd}")
|
logger.info(f"[ADB] {cmd}")
|
||||||
await asyncio.to_thread(self.device.shell, cmd)
|
await asyncio.to_thread(
|
||||||
|
self.device.shell, cmd, timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Swipe failed from ({start_x},{start_y}) to ({end_x},{end_y}): {e}")
|
logger.error(f"Swipe failed from ({start_x},{start_y}) to ({end_x},{end_y}): {e}")
|
||||||
@@ -314,7 +357,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
"input keyevent 67 && "
|
"input keyevent 67 && "
|
||||||
"input keyevent 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67"
|
"input keyevent 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67"
|
||||||
)
|
)
|
||||||
await asyncio.to_thread(self.device.shell, clear_cmd)
|
await asyncio.to_thread(
|
||||||
|
self.device.shell,
|
||||||
|
clear_cmd,
|
||||||
|
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Append mode: move cursor to the very end of existing text
|
# Append mode: move cursor to the very end of existing text
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
@@ -336,7 +383,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
set_clip_ok = True
|
set_clip_ok = True
|
||||||
|
|
||||||
if set_clip_ok:
|
if set_clip_ok:
|
||||||
await asyncio.to_thread(self.device.shell, "input keyevent 279")
|
await asyncio.to_thread(
|
||||||
|
self.device.shell,
|
||||||
|
"input keyevent 279",
|
||||||
|
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Clipboard paste fallback to ADB input: {e}")
|
logger.debug(f"Clipboard paste fallback to ADB input: {e}")
|
||||||
@@ -349,7 +400,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
if "adbkeyboard" in str(default_ime).lower():
|
if "adbkeyboard" in str(default_ime).lower():
|
||||||
b64_text = base64.b64encode(norm_text.encode("utf-8")).decode("utf-8")
|
b64_text = base64.b64encode(norm_text.encode("utf-8")).decode("utf-8")
|
||||||
broadcast_cmd = f"am broadcast -a ADB_INPUT_B64 --es msg '{b64_text}'"
|
broadcast_cmd = f"am broadcast -a ADB_INPUT_B64 --es msg '{b64_text}'"
|
||||||
await asyncio.to_thread(self.device.shell, broadcast_cmd)
|
await asyncio.to_thread(
|
||||||
|
self.device.shell,
|
||||||
|
broadcast_cmd,
|
||||||
|
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# ADBKeyboard probe/broadcast failed; fall through to native input.
|
# ADBKeyboard probe/broadcast failed; fall through to native input.
|
||||||
@@ -360,10 +415,18 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
if i > 0:
|
if i > 0:
|
||||||
# Send Enter key between lines
|
# Send Enter key between lines
|
||||||
await asyncio.to_thread(self.device.shell, "input keyevent 66")
|
await asyncio.to_thread(
|
||||||
|
self.device.shell,
|
||||||
|
"input keyevent 66",
|
||||||
|
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
if line:
|
if line:
|
||||||
escaped = _escape_for_adb_text(line)
|
escaped = _escape_for_adb_text(line)
|
||||||
await asyncio.to_thread(self.device.shell, f"input text {escaped}")
|
await asyncio.to_thread(
|
||||||
|
self.device.shell,
|
||||||
|
f"input text {escaped}",
|
||||||
|
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Input text failed for '{text}': {e}")
|
logger.error(f"Input text failed for '{text}': {e}")
|
||||||
@@ -376,7 +439,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
key_name = str(key).lower().replace("keycode.", "")
|
key_name = str(key).lower().replace("keycode.", "")
|
||||||
keycode_val = ANDROID_KEYCODE_MAP.get(key_name, key)
|
keycode_val = ANDROID_KEYCODE_MAP.get(key_name, key)
|
||||||
|
|
||||||
await asyncio.to_thread(self.device.shell, f"input keyevent {keycode_val}")
|
await asyncio.to_thread(
|
||||||
|
self.device.shell,
|
||||||
|
f"input keyevent {keycode_val}",
|
||||||
|
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Press key failed for '{key}': {e}")
|
logger.error(f"Press key failed for '{key}': {e}")
|
||||||
@@ -385,7 +452,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
async def launch_app(self, package_name: str) -> bool:
|
async def launch_app(self, package_name: str) -> bool:
|
||||||
try:
|
try:
|
||||||
cmd = f"monkey -p {package_name} -c android.intent.category.LAUNCHER 1"
|
cmd = f"monkey -p {package_name} -c android.intent.category.LAUNCHER 1"
|
||||||
await asyncio.to_thread(self.device.shell, cmd)
|
await asyncio.to_thread(
|
||||||
|
self.device.shell, cmd, timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Launch app failed for '{package_name}': {e}")
|
logger.error(f"Launch app failed for '{package_name}': {e}")
|
||||||
@@ -393,7 +462,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
|
|
||||||
async def stop_app(self, package_name: str) -> bool:
|
async def stop_app(self, package_name: str) -> bool:
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(self.device.shell, f"am force-stop {package_name}")
|
await asyncio.to_thread(
|
||||||
|
self.device.shell,
|
||||||
|
f"am force-stop {package_name}",
|
||||||
|
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Stop app failed for '{package_name}': {e}")
|
logger.error(f"Stop app failed for '{package_name}': {e}")
|
||||||
@@ -429,7 +502,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
async def execute_shell(self, command: str, timeout_seconds: float = 15.0) -> str:
|
async def execute_shell(self, command: str, timeout_seconds: float = 15.0) -> str:
|
||||||
try:
|
try:
|
||||||
return await asyncio.wait_for(
|
return await asyncio.wait_for(
|
||||||
asyncio.to_thread(self.device.shell, command),
|
asyncio.to_thread(
|
||||||
|
self.device.shell,
|
||||||
|
command,
|
||||||
|
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||||
|
),
|
||||||
timeout=timeout_seconds,
|
timeout=timeout_seconds,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+25
-3
@@ -284,6 +284,7 @@ async def exit_settlement_node(state: State, ctx: ArtemisContext):
|
|||||||
max_attempts = int(getattr(setup, "final_check_max_attempts", 3) or 3) if setup else 3
|
max_attempts = int(getattr(setup, "final_check_max_attempts", 3) or 3) if setup else 3
|
||||||
|
|
||||||
verify_blocked = False
|
verify_blocked = False
|
||||||
|
case_result = None
|
||||||
blocked_findings: list[str] = []
|
blocked_findings: list[str] = []
|
||||||
if final_check_enabled(ctx):
|
if final_check_enabled(ctx):
|
||||||
attempt_no = ctx.final_check_attempts + 1
|
attempt_no = ctx.final_check_attempts + 1
|
||||||
@@ -320,9 +321,20 @@ async def exit_settlement_node(state: State, ctx: ArtemisContext):
|
|||||||
),
|
),
|
||||||
timeout=checkpoint_timeout,
|
timeout=checkpoint_timeout,
|
||||||
)
|
)
|
||||||
|
case_result = report.test_result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Fail-open: release, but the verdict value stays inconclusive.
|
# Fail-open: release, but the verdict value stays inconclusive.
|
||||||
logger.warning(f"Final check errored ({e}); releasing fail-open.")
|
logger.warning(f"Final check errored ({e}); releasing fail-open.")
|
||||||
|
from artemis.runtime.case_result import CaseTestResult
|
||||||
|
|
||||||
|
detail = (
|
||||||
|
f"超过 {checkpoint_timeout:g} 秒"
|
||||||
|
if isinstance(e, TimeoutError)
|
||||||
|
else f"{type(e).__name__}: {e}"
|
||||||
|
)
|
||||||
|
case_result = CaseTestResult(
|
||||||
|
status="inconclusive", reason=f"最终验收未完成:{detail}。"
|
||||||
|
)
|
||||||
error_verdicts: list[dict] = []
|
error_verdicts: list[dict] = []
|
||||||
for ci in snapshot.all_check_items:
|
for ci in snapshot.all_check_items:
|
||||||
append_ledger_record(
|
append_ledger_record(
|
||||||
@@ -444,10 +456,20 @@ async def exit_settlement_node(state: State, ctx: ArtemisContext):
|
|||||||
verify_blocked=verify_blocked,
|
verify_blocked=verify_blocked,
|
||||||
retired=retired if isinstance(retired, set) else None,
|
retired=retired if isinstance(retired, set) else None,
|
||||||
)
|
)
|
||||||
if snapshot.all_check_items or records or verify_blocked:
|
if snapshot.all_check_items or records or verify_blocked or case_result is not None:
|
||||||
update["run_outcome"] = outcome.model_dump()
|
from artemis.runtime.case_result import resolve_case_result
|
||||||
|
|
||||||
|
payload = outcome.model_dump()
|
||||||
|
if case_result:
|
||||||
|
payload["test_result"] = case_result.model_dump()
|
||||||
|
resolved = resolve_case_result(
|
||||||
|
payload, "completed" if outcome.task_status == "completed" else "failed"
|
||||||
|
)
|
||||||
|
payload["test_result"] = resolved.model_dump() if resolved else None
|
||||||
|
update["run_outcome"] = payload
|
||||||
# BLOCKED/partial wrap-ups carry the last findings in the metadata file.
|
# BLOCKED/partial wrap-ups carry the last findings in the metadata file.
|
||||||
extra: dict = {"last_findings": blocked_findings} if blocked_findings else {}
|
extra: dict = {"last_findings": blocked_findings} if blocked_findings else {}
|
||||||
|
extra["test_result"] = payload["test_result"]
|
||||||
# Which UI-hierarchy source served the run, so a report reader can tell a
|
# Which UI-hierarchy source served the run, so a report reader can tell a
|
||||||
# helper run from a UIAutomator2 fallback without reading logs.
|
# helper run from a UIAutomator2 fallback without reading logs.
|
||||||
from artemis.clients.screen_client_factory import hierarchy_backend_summary
|
from artemis.clients.screen_client_factory import hierarchy_backend_summary
|
||||||
@@ -461,7 +483,7 @@ async def exit_settlement_node(state: State, ctx: ArtemisContext):
|
|||||||
{
|
{
|
||||||
"event": "run_outcome",
|
"event": "run_outcome",
|
||||||
"phase": "outcome",
|
"phase": "outcome",
|
||||||
**outcome.model_dump(),
|
**payload,
|
||||||
"last_findings": list(blocked_findings),
|
"last_findings": list(blocked_findings),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+10
-12
@@ -23,6 +23,7 @@ from artemis.controllers.unified_controller import UnifiedMobileController
|
|||||||
from artemis.data_engine.trace import trace
|
from artemis.data_engine.trace import trace
|
||||||
from artemis.graph.state import State
|
from artemis.graph.state import State
|
||||||
from artemis.graph.visibility import strict_state
|
from artemis.graph.visibility import strict_state
|
||||||
|
from artemis.runtime.task_control import TaskControl
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
from artemis.utils.ocr_api import is_ocr_configured, perform_ocr
|
from artemis.utils.ocr_api import is_ocr_configured, perform_ocr
|
||||||
from artemis.utils.ocr_xml_fusion import (
|
from artemis.utils.ocr_xml_fusion import (
|
||||||
@@ -78,18 +79,9 @@ def _check_injected_instruction_file(base_dir: str) -> dict | None:
|
|||||||
Returns the payload dict: `instruction` (str | None) plus the explicit
|
Returns the payload dict: `instruction` (str | None) plus the explicit
|
||||||
`release_loop` stop signal (bool) declared by the injecting caller.
|
`release_loop` stop signal (bool) declared by the injecting caller.
|
||||||
"""
|
"""
|
||||||
instruction_path = Path(base_dir) / "injected_instruction.json"
|
from artemis.runtime.task_control import consume_instructions
|
||||||
if instruction_path.exists():
|
|
||||||
with open(instruction_path, encoding="utf-8") as f:
|
return consume_instructions(base_dir)
|
||||||
data = json.load(f)
|
|
||||||
instruction_path.unlink()
|
|
||||||
payload = {
|
|
||||||
"instruction": data.get("instruction"),
|
|
||||||
"release_loop": bool(data.get("release_loop")),
|
|
||||||
}
|
|
||||||
logger.info(f"Read and deleted injected instruction payload: {payload}")
|
|
||||||
return payload
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _unprotect_current_check_lines(ctx: ArtemisContext) -> None:
|
def _unprotect_current_check_lines(ctx: ArtemisContext) -> None:
|
||||||
@@ -122,6 +114,12 @@ async def perception_node(state: State, ctx: ArtemisContext) -> dict:
|
|||||||
"""
|
"""
|
||||||
state = strict_state(state, "perception")
|
state = strict_state(state, "perception")
|
||||||
logger.info("Entering Perception Node...")
|
logger.info("Entering Perception Node...")
|
||||||
|
control = getattr(ctx, "task_control", None)
|
||||||
|
if isinstance(control, TaskControl):
|
||||||
|
await control.checkpoint(
|
||||||
|
on_pause=getattr(ctx, "on_task_pause", None),
|
||||||
|
on_resume=getattr(ctx, "on_task_resume", None),
|
||||||
|
)
|
||||||
|
|
||||||
if not ctx.data_engine.current_step_id:
|
if not ctx.data_engine.current_step_id:
|
||||||
ctx.data_engine.allocate_step_id()
|
ctx.data_engine.allocate_step_id()
|
||||||
|
|||||||
@@ -117,6 +117,11 @@ def init_command() -> None:
|
|||||||
"OPENAI_TIMEOUT_SECONDS=300",
|
"OPENAI_TIMEOUT_SECONDS=300",
|
||||||
"OPENAI_MAX_RETRIES=5",
|
"OPENAI_MAX_RETRIES=5",
|
||||||
"OPENAI_RETRY_INTERVAL_SECONDS=10",
|
"OPENAI_RETRY_INTERVAL_SECONDS=10",
|
||||||
|
"OPENAI_RATE_LIMIT_RETRY_SECONDS=60",
|
||||||
|
"OPENAI_TOOL_FALLBACK_MODEL=gpt-6-luna",
|
||||||
|
"OPENAI_TOOL_FALLBACK_REASONING_EFFORT=high",
|
||||||
|
"OPENAI_TOOL_FALLBACK_MAX_RETRIES=3",
|
||||||
|
"ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS=3",
|
||||||
"ARTEMIS_VIDEO_ENABLED=false",
|
"ARTEMIS_VIDEO_ENABLED=false",
|
||||||
"ARTEMIS_TRACES_DIR=./traces",
|
"ARTEMIS_TRACES_DIR=./traces",
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -51,212 +51,28 @@ ARTEMIS_MCP_TOOLS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _eager_tools_config() -> dict[str, dict[str, bool]]:
|
|
||||||
"""Return the eager-loading extension used by clients that support it."""
|
|
||||||
return {tool_name: {"eager": True} for tool_name in ARTEMIS_MCP_TOOLS}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_vscode_user_dir() -> Path:
|
|
||||||
"""Returns the platform-specific VS Code user configuration directory."""
|
|
||||||
if sys.platform == "darwin":
|
|
||||||
return Path.home() / "Library" / "Application Support" / "Code" / "User"
|
|
||||||
elif sys.platform == "win32":
|
|
||||||
appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
|
||||||
return Path(appdata) / "Code" / "User"
|
|
||||||
else:
|
|
||||||
return Path.home() / ".config" / "Code" / "User"
|
|
||||||
|
|
||||||
|
|
||||||
def _get_config_snippet(client: str, python_exe: str, project_root: str) -> dict:
|
def _get_config_snippet(client: str, python_exe: str, project_root: str) -> dict:
|
||||||
"""Generates MCP configuration dictionary for the specified client."""
|
"""Generate the supported Codex MCP configuration."""
|
||||||
config_body = {
|
if client != "codex":
|
||||||
"command": python_exe,
|
raise ValueError("Only Codex MCP configuration is supported")
|
||||||
"args": ["-m", "mcp_server"],
|
return {
|
||||||
"env": {
|
"mcp_servers": {
|
||||||
"PYTHONUNBUFFERED": "1",
|
"artemis": {
|
||||||
"PYTHONPATH": project_root,
|
"command": python_exe,
|
||||||
"ARTEMIS_DESKTOP_NOTIFY": "true",
|
"args": ["-m", "mcp_server"],
|
||||||
},
|
"cwd": project_root,
|
||||||
|
"env": {
|
||||||
|
"PYTHONUNBUFFERED": "1",
|
||||||
|
"PYTHONPATH": project_root,
|
||||||
|
"ARTEMIS_DESKTOP_NOTIFY": "true",
|
||||||
|
},
|
||||||
|
"enabled": True,
|
||||||
|
"required": True,
|
||||||
|
"startup_timeout_sec": 120,
|
||||||
|
"enabled_tools": list(ARTEMIS_MCP_TOOLS),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
config_with_cwd = {**config_body, "cwd": project_root}
|
|
||||||
|
|
||||||
if client == "antigravity":
|
|
||||||
return {
|
|
||||||
"mcpServers": {
|
|
||||||
"artemis": {
|
|
||||||
**config_with_cwd,
|
|
||||||
# This is the documented way to expose every tool. Avoid
|
|
||||||
# proprietary eager fields in the current config so a
|
|
||||||
# strict parser can always load the server.
|
|
||||||
"disabledTools": [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
elif client == "jetski":
|
|
||||||
return {
|
|
||||||
"mcpServers": {
|
|
||||||
"artemis": {
|
|
||||||
**config_with_cwd,
|
|
||||||
"disabledTools": [],
|
|
||||||
# Retain the legacy Jetski eager extension only in its
|
|
||||||
# legacy config file. Current Antigravity gets the strict,
|
|
||||||
# documented configuration above as a safe fallback.
|
|
||||||
"tools": _eager_tools_config(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
elif client == "openclaw":
|
|
||||||
return {
|
|
||||||
"mcp": {
|
|
||||||
"servers": {
|
|
||||||
"artemis": {
|
|
||||||
**config_with_cwd,
|
|
||||||
"enabled": True,
|
|
||||||
"connectionTimeoutMs": 120000,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
elif client == "codex":
|
|
||||||
return {
|
|
||||||
"mcp_servers": {
|
|
||||||
"artemis": {
|
|
||||||
**config_with_cwd,
|
|
||||||
# Codex has no eager/deferred MCP setting. Requiring the
|
|
||||||
# server makes it initialize during host startup, while an
|
|
||||||
# explicit allow-list guarantees all Artemis tools are
|
|
||||||
# registered. The host may still present schemas through
|
|
||||||
# tool search to control context size.
|
|
||||||
"enabled": True,
|
|
||||||
"required": True,
|
|
||||||
"startup_timeout_sec": 120,
|
|
||||||
"enabled_tools": list(ARTEMIS_MCP_TOOLS),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
elif client == "vscode":
|
|
||||||
return {"servers": {"artemis": {**config_with_cwd, "type": "stdio"}}}
|
|
||||||
elif client in ("claude", "claude_code", "claude_desktop"):
|
|
||||||
return {"mcpServers": {"artemis": {**config_body, "type": "stdio"}}}
|
|
||||||
elif client == "cline":
|
|
||||||
return {
|
|
||||||
"mcpServers": {
|
|
||||||
"artemis": {
|
|
||||||
**config_body,
|
|
||||||
"disabled": False,
|
|
||||||
"autoApprove": [],
|
|
||||||
"timeout": 120,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
elif client in ("roo", "roo_code"):
|
|
||||||
return {
|
|
||||||
"mcpServers": {
|
|
||||||
"artemis": {
|
|
||||||
**config_body,
|
|
||||||
"disabled": False,
|
|
||||||
"alwaysAllow": [],
|
|
||||||
"timeout": 120,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else: # cursor, windsurf, generic
|
|
||||||
return {"mcpServers": {"artemis": config_body}}
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_lenient(text: str) -> dict | None:
|
|
||||||
"""Parses JSON or JSONC (JSON with comments / trailing commas) safely.
|
|
||||||
|
|
||||||
Returns ``None`` when the text cannot be parsed as a JSON object, so callers
|
|
||||||
can tell a genuinely empty ``{}`` file apart from a corrupt one.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = json.loads(text)
|
|
||||||
return data if isinstance(data, dict) else None
|
|
||||||
except ValueError:
|
|
||||||
# Not strict JSON; fall through to the lenient JSONC cleanup below.
|
|
||||||
pass
|
|
||||||
# Remove // single-line comments
|
|
||||||
cleaned = re.sub(r"//.*", "", text)
|
|
||||||
# Remove /* ... */ multi-line comments
|
|
||||||
cleaned = re.sub(r"/\*.*?\*/", "", cleaned, flags=re.DOTALL)
|
|
||||||
# Remove trailing commas before } or ]
|
|
||||||
cleaned = re.sub(r",\s*([}\]])", r"\1", cleaned)
|
|
||||||
try:
|
|
||||||
data = json.loads(cleaned)
|
|
||||||
return data if isinstance(data, dict) else None
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_json_file(
|
|
||||||
file_path: Path,
|
|
||||||
server_name: str,
|
|
||||||
server_config: dict,
|
|
||||||
key_name: str | tuple[str, ...] = "mcpServers",
|
|
||||||
remove_paths: tuple[tuple[str, ...], ...] = (),
|
|
||||||
strict_parse: bool = False,
|
|
||||||
) -> bool:
|
|
||||||
"""Merges server configuration into a target JSON file without overwriting existing servers.
|
|
||||||
|
|
||||||
With ``strict_parse=True``, an existing non-empty file that cannot be parsed
|
|
||||||
aborts the merge instead of being replaced. Use this for files that hold
|
|
||||||
state beyond MCP config (e.g. ~/.claude.json), where a degraded rewrite
|
|
||||||
would destroy unrelated data.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
data: dict = {}
|
|
||||||
if file_path.exists() and file_path.stat().st_size > 0:
|
|
||||||
raw_text = file_path.read_text(encoding="utf-8")
|
|
||||||
parsed = _parse_json_lenient(raw_text)
|
|
||||||
data = parsed or {}
|
|
||||||
if parsed is None and raw_text.strip():
|
|
||||||
if strict_parse:
|
|
||||||
logger.warning(
|
|
||||||
f"Existing JSON in {file_path} could not be parsed; refusing to rewrite it. "
|
|
||||||
"Fix or remove the file, then re-run the install."
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
# Backup unparseable existing file to prevent accidental loss
|
|
||||||
backup_path = file_path.with_suffix(file_path.suffix + ".bak")
|
|
||||||
try:
|
|
||||||
backup_path.write_text(raw_text, encoding="utf-8")
|
|
||||||
logger.warning(
|
|
||||||
f"Existing JSON in {file_path} could not be parsed; backup created at {backup_path}"
|
|
||||||
)
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning(
|
|
||||||
f"Existing JSON in {file_path} could not be parsed AND the backup to"
|
|
||||||
f" {backup_path} failed ({exc}); refusing to rewrite it."
|
|
||||||
" Fix or remove the file, then re-run the install."
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
for remove_path in remove_paths:
|
|
||||||
parent = data
|
|
||||||
for part in remove_path[:-1]:
|
|
||||||
child = parent.get(part)
|
|
||||||
if not isinstance(child, dict):
|
|
||||||
parent = {}
|
|
||||||
break
|
|
||||||
parent = child
|
|
||||||
if parent:
|
|
||||||
parent.pop(remove_path[-1], None)
|
|
||||||
|
|
||||||
key_path = (key_name,) if isinstance(key_name, str) else key_name
|
|
||||||
target = data
|
|
||||||
for part in key_path:
|
|
||||||
if not isinstance(target.get(part), dict):
|
|
||||||
target[part] = {}
|
|
||||||
target = target[part]
|
|
||||||
|
|
||||||
target[server_name] = server_config
|
|
||||||
file_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not update MCP config file {file_path}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _codex_toml_block(server_config: dict) -> str:
|
def _codex_toml_block(server_config: dict) -> str:
|
||||||
@@ -302,7 +118,9 @@ def _merge_codex_toml(file_path: Path, server_config: dict) -> bool:
|
|||||||
managed_pattern = re.compile(
|
managed_pattern = re.compile(
|
||||||
rf"(?ms)^{re.escape(begin_marker)}\n.*?^{re.escape(end_marker)}\n?"
|
rf"(?ms)^{re.escape(begin_marker)}\n.*?^{re.escape(end_marker)}\n?"
|
||||||
)
|
)
|
||||||
updated = managed_pattern.sub(managed_block, existing, count=1)
|
# Windows paths contain backslashes; a replacement string would
|
||||||
|
# interpret them as regex escapes on a second installation.
|
||||||
|
updated = managed_pattern.sub(lambda _: managed_block, existing, count=1)
|
||||||
else:
|
else:
|
||||||
# Remove an older, unmanaged Artemis table before adding the managed block.
|
# Remove an older, unmanaged Artemis table before adding the managed block.
|
||||||
kept_lines: list[str] = []
|
kept_lines: list[str] = []
|
||||||
@@ -342,36 +160,6 @@ def _merge_codex_toml(file_path: Path, server_config: dict) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _remove_rules_block(file_path: Path) -> bool:
|
|
||||||
"""Removes a previously injected managed ARTEMIS block from a rules file.
|
|
||||||
|
|
||||||
Used to migrate clients that load both their main rules file and a
|
|
||||||
standalone rule file (e.g. Claude Code loads ~/.claude/CLAUDE.md AND
|
|
||||||
~/.claude/rules/*.md), where keeping the injected block would duplicate
|
|
||||||
the rules in the model's context. If the file contains nothing but the
|
|
||||||
managed block, the file is deleted entirely.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if not file_path.exists():
|
|
||||||
return True
|
|
||||||
begin_marker = "<!-- BEGIN ARTEMIS MOBILE TESTING RULES -->"
|
|
||||||
end_marker = "<!-- END ARTEMIS MOBILE TESTING RULES -->"
|
|
||||||
existing = file_path.read_text(encoding="utf-8")
|
|
||||||
if begin_marker not in existing or end_marker not in existing:
|
|
||||||
return True
|
|
||||||
prefix = existing.split(begin_marker)[0]
|
|
||||||
suffix = existing.split(end_marker)[1]
|
|
||||||
remainder = f"{prefix.rstrip()}\n\n{suffix.lstrip()}".strip()
|
|
||||||
if remainder:
|
|
||||||
file_path.write_text(remainder + "\n", encoding="utf-8")
|
|
||||||
else:
|
|
||||||
file_path.unlink()
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not remove rules block from {file_path}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _inject_rules_block(file_path: Path, content: str) -> bool:
|
def _inject_rules_block(file_path: Path, content: str) -> bool:
|
||||||
"""Injects or updates a managed ARTEMIS block in a global rules file without overwriting user content."""
|
"""Injects or updates a managed ARTEMIS block in a global rules file without overwriting user content."""
|
||||||
try:
|
try:
|
||||||
@@ -404,297 +192,36 @@ def _inject_rules_block(file_path: Path, content: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _write_rule_file(file_path: Path, content: str) -> bool:
|
|
||||||
"""Writes or overwrites a standalone rule file."""
|
|
||||||
try:
|
|
||||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
new_text = content.strip() + "\n"
|
|
||||||
if file_path.exists():
|
|
||||||
try:
|
|
||||||
if file_path.read_text(encoding="utf-8") == new_text:
|
|
||||||
return True
|
|
||||||
except (OSError, UnicodeDecodeError):
|
|
||||||
# Unreadable existing file: skip the no-op check and rewrite it.
|
|
||||||
pass
|
|
||||||
file_path.write_text(new_text, encoding="utf-8")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not write rule file {file_path}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _write_cursor_mdc(file_path: Path, content: str) -> bool:
|
|
||||||
"""Writes a Cursor .mdc rule file with required YAML frontmatter."""
|
|
||||||
try:
|
|
||||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
mdc_header = (
|
|
||||||
"---\n"
|
|
||||||
"description: Artemis Mobile Testing Mindset & Rules\n"
|
|
||||||
"globs: **/*\n"
|
|
||||||
"alwaysApply: true\n"
|
|
||||||
"---\n\n"
|
|
||||||
)
|
|
||||||
new_text = mdc_header + content.strip() + "\n"
|
|
||||||
if file_path.exists():
|
|
||||||
try:
|
|
||||||
if file_path.read_text(encoding="utf-8") == new_text:
|
|
||||||
return True
|
|
||||||
except (OSError, UnicodeDecodeError):
|
|
||||||
# Unreadable existing file: skip the no-op check and rewrite it.
|
|
||||||
pass
|
|
||||||
file_path.write_text(new_text, encoding="utf-8")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not write Cursor rule file {file_path}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def install_rules(client: str, project_root: str) -> list[str]:
|
def install_rules(client: str, project_root: str) -> list[str]:
|
||||||
"""Auto-installs ARTEMIS testing rules globally into user IDE rule directories across any OS."""
|
"""Install testing rules only into the Codex instructions file."""
|
||||||
installed_paths: list[str] = []
|
if client != "codex":
|
||||||
|
raise ValueError("Only Codex MCP configuration is supported")
|
||||||
rules_src = Path(project_root) / "mcp_server" / "rules.md"
|
rules_src = Path(project_root) / "mcp_server" / "rules.md"
|
||||||
if not rules_src.exists():
|
if not rules_src.exists():
|
||||||
alt_src = Path(__file__).resolve().parents[4] / "mcp_server" / "rules.md"
|
rules_src = Path(__file__).resolve().parents[4] / "mcp_server" / "rules.md"
|
||||||
if alt_src.exists():
|
|
||||||
rules_src = alt_src
|
|
||||||
else:
|
|
||||||
logger.warning(f"Could not locate rules.md at {rules_src}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw_rules = rules_src.read_text(encoding="utf-8").strip()
|
raw_rules = rules_src.read_text(encoding="utf-8").strip()
|
||||||
except Exception as e:
|
except OSError:
|
||||||
logger.warning(f"Could not read rules file {rules_src}: {e}")
|
logger.warning(f"Could not read rules file {rules_src}")
|
||||||
return []
|
return []
|
||||||
|
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
||||||
targets = (
|
override_file = codex_home / "AGENTS.override.md"
|
||||||
[
|
agents_file = (
|
||||||
"antigravity",
|
override_file
|
||||||
"cursor",
|
if override_file.exists() and override_file.read_text(encoding="utf-8").strip()
|
||||||
"claude",
|
else codex_home / "AGENTS.md"
|
||||||
"windsurf",
|
|
||||||
"vscode",
|
|
||||||
"cline",
|
|
||||||
"roo",
|
|
||||||
"openclaw",
|
|
||||||
"codex",
|
|
||||||
]
|
|
||||||
if client == "all"
|
|
||||||
else [client]
|
|
||||||
)
|
)
|
||||||
|
return [str(agents_file)] if _inject_rules_block(agents_file, raw_rules) else []
|
||||||
for target in targets:
|
|
||||||
if target in ("antigravity", "jetski"):
|
|
||||||
gemini_md = Path.home() / ".gemini" / "GEMINI.md"
|
|
||||||
global_rule = Path.home() / ".gemini" / "rules" / "artemis.md"
|
|
||||||
if _inject_rules_block(gemini_md, raw_rules):
|
|
||||||
installed_paths.append(str(gemini_md))
|
|
||||||
if _write_rule_file(global_rule, raw_rules):
|
|
||||||
installed_paths.append(str(global_rule))
|
|
||||||
elif target == "cursor":
|
|
||||||
cursor_rules_file = Path.home() / ".cursorrules"
|
|
||||||
global_mdc = Path.home() / ".cursor" / "rules" / "artemis.mdc"
|
|
||||||
if _inject_rules_block(cursor_rules_file, raw_rules):
|
|
||||||
installed_paths.append(str(cursor_rules_file))
|
|
||||||
if _write_cursor_mdc(global_mdc, raw_rules):
|
|
||||||
installed_paths.append(str(global_mdc))
|
|
||||||
elif target in ("claude", "claude_code"):
|
|
||||||
# Claude Code loads BOTH ~/.claude/CLAUDE.md and ~/.claude/rules/*.md
|
|
||||||
# into context every session, so install the rules in exactly one
|
|
||||||
# place (the standalone rule file) and migrate away any block a
|
|
||||||
# previous version injected into CLAUDE.md.
|
|
||||||
claude_md = Path.home() / ".claude" / "CLAUDE.md"
|
|
||||||
global_rule = Path.home() / ".claude" / "rules" / "artemis.md"
|
|
||||||
_remove_rules_block(claude_md)
|
|
||||||
if _write_rule_file(global_rule, raw_rules):
|
|
||||||
installed_paths.append(str(global_rule))
|
|
||||||
elif target == "claude_desktop":
|
|
||||||
# Claude Desktop only reads ~/.claude/CLAUDE.md (not rules/*.md), so
|
|
||||||
# the rules block stays injected there for this target.
|
|
||||||
claude_md = Path.home() / ".claude" / "CLAUDE.md"
|
|
||||||
if _inject_rules_block(claude_md, raw_rules):
|
|
||||||
installed_paths.append(str(claude_md))
|
|
||||||
elif target == "windsurf":
|
|
||||||
global_rule = Path.home() / ".codeium" / "windsurf" / "rules" / "artemis.md"
|
|
||||||
global_mem = Path.home() / ".codeium" / "windsurf" / "memories" / "global_rules.md"
|
|
||||||
if _inject_rules_block(global_mem, raw_rules):
|
|
||||||
installed_paths.append(str(global_mem))
|
|
||||||
if _write_rule_file(global_rule, raw_rules):
|
|
||||||
installed_paths.append(str(global_rule))
|
|
||||||
elif target == "vscode":
|
|
||||||
global_rule = Path.home() / ".vscode" / "rules" / "artemis.md"
|
|
||||||
user_rule = _get_vscode_user_dir() / "rules" / "artemis.md"
|
|
||||||
if _write_rule_file(global_rule, raw_rules):
|
|
||||||
installed_paths.append(str(global_rule))
|
|
||||||
if _write_rule_file(user_rule, raw_rules):
|
|
||||||
installed_paths.append(str(user_rule))
|
|
||||||
elif target == "cline":
|
|
||||||
clinerules = Path.home() / ".clinerules"
|
|
||||||
global_rule = Path.home() / ".cline" / "rules" / "artemis.md"
|
|
||||||
if _inject_rules_block(clinerules, raw_rules):
|
|
||||||
installed_paths.append(str(clinerules))
|
|
||||||
if _write_rule_file(global_rule, raw_rules):
|
|
||||||
installed_paths.append(str(global_rule))
|
|
||||||
elif target in ("roo", "roo_code"):
|
|
||||||
roorules = Path.home() / ".roorules"
|
|
||||||
global_rule = Path.home() / ".roo" / "rules" / "artemis.md"
|
|
||||||
if _inject_rules_block(roorules, raw_rules):
|
|
||||||
installed_paths.append(str(roorules))
|
|
||||||
if _write_rule_file(global_rule, raw_rules):
|
|
||||||
installed_paths.append(str(global_rule))
|
|
||||||
elif target == "openclaw":
|
|
||||||
openclaw_md = Path.home() / ".openclaw" / "OPENCLAW.md"
|
|
||||||
global_rule = Path.home() / ".openclaw" / "rules" / "artemis.md"
|
|
||||||
if _inject_rules_block(openclaw_md, raw_rules):
|
|
||||||
installed_paths.append(str(openclaw_md))
|
|
||||||
if _write_rule_file(global_rule, raw_rules):
|
|
||||||
installed_paths.append(str(global_rule))
|
|
||||||
elif target == "codex":
|
|
||||||
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
|
||||||
override_file = codex_home / "AGENTS.override.md"
|
|
||||||
agents_file = (
|
|
||||||
override_file
|
|
||||||
if override_file.exists() and override_file.read_text(encoding="utf-8").strip()
|
|
||||||
else codex_home / "AGENTS.md"
|
|
||||||
)
|
|
||||||
if _inject_rules_block(agents_file, raw_rules):
|
|
||||||
installed_paths.append(str(agents_file))
|
|
||||||
|
|
||||||
return installed_paths
|
|
||||||
|
|
||||||
|
|
||||||
def install_mcp_config(client: str, python_exe: str, project_root: str) -> list[str]:
|
def install_mcp_config(client: str, python_exe: str, project_root: str) -> list[str]:
|
||||||
"""Auto-installs/merges ARTEMIS MCP configuration and testing rules into IDE config files across any OS."""
|
"""Install ARTEMIS configuration and rules only for Codex."""
|
||||||
installed_paths: list[str] = []
|
server_cfg = _get_config_snippet(client, python_exe, project_root)["mcp_servers"]["artemis"]
|
||||||
targets = (
|
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
||||||
[
|
codex_path = codex_home / "config.toml"
|
||||||
"antigravity",
|
if not _merge_codex_toml(codex_path, server_cfg):
|
||||||
"cursor",
|
raise typer.Exit(1)
|
||||||
"claude",
|
return [str(codex_path), *install_rules(client, project_root)]
|
||||||
"windsurf",
|
|
||||||
"vscode",
|
|
||||||
"cline",
|
|
||||||
"roo",
|
|
||||||
"openclaw",
|
|
||||||
"codex",
|
|
||||||
]
|
|
||||||
if client == "all"
|
|
||||||
else [client]
|
|
||||||
)
|
|
||||||
|
|
||||||
for target in targets:
|
|
||||||
snippet = _get_config_snippet(target, python_exe, project_root)
|
|
||||||
if target in ("antigravity", "jetski"):
|
|
||||||
current_server_cfg = _get_config_snippet("antigravity", python_exe, project_root)[
|
|
||||||
"mcpServers"
|
|
||||||
]["artemis"]
|
|
||||||
legacy_server_cfg = _get_config_snippet("jetski", python_exe, project_root)[
|
|
||||||
"mcpServers"
|
|
||||||
]["artemis"]
|
|
||||||
jetski_path = Path.home() / ".gemini" / "jetski" / "mcp_config.json"
|
|
||||||
antigravity_legacy_path = Path.home() / ".gemini" / "antigravity" / "mcp_config.json"
|
|
||||||
config_path = Path.home() / ".gemini" / "config" / "mcp_config.json"
|
|
||||||
if _merge_json_file(jetski_path, "artemis", legacy_server_cfg):
|
|
||||||
installed_paths.append(str(jetski_path))
|
|
||||||
if _merge_json_file(antigravity_legacy_path, "artemis", current_server_cfg):
|
|
||||||
installed_paths.append(str(antigravity_legacy_path))
|
|
||||||
if _merge_json_file(config_path, "artemis", current_server_cfg):
|
|
||||||
installed_paths.append(str(config_path))
|
|
||||||
elif target in ("claude", "claude_code", "claude_desktop"):
|
|
||||||
server_cfg = snippet["mcpServers"]["artemis"]
|
|
||||||
if sys.platform == "darwin":
|
|
||||||
claude_path = (
|
|
||||||
Path.home()
|
|
||||||
/ "Library"
|
|
||||||
/ "Application Support"
|
|
||||||
/ "Claude"
|
|
||||||
/ "claude_desktop_config.json"
|
|
||||||
)
|
|
||||||
elif sys.platform == "win32":
|
|
||||||
appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
|
||||||
claude_path = Path(appdata) / "Claude" / "claude_desktop_config.json"
|
|
||||||
else:
|
|
||||||
claude_path = Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
|
|
||||||
if _merge_json_file(claude_path, "artemis", server_cfg):
|
|
||||||
installed_paths.append(str(claude_path))
|
|
||||||
|
|
||||||
# Also install to Claude Code CLI global config (~/.claude.json).
|
|
||||||
# This file holds Claude Code state well beyond MCP config, so a
|
|
||||||
# parse failure must abort the merge rather than rewrite the file.
|
|
||||||
claude_code_path = Path.home() / ".claude.json"
|
|
||||||
if _merge_json_file(claude_code_path, "artemis", server_cfg, strict_parse=True):
|
|
||||||
installed_paths.append(str(claude_code_path))
|
|
||||||
elif target == "cursor":
|
|
||||||
server_cfg = snippet["mcpServers"]["artemis"]
|
|
||||||
cursor_path = Path.home() / ".cursor" / "mcp.json"
|
|
||||||
if _merge_json_file(cursor_path, "artemis", server_cfg):
|
|
||||||
installed_paths.append(str(cursor_path))
|
|
||||||
elif target == "windsurf":
|
|
||||||
server_cfg = snippet["mcpServers"]["artemis"]
|
|
||||||
windsurf_path = Path.home() / ".codeium" / "windsurf" / "mcp_config.json"
|
|
||||||
if _merge_json_file(windsurf_path, "artemis", server_cfg):
|
|
||||||
installed_paths.append(str(windsurf_path))
|
|
||||||
elif target == "vscode":
|
|
||||||
server_cfg = snippet["servers"]["artemis"]
|
|
||||||
vscode_path = _get_vscode_user_dir() / "mcp.json"
|
|
||||||
copilot_path = Path.home() / ".copilot" / "mcp-config.json"
|
|
||||||
if _merge_json_file(vscode_path, "artemis", server_cfg, key_name="servers"):
|
|
||||||
installed_paths.append(str(vscode_path))
|
|
||||||
if _merge_json_file(copilot_path, "artemis", server_cfg, key_name="servers"):
|
|
||||||
installed_paths.append(str(copilot_path))
|
|
||||||
elif target == "cline":
|
|
||||||
server_cfg = snippet["mcpServers"]["artemis"]
|
|
||||||
cline_paths = (
|
|
||||||
Path.home() / ".cline" / "data" / "settings" / "cline_mcp_settings.json",
|
|
||||||
Path.home() / ".cline" / "mcp.json",
|
|
||||||
_get_vscode_user_dir()
|
|
||||||
/ "globalStorage"
|
|
||||||
/ "saoudrizwan.claude-dev"
|
|
||||||
/ "settings"
|
|
||||||
/ "cline_mcp_settings.json",
|
|
||||||
)
|
|
||||||
for cline_path in cline_paths:
|
|
||||||
if _merge_json_file(cline_path, "artemis", server_cfg):
|
|
||||||
installed_paths.append(str(cline_path))
|
|
||||||
elif target in ("roo", "roo_code"):
|
|
||||||
server_cfg = snippet["mcpServers"]["artemis"]
|
|
||||||
roo_settings_dir = (
|
|
||||||
_get_vscode_user_dir() / "globalStorage" / "rooveterinaryinc.roo-cline" / "settings"
|
|
||||||
)
|
|
||||||
roo_paths = (
|
|
||||||
roo_settings_dir / "mcp_settings.json",
|
|
||||||
roo_settings_dir / "cline_mcp_settings.json",
|
|
||||||
Path(project_root) / ".roo" / "mcp.json",
|
|
||||||
)
|
|
||||||
for roo_path in roo_paths:
|
|
||||||
if _merge_json_file(roo_path, "artemis", server_cfg):
|
|
||||||
installed_paths.append(str(roo_path))
|
|
||||||
elif target == "openclaw":
|
|
||||||
server_cfg = snippet["mcp"]["servers"]["artemis"]
|
|
||||||
openclaw_path = Path.home() / ".openclaw" / "openclaw.json"
|
|
||||||
if _merge_json_file(
|
|
||||||
openclaw_path,
|
|
||||||
"artemis",
|
|
||||||
server_cfg,
|
|
||||||
key_name=("mcp", "servers"),
|
|
||||||
remove_paths=(("plugins", "artemis_mcp"),),
|
|
||||||
):
|
|
||||||
installed_paths.append(str(openclaw_path))
|
|
||||||
elif target == "codex":
|
|
||||||
server_cfg = snippet["mcp_servers"]["artemis"]
|
|
||||||
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
|
||||||
codex_path = codex_home / "config.toml"
|
|
||||||
if _merge_codex_toml(codex_path, server_cfg):
|
|
||||||
installed_paths.append(str(codex_path))
|
|
||||||
|
|
||||||
rules_paths = install_rules(client, project_root)
|
|
||||||
installed_paths.extend(rules_paths)
|
|
||||||
|
|
||||||
unique_paths: list[str] = []
|
|
||||||
for p in installed_paths:
|
|
||||||
if p not in unique_paths:
|
|
||||||
unique_paths.append(p)
|
|
||||||
return unique_paths
|
|
||||||
|
|
||||||
|
|
||||||
def mcp_command(
|
def mcp_command(
|
||||||
@@ -733,7 +260,7 @@ def mcp_command(
|
|||||||
typer.Option(
|
typer.Option(
|
||||||
"--install",
|
"--install",
|
||||||
"-i",
|
"-i",
|
||||||
help="Auto-install and merge ARTEMIS MCP configuration and testing rules into 'antigravity', 'claude', 'cursor', 'windsurf', 'vscode', 'cline', 'roo', 'openclaw', 'codex', or 'all'.",
|
help="Install ARTEMIS MCP configuration and testing rules for Codex: --install codex.",
|
||||||
),
|
),
|
||||||
] = None,
|
] = None,
|
||||||
generate_config: Annotated[
|
generate_config: Annotated[
|
||||||
@@ -741,7 +268,7 @@ def mcp_command(
|
|||||||
typer.Option(
|
typer.Option(
|
||||||
"--generate-config",
|
"--generate-config",
|
||||||
"-g",
|
"-g",
|
||||||
help="Output ready-to-use MCP configuration for 'antigravity', 'cursor', 'claude', 'windsurf', 'vscode', 'cline', 'roo', 'openclaw', 'codex', or 'all'.",
|
help="Output ready-to-use Codex TOML configuration: --generate-config codex.",
|
||||||
),
|
),
|
||||||
] = None,
|
] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -751,24 +278,9 @@ def mcp_command(
|
|||||||
|
|
||||||
if install_config:
|
if install_config:
|
||||||
client = install_config.lower()
|
client = install_config.lower()
|
||||||
if client not in (
|
if client != "codex":
|
||||||
"antigravity",
|
|
||||||
"jetski",
|
|
||||||
"claude",
|
|
||||||
"claude_code",
|
|
||||||
"claude_desktop",
|
|
||||||
"cursor",
|
|
||||||
"windsurf",
|
|
||||||
"vscode",
|
|
||||||
"cline",
|
|
||||||
"roo",
|
|
||||||
"roo_code",
|
|
||||||
"openclaw",
|
|
||||||
"codex",
|
|
||||||
"all",
|
|
||||||
):
|
|
||||||
console.print(
|
console.print(
|
||||||
f"[bold red]Unsupported install target: '{install_config}'. Use 'antigravity', 'claude', 'cursor', 'windsurf', 'vscode', 'cline', 'roo', 'openclaw', 'codex', or 'all'.[/bold red]"
|
"[bold red]目前仅支持 Codex MCP 配置,请使用 --install codex。[/bold red]"
|
||||||
)
|
)
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
installed_paths = install_mcp_config(client, python_exe, project_root)
|
installed_paths = install_mcp_config(client, python_exe, project_root)
|
||||||
@@ -784,46 +296,14 @@ def mcp_command(
|
|||||||
|
|
||||||
if generate_config:
|
if generate_config:
|
||||||
client = generate_config.lower()
|
client = generate_config.lower()
|
||||||
if client == "all":
|
if client != "codex":
|
||||||
all_configs = {
|
console.print(
|
||||||
"antigravity (~/.gemini/jetski/mcp_config.json)": _get_config_snippet(
|
"[bold red]目前仅支持 Codex MCP 配置,请使用 --generate-config codex。[/bold red]"
|
||||||
"antigravity", python_exe, project_root
|
)
|
||||||
),
|
raise typer.Exit(1)
|
||||||
"cursor (.cursor/mcp.json)": _get_config_snippet(
|
server_cfg = _get_config_snippet(client, python_exe, project_root)["mcp_servers"]["artemis"]
|
||||||
"cursor", python_exe, project_root
|
json_str = _codex_toml_block(server_cfg)
|
||||||
),
|
syntax_language = "toml"
|
||||||
"claude (claude_desktop_config.json & ~/.claude.json)": _get_config_snippet(
|
|
||||||
"claude", python_exe, project_root
|
|
||||||
),
|
|
||||||
"windsurf (~/.codeium/windsurf/mcp_config.json)": _get_config_snippet(
|
|
||||||
"windsurf", python_exe, project_root
|
|
||||||
),
|
|
||||||
"vscode (mcp.json)": _get_config_snippet("vscode", python_exe, project_root),
|
|
||||||
"cline (cline_mcp_settings.json)": _get_config_snippet(
|
|
||||||
"cline", python_exe, project_root
|
|
||||||
),
|
|
||||||
"roo (cline_mcp_settings.json)": _get_config_snippet(
|
|
||||||
"roo", python_exe, project_root
|
|
||||||
),
|
|
||||||
"openclaw (openclaw.json)": _get_config_snippet(
|
|
||||||
"openclaw", python_exe, project_root
|
|
||||||
),
|
|
||||||
"codex (~/.codex/config.toml)": _get_config_snippet(
|
|
||||||
"codex", python_exe, project_root
|
|
||||||
),
|
|
||||||
}
|
|
||||||
json_str = json.dumps(all_configs, indent=2)
|
|
||||||
syntax_language = "json"
|
|
||||||
elif client == "codex":
|
|
||||||
server_cfg = _get_config_snippet("codex", python_exe, project_root)["mcp_servers"][
|
|
||||||
"artemis"
|
|
||||||
]
|
|
||||||
json_str = _codex_toml_block(server_cfg)
|
|
||||||
syntax_language = "toml"
|
|
||||||
else:
|
|
||||||
snippet = _get_config_snippet(client, python_exe, project_root)
|
|
||||||
json_str = json.dumps(snippet, indent=2)
|
|
||||||
syntax_language = "json"
|
|
||||||
|
|
||||||
syntax = Syntax(json_str, syntax_language, theme="monokai", line_numbers=False)
|
syntax = Syntax(json_str, syntax_language, theme="monokai", line_numbers=False)
|
||||||
console.print(f"[bold cyan]MCP Configuration for {client.upper()}:[/bold cyan]")
|
console.print(f"[bold cyan]MCP Configuration for {client.upper()}:[/bold cyan]")
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Adapt LangChain messages for Responses-compatible model families."""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
||||||
|
|
||||||
|
|
||||||
|
_USER_ROLE_MODELS = {"mimo-v2.6-pro", "mimo-v2.6-flash"}
|
||||||
|
|
||||||
|
|
||||||
|
def adapt_responses_messages(
|
||||||
|
messages: Sequence[BaseMessage], model_name: str | None
|
||||||
|
) -> Sequence[BaseMessage]:
|
||||||
|
"""Send selected models user-role instructions without changing stored history.
|
||||||
|
|
||||||
|
Full Artemis requests to GPT models can fail with system-role input. The
|
||||||
|
equivalent user-role request works with GPT and the two tested Mimo models.
|
||||||
|
Other models keep their existing message roles.
|
||||||
|
"""
|
||||||
|
if not model_name:
|
||||||
|
return messages
|
||||||
|
model = model_name.strip().lower()
|
||||||
|
if not (model.startswith("gpt") or model in _USER_ROLE_MODELS):
|
||||||
|
return messages
|
||||||
|
if not any(isinstance(message, SystemMessage) for message in messages):
|
||||||
|
return messages
|
||||||
|
|
||||||
|
adapted: list[BaseMessage] = []
|
||||||
|
pending_blocks: list[dict[str, Any]] = []
|
||||||
|
for message in messages:
|
||||||
|
if isinstance(message, SystemMessage):
|
||||||
|
content = message.content
|
||||||
|
if isinstance(content, str):
|
||||||
|
pending_blocks.append({"type": "text", "text": content})
|
||||||
|
else:
|
||||||
|
pending_blocks.extend(
|
||||||
|
{"type": "text", "text": block} if isinstance(block, str) else block
|
||||||
|
for block in content
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if pending_blocks:
|
||||||
|
if isinstance(message, HumanMessage):
|
||||||
|
content = message.content
|
||||||
|
blocks = (
|
||||||
|
[{"type": "text", "text": content}]
|
||||||
|
if isinstance(content, str)
|
||||||
|
else list(content)
|
||||||
|
)
|
||||||
|
adapted.append(
|
||||||
|
message.model_copy(
|
||||||
|
update={
|
||||||
|
"content": [
|
||||||
|
*pending_blocks,
|
||||||
|
*blocks,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pending_blocks = []
|
||||||
|
continue
|
||||||
|
adapted.append(HumanMessage(content=pending_blocks))
|
||||||
|
pending_blocks = []
|
||||||
|
adapted.append(message)
|
||||||
|
|
||||||
|
if pending_blocks:
|
||||||
|
adapted.append(HumanMessage(content=pending_blocks))
|
||||||
|
return adapted
|
||||||
@@ -75,7 +75,7 @@ def extract_status_code(error: BaseException) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
_RATE_LIMIT_MARKERS = ("rate limit", "quota", "resource_exhausted")
|
_RATE_LIMIT_MARKERS = ("rate limit", "too many requests", "quota", "resource_exhausted")
|
||||||
_UNAVAILABLE_MARKERS = ("unavailable", "overloaded", "high demand", "service unavailable")
|
_UNAVAILABLE_MARKERS = ("unavailable", "overloaded", "high demand", "service unavailable")
|
||||||
_AUTH_MARKERS = ("unauthorized", "forbidden", "api key", "credential")
|
_AUTH_MARKERS = ("unauthorized", "forbidden", "api key", "credential")
|
||||||
# "deadline exceeded"/"deadline_exceeded" cover google-genai SDK timeouts that
|
# "deadline exceeded"/"deadline_exceeded" cover google-genai SDK timeouts that
|
||||||
@@ -196,6 +196,10 @@ def retry_policy_for(category: FailureCategory | str) -> RetryPolicy:
|
|||||||
return _DEFAULT_RETRY_POLICIES[category]
|
return _DEFAULT_RETRY_POLICIES[category]
|
||||||
from artemis.config.settings import settings
|
from artemis.config.settings import settings
|
||||||
|
|
||||||
|
if category is FailureCategory.RATE_LIMIT:
|
||||||
|
return _configured_policy(
|
||||||
|
settings.OPENAI_MAX_RETRIES, settings.OPENAI_RATE_LIMIT_RETRY_SECONDS
|
||||||
|
)
|
||||||
return _configured_policy(settings.OPENAI_MAX_RETRIES, settings.OPENAI_RETRY_INTERVAL_SECONDS)
|
return _configured_policy(settings.OPENAI_MAX_RETRIES, settings.OPENAI_RETRY_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+24
-13
@@ -15,25 +15,36 @@
|
|||||||
"""Backward compatibility entrypoint for artemis CLI."""
|
"""Backward compatibility entrypoint for artemis CLI."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
from artemis.interfaces.cli.main import app
|
from artemis.interfaces.cli.main import app
|
||||||
|
from artemis.interfaces.cli.main import app as _typer_app
|
||||||
|
|
||||||
|
|
||||||
|
def _root_tokens() -> frozenset[str]:
|
||||||
|
"""Read command names and root flags from the registered CLI."""
|
||||||
|
group = typer.main.get_command(_typer_app)
|
||||||
|
tokens: set[str] = {"-h"}
|
||||||
|
tokens.update(getattr(group, "commands", {}) or {})
|
||||||
|
tokens.update(group.get_help_option_names(group.context_class(group)))
|
||||||
|
for param in group.params:
|
||||||
|
tokens.update(getattr(param, "opts", ()))
|
||||||
|
tokens.update(getattr(param, "secondary_opts", ()))
|
||||||
|
return frozenset(tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_argv(argv: list[str]) -> list[str]:
|
||||||
|
"""Keep implicit ``run`` for a goal while preserving registered commands."""
|
||||||
|
if len(argv) > 1 and argv[1] not in _root_tokens():
|
||||||
|
return [argv[0], "run", *argv[1:]]
|
||||||
|
return list(argv)
|
||||||
|
|
||||||
|
|
||||||
def cli():
|
def cli():
|
||||||
# If invoked directly as python -m artemis.main without subcommand 'run',
|
# If invoked directly as python -m artemis.main without subcommand 'run',
|
||||||
# check if first argument is a goal rather than a subcommand
|
# check if first argument is a goal rather than a subcommand
|
||||||
if len(sys.argv) > 1 and sys.argv[1] not in (
|
sys.argv[:] = normalize_argv(sys.argv)
|
||||||
"run",
|
|
||||||
"init",
|
|
||||||
"doctor",
|
|
||||||
"batch",
|
|
||||||
"bench",
|
|
||||||
"mcp",
|
|
||||||
"server",
|
|
||||||
"trace",
|
|
||||||
"--help",
|
|
||||||
"-h",
|
|
||||||
):
|
|
||||||
sys.argv.insert(1, "run")
|
|
||||||
app()
|
app()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ from artemis.mcp.action_manifest import OPTIONAL_ACTIONS, REQUIRED_ACTIONS
|
|||||||
from artemis.mcp.action_specs import exception_prefix
|
from artemis.mcp.action_specs import exception_prefix
|
||||||
from artemis.mcp.action_session import ActionSession, get_action_session
|
from artemis.mcp.action_session import ActionSession, get_action_session
|
||||||
from artemis.mcp.action_types import ActionCode, ActionResult
|
from artemis.mcp.action_types import ActionCode, ActionResult
|
||||||
|
from artemis.mcp.action_types import ObserveResult
|
||||||
from artemis.mcp.actuators.adb import AdbActuator
|
from artemis.mcp.actuators.adb import AdbActuator
|
||||||
from artemis.agents.validator.tool_declarations import (
|
from artemis.agents.validator.tool_declarations import (
|
||||||
ToolExecutionResult,
|
ToolExecutionResult,
|
||||||
@@ -56,6 +57,7 @@ from artemis.utils.coordinates import (
|
|||||||
parse_swipe_parameters,
|
parse_swipe_parameters,
|
||||||
)
|
)
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
|
from artemis.utils.image_hash import dhash_hex, hamming_distance_hex
|
||||||
from artemis.utils.notes import (
|
from artemis.utils.notes import (
|
||||||
format_list_notes_failure,
|
format_list_notes_failure,
|
||||||
format_list_notes_success,
|
format_list_notes_success,
|
||||||
@@ -76,6 +78,9 @@ AGENT_TOOL_NAMES: frozenset[str] = (
|
|||||||
|
|
||||||
#: Actions whose ``target`` is a single point: an element index or an [x, y] pair.
|
#: Actions whose ``target`` is a single point: an element index or an [x, y] pair.
|
||||||
_POINT_TARGET_ACTIONS: frozenset[str] = frozenset({"click", "long_press", "input_text"})
|
_POINT_TARGET_ACTIONS: frozenset[str] = frozenset({"click", "long_press", "input_text"})
|
||||||
|
_SCREEN_CHANGING_ACTIONS: frozenset[str] = frozenset(
|
||||||
|
{"click", "click_sequence", "long_press", "input_text", "press_key", "swipe", "manage_app"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _ArgError(ValueError):
|
class _ArgError(ValueError):
|
||||||
@@ -111,6 +116,99 @@ class McpActionExecutor:
|
|||||||
self._session = await get_action_session(self.ctx, actuator=self.actuator)
|
self._session = await get_action_session(self.ctx, actuator=self.actuator)
|
||||||
return self._session
|
return self._session
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _image_hash(observation: ObserveResult) -> str | None:
|
||||||
|
path = observation.screenshot_path
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return dhash_hex(Path(path).read_bytes())
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _same_screen(cls, first: ObserveResult, second: ObserveResult) -> bool:
|
||||||
|
"""Compare two observations without treating a dispatched tap as navigation."""
|
||||||
|
if not first.ok or not second.ok:
|
||||||
|
return False
|
||||||
|
checks: list[bool] = []
|
||||||
|
first_hash, second_hash = cls._image_hash(first), cls._image_hash(second)
|
||||||
|
distance = hamming_distance_hex(first_hash, second_hash)
|
||||||
|
if distance is not None:
|
||||||
|
checks.append(distance <= 4)
|
||||||
|
if first.hierarchy_ok and second.hierarchy_ok:
|
||||||
|
checks.append(first.elements_text == second.elements_text)
|
||||||
|
return bool(checks) and all(checks)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _element_signature(elements: list[dict[str, Any]]) -> list[tuple[Any, Any, Any]]:
|
||||||
|
return [(e.get("text"), e.get("resource_id"), e.get("bounds")) for e in elements]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _stale_image_with_new_hierarchy(
|
||||||
|
cls,
|
||||||
|
observation: ObserveResult,
|
||||||
|
previous_hash: str | None,
|
||||||
|
previous_elements: list[dict[str, Any]] | None,
|
||||||
|
) -> bool:
|
||||||
|
if previous_hash is None or previous_elements is None or not observation.hierarchy_ok:
|
||||||
|
return False
|
||||||
|
distance = hamming_distance_hex(previous_hash, cls._image_hash(observation))
|
||||||
|
return (
|
||||||
|
distance is not None
|
||||||
|
and distance <= 4
|
||||||
|
and cls._element_signature(previous_elements)
|
||||||
|
!= cls._element_signature(observation.elements)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _observe_after_action(
|
||||||
|
self,
|
||||||
|
session: ActionSession,
|
||||||
|
raw_name: str,
|
||||||
|
previous_screenshot: str | None,
|
||||||
|
previous_elements: list[dict[str, Any]] | None = None,
|
||||||
|
) -> tuple[ObserveResult, bool]:
|
||||||
|
"""Prefer a settled image/tree pair after actions that can change screens.
|
||||||
|
|
||||||
|
The Android helper captures its bitmap and hierarchy concurrently. During a
|
||||||
|
transition the bitmap can still show the old page while the hierarchy already
|
||||||
|
describes the new one. Bounded re-observation waits for adjacent stable
|
||||||
|
frames and rejects a new hierarchy paired with the previous page's bitmap.
|
||||||
|
"""
|
||||||
|
first = await session.observe(settle_ms=400)
|
||||||
|
if not first.ok or raw_name not in _SCREEN_CHANGING_ACTIONS:
|
||||||
|
return first, first.ok
|
||||||
|
|
||||||
|
second = await session.observe(settle_ms=250)
|
||||||
|
if not second.ok:
|
||||||
|
return first, False
|
||||||
|
previous_hash = None
|
||||||
|
if previous_screenshot:
|
||||||
|
try:
|
||||||
|
previous_hash = dhash_hex(Path(previous_screenshot).read_bytes())
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if self._same_screen(first, second):
|
||||||
|
distance = hamming_distance_hex(previous_hash, self._image_hash(second))
|
||||||
|
if (distance is None or distance > 4) and not self._stale_image_with_new_hierarchy(
|
||||||
|
second, previous_hash, previous_elements
|
||||||
|
):
|
||||||
|
return second, True
|
||||||
|
|
||||||
|
third = await session.observe(settle_ms=250)
|
||||||
|
if not third.ok:
|
||||||
|
return second, False
|
||||||
|
if self._same_screen(second, third) and not self._stale_image_with_new_hierarchy(
|
||||||
|
third, previous_hash, previous_elements
|
||||||
|
):
|
||||||
|
return third, True
|
||||||
|
fourth = await session.observe(settle_ms=250)
|
||||||
|
if not fourth.ok:
|
||||||
|
return third, False
|
||||||
|
return fourth, self._same_screen(
|
||||||
|
third, fourth
|
||||||
|
) and not self._stale_image_with_new_hierarchy(fourth, previous_hash, previous_elements)
|
||||||
|
|
||||||
# --- Public entry ----------------------------------------------------------------
|
# --- Public entry ----------------------------------------------------------------
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
@@ -171,6 +269,7 @@ class McpActionExecutor:
|
|||||||
xml_list: str | None = None
|
xml_list: str | None = None
|
||||||
target_semantics: dict[str, Any] = {}
|
target_semantics: dict[str, Any] = {}
|
||||||
target_coordinates: list[int] | None = None
|
target_coordinates: list[int] | None = None
|
||||||
|
observation_settled: bool | None = None
|
||||||
with span:
|
with span:
|
||||||
try:
|
try:
|
||||||
session = await self._session_or_start()
|
session = await self._session_or_start()
|
||||||
@@ -196,7 +295,12 @@ class McpActionExecutor:
|
|||||||
message = finalize(res) if finalize else res.message
|
message = finalize(res) if finalize else res.message
|
||||||
|
|
||||||
if res.ok or self._observe_despite_failure(raw_name, res):
|
if res.ok or self._observe_despite_failure(raw_name, res):
|
||||||
obs = await session.observe(settle_ms=400)
|
obs, observation_settled = await self._observe_after_action(
|
||||||
|
session,
|
||||||
|
raw_name,
|
||||||
|
getattr(state, "latest_screenshot", None),
|
||||||
|
getattr(state, "indexed_elements", None),
|
||||||
|
)
|
||||||
if obs.ok:
|
if obs.ok:
|
||||||
shot_path = obs.screenshot_path
|
shot_path = obs.screenshot_path
|
||||||
xml_list = obs.elements_text
|
xml_list = obs.elements_text
|
||||||
@@ -217,6 +321,8 @@ class McpActionExecutor:
|
|||||||
|
|
||||||
if shot_path:
|
if shot_path:
|
||||||
state.latest_screenshot = shot_path
|
state.latest_screenshot = shot_path
|
||||||
|
if observation_settled is False:
|
||||||
|
message = f"{message} Post-action screen is still changing; observe again before concluding."
|
||||||
|
|
||||||
status = "success" if res.ok else "error"
|
status = "success" if res.ok else "error"
|
||||||
post_image_name = None
|
post_image_name = None
|
||||||
@@ -247,6 +353,7 @@ class McpActionExecutor:
|
|||||||
"normalized_coordinates": res.normalized_coordinates,
|
"normalized_coordinates": res.normalized_coordinates,
|
||||||
"target_semantics": target_semantics,
|
"target_semantics": target_semantics,
|
||||||
"target_coordinates": target_coordinates,
|
"target_coordinates": target_coordinates,
|
||||||
|
"observation_settled": observation_settled,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from artemis.clients.screen_client_factory import create_screen_client
|
|||||||
from artemis.context import ArtemisContext, DeviceContext, DevicePlatform
|
from artemis.context import ArtemisContext, DeviceContext, DevicePlatform
|
||||||
from artemis.controllers.unified_controller import UnifiedMobileController
|
from artemis.controllers.unified_controller import UnifiedMobileController
|
||||||
from artemis.platform import platform
|
from artemis.platform import platform
|
||||||
|
from artemis.runtime.adb_endpoint import AdbEndpoint
|
||||||
from artemis.utils.app_launch_utils import launch_app_with_retries
|
from artemis.utils.app_launch_utils import launch_app_with_retries
|
||||||
|
|
||||||
|
|
||||||
@@ -127,7 +128,7 @@ def _get_controller(device_serial: str | None = None):
|
|||||||
port_str = os.environ.get("ADB_PORT", "5037")
|
port_str = os.environ.get("ADB_PORT", "5037")
|
||||||
port = int(port_str) if port_str.isdigit() else 5037
|
port = int(port_str) if port_str.isdigit() else 5037
|
||||||
if "ADB_SERVER_SOCKET" not in os.environ and (host != "localhost" or port != 5037):
|
if "ADB_SERVER_SOCKET" not in os.environ and (host != "localhost" or port != 5037):
|
||||||
os.environ["ADB_SERVER_SOCKET"] = f"tcp:{host}:{port}"
|
os.environ["ADB_SERVER_SOCKET"] = AdbEndpoint.create(host, port).client_socket
|
||||||
adb = AdbClient(host=host, port=port)
|
adb = AdbClient(host=host, port=port)
|
||||||
|
|
||||||
devices = adb.device_list()
|
devices = adb.device_list()
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ Validator -- which runs its own UI-change polling -- simply never calls it.
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
from io import BytesIO
|
||||||
import time
|
import time
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from artemis.config import get_temp_dir
|
from artemis.config import get_temp_dir
|
||||||
|
from artemis.config.settings import settings
|
||||||
from artemis.context import ArtemisContext
|
from artemis.context import ArtemisContext
|
||||||
from artemis.controllers.unified_controller import UnifiedMobileController
|
from artemis.controllers.unified_controller import UnifiedMobileController
|
||||||
from artemis.mcp.action_types import ActionCode, ObserveResult
|
from artemis.mcp.action_types import ActionCode, ObserveResult
|
||||||
@@ -34,7 +37,29 @@ from artemis.utils.visualization import format_minimal_list_with_elements
|
|||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
__all__ = ["observe"]
|
__all__ = ["observe", "require_observation", "DeviceObservationError"]
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceObservationError(RuntimeError):
|
||||||
|
"""A live device could not be observed after bounded recovery."""
|
||||||
|
|
||||||
|
|
||||||
|
async def require_observation(
|
||||||
|
ctx: ArtemisContext, controller: UnifiedMobileController, settle_ms: int = 400
|
||||||
|
) -> tuple[ObserveResult, bytes | None]:
|
||||||
|
"""Recover observation without spending model calls on unavailable screens."""
|
||||||
|
last = "设备未返回有效画面"
|
||||||
|
for attempt in range(settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS):
|
||||||
|
observation, image = await observe(ctx, controller, settle_ms=settle_ms)
|
||||||
|
if observation.ok:
|
||||||
|
return observation, image
|
||||||
|
last = observation.message
|
||||||
|
logger.warning(
|
||||||
|
f"设备观测恢复 {attempt + 1}/{settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS}:{last}"
|
||||||
|
)
|
||||||
|
raise DeviceObservationError(
|
||||||
|
f"设备观测失败(已尝试 {settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS} 次):{last}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def observe(
|
async def observe(
|
||||||
@@ -60,9 +85,19 @@ async def observe(
|
|||||||
await asyncio.sleep(settle_ms / 1000.0)
|
await asyncio.sleep(settle_ms / 1000.0)
|
||||||
else:
|
else:
|
||||||
logger.info("Retrieving screen data directly (skip settling)...")
|
logger.info("Retrieving screen data directly (skip settling)...")
|
||||||
device_data = await controller.get_screen_data()
|
device_data = await asyncio.wait_for(
|
||||||
|
controller.get_screen_data(), timeout=settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
screenshot_bytes = base64.b64decode(device_data.base64)
|
screenshot_bytes = base64.b64decode(device_data.base64)
|
||||||
|
with Image.open(BytesIO(screenshot_bytes)) as image:
|
||||||
|
if min(image.size) <= 1:
|
||||||
|
raise ValueError("设备截图为占位图,当前画面不可用")
|
||||||
|
image.verify()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if isinstance(e, TimeoutError):
|
||||||
|
e = TimeoutError(
|
||||||
|
f"设备屏幕观测超时({settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS:g} 秒)"
|
||||||
|
)
|
||||||
logger.error(f"Failed to capture screen state: {e}")
|
logger.error(f"Failed to capture screen state: {e}")
|
||||||
return (
|
return (
|
||||||
ObserveResult(
|
ObserveResult(
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ def ensure_step_memory(ctx):
|
|||||||
"retry_limit": cfg.memory.runtime.retry_limit,
|
"retry_limit": cfg.memory.runtime.retry_limit,
|
||||||
"max_concurrency": cfg.memory.runtime.max_concurrency,
|
"max_concurrency": cfg.memory.runtime.max_concurrency,
|
||||||
"flush_timeout_s": cfg.memory.runtime.flush_timeout_s,
|
"flush_timeout_s": cfg.memory.runtime.flush_timeout_s,
|
||||||
|
"timeout_seconds": cfg.flash.step_summarizer.timeout_seconds,
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
||||||
|
|
||||||
|
from artemis.llm.messages import adapt_responses_messages
|
||||||
from artemis.memory.step_memory import JobKey, StepLens, StepMemoryService
|
from artemis.memory.step_memory import JobKey, StepLens, StepMemoryService
|
||||||
from artemis.memory.transcript import format_session_offset
|
from artemis.memory.transcript import format_session_offset
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
@@ -503,7 +504,8 @@ class StepCapsuleLens(StepLens):
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
async def _invoke(self, llm: Any, messages: list[BaseMessage], model_label: str):
|
async def _invoke(self, llm: Any, messages: list[BaseMessage], model_label: str):
|
||||||
response = await asyncio.wait_for(llm.ainvoke(messages), timeout=90.0)
|
request_messages = adapt_responses_messages(messages, model_label)
|
||||||
|
response = await asyncio.wait_for(llm.ainvoke(request_messages), timeout=90.0)
|
||||||
# Raw-model bypass metering (gateway-wrapped models meter themselves);
|
# Raw-model bypass metering (gateway-wrapped models meter themselves);
|
||||||
# capsule prompts are small and are kept out of the session meter's
|
# capsule prompts are small and are kept out of the session meter's
|
||||||
# last_prompt_tokens (the compaction base itself is operator-owned on
|
# last_prompt_tokens (the compaction base itself is operator-owned on
|
||||||
|
|||||||
@@ -13,7 +13,14 @@
|
|||||||
"presets": {},
|
"presets": {},
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"planner": {},
|
"planner": {},
|
||||||
"operator": {},
|
"operator": {
|
||||||
|
"fallback": {
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "${env:OPENAI_TOOL_FALLBACK_MODEL:-gpt-6-luna}",
|
||||||
|
"reasoning_effort": "${env:OPENAI_TOOL_FALLBACK_REASONING_EFFORT:-high}",
|
||||||
|
"max_retries": "${env:OPENAI_TOOL_FALLBACK_MAX_RETRIES:-3}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"video_analyzer": {},
|
"video_analyzer": {},
|
||||||
"object_detector": {},
|
"object_detector": {},
|
||||||
"explorer": {},
|
"explorer": {},
|
||||||
@@ -27,12 +34,17 @@
|
|||||||
"checker": {}
|
"checker": {}
|
||||||
},
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
|
"screenshot": {
|
||||||
|
"enabled": "${env:ARTEMIS_SCREENSHOT_LIMIT_ENABLED:-true}",
|
||||||
|
"max_long_edge": 2048
|
||||||
|
},
|
||||||
"flash": {
|
"flash": {
|
||||||
"max_turns": 0,
|
"max_turns": 0,
|
||||||
"explorer_mode": "flash",
|
"explorer_mode": "flash",
|
||||||
"step_summarizer": {
|
"step_summarizer": {
|
||||||
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
||||||
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
||||||
|
"timeout_seconds": "${env:ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS:-120}",
|
||||||
"prune_history_xml": true
|
"prune_history_xml": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"""Bounded TCP device discovery shared by the console, CLI and MCP.
|
||||||
|
|
||||||
|
USB and Android SDK emulators are discovered by ADB itself. Third-party
|
||||||
|
emulators expose TCP adbd listeners; only listeners owned by known emulator
|
||||||
|
processes are probed. Explicitly connected TCP devices are remembered separately.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
from artemis.config import settings
|
||||||
|
from artemis.config.paths import get_app_dir
|
||||||
|
from artemis.runtime.adb_endpoint import AdbSession, current_adb_endpoint
|
||||||
|
from artemis.utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_target(target: str) -> str:
|
||||||
|
host, separator, port = target.rpartition(":")
|
||||||
|
if separator and host.lower() in {"localhost", "127.0.0.1"}:
|
||||||
|
return f"127.0.0.1:{port}"
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def emulator_listeners() -> list[list[str]]:
|
||||||
|
"""Return listener groups, one per running emulator (never scan a network)."""
|
||||||
|
owners = set()
|
||||||
|
for process in psutil.process_iter(["pid", "name", "exe"]):
|
||||||
|
info = process.info
|
||||||
|
name = str(info.get("name") or "").lower().removesuffix(".exe")
|
||||||
|
path = str(info.get("exe") or "").lower()
|
||||||
|
known = name in {
|
||||||
|
"mumunxdevice",
|
||||||
|
"nemuheadless",
|
||||||
|
"mumuheadless",
|
||||||
|
"ldvboxheadless",
|
||||||
|
"noxvmhandle",
|
||||||
|
"memuheadless",
|
||||||
|
"hd-player",
|
||||||
|
}
|
||||||
|
vendor_vm = name == "vboxheadless" and any(
|
||||||
|
vendor in path for vendor in ("mumu", "nemu", "ldplayer", "leidian", "nox", "memu")
|
||||||
|
)
|
||||||
|
if known or vendor_vm:
|
||||||
|
owners.add(info["pid"])
|
||||||
|
if not owners:
|
||||||
|
return []
|
||||||
|
groups: dict[int, set[int]] = {}
|
||||||
|
for connection in psutil.net_connections(kind="tcp"):
|
||||||
|
address = connection.laddr
|
||||||
|
pid = connection.pid
|
||||||
|
if (
|
||||||
|
pid is not None
|
||||||
|
and pid in owners
|
||||||
|
and address
|
||||||
|
and connection.status == psutil.CONN_LISTEN
|
||||||
|
and address.ip in {"127.0.0.1", "0.0.0.0"}
|
||||||
|
):
|
||||||
|
groups.setdefault(pid, set()).add(address.port)
|
||||||
|
# Prefer current MuMu ports over its compatibility aliases (same adbd).
|
||||||
|
return [
|
||||||
|
[f"127.0.0.1:{port}" for port in sorted(ports, key=lambda p: (p in {5555, 7555}, p))]
|
||||||
|
for ports in groups.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def is_adbd(target: str, timeout: float) -> bool:
|
||||||
|
"""Confirm an ADB CNXN/AUTH header before giving a local port to adb connect."""
|
||||||
|
host, port = target.rsplit(":", 1)
|
||||||
|
payload = b"host::\x00"
|
||||||
|
command = int.from_bytes(b"CNXN", "little")
|
||||||
|
packet = struct.pack(
|
||||||
|
"<6I", command, 0x01000000, 4096, len(payload), sum(payload), command ^ 0xFFFFFFFF
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, int(port)), timeout=timeout) as stream:
|
||||||
|
stream.sendall(packet + payload)
|
||||||
|
header = b""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while len(header) < 24:
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
return False
|
||||||
|
stream.settimeout(remaining)
|
||||||
|
chunk = stream.recv(24 - len(header))
|
||||||
|
if not chunk:
|
||||||
|
return False
|
||||||
|
header += chunk
|
||||||
|
kind, _, _, length, _, magic = struct.unpack("<6I", header)
|
||||||
|
return (
|
||||||
|
kind.to_bytes(4, "little") in {b"CNXN", b"AUTH"}
|
||||||
|
and magic == kind ^ 0xFFFFFFFF
|
||||||
|
and length <= 1048576
|
||||||
|
)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class AdbDeviceDiscovery:
|
||||||
|
"""Coalesce discovery and reconnect only missing transports, with a total budget."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._gate = threading.Lock()
|
||||||
|
self._last_scan = float("-inf")
|
||||||
|
self.results: list[dict[str, object]] = []
|
||||||
|
self._cursor = 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _directory() -> Path:
|
||||||
|
return get_app_dir() / "adb-devices"
|
||||||
|
|
||||||
|
def remember(self, target: str) -> bool:
|
||||||
|
"""Save an explicitly connected device, without credentials or shared-list races."""
|
||||||
|
directory = self._directory()
|
||||||
|
temporary = None
|
||||||
|
try:
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
destination = directory / (hashlib.sha256(target.encode()).hexdigest() + ".json")
|
||||||
|
fd, temporary = tempfile.mkstemp(dir=directory, prefix=".device-")
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||||
|
json.dump({"target": target}, stream)
|
||||||
|
os.replace(temporary, destination)
|
||||||
|
return True
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning(f"Cannot remember ADB device {target}: {exc}")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
if temporary:
|
||||||
|
Path(temporary).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _remembered(self) -> list[str]:
|
||||||
|
targets = []
|
||||||
|
for path in self._directory().glob("*.json"):
|
||||||
|
try:
|
||||||
|
target = json.loads(path.read_text(encoding="utf-8"))["target"]
|
||||||
|
host, port = target.rsplit(":", 1)
|
||||||
|
if host and 0 < int(port) < 65536:
|
||||||
|
targets.append(target)
|
||||||
|
except (OSError, ValueError, KeyError, TypeError, AttributeError) as exc:
|
||||||
|
logger.warning(f"Invalid remembered ADB device {path.name}: {exc}")
|
||||||
|
return targets
|
||||||
|
|
||||||
|
def refresh(self, adb_path: str, *, force: bool = False) -> list[dict[str, object]]:
|
||||||
|
if not settings.ARTEMIS_ADB_AUTO_CONNECT or not current_adb_endpoint().is_local_default:
|
||||||
|
self.results = []
|
||||||
|
return []
|
||||||
|
if not self._gate.acquire(blocking=False):
|
||||||
|
return self.results.copy()
|
||||||
|
try:
|
||||||
|
if (
|
||||||
|
not force
|
||||||
|
and time.monotonic() - self._last_scan
|
||||||
|
< settings.ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS
|
||||||
|
):
|
||||||
|
return self.results.copy()
|
||||||
|
self._last_scan = time.monotonic()
|
||||||
|
self.results = []
|
||||||
|
self._refresh(adb_path)
|
||||||
|
except (OSError, psutil.Error, ValueError, subprocess.SubprocessError) as exc:
|
||||||
|
logger.warning(f"ADB automatic discovery unavailable: {exc}")
|
||||||
|
self.results.append({"success": False, "message": str(exc)})
|
||||||
|
finally:
|
||||||
|
self._gate.release()
|
||||||
|
return self.results.copy()
|
||||||
|
|
||||||
|
def _refresh(self, adb_path: str) -> None:
|
||||||
|
session = AdbSession(current_adb_endpoint(), adb_path=adb_path)
|
||||||
|
deadline = time.monotonic() + settings.ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS
|
||||||
|
devices = session.run(
|
||||||
|
["devices"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=max(deadline - time.monotonic(), 0.01),
|
||||||
|
)
|
||||||
|
if devices.returncode:
|
||||||
|
return
|
||||||
|
ready = {
|
||||||
|
_canonical_target(parts[0])
|
||||||
|
for line in devices.stdout.splitlines()
|
||||||
|
if len(parts := line.split()) >= 2 and parts[1] in {"device", "unauthorized"}
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
groups = emulator_listeners()
|
||||||
|
except (OSError, psutil.Error) as exc:
|
||||||
|
# Missing process-inspection permissions must not stop reconnection
|
||||||
|
# of explicitly configured phones or make device enumeration fail.
|
||||||
|
groups = []
|
||||||
|
logger.warning(f"Cannot inspect local emulator listeners: {exc}")
|
||||||
|
self.results.append({"success": False, "message": str(exc)})
|
||||||
|
remembered = self._remembered()
|
||||||
|
targets = [(group, True) for group in groups] + [([target], False) for target in remembered]
|
||||||
|
# Resume at a different group if a slow/offline peer exhausts the budget.
|
||||||
|
# Large multi-instance installations must not starve later devices.
|
||||||
|
count = len(targets)
|
||||||
|
start = self._cursor % count if count else 0
|
||||||
|
for offset in range(count):
|
||||||
|
index = (start + offset) % count
|
||||||
|
candidates, probe = targets[index]
|
||||||
|
self._cursor = (index + 1) % count
|
||||||
|
if any(_canonical_target(target) in ready for target in candidates):
|
||||||
|
continue
|
||||||
|
for target in candidates:
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
return
|
||||||
|
if probe and not is_adbd(target, min(remaining, 0.3)):
|
||||||
|
continue
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
result = session.run(
|
||||||
|
["connect", target],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=min(remaining, 2),
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.results.append(
|
||||||
|
{"target": target, "success": False, "message": "ADB 连接超时"}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
output = (result.stdout + result.stderr).strip()
|
||||||
|
success = (
|
||||||
|
result.returncode == 0
|
||||||
|
and "connected to" in output.lower()
|
||||||
|
and "failed" not in output.lower()
|
||||||
|
)
|
||||||
|
state = self._wait_for_transport(session, target, deadline) if success else None
|
||||||
|
pending = success and state is None
|
||||||
|
if success and state is None:
|
||||||
|
success = False
|
||||||
|
output = f"{output};已发起连接,等待设备就绪,下次刷新会重试"
|
||||||
|
self.results.append(
|
||||||
|
{
|
||||||
|
"target": target,
|
||||||
|
"success": success,
|
||||||
|
"state": state,
|
||||||
|
"pending": pending,
|
||||||
|
"message": output,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
logger.info(f"ADB 自动连接:{target} — {output}")
|
||||||
|
ready.add(_canonical_target(target))
|
||||||
|
break
|
||||||
|
if pending:
|
||||||
|
logger.info(f"ADB 设备连接中:{target} — {output}")
|
||||||
|
break # Do not create a second alias while the first transport is settling.
|
||||||
|
logger.warning(f"ADB 自动连接失败:{target} — {output}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _wait_for_transport(session: AdbSession, target: str, deadline: float) -> str | None:
|
||||||
|
"""adb connect can return before its transport appears in adb devices."""
|
||||||
|
while (remaining := deadline - time.monotonic()) > 0:
|
||||||
|
try:
|
||||||
|
result = session.run(["devices"], capture_output=True, text=True, timeout=remaining)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return None
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
if (
|
||||||
|
len(parts) >= 2
|
||||||
|
and _canonical_target(parts[0]) == _canonical_target(target)
|
||||||
|
and parts[1] in {"device", "unauthorized"}
|
||||||
|
):
|
||||||
|
return parts[1]
|
||||||
|
time.sleep(min(0.1, max(deadline - time.monotonic(), 0)))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
adb_device_discovery = AdbDeviceDiscovery()
|
||||||
@@ -92,6 +92,19 @@ class AdbEndpoint:
|
|||||||
def mode(self) -> str:
|
def mode(self) -> str:
|
||||||
return "local" if self.is_local_default else "remote"
|
return "local" if self.is_local_default else "remote"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client_host(self) -> str:
|
||||||
|
"""ADB only permits daemon auto-start for the literal host 'localhost'.
|
||||||
|
|
||||||
|
Keep forwarded/custom-port endpoints explicit, even on loopback, so a
|
||||||
|
missing tunnel never starts a different local daemon at that address.
|
||||||
|
"""
|
||||||
|
return "localhost" if self.is_local_default else self.host
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client_socket(self) -> str:
|
||||||
|
return f"tcp:localhost:{self.port}" if self.is_local_default else self.socket
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
**asdict(self),
|
**asdict(self),
|
||||||
@@ -108,7 +121,7 @@ class AdbEndpoint:
|
|||||||
target = environment if environment is not None else os.environ
|
target = environment if environment is not None else os.environ
|
||||||
target["ADB_HOST"] = self.host
|
target["ADB_HOST"] = self.host
|
||||||
target["ADB_PORT"] = str(self.port)
|
target["ADB_PORT"] = str(self.port)
|
||||||
target["ADB_SERVER_SOCKET"] = self.socket
|
target["ADB_SERVER_SOCKET"] = self.client_socket
|
||||||
target[ADB_ENDPOINT_ID_ENV] = self.identity
|
target[ADB_ENDPOINT_ID_ENV] = self.identity
|
||||||
return target
|
return target
|
||||||
|
|
||||||
@@ -145,7 +158,7 @@ class AdbSession:
|
|||||||
return [
|
return [
|
||||||
self.adb_path,
|
self.adb_path,
|
||||||
"-H",
|
"-H",
|
||||||
self.endpoint.host,
|
self.endpoint.client_host,
|
||||||
"-P",
|
"-P",
|
||||||
str(self.endpoint.port),
|
str(self.endpoint.port),
|
||||||
*arguments,
|
*arguments,
|
||||||
|
|||||||
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user