Compare commits

..
Author SHA1 Message Date
chenmingxuan 42f5f3ed55 docs: publish the complete Artemis 2.0.0 release guide 2026-10-18 21:08:28 +08:00
chenmingxuan 838e9142a9 merge: preserve 1.5.2 MCP policy fixes in 2.0.0
# Conflicts:
#	docs/ai-automation.md
#	scripts/update_installation.py
#	tests/unit/test_installation_update.py
2026-10-18 20:56:53 +08:00
chenmingxuan 351b2fc905 fix: preserve Codex tool policies when synchronizing MCP 2026-10-18 20:56:06 +08:00
chenmingxuan 32441e79c4 fix: give final acceptance a shared retry budget and diagnostics 2026-10-18 20:51:19 +08:00
chenmingxuan 50638d4130 feat: unify model selection and complete favorite settings 2026-10-18 20:03:41 +08:00
chenmingxuan a4a5430922 feat: add favorite task templates and optimize evidence exports 2026-10-18 19:44:51 +08:00
chenmingxuan e1ea099592 feat: add official Codex skill and harden task execution
Install and update artemis-use with task diagnostics and plugin guidance. Preserve cross-device file receipts, review canonical reports, guard stage transitions and reduce small history summaries. Improve exploration lifecycle, request recovery and browser live connections.
2026-10-18 14:18:37 +08:00
chenmingxuan 2328a4df0c fix: improve cross-device evidence and exploration lifecycle
Align Responses cache affinity across execution and summary calls, preserve required checks and evidence uncertainty, and allow safe deletion of inactive explorations while retaining independent reruns.

Validation: backend suite passed except intentional prompt golden drift, then all 17 transcript tests passed after snapshot refresh; 268 frontend tests and production build passed; Ruff, quality ratchet and core type checks passed.
2026-10-17 19:43:20 +08:00
chenmingxuan f1bb5d02aa perf: preserve conversation prefixes across agent turns 2026-10-17 17:25:15 +08:00
chenmingxuan 1440a1f508 fix: preserve cross-device evidence and render recorded reports 2026-10-17 16:05:58 +08:00
chenmingxuan a66803651a fix: improve cross-device evidence, request diagnostics and prompt efficiency 2026-10-17 14:55:45 +08:00
chenmingxuan 621f30e437 fix: preserve exploration tool history and cap context budget 2026-10-16 19:05:35 +08:00
chenmingxuan ee256ad7ce feat: add guided exploration and share user-started MCP backend 2026-10-16 18:32:46 +08:00
chenmingxuan f86db1ced6 fix(android): launch apps with checked activity manager commands 2026-10-16 11:15:14 +08:00
chenmingxuan ef7af9988e feat(plugins): add source-aware evidence and recording timelines 2026-10-16 10:20:11 +08:00
chenmingxuan 72ff342605 feat: add Android task file evidence and finalize plugin design 2026-10-15 18:27:11 +08:00
chenmingxuan 96fedc816f feat(ui): simplify plugin activation and align task status badges 2026-10-15 00:32:20 +08:00
chenmingxuan 9c82a40cbf fix: make Android tasks use extensions automatically and populate marketplace 2026-10-14 22:32:38 +08:00
chenmingxuan 6663233ab8 feat: add plugin runtime and cross-device task orchestration 2026-10-14 20:21:44 +08:00
chenmingxuan 3ee4a5f7a8 chore: prepare 2.0.0 plugin development 2026-10-14 17:33:18 +08:00
chenmingxuan bdc75d0c65 fix: release 1.5.2 with resilient Windows task state writes 2026-10-12 17:58:20 +08:00
chenmingxuan bdd1aa2c14 feat: release 1.5.1 with human review and batch filters 2026-10-12 15:31:38 +08:00
chenmingxuan 53c5680260 fix: harden mirrored downloads and first-run upgrades 2026-10-12 13:26:58 +08:00
chenmingxuan df0a7dc37a test: cover result card evidence disclosure 2026-10-12 12:34:50 +08:00
chenmingxuan ec2c1265d3 feat: add shared test acceptance and release 1.5.0 2026-10-12 12:33:55 +08:00
chenmingxuan 3fbcdfefea fix: clean failed UIAutomator2 startups before retrying 2026-10-12 10:58:47 +08:00
chenmingxuan 4d6560dbd4 feat: support batch repetition and selective case import 2026-10-09 16:29:16 +08:00
chenmingxuan df66897ff4 feat: release 1.4.0 with isolated upgrades and device discovery 2026-10-09 15:46:28 +08:00
chenmingxuan a6a3ee2eb0 chore: prepare ARTEMIS-Eapil 1.3.3 release 2026-10-09 10:17:07 +08:00
chenmingxuan 49873e7f05 fix: restore automatic startup of local ADB server 2026-10-09 10:17:07 +08:00
chenmingxuan f826bdf3b0 chore: prepare ARTEMIS-Eapil 1.3.2 release 2026-10-08 18:01:16 +08:00
chenmingxuan c96935b47b fix: scope MCP configuration to Codex and stabilize Windows launchers 2026-10-08 18:01:15 +08:00
chenmingxuan 18d17da966 chore: prepare ARTEMIS-Eapil 1.3.1 release 2026-10-08 17:33:50 +08:00
chenmingxuan 0355af7833 fix: preserve live tasks during daemon recovery 2026-10-08 17:33:48 +08:00
chenmingxuan 079d7b1e28 fix: preserve playback state when seeking video steps 2026-10-08 16:49:07 +08:00
chenmingxuan 1fdf7c0d1d fix: bound task failures and release stalled workers 2026-10-08 16:10:10 +08:00
chenmingxuan 606ed94160 chore: prepare ARTEMIS-Eapil 1.3.0 release 2026-09-30 00:14:40 +08:00
chenmingxuan 8dbe054a8c feat: show progress while preparing batch archives 2026-09-30 00:04:16 +08:00
chenmingxuan 868f611c36 feat: add step markers to recording timeline 2026-09-29 22:41:44 +08:00
chenmingxuan d31eb2a19d feat: add tool-call fallback and resilient batch exports 2026-09-29 19:53:20 +08:00
chenmingxuan ab066733b8 feat: add configurable screenshot resolution limit 2026-09-29 17:12:22 +08:00
chenmingxuan 59f458205d fix: extend visual summary timeout to 120 seconds 2026-09-29 00:56:41 +08:00
chenmingxuan 4465a2e199 feat: settle mobile observations before summarizing steps 2026-09-29 00:39:05 +08:00
chenmingxuan c818a09a49 fix: align Responses messages and video exports 2026-09-29 00:12:35 +08:00
chenmingxuan e1e26772d9 fix: prevent false timeout failures and release 1.2.2 2026-09-28 19:11:16 +08:00
chenmingxuan 1dc2b0c686 fix: prevent timeout stalls and release 1.2.1 2026-09-28 15:20:22 +08:00
chenmingxuan 84e183d28d feat: improve batch task controls and release 1.2.0 2026-09-25 02:02:48 +08:00
447 changed files with 40300 additions and 5079 deletions

No files matched your search

+47 -4
View File
@@ -3,33 +3,62 @@
OPENAI_API_KEY= OPENAI_API_KEY=
# 接口根地址:通常以 /v1 结尾,不要添加 /responses 或 /chat/completions。 # 接口根地址:通常以 /v1 结尾,不要添加 /responses 或 /chat/completions。
OPENAI_BASE_URL=https://api-n-cd.playones.com/v1 OPENAI_BASE_URL=https://api-n-cd.playones.com/v1
OPENAI_MODEL=mimo-v2.6-flash OPENAI_MODEL=gpt-6-luna
# 单次模型 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
# 1 跳过本次网页启动的 Codex MCP 与官方 artemis-use Skill 同步;默认自动安装/更新。
ARTEMIS_SKIP_CODEX_SYNC=0
# 功能开关:保存后对新任务生效 # 功能开关:保存后对新任务生效
ARTEMIS_VIDEO_ENABLED=false ARTEMIS_VIDEO_ENABLED=false
ARTEMIS_RECORDING_ENABLED=false ARTEMIS_RECORDING_ENABLED=false
# 留档编码与质量;自动模式只有客户端确认 HEVC 可播放时才优先 H.265,否则使用 H.264。
# JSON 配置 agent.recording 和任务 recording 可覆盖。截图分辨率不受此配置影响。
ARTEMIS_RECORDING_CONFIG={"codec":"auto","max_long_edge":1280,"fps":15,"h265_kbps":1000,"h264_kbps":1500,"computer_scope":"screen","minimum_disk_mb":256,"hevc_playback":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 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。 # Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3 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
ARTEMIS_VIDEO_TIMEOUT_SECONDS=120 ARTEMIS_VIDEO_TIMEOUT_SECONDS=120
ARTEMIS_VIDEO_MAX_RETRIES=1 ARTEMIS_VIDEO_MAX_RETRIES=1
# 检查点、退出汇总与凭据验证超时(秒) # 中间检查点、退出汇总与凭据验证超时(秒);最终验收按模型请求配置自动分配独立预算。
ARTEMIS_CHECKPOINT_TIMEOUT_SECONDS=180 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
# 模型重试耗尽后的暂停等待上限(秒);0 表示无限等待 # 守护服务轻量探测超时;探测超时且端口仍占用时,不重复启动服务。
ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS=2
# 执行模型重试耗尽后的暂停等待上限(秒);0 表示无限等待。最终验收耗尽重试直接记为未验证。
LLM_PAUSE_TIMEOUT_SECONDS=900 LLM_PAUSE_TIMEOUT_SECONDS=900
# 可选 Google Cloud Vision OCR,独立于模型调用,不配置也可运行。 # 可选 Google Cloud Vision OCR,独立于模型调用,不配置也可运行。
@@ -39,12 +68,26 @@ 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
# 插件连接与安装预算(修改后重启服务)
ARTEMIS_PLUGIN_MARKETPLACE_URL=https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json
ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS=30
ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS=60
ARTEMIS_PLUGIN_SHUTDOWN_TIMEOUT_SECONDS=15
ARTEMIS_PLUGIN_DOWNLOAD_TIMEOUT_SECONDS=300
ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES=134217728
ARTEMIS_PLUGIN_EXTRACT_MAX_BYTES=536870912
ARTEMIS_PLUGIN_RESULT_MAX_BYTES=16777216
+2
View File
@@ -0,0 +1,2 @@
# cmd.exe requires CRLF for batch scripts, especially UTF-8 Chinese text.
*.bat text eol=crlf
+4 -3
View File
@@ -8,7 +8,7 @@
- 本文件适用于整个仓库;子目录的 AGENTS.md 在对应目录内优先。 - 本文件适用于整个仓库;子目录的 AGENTS.md 在对应目录内优先。
- 当前任务的用户指令优先于仓库指南。保留用户已有修改,只处理本次任务范围。 - 当前任务的用户指令优先于仓库指南。保留用户已有修改,只处理本次任务范围。
- 本文件指导开发;使用软件执行 Android 测试时,阅读 [自动化 Skill](skills/artemis-automation/SKILL.md)。 - 本文件指导开发;使用软件执行 Android 测试时,阅读 [自动化 Skill](skills/artemis-use/SKILL.md)。
- 以下是项目原生命令;宿主要求 RTK 等包装器时按宿主规则调用,不把本机路径或工具写成项目安装前提。 - 以下是项目原生命令;宿主要求 RTK 等包装器时按宿主规则调用,不把本机路径或工具写成项目安装前提。
## Quick Commands ## Quick Commands
@@ -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
@@ -38,7 +38,7 @@ Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。开
- `apps/admin_console/`:HTTP API、统一队列、批次、历史及报告服务。 - `apps/admin_console/`:HTTP API、统一队列、批次、历史及报告服务。
- `apps/showcase_ui/`:Angular 网页;遵循 [前端指南](apps/showcase_ui/AGENTS.md)。 - `apps/showcase_ui/`:Angular 网页;遵循 [前端指南](apps/showcase_ui/AGENTS.md)。
- `mcp_server/`:MCP 工具与客户端使用规则。 - `mcp_server/`:MCP 工具与客户端使用规则。
- `packages/artemis-client/`:远程 Python SDK;`skills/artemis-automation/`:随源码分发的 AI 使用技能。 - `packages/artemis-client/`:远程 Python SDK;`skills/artemis-use/`:随源码分发的 AI 使用技能。
- `scripts/`:安装、镜像、升级、检查及打包脚本。 - `scripts/`:安装、镜像、升级、检查及打包脚本。
- `tests/unit/`、`tests/tools/`、`packages/artemis-client/tests/`:默认测试范围。 - `tests/unit/`、`tests/tools/`、`packages/artemis-client/tests/`:默认测试范围。
@@ -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
+24 -11
View File
@@ -2,11 +2,16 @@
基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。 基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。
**2.0.0 已发布**:新增交互探索、插件扩展和精选任务,增强跨端证据与 AI 接入。完整变化及升级步骤见 [2.0.0 发行说明](docs/releases/2.0.0.md)。
## 主要功能 ## 主要功能
- **自然语言任务**:Flash 适合明确的操作流程,Pro 适合需要计划、分支探索和验证的任务。 - **自然语言任务**:Flash 适合明确的操作流程,Pro 适合需要计划、分支探索和验证的任务。
- **交互探索**:边执行边交流,也可录制人工示范;探索成功后生成一个可编辑、复跑的用例。默认使用独立的 `gpt-6.1-sol / high` 配置,超时暂停并保留进度。见 [探索模式](docs/exploration-mode.md)。
- **插件扩展**:在插件市场安装并启用电脑控制等能力,AI 在安卓任务中按需使用;手机、电脑的步骤与证据统一留档。见 [插件与跨端任务](docs/plugins.md)。
- **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。 - **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。
- **批量测试**:按新版 Excel 模板导入提示词,每项可独立指定模型、模式和参数;任务可暂停、补充指导并继续。 - **批量测试**:按新版 Excel 模板导入提示词,每项可独立指定模型、模式和参数;任务可暂停、补充指导并继续。
- **精选任务**:收藏单任务、批次或探索用例,在首页一键复跑,默认沿用已保存的配置。
- **报告与历史**:导出 Word、Excel、步骤截图、录屏和任务资料 ZIP;历史按日期分组,支持搜索和筛选。 - **报告与历史**:导出 Word、Excel、步骤截图、录屏和任务资料 ZIP;历史按日期分组,支持搜索和筛选。
- **AI 接入**:随源码提供 MCP 与 Skill,让 AI 完成设备诊断、任务执行、历史分析和报告交付。 - **AI 接入**:随源码提供 MCP 与 Skill,让 AI 完成设备诊断、任务执行、历史分析和报告交付。
@@ -29,50 +34,58 @@ cd Artemis-Eapil
常用选项: 常用选项:
```powershell ```powershell
.\start-cn.bat -SetupOnly # 准备环境并安装/更新已检测客户端的 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 配置异常时,仅启动网页 .\start.bat -SkipMcpSync # MCP 配置异常时,仅启动网页
``` ```
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。国内脚本使用山东大学镜像安装 scrcpy,并校验官方 SHA256;也可预先安装或自定义 HTTPS 下载地址。 镜像与下载超时在 [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 提供。
## 配置与使用 ## 配置与使用
1. 在首页“系统设置与运行准备”中选择设备,打开“模型配置”。 1. 在首页“系统设置与运行准备”中选择设备,打开“模型配置”。
2. 填写支持 Responses 的接口地址、API Key 和模型名称,测试连接后保存。接口地址通常是以 `/v1` 结尾的 API 根地址。 2. 填写支持 Responses 的接口地址、API Key 和模型名称,测试连接后保存。接口地址通常是以 `/v1` 结尾的 API 根地址。
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-6-luna`、`gpt-6.1-sol`、`gpt-6-astra`、`mimo-v2.6-pro`,主模型与兜底模型均可选择预设或填写自定义名称。新安装默认模型是 `gpt-6-luna`,思考强度默认 `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)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。 也可在项目 `.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 使用
Windows 启动脚本会为已检测到的 AI 客户端安装或更新 MCP。未被检测到的客户端,可手动安装,例如 Codex: 目前仅支持为 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` 查看接入方式。 使用工具前,由用户先启动 ARTEMIS 网页主程序。MCP 仅连接这份主程序,不会为每个 AI 会话启动后台;主程序离线不阻断 Codex 会话,工具会提示先启动网页。
启动 ARTEMIS 时会自动安装或覆盖更新官方 [artemis-use Skill](skills/artemis-use/SKILL.md),包含 Flash/Pro/探索模式、任务诊断复盘和插件开发指南。MCP 配置变化后在 Codex 中重新加载 MCP;Skill 未显示时重启 Codex。详细行为见 [AI 接入指南](docs/ai-automation.md)。可用 `uv run artemis mcp --generate-config codex` 查看配置模板。
示例指令: 示例指令:
> 使用 artemis-automation,在我指定的模拟器上验证系统设置首页可以打开,不修改设置,完成后输出 Word 和 Excel 报告。 > 使用 artemis-use,在我指定的模拟器上验证系统设置首页可以打开,不修改设置,完成后输出 Word 和 Excel 报告。
## 更新与排错 ## 更新与排错
Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建并同步已检测客户端的 MCP。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。MCP 配置同步失败会明确停止启动,可使用 `-SkipMcpSync` 仅启动网页。更新后需在 AI 客户端重新加载 MCP。 启动脚本只检查并提示源码更新,不自动拉取。升级运行 **`.\update.bat`**,国内网络运行 **`.\update-cn.bat`**;支持 `-Help`。升级会终止当前项目的 ARTEMIS 服务、MCP 和任务进程,然后快进更新源码,同步依赖、网页构建及 Codex MCP。升级程序先复制到项目外运行,避免更新正在执行的脚本;其他项目、Codex 本身、模拟器和共享 ADB 服务不会被终止。完成后重新启动 ARTEMIS,并在 Codex 中重载 MCP。
1.1.1 及更新版本的发布 ZIP 含浅层 Git 工作树,解压后再次运行一键脚本即可检查更新。1.1.0 及更早 ZIP 不含 Git 信息,无法自行拉取;需要下载新版源码包重新解压一次,之后即可自动更新。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。 本地修改、分支分叉或网络认证失败会停止升级,保留现有文件;`.env`、密钥和历史资料不会清空。依赖同步失败会明确报错;Codex MCP 或 Skill 同步失败会保留可用版本并告警,网页继续启动。可用 `-SkipMcpSync` 跳过该次 Codex 集成同步。
1.1.1 及更新版本的发布 ZIP 含浅层 Git 工作树;包含独立升级入口的新版源码包可直接用上述脚本升级。1.1.0 及更早 ZIP 不含 Git 信息,需改用 Git 克隆或新版源码包。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。
电脑控制插件由独立市场分发,升级 ARTEMIS 后还需在「插件」页面安装或更新并启用。当前 Cua 插件适用于 Windows x64,保留预览标识和实测范围;需要前台输入时会占用用户桌面。配置和开发约定见 [插件与跨端任务](docs/plugins.md)。
更多说明:[批量任务与报告](docs/automation-workspace.md) · [AI 接入](docs/ai-automation.md) · [开发协作指南](AGENTS.md) 更多说明:[批量任务与报告](docs/automation-workspace.md) · [AI 接入](docs/ai-automation.md) · [开发协作指南](AGENTS.md)
+10 -2
View File
@@ -14,6 +14,14 @@
"""Artemis Admin & Trace Console Package.""" """Artemis Admin & Trace Console Package."""
from apps.admin_console.server import app
__all__ = ["app"] __all__ = ["app"]
def __getattr__(name):
# Workers use shared persistence without importing the HTTP routers or
# constructing the web application. Keep the public app import compatible.
if name == "app":
from apps.admin_console.server import app
return app
raise AttributeError(name)
@@ -23,10 +23,7 @@ from artemis.runtime import trace_store
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: from apps.admin_console.database.connection import db_session
from admin_console.database.connection import db_session
except ImportError:
from apps.admin_console.database.connection import db_session
def _canonicalize_status(row: dict[str, Any]) -> dict[str, Any]: def _canonicalize_status(row: dict[str, Any]) -> dict[str, Any]:
@@ -323,6 +320,7 @@ class SessionRepository:
return 0 return 0
llm_calls = prompt = completion = total = cached = 0 llm_calls = prompt = completion = total = cached = 0
by_node_model: dict[tuple[str, str], dict] = {}
operator_context: int | None = None operator_context: int | None = None
operator_context_at: float | None = None operator_context_at: float | None = None
with db_session(self.db_path) as conn: with db_session(self.db_path) as conn:
@@ -351,6 +349,27 @@ class SessionRepository:
total += t total += t
cached += _int(payload.get("cached_tokens")) cached += _int(payload.get("cached_tokens"))
node = str(payload.get("node") or "").strip().lower() node = str(payload.get("node") or "").strip().lower()
model_name = str(payload.get("model") or "unknown")
group = by_node_model.setdefault(
(node, model_name),
{
"node": node,
"model": model_name,
"llm_calls": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
"cached_tokens": 0,
},
)
group["llm_calls"] += 1
group["prompt_tokens"] += p
group["completion_tokens"] += c
group["cached_tokens"] += _int(payload.get("cached_tokens"))
group["cache_hit_rate"] = (
group["cached_tokens"] / group["prompt_tokens"]
if group["prompt_tokens"]
else None
)
if node in OPERATOR_NODE_NAMES: if node in OPERATOR_NODE_NAMES:
operator_context = _int(payload.get("context_base_tokens")) or p operator_context = _int(payload.get("context_base_tokens")) or p
operator_context_at = row["timestamp"] operator_context_at = row["timestamp"]
@@ -378,6 +397,7 @@ class SessionRepository:
"completion_tokens": completion, "completion_tokens": completion,
"total_tokens": total, "total_tokens": total,
"cached_tokens": cached, "cached_tokens": cached,
"by_node_model": list(by_node_model.values()),
"operator_context_tokens": operator_context, "operator_context_tokens": operator_context,
"operator_context_window_tokens": OPERATOR_CONTEXT_WINDOW_TOKENS, "operator_context_window_tokens": OPERATOR_CONTEXT_WINDOW_TOKENS,
"operator_context_updated_at": operator_context_at, "operator_context_updated_at": operator_context_at,
@@ -16,10 +16,7 @@ import json
import re import re
from typing import Any from typing import Any
try: from apps.admin_console.database.connection import db_session
from admin_console.database.connection import db_session
except ImportError:
from apps.admin_console.database.connection import db_session
class StepRepository: class StepRepository:
@@ -183,6 +180,13 @@ class StepRepository:
if not isinstance(payload_obj, dict): if not isinstance(payload_obj, dict):
return None return None
action = payload_obj.get("action")
if payload_obj.get("plugin_id") or (isinstance(action, dict) and action.get("plugin_id")):
# Plugin payloads contain already-redacted evidence and explicit image
# references. The legacy compact-tool formatter would discard their
# provenance and make a desktop image inherit the phone's step source.
return self._clean_value(payload_obj)
if trace_type == "llm_call": if trace_type == "llm_call":
allowed_keys = ( allowed_keys = (
"error", "error",
@@ -15,10 +15,7 @@
import json import json
from typing import Any from typing import Any
try: from apps.admin_console.database.connection import db_session
from admin_console.database.connection import db_session
except ImportError:
from apps.admin_console.database.connection import db_session
class TraceRepository: class TraceRepository:
+140 -14
View File
@@ -8,9 +8,13 @@ from fastapi import APIRouter, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse, Response from fastapi.responses import FileResponse, Response
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from apps.admin_console.services.exploration_listing import EXPLORATION_RUNS_CTE
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,15 +25,22 @@ 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)
cases: list[task_import.TaskCase] = Field(min_length=1, max_length=task_import.MAX_ROWS) cases: list[task_import.TaskCase] = Field(min_length=1, max_length=task_import.MAX_ROWS)
device_serial: str = Field(min_length=1, max_length=200) device_serial: str | None = Field(default=None, 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 +60,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 +130,12 @@ 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}") @router.delete("/batches/{batch_id}")
@@ -128,10 +145,13 @@ def delete_batch(batch_id: str):
except ValueError as exc: except ValueError as exc:
raise HTTPException(400, str(exc)) from exc raise HTTPException(400, str(exc)) from exc
try: try:
if not store.delete_batch(key): if archive_service.status(key, require("batch", key))["status"] == "preparing":
raise HTTPException(409, "报告正在打包,请完成后再删除批次")
if not service.delete_batch(key):
raise HTTPException(404, "批次不存在") raise HTTPException(404, "批次不存在")
except ValueError as exc: except ValueError as exc:
raise HTTPException(409, str(exc)) from exc raise HTTPException(409, str(exc)) from exc
archive_service.forget(key)
return {"batch_id": key, "status": "deleted"} return {"batch_id": key, "status": "deleted"}
@@ -144,9 +164,25 @@ 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)
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 archive_service.status(batch_id, batch)["status"] != "ready":
path = await asyncio.to_thread(report_export.export_batch, batch_id) 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")
@@ -165,14 +201,52 @@ async def download_report(
): ):
try: try:
if fmt in {"screenshots", "video", "all"}: if fmt in {"screenshots", "video", "all"}:
path = await asyncio.to_thread(report_export.export_session_media, session_id, fmt) path, label = await asyncio.to_thread(report_export.session_download, session_id, fmt)
return FileResponse(path, filename=f"任务{session_id[:8]}-{fmt}.zip") 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(
*(["batch:" + batch_id] if batch_id else []), "session:" + session_id
):
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),
@@ -181,16 +255,24 @@ 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 = (
SELECT s.session_id, s.initial_goal, s.start_time, s.end_time, "WITH "
+ EXPLORATION_RUNS_CTE
+ """, history_base AS (
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.case_id'),'') case_id,
COALESCE(json_extract(j.payload,'$.case.category'),'') category, COALESCE(json_extract(j.payload,'$.case.category'),'') category,
COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory, COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory,
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions,
COALESCE(json_extract(j.payload,'$.error'),'') error, j.batch_id,
COALESCE(CASE WHEN json_valid(s.device_info) THEN json_extract(s.device_info,'$.profile') END,
json_extract(j.payload,'$.task_config.profile'),json_extract(j.payload,'$.profile')) profile
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'),
@@ -200,13 +282,38 @@ def history(
COALESCE(json_extract(j.payload,'$.case.case_id'),''), COALESCE(json_extract(j.payload,'$.case.case_id'),''),
COALESCE(json_extract(j.payload,'$.case.category'),''), COALESCE(json_extract(j.payload,'$.case.category'),''),
COALESCE(json_extract(j.payload,'$.case.subcategory'),''), COALESCE(json_extract(j.payload,'$.case.subcategory'),''),
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') COALESCE(json_extract(j.payload,'$.case.preconditions'),''),
COALESCE(json_extract(j.payload,'$.error'),''), j.batch_id,
COALESCE(json_extract(j.payload,'$.task_config.profile'),json_extract(j.payload,'$.profile'))
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)
), history AS (
SELECT h.*, e.exploration_id, e.exploration_kind, e.case_version,
e.exploration_status, e.exploration_latest_run_id, e.exploration_run_count,
e.exploration_case_name, e.exploration_profile
FROM history_base h LEFT JOIN exploration_runs e ON e.run_id=h.session_id
WHERE COALESCE(e.exploration_kind,'') != 'exploration'
OR (e.run_id=e.exploration_latest_run_id AND
COALESCE(e.exploration_status,'completed') NOT IN
('draft','reserved','queued','running','paused','waiting_user','demonstrating','analyzing_demo','finalizing','interrupted'))
) """ ) """
)
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 == "__exploration__":
where += " AND exploration_kind = 'exploration'"
elif batch_id == "__batch__":
where += " AND batch_id IS NOT NULL"
elif batch_id == "__single__":
where += " AND batch_id IS NULL AND COALESCE(exploration_kind,'') != 'exploration'"
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 '\\' OR case_id LIKE ? ESCAPE '\\' OR category LIKE ? ESCAPE '\\' OR subcategory LIKE ? ESCAPE '\\' OR preconditions LIKE ? ESCAPE '\\')" where += " AND (COALESCE(exploration_case_name,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] * 8) args.extend([escaped] * 8)
for condition, value in [ for condition, value in [
@@ -226,4 +333,23 @@ 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["profile"] = (
"explore"
if item.get("exploration_kind") == "exploration"
else item.get("exploration_profile") or item.get("profile")
)
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)
if item.get("exploration_kind") == "validation" and item.get("exploration_case_name"):
item["name"] = item["exploration_case_name"]
return {"items": items, "total": total, "page": page, "size": size}
+180
View File
@@ -0,0 +1,180 @@
"""Shared exploration operations; no separate Web execution path."""
from contextlib import contextmanager
from fastapi import APIRouter, HTTPException, Query
from artemis.config.exploration import ExplorationConfig
from artemis.exploration.models import (
CreateExploration,
ExplorationMessage,
ExplorationRequest,
SaveCaseRequest,
ValidateCaseRequest,
DemonstrationRequest,
ExtendDemonstration,
GenerateCaseRequest,
CaseBatchRequest,
)
from artemis.exploration.store import ExplorationConflict
from apps.admin_console.services.exploration_service import exploration_service as service
router = APIRouter(prefix="/api/explorations", tags=["exploration"])
@router.get("/{exploration_id}/deletion-preview")
def preview_deletion(exploration_id: str):
from apps.admin_console.services.task_deletion import exploration_deletion_preview
with domain_errors():
return exploration_deletion_preview(exploration_id)
@router.delete("/{exploration_id}")
def delete_conversation(exploration_id: str):
from apps.admin_console.services.exploration_deletion import delete_exploration
with domain_errors():
deleted = delete_exploration(service.repository, exploration_id)
return {"status": "success", "deleted_session_ids": deleted}
@contextmanager
def domain_errors():
try:
yield
except KeyError as exc:
raise HTTPException(404, str(exc).strip("'")) from exc
except ExplorationConflict as exc:
raise HTTPException(409, str(exc)) from exc
except ValueError as exc:
# Pydantic may echo sensitive input. Invalid nested fields get a plain error.
from pydantic import ValidationError
raise HTTPException(
400, "探索参数不合法" if isinstance(exc, ValidationError) else str(exc)
) from exc
@router.get("/settings")
def settings():
return service.settings()
@router.put("/settings")
def save_settings(value: ExplorationConfig):
return service.save_settings(value)
@router.get("")
def list_explorations(limit: int = Query(100, ge=1, le=500)):
return service.repository.list(limit)
@router.post("")
def create(value: CreateExploration, source: str = Query("web", pattern="^(web|mcp)$")):
with domain_errors():
return service.create(value, source=source)
@router.get("/{session_id}")
def detail(session_id: str, after: int = Query(0, ge=0)):
with domain_errors():
return service.detail(session_id, after=after)
@router.post("/{session_id}/start")
async def start(
session_id: str, value: ExplorationRequest, source: str = Query("web", pattern="^(web|mcp)$")
):
with domain_errors():
return await service.start(session_id, value.request_id, source=source)
@router.post("/{session_id}/messages")
async def message(
session_id: str, value: ExplorationMessage, source: str = Query("web", pattern="^(web|mcp)$")
):
with domain_errors():
result = service.message(session_id, value.text, value.request_id, source=source)
if not result.get("run_id"):
from apps.admin_console.services.automation_service import spawn
from artemis.exploration.editorial import discuss
spawn(discuss(service.repository, session_id, result))
return result
@router.post("/{session_id}/pause")
def pause(session_id: str):
with domain_errors():
return service.pause(session_id)
@router.post("/{session_id}/stop")
async def stop(session_id: str):
with domain_errors():
return service.stop(session_id)
@router.post("/{session_id}/continue")
async def resume(
session_id: str, value: ExplorationRequest, source: str = Query("web", pattern="^(web|mcp)$")
):
with domain_errors():
return await service.resume(session_id, value.request_id, source=source)
@router.get("/{session_id}/case")
def case(session_id: str, version: int | None = Query(None, ge=1)):
with domain_errors():
return service.repository.case(session_id, version)
@router.post("/{session_id}/case")
def save_case(session_id: str, value: SaveCaseRequest):
with domain_errors():
return service.save_case(session_id, value)
@router.post("/{session_id}/validate")
async def validate(
session_id: str, value: ValidateCaseRequest, source: str = Query("web", pattern="^(web|mcp)$")
):
with domain_errors():
return await service.validate(session_id, value, source=source)
@router.post("/{session_id}/demonstrations")
def demonstrate(session_id: str, value: DemonstrationRequest):
with domain_errors():
return service.demonstrate(session_id, value.request_id, value.source)
@router.post("/{session_id}/demonstrations/{demo_id}/extend")
def extend_demonstration(session_id: str, demo_id: str, value: ExtendDemonstration):
with domain_errors():
return service.demonstration_command(
session_id, demo_id, "extend", value.request_id, seconds=value.seconds
)
@router.post("/{session_id}/demonstrations/{demo_id}/{command}")
def demonstration_command(session_id: str, demo_id: str, command: str, value: ExplorationRequest):
if command not in {"finish", "cancel"}:
raise HTTPException(404, "示范操作不存在")
with domain_errors():
return service.demonstration_command(session_id, demo_id, command, value.request_id)
@router.post("/{session_id}/generate")
async def generate_case(session_id: str, value: GenerateCaseRequest):
with domain_errors():
return await service.generate_case(session_id, value)
@router.post("/{session_id}/batch")
def case_batch(session_id: str, value: CaseBatchRequest):
with domain_errors():
return service.create_case_batch(session_id, value)
+50
View File
@@ -0,0 +1,50 @@
"""Favorite templates shared by the home page and MCP."""
from fastapi import APIRouter, HTTPException
from apps.admin_console.services.favorites import (
FavoriteCreate,
FavoriteRun,
FavoriteUpdate,
favorites,
)
router = APIRouter(prefix="/api/automation/favorites", tags=["automation"])
@router.get("")
def list_favorites():
return favorites.list()
@router.post("")
def create_favorite(value: FavoriteCreate):
try:
return favorites.create(value)
except (KeyError, ValueError) as exc:
raise HTTPException(409, str(exc)) from exc
@router.patch("/{key}")
def update_favorite(key: str, value: FavoriteUpdate):
try:
return favorites.update(key, value)
except (KeyError, ValueError) as exc:
raise HTTPException(409, str(exc)) from exc
@router.delete("/{key}")
def delete_favorite(key: str):
try:
favorites.delete(key)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"status": "deleted"}
@router.post("/{key}/run")
async def run_favorite(key: str, value: FavoriteRun):
try:
return await favorites.run(key, value)
except (KeyError, ValueError) as exc:
raise HTTPException(409, str(exc)) from exc
+73 -6
View File
@@ -19,12 +19,8 @@ from fastapi.responses import FileResponse, HTMLResponse
from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
try: from apps.admin_console.database.repositories.session_repository import session_repo
from admin_console.database.repositories.session_repository import session_repo from apps.admin_console.services.media_service import media_service
from admin_console.services.media_service import media_service
except ImportError:
from apps.admin_console.database.repositories.session_repository import session_repo
from apps.admin_console.services.media_service import media_service
router = APIRouter(tags=["media"]) router = APIRouter(tags=["media"])
@@ -115,6 +111,77 @@ async def get_session_video(session_id: str):
def _get_session_video_sync(session_id: str): def _get_session_video_sync(session_id: str):
from apps.admin_console.services.evidence_service import plugin_sources
from apps.admin_console.services.exploration_evidence import demonstration_sources
from apps.admin_console.services.report_export import session_videos
legacy = _get_legacy_session_video_sync(session_id)
sources = []
if (
legacy.get("video_segments") or legacy.get("video_url")
) and "/evidence/demonstrations/" not in str(legacy.get("video_url") or ""):
segments = legacy.get("video_segments") or []
# Pauses create sibling resume directories. Only collect files already
# attached to this task; unrelated recordings never enter its timeline.
if segments and all("offset_ms" in segment for segment in segments):
combined = {}
for path, _ in session_videos({"id": session_id}):
for segment in media_service.resolve_video_segments(
media_service.path_to_video_url(path)
):
if "offset_ms" in segment:
combined[segment["url"]] = segment
if combined:
segments = sorted(combined.values(), key=lambda segment: segment["offset_ms"])
sources.append(
{
"id": "android",
"kind": "android",
"label": "安卓",
"target_ids": ["android"],
"status": "ready",
"message": "",
"segments": [
{**s, "start": float(s.get("offset_ms", s["start"] * 1000)) / 1000}
for s in segments
],
"url": legacy.get("video_url"),
}
)
sources += plugin_sources(session_id) + demonstration_sources(session_id)
legacy["sources"] = sources
legacy["session_timeline"] = any(source["segments"] for source in sources)
row = dict(session_repo.get_session_by_id(session_id) or {})
legacy["session_duration"] = max(
max(
0.0,
float(row.get("end_time") or row.get("start_time") or 0)
- float(row.get("start_time") or 0),
),
max(
(s["start"] + s["duration"] for source in sources for s in source["segments"]),
default=0,
),
)
ready = next((s for s in sources if s.get("segments") or s.get("url")), None)
if ready:
legacy.update(
status="ready",
has_video=True,
video_url=ready.get("url") or ready["segments"][0]["url"],
video_segments=ready["segments"],
)
elif any(s.get("status") == "recording" for s in sources):
legacy.update(status="processing", retry_after_ms=1000)
elif sources:
legacy.update(
status="failed",
message=";".join(str(s.get("message") or "录像不可用") for s in sources),
)
return legacy
def _get_legacy_session_video_sync(session_id: str):
video_rec_map = session_repo.get_video_recordings_map() video_rec_map = session_repo.get_video_recordings_map()
video_idx = media_service.build_video_index() video_idx = media_service.build_video_index()
row = session_repo.get_session_by_id(session_id) row = session_repo.get_session_by_id(session_id)
+245
View File
@@ -0,0 +1,245 @@
"""Plugin management for the local console; credentials never leave this API."""
from concurrent.futures import ThreadPoolExecutor
import io
from pathlib import Path
from threading import Lock
from typing import Literal
import uuid
import zipfile
from fastapi import APIRouter, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.routing import APIRoute
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, ValidationError
from artemis.plugins.installer import download, install_archive, install_local, load_catalog
from artemis.plugins.models import PluginManifest, PluginProfile, PluginSelection
from artemis.plugins.runtime import PluginRuntime
from artemis.plugins.store import PluginStore
class PluginRoute(APIRoute):
def get_route_handler(self):
handler = super().get_route_handler()
async def sanitized(request):
try:
return await handler(request)
except RequestValidationError:
return JSONResponse(
status_code=422,
content={
"detail": "插件配置格式不正确,请检查字段类型和清单;输入内容不会回显。"
},
)
return sanitized
router = APIRouter(prefix="/api/plugins", tags=["plugins"], route_class=PluginRoute)
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="plugin-installer")
_jobs: dict[str, dict] = {}
_lock = Lock()
def get_store() -> PluginStore:
return PluginStore()
class InstallRequest(BaseModel):
plugin_id: str | None = None
version: str | None = None
local_path: str | None = None
class EnableRequest(BaseModel):
enabled: bool
class DiagnoseRequest(BaseModel):
profile: str = "default"
class CustomRequest(BaseModel):
manifest: PluginManifest
skill_content: str = Field(default="", max_length=262144)
class ResourceConfirmation(BaseModel):
resource: str = Field(min_length=1, max_length=256)
confirmed_stopped: Literal[True]
def public_error(exc: Exception) -> str:
return "插件清单或配置格式不正确" if isinstance(exc, ValidationError) else str(exc)
@router.get("")
def list_plugins():
store = get_store()
return {"plugins": store.list(), "blocked_resources": store.blocked_resources()}
@router.post("/resources/confirm-stopped")
def confirm_resource_stopped(request: ResourceConfirmation):
from artemis.runtime.device_lock import DeviceExecutionLock
if any(
request.resource in owner.resource_ids
for owner in DeviceExecutionLock.get_active_owners().values()
):
raise HTTPException(409, "此资源仍被任务占用,请先停止相关任务并确认插件动作已停止")
get_store().confirm_resource_stopped(request.resource)
return {"ok": True}
@router.get("/catalog")
def marketplace():
try:
return load_catalog().model_dump()
except Exception:
raise HTTPException(502, "暂时无法读取插件市场,请稍后重试;已安装插件不受影响") from None
@router.post("/install", status_code=202)
def install_plugin(request: InstallRequest):
if bool(request.local_path) == bool(request.plugin_id):
raise HTTPException(400, "请选择一个市场插件或本地安装包")
with _lock:
if any(j["status"] in {"queued", "running"} for j in _jobs.values()):
raise HTTPException(409, "已有插件正在安装,请等待安装结束")
for key in list(_jobs)[:-30]:
del _jobs[key]
job_id = str(uuid.uuid4())
_jobs[job_id] = {"id": job_id, "status": "queued", "stage": "等待安装", "progress": 0}
def progress(stage, percent):
with _lock:
_jobs[job_id].update(status="running", stage=stage, progress=percent)
def run():
try:
store = get_store()
if request.local_path:
installed = install_local(store, Path(request.local_path), progress)
else:
progress("获取市场索引", 0)
candidates = [
p
for p in load_catalog().plugins
if p.manifest.id == request.plugin_id
and (request.version is None or p.manifest.version == request.version)
]
from packaging.version import Version
compatible = []
for entry in candidates:
try:
entry.manifest.check_compatible()
compatible.append(entry)
except ValueError:
continue
if not compatible:
raise ValueError("没有已验证且兼容的插件版本")
entry = max(compatible, key=lambda p: Version(p.manifest.version))
installed = install_archive(
store, download(entry.archive_url, progress), entry=entry, progress=progress
)
with _lock:
_jobs[job_id].update(
status="completed",
stage="安装完成,请配置并启用",
progress=100,
plugin_id=installed["manifest"]["id"],
)
except Exception as exc:
with _lock:
# HTTP exceptions may contain signed URLs; expose only our validation messages.
message = (
public_error(exc)
if isinstance(exc, (ValueError, FileNotFoundError))
else "下载或安装失败,请检查网络及安装包"
)
_jobs[job_id].update(status="failed", stage=message)
_executor.submit(run)
return {"job_id": job_id}
@router.get("/install/{job_id}")
def install_progress(job_id: str):
with _lock:
if job_id not in _jobs:
raise HTTPException(404, "安装记录不存在,服务可能已重启;请刷新插件列表")
return dict(_jobs[job_id])
@router.post("/custom")
def register_custom(request: CustomRequest):
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
archive.writestr("plugin.json", request.manifest.model_dump_json())
if request.manifest.skill:
archive.writestr(request.manifest.skill, request.skill_content)
try:
return install_archive(get_store(), buffer.getvalue())
except (ValueError, OSError) as exc:
raise HTTPException(400, public_error(exc)) from None
@router.put("/{plugin_id}/profiles")
def save_profile(plugin_id: str, profile: PluginProfile):
try:
get_store().save_profile(plugin_id, profile)
return get_store().get(plugin_id)
except ValueError as exc:
raise HTTPException(400, public_error(exc)) from None
@router.put("/{plugin_id}/enabled")
def enable_plugin(plugin_id: str, request: EnableRequest):
try:
get_store().set_enabled(plugin_id, request.enabled)
return get_store().get(plugin_id)
except ValueError as exc:
raise HTTPException(400, public_error(exc)) from None
@router.post("/{plugin_id}/diagnose")
async def diagnose_plugin(plugin_id: str, request: DiagnoseRequest):
runtime = None
result = {"ok": False, "message": "插件连接失败,请检查启用状态、授权、命令和配置"}
try:
store = get_store()
manifest = PluginManifest.model_validate(store.get(plugin_id)["manifest"])
runtime = PluginRuntime(
[PluginSelection(id=plugin_id, profile=request.profile)],
store=store,
target=manifest.targets[0],
)
loaded = await runtime.load(plugin_id)
result = {
"ok": loaded.status == "success",
"tools": loaded.structured_content or {},
"message": "插件连接正常",
}
except Exception:
result = {"ok": False, "message": "插件连接失败,请检查启用状态、授权、命令和配置"}
finally:
if runtime:
try:
await runtime.aclose()
except RuntimeError:
result = {"ok": False, "message": "插件未确认停止,请检查受保护的资源"}
return result
@router.delete("/{plugin_id}")
def uninstall_plugin(plugin_id: str):
try:
get_store().uninstall(plugin_id)
return {"ok": True}
except (ValueError, OSError) as exc:
raise HTTPException(409, public_error(exc)) from None
+2 -6
View File
@@ -17,12 +17,8 @@ from fastapi import APIRouter, HTTPException
from artemis.config import WORKSPACE_ROOT from artemis.config import WORKSPACE_ROOT
try: from apps.admin_console.schemas.task_schema import ReplayRequest
from admin_console.schemas.task_schema import ReplayRequest from apps.admin_console.replay_manager import ReplayManager
from admin_console.replay_manager import ReplayManager
except ImportError:
from apps.admin_console.schemas.task_schema import ReplayRequest
from apps.admin_console.replay_manager import ReplayManager
replay_manager = ReplayManager(WORKSPACE_ROOT) replay_manager = ReplayManager(WORKSPACE_ROOT)
+114 -14
View File
@@ -16,28 +16,57 @@ import asyncio
import json import json
import time import time
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import Response
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: from apps.admin_console.core.state import state
from admin_console.core.state import state from apps.admin_console.database.repositories.session_repository import session_repo
from admin_console.database.repositories.session_repository import session_repo from apps.admin_console.database.repositories.trace_repository import trace_repo
from admin_console.database.repositories.trace_repository import trace_repo from apps.admin_console.services.media_service import media_service
from admin_console.services.media_service import media_service from apps.admin_console.services.model_service import model_service
from admin_console.services.model_service import model_service from apps.admin_console.services.automation_store import store
except ImportError: from apps.admin_console.services.task_import import execution_title
from apps.admin_console.core.state import state from apps.admin_console.services.exploration_listing import exploration_metadata
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.services.media_service import media_service
from apps.admin_console.services.model_service import model_service
router = APIRouter(tags=["sessions"]) router = APIRouter(tags=["sessions"])
@router.get("/api/sessions/{session_id}/inspection")
def inspect_session(
session_id: str,
view: str = "report",
offset: int = Query(default=0, ge=0),
limit: int = Query(default=20, ge=1, le=100),
):
from apps.admin_console.services.task_inspection import inspect_task
try:
return inspect_task(session_id, view, offset=offset, limit=limit)
except LookupError as exc:
raise HTTPException(404, str(exc)) from exc
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.get("/api/sessions/{session_id}/request-diagnostics")
def get_request_diagnostics(session_id: str, download: bool = False):
from apps.admin_console.services.request_diagnostics import request_diagnostics
result = request_diagnostics(session_id, limit=None if download else 100)
if not download:
return result
return Response(
json.dumps(result, ensure_ascii=False, indent=2),
media_type="application/json",
headers={"Content-Disposition": 'attachment; filename="model-request-diagnostics.json"'},
)
@router.get("/api/sessions") @router.get("/api/sessions")
async def list_sessions(): async def list_sessions():
# The body does blocking work (sqlite queries, filesystem scans, ffmpeg # The body does blocking work (sqlite queries, filesystem scans, ffmpeg
@@ -52,6 +81,9 @@ async def list_sessions():
def _list_sessions_sync(): def _list_sessions_sync():
rows = session_repo.get_all_sessions() rows = session_repo.get_all_sessions()
with store.connection() as connection:
exploration_runs = exploration_metadata(connection)
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 +106,19 @@ 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"))
row_dict.update(exploration_runs.get(s_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 +159,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(
@@ -164,6 +216,19 @@ def _list_sessions_sync():
except Exception as e: except Exception as e:
print(f"[list_sessions] Failed to update orphaned sessions: {e}") print(f"[list_sessions] Failed to update orphaned sessions: {e}")
for row in result:
if row.get("exploration_kind") == "validation" and row.get("exploration_case_name"):
row["display_name"] = row["exploration_case_name"]
model = row.pop("exploration_model", None)
if row.get("exploration_kind") == "exploration":
row["model_info"] = {
"name": "探索",
"id": model,
"provider": "openai",
"architecture": "explore",
}
elif model:
row["model_info"] = {**row["model_info"], "id": model}
return result return result
@@ -173,7 +238,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")
@@ -227,11 +298,33 @@ async def cleanup_history_endpoint():
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/sessions/{session_id}/deletion-preview")
def preview_session_deletion(session_id: str):
from apps.admin_console.services.task_deletion import deletion_preview
try:
return deletion_preview(session_id)
except KeyError as exc:
raise HTTPException(404, str(exc)) from exc
except ValueError as exc:
raise HTTPException(409, str(exc)) from exc
@router.post("/api/sessions/{session_id}/delete") @router.post("/api/sessions/{session_id}/delete")
async def delete_session_endpoint(session_id: str): async def delete_session_endpoint(session_id: str):
from artemis.exploration.store import ExplorationConflict, explorations
try: try:
from artemis.data_engine.storage import StorageManager from artemis.data_engine.storage import StorageManager
session_id = str(UUID(session_id))
run = explorations.run(session_id)
if run and run["kind"] == "exploration":
from apps.admin_console.services.exploration_deletion import delete_exploration
deleted = delete_exploration(explorations, run["exploration_id"])
return {"status": "success", "deleted_session_ids": deleted}
preview_session_deletion(session_id)
storage = StorageManager(DB_PATH, TRACES_PATH) storage = StorageManager(DB_PATH, TRACES_PATH)
storage.delete_session(UUID(session_id)) storage.delete_session(UUID(session_id))
from apps.admin_console.services.automation_store import store from apps.admin_console.services.automation_store import store
@@ -240,6 +333,13 @@ async def delete_session_endpoint(session_id: str):
return { return {
"status": "success", "status": "success",
"message": f"Session {session_id} deleted successfully", "message": f"Session {session_id} deleted successfully",
"deleted_session_ids": [session_id],
} }
except ExplorationConflict as e:
raise HTTPException(status_code=409, detail=str(e)) from e
except HTTPException:
raise
except (ValueError, KeyError) as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
+3 -8
View File
@@ -19,14 +19,9 @@ from fastapi.responses import PlainTextResponse
from artemis.config import TEST_OUTPUTS_DIR from artemis.config import TEST_OUTPUTS_DIR
try: from apps.admin_console.database.repositories.step_repository import step_repo
from admin_console.database.repositories.step_repository import step_repo from apps.admin_console.database.repositories.trace_repository import trace_repo
from admin_console.database.repositories.trace_repository import trace_repo from apps.admin_console.services.media_service import media_service
from admin_console.services.media_service import media_service
except ImportError:
from apps.admin_console.database.repositories.step_repository import step_repo
from apps.admin_console.database.repositories.trace_repository import trace_repo
from apps.admin_console.services.media_service import media_service
router = APIRouter(tags=["steps"]) router = APIRouter(tags=["steps"])
+1 -4
View File
@@ -20,10 +20,7 @@ Exposes real-time screen streaming endpoints for the Web UI.
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
try: from apps.admin_console.services.device_stream_service import device_stream_service
from admin_console.services.device_stream_service import device_stream_service
except ImportError:
from apps.admin_console.services.device_stream_service import device_stream_service
router = APIRouter(tags=["stream"]) router = APIRouter(tags=["stream"])
+33 -14
View File
@@ -517,28 +517,47 @@ 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
except ImportError: except ImportError:
from admin_console.core.state import state from apps.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,
} }
@@ -555,7 +574,7 @@ async def restart_server_endpoint(request: Request):
try: try:
from apps.admin_console.core.state import state from apps.admin_console.core.state import state
except ImportError: except ImportError:
from admin_console.core.state import state from apps.admin_console.core.state import state
port = getattr(state, "port", 8000) port = getattr(state, "port", 8000)
current_pid = os.getpid() current_pid = os.getpid()
@@ -596,7 +615,7 @@ async def shutdown_server_endpoint(request: Request):
try: try:
from apps.admin_console.core.state import state from apps.admin_console.core.state import state
except ImportError: except ImportError:
from admin_console.core.state import state from apps.admin_console.core.state import state
_require_local_lifecycle_request(request) _require_local_lifecycle_request(request)
server = getattr(request.app.state, "uvicorn_server", None) server = getattr(request.app.state, "uvicorn_server", None)
+67 -22
View File
@@ -16,10 +16,14 @@ import asyncio
from contextlib import suppress from contextlib import suppress
import json import json
from typing import Any from typing import Any
from uuid import uuid4
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 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.exploration_listing import exploration_metadata
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 ( from artemis.runtime.task_control import (
add_instruction, add_instruction,
@@ -30,22 +34,13 @@ from artemis.runtime.task_control import (
request_resume, request_resume,
) )
try: from apps.admin_console.core.state import state
from admin_console.core.state import state from apps.admin_console.database.repositories.session_repository import session_repo
from admin_console.database.repositories.session_repository import session_repo from apps.admin_console.schemas.task_schema import RunRequest
from admin_console.schemas.task_schema import RunRequest from apps.admin_console.services.ipc_service import ipc_service
from admin_console.services.ipc_service import ipc_service from apps.admin_console.services.model_service import model_service
from admin_console.services.model_service import model_service from apps.admin_console.services.task_preset_catalog import task_recommendation_engine
from admin_console.services.task_preset_catalog import task_recommendation_engine from apps.admin_console.services.task_queue_service import task_queue_service
from admin_console.services.task_queue_service import task_queue_service
except ImportError:
from apps.admin_console.core.state import state
from apps.admin_console.database.repositories.session_repository import session_repo
from apps.admin_console.schemas.task_schema import RunRequest
from apps.admin_console.services.ipc_service import ipc_service
from apps.admin_console.services.model_service import model_service
from apps.admin_console.services.task_preset_catalog import task_recommendation_engine
from apps.admin_console.services.task_queue_service import task_queue_service
router = APIRouter(tags=["tasks"]) router = APIRouter(tags=["tasks"])
@@ -53,6 +48,7 @@ router = APIRouter(tags=["tasks"])
class GuidanceRequest(BaseModel): class GuidanceRequest(BaseModel):
instruction: str = Field(min_length=1, max_length=10000) instruction: str = Field(min_length=1, max_length=10000)
request_id: str | None = Field(default=None, min_length=1, max_length=200)
def _live_task(session_id: str) -> dict: def _live_task(session_id: str) -> dict:
@@ -75,7 +71,23 @@ def pause_running_task(session_id: str):
@router.post("/api/tasks/{session_id}/guide") @router.post("/api/tasks/{session_id}/guide")
def guide_running_task(session_id: str, value: GuidanceRequest): def guide_running_task(session_id: str, value: GuidanceRequest):
_live_task(session_id) _live_task(session_id)
guidance = add_instruction(session_id, value.instruction, source="web") from apps.admin_console.services.exploration_service import exploration_service
from artemis.exploration.store import ExplorationConflict
if run := exploration_service.repository.run(session_id):
try:
guidance = exploration_service.message(
run["exploration_id"],
value.instruction,
value.request_id or str(uuid4()),
expected_run_id=session_id,
)
except ExplorationConflict as exc:
raise HTTPException(409, str(exc)) from exc
else:
guidance = add_instruction(
session_id, value.instruction, source="web", instruction_id=value.request_id
)
if ( if (
read_state(session_id).get("status") == "task_paused" read_state(session_id).get("status") == "task_paused"
or (control_dir(session_id) / "pause.request").exists() or (control_dir(session_id) / "pause.request").exists()
@@ -181,7 +193,10 @@ async def run_task(request: RunRequest):
# never be selected. Only a successful, non-empty enumeration may reject: # never be selected. Only a successful, non-empty enumeration may reject:
# an indeterminate one (adb blip, startup) lets the submission queue and # an indeterminate one (adb blip, startup) lets the submission queue and
# fail downstream with a clear error instead. # fail downstream with a clear error instead.
if request.device_serial: execution_target = (
request.runtime_overrides.execution_target if request.runtime_overrides else "android"
)
if request.device_serial and execution_target != "computer":
try: try:
rejection = await device_pool.validate_explicit_serial_async(request.device_serial) rejection = await device_pool.validate_explicit_serial_async(request.device_serial)
except Exception: except Exception:
@@ -202,8 +217,14 @@ 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 = (
device_probe = await readiness_engine.run_device_submission_probe(target_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)
if execution_target != "computer"
else None
)
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 = (
device_probe.metadata.get("active_device", {}).get("serial") or target_serial or "" device_probe.metadata.get("active_device", {}).get("serial") or target_serial or ""
@@ -224,7 +245,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(
@@ -240,10 +261,15 @@ 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
if response.get("status") == "error":
raise HTTPException(400, response.get("message", "任务参数无效"))
for item in response.get("tasks", []): for item in response.get("tasks", []):
register_submission(item) register_submission(item)
return response return response
@@ -269,7 +295,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")
@@ -413,6 +444,20 @@ 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]
with store.connection() as connection:
exploration_runs = exploration_metadata(connection)
for item in [*queue_data, *active_tasks]:
item.update(exploration_runs.get(str(item.get("session_id")), {}))
if job := batch_jobs.get(str(item.get("session_id"))):
item["batch_id"] = job.get("batch_id")
item["display_name"] = execution_title(job)
if item.get("exploration_kind") == "validation" and item.get("exploration_case_name"):
item["display_name"] = item["exploration_case_name"]
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):
+100 -35
View File
@@ -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
@@ -45,7 +46,7 @@ for _p in (str(_workspace_root), str(_apps_dir), str(_admin_console_dir), str(_c
if _p not in sys.path: if _p not in sys.path:
sys.path.insert(0, _p) sys.path.insert(0, _p)
from fastapi import FastAPI from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse from fastapi.responses import FileResponse, HTMLResponse
import uvicorn import uvicorn
@@ -70,28 +71,18 @@ from artemis.config import (
) )
from artemis.resources import get_bundled_showcase_dist from artemis.resources import get_bundled_showcase_dist
try: from apps.admin_console.core.security import SameOriginBoundaryMiddleware
from admin_console.core.security import SameOriginBoundaryMiddleware from apps.admin_console.core.state import ServerState, state
from admin_console.core.state import ServerState, state from apps.admin_console.database.connection import db_session, get_db
from admin_console.database.connection import db_session, get_db from apps.admin_console.database.repositories.session_repository import session_repo
from admin_console.database.repositories.session_repository import session_repo from apps.admin_console.database.repositories.step_repository import step_repo
from admin_console.database.repositories.step_repository import step_repo from apps.admin_console.database.repositories.trace_repository import trace_repo
from admin_console.database.repositories.trace_repository import trace_repo from apps.admin_console.routers import media, replay, sessions, steps, stream, system, tasks
from admin_console.routers import media, replay, sessions, steps, stream, system, tasks from apps.admin_console.routers.replay import replay_manager
from admin_console.routers.replay import replay_manager from apps.admin_console.services.ipc_service import ipc_service
from admin_console.services.ipc_service import ipc_service from apps.admin_console.services.media_service import media_service
from admin_console.services.media_service import media_service from apps.admin_console.services.model_service import model_service
from admin_console.services.model_service import model_service from apps.admin_console.services.task_queue_service import task_queue_service
from admin_console.services.task_queue_service import task_queue_service
except ImportError:
from apps.admin_console.core.security import SameOriginBoundaryMiddleware
from apps.admin_console.core.state import state
from apps.admin_console.database.repositories.session_repository import session_repo
from apps.admin_console.routers import media, replay, sessions, steps, stream, system, tasks
from apps.admin_console.routers.replay import replay_manager
from apps.admin_console.services.ipc_service import ipc_service
from apps.admin_console.services.model_service import model_service
from apps.admin_console.services.task_queue_service import task_queue_service
# Initialize language server synchronization address # Initialize language server synchronization address
init_ls_address() init_ls_address()
@@ -100,11 +91,37 @@ init_ls_address()
@asynccontextmanager @asynccontextmanager
async def _lifespan(_app: "FastAPI"): async def _lifespan(_app: "FastAPI"):
"""Start server resources and release them on shutdown.""" """Start server resources and release them on shutdown."""
from artemis.config.paths import get_app_dir, get_traces_dir
from artemis.runtime.daemon_client import configure_local_daemon
from artemis.service_registry import ServiceRegistration
registration = ServiceRegistration(
port=state.port,
token=LIFECYCLE_TOKEN,
app_dir=get_app_dir(),
traces_dir=get_traces_dir(),
)
registration.acquire()
app.state.service_registration = registration
configure_local_daemon(state.port)
try:
from artemis.integrations.codex_skill import sync_on_startup
from artemis.config.paths import ROOT_DIR
await asyncio.to_thread(sync_on_startup, ROOT_DIR)
shared_tool_executor.start()
async with shared_mcp.session_manager.run():
await on_startup() await on_startup()
try: try:
yield yield
finally: finally:
await on_shutdown() await on_shutdown()
finally:
try:
await shared_tool_executor.stop()
finally:
configure_local_daemon(None)
registration.close()
# Initialize FastAPI application # Initialize FastAPI application
@@ -117,6 +134,26 @@ app.state.lifecycle_token = LIFECYCLE_TOKEN
# and unrecognized Host headers (DNS rebinding) instead. # and unrecognized Host headers (DNS rebinding) instead.
app.add_middleware(SameOriginBoundaryMiddleware) app.add_middleware(SameOriginBoundaryMiddleware)
from mcp_server.shared import LocalMcpBoundary, create_shared_mcp
shared_mcp, shared_tool_executor = create_shared_mcp()
app.mount("/api/mcp", LocalMcpBoundary(shared_mcp.streamable_http_app(), LIFECYCLE_TOKEN))
@app.get("/api/system/shared-service")
async def shared_service_identity(request: Request):
"""Authenticated identity probe for local connection-only MCP bridges."""
import hmac
from artemis.service_registry import TOKEN_HEADER
if not hmac.compare_digest(request.headers.get(TOKEN_HEADER, ""), LIFECYCLE_TOKEN):
raise HTTPException(status_code=403, detail="Invalid service token")
registration = getattr(app.state, "service_registration", None)
if registration is None:
raise HTTPException(status_code=503, detail="Service is starting")
return {key: registration.info[key] for key in ("protocol", "installation", "instance", "pid")}
async def on_startup(): async def on_startup():
"""Startup lifecycle hooks.""" """Startup lifecycle hooks."""
@@ -240,6 +277,17 @@ app.include_router(system.router)
from apps.admin_console.routers.automation import router as automation_router from apps.admin_console.routers.automation import router as automation_router
app.include_router(automation_router) app.include_router(automation_router)
from apps.admin_console.routers.favorites import router as favorites_router
app.include_router(favorites_router)
from apps.admin_console.routers.plugins import router as plugins_router
app.include_router(plugins_router)
from apps.admin_console.routers.exploration import router as exploration_router
app.include_router(exploration_router)
# Mount cloud gateway router for Frappe / Cloud integration if present # Mount cloud gateway router for Frappe / Cloud integration if present
try: try:
@@ -421,6 +469,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."""
@@ -470,31 +526,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: config = uvicorn.Config(
uvicorn.run( "apps.admin_console.server:app" if reload else app,
"apps.admin_console.server:app",
host=host, host=host,
port=port, port=port,
reload=True, 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 return
config = uvicorn.Config(
app,
host=host,
port=port,
timeout_graceful_shutdown=5,
)
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)
+190 -21
View File
@@ -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"}
@@ -32,15 +36,68 @@ def register_submission(item: dict, case: dict | None = None, formats=None, **ex
"status": "queued", "status": "queued",
"created_at": time.time(), "created_at": time.time(),
"device_serial": item.get("device_serial"), "device_serial": item.get("device_serial"),
"execution_config": item.get("execution_config"),
"submission": {
key: item.get(key)
for key in (
"goal",
"profile",
"device_serial",
"runtime_overrides",
"expected_output",
"enable_outputter",
"verification_level",
"explorer_mode",
"locked_app_package",
"app_path",
)
},
"formats": formats if formats is not None else store.preferences()["formats"], "formats": formats if formats is not None else store.preferences()["formats"],
**extra, **extra,
} }
) )
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"] == "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"}: 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"),
@@ -48,6 +105,9 @@ def recover_batches():
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,6 +120,8 @@ 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="服务已重启;请检查中断任务,点击继续执行未开始任务"
) )
@@ -82,9 +144,12 @@ 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
@@ -99,7 +164,9 @@ async def on_terminal(item: dict, status: str):
} }
): ):
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"])
@@ -107,14 +174,44 @@ 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()
from apps.admin_console.services.exploration_recovery import reconcile_explorations
reconcile_explorations()
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
@@ -125,24 +222,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( # The queue records started_at at submission, then replaces it
(job.get("case", {}).get("task_config") or {}).get("timeout_seconds") # when the worker actually starts. Waiting for a device must
or batch.get("timeout_seconds", 1800) # 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 {} task_state = getattr(state, "active_runs", {}).get(str(job["session_id"])) or {}
excluded = float(task_state.get("non_execution_seconds") or 0) excluded = float(task_state.get("non_execution_seconds") or 0)
if task_state.get("non_execution_started_at"): if task_state.get("non_execution_started_at"):
excluded += time.time() - float(task_state["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()) - excluded > limit
): ):
job.update(error="任务达到批次配置的执行时限", timed_out=True) job.update(
error=f"任务执行超时({limit} 秒,不含暂停/排队时间)", timed_out=True
)
store.save_job(job) store.save_job(job)
from artemis.runtime.task_result import save_failure_result
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"]) task_queue_service.stop_tasks(session_id=job["session_id"])
batch.update(status="paused", reason="任务超时,已暂停剩余任务;请确认设备状态") # on_terminal records the failed case. The next pending
store.put("batch", batch["id"], batch) # case then runs normally unless stop_on_failure is set.
continue continue
if batch["status"] != "running": if batch["status"] != "running":
continue continue
@@ -175,10 +289,12 @@ async def tick_batches():
job = pending[0] job = pending[0]
options = job["case"].get("task_config") or {} options = job["case"].get("task_config") or {}
serial = options.get("device_serial") or batch.get("device_serial") serial = options.get("device_serial") or batch.get("device_serial")
devices = await device_pool.try_list_devices_async() computer = options.get("execution_target") == "computer"
devices = await device_pool.try_list_devices_async() if not computer else []
rejection = ( rejection = (
None None
if serial and any(d.serial == serial and d.state == "device" for d in devices or []) if computer
or (serial and any(d.serial == serial and d.state == "device" for d in devices or []))
else "无法确认所选设备在线且已授权,请恢复设备连接后继续批次" else "无法确认所选设备在线且已授权,请恢复设备连接后继续批次"
) )
if rejection: if rejection:
@@ -202,6 +318,7 @@ async def tick_batches():
runtime_overrides=options, 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 "队列拒绝接收任务")
@@ -234,6 +351,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,
@@ -243,28 +369,65 @@ 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):
for number, case in enumerate(cases, 1):
jobs.append( jobs.append(
{ {
"session_id": new_id(), "session_id": new_id(),
"batch_id": bid, "batch_id": bid,
"position": number, "position": len(jobs) + 1,
"iteration": iteration,
"repeat_count": repeat_count,
"case_position": number,
"case": case, "case": case,
"status": "pending", "status": "pending",
"formats": batch["formats"], "formats": batch["formats"],
"created_at": time.time(), "created_at": batch["created_at"],
"device_serial": batch["device_serial"], "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(
"batch:" + batch_id, *("session:" + j["session_id"] for j in jobs)
):
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:
@@ -288,7 +451,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:
+127 -9
View File
@@ -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,8 +250,38 @@ 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: def delete_batch(self, batch_id: str) -> bool:
"""Remove a batch record; retain executed job metadata and its reports.""" """Remove a batch record and all of its jobs after cleanup."""
with self.connection() as conn: with self.connection() as conn:
conn.execute("BEGIN IMMEDIATE") conn.execute("BEGIN IMMEDIATE")
row = conn.execute( row = conn.execute(
@@ -175,17 +291,14 @@ class AutomationStore:
if row is None: if row is None:
return False return False
batch = json.loads(row[0]) batch = json.loads(row[0])
active = conn.execute( if batch.get("status") != "deleting":
"SELECT 1 FROM automation_jobs WHERE batch_id=? AND status IN " raise ValueError("批次尚未准备删除")
"('queued','running','task_paused','resuming','finalizing') LIMIT 1",
(batch_id,),
).fetchone()
if batch["status"] == "running" or active:
raise ValueError("批次仍有任务执行中或排队中,请等待完成后删除")
conn.execute( conn.execute(
"DELETE FROM automation_jobs WHERE batch_id=? AND status='pending'", "DELETE FROM automation_records WHERE kind='case_review' AND id IN "
"(SELECT session_id FROM automation_jobs WHERE batch_id=?)",
(batch_id,), (batch_id,),
) )
conn.execute("DELETE FROM automation_jobs WHERE batch_id=?", (batch_id,))
conn.execute( conn.execute(
"DELETE FROM automation_records WHERE kind='batch' AND id=?", "DELETE FROM automation_records WHERE kind='batch' AND id=?",
(batch_id,), (batch_id,),
@@ -198,9 +311,14 @@ class AutomationStore:
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,123 @@
"""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 cached(batch_id: str, batch: dict) -> bool:
return bool(batch.get("report_ready")) and report_export.reusable(
archive_path(batch_id), report_export.BATCH_ARCHIVE_SIGNATURE, "completed"
)
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 cached(batch_id, batch):
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 cached(batch_id, batch):
_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("batch:" + batch_id):
latest = report_export.store.get("batch", batch_id)
if not (latest and cached(batch_id, latest)):
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,
)
@@ -0,0 +1,160 @@
"""Read task-owned plugin evidence without executing tools or converting media."""
import json
import math
from pathlib import Path
import re
from artemis.config import TRACES_PATH
def task_root(session_id: str) -> Path:
if not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", session_id):
raise ValueError("无效任务编号")
root = TRACES_PATH.resolve() / session_id
if root.resolve() != root:
raise ValueError("任务证据目录不能是符号链接")
return root
def evidence_file(root: Path, relative: str) -> Path | None:
path = root / relative
try:
if (
path.is_file()
and path.resolve() == path.absolute()
and path.resolve().is_relative_to(root.resolve())
):
return path
except OSError:
return None
return None
def plugin_sources(session_id: str) -> list[dict]:
root = task_root(session_id)
path = evidence_file(root, "evidence/recordings/recordings.json")
if not path or path.stat().st_size > 4 * 1024 * 1024:
return []
try:
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("version") != 1 or not isinstance(data.get("sources"), list):
return []
from apps.admin_console.services.media_service import MediaService
sources = []
for item in data["sources"]:
if not isinstance(item, dict) or not isinstance(item.get("id"), str):
continue
source = {**item, "segments": []}
for segment in item.get("segments", []):
if not isinstance(segment, dict):
continue
video = evidence_file(root, str(segment.get("file") or ""))
if video is None or video.suffix.lower() != ".mp4":
continue
duration = float(segment.get("duration", 0))
start = float(segment.get("offset_ms", 0)) / 1000
if (
not math.isfinite(duration)
or duration <= 0
or not math.isfinite(start)
or start < 0
):
continue
source["segments"].append(
{
**segment,
"url": MediaService.path_to_video_url(video),
"start": start,
"duration": duration,
}
)
sources.append(source)
return sources
except (OSError, ValueError, TypeError, KeyError):
return []
def plugin_files(session_id: str) -> list[tuple[Path, str]]:
from apps.admin_console.services.exploration_evidence import demonstration_files
root = task_root(session_id)
files = dict(demonstration_files(session_id))
for source in plugin_sources(session_id):
for segment in source["segments"]:
path = evidence_file(root, segment["file"])
if path:
files[path] = "任务录屏/电脑/" + path.name
for directory in (root / "artifacts" / "plugins", root / "evidence"):
if not directory.is_dir():
continue
for path in directory.rglob("*"):
if path.suffix.lower() == ".tmp" or (
directory.name == "evidence" and path.suffix.lower() in {".mp4", ".mkv"}
):
continue
relative = path.relative_to(root).as_posix()
checked = evidence_file(root, relative)
if checked:
files[checked] = (
"任务文件/插件/" + path.relative_to(root / "artifacts" / "plugins").as_posix()
if directory.name == "plugins"
else "证据索引/" + path.relative_to(root / "evidence").as_posix()
)
return list(files.items())
def export_index(
session_id: str, report: dict, android_videos: list, *, images=None, artifacts=None, extras=None
) -> dict:
from apps.admin_console.services.report_export import session_artifacts, session_screenshots
from apps.admin_console.services.exploration_evidence import (
exploration_index,
demonstration_sources,
)
extras = plugin_files(session_id) if extras is None else extras
recordings = plugin_sources(session_id) + demonstration_sources(session_id)
root = task_root(session_id)
paths = {path.resolve(): name for path, name in extras}
for source in recordings:
for segment in source["segments"]:
segment["file"] = paths.get((root / segment["file"]).resolve())
segment.pop("url", None)
images = session_screenshots(report) if images is None else images
artifacts = session_artifacts(report) if artifacts is None else artifacts
files = [(path, "步骤截图/" + name) for path, name in images]
files += [(path, "任务录屏/安卓/" + name) for path, name in android_videos]
files += [(path, "任务文件/安卓/" + name) for path, name in artifacts]
files += extras
calls = []
ledger = evidence_file(root, "evidence/calls.jsonl")
if ledger:
with ledger.open(encoding="utf-8") as handle:
for line in handle:
try:
call = json.loads(line)
if not isinstance(call, dict):
continue
for attachment in call.get("attachments", []):
relative = str(attachment.get("path") or "")
attachment["file"] = paths.get((root / relative).resolve())
calls.append(call)
except (ValueError, TypeError, AttributeError):
continue # A partial final line after a crash is not a valid receipt.
return {
"version": 1,
"session_id": session_id,
"exploration": exploration_index(session_id, paths),
"name": report["name"],
"status": report["status"],
"verdict": report["verdict"],
"steps": report.get("steps", []),
"calls": calls,
"recordings": recordings,
"android_videos": ["任务录屏/安卓/" + name for _, name in android_videos],
"image_references": {path.stem: "步骤截图/" + name for path, name in images},
"files": [{"path": name, "bytes": path.stat().st_size} for path, name in files],
"note": "工具执行完成不等于用例通过。录像保留真实时间与缺口;各来源截图不能相互替代。",
}
@@ -0,0 +1,54 @@
"""Credential-free execution settings persisted for repeatable favorites."""
from apps.admin_console.services.task_import import TaskOptions
from artemis.config import load_agent_config, parse_llm_config, verification_level_for_checker
from artemis.config.settings import settings
def execution_config(item: dict) -> dict:
agent = load_agent_config()
model = parse_llm_config().operator
overrides = item.get("runtime_overrides") or {}
profile = overrides.get("profile") or item.get("profile") or "flash"
options = {
"profile": profile,
"model": model.model,
"reasoning_effort": model.reasoning_effort or "default",
"request_timeout_seconds": model.timeout,
"max_retries": model.max_retries,
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
"record_video": settings.ARTEMIS_RECORDING_ENABLED,
"recording": agent.recording.model_dump(),
"analyze_video": agent.video_analyzer.enable_ledger,
"timeout_seconds": settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
"verification_level": verification_level_for_checker(agent.checker),
"explorer_mode": agent.explorer.resolve(profile=profile),
"observation_timeout_seconds": settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS,
"observation_max_attempts": settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS,
"device_command_timeout_seconds": settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
"action_failure_attempts": settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS,
"worker_cleanup_timeout_seconds": settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS,
}
for key in ("device_serial", "verification_level", "explorer_mode", "locked_app_package"):
if item.get(key) is not None:
options[key] = item[key]
options["recording"] = {**options["recording"], **(overrides.get("recording") or {})}
options.update({k: v for k, v in overrides.items() if k != "recording" and v is not None})
return {
"runtime_overrides": TaskOptions.model_validate(options).model_dump(exclude_none=True),
"expected_output": item.get("expected_output"),
"enable_outputter": item.get("enable_outputter")
if item.get("enable_outputter") is not None
else agent.outputter.enabled,
"app_path": item.get("app_path"),
}
def persist_execution_config(item: dict) -> None:
from apps.admin_console.services.automation_store import store
snapshot = execution_config(item)
item["execution_config"] = snapshot
if job := store.job(str(item["session_id"])):
job["execution_config"] = snapshot
store.save_job(job)
@@ -0,0 +1,48 @@
"""Delete an inactive conversation while keeping independently executed reruns."""
import shutil
from uuid import UUID
from apps.admin_console.core.state import state
from apps.admin_console.services import report_export
from artemis.data_engine.storage import StorageManager
from artemis.exploration.store import ExplorationConflict, ExplorationStore
from artemis.runtime.process_probe import pid_is_alive
from artemis.runtime.task_control import read_state
def _check_runs(runs: list[dict]) -> None:
queued = {
item.get("session_id")
for item in state.queue_items
if item.get("status") in {"pending", "queued", "running"}
}
for run in runs:
run_id = run["id"]
if (
run_id in state.active_runs
or run_id in queued
or pid_is_alive(read_state(run_id).get("pid"))
):
raise ExplorationConflict("探索工作进程尚未结束,请停止任务并稍后重试删除")
def delete_exploration(repository: ExplorationStore, exploration_id: str) -> list[str]:
runs = repository.begin_delete(exploration_id, _check_runs)
# Validation sessions and batch copies remain ordinary Flash/Pro history.
deleted = [run["id"] for run in runs if run["kind"] == "exploration"]
storage = StorageManager(repository.database.path, report_export.TRACES_PATH)
with report_export.export_lock(*("session:" + run_id for run_id in deleted)):
for run_id in deleted:
safe_id = str(UUID(run_id))
storage.delete_session(UUID(safe_id))
repository.database.delete_history(safe_id)
for root in (
report_export.EXPORT_ROOT,
report_export.EXPORT_ROOT.parent / "mcp_exports",
):
folder = (root / "sessions" / safe_id).resolve()
if folder.is_relative_to(root.resolve()) and folder.is_dir():
shutil.rmtree(folder)
repository.finish_delete(exploration_id)
return deleted
@@ -0,0 +1,110 @@
"""Read-only exploration provenance for reports, archives and source-aware video."""
import json
from artemis.exploration.store import explorations
from artemis.exploration.demonstrations import Demonstrations
from apps.admin_console.services.evidence_service import evidence_file, task_root
def exploration_metadata(run_id: str) -> dict | None:
run = explorations.run(run_id)
if not run:
return None
return {
"id": run["exploration_id"],
"kind": run["kind"],
"case_version": run.get("case_version"),
}
def demonstration_sources(run_id: str) -> list[dict]:
root = task_root(run_id)
directory = root / "evidence" / "demonstrations"
from apps.admin_console.services.media_service import MediaService
sources = []
for manifest in directory.glob("*/demonstration.json"):
if (
not evidence_file(root, manifest.relative_to(root).as_posix())
or manifest.stat().st_size > 4 * 1024 * 1024
):
continue
try:
record = json.loads(manifest.read_text(encoding="utf-8"))
for track in record["media"]:
segments = []
for item in track["segments"]:
path = evidence_file(root, item["file"])
if path and path.suffix.lower() == ".mp4" and item["duration_ms"] > 0:
segments.append(
{
**item,
"start": item["offset_ms"] / 1000,
"duration": item["duration_ms"] / 1000,
"url": MediaService.path_to_video_url(path),
}
)
if segments:
sources.append(
{
**track,
"segments": segments,
"label": "人工示范 · "
+ ("安卓" if track["kind"] == "android" else "电脑"),
"status": "ready",
"message": "人工操作录像,不等于自动复跑通过",
"demonstration_id": record["id"],
}
)
except (OSError, ValueError, KeyError, TypeError):
continue
return sources
def demonstration_files(run_id: str) -> list[tuple]:
root = task_root(run_id)
directory = root / "evidence" / "demonstrations"
files = []
for path in directory.rglob("*"):
if path.suffix.lower() not in {".mp4", ".mkv", ".webm"}:
continue
checked = evidence_file(root, path.relative_to(root).as_posix())
if checked:
files.append((checked, "任务录屏/人工示范/" + path.relative_to(directory).as_posix()))
return files
def exploration_index(run_id: str, archive_paths: dict | None = None) -> dict | None:
run = explorations.run(run_id)
if not run:
return None
sid = run["exploration_id"]
session = explorations.get(sid)
messages = []
after = 0
while page := explorations.messages(sid, after=after):
messages.extend(page)
after = page[-1]["sequence"]
demos = Demonstrations(explorations).list(sid)
root = task_root(run_id)
for demo in demos:
for track in demo.get("media", []):
for segment in track.get("segments", []):
relative = segment.pop("file", "")
segment["trace_file"] = relative
segment["file"] = (
(archive_paths or {}).get((root / relative).resolve())
if demo["run_id"] == run_id
else None
)
return {
"session": session,
"messages": messages,
"runs": explorations.runs(sid),
"cases": [
explorations.case(sid, version) for version in range(1, session["case_version"] + 1)
],
"demonstrations": demos,
"note": "此导出保留会话、用例版本和各次运行的关联;只有当前任务的媒体文件在此目录中。",
}
@@ -0,0 +1,45 @@
"""Shared exploration metadata for queue cards and history pagination."""
# Rank execution runs independently from validations: a rerun never replaces the
# conversation card. Keep all raw sessions accessible for evidence inspection.
EXPLORATION_RUNS_CTE = """
exploration_runs AS (
SELECT r.id AS run_id,
json_extract(r.payload,'$.exploration_id') AS exploration_id,
json_extract(r.payload,'$.kind') AS exploration_kind,
json_extract(r.payload,'$.case_version') AS case_version,
json_extract(r.payload,'$.task_config.model') AS exploration_model,
json_extract(r.payload,'$.task_config.profile') AS exploration_profile,
json_extract(v.payload,'$.name') AS exploration_case_name,
CASE WHEN json_extract(r.payload,'$.kind')='exploration' AND
(json_extract(c.payload,'$.active_run_id') IS NULL
OR json_extract(c.payload,'$.active_run_id') = r.id)
THEN json_extract(c.payload,'$.status')
ELSE json_extract(r.payload,'$.status') END AS exploration_status,
FIRST_VALUE(r.id) OVER (
PARTITION BY json_extract(r.payload,'$.exploration_id'),json_extract(r.payload,'$.kind')
ORDER BY json_extract(r.payload,'$.created_at') DESC, r.id DESC
) AS exploration_latest_run_id,
COUNT(*) OVER (
PARTITION BY json_extract(r.payload,'$.exploration_id'),json_extract(r.payload,'$.kind')
) AS exploration_run_count
FROM automation_records r
LEFT JOIN automation_records c ON c.kind='exploration'
AND c.id=json_extract(r.payload,'$.exploration_id')
LEFT JOIN automation_records v ON v.kind='exploration_case'
AND v.id=json_extract(r.payload,'$.exploration_id') || '_' ||
printf('%010d',json_extract(r.payload,'$.case_version'))
WHERE r.kind='exploration_run'
)
"""
def exploration_metadata(connection) -> dict[str, dict]:
rows = connection.execute("WITH " + EXPLORATION_RUNS_CTE + " SELECT * FROM exploration_runs")
result = {}
for row in rows:
item = {key: row[key] for key in row.keys() if key != "run_id"}
if item.get("exploration_kind") == "validation" and item.get("exploration_case_name"):
item["display_name"] = item["exploration_case_name"]
result[row["run_id"]] = item
return result
@@ -0,0 +1,95 @@
"""Reconcile saved exploration metadata with the existing queue and worker owners."""
import json
import time
from apps.admin_console.core.state import state
from apps.admin_console.database.repositories.session_repository import SessionRepository
from artemis.exploration.store import ExplorationConflict, ExplorationStore, explorations
from artemis.runtime.process_probe import pid_is_alive
from artemis.runtime.task_control import read_state
from artemis.runtime.task_result import read_worker_outcome
from artemis.runtime.case_result import read_case_result
def reconcile_explorations(repository: ExplorationStore = explorations) -> None:
for kind in ("exploration_generation", "exploration_discussion"):
with repository._transaction() as connection:
for row in connection.execute(
"SELECT id, payload FROM automation_records WHERE kind=? AND json_extract(payload,'$.status')='running'",
(kind,),
).fetchall():
job = json.loads(row[1])
if (
not pid_is_alive(job.get("owner_pid"))
or job.get("lease_until", 0) < time.time()
):
repository._put(
connection,
kind,
row[0],
{
**job,
"status": "failed",
"lease_until": 0,
"error": "生成进程已中断;已有结果保留,可重试。",
},
)
with repository.database.connection() as connection:
rows = connection.execute(
"SELECT payload FROM automation_records WHERE kind='exploration' "
"AND json_extract(payload,'$.active_run_id') IS NOT NULL"
).fetchall()
history = SessionRepository(repository.database.path)
queued = {
str(item.get("session_id"))
for item in state.queue_items
if isinstance(item, dict) and item.get("status") in {"pending", "running", "queued"}
}
for row in rows:
session = json.loads(row[0])
run_id = session["active_run_id"]
run = repository.run(run_id)
if not run:
continue
outcome = read_worker_outcome(run_id)
if outcome:
repository.finish_run(run_id, {**outcome, "status": outcome["task_status"]})
continue
control = read_state(run_id)
if run["kind"] == "validation" and pid_is_alive(control.get("pid")):
observed = {"running": "running", "task_paused": "paused", "resuming": "queued"}.get(
control.get("status")
)
if observed and observed != run["status"]:
try:
repository.set_run_state(run_id, observed, reason=control.get("pause_reason"))
except ExplorationConflict:
continue # The owner finalized while this tick read its control file.
if run_id in queued or run_id in state.active_runs:
continue
if control.get("pid") and pid_is_alive(control["pid"]):
continue
execution = history.get_session_by_id(run_id) or {}
if execution.get("pid") and history.process_is_alive(execution["pid"]):
continue
job = repository.database.job(run_id) or {}
terminal = execution.get("status") or job.get("terminal_status") or job.get("status")
if terminal in {"completed", "failed", "cancelled", "interrupted", "skipped"}:
repository.finish_run(
run_id,
{
"status": terminal,
"error": execution.get("error") or job.get("error") or "",
"test_result": execution.get("test_result")
or read_case_result(run_id, terminal),
},
)
elif time.time() - run["created_at"] > 300:
repository.finish_run(
run_id,
{
"status": "interrupted",
"error": "探索工作进程已结束;原证据已保留,可继续探索并重新观察当前设备。",
},
)
@@ -0,0 +1,334 @@
"""Web and MCP use the same exploration admission and conversation service."""
from artemis.config.agent import load_agent_config
from artemis.config.exploration import ExplorationConfig
from artemis.exploration.case_prompt import require_resolved_case
from artemis.exploration.models import CreateExploration, SaveCaseRequest, ValidateCaseRequest
from artemis.exploration.demonstrations import Demonstrations
from artemis.exploration.store import ExplorationConflict, ExplorationStore, explorations
from artemis.runtime.task_control import add_instruction, read_state, request_pause, request_resume
from apps.admin_console.services.task_import import TaskOptions
def exploration_case_prompt(case: dict) -> str:
return require_resolved_case(case)
class ExplorationService:
def __init__(self, repository: ExplorationStore | None = None):
self.repository = repository if repository is not None else explorations
def settings(self) -> dict:
saved = self.repository.database.get("settings", "exploration") or {}
return ExplorationConfig.model_validate(
{
**load_agent_config().exploration.model_dump(),
**saved,
}
).model_dump()
def save_settings(self, value: ExplorationConfig) -> dict:
return self.repository.database.put("settings", "exploration", value.model_dump())
def create(self, value: CreateExploration, *, source: str = "web") -> dict:
options = TaskOptions.model_validate(value.task_config).model_dump(exclude_none=True)
return self.repository.create(
value.goal,
value.config or ExplorationConfig.model_validate(self.settings()),
options,
value.request_id,
source=source,
)
def detail(self, session_id: str, *, after: int = 0) -> dict:
session = self.repository.get(session_id)
runs = self.repository.runs(session_id)
version = self.repository.case(session_id)
with self.repository.database.connection() as connection:
import json
generations = [
json.loads(row[0])
for row in connection.execute(
"SELECT payload FROM automation_records WHERE kind='exploration_generation' "
"AND json_extract(payload,'$.exploration_id')=? ORDER BY updated_at DESC LIMIT 20",
(session_id,),
).fetchall()
]
from artemis.runtime.case_result import resolve_case_result
for run in runs:
outcome = run.get("outcome")
verdict = resolve_case_result(outcome, run["status"]) if outcome else None
run["test_result"] = verdict.model_dump() if verdict else None
return {
**session,
"messages": self.repository.messages(session_id, after=after),
"runs": runs,
"case": version,
"generations": [
{k: v for k, v in g.items() if k not in {"owner", "owner_pid", "lease_until"}}
for g in generations
],
"demonstrations": Demonstrations(self.repository).list(session_id),
"validation_runs": [
r
for r in runs
if r["kind"] == "validation" and r["case_version"] == session["case_version"]
],
"control": read_state(session["active_run_id"]) if session["active_run_id"] else {},
}
async def _admit(self, run: dict, *, source: str) -> dict:
# RunRequest performs the same readiness and idempotency checks as /api/run.
from apps.admin_console.routers.tasks import run_task
from apps.admin_console.schemas.task_schema import RunRequest
from fastapi import HTTPException
current = self.repository.run(run["id"])
if current and current.get("outcome") is not None:
return current
options = TaskOptions.model_validate(run["task_config"])
try:
result = await run_task(
RunRequest(
goal=run["goal"],
profile=options.profile or "pro",
session_id=run["id"],
ingress=source,
conversation_id=run["exploration_id"],
runtime_overrides=options,
)
)
except HTTPException as exc:
if exc.status_code < 500:
self.repository.finish_run(
run["id"], {"status": "rejected", "error": str(exc.detail)}
)
raise
if result.get("status") in {"error", "rejected"}:
self.repository.finish_run(
run["id"],
{
"status": "rejected",
"error": result.get("error") or result.get("message") or "任务入队失败",
},
)
else:
self.repository.set_run_state(run["id"], "queued", expected_status="reserved")
return {"run": self.repository.run(run["id"]), "admission": result}
async def start(self, session_id: str, request_id: str, *, source: str = "web") -> dict:
session = self.repository.get(session_id)
config = ExplorationConfig.model_validate(session["config"])
options = {
**session["task_config"],
"profile": "pro",
"model": config.model,
"reasoning_effort": config.reasoning_effort,
"timeout_seconds": config.timeout_seconds,
"verification_level": "final",
}
run = self.repository.reserve_run(
session_id,
request_id,
kind="exploration",
goal=session["goal"],
task_config=options,
)
return await self._admit(run, source=source)
async def validate(
self, session_id: str, value: ValidateCaseRequest, *, source: str = "web"
) -> dict:
case = self.repository.case(session_id, value.version)
if case is None:
raise KeyError("用例版本不存在")
session = self.repository.get(session_id)
# Device/plugin binding may be reused; exploration's expensive model is not.
options = {
k: v
for k, v in session["task_config"].items()
if k not in {"model", "reasoning_effort", "profile", "timeout_seconds"}
}
options.update(TaskOptions.model_validate(value.task_config).model_dump(exclude_none=True))
options["profile"] = value.profile
run = self.repository.reserve_run(
session_id,
value.request_id,
kind="validation",
goal=exploration_case_prompt(case),
task_config=options,
case_version=value.version,
)
return await self._admit(run, source=source)
def message(
self,
session_id: str,
text: str,
request_id: str,
*,
source: str = "web",
expected_run_id: str | None = None,
) -> dict:
session = self.repository.get(session_id)
if expected_run_id and session["active_run_id"] != expected_run_id:
raise ExplorationConflict("所选执行已结束,指导未发送,请刷新任务状态")
question = session.get("pending_question")
message = self.repository.add_message(
session_id,
text,
request_id,
source=source,
metadata={"question_id": question, "run_id": session["active_run_id"]},
)
question = message.get("question_id")
run_id = message.get("run_id")
if run_id and run_id == session["active_run_id"]:
run = self.repository.run(run_id)
if run and run.get("outcome") is None:
add_instruction(
run_id, message["text"], source=source, instruction_id=message["id"]
)
self.repository.answer_question(session_id, question)
if read_state(run_id).get("status") == "task_paused":
request_resume(run_id)
return message
def pause(self, session_id: str) -> dict:
session = self.repository.get(session_id)
if session["status"] == "finalizing":
raise ExplorationConflict("设备操作已结束,正在提炼用例,无需暂停。")
if not session["active_run_id"]:
raise ExplorationConflict("当前没有正在执行的探索")
request_pause(session["active_run_id"], reason="user")
return {"status": "pausing", "id": session_id}
def stop(self, session_id: str) -> dict:
from apps.admin_console.services.task_queue_service import TaskQueueService
session = self.repository.get(session_id)
if run_id := session["active_run_id"]:
requested = TaskQueueService.stop_tasks(session_id=run_id)
return {"status": "stopping" if requested else "not_running", "id": session_id}
return {"status": session["status"], "id": session_id}
async def resume(self, session_id: str, request_id: str, *, source: str = "web") -> dict:
repo = self.repository
with repo._transaction() as connection:
session = repo._require(connection, session_id)
def prepare():
if session.get("pending_question"):
raise ExplorationConflict("请先回答探索助手的问题,再继续执行。")
if run_id := session["active_run_id"]:
if read_state(run_id).get("status") != "task_paused":
raise ExplorationConflict("请等待探索安全暂停后再继续")
request_resume(run_id)
return {"status": "resuming", "id": session_id, "run_id": run_id}
return {"status": "start", "id": session_id}
result = repo._operation(connection, f"{session_id}:continue", request_id, {}, prepare)
if result["status"] == "resuming":
return result
return await self.start(session_id, request_id, source=source)
def save_case(self, session_id: str, value: SaveCaseRequest) -> dict:
return self.repository.save_case(
session_id, value.case, value.expected_version, value.request_id
)
def demonstrate(self, session_id: str, request_id: str, source: str | None = None) -> dict:
session = self.repository.get(session_id)
if session["status"] == "finalizing":
raise ExplorationConflict("正在提炼用例,请完成后继续探索再示范。")
source = source or (
"computer"
if session["task_config"].get("execution_target") == "computer"
else "android"
)
if source not in {"android", "computer", "both"}:
raise ValueError("无效的示范来源")
demo = Demonstrations(self.repository).request(session_id, source, request_id)
if demo["status"] == "requested":
request_pause(demo["run_id"], reason="demonstration")
if read_state(demo["run_id"]).get("status") == "task_paused":
request_resume(demo["run_id"])
return demo
def demonstration_command(
self, session_id: str, demo_id: str, command: str, request_id: str, *, seconds: int = 0
) -> dict:
return Demonstrations(self.repository).command(
session_id, demo_id, command, request_id, seconds=seconds
)
async def generate_case(self, session_id: str, value) -> dict:
from artemis.exploration.synthesis import generate_case, _generation
from apps.admin_console.services.automation_service import spawn
generation = _generation(
self.repository,
session_id,
value.request_id,
value.source_run_id,
value.expected_version,
)
if generation["status"] == "completed":
return {"status": "completed", "id": generation["id"], "case": generation["case"]}
async def work():
try:
await generate_case(
self.repository,
session_id,
value.request_id,
value.source_run_id,
value.expected_version,
generation=generation,
)
except Exception:
import logging
logging.getLogger(__name__).exception("Exploration synthesis request failed")
spawn(work())
return {"status": "running", "id": generation["id"]}
def create_case_batch(self, session_id: str, value) -> dict:
from apps.admin_console.routers.automation import BatchRequest
from apps.admin_console.services.automation_service import create_batch
from artemis.exploration.store import _digest
case = self.repository.case(session_id, value.version)
if not case:
raise KeyError("用例版本不存在")
session = self.repository.get(session_id)
options = {
k: v
for k, v in session["task_config"].items()
if k not in {"model", "reasoning_effort", "profile", "timeout_seconds"}
}
options.update(TaskOptions.model_validate(value.task_config).model_dump(exclude_none=True))
options["profile"] = value.profile
payload = BatchRequest(
name=value.name,
profile=value.profile,
request_id="explore-" + _digest([session_id, value.request_id]),
cases=[
{
"name": case["name"],
"goal": exploration_case_prompt(case),
"preconditions": case["preconditions"],
"case_id": f"{session_id}-v{value.version}",
"task_config": options,
"category": "探索用例",
"subcategory": f"版本{value.version}",
}
],
).model_dump()
return create_batch(payload)
exploration_service = ExplorationService()
@@ -0,0 +1,66 @@
"""Per-object export coordination and content-aware artifact reuse."""
from contextlib import contextmanager, ExitStack
import hashlib
import json
from pathlib import Path
import threading
from weakref import WeakValueDictionary
_guard = threading.Lock()
_locks: WeakValueDictionary = WeakValueDictionary()
TERMINAL = {"completed", "success", "failed", "cancelled", "interrupted", "skipped"}
@contextmanager
def export_lock(*keys: str):
# Retain strong references until every lock is released. Unrelated exports
# run concurrently; nested session exports can reacquire their own RLock.
with _guard:
locks = []
for key in sorted(set(keys)):
lock = _locks.get(key)
if lock is None:
lock = threading.RLock()
_locks[key] = lock
locks.append(lock)
with ExitStack() as stack:
for lock in locks:
stack.enter_context(lock)
yield
def fingerprint(report: dict, files=(), extra=None) -> str:
metadata = []
for path, name in files:
stat = path.stat()
metadata.append((name, str(path), stat.st_size, stat.st_mtime_ns))
payload = json.dumps([2, report, metadata, extra], sort_keys=True, default=str)
return hashlib.sha256(payload.encode()).hexdigest()
def reusable(path: Path, signature: str, status: str) -> bool:
if status not in TERMINAL or not path.is_file():
return False
try:
return path.with_suffix(path.suffix + ".fingerprint").read_text() == signature
except OSError:
return False
def remember(path: Path, signature: str) -> None:
marker = path.with_suffix(path.suffix + ".fingerprint")
temporary = marker.with_suffix(".tmp")
temporary.write_text(signature, encoding="ascii")
temporary.replace(marker)
def compression(path: Path) -> int:
import zipfile
return (
zipfile.ZIP_STORED
if path.suffix.lower()
in {".mp4", ".mkv", ".webm", ".docx", ".xlsx", ".png", ".jpg", ".jpeg", ".zip"}
else zipfile.ZIP_DEFLATED
)
+464
View File
@@ -0,0 +1,464 @@
"""Independent favorite snapshots dispatched through the existing task services."""
from copy import deepcopy
import asyncio
import hashlib
import json
import time
from typing import Literal
from uuid import NAMESPACE_URL, uuid5
from weakref import WeakValueDictionary
from pydantic import BaseModel, ConfigDict, Field
from apps.admin_console.database.repositories.session_repository import SessionRepository
from apps.admin_console.services.automation_store import AutomationStore, identifier, store
from apps.admin_console.services.execution_config import execution_config
from apps.admin_console.services.model_service import model_service
from apps.admin_console.services.task_import import TaskCase, TaskOptions, execution_title
from artemis.config.exploration import ExplorationConfig
from artemis.exploration.case_prompt import require_resolved_case
from artemis.exploration.store import ExplorationStore
class FavoriteCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
source_kind: Literal["task", "batch", "exploration", "exploration_case"] = "task"
source_id: str = Field(pattern=r"^[A-Za-z0-9_-]{1,100}$")
version: int | None = Field(default=None, ge=1)
name: str | None = Field(default=None, min_length=1, max_length=200)
class FavoriteUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, min_length=1, max_length=200)
task_config: TaskOptions | None = None
case_configs: dict[int, TaskOptions] = Field(default_factory=dict)
class FavoriteRun(FavoriteUpdate):
request_id: str = Field(pattern=r"^[A-Za-z0-9_-]{1,100}$")
def _merge_options(target: dict, updates: dict) -> None:
if isinstance(updates.get("recording"), dict):
updates = {
**updates,
"recording": {**(target.get("recording") or {}), **updates["recording"]},
}
target.update(updates)
class FavoriteService:
def __init__(self, storage: AutomationStore = store):
self.store = storage
self._run_locks: WeakValueDictionary = WeakValueDictionary()
def get(self, key: str) -> dict:
value = self.store.get("favorite", identifier(key))
if value is None:
raise KeyError("收藏不存在")
if value.get("config_version") != 1:
# Complete legacy snapshots once and preserve their list position.
# Re-read under the write lock so a concurrent edit cannot be lost.
with self.store.connection() as conn:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind='favorite' AND id=?", (key,)
).fetchone()
if row is None:
raise KeyError("收藏不存在")
value = self._complete(json.loads(row[0]))
conn.execute(
"UPDATE automation_records SET payload=? WHERE kind='favorite' AND id=?",
(json.dumps(value, ensure_ascii=False), key),
)
conn.commit()
return value
def list(self) -> list[dict]:
return [
self.get(item["id"]) if item.get("config_version") != 1 else item
for item in self.store.records("favorite", 10000)
]
def _complete(self, favorite: dict) -> dict:
result = deepcopy(favorite)
if result.get("config_version") == 1:
return result
snapshot = result["snapshot"]
if result["kind"] == "batch":
for case in snapshot["cases"]:
case["task_config"] = execution_config(
{
"profile": snapshot.get("profile"),
"device_serial": snapshot.get("device_serial"),
"runtime_overrides": {
"timeout_seconds": snapshot.get("timeout_seconds"),
**(case.get("task_config") or {}),
},
}
)["runtime_overrides"]
elif result["kind"] == "exploration":
config = ExplorationConfig.model_validate(snapshot.get("config") or {}).model_dump()
snapshot["config"] = config
snapshot["task_config"] = execution_config(
{
"runtime_overrides": {
**(snapshot.get("task_config") or {}),
**{
key: config[key]
for key in ("model", "reasoning_effort", "timeout_seconds")
},
}
}
)["runtime_overrides"]
else:
snapshot.update(execution_config(snapshot))
result["config_version"] = 1
return result
def _task(self, session_id: str, *, fallback: dict | None = None) -> tuple[dict, list[str]]:
job = self.store.job(session_id) or {}
if snapshot := job.get("execution_config"):
return execution_config(snapshot), []
row = SessionRepository(self.store.path).get_session_by_id(session_id) or {}
submission = job.get("submission") or fallback or {}
options = {
**(job.get("case") or {}).get("task_config", {}),
**(submission.get("runtime_overrides") or {}),
}
options = {key: value for key, value in options.items() if value is not None}
try:
device = row.get("device_info") or {}
device = json.loads(device) if isinstance(device, str) else device
except (ValueError, TypeError):
device = {}
device = device if isinstance(device, dict) else {}
profile = (
options.get("profile")
or submission.get("profile")
or model_service.resolve_session_profile(row)
or "flash"
)
options["profile"] = profile
serial = (
options.get("device_serial")
or submission.get("device_serial")
or job.get("device_serial")
or device.get("serial")
)
if serial:
options["device_serial"] = serial
if serial == "computer:local" or device.get("execution_target") == "computer":
options["execution_target"] = "computer"
# Historical diagnostics describe the actual model, unlike display metadata
# which may reflect today's defaults. Read only execution model roles.
with self.store.connection() as conn:
rows = conn.execute(
"SELECT payload FROM traces WHERE session_id=? AND type='llm_diagnostic' ORDER BY timestamp DESC",
(session_id,),
).fetchall()
for trace in rows:
try:
data = json.loads(trace[0])
except (ValueError, TypeError):
continue
if str(data.get("role", "")).lower() in {
"operator",
"flashrunner",
"reactiverunner",
"planner",
}:
for key in ("model", "reasoning_effort"):
if data.get(key):
options.setdefault(key, data[key])
if options.get("model"):
break
missing = [
key
for key in (
"model",
"reasoning_effort",
"record_video",
"recording",
"analyze_video",
"request_timeout_seconds",
"max_retries",
"retry_interval_seconds",
"timeout_seconds",
)
if key not in options
]
snapshot = execution_config(
{**submission, "profile": profile, "runtime_overrides": options}
)
return snapshot, missing
def create(self, value: FavoriteCreate) -> dict:
token = f"{value.source_kind}:{value.source_id}:{value.version or ''}"
key = hashlib.sha256(token.encode()).hexdigest()[:32]
if self.store.get("favorite", key):
return self.get(key)
snapshot, missing, kind, version = {}, [], value.source_kind, None
source_id = value.source_id
repo = ExplorationStore(self.store)
if kind == "task":
run = repo.run(source_id)
if run and run["kind"] == "exploration":
source_id = run["exploration_id"]
kind = "exploration_case" if repo.case(source_id) else "exploration"
if kind in {"exploration", "exploration_case"}:
session = repo.get(source_id)
options = deepcopy(session["task_config"])
if kind == "exploration":
snapshot = {
"goal": session["goal"],
"config": session["config"],
"task_config": options,
}
name = session["goal"][:120]
else:
case = repo.case(source_id, value.version)
if not case:
raise KeyError("确认用例不存在")
version = case["version"]
validations = [
r
for r in repo.runs(source_id)
if r["kind"] == "validation" and r.get("case_version") == version
]
if validations:
config, missing = self._task(
validations[-1]["id"],
fallback={"runtime_overrides": validations[-1]["task_config"]},
)
else:
options = {
k: v
for k, v in options.items()
if k not in {"profile", "model", "reasoning_effort", "timeout_seconds"}
}
config = execution_config({"profile": "flash", "runtime_overrides": options})
missing = ["首次用例复跑,默认 Flash"]
snapshot = {"goal": require_resolved_case(case), **config}
name = case["name"]
elif kind == "batch":
batch = self.store.get("batch", source_id)
if not batch or batch.get("status") == "deleting":
raise KeyError("批次不存在")
cases, seen = [], set()
for job in self.store.jobs(source_id):
position = job.get("case_position", job["position"])
if position in seen:
continue
seen.add(position)
case = deepcopy(job["case"])
config, inherited = self._task(
job["session_id"],
fallback={
"profile": batch["profile"],
"device_serial": batch.get("device_serial"),
"runtime_overrides": {
"timeout_seconds": batch.get("timeout_seconds"),
**case.get("task_config", {}),
},
},
)
case["task_config"] = config["runtime_overrides"]
cases.append(TaskCase.model_validate(case).model_dump(exclude_none=True))
missing.extend(inherited)
if not cases:
raise ValueError("批次没有可收藏的用例")
snapshot = {
key: batch[key]
for key in (
"name",
"formats",
"profile",
"device_serial",
"timeout_seconds",
"stop_on_failure",
"repeat_count",
)
if key in batch
}
snapshot["cases"] = cases
name = batch["name"]
else:
row = SessionRepository(self.store.path).get_session_by_id(source_id) or {}
job = self.store.job(source_id) or {}
goal = (
(job.get("case") or {}).get("goal")
or row.get("initial_goal")
or (job.get("submission") or {}).get("goal")
)
if not goal:
raise KeyError("任务不存在或没有可复用的提示词")
config, missing = self._task(source_id)
snapshot = {"goal": goal, **config}
name = execution_title(job) or goal[:120]
favorite = {
"id": key,
"name": (value.name or name).strip(),
"kind": kind,
"source_kind": value.source_kind,
"source_id": value.source_id,
"case_version": version,
"snapshot": snapshot,
"inherited_fields": sorted(set(missing)),
"created_at": time.time(),
"updated_at": time.time(),
}
if not favorite["name"]:
raise ValueError("收藏名称不能为空")
return self.store.put("favorite", key, self._complete(favorite))
def updated(self, favorite: dict, value: FavoriteUpdate) -> dict:
result = self._complete(favorite)
if value.name is not None:
if not value.name.strip():
raise ValueError("收藏名称不能为空")
result["name"] = value.name.strip()
snapshot = result["snapshot"]
options = value.task_config.model_dump(exclude_none=True) if value.task_config else {}
if result["kind"] == "batch":
for index, case in enumerate(snapshot["cases"]):
if override := value.case_configs.get(index):
_merge_options(case["task_config"], override.model_dump(exclude_none=True))
_merge_options(case["task_config"], options)
if set(value.case_configs) - set(range(len(snapshot["cases"]))):
raise ValueError("批次用例下标不存在")
if options.get("device_serial"):
snapshot["device_serial"] = options["device_serial"]
elif result["kind"] == "exploration":
config = snapshot["config"]
config.update(
{
k: options[k]
for k in ("model", "reasoning_effort", "timeout_seconds")
if k in options
}
)
snapshot["config"] = ExplorationConfig.model_validate(config).model_dump()
_merge_options(snapshot["task_config"], options)
else:
_merge_options(snapshot["runtime_overrides"], options)
return result
def update(self, key: str, value: FavoriteUpdate) -> dict:
favorite = self.updated(self.get(key), value)
favorite["updated_at"] = time.time()
return self.store.put("favorite", key, favorite)
def delete(self, key: str) -> None:
with self.store.connection() as conn:
conn.execute(
"DELETE FROM automation_records WHERE kind='favorite' AND id=?", (identifier(key),)
)
conn.commit()
async def run(self, key: str, value: FavoriteRun) -> dict:
lock = self._run_locks.setdefault(value.request_id, asyncio.Lock())
async with lock:
return await self._run(key, value)
async def _run(self, key: str, value: FavoriteRun) -> dict:
key = identifier(key)
request = {"favorite_id": key, **value.model_dump(exclude_none=True)}
signature = hashlib.sha256(json.dumps(request, sort_keys=True).encode()).hexdigest()
# Reserve a durable snapshot before admission. Retries survive restarts,
# edits/deletions of the favorite, and lost HTTP responses.
with self.store.connection() as conn:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind='favorite_run' AND id=?",
(value.request_id,),
).fetchone()
record = json.loads(row[0]) if row else None
if record and record["signature"] != signature:
raise ValueError("request_id 已用于其他内容")
if record and record.get("result"):
return record["result"]
if record is None:
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind='favorite' AND id=?", (key,)
).fetchone()
if row is None:
raise KeyError("收藏不存在")
record = {
"signature": signature,
"favorite": self.updated(json.loads(row[0]), value),
}
conn.execute(
"INSERT INTO automation_records VALUES (?,?,?,?)",
(
"favorite_run",
value.request_id,
json.dumps(record, ensure_ascii=False),
time.time(),
),
)
conn.commit()
favorite = record["favorite"]
snapshot = favorite["snapshot"]
run_id = str(uuid5(NAMESPACE_URL, "artemis-favorite:" + value.request_id))
if favorite["kind"] == "batch":
from apps.admin_console.services.automation_service import create_batch, batch_action
batch = create_batch(
{**snapshot, "name": favorite["name"], "request_id": "favorite-" + run_id}
)
if batch["status"] == "draft":
batch = batch_action(batch["id"], "resume")
result = {"kind": "batch", "batch_id": batch["id"], "status": batch["status"]}
elif favorite["kind"] == "exploration":
from apps.admin_console.services.exploration_service import exploration_service
from artemis.exploration.models import CreateExploration
session = exploration_service.create(CreateExploration(request_id=run_id, **snapshot))
admission = await exploration_service.start(session["id"], run_id)
run = admission.get("run", admission)
rejection = admission.get("admission", {})
outcome = run.get("outcome") or {}
if (
rejection.get("status") in {"error", "rejected"}
or outcome.get("status") == "rejected"
):
raise ValueError(
rejection.get("error")
or rejection.get("message")
or outcome.get("error")
or "探索未入队"
)
result = {
"kind": "exploration",
"session_id": run["id"],
"exploration_id": session["id"],
"admission": admission,
}
else:
from apps.admin_console.routers.tasks import run_task
from apps.admin_console.schemas.task_schema import RunRequest
admission = await run_task(
RunRequest(
goal=snapshot["goal"],
profile=snapshot["runtime_overrides"].get("profile", "flash"),
runtime_overrides=TaskOptions.model_validate(snapshot["runtime_overrides"]),
session_id=run_id,
ingress="favorite",
expected_output=snapshot.get("expected_output"),
enable_outputter=snapshot.get("enable_outputter"),
app_path=snapshot.get("app_path"),
)
)
if not admission.get("tasks"):
raise ValueError(admission.get("error") or admission.get("message") or "任务未入队")
result = {"kind": "task", "session_id": run_id, "admission": admission}
record["result"] = result
self.store.put("favorite_run", value.request_id, record)
return result
favorites = FavoriteService()
+1 -4
View File
@@ -20,10 +20,7 @@ from typing import Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: from apps.admin_console.core.state import state
from admin_console.core.state import state
except ImportError:
from apps.admin_console.core.state import state
class IPCService: class IPCService:
@@ -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
@@ -22,6 +23,13 @@ class ModelSettingsUpdate(BaseModel):
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"
@@ -44,9 +52,11 @@ class ModelSettingsUpdate(BaseModel):
raise ValueError("请输入接口根地址,例如 https://api-n-cd.playones.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("模型名称不能为空或包含换行")
@@ -63,8 +73,18 @@ def get_model_settings() -> dict:
"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-6-luna",
"gpt-6.1-sol",
"gpt-6-astra",
"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,29 +166,79 @@ 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 recording_enabled: bool = False
recording: dict = Field(default_factory=dict)
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, recording_enabled=settings.ARTEMIS_RECORDING_ENABLED,
recording=load_agent_config().recording.model_dump(),
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:
from artemis.config.agent import RecordingConfig
import json
recording = RecordingConfig.model_validate(
{**load_agent_config().recording.model_dump(), **update.recording}
).model_dump()
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_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()})
persist_environment({"ARTEMIS_RECORDING_CONFIG": json.dumps(recording)})
settings.ARTEMIS_RECORDING_CONFIG = recording
for key, value in values.items(): for key, value in values.items():
setattr(settings, key, value) setattr(settings, key, value)
return get_capability_settings() return get_capability_settings()
+390 -29
View File
@@ -1,12 +1,19 @@
"""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
import json import json
from pathlib import Path from pathlib import Path
import re import re
import threading
import zipfile import zipfile
from apps.admin_console.services.export_cache import (
compression,
export_lock,
fingerprint,
remember,
reusable,
)
from apps.admin_console.services.request_diagnostics import diagnostics_json
from docx import Document from docx import Document
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
@@ -22,9 +29,12 @@ 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() BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-2"
STATUS = { STATUS = {
"completed": "执行完成", "completed": "执行完成",
"success": "执行完成", "success": "执行完成",
@@ -40,6 +50,14 @@ STATUS = {
} }
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"))
@@ -53,6 +71,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 "验证失败"
@@ -79,27 +103,68 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
) )
return safe[:500] return safe[:500]
from apps.admin_console.services.automation_store import RECOVERY_ERRORS
if status == "completed" and recorded in RECOVERY_ERRORS:
return ""
if recorded: if recorded:
return redact(recorded) return redact(recorded)
if status not in {"failed", "interrupted"}: if status not in {"failed", "interrupted"}:
return "" 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"): for name in ("stdout.log", "stderr.log"):
path = directory / name path = directory / name
try: try:
with path.open("rb") as stream: with path.open("rb") as stream:
stream.seek(0, 2) stream.seek(0, 2)
stream.seek(max(0, stream.tell() - 131072)) stream.seek(max(0, stream.tell() - 131072))
lines = stream.read().decode("utf-8", errors="replace").splitlines() log_lines.extend(stream.read().decode("utf-8", errors="replace").splitlines())
except OSError: except OSError:
continue continue
for line in reversed(lines): for line in reversed(log_lines):
match = re.search(r"Error running automation:\s*(.+)", line) match = re.search(r"Error running automation:\s*(.+)", line)
if match: if match:
return redact(match.group(1)) 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 "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。" return "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
def session_report(session_id: str, db_path=None) -> dict: def session_report(session_id: str, db_path=None) -> dict:
from apps.admin_console.services.exploration_evidence import exploration_metadata
identifier(session_id) identifier(session_id)
meta = store.job(session_id) or {} meta = store.job(session_id) or {}
repo = SessionRepository(db_path) if db_path else SessionRepository() repo = SessionRepository(db_path) if db_path else SessionRepository()
@@ -131,6 +196,10 @@ def session_report(session_id: str, db_path=None) -> dict:
case = meta.get("case") or {} case = meta.get("case") or {}
status = meta.get("terminal_status") or meta.get("status") or session.get("status", "unknown") status = meta.get("terminal_status") or meta.get("status") or session.get("status", "unknown")
error = failure_reason(directory, status, meta.get("error")) 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) verification = verdict(outcome)
if status in {"failed", "interrupted"} and verification.startswith("未验证"): if status in {"failed", "interrupted"} and verification.startswith("未验证"):
verification = "未验证(任务异常终止)" verification = "未验证(任务异常终止)"
@@ -142,11 +211,15 @@ def session_report(session_id: str, db_path=None) -> dict:
) )
return { return {
"id": session_id, "id": session_id,
"name": case.get("name") or session.get("initial_goal", session_id)[:120], "exploration": exploration_metadata(session_id),
"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": status, "status": status,
"verdict": verification, "verdict": verification,
"test_result": test_result,
"outcome": outcome, "outcome": outcome,
"content": content, "content": content,
"steps": steps, "steps": steps,
@@ -174,6 +247,21 @@ def time_text(value) -> str:
def metadata(report: dict) -> list[tuple]: def metadata(report: dict) -> list[tuple]:
return [ return [
("任务编号", report["id"]), ("任务编号", report["id"]),
*(
(
[
("探索会话", report["exploration"]["id"]),
(
"探索关联",
"用例 v" + str(report["exploration"]["case_version"]) + " 复跑"
if report["exploration"]["kind"] == "validation"
else "探索执行(不等于独立复跑通过)",
),
]
)
if report.get("exploration")
else []
),
("用例编号", report.get("case_id") or "—"), ("用例编号", report.get("case_id") or "—"),
("检测项目", report.get("category") or "—"), ("检测项目", report.get("category") or "—"),
("测试子项", report.get("subcategory") or "—"), ("测试子项", report.get("subcategory") or "—"),
@@ -183,6 +271,18 @@ def metadata(report: dict) -> list[tuple]:
("预期结果", 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"]),
@@ -375,6 +475,65 @@ def add_markdown_content(doc, content: str):
) )
def plugin_operations(steps: list[dict]) -> list[dict]:
operations = []
for step in steps:
for trace in step.get("generic_tools", []):
payload = trace.get("payload") or {}
if isinstance(payload, str):
try:
payload = json.loads(payload)
except ValueError:
continue
if not isinstance(payload, dict) or not payload.get("plugin_id"):
continue
result = payload.get("result") or {}
if isinstance(result, str):
try:
result = json.loads(result)
except ValueError:
result = {}
if not isinstance(result, dict):
result = {}
content = "\n".join(
str(block.get("text", ""))
for block in result.get("content", [])
if isinstance(block, dict) and block.get("type") == "text"
)
source = result.get("source") or payload.get("source") or {}
evidence = []
if result.get("call_id"):
evidence.append("调用:" + str(result["call_id"]))
if source:
evidence.append(
f"来源:{source.get('kind', '')} · {source.get('label') or source.get('target_id', '')}"
)
if result.get("execution"):
evidence.append(
f"执行:{result['execution']};副作用:{result.get('side_effect', 'unknown')};错误码:{result.get('error_code') or '无'}"
)
for attachment in result.get("attachments") or []:
evidence.append(
f"文件:{attachment.get('name')};SHA-256:{attachment.get('sha256')}"
)
if evidence:
content = "\n".join([content or result.get("message") or "", *evidence])
operations.append(
{
"step": step.get("step_number"),
"plugin": payload["plugin_id"],
"version": payload.get("plugin_version", ""),
"tool": result.get("tool") or trace.get("name"),
"resources": ", ".join(payload.get("resource_ids") or []),
"status": result.get("status") or trace.get("status", "unknown"),
"content": (
content or payload.get("error") or result.get("message") or "未返回文字证据"
)[:6000],
}
)
return operations
def word_report(report: dict, path: Path): def word_report(report: dict, path: Path):
doc = word_styles(Document()) doc = word_styles(Document())
doc.add_paragraph(report["name"], style="Title") doc.add_paragraph(report["name"], style="Title")
@@ -384,6 +543,11 @@ def word_report(report: dict, path: Path):
if report["error"]: if report["error"]:
doc.add_heading("异常原因", 1) doc.add_heading("异常原因", 1)
doc.add_paragraph(text(report["error"])) 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) doc.add_heading("任务信息", 1)
details = [ details = [
(label, value) (label, value)
@@ -391,10 +555,11 @@ def word_report(report: dict, path: Path):
if label in {"任务编号", "用例编号", "检测项目", "测试子项", "开始时间", "结束时间", "设备"} if label in {"任务编号", "用例编号", "检测项目", "测试子项", "开始时间", "结束时间", "设备"}
] ]
word_table(doc, details, (3.2, 13.3)) word_table(doc, details, (3.2, 13.3))
doc.add_heading("任务目标", 2) doc.add_heading("任务目标与预期" if not report["expected"] else "任务目标", 2)
doc.add_paragraph(report["goal"] or "未记录") doc.add_paragraph(report["goal"] or "未记录")
if report["expected"]:
doc.add_heading("预期结果", 2) doc.add_heading("预期结果", 2)
doc.add_paragraph(report["expected"] or "未指定") doc.add_paragraph(report["expected"])
if report.get("preconditions"): if report.get("preconditions"):
doc.add_paragraph("前置约束:" + text(report["preconditions"])) doc.add_paragraph("前置约束:" + text(report["preconditions"]))
if report.get("task_config"): if report.get("task_config"):
@@ -409,6 +574,15 @@ def word_report(report: dict, path: Path):
f"步骤 {step['step_number']} · {time_text(step.get('timestamp'))}\n" f"步骤 {step['step_number']} · {time_text(step.get('timestamp'))}\n"
).bold = True ).bold = True
paragraph.add_run(step.get("summary") or "已记录操作") paragraph.add_run(step.get("summary") or "已记录操作")
operations = plugin_operations(report["steps"])
if operations:
doc.add_heading("扩展插件操作", 2)
for operation in operations:
doc.add_paragraph(
f"步骤 {operation['step']} · {operation['plugin']} v{operation['version']} · {operation['tool']} · {operation['status']}"
)
doc.add_paragraph("资源:" + operation["resources"])
doc.add_paragraph(operation["content"])
evidence = report["steps"][:1] + report["steps"][-1:] evidence = report["steps"][:1] + report["steps"][-1:]
seen: set[str] = set() seen: set[str] = set()
images: list[tuple[Path, int]] = [] images: list[tuple[Path, int]] = []
@@ -549,12 +723,36 @@ def excel_report(report: dict, path: Path):
], ],
(10, 22, 68, 26, 24), (10, 22, 68, 26, 24),
) )
operations = plugin_operations(report["steps"])
if operations:
sheet_rows(
wb.create_sheet("插件操作"),
[
("步骤", "插件", "版本", "工具", "资源", "调用状态", "观察与结果"),
*[
(
op["step"],
op["plugin"],
op["version"],
op["tool"],
op["resources"],
op["status"],
op["content"],
)
for op in operations
],
],
(10, 24, 14, 26, 24, 16, 80),
)
wb.save(path) wb.save(path)
def export_session(session_id: str, formats: list[str] | None = None) -> list[Path]: def export_session(
with EXPORT_LOCK: session_id: str, formats: list[str] | None = None, *, report: dict | None = None
report = session_report(session_id) ) -> list[Path]:
with export_lock("session:" + identifier(session_id)):
report = report if report is not None else session_report(session_id)
signature = fingerprint(report, session_screenshots(report))
directory = EXPORT_ROOT / "sessions" / identifier(session_id) directory = EXPORT_ROOT / "sessions" / identifier(session_id)
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
outputs = [] outputs = []
@@ -562,9 +760,13 @@ def export_session(session_id: str, formats: list[str] | None = None) -> list[Pa
if fmt not in {"docx", "xlsx"}: if fmt not in {"docx", "xlsx"}:
raise ValueError("不支持的报告格式") raise ValueError("不支持的报告格式")
path = directory / f"任务报告.{fmt}" path = directory / f"任务报告.{fmt}"
if reusable(path, signature, report["status"]):
outputs.append(path)
continue
temporary = directory / f"生成中.{fmt}" temporary = directory / f"生成中.{fmt}"
(word_report if fmt == "docx" else excel_report)(report, temporary) (word_report if fmt == "docx" else excel_report)(report, temporary)
temporary.replace(path) temporary.replace(path)
remember(path, signature)
outputs.append(path) outputs.append(path)
return outputs return outputs
@@ -582,6 +784,30 @@ def session_screenshots(report: dict) -> list[tuple[Path, str]]:
f"{index:04d}_步骤{int(step.get('step_number') or index):04d}_{phase}{source.suffix.lower()}", f"{index:04d}_步骤{int(step.get('step_number') or index):04d}_{phase}{source.suffix.lower()}",
) )
) )
# Plugin observations are attached to their own tool traces rather than
# replacing a phone's step image in a mixed task.
seen: set[tuple[str, str]] = set()
for tool_index, trace in enumerate(step.get("generic_tools", []), 1):
payload = trace.get("payload") or {}
if isinstance(payload, str):
try:
payload = json.loads(payload)
except ValueError:
continue
if not isinstance(payload, dict) or not payload.get("plugin_id"):
continue
plugin_id = re.sub(r"[^a-zA-Z0-9_-]", "_", str(payload["plugin_id"]))[:48]
names = payload.get("image_names") or [payload.get("post_image_name")]
for image_index, name in enumerate(names, 1):
source = image_path(name)
if source and (plugin_id, str(name)) not in seen:
seen.add((plugin_id, str(name)))
screenshots.append(
(
source,
f"{index:04d}_插件_{plugin_id}_{tool_index:03d}_{image_index:02d}{source.suffix.lower()}",
)
)
return screenshots return screenshots
@@ -593,15 +819,17 @@ def session_videos(report: dict) -> list[tuple[Path, str]]:
if not candidate: if not candidate:
return [] return []
source = Path(str(candidate)).resolve() source = Path(str(candidate)).resolve()
session_root = (TRACES_PATH / identifier(report["id"])).resolve() trace_root = TRACES_PATH.resolve()
if not source.is_relative_to(session_root): # 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 [] return []
# A resumed recording lives under resume_NNNN; the initial recording is # A resumed recording lives under resume_NNNN; the initial recording is
# in its parent, with any later segments in sibling resume folders. # in its parent, with any later segments in sibling resume folders.
directory = source.parent directory = source.parent
if directory.name.startswith("resume_"): if directory.name.startswith("resume_"):
directory = directory.parent directory = directory.parent
if not directory.is_dir() or not directory.is_relative_to(session_root): if not directory.is_dir() or not directory.is_relative_to(trace_root):
return [] return []
paths = [ paths = [
p p
@@ -614,45 +842,129 @@ def session_videos(report: dict) -> list[tuple[Path, str]]:
return [(path, f"{i:04d}_{path.parent.name}_{path.name}") for i, path in enumerate(paths, 1)] 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: def session_artifacts(report: dict) -> list[tuple[Path, str]]:
"""Only completed, task-owned Android files; never follow external links."""
traces = TRACES_PATH.resolve()
root = traces / identifier(report["id"]) / "artifacts" / "android"
if not root.is_dir() or root.resolve() != root:
return []
return [
(path, path.name)
for path in sorted(root.iterdir())
if path.is_file()
and not path.is_symlink()
and not path.name.startswith(".artemis-")
and path.resolve().parent == root.resolve()
]
def session_download(session_id: str, kind: str) -> tuple[Path, str]:
with export_lock("session:" + identifier(session_id)):
report = session_report(session_id)
return export_session_media(session_id, kind, report=report), archive_case_name(report)
def export_session_media(session_id: str, kind: str, *, report: dict | None = None) -> Path:
from apps.admin_console.services.evidence_service import export_index, plugin_files
if kind not in {"screenshots", "video", "all"}: if kind not in {"screenshots", "video", "all"}:
raise ValueError("不支持的下载类型") raise ValueError("不支持的下载类型")
with EXPORT_LOCK: with export_lock("session:" + identifier(session_id)):
report = session_report(session_id) report = report if report is not None else session_report(session_id)
target_dir = EXPORT_ROOT / "sessions" / identifier(session_id) target_dir = EXPORT_ROOT / "sessions" / identifier(session_id)
target_dir.mkdir(parents=True, exist_ok=True) target_dir.mkdir(parents=True, exist_ok=True)
images = session_screenshots(report) if kind in {"screenshots", "all"} else [] images = session_screenshots(report) if kind in {"screenshots", "all"} else []
videos = session_videos(report) if kind in {"video", "all"} else [] videos = session_videos(report) if kind in {"video", "all"} else []
if kind == "video" and not videos: artifacts = session_artifacts(report) if kind == "all" else []
extras = plugin_files(session_id) if kind in {"video", "all"} else []
if kind == "video":
extras = [
(path, name)
for path, name in extras
if path.suffix.lower() in {".mp4", ".mkv", ".webm"}
]
if kind == "video" and not videos and not extras:
raise ValueError("此任务没有已保存的录屏") raise ValueError("此任务没有已保存的录屏")
if kind == "screenshots" and not images: if kind == "screenshots" and not images:
raise ValueError("此任务没有可下载的步骤截图") raise ValueError("此任务没有可下载的步骤截图")
target = target_dir / f"{kind}.zip" target = target_dir / f"{kind}.zip"
temporary = target_dir / f".{kind}.tmp" temporary = target_dir / f".{kind}.tmp"
files = images + videos + artifacts + extras
diagnostics = diagnostics_json(session_id) if kind == "all" else None
diagnostic_state = json.loads(diagnostics) if diagnostics else None
if isinstance(diagnostic_state, dict):
diagnostic_state.pop("collected_at", None)
signature = fingerprint(report, files, diagnostic_state)
if reusable(target, signature, report["status"]):
return target
manifest = { manifest = {
"session_id": session_id, "session_id": session_id,
"name": report["name"], "name": report["name"],
"status": report["status"], "status": report["status"],
"verdict": report["verdict"], "verdict": report["verdict"],
"screenshots": [name for _, name in images], "screenshots": [name for _, name in images],
"videos": [name for _, name in videos], "videos": ["任务录屏/安卓/" + name for _, name in videos]
+ [name for path, name in extras if path.suffix.lower() in {".mp4", ".mkv", ".webm"}],
"artifacts": [name for _, name in artifacts],
"note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。", "note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
"snapshot": report["status"]
not in {"completed", "success", "failed", "cancelled", "interrupted", "skipped"},
} }
prefix = archive_case_name(report) + "/" if kind == "all" else ""
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive: with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
if kind == "all": if kind == "all":
for path in export_session(session_id, ["docx", "xlsx"]): for path in export_session(session_id, ["docx", "xlsx"], report=report):
archive.write(path, path.name) archive.write(path, prefix + path.name, compress_type=compression(path))
for source, name in images: for source, name in images:
archive.write(source, f"步骤截图/{name}") archive.write(source, f"{prefix}步骤截图/{name}", compress_type=compression(source))
for source, name in videos: for source, name in videos:
archive.write(source, f"任务录屏/{name}") archive.write(
archive.writestr("文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2)) source, f"{prefix}任务录屏/安卓/{name}", compress_type=zipfile.ZIP_STORED
)
for source, name in artifacts:
archive.write(
source, f"{prefix}任务文件/安卓/{name}", compress_type=compression(source)
)
for source, name in extras:
archive.write(
source,
prefix + name,
compress_type=compression(source),
)
if kind == "all":
archive.writestr(prefix + "模型请求诊断.json", diagnostics or "{}")
archive.writestr(
prefix + "证据索引.json",
json.dumps(
export_index(
session_id,
report,
videos,
images=images,
artifacts=artifacts,
extras=extras,
),
ensure_ascii=False,
indent=2,
default=str,
),
)
archive.writestr(
prefix + "文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2)
)
temporary.replace(target) temporary.replace(target)
if fingerprint(report, files, diagnostic_state) == signature:
remember(target, signature)
return target return target
def export_batch(batch_id: str) -> Path: def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None = None) -> Path:
with EXPORT_LOCK: from apps.admin_console.services.evidence_service import export_index, plugin_files
with export_lock(
"batch:" + identifier(batch_id),
*("session:" + job["session_id"] for job in store.jobs(batch_id)),
):
batch = store.get("batch", identifier(batch_id)) batch = store.get("batch", identifier(batch_id))
if not batch: if not batch:
raise ValueError("批次不存在") raise ValueError("批次不存在")
@@ -660,6 +972,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"])
@@ -688,9 +1002,56 @@ def export_batch(batch_id: str) -> Path:
temporary = directory / "批次报告.tmp" temporary = directory / "批次报告.tmp"
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive: with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
for path in outputs: for path in outputs:
archive.write(path, path.name) archive.write(path, path.name, compress_type=compression(path))
for number, report in enumerate(records, 1): for number, report in enumerate(records, 1):
for path in export_session(report["id"], formats): if progress:
archive.write(path, f"任务报告/{number:04d}_{report['id']}/{path.name}") progress(number - 1, len(records), f"正在打包任务 {number}/{len(records)}")
prefix = f"任务报告/{number:04d}_{archive_case_name(report)}"
images = session_screenshots(report)
videos = session_videos(report)
artifacts = session_artifacts(report)
extras = plugin_files(report["id"])
for path in export_session(report["id"], formats, report=report):
archive.write(path, f"{prefix}/{path.name}", compress_type=compression(path))
for source, name in images:
archive.write(
source, f"{prefix}/步骤截图/{name}", compress_type=compression(source)
)
for source, name in videos:
archive.write(
source, f"{prefix}/任务录屏/安卓/{name}", compress_type=zipfile.ZIP_STORED
)
for source, name in artifacts:
archive.write(
source, f"{prefix}/任务文件/安卓/{name}", compress_type=compression(source)
)
for source, name in extras:
archive.write(
source,
f"{prefix}/{name}",
compress_type=compression(source),
)
archive.writestr(f"{prefix}/模型请求诊断.json", diagnostics_json(report["id"]))
archive.writestr(
f"{prefix}/证据索引.json",
json.dumps(
export_index(
report["id"],
report,
videos,
images=images,
artifacts=artifacts,
extras=extras,
),
ensure_ascii=False,
indent=2,
default=str,
),
)
if progress:
progress(number, len(records), f"已打包任务 {number}/{len(records)}")
if progress:
progress(len(records), len(records), "正在完成压缩包")
temporary.replace(target) temporary.replace(target)
remember(target, BATCH_ARCHIVE_SIGNATURE)
return target return target
@@ -0,0 +1,112 @@
"""Read/export model diagnostics from the task's existing trace database."""
from datetime import UTC, datetime
import json
import time
from apps.admin_console.database.connection import db_session
def request_diagnostics(
session_id: str, *, db_path=None, limit: int | None = None, offset: int = 0
) -> dict:
with db_session(db_path) as conn:
session = conn.execute(
"SELECT status FROM sessions WHERE session_id = ?", (session_id,)
).fetchone()
sql = (
"SELECT payload FROM traces WHERE session_id = ? AND type = 'llm_diagnostic' "
"ORDER BY timestamp DESC"
)
params: tuple = (session_id,)
total = conn.execute(
"SELECT count(*) FROM traces WHERE session_id=? AND type='llm_diagnostic'",
params,
).fetchone()[0]
if limit is not None:
sql += " LIMIT ? OFFSET ?"
params += (limit, offset)
rows = conn.execute(sql, params).fetchall()
review_rows = conn.execute(
"SELECT payload FROM traces WHERE session_id = ? "
"AND type = 'final_review_diagnostic' ORDER BY timestamp DESC"
+ (" LIMIT ?" if limit is not None else ""),
(session_id, limit) if limit is not None else (session_id,),
).fetchall()
requests = []
seen = set()
running = bool(session and session["status"] in {"running", "resuming"})
for row in rows:
try:
value = json.loads(row["payload"])
except (ValueError, TypeError):
continue
if not isinstance(value, dict):
continue
request_id = value.get("request_id")
value["active"] = (
running
and offset == 0
and request_id not in seen
and value.get("phase")
in {
"waiting_response",
"waiting_output",
"receiving_output",
"generating_tool_arguments",
"retry_wait",
}
)
seen.add(request_id)
value["timestamps_utc"] = {
field: datetime.fromtimestamp(value[field], UTC).isoformat()
for field in (
"started_at",
"headers_at",
"first_output_at",
"last_output_at",
"ended_at",
"retry_at",
)
if isinstance(value.get(field), (int, float))
}
requests.append(value)
final_reviews = []
for row in review_rows:
try:
value = json.loads(row["payload"])
except (ValueError, TypeError):
continue
if not isinstance(value, dict):
continue
value["active"] = (
running and offset == 0 and not final_reviews and value.get("status") == "running"
)
if value["active"] and isinstance(value.get("started_at"), (int, float)):
value["elapsed_seconds"] = round(max(0.0, time.time() - value["started_at"]), 3)
value["remaining_seconds"] = round(
max(0.0, value.get("total_seconds", 0.0) - value["elapsed_seconds"]), 3
)
value["budget_observed_at"] = time.time()
value["timestamps_utc"] = {
field: datetime.fromtimestamp(value[field], UTC).isoformat()
for field in ("started_at", "deadline_at", "ended_at")
if isinstance(value.get(field), (int, float))
}
final_reviews.append(value)
return {
"schema_version": 1,
"session_id": session_id,
"session_status": session["status"] if session else "unknown",
"collected_at": time.time(),
"requests": requests,
"final_reviews": final_reviews,
"total": total,
"offset": offset,
"next_offset": offset + limit if limit is not None and offset + limit < total else None,
"note": "时间戳为 Unix 秒;timestamps_utc 为 UTC。HTTP 200 不代表完整输出。usage=null 表示服务未报告用量。历史任务没有记录的字段无法补推。",
}
def diagnostics_json(session_id: str) -> str:
return json.dumps(request_diagnostics(session_id), ensure_ascii=False, indent=2)
@@ -0,0 +1,40 @@
"""Read the impact of deleting a task without deleting or stopping anything."""
from uuid import UUID
from artemis.exploration.store import ExplorationConflict, explorations
from apps.admin_console.database.repositories.session_repository import session_repo
from apps.admin_console.services.automation_store import store
from apps.admin_console.services.exploration_deletion import _check_runs
def deletion_preview(session_id: str) -> dict:
session_id = str(UUID(session_id))
run = explorations.run(session_id)
if run and run["kind"] == "exploration":
return exploration_deletion_preview(run["exploration_id"])
if not session_repo.get_session_by_id(session_id) and not store.job(session_id):
raise KeyError("任务不存在")
_check_runs([{"id": session_id}])
job = store.job(session_id) or {}
if job.get("status") in {"pending", "queued", "running", "task_paused", "resuming"}:
raise ExplorationConflict("任务仍在执行或排队,请先停止并等待结束")
return {
"session_ids": [session_id],
"preserved_session_ids": [],
"removes": ["任务历史", "报告", "截图、录像及附件"],
}
def exploration_deletion_preview(exploration_id: str) -> dict:
session = explorations.get(exploration_id)
runs = explorations.runs(exploration_id)
_check_runs(runs)
if session.get("active_run_id") or session.get("active_demonstration_id"):
raise ExplorationConflict("探索尚有活动任务或示范,请先停止并等待结束")
return {
"exploration_id": exploration_id,
"session_ids": [run["id"] for run in runs if run["kind"] == "exploration"],
"preserved_session_ids": [run["id"] for run in runs if run["kind"] != "exploration"],
"removes": ["探索对话", "用例版本", "探索执行历史及证据"],
}
+32 -2
View File
@@ -8,7 +8,9 @@ import zipfile
from openpyxl import Workbook, load_workbook from openpyxl import Workbook, load_workbook
from openpyxl.styles import Alignment, Font, PatternFill from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, field_serializer
from artemis.plugins.models import ExecutionTarget, PluginSelection
from artemis.config.agent import RecordingConfig
HEADERS = ( HEADERS = (
"用例编号", "用例编号",
@@ -37,11 +39,24 @@ class TaskOptions(BaseModel):
explorer_mode: str | None = Field(default=None, pattern="^(flash|pro|ultra)$") 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) locked_app_package: str | None = Field(default=None, min_length=1, max_length=200)
record_video: bool | None = None record_video: bool | None = None
recording: RecordingConfig | None = None
analyze_video: bool | None = None analyze_video: bool | None = None
request_timeout_seconds: float | None = Field(default=None, gt=0, le=3600) request_timeout_seconds: float | None = Field(default=None, gt=0, le=3600)
max_retries: int | None = Field(default=None, ge=0, le=10) max_retries: int | None = Field(default=None, ge=0, le=10)
retry_interval_seconds: float | None = Field(default=None, ge=0, le=300) retry_interval_seconds: float | None = Field(default=None, ge=0, le=300)
timeout_seconds: int | None = Field(default=None, ge=30, le=86400) 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)
plugins: list[PluginSelection] | None = None
execution_target: ExecutionTarget | None = None
@field_serializer("recording", when_used="unless-none")
def serialize_recording(self, value: RecordingConfig) -> dict:
# A per-task partial override must not replace other saved recording defaults.
return value.model_dump(exclude_unset=True)
class TaskCase(BaseModel): class TaskCase(BaseModel):
@@ -88,7 +103,7 @@ def template() -> bytes:
json.dumps( json.dumps(
{ {
"profile": "pro", "profile": "pro",
"model": "mimo-v2.6-flash", "model": "gpt-6-luna",
"reasoning_effort": "high", "reasoning_effort": "high",
"verification_level": "final", "verification_level": "final",
"record_video": True, "record_video": True,
@@ -177,3 +192,18 @@ def parse_workbook(data: bytes) -> dict:
def case_goal(case: dict) -> str: def case_goal(case: dict) -> str:
"""Execution gets the prompt verbatim; metadata stays outside model input.""" """Execution gets the prompt verbatim; metadata stays outside model input."""
return case["goal"] return case["goal"]
def case_title(case: dict) -> str:
"""Human-facing title; never prepend metadata to the model prompt."""
case_id = str(case.get("case_id") or "").strip()
name = str(case.get("name") or "").strip()
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
@@ -0,0 +1,147 @@
"""Bounded, read-only task diagnostics shared by MCP and HTTP clients."""
import os
import re
from apps.admin_console.database.repositories.session_repository import session_repo
from apps.admin_console.services import evidence_service, report_export
from apps.admin_console.services.automation_store import identifier, store
from apps.admin_console.services.request_diagnostics import request_diagnostics
_SECRET = re.compile(
r"(?i)^(api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|password|secret|client_secret|token_key)$"
)
def public_value(value, *, max_chars: int = 60000):
"""Remove common credential forms and bound text without inventing missing evidence."""
remaining = max_chars
remaining_nodes = 2000
truncated = False
secrets = [
v
for k, v in os.environ.items()
if len(v) >= 8 and re.search(r"(?i)(api_?key|password|secret|token)$", k)
]
def clean(item, depth=0):
nonlocal remaining, remaining_nodes, truncated
remaining_nodes -= 1
if depth > 12:
truncated = True
return "[嵌套内容已截断]"
if isinstance(item, dict):
result = {}
for k, v in item.items():
if remaining_nodes <= 0 or len(result) >= 100:
truncated = True
break
key = clean(str(k), depth + 1)
result[key] = "[REDACTED]" if _SECRET.fullmatch(str(k)) else clean(v, depth + 1)
return result
if isinstance(item, list):
result = []
for v in item:
if remaining_nodes <= 0 or len(result) >= 100:
truncated = True
break
result.append(clean(v, depth + 1))
return result
if not isinstance(item, str):
return item
for secret in secrets:
item = item.replace(secret, "[REDACTED]")
item = re.sub(r"(?i)\bBearer\s+[^\s\"',;]+", "Bearer [REDACTED]", item)
item = re.sub(r"\bsk-[A-Za-z0-9_-]+", "[REDACTED]", item)
item = re.sub(r"(https?://)[^\s/@]+:[^\s/@]+@", r"\1[REDACTED]@", item)
item = re.sub(
r"""(?ix)((?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|password|secret|token_key)["']?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s&,;]+)""",
r"\1[REDACTED]",
item,
)
allowance = min(8000, remaining)
if len(item) > allowance:
truncated = True
item = item[:allowance] + "…[已截断;按步骤或关键词缩小范围]"
remaining = max(0, remaining - min(len(item), allowance))
return item
result = clean(value)
return {**result, "text_truncated": truncated, "content_truncated": truncated}
def page(items: list, offset: int, limit: int) -> dict:
return {
"items": items[offset : offset + limit],
"total": len(items),
"offset": offset,
"limit": limit,
"next_offset": offset + limit if offset + limit < len(items) else None,
}
def inspect_task(session_id: str, view: str, *, offset: int = 0, limit: int = 20) -> dict:
identifier(session_id)
if offset < 0 or not 1 <= limit <= 100:
raise ValueError("offset 必须非负,limit 必须在 1–100 之间")
if not session_repo.get_session_by_id(session_id) and not store.job(session_id):
raise LookupError("任务记录不存在")
if view == "usage":
result = session_repo.get_session_usage(session_id)
result["cache_hit_rate"] = (
result["cached_tokens"] / result["prompt_tokens"] if result["prompt_tokens"] else None
)
result["note"] = (
"缓存率=已记录 cached_tokens / prompt_tokens;无用量不等于零消耗,未上报的请求无法补推。"
)
elif view == "requests":
result = request_diagnostics(session_id, limit=limit, offset=offset)
elif view == "logs":
root = evidence_service.task_root(session_id)
result = {"logs": []}
for name in ("stdout.log", "stderr.log"):
path = evidence_service.evidence_file(root, name)
if path is None:
result["logs"].append({"name": name, "available": False})
continue
with path.open("rb") as stream:
size = path.stat().st_size
start = max(0, size - 128 * 1024)
stream.seek(start)
raw = stream.read(128 * 1024)
lines = raw.decode("utf-8", errors="replace").splitlines()
if start and lines:
lines = lines[1:]
# Offset counts backward from the newest line within the bounded tail.
end = max(0, len(lines) - offset)
result["logs"].append(
{
"name": name,
"available": True,
"bytes": size,
"lines": lines[max(0, end - limit) : end],
"offset": offset,
"next_offset": offset + limit if end > limit else None,
"older_bytes_omitted": start,
"order": "oldest_to_newest",
}
)
elif view in {"report", "evidence"}:
report = report_export.session_report(session_id)
if view == "report":
result = {**report, "steps": page(report.get("steps", []), offset, limit)}
else:
index = evidence_service.export_index(
session_id, report, report_export.session_videos(report)
)
result = {
**index,
**{
key: page(index.get(key, []), offset, limit)
for key in ("steps", "calls", "recordings", "android_videos", "files")
},
}
else:
raise ValueError("view 需为 report / usage / requests / logs / evidence")
return public_value({"session_id": session_id, **result})
+233 -14
View File
@@ -13,6 +13,7 @@
# limitations under the License. # limitations under the License.
import asyncio import asyncio
import json
from datetime import datetime from datetime import datetime
import logging import logging
import os import os
@@ -24,16 +25,10 @@ import time
from typing import Any from typing import Any
import uuid import uuid
try: from apps.admin_console.core.state import state
from admin_console.core.state import state from apps.admin_console.database.repositories.session_repository import session_repo
from admin_console.database.repositories.session_repository import session_repo from apps.admin_console.services import worker_process_io
from admin_console.services import worker_process_io from apps.admin_console.services.media_service import media_service
from admin_console.services.media_service import media_service
except ImportError:
from apps.admin_console.core.state import state
from apps.admin_console.database.repositories.session_repository import session_repo
from apps.admin_console.services import worker_process_io
from apps.admin_console.services.media_service import media_service
from artemis.config import ( from artemis.config import (
PAUSE_FILE, PAUSE_FILE,
@@ -275,6 +270,11 @@ class TaskQueueService:
] ]
for item in removed_items: for item in removed_items:
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket")) DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
from artemis.plugins.store import PluginStore
# A stop request may already have removed this row. The worker finalizer
# must still release the server-owned version reservation after exit.
PluginStore().release(str(session_id))
state.queue_items = [ state.queue_items = [
t t
for t in state.queue_items for t in state.queue_items
@@ -417,6 +417,9 @@ class TaskQueueService:
busy_devices = state.busy_device_ids | { busy_devices = state.busy_device_ids | {
cls._task_target(i).lock_key for i in in_flight if i.get("device_serial") cls._task_target(i).lock_key for i in in_flight if i.get("device_serial")
} }
busy_resources = {r for item in in_flight for r in item.get("plugin_resources", [])}
for owner in DeviceExecutionLock.get_active_owners().values():
busy_resources.update(owner.resource_ids)
dispatched_any = False dispatched_any = False
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
for item in list(state.queue_items): for item in list(state.queue_items):
@@ -429,6 +432,9 @@ class TaskQueueService:
device = item.get("device_serial") device = item.get("device_serial")
target = cls._task_target(item) target = cls._task_target(item)
resources = set(item.get("plugin_resources", []))
if resources & busy_resources:
continue
if limit == 0: if limit == 0:
# 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
@@ -446,6 +452,7 @@ class TaskQueueService:
continue continue
item["status"] = "running" item["status"] = "running"
busy_resources.update(resources)
dispatched_any = True dispatched_any = True
if device is not None: if device is not None:
busy_devices.add(target.lock_key) busy_devices.add(target.lock_key)
@@ -572,7 +579,17 @@ 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"
from artemis.exploration.store import explorations
exploration_run = explorations.run(str(sess_id)) if sess_id else None
env.pop("ARTEMIS_EXPLORATION_ID", None)
if exploration_run and exploration_run["kind"] == "exploration":
env["ARTEMIS_EXPLORATION_ID"] = exploration_run["exploration_id"]
overrides = task_item.get("runtime_overrides") or {} overrides = task_item.get("runtime_overrides") or {}
if overrides.get("recording") is not None:
env["ARTEMIS_TASK_RECORDING_CONFIG"] = json.dumps(overrides["recording"])
else:
env.pop("ARTEMIS_TASK_RECORDING_CONFIG", None)
for option, variable in { for option, variable in {
"model": "OPENAI_MODEL", "model": "OPENAI_MODEL",
"reasoning_effort": "OPENAI_REASONING_EFFORT", "reasoning_effort": "OPENAI_REASONING_EFFORT",
@@ -581,6 +598,10 @@ class TaskQueueService:
"retry_interval_seconds": "OPENAI_RETRY_INTERVAL_SECONDS", "retry_interval_seconds": "OPENAI_RETRY_INTERVAL_SECONDS",
"record_video": "ARTEMIS_RECORDING_ENABLED", "record_video": "ARTEMIS_RECORDING_ENABLED",
"analyze_video": "ARTEMIS_VIDEO_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(): }.items():
if overrides.get(option) is not None: if overrides.get(option) is not None:
env[variable] = ( env[variable] = (
@@ -589,6 +610,11 @@ class TaskQueueService:
else str(overrides[option]) else str(overrides[option])
) )
target.endpoint.apply_to_environment(env) target.endpoint.apply_to_environment(env)
if "plugins" in overrides:
env["ARTEMIS_TASK_PLUGINS"] = json.dumps(overrides["plugins"])
else:
env.pop("ARTEMIS_TASK_PLUGINS", None)
env["ARTEMIS_EXECUTION_TARGET"] = overrides.get("execution_target") or "android"
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")
if queue_ticket: if queue_ticket:
@@ -619,7 +645,7 @@ class TaskQueueService:
if app_path: if app_path:
cmd.extend(["--app-path", str(app_path)]) cmd.extend(["--app-path", str(app_path)])
device_serial = task_item.get("device_serial") device_serial = task_item.get("device_serial")
if device_serial: if device_serial and env["ARTEMIS_EXECUTION_TARGET"] != "computer":
cmd.extend(["--device-serial", str(device_serial)]) cmd.extend(["--device-serial", str(device_serial)])
env["ADB_DEVICE_SERIAL"] = str(device_serial) env["ADB_DEVICE_SERIAL"] = str(device_serial)
from apps.admin_console.services.automation_store import store from apps.admin_console.services.automation_store import store
@@ -630,6 +656,83 @@ 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)
from artemis.exploration.store import explorations
exploration_run = explorations.run(sid)
is_exploration = bool(exploration_run and exploration_run["kind"] == "exploration")
exploration_finalized = False
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:
if exploration_run and not exploration_finalized:
explorations.finish_run(sid, {**outcome, "status": outcome["task_status"]})
exploration_finalized = True
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()
if is_exploration:
# The worker owns its pausable budget so it survives a UI server restart.
continue
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,
@@ -721,6 +824,10 @@ 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 ( if (
state.is_shutting_down state.is_shutting_down
and not manual_stop and not manual_stop
@@ -777,6 +884,10 @@ class TaskQueueService:
registered at recording start; remuxing that file is all that is registered at recording start; remuxing that file is all that is
needed to publish the full video. needed to publish the full video.
""" """
from apps.admin_console.services.evidence_service import task_root
from artemis.plugins.recording import recover_interrupted_recordings
await recover_interrupted_recordings(task_root(str(sess_id)))
rec_info = session_repo.get_video_recording_for_session(sess_id) rec_info = session_repo.get_video_recording_for_session(sess_id)
rec_status = (rec_info or {}).get("status") rec_status = (rec_info or {}).get("status")
if rec_status == "ready": if rec_status == "ready":
@@ -922,6 +1033,9 @@ class TaskQueueService:
if not isinstance(goal, str) or not goal.strip(): if not isinstance(goal, str) or not goal.strip():
raise ValueError("Queued task must contain a non-empty string goal.") raise ValueError("Queued task must contain a non-empty string goal.")
cls._begin_task_run(task_item, run_key, sess_id, goal, profile) cls._begin_task_run(task_item, run_key, sess_id, goal, profile)
from apps.admin_console.services.execution_config import persist_execution_config
await asyncio.to_thread(persist_execution_config, task_item)
target = cls._task_target(task_item) target = cls._task_target(task_item)
cmd, env = cls._build_worker_invocation( cmd, env = cls._build_worker_invocation(
@@ -943,7 +1057,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(
@@ -977,8 +1091,34 @@ class TaskQueueService:
f"[QueueWorker] Could not persist failure status for [{sess_id}]" f"[QueueWorker] Could not persist failure status for [{sess_id}]"
) )
finally: finally:
try:
await cls._finish_output_forwarder(output_task) await cls._finish_output_forwarder(output_task)
# 5. Clean up the finished task and release this run's scheduling slot if sess_id:
from artemis.exploration.store import explorations
from artemis.runtime.task_result import read_worker_outcome
exploration_run = explorations.run(str(sess_id))
if exploration_run and exploration_run.get("outcome") is None:
from artemis.runtime.case_result import read_case_result
outcome = read_worker_outcome(str(sess_id)) or {}
session = session_repo.get_session_by_id(str(sess_id)) or {}
status = (
outcome.get("task_status") or session.get("status") or "interrupted"
)
explorations.finish_run(
str(sess_id),
{
**outcome,
"status": status,
"test_result": outcome.get("test_result")
or read_case_result(str(sess_id), status),
},
)
except Exception:
logger.exception("Cannot finalize exploration metadata for %s", sess_id)
finally:
# Metadata/storage failure must never leak a scheduling slot.
cls._release_run_slot(sess_id, run_key, proc) cls._release_run_slot(sess_id, run_key, proc)
@classmethod @classmethod
@@ -1133,6 +1273,7 @@ class TaskQueueService:
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, 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.
@@ -1140,6 +1281,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
) )
@@ -1156,11 +1303,40 @@ class TaskQueueService:
if duplicate_response is not None: if duplicate_response is not None:
return duplicate_response return duplicate_response
rejection_response = await cls._reject_unavailable_device(device_serial) execution_target = (runtime_overrides or {}).get("execution_target") or "android"
from pydantic import TypeAdapter
from artemis.plugins.models import PluginSelection
from artemis.plugins.store import PluginStore
choices = (runtime_overrides or {}).get("plugins")
try:
pinned = PluginStore().pin(
TypeAdapter(list[PluginSelection]).validate_python(choices)
if choices is not None
else None,
execution_target,
)
except ValueError:
return {
"status": "error",
"message": "插件未就绪、未授权或不支持所选任务目标,请检查插件配置",
}
runtime_overrides = {
**(runtime_overrides or {}),
"execution_target": execution_target,
"plugins": [p.model_dump() for p in pinned],
}
rejection_response = (
await cls._reject_unavailable_device(device_serial)
if execution_target != "computer"
else None
)
if rejection_response is not None: if rejection_response is not None:
return rejection_response return rejection_response
single_session_id = session_id if (session_id and len(goals) == 1) else None single_session_id = session_id if (session_id and len(goals) == 1) else None
if execution_target == "computer":
device_serial = "computer:local"
if not device_serial: if not device_serial:
# Device enumeration may block on ADB. # Device enumeration may block on ADB.
from artemis.runtime import device_pool from artemis.runtime import device_pool
@@ -1169,6 +1345,21 @@ class TaskQueueService:
device_serial = await device_pool.select_device_async() device_serial = await device_pool.select_device_async()
except Exception: except Exception:
device_serial = None device_serial = None
from apps.admin_console.services.execution_config import execution_config
config_snapshot = execution_config(
{
"profile": profile,
"device_serial": device_serial,
"expected_output": expected_output,
"enable_outputter": enable_outputter,
"verification_level": verification_level,
"explorer_mode": explorer_mode,
"locked_app_package": locked_app_package,
"app_path": app_path,
"runtime_overrides": runtime_overrides,
}
)
for i, goal in enumerate(goals): for i, goal in enumerate(goals):
task_item = cls._create_queue_item( task_item = cls._create_queue_item(
goal, goal,
@@ -1189,6 +1380,22 @@ class TaskQueueService:
) )
if runtime_overrides: if runtime_overrides:
task_item["runtime_overrides"] = runtime_overrides task_item["runtime_overrides"] = runtime_overrides
task_item["execution_config"] = config_snapshot
if pinned:
try:
task_item["plugin_resources"] = PluginStore().reserve(
task_item["session_id"], pinned, execution_target
)
except (ValueError, OSError):
DeviceExecutionLock.cancel_reservation(task_item.get("queue_ticket"))
for previous in enqueued_tasks:
cls._remove_task(previous["session_id"])
return {
"status": "error",
"message": "插件配置在入队前发生变化,请检查后重新提交",
}
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(
@@ -1297,6 +1504,7 @@ class TaskQueueService:
"""Terminate all active device owners and clear pending queue submissions.""" """Terminate all active device owners and clear pending queue submissions."""
from apps.admin_console.services.automation_store import store from apps.admin_console.services.automation_store import store
from apps.admin_console.services.automation_service import on_terminal, spawn from apps.admin_console.services.automation_service import on_terminal, spawn
from artemis.plugins.store import PluginStore
for batch in store.records("batch"): for batch in store.records("batch"):
if batch["status"] == "running": if batch["status"] == "running":
@@ -1306,6 +1514,7 @@ class TaskQueueService:
for item in state.queue_items: for item in state.queue_items:
if isinstance(item, dict) and item.get("status") != "running": if isinstance(item, dict) and item.get("status") != "running":
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket")) DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
PluginStore().release(str(item.get("session_id")))
if store.job(str(item.get("session_id"))): if store.job(str(item.get("session_id"))):
spawn(on_terminal(item, "cancelled")) spawn(on_terminal(item, "cancelled"))
state.clear_queue() state.clear_queue()
@@ -1629,7 +1838,9 @@ class TaskQueueService:
if stopped_item and stopped_item.get("status") != "running": if stopped_item and stopped_item.get("status") != "running":
from apps.admin_console.services.automation_store import store from apps.admin_console.services.automation_store import store
from apps.admin_console.services.automation_service import on_terminal, spawn from apps.admin_console.services.automation_service import on_terminal, spawn
from artemis.plugins.store import PluginStore
PluginStore().release(str(stopped_session_id))
if store.job(str(stopped_session_id)): if store.job(str(stopped_session_id)):
spawn(on_terminal(stopped_item, "cancelled")) spawn(on_terminal(stopped_item, "cancelled"))
if stopped_item and not reservation_cancelled: if stopped_item and not reservation_cancelled:
@@ -1743,6 +1954,14 @@ class TaskQueueService:
recordings published. recordings published.
""" """
recovered = 0 recovered = 0
from artemis.config import TRACES_PATH
from artemis.plugins.recording import recover_interrupted_recordings
# This hook runs in the startup worker thread, outside the server event loop.
for manifest in TRACES_PATH.glob("*/evidence/recordings/recordings.json"):
root = manifest.parent.parent.parent
if asyncio.run(recover_interrupted_recordings(root)):
recovered += 1
try: try:
pending = session_repo.get_unfinalized_video_recordings() pending = session_repo.get_unfinalized_video_recordings()
except Exception as exc: except Exception as exc:
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.1.2", "version": "2.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.1.2", "version": "2.0.0",
"dependencies": { "dependencies": {
"@angular/cdk": "^22.1.5", "@angular/cdk": "^22.1.5",
"@angular/common": "^22.1.4", "@angular/common": "^22.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.1.2", "version": "2.0.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
+1
View File
@@ -22,6 +22,7 @@ import { LegacyWorkspaceComponent } from './pages/legacy-workspace/legacy-worksp
export const routes: Routes = [ export const routes: Routes = [
{ path: '', component: HomeComponent }, { path: '', component: HomeComponent },
{ path: 'workspace', component: WorkspaceComponent }, { path: 'workspace', component: WorkspaceComponent },
{ path: 'plugins', loadComponent: () => import('./pages/plugins/plugins.component').then(m => m.PluginsComponent) },
{ path: 'check', component: LegacyWorkspaceComponent }, { path: 'check', component: LegacyWorkspaceComponent },
{ path: '**', redirectTo: '' } { path: '**', redirectTo: '' }
]; ];
@@ -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,8 @@ 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 />
@if (deleteError()) { <p class="item-error" role="alert">{{ deleteError() }}</p> }
<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,15 +78,19 @@ 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">
<div class="task-status-group">
<span class="task-badge" [ngClass]="getTaskStatus(session)"> <span class="task-badge" [ngClass]="getTaskStatus(session)">
@if (getTaskStatus(session) === 'running') { @if (getTaskStatus(session) === 'running') {
<span class="status-dot"></span> <span class="status-dot"></span>
} }
{{ getTaskStatus(session) | uiLabel }} {{ getTaskStatus(session) | uiLabel }}
</span> </span>
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
</div>
<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 +102,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 (!agentService.canDeleteSession(session) && ['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
<button <button
type="button" type="button"
class="btn-stop-card" class="btn-stop-card"
@@ -107,7 +113,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 (agentService.canDeleteSession(session)) {
<button <button
type="button" type="button"
class="btn-delete-card" class="btn-delete-card"
@@ -120,12 +126,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">
<app-task-id-copy [taskId]="session.session_id" /> <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>
} }
@@ -174,6 +183,9 @@ limitations under the License.
<!-- Rich Notes Content Viewer --> <!-- Rich Notes Content Viewer -->
@if (noteKeys().length > 0) { @if (noteKeys().length > 0) {
<div class="dropdown-notes-content"> <div class="dropdown-notes-content">
@if (agentService.selectedNoteKey() !== 'task_plan.md') {
<app-report-markdown [content]="currentNoteContent()" />
} @else {
@if (parsedNote().title) { @if (parsedNote().title) {
<h3 class="note-title">{{ parsedNote().title }}</h3> <h3 class="note-title">{{ parsedNote().title }}</h3>
} }
@@ -324,6 +336,7 @@ limitations under the License.
} }
</div> </div>
} }
}
</div> </div>
} }
</div> </div>
@@ -393,7 +406,7 @@ limitations under the License.
@if (runInfoContextPercent() !== null) { @if (runInfoContextPercent() !== null) {
<span class="run-info-value"> <span class="run-info-value">
{{ runInfoContextPercent() }}% {{ runInfoContextPercent() }}%
<span class="run-info-sub-inline">/ {{ formatRunTokens(runUsage()?.operator_context_window_tokens) }}</span> <span class="run-info-sub-inline">/ {{ formatRunTokens(runInfoContextWindow()) }}</span>
</span> </span>
} @else { } @else {
<span class="run-info-value is-muted">—</span> <span class="run-info-value is-muted">—</span>
@@ -417,7 +430,9 @@ limitations under the License.
<span class="run-info-label">屏幕识别</span> <span class="run-info-label">屏幕识别</span>
<span class="run-info-value">{{ runTuningLabel('explore', runInfoTuning()?.explorer_mode) }}</span> <span class="run-info-value">{{ runTuningLabel('explore', runInfoTuning()?.explorer_mode) }}</span>
</div> </div>
} @else { } @else if (runInfoMode() === 'explore') {
<div class="run-info-note">探索支持对话指导与人工示范,模型和时限使用独立的探索设置。</div>
} @else if (runInfoMode() === 'flash') {
<div class="run-info-note">Flash 无需额外设置。</div> <div class="run-info-note">Flash 无需额外设置。</div>
} }
</div> </div>
@@ -498,6 +513,8 @@ limitations under the License.
<!-- Event Logs Section --> <!-- Event Logs Section -->
<main class="stream-logs-content"> <main class="stream-logs-content">
<ng-content select="[task-summary]"></ng-content>
<app-llm-request-status [sessionId]="agentService.currentSessionId()" [running]="agentService.isCurrentSessionRunning()" (activeChange)="hasModelRequest.set($event)" />
@if (agentService.isSessionContentLoading()) { @if (agentService.isSessionContentLoading()) {
<div class="session-loading-state" aria-live="polite"> <div class="session-loading-state" aria-live="polite">
<span class="material-symbols-outlined spin-animation">progress_activity</span> <span class="material-symbols-outlined spin-animation">progress_activity</span>
@@ -505,7 +522,7 @@ limitations under the License.
</div> </div>
} }
@if (!agentService.isSessionContentLoading() && consolidatedBlocks().length === 0 && !showPlanningLoader() && startupWorkItems().length === 0) { @if (!agentService.isSessionContentLoading() && phases().length === 0 && !showPlanningLoader() && !hasModelRequest() && startupWorkItems().length === 0) {
<div class="empty-state"> <div class="empty-state">
<div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div> <div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div>
<h3>尚未选择执行记录</h3> <h3>尚未选择执行记录</h3>
@@ -544,8 +561,21 @@ limitations under the License.
</div> </div>
} }
@for (phase of phases(); track trackPhase($index, phase); let first = $first) { @for (phase of phases(); track trackPhase($index, phase); let first = $first) {
@if(phase.demonstration; as demo) {
<article class="exploration-message demonstration"><strong>人工示范 · {{demo.source === 'android' ? '安卓' : demo.source === 'computer' ? '电脑' : '安卓 + 电脑'}} · {{demoLabel(demo.status)}}</strong>
@if(demo.analysis){<div class="markdown-content" [innerHTML]="renderMarkdown(demo.analysis.summary, phase.id)"></div>}
@if(demo.error){<p class="error">{{demo.error}}</p>}
@for(track of demo.media; track $index){@for(segment of track.segments; track segment.file){<details><summary>{{track.kind === 'android' ? '安卓' : '电脑'}}示范录像 · 片段 {{$index + 1}}</summary><video controls preload="none" [src]="demoMediaUrl(demo.run_id,segment.file)"></video><a [href]="demoMediaUrl(demo.run_id,segment.file)" download>下载录像</a></details>}}
</article>
} @else if(phase.conversation; as message) {
<article class="exploration-message" [class.user]="message.role === 'user'" [class.assistant]="message.role === 'assistant'" [class.question]="pendingQuestion() === message.id" [class.system]="message.role === 'system'">
<small><span class="material-symbols-outlined">{{message.role === 'user' ? 'person' : message.role === 'assistant' ? 'auto_awesome' : 'info'}}</span>{{message.role === 'user' ? '你的指导' : message.role === 'assistant' ? '探索助手' : '探索记录'}} · {{message.created_at * 1000 | date:'HH:mm:ss'}} @if(pendingQuestion() === message.id){<b>等待你回答</b>}</small>
<div class="markdown-content" [innerHTML]="renderMarkdown(message.text, phase.id)"></div>
@if(message.recommendation){<div class="recommendation">建议:{{message.recommendation}} @if(pendingQuestion() === message.id){<button (click)="useRecommendation.emit(message.recommendation)">采用建议</button>}</div>}
</article>
} @else {
<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"
@@ -580,6 +610,9 @@ limitations under the License.
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>
@@ -602,6 +635,7 @@ limitations under the License.
} }
</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) {
<div class="step-actions-container"> <div class="step-actions-container">
@@ -741,10 +775,8 @@ limitations under the License.
<!-- Compact preview body (when collapsed, matches standard action card) --> <!-- Compact preview body (when collapsed, matches standard action card) -->
@if (!isActionExpanded('action-' + block.id + '-' + itemIdx) && getReportStatusExplanation(item.data)) { @if (!isActionExpanded('action-' + block.id + '-' + itemIdx) && getReportStatusExplanation(item.data)) {
<div class="action-card-body"> <div class="action-card-body">
<div class="action-detail-row"> <app-report-markdown [content]="getReportStatusExplanation(item.data)" [compact]="true" />
<span class="detail-label">摘要:</span> <button type="button" class="report-expand-button" (click)="toggleActionExpanded('action-' + block.id + '-' + itemIdx, $event)">查看完整报告</button>
<span class="detail-value">"{{ getReportStatusExplanation(item.data) }}"</span>
</div>
</div> </div>
} }
<!-- Detailed expanded view (when clicked, matches standard action card structure) --> <!-- Detailed expanded view (when clicked, matches standard action card structure) -->
@@ -754,7 +786,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>
<div class="grid-value report-markdown-body" [innerHTML]="renderMarkdown(expText, 'report-' + block.id + '-' + itemIdx)"></div> <app-report-markdown [content]="expText" />
</div> </div>
} }
</div> </div>
@@ -797,6 +829,7 @@ limitations under the License.
<div class="action-card-header" (click)="toggleActionExpanded('action-' + block.id + '-' + itemIdx, $event)"> <div class="action-card-header" (click)="toggleActionExpanded('action-' + block.id + '-' + itemIdx, $event)">
<span class="material-symbols-outlined action-icon" [class.is-failed]="isActionFailed(item.data, block.data)">{{ getActionIcon(item.data) }}</span> <span class="material-symbols-outlined action-icon" [class.is-failed]="isActionFailed(item.data, block.data)">{{ getActionIcon(item.data) }}</span>
<span class="action-title">{{ getActionTitle(item.data) }}</span> <span class="action-title">{{ getActionTitle(item.data) }}</span>
<button class="evidence-source" [title]="evidenceSourceLabel(item.data) + ' · 查看对应录像'" (click)="openEvidenceVideo(item.data, block.data, $event)">{{ evidenceSourceLabel(item.data) }}</button>
@if (isActionFailed(item.data, block.data)) { @if (isActionFailed(item.data, block.data)) {
<span class="action-status-badge is-failed"> <span class="action-status-badge is-failed">
<span class="material-symbols-outlined status-icon">error</span> <span class="material-symbols-outlined status-icon">error</span>
@@ -1038,7 +1071,7 @@ limitations under the License.
</div> </div>
} }
<!-- Generic Tool Calls (Text format for note tools, video analyzer, and other sub-agents) --> <!-- Generic Tool Calls (Text format for note tools, video analyzer, and other sub-agents) -->
@if (shouldShowTool(item.data, block.data) && !isDeviceActionTool(item.data)) { @if (shouldShowTool(item.data, block.data) && !isEvidenceTool(item.data)) {
<div class="step-generic-tool" <div class="step-generic-tool"
[class.is-running]="isToolLineRunning(item, block)" [class.is-running]="isToolLineRunning(item, block)"
[class.is-note-tool]="isNoteTool(item.data)" [class.is-note-tool]="isNoteTool(item.data)"
@@ -1079,9 +1112,11 @@ limitations under the License.
</div> </div>
</div> </div>
} }
<!-- Action Card format (For Device Actions & ADB Commands) --> <!-- Device actions and plugin calls share evidence cards with explicit sources. -->
@if (shouldShowTool(item.data, block.data) && isDeviceActionTool(item.data)) { @if (shouldShowTool(item.data, block.data) && isEvidenceTool(item.data)) {
<div class="step-android-action-card tool-action-card" <div class="step-android-action-card tool-action-card"
[class.plugin-tool-card]="getPluginToolView(item.data)"
[class.is-auxiliary]="getPluginToolView(item.data)?.auxiliary"
[class.is-running]="isItemRunning(item, block)" [class.is-running]="isItemRunning(item, block)"
[class.has-no-details]="isAdbCommandTool(item.data) ? !getAdbCommandLine(item.data) : (!getToolTargetText(item.data) && !getToolInputText(item.data) && !getToolCoords(item.data) && !getToolAnalysisText(item.data) && !getToolGenericDetails(item.data) && getToolExtraParams(item.data).length === 0)" [class.has-no-details]="isAdbCommandTool(item.data) ? !getAdbCommandLine(item.data) : (!getToolTargetText(item.data) && !getToolInputText(item.data) && !getToolCoords(item.data) && !getToolAnalysisText(item.data) && !getToolGenericDetails(item.data) && getToolExtraParams(item.data).length === 0)"
[class.is-failed]="isToolFailed(item.data)" [class.is-failed]="isToolFailed(item.data)"
@@ -1090,6 +1125,10 @@ limitations under the License.
<div class="action-card-header" (click)="toggleActionExpanded('tool-' + block.id + '-' + itemIdx, $event)"> <div class="action-card-header" (click)="toggleActionExpanded('tool-' + block.id + '-' + itemIdx, $event)">
<span class="material-symbols-outlined action-icon" [class.is-failed]="isToolFailed(item.data)">{{ getToolIcon(item.data) }}</span> <span class="material-symbols-outlined action-icon" [class.is-failed]="isToolFailed(item.data)">{{ getToolIcon(item.data) }}</span>
<span class="action-title">{{ getToolTitle(item.data) }}</span> <span class="action-title">{{ getToolTitle(item.data) }}</span>
<button class="evidence-source" [title]="evidenceSourceLabel(item.data) + ' · 查看对应证据'" (click)="openEvidenceVideo(item.data, block.data, $event)">{{ evidenceSourceLabel(item.data) }}</button>
@if (getPluginToolView(item.data); as plugin) {
<span class="plugin-call-kind">{{ plugin.kind }} · {{ plugin.status }}</span>
}
@if (isToolFailed(item.data)) { @if (isToolFailed(item.data)) {
<span class="action-status-badge is-failed"> <span class="action-status-badge is-failed">
<span class="material-symbols-outlined status-icon">error</span> <span class="material-symbols-outlined status-icon">error</span>
@@ -1102,7 +1141,7 @@ limitations under the License.
</span> </span>
</div> </div>
<!-- Compact preview body (when collapsed) --> <!-- Compact preview body (when collapsed) -->
@if (!isActionExpanded('tool-' + block.id + '-' + itemIdx) && (isAdbCommandTool(item.data) ? getAdbCommandLine(item.data) : (getToolTargetText(item.data) || getToolInputText(item.data) || getToolCoords(item.data) || getToolAnalysisText(item.data) || getToolGenericDetails(item.data)))) { @if (!getPluginToolView(item.data) && !isActionExpanded('tool-' + block.id + '-' + itemIdx) && (isAdbCommandTool(item.data) ? getAdbCommandLine(item.data) : (getToolTargetText(item.data) || getToolInputText(item.data) || getToolCoords(item.data) || getToolAnalysisText(item.data) || getToolGenericDetails(item.data)))) {
<div class="action-card-body"> <div class="action-card-body">
<!-- ADB command details --> <!-- ADB command details -->
@if (isAdbCommandTool(item.data)) { @if (isAdbCommandTool(item.data)) {
@@ -1184,7 +1223,21 @@ limitations under the License.
</div> </div>
} }
} }
@if (!isAdbCommandTool(item.data)) { @if (getPluginToolView(item.data); as plugin) {
@for (param of plugin.args; track param.key) {
<div class="grid-item">
<span class="grid-label">{{ param.key }}</span>
<span class="grid-value code-pill">{{ param.value }}</span>
</div>
}
@if (plugin.result) {
<div class="grid-item full-width">
<span class="grid-label">实际结果</span>
<span class="grid-value plugin-result">{{ plugin.result }}</span>
</div>
}
}
@if (!isAdbCommandTool(item.data) && !getPluginToolView(item.data)) {
@if (getToolTargetText(item.data)) { @if (getToolTargetText(item.data)) {
<div class="grid-item"> <div class="grid-item">
<span class="grid-label">目标</span> <span class="grid-label">目标</span>
@@ -1239,7 +1292,7 @@ limitations under the License.
} }
@if (getStepPostImageUrl(block.data, item.data)) { @if (getStepPostImageUrl(block.data, item.data)) {
<div class="screenshot-column"> <div class="screenshot-column">
<span class="screenshot-label">操作后</span> <span class="screenshot-label">{{ getPluginToolView(item.data)?.auxiliary ? '观察截图' : '操作后' }}</span>
<div class="image-wrapper" (click)="openImageModal(getStepPostImageUrl(block.data, item.data)!, null, $event)"> <div class="image-wrapper" (click)="openImageModal(getStepPostImageUrl(block.data, item.data)!, null, $event)">
<div class="img-relative-box"> <div class="img-relative-box">
<img [src]="getStepPostImageUrl(block.data, item.data)" alt="操作后截图" /> <img [src]="getStepPostImageUrl(block.data, item.data)" alt="操作后截图" />
@@ -1264,7 +1317,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">
@@ -1291,7 +1344,8 @@ limitations under the License.
} }
</div> </div>
} }
@if (awaitingGuidance().length) { }
@if (!conversationMessages().length && awaitingGuidance().length) {
<div class="guidance-queue" aria-label="待处理用户指导"> <div class="guidance-queue" aria-label="待处理用户指导">
@for (item of awaitingGuidance(); track item.id) { @for (item of awaitingGuidance(); track item.id) {
<div class="guidance-queue-item"> <div class="guidance-queue-item">
@@ -1324,62 +1378,7 @@ limitations under the License.
</div> </div>
</div> </div>
<div class="synthesis-card-preview"> <div class="synthesis-card-preview">
@if (parsedOutputReport(); as parsedOut) { <app-report-markdown [content]="outReport" />
<div class="parsed-note-container">
@if (parsedOut.title) {
<h3 class="parsed-note-title">{{ parsedOut.title }}</h3>
}
@if (parsedOut.milestones.length > 0) {
<div class="parsed-milestones">
@for (m of parsedOut.milestones; track trackMilestone($index, m)) {
<div class="milestone-item" [class]="'status-' + m.type">
<span class="material-symbols-outlined status-icon">
{{ m.type === 'checked' ? 'check_circle' : (m.type === 'progress' ? 'pending' : 'radio_button_unchecked') }}
</span>
<span class="milestone-text">
@for (seg of m.segments; track seg) {
@if (seg.bold) {
<strong>{{ seg.text }}</strong>
}
@if (seg.code) {
<code>{{ seg.text }}</code>
}
@if (!seg.bold && !seg.code) {
<span>{{ seg.text }}</span>
}
}
</span>
</div>
}
</div>
}
@if (parsedOut.otherLines.length > 0) {
<div class="parsed-other-lines">
@for (line of parsedOut.otherLines; track trackMarkdownLine($index, line)) {
<p class="other-line" [class]="'line-' + line.type">
@if (line.type === 'verify') {
<span class="verify-badge" [class.assert]="line.checkKind === 'assert'">{{ line.checkKind || 'verify' }}{{ line.atEnd ? '@end' : '' }}</span>
}
@if (line.type === 'finding') {
<span class="finding-badge">finding</span>
}
@for (seg of line.segments; track seg) {
@if (seg.bold) {
<strong>{{ seg.text }}</strong>
}
@if (seg.code) {
<code>{{ seg.text }}</code>
}
@if (!seg.bold && !seg.code) {
<span>{{ seg.text }}</span>
}
}
</p>
}
</div>
}
</div>
}
</div> </div>
</div> </div>
} }
@@ -1394,6 +1393,7 @@ limitations under the License.
} }
</div> </div>
} }
<ng-content select="[task-result]"></ng-content>
</main> </main>
<!-- Fullscreen Image Zoom Lightbox Modal --> <!-- Fullscreen Image Zoom Lightbox Modal -->
@@ -3684,21 +3684,33 @@ $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;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-start;
flex-wrap: wrap;
gap: 8px; gap: 8px;
min-width: 0; min-width: 0;
.task-status-group { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; }
.task-badge { .task-badge {
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;
@@ -3729,12 +3741,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 {
@@ -3746,21 +3770,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;
@@ -3768,6 +3793,7 @@ $stream-row-height: 25px;
} }
.item-header-actions { .item-header-actions {
margin-left: auto;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
@@ -4008,6 +4034,19 @@ $stream-row-height: 25px;
p { margin: 6px 0 10px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; } 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; } button { border: 0; background: transparent; padding: 0; color: #be123c; font: inherit; font-weight: 600; cursor: pointer; }
} }
.report-expand-button {
align-self: flex-start;
border: 1px solid #cbd5e1;
border-radius: 6px;
padding: 4px 10px;
background: #fff;
color: #334155;
font: inherit;
font-size: 12px;
cursor: pointer;
&:hover { background: #f1f5f9; }
&:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
}
:host ::ng-deep .report-markdown-body { :host ::ng-deep .report-markdown-body {
display: block; display: block;
width: 100%; width: 100%;
@@ -4027,3 +4066,26 @@ $stream-row-height: 25px;
th { background: #dbeafe; } th { background: #dbeafe; }
.md-para-gap { height: 8px; } .md-para-gap { height: 8px; }
} }
.evidence-source { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 2px 7px; border: 1px solid #cbd5e1; border-radius: 6px; background: #f1f5f9; color: #475569; font-size: 11px; cursor: pointer; }
.plugin-tool-card {
.action-card-header { flex-wrap: wrap; }
.plugin-call-kind { font-size: 10px; color: #64748b; }
.plugin-result { white-space: pre-wrap; overflow-wrap: anywhere; }
&.is-auxiliary:not(.is-expanded):not(.is-failed):not(.is-running) {
background: #f8fafc;
border-color: #e2e8f0;
box-shadow: none;
}
}
.stream-logs-content {padding-bottom:24px}
.exploration-message {padding:12px 16px;border:1px solid #e2e8f0;border-radius:12px;margin:14px 0;background:#fff;color:#334155;font-size:13px;line-height:1.7}
.exploration-message.user {background:#eff6ff;border-color:#dbeafe;margin-left:28px}.exploration-message.system {color:#64748b;background:#f8fafc;font-size:12px}
.exploration-message small {color:#64748b;font-size:11px}.exploration-message p {white-space:pre-wrap;overflow-wrap:anywhere;margin:6px 0}.exploration-message button {font:inherit;padding:4px 10px;border:1px solid #cbd5e1;border-radius:6px;background:#fff;color:#334155;cursor:pointer}
.exploration-message video {width:100%;max-height:300px;background:#0f172a}.exploration-message summary {cursor:pointer}.exploration-message .error {color:#b91c1c}
.exploration-message.assistant {border-left:3px solid #818cf8;background:#fafaff}
.exploration-message.question {border:1px solid #f4c97a;border-left:3px solid #f59e0b;background:#fffbeb}
.exploration-message small {display:flex;align-items:center;gap:6px;font-size:11px;margin-bottom:8px}.exploration-message small .material-symbols-outlined {font-size:16px;color:#6366f1}.exploration-message small b {margin-left:auto;color:#92400e;background:#fef3c7;border-radius:12px;padding:2px 8px}
@@ -1,5 +1,9 @@
import { ExplorationMessage, Demonstration } from '../../services/exploration.service';
import { mergeConversation } from '../../utils/exploration-timeline.util';
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component'; 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 { LlmRequestStatusComponent } from '../llm-request-status/llm-request-status.component';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.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';
/** /**
@@ -18,18 +22,23 @@ import { UiLabelPipe } from '../../utils/ui-label.pipe';
* limitations under the License. * limitations under the License.
*/ */
import { Component, ChangeDetectionStrategy, NgZone, signal, computed, effect, inject, untracked, DestroyRef, AfterViewInit } from '@angular/core'; import { Component, ChangeDetectionStrategy, NgZone, signal, computed, effect, inject, untracked, DestroyRef, AfterViewInit, input, output } from '@angular/core';
import { CommonModule } from '@angular/common'; 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, GuidanceEvent, 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';
import { import {
contextPercent, contextPercent,
contextWindow,
runMode,
formatCompactTokens, formatCompactTokens,
formatElapsed, formatElapsed,
parseRunTuning, parseRunTuning,
@@ -159,6 +168,7 @@ import {
extractCheckerResult, extractCheckerResult,
renderMarkdownToHtml renderMarkdownToHtml
} from '../../utils/markdown-parser.util'; } from '../../utils/markdown-parser.util';
import { getPluginToolView } from '../../utils/plugin-tool-view.util';
import { import {
getActionObject, getActionObject,
@@ -179,7 +189,8 @@ import {
getStepPostImageUrl, getStepPostImageUrl,
isReportStatusAction, isReportStatusAction,
getReportStatusValue, getReportStatusValue,
getReportStatusExplanation getReportStatusExplanation,
actionEvidenceSource
} from '../../utils/action-formatter.util'; } from '../../utils/action-formatter.util';
import { import {
@@ -237,12 +248,19 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
@Component({ @Component({
selector: 'app-agent-stream', selector: 'app-agent-stream',
standalone: true, standalone: true,
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent], imports: [ReportMarkdownComponent, LlmRequestStatusComponent, 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
}) })
export class AgentStreamComponent implements AfterViewInit { export class AgentStreamComponent implements AfterViewInit {
conversationMessages = input<ExplorationMessage[]>([]);
demonstrations = input<Demonstration[]>([]);
pendingQuestion = input('');
demoMediaUrl(runId: string, path: string) { return '/videos/' + [runId, ...path.split('/')].map(encodeURIComponent).join('/'); }
demoLabel(status: string) { return ({requested:'准备录制',recording:'录制中',analyzing:'分析中',completed:'已分析',failed:'分析失败',cancelled:'已取消',timed_out:'到时已保存'} as Record<string,string>)[status] || status; }
useRecommendation = output<string>();
public agentService = inject(AgentService); public agentService = inject(AgentService);
private http = inject(HttpClient); private http = inject(HttpClient);
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
@@ -276,6 +294,7 @@ export class AgentStreamComponent implements AfterViewInit {
public isTaskDropdownOpen = signal<boolean>(false); public isTaskDropdownOpen = signal<boolean>(false);
public isNotesDropdownOpen = signal<boolean>(false); public isNotesDropdownOpen = signal<boolean>(false);
public isAutomationOpen = signal(false); public isAutomationOpen = signal(false);
public deleteError = signal('');
// Run info popover (opened from the Flash / Pro chip): elapsed time, token // Run info popover (opened from the Flash / Pro chip): elapsed time, token
// usage, executor context share and the Pro tuning the run started with. // usage, executor context share and the Pro tuning the run started with.
@@ -283,7 +302,9 @@ export class AgentStreamComponent implements AfterViewInit {
public runUsage = signal<SessionUsage | null>(null); public runUsage = signal<SessionUsage | null>(null);
private runInfoClock = signal<number>(Date.now()); private runInfoClock = signal<number>(Date.now());
public runInfoIsPro = computed(() => this.getModelClass(this.agentService.viewedModel()?.name) === 'is-pro'); public runInfoMode = computed(() => runMode(this.agentService.currentSession(), this.agentService.viewedModel()));
public runInfoIsPro = computed(() => this.runInfoMode() === 'pro');
public runInfoContextWindow = computed(() => contextWindow(this.runUsage()?.operator_context_window_tokens));
public runInfoElapsed = computed(() => { public runInfoElapsed = computed(() => {
const session = this.agentService.currentSession(); const session = this.agentService.currentSession();
@@ -378,6 +399,7 @@ export class AgentStreamComponent implements AfterViewInit {
public expandedActionCards = signal<Set<string>>(new Set<string>()); public expandedActionCards = signal<Set<string>>(new Set<string>());
public collapsedActionCards = signal<Set<string>>(new Set<string>()); public collapsedActionCards = signal<Set<string>>(new Set<string>());
public retryClock = signal<number>(Date.now()); public retryClock = signal<number>(Date.now());
public hasModelRequest = signal(false);
// Performance caches for parameter and event extractions // Performance caches for parameter and event extractions
private actionParamsCache = new WeakMap<any, ActionParam[]>(); private actionParamsCache = new WeakMap<any, ActionParam[]>();
@@ -388,13 +410,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) && this.agentService.isQueueRepresentative(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);
@@ -404,7 +426,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);
}); });
}); });
@@ -425,13 +447,6 @@ export class AgentStreamComponent implements AfterViewInit {
return notes['output.md'] || null; return notes['output.md'] || null;
}); });
// Memoized so the Task Report card is not re-parsed (and its DOM rebuilt)
// on every change-detection pass while streams are typing.
public parsedOutputReport = computed<ParsedNote | null>(() => {
const report = this.outputterReport();
return report ? parseNote(report) : null;
});
public parsedNote = computed<ParsedNote>(() => { public parsedNote = computed<ParsedNote>(() => {
return this.getParsedNote(this.currentNoteContent()); return this.getParsedNote(this.currentNoteContent());
}); });
@@ -452,6 +467,7 @@ export class AgentStreamComponent implements AfterViewInit {
}); });
public showPlanningLoader = computed(() => { public showPlanningLoader = computed(() => {
if (this.hasModelRequest()) return false;
const isRunning = this.agentService.agentStatus() === 'running'; const isRunning = this.agentService.agentStatus() === 'running';
const currentSessionId = this.agentService.currentSessionId(); const currentSessionId = this.agentService.currentSessionId();
const runningSessionId = this.agentService.runningSessionId(); const runningSessionId = this.agentService.runningSessionId();
@@ -491,14 +507,14 @@ export class AgentStreamComponent implements AfterViewInit {
const terminal = ['completed', 'failed', 'cancelled', 'interrupted'] const terminal = ['completed', 'failed', 'cancelled', 'interrupted']
.includes(this.agentService.currentSession()?.status || ''); .includes(this.agentService.currentSession()?.status || '');
if (event.status === 'pending') { if (event.status === 'pending') {
return terminal ? '任务结束前未进入模型' : '已接收,等待下一步'; return terminal ? '任务结束前未进入模型' : '已接收,正在打断当前模型步骤';
} }
return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录'; return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录';
} }
public phases = computed<PhaseBlock[]>(() => { public phases = computed(() => {
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 mergeConversation(groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0), this.conversationMessages(), this.demonstrations());
}); });
public startupWorkItems = computed(() => { public startupWorkItems = computed(() => {
@@ -563,6 +579,12 @@ export class AgentStreamComponent implements AfterViewInit {
} }
constructor() { constructor() {
const conversationRevision = computed(() => JSON.stringify([
this.conversationMessages().map(message => message.id),
this.demonstrations().map(demo => [demo.id,demo.status])
]));
effect(() => { conversationRevision(); untracked(() => this.scheduleAutoScroll(false)); });
effect((onCleanup) => { effect((onCleanup) => {
const id = this.agentService.currentSessionId(); const id = this.agentService.currentSessionId();
const status = this.selectedTerminalStatus(); const status = this.selectedTerminalStatus();
@@ -852,6 +874,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);
@@ -912,14 +940,17 @@ 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 {
const exploration = this.agentService.explorationQueueStatus(session);
if (exploration) return exploration as 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled';
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')) {
@@ -967,12 +998,13 @@ export class AgentStreamComponent implements AfterViewInit {
public deleteTask(sessionId: string, event?: Event): void { public deleteTask(sessionId: string, event?: Event): void {
if (event) event.stopPropagation(); if (event) event.stopPropagation();
if (!confirm("确定删除此任务?此操作无法撤销。")) { if (!confirm(this.agentService.deletionPrompt(sessionId))) {
return; return;
} }
this.deleteError.set('');
this.agentService.deleteSession(sessionId).subscribe({ this.agentService.deleteSession(sessionId).subscribe({
error: (err) => { error: (err) => {
console.error(`删除会话失败:${sessionId}:`, err); this.deleteError.set(err.error?.detail || "删除失败,请稍后重试");
} }
}); });
} }
@@ -1360,6 +1392,14 @@ 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 { public hasRecordedDeviceAction(action: any): boolean {
const actions = Array.isArray(action) ? action : action ? [action] : []; const actions = Array.isArray(action) ? action : action ? [action] : [];
return actions.some(item => this.isAndroidAction(item) && !this.isReportStatusAction(item)); return actions.some(item => this.isAndroidAction(item) && !this.isReportStatusAction(item));
@@ -1595,6 +1635,17 @@ export class AgentStreamComponent implements AfterViewInit {
} }
} }
public evidenceSourceLabel(item: any): string { return actionEvidenceSource(item).label; }
public openEvidenceVideo(item: any, step: any, event: Event): void {
event.stopPropagation();
const started = this.agentService.currentSession()?.start_time;
const value = item?.timestamp || item?.start_time || step?.timestamp;
const timestamp = typeof value === 'number' ? value : Date.parse(value) / 1000;
if (!started || !Number.isFinite(timestamp)) return;
this.agentService.openVideoPlayer(undefined, undefined, undefined, Math.max(0, timestamp - started), undefined, actionEvidenceSource(item).id);
}
public getScreenRecordingButtonTitle(): string { public getScreenRecordingButtonTitle(): string {
if (this.agentService.isCurrentSessionRunning()) { if (this.agentService.isCurrentSessionRunning()) {
return this.agentService.currentSession()?.recording_status === 'recording' ? "任务正在运行(录屏中)" : "任务正在运行"; return this.agentService.currentSession()?.recording_status === 'recording' ? "任务正在运行(录屏中)" : "任务正在运行";
@@ -1620,6 +1671,12 @@ export class AgentStreamComponent implements AfterViewInit {
return isDeviceActionTool(tool); return isDeviceActionTool(tool);
} }
public getPluginToolView = getPluginToolView;
public isEvidenceTool(tool: any): boolean {
return isDeviceActionTool(tool) || getPluginToolView(tool) !== null;
}
public isFailureAnalyzerActionTool(tool: any): boolean { public isFailureAnalyzerActionTool(tool: any): boolean {
return isDeviceActionTool(tool); return isDeviceActionTool(tool);
} }
@@ -1746,7 +1803,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}`);
@@ -1788,7 +1847,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';
@@ -1803,12 +1864,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)
@@ -2012,11 +2073,12 @@ export class AgentStreamComponent implements AfterViewInit {
} }
public getToolIcon(tool: any): string { public getToolIcon(tool: any): string {
return getToolIcon(tool); const plugin = getPluginToolView(tool);
return plugin ? (plugin.auxiliary ? 'visibility' : 'extension') : getToolIcon(tool);
} }
public getToolTitle(tool: any): string { public getToolTitle(tool: any): string {
return getToolTitle(tool); return getPluginToolView(tool)?.title || getToolTitle(tool);
} }
public getToolTargetText(tool: any): string { public getToolTargetText(tool: any): string {
@@ -2192,6 +2254,7 @@ export class AgentStreamComponent implements AfterViewInit {
public getModelIcon(name: string | undefined | null): string { public getModelIcon(name: string | undefined | null): string {
if (!name) return 'bolt'; if (!name) return 'bolt';
const lower = name.toLowerCase(); const lower = name.toLowerCase();
if (lower === '探索') return 'explore';
if (lower.includes('pro')) return 'diamond'; if (lower.includes('pro')) return 'diamond';
if (lower.includes('flash')) return 'bolt'; if (lower.includes('flash')) return 'bolt';
return 'smart_toy'; return 'smart_toy';
@@ -2207,6 +2270,7 @@ export class AgentStreamComponent implements AfterViewInit {
public getArchitectureTooltip(model?: ModelInfo | null): string { public getArchitectureTooltip(model?: ModelInfo | null): string {
if (!model) return "执行模式:ARTEMIS Flash(快速响应)"; if (!model) return "执行模式:ARTEMIS Flash(快速响应)";
if (model.architecture === 'explore') return `探索模式(可交流与人工示范) · LLM: ${model.id}`;
const name = this.getModelDisplayName(model.name); const name = this.getModelDisplayName(model.name);
const isPro = name.toLowerCase().includes('pro'); const isPro = name.toLowerCase().includes('pro');
const archDesc = isPro const archDesc = isPro
@@ -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>
@@ -13,22 +14,23 @@
<section><h3>当前任务报告</h3> <section><h3>当前任务报告</h3>
@if (report(); as r) { @if (report(); as r) {
<div class="report-overview"> <div class="report-overview">
<strong class="report-title">{{r.name}}</strong> <div class="report-heading"><app-favorite-button [sourceId]="r.id" /><strong class="report-title">{{r.name}}</strong></div>
<div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><span>{{r.verdict}}</span></div> <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> } @if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> }
</div> </div>
<div class="report-meta"><div><strong>任务目标</strong><p>{{r.goal || '未记录'}}</p></div><div><strong>预期结果</strong><p>{{r.expected || '未指定'}}</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> <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+'/screenshots'">步骤截图 ZIP</a>
<a [href]="'/api/automation/reports/'+r.id+'/video'">分段录屏 ZIP</a> <a [href]="'/api/automation/reports/'+r.id+'/video'">分段录屏 ZIP</a>
<a [href]="'/api/automation/reports/'+r.id+'/all'">下载全部资料</a></div> <a [href]="'/api/automation/reports/'+r.id+'/all'">下载全部资料</a></div>
<p class="muted">报告包含目标、预期、实际结论、步骤与截图证据。执行中的下载为当前快照。</p> <p class="muted">报告包含目标、预期、实际结论、步骤与截图证据。执行中的下载为当前快照。</p>
<div class="report-content"><h3>结论与发现</h3><div class="report-body" [innerHTML]="renderReport(r.content)"></div></div> <div class="report-content"><h3>结论与发现</h3><app-report-markdown class="report-body" [content]="r.content" /></div>
} @else { <p>从任务队列或历史记录中选择任务,即可下载报告。</p> } } @else { <p>从任务队列或历史记录中选择任务,即可下载报告。</p> }
@if (liveTask() && agent.currentSessionId()) { @if (liveTask() && agent.currentSessionId()) {
<div class="task-guidance"> <div class="task-guidance">
<h3>任务指导 · {{label(taskStatus())}}</h3> <h3>任务指导 · {{label(taskStatus())}}</h3>
<p>{{ taskStatus() === 'paused' ? '模型请求已暂停;提交指导后请在执行日志中恢复模型调用。' : '运行中提交将在下一步执行前生效;暂停后会释放设备,提交指导即可重新排队恢复。' }}</p> <p>{{ taskStatus() === 'paused' ? '模型请求已暂停;提交指导后请在执行日志中恢复模型调用。' : '运行中提交会立即打断模型思考并重新观察设备;正在执行的设备动作会先安全结束。' }}</p>
<label for="task-guidance-text">补充信息或调整方向</label> <label for="task-guidance-text">补充信息或调整方向</label>
<textarea id="task-guidance-text" [(ngModel)]="guidance" maxlength="10000" rows="3" placeholder="描述已观察到的情况和下一步方向"></textarea> <textarea id="task-guidance-text" [(ngModel)]="guidance" maxlength="10000" rows="3" placeholder="描述已观察到的情况和下一步方向"></textarea>
<div class="actions"> <div class="actions">
@@ -41,39 +43,70 @@
</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> <app-task-plugins (changed)="taskPlugins = $event" /><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>每行一个用例,仅「Artemis提示词」作为任务正文;「任务配置(JSON)」可设置每项模型、模式、超时和录屏。「前置约束提取」仅供查看与搜索。示例在模板的「填写说明」页。</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><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><td>{{t.task_config | json}}</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() || !name.trim() || !formats().length" (click)="startBatch()">确认导入并开始执行</button>
} }
</section> </section>
<section><h3>批次记录</h3> <section><h3>批次记录</h3>
@for(b of batches(); track b.id){ @for(b of batches(); track b.id){
<div class="batch-record"> <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'}}</span></button> <app-favorite-button sourceKind="batch" [sourceId]="b.id" />
<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> <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> </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>
<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"> <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); tab.set('reports')">{{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"><app-favorite-button [sourceId]="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,14 +18,30 @@ 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;} .batch-record{position:relative;display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record>app-favorite-button{position:absolute;left:9px;top:9px;z-index:1}.batch-record .record{padding-left:44px;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{margin-top:16px;padding:14px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;}
.task-guidance label{margin:8px 0 4px;} .task-guidance label{margin:8px 0 4px;}
.report-overview{padding:14px;border:1px solid #dbeafe;border-radius:10px;background:#f8fbff;} .report-overview{padding:14px;border:1px solid #dbeafe;border-radius:10px;background:#f8fbff;}
.report-heading{display:flex;align-items:flex-start;gap:8px;}.report-heading .report-title{flex:1;min-width:0;}
.report-title{display:block;font-size:14px;line-height:1.5;color:#0f172a;overflow-wrap:anywhere;} .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-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-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;}}
@@ -12,15 +12,20 @@ describe('AutomationPanelComponent batch deletion', () => {
currentSessionId: signal<string | null>(null), currentSessionId: signal<string | null>(null),
runningSessionId: signal<string | null>(null), runningSessionId: signal<string | null>(null),
agentStatus: signal('idle'), agentStatus: signal('idle'),
sessions: signal<any[]>([]) 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', 'delete']); const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'post', 'delete']);
beforeEach(async () => { beforeEach(async () => {
(http.get.and as any).callFake((url: string) => of(url.endsWith('/preferences') (http.get.and as any).callFake((url: string) => of(url.endsWith('/preferences')
? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : [])); ? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : []));
http.delete.and.returnValue(of({ status: 'deleted' })); 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.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({ await TestBed.configureTestingModule({
imports: [AutomationPanelComponent], imports: [AutomationPanelComponent],
providers: [ providers: [
@@ -31,6 +36,60 @@ describe('AutomationPanelComponent batch deletion', () => {
}).compileComponents(); }).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 () => { it('confirms deletion and removes the batch from the visible list', async () => {
const fixture = TestBed.createComponent(AutomationPanelComponent); const fixture = TestBed.createComponent(AutomationPanelComponent);
fixture.componentInstance.tab.set('batches'); fixture.componentInstance.tab.set('batches');
@@ -61,6 +120,28 @@ describe('AutomationPanelComponent batch deletion', () => {
expect((fixture.nativeElement.querySelector('.delete-batch') as HTMLButtonElement).disabled).toBeTrue(); 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', () => { it('shows the failure reason and renders Chinese report structure with line breaks', () => {
const fixture = TestBed.createComponent(AutomationPanelComponent); const fixture = TestBed.createComponent(AutomationPanelComponent);
fixture.detectChanges(); fixture.detectChanges();
@@ -77,4 +158,57 @@ describe('AutomationPanelComponent batch deletion', () => {
expect(root.querySelector('.report-body')?.textContent).toContain('第二行'); expect(root.querySelector('.report-body')?.textContent).toContain('第二行');
expect(root.querySelector('.report-body table td')?.textContent).toBe('设置'); 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();
});
}); });
@@ -1,21 +1,42 @@
import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core'; import { Component, DestroyRef, computed, effect, inject, output, signal } from '@angular/core';
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
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';
import { TaskPluginsComponent, TaskPluginOptions } from '../task-plugins/task-plugins.component';
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: [FavoriteButtonComponent, ReportMarkdownComponent, CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent],
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 {
taskPlugins: TaskPluginOptions = {};
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 = ''; guidance = '';
taskStatus = computed(() => { taskStatus = computed(() => {
const sid = this.agent.currentSessionId(); const sid = this.agent.currentSessionId();
@@ -25,21 +46,57 @@ export class AutomationPanelComponent {
}); });
liveTask = computed(() => ['running', 'paused', 'task_paused', 'resuming'].includes(this.taskStatus())); 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(() => {
this.agent.resultRevision();
const sid = this.agent.currentSessionId(); this.report.set(null); this.guidance = ''; 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:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',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('');
@@ -60,25 +117,45 @@ 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.device) throw Error('请选择执行设备'); if (!this.selectedCount()) throw Error('请至少勾选一个用例');
const cases = this.preview().tasks.map(({row, ...c}: any) => c); if (!this.validRepetitions()) throw Error(`循环次数必须为正整数,执行总数不能超过 ${this.maxExecutions}`);
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 cases = this.preview().tasks.filter((t: any) => this.selectedRows().has(t.row)).map(({row, ...c}: any) => ({...c, task_config: {...this.taskPlugins, ...Object.fromEntries(Object.entries(c.task_config || {}).filter(([,v]) => v !== null && v !== undefined))}}));
if (!this.device && cases.some((c: any) => c.task_config.execution_target !== 'computer' && !c.task_config.device_serial)) throw Error('请选择执行设备');
const created: any = await firstValueFrom(this.http.post('/api/automation/batches',{name:this.name.trim(),cases,repeat_count:this.repeatCount,device_serial:this.device || null,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 }) { deleteBatch(item: { id: string; name: string }) {
if (!window.confirm(`删除批次“${item.name}”?未执行任务会移除,已执行任务与报告仍保留。`)) return; if (!window.confirm(`删除批次“${item.name}”及其全部任务记录、报告和文件?此操作无法撤销。`)) return;
void this.action(async () => { void this.action(async () => {
await firstValueFrom(this.http.delete(`/api/automation/batches/${encodeURIComponent(item.id)}`)); await firstValueFrom(this.http.delete(`/api/automation/batches/${encodeURIComponent(item.id)}`));
if (this.batch()?.id === item.id) this.batch.set(null); if (this.batch()?.id === item.id) this.batch.set(null);
await this.refresh(); await this.refresh();
this.notice.set('批次记录已删除;已执行任务与报告仍可在历史中查看'); 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(); }); }
@@ -58,6 +58,8 @@ 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 />
@if (errorMessage()) { <p class="error-message" role="alert">{{ errorMessage() }}</p> }
<!-- 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 }})
@@ -76,12 +78,17 @@ limitations under the License.
(click)="selectTask(session.session_id)" (click)="selectTask(session.session_id)"
> >
<div class="task-card-header"> <div class="task-card-header">
<div class="task-status-group">
<span class="task-badge" [ngClass]="getTaskStatus(session)"> <span class="task-badge" [ngClass]="getTaskStatus(session)">
@if (getTaskStatus(session) === 'running') { @if (getTaskStatus(session) === 'running') {
<span class="status-dot"></span> <span class="status-dot"></span>
} }
{{ getTaskStatus(session) | uiLabel }} {{ agentService.explorationStatusLabel(session) || (getTaskStatus(session) | uiLabel) }}
</span> </span>
@if (session.exploration_kind !== 'exploration') { <app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" /> }
<app-task-mode-badge [session]="session" />
@if (session.exploration_kind === 'validation') { <span class="task-origin-badge">用例复跑 · v{{session.case_version}}</span> }
</div>
<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)">
@@ -93,7 +100,7 @@ limitations under the License.
<span class="task-time-divider">·</span> <span class="task-time-divider">·</span>
} }
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span> <span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
@if (getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused') { @if (!agentService.canDeleteSession(session) && (getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused')) {
<button <button
type="button" type="button"
class="btn-stop-card" class="btn-stop-card"
@@ -104,7 +111,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 (agentService.canDeleteSession(session)) {
<button <button
type="button" type="button"
class="btn-delete-card" class="btn-delete-card"
@@ -118,8 +125,11 @@ limitations under the License.
} }
</div> </div>
</div> </div>
<div class="task-title-row">
<app-favorite-button [sourceId]="session.session_id" />
<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>
<div class="task-card-footer"> <div class="task-card-footer">
<app-task-id-copy [taskId]="session.session_id" /> <app-task-id-copy [taskId]="session.session_id" />
@@ -329,18 +329,25 @@ $text-muted: #a1a1aa;
.task-card-header { .task-card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: flex-start;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
min-width: 0; min-width: 0;
.task-status-group { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; }
.task-origin-badge { display: inline-flex; align-items: center; box-sizing: border-box; height: 20px; padding: 0 6px; border: 1px solid #e2e8f0; border-radius: 5px; background: #f8fafc; color: #64748b; font-size: 11px; line-height: 14px; white-space: nowrap; }
.task-badge { .task-badge {
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;
@@ -410,6 +417,7 @@ $text-muted: #a1a1aa;
} }
.task-card-header-actions { .task-card-header-actions {
margin-left: auto;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
@@ -551,6 +559,16 @@ $text-muted: #a1a1aa;
} }
} }
.task-title-row {
display: flex;
align-items: flex-start;
gap: 5px;
min-width: 0;
app-favorite-button { margin-top: -5px; }
.task-goal { flex: 1; min-width: 0; }
}
.task-goal { .task-goal {
font-size: 12.5px; font-size: 12.5px;
font-weight: 500; font-weight: 500;
@@ -1,4 +1,5 @@
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component'; import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.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';
@@ -23,6 +24,9 @@ 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 { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-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';
@@ -31,7 +35,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
@Component({ @Component({
selector: 'app-chat-interface', selector: 'app-chat-interface',
standalone: true, standalone: true,
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent], imports: [FavoriteButtonComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent, TestResultBadgeComponent, TaskModeBadgeComponent, 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
@@ -54,7 +58,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) && this.agentService.isQueueRepresentative(s);
}); });
return list.sort((a, b) => { return list.sort((a, b) => {
const statusA = this.getTaskStatus(a); const statusA = this.getTaskStatus(a);
@@ -159,7 +163,7 @@ export class ChatInterfaceComponent {
*/ */
public deleteTask(sessionId: string, event: MouseEvent): void { public deleteTask(sessionId: string, event: MouseEvent): void {
event.stopPropagation(); event.stopPropagation();
if (!confirm("确定删除此任务?此操作无法撤销。")) { if (!confirm(this.agentService.deletionPrompt(sessionId))) {
return; return;
} }
this.isSubmitting.set(true); this.isSubmitting.set(true);
@@ -180,6 +184,9 @@ export class ChatInterfaceComponent {
* Determine the current task execution status * Determine the current task execution status
*/ */
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' { public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
const exploration = this.agentService.explorationQueueStatus(session);
if (exploration) return exploration as 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled';
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 (s === 'completed' || s === 'success' || s === 'failed' || s === 'cancelled') {
@@ -0,0 +1,57 @@
@if(session(); as s) {
<app-agent-stream [conversationMessages]="messages()" [demonstrations]="s.demonstrations" [pendingQuestion]="s.pending_question || ''" (useRecommendation)="text = $event; send()">
<section task-summary class="session-summary">
<div class="row"><strong><span class="material-symbols-outlined">explore</span> {{activeValidation() ? '用例复跑中' : label(s.status)}}</strong><small>{{s.config.model}} / {{s.config.reasoning_effort}} · 续接 {{s.continuation_count}} 次</small></div>
<div class="exploration-goal"><app-favorite-button sourceKind="exploration" [sourceId]="s.id" label="收藏为重新探索" /><p>{{s.goal}}</p></div>
@if(s.pause_reason === 'execution_budget'){<p class="feedback">本段执行到时,进度已保存。可继续探索,不计为测试失败。</p>}
@if(s.pause_reason === 'device_observation'){<p class="feedback" role="status">设备画面暂时不可用,探索已暂停。指导已保留,请确认设备恢复响应后点击“继续探索”。</p>}
<details class="execution-history"><summary>执行记录({{s.runs.length}}) · 当前展示所选轮次的操作步骤</summary>@for(run of s.runs; track run.id){<div class="run"><button class="run-link" (click)="agent.selectSession(run.id,true)">{{run.kind === 'validation' ? '用例 v' + run.case_version + ' 复跑' : '探索执行'}} · {{label(run.status)}}{{agent.currentSessionId() === run.id ? ' · 当前查看' : ''}}</button><app-test-result-badge [result]="run.test_result" [executionStatus]="run.status" [compact]="true" /></div>}</details>
</section>
<div task-result>
@if(showCase()) {
<section class="case-editor" aria-label="可复用用例"><div class="row"><strong class="case-title"><span class="material-symbols-outlined">task_alt</span>{{s.case ? '探索成果 · 可复用用例' : '本轮探索已结束'}}</strong><button (click)="generate()" [disabled]="busy() || !!s.active_run_id || generating() || dirty">{{generating() ? '正在提炼…' : s.case ? '重新提炼' : '提炼用例'}}</button></div>
@if(!s.case){<p class="muted">探索验收通过后自动生成;也可以先编辑并保存草稿。</p><button (click)="editorOpen.set(true)">编辑草稿</button>}
@if(s.generations[0]?.status === 'failed'){<p class="error">上次提炼未完成,执行结果保留。可重试提炼。</p>}
@if(s.case){<div class="case-compact"><h3><app-favorite-button sourceKind="exploration_case" [sourceId]="s.id" [version]="s.case.version" label="收藏此版本用例" /><span>{{s.case.name}} <small>v{{s.case.version}}</small></span></h3><p class="muted">已整理为一个可复用用例。你可以查看修改,或交给 Flash / Pro 独立复跑。</p><div class="actions"><button class="primary" (click)="copyCase()"><span class="material-symbols-outlined">content_copy</span>复制任务提示词</button><button (click)="editorOpen.set(!editorOpen())">{{editorOpen() ? '收起编辑' : '查看 / 编辑'}}</button><button (click)="rerunOpen.set(!rerunOpen())">复跑 / 批次</button></div></div>}
@if(editorOpen()) {
@if(s.case){<label>查看版本<select [ngModel]="editVersion" (ngModelChange)="selectVersion($event)" [disabled]="dirty">@for(version of versions(); track version){<option [value]="version">v{{version}}</option>}</select></label>}
@if(dirty && editVersion !== s.case_version){<p class="error">已有新版本;当前编辑仍保留,请先复制修改内容再加载最新版本。</p>}
<label>用例名称<input [(ngModel)]="editName" (ngModelChange)="dirty = true" maxlength="200" /></label><label>前置条件<textarea [(ngModel)]="editPreconditions" (ngModelChange)="dirty = true" rows="2" maxlength="10000"></textarea></label><label>任务提示词<textarea [(ngModel)]="editGoal" (ngModelChange)="dirty = true" rows="7" maxlength="10000"></textarea></label>
<div class="actions"><button (click)="saveCase()" [disabled]="busy() || !dirty || !editName.trim() || !editGoal.trim()">保存新版本</button><button (click)="copyCase()" [disabled]="!editGoal.trim()">复制提示词</button></div>
}
@if(rerunOpen()) {<div class="rerun-options"><datalist id="exploration-models">@for(model of models;track model){<option [value]="model"></option>}</datalist><strong>复跑与批次</strong><p class="muted">固定使用所选已保存版本。复跑通过与探索通过分别记录。</p><label>执行模式<select [(ngModel)]="rerunProfile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label><label>复跑模型<input [(ngModel)]="rerunModel" list="exploration-models" placeholder="留空使用 Flash / Pro 默认模型" /></label><label>思考强度<select [(ngModel)]="rerunReasoning">@for(effort of efforts; track effort){<option [value]="effort">{{effort}}</option>}</select></label><div class="actions"><button (click)="rerun()" [disabled]="busy() || dirty || !editVersion || !!s.active_run_id">复跑此版本</button><button (click)="rerun('batch')" [disabled]="busy() || dirty || !editVersion">加入新批次草稿</button></div></div>}
</section>
}
</div>
</app-agent-stream>
<section class="composer" [class.needs-answer]="!!s.pending_question" aria-label="指导当前探索">
@if(error()){<p role="alert" class="feedback error">{{error()}} <button (click)="refresh()">重试读取</button></p>}
@if(notice()){<p role="status" class="feedback">{{notice()}}</p>}
@if(s.pending_question) {
<div class="question-banner" role="status" aria-live="polite"><span class="material-symbols-outlined">chat_bubble</span><div><strong>探索已暂停,等待你回答</strong><p>{{pendingQuestion()?.text || '探索助手有一个问题需要你确认,请查看上方对话。'}}</p>@if(pendingQuestion()?.recommendation; as suggestion){<button (click)="text = suggestion; send()" [disabled]="busy()">采用建议并继续</button>}</div></div>
} @else if(finalizing()) {
<div class="feedback" role="status">设备操作已结束,正在根据已验证的步骤提炼用例。完成后会在上方展示。</div>
}
@if(activeDemo(); as demo) {
<section class="demo" [class.warning]="demo.status === 'recording' && secondsLeft(demo) <= demo.warning_seconds" aria-live="polite"><strong><span class="record-dot" [class.recording]="demo.status === 'recording'"></span>{{label(demo.status)}}</strong>
@if(demo.status === 'recording'){<p>现在可以操作设备。剩余 {{secondsLeft(demo)}} 秒;AI 已暂停操作。</p><div class="actions"><button class="primary" (click)="demoAction('finish')" [disabled]="busy()">操作完成,开始分析</button><button (click)="demoAction('extend')" [disabled]="busy()">延长示范</button></div>}
@else {<p>{{demo.status === 'requested' ? '正在等待当前动作安全结束,请等“录制中”出现后再操作。' : '正在保存并分析本次示范录像。'}}</p>}
@if(['requested','recording'].includes(demo.status)){<button (click)="demoAction('cancel')" [disabled]="busy()">取消示范</button>}
</section>
} @else if(demoOptionsOpen() && s.active_run_id && !activeValidation() && ['running','waiting_user'].includes(s.status)) {
<div class="manual-control"><label>示范来源<select [(ngModel)]="source"><option value="android">安卓</option><option value="computer">电脑</option><option value="both">安卓 + 电脑</option></select></label><button (click)="action('demonstrations', {source})" [disabled]="busy()"><span class="material-symbols-outlined">touch_app</span>开始示范</button></div>
}
@if(activeValidation()){<p class="feedback">用例正在独立复跑,请进入复跑任务查看或指导。<button (click)="agent.selectSession(s.active_run_id!,true)">查看当前复跑</button></p>}
<div class="composer-heading"><label for="exploration-message">{{s.pending_question ? '回答探索助手' : '与探索助手交流'}}</label><span class="composer-status" [class.waiting]="!!s.pending_question">{{label(s.status)}}</span></div>
<textarea id="exploration-message" [(ngModel)]="text" (keydown)="onKeyDown($event)" rows="2" maxlength="10000" [placeholder]="s.pending_question ? '在这里回答,发送后继续探索…' : '告诉我下一步怎么做,或补充你的想法…'" [disabled]="busy() || activeValidation() || finalizing()"></textarea>
<div class="actions composer-actions">
@if(!activeDemo() && s.active_run_id && !activeValidation() && ['running','waiting_user'].includes(s.status)){<button class="tool-button" (click)="demoOptionsOpen.set(!demoOptionsOpen())" [attr.aria-expanded]="demoOptionsOpen()"><span class="material-symbols-outlined">touch_app</span>我来操作</button>}
@if(!activeValidation() && !s.pending_question && (!s.active_run_id || s.status === 'paused')){<button (click)="action(s.status === 'draft' ? 'start' : 'continue')" [disabled]="busy()">{{s.status === 'draft' ? '开始探索' : '继续探索'}}</button>}
@if(s.active_run_id && !activeValidation() && !finalizing()){<button class="tool-button" (click)="action('pause')" [disabled]="busy() || ['paused','waiting_user','demonstrating'].includes(s.status)">暂停</button><button class="tool-button" (click)="action('stop')" [disabled]="busy()">停止</button>}
<small class="send-hint">Enter 发送 · Shift+Enter 换行</small>
<button class="primary send-button" (click)="send()" [disabled]="busy() || activeValidation() || finalizing() || !text.trim()" [attr.aria-label]="s.pending_question ? '回答并继续' : '发送'"><span>{{s.pending_question ? '回答并继续' : '发送'}}</span><span class="material-symbols-outlined">arrow_upward</span></button>
</div>
@if(!s.active_run_id){<p class="composer-footnote">可在此讨论和修订用例;继续操作设备请点击“继续探索”。</p>}
</section>
} @else {<div class="empty" role="status">{{loading() ? '正在读取探索记录…' : '探索记录暂时无法读取'}} @if(error()){<p class="error">{{error()}}</p><button (click)="refresh()">重试</button>}</div>}
@@ -0,0 +1,77 @@
:host { display:flex; flex-direction:column; min-height:0; height:100%; overflow:hidden; color:#334155; font-size:13px; }
.exploration-panel { padding:18px; display:flex; flex-direction:column; gap:16px; }
header,.row,.actions,.manual-control { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
header,.row { justify-content:space-between; }
header strong { font-size:15px; color:#0f172a; }
p { margin:8px 0; line-height:1.65; white-space:pre-wrap; overflow-wrap:anywhere; }
header p,.muted,small { color:#64748b; font-size:12px; }
button,input,select,textarea { font:inherit; box-sizing:border-box; }
button { display:inline-flex; align-items:center; justify-content:center; gap:4px; border:1px solid #dbe3ec; border-radius:8px; background:white; color:#334155; padding:6px 10px; min-height:32px; cursor:pointer; }
button:hover:enabled { background:#f1f5f9; border-color:#94a3b8; }
button:disabled { opacity:.5; cursor:default; }
button.primary { background:#0f172a; color:white; border-color:#0f172a; }
.material-symbols-outlined { font-size:18px; }
label { display:flex; flex-direction:column; gap:6px; color:#475569; margin:8px 0; }
input,select,textarea { width:100%; min-width:0; border:1px solid #cbd5e1; border-radius:8px; padding:8px 10px; background:white; color:#0f172a; }
textarea { resize:vertical; line-height:1.6; }
input:focus,select:focus,textarea:focus { outline:2px solid #94a3b8; outline-offset:1px; }
details { border:1px solid #e2e8f0; border-radius:10px; padding:10px 12px; }
summary { cursor:pointer; font-size:12px; color:#475569; }
.session-summary,.case-editor { padding:12px; border:1px solid #e2e8f0; border-radius:12px; background:#f8fafc; }
.session-summary .actions { margin-top:10px; }
.conversation { display:flex; flex-direction:column; gap:12px; }
.message { padding:10px 12px; border-radius:12px; background:#f1f5f9; border:1px solid transparent; }
.message.user { margin-left:20px; background:#ecfdf5; border-color:#d1fae5; }
.message.system { padding:4px 8px; background:transparent; color:#64748b; font-size:12px; }
.recommendation { border-left:2px solid #94a3b8; padding-left:10px; line-height:1.6; }
.recommendation button { margin:6px 0 0 8px; }
.composer { border-top:1px solid #e2e8f0; padding-top:4px; }
.composer > button { margin-top:8px; }
.manual-control { padding:10px 12px; border:1px solid #dbe3ec; border-radius:10px; background:#fafcfd; }
.manual-control label { flex:1; margin:0; }
.manual-control button { align-self:flex-end; }
.demo { border:1px solid #bbf7d0; border-radius:12px; padding:12px; background:#f0fdf4; }
.demo.warning { background:#fffbeb; border-color:#fcd34d; }
.record-dot { display:inline-block; width:7px; height:7px; border-radius:50%; background:#94a3b8; margin-right:6px; }
.record-dot.recording { background:#dc2626; animation:pulse 1.8s infinite; }
@keyframes pulse { 50% { opacity:.4; } }
@media(prefers-reduced-motion:reduce) { .record-dot.recording { animation:none; } }
.feedback { padding:9px 12px; background:#f0fdf4; border-radius:8px; font-size:12px; }
.error { color:#b91c1c; }
.feedback.error { background:#fef2f2; }
.run,.demo-history { padding:10px 0; border-bottom:1px solid #e2e8f0; }
.run-link { border:none; padding-left:0; background:transparent; text-align:left; }
video { width:100%; max-height:260px; margin-top:8px; background:#0f172a; }
a { color:#0f766e; font-size:12px; }
.empty { color:#64748b; text-align:center; padding:25px 10px; }
.empty .material-symbols-outlined { font-size:32px; }
.settings-fields,.case-editor { display:flex; flex-direction:column; gap:3px; }
app-agent-stream {flex:1;min-height:0;display:block}
.composer {flex:none;margin:0 20px 16px;padding:12px 16px;background:#fff;border:1px solid #dbe3ec;border-radius:16px;box-shadow:0 4px 18px #0f172a06;max-height:45%;overflow:auto}
.composer label {margin:0 0 6px}.composer textarea {resize:none}.composer-actions {margin-top:8px}.composer-actions small {flex:1}
.session-summary {margin-bottom:18px;background:#fff}.session-summary p {margin:6px 0}.session-summary .row strong {display:flex;align-items:center;gap:6px}
.execution-history {margin-top:8px;border:0;padding:4px 0}.case-editor {margin:16px 0;background:#fff}.case-compact {display:flex;flex-wrap:wrap;align-items:center;gap:8px}.case-compact .actions {margin-left:auto}
.manual-control {padding:0;margin-bottom:8px;border:0;background:transparent;justify-content:flex-end}.manual-control label {flex:none;flex-direction:row;align-items:center}.manual-control select {width:auto}.demo {padding:8px 10px;margin-bottom:8px}.demo p {margin:4px 0}
.composer {margin:0 24px 18px;padding:14px 18px;border-radius:22px;box-shadow:0 6px 24px #0f172a08;max-height:50%;box-sizing:border-box;min-width:0}
.composer-heading {display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:4px}
.composer-heading label {margin:0;font-size:12px;color:#64748b}
.composer-status {font-size:11px;border-radius:20px;padding:3px 8px;background:#eef2ff;color:#4f46e5}
.composer-status.waiting {color:#92400e;background:#fef3c7}
.composer textarea {border:0;border-radius:0;padding:10px 0;background:transparent;min-height:66px;max-height:140px;font-size:14px;box-shadow:none}
.composer textarea:focus {outline:none}
.composer:focus-within {border-color:#a5b4fc;box-shadow:0 0 0 3px #eef2ff}
.composer-actions {gap:6px;flex-wrap:wrap;border-top:1px solid #f1f5f9;padding-top:10px}
.composer-actions .tool-button {border:0;background:#f8fafc;color:#64748b;border-radius:10px;padding:6px 9px}
.composer-actions .send-hint {flex:1;text-align:right;font-size:10px;color:#94a3b8;min-width:100px}
.send-button {min-height:36px;border-radius:12px;padding:7px 12px;background:#4f46e5!important;border-color:#4f46e5!important;gap:6px}
.composer-footnote {font-size:11px;color:#94a3b8;margin:8px 0 0}
.composer.needs-answer {border-color:#f4c97a}
.question-banner {display:flex;gap:10px;background:#fffbeb;border:1px solid #fde68a;border-radius:12px;padding:12px;margin-bottom:12px;color:#92400e}
.question-banner > .material-symbols-outlined {font-size:22px;margin-top:2px}.question-banner p {margin:4px 0;font-size:13px}.question-banner strong {font-size:13px}.question-banner button {margin-top:5px;background:#fff7d6;border-color:#f4c97a;color:#92400e}
.case-editor {border-color:#c7d2fe;background:linear-gradient(135deg,#f5f7ff,#fff);padding:20px;border-radius:18px}
.case-title {display:flex;align-items:center;gap:8px;color:#4f46e5;font-size:14px}.case-title .material-symbols-outlined {font-size:22px}
.case-compact {display:block}.case-compact h3 {font-size:18px;line-height:1.5;color:#1e293b;margin:14px 0 6px}.case-compact small {font-size:11px;font-weight:400;background:#e0e7ff;color:#4338ca;padding:3px 6px;border-radius:5px;white-space:nowrap}.case-compact .actions {margin:16px 0 0}.case-compact .primary {background:#4f46e5;border-color:#4f46e5}
.session-summary {padding:12px 16px;border-radius:14px}.exploration-goal{display:flex;align-items:flex-start;gap:6px}.session-summary .exploration-goal p{flex:1;min-width:0;margin:4px 0}.case-compact h3{display:flex;align-items:flex-start;gap:6px}.case-compact h3>span{min-width:0;overflow-wrap:anywhere}.session-summary p {color:#64748b;font-size:12px}
@media(max-width:600px) {.composer {margin:0 12px 12px;padding:12px}.send-hint {display:none}.send-button {margin-left:auto}.question-banner {max-height:160px;overflow:auto}.case-editor {padding:14px}}
@@ -0,0 +1,97 @@
import { Component, input, output, signal } from '@angular/core';
import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
import { ExplorationPanelComponent } from './exploration-panel.component';
import { ExplorationService, ExplorationSession } from '../../services/exploration.service';
import { AgentService } from '../../services/agent.service';
import { AgentStreamComponent } from '../agent-stream/agent-stream.component';
@Component({selector:'app-agent-stream',standalone:true,template:'<ng-content></ng-content>'}) class StreamStub {conversationMessages=input<any[]>([]);demonstrations=input<any[]>([]);pendingQuestion=input('');useRecommendation=output<string>();}
describe('Exploration conversation', () => {
const config = {model: 'gpt-6.1-sol', reasoning_effort: 'high', timeout_seconds: 1800, demonstration_timeout_seconds: 900, demonstration_warning_seconds: 60};
let current: ExplorationSession;
let api: any;
beforeEach(() => {
current = {id:'s', goal:'检查设置', status:'running', config, active_run_id:'run', active_demonstration_id:null, case_version:0, message_count:0, elapsed_seconds:10, continuation_count:0, messages:[], runs:[], case:null, demonstrations:[], generations:[]};
api = {drafts: new Map(),caseDrafts:new Map(),requests:new Map(),settings: jasmine.createSpy().and.resolveTo(config), list:jasmine.createSpy().and.resolveTo([]), get:jasmine.createSpy().and.callFake(async () => structuredClone(current)), act:jasmine.createSpy().and.resolveTo({}), version:jasmine.createSpy(), saveSettings:jasmine.createSpy().and.resolveTo(config)};
TestBed.configureTestingModule({imports:[ExplorationPanelComponent], providers:[
{provide:ExplorationService,useValue:api},
{provide:AgentService,useValue:{selectSession:jasmine.createSpy(),fetchSessions:jasmine.createSpy(),fetchStatus:jasmine.createSpy(),currentSessionId:signal('run')}}
]}).overrideComponent(ExplorationPanelComponent,{remove:{imports:[AgentStreamComponent]},add:{imports:[StreamStub]}});
});
function render() {
const fixture = TestBed.createComponent(ExplorationPanelComponent);
fixture.componentRef.setInput('sessionId','s');
fixture.detectChanges(); flushMicrotasks(); fixture.detectChanges();
return fixture;
}
it('highlights unanswered questions beside the composer and hides unfinished cases', fakeAsync(() => {
current.status='waiting_user'; current.pending_question='q';
current.messages=[{id:'q',sequence:1,role:'assistant',kind:'question',text:'下一步是否回到主页?',created_at:1}];
current.case={version:1,name:'旧用例',goal:'旧目标',preconditions:'',evidence:[]};current.case_version=1;
const fixture=render();
expect(fixture.nativeElement.querySelector('.question-banner').textContent).toContain('下一步是否回到主页');
expect(fixture.nativeElement.querySelector('[aria-label="可复用用例"]')).toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('继续探索');
current.active_run_id=null;current.status='completed';current.pending_question=undefined;
void fixture.componentInstance.refresh();flushMicrotasks();fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[aria-label="可复用用例"]').textContent).toContain('旧用例');
expect(fixture.nativeElement.querySelector('.question-banner')).toBeNull();fixture.destroy();
}));
it('keeps a draft and disables guidance while the verified run is being synthesized', fakeAsync(() => {
current.status='finalizing';
const fixture=render();fixture.componentInstance.text='返回主页';
void fixture.componentInstance.send();flushMicrotasks();
expect(api.act).not.toHaveBeenCalled();expect(fixture.componentInstance.text).toBe('返回主页');
expect(fixture.nativeElement.querySelector('textarea').disabled).toBeTrue();fixture.destroy();
}));
it('only invites manual input when the recorder is ready, and warns before its deadline', fakeAsync(() => {
current.active_demonstration_id='demo';
current.demonstrations=[{id:'demo',run_id:'run',source:'android',status:'requested',warning_seconds:60,media:[]}];
const fixture=render();
expect(fixture.nativeElement.textContent).toContain('请等“录制中”出现后再操作');
expect(fixture.nativeElement.textContent).not.toContain('现在可以操作设备');
current.demonstrations[0]={...current.demonstrations[0],status:'recording',deadline:Date.now()/1000+30};
void fixture.componentInstance.refresh(); flushMicrotasks(); fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('现在可以操作设备');
expect(fixture.nativeElement.querySelector('.demo.warning')).not.toBeNull();
fixture.destroy();
}));
it('shows budget exhaustion as a resumable pause, never a failed test', fakeAsync(() => {
current.status='paused'; current.pause_reason='execution_budget'; current.active_run_id=null;
current.case={version:1,name:'旧用例',goal:'旧目标',preconditions:'',evidence:[]};
const fixture=render();
expect(fixture.nativeElement.textContent).toContain('不计为测试失败');
expect(fixture.nativeElement.querySelector('[aria-label="可复用用例"]')).toBeNull();
const button=[...fixture.nativeElement.querySelectorAll('button')].find((b:any)=>b.textContent.includes('继续探索')) as HTMLButtonElement;
button.click(); flushMicrotasks();
expect(api.act.calls.mostRecent().args[1]).toBe('continue');
fixture.destroy();
}));
it('retains the request ID and unsent text when a send response is lost', fakeAsync(() => {
const fixture=render();
api.act.and.rejectWith(new Error('network'));
fixture.componentInstance.text='改用右上角入口';
void fixture.componentInstance.send(); flushMicrotasks();
const first=api.act.calls.mostRecent().args[3];
expect(fixture.componentInstance.text).toBe('改用右上角入口');
api.act.and.resolveTo({});
void fixture.componentInstance.send(); flushMicrotasks();
expect(api.act.calls.mostRecent().args[3]).toBe(first);
expect(fixture.componentInstance.text).toBe('');
fixture.destroy();
}));
it('preserves unsaved edits across polling, but clears them when choosing another conversation', fakeAsync(() => {
current.case_version=1; current.case={version:1,name:'检查',goal:'旧目标',preconditions:'',evidence:[]};
const fixture=render();
fixture.componentInstance.editGoal='用户草稿'; fixture.componentInstance.dirty=true;
current.case={...current.case,version:2,goal:'其他窗口保存'}; current.case_version=2;
void fixture.componentInstance.refresh(); flushMicrotasks();
expect(fixture.componentInstance.editGoal).toBe('用户草稿');
current={...current,id:'other',case:null,case_version:0}; fixture.componentRef.setInput('sessionId','other');
fixture.detectChanges(); flushMicrotasks(); fixture.detectChanges();
expect(fixture.componentInstance.editGoal).toBe('');
expect(api.caseDrafts.get('s')?.goal).toBe('用户草稿');
fixture.destroy();
}));
});
@@ -0,0 +1,166 @@
import { Component, ChangeDetectionStrategy, DestroyRef, effect, inject, signal, untracked, input } from '@angular/core';
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
import { FormsModule } from '@angular/forms';
import { AgentStreamComponent } from '../agent-stream/agent-stream.component';
import { ExplorationService, ExplorationSession, ExplorationMessage, Demonstration } from '../../services/exploration.service';
import { AgentService } from '../../services/agent.service';
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
@Component({
selector: 'app-exploration-panel', standalone: true,
imports: [FormsModule, AgentStreamComponent, TestResultBadgeComponent, FavoriteButtonComponent],
templateUrl: './exploration-panel.component.html', styleUrl: './exploration-panel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ExplorationPanelComponent {
api = inject(ExplorationService);
agent = inject(AgentService);
private destroyRef = inject(DestroyRef);
session = signal<ExplorationSession | null>(null);
messages = signal<ExplorationMessage[]>([]);
loading = signal(false);
busy = signal(false);
error = signal('');
notice = signal('');
now = signal(Date.now() / 1000);
sessionId = input.required<string>();
editorOpen = signal(false);
rerunOpen = signal(false);
demoOptionsOpen = signal(false);
get text() { return this.api.drafts.get(this.sessionId()) || ''; }
set text(value: string) { this.api.drafts.set(this.sessionId(), value); }
source = 'android';
editName = '';
editGoal = '';
editPreconditions = '';
editVersion = 0;
dirty = false;
rerunProfile = 'flash';
rerunModel = '';
rerunReasoning = 'high';
private polling = false;
private refreshSerial = 0;
private followsLatest = true;
private requests = this.api.requests;
private displayedId: string | null = null;
readonly models = ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-luna', 'mimo-v2.6-pro', 'mimo-v2.6-flash'];
readonly efforts = ['default', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
constructor() {
effect(() => {
const id = this.sessionId();
untracked(() => {
this.rememberDraft(); this.displayedId = id;
this.session.set(null); this.messages.set([]); this.dirty = false; this.editVersion = 0; this.followsLatest = true;
this.editName = ''; this.editGoal = ''; this.editPreconditions = '';
this.editorOpen.set(false); this.rerunOpen.set(false); this.error.set(''); this.notice.set('');
const draft = this.api.caseDrafts.get(id);
if (draft) { this.loadCase(draft); this.dirty = true; this.editorOpen.set(true); }
if (id) void this.refresh(id);
});
});
const interval = setInterval(() => {
this.now.set(Date.now() / 1000);
const id = this.sessionId();
if (id && !this.polling) void this.refresh(id, false);
}, 2000);
this.destroyRef.onDestroy(() => { this.rememberDraft(); this.refreshSerial++; clearInterval(interval); });
}
private rememberDraft() {
if (!this.displayedId) return;
if (this.dirty) this.api.caseDrafts.set(this.displayedId, {name:this.editName,goal:this.editGoal,preconditions:this.editPreconditions,version:this.editVersion});
else this.api.caseDrafts.delete(this.displayedId);
}
async refresh(id = this.sessionId(), initial = true) {
if (!id || this.destroyRef.destroyed) return;
const serial = ++this.refreshSerial;
if (initial) this.loading.set(true);
this.polling = true;
try {
const current = this.session();
const after = current?.id === id ? (this.messages().at(-1)?.sequence || 0) : 0;
const value = await this.api.get(id, after);
if (id !== this.sessionId() || serial !== this.refreshSerial) return;
const combined = new Map((current?.id === id ? this.messages() : []).map(m => [m.id, m]));
value.messages.forEach(m => combined.set(m.id, m));
this.messages.set([...combined.values()].sort((a, b) => a.sequence - b.sequence));
this.session.set(value);
if (!current) this.source = value.task_config?.['execution_target'] === 'computer' ? 'computer' : 'android';
if (value.case && this.followsLatest && !this.dirty && this.editVersion !== value.case.version) this.loadCase(value.case);
} catch (e) { if (id === this.sessionId()) this.showError(e); }
finally { if (serial === this.refreshSerial) { this.polling = false; this.loading.set(false); } }
}
private loadCase(value: {name: string; goal: string; preconditions: string; version: number}) {
this.editName = value.name; this.editGoal = value.goal; this.editPreconditions = value.preconditions;
this.editVersion = value.version; this.dirty = false;
}
async selectVersion(version: string) {
const id = this.sessionId(); if (!id || this.dirty) return;
this.followsLatest = Number(version) === this.session()?.case_version;
try { const value = await this.api.version(id, Number(version)); if (id === this.sessionId()) this.loadCase(value); }
catch(e) { this.showError(e); }
}
versions() { return Array.from({length: this.session()?.case_version || 0}, (_, i) => i + 1); }
label(status: string) {
return ({draft:'未开始', queued:'等待执行', reserved:'等待执行', running:'探索中', waiting_user:'等待你回答', demonstrating:'人工示范', analyzing_demo:'分析示范', finalizing:'正在提炼用例', paused:'已暂停,可继续', completed:'探索已结束', cancelled:'已取消', failed:'执行失败', interrupted:'已中断,可继续', requested:'等待安全接管', recording:'录制中', analyzing:'分析中', timed_out:'示范到时,已保存'} as Record<string,string>)[status] || status;
}
pendingQuestion() { return this.messages().find(m => m.id === this.session()?.pending_question); }
finalizing() { return this.session()?.status === 'finalizing'; }
showCase() { const s = this.session(); return !!s && !s.active_run_id && ['completed', 'cancelled'].includes(s.status); }
activeDemo() { const s = this.session(); return s?.demonstrations.find(d => d.id === s.active_demonstration_id); }
activeValidation() { const s = this.session(); return s?.runs.some(r => r.id === s.active_run_id && r.kind === 'validation') || false; }
secondsLeft(demo: Demonstration) { return Math.max(0, Math.ceil((demo.deadline || this.now()) - this.now())); }
async action(action: string, payload: Record<string, unknown> = {}): Promise<Record<string, unknown> | null> {
const id = this.sessionId(); if (!id || this.busy()) return null;
const selectedRun = this.agent.currentSessionId();
const key = JSON.stringify([id, action, payload]);
const requestId = this.requests.get(key) || crypto.randomUUID(); this.requests.set(key, requestId);
this.busy.set(true); this.error.set(''); this.notice.set('');
try {
const value = await this.api.act(id, action, payload, requestId);
this.requests.delete(key);
await this.refresh(id, false);
const run = value['run'] as {id?: string} | undefined;
if (id === this.sessionId() && selectedRun === this.agent.currentSessionId() && run?.id) { this.agent.selectSession(run.id, true); }
this.agent.fetchSessions(); this.agent.fetchStatus();
return id === this.sessionId() ? value : null;
} catch (e) { if (id === this.sessionId()) this.showError(e); return null; }
finally { this.busy.set(false); }
}
async send() {
const text = this.text.trim(), id = this.sessionId(); if (!text || this.activeValidation() || this.finalizing()) return;
if (await this.action('messages', {text}) && this.api.drafts.get(id)?.trim() === text) this.api.drafts.delete(id);
}
demoAction(action: string) {
const demo = this.activeDemo();
if (demo) void this.action(`demonstrations/${demo.id}/${action}`, action === 'extend' ? {seconds: this.session()?.config.demonstration_timeout_seconds || 900} : {});
}
onKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); void this.send(); }
}
async saveCase() {
if (!this.editName.trim() || !this.editGoal.trim()) return;
const result = await this.action('case', {expected_version: this.editVersion, case: {name: this.editName, goal: this.editGoal, preconditions: this.editPreconditions, evidence: this.session()?.case?.evidence || []}});
if (result) { this.dirty = false; this.followsLatest = true; await this.refresh(); this.notice.set('用例已保存为新版本'); }
}
generate() {
const source = [...(this.session()?.runs || [])].reverse().find(r => r.kind === 'exploration' && r.test_result?.status === 'passed' && r.status === 'completed');
if (source) void this.action('generate', {source_run_id: source.id, expected_version: this.session()?.case_version || 0});
else this.error.set('尚无验证通过的探索执行,请继续探索;也可以手动保存草稿。');
}
generating() { return this.session()?.generations.some(g => g.status === 'running') || false; }
rerun(action = 'validate') {
const task_config: Record<string, unknown> = {};
if (this.rerunModel.trim()) { task_config['model'] = this.rerunModel.trim(); task_config['reasoning_effort'] = this.rerunReasoning; }
const payload: Record<string, unknown> = {version: this.editVersion, profile: this.rerunProfile, task_config};
if (action === 'batch') payload['name'] = this.editName;
void this.action(action, payload).then(value => { if (value && action === 'batch') this.notice.set('已创建批次草稿,可在“报告与批量”中查看并启动。'); });
}
async copyCase() {
try { await navigator.clipboard.writeText(`${this.editPreconditions ? '前置条件:' + this.editPreconditions + '\n\n' : ''}${this.editGoal}`); this.notice.set('用例提示词已复制'); }
catch { this.error.set('剪贴板不可用,请选中下方提示词复制'); }
}
mediaUrl(runId: string, path: string) { return '/videos/' + [runId, ...path.split('/')].map(encodeURIComponent).join('/'); }
private showError(error: unknown) { const e = error as {error?: {detail?: unknown}; message?: string}; this.error.set(typeof e.error?.detail === 'string' ? e.error.detail : e.message || '操作失败,请刷新状态后重试'); }
}
@@ -0,0 +1,32 @@
import { Component, ChangeDetectionStrategy, computed, inject, input, signal } from '@angular/core';
import { FavoriteKind, FavoritesService } from '../../services/favorites.service';
@Component({
selector: 'app-favorite-button', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush,
template: `<button type="button" [class.saved]="!!favorite()" [disabled]="busy() || !api.loaded() || !sourceId()"
[attr.aria-pressed]="!!favorite()" [attr.aria-label]="favorite() ? '取消收藏' : label()" [title]="favorite() ? '取消收藏' : label()"
(click)="toggle($event)"><span class="material-symbols-outlined" aria-hidden="true">{{favorite() ? 'star' : 'star_outline'}}</span></button>
@if(error()){<span class="feedback" role="alert">{{error()}}</span>}
@if(api.error() && !api.loaded()){<button type="button" class="retry" (click)="$event.stopPropagation(); api.load(true)" title="重试加载收藏">重试</button>}`,
styles: [`:host{display:inline-flex;align-items:center;position:relative;flex-shrink:0}button{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:#64748b;cursor:pointer}button:hover{background:#fef3c7;color:#b45309}button.saved{color:#c47b0b}button.saved span{font-variation-settings:'FILL' 1}button:focus-visible{outline:2px solid #818cf8;outline-offset:2px}button:disabled{opacity:.45;cursor:default}.material-symbols-outlined{font-size:19px}.feedback{position:absolute;left:0;top:30px;min-width:170px;max-width:260px;padding:8px 10px;z-index:20;border:1px solid #fecaca;border-radius:8px;background:#fff7f7;color:#b91c1c;font-size:12px;line-height:1.5;box-shadow:0 4px 16px #0f172a12}.retry{font-size:10px;width:auto}`]
})
export class FavoriteButtonComponent {
api = inject(FavoritesService);
sourceId = input.required<string>();
sourceKind = input<FavoriteKind>('task');
version = input<number>();
label = input('收藏到精选任务');
busy = signal(false); error = signal('');
favorite = computed(() => this.api.items().find(i => i.source_kind === this.sourceKind() && i.source_id === this.sourceId() && (!this.version() || i.case_version === this.version())));
constructor() { void this.api.load(); }
async toggle(event: Event) {
event.stopPropagation(); if (this.busy()) return;
this.busy.set(true); this.error.set('');
try {
const saved = this.favorite();
if (saved) await this.api.remove(saved.id);
else await this.api.create(this.sourceKind(), this.sourceId(), this.version());
} catch(e) { this.error.set((e as any)?.error?.detail || '收藏操作失败,请重试'); }
finally { this.busy.set(false); }
}
}
@@ -0,0 +1,38 @@
<section class="featured" aria-label="精选任务">
<div class="heading"><div><h2><span class="material-symbols-outlined" aria-hidden="true">star</span>精选任务</h2><p>收藏常用流程,沿用原配置一键复跑。</p></div><input type="search" aria-label="搜索精选任务" placeholder="搜索收藏…" [ngModel]="query()" (ngModelChange)="query.set($event)"></div>
<div class="filters" aria-label="精选任务类型">@for(tab of filters; track tab.id){<button type="button" [class.selected]="filter() === tab.id" [attr.aria-pressed]="filter() === tab.id" (click)="filter.set(tab.id)">{{tab.label}}</button>}</div>
@if(error() || api.error()){<p class="error" role="alert">{{error() || api.error()}} @if(api.error()){<button (click)="api.load(true)">重试</button>}</p>}
@if(notice()){<p class="notice" role="status">{{notice()}}</p>}
@if(api.loading() && !api.loaded()){<p class="empty" role="status">正在读取精选任务…</p>}
@else if(!api.items().length && !api.error()){<div class="empty"><span class="material-symbols-outlined" aria-hidden="true">star_outline</span><strong>把常用测试留在这里</strong><p>在任务队列、历史记录或批次旁点击星标,即可收藏。删除原记录不会影响收藏。</p></div>}
@else if(!filtered().length){<p class="empty">没有匹配的精选任务。</p>}
<div class="cards">@for(item of filtered(); track item.id){
<article class="card" [class.expanded]="expanded() === item.id">
<div class="card-overview">
<div class="card-heading"><span class="mode" [class.pro]="options(item)['profile'] === 'pro'" [class.explore]="item.kind === 'exploration'">{{mode(item)}}</span>@if(item.case_version){<span class="case-version">用例 v{{item.case_version}}</span>}</div>
<div class="card-title"><button class="star" type="button" (click)="remove(item)" [disabled]="!!busy()" aria-label="取消收藏" title="取消收藏"><span class="material-symbols-outlined" aria-hidden="true">star</span></button><h3 [title]="item.name">{{item.name}}</h3></div><p class="model-summary">{{summary(item)}}</p>
<div class="card-actions"><button type="button" class="configure" (click)="toggle(item)" [attr.aria-expanded]="expanded() === item.id"><span class="material-symbols-outlined" aria-hidden="true">tune</span>配置<span class="material-symbols-outlined" aria-hidden="true">{{expanded() === item.id ? 'expand_less' : 'expand_more'}}</span></button><button type="button" class="primary" (click)="run(item)" [disabled]="!!busy()"><span class="material-symbols-outlined" aria-hidden="true">play_arrow</span>{{busy() === item.id ? '处理中…' : item.kind === 'exploration' ? '重新探索' : '运行'}}</button></div>
</div>
@if(expanded() === item.id){@let d = draft(item);
<div class="configuration"><label>收藏名称<input [(ngModel)]="d.name" maxlength="200" aria-label="收藏名称"></label>
@if(item.kind === 'batch'){
<label class="checkbox"><input type="checkbox" [(ngModel)]="d.uniform">本次统一模型和思考强度</label>
@if(d.uniform){<app-model-selector label="整批模型" [(value)]="d.model" /><app-setting-slider label="整批思考强度" [(value)]="d.reasoning_effort" [values]="efforts" [labels]="effortLabels" />}
@for(c of item.snapshot.cases; track $index; let i = $index){<details><summary>{{i + 1}}. {{c.name}}</summary>
<label>执行模式<select [(ngModel)]="d.cases[i].profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label>
<app-model-selector [label]="'用例 ' + (i + 1) + ' 模型'" [(value)]="d.cases[i].model" [disabled]="d.uniform" />
<app-setting-slider label="思考强度" [(value)]="d.cases[i].reasoning_effort" [values]="efforts" [labels]="effortLabels" [disabled]="d.uniform" />
@if(!computer(c.task_config)){<label>执行设备<select [(ngModel)]="d.cases[i].device_serial"><option value="">请选择在线设备</option>@if(d.cases[i].device_serial){<option [value]="d.cases[i].device_serial">原选择:{{d.cases[i].device_serial}}</option>}@for(device of system.connectedDevices(); track device.serial){<option [value]="device.serial">{{device.serial}}</option>}</select></label>}
</details>}
}@else{
@if(item.kind !== 'exploration'){<label>执行模式<select [(ngModel)]="d.profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label>}
<app-model-selector label="复跑模型" [(value)]="d.model" />
<app-setting-slider label="思考强度" [(value)]="d.reasoning_effort" [values]="efforts" [labels]="effortLabels" />
@if(!computer(options(item))){<label>执行设备<select [(ngModel)]="d.device_serial" aria-label="复跑设备"><option value="">请选择在线设备</option>@if(d.device_serial){<option [value]="d.device_serial">原选择:{{d.device_serial}}</option>}@for(device of system.connectedDevices(); track device.serial){<option [value]="device.serial">{{device.serial}}</option>}</select></label>}
}
<p class="hint">调整仅用于本次运行;保存后才会改变收藏的默认配置。</p><button class="save" (click)="save(item)" [disabled]="!!busy() || !d.name.trim() || (d.uniform && !d.model.trim())">保存为默认配置</button>
</div>
}
</article>
}</div>
</section>
@@ -0,0 +1,60 @@
:host { display: block; width: 100%; color: #334155; }
.featured { margin-top: 26px; }
.heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
h2 { display: flex; align-items: center; gap: 7px; margin: 0; font-size: 16px; font-weight: 650; color: #1e293b; }
h2 span { font-size: 20px; color: #d29a32; }
.heading p { font-size: 12px; color: #64748b; margin: 6px 0 0; }
.heading > input { max-width: 210px; width: 40%; border-radius: 20px; }
.filters { display: flex; gap: 5px; flex-wrap: wrap; margin: 17px 0; }
.filters button { border: 0; padding: 6px 13px; border-radius: 18px; font-size: 12px; background: transparent; color: #64748b; }
.filters button.selected { background: #e0e7ff; color: #4338ca; font-weight: 600; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 14px; align-items: start; }
.card { padding: 16px; border: 1px solid #e2e8f0; border-radius: 16px; background: #fff; min-width: 0; transition: border-color .15s, box-shadow .15s; }
.card:hover { border-color: #c7d2fe; box-shadow: 0 4px 16px #33415508; }
.card.expanded { border-color: #a5b4fc; }
.card-overview { display: flex; flex-direction: column; min-height: 178px; }
.card-heading { display: flex; align-items: center; justify-content: space-between; gap: 6px; min-height: 22px; }
.mode { padding: 3px 8px; background: #f1f5f9; color: #475569; border-radius: 6px; font-size: 11px; line-height: 16px; }
.mode.pro { background: #ede9fe; color: #6d28d9; }
.mode.explore { background: #e0f2fe; color: #0369a1; }
.case-version { font-size: 11px; color: #94a3b8; white-space: nowrap; }
.card-title { display: flex; align-items: flex-start; gap: 6px; margin: 13px 0 7px; min-height: 42px; }
.card-title h3 { flex: 1; min-width: 0; margin: 0; font-size: 14px; line-height: 21px; overflow-wrap: anywhere; color: #1e293b;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
.star { display: flex; flex-shrink: 0; width: 24px; height: 24px; align-items: center; justify-content: center; padding: 0; border: 0; background: transparent; color: #c58b23; }
.star span { font-size: 19px; font-variation-settings: 'FILL' 1; }
.model-summary { font-size: 12px; line-height: 18px; color: #64748b; margin: 0 0 12px; min-height: 36px; overflow-wrap: anywhere;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
.card-actions { display: flex; align-items: center; justify-content: space-between; margin-top: auto; gap: 10px; }
.card-actions button { display: flex; align-items: center; gap: 4px; min-height: 34px; padding: 7px 10px; border-radius: 8px; font-size: 12px; white-space: nowrap; }
.card-actions span { font-size: 16px; }
.configure { color: #64748b; border: 0; background: transparent; }
.primary { border: 1px solid #6366f1; background: #6366f1; color: white; }
.primary:hover { background: #4f46e5; }
.configuration { border-top: 1px solid #e2e8f0; margin-top: 14px; padding-top: 14px; display: grid; gap: 12px; }
.configuration label { display: grid; gap: 6px; font-size: 12px; color: #475569; }
.configuration .checkbox { display: flex; align-items: center; }
.checkbox input { width: auto; min-height: auto; }
input, select { box-sizing: border-box; min-width: 0; width: 100%; min-height: 40px; padding: 10px 12px; border: 1px solid #dbe3ec; border-radius: 10px; color: #334155; background: white; font: inherit; font-size: 13px; }
option { color: #334155; background: #fff; }
.hint { font-size: 11px; color: #64748b; line-height: 1.5; margin: 7px 0 0; }
.save { justify-self: start; padding: 7px 10px; border: 1px solid #cbd5e1; background: #fff; color: #475569; border-radius: 8px; font-size: 12px; }
details { border: 1px solid #e2e8f0; padding: 10px; border-radius: 9px; }
details summary { font-size: 12px; cursor: pointer; overflow-wrap: anywhere; }
details[open] > label, details[open] > app-setting-slider { margin-top: 12px; display: block; }
details[open] > app-model-selector { margin-top: 12px; }
button { font-family: inherit; cursor: pointer; }
button:disabled { opacity: .5; cursor: default; }
button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible { outline: 2px solid #818cf8; outline-offset: 2px; }
.empty { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 28px 18px; border: 1px dashed #cbd5e1; border-radius: 16px; color: #64748b; font-size: 12px; line-height: 1.7; }
.empty > span { font-size: 28px; color: #c5a365; margin-bottom: 9px; }
.empty strong { font-size: 14px; color: #475569; }
.empty p { max-width: 440px; margin: 8px 0 0; }
.error, .notice { padding: 10px 12px; border-radius: 8px; font-size: 12px; line-height: 1.6; }
.error { background: #fff1f2; color: #b91c1c; }
.notice { background: #f0fdf4; color: #166534; }
@media (max-width: 560px) {
.heading { align-items: flex-start; flex-direction: column; }
.heading > input { width: 100%; max-width: none; }
.cards { grid-template-columns: 1fr; }
}
@@ -0,0 +1,54 @@
import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
import { signal } from '@angular/core';
import { Router } from '@angular/router';
import { FeaturedTasksComponent } from './featured-tasks.component';
import { FavoritesService, Favorite } from '../../services/favorites.service';
import { AgentService } from '../../services/agent.service';
import { SystemService } from '../../services/system.service';
describe('Featured tasks',()=>{
let api:any, agent:any, system:any, router:any;
const favorite:Favorite={id:'favorite',name:'检查页面',kind:'task',source_kind:'task',source_id:'source',inherited_fields:[],snapshot:{goal:'检查主页',runtime_overrides:{profile:'pro',model:'original-model',reasoning_effort:'high',device_serial:'device'}}};
beforeEach(()=>{
api={items:signal([structuredClone(favorite)]),loaded:signal(true),loading:signal(false),error:signal(''),load:jasmine.createSpy().and.resolveTo(),run:jasmine.createSpy().and.resolveTo({kind:'task',session_id:'new'}),update:jasmine.createSpy().and.resolveTo(favorite),remove:jasmine.createSpy().and.resolveTo()};
agent={fetchSessions:jasmine.createSpy(),selectSession:jasmine.createSpy(),batchFilter:signal(''),activeTab:signal('tasks')};system={connectedDevices:signal([{serial:'device',state:'device'}])};router={navigate:jasmine.createSpy().and.resolveTo(true)};
TestBed.configureTestingModule({providers:[{provide:FavoritesService,useValue:api},{provide:AgentService,useValue:agent},{provide:SystemService,useValue:system},{provide:Router,useValue:router}]});
});
it('runs a card with original settings and keeps configuration folded initially',fakeAsync(()=>{
const fixture=TestBed.createComponent(FeaturedTasksComponent);fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.configuration')).toBeNull();
fixture.nativeElement.querySelector('.primary').click();flushMicrotasks();
expect(api.run).toHaveBeenCalledWith('favorite',{});expect(api.update).not.toHaveBeenCalled();expect(agent.selectSession).toHaveBeenCalledWith('new',true);expect(router.navigate).toHaveBeenCalledWith(['/workspace']);
}));
it('blocks unavailable original devices and opens a device chooser without submitting',fakeAsync(()=>{
system.connectedDevices.set([{serial:'other',state:'device'}]);const fixture=TestBed.createComponent(FeaturedTasksComponent);fixture.detectChanges();fixture.nativeElement.querySelector('.primary').click();flushMicrotasks();fixture.detectChanges();
expect(api.run).not.toHaveBeenCalled();expect(fixture.nativeElement.querySelector('[aria-label="复跑设备"]')).not.toBeNull();expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('原设备不在线');
}));
it('applies model changes only to this run unless defaults are explicitly saved',fakeAsync(()=>{
const fixture=TestBed.createComponent(FeaturedTasksComponent);const c=fixture.componentInstance;c.draft(favorite).model='temporary';void c.run(favorite);flushMicrotasks();
expect(api.run).toHaveBeenCalledWith('favorite',{task_config:{model:'temporary'}});expect(api.update).not.toHaveBeenCalled();expect(favorite.snapshot.runtime_overrides!['model']).toBe('original-model');
void c.save(favorite);flushMicrotasks();expect(api.update).toHaveBeenCalledWith('favorite',{task_config:{model:'temporary'},name:'检查页面'});
}));
it('uses the shared model picker and automatically keeps completed legacy settings without a warning',fakeAsync(()=>{
api.items.set([{...structuredClone(favorite),inherited_fields:['record_video','recording']}]);
const fixture=TestBed.createComponent(FeaturedTasksComponent);fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain('旧任务部分配置缺失');
fixture.nativeElement.querySelector('.configure').click();fixture.detectChanges();flushMicrotasks();
const select:HTMLSelectElement=fixture.nativeElement.querySelector('app-model-selector select');
expect(Array.from(select.options,option=>option.value)).toEqual(['gpt-6-luna','gpt-6.1-sol','gpt-6-astra','mimo-v2.6-pro','__custom__']);
expect(select.value).toBe('__custom__');
expect(fixture.nativeElement.querySelector('input[aria-label="复跑模型(自定义)"]').value).toBe('original-model');
select.value='gpt-6-luna';select.dispatchEvent(new Event('change'));fixture.detectChanges();
fixture.nativeElement.querySelector('.primary').click();flushMicrotasks();
expect(api.run).toHaveBeenCalledWith('favorite',{task_config:{model:'gpt-6-luna'}});
expect(api.update).not.toHaveBeenCalled();
}));
it('computer-only favorites need no Android device and can be filtered by name',fakeAsync(()=>{
const pc:Favorite={...structuredClone(favorite),kind:'exploration_case',case_version:1,snapshot:{goal:'检查电脑',runtime_overrides:{profile:'flash',model:'original-model',reasoning_effort:'high',execution_target:'computer'}}};api.items.set([pc]);system.connectedDevices.set([]);
const fixture=TestBed.createComponent(FeaturedTasksComponent),c=fixture.componentInstance;c.query.set('不存在');expect(c.filtered()).toEqual([]);c.query.set('页面');expect(c.filtered().length).toBe(1);void c.run(pc);flushMicrotasks();expect(api.run).toHaveBeenCalled();
}));
it('batch defaults keep each case and repetition count, with explicit uniform override',()=>{
const batch:Favorite={...structuredClone(favorite),kind:'batch',snapshot:{repeat_count:3,cases:[{name:'甲',goal:'检查甲',task_config:{profile:'flash',model:'a',reasoning_effort:'high',device_serial:'device'}},{name:'乙',goal:'检查乙',task_config:{profile:'pro',model:'b',reasoning_effort:'max',device_serial:'device'}}]}};
const fixture=TestBed.createComponent(FeaturedTasksComponent),c=fixture.componentInstance;expect(c.payload(batch)).toEqual({});const d=c.draft(batch);expect(c.payload(batch)).toEqual({case_configs:{}});d.uniform=true;d.model='uniform';d.reasoning_effort='high';expect(c.payload(batch)['task_config']).toEqual({model:'uniform',reasoning_effort:'high'});expect(batch.snapshot.repeat_count).toBe(3);
});
});
@@ -0,0 +1,91 @@
import { Component, ChangeDetectionStrategy, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { FavoritesService, Favorite } from '../../services/favorites.service';
import { AgentService } from '../../services/agent.service';
import { SystemService } from '../../services/system.service';
import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from '../setting-slider/setting-slider.component';
import { ModelSelectorComponent } from '../model-selector/model-selector.component';
interface ModelDraft {profile: string; model: string; reasoning_effort: string; device_serial: string;}
interface Draft extends ModelDraft {name: string; uniform: boolean; cases: ModelDraft[];}
@Component({
selector: 'app-featured-tasks', standalone: true, imports: [FormsModule, SettingSliderComponent, ModelSelectorComponent],
templateUrl: './featured-tasks.component.html', styleUrl: './featured-tasks.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FeaturedTasksComponent {
api = inject(FavoritesService); system = inject(SystemService); private agent = inject(AgentService); private router = inject(Router);
query = signal(''); filter = signal('all'); expanded = signal<string | null>(null);
busy = signal<string | null>(null); error = signal(''); notice = signal('');
drafts = new Map<string, Draft>();
readonly efforts = REASONING_VALUES; readonly effortLabels = REASONING_LABELS;
readonly filters = [{id:'all',label:'全部'},{id:'flash',label:'Flash'},{id:'pro',label:'Pro'},{id:'exploration',label:'探索'},{id:'batch',label:'批量'}];
filtered = computed(() => this.api.items().filter(item => {
const filter = this.filter(), query = this.query().trim().toLowerCase();
return (filter === 'all' || (filter === 'batch' ? item.kind === 'batch' : filter === 'exploration' ? ['exploration','exploration_case'].includes(item.kind) : item.kind !== 'batch' && this.options(item)['profile'] === filter)) && (!query || `${item.name} ${item.snapshot.goal || ''}`.toLowerCase().includes(query));
}));
constructor() { void this.api.load(); }
options(item: Favorite): Record<string, any> { return item.kind === 'exploration' ? {...item.snapshot.task_config,...item.snapshot.config,profile:'exploration'} : item.snapshot.runtime_overrides || {}; }
mode(item: Favorite) { return item.kind === 'batch' ? '批量' : item.kind === 'exploration' ? '重新探索' : (this.options(item)['profile'] === 'pro' ? 'Pro' : 'Flash') + (item.kind === 'exploration_case' ? ' · 用例复跑' : ''); }
summary(item: Favorite) { return item.kind === 'batch' ? `${item.snapshot.cases?.length || 0} 个用例 · ${item.snapshot.repeat_count || 1} 轮 · 保留各项配置` : `${this.options(item)['model'] || '当前默认'} / ${this.options(item)['reasoning_effort'] || '当前默认'}`; }
computer(options: Record<string, any>) { return options['execution_target'] === 'computer'; }
modelDraft(options: Record<string, any>): ModelDraft { return {profile:options['profile'] || 'flash', model:options['model'] || '', reasoning_effort:options['reasoning_effort'] || 'default', device_serial:this.computer(options) ? '' : options['device_serial'] || ''}; }
draft(item: Favorite): Draft {
let draft = this.drafts.get(item.id);
if (!draft) { draft = {...this.modelDraft(item.kind === 'batch' ? item.snapshot.cases?.[0]?.task_config || {} : this.options(item)), name:item.name, uniform:false, cases:(item.snapshot.cases || []).map(c => this.modelDraft(c.task_config))}; this.drafts.set(item.id,draft); }
return draft;
}
toggle(item: Favorite) { this.expanded.set(this.expanded() === item.id ? null : item.id); }
private changes(current: ModelDraft, original: Record<string, any>) {
const baseline = this.modelDraft(original), result: Record<string, unknown> = {};
for (const key of ['profile','model','reasoning_effort','device_serial'] as const) {
if (current[key] !== baseline[key] && current[key]) result[key] = current[key];
}
return result;
}
payload(item: Favorite): Record<string, unknown> {
const draft = this.drafts.get(item.id); if (!draft) return {};
if (item.kind === 'batch') {
const case_configs: Record<number, unknown> = {};
draft.cases.forEach((c,i) => { const changes = this.changes(c,item.snapshot.cases![i].task_config); if(Object.keys(changes).length)case_configs[i]=changes; });
return {case_configs, ...(draft.uniform ? {task_config:{model:draft.model,reasoning_effort:draft.reasoning_effort}} : {})};
}
return {task_config:this.changes(draft,this.options(item))};
}
private deviceMissing(item: Favorite) {
const devices = this.system.connectedDevices();
const cases = item.kind === 'batch' ? item.snapshot.cases!.map((c,i) => ({...c.task_config,...(this.payload(item)['case_configs'] as any)?.[i]})) : [{...this.options(item),...(this.payload(item)['task_config'] as any)}];
return cases.some(options => !this.computer(options) && (!options['device_serial'] || !devices.some(d => d.serial === options['device_serial'] && d.state === 'device')));
}
async save(item: Favorite) {
if(!this.validModels(item)){this.error.set('模型名称不能为空');return;}
if(this.busy())return;this.busy.set(item.id);this.error.set('');this.notice.set('');
try { const saved = await this.api.update(item.id,{...this.payload(item),name:this.draft(item).name});this.drafts.delete(item.id);this.draft(saved);this.notice.set('已保存收藏的默认配置'); }
catch(e){this.error.set((e as any)?.error?.detail || '保存失败,请重试');}finally{this.busy.set(null);}
}
async remove(item: Favorite) {
if(this.busy())return;this.busy.set(item.id);this.error.set('');
try{await this.api.remove(item.id);this.drafts.delete(item.id);}catch(e){this.error.set((e as any)?.error?.detail || '取消收藏失败');}finally{this.busy.set(null);}
}
async run(item: Favorite) {
if(this.busy())return;this.error.set('');this.notice.set('');
if(!this.validModels(item)){this.error.set('模型名称不能为空');return;}
if(this.deviceMissing(item)){this.expanded.set(item.id);this.error.set('原设备不在线,请在配置中选择可用设备后运行。');return;}
const draft=this.drafts.get(item.id);if(draft?.uniform && !draft.model.trim()){this.error.set('请填写统一使用的模型');return;}
this.busy.set(item.id);
try{
const result=await this.api.run(item.id,this.payload(item));
this.agent.fetchSessions();
if(result.batch_id){this.agent.batchFilter.set(result.batch_id);this.agent.activeTab.set('tasks');}
else{const id=result.session_id || result.admission?.run?.id;if(id)this.agent.selectSession(id,true);}
await this.router.navigate(['/workspace']);
}catch(e){this.error.set((e as any)?.error?.detail || '运行请求失败;重试将沿用相同请求编号,避免重复执行。');}
finally{this.busy.set(null);}
}
private validModels(item: Favorite) {
const draft=this.drafts.get(item.id);if(!draft)return true;
return item.kind === 'batch' ? draft.uniform ? !!draft.model.trim() : draft.cases.every(c => !!c.model.trim()) : !!draft.model.trim();
}
}
@@ -29,6 +29,7 @@ limitations under the License.
[class.theater]="isTheaterMode()" [class.theater]="isTheaterMode()"
[style.left.px]="!isTheaterMode() ? posX() : null" [style.left.px]="!isTheaterMode() ? posX() : null"
[style.top.px]="!isTheaterMode() ? posY() : null" [style.top.px]="!isTheaterMode() ? posY() : null"
[style.--player-top]="posY() + 'px'"
> >
<!-- Window Header (Draggable Handle) --> <!-- Window Header (Draggable Handle) -->
<header class="window-header" (mousedown)="startDrag($event)"> <header class="window-header" (mousedown)="startDrag($event)">
@@ -95,12 +96,31 @@ limitations under the License.
</button> </button>
</div> </div>
</header> </header>
@if (sources().length && !agentService.isVideoMinimized()) {
<div class="recording-sources">
<label for="recording-source">录像来源</label>
<select id="recording-source" [ngModel]="agentService.selectedRecordingSource()" (ngModelChange)="selectSource($event)">
@for (source of sources(); track source.id) {
<option [value]="source.id">{{ source.label }}{{ source.status === 'failed' ? '(录制异常)' : '' }}</option>
}
</select>
</div>
@if (agentService.recordingPlaybackMessage()) {
<div class="recording-notice" role="status">{{ agentService.recordingPlaybackMessage() }}</div>
}
@if (!agentService.activeVideoUrl()) {
<div class="recording-notice">此来源暂无可播放录像,请查看步骤截图或日志。</div>
}
}
@if (!agentService.isVideoMinimized() && agentService.playerMode() === 'video' && missingRecordingLabels().length) {
<div class="recording-notice" role="status">{{ missingRecordingLabels().join('、') }}步骤没有对应录像,可切换「步骤」查看已有截图。</div>
}
<!-- Video Main Content Area --> <!-- Video Main Content Area -->
@if (!agentService.isVideoMinimized()) { @if (!agentService.isVideoMinimized()) {
<div class="video-body"> <div class="video-body">
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) --> <!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
@if (agentService.recordingPlaybackStatus() === 'live') { @if (agentService.recordingPlaybackStatus() === 'live') {
@if (!liveStreamError()) { @if (!liveStreamError() && pageVisible()) {
<div class="live-stream-container"> <div class="live-stream-container">
<img <img
class="live-screen-img" class="live-screen-img"
@@ -332,12 +352,16 @@ limitations under the License.
@if (agentService.playerMode() === 'video' && agentService.recordingPlaybackStatus() === 'ready' && !agentService.isVideoLoading() && agentService.activeVideoUrl(); as videoUrl) { @if (agentService.playerMode() === 'video' && agentService.recordingPlaybackStatus() === 'ready' && !agentService.isVideoLoading() && agentService.activeVideoUrl(); as videoUrl) {
@if (!videoLoadError()) { @if (!videoLoadError()) {
<div class="video-container"> <div class="video-container">
@if (gapMessage()) { <div class="recording-gap" role="status">{{ gapMessage() }}</div> }
<video <video
#videoRef #videoRef
[style.visibility]="gapMessage() ? 'hidden' : 'visible'"
[src]="currentVideoUrl()" [src]="currentVideoUrl()"
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()"
@@ -349,11 +373,18 @@ limitations under the License.
<!-- Dedicated Bottom Controls Bar for Video --> <!-- Dedicated Bottom Controls Bar for Video -->
@if (!videoLoadError()) { @if (!videoLoadError()) {
<div class="video-controls-footer"> <div class="video-controls-footer">
@if (hasRecordingGaps()) {
<div class="recording-coverage-summary">
<span>已录制 {{ formatTime(recordedDuration()) }}</span>
<span>空白区间自动跳过</span>
</div>
}
<!-- Timeline Scrubber Bar --> <!-- Timeline Scrubber Bar -->
<div class="scrubber-bar-container"> <div class="scrubber-bar-container" [class.has-recording-gaps]="hasRecordingGaps()">
<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 +392,30 @@ 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 (hasRecordingGaps()) {
<div class="recording-coverage" aria-hidden="true">
@for (range of recordedCoverage(); track $index) {
<span [style.left.%]="range.left" [style.width.%]="range.width"></span>
}
<i [style.left.%]="progressPercent()"></i>
</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">
@@ -375,7 +430,7 @@ limitations under the License.
</button> </button>
<!-- Time Display --> <!-- Time Display -->
<span class="time-display"> <span class="time-display">
{{ formatTime(currentTime()) }} / {{ formatTime(duration()) }} <span [title]="agentService.usesSessionTimeline() ? '任务时间;蓝色区间有录像,空白区间未录制' : '录像时间'">{{ formatTime(currentTime()) }} / {{ formatTime(duration()) }}</span>
</span> </span>
</div> </div>
<div class="controls-right"> <div class="controls-right">
@@ -406,6 +461,9 @@ limitations under the License.
</button> </button>
</div> </div>
</div> </div>
@if (skippedInterval()) {
<div class="recording-skip-notice" role="status">{{ skippedInterval() }}</div>
}
</div> </div>
} }
} }
@@ -31,6 +31,7 @@
position: fixed; position: fixed;
width: 400px; width: 400px;
max-width: calc(100vw - 32px); max-width: calc(100vw - 32px);
max-height: calc(100dvh - var(--player-top, 60px) - 12px);
background: rgba(255, 255, 255, 0.84); background: rgba(255, 255, 255, 0.84);
backdrop-filter: blur(24px) saturate(180%); backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%); -webkit-backdrop-filter: blur(24px) saturate(180%);
@@ -298,6 +299,7 @@
/* --- Video Body Area --- */ /* --- Video Body Area --- */
.video-body { .video-body {
min-height: 0;
position: relative; position: relative;
background: #ffffff; background: #ffffff;
display: flex; display: flex;
@@ -655,6 +657,7 @@
} }
.video-container { .video-container {
min-height: 0;
position: relative; position: relative;
width: 100%; width: 100%;
display: flex; display: flex;
@@ -664,6 +667,7 @@
background: #ffffff; background: #ffffff;
video { video {
min-height: 0;
width: 100%; width: 100%;
height: auto; height: auto;
max-height: 520px; max-height: 520px;
@@ -724,6 +728,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;
@@ -985,3 +1021,86 @@
} }
} }
} }
.recording-sources {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 12px;
color: #475569;
select {
min-width: 0;
flex: 1;
border: 1px solid #cbd5e1;
border-radius: 6px;
padding: 4px;
background: white;
}
}
.recording-notice {
padding: 8px 12px;
background: #fffbeb;
color: #92400e;
font-size: 12px;
}
.recording-gap {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
color: #475569;
background: #f1f5f9;
text-align: center;
z-index: 2;
}
.recording-coverage-summary {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 4px 12px;
color: #64748b;
font-size: 11px;
line-height: 16px;
}
.recording-skip-notice {
color: #64748b;
font-size: 11px;
line-height: 16px;
}
.has-recording-gaps {
.scrubber-progress-fill { opacity: 0; }
.recording-coverage {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
span {
position: absolute;
top: 2px;
height: 5px;
border-radius: 3px;
background: #0284c7;
}
i {
position: absolute;
top: 0;
width: 3px;
height: 8px;
background: #0f172a;
transform: translateX(-50%);
}
}
}
@@ -0,0 +1,507 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { AgentService, RecordingSource, VideoSegment } 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
};
const service = {
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<VideoSegment[]>([{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 }]),
recordingSources: signal<RecordingSource[]>([]),
selectedRecordingSource: signal(''),
usesSessionTimeline: signal(false),
recordingSessionDuration: signal(0),
recordingPlaybackMessage: signal(''),
selectRecordingSource: jasmine.createSpy('selectRecordingSource'),
currentSessionStepFrames: signal([frame]),
currentSession: signal({ session_id: 'test', start_time: start }),
currentSessionId: signal('test'),
videoSeekRequest: signal<{ seconds: number; requestId: number; sourceId?: string } | null>(null),
stepSeekRequest: signal(null),
consumeVideoAutoplay: jasmine.createSpy('consumeVideoAutoplay').and.returnValue(false)
};
service.selectRecordingSource.and.callFake((id: string) => {
const source = service.recordingSources().find(item => item.id === id);
if (!source) return false;
if (service.selectedRecordingSource() === id) return true;
service.selectedRecordingSource.set(id);
service.activeVideoSegments.set(source.segments);
service.activeVideoUrl.set(source.url || source.segments[0]?.url || '');
return true;
});
return service;
}
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 },
readyState: { configurable: true, get: () => 1 },
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();
});
video.dispatchEvent(new Event('loadedmetadata'));
});
function finishSeek(): void {
seeking = false;
video.dispatchEvent(new Event('seeked'));
fixture.detectChanges();
}
it('removes the live image while hidden and reconnects it on return', () => {
const hidden = spyOnProperty(document, 'hidden', 'get').and.returnValue(false);
agentService.recordingPlaybackStatus.set('live');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('img.live-screen-img')).not.toBeNull();
hidden.and.returnValue(true);
document.dispatchEvent(new Event('visibilitychange'));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('img.live-screen-img')).toBeNull();
hidden.and.returnValue(false);
document.dispatchEvent(new Event('visibilitychange'));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('img.live-screen-img')).not.toBeNull();
expect(component.liveStreamError()).toBeFalse();
});
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([]);
agentService.usesSessionTimeline.set(true);
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);
});
function setSources(computerStart = 0): void {
agentService.recordingSources.set([
{ id: 'android', label: '安卓', kind: 'android', target_ids: ['android'], status: 'ready',
segments: [{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 }] },
{ id: 'screen', label: '电脑', kind: 'computer', target_ids: ['window-1'], status: 'ready',
segments: [{ url: '/computer.mp4', start: computerStart, duration: 20, width: 1280, height: 720 }] }
]);
agentService.usesSessionTimeline.set(true);
agentService.recordingSessionDuration.set(40);
agentService.selectRecordingSource('android');
fixture.detectChanges();
}
function setGappedRecording(): void {
agentService.usesSessionTimeline.set(true);
agentService.recordingSessionDuration.set(1040);
agentService.activeVideoSegments.set([
{ url: '/test.mp4', start: 2, offset_ms: 2000, duration: 20, width: 400, height: 800 },
{ url: '/resume.mp4', start: 1000, offset_ms: 1000000, duration: 20, width: 400, height: 800 }
]);
fixture.detectChanges();
video.dispatchEvent(new Event('loadedmetadata'));
}
it('automatically skips a long pause and plays the next clip after it loads', () => {
setGappedRecording();
component.togglePlay();
play.calls.reset();
time = 20;
paused = true;
component.onEnded();
expect(component.activeSegmentIndex()).toBe(1);
expect(component.currentTime()).toBe(1000);
expect(component.gapMessage()).toBe('');
expect(component.skippedInterval()).toContain('00:22–16:40');
expect(play).not.toHaveBeenCalled();
component.onTimeUpdate(); // Late timeupdate from the ended clip must not move the new timeline.
expect(component.currentTime()).toBe(1000);
fixture.detectChanges();
video.dispatchEvent(new Event('loadedmetadata'));
finishSeek();
expect(time).toBe(0);
expect(paused).toBeFalse();
expect(play).toHaveBeenCalledTimes(1);
});
it('restarts and loops at the first recorded frame, skipping leading and trailing gaps', () => {
setGappedRecording();
component.restart();
finishSeek();
expect(component.currentTime()).toBe(2);
expect(paused).toBeFalse();
component.seek(1010, true);
fixture.detectChanges();
video.dispatchEvent(new Event('loadedmetadata'));
finishSeek();
component.isLooping.set(true);
paused = true;
component.onEnded();
fixture.detectChanges();
video.dispatchEvent(new Event('loadedmetadata'));
finishSeek();
expect(component.activeSegmentIndex()).toBe(0);
expect(component.currentTime()).toBe(2);
expect(component.gapMessage()).toBe('');
expect(paused).toBeFalse();
});
for (const playing of [false, true]) {
it(`snaps a scrub in a gap forward while preserving ${playing ? 'playing' : 'paused'} intent`, () => {
setGappedRecording();
if (playing) component.togglePlay();
const input = fixture.nativeElement.querySelector('.scrubber-range') as HTMLInputElement;
input.value = '500';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
video.dispatchEvent(new Event('loadedmetadata'));
finishSeek();
expect(component.currentTime()).toBe(1000);
expect(component.skippedInterval()).toContain('08:20–16:40');
expect(paused).toBe(!playing);
});
}
it('does not substitute another time for an exact step seek in a gap', () => {
setGappedRecording();
component.seekToSessionTime(500, true);
expect(component.currentTime()).toBe(500);
expect(component.gapMessage()).toContain('未录制');
expect(paused).toBeTrue();
expect(play).not.toHaveBeenCalled();
});
it('ends at the last recorded frame without playing the trailing gap', () => {
setGappedRecording();
component.seek(1010, true);
fixture.detectChanges();
video.dispatchEvent(new Event('loadedmetadata'));
finishSeek();
play.calls.reset();
paused = true;
component.onEnded();
expect(component.currentTime()).toBe(1020);
expect(component.isPlaying()).toBeFalse();
expect(component.skippedInterval()).toContain('录像已结束');
expect(play).not.toHaveBeenCalled();
});
it('shows actual recorded duration and coverage separately from task duration', () => {
setGappedRecording();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.recording-coverage-summary').textContent).toContain('00:40');
expect(fixture.nativeElement.querySelector('.time-display').textContent).toContain('17:20');
expect(fixture.nativeElement.querySelectorAll('.recording-coverage span').length).toBe(2);
expect(component.recordedCoverage()[1].left).toBeCloseTo(1000 / 1040 * 100);
});
it('explains missing computer footage without treating Android video as its evidence', () => {
agentService.currentSessionStepFrames.update(frames => [...frames,
{ ...frames[0], sourceId: 'plugin:open-computer-use', sourceLabel: '电脑', index: 1 }
]);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.recording-notice').textContent).toContain('电脑步骤没有对应录像');
agentService.recordingSources.set([
{ id: 'pc', label: '电脑', kind: 'computer', target_ids: ['plugin:open-computer-use'],
status: 'ready', segments: agentService.activeVideoSegments() }
]);
fixture.detectChanges();
expect(component.missingRecordingLabels()).toEqual([]);
});
it('handles a seek request once without seeking back on play or pause', () => {
const seek = spyOn(component, 'seekToSessionTime').and.callThrough();
agentService.videoSeekRequest.set({ seconds: 5, requestId: 1 });
fixture.detectChanges();
finishSeek();
time = 9;
component.togglePlay();
fixture.detectChanges();
expect(time).toBe(9);
component.togglePlay();
fixture.detectChanges();
expect(time).toBe(9);
expect(seek).toHaveBeenCalledOnceWith(5, false);
});
it('defers a seek command until the recording is ready', () => {
const seek = spyOn(component, 'seekToSessionTime');
agentService.recordingPlaybackStatus.set('processing');
agentService.videoSeekRequest.set({ seconds: 5, requestId: 2 });
fixture.detectChanges();
expect(seek).not.toHaveBeenCalled();
agentService.recordingPlaybackStatus.set('ready');
fixture.detectChanges();
component.onLoadedMetadata();
expect(seek).toHaveBeenCalledOnceWith(5, false);
});
it('switches to the requested source, waits for seeked and allows later manual source changes', () => {
setSources(2);
component.togglePlay();
agentService.videoSeekRequest.set({ seconds: 7, requestId: 3, sourceId: 'window-1' });
fixture.detectChanges();
play.calls.reset();
video.dispatchEvent(new Event('loadedmetadata'));
expect(time).toBe(5);
expect(play).not.toHaveBeenCalled();
finishSeek();
expect(component.currentTime()).toBe(7);
expect(paused).toBeFalse();
expect(play).toHaveBeenCalledTimes(1);
component.selectSource('android');
fixture.detectChanges();
expect(agentService.selectedRecordingSource()).toBe('android');
video.dispatchEvent(new Event('loadedmetadata'));
finishSeek();
expect(time).toBe(7);
expect(agentService.selectedRecordingSource()).toBe('android');
});
it('preserves task time when switching into a gap and advances only on explicit play', () => {
setSources(20);
time = 12;
component.onTimeUpdate();
component.togglePlay();
component.selectSource('screen');
fixture.detectChanges();
play.calls.reset();
video.dispatchEvent(new Event('loadedmetadata'));
expect(component.currentTime()).toBe(12);
expect(component.gapMessage()).toContain('未录制');
expect(paused).toBeTrue();
expect(play).not.toHaveBeenCalled();
component.togglePlay();
finishSeek();
expect(component.currentTime()).toBe(20);
expect(component.gapMessage()).toBe('');
expect(paused).toBeFalse();
});
it('retains a paused source switch and resumes with one play click', () => {
setSources();
time = 6;
component.onTimeUpdate();
component.selectSource('screen');
fixture.detectChanges();
video.dispatchEvent(new Event('loadedmetadata'));
finishSeek();
expect(component.currentTime()).toBe(6);
expect(paused).toBeTrue();
expect(play).not.toHaveBeenCalled();
component.togglePlay();
expect(play).toHaveBeenCalledTimes(1);
expect(paused).toBeFalse();
});
it('does not play another source when the requested source has no recording', () => {
setSources();
agentService.videoSeekRequest.set({ seconds: 5, requestId: 4, sourceId: 'missing-window' });
fixture.detectChanges();
component.togglePlay();
expect(component.gapMessage()).toContain('来源没有录像');
expect(component.currentTime()).toBe(5);
expect(paused).toBeTrue();
expect(play).not.toHaveBeenCalled();
});
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();
});
});
@@ -25,6 +25,7 @@ import {
ViewChild, ViewChild,
HostListener, HostListener,
effect, effect,
untracked,
OnDestroy OnDestroy
} from '@angular/core'; } from '@angular/core';
@@ -33,7 +34,8 @@ import { AgentService } from '../../services/agent.service';
import { StepReplayFrame } from '../../core/models/stream.model'; 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 { locateCoveredSessionTime, 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',
@@ -60,8 +62,29 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
public isMuted = signal<boolean>(false); public isMuted = signal<boolean>(false);
public isTheaterMode = signal<boolean>(false); public isTheaterMode = signal<boolean>(false);
public videoLoadError = signal<boolean>(false); public videoLoadError = signal<boolean>(false);
public gapMessage = signal('');
public skippedInterval = signal('');
private sourceSwitch: { time: number; play: boolean } | null = null;
private lastHandledVideoSeekRequestId: number | null = null;
private gapCanAdvance = true;
public sources = computed(() => this.agentService.recordingSources?.() || []);
public liveStreamUrl = signal<string>('/api/stream/device-live'); public liveStreamUrl = signal<string>('/api/stream/device-live');
public liveStreamError = signal<boolean>(false); public liveStreamError = signal<boolean>(false);
public pageVisible = signal(!document.hidden);
@HostListener('document:visibilitychange')
@HostListener('window:pageshow')
onPageVisibilityChange(): void {
this.pageVisible.set(!document.hidden);
if (!document.hidden && this.agentService.recordingPlaybackStatus() === 'live') {
this.retryLiveStream();
}
}
@HostListener('window:pagehide')
onPageHide(): void {
this.pageVisible.set(false);
}
public activeSegmentIndex = signal<number>(0); public activeSegmentIndex = signal<number>(0);
public currentVideoUrl = computed(() => { public currentVideoUrl = computed(() => {
const segments = this.agentService.activeVideoSegments(); const segments = this.agentService.activeVideoSegments();
@@ -70,11 +93,14 @@ 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 waitingForMetadata = true;
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);
public isStepPlaying = signal<boolean>(false); public isStepPlaying = signal<boolean>(false);
public viewMode = signal<'pre' | 'post'>('pre'); // 'pre' = action preview with coords, 'post' = action result public viewMode = signal<'pre' | 'post'>('post'); // default to the observed result
public isCardHovered = signal<boolean>(false); public isCardHovered = signal<boolean>(false);
public isCardPinned = signal<boolean>(false); public isCardPinned = signal<boolean>(false);
public isCardVisible = computed(() => this.isCardHovered() || this.isCardPinned()); public isCardVisible = computed(() => this.isCardHovered() || this.isCardPinned());
@@ -83,6 +109,33 @@ 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 missingRecordingLabels = computed(() => {
const sources = this.sources();
return [...new Set(this.stepFrames()
.filter(frame => frame.sourceId && frame.sourceId !== 'android' && !sources.some(source =>
(source.id === frame.sourceId || source.target_ids.includes(frame.sourceId!)) &&
(source.segments.length > 0 || source.url)))
.map(frame => frame.sourceLabel || '插件'))];
});
public recordedDuration = computed(() => this.agentService.activeVideoSegments()
.reduce((sum, segment) => sum + segment.duration, 0));
public hasRecordingGaps = computed(() => !!this.agentService.usesSessionTimeline?.() &&
this.agentService.activeVideoSegments().length > 0 && this.duration() - this.recordedDuration() > 0.1);
public recordedCoverage = computed(() => this.agentService.activeVideoSegments().map(segment => ({
start: segment.start,
left: this.duration() > 0 ? segment.start / this.duration() * 100 : 0,
width: this.duration() > 0 ? segment.duration / this.duration() * 100 : 0
})));
public videoStepMarkers = computed(() => buildVideoStepMarkers(
this.stepFrames().filter(frame => {
const source = this.sources().find(s => s.id === this.agentService.selectedRecordingSource?.());
return !source || source.target_ids.includes(frame.sourceId || 'android');
}),
this.agentService.currentSession()?.start_time,
this.agentService.activeVideoSegments(),
this.duration(),
this.agentService.usesSessionTimeline?.() || false
));
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();
@@ -127,10 +180,26 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
const url = this.agentService.activeVideoUrl(); const url = this.agentService.activeVideoUrl();
const segments = this.agentService.activeVideoSegments(); const segments = this.agentService.activeVideoSegments();
this.videoLoadError.set(false); this.videoLoadError.set(false);
this.waitingForMetadata = true;
this.activeSegmentIndex.set(0); this.activeSegmentIndex.set(0);
this.currentTime.set(0); const switching = this.sourceSwitch;
this.duration.set(segments.reduce((sum, segment) => sum + segment.duration, 0)); this.sourceSwitch = null;
this.currentTime.set(switching?.time || 0);
this.gapMessage.set('');
this.skippedInterval.set('');
this.gapCanAdvance = true;
this.duration.set(this.agentService.usesSessionTimeline?.()
? Math.max(this.agentService.recordingSessionDuration?.() || 0, ...segments.map(s => s.start + s.duration))
: segments.reduce((sum, segment) => sum + segment.duration, 0));
this.isPlaying.set(false); this.isPlaying.set(false);
this.pendingLocalTime = null;
this.pendingAutoplay = false;
if (switching) {
this.pendingAbsoluteSeek = switching.time;
this.pendingAutoplay = switching.play;
}
this.seekPlaybackIntent = null;
this.playRequestId++;
}, },
{ allowSignalWrites: true } { allowSignalWrites: true }
); );
@@ -144,7 +213,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
if (curSessionId !== lastTrackedSessionId) { if (curSessionId !== lastTrackedSessionId) {
lastTrackedSessionId = curSessionId; lastTrackedSessionId = curSessionId;
this.activeStepIndex.set(0); this.activeStepIndex.set(0);
this.viewMode.set('pre'); this.viewMode.set('post');
this.pauseStepPlay(); this.pauseStepPlay();
} else if (total > 0 && this.activeStepIndex() >= total) { } else if (total > 0 && this.activeStepIndex() >= total) {
this.activeStepIndex.set(total - 1); this.activeStepIndex.set(total - 1);
@@ -173,15 +242,42 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
effect(() => { effect(() => {
const request = this.agentService.videoSeekRequest(); const request = this.agentService.videoSeekRequest();
if (!request) return; const status = this.agentService.recordingPlaybackStatus();
const sources = this.sources();
if (!request || status !== 'ready' || request.requestId === this.lastHandledVideoSeekRequestId) return;
// A seek is a command, not a binding to playback/source state. Reading
// those signals in this effect would replay the command after play/pause
// or a manual source selection.
untracked(() => {
this.lastHandledVideoSeekRequestId = request.requestId;
this.pendingAbsoluteSeek = request.seconds; this.pendingAbsoluteSeek = request.seconds;
if (this.agentService.recordingPlaybackStatus() !== 'ready') return; if (request.sourceId && sources.length) {
const video = this.videoRef?.nativeElement; const source = sources.find(s => s.id === request.sourceId || s.target_ids.includes(request.sourceId!));
if (video && video.readyState >= 1) { if (!source) {
const target = this.pendingAbsoluteSeek;
this.pendingAbsoluteSeek = null; this.pendingAbsoluteSeek = null;
this.seekToSessionTime(target, true); this.pendingLocalTime = null;
this.pendingAutoplay = false;
this.seekPlaybackIntent = null;
this.playRequestId++;
this.gapCanAdvance = false;
this.currentTime.set(request.seconds);
this.gapMessage.set('此步骤的来源没有录像,请查看步骤截图或日志。');
this.videoRef?.nativeElement.pause();
this.isPlaying.set(false);
return;
} }
if (source.id !== this.agentService.selectedRecordingSource?.()) {
this.sourceSwitch = { time: request.seconds, play: this.isPlaying() };
this.agentService.selectRecordingSource(source.id);
return;
}
}
const video = this.videoRef?.nativeElement;
if (video && video.readyState >= 1 && !this.waitingForMetadata) {
this.pendingAbsoluteSeek = null;
this.seekToSessionTime(request.seconds, this.isPlaying());
}
});
}); });
effect(() => { effect(() => {
@@ -292,6 +388,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
public onLoadedMetadata(): void { public onLoadedMetadata(): void {
const v = this.videoRef?.nativeElement; const v = this.videoRef?.nativeElement;
if (v) { if (v) {
this.waitingForMetadata = false;
const segments = this.agentService.activeVideoSegments(); const segments = this.agentService.activeVideoSegments();
if (!segments.length) { if (!segments.length) {
this.duration.set(v.duration || 0); this.duration.set(v.duration || 0);
@@ -300,33 +397,34 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
v.playbackRate = this.playbackRate(); v.playbackRate = this.playbackRate();
v.loop = this.isLooping() && !segments.length; v.loop = this.isLooping() && !segments.length;
v.muted = this.isMuted(); v.muted = this.isMuted();
const autoplay = this.agentService.consumeVideoAutoplay();
if (autoplay) {
this.isMuted.set(true);
v.muted = true;
}
const shouldPlay = this.pendingAutoplay || autoplay;
this.pendingAutoplay = false;
if (this.pendingAbsoluteSeek !== null) { if (this.pendingAbsoluteSeek !== null) {
const target = this.pendingAbsoluteSeek; const target = this.pendingAbsoluteSeek;
this.pendingAbsoluteSeek = null; this.pendingAbsoluteSeek = null;
this.seekToSessionTime(target, true); this.seekToSessionTime(target, shouldPlay);
// A seek into another finalized segment changes the video source. Let // Same-source seeks resume on seeked; cross-segment seeks resume after
// that segment's metadata event apply pendingLocalTime. // the destination metadata and seeked. Never play the old frame here.
if (this.pendingLocalTime !== null) return; return;
} }
if (this.pendingLocalTime !== null) { if (this.pendingLocalTime !== null) {
v.currentTime = Math.max(0, Math.min(v.duration || this.pendingLocalTime, this.pendingLocalTime)); const localTime = Math.max(0, Math.min(v.duration || this.pendingLocalTime, this.pendingLocalTime));
this.pendingLocalTime = null; this.pendingLocalTime = null;
this.seekWithinVideo(localTime, shouldPlay);
} else if (shouldPlay) {
this.playVideo();
} }
if (this.pendingAutoplay) { if (this.pendingLocalTime === null) this.onTimeUpdate();
this.pendingAutoplay = false;
v.play().catch(() => {});
}
if (this.agentService.consumeVideoAutoplay()) {
this.isMuted.set(true);
v.muted = true;
v.play().catch(() => {
this.isPlaying.set(false);
});
}
} }
} }
public onTimeUpdate(): void { public onTimeUpdate(): void {
if (this.gapMessage() || this.pendingLocalTime !== null) return;
const v = this.videoRef?.nativeElement; const v = this.videoRef?.nativeElement;
if (v) { if (v) {
const segments = this.agentService.activeVideoSegments(); const segments = this.agentService.activeVideoSegments();
@@ -339,29 +437,57 @@ 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; const current = segments[this.activeSegmentIndex()];
this.pendingAutoplay = true; const next = segments[this.activeSegmentIndex() + 1];
this.activeSegmentIndex.update(index => index + 1); this.seek(next.start, true);
if (this.agentService.usesSessionTimeline?.() && next.start - current.start - current.duration > 0.05) {
this.showSkippedInterval(current.start + current.duration, next.start);
}
return; return;
} }
if (segments.length && this.isLooping()) { if (segments.length && this.isLooping()) {
this.seek(0, true); this.restart();
return; return;
} }
const last = segments[segments.length - 1];
if (last && this.agentService.usesSessionTimeline?.()) {
this.currentTime.set(last.start + last.duration);
if (this.duration() - this.currentTime() > 0.05) {
this.skippedInterval.set('录像已结束,之后的任务时间未录制。');
}
}
this.isPlaying.set(false); this.isPlaying.set(false);
} }
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);
} }
@@ -370,17 +496,73 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
* Playback actions * Playback actions
*/ */
public togglePlay(): void { public togglePlay(): void {
if (this.gapMessage()) {
if (!this.gapCanAdvance) return;
const next = this.agentService.activeVideoSegments().find(s => s.start > this.currentTime());
if (next) {
const from = this.currentTime();
this.seek(next.start, true);
this.showSkippedInterval(from, next.start);
}
return;
}
const v = this.videoRef?.nativeElement; const v = this.videoRef?.nativeElement;
if (!v) return; if (!v) return;
if (v.ended && this.agentService.activeVideoSegments().length) {
this.restart();
return;
}
if (this.pendingLocalTime !== null) {
this.pendingAutoplay = !this.isPlaying();
this.isPlaying.set(this.pendingAutoplay);
return;
}
if (this.seekPlaybackIntent !== null) {
this.seekPlaybackIntent = !this.isPlaying();
this.isPlaying.set(this.seekPlaybackIntent);
return;
}
if (v.paused) { if (v.paused) {
v.play().catch(() => {}); this.playVideo();
} else { } else {
this.playRequestId++;
v.pause(); 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 {
this.seek(0, true); const first = this.agentService.activeVideoSegments()[0];
this.seek(this.agentService.usesSessionTimeline?.() ? first?.start || 0 : 0, true);
} }
/** /**
@@ -391,29 +573,66 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
*/ */
public seekToSessionTime(sessionSeconds: number, autoplay = false): void { public seekToSessionTime(sessionSeconds: number, autoplay = false): void {
const segments = this.agentService.activeVideoSegments(); const segments = this.agentService.activeVideoSegments();
this.seek(sessionTimeToTimelineTime(segments, sessionSeconds), autoplay); this.seek(this.agentService.usesSessionTimeline?.() ? sessionSeconds : sessionTimeToTimelineTime(segments, sessionSeconds), autoplay);
} }
/** Seek on the back-to-back playlist axis used by the scrubber. */ public selectSource(id: string): void {
if (id === this.agentService.selectedRecordingSource?.()) return;
this.sourceSwitch = { time: this.currentTime(), play: this.isPlaying() };
if (!this.agentService.selectRecordingSource(id)) this.sourceSwitch = null;
}
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 exactly: a step/source request must never display unrelated evidence. */
public seek(seconds: number, autoplay = false): void { public seek(seconds: number, autoplay = false): void {
const v = this.videoRef?.nativeElement; const v = this.videoRef?.nativeElement;
if (!v) return; if (!v) return;
this.skippedInterval.set('');
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); // URL-only legacy recordings have no coverage manifest. Preserve their
if (!location) { // native video seeking instead of treating every timestamp as a gap.
v.currentTime = target; const sessionTimeline = this.agentService.usesSessionTimeline?.() && segments.length > 0;
if (autoplay) v.play().catch(() => {}); const location = sessionTimeline ? locateCoveredSessionTime(segments, target) : locateTimelineTime(segments, target);
if (sessionTimeline && !location) {
this.playRequestId++;
this.pendingLocalTime = null;
this.pendingAutoplay = false;
this.seekPlaybackIntent = null;
v.pause();
this.isPlaying.set(false);
this.currentTime.set(target);
this.gapCanAdvance = true;
this.gapMessage.set('此时间段未录制(尚未开始、暂停或录制中断),可查看步骤截图与日志。');
return; return;
} }
const { index, localTime } = location; this.gapMessage.set('');
if (index === this.activeSegmentIndex()) { const shouldPlay = autoplay || this.pendingAutoplay || (this.seekPlaybackIntent ?? !v.paused);
v.currentTime = localTime; if (!location) {
if (autoplay) v.play().catch(() => {}); this.seekWithinVideo(target, shouldPlay);
} else if (location.index === this.activeSegmentIndex() && this.pendingLocalTime === null) {
this.seekWithinVideo(location.localTime, shouldPlay);
} 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.waitingForMetadata = true;
this.activeSegmentIndex.set(location.index);
} }
this.currentTime.set(target); this.currentTime.set(target);
} }
@@ -421,9 +640,22 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
public onScrub(event: Event): void { public onScrub(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const targetTime = parseFloat(input.value); const targetTime = parseFloat(input.value);
const segments = this.agentService.activeVideoSegments();
if (this.agentService.usesSessionTimeline?.() && segments.length && !locateCoveredSessionTime(segments, targetTime)) {
const next = segments.find(segment => segment.start > targetTime);
if (next) {
this.seek(next.start);
this.showSkippedInterval(targetTime, next.start);
return;
}
}
this.seek(targetTime); this.seek(targetTime);
} }
private showSkippedInterval(from: number, to: number): void {
this.skippedInterval.set(`已跳过未录制区间 ${this.formatTime(from)}–${this.formatTime(to)}`);
}
public setSpeed(rate: number): void { public setSpeed(rate: number): void {
this.playbackRate.set(rate); this.playbackRate.set(rate);
const v = this.videoRef?.nativeElement; const v = this.videoRef?.nativeElement;
@@ -621,6 +853,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;
@@ -0,0 +1,80 @@
@if (sessionId()) {
<section class="model-requests" aria-label="模型请求状态">
@for (review of activeReviews(); track review.review_id) {
<div class="request-live review-live" [class.stale]="unavailable()" aria-live="polite">
<div class="request-heading">
<span class="status-dot" [class.waiting]="!unavailable()"></span>
<strong>{{ unavailable() ? '验收状态暂时无法更新' : reviewStage(review) }}</strong>
<span class="elapsed">验收已用 {{ reviewElapsed(review) }} 秒</span>
</div>
@if (!unavailable()) {
<p>完整结论确认前,测试仍为待判定。剩余验收预算 {{ reviewRemaining(review) }} 秒 / 总计 {{ review.total_seconds }} 秒。</p>
<p class="notice">已发起 {{ review.request_count }} 次模型请求,已重试 {{ review.retry_count }} 次;重试复用已有证据。</p>
}
</div>
}
@for (request of active(); track request.attempt_id) {
<div class="request-live" [class.retrying]="request.phase === 'retry_wait'" [class.stale]="unavailable()">
<div class="request-heading" aria-live="polite">
<span class="status-dot" [class.waiting]="!unavailable()"></span>
<strong>{{ unavailable() ? '请求状态暂时无法更新' : phase(request) }}</strong>
<span class="model">{{ request.model || '模型未记录' }} · {{ role(request) }}{{ request.is_fallback ? ' · 兜底模型' : '' }}</span>
<span class="elapsed">已用 {{ elapsed(request) }} 秒</span>
</div>
@if (!unavailable()) {
<p>{{ hint(request, now()) }} @if (request.phase !== 'retry_wait') { 本次请求最长等待 {{ request.timeout_seconds }} 秒。 }</p>
}
@if (request.error) { <p class="error">{{ request.error }}</p> }
</div>
}
@if (unavailable()) { <p class="notice" role="status">诊断信息暂时不可用;显示的是上次记录,不能据此判断任务是否卡住。</p> }
<details>
<summary>模型请求诊断 @if (requests().length) { <span>· 最近 {{ requests().length }} 次尝试</span> }</summary>
<div class="diagnostics-header"><span>时间按本机时区显示;下载文件同时包含 UTC 时间。</span><a [href]="downloadUrl()" download>下载完整诊断</a></div>
@if (loading()) { <p class="notice">正在加载请求记录…</p> }
@if (!loading() && !unavailable() && !requests().length) { <p class="notice">暂无请求诊断。旧任务没有记录的时间和用量无法补推。</p> }
<div class="request-history">
@for (review of reviews(); track review.review_id) {
<details class="request-detail review-detail">
<summary>{{ review.started_at * 1000 | date:'HH:mm:ss' }} · 最终验收 · {{ reviewStage(review) }}</summary>
<dl>
<dt>验收编号</dt><dd>{{ review.review_id }} · {{ review.attempt_id }}</dd>
<dt>验收预算</dt><dd>总计 {{ review.total_seconds }} 秒 · 已用 {{ reviewElapsed(review) }} 秒</dd>
<dt>模型请求</dt><dd>{{ review.request_count }} 次,实际重试 {{ review.retry_count }} 次;每个请求最多重试 {{ review.max_retries }} 次,总预算内执行</dd>
<dt>开始时间</dt><dd>{{ review.started_at * 1000 | date:'yyyy-MM-dd HH:mm:ss.SSS Z' }}</dd>
<dt>预算截止</dt><dd>{{ review.deadline_at * 1000 | date:'yyyy-MM-dd HH:mm:ss.SSS Z' }}</dd>
<dt>结束时间</dt><dd>{{ review.ended_at ? (review.ended_at * 1000 | date:'HH:mm:ss.SSS') : '未记录' }}</dd>
@if (review.last_failure_category) { <dt>最近错误类别</dt><dd>{{ review.last_failure_category }}</dd> }
@if (review.status === 'budget_exhausted') { <dt>结束原因</dt><dd class="error">取证、模型请求与重试已耗尽验收总预算;未确认通过。</dd> }
@if (review.status === 'request_retries_exhausted') { <dt>结束原因</dt><dd class="error">验收模型请求重试已用尽;未确认通过,没有重跑设备操作。</dd> }
@if (review.status === 'cancelled') { <dt>结束原因</dt><dd>验收被停止或指导中断,不作为请求超时重试。</dd> }
</dl>
</details>
}
@for (request of requests(); track request.attempt_id) {
<details class="request-detail">
<summary><span>{{ request.started_at * 1000 | date:'HH:mm:ss' }}</span> {{ request.model || '模型' }} · {{ role(request) }} · {{ phase(request) }} @if (!request.active && !request.ended_at) { <span>(未记录结束,当前未在执行)</span> } @if (request.is_fallback) { <span class="fallback">兜底</span> }</summary>
<dl>
<dt>本地请求编号</dt><dd>{{ request.request_id }}</dd>
<dt>本次尝试编号</dt><dd>{{ request.attempt_id }}</dd>
<dt>服务端请求编号</dt><dd>{{ request.server_request_id || '服务未提供' }}</dd>
<dt>模型配置</dt><dd>{{ request.model || '未记录' }} / {{ request.reasoning_effort || '默认' }} · 第 {{ request.attempt }} 次请求,最多重试 {{ request.max_retries }} 次</dd>
@if (request.final_review; as review) { <dt>所属最终验收</dt><dd>{{ review.review_id }} · {{ reviewStage(review) }} · 请求记录时剩余 {{ review.remaining_seconds }} 秒</dd> }
<dt>输出 Token 上限</dt><dd>{{ request.max_output_tokens || '未设置(由模型服务决定)' }}</dd>
<dt>有效输出片段</dt><dd>{{ request.output_chunks ?? '未记录' }}(客户端计数,与服务端 SSE 事件数口径不同)</dd>
<dt>工具参数</dt><dd>{{ request.tool_argument_chars ?? '未记录' }} 字符 @for (tool of request.tools || []; track $index) { <span class="image-size">{{ tool.name || '工具名未提供' }} · {{ tool.argument_chars }} 字符</span> }</dd>
<dt>请求开始</dt><dd>{{ request.started_at * 1000 | date:'yyyy-MM-dd HH:mm:ss.SSS Z' }}</dd>
<dt>收到响应头</dt><dd>{{ request.headers_at ? (request.headers_at * 1000 | date:'HH:mm:ss.SSS') : '未记录' }} · HTTP {{ request.http_status || '未知' }}</dd>
<dt>首个有效输出</dt><dd>{{ request.first_output_at ? (request.first_output_at * 1000 | date:'HH:mm:ss.SSS') : '未收到/未记录' }}</dd>
<dt>最后有效输出</dt><dd>{{ request.last_output_at ? (request.last_output_at * 1000 | date:'HH:mm:ss.SSS') : '未收到/未记录' }}</dd>
<dt>结束时间</dt><dd>{{ request.ended_at ? (request.ended_at * 1000 | date:'HH:mm:ss.SSS') : '尚未记录' }}</dd>
<dt>输入图片</dt><dd>{{ request.images.length }} 张 @for (image of request.images; track $index) { <span class="image-size">{{ image.width && image.height ? image.width + ' × ' + image.height : '尺寸未知' }}</span> }</dd>
<dt>Token 用量</dt><dd>{{ request.usage ? '输入 ' + request.usage.prompt_tokens + ' / 输出 ' + request.usage.completion_tokens + ' / 合计 ' + request.usage.total_tokens : '未知(服务未报告)' }}</dd>
@if (request.error) { <dt>失败原因</dt><dd class="error">{{ request.error_type }} · {{ request.error }} · 阶段 {{ phase(request.failure_phase || '') }} @if (request.category) { · {{ request.category }} }</dd> }
</dl>
</details>
}
</div>
</details>
</section>
}
@@ -0,0 +1,26 @@
:host { display: block; }
.model-requests { margin: 10px 0 16px; color: #475569; font-size: 12px; }
.request-live { padding: 12px 14px; border: 1px solid #dbeafe; border-radius: 10px; background: #f8fbff; margin-bottom: 8px; }
.request-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
strong { color: #334155; font-size: 13px; }
.model { color: #64748b; overflow-wrap: anywhere; }
.elapsed { margin-left: auto; color: #1d4ed8; font-variant-numeric: tabular-nums; }
.status-dot { height: 7px; width: 7px; border-radius: 50%; background: #64748b; flex: 0 0 auto; }
.waiting { background: #3b82f6; animation: pulse 1.5s ease-in-out infinite; }
.retrying { border-color: #fde68a; background: #fffbeb; .elapsed { color: #92400e; } .waiting { background: #d97706; } }
.stale { border-color: #cbd5e1; background: #f8fafc; }
p { margin: 6px 0 0; line-height: 1.7; }
.error { color: #b45309; overflow-wrap: anywhere; }
summary { cursor: pointer; padding: 5px 0; line-height: 1.7; } summary:hover { color: #2563eb; }
summary:focus-visible, a:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
.diagnostics-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin: 8px 0; color: #64748b; }
a { color: #2563eb; text-decoration: none; border: 1px solid #dbeafe; border-radius: 6px; padding: 4px 9px; }
.request-history { max-height: 360px; overflow-y: auto; }
.request-detail { border-top: 1px solid #e2e8f0; padding: 4px 0; }
dl { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 7px 10px; margin: 8px 0 12px; line-height: 1.6; }
dt { color: #64748b; } dd { margin: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.image-size, .fallback { display: inline-block; padding: 0 5px; margin: 2px 3px; background: #f1f5f9; border-radius: 4px; }
.notice { color: #64748b; }
@keyframes pulse { 50% { opacity: .35; } }
@media (prefers-reduced-motion: reduce) { .waiting { animation: none; } }
@media (max-width: 480px) { dl { grid-template-columns: 90px minmax(0, 1fr); } .elapsed { margin-left: 15px; } }
@@ -0,0 +1,121 @@
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { FinalReview, LlmRequestStatusComponent, ModelRequest, requestHint, reviewStage } from './llm-request-status.component';
const waiting: ModelRequest = {
request_id: 'call-1', attempt_id: 'attempt-1', model: 'gpt-6-luna', role: 'FlashRunner',
is_fallback: false, phase: 'waiting_output', active: true, attempt: 1, max_retries: 5,
timeout_seconds: 300, elapsed_seconds: 42, started_at: Date.now() / 1000 - 42,
http_status: 200, usage: null, images: []
};
describe('模型请求诊断', () => {
beforeEach(() => TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] }));
afterEach(() => TestBed.inject(HttpTestingController).verify());
it('收到200仍显示等待,未知用量不当作0;完成后取消轮询', fakeAsync(() => {
const fixture = TestBed.createComponent(LlmRequestStatusComponent);
fixture.componentRef.setInput('sessionId', 'task-one');
fixture.componentRef.setInput('running', true);
fixture.detectChanges();
const http = TestBed.inject(HttpTestingController);
http.expectOne('/api/sessions/task-one/request-diagnostics').flush({ requests: [waiting] });
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.request-live').textContent).toContain('等待模型输出');
expect(fixture.nativeElement.textContent).toContain('未知(服务未报告)');
expect(fixture.nativeElement.querySelector('details').open).toBeFalse();
tick(3000);
http.expectOne('/api/sessions/task-one/request-diagnostics').flush({ requests: [{ ...waiting, active: false, phase: 'completed', ended_at: Date.now() / 1000 }] });
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.request-live')).toBeNull();
fixture.destroy(); tick(5000);
http.expectNone('/api/sessions/task-one/request-diagnostics');
}));
it('切换会话取消旧请求,历史失败不会显示正在重试', () => {
const fixture = TestBed.createComponent(LlmRequestStatusComponent);
const http = TestBed.inject(HttpTestingController);
fixture.componentRef.setInput('sessionId', 'old'); fixture.detectChanges();
const old = http.expectOne('/api/sessions/old/request-diagnostics');
fixture.componentRef.setInput('sessionId', 'new'); fixture.detectChanges();
expect(old.cancelled).toBeTrue();
http.expectOne('/api/sessions/new/request-diagnostics').flush({ requests: [{ ...waiting, phase: 'retry_wait' }] });
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.request-live')).toBeNull();
expect(fixture.nativeElement.querySelector('a').href).toContain('/new/request-diagnostics?download=true');
fixture.destroy();
});
it('诊断接口失败时明确提示状态未知', () => {
const fixture = TestBed.createComponent(LlmRequestStatusComponent);
fixture.componentRef.setInput('sessionId', 'task'); fixture.detectChanges();
TestBed.inject(HttpTestingController).expectOne('/api/sessions/task/request-diagnostics').flush({}, { status: 503, statusText: 'Unavailable' });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('不能据此判断任务是否卡住');
fixture.destroy();
});
it('重试倒计时到0后不谎称已发送请求', () => {
expect(requestHint({ ...waiting, phase: 'retry_wait', retry_at: 110 }, 105)).toContain('5 秒后进行第 1/5 次重试');
expect(requestHint({ ...waiting, phase: 'retry_wait', retry_at: 110 }, 111)).toContain('等待下一次请求状态');
});
it('持续生成工具参数时展示工具名和字符数,不误报工具已执行', () => {
const fixture = TestBed.createComponent(LlmRequestStatusComponent);
fixture.componentRef.setInput('sessionId', 'tool-task');
fixture.componentRef.setInput('running', true);
fixture.detectChanges();
TestBed.inject(HttpTestingController).expectOne('/api/sessions/tool-task/request-diagnostics').flush({ requests: [{
...waiting, phase: 'generating_tool_arguments', tool_argument_chars: 15000,
tools: [{ name: 'click', argument_chars: 15000 }], output_chunks: 200
}] });
fixture.detectChanges();
const text = fixture.nativeElement.querySelector('.request-live').textContent;
expect(text).toContain('正在生成工具参数');
expect(text).toContain('click');
expect(text).toContain((15000).toLocaleString());
expect(text).toContain('本次工具尚未执行');
fixture.destroy();
});
it('无模型请求时仍展示验收阶段与总预算,耗尽后保留明确原因', fakeAsync(() => {
const fixture = TestBed.createComponent(LlmRequestStatusComponent);
const http = TestBed.inject(HttpTestingController);
const now = Date.now() / 1000;
const review: FinalReview = {
review_id: 'review-1', attempt_id: 'final#1', status: 'running', stage: 'reading_evidence', active: true,
started_at: now - 20, deadline_at: now + 80, total_seconds: 100, elapsed_seconds: 20, remaining_seconds: 80,
request_count: 2, retry_count: 1, max_retries: 5
};
fixture.componentRef.setInput('sessionId', 'review-task');
fixture.componentRef.setInput('running', true);
fixture.detectChanges();
http.expectOne('/api/sessions/review-task/request-diagnostics').flush({ requests: [], final_reviews: [review] });
fixture.detectChanges();
const text = fixture.nativeElement.querySelector('.review-live').textContent;
expect(text).toContain('正在读取验收证据');
expect(text).toContain('测试仍为待判定');
expect(text).toContain('剩余验收预算 80 秒');
expect(text).toContain('已重试 1 次');
tick(1000); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.review-live').textContent).toContain('剩余验收预算 79 秒');
tick(2000);
http.expectOne('/api/sessions/review-task/request-diagnostics').flush({ requests: [], final_reviews: [{
...review, active: false, status: 'budget_exhausted', ended_at: now + 3, elapsed_seconds: 100
}] });
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.review-live')).toBeNull();
expect(fixture.nativeElement.textContent).toContain('验收总预算已用尽');
expect(fixture.nativeElement.textContent).toContain('未确认通过');
fixture.destroy(); tick(3000);
http.expectNone('/api/sessions/review-task/request-diagnostics');
}));
it('区分重试耗尽、验收中断与完成,不把完成当成通过', () => {
const review = { status: 'request_retries_exhausted' } as FinalReview;
expect(reviewStage(review)).toBe('验收请求重试已用尽');
expect(reviewStage({ ...review, status: 'cancelled' })).toBe('验收已中断');
expect(reviewStage({ ...review, status: 'completed' })).toBe('验收完成');
});
});
@@ -0,0 +1,124 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, output, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Subscription } from 'rxjs';
export interface ModelRequest {
request_id: string; attempt_id: string; model?: string; reasoning_effort?: string;
role: string; is_fallback: boolean; phase: string; active: boolean;
attempt: number; max_retries: number; timeout_seconds: number; elapsed_seconds: number;
started_at: number; headers_at?: number; first_output_at?: number; last_output_at?: number;
ended_at?: number; retry_at?: number; http_status?: number; server_request_id?: string;
error?: string; error_type?: string; failure_phase?: string;
output_chunks?: number; tool_argument_chars?: number; max_output_tokens?: number | null;
tools?: { name: string | null; argument_chars: number }[];
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | null;
images: { width: number | null; height: number | null }[];
final_review?: FinalReview;
category?: string;
}
export interface FinalReview {
review_id: string; attempt_id: string; status: string; stage: string; active: boolean;
started_at: number; deadline_at: number; ended_at?: number;
total_seconds: number; elapsed_seconds: number; remaining_seconds: number;
request_count: number; retry_count: number; request_attempt?: number; max_retries: number;
request_phase?: string; last_failure_category?: string;
}
export function reviewStage(review: FinalReview): string {
if (review.status !== 'running') {
return ({ completed: '验收完成', budget_exhausted: '验收总预算已用尽', request_retries_exhausted: '验收请求重试已用尽', cancelled: '验收已中断', failed: '验收未完成' } as Record<string, string>)[review.status] || '验收状态待核实';
}
return ({ preparing_evidence: '正在准备验收证据', reviewing_evidence: '正在核对证据与结论', reading_evidence: '正在读取验收证据', structuring_verdict: '正在整理验收结论', validating_verdict: '正在校验完整结论' } as Record<string, string>)[review.stage] || '正在进行最终验收';
}
export function requestPhase(request: ModelRequest | string): string {
const phase = typeof request === 'string' ? request : request.phase;
return ({ waiting_response: '等待模型响应', waiting_output: '等待模型输出', receiving_output: '正在接收模型输出', generating_tool_arguments: '正在生成工具参数', retry_wait: '等待重试', completed: '请求完成', failed: '请求失败', cancelled: '请求已取消' } as Record<string, string>)[phase] || '请求状态待核实';
}
export function requestHint(request: ModelRequest, now: number): string {
if (request.phase === 'retry_wait') {
const remaining = Math.max(0, Math.ceil((request.retry_at || now) - now));
return remaining ? `${remaining} 秒后进行第 ${request.attempt}/${request.max_retries} 次重试。` : '重试等待已结束,正在等待下一次请求状态。';
}
if (request.phase === 'waiting_output') return '已收到服务响应,尚未收到有效模型输出。';
if (request.phase === 'generating_tool_arguments') {
const names = (request.tools || []).map(tool => tool.name).filter(Boolean).join('、');
return `${names ? names + ' · ' : ''}已接收 ${(request.tool_argument_chars || 0).toLocaleString()} 个参数字符,最近输出在 ${Math.max(0, Math.floor(now - (request.last_output_at || now)))} 秒前;本次工具尚未执行。`;
}
if (request.phase === 'receiving_output') return `最近一次输出在 ${Math.max(0, Math.floor(now - (request.last_output_at || now)))} 秒前;完整响应仍在接收中。`;
return '正在等待服务响应;尚不能确认模型是否开始生成。';
}
@Component({
selector: 'app-llm-request-status', standalone: true, imports: [DatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './llm-request-status.component.html', styleUrl: './llm-request-status.component.scss'
})
export class LlmRequestStatusComponent {
private http = inject(HttpClient);
sessionId = input<string | null>(null);
running = input(false);
activeChange = output<boolean>();
requests = signal<ModelRequest[]>([]);
reviews = signal<FinalReview[]>([]);
now = signal(Date.now() / 1000);
unavailable = signal(false);
loading = signal(false);
active = computed(() => this.running() ? this.requests().filter(r => r.active) : []);
activeReviews = computed(() => this.running() ? this.reviews().filter(r => r.active) : []);
downloadUrl = computed(() => `/api/sessions/${encodeURIComponent(this.sessionId() || '')}/request-diagnostics?download=true`);
phase = requestPhase;
hint = requestHint;
reviewStage = reviewStage;
constructor() {
effect(() => this.activeChange.emit(this.active().length > 0 || this.activeReviews().length > 0));
effect(onCleanup => {
const id = this.sessionId();
const running = this.running();
this.requests.set([]);
this.reviews.set([]);
this.unavailable.set(false);
this.loading.set(!!id);
if (!id) return;
let stopped = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let subscription: Subscription | undefined;
const schedule = () => { if (running && !stopped) timer = setTimeout(load, 3000); };
const load = () => {
subscription = this.http.get<{ requests: ModelRequest[]; final_reviews?: FinalReview[] }>(`/api/sessions/${encodeURIComponent(id)}/request-diagnostics`).subscribe({
next: data => {
this.requests.set(data.requests || []);
this.reviews.set(data.final_reviews || []);
this.unavailable.set(false);
this.loading.set(false);
schedule();
},
error: () => { this.unavailable.set(true); this.loading.set(false); schedule(); }
});
};
load();
const clock = running ? setInterval(() => this.now.set(Date.now() / 1000), 1000) : undefined;
onCleanup(() => { stopped = true; clearTimeout(timer); clearInterval(clock); subscription?.unsubscribe(); });
});
}
elapsed(request: ModelRequest): number {
return Math.max(0, Math.floor(request.ended_at ? request.elapsed_seconds : this.now() - request.started_at));
}
reviewElapsed(review: FinalReview): number {
return Math.max(0, Math.floor(review.ended_at ? review.elapsed_seconds : this.now() - review.started_at));
}
reviewRemaining(review: FinalReview): number {
return Math.max(0, Math.ceil(review.deadline_at - this.now()));
}
role(request: ModelRequest): string {
return ({ FlashRunner: '任务执行', operator: '任务执行', planner: '规划', checker: '结果检查', outputter: '报告整理', step_summarizer: '步骤整理' } as Record<string, string>)[request.role] || request.role;
}
}
@@ -0,0 +1,28 @@
import { TestBed } from '@angular/core/testing';
import { ModelSelectorComponent } from './model-selector.component';
describe('Shared model selector', () => {
it('preserves a saved custom or retired model without silently replacing it', () => {
const fixture = TestBed.createComponent(ModelSelectorComponent);
fixture.componentRef.setInput('value', 'mimo-v2.6-flash');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('select').value).toBe('__custom__');
expect(fixture.nativeElement.querySelector('input').value).toBe('mimo-v2.6-flash');
expect(Array.from(fixture.nativeElement.querySelectorAll('option'), (o: any) => o.value))
.not.toContain('mimo-v2.6-flash');
});
it('allows editing custom names even when the typed name matches a preset', () => {
const fixture = TestBed.createComponent(ModelSelectorComponent);
fixture.componentRef.setInput('value', 'gpt-6-luna');
fixture.detectChanges();
const select: HTMLSelectElement = fixture.nativeElement.querySelector('select');
select.value = '__custom__'; select.dispatchEvent(new Event('change')); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('input').value).toBe('gpt-6-luna');
fixture.componentRef.setInput('value', 'gpt-6.1-sol'); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('input').value).toBe('gpt-6.1-sol');
fixture.componentRef.setInput('disabled', true); fixture.detectChanges();
expect(select.disabled).toBeTrue();
expect(fixture.nativeElement.querySelector('input').disabled).toBeTrue();
});
});
@@ -0,0 +1,56 @@
import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core';
import { MODEL_PRESETS } from '../../core/model-presets';
let nextId = 0;
@Component({
selector: 'app-model-selector',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<label [for]="id">{{label()}}</label>
<div class="selector">
<select [id]="id" [value]="selection()" [disabled]="disabled()"
(change)="choose($any($event.target).value)">
@for (preset of presets(); track preset) { <option [value]="preset">{{preset}}</option> }
<option value="__custom__">自定义模型…</option>
</select>
<span class="material-symbols-outlined" aria-hidden="true">expand_more</span>
</div>
@if (selection() === '__custom__') {
<input type="text" [value]="value()" (input)="valueChange.emit($any($event.target).value)"
[attr.aria-label]="label() + '(自定义)'" [disabled]="disabled()" required maxlength="200"
autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="输入接口支持的模型名称" />
}
`,
styles: [`
:host { display: grid; gap: 7px; min-width: 0; color: #334155; font-size: 13px; }
label { font-weight: 600; line-height: 1.5; }
.selector { position: relative; min-width: 0; }
select, input { box-sizing: border-box; width: 100%; min-width: 0; min-height: 40px;
padding: 10px 12px; border: 1px solid #dbe3ec; border-radius: 10px; background: #fff;
color: inherit; font: inherit; }
select { appearance: none; padding-right: 36px; cursor: pointer; text-overflow: ellipsis; }
.selector > span { position: absolute; right: 11px; top: 50%; transform: translateY(-50%);
pointer-events: none; font-size: 19px; color: #64748b; }
select:hover:not(:disabled), input:hover:not(:disabled) { border-color: #a5b4fc; }
select:focus-visible, input:focus-visible { outline: 2px solid #a5b4fc; outline-offset: 2px; }
select:disabled, input:disabled { opacity: .5; cursor: default; }
option { background: #fff; color: #334155; }
`]
})
export class ModelSelectorComponent {
readonly id = `model-selector-${nextId++}`;
label = input('模型');
value = input.required<string>();
presets = input<string[]>(MODEL_PRESETS);
disabled = input(false);
valueChange = output<string>();
private custom = signal(false);
selection = computed(() => this.custom() || !this.presets().includes(this.value()) ? '__custom__' : this.value());
choose(value: string): void {
this.custom.set(value === '__custom__');
if (value !== '__custom__') this.valueChange.emit(value);
}
}
@@ -1,13 +1,26 @@
import { Component, inject, OnInit, signal, output } from '@angular/core'; import { Component, inject, OnInit, signal, output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { finalize } from 'rxjs'; import { finalize, timeout } from 'rxjs';
import { UI_READ_TIMEOUT_MS } from '../../core/http-timeouts';
interface Capabilities { interface Capabilities {
recording?: { codec: string; max_long_edge: number; fps: number; h265_kbps: number; h264_kbps: number; computer_scope: string; minimum_disk_mb: number; hevc_playback: boolean };
adb_auto_connect: boolean;
adb_discovery_interval_seconds: number;
adb_discovery_timeout_seconds: number;
video_enabled: boolean; video_enabled: boolean;
recording_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({
@@ -16,15 +29,47 @@ 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()" /> <label><input type="checkbox" role="switch" [(ngModel)]="config.recording_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>保存录屏<small>保留任务录像以便复盘,可单独开启,不要求启用视频分析。</small></span></label> <span>保存录屏<small>保留任务录像以便复盘,可单独开启,不要求启用视频分析。</small></span></label>
@if (config.recording; as recording) {
<details class="execution-settings">
<summary>录像留档设置</summary>
<p>默认低码率留档,不影响模型观察截图。仅录实际使用的来源;屏幕模式包含该屏幕其他可见内容。</p>
<div class="number-fields">
<label>编码<select [(ngModel)]="recording.codec" [disabled]="busy() || !loaded()"><option value="auto">自动选择兼容编码</option><option value="h265">优先 H.265</option><option value="h264">H.264</option></select></label>
<label>电脑录制范围<select [(ngModel)]="recording.computer_scope" [disabled]="busy() || !loaded()"><option value="screen">当前控制的屏幕</option><option value="window">仅目标窗口</option></select></label>
<label>最长边(像素)<input type="number" min="320" max="4096" [(ngModel)]="recording.max_long_edge" [disabled]="busy() || !loaded()" /></label>
<label>最高帧率<input type="number" min="5" max="60" [(ngModel)]="recording.fps" [disabled]="busy() || !loaded()" /></label>
<label>H.265 目标码率(kbps)<input type="number" min="128" max="10000" [(ngModel)]="recording.h265_kbps" [disabled]="busy() || !loaded()" /></label>
<label>H.264 目标码率(kbps)<input type="number" min="128" max="20000" [(ngModel)]="recording.h264_kbps" [disabled]="busy() || !loaded()" /></label>
</div>
</details>
}
<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>
@@ -34,24 +79,56 @@ 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, .number-fields select { box-sizing: border-box; display: block; width: 100%; height: auto; margin-top: 8px; padding: 9px; border: 1px solid #cbd5e1; border-radius: 8px; font: inherit; }
section {font-size:13px;border-color:#e2e8f0}h3 {font-size:15px;color:#334155}
section > label {align-items:center;flex-direction:row-reverse;justify-content:space-between;border-bottom:1px solid #f1f5f9;gap:20px}section > label > span {flex:1}small {font-size:12px}
input[role=switch] {appearance:none;position:relative;width:36px;height:22px;border:1px solid #cbd5e1;border-radius:20px;background:#e2e8f0;cursor:pointer;transition:background .15s}
input[role=switch]::after {content:'';position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 3px #0f172a25;transition:transform .15s}
input[role=switch]:checked {background:#6366f1;border-color:#6366f1}input[role=switch]:checked::after {transform:translateX(14px)}input[role=switch]:focus-visible {outline:2px solid #a5b4fc;outline-offset:3px}input:disabled {opacity:.5;cursor:default}
button {background:#4f46e5;border-color:#4f46e5;border-radius:12px;font-weight:600}.execution-settings {border-color:#e2e8f0;border-radius:14px;padding:14px 16px}.number-fields input,.number-fields select {background:#fff;border-color:#dbe3ec}
@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, recording_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(timeout(UI_READ_TIMEOUT_MS), finalize(() => this.loading.set(false))).subscribe({
next: config => { this.config = config; this.loaded.set(true); this.changed.emit(config); }, error: () => this.error.set('功能配置加载失败,请刷新页面重试。') next: config => { this.config = config; this.loaded.set(true); this.changed.emit(config); }, error: () => this.error.set('功能配置加载失败,请刷新页面重试。')
}); });
} }
save(): void { save(): void {
if (this.busy() || this.loading() || !this.loaded()) return; if (this.busy() || this.loading() || !this.loaded() || !this.valid()) return;
if (this.config.recording) {
this.config.recording.hevc_playback = !!document.createElement('video').canPlayType('video/mp4; codecs="hvc1"');
}
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;
const r = c.recording;
if (r && (!['auto', 'h264', 'h265'].includes(r.codec) || !['screen', 'window'].includes(r.computer_scope)
|| !Number.isInteger(r.max_long_edge) || r.max_long_edge < 320 || r.max_long_edge > 4096
|| !Number.isInteger(r.fps) || r.fps < 5 || r.fps > 60
|| !Number.isInteger(r.h265_kbps) || r.h265_kbps < 128 || r.h265_kbps > 10000
|| !Number.isInteger(r.h264_kbps) || r.h264_kbps < 128 || r.h264_kbps > 20000)) return false;
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;
}
} }
@@ -1,4 +1,5 @@
import { TestBed } from '@angular/core/testing'; import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { UI_READ_TIMEOUT_MS } from '../../core/http-timeouts';
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';
import { ModelSettingsComponent } from './model-settings.component'; import { ModelSettingsComponent } from './model-settings.component';
@@ -6,14 +7,29 @@ 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: 'gpt-6-luna',
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-6-luna', 'gpt-6.1-sol', 'gpt-6-astra', '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);
}); });
afterEach(() => http.verify()); afterEach(() => http.verify());
it('配置请求排队超时后退出加载状态,不会发送保存请求', fakeAsync(() => {
const fixture = TestBed.createComponent(ModelSettingsComponent);
fixture.detectChanges();
const request = http.expectOne('/api/system/model-settings');
tick(UI_READ_TIMEOUT_MS);
expect(request.cancelled).toBeTrue();
expect(fixture.componentInstance.loading()).toBeFalse();
expect(fixture.componentInstance.error()).toBeTruthy();
expect(fixture.componentInstance.valid()).toBeFalse();
http.expectNone('/api/system/model-settings');
}));
it('加载配置后保留密钥,保存新模型且不发送空密钥', () => { it('加载配置后保留密钥,保存新模型且不发送空密钥', () => {
const fixture = TestBed.createComponent(ModelSettingsComponent); const fixture = TestBed.createComponent(ModelSettingsComponent);
fixture.detectChanges(); fixture.detectChanges();
@@ -30,37 +46,40 @@ 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();
expect(component.message()).toContain('保存'); expect(component.message()).toContain('保存');
}); });
it('预设按钮填入名称,自定义模型与 max 可以保存并重新加载', async () => { it('统一选择预设或自定义模型,自定义名称与 max 可以保存并重新加载', async () => {
const fixture = TestBed.createComponent(ModelSettingsComponent); const fixture = TestBed.createComponent(ModelSettingsComponent);
fixture.detectChanges(); fixture.detectChanges();
http.expectOne('/api/system/model-settings').flush(config); http.expectOne('/api/system/model-settings').flush(config);
fixture.detectChanges(); fixture.detectChanges();
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 select = root.querySelector<HTMLSelectElement>('app-model-selector select')!;
expect(presets.length).toBe(2); expect(Array.from(select.options, option => option.value)).toEqual([...config.model_presets, '__custom__']);
presets[0].click(); expect(select.value).toBe('gpt-6-luna');
select.value = 'mimo-v2.6-pro';
select.dispatchEvent(new Event('change'));
expect(fixture.componentInstance.model).toBe('mimo-v2.6-pro');
select.value = '__custom__';
select.dispatchEvent(new Event('change'));
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); const input = root.querySelector<HTMLInputElement>('input[aria-label="模型名称(自定义)"]')!;
const input = root.querySelector<HTMLInputElement>('#model-name')!;
expect(input.value).toBe('gpt-5.6-luna');
expect(presets[0].getAttribute('aria-pressed')).toBe('true');
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';
input.dispatchEvent(new Event('input')); input.dispatchEvent(new Event('input'));
const reasoning = root.querySelector<HTMLSelectElement>('#model-reasoning')!; const reasoning = root.querySelector<HTMLInputElement>('input[aria-label="思考强度"]')!;
reasoning.value = 'max'; reasoning.value = '7';
reasoning.dispatchEvent(new Event('change')); reasoning.dispatchEvent(new Event('input'));
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
expect(presets[0].getAttribute('aria-pressed')).toBe('false');
root.querySelector<HTMLFormElement>('form')!.dispatchEvent(new Event('submit', { cancelable: true })); root.querySelector<HTMLFormElement>('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
const request = http.expectOne('/api/system/model-settings'); const request = http.expectOne('/api/system/model-settings');
expect(request.request.body.model).toBe('custom/model-v2'); expect(request.request.body.model).toBe('custom/model-v2');
@@ -73,8 +92,36 @@ describe('持久化模型与功能设置', () => {
http.expectOne('/api/system/model-settings').flush(saved); http.expectOne('/api/system/model-settings').flush(saved);
reopened.detectChanges(); reopened.detectChanges();
await reopened.whenStable(); await reopened.whenStable();
expect(reopened.nativeElement.querySelector('#model-name').value).toBe('custom/model-v2'); expect(reopened.nativeElement.querySelector('input[aria-label="模型名称(自定义)"]').value).toBe('custom/model-v2');
expect(reopened.nativeElement.querySelector('#model-reasoning').value).toBe('max'); expect(reopened.nativeElement.querySelector('input[aria-label="思考强度"]').getAttribute('aria-valuetext')).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 fallbackSelect = root.querySelectorAll<HTMLSelectElement>('app-model-selector select')[1];
expect(Array.from(fallbackSelect.options, option => option.value)).toEqual([...config.model_presets, '__custom__']);
fallbackSelect.value = '__custom__';
fallbackSelect.dispatchEvent(new Event('change'));
fixture.detectChanges();
const fallback = root.querySelector<HTMLInputElement>('input[aria-label="兜底模型名称(自定义)"]')!;
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('连接测试使用当前输入而不写入保存接口', () => {
@@ -83,7 +130,7 @@ describe('持久化模型与功能设置', () => {
http.expectOne('/api/system/model-settings').flush(config); http.expectOne('/api/system/model-settings').flush(config);
fixture.componentInstance.test(); fixture.componentInstance.test();
const request = http.expectOne('/api/system/model-settings/test'); const request = http.expectOne('/api/system/model-settings/test');
expect(request.request.body.model).toBe('mimo-v2.6-flash'); expect(request.request.body.model).toBe('gpt-6-luna');
request.flush({ message: 'Responses 连接测试成功' }); request.flush({ message: 'Responses 连接测试成功' });
http.expectNone('/api/system/model-settings'); http.expectNone('/api/system/model-settings');
expect(fixture.componentInstance.message()).toContain('成功'); expect(fixture.componentInstance.message()).toContain('成功');
@@ -99,17 +146,19 @@ describe('持久化模型与功能设置', () => {
}); });
it('录屏与视频分析独立保存,重新打开后保留各自状态', () => { it('录屏与视频分析独立保存,重新打开后保留各自状态', () => {
const capabilities = { video_enabled: true, recording_enabled: false, 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.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();
expect(request.request.body.recording_enabled).toBeTrue(); expect(request.request.body.recording_enabled).toBeTrue();
const saved = { ...capabilities, video_enabled: false, recording_enabled: true }; 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);
@@ -117,5 +166,58 @@ describe('持久化模型与功能设置', () => {
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.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');
}); });
}); });
@@ -1,7 +1,11 @@
import { Component, OnInit, inject, signal, output } from '@angular/core'; import { Component, OnInit, inject, signal, output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { finalize } from 'rxjs'; import { finalize, timeout } from 'rxjs';
import { UI_READ_TIMEOUT_MS } from '../../core/http-timeouts';
import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from '../setting-slider/setting-slider.component';
import { ModelSelectorComponent } from '../model-selector/model-selector.component';
import { MODEL_PRESETS } from '../../core/model-presets';
interface ModelSettings { interface ModelSettings {
base_url: string; base_url: string;
@@ -10,7 +14,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;
} }
@@ -18,7 +28,7 @@ interface ModelSettings {
@Component({ @Component({
selector: 'app-model-settings', selector: 'app-model-settings',
standalone: true, standalone: true,
imports: [FormsModule], imports: [FormsModule, SettingSliderComponent, ModelSelectorComponent],
template: ` template: `
<form class="model-settings" (ngSubmit)="save()"> <form class="model-settings" (ngSubmit)="save()">
<p>统一使用 OpenAI Responses。保存后新任务立即生效,重启后自动加载。</p> <p>统一使用 OpenAI Responses。保存后新任务立即生效,重启后自动加载。</p>
@@ -26,25 +36,9 @@ interface ModelSettings {
<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-n-cd.playones.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> <app-model-selector label="模型名称" [(value)]="model" [presets]="modelPresets" [disabled]="!!busy() || loading()" />
<div class="model-presets" role="group" aria-label="预设模型"> <small>选择预设模型,或选择“自定义模型”填写名称。</small>
@for (preset of modelPresets; track preset) { <app-setting-slider label="思考强度" [(value)]="reasoning" [values]="reasoningValues" [labels]="reasoningLabels" [disabled]="!!busy() || loading()" hint="更高的强度适合复杂推理;需接口支持,也可选择模型默认。" />
<button type="button" [attr.aria-pressed]="model === preset" (click)="model = preset"
[disabled]="busy() || loading()">{{ preset }}</button>
}
</div>
<input id="model-name" name="llmModelName" type="text" autocomplete="off" autocapitalize="off" spellcheck="false"
[(ngModel)]="model" required placeholder="输入接口支持的模型名" aria-describedby="model-name-help"
[disabled]="busy() || loading()" />
<small id="model-name-help">点击预设填入,或直接输入自定义模型名称;保存后可继续使用。</small>
<label for="model-reasoning">思考强度</label>
<select id="model-reasoning" name="reasoning" [(ngModel)]="reasoning" [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>
<small>接口需支持所选强度;不支持时会明确报错,可手动选择“由模型默认”。</small>
<label for="model-key">API Key <span>{{ keyConfigured() ? '(已配置,留空保留)' : '(尚未配置)' }}</span></label> <label for="model-key">API Key <span>{{ keyConfigured() ? '(已配置,留空保留)' : '(尚未配置)' }}</span></label>
<input id="model-key" name="apiKey" type="password" [(ngModel)]="apiKey" autocomplete="new-password" <input id="model-key" name="apiKey" type="password" [(ngModel)]="apiKey" autocomplete="new-password"
placeholder="输入新密钥" [disabled]="busy() || loading()" /> placeholder="输入新密钥" [disabled]="busy() || loading()" />
@@ -55,6 +49,22 @@ 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>
<app-model-selector label="兜底模型名称" [(value)]="toolFallbackModel" [presets]="modelPresets" [disabled]="!!busy() || loading()" />
<app-setting-slider label="兜底思考强度" [(value)]="toolFallbackReasoning" [values]="reasoningValues" [labels]="reasoningLabels" [disabled]="!!busy() || loading()" />
<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,43 +86,63 @@ 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; }
.model-presets { display: flex; flex-wrap: wrap; gap: 8px; }
.model-presets button { padding: 8px 12px; background: #f8fafc; color: #475569; border-color: #cbd5e1; font-size: 14px; }
.model-presets button:hover:not(:disabled) { background: #e0f2fe; border-color: #38bdf8; }
.model-presets button[aria-pressed="true"] { background: #e0f2fe; border-color: #0284c7; color: #075985; }
button:focus-visible { outline: 2px solid #0284c7; outline-offset: 2px; } button:focus-visible { outline: 2px solid #0284c7; outline-offset: 2px; }
small { overflow-wrap: anywhere; } @media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } } small { overflow-wrap: anywhere; } @media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }
.model-settings {gap:12px;font-size:13px} label {margin-top:6px}input,select {background:#fff;border-color:#dbe3ec;padding:11px 12px}
input:focus,select:focus {outline:2px solid #a5b4fc;outline-offset:2px}
.actions {border-top:1px solid #e2e8f0;padding-top:16px;align-items:center}.actions button {min-height:40px;background:#4f46e5;border-color:#4f46e5;font-weight:600;border-radius:12px}.actions button[type=button] {background:#fff;border-color:#dbe3ec;color:#475569}
.recovery-settings {border-color:#e2e8f0;border-radius:14px;padding:14px 16px}.recovery-settings summary {font-size:13px}
`] `]
}) })
export class ModelSettingsComponent implements OnInit { export class ModelSettingsComponent implements OnInit {
readonly reasoningValues = REASONING_VALUES;
readonly reasoningLabels = REASONING_LABELS;
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[] = MODEL_PRESETS;
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('');
ngOnInit(): void { ngOnInit(): void {
this.http.get<ModelSettings>('/api/system/model-settings').pipe(finalize(() => this.loading.set(false))).subscribe({ this.http.get<ModelSettings>('/api/system/model-settings').pipe(timeout(UI_READ_TIMEOUT_MS), finalize(() => this.loading.set(false))).subscribe({
next: config => this.apply(config), next: config => this.apply(config),
error: () => this.error.set('配置加载失败,请刷新页面重试。') error: () => this.error.set('配置加载失败,请刷新页面重试。')
}); });
} }
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,15 @@ 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>
<a routerLink="/plugins" routerLinkActive="active" class="nav-tab-btn" title="管理扩展插件">
<span class="material-symbols-outlined tab-icon">extension</span><span class="tab-label">插件</span>
</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;
}
@@ -0,0 +1,36 @@
import { TestBed } from '@angular/core/testing';
import { ReportMarkdownComponent } from './report-markdown.component';
describe('报告 Markdown 展示', () => {
it('renders report headings, emphasis, lists, tables and code in both preview and full view', () => {
const fixture = TestBed.createComponent(ReportMarkdownComponent);
fixture.componentRef.setInput('content', '### 双端布局报告\n- **手机**:`/zh`\n\n| 区域 | 结论 |\n| --- | --- |\n| 页底 | 已确认 |\n\n```text\n<原始证据>\n```');
fixture.componentRef.setInput('compact', true);
fixture.detectChanges();
const view: HTMLElement = fixture.nativeElement;
expect(view.querySelector('h3')?.textContent).toBe('双端布局报告');
expect(view.querySelector('li strong')?.textContent).toBe('手机');
expect(view.querySelector('li code')?.textContent).toBe('/zh');
expect(view.querySelector('td')?.textContent).toBe('页底');
expect(view.querySelector('pre code')?.textContent).toBe('<原始证据>');
expect(view.querySelector('.compact')).not.toBeNull();
fixture.componentRef.setInput('compact', false);
fixture.detectChanges();
expect(view.querySelector('.compact')).toBeNull();
expect(view.querySelectorAll('td').length).toBe(2);
fixture.destroy();
});
it('keeps HTML in model output inert and updates when the selected report changes', () => {
const fixture = TestBed.createComponent(ReportMarkdownComponent);
fixture.componentRef.setInput('content', '<img src=x onerror=alert(1)>\n<script>alert(1)</script>');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('img,script')).toBeNull();
expect(fixture.nativeElement.textContent).toContain('<img');
fixture.componentRef.setInput('content', '## 新报告\n**待确认**');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('h2').textContent).toBe('新报告');
expect(fixture.nativeElement.textContent).not.toContain('alert');
fixture.destroy();
});
});
@@ -0,0 +1,39 @@
import { Component, computed, input } from '@angular/core';
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
/** Shared, sanitized presentation for report prose (not the editable task plan). */
@Component({
selector: 'app-report-markdown',
standalone: true,
template: `<div class="report-prose" [class.compact]="compact()" [innerHTML]="html()"></div>`,
styles: [`
:host { display: block; min-width: 0; max-width: 100%; }
.report-prose { color: inherit; font-size: 13px; font-weight: 400; line-height: 1.7; white-space: normal; overflow-wrap: anywhere; word-break: normal; }
.compact { max-height: 170px; overflow: hidden; }
:host ::ng-deep .report-prose {
> div, p { margin: 4px 0; }
> :first-child { margin-top: 0; }
> :last-child { margin-bottom: 0; }
h1,h2,h3,h4,h5,h6 { margin: 16px 0 6px; font-weight: 600; line-height: 1.5; color: #0f172a; }
h1 { font-size: 18px; } h2 { font-size: 16px; } h3,h4,h5,h6 { font-size: 14px; }
strong { font-weight: 600; }
ul,ol { margin: 6px 0; padding-left: 24px; }
li { margin: 4px 0; }
blockquote { margin: 10px 0; padding: 6px 12px; border-left: 3px solid #93c5fd; background: #f8fafc; }
code { font-family: Consolas, monospace; font-size: .92em; border-radius: 4px; padding: 1px 4px; background: #f1f5f9; }
pre { max-width: 100%; overflow: auto; padding: 10px 12px; border-radius: 6px; background: #f1f5f9; white-space: pre; }
pre code { padding: 0; background: none; }
.md-table-scroll { max-width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th,td { border: 1px solid #cbd5e1; padding: 7px 9px; text-align: left; vertical-align: top; }
th { background: #eff6ff; font-weight: 600; }
.md-para-gap { height: 4px; margin: 0; }
hr { border: 0; border-top: 1px solid #e2e8f0; margin: 12px 0; }
}
`]
})
export class ReportMarkdownComponent {
content = input('');
compact = input(false);
html = computed(() => renderMarkdownToHtml(this.content()));
}
@@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from './setting-slider.component';
describe('Setting slider', () => {
it('maps the selected position to the exact request value and exposes its readable label', () => {
const f=TestBed.createComponent(SettingSliderComponent);
f.componentRef.setInput('label','思考强度');f.componentRef.setInput('values',REASONING_VALUES);f.componentRef.setInput('labels',REASONING_LABELS);f.componentRef.setInput('value','high');f.detectChanges();
const slider=f.nativeElement.querySelector('input');expect(slider.getAttribute('aria-valuetext')).toBe('高 · high');
const emit=spyOn(f.componentInstance.valueChange,'emit');slider.value='7';slider.dispatchEvent(new Event('input'));
expect(emit).toHaveBeenCalledWith('max');f.destroy();
});
it('preserves a saved custom time until the slider is actually changed', () => {
const f=TestBed.createComponent(SettingSliderComponent);const emit=spyOn(f.componentInstance.valueChange,'emit');
f.componentRef.setInput('label','执行时限');f.componentRef.setInput('values',[60,300,1800]);f.componentRef.setInput('value',125);f.detectChanges();
expect(f.nativeElement.querySelector('output').textContent).toBe('125 秒');expect(emit).not.toHaveBeenCalled();f.destroy();
});
});
@@ -0,0 +1,46 @@
import { Component, computed, input, output } from '@angular/core';
@Component({
selector: 'app-setting-slider', standalone: true,
template: `
<div class="slider-heading"><span>{{label()}}</span><output>{{display()}}</output></div>
<input type="range" min="0" [max]="values().length - 1" step="1" [value]="index()"
[disabled]="disabled()" [attr.aria-label]="label()" [attr.aria-valuetext]="display()"
[style.--fill]="(index() / (values().length - 1) * 100) + '%'"
(input)="change($any($event.target).value)" />
<div class="slider-ends"><span>{{labels()[0] || values()[0]}}</span><span>{{labels().at(-1) || values().at(-1)}}</span></div>
@if(hint()){<p>{{hint()}}</p>}
`,
styles: [`
:host{display:block;min-width:0;padding:14px 16px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:14px;font-size:13px;color:#334155}
.slider-heading,.slider-ends{display:flex;align-items:center;justify-content:space-between;gap:10px}
.slider-heading{font-weight:600}output{color:#4f46e5;font-size:12px;white-space:nowrap}.slider-ends{font-size:11px;color:#64748b}
input{appearance:none;display:block;box-sizing:border-box;width:100%;height:6px;border:0;border-radius:10px;margin:20px 0 12px;background:linear-gradient(to right,#6366f1 var(--fill),#dbe3ee var(--fill));cursor:pointer}
input::-webkit-slider-thumb{appearance:none;width:18px;height:18px;border-radius:50%;background:#fff;border:2px solid #6366f1;box-shadow:0 2px 6px #33415526}
input::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#fff;border:2px solid #6366f1}
input:focus-visible{outline:2px solid #a5b4fc;outline-offset:5px}input:disabled{opacity:.5;cursor:default}p{margin:10px 0 0;font-size:12px;line-height:1.6;color:#64748b}
`]
})
export class SettingSliderComponent {
label = input.required<string>();
value = input.required<string | number>();
values = input.required<(string | number)[]>();
labels = input<string[]>([]);
hint = input('');
disabled = input(false);
valueChange = output<any>();
index = computed(() => {
const exact = this.values().indexOf(this.value());
if(exact >= 0) return exact;
if(typeof this.value() === 'number') return this.values().reduce<number>((best, v, i) => Math.abs(Number(v)-Number(this.value())) < Math.abs(Number(this.values()[best])-Number(this.value())) ? i : best, 0);
return 0;
});
display = computed(() => {
const exact = this.values().indexOf(this.value());
return exact >= 0 ? this.labels()[exact] || String(this.value()) : String(this.value()) + (typeof this.value() === 'number' ? ' 秒' : '');
});
change(index: string) { this.valueChange.emit(this.values()[Number(index)]); }
}
export const REASONING_VALUES = ['default','none','minimal','low','medium','high','xhigh','max'];
export const REASONING_LABELS = ['模型默认','关闭 · none','最低 · minimal','低 · low','中 · medium','高 · high','极高 · xhigh','最高 · max'];
@@ -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(6);
for(const value of ['__batch__','__single__','__exploration__','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><option value="__exploration__">探索任务</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__','__exploration__'].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,20 +1,108 @@
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { of, throwError } from 'rxjs';
import { Clipboard } from '@angular/cdk/clipboard'; import { Clipboard } from '@angular/cdk/clipboard';
import { TestBed } from '@angular/core/testing'; import { fakeAsync, TestBed, tick } 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';
import { AgentService } from '../../services/agent.service'; import { AgentService } from '../../services/agent.service';
import { TaskHistoryComponent } from './task-history.component'; import { TaskHistoryComponent } from './task-history.component';
import { FavoritesService } from '../../services/favorites.service';
describe('历史任务检索', () => { 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(), deleteSession: jasmine.createSpy().and.returnValue(of({})), canDeleteSession: AgentService.prototype.canDeleteSession, deletionPrompt: (_id: string, kind: string) => kind === 'exploration' ? '删除探索及全部探索记录,复跑与批次保留' : '删除历史任务'}}]});
http = TestBed.inject(HttpTestingController); http = TestBed.inject(HttpTestingController);
TestBed.inject(FavoritesService).loaded.set(true);
}); });
afterEach(() => http.verify()); afterEach(() => http.verify());
it('已终止探索可删除,确认范围后刷新,取消不发请求', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
const row = {session_id:'stopped', status:'cancelled', exploration_kind:'exploration', exploration_status:'paused', start_time:100};
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items:[row]});
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.delete-history');
expect(button).not.toBeNull();
const confirm = spyOn(window, 'confirm').and.returnValue(false);
button.click();
expect(TestBed.inject(AgentService).deleteSession).not.toHaveBeenCalled();
expect(confirm.calls.mostRecent().args[0]).toContain('复跑与批次保留');
confirm.and.returnValue(true);
button.click();
expect(TestBed.inject(AgentService).deleteSession).toHaveBeenCalledOnceWith('stopped');
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.delete-history')).toBeNull();
fixture.destroy();
});
it('服务端拒绝删除时保留记录并显示具体原因', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items:[
{session_id:'stopped',status:'cancelled',exploration_kind:'exploration',start_time:100}
]});
spyOn(window, 'confirm').and.returnValue(true);
(TestBed.inject(AgentService).deleteSession as jasmine.Spy).and.returnValue(throwError(() => ({error:{detail:'探索正在分析,请稍后删除'}})));
fixture.detectChanges();
fixture.nativeElement.querySelector('.delete-history').click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('探索正在分析');
expect(fixture.nativeElement.querySelector('.delete-history')).not.toBeNull();
fixture.destroy();
});
it('完成任务分别显示探索、Flash、Pro,模式不受通过或失败颜色影响', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total:3,items:[
{session_id:'e',status:'completed',start_time:100,profile:'pro',exploration_kind:'exploration'},
{session_id:'f',status:'failed',start_time:100,profile:'flash',exploration_kind:'validation',case_version:2},
{session_id:'p',status:'completed',start_time:100,profile:'pro'}
]});
fixture.detectChanges();
const badges = fixture.nativeElement.querySelectorAll('.mode-badge');
expect(badges.length).toBe(3);
expect(badges[0].textContent).toContain('探索');
expect(badges[1].textContent).toContain('Flash');
expect(badges[2].textContent).toContain('Pro');
expect(fixture.nativeElement.textContent).toContain('用例复跑 · v2');
fixture.destroy();
});
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 () => { it('历史 ID 独立于打开任务按钮,点击复制不会打开任务', async () => {
spyOn(navigator.clipboard, 'writeText').and.rejectWith(new Error('blocked')); spyOn(navigator.clipboard, 'writeText').and.rejectWith(new Error('blocked'));
const fixture = TestBed.createComponent(TaskHistoryComponent); const fixture = TestBed.createComponent(TaskHistoryComponent);
@@ -49,6 +137,142 @@ describe('历史任务检索', () => {
fixture.destroy(); fixture.destroy();
}); });
it('定时刷新不插入加载行或禁用翻页,并保留滚动、焦点、折叠和选中项', fakeAsync(() => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
const now = Date.now()/1000;
const items = Array.from({length:8}, (_,i) => ({session_id:`task-${i}`, name:`历史用例 ${i}`, status:'completed', start_time:now}));
items.push({session_id:'older', name:'前一天的用例', status:'completed', start_time:now-86400});
TestBed.inject(AgentService).currentSessionId.set('task-0');
http.expectOne(r => r.url === '/api/automation/history').flush({total:60,items});
fixture.detectChanges();
tick();
const host: HTMLElement = fixture.nativeElement;
host.style.cssText = 'display:block;width:360px;height:400px;overflow:auto';
const group = host.querySelectorAll('details')[1];
group.open = false;
group.dispatchEvent(new Event('toggle'));
const search = host.querySelector('input')!;
search.focus({preventScroll:true});
host.scrollTop = 100;
const row = host.querySelector('.row.selected')!;
const before = {height:host.scrollHeight, scroll:host.scrollTop, top:row.getBoundingClientRect().top};
expect(before.scroll).toBeGreaterThan(0);
tick(15000);
const refresh = http.expectOne(r => r.url === '/api/automation/history');
fixture.detectChanges();
expect(host.textContent).not.toContain('正在查询');
expect(host.querySelectorAll<HTMLButtonElement>('.pager button')[1].disabled).toBeFalse();
expect(host.scrollHeight).toBe(before.height);
expect(host.scrollTop).toBe(before.scroll);
expect(row.getBoundingClientRect().top).toBe(before.top);
refresh.flush({total:60,items:items.map(item => ({...item,name:item.name.replace('历史','更新')}))});
fixture.detectChanges();
expect(host.querySelector('.row.selected')).toBe(row);
expect(row.textContent).toContain('更新用例 0');
expect(host.querySelectorAll('details')[1]).toBe(group);
expect(group.open).toBeFalse();
expect(document.activeElement).toBe(search);
expect(host.scrollTop).toBe(before.scroll);
fixture.destroy();
}));
it('空历史在后台刷新期间保持空状态,收到新任务后正常更新', fakeAsync(() => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
fixture.detectChanges();
const empty = fixture.nativeElement.querySelector('p');
tick(15000);
const refresh = http.expectOne(r => r.url === '/api/automation/history');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('p')).toBe(empty);
expect(fixture.nativeElement.textContent).not.toContain('正在查询');
refresh.flush({total:1,items:[{session_id:'new',name:'新完成任务',status:'completed',start_time:Date.now()/1000}]});
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('新完成任务');
expect(fixture.nativeElement.textContent).not.toContain('没有符合条件');
fixture.destroy();
}));
it('慢刷新不叠加请求,搜索优先且旧刷新不能覆盖搜索结果', fakeAsync(() => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
const first = http.expectOne(r => r.url === '/api/automation/history');
tick(15000);
http.expectNone(r => r.url === '/api/automation/history');
first.flush({total:0,items:[]});
tick(15000);
const refresh = http.expectOne(r => r.url === '/api/automation/history');
tick(15000);
http.expectNone(r => r.url === '/api/automation/history');
fixture.componentInstance.query = '设置';
fixture.componentInstance.search();
tick(250);
const search = http.expectOne(r => r.params.get('q') === '设置');
search.flush({total:1,items:[{session_id:'settings',name:'设置',status:'completed',start_time:100}]});
refresh.flush({total:99,items:[]});
expect(fixture.componentInstance.total()).toBe(1);
expect(fixture.componentInstance.groups()[0].rows[0].session_id).toBe('settings');
fixture.destroy();
}));
it('轮询和结果通知不抢先提交仍在输入的搜索条件', fakeAsync(() => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
fixture.detectChanges();
tick(14900);
fixture.componentInstance.query = '新条件';
fixture.componentInstance.search();
TestBed.inject(AgentService).resultRevision.update(value => value+1);
fixture.detectChanges();
tick(100);
http.expectNone(r => r.url === '/api/automation/history');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('h3 [role="status"]').textContent).toContain('正在查询');
tick(150);
http.expectOne(r => r.params.get('q') === '新条件').flush({total:0,items:[]});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('h3 [role="status"]').textContent).toBe('');
fixture.destroy();
}));
it('刷新失败保留列表和错误提示,重试期间不反复移除提示', fakeAsync(() => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
const items = [{session_id:'kept',name:'已有历史',status:'completed',start_time:100}];
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items});
fixture.detectChanges();
const row = fixture.nativeElement.querySelector('.row');
tick(15000);
http.expectOne(r => r.url === '/api/automation/history').flush({}, {status:503,statusText:'Unavailable'});
fixture.detectChanges();
const alert = fixture.nativeElement.querySelector('[role="alert"]');
expect(alert.textContent).toContain('历史查询失败');
tick(15000);
const retry = http.expectOne(r => r.url === '/api/automation/history');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[role="alert"]')).toBe(alert);
expect(fixture.nativeElement.querySelector('.row')).toBe(row);
retry.flush({total:1,items});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[role="alert"]')).toBeNull();
expect(fixture.nativeElement.querySelector('.row')).toBe(row);
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,45 +1,65 @@
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component'; import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
import { Component, DestroyRef, inject, signal } from '@angular/core'; import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
import { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-badge.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,TaskIdCopyComponent], selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent,TaskModeBadgeComponent,FavoriteButtonComponent],
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small></h3> template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small><small class="query-status" role="status">{{loading() ? '正在查询…' : ''}}</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>}
@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></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>} @for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><div class="history-heading"><app-favorite-button [sourceId]="row.session_id" /><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"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<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></div><app-task-id-copy [taskId]="row.session_id" />@if(agent.canDeleteSession(row)){<button class="delete-history" (click)="remove(row.session_id, row.exploration_kind)" 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;border:1px solid #64748b55;border-radius:6px;margin:5px 0;padding:8px}.history-item:has(.row.selected){border-color:#60a5fa;background:#3b82f615}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.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{display:flex;align-items:center;gap:6px;font-size:14px;margin:14px 0 8px}.query-status{margin-left:auto;font-size:11px;font-weight:400;white-space:nowrap}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-heading{display:flex;align-items:flex-start;gap:5px;min-width:0}.history-heading>app-favorite-button{margin-top:-5px}.history-heading>.row{flex:1;min-width:0;width:auto}.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 pendingRequest=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(){
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});} 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){
const filterChanged=nextFilter!==filter;
if(filterChanged)this.page=1;
filter=nextFilter;resultRevision=nextRevision;untracked(()=>this.load(!filterChanged));
}
});
const timer=setInterval(()=>this.load(true),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});
}
remove(id:string, kind?:string){if(!confirm(this.agent.deletionPrompt(id,kind)))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:e=>this.error.set(e.error?.detail || "删除失败,请稍后重试")});}
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++;this.loading.set(true);clearTimeout(this.debounce);this.debounce=setTimeout(()=>this.load(),250);}
load(){ load(background=false){
const revision=++this.revision;this.loading.set(true);this.error.set(''); // Polling must not displace rows, toggle pagination, or supersede a user's query.
if(background && (this.pendingRequest || this.debounce!==undefined))return;
if(!background){clearTimeout(this.debounce);this.debounce=undefined;this.loading.set(true);}
const revision=++this.revision;this.pendingRequest=revision;
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=>{
if(revision!==this.revision)return; if(revision!==this.revision)return;
this.pendingRequest=0;this.error.set('');
this.total.set(r.total);const map=new Map<string,any>();const today=new Date();const yesterday=new Date();yesterday.setDate(today.getDate()-1); this.total.set(r.total);const map=new Map<string,any>();const today=new Date();const yesterday=new Date();yesterday.setDate(today.getDate()-1);
for(const row of r.items){const d=new Date(row.start_time*1000);const key=d.toDateString();if(!map.has(key))map.set(key,{key,title:key===today.toDateString()?'今天':key===yesterday.toDateString()?'昨天':d.toLocaleDateString('zh-CN'),rows:[]});map.get(key).rows.push(row);} for(const row of r.items){const d=new Date(row.start_time*1000);const key=d.toDateString();if(!map.has(key))map.set(key,{key,title:key===today.toDateString()?'今天':key===yesterday.toDateString()?'昨天':d.toLocaleDateString('zh-CN'),rows:[]});map.get(key).rows.push(row);}
this.groups.set([...map.values()]);this.loading.set(false); this.groups.set([...map.values()]);this.loading.set(false);
},error:e=>{if(revision===this.revision){this.loading.set(false);this.error.set(e.error?.detail || '历史查询失败,请稍后重试');}}}); },error:e=>{if(revision===this.revision){this.pendingRequest=0;this.loading.set(false);this.error.set(e.error?.detail || '历史查询失败,请稍后重试');}}});
} }
} }
@@ -0,0 +1,21 @@
import { TestBed } from '@angular/core/testing';
import { TaskModeBadgeComponent } from './task-mode-badge.component';
describe('执行模式徽标', () => {
it('探索优先于底层 Pro,复跑显示实际使用的 Flash / Pro', () => {
const fixture = TestBed.createComponent(TaskModeBadgeComponent);
const check = (session: object, mode: string, text: string) => {
fixture.componentRef.setInput('session', session);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.mode-badge');
expect(badge.classList.contains(mode)).toBeTrue();
expect(badge.textContent).toContain(text);
expect(badge.title).toBe('执行模式:' + text);
};
check({exploration_kind:'exploration',profile:'pro'}, 'explore', '探索');
check({exploration_kind:'validation',profile:'flash'}, 'flash', 'Flash');
check({profile:'pro'}, 'pro', 'Pro');
check({model_info:{architecture:'ARTEMIS Flash'}}, 'flash', 'Flash');
check({model_info:{id:'mimo-v2.6-pro'}}, 'unknown', '模式未记录');
});
});
@@ -0,0 +1,27 @@
import { Component, computed, input } from '@angular/core';
import { Session } from '../../core/models/session.model';
@Component({
selector: 'app-task-mode-badge',
standalone: true,
template: `<span class="mode-badge" [class]="'mode-badge ' + mode()" [title]="'执行模式:' + label()"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
styles: [`
:host{display:inline-flex;vertical-align:middle}
.mode-badge{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;gap:3px;padding:0 6px;border:1px solid #e2e8f0;border-radius:5px;background:#f8fafc;color:#475569;font-size:11px;font-weight:600;line-height:14px;white-space:nowrap}
.material-symbols-outlined{font-size:13px;line-height:1}
.explore{background:#eef2ff;border-color:#c7d2fe;color:#4338ca}
.flash{background:#fff7ed;border-color:#fed7aa;color:#9a3412}
.pro{background:#eff6ff;border-color:#bfdbfe;color:#1d4ed8}
`]
})
export class TaskModeBadgeComponent {
session = input.required<Partial<Session>>();
mode = computed<'explore' | 'flash' | 'pro' | 'unknown'>(() => {
const s = this.session();
if (s.exploration_kind === 'exploration') return 'explore';
const profile = (s.profile || s.model_info?.architecture || '').toLowerCase().replace(/^artemis\s+/, '');
return profile === 'explore' || profile === 'flash' || profile === 'pro' ? profile : 'unknown';
});
label = computed(() => ({explore:'探索',flash:'Flash',pro:'Pro',unknown:'模式未记录'})[this.mode()]);
icon = computed(() => ({explore:'explore',flash:'bolt',pro:'psychology',unknown:'help_outline'})[this.mode()]);
}
@@ -0,0 +1,30 @@
<button class="plugin-toggle" type="button" (click)="toggle()" [attr.aria-expanded]="open()">
<span class="material-symbols-outlined" aria-hidden="true">extension</span>
<span>扩展插件</span><span class="mode">{{ summary() }}</span>
<span class="material-symbols-outlined chevron" aria-hidden="true">{{ open() ? 'expand_less' : 'expand_more' }}</span>
</button>
@if (open()) {
<section class="plugin-options" aria-label="扩展插件设置">
<p>照常描述安卓测试任务。需要操作电脑、浏览器等外部步骤时,AI 会按需调用已启用并授权的插件。</p>
<label class="auto-option"><input type="checkbox" [(ngModel)]="automatic" (ngModelChange)="emit()">自动使用可用插件</label>
@if (loading()) { <p role="status">正在读取插件…</p> }
@else if (error()) { <p class="error" role="alert">{{ error() }} <button type="button" class="text-button" (click)="load()">重试</button></p> }
@else if (!automatic) {
@for (plugin of available(); track plugin.manifest.id) {
<div class="choice">
<label><input type="checkbox" [checked]="selected[plugin.manifest.id] !== undefined" (change)="choose(plugin, $event)">{{ plugin.manifest.name }}</label>
@if (authorizedProfiles(plugin).length > 1) {
<select [disabled]="selected[plugin.manifest.id] === undefined" [ngModel]="selected[plugin.manifest.id] || ''" (ngModelChange)="selected[plugin.manifest.id] = $event; emit()" [attr.aria-label]="plugin.manifest.name + '使用设置'">
<option value="" disabled>选择要使用的设置</option>
@for (profile of authorizedProfiles(plugin); track profile.name) { <option [value]="profile.name">{{ profile.label }}</option> }
</select>
}
</div>
} @empty { <p>还没有可用插件,请先安装、授权并启用。</p> }
<p class="hint">不勾选任何插件时,本次任务只使用手机能力。</p>
} @else {
<p class="hint">{{ available().length ? 'AI 会按需使用可用插件,无需逐次选择。关闭自动使用后,可指定本次允许使用的插件。' : '还没有可用插件,手机测试不受影响。' }}</p>
}
<a routerLink="/plugins" class="manage-link">管理插件 <span class="material-symbols-outlined" aria-hidden="true">arrow_forward</span></a>
</section>
}
@@ -0,0 +1,20 @@
:host { display: block; width: 100%; color: #475569; font-size: 12px; }
.plugin-toggle { display: inline-flex; align-items: center; gap: 6px; border: 1px solid #e2e8f0; border-radius: 20px; padding: 5px 10px; background: #f8fafc; color: #64748b; font: inherit; cursor: pointer; transition: background .15s, border-color .15s; }
.plugin-toggle:hover, .plugin-toggle[aria-expanded=true] { background: #eff6ff; border-color: #bfdbfe; color: #2563eb; }
.material-symbols-outlined { font-size: 16px; }
.mode { color: #2563eb; padding-left: 6px; border-left: 1px solid #dce5ef; }
.chevron { font-size: 15px; }
.plugin-options { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 14px; padding: 14px 16px; margin-top: 8px; display: grid; gap: 12px; }
p { margin: 0; line-height: 1.65; }
label { display: flex; align-items: center; gap: 8px; }
.auto-option { font-weight: 500; color: #334155; }
input[type=checkbox] { accent-color: #2563eb; margin: 0; width: 14px; height: 14px; flex-shrink: 0; }
.choice { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
select { font: inherit; color: #334155; background: #fff; border: 1px solid #dce3eb; border-radius: 8px; padding: 6px 10px; max-width: 100%; }
select:disabled { opacity: .5; }
.hint { color: #64748b; font-size: 11px; }
.error { color: #b91c1c; }
.text-button, .manage-link { color: #2563eb; font: inherit; text-decoration: none; }
.text-button { background: none; border: 0; padding: 0 6px; cursor: pointer; }
.manage-link { display: inline-flex; align-items: center; gap: 4px; justify-self: start; }
:focus-visible { outline: 2px solid #3b82f6; outline-offset: 3px; }
@@ -0,0 +1,78 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { TaskPluginsComponent } from './task-plugins.component';
describe('TaskPluginsComponent', () => {
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({ imports: [TaskPluginsComponent], providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()] });
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
it('starts collapsed and automatic without an execution target selector', async () => {
const fixture = TestBed.createComponent(TaskPluginsComponent);
fixture.detectChanges();
http.expectNone('/api/plugins');
expect(fixture.nativeElement.textContent).toContain('自动');
expect(fixture.nativeElement.textContent).not.toContain('执行目标');
expect(fixture.nativeElement.querySelector('select')).toBeNull();
const emitted = jasmine.createSpy('changed');
fixture.componentInstance.changed.subscribe(emitted);
fixture.nativeElement.querySelector('button').click(); fixture.detectChanges();
http.expectOne('/api/plugins').flush({ plugins: [] });
await fixture.whenStable(); fixture.detectChanges();
fixture.nativeElement.querySelector('input').click(); fixture.detectChanges();
expect(emitted).toHaveBeenCalledWith({ plugins: [] });
fixture.nativeElement.querySelector('input').click(); fixture.detectChanges();
expect(emitted).toHaveBeenCalledWith({});
});
it('uses the single authorized setting without exposing a connection selector', async () => {
const fixture = TestBed.createComponent(TaskPluginsComponent);
const component = fixture.componentInstance;
component.toggle();
const plugin = { enabled: true, manifest: { id: 'desktop', name: '电脑控制', targets: ['computer', 'mixed'] }, profiles: [
{ name: 'default', label: '旧配置', authorized: false },
{ name: 'lab', label: '实验室', authorized: true, is_default: true }
] };
http.expectOne('/api/plugins').flush({ plugins: [plugin, { ...plugin, enabled: false, manifest: { ...plugin.manifest, id: 'disabled' } }] });
component.automatic = false; fixture.detectChanges(); await fixture.whenStable();
const emitted = jasmine.createSpy('changed'); component.changed.subscribe(emitted);
const checkbox = fixture.nativeElement.querySelector('.choice input');
checkbox.click(); fixture.detectChanges(); await fixture.whenStable();
expect(emitted).toHaveBeenCalledWith({ plugins: [{ id: 'desktop', profile: 'lab' }] });
expect(fixture.nativeElement.querySelectorAll('.choice').length).toBe(1);
expect(fixture.nativeElement.querySelector('select')).toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('连接配置');
});
it('allows choosing among multiple authorized settings and excludes unauthorized ones', async () => {
const fixture = TestBed.createComponent(TaskPluginsComponent); const component = fixture.componentInstance;
component.toggle();
http.expectOne('/api/plugins').flush({ plugins: [{ enabled: true,
manifest: { id: 'desktop', name: '电脑控制', targets: ['mixed'] }, profiles: [
{ name: 'old', label: '旧配置', authorized: false },
{ name: 'lab', label: '实验室', authorized: true, is_default: true },
{ name: 'office', label: '办公室', authorized: true }
] }] });
component.automatic = false; fixture.detectChanges(); await fixture.whenStable();
const emitted = jasmine.createSpy('changed'); component.changed.subscribe(emitted);
fixture.nativeElement.querySelector('.choice input').click(); fixture.detectChanges(); await fixture.whenStable();
const select: HTMLSelectElement = fixture.nativeElement.querySelector('select');
expect(select.value).toBe('lab'); expect(select.textContent).not.toContain('旧配置');
select.value = 'office'; select.dispatchEvent(new Event('change')); fixture.detectChanges(); await fixture.whenStable();
expect(emitted).toHaveBeenCalledWith({ plugins: [{ id: 'desktop', profile: 'office' }] });
});
it('shows a retryable error and keeps automatic mode on lookup failure', () => {
const fixture = TestBed.createComponent(TaskPluginsComponent);
fixture.componentInstance.toggle();
http.expectOne('/api/plugins').flush({}, { status: 503, statusText: 'Unavailable' });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('重试');
expect(fixture.componentInstance.automatic).toBeTrue();
});
});
@@ -0,0 +1,55 @@
import { Component, inject, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { InstalledPlugin, PluginService } from '../../services/plugin.service';
export interface TaskPluginOptions {
plugins?: { id: string; profile: string; version?: string }[];
}
@Component({
selector: 'app-task-plugins', standalone: true, imports: [FormsModule, RouterLink],
templateUrl: './task-plugins.component.html', styleUrl: './task-plugins.component.scss'
})
export class TaskPluginsComponent {
changed = output<TaskPluginOptions>();
private api = inject(PluginService);
automatic = true;
selected: Record<string, string> = {};
open = signal(false);
loading = signal(false);
error = signal('');
plugins = signal<InstalledPlugin[]>([]);
summary() {
return this.automatic ? '自动' : Object.keys(this.selected).length ? `已选 ${Object.keys(this.selected).length} 个` : '本次停用';
}
available() {
return this.plugins().filter(p => p.enabled && p.profiles.some(profile => profile.authorized) &&
p.manifest.targets.some(target => target === 'android' || target === 'mixed'));
}
authorizedProfiles(plugin: InstalledPlugin) { return plugin.profiles.filter(p => p.authorized); }
toggle() {
this.open.update(v => !v);
if (this.open()) this.load();
}
load() {
this.loading.set(true); this.error.set('');
this.api.list().subscribe({
next: value => { this.plugins.set(value.plugins); this.loading.set(false); },
error: () => { this.error.set('暂时无法读取插件。'); this.loading.set(false); }
});
}
choose(plugin: InstalledPlugin, event: Event) {
if ((event.target as HTMLInputElement).checked) {
const profiles = this.authorizedProfiles(plugin);
this.selected[plugin.manifest.id] = (profiles.find(p => p.is_default) || profiles[0]).name;
} else delete this.selected[plugin.manifest.id];
this.emit();
}
emit() {
this.changed.emit(this.automatic ? {} : {
plugins: Object.entries(this.selected).map(([id, profile]) => ({ id, profile }))
});
}
}
@@ -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()]);
}
@@ -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,77 @@
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 { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
import { TestResultBadgeComponent } from './test-result-badge.component';
@Component({
selector: 'app-test-result-card',
standalone: true,
imports: [ReportMarkdownComponent, 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>
<app-report-markdown class="reason" [content]="result().reason" />
@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><app-report-markdown class="reason" [content]="original.reason" />@if(original.evidence.length){<ol>@for(item of original.evidence; track $index){<li><app-report-markdown [content]="item" /></li>}</ol>}</details>}
@if(result().evidence.length){<details><summary>查看验收证据 · {{result().evidence.length}} 条</summary><ol>@for(item of result().evidence; track $index){<li><app-report-markdown [content]="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 || '复验保存失败,请重试'); }
});
}
}
@@ -0,0 +1,3 @@
// Local metadata reads must also time out when the browser is waiting for a
// connection. This is independent of model execution and long-running actions.
export const UI_READ_TIMEOUT_MS = 15_000;
@@ -0,0 +1 @@
export const MODEL_PRESETS = ['gpt-6-luna', 'gpt-6.1-sol', 'gpt-6-astra', 'mimo-v2.6-pro'];
@@ -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,22 @@ export interface TaskQueueItem {
export interface Session { export interface Session {
session_id: string; session_id: string;
profile?: string | null;
exploration_id?: string;
exploration_latest_run_id?: string;
exploration_status?: string;
exploration_run_count?: number;
exploration_kind?: 'exploration' | 'validation';
case_version?: number | null;
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;
@@ -224,6 +225,8 @@ export interface CheckerResult {
} }
export interface StepReplayFrame { export interface StepReplayFrame {
sourceId?: string;
sourceLabel?: string;
index: number; index: number;
stepNumber: number; stepNumber: number;
rawStepNumber?: number; rawStepNumber?: number;
Loaded 100 of 447 files, more files were not shown because too many files have changed in this diff. Show more