Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b53ca18595 | ||
|
|
ac3d8cf54a | ||
|
|
e90da727d4 | ||
|
|
4dc2057d43 | ||
|
|
baf069854b | ||
|
|
6b2c0f199d | ||
|
|
bb873ec0e0 | ||
|
|
8380057548 | ||
|
|
be6e8c2abd | ||
|
|
fac4ccb760 | ||
|
|
83bcfd54d5 | ||
|
|
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 |
No files matched your search
+33
-3
@@ -3,9 +3,12 @@
|
|||||||
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
|
||||||
|
# 模型请求和模型凭据检查默认直连;设为 true 才读取 Windows/HTTP(S)_PROXY/ALL_PROXY/NO_PROXY。
|
||||||
|
# 仅环境配置,不显示在网页中;修改后重启服务生效。TLS 校验及自定义 CA 不受影响。
|
||||||
|
OPENAI_USE_SYSTEM_PROXY=false
|
||||||
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
|
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
|
||||||
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
|
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
|
||||||
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
||||||
@@ -19,10 +22,22 @@ ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS=3
|
|||||||
# default 表示不发送思考强度参数;接口不支持所选值时明确报错。
|
# default 表示不发送思考强度参数;接口不支持所选值时明确报错。
|
||||||
OPENAI_REASONING_EFFORT=high
|
OPENAI_REASONING_EFFORT=high
|
||||||
|
|
||||||
|
# 探索独立默认值;网页已保存的探索配置优先,已创建会话保留快照。
|
||||||
|
ARTEMIS_EXPLORATION_MODEL=gpt-6.1-terra
|
||||||
|
ARTEMIS_EXPLORATION_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
|
||||||
|
# Flash 执行结束后的独立 AI 分析与判定,默认关闭;不影响 Pro 或执行过程。
|
||||||
|
ARTEMIS_FLASH_FINAL_CHECK_ENABLED=false
|
||||||
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
||||||
# 默认将任务截图长边压至 2048;false 保留设备原始截图。长边值可在 config/artemis.jsonc 的 agent.screenshot 中覆盖。
|
# 默认将任务截图长边压至 2048;false 保留设备原始截图。长边值可在 config/artemis.jsonc 的 agent.screenshot 中覆盖。
|
||||||
ARTEMIS_SCREENSHOT_LIMIT_ENABLED=true
|
ARTEMIS_SCREENSHOT_LIMIT_ENABLED=true
|
||||||
@@ -45,14 +60,14 @@ 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
|
||||||
# 守护服务轻量探测超时;探测超时且端口仍占用时,不重复启动服务。
|
# 守护服务轻量探测超时;探测超时且端口仍占用时,不重复启动服务。
|
||||||
ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS=2
|
ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS=2
|
||||||
# 模型重试耗尽后的暂停等待上限(秒);0 表示无限等待
|
# 执行模型重试耗尽后的暂停等待上限(秒);0 表示无限等待。最终验收耗尽重试直接记为未验证。
|
||||||
LLM_PAUSE_TIMEOUT_SECONDS=900
|
LLM_PAUSE_TIMEOUT_SECONDS=900
|
||||||
|
|
||||||
# 可选 Google Cloud Vision OCR,独立于模型调用,不配置也可运行。
|
# 可选 Google Cloud Vision OCR,独立于模型调用,不配置也可运行。
|
||||||
@@ -62,6 +77,10 @@ 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
|
||||||
@@ -69,4 +88,15 @@ ARTEMIS_DEFAULT_PROFILE=flash
|
|||||||
|
|
||||||
# 批量任务与 Excel 导入限制
|
# 批量任务与 Excel 导入限制
|
||||||
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-v3.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),保持标准库薄客户端边界 |
|
||||||
|
| 继续历史开发、确认版本或发布 | [README 当前发行入口](README.md)、对应发行说明及实时 Git 状态;[开发快照](docs/development-handoff.md)与 `docs/plans/` 仅作为历史线索,不替代当前源码与发布包核验 |
|
||||||
|
|
||||||
## Quick Commands
|
## Quick Commands
|
||||||
|
|
||||||
在仓库根目录执行,前端命令见局部指南:
|
在仓库根目录执行,前端命令见局部指南:
|
||||||
@@ -27,46 +39,51 @@
|
|||||||
| 核心类型检查 | `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-terra / high`;请求超时 300 秒、失败后重试 5 次、间隔 10 秒,视频分析与普通录屏关闭。接口不支持思考强度时明确报错,不静默降级。
|
||||||
|
- 业务环境恢复由任务提示词提出,不新增默认业务回滚流程;连接、录像、子进程和资源锁的运行时收尾仍然必需。
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Python 使用绝对导入、4 空格缩进和双引号,由 Ruff 检查;不要放宽质量基线来掩盖新增问题。
|
- Python 使用绝对导入、4 空格缩进和双引号,由 Ruff 检查;不要放宽质量基线来掩盖新增问题。
|
||||||
- 新增可调参数集中进入配置层,并更新环境示例、网页/MCP 入口和必要文档,避免散落硬编码。
|
- 新增可调参数集中进入配置层,按作用域更新环境示例、网页/MCP 入口和文档;明确仅供环境配置的参数不增加前端入口。JSON 默认值同步 `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` 和暂存范围,只暂存本次具体路径;报告实际执行的验证及未完成项,不将未运行的检查写成通过。
|
||||||
|
|
||||||
|
## Release Gates
|
||||||
|
|
||||||
|
- 主程序、SDK、插件和第三方依赖独立版本化。主程序升版只修改 `pyproject.toml` 的项目版本、`uv.lock` 的 artemis 条目及前端应用的根版本字段,发行文档另行更新。禁止对仓库或整个锁文件全局替换版本字符串。
|
||||||
|
- 单独审查锁文件差异。第三方条目的版本、下载地址、完整性校验和依赖声明必须相互匹配;主程序升版不应改变这些条目。真正升级依赖时由包管理器生成并核对上游声明;发生 EINTEGRITY 先核验来源,禁止关闭校验或只照错误提示改哈希。
|
||||||
|
- 发布前必须在隔离目录解压最终 ZIP,使用独立空 npm 缓存完成 `npm ci` 和生产构建,覆盖本次发布的官方/国内安装路径。包内安装或构建输入变化后必须重跑;不得清空用户全局缓存或改动用户运行目录来制造干净环境。
|
||||||
|
- 记录实际 Node/npm 版本、镜像、源码提交、ZIP SHA256 和验证范围。已有 node_modules、构建指纹、缓存命中或大量单元测试通过,均不能证明全新安装成功;某个 Node 版本通过也不能冒充其他版本实测。测试和构建命令见前端局部指南。
|
||||||
|
- 发布顺序:固定提交并打包 → 验证最终包 → 推送标签 → 创建草稿并上传包及校验文件 → 回读校验 → 正式发布 → 无认证下载核验。标签必须指向打包提交;推送标签后检查 Release 状态,本仓库曾出现 Gitea 将已有草稿自动发布的情况。失败时修复并重新验证,不提前宣布完成。
|
||||||
|
- 候选包与正式包目录明确区分。候选包失效后移出可分发路径并标明替代版本,不能留下同名旧 ZIP 供用户误取;对外只给已核验的正式发布链接或准确文件路径。正式版本不覆盖原标签或附件,后续修复发补丁版。
|
||||||
|
- 安装或市场版本不符时,先核对用户实际 ZIP 的 SHA256、运行源码提交及生效市场地址;不凭界面版本号判断,也不把未经验证的缓存假设当成原因。主程序与插件版本、索引兼容性分别验证。
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
@@ -78,5 +95,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,205 @@
|
|||||||
|
---
|
||||||
|
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.2.0 说明](docs/releases/2.2.0.md)。
|
||||||
|
|
||||||
|
## System architecture
|
||||||
|
|
||||||
|
| 层 | 职责与源码入口 |
|
||||||
|
| --- | --- |
|
||||||
|
| 网页、MCP、远程 SDK | 请求建模、交互与结果读取。`apps/showcase_ui`、`mcp_server`、`packages/artemis-client` 共同使用后台服务;MCP 和薄 SDK 不另启完整后台 |
|
||||||
|
| HTTP 与业务服务 | `apps/admin_console` 管理配置、统一队列、批次、收藏、历史、人工复验和导出;2.2.0 的 PC、Android、跨端任务按提交顺序串行执行,`TaskQueueService` 与跨进程共享执行锁共同准入,旧并行参数不能绕过;暂停释放执行资源,恢复时重新排队 |
|
||||||
|
| 任务与模型 | `artemis/agents`、`artemis/runtime` 执行 Flash/Pro;`artemis/exploration` 管理交流、预算、示范与用例版本;`artemis/llm`、`artemis/services/llm.py` 统一 Responses 请求 |
|
||||||
|
| 插件与证据 | `artemis/plugins` 管理安装、会话、版本固定、来源、截图、录像和附件;插件执行工具动作,宿主管理任务生命周期和最终验收 |
|
||||||
|
| 配置与交付 | `artemis/config`、两份 `artemis.jsonc` 和环境配置提供默认值;`scripts` 负责启动、镜像、独立升级和源码打包 |
|
||||||
|
|
||||||
|
请求重试、业务执行与最终验收分别处理。提交使用稳定标识;创建批次或探索草稿不自动执行,查询不产生设备动作。执行准入和恢复遵守现有资源锁,任务固定所用插件版本,不能通过另建队列绕开互斥。
|
||||||
|
|
||||||
|
设备保活是辅助服务。保活所属锁或旧保活清理目录不可访问时记录警告,不使设备连接、恢复或监控退出;未取得保活所属锁时不发送保活动作,也不创建替代锁绕过所属权。执行资源锁、主服务注册锁和队列写锁仍须成功取得。
|
||||||
|
|
||||||
|
运行时准备阶段失败记录 `initialization_failed`,批次保留详细原因并暂停未开始项,修复环境后由用户继续,已失败项不自动重跑。模型请求导致的批次暂停单独标记;模型暂停结束后恢复既定调度及失败即停止策略。用户暂停、停止全部任务和服务重启后的恢复暂停均需用户继续,不被模型恢复覆盖。Flash 与 Pro/探索 Operator 的生成失败复用统一备用模型与重试预算;不完整输出不会成为动作,已有动作不因模型切换而重放。
|
||||||
|
|
||||||
|
最终验收有独立总预算,按请求超时、重试和剩余任务时间协调,保留取证结果;完整有效的结构化结论才更新正式裁定。`completed`、工具成功、探索通过、用例提炼和复跑通过各自表达自己的状态。
|
||||||
|
|
||||||
|
Responses 消息交给工具、下游解析器或正式裁定前,必须具有服务端 `completed` 状态;工具参数的原始字符串必须是完整 JSON 对象,不能使用库自动补全的半截参数。流结束或库补出的 `last` 标记不能替代完成状态。缺少终止事件、明确未完成或失败的流丢弃预览,进入现有无效输出恢复策略,最多重新请求一次,配置为零次重试时不重试;恢复保留已有工具证据,不重新提交业务任务。非流式、结构化输出和串接解析器遵守同一检查。
|
||||||
|
|
||||||
|
普通应用启动及重试只负责启动或恢复前台,不隐式停止或重启应用;停止是独立的显式动作。焦点窗口为空时,使用已恢复活动所在任务的归属补充判断;只有可见候选任务、没有已恢复活动时继续等待。超时返回焦点与任务诊断,不升级为强制停止。
|
||||||
|
|
||||||
|
测试执行遵循用户任务指令和中途指导;明确的次数及禁止要求交给执行模型结合观察和历史遵守。执行层不额外编译自然语言约束、维护动作配额、比对提交内容指纹或请求 AI 二次审批。Flash、Pro 和插件动作经既有参数、目标及插件授权校验后调用工具;资源锁、取消与暂停、真实执行回执、输入状态观察和结果不明时不自动重发的行为继续保留。历史任务中的旧约束日志仅作为原始诊断记录,不再参与执行判定。
|
||||||
|
|
||||||
|
取消来源由拥有中断动作的运行时明确赋值,并穿过任务控制及模型等待包装层传递。用户停止、指导替换、暂停、任务或检查替换、已知外层时限,与单次请求超时、传输超时及最终验收总预算分开记录。没有来源的取消保留未知,HTTP 499 不能单独证明用户取消或代理故障;取消不进入模型重试,也不保存取消消息中的指导正文。
|
||||||
|
|
||||||
|
模型诊断复用现有 trace:自动重试保留本地请求编号,用尝试编号和上次尝试编号关联,重试及终止事件带对应尝试编号。可用的出站、服务端请求、Responses 响应及服务端链路编号随记录导出,仅采集白名单字段;历史缺失的来源及编号不补推。
|
||||||
|
|
||||||
|
任务耗时复用现有队列、工作进程控制文件与验收 trace,网页、MCP 和报告使用同一只读投影。分别列出后台排队、等待设备/资源、暂停与示范、执行与整理、最终验收及报告导出;总历时包含这些等待,不代表模型速度。缺少时间记录显示未知,不将旧任务缺失的分项补成零。运行结束后不继续累计执行耗时,报告导出单独计时。
|
||||||
|
|
||||||
|
指导接收不保证赶在终态前消费。保留接收时点、执行器消费回执及后续动作记录的区别;`consumed` 仅证明执行器已读取,不能宣称模型已应用或验收已通过。终态仍未领取的指导投影为 `unapplied`,领取后未留下消费回执的为 `unconfirmed`,不改写原始文件。网页详细查询复用原指导入口,旧数组响应保持兼容。
|
||||||
|
|
||||||
|
执行平台与证据来源分别记录。报告和证据索引从执行记录汇总来源及实际 step_id/call_id/source_id;界面、接口、文件和终端各自只能支持其观察范围,业务后台名称或网址不能推断平台。最终复核明确事实、矛盾和未验证边界;来源清单不生成新结论,不覆盖 AI 原判、有效结论或人工复验。
|
||||||
|
|
||||||
|
原生安卓动作在派发时记录所选设备来源。安卓截图来源也可从已保存的成功截图回执读取设备、采集时间、文件路径和校验值,并引用实际 trace_id;兼容 Flash 与 Pro 的回执封装,不从模型参数补造来源。空步骤、笔记和结束声明不计入缺失动作来源;窗口枚举等无单一目标的操作保留未关联状态,该计数不等于截图丢失。未生成独立输出正文时,报告展示当前有效判定及依据,保留人工复验优先级,不把正文缺失写成验收结论缺失。
|
||||||
|
|
||||||
|
规划器的笔记工具与执行器的设备、插件工具分别绑定。规划器依据任务的实际授权插件清单制定计划,由执行器加载并验证能力;规划阶段没有绑定动作工具,不表示任务缺少这些能力,也不构成重新申请授权的依据。
|
||||||
|
|
||||||
|
MCP 与单任务 HTTP 入参拒绝未知字段,校验失败不调度任务。MCP JSON 结果保留兼容文本并提供 structuredContent;任务摘要分页明确总数与 next_offset,引用实际存储编号。跨端阶段先完整初始化,再按既有阶段 id 更新状态与依据;补丁保留其他阶段、范围、资源和顺序,合并后统一校验并保存,失败不更新内存状态。
|
||||||
|
|
||||||
|
Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配置和 MCP 共用保存的全局设置。任务开始时固定设置,尚未开始的任务使用最新值;批量、精选及提炼用例的 Flash 复跑同样遵循。关闭时跳过最终 Checker,保留正常执行、步骤、截图、报告和人工复验;结果为“执行完成 · 未做 AI 判定”,不标记“AI确认”或测试通过。Pro 与主动探索的验收规则保持不变。
|
||||||
|
|
||||||
|
示范来源保存在元数据中,贯穿分析、提问、规划、执行、验收和用例生成。补充网址不会隐式把电脑示范改到安卓;用户明确改端或提出跨端步骤时按其要求执行。证据必须对应真实来源、窗口、采集时间与操作。
|
||||||
|
|
||||||
|
录像使用用户选择的范围。人工示范的窗口模式跟随前台窗口,仅保留一路,无有效窗口时保留缺口;普通录屏在只读验收前停止。恢复操作再追加片段,时间映射以实际录像为准,不借用另一来源或时间的画面。
|
||||||
|
|
||||||
|
收藏保存独立模板与执行配置,用例复跑固定版本;删除原任务不破坏已保存模板。历史与导出的有效结论合并人工复验,不能用新裁定改写旧证据。
|
||||||
|
|
||||||
|
普通任务新安装默认 `gpt-6-luna / high`,探索单独使用 `gpt-6.1-terra / high`,保留已有配置。上下文参照上限 300k、默认历史压缩预算 80k,均不表示整条任务累计 token 限额。优化稳定前缀时保留关键文件回执、路径、校验和来源;费用收益依实际网关用量及计价判断。
|
||||||
|
|
||||||
|
普通 Pro 的计划全部完成后,执行循环返回既有收尾和最终验收流程,不要求额外设备动作;有新指导或验收反馈时先处理反馈,探索模式仍须交付完整报告。完成计划不等于测试通过。Operator 写入计划时拒绝新增或增加同名顶层里程碑,撤销该次写入并返回工具错误;局部替换不得夹带目标范围外的里程碑,已有重复项可继续整理,确需重复的阶段使用不同名称。
|
||||||
|
|
||||||
|
## 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` |
|
||||||
|
| 成功、等待、错误 | 浅色外观用深色状态文字配对应浅色面,暗色采用下方语义映射;同时给出文字、图标或说明,结论不只依靠颜色 |
|
||||||
|
|
||||||
|
全局导航使用单个太阳/月亮按钮,点击一次直接切换浅色与暗色,不使用下拉菜单。首次使用跟随系统,点击后记住当前浏览器的明确选择;首次绘制前应用,页面切换和刷新保持一致。按钮图标和可访问名称提示点击后将切换的外观。
|
||||||
|
|
||||||
|
颜色通过 `--app-*` 语义变量引用,浅色保留组件原有回退值,暗色集中定义在[主题样式](apps/showcase_ui/src/_theme.scss)。主要映射如下:
|
||||||
|
|
||||||
|
| 语义变量 | 暗色值 |
|
||||||
|
| --- | --- |
|
||||||
|
| `--app-canvas` / `--app-surface` | `#0b1220` / `#111c2e` |
|
||||||
|
| `--app-surface-soft` / `--app-surface-raised` | `#172337` / `#1e2c42` |
|
||||||
|
| `--app-text-strong` / `--app-text-control` | `#f1f5f9` / `#dbe4f0` |
|
||||||
|
| `--app-text-secondary` / `--app-text-muted` | `#bdcadd` / `#98aac1` |
|
||||||
|
| `--app-border` / `--app-border-control` | `#2b3b52` / `#3d506a` |
|
||||||
|
| `--app-primary-text` / `--app-exploration-text` | `#8ebcff` / `#b7b3ff` |
|
||||||
|
| `--app-success-text` / `--app-warning-text` / `--app-error-text` | `#6ee7b7` / `#fcd089` / `#ff9ca7` |
|
||||||
|
|
||||||
|
状态文字、状态背景、边框和高光分别引用对应变量;保留实体主按钮的色彩与白色按钮文字。截图、录像和用户文件使用原始颜色,不对页面整体施加反色滤镜。
|
||||||
|
|
||||||
|
加载、空状态、断开、未验证与失败使用明确文案。执行完成可以有完成标识,测试裁定仍需独立展示;不能让绿色完成态替代测试通过结论。
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
沿用全局字体栈:`Google Sans`、`Google Sans Text`、`Inter`、系统无衬线字体;中文使用系统可用字形。代码、路径及请求诊断使用现有等宽样式,保留换行与复制能力。
|
||||||
|
|
||||||
|
`Inter`、`Outfit`、`Roboto` 及 `Material Symbols Outlined` 使用随源码分发的[本地字体](apps/showcase_ui/public/fonts/README.md),构建和显示不依赖 Google Fonts。图标保留完整字形及 `FILL`、`GRAD`、`opsz`、`wght` 可变轴,不用文字代替缺失图标。
|
||||||
|
|
||||||
|
模型控件正文为 13px,探索输入正文为 14px,用例标题为 18px;辅助信息依现有组件使用 11–12px。保留字号、字重、留白形成的层次,不通过大段加粗堆叠状态。Markdown、表格、长链接和中文长标题应换行,不撑破容器。
|
||||||
|
|
||||||
|
计划、笔记和报告共用 Markdown 渲染,支持标题、表格与列对齐、嵌套列表、任务清单、引用、分隔线、链接、图片、删除线及围栏代码块。宽表格与代码在自身区域横向滚动,保留代码空白和字面内容。计划中的 `[/]` 进行中、`verify` / `assert@end` 和 `finding` 标识继续可辨认。原始 HTML 按文字展示,链接与图片限制安全协议,并保留 Angular 的 HTML 清理。
|
||||||
|
|
||||||
|
图标沿用 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) |
|
||||||
|
| 插件工具回执 | 先显示窗口、控件、截图等已有关键字段与短预览;长文本、控件树及完整原始 payload 可展开查看。只调整网页呈现,保留原始证据和模型上下文 | [plugin-tool-result](apps/showcase_ui/src/app/components/plugin-tool-result/plugin-tool-result.component.ts) |
|
||||||
|
| 截图、录像与文件 | 显示真实来源和缺口;按时间定位并保留跳转前的播放/暂停状态,文件成功以真实保存回执为准 | [工作区说明](docs/automation-workspace.md) |
|
||||||
|
| 保存和错误反馈 | 配置加载、保存、空值、失败与密钥已配置状态有明确提示;空密钥保留已有值,连接测试与保存分开 | [模型配置](apps/showcase_ui/src/app/components/model-settings/model-settings.component.ts) |
|
||||||
|
| Flash 判定开关 | 切换即保存,保存中禁用,失败恢复已保存值;不顺带提交其他配置草稿。说明尚未开始的任务生效、无需重启,运行中任务保持原配置 | [功能配置](apps/showcase_ui/src/app/components/model-settings/capability-settings.component.ts) |
|
||||||
|
| 仅 PC 勾选项 | 位于首页输入区,与扩展插件控件共用配色和尺寸;默认关闭。开启后切换电脑文案与插件目标,就绪条件保留运行时和模型,排除 Android 设备、锁屏、ADB 及手机录屏工具;单任务提交使用 execution_target=computer | [首页](apps/showcase_ui/src/app/pages/home/home.component.ts) |
|
||||||
|
| 外观选择 | 所有页面共用,键盘可操作;窄屏可收起导航文字,但保留链接的可访问名称 | [导航](apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.ts) |
|
||||||
|
|
||||||
|
这些规则对应已接受的现有行为。改动后结合实际层叠样式、渲染尺寸和相关状态检查,不能只核对一处声明。
|
||||||
|
|
||||||
|
## Do's and Don'ts
|
||||||
|
|
||||||
|
- 复用现有组件、API 类型、中文文案和状态语义。新增状态先说明它与执行、验收或人工操作的关系。
|
||||||
|
- 保持实际来源与证据一致,缺失时说明原因;不使用手机画面替代电脑操作,也不把工具成功当作业务通过。
|
||||||
|
- 保留可见焦点、禁用反馈和减少动画偏好;录制红点表示实际录制,等待或执行中使用各自文案。
|
||||||
|
- 源码已更新而页面仍旧时,先核对服务目录、构建与缓存;不通过反复增加样式覆盖掩盖旧构建问题。
|
||||||
|
- 视觉变化验证代表性宽度、长标题、加载、等待回答、错误与展开状态;文档格式检查不等于渲染验证。开发检查按[前端指南](apps/showcase_ui/AGENTS.md)执行。
|
||||||
|
|
||||||
|
## Workspace plugin extensions
|
||||||
|
|
||||||
|
工作区扩展使用声明式清单版本 3,宿主提供回放面板、人工表单、任务存储和报告接口;不运行插件页面脚本,不另建队列或结果库。纯工作区插件不加入模型执行选择。接口定义见 [工作区插件](docs/workspace-plugins.md)。
|
||||||
|
|
||||||
|
回放两侧面板默认收起、独立展开,普通浮窗和影院模式共用;插件声明期望/最小/最大宽度,宿主按视口收缩,窄屏一次展开一侧,始终保持面板位于回放的左侧或右侧。任务需求只展示本次运行最初的提示词,复用安全 Markdown 渲染。人工表单字段均可留空,不调用模型、不覆盖已有执行结论或人工复验。
|
||||||
|
|
||||||
|
评审按单次任务和插件 ID 保存版本历史;运行中只读,终态可编辑或清空。停用/卸载隐藏面板与新导出字段但保留记录,任务删除时同步清理。报告只采用已安装且启用的贡献,空值保持空白;修改记录或插件状态使后续报告缓存失效。
|
||||||
|
|
||||||
|
执行详情中的 Payload 默认折叠;其他超长字段也默认折叠,展开后保留完整可复制内容并限制显示高度。原始用例面板仅由启用的工作区插件声明,额外导入列不影响未启用插件的用户。评审支持所有执行模式和目标,用户修改评审后自动保存:结果选择立即保存,备注短暂防抖;切换任务前保存,失败时保留输入和当前任务。仅用户编辑触发写入,AI 不推断人工结论。
|
||||||
|
|
||||||
|
模型网络代理策略仅由环境项 `OPENAI_USE_SYSTEM_PROXY` 控制,默认 false。同步、异步模型客户端和模型凭据检查统一遵循;不出现在前端配置中,不改变 TLS 验证与自定义 CA 设置。
|
||||||
@@ -2,17 +2,26 @@
|
|||||||
|
|
||||||
基于 [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.4.2**:批次任务增加人工已审标签,优化收藏、标题与状态标签布局。人工评审插件保持 1.1.0。见 [2.4.2 发行说明](docs/releases/2.4.2.md);安装修复见 [2.4.1](docs/releases/2.4.1.md)。
|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
- **自然语言任务**:Flash 适合明确的操作流程,Pro 适合需要计划、分支探索和验证的任务。
|
- **自然语言任务**:Flash 适合明确的操作流程,Pro 适合需要计划、分支探索和验证的任务。
|
||||||
|
- **交互探索**:边执行边交流,也可录制人工示范;探索成功后生成一个可编辑、复跑的用例。默认使用独立的 `gpt-6.1-terra / high` 配置,超时暂停并保留进度。见 [探索模式](docs/exploration-mode.md)。
|
||||||
|
- **插件扩展**:安装并启用电脑控制等执行能力,或使用工作区插件为回放和报告增加人工评审。手机、电脑的步骤与证据统一留档。见 [插件与跨端任务](docs/plugins.md)和[工作区插件接口](docs/workspace-plugins.md)。
|
||||||
- **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。
|
- **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。
|
||||||
- **批量测试**:按新版 Excel 模板导入提示词,每项可独立指定模型、模式和参数;任务可暂停、补充指导并继续。
|
- **批量测试**:按新版 Excel 模板导入提示词,每项可独立指定模型、模式和参数;任务可暂停、补充指导并继续。
|
||||||
|
- **精选任务**:收藏单任务、批次或探索用例,在首页一键复跑,默认沿用已保存的配置。
|
||||||
- **报告与历史**:导出 Word、Excel、步骤截图、录屏和任务资料 ZIP;历史按日期分组,支持搜索和筛选。
|
- **报告与历史**:导出 Word、Excel、步骤截图、录屏和任务资料 ZIP;历史按日期分组,支持搜索和筛选。
|
||||||
- **AI 接入**:随源码提供 MCP 与 Skill,让 AI 完成设备诊断、任务执行、历史分析和报告交付。
|
- **AI 接入**:随源码提供 MCP 与 Skill,让 AI 完成设备诊断、任务执行、历史分析和报告交付。
|
||||||
|
|
||||||
## 安装与启动
|
## 安装与启动
|
||||||
|
|
||||||
准备一台开启 USB 调试并完成授权的 Android 手机,或已启动的 Android 模拟器。首次安装需要联网。可使用 Git 克隆或下载 1.1.1 及更新版本的发布源码包;新版 ZIP 解压后也保留 Git 更新能力。
|
Android 或跨端测试需准备一台开启 USB 调试并完成授权的 Android 手机,或已启动的 Android 模拟器;仅 PC 测试无需手机在线,但需启用并授权电脑观察插件。首次安装需要联网。可使用 Git 克隆或下载 1.1.1 及更新版本的发布源码包;新版 ZIP 解压后也保留 Git 更新能力。
|
||||||
|
|
||||||
|
网页字体和 Material Symbols 图标已随源码提供,网页构建及显示无需访问 Google Fonts。
|
||||||
|
|
||||||
|
顶部导航的太阳/月亮按钮可一键切换亮暗主题,自动记住当前浏览器的选择;首次使用跟随系统。计划、笔记和报告支持 Markdown 表格、嵌套列表、任务清单、引用、链接及代码块,宽表格可在内容区横向滚动。
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
@@ -29,52 +38,60 @@ 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 或探索后执行;仅测试电脑时勾选输入区的“仅 PC”,无需手机或模拟器在线,但需已启用并授权的电脑观察插件。在工作区查看步骤、截图和结果。流程尚不明确时,选择探索并随时交流。
|
||||||
4. 通过“报告与批量”导出报告或导入 Excel 用例;任务队列支持历史搜索。
|
4. 通过“报告与批量”导出报告或导入 Excel 用例;任务队列支持历史搜索。
|
||||||
|
|
||||||
预设模型为 `gpt-5.6-luna`、`gpt-6-luna`、`mimo-v2.6-flash`、`mimo-v2.6-pro`,也可填写自定义名称。默认模型是 `mimo-v2.6-flash`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**。遇到 429 请求过多时改为每次等待 **60 秒**。连续 3 次没有有效工具调用时,本步骤改用 `gpt-6-luna`(high),兜底模型最多再尝试 3 次;下一步恢复首选模型。视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。
|
预设模型为 `gpt-6-luna`、`gpt-6.1-terra`、`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`,重启后对新任务生效。
|
“功能开关”默认将任务截图长边限制为 2048 像素;关闭后保留设备原始分辨率。可在 [config/artemis.jsonc](config/artemis.jsonc) 的 `agent.screenshot` 中覆盖 `enabled` 和 `max_long_edge`,重启后对新任务生效。
|
||||||
|
|
||||||
|
首页“功能配置”提供“Flash 最终 AI 判定与分析”,默认关闭。切换后自动保存并提示结果,失败恢复原值;无需重启服务。开启后,Flash 在执行结束时根据用例需求和执行步骤进行独立复核;关闭后显示“执行完成 · 未做 AI 判定”,保留执行步骤、截图和报告,不自动记为测试通过。此设置适用于所有 Flash 任务,包括批量、精选、MCP/SDK 和探索提炼用例的 Flash 复跑;保存后对尚未开始的任务生效,运行中的任务保持原设置。Pro 和主动探索不受影响。其余功能配置仍需点击底部保存按钮。
|
||||||
|
|
||||||
报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。
|
报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。
|
||||||
|
|
||||||
## 让 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."""
|
||||||
|
|
||||||
|
__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
|
from apps.admin_console.server import app
|
||||||
|
|
||||||
__all__ = ["app"]
|
return app
|
||||||
|
raise AttributeError(name)
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -23,9 +24,6 @@ from artemis.runtime import trace_store
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
|
||||||
from admin_console.database.connection import db_session
|
|
||||||
except ImportError:
|
|
||||||
from apps.admin_console.database.connection import db_session
|
from apps.admin_console.database.connection import db_session
|
||||||
|
|
||||||
|
|
||||||
@@ -238,6 +236,47 @@ class SessionRepository:
|
|||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
return [r[0] for r in rows if r and r[0]]
|
return [r[0] for r in rows if r and r[0]]
|
||||||
|
|
||||||
|
def get_recorded_model_info_map(self, session_ids: list[str]) -> dict[str, dict[str, str]]:
|
||||||
|
"""Read each session's own requested model from its earliest diagnostics.
|
||||||
|
|
||||||
|
Prefer operator/Flash requests over auxiliary roles. Older sessions may
|
||||||
|
have no model diagnostics; do not replace their missing data with the
|
||||||
|
current global configuration.
|
||||||
|
"""
|
||||||
|
result: dict[str, dict[str, str]] = {}
|
||||||
|
safe_payload = "CASE WHEN json_valid(t.payload) THEN t.payload ELSE '{}' END"
|
||||||
|
valid_model = f"json_type({safe_payload}, '$.model') = 'text'"
|
||||||
|
role = f"lower(json_extract({safe_payload}, '$.role'))"
|
||||||
|
with db_session(self.db_path) as conn:
|
||||||
|
for offset in range(0, len(session_ids), 500):
|
||||||
|
batch = session_ids[offset : offset + 500]
|
||||||
|
placeholders = ",".join("?" for _ in batch)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT s.session_id, COALESCE("
|
||||||
|
" (SELECT t.payload FROM traces t WHERE t.session_id = s.session_id"
|
||||||
|
f" AND t.type = 'llm_diagnostic' AND {valid_model}"
|
||||||
|
f" AND {role} IN ('operator', 'flashrunner')"
|
||||||
|
" ORDER BY t.timestamp ASC LIMIT 1),"
|
||||||
|
" (SELECT t.payload FROM traces t WHERE t.session_id = s.session_id"
|
||||||
|
f" AND t.type = 'llm_diagnostic' AND {valid_model}"
|
||||||
|
" ORDER BY t.timestamp ASC LIMIT 1)"
|
||||||
|
") AS model_payload FROM sessions s"
|
||||||
|
f" WHERE s.session_id IN ({placeholders})",
|
||||||
|
batch,
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
if not row[1]:
|
||||||
|
continue
|
||||||
|
payload = json.loads(row[1])
|
||||||
|
model = payload.get("model")
|
||||||
|
if isinstance(model, str) and model.strip():
|
||||||
|
provider = payload.get("provider")
|
||||||
|
result[str(row[0])] = {
|
||||||
|
"id": model,
|
||||||
|
"provider": provider if isinstance(provider, str) else "",
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
def get_llm_traces_for_profiles_map(
|
def get_llm_traces_for_profiles_map(
|
||||||
self, session_ids: list[str] | None = None, limit: int = 3
|
self, session_ids: list[str] | None = None, limit: int = 3
|
||||||
) -> dict[str, list[str]]:
|
) -> dict[str, list[str]]:
|
||||||
@@ -323,6 +362,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 +391,42 @@ 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 = payload.get("model")
|
||||||
|
if not model_name:
|
||||||
|
# Older gateway traces carry the model in their own endpoint key.
|
||||||
|
source = str(payload.get("source") or "")
|
||||||
|
source_parts = source.split(":", 2)
|
||||||
|
if len(source_parts) == 3 and source_parts[0] == "openai":
|
||||||
|
# Model aliases can contain colons. The HTTP(S) base URL
|
||||||
|
# marks the boundary; an ambiguous key stays unknown.
|
||||||
|
match = re.match(r"^openai:(.+?):https?://", source)
|
||||||
|
model_name = match[1] if match else None
|
||||||
|
elif len(source_parts) == 3 and source_parts[:2] in (
|
||||||
|
["lens", "visual_transition"],
|
||||||
|
["lens", "step_capsule"],
|
||||||
|
):
|
||||||
|
model_name = source_parts[2] if source_parts[2] != "fallback" else None
|
||||||
|
model_name = str(model_name 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 +454,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,9 +16,6 @@ import json
|
|||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
try:
|
|
||||||
from admin_console.database.connection import db_session
|
|
||||||
except ImportError:
|
|
||||||
from apps.admin_console.database.connection import db_session
|
from apps.admin_console.database.connection import db_session
|
||||||
|
|
||||||
|
|
||||||
@@ -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",
|
||||||
@@ -216,6 +220,41 @@ class StepRepository:
|
|||||||
# recovering, waiting, completed, and terminal outcomes. Other tools
|
# recovering, waiting, completed, and terminal outcomes. Other tools
|
||||||
# retain the historical compact payload to avoid sending large blobs.
|
# retain the historical compact payload to avoid sending large blobs.
|
||||||
res = payload_obj.get("result")
|
res = payload_obj.get("result")
|
||||||
|
if trace_type == "tool" and trace_name == "android_file":
|
||||||
|
# Preserve host-written file receipts, not file contents. A file's
|
||||||
|
# checksum is not a screenshot-store image ID.
|
||||||
|
if isinstance(res, str):
|
||||||
|
try:
|
||||||
|
res = json.loads(res)
|
||||||
|
except ValueError:
|
||||||
|
res = None
|
||||||
|
if isinstance(res, dict) and "outcome" in res:
|
||||||
|
res = res["outcome"]
|
||||||
|
if isinstance(res, str):
|
||||||
|
try:
|
||||||
|
res = json.loads(res)
|
||||||
|
except ValueError:
|
||||||
|
res = None
|
||||||
|
if isinstance(res, dict):
|
||||||
|
result_payload["result"] = {
|
||||||
|
key: self._clean_value(res[key])
|
||||||
|
for key in (
|
||||||
|
"ok",
|
||||||
|
"action",
|
||||||
|
"path",
|
||||||
|
"trace_path",
|
||||||
|
"device_path",
|
||||||
|
"bytes",
|
||||||
|
"sha256",
|
||||||
|
"verified",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"captured_at",
|
||||||
|
"device_serial",
|
||||||
|
)
|
||||||
|
if key in res
|
||||||
|
}
|
||||||
|
return result_payload
|
||||||
is_video_analysis = str(trace_name or "").lower() in {
|
is_video_analysis = str(trace_name or "").lower() in {
|
||||||
"video_analysis",
|
"video_analysis",
|
||||||
"video_analyzer",
|
"video_analyzer",
|
||||||
|
|||||||
@@ -15,9 +15,6 @@
|
|||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
try:
|
|
||||||
from admin_console.database.connection import db_session
|
|
||||||
except ImportError:
|
|
||||||
from apps.admin_console.database.connection import db_session
|
from apps.admin_console.database.connection import db_session
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +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 batch_archive as archive_service
|
||||||
from apps.admin_console.services import report_export, task_import
|
from apps.admin_console.services import report_export, task_extensions, task_import
|
||||||
from artemis.config.settings import settings
|
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"])
|
||||||
|
|
||||||
@@ -23,13 +25,20 @@ 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: settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
default_factory=lambda: settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||||
ge=30,
|
ge=30,
|
||||||
@@ -52,6 +61,7 @@ def preferences():
|
|||||||
return {
|
return {
|
||||||
**store.preferences(),
|
**store.preferences(),
|
||||||
"batch_timeout_seconds": settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
"batch_timeout_seconds": settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||||
|
"batch_max_executions": settings.ARTEMIS_BATCH_MAX_EXECUTIONS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -120,7 +130,14 @@ 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)
|
||||||
|
reviewed = task_extensions.reviewed_batch_sessions(batch_id, store)
|
||||||
|
for job in jobs:
|
||||||
|
job["human_reviewed"] = job["session_id"] in reviewed
|
||||||
|
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}")
|
||||||
@@ -167,11 +184,7 @@ async def batch_archive(batch_id: str):
|
|||||||
if archive_service.status(batch_id, batch)["status"] == "preparing":
|
if archive_service.status(batch_id, batch)["status"] == "preparing":
|
||||||
raise HTTPException(409, "报告正在打包,请等待准备完成")
|
raise HTTPException(409, "报告正在打包,请等待准备完成")
|
||||||
path = archive_service.archive_path(batch_id)
|
path = archive_service.archive_path(batch_id)
|
||||||
if (
|
if not path.is_file() or archive_service.status(batch_id, batch)["status"] != "ready":
|
||||||
not path.is_file()
|
|
||||||
or not batch.get("report_ready")
|
|
||||||
and archive_service.status(batch_id, batch)["status"] != "ready"
|
|
||||||
):
|
|
||||||
path = await asyncio.to_thread(report_export.export_batch, batch_id)
|
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")
|
||||||
|
|
||||||
@@ -190,8 +203,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}")
|
||||||
@@ -199,6 +211,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),
|
||||||
@@ -207,9 +257,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,
|
||||||
@@ -218,7 +272,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'),
|
||||||
@@ -229,13 +285,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 [
|
||||||
@@ -256,7 +336,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,10 +19,6 @@ 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 admin_console.database.repositories.session_repository import session_repo
|
|
||||||
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.database.repositories.session_repository import session_repo
|
||||||
from apps.admin_console.services.media_service import media_service
|
from apps.admin_console.services.media_service import media_service
|
||||||
|
|
||||||
@@ -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,248 @@
|
|||||||
|
"""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"])
|
||||||
|
if manifest.workspace and manifest.server is None and manifest.skill is None:
|
||||||
|
store.resolve(PluginSelection(id=plugin_id, profile=request.profile))
|
||||||
|
return {"ok": True, "tools": {}, "message": "工作区扩展已就绪"}
|
||||||
|
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,10 +17,6 @@ from fastapi import APIRouter, HTTPException
|
|||||||
|
|
||||||
from artemis.config import WORKSPACE_ROOT
|
from artemis.config import WORKSPACE_ROOT
|
||||||
|
|
||||||
try:
|
|
||||||
from admin_console.schemas.task_schema import ReplayRequest
|
|
||||||
from admin_console.replay_manager import ReplayManager
|
|
||||||
except ImportError:
|
|
||||||
from apps.admin_console.schemas.task_schema import ReplayRequest
|
from apps.admin_console.schemas.task_schema import ReplayRequest
|
||||||
from apps.admin_console.replay_manager import ReplayManager
|
from apps.admin_console.replay_manager import ReplayManager
|
||||||
|
|
||||||
|
|||||||
@@ -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 admin_console.core.state import state
|
|
||||||
from admin_console.database.repositories.session_repository import session_repo
|
|
||||||
from admin_console.database.repositories.trace_repository import trace_repo
|
|
||||||
from admin_console.services.media_service import media_service
|
|
||||||
from admin_console.services.model_service import model_service
|
|
||||||
from admin_console.services.automation_store import store
|
|
||||||
from admin_console.services.task_import import case_title
|
|
||||||
except ImportError:
|
|
||||||
from apps.admin_console.core.state import state
|
from apps.admin_console.core.state import state
|
||||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||||
from apps.admin_console.database.repositories.trace_repository import trace_repo
|
from apps.admin_console.database.repositories.trace_repository import trace_repo
|
||||||
from apps.admin_console.services.media_service import media_service
|
from apps.admin_console.services.media_service import media_service
|
||||||
from apps.admin_console.services.model_service import model_service
|
from apps.admin_console.services.model_service import model_service
|
||||||
from apps.admin_console.services.automation_store import store
|
from apps.admin_console.services.automation_store import store
|
||||||
from apps.admin_console.services.task_import import case_title
|
from apps.admin_console.services.task_import import execution_title
|
||||||
|
from apps.admin_console.services.exploration_listing import exploration_metadata
|
||||||
|
|
||||||
|
|
||||||
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,7 +81,12 @@ 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])
|
||||||
|
recorded_models = session_repo.get_recorded_model_info_map(
|
||||||
|
[str(row.get("session_id")) for row in rows]
|
||||||
|
)
|
||||||
video_rec_map = session_repo.get_video_recordings_map()
|
video_rec_map = session_repo.get_video_recordings_map()
|
||||||
latest_recordings = session_repo.get_latest_video_recordings_map()
|
latest_recordings = session_repo.get_latest_video_recordings_map()
|
||||||
agent_names_by_session = session_repo.get_agent_trace_names_map()
|
agent_names_by_session = session_repo.get_agent_trace_names_map()
|
||||||
@@ -79,11 +109,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 +162,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 +219,26 @@ 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:
|
||||||
|
# Session history must not inherit today's global model configuration.
|
||||||
|
row["model_info"] = {
|
||||||
|
**row["model_info"],
|
||||||
|
"id": "",
|
||||||
|
"provider": "",
|
||||||
|
**recorded_models.get(str(row.get("session_id")), {}),
|
||||||
|
}
|
||||||
|
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 +248,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 +308,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 +343,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,11 +19,6 @@ from fastapi.responses import PlainTextResponse
|
|||||||
|
|
||||||
from artemis.config import TEST_OUTPUTS_DIR
|
from artemis.config import TEST_OUTPUTS_DIR
|
||||||
|
|
||||||
try:
|
|
||||||
from admin_console.database.repositories.step_repository import step_repo
|
|
||||||
from admin_console.database.repositories.trace_repository import trace_repo
|
|
||||||
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.step_repository import step_repo
|
||||||
from apps.admin_console.database.repositories.trace_repository import trace_repo
|
from apps.admin_console.database.repositories.trace_repository import trace_repo
|
||||||
from apps.admin_console.services.media_service import media_service
|
from apps.admin_console.services.media_service import media_service
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ 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 admin_console.services.device_stream_service import device_stream_service
|
|
||||||
except ImportError:
|
|
||||||
from apps.admin_console.services.device_stream_service import device_stream_service
|
from apps.admin_console.services.device_stream_service import device_stream_service
|
||||||
|
|
||||||
router = APIRouter(tags=["stream"])
|
router = APIRouter(tags=["stream"])
|
||||||
|
|||||||
@@ -527,7 +527,7 @@ async def get_server_runtime_status():
|
|||||||
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()
|
||||||
@@ -574,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()
|
||||||
@@ -615,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)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Workspace UI extension API. No model or device actions are dispatched."""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from apps.admin_console.services import batch_archive, report_export, task_extensions
|
||||||
|
from apps.admin_console.services.automation_store import store
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/task-extensions", tags=["task-extensions"])
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionUpdate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
values: dict[str, str] = Field(max_length=20)
|
||||||
|
expected_revision: int = Field(ge=0)
|
||||||
|
request_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{session_id}")
|
||||||
|
def read_extensions(session_id: str):
|
||||||
|
try:
|
||||||
|
return {
|
||||||
|
**task_extensions.task_context(session_id, store),
|
||||||
|
"plugins": task_extensions.contributions(session_id, store),
|
||||||
|
}
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(404, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{session_id}/{plugin_id}")
|
||||||
|
def update_extension(session_id: str, plugin_id: str, value: ExtensionUpdate):
|
||||||
|
job = store.job(session_id) or {}
|
||||||
|
batch_id = job.get("batch_id")
|
||||||
|
if (
|
||||||
|
batch_id
|
||||||
|
and batch_archive.status(batch_id, store.get("batch", batch_id) or {})["status"]
|
||||||
|
== "preparing"
|
||||||
|
):
|
||||||
|
raise HTTPException(409, "报告正在打包,请稍后保存评审")
|
||||||
|
with (
|
||||||
|
report_export.export_lock(
|
||||||
|
*(["batch:" + batch_id] if batch_id else []), "session:" + session_id
|
||||||
|
),
|
||||||
|
task_extensions.plugin_store().mutation_lock,
|
||||||
|
):
|
||||||
|
context = read_extensions(session_id)
|
||||||
|
if not context["editable"]:
|
||||||
|
raise HTTPException(409, "请在任务结束后填写评审")
|
||||||
|
manifest = next((m for m in task_extensions.active_manifests() if m.id == plugin_id), None)
|
||||||
|
if manifest is None:
|
||||||
|
raise HTTPException(409, "插件未安装或未启用,请刷新页面")
|
||||||
|
try:
|
||||||
|
task_extensions.save_values(
|
||||||
|
store,
|
||||||
|
session_id,
|
||||||
|
manifest,
|
||||||
|
value.values,
|
||||||
|
value.expected_revision,
|
||||||
|
str(value.request_id),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(409, str(exc)) from exc
|
||||||
|
if batch_id:
|
||||||
|
batch_archive.forget(batch_id)
|
||||||
|
return read_extensions(session_id)
|
||||||
@@ -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,15 +34,6 @@ from artemis.runtime.task_control import (
|
|||||||
request_resume,
|
request_resume,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
|
||||||
from admin_console.core.state import state
|
|
||||||
from admin_console.database.repositories.session_repository import session_repo
|
|
||||||
from admin_console.schemas.task_schema import RunRequest
|
|
||||||
from admin_console.services.ipc_service import ipc_service
|
|
||||||
from admin_console.services.model_service import model_service
|
|
||||||
from admin_console.services.task_preset_catalog import task_recommendation_engine
|
|
||||||
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.core.state import state
|
||||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||||
from apps.admin_console.schemas.task_schema import RunRequest
|
from apps.admin_console.schemas.task_schema import RunRequest
|
||||||
@@ -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:
|
||||||
@@ -74,25 +70,56 @@ 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 = _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()
|
||||||
):
|
):
|
||||||
request_resume(session_id)
|
request_resume(session_id)
|
||||||
return {"session_id": session_id, "status": "guidance_submitted", "guidance": guidance}
|
from apps.admin_console.services.task_lifecycle import guidance_details
|
||||||
|
|
||||||
|
details = guidance_details(session_id)
|
||||||
|
guidance = next(
|
||||||
|
(item for item in details["instructions"] if item["id"] == guidance.get("id")), guidance
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"status": "guidance_submitted",
|
||||||
|
"guidance": guidance,
|
||||||
|
"worker_active": live["process"].returncode is None,
|
||||||
|
"message": "指导已接收;接收不保证任务结束前消费,请以消费记录为准。",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/tasks/{session_id}/guidance")
|
@router.get("/api/tasks/{session_id}/guidance")
|
||||||
def task_guidance_history(session_id: str):
|
def task_guidance_history(session_id: str, details: bool = False):
|
||||||
from apps.admin_console.services.automation_store import identifier
|
from apps.admin_console.services.automation_store import identifier
|
||||||
|
|
||||||
try:
|
try:
|
||||||
identifier(session_id)
|
identifier(session_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(400, str(exc)) from exc
|
raise HTTPException(400, str(exc)) from exc
|
||||||
return list_instructions(session_id)
|
from apps.admin_console.services.task_lifecycle import guidance_details
|
||||||
|
|
||||||
|
result = guidance_details(session_id)
|
||||||
|
return result if details else result["instructions"]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/tasks/{session_id}/continue")
|
@router.post("/api/tasks/{session_id}/continue")
|
||||||
@@ -181,7 +208,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:
|
||||||
@@ -205,7 +235,11 @@ async def run_task(request: RunRequest):
|
|||||||
target_serial = (
|
target_serial = (
|
||||||
request.runtime_overrides.device_serial if request.runtime_overrides else None
|
request.runtime_overrides.device_serial if request.runtime_overrides else None
|
||||||
) or request.device_serial
|
) or request.device_serial
|
||||||
device_probe = await readiness_engine.run_device_submission_probe(target_serial=target_serial)
|
device_probe = (
|
||||||
|
await readiness_engine.run_device_submission_probe(target_serial=target_serial)
|
||||||
|
if 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 ""
|
||||||
@@ -249,6 +283,8 @@ async def run_task(request: RunRequest):
|
|||||||
|
|
||||||
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
|
||||||
@@ -274,7 +310,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")
|
||||||
@@ -400,6 +441,24 @@ async def get_status():
|
|||||||
)
|
)
|
||||||
|
|
||||||
model_info = model_service.get_active_model_info(active_profile)
|
model_info = model_service.get_active_model_info(active_profile)
|
||||||
|
model_sid = running_sid if is_running else latest_session_id
|
||||||
|
if model_info and model_sid:
|
||||||
|
recorded_model = session_repo.get_recorded_model_info_map([str(model_sid)]).get(
|
||||||
|
str(model_sid)
|
||||||
|
)
|
||||||
|
if not isinstance(recorded_model, dict):
|
||||||
|
matching_task = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in state.queue_items
|
||||||
|
if isinstance(item, dict) and str(item.get("session_id")) == str(model_sid)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
override = (matching_task or {}).get("runtime_overrides") or {}
|
||||||
|
model = override.get("model")
|
||||||
|
recorded_model = {"id": model, "provider": "openai"} if model else {}
|
||||||
|
model_info = {**model_info, "id": "", "provider": "", **recorded_model}
|
||||||
|
|
||||||
# Unified Global Queue: merge web tasks and external SDK/CLI device queue tickets
|
# Unified Global Queue: merge web tasks and external SDK/CLI device queue tickets
|
||||||
global_queued = DeviceExecutionLock.get_queued_tasks()
|
global_queued = DeviceExecutionLock.get_queued_tasks()
|
||||||
@@ -418,6 +477,30 @@ 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]:
|
||||||
|
control_status = (
|
||||||
|
read_state(str(item["session_id"])).get("status") if item.get("session_id") else None
|
||||||
|
)
|
||||||
|
item["phase"] = {
|
||||||
|
"queued": "waiting_resources",
|
||||||
|
"resuming": "waiting_resources",
|
||||||
|
"task_paused": "paused",
|
||||||
|
"demonstrating": "demonstrating",
|
||||||
|
"analyzing_demo": "analyzing_demo",
|
||||||
|
}.get(control_status)
|
||||||
|
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 {
|
||||||
@@ -443,6 +526,12 @@ async def get_status():
|
|||||||
is_paused = state.is_paused
|
is_paused = state.is_paused
|
||||||
conn_profile = conn_info.get("profile") or active_profile
|
conn_profile = conn_info.get("profile") or active_profile
|
||||||
conn_model_info = model_service.get_active_model_info(conn_profile)
|
conn_model_info = model_service.get_active_model_info(conn_profile)
|
||||||
|
if conn_model_info and model_info:
|
||||||
|
conn_model_info = {
|
||||||
|
**conn_model_info,
|
||||||
|
"id": model_info["id"],
|
||||||
|
"provider": model_info["provider"],
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"status": "paused" if is_paused else "running",
|
"status": "paused" if is_paused else "running",
|
||||||
"paused_error": state.paused_error if is_paused else None,
|
"paused_error": state.paused_error if is_paused else None,
|
||||||
|
|||||||
@@ -12,11 +12,13 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, ConfigDict
|
||||||
from apps.admin_console.services.task_import import TaskOptions
|
from apps.admin_console.services.task_import import TaskOptions
|
||||||
|
|
||||||
|
|
||||||
class RunRequest(BaseModel):
|
class RunRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
goal: str | None = None
|
goal: str | None = None
|
||||||
goals: list[str] | None = None
|
goals: list[str] | None = None
|
||||||
profile: str | None = "flash"
|
profile: str | None = "flash"
|
||||||
|
|||||||
@@ -46,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
|
||||||
|
|
||||||
@@ -71,26 +71,16 @@ from artemis.config import (
|
|||||||
)
|
)
|
||||||
from artemis.resources import get_bundled_showcase_dist
|
from artemis.resources import get_bundled_showcase_dist
|
||||||
|
|
||||||
try:
|
|
||||||
from admin_console.core.security import SameOriginBoundaryMiddleware
|
|
||||||
from admin_console.core.state import ServerState, state
|
|
||||||
from admin_console.database.connection import db_session, get_db
|
|
||||||
from admin_console.database.repositories.session_repository import session_repo
|
|
||||||
from admin_console.database.repositories.step_repository import step_repo
|
|
||||||
from admin_console.database.repositories.trace_repository import trace_repo
|
|
||||||
from admin_console.routers import media, replay, sessions, steps, stream, system, tasks
|
|
||||||
from admin_console.routers.replay import replay_manager
|
|
||||||
from admin_console.services.ipc_service import ipc_service
|
|
||||||
from admin_console.services.media_service import media_service
|
|
||||||
from admin_console.services.model_service import model_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.security import SameOriginBoundaryMiddleware
|
||||||
from apps.admin_console.core.state import state
|
from apps.admin_console.core.state import ServerState, state
|
||||||
|
from apps.admin_console.database.connection import db_session, get_db
|
||||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||||
|
from apps.admin_console.database.repositories.step_repository import step_repo
|
||||||
|
from apps.admin_console.database.repositories.trace_repository import trace_repo
|
||||||
from apps.admin_console.routers import media, replay, sessions, steps, stream, system, tasks
|
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.routers.replay import replay_manager
|
||||||
from apps.admin_console.services.ipc_service import ipc_service
|
from apps.admin_console.services.ipc_service import ipc_service
|
||||||
|
from apps.admin_console.services.media_service import media_service
|
||||||
from apps.admin_console.services.model_service import model_service
|
from apps.admin_console.services.model_service import model_service
|
||||||
from apps.admin_console.services.task_queue_service import task_queue_service
|
from apps.admin_console.services.task_queue_service import task_queue_service
|
||||||
|
|
||||||
@@ -101,11 +91,37 @@ init_ls_address()
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _lifespan(_app: "FastAPI"):
|
async def _lifespan(_app: "FastAPI"):
|
||||||
"""Start server resources and release them on shutdown."""
|
"""Start server resources and release them on shutdown."""
|
||||||
|
from artemis.config.paths import get_app_dir, get_traces_dir
|
||||||
|
from artemis.runtime.daemon_client import configure_local_daemon
|
||||||
|
from artemis.service_registry import ServiceRegistration
|
||||||
|
|
||||||
|
registration = ServiceRegistration(
|
||||||
|
port=state.port,
|
||||||
|
token=LIFECYCLE_TOKEN,
|
||||||
|
app_dir=get_app_dir(),
|
||||||
|
traces_dir=get_traces_dir(),
|
||||||
|
)
|
||||||
|
registration.acquire()
|
||||||
|
app.state.service_registration = registration
|
||||||
|
configure_local_daemon(state.port)
|
||||||
|
try:
|
||||||
|
from artemis.integrations.codex_skill import sync_on_startup
|
||||||
|
from artemis.config.paths import ROOT_DIR
|
||||||
|
|
||||||
|
await asyncio.to_thread(sync_on_startup, ROOT_DIR)
|
||||||
|
shared_tool_executor.start()
|
||||||
|
async with shared_mcp.session_manager.run():
|
||||||
await on_startup()
|
await on_startup()
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
await on_shutdown()
|
await on_shutdown()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await shared_tool_executor.stop()
|
||||||
|
finally:
|
||||||
|
configure_local_daemon(None)
|
||||||
|
registration.close()
|
||||||
|
|
||||||
|
|
||||||
# Initialize FastAPI application
|
# Initialize FastAPI application
|
||||||
@@ -118,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."""
|
||||||
@@ -241,6 +277,20 @@ 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.task_extensions import router as task_extensions_router
|
||||||
|
|
||||||
|
app.include_router(task_extensions_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:
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ 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 RECOVERY_ERRORS, 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.config.settings import settings
|
||||||
from artemis.runtime.task_limits import task_timeout
|
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"}
|
||||||
|
MODEL_PAUSE_REASON = "模型请求已暂停,请先恢复模型连接再继续批次"
|
||||||
background_tasks: set[asyncio.Task] = set()
|
background_tasks: set[asyncio.Task] = set()
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +37,22 @@ 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,
|
||||||
}
|
}
|
||||||
@@ -107,7 +124,9 @@ def recover_batches():
|
|||||||
if any(str(job["session_id"]) in live_sessions for job in store.jobs(batch["id"])):
|
if any(str(job["session_id"]) in live_sessions for job in store.jobs(batch["id"])):
|
||||||
continue
|
continue
|
||||||
batch.update(
|
batch.update(
|
||||||
status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务"
|
status="paused",
|
||||||
|
reason="服务已重启;请检查中断任务,点击继续执行未开始任务",
|
||||||
|
pause_source="recovery",
|
||||||
)
|
)
|
||||||
for job in store.jobs(batch["id"]):
|
for job in store.jobs(batch["id"]):
|
||||||
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
|
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
|
||||||
@@ -148,9 +167,29 @@ async def on_terminal(item: dict, status: str):
|
|||||||
}
|
}
|
||||||
):
|
):
|
||||||
job["status"] = "failed"
|
job["status"] = "failed"
|
||||||
job["error"] = job.get("error") or "任务执行结束,但业务检查未通过"
|
job["error"] = (
|
||||||
|
job.get("error")
|
||||||
|
or report.get("error")
|
||||||
|
or (report.get("test_result") or {}).get("reason")
|
||||||
|
or "任务未完成或验证未通过,请查看报告中的具体原因"
|
||||||
|
)
|
||||||
elif job["status"] == "failed" and report.get("error") and not job.get("error"):
|
elif job["status"] == "failed" and report.get("error") and not job.get("error"):
|
||||||
job["error"] = report["error"]
|
job["error"] = report["error"]
|
||||||
|
if job["status"] == "failed" and report["outcome"].get("code") == "initialization_failed":
|
||||||
|
job["failure_code"] = "initialization_failed"
|
||||||
|
batch = store.get("batch", job.get("batch_id")) if job.get("batch_id") else None
|
||||||
|
if (
|
||||||
|
batch
|
||||||
|
and (batch["status"] == "running" or batch.get("pause_source") == "model")
|
||||||
|
and any(j["status"] == "pending" for j in store.jobs(batch["id"]))
|
||||||
|
):
|
||||||
|
batch.update(
|
||||||
|
status="paused",
|
||||||
|
pause_source="initialization",
|
||||||
|
reason="任务初始化失败;请处理环境问题后继续剩余用例:"
|
||||||
|
+ (job.get("error") or "请查看任务报告"),
|
||||||
|
)
|
||||||
|
store.put("batch", batch["id"], batch)
|
||||||
# Persist terminal classification before export, but the coordinator waits for finalization.
|
# Persist terminal classification before export, but the coordinator waits for finalization.
|
||||||
store.save_job({**job, "status": "finalizing", "terminal_status": job["status"]})
|
store.save_job({**job, "status": "finalizing", "terminal_status": job["status"]})
|
||||||
await asyncio.to_thread(export_session, sid, job["formats"])
|
await asyncio.to_thread(export_session, sid, job["formats"])
|
||||||
@@ -159,6 +198,7 @@ async def on_terminal(item: dict, status: str):
|
|||||||
logger.exception("Report finalization failed for %s", sid)
|
logger.exception("Report finalization failed for %s", sid)
|
||||||
job["report_error"] = str(exc)[:500]
|
job["report_error"] = str(exc)[:500]
|
||||||
job.pop("terminal_status", None)
|
job.pop("terminal_status", None)
|
||||||
|
job["finalized_at"] = time.time()
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
|
|
||||||
|
|
||||||
@@ -193,14 +233,25 @@ async def tick_batches():
|
|||||||
from apps.admin_console.services.report_export import export_batch
|
from apps.admin_console.services.report_export import export_batch
|
||||||
|
|
||||||
await reconcile_recovered_jobs()
|
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
|
||||||
jobs = store.jobs(batch["id"])
|
jobs = store.jobs(batch["id"])
|
||||||
|
if (
|
||||||
|
batch["status"] == "paused"
|
||||||
|
and batch.get("pause_source") == "model"
|
||||||
|
and not state.is_paused
|
||||||
|
):
|
||||||
|
batch.update(status="running", reason="")
|
||||||
|
batch.pop("pause_source", None)
|
||||||
|
store.put("batch", batch["id"], batch)
|
||||||
active = [j for j in jobs if j["status"] in {"running", "queued", "finalizing"}]
|
active = [j for j in jobs if j["status"] in {"running", "queued", "finalizing"}]
|
||||||
if active:
|
if active:
|
||||||
if state.is_paused:
|
if state.is_paused and batch["status"] == "running":
|
||||||
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
|
batch.update(status="paused", reason=MODEL_PAUSE_REASON, pause_source="model")
|
||||||
store.put("batch", batch["id"], batch)
|
store.put("batch", batch["id"], batch)
|
||||||
for job in active:
|
for job in active:
|
||||||
# The queue records started_at at submission, then replaces it
|
# The queue records started_at at submission, then replaces it
|
||||||
@@ -264,16 +315,18 @@ async def tick_batches():
|
|||||||
store.put("batch", batch["id"], batch)
|
store.put("batch", batch["id"], batch)
|
||||||
continue
|
continue
|
||||||
if state.is_paused:
|
if state.is_paused:
|
||||||
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
|
batch.update(status="paused", reason=MODEL_PAUSE_REASON, pause_source="model")
|
||||||
store.put("batch", batch["id"], batch)
|
store.put("batch", batch["id"], batch)
|
||||||
continue
|
continue
|
||||||
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:
|
||||||
@@ -297,7 +350,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 "队列拒绝接收任务")
|
||||||
@@ -330,6 +383,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,
|
||||||
@@ -342,18 +404,25 @@ def create_batch(payload: dict) -> dict:
|
|||||||
"timeout_seconds": payload.get("timeout_seconds") or settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
"timeout_seconds": payload.get("timeout_seconds") or settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||||
"stop_on_failure": payload.get("stop_on_failure", False),
|
"stop_on_failure": payload.get("stop_on_failure", False),
|
||||||
"request_id": payload.get("request_id"),
|
"request_id": payload.get("request_id"),
|
||||||
|
"repeat_count": repeat_count,
|
||||||
|
"case_count": len(cases),
|
||||||
|
"total_executions": total,
|
||||||
}
|
}
|
||||||
jobs = []
|
jobs = []
|
||||||
for number, case in enumerate(payload["cases"], 1):
|
for iteration in range(1, repeat_count + 1):
|
||||||
|
for number, case in enumerate(cases, 1):
|
||||||
jobs.append(
|
jobs.append(
|
||||||
{
|
{
|
||||||
"session_id": new_id(),
|
"session_id": new_id(),
|
||||||
"batch_id": bid,
|
"batch_id": bid,
|
||||||
"position": number,
|
"position": len(jobs) + 1,
|
||||||
|
"iteration": iteration,
|
||||||
|
"repeat_count": repeat_count,
|
||||||
|
"case_position": number,
|
||||||
"case": case,
|
"case": case,
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"formats": batch["formats"],
|
"formats": batch["formats"],
|
||||||
"created_at": time.time(),
|
"created_at": batch["created_at"],
|
||||||
"device_serial": batch["device_serial"],
|
"device_serial": batch["device_serial"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -370,7 +439,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))
|
||||||
@@ -398,11 +469,14 @@ def batch_action(batch_id: str, action: str) -> dict:
|
|||||||
if action == "pause":
|
if action == "pause":
|
||||||
if batch["status"] not in {"running", "paused"}:
|
if batch["status"] not in {"running", "paused"}:
|
||||||
raise ValueError("只能暂停正在执行的批次")
|
raise ValueError("只能暂停正在执行的批次")
|
||||||
batch.update(status="paused", reason="用户暂停;当前任务继续,后续任务等待")
|
batch.update(
|
||||||
|
status="paused", reason="用户暂停;当前任务继续,后续任务等待", pause_source="user"
|
||||||
|
)
|
||||||
elif action == "resume":
|
elif action == "resume":
|
||||||
if batch["status"] not in {"draft", "paused"}:
|
if batch["status"] not in {"draft", "paused"}:
|
||||||
raise ValueError("只能开始草稿或继续已暂停批次")
|
raise ValueError("只能开始草稿或继续已暂停批次")
|
||||||
batch.update(status="running", reason="")
|
batch.update(status="running", reason="")
|
||||||
|
batch.pop("pause_source", None)
|
||||||
elif action == "retry_failed":
|
elif action == "retry_failed":
|
||||||
if batch["status"] == "running":
|
if batch["status"] == "running":
|
||||||
raise ValueError("请先暂停批次")
|
raise ValueError("请先暂停批次")
|
||||||
@@ -412,7 +486,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:
|
||||||
|
|||||||
@@ -149,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")
|
||||||
@@ -247,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 IN ('case_review','task_extensions') 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=?",
|
||||||
@@ -260,9 +311,16 @@ 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 IN ('case_review','task_extensions') 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 IN ('case_review','task_extensions')"
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ def archive_path(batch_id: str):
|
|||||||
return report_export.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip"
|
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(batch_id), "completed"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _snapshot(state: dict) -> dict:
|
def _snapshot(state: dict) -> dict:
|
||||||
elapsed = max(0, (state.get("finished_at") or time.monotonic()) - state["started_at"])
|
elapsed = max(0, (state.get("finished_at") or time.monotonic()) - state["started_at"])
|
||||||
completed, total = state["completed"], state["total"]
|
completed, total = state["completed"], state["total"]
|
||||||
@@ -35,9 +41,15 @@ def _snapshot(state: dict) -> dict:
|
|||||||
def status(batch_id: str, batch: dict) -> dict:
|
def status(batch_id: str, batch: dict) -> dict:
|
||||||
with _lock:
|
with _lock:
|
||||||
state = _states.get(batch_id)
|
state = _states.get(batch_id)
|
||||||
if state:
|
if state and state["status"] != "ready":
|
||||||
return _snapshot(state)
|
return _snapshot(state)
|
||||||
if batch.get("report_ready") and archive_path(batch_id).is_file():
|
if (
|
||||||
|
state
|
||||||
|
and state.get("signature") == report_export.batch_archive_signature(batch_id)
|
||||||
|
and archive_path(batch_id).is_file()
|
||||||
|
):
|
||||||
|
return _snapshot(state)
|
||||||
|
if cached(batch_id, batch):
|
||||||
return {
|
return {
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
"stage": "报告包已就绪",
|
"stage": "报告包已就绪",
|
||||||
@@ -59,11 +71,12 @@ def status(batch_id: str, batch: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def prepare(batch_id: str, batch: dict) -> dict:
|
def prepare(batch_id: str, batch: dict) -> dict:
|
||||||
|
cache_ready = cached(batch_id, batch)
|
||||||
with _lock:
|
with _lock:
|
||||||
current = _states.get(batch_id)
|
current = _states.get(batch_id)
|
||||||
if current and current["status"] == "preparing":
|
if current and current["status"] == "preparing":
|
||||||
return _snapshot(current)
|
return _snapshot(current)
|
||||||
if batch.get("report_ready") and archive_path(batch_id).is_file():
|
if cache_ready:
|
||||||
_states.pop(batch_id, None)
|
_states.pop(batch_id, None)
|
||||||
return status_from_cache()
|
return status_from_cache()
|
||||||
state = {
|
state = {
|
||||||
@@ -81,12 +94,18 @@ def prepare(batch_id: str, batch: dict) -> dict:
|
|||||||
|
|
||||||
def run() -> None:
|
def run() -> None:
|
||||||
try:
|
try:
|
||||||
with report_export.EXPORT_LOCK:
|
with report_export.export_lock("batch:" + batch_id):
|
||||||
|
signature = report_export.batch_archive_signature(batch_id)
|
||||||
latest = report_export.store.get("batch", batch_id)
|
latest = report_export.store.get("batch", batch_id)
|
||||||
if not (latest and latest.get("report_ready") and archive_path(batch_id).is_file()):
|
if not (latest and cached(batch_id, latest)):
|
||||||
report_export.export_batch(batch_id, progress=update)
|
report_export.export_batch(batch_id, progress=update)
|
||||||
with _lock:
|
with _lock:
|
||||||
state.update(status="ready", stage="报告包已就绪", finished_at=time.monotonic())
|
state.update(
|
||||||
|
status="ready",
|
||||||
|
stage="报告包已就绪",
|
||||||
|
finished_at=time.monotonic(),
|
||||||
|
signature=signature,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
with _lock:
|
with _lock:
|
||||||
state.update(
|
state.update(
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""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": "system" if ai_result.get("verification_skipped") else "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,161 @@
|
|||||||
|
"""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", []),
|
||||||
|
"evidence_sources": report.get("evidence_sources"),
|
||||||
|
"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,57 @@
|
|||||||
|
"""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 {
|
||||||
|
"flash_final_check_enabled": agent.flash.final_check_enabled
|
||||||
|
if profile == "flash"
|
||||||
|
else None,
|
||||||
|
"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,9 +20,6 @@ from typing import Any
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
|
||||||
from admin_console.core.state import state
|
|
||||||
except ImportError:
|
|
||||||
from apps.admin_console.core.state import state
|
from apps.admin_console.core.state import state
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ class MediaService:
|
|||||||
return p
|
return p
|
||||||
|
|
||||||
LIVE_RECORDING_GRACE_SECONDS = 10.0
|
LIVE_RECORDING_GRACE_SECONDS = 10.0
|
||||||
TRACE_STATUS_SUFFIXES = ("_PASS_", "_FAIL_", "_TESTFAIL_")
|
TRACE_STATUS_SUFFIXES = ("_PASS_", "_FAIL_", "_TESTFAIL_", "_BLOCKED_", "_UNVERIFIED_")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_live_recording(cls, p: Path) -> bool:
|
def is_live_recording(cls, p: Path) -> bool:
|
||||||
|
|||||||
@@ -80,9 +80,10 @@ def get_model_settings() -> dict:
|
|||||||
"tool_fallback_no_progress_attempts": settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS,
|
"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": [
|
"model_presets": [
|
||||||
"gpt-5.6-luna",
|
|
||||||
"gpt-6-luna",
|
"gpt-6-luna",
|
||||||
"mimo-v2.6-flash",
|
"gpt-6.1-terra",
|
||||||
|
"gpt-6.1-sol",
|
||||||
|
"gpt-6-astra",
|
||||||
"mimo-v2.6-pro",
|
"mimo-v2.6-pro",
|
||||||
],
|
],
|
||||||
"env_path": str(get_env_file()),
|
"env_path": str(get_env_file()),
|
||||||
@@ -166,9 +167,20 @@ 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
|
||||||
|
flash_final_check_enabled: bool = Field(
|
||||||
|
default_factory=lambda: load_agent_config().flash.final_check_enabled
|
||||||
|
)
|
||||||
step_summarizer_enabled: bool = True
|
step_summarizer_enabled: bool = True
|
||||||
screenshot_limit_enabled: bool = True
|
screenshot_limit_enabled: bool = True
|
||||||
screenshot_max_long_edge: int = 2048
|
screenshot_max_long_edge: int = 2048
|
||||||
@@ -197,7 +209,9 @@ def get_capability_settings() -> CapabilitySettings:
|
|||||||
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,
|
||||||
|
flash_final_check_enabled=load_agent_config().flash.final_check_enabled,
|
||||||
step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED,
|
step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED,
|
||||||
screenshot_limit_enabled=screenshot.enabled,
|
screenshot_limit_enabled=screenshot.enabled,
|
||||||
screenshot_max_long_edge=screenshot.max_long_edge,
|
screenshot_max_long_edge=screenshot.max_long_edge,
|
||||||
@@ -205,10 +219,20 @@ def get_capability_settings() -> CapabilitySettings:
|
|||||||
|
|
||||||
|
|
||||||
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_FLASH_FINAL_CHECK_ENABLED": update.flash_final_check_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_SCREENSHOT_LIMIT_ENABLED": update.screenshot_limit_enabled,
|
||||||
"ARTEMIS_TASK_TIMEOUT_SECONDS": update.task_timeout_seconds,
|
"ARTEMIS_TASK_TIMEOUT_SECONDS": update.task_timeout_seconds,
|
||||||
@@ -219,6 +243,8 @@ def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
|
|||||||
"ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS": update.worker_cleanup_timeout_seconds,
|
"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()
|
||||||
|
|||||||
@@ -5,8 +5,17 @@ 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 apps.admin_console.services.task_lifecycle import guidance_details
|
||||||
|
from apps.admin_console.services import task_extensions
|
||||||
|
|
||||||
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 +31,37 @@ 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-3"
|
||||||
|
|
||||||
|
|
||||||
|
def batch_archive_signature(batch_id: str | None = None) -> str:
|
||||||
|
signature = task_extensions.installation_signature()
|
||||||
|
if batch_id:
|
||||||
|
with store.connection() as conn:
|
||||||
|
changes = conn.execute(
|
||||||
|
"SELECT r.id,r.updated_at FROM automation_records r "
|
||||||
|
"JOIN automation_jobs j ON j.session_id=r.id "
|
||||||
|
"WHERE r.kind='task_extensions' AND j.batch_id=? ORDER BY r.id",
|
||||||
|
(batch_id,),
|
||||||
|
).fetchall()
|
||||||
|
if changes:
|
||||||
|
signature += ":" + fingerprint({"reviews": [tuple(row) for row in changes]})
|
||||||
|
return BATCH_ARCHIVE_SIGNATURE + (":" + signature if signature else "")
|
||||||
|
|
||||||
|
|
||||||
|
def extension_rows(report: dict) -> list[tuple[str, str, str]]:
|
||||||
|
return [
|
||||||
|
(section["name"], field["label"], field["value"])
|
||||||
|
for section in report.get("task_extensions", [])
|
||||||
|
for field in section["fields"]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
STATUS = {
|
STATUS = {
|
||||||
"completed": "执行完成",
|
"completed": "执行完成",
|
||||||
"success": "执行完成",
|
"success": "执行完成",
|
||||||
@@ -62,6 +98,14 @@ 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:
|
||||||
|
if result["status"] == "inconclusive" and result.get("verification_skipped"):
|
||||||
|
return "未做 AI 判定"
|
||||||
|
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 "验证失败"
|
||||||
@@ -148,6 +192,9 @@ 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
|
||||||
|
from artemis.data_engine.evidence_sources import recorded_sources
|
||||||
|
|
||||||
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()
|
||||||
@@ -179,25 +226,38 @@ 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 = "未验证(任务异常终止)"
|
||||||
if not content:
|
if not content:
|
||||||
content = (
|
lines = ["本任务未保存独立输出正文。", f"当前有效判定:{verification}"]
|
||||||
"本任务未生成最终结论。请查看异常原因和已记录的执行步骤。"
|
if test_result:
|
||||||
if status in {"failed", "interrupted"}
|
if test_result.get("reason"):
|
||||||
else "本任务没有保存最终结论。以下内容仅来自已记录的执行状态和证据。"
|
lines.append(f"判定依据:{test_result['reason']}")
|
||||||
)
|
lines.extend(f"- {item}" for item in test_result.get("evidence") or [])
|
||||||
|
elif error:
|
||||||
|
lines.append(f"异常原因:{error}")
|
||||||
|
content = "\n\n".join(lines)
|
||||||
return {
|
return {
|
||||||
"id": session_id,
|
"id": session_id,
|
||||||
"name": case_title(case) or session.get("initial_goal", session_id)[:120],
|
**guidance_details(session_id, db_path=repo.db_path),
|
||||||
|
"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,
|
||||||
|
"evidence_sources": recorded_sources(steps),
|
||||||
"start": session.get("start_time") or meta.get("started_at"),
|
"start": session.get("start_time") or meta.get("started_at"),
|
||||||
"end": session.get("end_time") or meta.get("ended_at"),
|
"end": session.get("end_time") or meta.get("ended_at"),
|
||||||
"error": error,
|
"error": error,
|
||||||
@@ -207,7 +267,9 @@ def session_report(session_id: str, db_path=None) -> dict:
|
|||||||
"category": case.get("category", ""),
|
"category": case.get("category", ""),
|
||||||
"subcategory": case.get("subcategory", ""),
|
"subcategory": case.get("subcategory", ""),
|
||||||
"preconditions": case.get("preconditions", ""),
|
"preconditions": case.get("preconditions", ""),
|
||||||
|
"original_case": case.get("original_case"),
|
||||||
"task_config": case.get("task_config") or {},
|
"task_config": case.get("task_config") or {},
|
||||||
|
"task_extensions": task_extensions.report_sections(session_id, store),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -219,18 +281,136 @@ def time_text(value) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def lifecycle_text(value: dict) -> str:
|
||||||
|
labels = {
|
||||||
|
"total": "总历时",
|
||||||
|
"queued": "后台排队",
|
||||||
|
"resource_wait": "等待设备/资源",
|
||||||
|
"paused": "暂停与示范",
|
||||||
|
"execution": "执行与整理",
|
||||||
|
"final_review": "最终验收",
|
||||||
|
"report_export": "报告导出",
|
||||||
|
}
|
||||||
|
durations = value.get("durations") or {}
|
||||||
|
return (
|
||||||
|
"\n".join(
|
||||||
|
f"{label}:{round(durations[key], 1)} 秒"
|
||||||
|
if durations.get(key) is not None
|
||||||
|
else f"{label}:未记录"
|
||||||
|
for key, label in labels.items()
|
||||||
|
)
|
||||||
|
+ "\n执行含初始化与报告整理,不代表模型速度。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def guidance_text(items: list[dict]) -> str:
|
||||||
|
labels = {
|
||||||
|
"pending": "已接收,等待读取",
|
||||||
|
"reading": "执行器正在读取",
|
||||||
|
"consumed": "执行器已读取",
|
||||||
|
"unapplied": "任务结束,未记录消费",
|
||||||
|
"unconfirmed": "任务结束,读取未确认",
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
"\n".join(
|
||||||
|
f"{item['id']} · {labels.get(item['status'], '状态未知')} · 接收 {time_text(item.get('accepted_at') or item.get('timestamp'))}"
|
||||||
|
+ (f" · 读取 {time_text(item['consumed_at'])}" if item.get("consumed_at") else "")
|
||||||
|
for item in items
|
||||||
|
)
|
||||||
|
or "无指导记录"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def source_text(value: dict) -> str:
|
||||||
|
labels = {
|
||||||
|
"android": "安卓界面",
|
||||||
|
"computer": "电脑界面",
|
||||||
|
"api": "接口数据",
|
||||||
|
"terminal": "终端",
|
||||||
|
"file": "文件",
|
||||||
|
"other": "其他",
|
||||||
|
}
|
||||||
|
lines = []
|
||||||
|
for item in value.get("sources", []):
|
||||||
|
references = ", ".join(
|
||||||
|
" / ".join(
|
||||||
|
f"{key}={ref[key]}"
|
||||||
|
for key in ("step_id", "call_id", "source_id", "trace_id")
|
||||||
|
if ref.get(key)
|
||||||
|
)
|
||||||
|
or "编号未记录"
|
||||||
|
for ref in item.get("references", [])
|
||||||
|
)
|
||||||
|
variation = "(来源标签随步骤变化,详见原始步骤)" if item.get("label_varies") else ""
|
||||||
|
lines.append(
|
||||||
|
f"{labels.get(item['kind'], '其他')} · {item['label'] or item['target_id']}"
|
||||||
|
f"{variation} · {references}"
|
||||||
|
)
|
||||||
|
if value.get("sources_omitted"):
|
||||||
|
lines.append(f"另 {value['sources_omitted']} 个来源见原始步骤记录。")
|
||||||
|
lines.append(
|
||||||
|
f"未关联到单一来源的步骤:{value.get('unattributed_step_count', '未知')}。"
|
||||||
|
"窗口枚举等操作可能没有单一目标;此计数不表示截图丢失。"
|
||||||
|
)
|
||||||
|
lines.append(value.get("note") or "来源清单不代表断言已验证。")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
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 "—"),
|
||||||
("前置约束提取", report.get("preconditions") or "—"),
|
("前置约束提取", report.get("preconditions") or "—"),
|
||||||
|
*(
|
||||||
|
[
|
||||||
|
("原始用例 · 预置条件", report["original_case"].get("preconditions") or "未填写"),
|
||||||
|
("原始用例 · 操作步骤", report["original_case"].get("steps") or "未填写"),
|
||||||
|
("原始用例 · 预期结果", report["original_case"].get("expected") or "未填写"),
|
||||||
|
]
|
||||||
|
if report.get("original_case") is not None
|
||||||
|
else []
|
||||||
|
),
|
||||||
("任务配置", config_text(report.get("task_config"))),
|
("任务配置", config_text(report.get("task_config"))),
|
||||||
("任务目标", report["goal"]),
|
("任务目标", report["goal"]),
|
||||||
("预期结果", report["expected"] or "未指定"),
|
("预期结果", report["expected"] or "未指定"),
|
||||||
("执行状态", STATUS.get(report["status"], report["status"])),
|
("执行状态", STATUS.get(report["status"], report["status"])),
|
||||||
|
("耗时分项", lifecycle_text(report.get("lifecycle") or {})),
|
||||||
|
("指导消费", guidance_text(report.get("instructions") or [])),
|
||||||
|
("证据来源与边界", source_text(report.get("evidence_sources") or {})),
|
||||||
("验证结论", report["verdict"]),
|
("验证结论", report["verdict"]),
|
||||||
|
("人工复验时间", time_text((report.get("test_result") or {}).get("reviewed_at"))),
|
||||||
|
(
|
||||||
|
"原始结果"
|
||||||
|
if ((report.get("test_result") or {}).get("ai_result") or {}).get(
|
||||||
|
"verification_skipped"
|
||||||
|
)
|
||||||
|
else "原 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"]),
|
||||||
@@ -423,6 +603,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")
|
||||||
@@ -432,23 +671,46 @@ 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)
|
||||||
for label, value in metadata(report)
|
for label, value in metadata(report)
|
||||||
if label in {"任务编号", "用例编号", "检测项目", "测试子项", "开始时间", "结束时间", "设备"}
|
if label
|
||||||
|
in {
|
||||||
|
"任务编号",
|
||||||
|
"用例编号",
|
||||||
|
"检测项目",
|
||||||
|
"测试子项",
|
||||||
|
"开始时间",
|
||||||
|
"结束时间",
|
||||||
|
"设备",
|
||||||
|
"耗时分项",
|
||||||
|
"指导消费",
|
||||||
|
"证据来源与边界",
|
||||||
|
}
|
||||||
]
|
]
|
||||||
word_table(doc, details, (3.2, 13.3))
|
word_table(doc, details, (3.2, 13.3))
|
||||||
doc.add_heading("任务目标", 2)
|
doc.add_heading("任务目标与预期" if not report["expected"] else "任务目标", 2)
|
||||||
doc.add_paragraph(report["goal"] or "未记录")
|
doc.add_paragraph(report["goal"] or "未记录")
|
||||||
|
if report["expected"]:
|
||||||
doc.add_heading("预期结果", 2)
|
doc.add_heading("预期结果", 2)
|
||||||
doc.add_paragraph(report["expected"] or "未指定")
|
doc.add_paragraph(report["expected"])
|
||||||
if report.get("preconditions"):
|
if report.get("preconditions"):
|
||||||
doc.add_paragraph("前置约束:" + text(report["preconditions"]))
|
doc.add_paragraph("前置约束:" + text(report["preconditions"]))
|
||||||
if report.get("task_config"):
|
if report.get("task_config"):
|
||||||
doc.add_paragraph("任务配置:" + config_text(report["task_config"]))
|
doc.add_paragraph("任务配置:" + config_text(report["task_config"]))
|
||||||
doc.add_heading("结论与发现", 1)
|
doc.add_heading("结论与发现", 1)
|
||||||
add_markdown_content(doc, report["content"])
|
add_markdown_content(doc, report["content"])
|
||||||
|
if review_rows := extension_rows(report):
|
||||||
|
doc.add_heading("人工评审(插件)", 1)
|
||||||
|
word_table(
|
||||||
|
doc, [(f"{name} · {label}", value) for name, label, value in review_rows], (5.5, 11)
|
||||||
|
)
|
||||||
doc.add_heading("执行步骤与证据", 1)
|
doc.add_heading("执行步骤与证据", 1)
|
||||||
for step in report["steps"]:
|
for step in report["steps"]:
|
||||||
paragraph = doc.add_paragraph()
|
paragraph = doc.add_paragraph()
|
||||||
@@ -457,6 +719,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]] = []
|
||||||
@@ -573,6 +844,10 @@ def excel_report(report: dict, path: Path):
|
|||||||
wb.active.title = "任务结果"
|
wb.active.title = "任务结果"
|
||||||
sheet_rows(wb.active, [("项目", "内容"), *metadata(report)], (22, 82))
|
sheet_rows(wb.active, [("项目", "内容"), *metadata(report)], (22, 82))
|
||||||
sheet_rows(wb.create_sheet("结论"), conclusion_rows(report["content"]), (15, 96))
|
sheet_rows(wb.create_sheet("结论"), conclusion_rows(report["content"]), (15, 96))
|
||||||
|
if review_rows := extension_rows(report):
|
||||||
|
sheet_rows(
|
||||||
|
wb.create_sheet("人工评审"), [("插件", "项目", "内容"), *review_rows], (24, 30, 82)
|
||||||
|
)
|
||||||
table_number = 0
|
table_number = 0
|
||||||
for kind, rows in markdown_blocks(report["content"]):
|
for kind, rows in markdown_blocks(report["content"]):
|
||||||
if kind == "table":
|
if kind == "table":
|
||||||
@@ -597,12 +872,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 = []
|
||||||
@@ -610,9 +909,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
|
||||||
|
|
||||||
@@ -630,6 +933,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
|
||||||
|
|
||||||
|
|
||||||
@@ -642,8 +969,7 @@ def session_videos(report: dict) -> list[tuple[Path, str]]:
|
|||||||
return []
|
return []
|
||||||
source = Path(str(candidate)).resolve()
|
source = Path(str(candidate)).resolve()
|
||||||
trace_root = TRACES_PATH.resolve()
|
trace_root = TRACES_PATH.resolve()
|
||||||
# Workers save recordings in their finalized web_<time>_<id>_PASS/FAIL
|
# The recording row already ties the finalized trace folder to this session.
|
||||||
# folder. The recording row already ties that path to this session.
|
|
||||||
if not source.is_relative_to(trace_root):
|
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
|
||||||
@@ -664,54 +990,137 @@ 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, progress: Callable[[int, int, str], None] | None = None) -> 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("批次不存在")
|
||||||
directory = EXPORT_ROOT / "batches" / batch_id
|
directory = EXPORT_ROOT / "batches" / batch_id
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
formats = batch["formats"]
|
formats = batch["formats"]
|
||||||
|
with task_extensions.plugin_store().mutation_lock:
|
||||||
|
signature = batch_archive_signature(batch_id)
|
||||||
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:
|
if progress:
|
||||||
progress(0, len(records), "正在生成批次汇总")
|
progress(0, len(records), "正在生成批次汇总")
|
||||||
@@ -726,6 +1135,17 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
|
|||||||
assert wb.active is not None
|
assert wb.active is not None
|
||||||
wb.active.title = "批次结果"
|
wb.active.title = "批次结果"
|
||||||
sheet_rows(wb.active, rows, (40, 48, 16, 28, 70))
|
sheet_rows(wb.active, rows, (40, 48, 16, 28, 70))
|
||||||
|
review_rows = [
|
||||||
|
(record["id"], record["name"], *row)
|
||||||
|
for record in records
|
||||||
|
for row in extension_rows(record)
|
||||||
|
]
|
||||||
|
if review_rows:
|
||||||
|
sheet_rows(
|
||||||
|
wb.create_sheet("人工评审"),
|
||||||
|
[("任务编号", "任务名称", "插件", "项目", "内容"), *review_rows],
|
||||||
|
(40, 40, 24, 30, 70),
|
||||||
|
)
|
||||||
path = directory / "批次结果.xlsx"
|
path = directory / "批次结果.xlsx"
|
||||||
wb.save(path)
|
wb.save(path)
|
||||||
outputs.append(path)
|
outputs.append(path)
|
||||||
@@ -736,6 +1156,15 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
|
|||||||
f"批次编号:{batch_id};共 {len(records)} 个任务。验证通过与执行完成分别统计。"
|
f"批次编号:{batch_id};共 {len(records)} 个任务。验证通过与执行完成分别统计。"
|
||||||
)
|
)
|
||||||
word_table(doc, rows, (3.5, 5.4, 2.5, 3.5, 7.3), header=True)
|
word_table(doc, rows, (3.5, 5.4, 2.5, 3.5, 7.3), header=True)
|
||||||
|
for record in records:
|
||||||
|
if review_rows := extension_rows(record):
|
||||||
|
doc.add_heading("人工评审 · " + record["name"], 1)
|
||||||
|
doc.add_paragraph("任务编号:" + record["id"])
|
||||||
|
word_table(
|
||||||
|
doc,
|
||||||
|
[(f"{name} · {label}", value) for name, label, value in review_rows],
|
||||||
|
(7, 15.2),
|
||||||
|
)
|
||||||
path = directory / "批次汇总.docx"
|
path = directory / "批次汇总.docx"
|
||||||
doc.save(str(path))
|
doc.save(str(path))
|
||||||
outputs.append(path)
|
outputs.append(path)
|
||||||
@@ -743,20 +1172,56 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
|
|||||||
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):
|
||||||
if progress:
|
if progress:
|
||||||
progress(number - 1, len(records), f"正在打包任务 {number}/{len(records)}")
|
progress(number - 1, len(records), f"正在打包任务 {number}/{len(records)}")
|
||||||
prefix = f"任务报告/{number:04d}_{archive_case_name(report)}"
|
prefix = f"任务报告/{number:04d}_{archive_case_name(report)}"
|
||||||
for path in export_session(report["id"], formats):
|
images = session_screenshots(report)
|
||||||
archive.write(path, f"{prefix}/{path.name}")
|
videos = session_videos(report)
|
||||||
for source, name in session_screenshots(report):
|
artifacts = session_artifacts(report)
|
||||||
archive.write(source, f"{prefix}/步骤截图/{name}")
|
extras = plugin_files(report["id"])
|
||||||
for source, name in session_videos(report):
|
for path in export_session(report["id"], formats, report=report):
|
||||||
archive.write(source, f"{prefix}/任务录屏/{name}")
|
archive.write(path, f"{prefix}/{path.name}", compress_type=compression(path))
|
||||||
|
for source, name in images:
|
||||||
|
archive.write(
|
||||||
|
source, f"{prefix}/步骤截图/{name}", compress_type=compression(source)
|
||||||
|
)
|
||||||
|
for source, name in videos:
|
||||||
|
archive.write(
|
||||||
|
source, f"{prefix}/任务录屏/安卓/{name}", compress_type=zipfile.ZIP_STORED
|
||||||
|
)
|
||||||
|
for source, name in artifacts:
|
||||||
|
archive.write(
|
||||||
|
source, f"{prefix}/任务文件/安卓/{name}", compress_type=compression(source)
|
||||||
|
)
|
||||||
|
for source, name in extras:
|
||||||
|
archive.write(
|
||||||
|
source,
|
||||||
|
f"{prefix}/{name}",
|
||||||
|
compress_type=compression(source),
|
||||||
|
)
|
||||||
|
archive.writestr(f"{prefix}/模型请求诊断.json", diagnostics_json(report["id"]))
|
||||||
|
archive.writestr(
|
||||||
|
f"{prefix}/证据索引.json",
|
||||||
|
json.dumps(
|
||||||
|
export_index(
|
||||||
|
report["id"],
|
||||||
|
report,
|
||||||
|
videos,
|
||||||
|
images=images,
|
||||||
|
artifacts=artifacts,
|
||||||
|
extras=extras,
|
||||||
|
),
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
default=str,
|
||||||
|
),
|
||||||
|
)
|
||||||
if progress:
|
if progress:
|
||||||
progress(number, len(records), f"已打包任务 {number}/{len(records)}")
|
progress(number, len(records), f"已打包任务 {number}/{len(records)}")
|
||||||
if progress:
|
if progress:
|
||||||
progress(len(records), len(records), "正在完成压缩包")
|
progress(len(records), len(records), "正在完成压缩包")
|
||||||
temporary.replace(target)
|
temporary.replace(target)
|
||||||
|
remember(target, signature)
|
||||||
return target
|
return target
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""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",
|
||||||
|
"request_sent_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": ["探索对话", "用例版本", "探索执行历史及证据"],
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""Human-authored task metadata owned by the host, scoped to installed plugins."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from apps.admin_console.services.automation_store import AutomationStore, identifier
|
||||||
|
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||||
|
from artemis.plugins.models import PluginManifest
|
||||||
|
from artemis.plugins.store import PluginStore
|
||||||
|
from apps.admin_console.services.task_import import case_title
|
||||||
|
|
||||||
|
TERMINAL = {"completed", "success", "failed", "cancelled", "interrupted", "skipped"}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def plugin_store() -> PluginStore:
|
||||||
|
return PluginStore()
|
||||||
|
|
||||||
|
|
||||||
|
def active_manifests() -> list[PluginManifest]:
|
||||||
|
return [
|
||||||
|
PluginManifest.model_validate(plugin["manifest"])
|
||||||
|
for plugin in plugin_store().list()
|
||||||
|
if plugin["enabled"] and plugin["manifest"].get("workspace")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def installation_signature() -> str:
|
||||||
|
manifests = [manifest.model_dump(mode="json") for manifest in active_manifests()]
|
||||||
|
return (
|
||||||
|
hashlib.sha256(json.dumps(manifests, sort_keys=True).encode()).hexdigest()
|
||||||
|
if manifests
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reviewed_batch_sessions(batch_id: str, store: AutomationStore) -> set[str]:
|
||||||
|
"""Read current human review values once per batch, without loading history."""
|
||||||
|
manifest = next((item for item in active_manifests() if item.id == "task-review"), None)
|
||||||
|
if manifest is None or manifest.workspace is None:
|
||||||
|
return set()
|
||||||
|
with store.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT j.session_id, json_extract(r.payload, '$."task-review".values') AS review_values
|
||||||
|
FROM automation_jobs j JOIN automation_records r
|
||||||
|
ON r.kind='task_extensions' AND r.id=j.session_id
|
||||||
|
WHERE j.batch_id=?""",
|
||||||
|
(batch_id,),
|
||||||
|
).fetchall()
|
||||||
|
reviewed = set()
|
||||||
|
for row in rows:
|
||||||
|
values = json.loads(row["review_values"] or "{}")
|
||||||
|
if any(
|
||||||
|
isinstance(value := values.get(field.id), str)
|
||||||
|
and value.strip()
|
||||||
|
and (field.kind == "text" or value in field.options)
|
||||||
|
for field in manifest.workspace.fields
|
||||||
|
):
|
||||||
|
reviewed.add(row["session_id"])
|
||||||
|
return reviewed
|
||||||
|
|
||||||
|
|
||||||
|
def task_context(session_id: str, store: AutomationStore) -> dict:
|
||||||
|
identifier(session_id)
|
||||||
|
session = SessionRepository(store.path).get_session_by_id(session_id) or {}
|
||||||
|
job = store.job(session_id) or {}
|
||||||
|
if not session and not job:
|
||||||
|
raise ValueError("任务记录不存在")
|
||||||
|
case = job.get("case") or {}
|
||||||
|
navigation = None
|
||||||
|
if batch_id := job.get("batch_id"):
|
||||||
|
jobs = [item for item in store.jobs(batch_id) if item.get("status") in TERMINAL]
|
||||||
|
ids = [item["session_id"] for item in jobs]
|
||||||
|
index = ids.index(session_id) if session_id in ids else -1
|
||||||
|
navigation = {
|
||||||
|
"scope": "batch",
|
||||||
|
"position": index + 1,
|
||||||
|
"total": len(ids),
|
||||||
|
"previous": ids[index - 1] if index > 0 else None,
|
||||||
|
"next": ids[index + 1] if 0 <= index < len(ids) - 1 else None,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"initial_prompt": session.get("initial_goal") or (job.get("case") or {}).get("goal", ""),
|
||||||
|
"editable": (job.get("status") or session.get("status")) in TERMINAL,
|
||||||
|
"case_title": case_title(case),
|
||||||
|
"original_case": case.get("original_case"),
|
||||||
|
"navigation": navigation,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def contributions(session_id: str, store: AutomationStore) -> list[dict]:
|
||||||
|
record = store.get("task_extensions", session_id) or {}
|
||||||
|
result = []
|
||||||
|
for manifest in active_manifests():
|
||||||
|
assert manifest.workspace is not None
|
||||||
|
saved = record.get(manifest.id) or {}
|
||||||
|
values = saved.get("values", {})
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"plugin_id": manifest.id,
|
||||||
|
"name": manifest.name,
|
||||||
|
"version": manifest.version,
|
||||||
|
"workspace": manifest.workspace.model_dump(),
|
||||||
|
"revision": saved.get("revision", 0),
|
||||||
|
"values": {
|
||||||
|
field.id: values.get(field.id, "") for field in manifest.workspace.fields
|
||||||
|
},
|
||||||
|
"history": saved.get("history", []),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def report_sections(session_id: str, store: AutomationStore) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"plugin_id": item["plugin_id"],
|
||||||
|
"name": item["name"],
|
||||||
|
"version": item["version"],
|
||||||
|
"revision": item["revision"],
|
||||||
|
"fields": [
|
||||||
|
{"id": field["id"], "label": field["label"], "value": item["values"][field["id"]]}
|
||||||
|
for field in item["workspace"]["fields"]
|
||||||
|
if field["report"]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for item in contributions(session_id, store)
|
||||||
|
if any(field["report"] for field in item["workspace"]["fields"])
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def save_values(
|
||||||
|
store: AutomationStore,
|
||||||
|
session_id: str,
|
||||||
|
manifest: PluginManifest,
|
||||||
|
values: dict[str, str],
|
||||||
|
expected_revision: int,
|
||||||
|
request_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""CAS + request receipts prevent lost edits and repeated history entries."""
|
||||||
|
identifier(session_id)
|
||||||
|
assert manifest.workspace is not None
|
||||||
|
fields = {field.id: field for field in manifest.workspace.fields}
|
||||||
|
if values.keys() - fields.keys():
|
||||||
|
raise ValueError("包含未声明的扩展字段")
|
||||||
|
for key, value in values.items():
|
||||||
|
field = fields[key]
|
||||||
|
if len(value) > field.max_length or (
|
||||||
|
field.kind == "choice" and value and value not in field.options
|
||||||
|
):
|
||||||
|
raise ValueError(f"字段 {field.label} 的内容不符合插件声明")
|
||||||
|
now = time.time()
|
||||||
|
with store.connection() as conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='task_extensions' AND id=?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
record = json.loads(row[0]) if row else {}
|
||||||
|
saved = record.get(manifest.id, {"revision": 0, "values": {}, "history": []})
|
||||||
|
for entry in saved["history"]:
|
||||||
|
if entry["request_id"] == request_id:
|
||||||
|
if entry["patch"] != values:
|
||||||
|
raise ValueError("同一保存请求不能提交不同内容")
|
||||||
|
return
|
||||||
|
if saved["revision"] != expected_revision:
|
||||||
|
raise ValueError("评审已在其他页面修改,请重新加载后保存")
|
||||||
|
updated = {**saved["values"], **values}
|
||||||
|
revision = saved["revision"] + 1
|
||||||
|
saved["history"].append(
|
||||||
|
{
|
||||||
|
"revision": revision,
|
||||||
|
"saved_at": now,
|
||||||
|
"request_id": request_id,
|
||||||
|
"patch": values,
|
||||||
|
"values": updated,
|
||||||
|
"plugin_version": manifest.version,
|
||||||
|
"fields": [field.model_dump() for field in manifest.workspace.fields],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
record[manifest.id] = {**saved, "revision": revision, "values": updated}
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO automation_records VALUES ('task_extensions',?,?,?) "
|
||||||
|
"ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at",
|
||||||
|
(session_id, json.dumps(record, ensure_ascii=False), now),
|
||||||
|
)
|
||||||
|
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), now, job[0]),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
@@ -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 = (
|
||||||
"用例编号",
|
"用例编号",
|
||||||
@@ -19,6 +21,7 @@ HEADERS = (
|
|||||||
"Artemis提示词",
|
"Artemis提示词",
|
||||||
"任务配置(JSON)",
|
"任务配置(JSON)",
|
||||||
)
|
)
|
||||||
|
CASE_HEADERS = ("预置条件", "操作步骤", "预期结果")
|
||||||
MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000"))
|
MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000"))
|
||||||
MAX_BYTES = int(os.getenv("ARTEMIS_IMPORT_MAX_BYTES", "10485760"))
|
MAX_BYTES = int(os.getenv("ARTEMIS_IMPORT_MAX_BYTES", "10485760"))
|
||||||
|
|
||||||
@@ -37,6 +40,7 @@ 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)
|
||||||
@@ -47,6 +51,24 @@ class TaskOptions(BaseModel):
|
|||||||
device_command_timeout_seconds: float | None = Field(default=None, gt=0, le=60)
|
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)
|
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)
|
worker_cleanup_timeout_seconds: float | None = Field(default=None, gt=0, le=300)
|
||||||
|
plugins: list[PluginSelection] | None = None
|
||||||
|
execution_target: ExecutionTarget | None = Field(
|
||||||
|
default=None, description="android (default), computer (PC only, no Android), or mixed"
|
||||||
|
)
|
||||||
|
|
||||||
|
@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 OriginalCase(BaseModel):
|
||||||
|
"""Imported review material, never appended to the execution prompt."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
preconditions: str = Field(default="", max_length=10000)
|
||||||
|
steps: str = Field(default="", max_length=10000)
|
||||||
|
expected: str = Field(default="", max_length=10000)
|
||||||
|
|
||||||
|
|
||||||
class TaskCase(BaseModel):
|
class TaskCase(BaseModel):
|
||||||
@@ -58,16 +80,29 @@ class TaskCase(BaseModel):
|
|||||||
preconditions: str = Field(default="", max_length=10000)
|
preconditions: str = Field(default="", max_length=10000)
|
||||||
goal: str = Field(min_length=1, max_length=10000)
|
goal: str = Field(min_length=1, max_length=10000)
|
||||||
task_config: TaskOptions = Field(default_factory=TaskOptions)
|
task_config: TaskOptions = Field(default_factory=TaskOptions)
|
||||||
|
original_case: OriginalCase | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def original_case_enabled() -> bool:
|
||||||
|
from apps.admin_console.services.task_extensions import active_manifests
|
||||||
|
|
||||||
|
return any(
|
||||||
|
panel.kind == "original_case"
|
||||||
|
for manifest in active_manifests()
|
||||||
|
if manifest.workspace is not None
|
||||||
|
for panel in manifest.workspace.panels
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def template() -> bytes:
|
def template() -> bytes:
|
||||||
|
headers = (*HEADERS, *CASE_HEADERS) if original_case_enabled() else HEADERS
|
||||||
wb = Workbook()
|
wb = Workbook()
|
||||||
ws = wb.active
|
ws = wb.active
|
||||||
assert ws is not None
|
assert ws is not None
|
||||||
ws.title = "任务"
|
ws.title = "任务"
|
||||||
ws.append(HEADERS)
|
ws.append(headers)
|
||||||
ws.freeze_panes = "A2"
|
ws.freeze_panes = "A2"
|
||||||
ws.auto_filter.ref = "A1:G1"
|
ws.auto_filter.ref = f"A1:{get_column_letter(len(headers))}1"
|
||||||
for cell in ws[1]:
|
for cell in ws[1]:
|
||||||
cell.font = Font(bold=True, color="FFFFFF")
|
cell.font = Font(bold=True, color="FFFFFF")
|
||||||
cell.fill = PatternFill("solid", fgColor="245C78")
|
cell.fill = PatternFill("solid", fgColor="245C78")
|
||||||
@@ -81,7 +116,7 @@ def template() -> bytes:
|
|||||||
help_sheet.append(["前置约束提取及其他描述列仅作查看、搜索和报告元数据,不追加到提示词。"])
|
help_sheet.append(["前置约束提取及其他描述列仅作查看、搜索和报告元数据,不追加到提示词。"])
|
||||||
help_sheet.append(["任务配置(JSON)可留空,空字段继承批次或全局配置;禁止密钥与未知字段。"])
|
help_sheet.append(["任务配置(JSON)可留空,空字段继承批次或全局配置;禁止密钥与未知字段。"])
|
||||||
help_sheet.append(["示例仅供参考,不会导入执行。请在“任务”页填写自己的用例。"])
|
help_sheet.append(["示例仅供参考,不会导入执行。请在“任务”页填写自己的用例。"])
|
||||||
help_sheet.append(HEADERS)
|
help_sheet.append(headers)
|
||||||
help_sheet.append(
|
help_sheet.append(
|
||||||
[
|
[
|
||||||
"示例-001",
|
"示例-001",
|
||||||
@@ -93,7 +128,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,
|
||||||
@@ -107,7 +142,29 @@ def template() -> bytes:
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
for column in range(1, 8):
|
help_sheet.append(
|
||||||
|
[
|
||||||
|
"示例-PC-001",
|
||||||
|
"电脑桌面",
|
||||||
|
"浏览器检查",
|
||||||
|
"仅 PC 测试",
|
||||||
|
"已启用并授权提供画面观察的电脑插件,无需手机或模拟器在线",
|
||||||
|
"打开电脑浏览器,访问指定网页并核对实际结果。",
|
||||||
|
json.dumps({"profile": "flash", "execution_target": "computer"}, ensure_ascii=False),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if len(headers) > len(HEADERS):
|
||||||
|
help_sheet.append(
|
||||||
|
[
|
||||||
|
"人工评审插件附加列:预置条件、操作步骤、预期结果。仅保存原始用例,均不追加到 AI 提示词。"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for row_number in (6, 7):
|
||||||
|
for column, value in enumerate(
|
||||||
|
("填写原始用例的预置条件", "填写原始用例的操作步骤", "填写原始用例的预期结果"), 8
|
||||||
|
):
|
||||||
|
help_sheet.cell(row_number, column, value)
|
||||||
|
for column in range(1, len(headers) + 1):
|
||||||
help_sheet.column_dimensions[get_column_letter(column)].width = 70 if column > 5 else 26
|
help_sheet.column_dimensions[get_column_letter(column)].width = 70 if column > 5 else 26
|
||||||
for row in help_sheet:
|
for row in help_sheet:
|
||||||
for cell in row:
|
for cell in row:
|
||||||
@@ -141,7 +198,10 @@ def parse_workbook(data: bytes) -> dict:
|
|||||||
raise ValueError("缺少“任务”工作表,请使用下载的模板")
|
raise ValueError("缺少“任务”工作表,请使用下载的模板")
|
||||||
rows = iter(wb["任务"].iter_rows())
|
rows = iter(wb["任务"].iter_rows())
|
||||||
headers = [str(cell.value or "").strip() for cell in next(rows, [])]
|
headers = [str(cell.value or "").strip() for cell in next(rows, [])]
|
||||||
if headers != list(HEADERS):
|
supported_headers = [list(HEADERS)]
|
||||||
|
if original_case_enabled():
|
||||||
|
supported_headers.append([*HEADERS, *CASE_HEADERS])
|
||||||
|
if headers not in supported_headers:
|
||||||
raise ValueError("列名或顺序与新版模板不一致,请重新下载模板")
|
raise ValueError("列名或顺序与新版模板不一致,请重新下载模板")
|
||||||
tasks, errors = [], []
|
tasks, errors = [], []
|
||||||
for number, row in enumerate(rows, 2):
|
for number, row in enumerate(rows, 2):
|
||||||
@@ -167,9 +227,29 @@ def parse_workbook(data: bytes) -> dict:
|
|||||||
"preconditions": values[4],
|
"preconditions": values[4],
|
||||||
"goal": values[5],
|
"goal": values[5],
|
||||||
"task_config": config,
|
"task_config": config,
|
||||||
|
"original_case": (
|
||||||
|
dict(
|
||||||
|
zip(
|
||||||
|
("preconditions", "steps", "expected"),
|
||||||
|
(
|
||||||
|
str(cell.value if cell.value is not None else "").strip()
|
||||||
|
for cell in row[7:10]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(headers) == 10
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
tasks.append(
|
||||||
|
{
|
||||||
|
"row": number,
|
||||||
|
**case.model_dump(
|
||||||
|
exclude={"original_case"} if case.original_case is None else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
tasks.append({"row": number, **case.model_dump()})
|
|
||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError) as exc:
|
||||||
errors.append({"row": number, "message": str(exc)[:500]})
|
errors.append({"row": number, "message": str(exc)[:500]})
|
||||||
if not tasks and not errors:
|
if not tasks and not errors:
|
||||||
@@ -189,3 +269,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})
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""One read-only lifecycle view for web, MCP and exported reports."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from artemis.config import DB_PATH
|
||||||
|
from artemis.runtime.task_control import list_instructions, read_state
|
||||||
|
from artemis.runtime.task_progress import progress_snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def _records(session_id: str, path: Path):
|
||||||
|
if not path.is_file():
|
||||||
|
return None, None, []
|
||||||
|
with sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
tables = {
|
||||||
|
row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
}
|
||||||
|
session = (
|
||||||
|
conn.execute("SELECT * FROM sessions WHERE session_id=?", (session_id,)).fetchone()
|
||||||
|
if "sessions" in tables
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
record = (
|
||||||
|
conn.execute(
|
||||||
|
"SELECT payload FROM automation_jobs WHERE session_id=?", (session_id,)
|
||||||
|
).fetchone()
|
||||||
|
if "automation_jobs" in tables
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
conn.execute(
|
||||||
|
"SELECT payload FROM traces WHERE session_id=? AND type='final_review_diagnostic'",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
if "traces" in tables
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
return session, record, rows
|
||||||
|
|
||||||
|
|
||||||
|
def task_lifecycle(session_id: str, *, db_path=None, now=None) -> dict:
|
||||||
|
session, record, rows = _records(session_id, Path(db_path) if db_path else DB_PATH)
|
||||||
|
try:
|
||||||
|
job = json.loads(record[0]) if record else {}
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
job = {}
|
||||||
|
reviews = []
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
value = json.loads(row[0])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if isinstance(value, dict):
|
||||||
|
reviews.append(value)
|
||||||
|
session = dict(session) if session else {}
|
||||||
|
control = read_state(session_id)
|
||||||
|
if session.get("pid") and control.get("pid") != session["pid"]:
|
||||||
|
control = {}
|
||||||
|
value = progress_snapshot(
|
||||||
|
job=job if isinstance(job, dict) else {},
|
||||||
|
session=session,
|
||||||
|
control=control,
|
||||||
|
reviews=reviews,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
instructions = list_instructions(session_id, task_status=value["status"])
|
||||||
|
value["guidance_counts"] = {
|
||||||
|
status: sum(item["status"] == status for item in instructions)
|
||||||
|
for status in ("pending", "reading", "consumed", "unapplied", "unconfirmed")
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def guidance_details(session_id: str, *, db_path=None) -> dict:
|
||||||
|
lifecycle = task_lifecycle(session_id, db_path=db_path)
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"lifecycle": lifecycle,
|
||||||
|
"instructions": list_instructions(session_id, task_status=lifecycle["status"]),
|
||||||
|
}
|
||||||
@@ -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,12 +25,6 @@ import time
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
try:
|
|
||||||
from admin_console.core.state import state
|
|
||||||
from admin_console.database.repositories.session_repository import session_repo
|
|
||||||
from admin_console.services import worker_process_io
|
|
||||||
from admin_console.services.media_service import media_service
|
|
||||||
except ImportError:
|
|
||||||
from apps.admin_console.core.state import state
|
from apps.admin_console.core.state import state
|
||||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||||
from apps.admin_console.services import worker_process_io
|
from apps.admin_console.services import worker_process_io
|
||||||
@@ -52,6 +47,7 @@ from artemis.runtime import (
|
|||||||
request_cancel,
|
request_cancel,
|
||||||
trace_store,
|
trace_store,
|
||||||
)
|
)
|
||||||
|
from artemis.runtime.cancellation import CancellationReason
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -141,6 +137,7 @@ class TaskQueueService:
|
|||||||
pid=pid_int,
|
pid=pid_int,
|
||||||
process_created_at=created_at,
|
process_created_at=created_at,
|
||||||
reason=reason,
|
reason=reason,
|
||||||
|
cancel_reason=CancellationReason.USER_STOP,
|
||||||
)
|
)
|
||||||
if not written:
|
if not written:
|
||||||
return cls._hard_kill(pid_int, created_at), False
|
return cls._hard_kill(pid_int, created_at), False
|
||||||
@@ -275,6 +272,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
|
||||||
@@ -344,7 +346,7 @@ class TaskQueueService:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _concurrency_limit(cls) -> int:
|
def _concurrency_limit(cls) -> int:
|
||||||
"""Return 0 for per-device concurrency, or the global task limit."""
|
"""Return the shared global single-task limit, including computer targets."""
|
||||||
return DeviceExecutionLock.resolve_env_concurrency()
|
return DeviceExecutionLock.resolve_env_concurrency()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -352,10 +354,8 @@ class TaskQueueService:
|
|||||||
"""Persistent dispatcher scheduling pending tasks onto devices.
|
"""Persistent dispatcher scheduling pending tasks onto devices.
|
||||||
|
|
||||||
Scans the queue and launches each eligible task as an independent
|
Scans the queue and launches each eligible task as an independent
|
||||||
coroutine. Admission is governed by _concurrency_limit(): 0 admits one
|
coroutine. Admission allows one executing task across all targets.
|
||||||
task per device so distinct devices execute concurrently, N>=1 admits
|
Global FIFO ordering and cross-process mutual exclusion are enforced by DeviceExecutionLock
|
||||||
at most N tasks across all devices. Per-device FIFO ordering and
|
|
||||||
cross-process mutual exclusion remain enforced by DeviceExecutionLock
|
|
||||||
inside each worker process.
|
inside each worker process.
|
||||||
"""
|
"""
|
||||||
print(
|
print(
|
||||||
@@ -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,11 @@ 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:
|
||||||
|
if limit == 1:
|
||||||
|
return
|
||||||
|
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
|
||||||
@@ -443,9 +451,12 @@ class TaskQueueService:
|
|||||||
continue
|
continue
|
||||||
elif limit >= 1 and device is not None and target.lock_key in busy_devices:
|
elif limit >= 1 and device is not None and target.lock_key in busy_devices:
|
||||||
# A second worker for this device would wait on its lock.
|
# A second worker for this device would wait on its lock.
|
||||||
|
if limit == 1:
|
||||||
|
return
|
||||||
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)
|
||||||
@@ -511,7 +522,11 @@ class TaskQueueService:
|
|||||||
|
|
||||||
job = store.job(str(sess_id)) if sess_id else None
|
job = store.job(str(sess_id)) if sess_id else None
|
||||||
if job:
|
if job:
|
||||||
job.update(status="running", started_at=time.time())
|
job.update(
|
||||||
|
status="running",
|
||||||
|
started_at=task_item["start_time"],
|
||||||
|
worker_started_at=task_item["start_time"],
|
||||||
|
)
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
|
|
||||||
# A fresh launch clears a stale stop request left over for this run
|
# A fresh launch clears a stale stop request left over for this run
|
||||||
@@ -572,7 +587,24 @@ 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"
|
||||||
|
# Capture the global Flash choice at launch, including saved favorites.
|
||||||
|
# It is metadata in the snapshot, never a favorite's runtime override.
|
||||||
|
flash_final_check = (task_item.get("execution_config") or {}).get(
|
||||||
|
"flash_final_check_enabled"
|
||||||
|
)
|
||||||
|
if profile == "flash" and flash_final_check is not None:
|
||||||
|
env["ARTEMIS_FLASH_FINAL_CHECK_ENABLED"] = str(flash_final_check).lower()
|
||||||
|
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",
|
||||||
@@ -593,6 +625,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:
|
||||||
@@ -623,7 +660,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
|
||||||
@@ -647,6 +684,11 @@ class TaskQueueService:
|
|||||||
batch = store.get("batch", job["batch_id"]) if job and job.get("batch_id") else None
|
batch = store.get("batch", job["batch_id"]) if job and job.get("batch_id") else None
|
||||||
limit = task_timeout(task_item, job, batch)
|
limit = task_timeout(task_item, job, batch)
|
||||||
budget = ExecutionBudget(limit)
|
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(
|
cleanup_limit = (task_item.get("runtime_overrides") or {}).get(
|
||||||
"worker_cleanup_timeout_seconds"
|
"worker_cleanup_timeout_seconds"
|
||||||
) or settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS
|
) or settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS
|
||||||
@@ -659,6 +701,9 @@ class TaskQueueService:
|
|||||||
return waiter.result()
|
return waiter.result()
|
||||||
outcome = read_worker_outcome(sid)
|
outcome = read_worker_outcome(sid)
|
||||||
if outcome:
|
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()
|
finalized_at = finalized_at if finalized_at is not None else time.monotonic()
|
||||||
if time.monotonic() - finalized_at >= cleanup_limit:
|
if time.monotonic() - finalized_at >= cleanup_limit:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -670,6 +715,9 @@ class TaskQueueService:
|
|||||||
return await waiter
|
return await waiter
|
||||||
continue # Final result is authoritative; only bound resource cleanup now.
|
continue # Final result is authoritative; only bound resource cleanup now.
|
||||||
cls._sync_control_states()
|
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, {})
|
run = state.active_runs.get(run_key, {})
|
||||||
remaining = budget.update(
|
remaining = budget.update(
|
||||||
excluded=bool(run.get("parked")) or read_state(sid).get("status") == "queued"
|
excluded=bool(run.get("parked")) or read_state(sid).get("status") == "queued"
|
||||||
@@ -851,6 +899,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":
|
||||||
@@ -996,6 +1048,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(
|
||||||
@@ -1051,8 +1106,34 @@ class TaskQueueService:
|
|||||||
f"[QueueWorker] Could not persist failure status for [{sess_id}]"
|
f"[QueueWorker] Could not persist failure status for [{sess_id}]"
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
try:
|
||||||
await cls._finish_output_forwarder(output_task)
|
await cls._finish_output_forwarder(output_task)
|
||||||
# 5. Clean up the finished task and release this run's scheduling slot
|
if sess_id:
|
||||||
|
from artemis.exploration.store import explorations
|
||||||
|
from artemis.runtime.task_result import read_worker_outcome
|
||||||
|
|
||||||
|
exploration_run = explorations.run(str(sess_id))
|
||||||
|
if exploration_run and exploration_run.get("outcome") is None:
|
||||||
|
from artemis.runtime.case_result import read_case_result
|
||||||
|
|
||||||
|
outcome = read_worker_outcome(str(sess_id)) or {}
|
||||||
|
session = session_repo.get_session_by_id(str(sess_id)) or {}
|
||||||
|
status = (
|
||||||
|
outcome.get("task_status") or session.get("status") or "interrupted"
|
||||||
|
)
|
||||||
|
explorations.finish_run(
|
||||||
|
str(sess_id),
|
||||||
|
{
|
||||||
|
**outcome,
|
||||||
|
"status": status,
|
||||||
|
"test_result": outcome.get("test_result")
|
||||||
|
or read_case_result(str(sess_id), status),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Cannot finalize exploration metadata for %s", sess_id)
|
||||||
|
finally:
|
||||||
|
# Metadata/storage failure must never leak a scheduling slot.
|
||||||
cls._release_run_slot(sess_id, run_key, proc)
|
cls._release_run_slot(sess_id, run_key, proc)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1237,11 +1318,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
|
||||||
@@ -1250,6 +1360,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,
|
||||||
@@ -1270,6 +1395,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)
|
||||||
@@ -1380,15 +1519,19 @@ 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"] in {"running", "paused"}:
|
||||||
batch.update(status="paused", reason="已停止全部任务,等待手动继续")
|
batch.update(
|
||||||
|
status="paused", reason="已停止全部任务,等待手动继续", pause_source="user"
|
||||||
|
)
|
||||||
store.put("batch", batch["id"], batch)
|
store.put("batch", batch["id"], batch)
|
||||||
# 1. Cancel local queue reservations
|
# 1. Cancel local queue reservations
|
||||||
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()
|
||||||
@@ -1712,7 +1855,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:
|
||||||
@@ -1826,6 +1971,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:
|
||||||
@@ -28,12 +30,20 @@ npm run build
|
|||||||
|
|
||||||
## Local Conventions
|
## Local Conventions
|
||||||
|
|
||||||
|
- 发版遵守根指南的 Release Gates。前端应用版本只改 package.json 顶层 `version` 和 package-lock.json 顶层及 `packages[""].version`;禁止把相同数字的第三方依赖一并替换。
|
||||||
|
- Node 支持范围以锁定的 Angular CLI/构建依赖实际 `engines` 为准,核对启动脚本的主、次、补丁版本边界和提示;不要只判断“Node 22/24/26”。
|
||||||
|
- 安装或依赖变更需在隔离目录、独立空 npm 缓存下运行 `npm ci`,再运行上述测试和构建;国内入口同时核验镜像安装。不得以关闭完整性校验、强制安装或删除锁文件掩盖不一致,也不得用已装依赖的构建结果宣称全新安装通过。
|
||||||
- 页面组件在 src/app/components,服务在 src/app/services;先复用已有组件、样式及 API 服务。
|
- 页面组件在 src/app/components,服务在 src/app/services;先复用已有组件、样式及 API 服务。
|
||||||
- 保持现有简体中文文案、颜色、间距和控件样式,菜单、弹窗和表单必须清晰可读。
|
- 保持现有简体中文文案、颜色、间距和控件样式,菜单、弹窗和表单必须清晰可读。
|
||||||
- 修改任务或配置交互时覆盖加载、保存、失败和空状态;不要将执行中误标成录屏中或把完成误标成验证通过。
|
- 修改任务或配置交互时覆盖加载、保存、失败和空状态;不要将执行中误标成录屏中或把完成误标成验证通过。
|
||||||
- 使用相邻的 .spec.ts 测试交互和请求数据;不要只测试实现细节。
|
- 使用相邻的 .spec.ts 测试交互和请求数据;不要只测试实现细节。
|
||||||
- 后端字段调整需同时核对类型、请求和响应;设备序列号、报告格式、批次编号等不能在前端猜测。
|
- 后端字段调整需同时核对类型、请求和响应;设备序列号、报告格式、批次编号等不能在前端猜测。
|
||||||
- UI 有明显变化时在浏览器验证实际布局和关键操作,构建通过不能代替可读性检查。
|
- UI 有明显变化时在浏览器验证实际布局和关键操作,构建通过不能代替可读性检查。
|
||||||
|
- 工作区底部输入框服务当前任务;探索建议只填入草稿,用户发送后再提交。用例编辑和配置按需展开,复跑固定保存版本。
|
||||||
|
- 主模型、兜底模型及精选任务复用现有模型选择组件;收藏使用已保存的独立配置,设备离线时给出可操作的选择提示。
|
||||||
|
- 星标仅占标题区域,后续状态行正常左对齐;人工示范来源与按钮保持等高、居中,窄屏可换行。执行、等待回答、示范准备/录制/分析、最终验收及未验证各有明确文字。
|
||||||
|
- 电脑步骤使用电脑证据,录像缺口明确展示。播放器按真实来源和时间定位,跳转保留原播放/暂停状态。
|
||||||
|
- 列表后台刷新保留选中项、滚动及折叠状态,不用反复插入的加载行挤动布局;实时连接沿用现有共享机制和页面可见性管理。
|
||||||
|
|
||||||
## Local Risks
|
## Local Risks
|
||||||
|
|
||||||
|
|||||||
Generated
+1871
-2313
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.3.1",
|
"version": "2.4.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve --proxy-config proxy.conf.json",
|
"start": "ng serve --proxy-config proxy.conf.json",
|
||||||
@@ -9,23 +9,29 @@
|
|||||||
"test": "ng test"
|
"test": "ng test"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"overrides": {
|
||||||
|
"@angular/build": {
|
||||||
|
"vite": "8.3.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.1.5",
|
"@angular/cdk": "^22.2.4",
|
||||||
"@angular/common": "^22.1.4",
|
"@angular/common": "^22.2.3",
|
||||||
"@angular/compiler": "^22.1.4",
|
"@angular/compiler": "^22.2.3",
|
||||||
"@angular/core": "^22.1.4",
|
"@angular/core": "^22.2.3",
|
||||||
"@angular/forms": "^22.1.4",
|
"@angular/forms": "^22.2.3",
|
||||||
"@angular/platform-browser": "^22.1.4",
|
"@angular/platform-browser": "^22.2.3",
|
||||||
"@angular/platform-browser-dynamic": "^22.1.4",
|
"@angular/platform-browser-dynamic": "^22.2.3",
|
||||||
"@angular/router": "^22.1.4",
|
"@angular/router": "^22.2.3",
|
||||||
|
"marked": "18.1.1",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^22.1.6",
|
"@angular/build": "^22.2.4",
|
||||||
"@angular/cli": "^22.1.6",
|
"@angular/cli": "^22.2.4",
|
||||||
"@angular/compiler-cli": "^22.1.4",
|
"@angular/compiler-cli": "^22.2.3",
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
"istanbul-lib-instrument": "^6.0.3",
|
"istanbul-lib-instrument": "^6.0.3",
|
||||||
"jasmine-core": "~5.6.0",
|
"jasmine-core": "~5.6.0",
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 本地网页字体
|
||||||
|
|
||||||
|
这些字体作为静态资源随源码和网页构建产物分发,由 `src/styles.scss` 的 `@font-face` 引用。构建和页面显示不需要连接 Google Fonts;不要恢复外部字体样式表或预连接。
|
||||||
|
|
||||||
|
正文提供 Latin 字形,中文继续使用系统字体。Material Symbols 保留上游完整图标集和 `FILL`、`GRAD`、`opsz`、`wght` 四个可变轴,兼容现有连字名称及组件样式。
|
||||||
|
|
||||||
|
## 来源与许可证
|
||||||
|
|
||||||
|
| 文件 | 固定来源 | 许可证 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `inter-latin-wght-normal.woff2` | [@fontsource-variable/inter 5.3.0](https://www.npmjs.com/package/@fontsource-variable/inter/v/5.3.0),`files/inter-latin-wght-normal.woff2` | [SIL OFL 1.1](licenses/inter-OFL.txt) |
|
||||||
|
| `outfit-latin-wght-normal.woff2` | [@fontsource-variable/outfit 5.3.0](https://www.npmjs.com/package/@fontsource-variable/outfit/v/5.3.0),`files/outfit-latin-wght-normal.woff2` | [SIL OFL 1.1](licenses/outfit-OFL.txt) |
|
||||||
|
| `roboto-latin-wght-normal.woff2` | [@fontsource-variable/roboto 5.3.0](https://www.npmjs.com/package/@fontsource-variable/roboto/v/5.3.0),`files/roboto-latin-wght-normal.woff2` | [SIL OFL 1.1](licenses/roboto-OFL.txt) |
|
||||||
|
| `material-symbols-outlined.woff2` | Google [material-design-icons 的固定提交](https://github.com/google/material-design-icons/tree/10ecc14790612ca8fd164e3f4ffe9c16fc50d5b7/variablefont),`MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].woff2` | [Apache 2.0](licenses/material-symbols-outlined-Apache-2.0.txt) |
|
||||||
|
|
||||||
|
字体文件未经修改。更新时从固定版本获取,保留上游版权与许可证,并同步以下 SHA256;不要在安装或构建时下载字体。
|
||||||
|
|
||||||
|
| 文件 | SHA256 |
|
||||||
|
| --- | --- |
|
||||||
|
| `inter-latin-wght-normal.woff2` | `3100e775e8616cd2611beecfa23a4263d7037586789b43f035236a2e6fbd4c62` |
|
||||||
|
| `outfit-latin-wght-normal.woff2` | `6c18d579fd87c3776be068b762cbc83fde3acb543d49eabd3ade842eb987e887` |
|
||||||
|
| `roboto-latin-wght-normal.woff2` | `1404ca348bd75ef836f4dd8b6f2cc719458642d1237c368296b2fc652dca47dc` |
|
||||||
|
| `material-symbols-outlined.woff2` | `3ae08513a06396b0258cf1d1a1091c2d336977518fb755ea5b4a13aacdbdc6ef` |
|
||||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic) Roboto-Italic[wdth,wght].ttf: Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,41 @@
|
|||||||
|
// Shared markup appears in component innerHTML and in body-mounted overlays.
|
||||||
|
:is(.report-prose, .stream-text, .markdown-content) {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
|
||||||
|
a { color: var(--app-primary-text, #2563eb); text-decoration: underline; }
|
||||||
|
img { max-width: 100%; height: auto; border-radius: 8px; }
|
||||||
|
.md-table-scroll { max-width: 100%; overflow-x: auto; margin: 10px 0; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
th, td {
|
||||||
|
border: 1px solid var(--app-border-control, #cbd5e1);
|
||||||
|
padding: 7px 9px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
th { background: var(--app-primary-soft, #eff6ff); font-weight: 600; }
|
||||||
|
.md-table-scroll .md-align-left { text-align: left; }
|
||||||
|
.md-table-scroll .md-align-center { text-align: center; }
|
||||||
|
.md-table-scroll .md-align-right { text-align: right; }
|
||||||
|
pre { max-width: 100%; overflow-x: auto; white-space: pre; }
|
||||||
|
ul, ol { padding-left: 24px; }
|
||||||
|
li > ul, li > ol { margin: 4px 0; }
|
||||||
|
.md-task-item { list-style: none; }
|
||||||
|
.md-task-marker { display: inline-block; margin-left: -18px; margin-right: 4px; }
|
||||||
|
.md-task-marker.checked { color: var(--app-success-text, #047857); }
|
||||||
|
.md-task-marker.progress { color: var(--app-primary-text, #2563eb); }
|
||||||
|
.verify-badge, .finding-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 6px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--app-primary-soft, #eff6ff);
|
||||||
|
color: var(--app-primary-text, #1d4ed8);
|
||||||
|
}
|
||||||
|
.verify-badge.assert, .finding-badge {
|
||||||
|
background: var(--app-warning-soft, #fffbeb);
|
||||||
|
color: var(--app-warning-text, #92400e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
@use 'sass:color';
|
||||||
|
|
||||||
|
// Light values stay beside each existing declaration as fallbacks. These shared
|
||||||
|
// semantic overrides also reach CDK overlays mounted outside routed components.
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
@each $role, $value in (
|
||||||
|
canvas: #0b1220,
|
||||||
|
surface: #111c2e,
|
||||||
|
surface-soft: #172337,
|
||||||
|
surface-raised: #1e2c42,
|
||||||
|
text-strong: #f1f5f9,
|
||||||
|
text-control: #dbe4f0,
|
||||||
|
text-secondary: #bdcadd,
|
||||||
|
text-muted: #98aac1,
|
||||||
|
text-faint: #8da1bb,
|
||||||
|
border: #2b3b52,
|
||||||
|
border-control: #3d506a,
|
||||||
|
highlight: #24344d,
|
||||||
|
primary-text: #8ebcff,
|
||||||
|
primary-soft: #152e50,
|
||||||
|
primary-border: #375b83,
|
||||||
|
exploration-text: #b7b3ff,
|
||||||
|
exploration-soft: #29264b,
|
||||||
|
exploration-border: #514a83,
|
||||||
|
success-text: #6ee7b7,
|
||||||
|
success-soft: #103b32,
|
||||||
|
success-border: #24634d,
|
||||||
|
warning-text: #fcd089,
|
||||||
|
warning-soft: #3b2b16,
|
||||||
|
warning-border: #775728,
|
||||||
|
error-text: #ff9ca7,
|
||||||
|
error-soft: #421f2b,
|
||||||
|
error-border: #7d3d4e,
|
||||||
|
info-text: #83d8f5,
|
||||||
|
info-soft: #153747,
|
||||||
|
info-border: #316479
|
||||||
|
) {
|
||||||
|
--app-#{$role}: #{$value};
|
||||||
|
--app-#{$role}-rgb: #{color.channel($value, 'red', $space: rgb)} #{color.channel($value, 'green', $space: rgb)} #{color.channel($value, 'blue', $space: rgb)};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
background: var(--app-canvas, #f8f9fa);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--app-canvas, #f8f9fa);
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea, select {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--app-primary-border, #bfdbfe);
|
||||||
|
}
|
||||||
@@ -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: '' }
|
||||||
];
|
];
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { AgentStreamComponent } from './agent-stream.component';
|
||||||
|
|
||||||
|
describe('action card text selection', () => {
|
||||||
|
let component: AgentStreamComponent;
|
||||||
|
let header: HTMLDivElement;
|
||||||
|
let outside: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])]
|
||||||
|
});
|
||||||
|
component = TestBed.createComponent(AgentStreamComponent).componentInstance;
|
||||||
|
header = document.createElement('div');
|
||||||
|
header.textContent = '点击元素 Projects';
|
||||||
|
header.addEventListener('click', event => component.toggleActionExpanded('action-1', event));
|
||||||
|
outside = document.createElement('div');
|
||||||
|
outside.textContent = '其他步骤';
|
||||||
|
document.body.append(header, outside);
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
header.remove();
|
||||||
|
outside.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectText(element: HTMLElement): void {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(element);
|
||||||
|
window.getSelection()?.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps selected header text and the expanded card in place for copying', () => {
|
||||||
|
component.toggleActionExpanded('action-1');
|
||||||
|
selectText(header);
|
||||||
|
header.click();
|
||||||
|
expect(component.isActionExpanded('action-1')).toBeTrue();
|
||||||
|
expect(window.getSelection()?.toString()).toBe('点击元素 Projects');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands and collapses normally without selected text', () => {
|
||||||
|
header.click();
|
||||||
|
expect(component.isActionExpanded('action-1')).toBeTrue();
|
||||||
|
header.click();
|
||||||
|
expect(component.isActionExpanded('action-1')).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows another card to open while unrelated text is selected', () => {
|
||||||
|
selectText(outside);
|
||||||
|
header.click();
|
||||||
|
expect(component.isActionExpanded('action-1')).toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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:var(--app-text-muted, #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 }}
|
{{ taskPhaseLabel(session) || (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,156 +183,7 @@ 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 (parsedNote().title) {
|
<app-report-markdown [content]="currentNoteContent()" />
|
||||||
<h3 class="note-title">{{ parsedNote().title }}</h3>
|
|
||||||
}
|
|
||||||
<!-- Milestones list -->
|
|
||||||
@if (parsedNote().milestones.length > 0) {
|
|
||||||
<div class="milestones-container">
|
|
||||||
@for (milestone of parsedNote().milestones; track trackMilestone($index, milestone)) {
|
|
||||||
<div
|
|
||||||
class="milestone-block"
|
|
||||||
[ngClass]="milestone.type"
|
|
||||||
>
|
|
||||||
<div class="milestone-title-row">
|
|
||||||
@switch (milestone.type) {
|
|
||||||
@case ('checked') {
|
|
||||||
<span class="material-symbols-outlined milestone-status-icon checked">check_box</span>
|
|
||||||
}
|
|
||||||
@case ('progress') {
|
|
||||||
<span class="material-symbols-outlined milestone-status-icon progress">indeterminate_check_box</span>
|
|
||||||
}
|
|
||||||
@case ('unchecked') {
|
|
||||||
<span class="material-symbols-outlined milestone-status-icon unchecked">check_box_outline_blank</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
<div class="milestone-text">
|
|
||||||
@for (seg of milestone.segments; track seg) {
|
|
||||||
<span
|
|
||||||
[class.bold-segment]="seg.bold"
|
|
||||||
[class.code-segment]="seg.code"
|
|
||||||
>{{ seg.text }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Sub-steps list -->
|
|
||||||
@if (milestone.subSteps.length > 0) {
|
|
||||||
<div class="milestone-substeps">
|
|
||||||
@for (step of milestone.subSteps; track trackMarkdownLine($index, step)) {
|
|
||||||
<div
|
|
||||||
class="substep-row"
|
|
||||||
[ngClass]="step.type"
|
|
||||||
[style.padding-left.px]="(step.indent > 2 ? (step.indent - 2) * 12 : 0)"
|
|
||||||
>
|
|
||||||
@switch (step.type) {
|
|
||||||
@case ('checked') {
|
|
||||||
<span class="material-symbols-outlined check-icon checked">check_box</span>
|
|
||||||
}
|
|
||||||
@case ('progress') {
|
|
||||||
<span class="material-symbols-outlined check-icon progress">indeterminate_check_box</span>
|
|
||||||
}
|
|
||||||
@case ('unchecked') {
|
|
||||||
<span class="material-symbols-outlined check-icon unchecked">check_box_outline_blank</span>
|
|
||||||
}
|
|
||||||
@case ('list-item') {
|
|
||||||
<span class="bullet">•</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@if (step.type !== 'empty') {
|
|
||||||
<span class="line-text">
|
|
||||||
@for (seg of step.segments; track seg) {
|
|
||||||
<span
|
|
||||||
[class.bold-segment]="seg.bold"
|
|
||||||
[class.code-segment]="seg.code"
|
|
||||||
>{{ seg.text }}</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<!-- Verification result -->
|
|
||||||
@if (milestone.checks.length > 0) {
|
|
||||||
<div class="milestone-checks-container">
|
|
||||||
@for (check of milestone.checks; track trackMarkdownLine($index, check)) {
|
|
||||||
<div
|
|
||||||
class="verification-card"
|
|
||||||
[ngClass]="check.type"
|
|
||||||
[style.margin-left.px]="(check.indent > 2 ? (check.indent - 2) * 12 : 0)"
|
|
||||||
>
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="card-tag" [class.assert]="check.checkKind === 'assert'">{{ check.checkKind || 'verify' }}{{ check.atEnd ? ' @end' : '' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
@for (seg of check.segments; track seg) {
|
|
||||||
<span
|
|
||||||
[class.bold-segment]="seg.bold"
|
|
||||||
[class.code-segment]="seg.code"
|
|
||||||
>{{ seg.text }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<!-- Other Markdown Lines Container -->
|
|
||||||
@if (parsedNote().otherLines.length > 0) {
|
|
||||||
<div class="other-lines-container">
|
|
||||||
@for (line of parsedNote().otherLines; track trackMarkdownLine($index, line)) {
|
|
||||||
@if (line.type === 'verify' || line.type === 'finding') {
|
|
||||||
<div class="verification-card standalone" [ngClass]="line.type" [style.margin-left.px]="line.indent * 12">
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="card-tag" [class.assert]="line.checkKind === 'assert'">{{ line.checkKind || 'verify' }}{{ line.atEnd ? ' @end' : '' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
@for (seg of line.segments; track seg) {
|
|
||||||
<span
|
|
||||||
[class.bold-segment]="seg.bold"
|
|
||||||
[class.code-segment]="seg.code"
|
|
||||||
>{{ seg.text }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div
|
|
||||||
class="md-line"
|
|
||||||
[ngClass]="line.type"
|
|
||||||
[style.margin-left.px]="line.indent * 12"
|
|
||||||
>
|
|
||||||
@switch (line.type) {
|
|
||||||
@case ('checked') {
|
|
||||||
<span class="material-symbols-outlined check-icon checked">check_box</span>
|
|
||||||
}
|
|
||||||
@case ('progress') {
|
|
||||||
<span class="material-symbols-outlined check-icon progress">indeterminate_check_box</span>
|
|
||||||
}
|
|
||||||
@case ('unchecked') {
|
|
||||||
<span class="material-symbols-outlined check-icon unchecked">check_box_outline_blank</span>
|
|
||||||
}
|
|
||||||
@case ('list-item') {
|
|
||||||
<span class="bullet">•</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@if (line.type !== 'empty') {
|
|
||||||
<span class="line-text">
|
|
||||||
@for (seg of line.segments; track seg) {
|
|
||||||
<span
|
|
||||||
[class.bold-segment]="seg.bold"
|
|
||||||
[class.code-segment]="seg.code"
|
|
||||||
>{{ seg.text }}</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -397,7 +253,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 +277,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 +360,9 @@ 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-task-lifecycle [value]="agentService.currentLifecycle()" />
|
||||||
|
<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,16 +370,16 @@ 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 && awaitingGuidance().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>{{ agentService.currentSessionId() ? '暂无步骤记录' : '尚未选择执行记录' }}</h3>
|
||||||
<p>请从右侧任务列表选择会话,查看实时执行记录。</p>
|
<p>{{ agentService.currentSessionId() ? '此任务尚未留下执行步骤。' : '请从右侧任务列表选择会话,查看实时执行记录。' }}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- The phase-based session logs list -->
|
<!-- The phase-based session logs list -->
|
||||||
@if (!agentService.isSessionContentLoading() && (phases().length > 0 || showPlanningLoader() || startupWorkItems().length > 0)) {
|
@if (!agentService.isSessionContentLoading() && (phases().length > 0 || showPlanningLoader() || startupWorkItems().length > 0 || awaitingGuidance().length > 0)) {
|
||||||
<div class="logs-list-container">
|
<div class="logs-list-container">
|
||||||
@if (startupWorkItems().length > 0) {
|
@if (startupWorkItems().length > 0) {
|
||||||
<div class="phase-container startup-phase" aria-live="polite">
|
<div class="phase-container startup-phase" aria-live="polite">
|
||||||
@@ -548,8 +409,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 +458,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>
|
||||||
@@ -606,6 +483,7 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
<!-- Text, tool calls, and actions share one chronological timeline. -->
|
<!-- Text, tool calls, and actions share one chronological timeline. -->
|
||||||
@if (getSortedStepEvents(block.data).length > 0) {
|
@if (getSortedStepEvents(block.data).length > 0) {
|
||||||
<div class="step-actions-container">
|
<div class="step-actions-container">
|
||||||
@@ -745,10 +623,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 +634,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 +677,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>
|
||||||
@@ -818,13 +695,13 @@ limitations under the License.
|
|||||||
@if (getActionTargetText(item.data)) {
|
@if (getActionTargetText(item.data)) {
|
||||||
<div class="action-detail-row">
|
<div class="action-detail-row">
|
||||||
<span class="detail-label">目标:</span>
|
<span class="detail-label">目标:</span>
|
||||||
<span class="detail-value">"{{ getActionTargetText(item.data) }}"</span>
|
<app-expandable-value class="detail-value" label="目标" [value]="getActionTargetText(item.data)" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (getActionInputText(item.data)) {
|
@if (getActionInputText(item.data)) {
|
||||||
<div class="action-detail-row">
|
<div class="action-detail-row">
|
||||||
<span class="detail-label">{{ getActionInputLabel(item.data) }}:</span>
|
<span class="detail-label">{{ getActionInputLabel(item.data) }}:</span>
|
||||||
<span class="detail-value">"{{ getActionInputText(item.data) }}"</span>
|
<app-expandable-value class="detail-value" label="输入" [value]="getActionInputText(item.data)" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (getActionCoords(item.data)) {
|
@if (getActionCoords(item.data)) {
|
||||||
@@ -842,13 +719,13 @@ limitations under the License.
|
|||||||
@if (getActionTargetText(item.data)) {
|
@if (getActionTargetText(item.data)) {
|
||||||
<div class="grid-item">
|
<div class="grid-item">
|
||||||
<span class="grid-label">目标</span>
|
<span class="grid-label">目标</span>
|
||||||
<span class="grid-value">"{{ getActionTargetText(item.data) }}"</span>
|
<app-expandable-value label="目标" [value]="getActionTargetText(item.data)" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (getActionInputText(item.data)) {
|
@if (getActionInputText(item.data)) {
|
||||||
<div class="grid-item">
|
<div class="grid-item">
|
||||||
<span class="grid-label">{{ getActionInputLabel(item.data) }}</span>
|
<span class="grid-label">{{ getActionInputLabel(item.data) }}</span>
|
||||||
<span class="grid-value">"{{ getActionInputText(item.data) }}"</span>
|
<app-expandable-value label="输入" [value]="getActionInputText(item.data)" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (getActionCoords(item.data)) {
|
@if (getActionCoords(item.data)) {
|
||||||
@@ -870,9 +747,9 @@ limitations under the License.
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@for (param of getActionExtraParams(item.data); track trackParam($index, param)) {
|
@for (param of getActionExtraParams(item.data); track trackParam($index, param)) {
|
||||||
<div class="grid-item">
|
<div class="grid-item" [class.full-width]="param.key === 'Result'">
|
||||||
<span class="grid-label">{{ param.key }}</span>
|
<span class="grid-label">{{ param.key }}</span>
|
||||||
<span class="grid-value code-pill">{{ param.value }}</span>
|
<app-expandable-value [label]="param.key" [value]="param.value" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Fallback item if all detail fields are empty -->
|
<!-- Fallback item if all detail fields are empty -->
|
||||||
@@ -1042,7 +919,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 +960,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 +973,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,14 +989,14 @@ 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)) {
|
||||||
@if (getAdbCommandLine(item.data)) {
|
@if (getAdbCommandLine(item.data)) {
|
||||||
<div class="action-detail-row">
|
<div class="action-detail-row">
|
||||||
<span class="detail-label">命令:</span>
|
<span class="detail-label">命令:</span>
|
||||||
<span class="detail-value command-value"><code>{{ getAdbCommandLine(item.data) }}</code></span>
|
<app-expandable-value class="detail-value" label="命令" [value]="getAdbCommandLine(item.data)" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (getAdbCwd(item.data)) {
|
@if (getAdbCwd(item.data)) {
|
||||||
@@ -1134,13 +1017,13 @@ limitations under the License.
|
|||||||
@if (getToolTargetText(item.data)) {
|
@if (getToolTargetText(item.data)) {
|
||||||
<div class="action-detail-row">
|
<div class="action-detail-row">
|
||||||
<span class="detail-label">目标:</span>
|
<span class="detail-label">目标:</span>
|
||||||
<span class="detail-value">"{{ getToolTargetText(item.data) }}"</span>
|
<app-expandable-value class="detail-value" label="目标" [value]="getToolTargetText(item.data)" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (getToolInputText(item.data)) {
|
@if (getToolInputText(item.data)) {
|
||||||
<div class="action-detail-row">
|
<div class="action-detail-row">
|
||||||
<span class="detail-label">{{ getToolInputLabel(item.data) }}:</span>
|
<span class="detail-label">{{ getToolInputLabel(item.data) }}:</span>
|
||||||
<span class="detail-value">"{{ getToolInputText(item.data) }}"</span>
|
<app-expandable-value class="detail-value" label="输入" [value]="getToolInputText(item.data)" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (getToolCoords(item.data)) {
|
@if (getToolCoords(item.data)) {
|
||||||
@@ -1152,13 +1035,13 @@ limitations under the License.
|
|||||||
@if (getToolAnalysisText(item.data)) {
|
@if (getToolAnalysisText(item.data)) {
|
||||||
<div class="action-detail-row">
|
<div class="action-detail-row">
|
||||||
<span class="detail-label">分析:</span>
|
<span class="detail-label">分析:</span>
|
||||||
<span class="detail-value">"{{ getToolAnalysisText(item.data) }}"</span>
|
<app-expandable-value class="detail-value" label="分析" [value]="getToolAnalysisText(item.data)" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (getToolGenericDetails(item.data)) {
|
@if (getToolGenericDetails(item.data)) {
|
||||||
<div class="action-detail-row">
|
<div class="action-detail-row">
|
||||||
<span class="detail-label">详情:</span>
|
<span class="detail-label">详情:</span>
|
||||||
<span class="detail-value">"{{ getToolGenericDetails(item.data) }}"</span>
|
<app-expandable-value class="detail-value" label="详情" [value]="getToolGenericDetails(item.data)" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1172,7 +1055,7 @@ limitations under the License.
|
|||||||
@if (getAdbCommandLine(item.data)) {
|
@if (getAdbCommandLine(item.data)) {
|
||||||
<div class="grid-item full-width">
|
<div class="grid-item full-width">
|
||||||
<span class="grid-label">命令</span>
|
<span class="grid-label">命令</span>
|
||||||
<span class="grid-value command-value"><code>{{ getAdbCommandLine(item.data) }}</code></span>
|
<app-expandable-value label="命令" [value]="getAdbCommandLine(item.data)" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (getAdbCwd(item.data)) {
|
@if (getAdbCwd(item.data)) {
|
||||||
@@ -1188,17 +1071,31 @@ 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>
|
||||||
|
<app-expandable-value [label]="param.key" [value]="param.value" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (plugin.result || plugin.rawResult || plugin.facts.length) {
|
||||||
|
<div class="grid-item full-width">
|
||||||
|
<span class="grid-label">实际结果</span>
|
||||||
|
<app-plugin-tool-result [view]="plugin" />
|
||||||
|
</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>
|
||||||
<span class="grid-value">"{{ getToolTargetText(item.data) }}"</span>
|
<app-expandable-value label="目标" [value]="getToolTargetText(item.data)" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (getToolInputText(item.data)) {
|
@if (getToolInputText(item.data)) {
|
||||||
<div class="grid-item">
|
<div class="grid-item">
|
||||||
<span class="grid-label">{{ getToolInputLabel(item.data) }}</span>
|
<span class="grid-label">{{ getToolInputLabel(item.data) }}</span>
|
||||||
<span class="grid-value">"{{ getToolInputText(item.data) }}"</span>
|
<app-expandable-value label="输入" [value]="getToolInputText(item.data)" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (getToolCoords(item.data)) {
|
@if (getToolCoords(item.data)) {
|
||||||
@@ -1210,19 +1107,19 @@ limitations under the License.
|
|||||||
@if (getToolAnalysisText(item.data)) {
|
@if (getToolAnalysisText(item.data)) {
|
||||||
<div class="grid-item">
|
<div class="grid-item">
|
||||||
<span class="grid-label">分析</span>
|
<span class="grid-label">分析</span>
|
||||||
<span class="grid-value">"{{ getToolAnalysisText(item.data) }}"</span>
|
<app-expandable-value label="分析" [value]="getToolAnalysisText(item.data)" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (getToolGenericDetails(item.data)) {
|
@if (getToolGenericDetails(item.data)) {
|
||||||
<div class="grid-item">
|
<div class="grid-item">
|
||||||
<span class="grid-label">详情</span>
|
<span class="grid-label">详情</span>
|
||||||
<span class="grid-value">"{{ getToolGenericDetails(item.data) }}"</span>
|
<app-expandable-value label="详情" [value]="getToolGenericDetails(item.data)" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@for (param of getToolExtraParams(item.data); track trackParam($index, param)) {
|
@for (param of getToolExtraParams(item.data); track trackParam($index, param)) {
|
||||||
<div class="grid-item">
|
<div class="grid-item">
|
||||||
<span class="grid-label">{{ param.key }}</span>
|
<span class="grid-label">{{ param.key }}</span>
|
||||||
<span class="grid-value code-pill">{{ param.value }}</span>
|
<app-expandable-value [label]="param.key" [value]="param.value" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1243,7 +1140,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 +1165,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">
|
||||||
@@ -1295,11 +1192,13 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (awaitingGuidance().length) {
|
}
|
||||||
<div class="guidance-queue" aria-label="待处理用户指导">
|
@if (!conversationMessages().length && awaitingGuidance().length) {
|
||||||
|
<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">
|
||||||
<strong>用户指导 · {{ guidanceStatusLabel(item) }}</strong>
|
<strong>用户指导 · {{ guidanceStatusLabel(item) }}</strong>
|
||||||
|
<small>接收 {{ (item.accepted_at || item.timestamp) * 1000 | date:'HH:mm:ss' }}@if (item.consumed_at) { · 执行器读取 {{ item.consumed_at * 1000 | date:'HH:mm:ss' }} }</small>
|
||||||
<p>{{ item.instruction }}</p>
|
<p>{{ item.instruction }}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1328,62 +1227,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 +1242,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 -->
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,12 @@
|
|||||||
|
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 { TaskLifecycleComponent } from '../task-lifecycle/task-lifecycle.component';
|
||||||
|
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
|
||||||
|
import { PluginToolResultComponent } from '../plugin-tool-result/plugin-tool-result.component';
|
||||||
|
import { ExpandableValueComponent } from '../expandable-value/expandable-value.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 +25,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 +171,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 +192,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 +251,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: [ExpandableValueComponent, PluginToolResultComponent, ReportMarkdownComponent, LlmRequestStatusComponent, TaskLifecycleComponent, 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 +297,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 +305,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 +402,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 +413,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 +450,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 +470,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();
|
||||||
@@ -488,17 +507,18 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
public guidanceStatusLabel(event: GuidanceEvent): string {
|
public guidanceStatusLabel(event: GuidanceEvent): string {
|
||||||
const terminal = ['completed', 'failed', 'cancelled', 'interrupted']
|
const terminal = ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped']
|
||||||
.includes(this.agentService.currentSession()?.status || '');
|
.includes(this.agentService.currentSession()?.status || '');
|
||||||
if (event.status === 'pending') {
|
if (event.status === 'unapplied' || (event.status === 'pending' && terminal)) return '任务结束,未记录消费';
|
||||||
return terminal ? '任务结束前未进入模型' : '已接收,正在打断当前模型步骤';
|
if (event.status === 'unconfirmed' || (event.status === 'reading' && terminal)) return '任务结束,读取未确认';
|
||||||
}
|
if (event.status === 'reading') return '执行器正在读取';
|
||||||
return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录';
|
if (event.status === 'pending') return '已接收,等待安全时点读取';
|
||||||
|
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 +583,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 +878,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 +945,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)) {
|
||||||
@@ -928,6 +963,11 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
return 'completed';
|
return 'completed';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public taskPhaseLabel(session: Session): string | null {
|
||||||
|
if (['completed', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.getTaskStatus(session))) return null;
|
||||||
|
return ({ waiting_resources: '等待资源', paused: '已暂停', demonstrating: '人工示范中', analyzing_demo: '分析示范中' } as Record<string, string>)[session.phase || ''] || null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine the device serial number for the session
|
* Determine the device serial number for the session
|
||||||
*/
|
*/
|
||||||
@@ -967,12 +1007,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 || "删除失败,请稍后重试");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1414,6 +1455,11 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
public toggleActionExpanded(cardId: string, event?: Event, defaultExpanded: boolean = false): void {
|
public toggleActionExpanded(cardId: string, event?: Event, defaultExpanded: boolean = false): void {
|
||||||
if (event) {
|
if (event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && !selection.isCollapsed && event.currentTarget instanceof Node
|
||||||
|
&& selection.containsNode(event.currentTarget, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const isCurrentlyExpanded = this.isActionExpanded(cardId, defaultExpanded);
|
const isCurrentlyExpanded = this.isActionExpanded(cardId, defaultExpanded);
|
||||||
if (isCurrentlyExpanded) {
|
if (isCurrentlyExpanded) {
|
||||||
@@ -1603,6 +1649,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 +1685,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 +1817,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 +1861,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 +1878,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 +2087,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 +2268,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 +2284,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
|
||||||
|
|||||||
+44
-10
@@ -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,19 @@
|
|||||||
<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>
|
||||||
|
<app-evidence-sources [value]="r.evidence_sources || null" />
|
||||||
<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,33 +44,51 @@
|
|||||||
</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>}
|
||||||
@@ -87,6 +108,19 @@
|
|||||||
</div>
|
</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">
|
||||||
|
<div class="job-heading">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="job-statuses">
|
||||||
|
<span>{{label(j.status)}}</span>
|
||||||
|
<app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />
|
||||||
|
@if(j.human_reviewed){<span class="human-review-badge" title="人工评审已有保存内容,不代表测试通过">人工已审</span>}
|
||||||
|
</div>
|
||||||
|
@if(j.error){<small>{{j.error}}</small>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</section>}
|
</section>}
|
||||||
}
|
}
|
||||||
+59
-39
@@ -1,50 +1,70 @@
|
|||||||
:host { display:block;min-width:0;color:#1e293b;font-size:13px;line-height:1.65; }
|
:host { display:block;min-width:0;color:var(--app-text-control, #1e293b);font-size:13px;line-height:1.65; }
|
||||||
.tabs {display:flex;gap:4px;padding:4px;margin-bottom:18px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:20px;
|
.back-to-batch{margin-bottom:12px;max-width:100%;text-align:left;overflow-wrap:anywhere}
|
||||||
button {flex:1;padding:7px 8px;border:0;border-radius:16px;color:#475569;background:transparent;white-space:nowrap;
|
.job.selected{background:var(--app-primary-soft, #eff6ff);border-radius:8px;padding-left:8px;padding-right:8px}.job.selected button{color:var(--app-primary-text, #1d4ed8);border-color:var(--app-primary-border, #93c5fd);background:var(--app-surface, #fff)}
|
||||||
&.selected {color:#2563eb;background:#fff;box-shadow:0 1px 4px #0f172a12;} }
|
.tabs {display:flex;gap:4px;padding:4px;margin-bottom:18px;background:var(--app-surface-soft, #f1f5f9);border:1px solid var(--app-border, #e2e8f0);border-radius:20px;
|
||||||
|
button {flex:1;padding:7px 8px;border:0;border-radius:16px;color:var(--app-text-secondary, #475569);background:transparent;white-space:nowrap;
|
||||||
|
&.selected {color:var(--app-primary-text, #2563eb);background:var(--app-surface, #fff);box-shadow:0 1px 4px #0f172a12;} }
|
||||||
}
|
}
|
||||||
section {padding:16px;margin-bottom:14px;border:1px solid #e2e8f0;border-radius:12px;background:#fff;&:last-child{margin-bottom:0;}}
|
section {padding:16px;margin-bottom:14px;border:1px solid var(--app-border, #e2e8f0);border-radius:12px;background:var(--app-surface, #fff);&:last-child{margin-bottom:0;}}
|
||||||
h3 {color:#0f172a;font-size:14px;font-weight:600;margin:0 0 8px;}
|
h3 {color:var(--app-text-strong, #0f172a);font-size:14px;font-weight:600;margin:0 0 8px;}
|
||||||
p {margin:8px 0 12px;color:#475569;overflow-wrap:anywhere;}strong{font-weight:600;}.muted,small{color:#64748b;font-size:12px;}
|
p {margin:8px 0 12px;color:var(--app-text-secondary, #475569);overflow-wrap:anywhere;}strong{font-weight:600;}.muted,small{color:var(--app-text-muted, #64748b);font-size:12px;}
|
||||||
.error,.notice{padding:10px 12px;border-radius:8px;margin:0 0 14px;}.error{color:#b91c1c;background:#fff1f2;border:1px solid #fecdd3;}.notice{color:#047857;background:#ecfdf5;border:1px solid #a7f3d0;}
|
.error,.notice{padding:10px 12px;border-radius:8px;margin:0 0 14px;}.error{color:var(--app-error-text, #b91c1c);background:var(--app-error-soft, #fff1f2);border:1px solid var(--app-error-border, #fecdd3);}.notice{color:var(--app-success-text, #047857);background:var(--app-success-soft, #ecfdf5);border:1px solid var(--app-success-border, #a7f3d0);}
|
||||||
button,a,.upload {display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:34px;border:1px solid #cbd5e1;border-radius:8px;padding:7px 12px;background:#fff;color:#334155;text-decoration:none;cursor:pointer;font:inherit;font-weight:500;line-height:1.5;transition:background .15s,border-color .15s;
|
button,a,.upload {display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:34px;border:1px solid var(--app-border-control, #cbd5e1);border-radius:8px;padding:7px 12px;background:var(--app-surface, #fff);color:var(--app-text-control, #334155);text-decoration:none;cursor:pointer;font:inherit;font-weight:500;line-height:1.5;transition:background .15s,border-color .15s;
|
||||||
&:hover{background:#f8fafc;border-color:#94a3b8;}&:focus-visible{outline:2px solid #3b82f6;outline-offset:2px;}&:disabled{color:#94a3b8;background:#f8fafc;border-color:#e2e8f0;cursor:not-allowed;}}
|
&:hover{background:var(--app-surface-soft, #f8fafc);border-color:var(--app-border-control, #94a3b8);}&:focus-visible{outline:2px solid #3b82f6;outline-offset:2px;}&:disabled{color:var(--app-text-faint, #94a3b8);background:var(--app-surface-soft, #f8fafc);border-color:var(--app-border, #e2e8f0);cursor:not-allowed;}}
|
||||||
a{color:#2563eb;border-color:#bfdbfe;background:#eff6ff;}button.primary{background:#2563eb;color:white;border-color:#2563eb;width:100%;margin-top:10px;}button.primary:hover{background:#1d4ed8;}button.primary:disabled{background:#e2e8f0;color:#64748b;border-color:#e2e8f0;}
|
a{color:var(--app-primary-text, #2563eb);border-color:var(--app-primary-border, #bfdbfe);background:var(--app-primary-soft, #eff6ff);}button.primary{background:#2563eb;color:white;border-color:#2563eb;width:100%;margin-top:10px;}button.primary:hover{background:#1d4ed8;}button.primary:disabled{background:var(--app-surface-raised, #e2e8f0);color:var(--app-text-muted, #64748b);border-color:var(--app-border, #e2e8f0);}
|
||||||
.actions,.fields,.format-options{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}.actions{margin:12px 0 4px;}.fields>*{flex:1;min-width:130px;}.format-options{margin:12px 0;}.format-options label{flex:1;min-width:120px;margin:0;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;background:#f8fafc;}
|
.actions,.fields,.format-options{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}.actions{margin:12px 0 4px;}.fields>*{flex:1;min-width:130px;}.format-options{margin:12px 0;}.format-options label{flex:1;min-width:120px;margin:0;padding:10px 12px;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;background:var(--app-surface-soft, #f8fafc);}
|
||||||
label{display:block;margin:12px 0;color:#334155;font-weight:500;}
|
label{display:block;margin:12px 0;color:var(--app-text-control, #334155);font-weight:500;}
|
||||||
input:not([type=checkbox]):not([type=file]),select,textarea{box-sizing:border-box;display:block;width:100%;margin-top:6px;border:1px solid #cbd5e1;border-radius:8px;padding:9px 10px;background:#fff;color:#1e293b;font:inherit;font-weight:400;&:focus{outline:2px solid #bfdbfe;border-color:#3b82f6;}}
|
input:not([type=checkbox]):not([type=file]),select,textarea{box-sizing:border-box;display:block;width:100%;margin-top:6px;border:1px solid var(--app-border-control, #cbd5e1);border-radius:8px;padding:9px 10px;background:var(--app-surface, #fff);color:var(--app-text-control, #1e293b);font:inherit;font-weight:400;&:focus{outline:2px solid var(--app-primary-border, #bfdbfe);border-color:#3b82f6;}}
|
||||||
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:var(--app-primary-text, #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:var(--app-surface-soft, #f8fafc);padding:12px 16px;}.device label{margin:0;}
|
||||||
.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;}
|
.selection-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0;color:var(--app-text-secondary, #475569);}
|
||||||
.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;}
|
.selection-column{flex:0 0 18px;text-align:center;}.selection-column input{margin:0;}
|
||||||
.archive-progress{margin:12px 0;padding:11px 13px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;color:#1e40af;}
|
.case-preview-list{max-height:320px;overflow:auto;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;margin-bottom:14px;}
|
||||||
|
.case-preview{display:flex;align-items:flex-start;gap:10px;padding:12px;border-bottom:1px solid var(--app-border, #e2e8f0);&:last-child{border-bottom:0;}&.unselected{background:var(--app-surface-soft, #f8fafc);color:var(--app-text-muted, #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:var(--app-primary-text, #2563eb);}pre{max-height:160px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;background:var(--app-surface-soft, #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:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #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:var(--app-text-muted, #64748b);font-size:12px;font-weight:400;}.record.selected{border-color:var(--app-primary-border, #93c5fd);background:var(--app-primary-soft, #eff6ff);}.record.selected strong{color:var(--app-primary-text, #1d4ed8);}
|
||||||
|
.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:var(--app-error-text, #b91c1c);}.batch-record .delete-batch:hover:not(:disabled){background:var(--app-error-soft, #fff1f2);border-color:var(--app-error-border, #fda4af);}.batch-record .delete-batch .material-symbols-outlined{font-size:19px;}
|
||||||
|
.archive-progress{margin:12px 0;padding:11px 13px;border:1px solid var(--app-primary-border, #bfdbfe);border-radius:10px;background:var(--app-primary-soft, #f8fbff);color:var(--app-primary-text, #1e40af);}
|
||||||
.archive-progress-heading{display:flex;align-items:center;gap:8px;}
|
.archive-progress-heading{display:flex;align-items:center;gap:8px;}
|
||||||
.archive-progress p{margin:7px 0 0;font-size:12px;color:#475569;}
|
.archive-progress p{margin:7px 0 0;font-size:12px;color:var(--app-text-secondary, #475569);}
|
||||||
.archive-progress.archive-failed{border-color:#fecdd3;background:#fff1f2;color:#be123c;}
|
.archive-progress.archive-failed{border-color:var(--app-error-border, #fecdd3);background:var(--app-error-soft, #fff1f2);color:var(--app-error-text, #be123c);}
|
||||||
.archive-track{height:7px;margin-top:10px;border-radius:999px;background:#dbeafe;overflow:hidden;}
|
.archive-track{height:7px;margin-top:10px;border-radius:999px;background:var(--app-primary-soft, #dbeafe);overflow:hidden;}
|
||||||
.archive-track span{display:block;height:100%;border-radius:inherit;background:#3b82f6;transition:width .3s ease;}
|
.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;}
|
.archive-spinner{width:13px;height:13px;border:2px solid var(--app-primary-border, #bfdbfe);border-top-color:#2563eb;border-radius:50%;animation:archive-spin .8s linear infinite;}
|
||||||
@keyframes archive-spin{to{transform:rotate(360deg);}}
|
@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:var(--app-surface-soft, #f8fafc);}.table-wrap{max-height:260px;overflow:auto;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid var(--app-border, #e2e8f0);text-align:left;overflow-wrap:anywhere;}th{background:var(--app-surface-soft, #f8fafc);color:var(--app-text-secondary, #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:var(--app-text-control, #334155);background:var(--app-surface-soft, #f8fafc);border-radius:8px;padding:12px;border:1px solid var(--app-border, #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-direction:column;gap:8px;padding:12px 0;border-bottom:1px solid var(--app-border, #f1f5f9);}
|
||||||
.task-guidance{margin-top:16px;padding:14px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;}
|
.job:last-child{border-bottom:0;}
|
||||||
|
.job-heading{position:relative;min-width:0;}
|
||||||
|
.job-heading>app-favorite-button{position:absolute;left:6px;top:4px;z-index:1;}
|
||||||
|
.job-heading>button{box-sizing:border-box;width:100%;min-width:0;padding-left:40px;justify-content:flex-start;text-align:left;overflow-wrap:anywhere;}
|
||||||
|
.job-statuses{display:flex;flex-wrap:wrap;align-items:center;gap:8px;}
|
||||||
|
.job-statuses>span{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;padding:0 6px;border:1px solid transparent;border-radius:5px;font-size:11px;line-height:14px;background:var(--app-surface-soft, #f1f5f9);color:var(--app-text-secondary, #475569);white-space:nowrap;}
|
||||||
|
.job small{overflow-wrap:anywhere;}
|
||||||
|
details{margin:14px 0;}summary{color:var(--app-text-secondary, #475569);cursor:pointer;padding:8px 0;}
|
||||||
|
.task-guidance{margin-top:16px;padding:14px;border:1px solid var(--app-primary-border, #bfdbfe);border-radius:10px;background:var(--app-primary-soft, #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 var(--app-primary-border, #dbeafe);border-radius:10px;background:var(--app-primary-soft, #f8fbff);}
|
||||||
.report-title{display:block;font-size:14px;line-height:1.5;color:#0f172a;overflow-wrap:anywhere;}
|
.report-heading{display:flex;align-items:flex-start;gap:8px;}.report-heading .report-title{flex:1;min-width:0;}
|
||||||
.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-title{display:block;font-size:14px;line-height:1.5;color:var(--app-text-strong, #0f172a);overflow-wrap:anywhere;}
|
||||||
.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-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px;span{padding:3px 8px;border:1px solid var(--app-primary-border, #bfdbfe);border-radius:20px;background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1e40af);font-size:12px;&.failed{background:var(--app-error-soft, #fff1f2);border-color:var(--app-error-border, #fecdd3);color:var(--app-error-text, #be123c);}}}
|
||||||
.report-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin:12px 0;div{min-width:0;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;background:#fff;}strong{font-size:12px;color:#475569;}p{margin:5px 0 0;white-space:pre-wrap;color:#1e293b;}}
|
.report-error{margin-top:11px;padding:10px 12px;background:var(--app-error-soft, #fff1f2);border-left:3px solid #e11d48;border-radius:6px;color:var(--app-error-text, #9f1239);strong{font-size:12px;}p{margin:4px 0 0;color:var(--app-error-text, #9f1239);white-space:pre-wrap;}}
|
||||||
.report-content{margin-top:14px;padding:14px;border:1px solid #e2e8f0;border-radius:10px;background:#fff;h3{margin-bottom:12px;}}
|
.report-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin:12px 0;div{min-width:0;padding:10px 12px;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;background:var(--app-surface, #fff);}strong{font-size:12px;color:var(--app-text-secondary, #475569);}p{margin:5px 0 0;white-space:pre-wrap;color:var(--app-text-control, #1e293b);}}
|
||||||
.report-body{overflow-x:auto;overflow-wrap:anywhere;color:#1e293b;line-height:1.75;}
|
.report-content{margin-top:14px;padding:14px;border:1px solid var(--app-border, #e2e8f0);border-radius:10px;background:var(--app-surface, #fff);h3{margin-bottom:12px;}}
|
||||||
|
.report-body{overflow-x:auto;overflow-wrap:anywhere;color:var(--app-text-control, #1e293b);line-height:1.75;}
|
||||||
:host ::ng-deep .report-body, :host ::ng-deep .report-markdown-body {
|
:host ::ng-deep .report-body, :host ::ng-deep .report-markdown-body {
|
||||||
h1,h2,h3,h4{margin:18px 0 8px;color:#0f172a;line-height:1.4;}h1{font-size:18px;}h2{font-size:16px;}h3,h4{font-size:14px;}
|
h1,h2,h3,h4{margin:18px 0 8px;color:var(--app-text-strong, #0f172a);line-height:1.4;}h1{font-size:18px;}h2{font-size:16px;}h3,h4{font-size:14px;}
|
||||||
> div{margin:3px 0;}
|
> div{margin:3px 0;}
|
||||||
ul,ol{margin:8px 0;padding-left:24px;}li{margin:4px 0;}
|
ul,ol{margin:8px 0;padding-left:24px;}li{margin:4px 0;}
|
||||||
blockquote{margin:10px 0;padding:8px 12px;border-left:3px solid #93c5fd;background:#f8fafc;}
|
blockquote{margin:10px 0;padding:8px 12px;border-left:3px solid var(--app-primary-border, #93c5fd);background:var(--app-surface-soft, #f8fafc);}
|
||||||
pre{max-width:100%;overflow:auto;padding:10px 12px;background:#f1f5f9;border-radius:6px;white-space:pre-wrap;}
|
pre{max-width:100%;overflow:auto;padding:10px 12px;background:var(--app-surface-soft, #f1f5f9);border-radius:6px;white-space:pre-wrap;}
|
||||||
code{font-family:Consolas,monospace;background:#f1f5f9;padding:1px 3px;border-radius:3px;}
|
code{font-family:Consolas,monospace;background:var(--app-surface-soft, #f1f5f9);padding:1px 3px;border-radius:3px;}
|
||||||
table{width:100%;border-collapse:collapse;font-size:12px;}th,td{padding:7px 9px;border:1px solid #e2e8f0;vertical-align:top;}th{background:#eff6ff;color:#1e3a8a;}
|
table{width:100%;border-collapse:collapse;font-size:12px;}th,td{padding:7px 9px;border:1px solid var(--app-border, #e2e8f0);vertical-align:top;}th{background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1e3a8a);}
|
||||||
.md-para-gap{height:8px;}
|
.md-para-gap{height:8px;}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.job .human-review-badge{white-space:nowrap;border:1px solid var(--app-primary-border, #bfdbfe);background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1d4ed8);font-weight:500;}
|
||||||
+105
-1
@@ -12,7 +12,9 @@ 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', 'post', 'delete']);
|
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'post', 'delete']);
|
||||||
|
|
||||||
@@ -22,6 +24,8 @@ describe('AutomationPanelComponent batch deletion', () => {
|
|||||||
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.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: [
|
||||||
@@ -32,6 +36,84 @@ 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('shows only saved human review badges and removes them on a batch refresh', async () => {
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
component.tab.set('batches');
|
||||||
|
const batch = { id: 'review-batch', name: '评审批次', status: 'completed', jobs: [
|
||||||
|
{ session_id: 'reviewed', status: 'completed', human_reviewed: true, case: { name: '已评用例' } },
|
||||||
|
{ session_id: 'empty', status: 'completed', human_reviewed: false, case: { name: '空白用例' } },
|
||||||
|
{ session_id: 'legacy', status: 'completed', case: { name: '旧版任务' } }
|
||||||
|
] };
|
||||||
|
component.batch.set(batch);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const rows = fixture.nativeElement.querySelectorAll('.job');
|
||||||
|
expect(rows[0].querySelector('.human-review-badge').textContent).toBe('人工已审');
|
||||||
|
expect(rows[1].querySelector('.human-review-badge')).toBeNull();
|
||||||
|
expect(rows[2].querySelector('.human-review-badge')).toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('人工未审');
|
||||||
|
(http.get.and as any).callFake((url: string) => of(url.endsWith('/review-batch')
|
||||||
|
? { ...batch, jobs: batch.jobs.map(job => ({ ...job, human_reviewed: false })) } : []));
|
||||||
|
await component.refresh();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.human-review-badge')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
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');
|
||||||
@@ -62,6 +144,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-heading > 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();
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
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';
|
||||||
|
import { EvidenceSourcesComponent } from '../evidence-sources/evidence-sources.component';
|
||||||
|
|
||||||
interface ArchiveProgress {
|
interface ArchiveProgress {
|
||||||
status: 'idle' | 'preparing' | 'ready' | 'failed';
|
status: 'idle' | 'preparing' | 'ready' | 'failed';
|
||||||
@@ -18,13 +24,17 @@ interface ArchiveProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@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, EvidenceSourcesComponent],
|
||||||
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);
|
archiveProgress = signal<ArchiveProgress | null>(null);
|
||||||
archiveBatchId = signal(''); archiveBusy = signal(false);
|
archiveBatchId = signal(''); archiveBusy = signal(false);
|
||||||
@@ -37,21 +47,25 @@ 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} 秒`; }
|
duration(seconds: number) { const minutes = Math.floor(seconds / 60); return minutes ? `${minutes} 分 ${seconds % 60} 秒` : `${seconds} 秒`; }
|
||||||
downloadBatchArchive(id: string) { void this.prepareBatchArchive(id); }
|
downloadBatchArchive(id: string) { void this.prepareBatchArchive(id); }
|
||||||
private async prepareBatchArchive(id: string) {
|
private async prepareBatchArchive(id: string) {
|
||||||
@@ -104,13 +118,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-title-row">
|
||||||
|
<app-favorite-button [sourceId]="session.session_id" />
|
||||||
<div class="task-goal" title="{{ session.initial_goal }}">
|
<div class="task-goal" title="{{ session.initial_goal }}">
|
||||||
{{ session.initial_goal || '未命名任务' }}
|
{{ session.display_name || session.initial_goal || '未命名任务' }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-card-footer">
|
<div class="task-card-footer">
|
||||||
<app-task-id-copy [taskId]="session.session_id" />
|
<app-task-id-copy [taskId]="session.session_id" />
|
||||||
|
|||||||
+144
-126
@@ -15,9 +15,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
$border-color: rgba(0, 0, 0, 0.08);
|
$border-color: rgba(0, 0, 0, 0.08);
|
||||||
$text-primary: #1f1f1f;
|
$text-primary: var(--app-text-strong, #1f1f1f);
|
||||||
$text-secondary: #71717a;
|
$text-secondary: var(--app-text-muted, #71717a);
|
||||||
$text-muted: #a1a1aa;
|
$text-muted: var(--app-text-faint, #a1a1aa);
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -32,7 +32,7 @@ $text-muted: #a1a1aa;
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
.chat-header {
|
.chat-header {
|
||||||
@@ -43,23 +43,23 @@ $text-muted: #a1a1aa;
|
|||||||
height: 60px;
|
height: 60px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
border-bottom: 1px solid $border-color;
|
border-bottom: 1px solid $border-color;
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
.tab-selector-bar {
|
.tab-selector-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: rgba(241, 245, 249, 0.72);
|
background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.72);
|
||||||
backdrop-filter: blur(16px) saturate(180%);
|
backdrop-filter: blur(16px) saturate(180%);
|
||||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||||
border: 1px solid rgba(226, 232, 240, 0.85);
|
border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.85);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.8);
|
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.8);
|
||||||
|
|
||||||
.tab-selector-btn {
|
.tab-selector-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -74,7 +74,7 @@ $text-muted: #a1a1aa;
|
|||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #64748b;
|
color: var(--app-text-muted, #64748b);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
@@ -90,13 +90,13 @@ $text-muted: #a1a1aa;
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:hover:not(.active) {
|
&:hover:not(.active) {
|
||||||
color: #0f172a;
|
color: var(--app-text-strong, #0f172a);
|
||||||
background: rgba(255, 255, 255, 0.7);
|
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
color: #2563eb;
|
color: var(--app-primary-text, #2563eb);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.02);
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.02);
|
||||||
}
|
}
|
||||||
@@ -116,7 +116,7 @@ $text-muted: #a1a1aa;
|
|||||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.92), rgba(26, 115, 232, 0.85));
|
background: linear-gradient(135deg, rgba(37, 99, 235, 0.92), rgba(26, 115, 232, 0.85));
|
||||||
backdrop-filter: blur(14px) saturate(180%);
|
backdrop-filter: blur(14px) saturate(180%);
|
||||||
-webkit-backdrop-filter: blur(14px) saturate(180%);
|
-webkit-backdrop-filter: blur(14px) saturate(180%);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
border: 1px solid rgb(var(--app-border-rgb, 255 255 255) / 0.35);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -127,7 +127,7 @@ $text-muted: #a1a1aa;
|
|||||||
gap: 5px;
|
gap: 5px;
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
box-shadow: 0 4px 14px rgba(37, 99, 235, 0.3), inset 0 1px 1px rgba(255, 255, 255, 0.45);
|
box-shadow: 0 4px 14px rgba(37, 99, 235, 0.3), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.45);
|
||||||
|
|
||||||
.material-symbols-outlined {
|
.material-symbols-outlined {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -136,8 +136,8 @@ $text-muted: #a1a1aa;
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: linear-gradient(135deg, rgba(37, 99, 235, 1), rgba(26, 115, 232, 0.95));
|
background: linear-gradient(135deg, rgba(37, 99, 235, 1), rgba(26, 115, 232, 0.95));
|
||||||
border-color: rgba(255, 255, 255, 0.6);
|
border-color: rgb(var(--app-border-rgb, 255 255 255) / 0.6);
|
||||||
box-shadow: 0 6px 18px rgba(37, 99, 235, 0.42), inset 0 1px 1px rgba(255, 255, 255, 0.6);
|
box-shadow: 0 6px 18px rgba(37, 99, 235, 0.42), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.6);
|
||||||
transform: translateY(-0.5px);
|
transform: translateY(-0.5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,17 +151,17 @@ $text-muted: #a1a1aa;
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: rgba(255, 255, 255, 0.72);
|
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.72);
|
||||||
backdrop-filter: blur(16px) saturate(180%);
|
backdrop-filter: blur(16px) saturate(180%);
|
||||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||||
border: 1px solid rgba(203, 213, 225, 0.85);
|
border: 1px solid rgb(var(--app-border-control-rgb, 203 213 225) / 0.85);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
color: #64748b;
|
color: var(--app-text-muted, #64748b);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 2px 6px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.9);
|
box-shadow: 0 2px 6px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.9);
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
.material-symbols-outlined {
|
.material-symbols-outlined {
|
||||||
@@ -170,9 +170,9 @@ $text-muted: #a1a1aa;
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: #fef2f2;
|
background: var(--app-error-soft, #fef2f2);
|
||||||
color: #ef4444;
|
color: var(--app-error-text, #ef4444);
|
||||||
border-color: #fca5a5;
|
border-color: var(--app-error-border, #fca5a5);
|
||||||
box-shadow: 0 3px 10px rgba(239, 68, 68, 0.15);
|
box-shadow: 0 3px 10px rgba(239, 68, 68, 0.15);
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
@@ -192,7 +192,7 @@ $text-muted: #a1a1aa;
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 5px;
|
width: 5px;
|
||||||
@@ -205,7 +205,7 @@ $text-muted: #a1a1aa;
|
|||||||
.queue-section-header {
|
.queue-section-header {
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #475569;
|
color: var(--app-text-secondary, #475569);
|
||||||
margin: 6px 0 2px;
|
margin: 6px 0 2px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -221,18 +221,18 @@ $text-muted: #a1a1aa;
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #f1f5f9;
|
background: var(--app-surface-soft, #f1f5f9);
|
||||||
border: 1px dashed #cbd5e1;
|
border: 1px dashed var(--app-border-control, #cbd5e1);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
color: #64748b;
|
color: var(--app-text-muted, #64748b);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-section {
|
.history-section {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
border-top: 1px solid rgba(226, 232, 240, 0.8);
|
border-top: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.8);
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
|
|
||||||
&[open] .history-summary {
|
&[open] .history-summary {
|
||||||
@@ -242,7 +242,7 @@ $text-muted: #a1a1aa;
|
|||||||
.history-summary {
|
.history-summary {
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #475569;
|
color: var(--app-text-secondary, #475569);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
outline: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -265,8 +265,8 @@ $text-muted: #a1a1aa;
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f1f5f9;
|
background: var(--app-surface-soft, #f1f5f9);
|
||||||
color: #1e293b;
|
color: var(--app-text-control, #1e293b);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,8 +278,8 @@ $text-muted: #a1a1aa;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.task-card {
|
.task-card {
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
border: 1px solid rgba(226, 232, 240, 0.8);
|
border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.8);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -292,7 +292,7 @@ $text-muted: #a1a1aa;
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
border-color: rgba(203, 213, 225, 1);
|
border-color: rgb(var(--app-border-control-rgb, 203 213 225) / 1);
|
||||||
box-shadow: 0 3px 8px rgba(148, 163, 184, 0.08);
|
box-shadow: 0 3px 8px rgba(148, 163, 184, 0.08);
|
||||||
|
|
||||||
.task-card-header .task-card-header-actions {
|
.task-card-header .task-card-header-actions {
|
||||||
@@ -314,7 +314,7 @@ $text-muted: #a1a1aa;
|
|||||||
&.selected {
|
&.selected {
|
||||||
border-color: #3b82f6;
|
border-color: #3b82f6;
|
||||||
box-shadow: 0 0 0 1px #3b82f6, 0 4px 12px rgba(59, 130, 246, 0.08);
|
box-shadow: 0 0 0 1px #3b82f6, 0 4px 12px rgba(59, 130, 246, 0.08);
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.running {
|
&.running {
|
||||||
@@ -329,23 +329,30 @@ $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 var(--app-border, #e2e8f0); border-radius: 5px; background: var(--app-surface-soft, #f8fafc); color: var(--app-text-muted, #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 var(--app-border, #e2e8f0);
|
||||||
background: #fafafa;
|
background: var(--app-surface-soft, #fafafa);
|
||||||
color: #64748b;
|
color: var(--app-text-muted, #64748b);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
@@ -357,48 +364,48 @@ $text-muted: #a1a1aa;
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.running {
|
&.running {
|
||||||
background: #fafafa;
|
background: var(--app-surface-soft, #fafafa);
|
||||||
color: #1f1f1f;
|
color: var(--app-text-strong, #1f1f1f);
|
||||||
border-color: #1f1f1f;
|
border-color: var(--app-border-control, #1f1f1f);
|
||||||
&::before {
|
&::before {
|
||||||
background-color: #1f1f1f;
|
background-color: #1f1f1f;
|
||||||
animation: pulse-black-dot-small 1.5s infinite ease-in-out;
|
animation: pulse-black-dot-small 1.5s infinite ease-in-out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.paused {
|
&.paused {
|
||||||
background: #fff7ed;
|
background: var(--app-warning-soft, #fff7ed);
|
||||||
color: #c2410c;
|
color: var(--app-warning-text, #c2410c);
|
||||||
border-color: #fdba74;
|
border-color: var(--app-warning-border, #fdba74);
|
||||||
&::before { background-color: #f97316; }
|
&::before { background-color: #f97316; }
|
||||||
}
|
}
|
||||||
&.completed {
|
&.completed {
|
||||||
background: #fafafa;
|
background: var(--app-surface-soft, #fafafa);
|
||||||
color: #1f1f1f;
|
color: var(--app-text-strong, #1f1f1f);
|
||||||
border-color: #e2e8f0;
|
border-color: var(--app-border, #e2e8f0);
|
||||||
&::before {
|
&::before {
|
||||||
background-color: #1f1f1f;
|
background-color: #1f1f1f;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.pending {
|
&.pending {
|
||||||
background: #fafafa;
|
background: var(--app-surface-soft, #fafafa);
|
||||||
color: #64748b;
|
color: var(--app-text-muted, #64748b);
|
||||||
border-color: #e2e8f0;
|
border-color: var(--app-border, #e2e8f0);
|
||||||
&::before {
|
&::before {
|
||||||
background-color: #94a3b8;
|
background-color: #94a3b8;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.failed {
|
&.failed {
|
||||||
background: #fafafa;
|
background: var(--app-surface-soft, #fafafa);
|
||||||
color: #ef4444;
|
color: var(--app-error-text, #ef4444);
|
||||||
border-color: #fee2e2;
|
border-color: var(--app-error-border, #fee2e2);
|
||||||
&::before {
|
&::before {
|
||||||
background-color: #ef4444;
|
background-color: #ef4444;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.cancelled {
|
&.cancelled {
|
||||||
background: #fafafa;
|
background: var(--app-surface-soft, #fafafa);
|
||||||
color: #d97706;
|
color: var(--app-warning-text, #d97706);
|
||||||
border-color: #fef3c7;
|
border-color: var(--app-warning-border, #fef3c7);
|
||||||
&::before {
|
&::before {
|
||||||
background-color: #f59e0b;
|
background-color: #f59e0b;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -425,7 +433,7 @@ $text-muted: #a1a1aa;
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #94a3b8;
|
color: var(--app-text-faint, #94a3b8);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
transition: color 0.15s ease, opacity 0.15s ease;
|
transition: color 0.15s ease, opacity 0.15s ease;
|
||||||
@@ -435,7 +443,7 @@ $text-muted: #a1a1aa;
|
|||||||
width: 12.5px;
|
width: 12.5px;
|
||||||
height: 12.5px;
|
height: 12.5px;
|
||||||
line-height: 12.5px;
|
line-height: 12.5px;
|
||||||
color: #94a3b8;
|
color: var(--app-text-faint, #94a3b8);
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -450,9 +458,9 @@ $text-muted: #a1a1aa;
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #64748b;
|
color: var(--app-text-muted, #64748b);
|
||||||
.device-icon {
|
.device-icon {
|
||||||
color: #64748b;
|
color: var(--app-text-muted, #64748b);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -469,7 +477,7 @@ $text-muted: #a1a1aa;
|
|||||||
|
|
||||||
.task-time {
|
.task-time {
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
color: #94a3b8;
|
color: var(--app-text-faint, #94a3b8);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -487,7 +495,7 @@ $text-muted: #a1a1aa;
|
|||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: #94a3b8;
|
color: var(--app-text-faint, #94a3b8);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -502,8 +510,8 @@ $text-muted: #a1a1aa;
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
color: #ef4444;
|
color: var(--app-error-text, #ef4444);
|
||||||
background: #fee2e2;
|
background: var(--app-error-soft, #fee2e2);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
@@ -523,7 +531,7 @@ $text-muted: #a1a1aa;
|
|||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: #ef4444;
|
color: var(--app-error-text, #ef4444);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -538,8 +546,8 @@ $text-muted: #a1a1aa;
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
color: #dc2626;
|
color: var(--app-error-text, #dc2626);
|
||||||
background: #fee2e2;
|
background: var(--app-error-soft, #fee2e2);
|
||||||
transform: translateY(-50%) scale(1.1);
|
transform: translateY(-50%) scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,10 +559,20 @@ $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;
|
||||||
color: #1e293b;
|
color: var(--app-text-control, #1e293b);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
@@ -569,15 +587,15 @@ $text-muted: #a1a1aa;
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
border-top: 1px solid rgba(241, 245, 249, 0.8);
|
border-top: 1px solid rgb(var(--app-border-rgb, 241 245 249) / 0.8);
|
||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
color: #94a3b8;
|
color: var(--app-text-faint, #94a3b8);
|
||||||
|
|
||||||
.task-id {
|
.task-id {
|
||||||
font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace;
|
font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #94a3b8;
|
color: var(--app-text-faint, #94a3b8);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -586,14 +604,14 @@ $text-muted: #a1a1aa;
|
|||||||
transition: color 0.15s ease;
|
transition: color 0.15s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #475569;
|
color: var(--app-text-secondary, #475569);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-date {
|
.task-date {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #94a3b8;
|
color: var(--app-text-faint, #94a3b8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -617,7 +635,7 @@ $text-muted: #a1a1aa;
|
|||||||
.selector-title {
|
.selector-title {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #94a3b8;
|
color: var(--app-text-faint, #94a3b8);
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,12 +649,12 @@ $text-muted: #a1a1aa;
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid var(--app-border, #e2e8f0);
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #475569;
|
color: var(--app-text-secondary, #475569);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
@@ -645,15 +663,15 @@ $text-muted: #a1a1aa;
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f8fafc;
|
background: var(--app-surface-soft, #f8fafc);
|
||||||
border-color: #cbd5e1;
|
border-color: var(--app-border-control, #cbd5e1);
|
||||||
color: #1e293b;
|
color: var(--app-text-control, #1e293b);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: #eff6ff;
|
background: var(--app-primary-soft, #eff6ff);
|
||||||
border-color: #bfdbfe;
|
border-color: var(--app-primary-border, #bfdbfe);
|
||||||
color: #1d4ed8;
|
color: var(--app-primary-text, #1d4ed8);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -676,8 +694,8 @@ $text-muted: #a1a1aa;
|
|||||||
width: 52px;
|
width: 52px;
|
||||||
height: 52px;
|
height: 52px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #f1f5f9;
|
background: var(--app-surface-soft, #f1f5f9);
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid var(--app-border, #e2e8f0);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -686,7 +704,7 @@ $text-muted: #a1a1aa;
|
|||||||
|
|
||||||
.material-symbols-outlined {
|
.material-symbols-outlined {
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
color: #94a3b8;
|
color: var(--app-text-faint, #94a3b8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,14 +712,14 @@ $text-muted: #a1a1aa;
|
|||||||
font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1e293b;
|
color: var(--app-text-control, #1e293b);
|
||||||
margin: 0 0 6px 0;
|
margin: 0 0 6px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-notes-subtitle {
|
.empty-notes-subtitle {
|
||||||
font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
color: #64748b;
|
color: var(--app-text-muted, #64748b);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
max-width: 240px;
|
max-width: 240px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -718,7 +736,7 @@ $text-muted: #a1a1aa;
|
|||||||
.note-title {
|
.note-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1f1f1f;
|
color: var(--app-text-strong, #1f1f1f);
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif;
|
font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif;
|
||||||
@@ -726,8 +744,8 @@ $text-muted: #a1a1aa;
|
|||||||
|
|
||||||
.code-segment {
|
.code-segment {
|
||||||
font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace;
|
font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace;
|
||||||
background-color: #f1f3f4;
|
background-color: var(--app-info-soft, #f1f3f4);
|
||||||
color: #1967d2;
|
color: var(--app-primary-text, #1967d2);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -738,8 +756,8 @@ $text-muted: #a1a1aa;
|
|||||||
|
|
||||||
.checked {
|
.checked {
|
||||||
.code-segment {
|
.code-segment {
|
||||||
color: #a1a1aa;
|
color: var(--app-text-faint, #a1a1aa);
|
||||||
background-color: #f8f9fa;
|
background-color: var(--app-surface-soft, #f8f9fa);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -760,7 +778,7 @@ $text-muted: #a1a1aa;
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
.milestone-text {
|
.milestone-text {
|
||||||
color: #a1a1aa;
|
color: var(--app-text-faint, #a1a1aa);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -777,11 +795,11 @@ $text-muted: #a1a1aa;
|
|||||||
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||||
|
|
||||||
&.checked {
|
&.checked {
|
||||||
color: #1a73e8;
|
color: var(--app-primary-text, #1a73e8);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.progress {
|
&.progress {
|
||||||
color: #1a73e8;
|
color: var(--app-primary-text, #1a73e8);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.unchecked {
|
&.unchecked {
|
||||||
@@ -791,7 +809,7 @@ $text-muted: #a1a1aa;
|
|||||||
|
|
||||||
.milestone-text {
|
.milestone-text {
|
||||||
font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif;
|
font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif;
|
||||||
color: #1f1f1f;
|
color: var(--app-text-strong, #1f1f1f);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -821,7 +839,7 @@ $text-muted: #a1a1aa;
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif;
|
font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif;
|
||||||
color: #1f1f1f;
|
color: var(--app-text-strong, #1f1f1f);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
|
|
||||||
@@ -830,7 +848,7 @@ $text-muted: #a1a1aa;
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.checked {
|
&.checked {
|
||||||
color: #a1a1aa;
|
color: var(--app-text-faint, #a1a1aa);
|
||||||
|
|
||||||
.check-icon.checked {
|
.check-icon.checked {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
@@ -844,11 +862,11 @@ $text-muted: #a1a1aa;
|
|||||||
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||||
|
|
||||||
&.checked {
|
&.checked {
|
||||||
color: #1a73e8;
|
color: var(--app-primary-text, #1a73e8);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.progress {
|
&.progress {
|
||||||
color: #1a73e8;
|
color: var(--app-primary-text, #1a73e8);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.unchecked {
|
&.unchecked {
|
||||||
@@ -857,7 +875,7 @@ $text-muted: #a1a1aa;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bullet {
|
.bullet {
|
||||||
color: #9ca3af;
|
color: var(--app-text-faint, #9ca3af);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
padding-left: 2px;
|
padding-left: 2px;
|
||||||
@@ -889,8 +907,8 @@ $text-muted: #a1a1aa;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.verification-card {
|
.verification-card {
|
||||||
background: #f8fafc;
|
background: var(--app-surface-soft, #f8fafc);
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid var(--app-border, #e2e8f0);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -913,10 +931,10 @@ $text-muted: #a1a1aa;
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
letter-spacing: 0.6px;
|
letter-spacing: 0.6px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #64748b;
|
color: var(--app-text-muted, #64748b);
|
||||||
|
|
||||||
&.assert {
|
&.assert {
|
||||||
color: #0284c7;
|
color: var(--app-info-text, #0284c7);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -925,35 +943,35 @@ $text-muted: #a1a1aa;
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #334155;
|
color: var(--app-text-control, #334155);
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
|
||||||
.bold-segment {
|
.bold-segment {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1e293b;
|
color: var(--app-text-control, #1e293b);
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-segment {
|
.code-segment {
|
||||||
font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace;
|
font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background: #f1f5f9;
|
background: var(--app-surface-soft, #f1f5f9);
|
||||||
color: #0f172a;
|
color: var(--app-text-strong, #0f172a);
|
||||||
padding: 1.5px 6px;
|
padding: 1.5px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid var(--app-border, #e2e8f0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.finding {
|
&.finding {
|
||||||
background: #fffbeb;
|
background: var(--app-warning-soft, #fffbeb);
|
||||||
border-color: #fef3c7;
|
border-color: var(--app-warning-border, #fef3c7);
|
||||||
|
|
||||||
.card-header .card-tag {
|
.card-header .card-tag {
|
||||||
color: #b45309;
|
color: var(--app-warning-text, #b45309);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-body {
|
.card-body {
|
||||||
color: #92400e;
|
color: var(--app-warning-text, #92400e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -970,7 +988,7 @@ $text-muted: #a1a1aa;
|
|||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
min-height: 22px;
|
min-height: 22px;
|
||||||
font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif;
|
font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif;
|
||||||
color: #1f1f1f;
|
color: var(--app-text-strong, #1f1f1f);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
|
||||||
@@ -983,7 +1001,7 @@ $text-muted: #a1a1aa;
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid var(--app-border, #e5e7eb);
|
||||||
padding-bottom: 6px;
|
padding-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1002,17 +1020,17 @@ $text-muted: #a1a1aa;
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.checked {
|
&.checked {
|
||||||
color: #a1a1aa;
|
color: var(--app-text-faint, #a1a1aa);
|
||||||
|
|
||||||
.check-icon {
|
.check-icon {
|
||||||
color: #1a73e8;
|
color: var(--app-primary-text, #1a73e8);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.progress {
|
&.progress {
|
||||||
.check-icon {
|
.check-icon {
|
||||||
color: #1a73e8;
|
color: var(--app-primary-text, #1a73e8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1024,7 +1042,7 @@ $text-muted: #a1a1aa;
|
|||||||
|
|
||||||
&.list-item {
|
&.list-item {
|
||||||
.bullet {
|
.bullet {
|
||||||
color: #9ca3af;
|
color: var(--app-text-faint, #9ca3af);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { EvidenceSourcesComponent } from './evidence-sources.component';
|
||||||
|
|
||||||
|
describe('EvidenceSourcesComponent', () => {
|
||||||
|
it('keeps supporting API data separate from the platform and retains actual record identifiers', () => {
|
||||||
|
const fixture = TestBed.createComponent(EvidenceSourcesComponent);
|
||||||
|
fixture.componentRef.setInput('value', {total_sources:1, sources_omitted:0, unattributed_step_count:2,
|
||||||
|
note:'界面结果不能单独证明后端持久状态。', sources:[{
|
||||||
|
id:'src', category:'auxiliary', kind:'api', label:'安卓业务后台', target_id:'service', plugin_id:'data',
|
||||||
|
reference_count:1, references:[{step_number:7, step_id:'actual-step', call_id:'actual-call'}]
|
||||||
|
}]});
|
||||||
|
fixture.detectChanges();
|
||||||
|
const text = fixture.nativeElement.textContent;
|
||||||
|
expect(text).toContain('辅助来源 · 接口数据');
|
||||||
|
expect(text).toContain('actual-step');
|
||||||
|
expect(text).toContain('actual-call');
|
||||||
|
expect(text).toContain('来源未完整记录的步骤:2');
|
||||||
|
expect(text).not.toContain('界面来源 · 安卓界面');
|
||||||
|
expect(fixture.nativeElement.querySelector('app-test-result-badge')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fabricate source metadata for an older backend', () => {
|
||||||
|
const fixture = TestBed.createComponent(EvidenceSourcesComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('details')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||||
|
|
||||||
|
interface SourceReference { step_id?: string; step_number?: number; call_id?: string; source_id?: string; }
|
||||||
|
interface RecordedSource {
|
||||||
|
id: string; kind: string; label: string; target_id: string; plugin_id: string;
|
||||||
|
category: 'interface' | 'auxiliary'; references: SourceReference[]; reference_count: number;
|
||||||
|
}
|
||||||
|
interface SourceSummary {
|
||||||
|
sources: RecordedSource[]; total_sources: number; sources_omitted: number;
|
||||||
|
unattributed_step_count: number; note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-evidence-sources', standalone: true,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `@if (value(); as summary) {
|
||||||
|
<details class="sources" aria-label="证据来源与验收边界">
|
||||||
|
<summary>证据来源 · {{ summary.total_sources ? summary.total_sources + ' 个' : '未记录' }}</summary>
|
||||||
|
<p>{{ summary.note }}</p>
|
||||||
|
@for (source of summary.sources; track source.id) {
|
||||||
|
<article>
|
||||||
|
<strong>{{ source.category === 'interface' ? '界面来源' : '辅助来源' }} · {{ kind(source.kind) }}</strong>
|
||||||
|
<div>{{ source.label || source.target_id }}</div>
|
||||||
|
<small>目标 {{ source.target_id }}@if(source.plugin_id) { · 插件 {{ source.plugin_id }} }</small>
|
||||||
|
<ul>
|
||||||
|
@for (ref of source.references; track $index) {
|
||||||
|
<li>步骤 {{ ref.step_number ?? '未记录' }}@if(ref.step_id) { · <code>{{ ref.step_id }}</code> }@if(ref.call_id) { · 调用 <code>{{ ref.call_id }}</code> }</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
@if(source.reference_count > source.references.length) { <small>另 {{ source.reference_count - source.references.length }} 条引用见步骤记录。</small> }
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
@if(summary.sources_omitted) { <p>另 {{ summary.sources_omitted }} 个来源见原始步骤记录。</p> }
|
||||||
|
@if(summary.unattributed_step_count) { <p>来源未完整记录的步骤:{{ summary.unattributed_step_count }};不据此推断执行平台。</p> }
|
||||||
|
</details>
|
||||||
|
}`,
|
||||||
|
styles: [`
|
||||||
|
:host { display:block; margin:10px 0; }
|
||||||
|
.sources { border:1px solid var(--app-border-control,#cbd5e1); border-radius:8px; padding:10px 12px; background:var(--app-surface,#fff); color:var(--app-text-control,#1e293b); font-size:12px; line-height:1.7; overflow-wrap:anywhere; }
|
||||||
|
summary { cursor:pointer; font-weight:600; }
|
||||||
|
article { border-top:1px solid var(--app-border-control,#cbd5e1); margin-top:8px; padding-top:8px; }
|
||||||
|
p,small { color:var(--app-text-secondary,#475569); }
|
||||||
|
ul { padding-left:20px; margin:4px 0; }
|
||||||
|
code { font-size:11px; }
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class EvidenceSourcesComponent {
|
||||||
|
value = input<SourceSummary | null>(null);
|
||||||
|
kind(kind: string): string {
|
||||||
|
return ({android:'安卓界面', computer:'电脑界面', api:'接口数据', terminal:'终端', file:'文件', other:'其他'} as Record<string,string>)[kind] || '来源类型未记录';
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ExpandableValueComponent } from './expandable-value.component';
|
||||||
|
|
||||||
|
describe('执行字段折叠', () => {
|
||||||
|
for (const [label, value] of [['Payload', '{"x":1}'], ['输入', '完整内容'.repeat(100)], ['输出', '行\n'.repeat(6)]]) {
|
||||||
|
it(`默认折叠 ${label} 并保留完整内容`, () => {
|
||||||
|
const fixture = TestBed.createComponent(ExpandableValueComponent);
|
||||||
|
fixture.componentRef.setInput('label', label);
|
||||||
|
fixture.componentRef.setInput('value', value);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const details: HTMLDetailsElement = fixture.nativeElement.querySelector('details');
|
||||||
|
expect(details.open).toBeFalse();
|
||||||
|
details.querySelector('summary')!.click();
|
||||||
|
expect(details.open).toBeTrue();
|
||||||
|
expect(details.querySelector('pre')!.textContent).toBe(value);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(details.open).toBeTrue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
it('直接显示普通短字段', () => {
|
||||||
|
const fixture = TestBed.createComponent(ExpandableValueComponent);
|
||||||
|
fixture.componentRef.setInput('value', '点击设置'); fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('details')).toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('点击设置');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||||
|
|
||||||
|
/** Visual folding only: the full receipt remains selectable and unchanged. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-expandable-value', standalone: true,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
@if (foldable()) {
|
||||||
|
<details>
|
||||||
|
<summary><span>{{ label() }} · {{ value().length }} 字符</span><span class="hint">展开查看</span></summary>
|
||||||
|
<pre tabindex="0" [attr.aria-label]="label() + '完整内容,可选择复制'">{{ value() }}</pre>
|
||||||
|
</details>
|
||||||
|
} @else { <span class="short-value">{{ value() }}</span> }
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
:host { display:block; min-width:0; max-width:100%; }
|
||||||
|
details { border:1px solid var(--app-border, #e2e8f0); border-radius:8px; background:var(--app-surface-soft, #f8fafc); }
|
||||||
|
summary { cursor:pointer; padding:7px 10px; font-size:12px; color:var(--app-text-secondary, #475569); overflow-wrap:anywhere; }
|
||||||
|
.hint { margin-left:8px; color:var(--app-primary-text, #1a73e8); }
|
||||||
|
details[open] .hint { display:none; }
|
||||||
|
pre { margin:0; padding:10px; max-height:24rem; overflow:auto; white-space:pre-wrap; overflow-wrap:anywhere; font-size:12px; user-select:text; }
|
||||||
|
.short-value { white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||||
|
summary:focus-visible, pre:focus-visible { outline:2px solid var(--app-focus, #a5b4fc); outline-offset:2px; }
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class ExpandableValueComponent {
|
||||||
|
value = input('');
|
||||||
|
label = input('详情');
|
||||||
|
foldable = computed(() => /payload|原始|^result$/i.test(this.label())
|
||||||
|
|| this.value().length > 240 || this.value().split('\n').length > 4);
|
||||||
|
}
|
||||||
+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:var(--app-text-control, #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:var(--app-text-strong, #0f172a); }
|
||||||
|
p { margin:8px 0; line-height:1.65; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||||
|
header p,.muted,small { color:var(--app-text-muted, #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 var(--app-border, #dbe3ec); border-radius:8px; background:var(--app-surface, white); color:var(--app-text-control, #334155); padding:6px 10px; min-height:32px; cursor:pointer; }
|
||||||
|
button:hover:enabled { background:var(--app-surface-soft, #f1f5f9); border-color:var(--app-border-control, #94a3b8); }
|
||||||
|
button:disabled { opacity:.5; cursor:default; }
|
||||||
|
button.primary { background:#0f172a; color:white; border-color:var(--app-border-control, #0f172a); }
|
||||||
|
.material-symbols-outlined { font-size:18px; }
|
||||||
|
label { display:flex; flex-direction:column; gap:6px; color:var(--app-text-secondary, #475569); margin:8px 0; }
|
||||||
|
input,select,textarea { width:100%; min-width:0; border:1px solid var(--app-border-control, #cbd5e1); border-radius:8px; padding:8px 10px; background:var(--app-surface, white); color:var(--app-text-strong, #0f172a); }
|
||||||
|
textarea { resize:vertical; line-height:1.6; }
|
||||||
|
input:focus,select:focus,textarea:focus { outline:2px solid var(--app-border-control, #94a3b8); outline-offset:1px; }
|
||||||
|
details { border:1px solid var(--app-border, #e2e8f0); border-radius:10px; padding:10px 12px; }
|
||||||
|
summary { cursor:pointer; font-size:12px; color:var(--app-text-secondary, #475569); }
|
||||||
|
.session-summary,.case-editor { padding:12px; border:1px solid var(--app-border, #e2e8f0); border-radius:12px; background:var(--app-surface-soft, #f8fafc); }
|
||||||
|
.session-summary .actions { margin-top:10px; }
|
||||||
|
.conversation { display:flex; flex-direction:column; gap:12px; }
|
||||||
|
.message { padding:10px 12px; border-radius:12px; background:var(--app-surface-soft, #f1f5f9); border:1px solid transparent; }
|
||||||
|
.message.user { margin-left:20px; background:var(--app-success-soft, #ecfdf5); border-color:var(--app-success-border, #d1fae5); }
|
||||||
|
.message.system { padding:4px 8px; background:transparent; color:var(--app-text-muted, #64748b); font-size:12px; }
|
||||||
|
.recommendation { border-left:2px solid var(--app-border-control, #94a3b8); padding-left:10px; line-height:1.6; }
|
||||||
|
.recommendation button { margin:6px 0 0 8px; }
|
||||||
|
.composer { border-top:1px solid var(--app-border, #e2e8f0); padding-top:4px; }
|
||||||
|
.composer > button { margin-top:8px; }
|
||||||
|
.demo { border:1px solid var(--app-success-border, #bbf7d0); border-radius:12px; padding:12px; background:var(--app-success-soft, #f0fdf4); }
|
||||||
|
.demo.warning { background:var(--app-warning-soft, #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:var(--app-success-soft, #f0fdf4); border-radius:8px; font-size:12px; }
|
||||||
|
.error { color:var(--app-error-text, #b91c1c); }
|
||||||
|
.feedback.error { background:var(--app-error-soft, #fef2f2); }
|
||||||
|
.run,.demo-history { padding:10px 0; border-bottom:1px solid var(--app-border, #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:var(--app-info-text, #0f766e); font-size:12px; }
|
||||||
|
.empty { color:var(--app-text-muted, #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:var(--app-surface, #fff);border:1px solid var(--app-border, #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:var(--app-surface, #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:var(--app-surface, #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:var(--app-text-muted, #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:var(--app-primary-soft, #eef2ff);color:var(--app-exploration-text, #4f46e5)}
|
||||||
|
.composer-status.waiting {color:var(--app-warning-text, #92400e);background:var(--app-warning-soft, #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:var(--app-primary-border, #a5b4fc);box-shadow:0 0 0 3px #eef2ff}
|
||||||
|
.composer-actions {gap:6px;flex-wrap:wrap;border-top:1px solid var(--app-border, #f1f5f9);padding-top:10px}
|
||||||
|
.composer-actions .tool-button {border:0;background:var(--app-surface-soft, #f8fafc);color:var(--app-text-muted, #64748b);border-radius:10px;padding:6px 9px}
|
||||||
|
.composer-actions .send-hint {flex:1;text-align:right;font-size:10px;color:var(--app-text-faint, #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:var(--app-text-faint, #94a3b8);margin:8px 0 0}
|
||||||
|
.composer.needs-answer {border-color:var(--app-warning-border, #f4c97a)}
|
||||||
|
.question-banner {display:flex;gap:10px;background:var(--app-warning-soft, #fffbeb);border:1px solid var(--app-warning-border, #fde68a);border-radius:12px;padding:12px;margin-bottom:12px;color:var(--app-warning-text, #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:var(--app-warning-soft, #fff7d6);border-color:var(--app-warning-border, #f4c97a);color:var(--app-warning-text, #92400e)}
|
||||||
|
.case-editor {border-color:var(--app-primary-border, #c7d2fe);background:linear-gradient(135deg,var(--app-primary-soft, #f5f7ff),var(--app-surface, #fff));padding:20px;border-radius:18px}
|
||||||
|
.case-title {display:flex;align-items:center;gap:8px;color:var(--app-exploration-text, #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:var(--app-text-control, #1e293b);margin:14px 0 6px}.case-compact small {font-size:11px;font-weight:400;background:var(--app-primary-soft, #e0e7ff);color:var(--app-exploration-text, #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:var(--app-text-muted, #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-terra', 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-terra', '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:var(--app-text-muted, #64748b);cursor:pointer}button:hover{background:var(--app-warning-soft, #fef3c7);color:var(--app-warning-text, #b45309)}button.saved{color:var(--app-warning-text, #c47b0b)}button.saved span{font-variation-settings:'FILL' 1}button:focus-visible{outline:2px solid var(--app-exploration-border, #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 var(--app-error-border, #fecaca);border-radius:8px;background:var(--app-error-soft, #fff7f7);color:var(--app-error-text, #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: var(--app-text-control, #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: var(--app-text-control, #1e293b); }
|
||||||
|
h2 span { font-size: 20px; color: var(--app-warning-text, #d29a32); }
|
||||||
|
.heading p { font-size: 12px; color: var(--app-text-muted, #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: var(--app-text-muted, #64748b); }
|
||||||
|
.filters button.selected { background: var(--app-primary-soft, #e0e7ff); color: var(--app-exploration-text, #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 var(--app-border, #e2e8f0); border-radius: 16px; background: var(--app-surface, #fff); min-width: 0; transition: border-color .15s, box-shadow .15s; }
|
||||||
|
.card:hover { border-color: var(--app-primary-border, #c7d2fe); box-shadow: 0 4px 16px #33415508; }
|
||||||
|
.card.expanded { border-color: var(--app-primary-border, #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: var(--app-surface-soft, #f1f5f9); color: var(--app-text-secondary, #475569); border-radius: 6px; font-size: 11px; line-height: 16px; }
|
||||||
|
.mode.pro { background: var(--app-exploration-soft, #ede9fe); color: var(--app-exploration-text, #6d28d9); }
|
||||||
|
.mode.explore { background: var(--app-info-soft, #e0f2fe); color: var(--app-info-text, #0369a1); }
|
||||||
|
.case-version { font-size: 11px; color: var(--app-text-faint, #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: var(--app-text-control, #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: var(--app-warning-text, #c58b23); }
|
||||||
|
.star span { font-size: 19px; font-variation-settings: 'FILL' 1; }
|
||||||
|
.model-summary { font-size: 12px; line-height: 18px; color: var(--app-text-muted, #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: var(--app-text-muted, #64748b); border: 0; background: transparent; }
|
||||||
|
.primary { border: 1px solid #6366f1; background: #6366f1; color: white; }
|
||||||
|
.primary:hover { background: #4f46e5; }
|
||||||
|
.configuration { border-top: 1px solid var(--app-border, #e2e8f0); margin-top: 14px; padding-top: 14px; display: grid; gap: 12px; }
|
||||||
|
.configuration label { display: grid; gap: 6px; font-size: 12px; color: var(--app-text-secondary, #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 var(--app-border, #dbe3ec); border-radius: 10px; color: var(--app-text-control, #334155); background: var(--app-surface, white); font: inherit; font-size: 13px; }
|
||||||
|
option { color: var(--app-text-control, #334155); background: var(--app-surface, #fff); }
|
||||||
|
.hint { font-size: 11px; color: var(--app-text-muted, #64748b); line-height: 1.5; margin: 7px 0 0; }
|
||||||
|
.save { justify-self: start; padding: 7px 10px; border: 1px solid var(--app-border-control, #cbd5e1); background: var(--app-surface, #fff); color: var(--app-text-secondary, #475569); border-radius: 8px; font-size: 12px; }
|
||||||
|
details { border: 1px solid var(--app-border, #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 var(--app-exploration-border, #818cf8); outline-offset: 2px; }
|
||||||
|
.empty { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 28px 18px; border: 1px dashed var(--app-border-control, #cbd5e1); border-radius: 16px; color: var(--app-text-muted, #64748b); font-size: 12px; line-height: 1.7; }
|
||||||
|
.empty > span { font-size: 28px; color: var(--app-warning-text, #c5a365); margin-bottom: 9px; }
|
||||||
|
.empty strong { font-size: 14px; color: var(--app-text-secondary, #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: var(--app-error-soft, #fff1f2); color: var(--app-error-text, #b91c1c); }
|
||||||
|
.notice { background: var(--app-success-soft, #f0fdf4); color: var(--app-success-text, #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-terra', '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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
-4
@@ -27,8 +27,11 @@ limitations under the License.
|
|||||||
class="floating-video-wrapper"
|
class="floating-video-wrapper"
|
||||||
[class.minimized]="agentService.isVideoMinimized()"
|
[class.minimized]="agentService.isVideoMinimized()"
|
||||||
[class.theater]="isTheaterMode()"
|
[class.theater]="isTheaterMode()"
|
||||||
[style.left.px]="!isTheaterMode() ? posX() : null"
|
[style.--extension-width]="extensionWidth() + 'px'"
|
||||||
|
[style.left.px]="!isTheaterMode() ? windowLeft() : null"
|
||||||
|
[style.width.px]="isTheaterMode() || agentService.isVideoMinimized() ? null : 400 + extensionWidth()"
|
||||||
[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)">
|
||||||
@@ -41,6 +44,9 @@ limitations under the License.
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions" (mousedown)="$event.stopPropagation()">
|
<div class="header-actions" (mousedown)="$event.stopPropagation()">
|
||||||
|
@if (extensionPanels(); as panels) {
|
||||||
|
<ng-container [ngTemplateOutlet]="panels.headerControls() || null" />
|
||||||
|
}
|
||||||
<!-- Mode Toggle Group: Video vs. Step Replay -->
|
<!-- Mode Toggle Group: Video vs. Step Replay -->
|
||||||
@if (agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()) {
|
@if (agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()) {
|
||||||
<div class="mode-toggle-group">
|
<div class="mode-toggle-group">
|
||||||
@@ -95,12 +101,34 @@ 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()) {
|
||||||
|
<app-task-extension-panels [sessionId]="agentService.currentSessionId()" [enabled]="true"
|
||||||
|
[sessionIds]="reviewSessionIds()" (navigate)="agentService.selectSession($event, true)"
|
||||||
|
[taskStatus]="agentService.currentSession()?.status || ''" (expandedWidth)="extensionWidth.set($event)">
|
||||||
<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,8 +360,10 @@ 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()"
|
||||||
@@ -351,8 +381,14 @@ 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"
|
||||||
@@ -364,6 +400,14 @@ 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) {
|
@if (videoStepMarkers().length > 0) {
|
||||||
<div class="video-markers" aria-label="录像关键步骤">
|
<div class="video-markers" aria-label="录像关键步骤">
|
||||||
@for (marker of videoStepMarkers(); track marker.time) {
|
@for (marker of videoStepMarkers(); track marker.time) {
|
||||||
@@ -394,7 +438,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">
|
||||||
@@ -425,6 +469,9 @@ limitations under the License.
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@if (skippedInterval()) {
|
||||||
|
<div class="recording-skip-notice" role="status">{{ skippedInterval() }}</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,6 +496,7 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</app-task-extension-panels>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
+195
-102
@@ -28,15 +28,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.floating-video-wrapper {
|
.floating-video-wrapper {
|
||||||
|
container-type: inline-size;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
width: 400px;
|
width: 400px;
|
||||||
max-width: calc(100vw - 32px);
|
max-width: calc(100vw - 32px);
|
||||||
background: rgba(255, 255, 255, 0.84);
|
max-height: calc(100dvh - var(--player-top, 60px) - 12px);
|
||||||
|
background: rgb(var(--app-surface-rgb, 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%);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.85);
|
border: 1px solid rgb(var(--app-border-rgb, 255 255 255) / 0.85);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 20px 50px -10px rgba(15, 23, 42, 0.22), 0 0 0 1px rgba(226, 232, 240, 0.6), inset 0 1px 1px rgba(255, 255, 255, 0.9);
|
box-shadow: 0 20px 50px -10px rgba(15, 23, 42, 0.22), 0 0 0 1px rgba(226, 232, 240, 0.6), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.9);
|
||||||
z-index: 1050;
|
z-index: 1050;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -59,8 +61,8 @@
|
|||||||
left: 50% !important;
|
left: 50% !important;
|
||||||
top: 50% !important;
|
top: 50% !important;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
width: 85vw;
|
width: min(calc(85vw + var(--extension-width, 0px)), calc(100vw - 32px));
|
||||||
max-width: 960px;
|
max-width: calc(960px + var(--extension-width, 0px));
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 88vh;
|
max-height: 88vh;
|
||||||
box-shadow: 0 24px 60px -12px rgba(15, 23, 42, 0.35);
|
box-shadow: 0 24px 60px -12px rgba(15, 23, 42, 0.35);
|
||||||
@@ -79,9 +81,9 @@
|
|||||||
/* --- Window Header (Draggable Handle) --- */
|
/* --- Window Header (Draggable Handle) --- */
|
||||||
.window-header {
|
.window-header {
|
||||||
height: 44px;
|
height: 44px;
|
||||||
background: rgba(255, 255, 255, 0.75);
|
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.75);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
border-bottom: 1px solid rgba(226, 232, 240, 0.75);
|
border-bottom: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.75);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -103,14 +105,14 @@
|
|||||||
|
|
||||||
.header-icon {
|
.header-icon {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #2563eb;
|
color: var(--app-primary-text, #2563eb);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #0f172a;
|
color: var(--app-text-strong, #0f172a);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -120,9 +122,9 @@
|
|||||||
.mode-switcher-group {
|
.mode-switcher-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: rgba(241, 245, 249, 0.65);
|
background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.65);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
border: 1px solid rgba(226, 232, 240, 0.8);
|
border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.8);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
@@ -131,7 +133,7 @@
|
|||||||
.mode-btn {
|
.mode-btn {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #475569;
|
color: var(--app-text-secondary, #475569);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
@@ -151,8 +153,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
color: #0f172a;
|
color: var(--app-text-strong, #0f172a);
|
||||||
background: rgba(255, 255, 255, 0.6);
|
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
@@ -179,9 +181,9 @@
|
|||||||
.mode-toggle-group {
|
.mode-toggle-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: rgba(241, 245, 249, 0.7);
|
background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.7);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
border: 1px solid rgba(226, 232, 240, 0.85);
|
border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.85);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
@@ -193,7 +195,7 @@
|
|||||||
gap: 3px;
|
gap: 3px;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #475569;
|
color: var(--app-text-secondary, #475569);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
@@ -206,8 +208,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:hover:not(.active) {
|
&:hover:not(.active) {
|
||||||
color: #0f172a;
|
color: var(--app-text-strong, #0f172a);
|
||||||
background: rgba(255, 255, 255, 0.8);
|
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
@@ -221,9 +223,9 @@
|
|||||||
.speed-pill-group {
|
.speed-pill-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: rgba(241, 245, 249, 0.65);
|
background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.65);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
border: 1px solid rgba(226, 232, 240, 0.8);
|
border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.8);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
@@ -231,7 +233,7 @@
|
|||||||
.speed-btn {
|
.speed-btn {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #334155;
|
color: var(--app-text-control, #334155);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
@@ -240,8 +242,8 @@
|
|||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #0f172a;
|
color: var(--app-text-strong, #0f172a);
|
||||||
background: #cbd5e1;
|
background: var(--app-surface-raised, #cbd5e1);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
@@ -254,11 +256,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-btn {
|
.header-btn {
|
||||||
background: rgba(241, 245, 249, 0.75);
|
background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.75);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
border: 1px solid rgba(226, 232, 240, 0.85);
|
border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.85);
|
||||||
color: #1e293b;
|
color: var(--app-text-control, #1e293b);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -266,21 +268,21 @@
|
|||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.8);
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.8);
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.95);
|
||||||
color: #0f172a;
|
color: var(--app-text-strong, #0f172a);
|
||||||
border-color: rgba(203, 213, 225, 0.95);
|
border-color: rgb(var(--app-border-control-rgb, 203 213 225) / 0.95);
|
||||||
transform: translateY(-0.5px);
|
transform: translateY(-0.5px);
|
||||||
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.08);
|
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.close-btn:hover {
|
&.close-btn:hover {
|
||||||
background: rgba(254, 226, 226, 0.95);
|
background: rgb(var(--app-error-soft-rgb, 254 226 226) / 0.95);
|
||||||
color: #dc2626;
|
color: var(--app-error-text, #dc2626);
|
||||||
border-color: rgba(252, 165, 165, 0.9);
|
border-color: rgb(var(--app-error-border-rgb, 252 165 165) / 0.9);
|
||||||
box-shadow: 0 2px 6px rgba(220, 38, 38, 0.15);
|
box-shadow: 0 2px 6px rgba(220, 38, 38, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,8 +300,9 @@
|
|||||||
|
|
||||||
/* --- Video Body Area --- */
|
/* --- Video Body Area --- */
|
||||||
.video-body {
|
.video-body {
|
||||||
|
min-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@@ -312,16 +315,16 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
color: #475569;
|
color: var(--app-text-secondary, #475569);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
.spinning-icon {
|
.spinning-icon {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
color: #2563eb;
|
color: var(--app-primary-text, #2563eb);
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,7 +337,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
min-height: 260px;
|
min-height: 260px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
@@ -344,7 +347,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-status-overlay {
|
.live-status-overlay {
|
||||||
@@ -361,9 +364,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
background: #fef2f2;
|
background: var(--app-error-soft, #fef2f2);
|
||||||
border: 1px solid #fca5a5;
|
border: 1px solid var(--app-error-border, #fca5a5);
|
||||||
color: #dc2626;
|
color: var(--app-error-text, #dc2626);
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 3px 9px;
|
padding: 3px 9px;
|
||||||
@@ -382,10 +385,10 @@
|
|||||||
|
|
||||||
.live-refresh-btn {
|
.live-refresh-btn {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.95);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
border: 1px solid #cbd5e1;
|
border: 1px solid var(--app-border-control, #cbd5e1);
|
||||||
color: #1e293b;
|
color: var(--app-text-control, #1e293b);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -397,9 +400,9 @@
|
|||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f1f5f9;
|
background: var(--app-surface-soft, #f1f5f9);
|
||||||
color: #0f172a;
|
color: var(--app-text-strong, #0f172a);
|
||||||
border-color: #94a3b8;
|
border-color: var(--app-border-control, #94a3b8);
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +416,7 @@
|
|||||||
.step-replay-container {
|
.step-replay-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -422,16 +425,16 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
background: #fffbeb;
|
background: var(--app-warning-soft, #fffbeb);
|
||||||
color: #b45309;
|
color: var(--app-warning-text, #b45309);
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-bottom: 1px solid #fde68a;
|
border-bottom: 1px solid var(--app-warning-border, #fde68a);
|
||||||
|
|
||||||
.notice-icon {
|
.notice-icon {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #d97706;
|
color: var(--app-warning-text, #d97706);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,7 +444,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #f8fafc;
|
background: var(--app-surface-soft, #f8fafc);
|
||||||
min-height: 280px;
|
min-height: 280px;
|
||||||
max-height: 520px;
|
max-height: 520px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -456,21 +459,21 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: #f8fafc;
|
background: var(--app-surface-soft, #f8fafc);
|
||||||
color: #64748b;
|
color: var(--app-text-muted, #64748b);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
|
|
||||||
.spinning-icon {
|
.spinning-icon {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
color: #0284c7;
|
color: var(--app-info-text, #0284c7);
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.broken-icon {
|
.broken-icon {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
color: #94a3b8;
|
color: var(--app-text-faint, #94a3b8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,7 +496,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 20px -2px rgba(15, 23, 42, 0.14), 0 0 0 1px rgba(226, 232, 240, 0.9);
|
box-shadow: 0 4px 20px -2px rgba(15, 23, 42, 0.14), 0 0 0 1px rgba(226, 232, 240, 0.9);
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
transition: opacity 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -530,7 +533,7 @@
|
|||||||
background: rgba(15, 23, 42, 0.88);
|
background: rgba(15, 23, 42, 0.88);
|
||||||
backdrop-filter: blur(14px) saturate(180%);
|
backdrop-filter: blur(14px) saturate(180%);
|
||||||
-webkit-backdrop-filter: blur(14px) saturate(180%);
|
-webkit-backdrop-filter: blur(14px) saturate(180%);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 1px solid rgb(var(--app-border-rgb, 255 255 255) / 0.2);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -578,7 +581,7 @@
|
|||||||
|
|
||||||
.action-type-icon {
|
.action-type-icon {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
color: #38bdf8;
|
color: var(--app-info-text, #38bdf8);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -593,8 +596,8 @@
|
|||||||
.pre-post-toggle {
|
.pre-post-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.12);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
border: 1px solid rgb(var(--app-border-rgb, 255 255 255) / 0.15);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -612,7 +615,7 @@
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
@@ -637,11 +640,11 @@
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.pinned {
|
&.pinned {
|
||||||
color: #38bdf8;
|
color: var(--app-info-text, #38bdf8);
|
||||||
background: rgba(56, 189, 248, 0.2);
|
background: rgba(56, 189, 248, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,36 +658,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.video-container {
|
.video-container {
|
||||||
|
min-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
|
|
||||||
video {
|
video {
|
||||||
|
min-height: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 520px;
|
max-height: 520px;
|
||||||
display: block;
|
display: block;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-controls-footer {
|
.video-controls-footer {
|
||||||
background: rgba(255, 255, 255, 0.84);
|
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.84);
|
||||||
backdrop-filter: blur(20px) saturate(180%);
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
border-top: 1px solid rgba(226, 232, 240, 0.75);
|
border-top: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.75);
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.9);
|
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.9);
|
||||||
|
|
||||||
.scrubber-bar-container {
|
.scrubber-bar-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -710,7 +715,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 5px;
|
height: 5px;
|
||||||
background: rgba(226, 232, 240, 0.85);
|
background: rgb(var(--app-surface-raised-rgb, 226 232 240) / 0.85);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,7 +741,7 @@
|
|||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 2px solid #fff;
|
border: 2px solid var(--app-border, #fff);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #64748b;
|
background: #64748b;
|
||||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.25);
|
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.25);
|
||||||
@@ -771,8 +776,8 @@
|
|||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.9);
|
||||||
border: 1.5px solid #94a3b8;
|
border: 1.5px solid var(--app-border-control, #94a3b8);
|
||||||
transform: translate(-50%, 0);
|
transform: translate(-50%, 0);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -781,17 +786,17 @@
|
|||||||
&:hover {
|
&:hover {
|
||||||
transform: translate(-50%, 0) scale(1.4);
|
transform: translate(-50%, 0) scale(1.4);
|
||||||
border-color: #0284c7;
|
border-color: #0284c7;
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.passed {
|
&.passed {
|
||||||
background: #0284c7;
|
background: #0284c7;
|
||||||
border-color: #ffffff;
|
border-color: var(--app-border, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: #38bdf8;
|
background: #38bdf8;
|
||||||
border-color: #ffffff;
|
border-color: var(--app-border, #ffffff);
|
||||||
transform: translate(-50%, 0) scale(1.3);
|
transform: translate(-50%, 0) scale(1.3);
|
||||||
box-shadow: 0 0 6px rgba(56, 189, 248, 0.8);
|
box-shadow: 0 0 6px rgba(56, 189, 248, 0.8);
|
||||||
}
|
}
|
||||||
@@ -801,22 +806,27 @@
|
|||||||
|
|
||||||
.controls-row {
|
.controls-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
||||||
.controls-left, .controls-right {
|
.controls-left, .controls-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
white-space: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ctrl-btn {
|
.ctrl-btn {
|
||||||
background: rgba(241, 245, 249, 0.75);
|
flex-shrink: 0;
|
||||||
|
background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.75);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
border: 1px solid rgba(226, 232, 240, 0.85);
|
border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.85);
|
||||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.85);
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.85);
|
||||||
color: #0f172a;
|
color: var(--app-text-strong, #0f172a);
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -827,9 +837,9 @@
|
|||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.95);
|
||||||
border-color: rgba(191, 219, 254, 0.9);
|
border-color: rgb(var(--app-primary-border-rgb, 191 219 254) / 0.9);
|
||||||
color: #0284c7;
|
color: var(--app-info-text, #0284c7);
|
||||||
transform: translateY(-0.5px);
|
transform: translateY(-0.5px);
|
||||||
box-shadow: 0 3px 8px rgba(15, 23, 42, 0.08);
|
box-shadow: 0 3px 8px rgba(15, 23, 42, 0.08);
|
||||||
}
|
}
|
||||||
@@ -839,8 +849,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
color: #0284c7;
|
color: var(--app-info-text, #0284c7);
|
||||||
background: rgba(224, 242, 254, 0.85);
|
background: rgb(var(--app-info-soft-rgb, 224 242 254) / 0.85);
|
||||||
border-color: rgba(56, 189, 248, 0.8);
|
border-color: rgba(56, 189, 248, 0.8);
|
||||||
box-shadow: 0 2px 6px rgba(2, 132, 199, 0.15);
|
box-shadow: 0 2px 6px rgba(2, 132, 199, 0.15);
|
||||||
}
|
}
|
||||||
@@ -849,16 +859,16 @@
|
|||||||
background: linear-gradient(135deg, rgba(2, 132, 199, 0.92), rgba(37, 99, 235, 0.88));
|
background: linear-gradient(135deg, rgba(2, 132, 199, 0.92), rgba(37, 99, 235, 0.88));
|
||||||
backdrop-filter: blur(14px) saturate(180%);
|
backdrop-filter: blur(14px) saturate(180%);
|
||||||
-webkit-backdrop-filter: blur(14px) saturate(180%);
|
-webkit-backdrop-filter: blur(14px) saturate(180%);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
border: 1px solid rgb(var(--app-border-rgb, 255 255 255) / 0.35);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 4px 14px rgba(2, 132, 199, 0.35), inset 0 1px 1px rgba(255, 255, 255, 0.5);
|
box-shadow: 0 4px 14px rgba(2, 132, 199, 0.35), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.5);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: linear-gradient(135deg, rgba(2, 132, 199, 1), rgba(37, 99, 235, 0.95));
|
background: linear-gradient(135deg, rgba(2, 132, 199, 1), rgba(37, 99, 235, 0.95));
|
||||||
box-shadow: 0 6px 18px rgba(2, 132, 199, 0.45), inset 0 1px 1px rgba(255, 255, 255, 0.6);
|
box-shadow: 0 6px 18px rgba(2, 132, 199, 0.45), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.6);
|
||||||
transform: translateY(-1px) scale(1.04);
|
transform: translateY(-1px) scale(1.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,18 +889,18 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
|
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
|
||||||
color: #334155;
|
color: var(--app-text-control, #334155);
|
||||||
letter-spacing: -0.2px;
|
letter-spacing: -0.2px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #0284c7;
|
color: var(--app-info-text, #0284c7);
|
||||||
border-color: rgba(191, 219, 254, 0.9);
|
border-color: rgb(var(--app-primary-border-rgb, 191 219 254) / 0.9);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-symbols-outlined {
|
.material-symbols-outlined {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #1e293b;
|
color: var(--app-text-control, #1e293b);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -898,7 +908,7 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
|
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
|
||||||
color: #1e293b;
|
color: var(--app-text-control, #1e293b);
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
@@ -907,7 +917,7 @@
|
|||||||
|
|
||||||
/* --- Clean, Minimal High-Contrast Empty State --- */
|
/* --- Clean, Minimal High-Contrast Empty State --- */
|
||||||
.empty-video-fallback {
|
.empty-video-fallback {
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
padding: 36px 20px;
|
padding: 36px 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -920,8 +930,8 @@
|
|||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #fef2f2;
|
background: var(--app-error-soft, #fef2f2);
|
||||||
border: 1px solid #fee2e2;
|
border: 1px solid var(--app-error-border, #fee2e2);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -929,7 +939,7 @@
|
|||||||
|
|
||||||
.fallback-icon {
|
.fallback-icon {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
color: #ef4444;
|
color: var(--app-error-text, #ef4444);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -937,13 +947,13 @@
|
|||||||
margin: 0 0 6px 0;
|
margin: 0 0 6px 0;
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #0f172a;
|
color: var(--app-text-strong, #0f172a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fallback-desc {
|
.fallback-desc {
|
||||||
margin: 0 0 12px 0;
|
margin: 0 0 12px 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #64748b;
|
color: var(--app-text-muted, #64748b);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
max-width: 240px;
|
max-width: 240px;
|
||||||
}
|
}
|
||||||
@@ -1017,3 +1027,86 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.recording-sources {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--app-surface-soft, #f8fafc);
|
||||||
|
border-bottom: 1px solid var(--app-border, #e2e8f0);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--app-text-secondary, #475569);
|
||||||
|
|
||||||
|
select {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid var(--app-border-control, #cbd5e1);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--app-surface, white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-notice {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--app-warning-soft, #fffbeb);
|
||||||
|
color: var(--app-warning-text, #92400e);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-gap {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--app-text-secondary, #475569);
|
||||||
|
background: var(--app-surface-soft, #f1f5f9);
|
||||||
|
text-align: center;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-coverage-summary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 12px;
|
||||||
|
color: var(--app-text-muted, #64748b);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-skip-notice {
|
||||||
|
color: var(--app-text-muted, #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%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+293
-5
@@ -1,6 +1,8 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { signal } from '@angular/core';
|
import { signal } from '@angular/core';
|
||||||
import { AgentService } from '../../services/agent.service';
|
import { AgentService, RecordingSource, VideoSegment } from '../../services/agent.service';
|
||||||
import { StepReplayFrame } from '../../core/models/stream.model';
|
import { StepReplayFrame } from '../../core/models/stream.model';
|
||||||
import { FloatingVideoPlayerComponent } from './floating-video-player.component';
|
import { FloatingVideoPlayerComponent } from './floating-video-player.component';
|
||||||
|
|
||||||
@@ -20,7 +22,7 @@ describe('FloatingVideoPlayerComponent video markers', () => {
|
|||||||
index: 0, stepNumber: 1, title: '步骤 1: 点击设置', imageUrl: 'step.png',
|
index: 0, stepNumber: 1, title: '步骤 1: 点击设置', imageUrl: 'step.png',
|
||||||
actionText: '点击设置', timestamp: start + 5
|
actionText: '点击设置', timestamp: start + 5
|
||||||
};
|
};
|
||||||
return {
|
const service = {
|
||||||
isVideoWindowOpen: signal(true),
|
isVideoWindowOpen: signal(true),
|
||||||
isVideoMinimized: signal(false),
|
isVideoMinimized: signal(false),
|
||||||
recordingPlaybackStatus: signal('ready'),
|
recordingPlaybackStatus: signal('ready'),
|
||||||
@@ -29,25 +31,46 @@ describe('FloatingVideoPlayerComponent video markers', () => {
|
|||||||
activeVideoTitle: signal('测试录像'),
|
activeVideoTitle: signal('测试录像'),
|
||||||
isVideoLoading: signal(false),
|
isVideoLoading: signal(false),
|
||||||
activeVideoUrl: signal('/test.mp4'),
|
activeVideoUrl: signal('/test.mp4'),
|
||||||
activeVideoSegments: signal([{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 }]),
|
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]),
|
currentSessionStepFrames: signal([frame]),
|
||||||
currentSession: signal({ session_id: 'test', start_time: start }),
|
currentSession: signal({ session_id: 'test', start_time: start }),
|
||||||
currentSessionId: signal('test'),
|
currentSessionId: signal('test'),
|
||||||
videoSeekRequest: signal(null),
|
sessions: signal([{ session_id: 'test', status: 'completed' }]),
|
||||||
|
selectSession: jasmine.createSpy('selectSession'),
|
||||||
|
videoSeekRequest: signal<{ seconds: number; requestId: number; sourceId?: string } | null>(null),
|
||||||
stepSeekRequest: signal(null),
|
stepSeekRequest: signal(null),
|
||||||
consumeVideoAutoplay: jasmine.createSpy('consumeVideoAutoplay').and.returnValue(false)
|
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(() => {
|
beforeEach(() => {
|
||||||
agentService = createAgentService();
|
agentService = createAgentService();
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [FloatingVideoPlayerComponent],
|
imports: [FloatingVideoPlayerComponent],
|
||||||
providers: [{ provide: AgentService, useValue: agentService }]
|
providers: [provideHttpClient(), provideHttpClientTesting(), { provide: AgentService, useValue: agentService }]
|
||||||
});
|
});
|
||||||
|
|
||||||
fixture = TestBed.createComponent(FloatingVideoPlayerComponent);
|
fixture = TestBed.createComponent(FloatingVideoPlayerComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
TestBed.inject(HttpTestingController).expectOne('/api/task-extensions/test').flush({
|
||||||
|
session_id: 'test', initial_prompt: '', editable: true, plugins: []
|
||||||
|
});
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
video = component.videoRef!.nativeElement;
|
video = component.videoRef!.nativeElement;
|
||||||
paused = true;
|
paused = true;
|
||||||
@@ -58,6 +81,7 @@ describe('FloatingVideoPlayerComponent video markers', () => {
|
|||||||
seeking: { configurable: true, get: () => seeking },
|
seeking: { configurable: true, get: () => seeking },
|
||||||
ended: { configurable: true, get: () => false },
|
ended: { configurable: true, get: () => false },
|
||||||
duration: { configurable: true, get: () => 20 },
|
duration: { configurable: true, get: () => 20 },
|
||||||
|
readyState: { configurable: true, get: () => 1 },
|
||||||
currentTime: {
|
currentTime: {
|
||||||
configurable: true, get: () => time,
|
configurable: true, get: () => time,
|
||||||
set: (value: number) => { time = value; seeking = true; }
|
set: (value: number) => { time = value; seeking = true; }
|
||||||
@@ -72,6 +96,7 @@ describe('FloatingVideoPlayerComponent video markers', () => {
|
|||||||
video.dispatchEvent(new Event('play'));
|
video.dispatchEvent(new Event('play'));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
});
|
});
|
||||||
|
video.dispatchEvent(new Event('loadedmetadata'));
|
||||||
});
|
});
|
||||||
|
|
||||||
function finishSeek(): void {
|
function finishSeek(): void {
|
||||||
@@ -80,6 +105,41 @@ describe('FloatingVideoPlayerComponent video markers', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const mode of ['video', 'steps']) {
|
||||||
|
it(`shows review controls in ${mode} mode even without a recording`, () => {
|
||||||
|
agentService.playerMode.set(mode);
|
||||||
|
agentService.recordingPlaybackStatus.set('unavailable');
|
||||||
|
component.extensionPanels()!.refresh(); fixture.detectChanges();
|
||||||
|
TestBed.inject(HttpTestingController).expectOne('/api/task-extensions/test').flush({
|
||||||
|
session_id: 'test', initial_prompt: '', editable: true, plugins: [{
|
||||||
|
plugin_id: 'review', name: '原始用例', version: '1.1.0', revision: 0, values: {}, history: [],
|
||||||
|
workspace: { fields: [], panels: [{ id: 'case', title: '原始用例', side: 'left', kind: 'original_case', width: {minimum: 240, preferred: 340, maximum: 480} }] }
|
||||||
|
}]
|
||||||
|
}); fixture.detectChanges();
|
||||||
|
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button[aria-label="展开原始用例"]');
|
||||||
|
expect(button).not.toBeNull(); button.click(); fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('aside').textContent).toContain('未记录原始用例');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
function playButton(): HTMLButtonElement {
|
||||||
return fixture.nativeElement.querySelector('.play-btn');
|
return fixture.nativeElement.querySelector('.play-btn');
|
||||||
}
|
}
|
||||||
@@ -189,6 +249,7 @@ describe('FloatingVideoPlayerComponent video markers', () => {
|
|||||||
|
|
||||||
it('resumes after seeking a recording without a segment manifest', () => {
|
it('resumes after seeking a recording without a segment manifest', () => {
|
||||||
agentService.activeVideoSegments.set([]);
|
agentService.activeVideoSegments.set([]);
|
||||||
|
agentService.usesSessionTimeline.set(true);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
video.dispatchEvent(new Event('loadedmetadata'));
|
video.dispatchEvent(new Event('loadedmetadata'));
|
||||||
component.togglePlay();
|
component.togglePlay();
|
||||||
@@ -201,6 +262,233 @@ describe('FloatingVideoPlayerComponent video markers', () => {
|
|||||||
expect(play).toHaveBeenCalledTimes(1);
|
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 () => {
|
it('resets a rejected play request so a single subsequent click can resume', async () => {
|
||||||
play.and.callFake(() => {
|
play.and.callFake(() => {
|
||||||
paused = false;
|
paused = false;
|
||||||
|
|||||||
+192
-32
@@ -25,27 +25,40 @@ import {
|
|||||||
ViewChild,
|
ViewChild,
|
||||||
HostListener,
|
HostListener,
|
||||||
effect,
|
effect,
|
||||||
OnDestroy
|
untracked,
|
||||||
|
OnDestroy,
|
||||||
|
viewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
|
import { TaskExtensionPanelsComponent } from '../task-extension-panels/task-extension-panels.component';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { NgTemplateOutlet } from '@angular/common';
|
||||||
import { AgentService } from '../../services/agent.service';
|
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';
|
import { buildVideoStepMarkers, VideoStepMarker } from '../../utils/video-step-markers.util';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-floating-video-player',
|
selector: 'app-floating-video-player',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [FormsModule],
|
imports: [FormsModule, NgTemplateOutlet, TaskExtensionPanelsComponent],
|
||||||
templateUrl: './floating-video-player.component.html',
|
templateUrl: './floating-video-player.component.html',
|
||||||
styleUrl: './floating-video-player.component.scss',
|
styleUrl: './floating-video-player.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class FloatingVideoPlayerComponent implements OnDestroy {
|
export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||||
public agentService = inject(AgentService);
|
public agentService = inject(AgentService);
|
||||||
|
public extensionWidth = signal(0);
|
||||||
|
public reviewSessionIds = computed(() => this.agentService.sessions()
|
||||||
|
.filter(session => ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(session.status || ''))
|
||||||
|
.map(session => session.session_id));
|
||||||
|
public extensionPanels = viewChild(TaskExtensionPanelsComponent);
|
||||||
|
public windowLeft(): number {
|
||||||
|
const width = this.agentService.isVideoMinimized() ? 280 : 400 + this.extensionWidth();
|
||||||
|
return Math.max(16, Math.min(this.posX(), window.innerWidth - Math.min(width, window.innerWidth - 32) - 16));
|
||||||
|
}
|
||||||
private zone = inject(NgZone);
|
private zone = inject(NgZone);
|
||||||
|
|
||||||
@ViewChild('videoRef') videoRef?: ElementRef<HTMLVideoElement>;
|
@ViewChild('videoRef') videoRef?: ElementRef<HTMLVideoElement>;
|
||||||
@@ -61,8 +74,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();
|
||||||
@@ -71,13 +105,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 seekPlaybackIntent: boolean | null = null;
|
||||||
private playRequestId = 0;
|
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());
|
||||||
@@ -86,11 +121,32 @@ 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(
|
public videoStepMarkers = computed(() => buildVideoStepMarkers(
|
||||||
this.stepFrames(),
|
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.currentSession()?.start_time,
|
||||||
this.agentService.activeVideoSegments(),
|
this.agentService.activeVideoSegments(),
|
||||||
this.duration()
|
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>(() => {
|
||||||
@@ -136,12 +192,24 @@ 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.pendingLocalTime = null;
|
||||||
this.pendingAutoplay = false;
|
this.pendingAutoplay = false;
|
||||||
|
if (switching) {
|
||||||
|
this.pendingAbsoluteSeek = switching.time;
|
||||||
|
this.pendingAutoplay = switching.play;
|
||||||
|
}
|
||||||
this.seekPlaybackIntent = null;
|
this.seekPlaybackIntent = null;
|
||||||
this.playRequestId++;
|
this.playRequestId++;
|
||||||
},
|
},
|
||||||
@@ -157,7 +225,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);
|
||||||
@@ -186,15 +254,42 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const request = this.agentService.videoSeekRequest();
|
const request = this.agentService.videoSeekRequest();
|
||||||
if (!request) return;
|
const status = this.agentService.recordingPlaybackStatus();
|
||||||
|
const sources = this.sources();
|
||||||
|
if (!request || status !== 'ready' || request.requestId === this.lastHandledVideoSeekRequestId) return;
|
||||||
|
// A seek is a command, not a binding to playback/source state. Reading
|
||||||
|
// those signals in this effect would replay the command after play/pause
|
||||||
|
// or a manual source selection.
|
||||||
|
untracked(() => {
|
||||||
|
this.lastHandledVideoSeekRequestId = request.requestId;
|
||||||
this.pendingAbsoluteSeek = request.seconds;
|
this.pendingAbsoluteSeek = request.seconds;
|
||||||
if (this.agentService.recordingPlaybackStatus() !== 'ready') return;
|
if (request.sourceId && sources.length) {
|
||||||
const video = this.videoRef?.nativeElement;
|
const source = sources.find(s => s.id === request.sourceId || s.target_ids.includes(request.sourceId!));
|
||||||
if (video && video.readyState >= 1) {
|
if (!source) {
|
||||||
const target = this.pendingAbsoluteSeek;
|
|
||||||
this.pendingAbsoluteSeek = null;
|
this.pendingAbsoluteSeek = null;
|
||||||
this.seekToSessionTime(target, true);
|
this.pendingLocalTime = null;
|
||||||
|
this.pendingAutoplay = false;
|
||||||
|
this.seekPlaybackIntent = null;
|
||||||
|
this.playRequestId++;
|
||||||
|
this.gapCanAdvance = false;
|
||||||
|
this.currentTime.set(request.seconds);
|
||||||
|
this.gapMessage.set('此步骤的来源没有录像,请查看步骤截图或日志。');
|
||||||
|
this.videoRef?.nativeElement.pause();
|
||||||
|
this.isPlaying.set(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (source.id !== this.agentService.selectedRecordingSource?.()) {
|
||||||
|
this.sourceSwitch = { time: request.seconds, play: this.isPlaying() };
|
||||||
|
this.agentService.selectRecordingSource(source.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const video = this.videoRef?.nativeElement;
|
||||||
|
if (video && video.readyState >= 1 && !this.waitingForMetadata) {
|
||||||
|
this.pendingAbsoluteSeek = null;
|
||||||
|
this.seekToSessionTime(request.seconds, this.isPlaying());
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -244,7 +339,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
this.isDragging = true;
|
this.isDragging = true;
|
||||||
this.dragStartX = event.clientX;
|
this.dragStartX = event.clientX;
|
||||||
this.dragStartY = event.clientY;
|
this.dragStartY = event.clientY;
|
||||||
this.initialPosX = this.posX();
|
this.initialPosX = this.windowLeft();
|
||||||
this.initialPosY = this.posY();
|
this.initialPosY = this.posY();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
@@ -305,6 +400,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);
|
||||||
@@ -313,14 +409,6 @@ 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();
|
||||||
if (this.pendingAbsoluteSeek !== null) {
|
|
||||||
const target = this.pendingAbsoluteSeek;
|
|
||||||
this.pendingAbsoluteSeek = null;
|
|
||||||
this.seekToSessionTime(target, true);
|
|
||||||
// A seek into another finalized segment changes the video source. Let
|
|
||||||
// that segment's metadata event apply pendingLocalTime.
|
|
||||||
if (this.pendingLocalTime !== null) return;
|
|
||||||
}
|
|
||||||
const autoplay = this.agentService.consumeVideoAutoplay();
|
const autoplay = this.agentService.consumeVideoAutoplay();
|
||||||
if (autoplay) {
|
if (autoplay) {
|
||||||
this.isMuted.set(true);
|
this.isMuted.set(true);
|
||||||
@@ -328,6 +416,14 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
const shouldPlay = this.pendingAutoplay || autoplay;
|
const shouldPlay = this.pendingAutoplay || autoplay;
|
||||||
this.pendingAutoplay = false;
|
this.pendingAutoplay = false;
|
||||||
|
if (this.pendingAbsoluteSeek !== null) {
|
||||||
|
const target = this.pendingAbsoluteSeek;
|
||||||
|
this.pendingAbsoluteSeek = null;
|
||||||
|
this.seekToSessionTime(target, shouldPlay);
|
||||||
|
// Same-source seeks resume on seeked; cross-segment seeks resume after
|
||||||
|
// the destination metadata and seeked. Never play the old frame here.
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.pendingLocalTime !== null) {
|
if (this.pendingLocalTime !== null) {
|
||||||
const localTime = 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;
|
||||||
@@ -335,10 +431,12 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
} else if (shouldPlay) {
|
} else if (shouldPlay) {
|
||||||
this.playVideo();
|
this.playVideo();
|
||||||
}
|
}
|
||||||
|
if (this.pendingLocalTime === null) this.onTimeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
@@ -375,15 +473,25 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
this.isPlaying.set(false);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,8 +508,22 @@ 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) {
|
if (this.pendingLocalTime !== null) {
|
||||||
this.pendingAutoplay = !this.isPlaying();
|
this.pendingAutoplay = !this.isPlaying();
|
||||||
this.isPlaying.set(this.pendingAutoplay);
|
this.isPlaying.set(this.pendingAutoplay);
|
||||||
@@ -451,7 +573,8 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -462,7 +585,13 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
public seekToVideoMarker(marker: VideoStepMarker): void {
|
||||||
@@ -478,13 +607,30 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
: `步骤 ${first.stepNumber} · ${this.formatTime(marker.time)} · ${action}`;
|
: `步骤 ${first.stepNumber} · ${this.formatTime(marker.time)} · ${action}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seek on the back-to-back playlist axis used by the scrubber. */
|
/** Seek 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
|
||||||
|
// native video seeking instead of treating every timestamp as a gap.
|
||||||
|
const sessionTimeline = this.agentService.usesSessionTimeline?.() && segments.length > 0;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
this.gapMessage.set('');
|
||||||
const shouldPlay = autoplay || this.pendingAutoplay || (this.seekPlaybackIntent ?? !v.paused);
|
const shouldPlay = autoplay || this.pendingAutoplay || (this.seekPlaybackIntent ?? !v.paused);
|
||||||
if (!location) {
|
if (!location) {
|
||||||
this.seekWithinVideo(target, shouldPlay);
|
this.seekWithinVideo(target, shouldPlay);
|
||||||
@@ -497,6 +643,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
this.isPlaying.set(false);
|
this.isPlaying.set(false);
|
||||||
this.pendingLocalTime = location.localTime;
|
this.pendingLocalTime = location.localTime;
|
||||||
this.pendingAutoplay = shouldPlay;
|
this.pendingAutoplay = shouldPlay;
|
||||||
|
this.waitingForMetadata = true;
|
||||||
this.activeSegmentIndex.set(location.index);
|
this.activeSegmentIndex.set(location.index);
|
||||||
}
|
}
|
||||||
this.currentTime.set(target);
|
this.currentTime.set(target);
|
||||||
@@ -505,9 +652,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;
|
||||||
|
|||||||
+46
-46
@@ -15,10 +15,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
$border-color: rgba(0, 0, 0, 0.08);
|
$border-color: rgba(0, 0, 0, 0.08);
|
||||||
$text-primary: #1f2937;
|
$text-primary: var(--app-text-strong, #1f2937);
|
||||||
$text-secondary: #4b5563;
|
$text-secondary: var(--app-text-secondary, #4b5563);
|
||||||
$text-muted: #9ca3af;
|
$text-muted: var(--app-text-faint, #9ca3af);
|
||||||
$bg-main: #f9fafb;
|
$bg-main: var(--app-surface-soft, #f9fafb);
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -32,7 +32,7 @@ $bg-main: #f9fafb;
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: rgba(255, 255, 255, 0.4);
|
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.4);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ $bg-main: #f9fafb;
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
border-bottom: 1px solid $border-color;
|
border-bottom: 1px solid $border-color;
|
||||||
background: rgba(255, 255, 255, 0.6);
|
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.6);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|
||||||
@@ -74,10 +74,10 @@ $bg-main: #f9fafb;
|
|||||||
|
|
||||||
.session-dropdown {
|
.session-dropdown {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
border: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.12);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
background-color: #ffffff;
|
background-color: var(--app-surface, #ffffff);
|
||||||
outline: none;
|
outline: none;
|
||||||
color: $text-primary;
|
color: $text-primary;
|
||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
@@ -92,8 +92,8 @@ $bg-main: #f9fafb;
|
|||||||
|
|
||||||
.btn-refresh {
|
.btn-refresh {
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
border: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.12);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -105,7 +105,7 @@ $bg-main: #f9fafb;
|
|||||||
transition: background 0.2s, transform 0.1s;
|
transition: background 0.2s, transform 0.1s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f3f4f6;
|
background: var(--app-surface-raised, #f3f4f6);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
@@ -119,7 +119,7 @@ $bg-main: #f9fafb;
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
background: rgba(249, 250, 251, 0.3);
|
background: rgb(var(--app-surface-soft-rgb, 249 250 251) / 0.3);
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
@@ -172,8 +172,8 @@ $bg-main: #f9fafb;
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|
||||||
.log-card {
|
.log-card {
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
border: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.06);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.01), 0 2px 4px -1px rgba(0, 0, 0, 0.006);
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.01), 0 2px 4px -1px rgba(0, 0, 0, 0.006);
|
||||||
@@ -214,24 +214,24 @@ $bg-main: #f9fafb;
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
|
|
||||||
&.llm_stream {
|
&.llm_stream {
|
||||||
background: #e0f2fe;
|
background: var(--app-info-soft, #e0f2fe);
|
||||||
color: #0369a1;
|
color: var(--app-info-text, #0369a1);
|
||||||
}
|
}
|
||||||
&.trace_recorded {
|
&.trace_recorded {
|
||||||
background: #f3e8ff;
|
background: var(--app-exploration-soft, #f3e8ff);
|
||||||
color: #6b21a8;
|
color: var(--app-exploration-text, #6b21a8);
|
||||||
}
|
}
|
||||||
&.step_recorded {
|
&.step_recorded {
|
||||||
background: #ecfdf5;
|
background: var(--app-success-soft, #ecfdf5);
|
||||||
color: #047857;
|
color: var(--app-success-text, #047857);
|
||||||
}
|
}
|
||||||
&.step_updated {
|
&.step_updated {
|
||||||
background: #fef3c7;
|
background: var(--app-warning-soft, #fef3c7);
|
||||||
color: #b45309;
|
color: var(--app-warning-text, #b45309);
|
||||||
}
|
}
|
||||||
&.background_tasks_updated {
|
&.background_tasks_updated {
|
||||||
background: #fff1f2;
|
background: var(--app-error-soft, #fff1f2);
|
||||||
color: #be123c;
|
color: var(--app-error-text, #be123c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,28 +260,28 @@ $bg-main: #f9fafb;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.summary-bubble {
|
.summary-bubble {
|
||||||
background: #f1f5f9;
|
background: var(--app-surface-soft, #f1f5f9);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
border: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.05);
|
||||||
border-left: 4px solid #475569;
|
border-left: 4px solid var(--app-border-control, #475569);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: #1e293b;
|
color: var(--app-text-control, #1e293b);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
details {
|
details {
|
||||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
border: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.06);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fafafa;
|
background: var(--app-surface-soft, #fafafa);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
|
|
||||||
&[open] {
|
&[open] {
|
||||||
border-color: rgba(0, 0, 0, 0.12);
|
border-color: rgb(var(--app-border-control-rgb, 0 0 0) / 0.12);
|
||||||
.details-summary {
|
.details-summary {
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
border-bottom: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.06);
|
||||||
background: rgba(0, 0, 0, 0.02);
|
background: rgba(0, 0, 0, 0.02);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,7 +294,7 @@ $bg-main: #f9fafb;
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -306,18 +306,18 @@ $bg-main: #f9fafb;
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #334155;
|
color: var(--app-text-control, #334155);
|
||||||
|
|
||||||
&.thinking-text {
|
&.thinking-text {
|
||||||
background: #f0fdf4;
|
background: var(--app-success-soft, #f0fdf4);
|
||||||
color: #14532d;
|
color: var(--app-success-text, #14532d);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.planning-text {
|
&.planning-text {
|
||||||
background: #f8fafc;
|
background: var(--app-surface-soft, #f8fafc);
|
||||||
color: #334155;
|
color: var(--app-text-control, #334155);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
@@ -326,28 +326,28 @@ $bg-main: #f9fafb;
|
|||||||
&.thinking-details {
|
&.thinking-details {
|
||||||
border-left: 4px solid #16a34a;
|
border-left: 4px solid #16a34a;
|
||||||
.details-summary {
|
.details-summary {
|
||||||
color: #16a34a;
|
color: var(--app-success-text, #16a34a);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.planning-details {
|
&.planning-details {
|
||||||
border-left: 4px solid #4f46e5;
|
border-left: 4px solid #4f46e5;
|
||||||
.details-summary {
|
.details-summary {
|
||||||
color: #4f46e5;
|
color: var(--app-exploration-text, #4f46e5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.tool-details {
|
&.tool-details {
|
||||||
border-left: 4px solid #2563eb;
|
border-left: 4px solid #2563eb;
|
||||||
.details-summary {
|
.details-summary {
|
||||||
color: #2563eb;
|
color: var(--app-primary-text, #2563eb);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.result-details {
|
&.result-details {
|
||||||
border-left: 4px solid #0891b2;
|
border-left: 4px solid #0891b2;
|
||||||
.details-summary {
|
.details-summary {
|
||||||
color: #0891b2;
|
color: var(--app-info-text, #0891b2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,7 +355,7 @@ $bg-main: #f9fafb;
|
|||||||
|
|
||||||
&.raw-body {
|
&.raw-body {
|
||||||
details {
|
details {
|
||||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
border: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.06);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
@@ -366,7 +366,7 @@ $bg-main: #f9fafb;
|
|||||||
color: $text-secondary;
|
color: $text-secondary;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
outline: none;
|
||||||
background: #ffffff;
|
background: var(--app-surface, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.details-content {
|
.details-content {
|
||||||
@@ -392,11 +392,11 @@ $bg-main: #f9fafb;
|
|||||||
height: 4px;
|
height: 4px;
|
||||||
}
|
}
|
||||||
&::-webkit-scrollbar-thumb {
|
&::-webkit-scrollbar-thumb {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.2);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
&::-webkit-scrollbar-thumb:hover {
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(255, 255, 255, 0.3);
|
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
@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>{{ cancellationReason(review.cancel_reason) }}</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>
|
||||||
|
@if (request.previous_attempt_id) { <dt>上次尝试编号</dt><dd>{{ request.previous_attempt_id }}</dd> }
|
||||||
|
<dt>出站请求编号</dt><dd>{{ request.client_request_id || '未记录' }}</dd>
|
||||||
|
<dt>服务端请求编号</dt><dd>{{ request.server_request_id || '服务未提供' }}</dd>
|
||||||
|
<dt>响应编号</dt><dd>{{ request.response_id || '服务未提供' }}</dd>
|
||||||
|
@if (request.server_trace_id) { <dt>服务端链路编号</dt><dd>{{ request.server_trace_id }}</dd> }
|
||||||
|
@if (request.traceparent) { <dt>链路上下文</dt><dd>{{ request.traceparent }}</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>
|
||||||
|
@if (termination(request); as reason) { <dt>结束原因</dt><dd>{{ reason }}</dd> }
|
||||||
|
@if (request.cancel_phase) { <dt>中断阶段</dt><dd>{{ phase(request.cancel_phase) }}</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: var(--app-text-secondary, #475569); font-size: 12px; }
|
||||||
|
.request-live { padding: 12px 14px; border: 1px solid var(--app-primary-border, #dbeafe); border-radius: 10px; background: var(--app-primary-soft, #f8fbff); margin-bottom: 8px; }
|
||||||
|
.request-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||||
|
strong { color: var(--app-text-control, #334155); font-size: 13px; }
|
||||||
|
.model { color: var(--app-text-muted, #64748b); overflow-wrap: anywhere; }
|
||||||
|
.elapsed { margin-left: auto; color: var(--app-primary-text, #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: var(--app-warning-border, #fde68a); background: var(--app-warning-soft, #fffbeb); .elapsed { color: var(--app-warning-text, #92400e); } .waiting { background: #d97706; } }
|
||||||
|
.stale { border-color: var(--app-border-control, #cbd5e1); background: var(--app-surface-soft, #f8fafc); }
|
||||||
|
p { margin: 6px 0 0; line-height: 1.7; }
|
||||||
|
.error { color: var(--app-warning-text, #b45309); overflow-wrap: anywhere; }
|
||||||
|
summary { cursor: pointer; padding: 5px 0; line-height: 1.7; } summary:hover { color: var(--app-primary-text, #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: var(--app-text-muted, #64748b); }
|
||||||
|
a { color: var(--app-primary-text, #2563eb); text-decoration: none; border: 1px solid var(--app-primary-border, #dbeafe); border-radius: 6px; padding: 4px 9px; }
|
||||||
|
.request-history { max-height: 360px; overflow-y: auto; }
|
||||||
|
.request-detail { border-top: 1px solid var(--app-border, #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: var(--app-text-muted, #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: var(--app-surface-soft, #f1f5f9); border-radius: 4px; }
|
||||||
|
.notice { color: var(--app-text-muted, #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; } }
|
||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { cancellationReason, FinalReview, LlmRequestStatusComponent, ModelRequest, requestHint, requestTermination, 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('验收完成');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('取消原因有来源才显示,旧记录与未知字段保留未知', () => {
|
||||||
|
expect(cancellationReason('user_stop')).toContain('用户停止');
|
||||||
|
expect(cancellationReason('guidance_replaced')).toContain('新指导替换');
|
||||||
|
expect(cancellationReason('task_paused')).toContain('任务暂停');
|
||||||
|
expect(cancellationReason('task_replaced')).toContain('新任务替换');
|
||||||
|
expect(cancellationReason('superseded')).toContain('新的检查或计划');
|
||||||
|
expect(cancellationReason()).toContain('取消来源未记录');
|
||||||
|
expect(cancellationReason('untrusted new instruction')).toContain('取消来源未记录');
|
||||||
|
expect(requestTermination({ ...waiting, phase: 'cancelled' })).toContain('取消来源未记录');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('区分三层超时、未完成输出与未确认来源的499', () => {
|
||||||
|
expect(requestTermination({ ...waiting, phase: 'failed', timeout_scope: 'request' })).toContain('单次模型请求');
|
||||||
|
expect(requestTermination({ ...waiting, phase: 'failed', timeout_scope: 'transport' })).toContain('连接或读写超时');
|
||||||
|
expect(requestTermination({ ...waiting, phase: 'failed', timeout_scope: 'final_review' })).toContain('最终验收总预算');
|
||||||
|
expect(requestTermination({ ...waiting, phase: 'cancelled', cancel_reason: 'outer_timeout', timeout_scope: 'outer' })).toContain('外层等待时限');
|
||||||
|
expect(requestTermination({ ...waiting, phase: 'failed', http_status: 499 })).toContain('来源尚未确认');
|
||||||
|
expect(requestTermination({ ...waiting, phase: 'failed', category: 'connection' })).toContain('来源需结合服务端记录');
|
||||||
|
expect(requestTermination({ ...waiting, phase: 'failed', response_guard: { reason: 'missing_completion' } })).toContain('本次输出未被采用');
|
||||||
|
expect(requestTermination({ ...waiting, phase: 'completed' })).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('将本次尝试、上次重试、链路编号与结束原因一起展示', () => {
|
||||||
|
const fixture = TestBed.createComponent(LlmRequestStatusComponent);
|
||||||
|
fixture.componentRef.setInput('sessionId', 'cancelled'); fixture.detectChanges();
|
||||||
|
TestBed.inject(HttpTestingController).expectOne('/api/sessions/cancelled/request-diagnostics').flush({ requests: [{
|
||||||
|
...waiting, active: false, phase: 'cancelled', cancel_reason: 'guidance_replaced',
|
||||||
|
cancel_phase: 'retry_wait', previous_attempt_id: 'attempt-before', client_request_id: 'client-wire-id',
|
||||||
|
server_request_id: 'server-wire-id', response_id: 'resp-from-server', server_trace_id: 'gateway-trace',
|
||||||
|
traceparent: '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', ended_at: Date.now() / 1000
|
||||||
|
}] });
|
||||||
|
fixture.detectChanges();
|
||||||
|
const text = fixture.nativeElement.textContent;
|
||||||
|
for (const value of ['attempt-before', 'client-wire-id', 'server-wire-id', 'resp-from-server', 'gateway-trace', '新指导替换了旧请求', '中断阶段', '等待重试']) expect(text).toContain(value);
|
||||||
|
expect(fixture.nativeElement.querySelector('.request-live')).toBeNull();
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
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;
|
||||||
|
client_request_id?: string | null; previous_attempt_id?: string | null; response_id?: string;
|
||||||
|
server_trace_id?: string | null; traceparent?: string;
|
||||||
|
cancel_reason?: string; cancel_phase?: string; timeout_scope?: string;
|
||||||
|
response_guard?: { reason: string; status?: 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;
|
||||||
|
cancel_reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancellationReason(reason?: string): string {
|
||||||
|
return ({
|
||||||
|
user_stop: '用户停止了任务。',
|
||||||
|
guidance_replaced: '新指导替换了旧请求,任务可按新指导继续。',
|
||||||
|
task_paused: '任务暂停,已中断当前请求。',
|
||||||
|
task_replaced: '新任务替换了当前任务。',
|
||||||
|
superseded: '新的检查或计划替换了当前请求。',
|
||||||
|
outer_timeout: '外层等待时限已到,取消了当前请求。'
|
||||||
|
} as Record<string, string>)[reason || ''] || '取消来源未记录,无法确定是否由用户停止、指导或外层时限触发。';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestTermination(request: ModelRequest): string {
|
||||||
|
if (request.phase === 'cancelled') return cancellationReason(request.cancel_reason);
|
||||||
|
if (request.timeout_scope === 'final_review') return '最终验收总预算已用尽,本次请求已中断。';
|
||||||
|
if (request.timeout_scope === 'request') return '单次模型请求时限已到。';
|
||||||
|
if (request.timeout_scope === 'transport') return '模型连接或读写超时。';
|
||||||
|
if (request.http_status === 499) return '服务返回 HTTP 499;来源尚未确认,需对照本地记录和服务端日志。';
|
||||||
|
if (request.response_guard) return '模型响应未完整结束或工具参数无效,本次输出未被采用。';
|
||||||
|
if (request.category === 'connection') return '模型连接中断或不可用,来源需结合服务端记录核对。';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
termination = requestTermination;
|
||||||
|
cancellationReason = cancellationReason;
|
||||||
|
|
||||||
|
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: var(--app-text-control, #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 var(--app-border, #dbe3ec); border-radius: 10px; background: var(--app-surface, #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: var(--app-text-muted, #64748b); }
|
||||||
|
select:hover:not(:disabled), input:hover:not(:disabled) { border-color: var(--app-primary-border, #a5b4fc); }
|
||||||
|
select:focus-visible, input:focus-visible { outline: 2px solid var(--app-primary-border, #a5b4fc); outline-offset: 2px; }
|
||||||
|
select:disabled, input:disabled { opacity: .5; cursor: default; }
|
||||||
|
option { background: var(--app-surface, #fff); color: var(--app-text-control, #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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
-15
@@ -1,12 +1,18 @@
|
|||||||
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;
|
||||||
|
flash_final_check_enabled: boolean;
|
||||||
step_summarizer_enabled: boolean;
|
step_summarizer_enabled: boolean;
|
||||||
screenshot_limit_enabled: boolean;
|
screenshot_limit_enabled: boolean;
|
||||||
screenshot_max_long_edge: number;
|
screenshot_max_long_edge: number;
|
||||||
@@ -23,13 +29,31 @@ interface Capabilities {
|
|||||||
template: `
|
template: `
|
||||||
<section aria-label="功能开关">
|
<section aria-label="功能开关">
|
||||||
<h3>功能开关</h3>
|
<h3>功能开关</h3>
|
||||||
<p>按需启用,保存后对新任务生效。</p>
|
<p>Flash 最终 AI 判定切换后自动保存;其余设置点击底部按钮保存。保存后对尚未开始的任务生效,不影响运行中的任务。</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" aria-label="Flash 最终 AI 判定与分析" [ngModel]="config.flash_final_check_enabled" (ngModelChange)="saveFlashFinalCheck($event)" [disabled]="busy() || loading() || !loaded()" />
|
||||||
|
<span>Flash 最终 AI 判定与分析<small>默认关闭。开启后,在 Flash 执行结束时根据用例需求和执行步骤复核结果,会增加耗时。关闭时显示“执行完成 · 未做 AI 判定”,仍保留步骤和报告。不影响 Pro。</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>
|
||||||
<label><input type="checkbox" role="switch" [(ngModel)]="config.screenshot_limit_enabled" [disabled]="busy() || loading() || !loaded()" />
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.screenshot_limit_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
@@ -44,6 +68,8 @@ interface Capabilities {
|
|||||||
<label>ADB 命令超时(秒)<input type="number" min="1" max="60" [(ngModel)]="config.device_command_timeout_seconds" [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="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.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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<button type="button" (click)="save()" [disabled]="busy() || loading() || !loaded() || !valid()">{{ busy() ? '正在保存…' : '保存功能设置' }}</button>
|
<button type="button" (click)="save()" [disabled]="busy() || loading() || !loaded() || !valid()">{{ busy() ? '正在保存…' : '保存功能设置' }}</button>
|
||||||
@@ -51,44 +77,83 @@ interface Capabilities {
|
|||||||
@if (error()) { <p role="alert" class="error">{{ error() }}</p> }
|
@if (error()) { <p role="alert" class="error">{{ error() }}</p> }
|
||||||
</section>
|
</section>
|
||||||
`,
|
`,
|
||||||
styles: [`section { margin-top: 24px; padding-top: 20px; border-top: 1px solid #64748b55; }
|
styles: [`section { margin-top: 24px; padding-top: 20px; border-top: 1px solid rgb(var(--app-border-control-rgb, 100 116 139) / 0.3333); }
|
||||||
h3 { margin: 0 0 8px; } p, small { color: #64748b; line-height: 1.6; }
|
h3 { margin: 0 0 8px; } p, small { color: var(--app-text-muted, #64748b); line-height: 1.6; }
|
||||||
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: var(--app-info-text, #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: var(--app-error-text, #be123c); }
|
||||||
.execution-settings { margin: 12px 0 20px; padding: 12px 14px; border: 1px solid #cbd5e1; border-radius: 10px; background: #f8fafc; }
|
.execution-settings { margin: 12px 0 20px; padding: 12px 14px; border: 1px solid var(--app-border-control, #cbd5e1); border-radius: 10px; background: var(--app-surface-soft, #f8fafc); }
|
||||||
summary { cursor: pointer; font-weight: 600; color: #334155; }
|
summary { cursor: pointer; font-weight: 600; color: var(--app-text-control, #334155); }
|
||||||
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; }
|
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; }
|
||||||
.number-fields label { display: block; padding: 0; color: #334155; }
|
.number-fields label { display: block; padding: 0; color: var(--app-text-control, #334155); }
|
||||||
.number-fields input { box-sizing: border-box; display: block; width: 100%; height: auto; margin-top: 8px; padding: 9px; border: 1px solid #cbd5e1; border-radius: 8px; font: inherit; }
|
.number-fields input, .number-fields select { box-sizing: border-box; display: block; width: 100%; height: auto; margin-top: 8px; padding: 9px; border: 1px solid var(--app-border-control, #cbd5e1); border-radius: 8px; font: inherit; }
|
||||||
|
section {font-size:13px;border-color:var(--app-border, #e2e8f0)}h3 {font-size:15px;color:var(--app-text-control, #334155)}
|
||||||
|
section > label {align-items:center;flex-direction:row-reverse;justify-content:space-between;border-bottom:1px solid var(--app-border, #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 var(--app-border-control, #cbd5e1);border-radius:20px;background:var(--app-surface-raised, #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:var(--app-surface, #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 var(--app-primary-border, #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:var(--app-border, #e2e8f0);border-radius:14px;padding:14px 16px}.number-fields input,.number-fields select {background:var(--app-surface, #fff);border-color:var(--app-border, #dbe3ec)}
|
||||||
@media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }`]
|
@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, 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 };
|
private savedConfig: Capabilities | null = null;
|
||||||
|
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, flash_final_check_enabled: false, 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.savedConfig = structuredClone(config); this.config = config; this.loaded.set(true); this.changed.emit(config); }, error: () => this.error.set('功能配置加载失败,请刷新页面重试。')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
saveFlashFinalCheck(enabled: boolean): void {
|
||||||
|
if (this.busy() || !this.loaded() || !this.savedConfig) return;
|
||||||
|
const previous = this.savedConfig.flash_final_check_enabled;
|
||||||
|
this.config.flash_final_check_enabled = enabled;
|
||||||
|
this.busy.set(true); this.message.set('正在保存 Flash 判定设置…'); this.error.set('');
|
||||||
|
// Preserve other settings that are still being edited, including invalid drafts.
|
||||||
|
const update = { ...this.savedConfig, flash_final_check_enabled: enabled };
|
||||||
|
this.http.post<Capabilities>('/api/system/capability-settings', update).pipe(finalize(() => this.busy.set(false))).subscribe({
|
||||||
|
next: config => {
|
||||||
|
this.savedConfig = structuredClone(config);
|
||||||
|
this.config.flash_final_check_enabled = config.flash_final_check_enabled;
|
||||||
|
this.changed.emit(config);
|
||||||
|
this.message.set(`Flash 最终 AI 判定已${config.flash_final_check_enabled ? '开启' : '关闭'},尚未开始的任务将使用此设置,无需重启服务。`);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.config.flash_final_check_enabled = previous;
|
||||||
|
this.message.set('');
|
||||||
|
this.error.set('Flash 判定设置保存失败,开关已恢复。请检查连接后重新切换。');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
save(): void {
|
save(): void {
|
||||||
if (this.busy() || this.loading() || !this.loaded() || !this.valid()) 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.savedConfig = structuredClone(config); this.config = config; this.changed.emit(config); this.message.set('功能设置已保存,新任务将使用此配置。'); },
|
||||||
error: () => this.error.set('保存失败,请检查服务器连接和 .env 文件权限。')
|
error: () => this.error.set('保存失败,请检查服务器连接和 .env 文件权限。')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
valid(): boolean {
|
valid(): boolean {
|
||||||
const c = this.config;
|
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
|
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
|
&& 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
|
&& 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
|
&& 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
|
&& 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.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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+118
-32
@@ -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,17 +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',
|
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',
|
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,
|
tool_fallback_max_retries: 3, tool_fallback_no_progress_attempts: 3,
|
||||||
model_presets: ['gpt-5.6-luna', 'gpt-6-luna', 'mimo-v2.6-flash', 'mimo-v2.6-pro'], env_path: '/app/.env' };
|
model_presets: ['gpt-6-luna', 'gpt-6.1-terra', '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();
|
||||||
@@ -42,41 +55,31 @@ describe('持久化模型与功能设置', () => {
|
|||||||
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(Array.from(presets, button => button.textContent?.trim())).toEqual([
|
expect(Array.from(select.options, option => option.value)).toEqual([...config.model_presets, '__custom__']);
|
||||||
'gpt-5.6-luna', 'gpt-6-luna', 'mimo-v2.6-flash', 'mimo-v2.6-pro'
|
expect(select.value).toBe('gpt-6-luna');
|
||||||
]);
|
select.value = 'mimo-v2.6-pro';
|
||||||
presets[0].click();
|
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');
|
|
||||||
presets[1].click();
|
|
||||||
fixture.detectChanges();
|
|
||||||
await fixture.whenStable();
|
|
||||||
expect(input.value).toBe('gpt-6-luna');
|
|
||||||
presets[3].click();
|
|
||||||
fixture.detectChanges();
|
|
||||||
await fixture.whenStable();
|
|
||||||
expect(input.value).toBe('mimo-v2.6-pro');
|
|
||||||
expect(input.getAttribute('list')).toBeNull();
|
|
||||||
expect(input.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');
|
||||||
@@ -89,8 +92,8 @@ 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 () => {
|
it('高级稳定性设置可编辑并保存', async () => {
|
||||||
@@ -101,7 +104,12 @@ describe('持久化模型与功能设置', () => {
|
|||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
const root: HTMLElement = fixture.nativeElement;
|
const root: HTMLElement = fixture.nativeElement;
|
||||||
root.querySelector<HTMLDetailsElement>('.recovery-settings')!.open = true;
|
root.querySelector<HTMLDetailsElement>('.recovery-settings')!.open = true;
|
||||||
const fallback = root.querySelector<HTMLInputElement>('input[name="toolFallbackModel"]')!;
|
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.value = 'custom/fallback';
|
||||||
fallback.dispatchEvent(new Event('input'));
|
fallback.dispatchEvent(new Event('input'));
|
||||||
const wait = root.querySelector<HTMLInputElement>('input[name="rateLimitRetry"]')!;
|
const wait = root.querySelector<HTMLInputElement>('input[name="rateLimitRetry"]')!;
|
||||||
@@ -122,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('成功');
|
||||||
@@ -138,7 +146,7 @@ describe('持久化模型与功能设置', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('录屏与视频分析独立保存,重新打开后保留各自状态', () => {
|
it('录屏与视频分析独立保存,重新打开后保留各自状态', () => {
|
||||||
const capabilities = { 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 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);
|
||||||
@@ -162,7 +170,7 @@ describe('持久化模型与功能设置', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('执行保护默认折叠,修改时限可保存且拒绝无效数值', async () => {
|
it('执行保护默认折叠,修改时限可保存且拒绝无效数值', async () => {
|
||||||
const capabilities = { 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 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);
|
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);
|
||||||
@@ -190,4 +198,82 @@ describe('持久化模型与功能设置', () => {
|
|||||||
fixture.componentInstance.save();
|
fixture.componentInstance.save();
|
||||||
http.expectNone('/api/system/capability-settings');
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Flash 判定切换即保存,失败恢复原值,重新切换后持久生效', async () => {
|
||||||
|
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
||||||
|
const capabilities = { ...fixture.componentInstance.config };
|
||||||
|
fixture.detectChanges();
|
||||||
|
const root: HTMLElement = fixture.nativeElement;
|
||||||
|
const toggle = root.querySelector<HTMLInputElement>('[aria-label="Flash 最终 AI 判定与分析"]')!;
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(toggle.disabled).toBeTrue();
|
||||||
|
http.expectOne('/api/system/capability-settings').flush(capabilities);
|
||||||
|
fixture.detectChanges(); await fixture.whenStable();
|
||||||
|
expect(toggle.checked).toBeFalse();
|
||||||
|
expect(root.textContent).toContain('不影响 Pro');
|
||||||
|
toggle.click(); fixture.detectChanges(); await fixture.whenStable();
|
||||||
|
let request = http.expectOne('/api/system/capability-settings');
|
||||||
|
expect(request.request.body.flash_final_check_enabled).toBeTrue();
|
||||||
|
expect(fixture.componentInstance.busy()).toBeTrue();
|
||||||
|
request.flush({}, {status:500,statusText:'Error'});
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(fixture.componentInstance.config.flash_final_check_enabled).toBeFalse();
|
||||||
|
expect(toggle.checked).toBeFalse();
|
||||||
|
expect(fixture.componentInstance.error()).toContain('保存失败');
|
||||||
|
toggle.click(); fixture.detectChanges(); await fixture.whenStable();
|
||||||
|
request = http.expectOne('/api/system/capability-settings');
|
||||||
|
const saved = request.request.body;
|
||||||
|
request.flush(saved);
|
||||||
|
expect(fixture.componentInstance.message()).toContain('无需重启');
|
||||||
|
fixture.destroy();
|
||||||
|
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
|
||||||
|
reopened.detectChanges();
|
||||||
|
http.expectOne('/api/system/capability-settings').flush(saved);
|
||||||
|
reopened.detectChanges(); await reopened.whenStable();
|
||||||
|
expect(reopened.nativeElement.querySelector('[aria-label="Flash 最终 AI 判定与分析"]').checked).toBeTrue();
|
||||||
|
reopened.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('自动保存 Flash 判定不提交其他未保存或无效的草稿', async () => {
|
||||||
|
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
||||||
|
const capabilities = { ...fixture.componentInstance.config };
|
||||||
|
fixture.detectChanges();
|
||||||
|
http.expectOne('/api/system/capability-settings').flush(capabilities);
|
||||||
|
fixture.detectChanges(); await fixture.whenStable();
|
||||||
|
fixture.componentInstance.config.task_timeout_seconds = 0;
|
||||||
|
fixture.componentInstance.config.recording_enabled = true;
|
||||||
|
const toggle: HTMLInputElement = fixture.nativeElement.querySelector('[aria-label="Flash 最终 AI 判定与分析"]');
|
||||||
|
toggle.click(); fixture.detectChanges(); await fixture.whenStable();
|
||||||
|
const request = http.expectOne('/api/system/capability-settings');
|
||||||
|
expect(request.request.body.flash_final_check_enabled).toBeTrue();
|
||||||
|
expect(request.request.body.task_timeout_seconds).toBe(1800);
|
||||||
|
expect(request.request.body.recording_enabled).toBeFalse();
|
||||||
|
request.flush(request.request.body);
|
||||||
|
expect(fixture.componentInstance.config.task_timeout_seconds).toBe(0);
|
||||||
|
expect(fixture.componentInstance.config.recording_enabled).toBeTrue();
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
@@ -24,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>
|
||||||
@@ -32,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()" />
|
||||||
@@ -66,16 +54,8 @@ interface ModelSettings {
|
|||||||
<div class="recovery-content">
|
<div class="recovery-content">
|
||||||
<label>429 等待重试(秒)<input name="rateLimitRetry" type="number" min="0" max="3600" required
|
<label>429 等待重试(秒)<input name="rateLimitRetry" type="number" min="0" max="3600" required
|
||||||
[(ngModel)]="rateLimitRetry" [disabled]="busy() || loading()" /></label>
|
[(ngModel)]="rateLimitRetry" [disabled]="busy() || loading()" /></label>
|
||||||
<label>兜底模型名称<input name="toolFallbackModel" type="text" required autocomplete="off"
|
<app-model-selector label="兜底模型名称" [(value)]="toolFallbackModel" [presets]="modelPresets" [disabled]="!!busy() || loading()" />
|
||||||
autocapitalize="off" spellcheck="false" [(ngModel)]="toolFallbackModel"
|
<app-setting-slider label="兜底思考强度" [(value)]="toolFallbackReasoning" [values]="reasoningValues" [labels]="reasoningLabels" [disabled]="!!busy() || loading()" />
|
||||||
[disabled]="busy() || loading()" /></label>
|
|
||||||
<label>兜底思考强度<select name="toolFallbackReasoning" [(ngModel)]="toolFallbackReasoning"
|
|
||||||
[disabled]="busy() || loading()">
|
|
||||||
<option value="default">由模型默认</option><option value="none">关闭(none)</option>
|
|
||||||
<option value="minimal">最低(minimal)</option><option value="low">低(low)</option>
|
|
||||||
<option value="medium">中(medium)</option><option value="high">高(high)</option>
|
|
||||||
<option value="xhigh">极高(xhigh)</option><option value="max">最高(max)</option>
|
|
||||||
</select></label>
|
|
||||||
<div class="number-fields">
|
<div class="number-fields">
|
||||||
<label>兜底请求重试次数<input name="toolFallbackRetries" type="number" min="0" max="10" required
|
<label>兜底请求重试次数<input name="toolFallbackRetries" type="number" min="0" max="10" required
|
||||||
[(ngModel)]="toolFallbackRetries" [disabled]="busy() || loading()" /></label>
|
[(ngModel)]="toolFallbackRetries" [disabled]="busy() || loading()" /></label>
|
||||||
@@ -98,33 +78,35 @@ interface ModelSettings {
|
|||||||
</form>
|
</form>
|
||||||
`,
|
`,
|
||||||
styles: [`
|
styles: [`
|
||||||
.model-settings { display: grid; gap: 10px; color: #1e293b; }
|
.model-settings { display: grid; gap: 10px; color: var(--app-text-control, #1e293b); }
|
||||||
p, small { margin: 0; line-height: 1.6; color: #64748b; }
|
p, small { margin: 0; line-height: 1.6; color: var(--app-text-muted, #64748b); }
|
||||||
label { font-weight: 600; } label span { font-weight: 400; font-size: 12px; }
|
label { font-weight: 600; } label span { font-weight: 400; font-size: 12px; }
|
||||||
input, select { box-sizing: border-box; width: 100%; padding: 12px; border-radius: 10px; border: 1px solid #64748b66;
|
input, select { box-sizing: border-box; width: 100%; padding: 12px; border-radius: 10px; border: 1px solid rgb(var(--app-border-control-rgb, 100 116 139) / 0.4000);
|
||||||
background: #f8fafc; color: inherit; font: inherit; }
|
background: var(--app-surface-soft, #f8fafc); color: inherit; font: inherit; }
|
||||||
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 { border: 1px solid var(--app-border-control, #cbd5e1); border-radius: 10px; background: var(--app-surface-soft, #f8fafc); padding: 10px 14px; }
|
||||||
.recovery-settings summary { cursor: pointer; font-weight: 600; color: #334155; }
|
.recovery-settings summary { cursor: pointer; font-weight: 600; color: var(--app-text-control, #334155); }
|
||||||
.recovery-content { display: grid; gap: 10px; padding-top: 12px; }
|
.recovery-content { display: grid; gap: 10px; padding-top: 12px; }
|
||||||
.recovery-content label input, .recovery-content label select { margin-top: 8px; }
|
.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: var(--app-success-text, #047857); } .error { color: var(--app-error-text, #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:var(--app-surface, #fff);border-color:var(--app-border, #dbe3ec);padding:11px 12px}
|
||||||
|
input:focus,select:focus {outline:2px solid var(--app-primary-border, #a5b4fc);outline-offset:2px}
|
||||||
|
.actions {border-top:1px solid var(--app-border, #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:var(--app-surface, #fff);border-color:var(--app-border, #dbe3ec);color:var(--app-text-secondary, #475569)}
|
||||||
|
.recovery-settings {border-color:var(--app-border, #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>();
|
||||||
modelPresets: string[] = [];
|
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';
|
rateLimitRetry = 60; toolFallbackModel = 'gpt-6-luna'; toolFallbackReasoning = 'high';
|
||||||
toolFallbackRetries = 3; toolFallbackNoProgress = 3;
|
toolFallbackRetries = 3; toolFallbackNoProgress = 3;
|
||||||
@@ -133,7 +115,7 @@ export class ModelSettingsComponent implements OnInit {
|
|||||||
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('配置加载失败,请刷新页面重试。')
|
||||||
});
|
});
|
||||||
|
|||||||
Loaded 100 of 548 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user