Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b584dae80a | ||
|
|
bf6247703d | ||
|
|
973ccae679 | ||
|
|
f4cb55c030 | ||
|
|
42f5f3ed55 | ||
|
|
838e9142a9 | ||
|
|
351b2fc905 | ||
|
|
32441e79c4 | ||
|
|
50638d4130 | ||
|
|
a4a5430922 | ||
|
|
e1ea099592 | ||
|
|
2328a4df0c | ||
|
|
f1bb5d02aa | ||
|
|
1440a1f508 | ||
|
|
a66803651a | ||
|
|
621f30e437 | ||
|
|
ee256ad7ce | ||
|
|
f86db1ced6 | ||
|
|
ef7af9988e | ||
|
|
72ff342605 | ||
|
|
96fedc816f | ||
|
|
9c82a40cbf | ||
|
|
6663233ab8 | ||
|
|
3ee4a5f7a8 | ||
|
|
bdc75d0c65 | ||
|
|
bdd1aa2c14 | ||
|
|
53c5680260 | ||
|
|
df0a7dc37a | ||
|
|
ec2c1265d3 | ||
|
|
3fbcdfefea | ||
|
|
4d6560dbd4 | ||
|
|
df66897ff4 | ||
|
|
a6a3ee2eb0 | ||
|
|
49873e7f05 | ||
|
|
f826bdf3b0 | ||
|
|
c96935b47b | ||
|
|
18d17da966 | ||
|
|
0355af7833 | ||
|
|
079d7b1e28 | ||
|
|
1fdf7c0d1d | ||
|
|
606ed94160 | ||
|
|
8dbe054a8c | ||
|
|
868f611c36 | ||
|
|
d31eb2a19d | ||
|
|
ab066733b8 | ||
|
|
59f458205d | ||
|
|
4465a2e199 | ||
|
|
c818a09a49 |
No files matched your search
+44
-5
@@ -3,37 +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=25
|
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 连续无工具调用的最大次数;无效回复不会重复送回模型。
|
# Flash 连续无工具调用的最大次数;无效回复不会重复送回模型。
|
||||||
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS=3
|
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,独立于模型调用,不配置也可运行。
|
||||||
@@ -43,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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# cmd.exe requires CRLF for batch scripts, especially UTF-8 Chinese text.
|
||||||
|
*.bat text eol=crlf
|
||||||
@@ -2,15 +2,27 @@
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
本仓库是基于 ARTEMIS 扩展的 Android AI 自动化测试平台,提供简体中文网页、OpenAI Responses 模型调用、批量任务、历史检索及 Word/Excel 报告。后端使用 Python 3.12+、FastAPI、LangGraph,前端使用 Angular,AI 客户端通过 MCP 接入。
|
本仓库是基于 ARTEMIS 扩展的自动化测试平台,提供简体中文网页、Flash/Pro 执行、交互探索、插件跨端操作、精选任务、批量任务及 Word/Excel 报告。后端使用 Python 3.12+、FastAPI、LangGraph,前端使用 Angular;模型统一使用 OpenAI Responses,AI 客户端通过轻量 MCP 桥接用户启动的主程序。
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
- 本文件适用于整个仓库;子目录的 AGENTS.md 在对应目录内优先。
|
- 本文件适用于整个仓库;子目录的 AGENTS.md 在对应目录内优先。
|
||||||
- 当前任务的用户指令优先于仓库指南。保留用户已有修改,只处理本次任务范围。
|
- 当前任务的用户指令优先于仓库指南。保留用户已有修改,只处理本次任务范围。
|
||||||
- 本文件指导开发;使用软件执行 Android 测试时,阅读 [自动化 Skill](skills/artemis-automation/SKILL.md)。
|
- 主程序与 `Artemis-Plugins` 是独立仓库。跨仓库任务分别检查分支、未提交改动和远端状态;发布文档或修复时,不夹带已有未发布提交。必要时使用隔离工作树,只移植本次提交。
|
||||||
|
- 本文件指导开发;使用软件执行设备或跨端任务、排查任务时,阅读 [自动化 Skill](skills/artemis-use/SKILL.md)。
|
||||||
- 以下是项目原生命令;宿主要求 RTK 等包装器时按宿主规则调用,不把本机路径或工具写成项目安装前提。
|
- 以下是项目原生命令;宿主要求 RTK 等包装器时按宿主规则调用,不把本机路径或工具写成项目安装前提。
|
||||||
|
|
||||||
|
## Read When Needed
|
||||||
|
|
||||||
|
| 修改范围 | 先读 |
|
||||||
|
| --- | --- |
|
||||||
|
| 执行、验收、探索、插件或证据的职责边界 | [DESIGN.md 的架构与行为约定](DESIGN.md#system-architecture) |
|
||||||
|
| 网页布局、样式或交互 | [视觉与交互设计](DESIGN.md#colors)及[前端指南](apps/showcase_ui/AGENTS.md) |
|
||||||
|
| 探索、示范、用例提炼与复跑 | [探索说明](docs/exploration-mode.md) |
|
||||||
|
| 插件协议、适配或市场协同 | [插件开发规范](docs/plugin-development.md)及插件仓库自身的 AGENTS.md、DESIGN.md |
|
||||||
|
| 远程 Python SDK | [SDK 设计](packages/artemis-client/DESIGN.md),保持标准库薄客户端边界 |
|
||||||
|
| 继续历史开发、确认版本或发布 | [迁入时的开发快照](docs/development-handoff.md)、[2.0.1 发行说明](docs/releases/2.0.1.md),再核对实时 Git 状态;`docs/plans/` 保留各自阶段的状态 |
|
||||||
|
|
||||||
## Quick Commands
|
## Quick Commands
|
||||||
|
|
||||||
在仓库根目录执行,前端命令见局部指南:
|
在仓库根目录执行,前端命令见局部指南:
|
||||||
@@ -27,46 +39,41 @@
|
|||||||
| 核心类型检查 | `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
|
|
||||||
|
|
||||||
- `artemis/agents/`、`artemis/runtime/`:Flash/Pro 执行、设备与任务运行时。
|
|
||||||
- `artemis/llm/`、`artemis/services/llm.py`:模型请求、超时、重试与恢复。
|
|
||||||
- `artemis/config/`、`.env.example`:运行配置、环境变量及默认值。
|
|
||||||
- `config/artemis.jsonc`、`artemis/resources/config/artemis.jsonc`:开发与打包使用的配置,默认值变更须同步。
|
|
||||||
- `apps/admin_console/`:HTTP API、统一队列、批次、历史及报告服务。
|
|
||||||
- `apps/showcase_ui/`:Angular 网页;遵循 [前端指南](apps/showcase_ui/AGENTS.md)。
|
|
||||||
- `mcp_server/`:MCP 工具与客户端使用规则。
|
|
||||||
- `packages/artemis-client/`:远程 Python SDK;`skills/artemis-automation/`:随源码分发的 AI 使用技能。
|
|
||||||
- `scripts/`:安装、镜像、升级、检查及打包脚本。
|
|
||||||
- `tests/unit/`、`tests/tools/`、`packages/artemis-client/tests/`:默认测试范围。
|
|
||||||
|
|
||||||
## Architecture Boundaries
|
## Architecture Boundaries
|
||||||
|
|
||||||
- 模型传输只使用 OpenAI Responses,不恢复 Gemini 专用调用或 Chat Completions 分支。API 模型名与 Flash/Pro 执行模式分开处理。
|
- 模型传输只使用 OpenAI Responses,不恢复 Gemini 专用调用或 Chat Completions 分支。API 模型名与 Flash/Pro 执行模式分开处理。
|
||||||
- 网页和工作区 MCP 工具复用后台配置、队列、历史和报告服务;不要另建调度器或第二套状态存储。
|
- 网页、MCP 与 SDK 复用后台配置、队列、历史和报告服务;不另建调度器或第二套状态存储。MCP 不自行启动完整后台,主程序离线时仍可握手并明确提示连接状态。
|
||||||
- 批次创建与执行分开;保持 request_id 幂等性。同设备串行,失败项不自动重跑;模型重试不能变成业务用例重提。
|
- 批次创建与执行分开;保持 `request_id` 幂等性。同设备及共享桌面资源按现有锁串行,任务固定插件版本;失败项不自动重跑,模型重试不能变成业务用例重提,结果不明的写操作不盲目重发。
|
||||||
- 区分“执行完成”与“验证通过”。报告结论必须有实际证据,保留未验证、失败、跳过和中断状态。
|
- 最终验收使用独立总预算,协调单次模型超时与重试;不能套用中间检查点的 180 秒时限。重试保留已取得的工具证据,用户停止或探索新指导仍可中断。
|
||||||
|
- 区分执行完成、工具成功和验证通过。正式结论必须经过完整解析与校验,取消或截断的流式内容不能显示为通过;报告、网页、MCP 与人工复验后的有效结论保持一致。
|
||||||
|
- 示范的实际来源贯穿分析、澄清、规划、执行、验收与用例提炼。补充网址不等于更换平台;用户明确要求改端或跨端时再按要求执行,手机证据不能替代电脑证据。
|
||||||
|
- 人工示范遵循窗口/屏幕录制配置。窗口模式跟随前台窗口,无有效窗口时保留缺口,不扩大到多屏桌面;普通录屏在只读最终验收前停止,恢复操作时再追加片段。
|
||||||
|
- 探索主动执行预算耗尽后保留进度并暂停,不直接判失败;探索成功、用例提炼成功和固定版本复跑通过分别记录。收藏保存独立模板与配置,不依赖原任务一直存在。
|
||||||
|
- 历史压缩、视觉摘要与工具上下文调整须保留关键文件回执和来源,维护稳定的多轮前缀;缓存率使用实际用量加权统计,不把缓存输入当作消失的 token,也不承诺固定收益。
|
||||||
- 设备动作回放已移除;只读历史步骤、截图和上下文回忆仍保留,不因 replay 命名误删这些能力。
|
- 设备动作回放已移除;只读历史步骤、截图和上下文回忆仍保留,不因 replay 命名误删这些能力。
|
||||||
- 密钥仅通过网页或环境配置写入,读取配置不得回显;MCP 不接受密钥。全局配置修改影响后续任务。
|
- 密钥仅通过网页或环境配置写入,读取配置不得回显;MCP 不接受密钥。全局配置修改影响后续任务,已保存配置优先于新安装默认值。
|
||||||
- 默认 high、请求超时 300 秒、失败后重试 5 次、间隔 10 秒,视频分析与录屏关闭。接口不支持思考强度时明确报错,不静默降级。
|
- 新安装普通任务默认 `gpt-6-luna / high`,探索默认 `gpt-6.1-sol / high`;请求超时 300 秒、失败后重试 5 次、间隔 10 秒,视频分析与普通录屏关闭。接口不支持思考强度时明确报错,不静默降级。
|
||||||
|
- 业务环境恢复由任务提示词提出,不新增默认业务回滚流程;连接、录像、子进程和资源锁的运行时收尾仍然必需。
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Python 使用绝对导入、4 空格缩进和双引号,由 Ruff 检查;不要放宽质量基线来掩盖新增问题。
|
- Python 使用绝对导入、4 空格缩进和双引号,由 Ruff 检查;不要放宽质量基线来掩盖新增问题。
|
||||||
- 新增可调参数集中进入配置层,并更新环境示例、网页/MCP 入口和必要文档,避免散落硬编码。
|
- 新增可调参数集中进入配置层,并更新环境示例、网页/MCP 入口和必要文档;默认值同步 `config/artemis.jsonc` 与 `artemis/resources/config/artemis.jsonc`。
|
||||||
- MCP 工具增删时同步工具注册、CLI 安装白名单、测试、Skill 和能力文档。
|
- MCP 工具增删时同步工具注册、CLI 安装白名单、测试、Skill 和能力文档。
|
||||||
|
- 同步 Codex MCP/Skill 时保留已有工具白名单、禁用状态和审批策略;异常时保留原配置并告警,不生成非法 TOML 或阻断网页启动。
|
||||||
- Windows PowerShell 启动脚本保持兼容 Windows PowerShell 5.1,避免编码导致解析失败。升级只做 Git 快进,保护已有配置和本地修改。
|
- Windows PowerShell 启动脚本保持兼容 Windows PowerShell 5.1,避免编码导致解析失败。升级只做 Git 快进,保护已有配置和本地修改。
|
||||||
- 前端遵循现有简体中文文案与视觉样式;不直接编辑构建产物。
|
- `update*.bat` 保持精简的 ASCII 入口;升级程序及其辅助脚本先复制到项目外执行。更新后通过新版 `start.ps1 -PrepareOnly -SkipUpdate` 同步环境,保留此内部调用约定,避免继续运行旧版安装函数。
|
||||||
|
- 前端遵循 DESIGN.md 与局部指南,复用现有组件和简体中文文案;不直接编辑构建产物。页面仍显示旧效果时,先核对实际服务的源码目录及构建来源。
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
|
|
||||||
- Python 行为改动运行默认测试、Ruff 两项检查、质量基线和核心类型检查;前端改动遵循局部指南的测试与构建要求。
|
- Python 行为改动运行默认测试、Ruff 两项检查、质量基线和核心类型检查;前端改动遵循局部指南的测试与构建要求。
|
||||||
- 文档改动核对命令、相对链接和功能说明;仅文档变更无需启动设备或调用付费模型。
|
- 文档改动核对命令、相对链接和功能说明;历史测试数量与未测范围见对应版本记录,不能冒充本次验证。仅文档变更无需启动设备或调用付费模型。
|
||||||
- 默认测试不依赖设备、密钥或私有服务;外部测试按现有 android、integration、e2e、cloud、manual 标记分层,不默认执行全量外部测试。
|
- 默认测试不依赖设备、密钥或私有服务;外部测试按现有 android、integration、e2e、cloud、manual 标记分层,不默认执行全量外部测试。
|
||||||
- 设备验证沿用用户指定设备;多设备且未指定时先确认。编写应用交互测试前,先通过 ARTEMIS 探索实际页面和操作路径。
|
- 设备验证沿用用户指定设备;多设备且未指定时先确认。编写应用交互测试前,先通过 ARTEMIS 探索实际页面和操作路径。
|
||||||
- 提交前检查 `git diff --check` 和暂存范围;报告实际执行的验证及未完成项,不将未运行的检查写成通过。
|
- 提交前检查 `git diff --check` 和暂存范围,只暂存本次具体路径;报告实际执行的验证及未完成项,不将未运行的检查写成通过。
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
@@ -78,5 +85,6 @@ Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。开
|
|||||||
## References
|
## References
|
||||||
|
|
||||||
- [用户入口](README.md):安装、配置与日常使用。
|
- [用户入口](README.md):安装、配置与日常使用。
|
||||||
|
- [设计约定](DESIGN.md):当前架构、行为与网页视觉规则。
|
||||||
- [AI 自动化](docs/ai-automation.md):MCP、Skill 及升级行为。
|
- [AI 自动化](docs/ai-automation.md):MCP、Skill 及升级行为。
|
||||||
- [工作区说明](docs/automation-workspace.md):报告、批量导入和历史检索。
|
- [工作区说明](docs/automation-workspace.md):报告、批量导入和历史检索。
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
---
|
||||||
|
name: ARTEMIS 控制台
|
||||||
|
colors:
|
||||||
|
canvas: "#f8f9fa"
|
||||||
|
surface: "#ffffff"
|
||||||
|
text: "#1e293b"
|
||||||
|
text-strong: "#0f172a"
|
||||||
|
text-secondary: "#475569"
|
||||||
|
text-muted: "#64748b"
|
||||||
|
text-control: "#334155"
|
||||||
|
border: "#e2e8f0"
|
||||||
|
border-control: "#dbe3ec"
|
||||||
|
primary: "#1a73e8"
|
||||||
|
primary-hover: "#1557b0"
|
||||||
|
primary-soft: "#eff6ff"
|
||||||
|
exploration-action: "#4f46e5"
|
||||||
|
exploration-soft: "#eef2ff"
|
||||||
|
focus: "#a5b4fc"
|
||||||
|
success: "#047857"
|
||||||
|
success-soft: "#ecfdf5"
|
||||||
|
warning: "#92400e"
|
||||||
|
warning-soft: "#fffbeb"
|
||||||
|
error: "#b91c1c"
|
||||||
|
error-soft: "#fef2f2"
|
||||||
|
rounded:
|
||||||
|
compact: 8px
|
||||||
|
control: 10px
|
||||||
|
card: 16px
|
||||||
|
composer: 22px
|
||||||
|
spacing:
|
||||||
|
tight: 4px
|
||||||
|
icon: 6px
|
||||||
|
small: 8px
|
||||||
|
group: 12px
|
||||||
|
section: 16px
|
||||||
|
workspace: 24px
|
||||||
|
components:
|
||||||
|
model-selector:
|
||||||
|
backgroundColor: "{colors.surface}"
|
||||||
|
textColor: "{colors.text-control}"
|
||||||
|
rounded: "{rounded.control}"
|
||||||
|
demonstration-control:
|
||||||
|
height: 36px
|
||||||
|
exploration-composer:
|
||||||
|
backgroundColor: "{colors.surface}"
|
||||||
|
rounded: "{rounded.composer}"
|
||||||
|
---
|
||||||
|
|
||||||
|
# ARTEMIS 设计与交互约定
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
ARTEMIS 面向使用自然语言执行、探索和复跑测试的人。当前产品包含 Flash、Pro、探索三种入口,按需调用安卓设备和电脑插件,提供批次、精选任务、历史、报告及证据。用户应能看懂当前在做什么、何时需要回答、哪些结果已有证据。
|
||||||
|
|
||||||
|
本文记录主程序的职责边界及 `apps/showcase_ui` 的现有视觉与交互规则,不提出新的主题改版。远程 SDK 的协议边界见[局部设计](packages/artemis-client/DESIGN.md);独立插件仓库维护适配器设计和构建规则。阶段性进度见[开发快照](docs/development-handoff.md),发行状态见[2.0.1 说明](docs/releases/2.0.1.md)。
|
||||||
|
|
||||||
|
## System architecture
|
||||||
|
|
||||||
|
| 层 | 职责与源码入口 |
|
||||||
|
| --- | --- |
|
||||||
|
| 网页、MCP、远程 SDK | 请求建模、交互与结果读取。`apps/showcase_ui`、`mcp_server`、`packages/artemis-client` 共同使用后台服务;MCP 和薄 SDK 不另启完整后台 |
|
||||||
|
| HTTP 与业务服务 | `apps/admin_console` 管理配置、统一队列、批次、收藏、历史、人工复验和导出;`TaskQueueService` 及现有资源锁负责执行准入与互斥 |
|
||||||
|
| 任务与模型 | `artemis/agents`、`artemis/runtime` 执行 Flash/Pro;`artemis/exploration` 管理交流、预算、示范与用例版本;`artemis/llm`、`artemis/services/llm.py` 统一 Responses 请求 |
|
||||||
|
| 插件与证据 | `artemis/plugins` 管理安装、会话、版本固定、来源、截图、录像和附件;插件执行工具动作,宿主管理任务生命周期和最终验收 |
|
||||||
|
| 配置与交付 | `artemis/config`、两份 `artemis.jsonc` 和环境配置提供默认值;`scripts` 负责启动、镜像、独立升级和源码打包 |
|
||||||
|
|
||||||
|
请求重试、业务执行与最终验收分别处理。提交使用稳定标识;创建批次或探索草稿不自动执行,查询不产生设备动作。执行准入和恢复遵守现有资源锁,任务固定所用插件版本,不能通过另建队列绕开互斥。
|
||||||
|
|
||||||
|
最终验收有独立总预算,按请求超时、重试和剩余任务时间协调,保留取证结果;完整有效的结构化结论才更新正式裁定。`completed`、工具成功、探索通过、用例提炼和复跑通过各自表达自己的状态。
|
||||||
|
|
||||||
|
示范来源保存在元数据中,贯穿分析、提问、规划、执行、验收和用例生成。补充网址不会隐式把电脑示范改到安卓;用户明确改端或提出跨端步骤时按其要求执行。证据必须对应真实来源、窗口、采集时间与操作。
|
||||||
|
|
||||||
|
录像使用用户选择的范围。人工示范的窗口模式跟随前台窗口,仅保留一路,无有效窗口时保留缺口;普通录屏在只读验收前停止。恢复操作再追加片段,时间映射以实际录像为准,不借用另一来源或时间的画面。
|
||||||
|
|
||||||
|
收藏保存独立模板与执行配置,用例复跑固定版本;删除原任务不破坏已保存模板。历史与导出的有效结论合并人工复验,不能用新裁定改写旧证据。
|
||||||
|
|
||||||
|
普通任务新安装默认 `gpt-6-luna / high`,探索单独使用 `gpt-6.1-sol / high`,保留已有配置。上下文参照上限 300k、默认历史压缩预算 80k,均不表示整条任务累计 token 限额。优化稳定前缀时保留关键文件回执、路径、校验和来源;费用收益依实际网关用量及计价判断。
|
||||||
|
|
||||||
|
## Colors
|
||||||
|
|
||||||
|
整体使用浅灰画布、白色内容面和深色正文。首页主要操作使用蓝色,探索发送及相关主操作保留现有靛色;绿色、琥珀色、红色用于结果与需要处理的状态。新控件复用所在区域的语义色。
|
||||||
|
|
||||||
|
| 用途 | 值与来源 |
|
||||||
|
| --- | --- |
|
||||||
|
| 画布 / 正文 | `#f8f9fa` / `#1e293b`,来自[全局样式](apps/showcase_ui/src/styles.scss) |
|
||||||
|
| 首页操作 / 悬停 | `#1a73e8` / `#1557b0`,来自[首页样式](apps/showcase_ui/src/app/pages/home/home.component.scss) |
|
||||||
|
| 探索操作 / 浅色状态面 | `#4f46e5` / `#eef2ff`,来自[探索面板](apps/showcase_ui/src/app/components/exploration-panel/exploration-panel.component.scss) |
|
||||||
|
| 正文层级 / 边框 | 强调 `#0f172a`、次级 `#475569`、辅助 `#64748b`;常规边框 `#e2e8f0` |
|
||||||
|
| 成功、等待、错误 | 深色状态文字配对应浅色面;同时给出文字、图标或说明,结论不只依靠颜色 |
|
||||||
|
|
||||||
|
加载、空状态、断开、未验证与失败使用明确文案。执行完成可以有完成标识,测试裁定仍需独立展示;不能让绿色完成态替代测试通过结论。
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
沿用全局字体栈:`Google Sans`、`Google Sans Text`、`Inter`、系统无衬线字体;中文使用系统可用字形。代码、路径及请求诊断使用现有等宽样式,保留换行与复制能力。
|
||||||
|
|
||||||
|
模型控件正文为 13px,探索输入正文为 14px,用例标题为 18px;辅助信息依现有组件使用 11–12px。保留字号、字重、留白形成的层次,不通过大段加粗堆叠状态。Markdown、表格、长链接和中文长标题应换行,不撑破容器。
|
||||||
|
|
||||||
|
图标沿用 Material Symbols Outlined,常规全局尺寸 20px;组件局部按原有尺寸调整。图标与相邻文字垂直居中,独立图标按钮提供可访问名称。
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- 首页负责创建任务与访问精选模板;工作区主区域展示当前任务的交流、执行步骤和结果,底部输入框始终服务当前任务。
|
||||||
|
- 编辑、模型设置和复跑参数按需展开,默认保持任务时间线可读。精选卡片在收起状态下等高,长标题、不同状态和展开内容不会遮挡操作入口。
|
||||||
|
- 通常以 4、6、8、12、16、24px 组织内部间距;沿用组件现有网格和断点。子容器允许收缩,长内容使用 `min-width: 0` 及合适换行。
|
||||||
|
- 探索面板在 600px 以下收紧外边距并允许操作换行;模型选择控件宽度跟随容器。窄屏仍可辨认状态、选择来源和发送指导。
|
||||||
|
- 列表后台刷新保留滚动、选择和折叠状态;首次加载与静默更新分开,避免周期性加载提示挤动内容。实时连接复用现有页面可见性管理。
|
||||||
|
|
||||||
|
## Elevation
|
||||||
|
|
||||||
|
卡片使用细边框和克制的阴影区分区域。首页现有玻璃效果、渐变和按钮悬停保留在原有区域,正文、报告、表单和选项保持清晰背景及文字对比。
|
||||||
|
|
||||||
|
探索输入区沿用 `0 6px 24px #0f172a08` 的阴影;焦点反馈沿用所在控件的轮廓和浅色描边。全局 CDK overlay 层级为 1200,新增弹层先使用既有 overlay 机制,不任意叠加更大的层级。
|
||||||
|
|
||||||
|
## Shapes
|
||||||
|
|
||||||
|
模型输入与选择框圆角 10px;紧凑控件常用 8px,内容卡片按组件使用 12–16px,探索输入区为 22px。保留现有徽标、标签与胶囊形控件的局部形状;这些值不要求把全部历史控件改成同一圆角。
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| 组件或状态 | 必须维持的交互与尺寸 | 实现依据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 模型选择 | 预设选择加自定义输入;主模型、兜底和精选任务复用同一组件。最小高度 40px、内边距 10px 12px、圆角 10px,保留焦点与禁用态 | [model-selector](apps/showcase_ui/src/app/components/model-selector/model-selector.component.ts) |
|
||||||
|
| 人工示范控件 | 来源标签与控件水平对齐,选择框及开始按钮均为 36px 高、垂直居中;窄屏可换行。准备、录制、分析分别显示 | [exploration-panel](apps/showcase_ui/src/app/components/exploration-panel/exploration-panel.component.scss) |
|
||||||
|
| 探索建议 | 推荐内容为用户口吻的可编辑回复;点击只填草稿,用户发送后才继续任务 | [探索组件](apps/showcase_ui/src/app/components/exploration-panel/exploration-panel.component.ts) |
|
||||||
|
| 历史标题与星标 | 星标只占标题,第二行状态与编号正常左对齐;状态徽标沿用 20px 高及统一内边距 | [task-history](apps/showcase_ui/src/app/components/task-history/task-history.component.ts) |
|
||||||
|
| 验收状态 | 展示准备证据、核对、生成与校验等阶段及可用的耗时、预算、重试诊断;未完成的流式文字不变为正式通过标签 | [agent-stream](apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html) |
|
||||||
|
| 截图、录像与文件 | 显示真实来源和缺口;按时间定位并保留跳转前的播放/暂停状态,文件成功以真实保存回执为准 | [工作区说明](docs/automation-workspace.md) |
|
||||||
|
| 保存和错误反馈 | 配置加载、保存、空值、失败与密钥已配置状态有明确提示;空密钥保留已有值,连接测试与保存分开 | [模型配置](apps/showcase_ui/src/app/components/model-settings/model-settings.component.ts) |
|
||||||
|
|
||||||
|
这些规则对应已接受的现有行为。改动后结合实际层叠样式、渲染尺寸和相关状态检查,不能只核对一处声明。
|
||||||
|
|
||||||
|
## Do's and Don'ts
|
||||||
|
|
||||||
|
- 复用现有组件、API 类型、中文文案和状态语义。新增状态先说明它与执行、验收或人工操作的关系。
|
||||||
|
- 保持实际来源与证据一致,缺失时说明原因;不使用手机画面替代电脑操作,也不把工具成功当作业务通过。
|
||||||
|
- 保留可见焦点、禁用反馈和减少动画偏好;录制红点表示实际录制,等待或执行中使用各自文案。
|
||||||
|
- 源码已更新而页面仍旧时,先核对服务目录、构建与缓存;不通过反复增加样式覆盖掩盖旧构建问题。
|
||||||
|
- 视觉变化验证代表性宽度、长标题、加载、等待回答、错误与展开状态;文档格式检查不等于渲染验证。开发检查按[前端指南](apps/showcase_ui/AGENTS.md)执行。
|
||||||
@@ -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.1 已发布**:修复 Python 3.12 全新安装时网页服务无法启动的问题,并统一不同 Python 版本的 MCP 工具描述。修复与升级步骤见 [2.0.1 发行说明](docs/releases/2.0.1.md);探索、插件和精选任务等功能见 [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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 archive_service.status(key, require("batch", key))["status"] == "preparing":
|
||||||
|
raise HTTPException(409, "报告正在打包,请完成后再删除批次")
|
||||||
if not service.delete_batch(key):
|
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,10 +164,26 @@ def batch_action(batch_id: str, action: Literal["pause", "resume", "retry_failed
|
|||||||
raise HTTPException(409, str(exc)) from exc
|
raise HTTPException(409, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batches/{batch_id}/archive/prepare")
|
||||||
|
def prepare_batch_archive(batch_id: str):
|
||||||
|
batch = require("batch", batch_id)
|
||||||
|
return archive_service.prepare(batch_id, batch)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/batches/{batch_id}/archive/status")
|
||||||
|
def batch_archive_status(batch_id: str):
|
||||||
|
batch = require("batch", batch_id)
|
||||||
|
return archive_service.status(batch_id, batch)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/batches/{batch_id}/archive")
|
@router.get("/batches/{batch_id}/archive")
|
||||||
async def batch_archive(batch_id: str):
|
async def batch_archive(batch_id: str):
|
||||||
require("batch", batch_id)
|
batch = require("batch", batch_id)
|
||||||
path = await asyncio.to_thread(report_export.export_batch, batch_id)
|
if archive_service.status(batch_id, batch)["status"] == "preparing":
|
||||||
|
raise HTTPException(409, "报告正在打包,请等待准备完成")
|
||||||
|
path = archive_service.archive_path(batch_id)
|
||||||
|
if not path.is_file() or archive_service.status(batch_id, batch)["status"] != "ready":
|
||||||
|
path = await asyncio.to_thread(report_export.export_batch, batch_id)
|
||||||
return FileResponse(path, filename=f"批次报告-{batch_id[:8]}.zip")
|
return FileResponse(path, filename=f"批次报告-{batch_id[:8]}.zip")
|
||||||
|
|
||||||
|
|
||||||
@@ -165,8 +201,7 @@ 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)
|
||||||
label = report_export.archive_case_name(report_export.session_report(session_id))
|
|
||||||
return FileResponse(path, filename=f"{label}-{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}")
|
||||||
@@ -174,6 +209,44 @@ async def download_report(
|
|||||||
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),
|
||||||
@@ -182,9 +255,13 @@ def history(
|
|||||||
end: float | None = None,
|
end: float | None = None,
|
||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
size: int = Query(default=30, ge=1, le=100),
|
size: int = Query(default=30, ge=1, le=100),
|
||||||
|
batch_id: str = Query(default="", max_length=100),
|
||||||
):
|
):
|
||||||
# SQL filtering/pagination searches the entire persisted history, not a UI cache.
|
# SQL filtering/pagination searches the entire persisted history, not a UI cache.
|
||||||
query = """WITH history AS (
|
query = (
|
||||||
|
"WITH "
|
||||||
|
+ EXPLORATION_RUNS_CTE
|
||||||
|
+ """, history_base AS (
|
||||||
SELECT s.session_id, s.initial_goal, s.start_time,
|
SELECT s.session_id, s.initial_goal, s.start_time,
|
||||||
COALESCE(json_extract(j.payload,'$.ended_at'),s.end_time) end_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,
|
||||||
@@ -193,7 +270,9 @@ def history(
|
|||||||
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
|
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'),
|
||||||
@@ -204,13 +283,37 @@ def history(
|
|||||||
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'),'')
|
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 [
|
||||||
@@ -231,7 +334,22 @@ def history(
|
|||||||
[*args, size, (page - 1) * size],
|
[*args, size, (page - 1) * size],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
items = [dict(row) for row in rows]
|
items = [dict(row) for row in rows]
|
||||||
|
jobs = store.jobs_for_sessions([item["session_id"] for item in items])
|
||||||
for item in items:
|
for item in items:
|
||||||
if item["case_id"]:
|
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)
|
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}
|
return {"items": items, "total": total, "page": page, "size": size}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -16,32 +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
|
||||||
from admin_console.services.automation_store import store
|
from apps.admin_console.services.task_import import execution_title
|
||||||
from admin_console.services.task_import import case_title
|
from apps.admin_console.services.exploration_listing import exploration_metadata
|
||||||
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.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
|
|
||||||
from apps.admin_console.services.automation_store import store
|
|
||||||
from apps.admin_console.services.task_import import case_title
|
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -56,6 +81,8 @@ 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])
|
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()
|
||||||
@@ -79,11 +106,15 @@ 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)
|
job = jobs_by_id.get(s_id)
|
||||||
if job:
|
if job:
|
||||||
case = job.get("case") or {}
|
case = job.get("case") or {}
|
||||||
|
row_dict["batch_id"] = job.get("batch_id")
|
||||||
row_dict["case_id"] = case.get("case_id") or ""
|
row_dict["case_id"] = case.get("case_id") or ""
|
||||||
row_dict["display_name"] = case_title(case) or row_dict.get("initial_goal")
|
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 ""
|
row_dict["error"] = job.get("error") or ""
|
||||||
if job.get("status") in {"completed", "failed", "cancelled", "interrupted", "skipped"}:
|
if job.get("status") in {"completed", "failed", "cancelled", "interrupted", "skipped"}:
|
||||||
row_dict["status"] = job["status"]
|
row_dict["status"] = job["status"]
|
||||||
@@ -128,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(
|
||||||
@@ -178,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
|
||||||
|
|
||||||
|
|
||||||
@@ -187,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")
|
||||||
@@ -241,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
|
||||||
@@ -254,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))
|
||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
+104
-39
@@ -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."""
|
||||||
await on_startup()
|
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:
|
try:
|
||||||
yield
|
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()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
await on_shutdown()
|
||||||
finally:
|
finally:
|
||||||
await on_shutdown()
|
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:
|
|
||||||
uvicorn.run(
|
|
||||||
"apps.admin_console.server:app",
|
|
||||||
host=host,
|
|
||||||
port=port,
|
|
||||||
reload=True,
|
|
||||||
timeout_graceful_shutdown=5,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
config = uvicorn.Config(
|
config = uvicorn.Config(
|
||||||
app,
|
"apps.admin_console.server:app" if reload else app,
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
|
reload=reload,
|
||||||
timeout_graceful_shutdown=5,
|
timeout_graceful_shutdown=5,
|
||||||
)
|
)
|
||||||
|
if reload:
|
||||||
|
from uvicorn.supervisors import ChangeReload
|
||||||
|
|
||||||
|
server = ArtemisUvicornServer(config)
|
||||||
|
ChangeReload(config, target=server.run, sockets=[listener]).run()
|
||||||
|
return
|
||||||
|
|
||||||
server = ArtemisUvicornServer(config)
|
server = ArtemisUvicornServer(config)
|
||||||
app.state.uvicorn_server = server
|
app.state.uvicorn_server = server
|
||||||
server.run()
|
server.run(sockets=[listener])
|
||||||
finally:
|
finally:
|
||||||
|
listener.close()
|
||||||
app.state.uvicorn_server = None
|
app.state.uvicorn_server = None
|
||||||
clear_server_info(port=port, lifecycle_token=LIFECYCLE_TOKEN)
|
clear_server_info(port=port, lifecycle_token=LIFECYCLE_TOKEN)
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import time
|
|||||||
from uuid import UUID
|
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, case_title
|
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"}
|
||||||
@@ -34,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"),
|
||||||
@@ -50,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"]):
|
||||||
@@ -62,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="服务已重启;请检查中断任务,点击继续执行未开始任务"
|
||||||
)
|
)
|
||||||
@@ -86,6 +146,9 @@ async def on_terminal(item: dict, status: str):
|
|||||||
raise RuntimeError("任务元数据写入失败")
|
raise RuntimeError("任务元数据写入失败")
|
||||||
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())
|
job.update(status="finalizing", terminal_status=status, ended_at=time.time())
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
try:
|
try:
|
||||||
@@ -115,11 +178,40 @@ async def on_terminal(item: dict, status: str):
|
|||||||
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
|
||||||
@@ -135,10 +227,11 @@ async def tick_batches():
|
|||||||
# not consume this case's execution budget.
|
# not consume this case's execution budget.
|
||||||
if job["status"] != "running":
|
if job["status"] != "running":
|
||||||
continue
|
continue
|
||||||
limit = int(
|
from artemis.runtime.task_result import read_worker_outcome
|
||||||
(job.get("case", {}).get("task_config") or {}).get("timeout_seconds")
|
|
||||||
or batch.get("timeout_seconds", 1800)
|
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"):
|
||||||
@@ -151,7 +244,17 @@ async def tick_batches():
|
|||||||
error=f"任务执行超时({limit} 秒,不含暂停/排队时间)", timed_out=True
|
error=f"任务执行超时({limit} 秒,不含暂停/排队时间)", timed_out=True
|
||||||
)
|
)
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
task_queue_service.stop_tasks(session_id=job["session_id"])
|
from artemis.runtime.task_result import save_failure_result
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
save_failure_result,
|
||||||
|
str(job["session_id"]),
|
||||||
|
job["error"],
|
||||||
|
code="task_timeout",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
task_queue_service.stop_tasks(session_id=job["session_id"])
|
||||||
# on_terminal records the failed case. The next pending
|
# on_terminal records the failed case. The next pending
|
||||||
# case then runs normally unless stop_on_failure is set.
|
# case then runs normally unless stop_on_failure is set.
|
||||||
continue
|
continue
|
||||||
@@ -186,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:
|
||||||
@@ -213,7 +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=case_title(job["case"]),
|
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 "队列拒绝接收任务")
|
||||||
@@ -246,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,
|
||||||
@@ -255,25 +369,31 @@ def create_batch(payload: dict) -> dict:
|
|||||||
"formats": payload.get("formats") or store.preferences()["formats"],
|
"formats": payload.get("formats") or store.preferences()["formats"],
|
||||||
"profile": payload.get("profile", "flash"),
|
"profile": payload.get("profile", "flash"),
|
||||||
"device_serial": payload.get("device_serial"),
|
"device_serial": payload.get("device_serial"),
|
||||||
"timeout_seconds": payload.get("timeout_seconds")
|
"timeout_seconds": payload.get("timeout_seconds") or settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||||
or int(os.getenv("ARTEMIS_BATCH_TASK_TIMEOUT", "1800")),
|
|
||||||
"stop_on_failure": payload.get("stop_on_failure", False),
|
"stop_on_failure": payload.get("stop_on_failure", False),
|
||||||
"request_id": payload.get("request_id"),
|
"request_id": payload.get("request_id"),
|
||||||
|
"repeat_count": repeat_count,
|
||||||
|
"case_count": len(cases),
|
||||||
|
"total_executions": total,
|
||||||
}
|
}
|
||||||
jobs = []
|
jobs = []
|
||||||
for number, case in enumerate(payload["cases"], 1):
|
for iteration in range(1, repeat_count + 1):
|
||||||
jobs.append(
|
for number, case in enumerate(cases, 1):
|
||||||
{
|
jobs.append(
|
||||||
"session_id": new_id(),
|
{
|
||||||
"batch_id": bid,
|
"session_id": new_id(),
|
||||||
"position": number,
|
"batch_id": bid,
|
||||||
"case": case,
|
"position": len(jobs) + 1,
|
||||||
"status": "pending",
|
"iteration": iteration,
|
||||||
"formats": batch["formats"],
|
"repeat_count": repeat_count,
|
||||||
"created_at": time.time(),
|
"case_position": number,
|
||||||
"device_serial": batch["device_serial"],
|
"case": case,
|
||||||
}
|
"status": "pending",
|
||||||
)
|
"formats": batch["formats"],
|
||||||
|
"created_at": batch["created_at"],
|
||||||
|
"device_serial": batch["device_serial"],
|
||||||
|
}
|
||||||
|
)
|
||||||
return store.create_batch(batch, jobs, payload)
|
return store.create_batch(batch, jobs, payload)
|
||||||
|
|
||||||
|
|
||||||
@@ -287,7 +407,9 @@ def delete_batch(batch_id: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
storage = StorageManager(store.path, report_export.TRACES_PATH)
|
storage = StorageManager(store.path, report_export.TRACES_PATH)
|
||||||
mcp_exports = report_export.EXPORT_ROOT.parent / "mcp_exports"
|
mcp_exports = report_export.EXPORT_ROOT.parent / "mcp_exports"
|
||||||
with report_export.EXPORT_LOCK:
|
with report_export.export_lock(
|
||||||
|
"batch:" + batch_id, *("session:" + j["session_id"] for j in jobs)
|
||||||
|
):
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
session_id = str(UUID(job["session_id"]))
|
session_id = str(UUID(job["session_id"]))
|
||||||
storage.delete_session(UUID(session_id))
|
storage.delete_session(UUID(session_id))
|
||||||
@@ -329,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:
|
||||||
|
|||||||
@@ -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("无效的记录编号")
|
||||||
@@ -143,6 +149,52 @@ class AutomationStore:
|
|||||||
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")
|
||||||
@@ -241,6 +293,11 @@ class AutomationStore:
|
|||||||
batch = json.loads(row[0])
|
batch = json.loads(row[0])
|
||||||
if batch.get("status") != "deleting":
|
if batch.get("status") != "deleting":
|
||||||
raise ValueError("批次尚未准备删除")
|
raise ValueError("批次尚未准备删除")
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM automation_records WHERE kind='case_review' AND id IN "
|
||||||
|
"(SELECT session_id FROM automation_jobs WHERE batch_id=?)",
|
||||||
|
(batch_id,),
|
||||||
|
)
|
||||||
conn.execute("DELETE FROM automation_jobs WHERE batch_id=?", (batch_id,))
|
conn.execute("DELETE FROM automation_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=?",
|
||||||
@@ -254,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
|
||||||
|
)
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
"""Independent favorite snapshots dispatched through the existing task services."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,10 +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 case_title
|
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": "执行完成",
|
||||||
@@ -62,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 "验证失败"
|
||||||
@@ -88,6 +103,10 @@ 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"}:
|
||||||
@@ -95,6 +114,11 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
|
|||||||
state = read_json(directory / "status.json", {})
|
state = read_json(directory / "status.json", {})
|
||||||
if isinstance(state, dict) and state.get("error"):
|
if isinstance(state, dict) and state.get("error"):
|
||||||
return redact(state["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] = []
|
log_lines: list[str] = []
|
||||||
for name in ("stdout.log", "stderr.log"):
|
for name in ("stdout.log", "stderr.log"):
|
||||||
path = directory / name
|
path = directory / name
|
||||||
@@ -139,6 +163,8 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
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()
|
||||||
@@ -170,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 = "未验证(任务异常终止)"
|
||||||
@@ -181,11 +211,15 @@ def session_report(session_id: str, db_path=None) -> dict:
|
|||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"id": session_id,
|
"id": session_id,
|
||||||
"name": case_title(case) 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,
|
||||||
@@ -213,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 "—"),
|
||||||
@@ -222,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"]),
|
||||||
@@ -414,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")
|
||||||
@@ -423,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)
|
||||||
@@ -430,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 "未记录")
|
||||||
doc.add_heading("预期结果", 2)
|
if report["expected"]:
|
||||||
doc.add_paragraph(report["expected"] or "未指定")
|
doc.add_heading("预期结果", 2)
|
||||||
|
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"):
|
||||||
@@ -448,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]] = []
|
||||||
@@ -588,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 = []
|
||||||
@@ -601,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
|
||||||
|
|
||||||
@@ -621,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
|
||||||
|
|
||||||
|
|
||||||
@@ -632,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
|
||||||
@@ -653,48 +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 ""
|
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, prefix + 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"{prefix}步骤截图/{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"{prefix}任务录屏/{name}")
|
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,
|
||||||
|
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(
|
archive.writestr(
|
||||||
prefix + "文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2)
|
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("批次不存在")
|
||||||
@@ -702,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"])
|
||||||
@@ -730,11 +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:
|
||||||
|
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(
|
archive.write(
|
||||||
path, f"任务报告/{number:04d}_{archive_case_name(report)}/{path.name}"
|
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": ["探索对话", "用例版本", "探索执行历史及证据"],
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -184,3 +199,11 @@ def case_title(case: dict) -> str:
|
|||||||
case_id = str(case.get("case_id") or "").strip()
|
case_id = str(case.get("case_id") or "").strip()
|
||||||
name = str(case.get("name") or "").strip()
|
name = str(case.get("name") or "").strip()
|
||||||
return " ".join(part for part in (case_id, name) if part)
|
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})
|
||||||
@@ -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,9 +1091,35 @@ class TaskQueueService:
|
|||||||
f"[QueueWorker] Could not persist failure status for [{sess_id}]"
|
f"[QueueWorker] Could not persist failure status for [{sess_id}]"
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
await cls._finish_output_forwarder(output_task)
|
try:
|
||||||
# 5. Clean up the finished task and release this run's scheduling slot
|
await cls._finish_output_forwarder(output_task)
|
||||||
cls._release_run_slot(sess_id, run_key, proc)
|
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)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _find_duplicate_submission(
|
def _find_duplicate_submission(
|
||||||
@@ -1141,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
|
||||||
)
|
)
|
||||||
@@ -1157,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
|
||||||
@@ -1170,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,
|
||||||
@@ -1190,6 +1380,20 @@ 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:
|
if display_name:
|
||||||
task_item["display_name"] = display_name
|
task_item["display_name"] = display_name
|
||||||
state.queue_items.append(task_item)
|
state.queue_items.append(task_item)
|
||||||
@@ -1300,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":
|
||||||
@@ -1309,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()
|
||||||
@@ -1632,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:
|
||||||
@@ -1746,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,12 +2,14 @@
|
|||||||
|
|
||||||
## Local Purpose
|
## Local Purpose
|
||||||
|
|
||||||
本目录维护简体中文 Angular 网页,包括设备与任务操作、模型配置、报告、批量导入和历史检索。
|
本目录维护简体中文 Angular 网页,包括 Flash/Pro/探索、人工示范、插件、精选任务、模型配置、批次、历史、报告与跨端证据。
|
||||||
|
|
||||||
## Inherits
|
## Inherits
|
||||||
|
|
||||||
继承仓库根目录 AGENTS.md;本文件仅补充前端规则,当前用户指令优先。
|
继承仓库根目录 AGENTS.md;本文件仅补充前端规则,当前用户指令优先。
|
||||||
|
|
||||||
|
修改 UI 前阅读根目录 [DESIGN.md](../../DESIGN.md) 的视觉与交互约定;本应用没有独立的局部 DESIGN.md。SDK 的同名文件描述远程协议,不是网页设计来源。
|
||||||
|
|
||||||
## Local Commands
|
## Local Commands
|
||||||
|
|
||||||
在本目录执行,包管理器使用 npm 和现有 package-lock.json:
|
在本目录执行,包管理器使用 npm 和现有 package-lock.json:
|
||||||
@@ -34,6 +36,11 @@ npm run build
|
|||||||
- 使用相邻的 .spec.ts 测试交互和请求数据;不要只测试实现细节。
|
- 使用相邻的 .spec.ts 测试交互和请求数据;不要只测试实现细节。
|
||||||
- 后端字段调整需同时核对类型、请求和响应;设备序列号、报告格式、批次编号等不能在前端猜测。
|
- 后端字段调整需同时核对类型、请求和响应;设备序列号、报告格式、批次编号等不能在前端猜测。
|
||||||
- UI 有明显变化时在浏览器验证实际布局和关键操作,构建通过不能代替可读性检查。
|
- UI 有明显变化时在浏览器验证实际布局和关键操作,构建通过不能代替可读性检查。
|
||||||
|
- 工作区底部输入框服务当前任务;探索建议只填入草稿,用户发送后再提交。用例编辑和配置按需展开,复跑固定保存版本。
|
||||||
|
- 主模型、兜底模型及精选任务复用现有模型选择组件;收藏使用已保存的独立配置,设备离线时给出可操作的选择提示。
|
||||||
|
- 星标仅占标题区域,后续状态行正常左对齐;人工示范来源与按钮保持等高、居中,窄屏可换行。执行、等待回答、示范准备/录制/分析、最终验收及未验证各有明确文字。
|
||||||
|
- 电脑步骤使用电脑证据,录像缺口明确展示。播放器按真实来源和时间定位,跳转保留原播放/暂停状态。
|
||||||
|
- 列表后台刷新保留选中项、滚动及折叠状态,不用反复插入的加载行挤动布局;实时连接沿用现有共享机制和页面可见性管理。
|
||||||
|
|
||||||
## Local Risks
|
## Local Risks
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.2.2",
|
"version": "2.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.2.2",
|
"version": "2.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.1.5",
|
"@angular/cdk": "^22.1.5",
|
||||||
"@angular/common": "^22.1.4",
|
"@angular/common": "^22.1.4",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.2.2",
|
"version": "2.0.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve --proxy-config proxy.conf.json",
|
"start": "ng serve --proxy-config proxy.conf.json",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -80,12 +82,15 @@ limitations under the License.
|
|||||||
(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)">
|
||||||
@@ -97,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 (['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
|
@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"
|
||||||
@@ -108,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 (!['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
|
@if (agentService.canDeleteSession(session)) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-delete-card"
|
class="btn-delete-card"
|
||||||
@@ -178,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>
|
||||||
}
|
}
|
||||||
@@ -328,6 +336,7 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -397,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>
|
||||||
@@ -421,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>
|
||||||
@@ -502,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>
|
||||||
@@ -509,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>
|
||||||
@@ -548,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"
|
||||||
@@ -584,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>
|
||||||
@@ -605,6 +634,7 @@ limitations under the License.
|
|||||||
</ol>
|
</ol>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
<!-- Text, tool calls, and actions share one chronological timeline. -->
|
<!-- Text, tool calls, and actions share one chronological timeline. -->
|
||||||
@if (getSortedStepEvents(block.data).length > 0) {
|
@if (getSortedStepEvents(block.data).length > 0) {
|
||||||
@@ -745,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) -->
|
||||||
@@ -758,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>
|
||||||
@@ -801,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>
|
||||||
@@ -1042,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)"
|
||||||
@@ -1083,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)"
|
||||||
@@ -1094,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>
|
||||||
@@ -1106,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)) {
|
||||||
@@ -1188,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>
|
||||||
@@ -1243,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="操作后截图" />
|
||||||
@@ -1268,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">
|
||||||
@@ -1294,8 +1343,9 @@ limitations under the License.
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</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">
|
||||||
@@ -1328,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>
|
||||||
}
|
}
|
||||||
@@ -1398,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 -->
|
||||||
|
|||||||
@@ -3694,17 +3694,23 @@ $stream-row-height: 25px;
|
|||||||
.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;
|
||||||
@@ -3787,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;
|
||||||
@@ -4027,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%;
|
||||||
@@ -4046,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,7 +410,7 @@ 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 ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status);
|
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);
|
||||||
@@ -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();
|
||||||
@@ -496,9 +512,9 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
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);
|
||||||
@@ -913,6 +941,9 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getTaskStatus(session: Session): string {
|
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 (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(s)) {
|
if (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(s)) {
|
||||||
@@ -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 || "删除失败,请稍后重试");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1603,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' ? "任务正在运行(录屏中)" : "任务正在运行";
|
||||||
@@ -1628,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);
|
||||||
}
|
}
|
||||||
@@ -1754,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}`);
|
||||||
@@ -1796,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';
|
||||||
@@ -1811,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)
|
||||||
@@ -2020,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 {
|
||||||
@@ -2200,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';
|
||||||
@@ -2215,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
|
||||||
|
|||||||
+45
-12
@@ -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,17 +14,18 @@
|
|||||||
<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">
|
||||||
@@ -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="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>
|
<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' && b.status !== 'deleting'){<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')" [title]="j.case.goal">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{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>}
|
||||||
}
|
}
|
||||||
+19
-1
@@ -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;}}
|
||||||
|
|||||||
+136
-2
@@ -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:'正在生成报告',deleting:'删除中,可重试清理'} 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,13 +117,32 @@ 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();
|
||||||
|
|||||||
@@ -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-goal" title="{{ session.initial_goal }}">
|
<div class="task-title-row">
|
||||||
{{ session.initial_goal || '未命名任务' }}
|
<app-favorite-button [sourceId]="session.session_id" />
|
||||||
|
<div class="task-goal" title="{{ 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') {
|
||||||
|
|||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
@if(session(); as s) {
|
||||||
|
<app-agent-stream [conversationMessages]="messages()" [demonstrations]="s.demonstrations" [pendingQuestion]="s.pending_question || ''" (useRecommendation)="fillRecommendation($event)">
|
||||||
|
<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)="fillRecommendation(suggestion)" [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 #messageInput 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>}
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
: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 { 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; }
|
||||||
|
.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}
|
||||||
|
.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 .manual-control {display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin:0 0 10px}
|
||||||
|
.composer .manual-control label {display:flex;flex-direction:row;align-items:center;gap:8px;margin:0;min-width:0}
|
||||||
|
.composer .manual-control select {width:auto;min-width:110px;height:36px;padding:6px 10px}
|
||||||
|
.composer .manual-control button {align-self:center;height:36px;white-space:nowrap}
|
||||||
|
.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}}
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
import { Component, input, output, signal } from '@angular/core';
|
||||||
|
import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
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('fills a suggested reply for review from either entry point and only sends after submission', fakeAsync(() => {
|
||||||
|
current.status='waiting_user'; current.pending_question='q';
|
||||||
|
current.messages=[{id:'q',sequence:1,role:'assistant',kind:'question',text:'是否按示范流程继续?',recommendation:'确认,按示范流程继续探索。',created_at:1}];
|
||||||
|
const fixture=render();
|
||||||
|
fixture.nativeElement.querySelector('.question-banner button').click();
|
||||||
|
fixture.detectChanges(); flushMicrotasks();
|
||||||
|
const input: HTMLTextAreaElement = fixture.nativeElement.querySelector('#exploration-message');
|
||||||
|
expect(input.value).toBe('确认,按示范流程继续探索。');
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
expect(api.act).not.toHaveBeenCalled();
|
||||||
|
const stream = fixture.debugElement.query(By.directive(StreamStub)).componentInstance as StreamStub;
|
||||||
|
stream.useRecommendation.emit(current.messages[0].recommendation!);
|
||||||
|
expect(api.act).not.toHaveBeenCalled();
|
||||||
|
fixture.componentInstance.text='确认,按示范流程继续,最后返回顶部。';
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.nativeElement.querySelector('.send-button').click(); flushMicrotasks();
|
||||||
|
expect(api.act).toHaveBeenCalledTimes(1);
|
||||||
|
expect(api.act.calls.mostRecent().args.slice(0,3)).toEqual(['s','messages',{text:'确认,按示范流程继续,最后返回顶部。'}]);
|
||||||
|
expect(fixture.componentInstance.text).toBe('');
|
||||||
|
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,172 @@
|
|||||||
|
import { Component, ChangeDetectionStrategy, DestroyRef, ElementRef, effect, inject, signal, untracked, input, viewChild } 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);
|
||||||
|
private messageInput = viewChild<ElementRef<HTMLTextAreaElement>>('messageInput');
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
fillRecommendation(reply: string) {
|
||||||
|
if (this.busy() || this.activeValidation() || this.finalizing() || this.pendingQuestion()?.recommendation !== reply) return;
|
||||||
|
this.text = reply;
|
||||||
|
this.messageInput()?.nativeElement.focus();
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
-3
@@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+119
@@ -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%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+507
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+283
-50
@@ -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();
|
||||||
this.pendingAbsoluteSeek = request.seconds;
|
const sources = this.sources();
|
||||||
if (this.agentService.recordingPlaybackStatus() !== 'ready') return;
|
if (!request || status !== 'ready' || request.requestId === this.lastHandledVideoSeekRequestId) return;
|
||||||
const video = this.videoRef?.nativeElement;
|
// A seek is a command, not a binding to playback/source state. Reading
|
||||||
if (video && video.readyState >= 1) {
|
// those signals in this effect would replay the command after play/pause
|
||||||
const target = this.pendingAbsoluteSeek;
|
// or a manual source selection.
|
||||||
this.pendingAbsoluteSeek = null;
|
untracked(() => {
|
||||||
this.seekToSessionTime(target, true);
|
this.lastHandledVideoSeekRequestId = request.requestId;
|
||||||
}
|
this.pendingAbsoluteSeek = request.seconds;
|
||||||
|
if (request.sourceId && sources.length) {
|
||||||
|
const source = sources.find(s => s.id === request.sourceId || s.target_ids.includes(request.sourceId!));
|
||||||
|
if (!source) {
|
||||||
|
this.pendingAbsoluteSeek = null;
|
||||||
|
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;
|
||||||
|
|||||||
+80
@@ -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>
|
||||||
|
}
|
||||||
+26
@@ -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; } }
|
||||||
+121
@@ -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('验收完成');
|
||||||
|
});
|
||||||
|
});
|
||||||
+124
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+83
-6
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+123
-21
@@ -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'];
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
import { TaskBatchFilterComponent } from './task-batch-filter.component';
|
||||||
|
|
||||||
|
describe('队列批次筛选', () => {
|
||||||
|
it('支持全部批量、单独任务及具体批次,可清除筛选', fakeAsync(() => {
|
||||||
|
const agent = {batchFilter:signal('batch-1')};
|
||||||
|
TestBed.configureTestingModule({providers:[provideHttpClient(),provideHttpClientTesting(),{provide:AgentService,useValue:agent}]});
|
||||||
|
const fixture = TestBed.createComponent(TaskBatchFilterComponent);
|
||||||
|
const http = TestBed.inject(HttpTestingController);
|
||||||
|
http.expectOne('/api/automation/batches').flush([{id:'batch-1',name:'第一轮冒烟',created_at:100},{id:'batch-2',name:'第二轮冒烟',created_at:200}]);
|
||||||
|
fixture.detectChanges(); tick(); fixture.detectChanges();
|
||||||
|
const select: HTMLSelectElement = fixture.nativeElement.querySelector('select');
|
||||||
|
expect(select.value).toBe('batch-1'); expect(select.options.length).toBe(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,127 @@ 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('显示明确的结果、完成时间与总耗时,标题悬停显示完整提示词', () => {
|
it('显示明确的结果、完成时间与总耗时,标题悬停显示完整提示词', () => {
|
||||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
http.expectOne(r => r.url === '/api/automation/history').flush({total: 1, items: [
|
http.expectOne(r => r.url === '/api/automation/history').flush({total: 1, items: [
|
||||||
|
|||||||
@@ -1,46 +1,103 @@
|
|||||||
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" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><b class="history-status">{{label(row.status)}}</b> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
|
@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:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-block;border-radius:5px;padding:2px 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}`]
|
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;display:flex;align-items:center;min-height:28px;padding-left:33px}
|
||||||
|
.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 35px 0 0;margin:0 0 4px}
|
||||||
|
.history-item .row.selected{background:transparent}
|
||||||
|
.history-heading{position:relative;min-width:0}
|
||||||
|
.history-heading>app-favorite-button{position:absolute;left:0;top:0;z-index:1}
|
||||||
|
.history-heading>.row{min-width:0;width:100%}
|
||||||
|
.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}秒`;}
|
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()]);
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestResultCardComponent } from './test-result-card.component';
|
||||||
|
|
||||||
|
describe('用例测试结果卡片', () => {
|
||||||
|
beforeEach(() => TestBed.configureTestingModule({providers:[provideHttpClient(), provideHttpClientTesting()]}));
|
||||||
|
afterEach(() => TestBed.inject(HttpTestingController).verify());
|
||||||
|
it('显式展示不通过及原因,允许展开证据', () => {
|
||||||
|
const fixture = TestBed.createComponent(TestResultCardComponent);
|
||||||
|
fixture.componentRef.setInput('result', {status: 'failed', reason: '预期英文,实际为中文。', evidence: ['截图显示搜索设置']});
|
||||||
|
fixture.detectChanges();
|
||||||
|
const view: HTMLElement = fixture.nativeElement;
|
||||||
|
expect(view.querySelector('[aria-label="用例测试结果"]')).not.toBeNull();
|
||||||
|
expect(view.querySelector('.prominent')?.textContent).toContain('测试不通过');
|
||||||
|
expect(view.querySelector('.reason')?.textContent).toContain('预期英文,实际为中文。');
|
||||||
|
const details = view.querySelector('details')!;
|
||||||
|
expect(details.open).toBeFalse();
|
||||||
|
details.querySelector('summary')!.click();
|
||||||
|
expect(details.open).toBeTrue();
|
||||||
|
expect(details.textContent).toContain('截图显示搜索设置');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('人工复验可覆盖 AI 判定,保留原始结论并提交备注', () => {
|
||||||
|
const fixture = TestBed.createComponent(TestResultCardComponent);
|
||||||
|
const ai = {status:'failed',reason:'AI发现不一致',evidence:['步骤 3 截图'],source:'ai'};
|
||||||
|
fixture.componentRef.setInput('result', ai);
|
||||||
|
fixture.componentRef.setInput('sessionId', 'task-1'); fixture.componentRef.setInput('executionStatus','completed');
|
||||||
|
const saved = jasmine.createSpy(); fixture.componentInstance.reviewSaved.subscribe(saved);
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.nativeElement.querySelector('.review-toolbar button').click(); fixture.detectChanges();
|
||||||
|
fixture.componentInstance.note = '实际符合预期,已人工核对';
|
||||||
|
fixture.nativeElement.querySelector('.confirm-pass').click(); fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.confirm-fail').disabled).toBeTrue();
|
||||||
|
const request = TestBed.inject(HttpTestingController).expectOne('/api/automation/reports/task-1/review');
|
||||||
|
expect(request.request.method).toBe('PUT');
|
||||||
|
expect(request.request.body).toEqual({status:'passed',reason:'实际符合预期,已人工核对'});
|
||||||
|
const manual = {status:'passed',reason:'实际符合预期,已人工核对',evidence:[],source:'human',reviewed_at:100,ai_result:ai};
|
||||||
|
request.flush({id:'task-1',test_result:manual,verdict:'测试通过(人工确认)'});
|
||||||
|
fixture.componentRef.setInput('result',manual); fixture.detectChanges();
|
||||||
|
expect(saved).toHaveBeenCalled();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('人工通过');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('原 AI 结论');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('AI发现不一致');
|
||||||
|
expect(fixture.nativeElement.querySelector('.review-form')).toBeNull();
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('保存失败时保留备注并允许重试,执行中不显示人工复验', () => {
|
||||||
|
const fixture = TestBed.createComponent(TestResultCardComponent);
|
||||||
|
fixture.componentRef.setInput('result',{status:'inconclusive',reason:'尚未验证',evidence:[]});
|
||||||
|
fixture.componentRef.setInput('sessionId','task-2'); fixture.componentRef.setInput('executionStatus','running');
|
||||||
|
fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.review-toolbar')).toBeNull();
|
||||||
|
fixture.componentRef.setInput('executionStatus','failed'); fixture.detectChanges();
|
||||||
|
fixture.nativeElement.querySelector('.review-toolbar button').click(); fixture.detectChanges();
|
||||||
|
fixture.componentInstance.note = '人工发现异常';
|
||||||
|
fixture.nativeElement.querySelector('.confirm-fail').click();
|
||||||
|
TestBed.inject(HttpTestingController).expectOne('/api/automation/reports/task-2/review').flush({detail:'报告正在打包'}, {status:409,statusText:'Conflict'});
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.componentInstance.note).toBe('人工发现异常');
|
||||||
|
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('报告正在打包');
|
||||||
|
expect(fixture.nativeElement.querySelector('.confirm-fail').disabled).toBeFalse();
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,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'];
|
||||||
Loaded 100 of 449 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user