Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42f5f3ed55 | ||
|
|
838e9142a9 | ||
|
|
351b2fc905 | ||
|
|
32441e79c4 | ||
|
|
50638d4130 | ||
|
|
a4a5430922 | ||
|
|
e1ea099592 | ||
|
|
2328a4df0c | ||
|
|
f1bb5d02aa | ||
|
|
1440a1f508 | ||
|
|
a66803651a | ||
|
|
621f30e437 | ||
|
|
ee256ad7ce | ||
|
|
f86db1ced6 | ||
|
|
ef7af9988e | ||
|
|
72ff342605 | ||
|
|
96fedc816f | ||
|
|
9c82a40cbf | ||
|
|
6663233ab8 | ||
|
|
3ee4a5f7a8 | ||
|
|
bdc75d0c65 |
No files matched your search
+19
-3
@@ -3,7 +3,7 @@
|
|||||||
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
|
||||||
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
|
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
|
||||||
@@ -19,9 +19,15 @@ ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS=3
|
|||||||
# default 表示不发送思考强度参数;接口不支持所选值时明确报错。
|
# default 表示不发送思考强度参数;接口不支持所选值时明确报错。
|
||||||
OPENAI_REASONING_EFFORT=high
|
OPENAI_REASONING_EFFORT=high
|
||||||
|
|
||||||
|
# 1 跳过本次网页启动的 Codex MCP 与官方 artemis-use Skill 同步;默认自动安装/更新。
|
||||||
|
ARTEMIS_SKIP_CODEX_SYNC=0
|
||||||
|
|
||||||
# 功能开关:保存后对新任务生效
|
# 功能开关:保存后对新任务生效
|
||||||
ARTEMIS_VIDEO_ENABLED=false
|
ARTEMIS_VIDEO_ENABLED=false
|
||||||
ARTEMIS_RECORDING_ENABLED=false
|
ARTEMIS_RECORDING_ENABLED=false
|
||||||
|
# 留档编码与质量;自动模式只有客户端确认 HEVC 可播放时才优先 H.265,否则使用 H.264。
|
||||||
|
# JSON 配置 agent.recording 和任务 recording 可覆盖。截图分辨率不受此配置影响。
|
||||||
|
ARTEMIS_RECORDING_CONFIG={"codec":"auto","max_long_edge":1280,"fps":15,"h265_kbps":1000,"h264_kbps":1500,"computer_scope":"screen","minimum_disk_mb":256,"hevc_playback":false}
|
||||||
ARTEMIS_OUTPUTTER_ENABLED=true
|
ARTEMIS_OUTPUTTER_ENABLED=true
|
||||||
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
||||||
# 默认将任务截图长边压至 2048;false 保留设备原始截图。长边值可在 config/artemis.jsonc 的 agent.screenshot 中覆盖。
|
# 默认将任务截图长边压至 2048;false 保留设备原始截图。长边值可在 config/artemis.jsonc 的 agent.screenshot 中覆盖。
|
||||||
@@ -45,14 +51,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,独立于模型调用,不配置也可运行。
|
||||||
@@ -75,3 +81,13 @@ ARTEMIS_DEFAULT_PROFILE=flash
|
|||||||
ARTEMIS_IMPORT_MAX_ROWS=1000
|
ARTEMIS_IMPORT_MAX_ROWS=1000
|
||||||
ARTEMIS_BATCH_MAX_EXECUTIONS=10000
|
ARTEMIS_BATCH_MAX_EXECUTIONS=10000
|
||||||
ARTEMIS_IMPORT_MAX_BYTES=10485760
|
ARTEMIS_IMPORT_MAX_BYTES=10485760
|
||||||
|
|
||||||
|
# 插件连接与安装预算(修改后重启服务)
|
||||||
|
ARTEMIS_PLUGIN_MARKETPLACE_URL=https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json
|
||||||
|
ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS=30
|
||||||
|
ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS=60
|
||||||
|
ARTEMIS_PLUGIN_SHUTDOWN_TIMEOUT_SECONDS=15
|
||||||
|
ARTEMIS_PLUGIN_DOWNLOAD_TIMEOUT_SECONDS=300
|
||||||
|
ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES=134217728
|
||||||
|
ARTEMIS_PLUGIN_EXTRACT_MAX_BYTES=536870912
|
||||||
|
ARTEMIS_PLUGIN_RESULT_MAX_BYTES=16777216
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
- 本文件适用于整个仓库;子目录的 AGENTS.md 在对应目录内优先。
|
- 本文件适用于整个仓库;子目录的 AGENTS.md 在对应目录内优先。
|
||||||
- 当前任务的用户指令优先于仓库指南。保留用户已有修改,只处理本次任务范围。
|
- 当前任务的用户指令优先于仓库指南。保留用户已有修改,只处理本次任务范围。
|
||||||
- 本文件指导开发;使用软件执行 Android 测试时,阅读 [自动化 Skill](skills/artemis-automation/SKILL.md)。
|
- 本文件指导开发;使用软件执行 Android 测试时,阅读 [自动化 Skill](skills/artemis-use/SKILL.md)。
|
||||||
- 以下是项目原生命令;宿主要求 RTK 等包装器时按宿主规则调用,不把本机路径或工具写成项目安装前提。
|
- 以下是项目原生命令;宿主要求 RTK 等包装器时按宿主规则调用,不把本机路径或工具写成项目安装前提。
|
||||||
|
|
||||||
## Quick Commands
|
## Quick Commands
|
||||||
@@ -38,7 +38,7 @@ Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。启
|
|||||||
- `apps/admin_console/`:HTTP API、统一队列、批次、历史及报告服务。
|
- `apps/admin_console/`:HTTP API、统一队列、批次、历史及报告服务。
|
||||||
- `apps/showcase_ui/`:Angular 网页;遵循 [前端指南](apps/showcase_ui/AGENTS.md)。
|
- `apps/showcase_ui/`:Angular 网页;遵循 [前端指南](apps/showcase_ui/AGENTS.md)。
|
||||||
- `mcp_server/`:MCP 工具与客户端使用规则。
|
- `mcp_server/`:MCP 工具与客户端使用规则。
|
||||||
- `packages/artemis-client/`:远程 Python SDK;`skills/artemis-automation/`:随源码分发的 AI 使用技能。
|
- `packages/artemis-client/`:远程 Python SDK;`skills/artemis-use/`:随源码分发的 AI 使用技能。
|
||||||
- `scripts/`:安装、镜像、升级、检查及打包脚本。
|
- `scripts/`:安装、镜像、升级、检查及打包脚本。
|
||||||
- `tests/unit/`、`tests/tools/`、`packages/artemis-client/tests/`:默认测试范围。
|
- `tests/unit/`、`tests/tools/`、`packages/artemis-client/tests/`:默认测试范围。
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,16 @@
|
|||||||
|
|
||||||
基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。
|
基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。
|
||||||
|
|
||||||
|
**2.0.0 已发布**:新增交互探索、插件扩展和精选任务,增强跨端证据与 AI 接入。完整变化及升级步骤见 [2.0.0 发行说明](docs/releases/2.0.0.md)。
|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
- **自然语言任务**:Flash 适合明确的操作流程,Pro 适合需要计划、分支探索和验证的任务。
|
- **自然语言任务**:Flash 适合明确的操作流程,Pro 适合需要计划、分支探索和验证的任务。
|
||||||
|
- **交互探索**:边执行边交流,也可录制人工示范;探索成功后生成一个可编辑、复跑的用例。默认使用独立的 `gpt-6.1-sol / high` 配置,超时暂停并保留进度。见 [探索模式](docs/exploration-mode.md)。
|
||||||
|
- **插件扩展**:在插件市场安装并启用电脑控制等能力,AI 在安卓任务中按需使用;手机、电脑的步骤与证据统一留档。见 [插件与跨端任务](docs/plugins.md)。
|
||||||
- **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。
|
- **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。
|
||||||
- **批量测试**:按新版 Excel 模板导入提示词,每项可独立指定模型、模式和参数;任务可暂停、补充指导并继续。
|
- **批量测试**:按新版 Excel 模板导入提示词,每项可独立指定模型、模式和参数;任务可暂停、补充指导并继续。
|
||||||
|
- **精选任务**:收藏单任务、批次或探索用例,在首页一键复跑,默认沿用已保存的配置。
|
||||||
- **报告与历史**:导出 Word、Excel、步骤截图、录屏和任务资料 ZIP;历史按日期分组,支持搜索和筛选。
|
- **报告与历史**:导出 Word、Excel、步骤截图、录屏和任务资料 ZIP;历史按日期分组,支持搜索和筛选。
|
||||||
- **AI 接入**:随源码提供 MCP 与 Skill,让 AI 完成设备诊断、任务执行、历史分析和报告交付。
|
- **AI 接入**:随源码提供 MCP 与 Skill,让 AI 完成设备诊断、任务执行、历史分析和报告交付。
|
||||||
|
|
||||||
@@ -45,10 +50,10 @@ cd Artemis-Eapil
|
|||||||
|
|
||||||
1. 在首页“系统设置与运行准备”中选择设备,打开“模型配置”。
|
1. 在首页“系统设置与运行准备”中选择设备,打开“模型配置”。
|
||||||
2. 填写支持 Responses 的接口地址、API Key 和模型名称,测试连接后保存。接口地址通常是以 `/v1` 结尾的 API 根地址。
|
2. 填写支持 Responses 的接口地址、API Key 和模型名称,测试连接后保存。接口地址通常是以 `/v1` 结尾的 API 根地址。
|
||||||
3. 输入任务目标,选择 Flash 或 Pro 后执行;在工作区查看步骤、截图和结果。
|
3. 输入任务目标,选择 Flash、Pro 或探索后执行;在工作区查看步骤、截图和结果。流程尚不明确时,选择探索并随时交流。
|
||||||
4. 通过“报告与批量”导出报告或导入 Excel 用例;任务队列支持历史搜索。
|
4. 通过“报告与批量”导出报告或导入 Excel 用例;任务队列支持历史搜索。
|
||||||
|
|
||||||
预设模型为 `gpt-5.6-luna`、`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-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`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
|
||||||
|
|
||||||
@@ -64,20 +69,24 @@ cd Artemis-Eapil
|
|||||||
uv run artemis mcp --install codex
|
uv run artemis mcp --install codex
|
||||||
```
|
```
|
||||||
|
|
||||||
安装或更新后,在 Codex 中重新加载 MCP 或重启 Codex,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。可用 `uv run artemis mcp --generate-config codex` 查看配置模板。
|
使用工具前,由用户先启动 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 报告。
|
||||||
|
|
||||||
## 更新与排错
|
## 更新与排错
|
||||||
|
|
||||||
启动脚本只检查并提示源码更新,不自动拉取。升级运行 **`.\update.bat`**,国内网络运行 **`.\update-cn.bat`**;支持 `-Help`。升级会终止当前项目的 ARTEMIS 服务、MCP 和任务进程,然后快进更新源码,同步依赖、网页构建及 Codex MCP。升级程序先复制到项目外运行,避免更新正在执行的脚本;其他项目、Codex 本身、模拟器和共享 ADB 服务不会被终止。完成后重新启动 ARTEMIS,并在 Codex 中重载 MCP。
|
启动脚本只检查并提示源码更新,不自动拉取。升级运行 **`.\update.bat`**,国内网络运行 **`.\update-cn.bat`**;支持 `-Help`。升级会终止当前项目的 ARTEMIS 服务、MCP 和任务进程,然后快进更新源码,同步依赖、网页构建及 Codex MCP。升级程序先复制到项目外运行,避免更新正在执行的脚本;其他项目、Codex 本身、模拟器和共享 ADB 服务不会被终止。完成后重新启动 ARTEMIS,并在 Codex 中重载 MCP。
|
||||||
|
|
||||||
本地修改、分支分叉或网络认证失败会停止升级,保留现有文件;`.env`、密钥和历史资料不会清空。启动/升级中的依赖或 MCP 同步失败会明确报错,修复后可重新执行;仅启动网页时可用 `-SkipMcpSync`。
|
本地修改、分支分叉或网络认证失败会停止升级,保留现有文件;`.env`、密钥和历史资料不会清空。依赖同步失败会明确报错;Codex MCP 或 Skill 同步失败会保留可用版本并告警,网页继续启动。可用 `-SkipMcpSync` 跳过该次 Codex 集成同步。
|
||||||
|
|
||||||
1.1.1 及更新版本的发布 ZIP 含浅层 Git 工作树;包含独立升级入口的新版源码包可直接用上述脚本升级。1.1.0 及更早 ZIP 不含 Git 信息,需改用 Git 克隆或新版源码包。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。
|
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)
|
||||||
|
|
||||||
基于上游 ARTEMIS 开发,遵循 [Apache-2.0 许可证](LICENSE)。
|
基于上游 ARTEMIS 开发,遵循 [Apache-2.0 许可证](LICENSE)。
|
||||||
@@ -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)
|
||||||
@@ -23,9 +23,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
|
||||||
|
|
||||||
|
|
||||||
@@ -323,6 +320,7 @@ class SessionRepository:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
llm_calls = prompt = completion = total = cached = 0
|
llm_calls = prompt = completion = total = cached = 0
|
||||||
|
by_node_model: dict[tuple[str, str], dict] = {}
|
||||||
operator_context: int | None = None
|
operator_context: int | None = None
|
||||||
operator_context_at: float | None = None
|
operator_context_at: float | None = None
|
||||||
with db_session(self.db_path) as conn:
|
with db_session(self.db_path) as conn:
|
||||||
@@ -351,6 +349,27 @@ class SessionRepository:
|
|||||||
total += t
|
total += t
|
||||||
cached += _int(payload.get("cached_tokens"))
|
cached += _int(payload.get("cached_tokens"))
|
||||||
node = str(payload.get("node") or "").strip().lower()
|
node = str(payload.get("node") or "").strip().lower()
|
||||||
|
model_name = str(payload.get("model") or "unknown")
|
||||||
|
group = by_node_model.setdefault(
|
||||||
|
(node, model_name),
|
||||||
|
{
|
||||||
|
"node": node,
|
||||||
|
"model": model_name,
|
||||||
|
"llm_calls": 0,
|
||||||
|
"prompt_tokens": 0,
|
||||||
|
"completion_tokens": 0,
|
||||||
|
"cached_tokens": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
group["llm_calls"] += 1
|
||||||
|
group["prompt_tokens"] += p
|
||||||
|
group["completion_tokens"] += c
|
||||||
|
group["cached_tokens"] += _int(payload.get("cached_tokens"))
|
||||||
|
group["cache_hit_rate"] = (
|
||||||
|
group["cached_tokens"] / group["prompt_tokens"]
|
||||||
|
if group["prompt_tokens"]
|
||||||
|
else None
|
||||||
|
)
|
||||||
if node in OPERATOR_NODE_NAMES:
|
if node in OPERATOR_NODE_NAMES:
|
||||||
operator_context = _int(payload.get("context_base_tokens")) or p
|
operator_context = _int(payload.get("context_base_tokens")) or p
|
||||||
operator_context_at = row["timestamp"]
|
operator_context_at = row["timestamp"]
|
||||||
@@ -378,6 +397,7 @@ class SessionRepository:
|
|||||||
"completion_tokens": completion,
|
"completion_tokens": completion,
|
||||||
"total_tokens": total,
|
"total_tokens": total,
|
||||||
"cached_tokens": cached,
|
"cached_tokens": cached,
|
||||||
|
"by_node_model": list(by_node_model.values()),
|
||||||
"operator_context_tokens": operator_context,
|
"operator_context_tokens": operator_context,
|
||||||
"operator_context_window_tokens": OPERATOR_CONTEXT_WINDOW_TOKENS,
|
"operator_context_window_tokens": OPERATOR_CONTEXT_WINDOW_TOKENS,
|
||||||
"operator_context_updated_at": operator_context_at,
|
"operator_context_updated_at": operator_context_at,
|
||||||
|
|||||||
@@ -16,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",
|
||||||
|
|||||||
@@ -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,6 +8,7 @@ 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
|
||||||
@@ -34,7 +35,7 @@ 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)
|
repeat_count: int = Field(default=1, ge=1, strict=True)
|
||||||
@@ -181,11 +182,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")
|
||||||
|
|
||||||
@@ -204,8 +201,7 @@ async def download_report(
|
|||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
if fmt in {"screenshots", "video", "all"}:
|
if fmt in {"screenshots", "video", "all"}:
|
||||||
path = await asyncio.to_thread(report_export.export_session_media, session_id, fmt)
|
path, label = await asyncio.to_thread(report_export.session_download, session_id, fmt)
|
||||||
label = report_export.archive_case_name(report_export.session_report(session_id))
|
|
||||||
return FileResponse(path, filename=f"{label}-{fmt}.zip")
|
return FileResponse(path, filename=f"{label}-{fmt}.zip")
|
||||||
paths = await asyncio.to_thread(report_export.export_session, session_id, [fmt])
|
paths = await asyncio.to_thread(report_export.export_session, session_id, [fmt])
|
||||||
return FileResponse(paths[0], filename=f"任务报告-{session_id[:8]}.{fmt}")
|
return FileResponse(paths[0], filename=f"任务报告-{session_id[:8]}.{fmt}")
|
||||||
@@ -223,7 +219,9 @@ def review_case(session_id: str, value: CaseReviewRequest):
|
|||||||
and archive_service.status(batch_id, require("batch", batch_id))["status"] == "preparing"
|
and archive_service.status(batch_id, require("batch", batch_id))["status"] == "preparing"
|
||||||
):
|
):
|
||||||
raise HTTPException(409, "批次报告正在打包,请完成后再保存人工复验")
|
raise HTTPException(409, "批次报告正在打包,请完成后再保存人工复验")
|
||||||
with report_export.EXPORT_LOCK:
|
with report_export.export_lock(
|
||||||
|
*(["batch:" + batch_id] if batch_id else []), "session:" + session_id
|
||||||
|
):
|
||||||
current = report(session_id)
|
current = report(session_id)
|
||||||
if current["status"] not in {
|
if current["status"] not in {
|
||||||
"completed",
|
"completed",
|
||||||
@@ -260,7 +258,10 @@ def history(
|
|||||||
batch_id: str = Query(default="", max_length=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,
|
||||||
@@ -269,7 +270,9 @@ def history(
|
|||||||
COALESCE(json_extract(j.payload,'$.case.category'),'') category,
|
COALESCE(json_extract(j.payload,'$.case.category'),'') category,
|
||||||
COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory,
|
COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory,
|
||||||
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions,
|
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions,
|
||||||
COALESCE(json_extract(j.payload,'$.error'),'') error, j.batch_id
|
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'),
|
||||||
@@ -280,15 +283,28 @@ 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'),''), j.batch_id
|
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 == "__batch__":
|
if batch_id == "__exploration__":
|
||||||
|
where += " AND exploration_kind = 'exploration'"
|
||||||
|
elif batch_id == "__batch__":
|
||||||
where += " AND batch_id IS NOT NULL"
|
where += " AND batch_id IS NOT NULL"
|
||||||
elif batch_id == "__single__":
|
elif batch_id == "__single__":
|
||||||
where += " AND batch_id IS NULL"
|
where += " AND batch_id IS NULL AND COALESCE(exploration_kind,'') != 'exploration'"
|
||||||
elif batch_id:
|
elif batch_id:
|
||||||
try:
|
try:
|
||||||
identifier(batch_id)
|
identifier(batch_id)
|
||||||
@@ -297,7 +313,7 @@ def history(
|
|||||||
where += " AND batch_id = ?"
|
where += " AND batch_id = ?"
|
||||||
args.append(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 [
|
||||||
@@ -320,6 +336,11 @@ def history(
|
|||||||
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])
|
jobs = store.jobs_for_sessions([item["session_id"] for item in items])
|
||||||
for item in items:
|
for item in items:
|
||||||
|
item["profile"] = (
|
||||||
|
"explore"
|
||||||
|
if item.get("exploration_kind") == "exploration"
|
||||||
|
else item.get("exploration_profile") or item.get("profile")
|
||||||
|
)
|
||||||
item["test_result"] = read_reviewed_result(
|
item["test_result"] = read_reviewed_result(
|
||||||
item["session_id"], item["status"], item.get("error") or "", storage=store
|
item["session_id"], item["status"], item.get("error") or "", storage=store
|
||||||
)
|
)
|
||||||
@@ -329,4 +350,6 @@ def history(
|
|||||||
item["repeat_count"] = job.get("repeat_count", 1)
|
item["repeat_count"] = job.get("repeat_count", 1)
|
||||||
elif item["case_id"]:
|
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,245 @@
|
|||||||
|
"""Plugin management for the local console; credentials never leave this API."""
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Literal
|
||||||
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
|
|
||||||
|
from artemis.plugins.installer import download, install_archive, install_local, load_catalog
|
||||||
|
from artemis.plugins.models import PluginManifest, PluginProfile, PluginSelection
|
||||||
|
from artemis.plugins.runtime import PluginRuntime
|
||||||
|
from artemis.plugins.store import PluginStore
|
||||||
|
|
||||||
|
|
||||||
|
class PluginRoute(APIRoute):
|
||||||
|
def get_route_handler(self):
|
||||||
|
handler = super().get_route_handler()
|
||||||
|
|
||||||
|
async def sanitized(request):
|
||||||
|
try:
|
||||||
|
return await handler(request)
|
||||||
|
except RequestValidationError:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content={
|
||||||
|
"detail": "插件配置格式不正确,请检查字段类型和清单;输入内容不会回显。"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/plugins", tags=["plugins"], route_class=PluginRoute)
|
||||||
|
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="plugin-installer")
|
||||||
|
_jobs: dict[str, dict] = {}
|
||||||
|
_lock = Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_store() -> PluginStore:
|
||||||
|
return PluginStore()
|
||||||
|
|
||||||
|
|
||||||
|
class InstallRequest(BaseModel):
|
||||||
|
plugin_id: str | None = None
|
||||||
|
version: str | None = None
|
||||||
|
local_path: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EnableRequest(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DiagnoseRequest(BaseModel):
|
||||||
|
profile: str = "default"
|
||||||
|
|
||||||
|
|
||||||
|
class CustomRequest(BaseModel):
|
||||||
|
manifest: PluginManifest
|
||||||
|
skill_content: str = Field(default="", max_length=262144)
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceConfirmation(BaseModel):
|
||||||
|
resource: str = Field(min_length=1, max_length=256)
|
||||||
|
confirmed_stopped: Literal[True]
|
||||||
|
|
||||||
|
|
||||||
|
def public_error(exc: Exception) -> str:
|
||||||
|
return "插件清单或配置格式不正确" if isinstance(exc, ValidationError) else str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_plugins():
|
||||||
|
store = get_store()
|
||||||
|
return {"plugins": store.list(), "blocked_resources": store.blocked_resources()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/resources/confirm-stopped")
|
||||||
|
def confirm_resource_stopped(request: ResourceConfirmation):
|
||||||
|
from artemis.runtime.device_lock import DeviceExecutionLock
|
||||||
|
|
||||||
|
if any(
|
||||||
|
request.resource in owner.resource_ids
|
||||||
|
for owner in DeviceExecutionLock.get_active_owners().values()
|
||||||
|
):
|
||||||
|
raise HTTPException(409, "此资源仍被任务占用,请先停止相关任务并确认插件动作已停止")
|
||||||
|
get_store().confirm_resource_stopped(request.resource)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalog")
|
||||||
|
def marketplace():
|
||||||
|
try:
|
||||||
|
return load_catalog().model_dump()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(502, "暂时无法读取插件市场,请稍后重试;已安装插件不受影响") from None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/install", status_code=202)
|
||||||
|
def install_plugin(request: InstallRequest):
|
||||||
|
if bool(request.local_path) == bool(request.plugin_id):
|
||||||
|
raise HTTPException(400, "请选择一个市场插件或本地安装包")
|
||||||
|
with _lock:
|
||||||
|
if any(j["status"] in {"queued", "running"} for j in _jobs.values()):
|
||||||
|
raise HTTPException(409, "已有插件正在安装,请等待安装结束")
|
||||||
|
for key in list(_jobs)[:-30]:
|
||||||
|
del _jobs[key]
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
_jobs[job_id] = {"id": job_id, "status": "queued", "stage": "等待安装", "progress": 0}
|
||||||
|
|
||||||
|
def progress(stage, percent):
|
||||||
|
with _lock:
|
||||||
|
_jobs[job_id].update(status="running", stage=stage, progress=percent)
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
store = get_store()
|
||||||
|
if request.local_path:
|
||||||
|
installed = install_local(store, Path(request.local_path), progress)
|
||||||
|
else:
|
||||||
|
progress("获取市场索引", 0)
|
||||||
|
candidates = [
|
||||||
|
p
|
||||||
|
for p in load_catalog().plugins
|
||||||
|
if p.manifest.id == request.plugin_id
|
||||||
|
and (request.version is None or p.manifest.version == request.version)
|
||||||
|
]
|
||||||
|
from packaging.version import Version
|
||||||
|
|
||||||
|
compatible = []
|
||||||
|
for entry in candidates:
|
||||||
|
try:
|
||||||
|
entry.manifest.check_compatible()
|
||||||
|
compatible.append(entry)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if not compatible:
|
||||||
|
raise ValueError("没有已验证且兼容的插件版本")
|
||||||
|
entry = max(compatible, key=lambda p: Version(p.manifest.version))
|
||||||
|
installed = install_archive(
|
||||||
|
store, download(entry.archive_url, progress), entry=entry, progress=progress
|
||||||
|
)
|
||||||
|
with _lock:
|
||||||
|
_jobs[job_id].update(
|
||||||
|
status="completed",
|
||||||
|
stage="安装完成,请配置并启用",
|
||||||
|
progress=100,
|
||||||
|
plugin_id=installed["manifest"]["id"],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
with _lock:
|
||||||
|
# HTTP exceptions may contain signed URLs; expose only our validation messages.
|
||||||
|
message = (
|
||||||
|
public_error(exc)
|
||||||
|
if isinstance(exc, (ValueError, FileNotFoundError))
|
||||||
|
else "下载或安装失败,请检查网络及安装包"
|
||||||
|
)
|
||||||
|
_jobs[job_id].update(status="failed", stage=message)
|
||||||
|
|
||||||
|
_executor.submit(run)
|
||||||
|
return {"job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/install/{job_id}")
|
||||||
|
def install_progress(job_id: str):
|
||||||
|
with _lock:
|
||||||
|
if job_id not in _jobs:
|
||||||
|
raise HTTPException(404, "安装记录不存在,服务可能已重启;请刷新插件列表")
|
||||||
|
return dict(_jobs[job_id])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/custom")
|
||||||
|
def register_custom(request: CustomRequest):
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buffer, "w") as archive:
|
||||||
|
archive.writestr("plugin.json", request.manifest.model_dump_json())
|
||||||
|
if request.manifest.skill:
|
||||||
|
archive.writestr(request.manifest.skill, request.skill_content)
|
||||||
|
try:
|
||||||
|
return install_archive(get_store(), buffer.getvalue())
|
||||||
|
except (ValueError, OSError) as exc:
|
||||||
|
raise HTTPException(400, public_error(exc)) from None
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{plugin_id}/profiles")
|
||||||
|
def save_profile(plugin_id: str, profile: PluginProfile):
|
||||||
|
try:
|
||||||
|
get_store().save_profile(plugin_id, profile)
|
||||||
|
return get_store().get(plugin_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, public_error(exc)) from None
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{plugin_id}/enabled")
|
||||||
|
def enable_plugin(plugin_id: str, request: EnableRequest):
|
||||||
|
try:
|
||||||
|
get_store().set_enabled(plugin_id, request.enabled)
|
||||||
|
return get_store().get(plugin_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, public_error(exc)) from None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{plugin_id}/diagnose")
|
||||||
|
async def diagnose_plugin(plugin_id: str, request: DiagnoseRequest):
|
||||||
|
runtime = None
|
||||||
|
result = {"ok": False, "message": "插件连接失败,请检查启用状态、授权、命令和配置"}
|
||||||
|
try:
|
||||||
|
store = get_store()
|
||||||
|
manifest = PluginManifest.model_validate(store.get(plugin_id)["manifest"])
|
||||||
|
runtime = PluginRuntime(
|
||||||
|
[PluginSelection(id=plugin_id, profile=request.profile)],
|
||||||
|
store=store,
|
||||||
|
target=manifest.targets[0],
|
||||||
|
)
|
||||||
|
loaded = await runtime.load(plugin_id)
|
||||||
|
result = {
|
||||||
|
"ok": loaded.status == "success",
|
||||||
|
"tools": loaded.structured_content or {},
|
||||||
|
"message": "插件连接正常",
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
result = {"ok": False, "message": "插件连接失败,请检查启用状态、授权、命令和配置"}
|
||||||
|
finally:
|
||||||
|
if runtime:
|
||||||
|
try:
|
||||||
|
await runtime.aclose()
|
||||||
|
except RuntimeError:
|
||||||
|
result = {"ok": False, "message": "插件未确认停止,请检查受保护的资源"}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{plugin_id}")
|
||||||
|
def uninstall_plugin(plugin_id: str):
|
||||||
|
try:
|
||||||
|
get_store().uninstall(plugin_id)
|
||||||
|
return {"ok": True}
|
||||||
|
except (ValueError, OSError) as exc:
|
||||||
|
raise HTTPException(409, public_error(exc)) from None
|
||||||
@@ -17,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,21 +16,13 @@ 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
|
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 execution_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
|
||||||
@@ -38,11 +30,43 @@ except ImportError:
|
|||||||
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 execution_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
|
||||||
@@ -57,6 +81,8 @@ async def list_sessions():
|
|||||||
|
|
||||||
def _list_sessions_sync():
|
def _list_sessions_sync():
|
||||||
rows = session_repo.get_all_sessions()
|
rows = session_repo.get_all_sessions()
|
||||||
|
with store.connection() as connection:
|
||||||
|
exploration_runs = exploration_metadata(connection)
|
||||||
jobs_by_id = store.jobs_for_sessions([str(row.get("session_id")) for row in rows])
|
jobs_by_id = store.jobs_for_sessions([str(row.get("session_id")) for row in rows])
|
||||||
video_rec_map = session_repo.get_video_recordings_map()
|
video_rec_map = session_repo.get_video_recordings_map()
|
||||||
latest_recordings = session_repo.get_latest_video_recordings_map()
|
latest_recordings = session_repo.get_latest_video_recordings_map()
|
||||||
@@ -80,6 +106,7 @@ 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 {}
|
||||||
@@ -189,6 +216,19 @@ def _list_sessions_sync():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[list_sessions] Failed to update orphaned sessions: {e}")
|
print(f"[list_sessions] Failed to update orphaned sessions: {e}")
|
||||||
|
|
||||||
|
for row in result:
|
||||||
|
if row.get("exploration_kind") == "validation" and row.get("exploration_case_name"):
|
||||||
|
row["display_name"] = row["exploration_case_name"]
|
||||||
|
model = row.pop("exploration_model", None)
|
||||||
|
if row.get("exploration_kind") == "exploration":
|
||||||
|
row["model_info"] = {
|
||||||
|
"name": "探索",
|
||||||
|
"id": model,
|
||||||
|
"provider": "openai",
|
||||||
|
"architecture": "explore",
|
||||||
|
}
|
||||||
|
elif model:
|
||||||
|
row["model_info"] = {**row["model_info"], "id": model}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -258,11 +298,33 @@ async def cleanup_history_endpoint():
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/sessions/{session_id}/deletion-preview")
|
||||||
|
def preview_session_deletion(session_id: str):
|
||||||
|
from apps.admin_console.services.task_deletion import deletion_preview
|
||||||
|
|
||||||
|
try:
|
||||||
|
return deletion_preview(session_id)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(404, str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(409, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/sessions/{session_id}/delete")
|
@router.post("/api/sessions/{session_id}/delete")
|
||||||
async def delete_session_endpoint(session_id: str):
|
async def delete_session_endpoint(session_id: str):
|
||||||
|
from artemis.exploration.store import ExplorationConflict, explorations
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from artemis.data_engine.storage import StorageManager
|
from artemis.data_engine.storage import StorageManager
|
||||||
|
|
||||||
|
session_id = str(UUID(session_id))
|
||||||
|
run = explorations.run(session_id)
|
||||||
|
if run and run["kind"] == "exploration":
|
||||||
|
from apps.admin_console.services.exploration_deletion import delete_exploration
|
||||||
|
|
||||||
|
deleted = delete_exploration(explorations, run["exploration_id"])
|
||||||
|
return {"status": "success", "deleted_session_ids": deleted}
|
||||||
|
preview_session_deletion(session_id)
|
||||||
storage = StorageManager(DB_PATH, TRACES_PATH)
|
storage = StorageManager(DB_PATH, TRACES_PATH)
|
||||||
storage.delete_session(UUID(session_id))
|
storage.delete_session(UUID(session_id))
|
||||||
from apps.admin_console.services.automation_store import store
|
from apps.admin_console.services.automation_store import store
|
||||||
@@ -271,6 +333,13 @@ async def delete_session_endpoint(session_id: str):
|
|||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"Session {session_id} deleted successfully",
|
"message": f"Session {session_id} deleted successfully",
|
||||||
|
"deleted_session_ids": [session_id],
|
||||||
}
|
}
|
||||||
|
except ExplorationConflict as e:
|
||||||
|
raise HTTPException(status_code=409, detail=str(e)) from e
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except (ValueError, KeyError) as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -19,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)
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ 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.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 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 (
|
||||||
@@ -32,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
|
||||||
@@ -55,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:
|
||||||
@@ -77,7 +71,23 @@ def pause_running_task(session_id: str):
|
|||||||
@router.post("/api/tasks/{session_id}/guide")
|
@router.post("/api/tasks/{session_id}/guide")
|
||||||
def guide_running_task(session_id: str, value: GuidanceRequest):
|
def guide_running_task(session_id: str, value: GuidanceRequest):
|
||||||
_live_task(session_id)
|
_live_task(session_id)
|
||||||
guidance = add_instruction(session_id, value.instruction, source="web")
|
from apps.admin_console.services.exploration_service import exploration_service
|
||||||
|
from artemis.exploration.store import ExplorationConflict
|
||||||
|
|
||||||
|
if run := exploration_service.repository.run(session_id):
|
||||||
|
try:
|
||||||
|
guidance = exploration_service.message(
|
||||||
|
run["exploration_id"],
|
||||||
|
value.instruction,
|
||||||
|
value.request_id or str(uuid4()),
|
||||||
|
expected_run_id=session_id,
|
||||||
|
)
|
||||||
|
except ExplorationConflict as exc:
|
||||||
|
raise HTTPException(409, str(exc)) from exc
|
||||||
|
else:
|
||||||
|
guidance = add_instruction(
|
||||||
|
session_id, value.instruction, source="web", instruction_id=value.request_id
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
read_state(session_id).get("status") == "task_paused"
|
read_state(session_id).get("status") == "task_paused"
|
||||||
or (control_dir(session_id) / "pause.request").exists()
|
or (control_dir(session_id) / "pause.request").exists()
|
||||||
@@ -183,7 +193,10 @@ async def run_task(request: RunRequest):
|
|||||||
# never be selected. Only a successful, non-empty enumeration may reject:
|
# never be selected. Only a successful, non-empty enumeration may reject:
|
||||||
# an indeterminate one (adb blip, startup) lets the submission queue and
|
# an indeterminate one (adb blip, startup) lets the submission queue and
|
||||||
# fail downstream with a clear error instead.
|
# fail downstream with a clear error instead.
|
||||||
if request.device_serial:
|
execution_target = (
|
||||||
|
request.runtime_overrides.execution_target if request.runtime_overrides else "android"
|
||||||
|
)
|
||||||
|
if request.device_serial and execution_target != "computer":
|
||||||
try:
|
try:
|
||||||
rejection = await device_pool.validate_explicit_serial_async(request.device_serial)
|
rejection = await device_pool.validate_explicit_serial_async(request.device_serial)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -207,7 +220,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 ""
|
||||||
@@ -251,6 +268,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
|
||||||
@@ -429,10 +448,15 @@ async def get_status():
|
|||||||
[str(item["session_id"]) for item in [*queue_data, *active_tasks] if item.get("session_id")]
|
[str(item["session_id"]) for item in [*queue_data, *active_tasks] if item.get("session_id")]
|
||||||
)
|
)
|
||||||
queue_data = [{**item} for item in queue_data]
|
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]:
|
for item in [*queue_data, *active_tasks]:
|
||||||
|
item.update(exploration_runs.get(str(item.get("session_id")), {}))
|
||||||
if job := batch_jobs.get(str(item.get("session_id"))):
|
if job := batch_jobs.get(str(item.get("session_id"))):
|
||||||
item["batch_id"] = job.get("batch_id")
|
item["batch_id"] = job.get("batch_id")
|
||||||
item["display_name"] = execution_title(job)
|
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
|
||||||
|
|||||||
@@ -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,17 @@ app.include_router(system.router)
|
|||||||
from apps.admin_console.routers.automation import router as automation_router
|
from apps.admin_console.routers.automation import router as automation_router
|
||||||
|
|
||||||
app.include_router(automation_router)
|
app.include_router(automation_router)
|
||||||
|
from apps.admin_console.routers.favorites import router as favorites_router
|
||||||
|
|
||||||
|
app.include_router(favorites_router)
|
||||||
|
|
||||||
|
from apps.admin_console.routers.plugins import router as plugins_router
|
||||||
|
|
||||||
|
app.include_router(plugins_router)
|
||||||
|
|
||||||
|
from apps.admin_console.routers.exploration import router as exploration_router
|
||||||
|
|
||||||
|
app.include_router(exploration_router)
|
||||||
|
|
||||||
# Mount cloud gateway router for Frappe / Cloud integration if present
|
# Mount cloud gateway router for Frappe / Cloud integration if present
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -36,6 +36,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,
|
||||||
}
|
}
|
||||||
@@ -193,6 +209,9 @@ 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
|
||||||
@@ -270,10 +289,12 @@ async def tick_batches():
|
|||||||
job = pending[0]
|
job = pending[0]
|
||||||
options = job["case"].get("task_config") or {}
|
options = job["case"].get("task_config") or {}
|
||||||
serial = options.get("device_serial") or batch.get("device_serial")
|
serial = options.get("device_serial") or batch.get("device_serial")
|
||||||
devices = await device_pool.try_list_devices_async()
|
computer = options.get("execution_target") == "computer"
|
||||||
|
devices = await device_pool.try_list_devices_async() if not computer else []
|
||||||
rejection = (
|
rejection = (
|
||||||
None
|
None
|
||||||
if serial and any(d.serial == serial and d.state == "device" for d in devices or [])
|
if computer
|
||||||
|
or (serial and any(d.serial == serial and d.state == "device" for d in devices or []))
|
||||||
else "无法确认所选设备在线且已授权,请恢复设备连接后继续批次"
|
else "无法确认所选设备在线且已授权,请恢复设备连接后继续批次"
|
||||||
)
|
)
|
||||||
if rejection:
|
if rejection:
|
||||||
@@ -386,7 +407,9 @@ def delete_batch(batch_id: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
storage = StorageManager(store.path, report_export.TRACES_PATH)
|
storage = StorageManager(store.path, report_export.TRACES_PATH)
|
||||||
mcp_exports = report_export.EXPORT_ROOT.parent / "mcp_exports"
|
mcp_exports = report_export.EXPORT_ROOT.parent / "mcp_exports"
|
||||||
with report_export.EXPORT_LOCK:
|
with report_export.export_lock(
|
||||||
|
"batch:" + batch_id, *("session:" + j["session_id"] for j in jobs)
|
||||||
|
):
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
session_id = str(UUID(job["session_id"]))
|
session_id = str(UUID(job["session_id"]))
|
||||||
storage.delete_session(UUID(session_id))
|
storage.delete_session(UUID(session_id))
|
||||||
|
|||||||
@@ -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, "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"]
|
||||||
@@ -37,7 +43,7 @@ def status(batch_id: str, batch: dict) -> dict:
|
|||||||
state = _states.get(batch_id)
|
state = _states.get(batch_id)
|
||||||
if state:
|
if state:
|
||||||
return _snapshot(state)
|
return _snapshot(state)
|
||||||
if batch.get("report_ready") and archive_path(batch_id).is_file():
|
if cached(batch_id, batch):
|
||||||
return {
|
return {
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
"stage": "报告包已就绪",
|
"stage": "报告包已就绪",
|
||||||
@@ -63,7 +69,7 @@ def prepare(batch_id: str, batch: dict) -> dict:
|
|||||||
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 cached(batch_id, batch):
|
||||||
_states.pop(batch_id, None)
|
_states.pop(batch_id, None)
|
||||||
return status_from_cache()
|
return status_from_cache()
|
||||||
state = {
|
state = {
|
||||||
@@ -81,9 +87,9 @@ 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):
|
||||||
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())
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""Read task-owned plugin evidence without executing tools or converting media."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
from artemis.config import TRACES_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def task_root(session_id: str) -> Path:
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", session_id):
|
||||||
|
raise ValueError("无效任务编号")
|
||||||
|
root = TRACES_PATH.resolve() / session_id
|
||||||
|
if root.resolve() != root:
|
||||||
|
raise ValueError("任务证据目录不能是符号链接")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def evidence_file(root: Path, relative: str) -> Path | None:
|
||||||
|
path = root / relative
|
||||||
|
try:
|
||||||
|
if (
|
||||||
|
path.is_file()
|
||||||
|
and path.resolve() == path.absolute()
|
||||||
|
and path.resolve().is_relative_to(root.resolve())
|
||||||
|
):
|
||||||
|
return path
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_sources(session_id: str) -> list[dict]:
|
||||||
|
root = task_root(session_id)
|
||||||
|
path = evidence_file(root, "evidence/recordings/recordings.json")
|
||||||
|
if not path or path.stat().st_size > 4 * 1024 * 1024:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if data.get("version") != 1 or not isinstance(data.get("sources"), list):
|
||||||
|
return []
|
||||||
|
from apps.admin_console.services.media_service import MediaService
|
||||||
|
|
||||||
|
sources = []
|
||||||
|
for item in data["sources"]:
|
||||||
|
if not isinstance(item, dict) or not isinstance(item.get("id"), str):
|
||||||
|
continue
|
||||||
|
source = {**item, "segments": []}
|
||||||
|
for segment in item.get("segments", []):
|
||||||
|
if not isinstance(segment, dict):
|
||||||
|
continue
|
||||||
|
video = evidence_file(root, str(segment.get("file") or ""))
|
||||||
|
if video is None or video.suffix.lower() != ".mp4":
|
||||||
|
continue
|
||||||
|
duration = float(segment.get("duration", 0))
|
||||||
|
start = float(segment.get("offset_ms", 0)) / 1000
|
||||||
|
if (
|
||||||
|
not math.isfinite(duration)
|
||||||
|
or duration <= 0
|
||||||
|
or not math.isfinite(start)
|
||||||
|
or start < 0
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
source["segments"].append(
|
||||||
|
{
|
||||||
|
**segment,
|
||||||
|
"url": MediaService.path_to_video_url(video),
|
||||||
|
"start": start,
|
||||||
|
"duration": duration,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
sources.append(source)
|
||||||
|
return sources
|
||||||
|
except (OSError, ValueError, TypeError, KeyError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_files(session_id: str) -> list[tuple[Path, str]]:
|
||||||
|
from apps.admin_console.services.exploration_evidence import demonstration_files
|
||||||
|
|
||||||
|
root = task_root(session_id)
|
||||||
|
files = dict(demonstration_files(session_id))
|
||||||
|
for source in plugin_sources(session_id):
|
||||||
|
for segment in source["segments"]:
|
||||||
|
path = evidence_file(root, segment["file"])
|
||||||
|
if path:
|
||||||
|
files[path] = "任务录屏/电脑/" + path.name
|
||||||
|
for directory in (root / "artifacts" / "plugins", root / "evidence"):
|
||||||
|
if not directory.is_dir():
|
||||||
|
continue
|
||||||
|
for path in directory.rglob("*"):
|
||||||
|
if path.suffix.lower() == ".tmp" or (
|
||||||
|
directory.name == "evidence" and path.suffix.lower() in {".mp4", ".mkv"}
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
relative = path.relative_to(root).as_posix()
|
||||||
|
checked = evidence_file(root, relative)
|
||||||
|
if checked:
|
||||||
|
files[checked] = (
|
||||||
|
"任务文件/插件/" + path.relative_to(root / "artifacts" / "plugins").as_posix()
|
||||||
|
if directory.name == "plugins"
|
||||||
|
else "证据索引/" + path.relative_to(root / "evidence").as_posix()
|
||||||
|
)
|
||||||
|
return list(files.items())
|
||||||
|
|
||||||
|
|
||||||
|
def export_index(
|
||||||
|
session_id: str, report: dict, android_videos: list, *, images=None, artifacts=None, extras=None
|
||||||
|
) -> dict:
|
||||||
|
from apps.admin_console.services.report_export import session_artifacts, session_screenshots
|
||||||
|
from apps.admin_console.services.exploration_evidence import (
|
||||||
|
exploration_index,
|
||||||
|
demonstration_sources,
|
||||||
|
)
|
||||||
|
|
||||||
|
extras = plugin_files(session_id) if extras is None else extras
|
||||||
|
recordings = plugin_sources(session_id) + demonstration_sources(session_id)
|
||||||
|
root = task_root(session_id)
|
||||||
|
paths = {path.resolve(): name for path, name in extras}
|
||||||
|
for source in recordings:
|
||||||
|
for segment in source["segments"]:
|
||||||
|
segment["file"] = paths.get((root / segment["file"]).resolve())
|
||||||
|
segment.pop("url", None)
|
||||||
|
images = session_screenshots(report) if images is None else images
|
||||||
|
artifacts = session_artifacts(report) if artifacts is None else artifacts
|
||||||
|
files = [(path, "步骤截图/" + name) for path, name in images]
|
||||||
|
files += [(path, "任务录屏/安卓/" + name) for path, name in android_videos]
|
||||||
|
files += [(path, "任务文件/安卓/" + name) for path, name in artifacts]
|
||||||
|
files += extras
|
||||||
|
calls = []
|
||||||
|
ledger = evidence_file(root, "evidence/calls.jsonl")
|
||||||
|
if ledger:
|
||||||
|
with ledger.open(encoding="utf-8") as handle:
|
||||||
|
for line in handle:
|
||||||
|
try:
|
||||||
|
call = json.loads(line)
|
||||||
|
if not isinstance(call, dict):
|
||||||
|
continue
|
||||||
|
for attachment in call.get("attachments", []):
|
||||||
|
relative = str(attachment.get("path") or "")
|
||||||
|
attachment["file"] = paths.get((root / relative).resolve())
|
||||||
|
calls.append(call)
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
continue # A partial final line after a crash is not a valid receipt.
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"session_id": session_id,
|
||||||
|
"exploration": exploration_index(session_id, paths),
|
||||||
|
"name": report["name"],
|
||||||
|
"status": report["status"],
|
||||||
|
"verdict": report["verdict"],
|
||||||
|
"steps": report.get("steps", []),
|
||||||
|
"calls": calls,
|
||||||
|
"recordings": recordings,
|
||||||
|
"android_videos": ["任务录屏/安卓/" + name for _, name in android_videos],
|
||||||
|
"image_references": {path.stem: "步骤截图/" + name for path, name in images},
|
||||||
|
"files": [{"path": name, "bytes": path.stat().st_size} for path, name in files],
|
||||||
|
"note": "工具执行完成不等于用例通过。录像保留真实时间与缺口;各来源截图不能相互替代。",
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Credential-free execution settings persisted for repeatable favorites."""
|
||||||
|
|
||||||
|
from apps.admin_console.services.task_import import TaskOptions
|
||||||
|
from artemis.config import load_agent_config, parse_llm_config, verification_level_for_checker
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
def execution_config(item: dict) -> dict:
|
||||||
|
agent = load_agent_config()
|
||||||
|
model = parse_llm_config().operator
|
||||||
|
overrides = item.get("runtime_overrides") or {}
|
||||||
|
profile = overrides.get("profile") or item.get("profile") or "flash"
|
||||||
|
options = {
|
||||||
|
"profile": profile,
|
||||||
|
"model": model.model,
|
||||||
|
"reasoning_effort": model.reasoning_effort or "default",
|
||||||
|
"request_timeout_seconds": model.timeout,
|
||||||
|
"max_retries": model.max_retries,
|
||||||
|
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
|
||||||
|
"record_video": settings.ARTEMIS_RECORDING_ENABLED,
|
||||||
|
"recording": agent.recording.model_dump(),
|
||||||
|
"analyze_video": agent.video_analyzer.enable_ledger,
|
||||||
|
"timeout_seconds": settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||||
|
"verification_level": verification_level_for_checker(agent.checker),
|
||||||
|
"explorer_mode": agent.explorer.resolve(profile=profile),
|
||||||
|
"observation_timeout_seconds": settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS,
|
||||||
|
"observation_max_attempts": settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS,
|
||||||
|
"device_command_timeout_seconds": settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||||
|
"action_failure_attempts": settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS,
|
||||||
|
"worker_cleanup_timeout_seconds": settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS,
|
||||||
|
}
|
||||||
|
for key in ("device_serial", "verification_level", "explorer_mode", "locked_app_package"):
|
||||||
|
if item.get(key) is not None:
|
||||||
|
options[key] = item[key]
|
||||||
|
options["recording"] = {**options["recording"], **(overrides.get("recording") or {})}
|
||||||
|
options.update({k: v for k, v in overrides.items() if k != "recording" and v is not None})
|
||||||
|
return {
|
||||||
|
"runtime_overrides": TaskOptions.model_validate(options).model_dump(exclude_none=True),
|
||||||
|
"expected_output": item.get("expected_output"),
|
||||||
|
"enable_outputter": item.get("enable_outputter")
|
||||||
|
if item.get("enable_outputter") is not None
|
||||||
|
else agent.outputter.enabled,
|
||||||
|
"app_path": item.get("app_path"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def persist_execution_config(item: dict) -> None:
|
||||||
|
from apps.admin_console.services.automation_store import store
|
||||||
|
|
||||||
|
snapshot = execution_config(item)
|
||||||
|
item["execution_config"] = snapshot
|
||||||
|
if job := store.job(str(item["session_id"])):
|
||||||
|
job["execution_config"] = snapshot
|
||||||
|
store.save_job(job)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Delete an inactive conversation while keeping independently executed reruns."""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from apps.admin_console.core.state import state
|
||||||
|
from apps.admin_console.services import report_export
|
||||||
|
from artemis.data_engine.storage import StorageManager
|
||||||
|
from artemis.exploration.store import ExplorationConflict, ExplorationStore
|
||||||
|
from artemis.runtime.process_probe import pid_is_alive
|
||||||
|
from artemis.runtime.task_control import read_state
|
||||||
|
|
||||||
|
|
||||||
|
def _check_runs(runs: list[dict]) -> None:
|
||||||
|
queued = {
|
||||||
|
item.get("session_id")
|
||||||
|
for item in state.queue_items
|
||||||
|
if item.get("status") in {"pending", "queued", "running"}
|
||||||
|
}
|
||||||
|
for run in runs:
|
||||||
|
run_id = run["id"]
|
||||||
|
if (
|
||||||
|
run_id in state.active_runs
|
||||||
|
or run_id in queued
|
||||||
|
or pid_is_alive(read_state(run_id).get("pid"))
|
||||||
|
):
|
||||||
|
raise ExplorationConflict("探索工作进程尚未结束,请停止任务并稍后重试删除")
|
||||||
|
|
||||||
|
|
||||||
|
def delete_exploration(repository: ExplorationStore, exploration_id: str) -> list[str]:
|
||||||
|
runs = repository.begin_delete(exploration_id, _check_runs)
|
||||||
|
# Validation sessions and batch copies remain ordinary Flash/Pro history.
|
||||||
|
deleted = [run["id"] for run in runs if run["kind"] == "exploration"]
|
||||||
|
storage = StorageManager(repository.database.path, report_export.TRACES_PATH)
|
||||||
|
with report_export.export_lock(*("session:" + run_id for run_id in deleted)):
|
||||||
|
for run_id in deleted:
|
||||||
|
safe_id = str(UUID(run_id))
|
||||||
|
storage.delete_session(UUID(safe_id))
|
||||||
|
repository.database.delete_history(safe_id)
|
||||||
|
for root in (
|
||||||
|
report_export.EXPORT_ROOT,
|
||||||
|
report_export.EXPORT_ROOT.parent / "mcp_exports",
|
||||||
|
):
|
||||||
|
folder = (root / "sessions" / safe_id).resolve()
|
||||||
|
if folder.is_relative_to(root.resolve()) and folder.is_dir():
|
||||||
|
shutil.rmtree(folder)
|
||||||
|
repository.finish_delete(exploration_id)
|
||||||
|
return deleted
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Read-only exploration provenance for reports, archives and source-aware video."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from artemis.exploration.store import explorations
|
||||||
|
from artemis.exploration.demonstrations import Demonstrations
|
||||||
|
from apps.admin_console.services.evidence_service import evidence_file, task_root
|
||||||
|
|
||||||
|
|
||||||
|
def exploration_metadata(run_id: str) -> dict | None:
|
||||||
|
run = explorations.run(run_id)
|
||||||
|
if not run:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": run["exploration_id"],
|
||||||
|
"kind": run["kind"],
|
||||||
|
"case_version": run.get("case_version"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def demonstration_sources(run_id: str) -> list[dict]:
|
||||||
|
root = task_root(run_id)
|
||||||
|
directory = root / "evidence" / "demonstrations"
|
||||||
|
from apps.admin_console.services.media_service import MediaService
|
||||||
|
|
||||||
|
sources = []
|
||||||
|
for manifest in directory.glob("*/demonstration.json"):
|
||||||
|
if (
|
||||||
|
not evidence_file(root, manifest.relative_to(root).as_posix())
|
||||||
|
or manifest.stat().st_size > 4 * 1024 * 1024
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
record = json.loads(manifest.read_text(encoding="utf-8"))
|
||||||
|
for track in record["media"]:
|
||||||
|
segments = []
|
||||||
|
for item in track["segments"]:
|
||||||
|
path = evidence_file(root, item["file"])
|
||||||
|
if path and path.suffix.lower() == ".mp4" and item["duration_ms"] > 0:
|
||||||
|
segments.append(
|
||||||
|
{
|
||||||
|
**item,
|
||||||
|
"start": item["offset_ms"] / 1000,
|
||||||
|
"duration": item["duration_ms"] / 1000,
|
||||||
|
"url": MediaService.path_to_video_url(path),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if segments:
|
||||||
|
sources.append(
|
||||||
|
{
|
||||||
|
**track,
|
||||||
|
"segments": segments,
|
||||||
|
"label": "人工示范 · "
|
||||||
|
+ ("安卓" if track["kind"] == "android" else "电脑"),
|
||||||
|
"status": "ready",
|
||||||
|
"message": "人工操作录像,不等于自动复跑通过",
|
||||||
|
"demonstration_id": record["id"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (OSError, ValueError, KeyError, TypeError):
|
||||||
|
continue
|
||||||
|
return sources
|
||||||
|
|
||||||
|
|
||||||
|
def demonstration_files(run_id: str) -> list[tuple]:
|
||||||
|
root = task_root(run_id)
|
||||||
|
directory = root / "evidence" / "demonstrations"
|
||||||
|
files = []
|
||||||
|
for path in directory.rglob("*"):
|
||||||
|
if path.suffix.lower() not in {".mp4", ".mkv", ".webm"}:
|
||||||
|
continue
|
||||||
|
checked = evidence_file(root, path.relative_to(root).as_posix())
|
||||||
|
if checked:
|
||||||
|
files.append((checked, "任务录屏/人工示范/" + path.relative_to(directory).as_posix()))
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def exploration_index(run_id: str, archive_paths: dict | None = None) -> dict | None:
|
||||||
|
run = explorations.run(run_id)
|
||||||
|
if not run:
|
||||||
|
return None
|
||||||
|
sid = run["exploration_id"]
|
||||||
|
session = explorations.get(sid)
|
||||||
|
messages = []
|
||||||
|
after = 0
|
||||||
|
while page := explorations.messages(sid, after=after):
|
||||||
|
messages.extend(page)
|
||||||
|
after = page[-1]["sequence"]
|
||||||
|
demos = Demonstrations(explorations).list(sid)
|
||||||
|
root = task_root(run_id)
|
||||||
|
for demo in demos:
|
||||||
|
for track in demo.get("media", []):
|
||||||
|
for segment in track.get("segments", []):
|
||||||
|
relative = segment.pop("file", "")
|
||||||
|
segment["trace_file"] = relative
|
||||||
|
segment["file"] = (
|
||||||
|
(archive_paths or {}).get((root / relative).resolve())
|
||||||
|
if demo["run_id"] == run_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"session": session,
|
||||||
|
"messages": messages,
|
||||||
|
"runs": explorations.runs(sid),
|
||||||
|
"cases": [
|
||||||
|
explorations.case(sid, version) for version in range(1, session["case_version"] + 1)
|
||||||
|
],
|
||||||
|
"demonstrations": demos,
|
||||||
|
"note": "此导出保留会话、用例版本和各次运行的关联;只有当前任务的媒体文件在此目录中。",
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Shared exploration metadata for queue cards and history pagination."""
|
||||||
|
|
||||||
|
# Rank execution runs independently from validations: a rerun never replaces the
|
||||||
|
# conversation card. Keep all raw sessions accessible for evidence inspection.
|
||||||
|
EXPLORATION_RUNS_CTE = """
|
||||||
|
exploration_runs AS (
|
||||||
|
SELECT r.id AS run_id,
|
||||||
|
json_extract(r.payload,'$.exploration_id') AS exploration_id,
|
||||||
|
json_extract(r.payload,'$.kind') AS exploration_kind,
|
||||||
|
json_extract(r.payload,'$.case_version') AS case_version,
|
||||||
|
json_extract(r.payload,'$.task_config.model') AS exploration_model,
|
||||||
|
json_extract(r.payload,'$.task_config.profile') AS exploration_profile,
|
||||||
|
json_extract(v.payload,'$.name') AS exploration_case_name,
|
||||||
|
CASE WHEN json_extract(r.payload,'$.kind')='exploration' AND
|
||||||
|
(json_extract(c.payload,'$.active_run_id') IS NULL
|
||||||
|
OR json_extract(c.payload,'$.active_run_id') = r.id)
|
||||||
|
THEN json_extract(c.payload,'$.status')
|
||||||
|
ELSE json_extract(r.payload,'$.status') END AS exploration_status,
|
||||||
|
FIRST_VALUE(r.id) OVER (
|
||||||
|
PARTITION BY json_extract(r.payload,'$.exploration_id'),json_extract(r.payload,'$.kind')
|
||||||
|
ORDER BY json_extract(r.payload,'$.created_at') DESC, r.id DESC
|
||||||
|
) AS exploration_latest_run_id,
|
||||||
|
COUNT(*) OVER (
|
||||||
|
PARTITION BY json_extract(r.payload,'$.exploration_id'),json_extract(r.payload,'$.kind')
|
||||||
|
) AS exploration_run_count
|
||||||
|
FROM automation_records r
|
||||||
|
LEFT JOIN automation_records c ON c.kind='exploration'
|
||||||
|
AND c.id=json_extract(r.payload,'$.exploration_id')
|
||||||
|
LEFT JOIN automation_records v ON v.kind='exploration_case'
|
||||||
|
AND v.id=json_extract(r.payload,'$.exploration_id') || '_' ||
|
||||||
|
printf('%010d',json_extract(r.payload,'$.case_version'))
|
||||||
|
WHERE r.kind='exploration_run'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def exploration_metadata(connection) -> dict[str, dict]:
|
||||||
|
rows = connection.execute("WITH " + EXPLORATION_RUNS_CTE + " SELECT * FROM exploration_runs")
|
||||||
|
result = {}
|
||||||
|
for row in rows:
|
||||||
|
item = {key: row[key] for key in row.keys() if key != "run_id"}
|
||||||
|
if item.get("exploration_kind") == "validation" and item.get("exploration_case_name"):
|
||||||
|
item["display_name"] = item["exploration_case_name"]
|
||||||
|
result[row["run_id"]] = item
|
||||||
|
return result
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Reconcile saved exploration metadata with the existing queue and worker owners."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from apps.admin_console.core.state import state
|
||||||
|
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||||
|
from artemis.exploration.store import ExplorationConflict, ExplorationStore, explorations
|
||||||
|
from artemis.runtime.process_probe import pid_is_alive
|
||||||
|
from artemis.runtime.task_control import read_state
|
||||||
|
from artemis.runtime.task_result import read_worker_outcome
|
||||||
|
from artemis.runtime.case_result import read_case_result
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_explorations(repository: ExplorationStore = explorations) -> None:
|
||||||
|
for kind in ("exploration_generation", "exploration_discussion"):
|
||||||
|
with repository._transaction() as connection:
|
||||||
|
for row in connection.execute(
|
||||||
|
"SELECT id, payload FROM automation_records WHERE kind=? AND json_extract(payload,'$.status')='running'",
|
||||||
|
(kind,),
|
||||||
|
).fetchall():
|
||||||
|
job = json.loads(row[1])
|
||||||
|
if (
|
||||||
|
not pid_is_alive(job.get("owner_pid"))
|
||||||
|
or job.get("lease_until", 0) < time.time()
|
||||||
|
):
|
||||||
|
repository._put(
|
||||||
|
connection,
|
||||||
|
kind,
|
||||||
|
row[0],
|
||||||
|
{
|
||||||
|
**job,
|
||||||
|
"status": "failed",
|
||||||
|
"lease_until": 0,
|
||||||
|
"error": "生成进程已中断;已有结果保留,可重试。",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with repository.database.connection() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='exploration' "
|
||||||
|
"AND json_extract(payload,'$.active_run_id') IS NOT NULL"
|
||||||
|
).fetchall()
|
||||||
|
history = SessionRepository(repository.database.path)
|
||||||
|
queued = {
|
||||||
|
str(item.get("session_id"))
|
||||||
|
for item in state.queue_items
|
||||||
|
if isinstance(item, dict) and item.get("status") in {"pending", "running", "queued"}
|
||||||
|
}
|
||||||
|
for row in rows:
|
||||||
|
session = json.loads(row[0])
|
||||||
|
run_id = session["active_run_id"]
|
||||||
|
run = repository.run(run_id)
|
||||||
|
if not run:
|
||||||
|
continue
|
||||||
|
outcome = read_worker_outcome(run_id)
|
||||||
|
if outcome:
|
||||||
|
repository.finish_run(run_id, {**outcome, "status": outcome["task_status"]})
|
||||||
|
continue
|
||||||
|
control = read_state(run_id)
|
||||||
|
if run["kind"] == "validation" and pid_is_alive(control.get("pid")):
|
||||||
|
observed = {"running": "running", "task_paused": "paused", "resuming": "queued"}.get(
|
||||||
|
control.get("status")
|
||||||
|
)
|
||||||
|
if observed and observed != run["status"]:
|
||||||
|
try:
|
||||||
|
repository.set_run_state(run_id, observed, reason=control.get("pause_reason"))
|
||||||
|
except ExplorationConflict:
|
||||||
|
continue # The owner finalized while this tick read its control file.
|
||||||
|
if run_id in queued or run_id in state.active_runs:
|
||||||
|
continue
|
||||||
|
if control.get("pid") and pid_is_alive(control["pid"]):
|
||||||
|
continue
|
||||||
|
execution = history.get_session_by_id(run_id) or {}
|
||||||
|
if execution.get("pid") and history.process_is_alive(execution["pid"]):
|
||||||
|
continue
|
||||||
|
job = repository.database.job(run_id) or {}
|
||||||
|
terminal = execution.get("status") or job.get("terminal_status") or job.get("status")
|
||||||
|
if terminal in {"completed", "failed", "cancelled", "interrupted", "skipped"}:
|
||||||
|
repository.finish_run(
|
||||||
|
run_id,
|
||||||
|
{
|
||||||
|
"status": terminal,
|
||||||
|
"error": execution.get("error") or job.get("error") or "",
|
||||||
|
"test_result": execution.get("test_result")
|
||||||
|
or read_case_result(run_id, terminal),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif time.time() - run["created_at"] > 300:
|
||||||
|
repository.finish_run(
|
||||||
|
run_id,
|
||||||
|
{
|
||||||
|
"status": "interrupted",
|
||||||
|
"error": "探索工作进程已结束;原证据已保留,可继续探索并重新观察当前设备。",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
"""Web and MCP use the same exploration admission and conversation service."""
|
||||||
|
|
||||||
|
from artemis.config.agent import load_agent_config
|
||||||
|
from artemis.config.exploration import ExplorationConfig
|
||||||
|
from artemis.exploration.case_prompt import require_resolved_case
|
||||||
|
from artemis.exploration.models import CreateExploration, SaveCaseRequest, ValidateCaseRequest
|
||||||
|
from artemis.exploration.demonstrations import Demonstrations
|
||||||
|
from artemis.exploration.store import ExplorationConflict, ExplorationStore, explorations
|
||||||
|
from artemis.runtime.task_control import add_instruction, read_state, request_pause, request_resume
|
||||||
|
from apps.admin_console.services.task_import import TaskOptions
|
||||||
|
|
||||||
|
|
||||||
|
def exploration_case_prompt(case: dict) -> str:
|
||||||
|
return require_resolved_case(case)
|
||||||
|
|
||||||
|
|
||||||
|
class ExplorationService:
|
||||||
|
def __init__(self, repository: ExplorationStore | None = None):
|
||||||
|
self.repository = repository if repository is not None else explorations
|
||||||
|
|
||||||
|
def settings(self) -> dict:
|
||||||
|
saved = self.repository.database.get("settings", "exploration") or {}
|
||||||
|
return ExplorationConfig.model_validate(
|
||||||
|
{
|
||||||
|
**load_agent_config().exploration.model_dump(),
|
||||||
|
**saved,
|
||||||
|
}
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
def save_settings(self, value: ExplorationConfig) -> dict:
|
||||||
|
return self.repository.database.put("settings", "exploration", value.model_dump())
|
||||||
|
|
||||||
|
def create(self, value: CreateExploration, *, source: str = "web") -> dict:
|
||||||
|
options = TaskOptions.model_validate(value.task_config).model_dump(exclude_none=True)
|
||||||
|
return self.repository.create(
|
||||||
|
value.goal,
|
||||||
|
value.config or ExplorationConfig.model_validate(self.settings()),
|
||||||
|
options,
|
||||||
|
value.request_id,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
def detail(self, session_id: str, *, after: int = 0) -> dict:
|
||||||
|
session = self.repository.get(session_id)
|
||||||
|
runs = self.repository.runs(session_id)
|
||||||
|
version = self.repository.case(session_id)
|
||||||
|
with self.repository.database.connection() as connection:
|
||||||
|
import json
|
||||||
|
|
||||||
|
generations = [
|
||||||
|
json.loads(row[0])
|
||||||
|
for row in connection.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='exploration_generation' "
|
||||||
|
"AND json_extract(payload,'$.exploration_id')=? ORDER BY updated_at DESC LIMIT 20",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
from artemis.runtime.case_result import resolve_case_result
|
||||||
|
|
||||||
|
for run in runs:
|
||||||
|
outcome = run.get("outcome")
|
||||||
|
verdict = resolve_case_result(outcome, run["status"]) if outcome else None
|
||||||
|
run["test_result"] = verdict.model_dump() if verdict else None
|
||||||
|
return {
|
||||||
|
**session,
|
||||||
|
"messages": self.repository.messages(session_id, after=after),
|
||||||
|
"runs": runs,
|
||||||
|
"case": version,
|
||||||
|
"generations": [
|
||||||
|
{k: v for k, v in g.items() if k not in {"owner", "owner_pid", "lease_until"}}
|
||||||
|
for g in generations
|
||||||
|
],
|
||||||
|
"demonstrations": Demonstrations(self.repository).list(session_id),
|
||||||
|
"validation_runs": [
|
||||||
|
r
|
||||||
|
for r in runs
|
||||||
|
if r["kind"] == "validation" and r["case_version"] == session["case_version"]
|
||||||
|
],
|
||||||
|
"control": read_state(session["active_run_id"]) if session["active_run_id"] else {},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _admit(self, run: dict, *, source: str) -> dict:
|
||||||
|
# RunRequest performs the same readiness and idempotency checks as /api/run.
|
||||||
|
from apps.admin_console.routers.tasks import run_task
|
||||||
|
from apps.admin_console.schemas.task_schema import RunRequest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
current = self.repository.run(run["id"])
|
||||||
|
if current and current.get("outcome") is not None:
|
||||||
|
return current
|
||||||
|
options = TaskOptions.model_validate(run["task_config"])
|
||||||
|
try:
|
||||||
|
result = await run_task(
|
||||||
|
RunRequest(
|
||||||
|
goal=run["goal"],
|
||||||
|
profile=options.profile or "pro",
|
||||||
|
session_id=run["id"],
|
||||||
|
ingress=source,
|
||||||
|
conversation_id=run["exploration_id"],
|
||||||
|
runtime_overrides=options,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code < 500:
|
||||||
|
self.repository.finish_run(
|
||||||
|
run["id"], {"status": "rejected", "error": str(exc.detail)}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
if result.get("status") in {"error", "rejected"}:
|
||||||
|
self.repository.finish_run(
|
||||||
|
run["id"],
|
||||||
|
{
|
||||||
|
"status": "rejected",
|
||||||
|
"error": result.get("error") or result.get("message") or "任务入队失败",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.repository.set_run_state(run["id"], "queued", expected_status="reserved")
|
||||||
|
return {"run": self.repository.run(run["id"]), "admission": result}
|
||||||
|
|
||||||
|
async def start(self, session_id: str, request_id: str, *, source: str = "web") -> dict:
|
||||||
|
session = self.repository.get(session_id)
|
||||||
|
config = ExplorationConfig.model_validate(session["config"])
|
||||||
|
options = {
|
||||||
|
**session["task_config"],
|
||||||
|
"profile": "pro",
|
||||||
|
"model": config.model,
|
||||||
|
"reasoning_effort": config.reasoning_effort,
|
||||||
|
"timeout_seconds": config.timeout_seconds,
|
||||||
|
"verification_level": "final",
|
||||||
|
}
|
||||||
|
run = self.repository.reserve_run(
|
||||||
|
session_id,
|
||||||
|
request_id,
|
||||||
|
kind="exploration",
|
||||||
|
goal=session["goal"],
|
||||||
|
task_config=options,
|
||||||
|
)
|
||||||
|
return await self._admit(run, source=source)
|
||||||
|
|
||||||
|
async def validate(
|
||||||
|
self, session_id: str, value: ValidateCaseRequest, *, source: str = "web"
|
||||||
|
) -> dict:
|
||||||
|
case = self.repository.case(session_id, value.version)
|
||||||
|
if case is None:
|
||||||
|
raise KeyError("用例版本不存在")
|
||||||
|
session = self.repository.get(session_id)
|
||||||
|
# Device/plugin binding may be reused; exploration's expensive model is not.
|
||||||
|
options = {
|
||||||
|
k: v
|
||||||
|
for k, v in session["task_config"].items()
|
||||||
|
if k not in {"model", "reasoning_effort", "profile", "timeout_seconds"}
|
||||||
|
}
|
||||||
|
options.update(TaskOptions.model_validate(value.task_config).model_dump(exclude_none=True))
|
||||||
|
options["profile"] = value.profile
|
||||||
|
run = self.repository.reserve_run(
|
||||||
|
session_id,
|
||||||
|
value.request_id,
|
||||||
|
kind="validation",
|
||||||
|
goal=exploration_case_prompt(case),
|
||||||
|
task_config=options,
|
||||||
|
case_version=value.version,
|
||||||
|
)
|
||||||
|
return await self._admit(run, source=source)
|
||||||
|
|
||||||
|
def message(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
text: str,
|
||||||
|
request_id: str,
|
||||||
|
*,
|
||||||
|
source: str = "web",
|
||||||
|
expected_run_id: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
session = self.repository.get(session_id)
|
||||||
|
if expected_run_id and session["active_run_id"] != expected_run_id:
|
||||||
|
raise ExplorationConflict("所选执行已结束,指导未发送,请刷新任务状态")
|
||||||
|
question = session.get("pending_question")
|
||||||
|
message = self.repository.add_message(
|
||||||
|
session_id,
|
||||||
|
text,
|
||||||
|
request_id,
|
||||||
|
source=source,
|
||||||
|
metadata={"question_id": question, "run_id": session["active_run_id"]},
|
||||||
|
)
|
||||||
|
question = message.get("question_id")
|
||||||
|
run_id = message.get("run_id")
|
||||||
|
if run_id and run_id == session["active_run_id"]:
|
||||||
|
run = self.repository.run(run_id)
|
||||||
|
if run and run.get("outcome") is None:
|
||||||
|
add_instruction(
|
||||||
|
run_id, message["text"], source=source, instruction_id=message["id"]
|
||||||
|
)
|
||||||
|
self.repository.answer_question(session_id, question)
|
||||||
|
if read_state(run_id).get("status") == "task_paused":
|
||||||
|
request_resume(run_id)
|
||||||
|
return message
|
||||||
|
|
||||||
|
def pause(self, session_id: str) -> dict:
|
||||||
|
session = self.repository.get(session_id)
|
||||||
|
if session["status"] == "finalizing":
|
||||||
|
raise ExplorationConflict("设备操作已结束,正在提炼用例,无需暂停。")
|
||||||
|
if not session["active_run_id"]:
|
||||||
|
raise ExplorationConflict("当前没有正在执行的探索")
|
||||||
|
request_pause(session["active_run_id"], reason="user")
|
||||||
|
return {"status": "pausing", "id": session_id}
|
||||||
|
|
||||||
|
def stop(self, session_id: str) -> dict:
|
||||||
|
from apps.admin_console.services.task_queue_service import TaskQueueService
|
||||||
|
|
||||||
|
session = self.repository.get(session_id)
|
||||||
|
if run_id := session["active_run_id"]:
|
||||||
|
requested = TaskQueueService.stop_tasks(session_id=run_id)
|
||||||
|
return {"status": "stopping" if requested else "not_running", "id": session_id}
|
||||||
|
return {"status": session["status"], "id": session_id}
|
||||||
|
|
||||||
|
async def resume(self, session_id: str, request_id: str, *, source: str = "web") -> dict:
|
||||||
|
repo = self.repository
|
||||||
|
with repo._transaction() as connection:
|
||||||
|
session = repo._require(connection, session_id)
|
||||||
|
|
||||||
|
def prepare():
|
||||||
|
if session.get("pending_question"):
|
||||||
|
raise ExplorationConflict("请先回答探索助手的问题,再继续执行。")
|
||||||
|
if run_id := session["active_run_id"]:
|
||||||
|
if read_state(run_id).get("status") != "task_paused":
|
||||||
|
raise ExplorationConflict("请等待探索安全暂停后再继续")
|
||||||
|
request_resume(run_id)
|
||||||
|
return {"status": "resuming", "id": session_id, "run_id": run_id}
|
||||||
|
return {"status": "start", "id": session_id}
|
||||||
|
|
||||||
|
result = repo._operation(connection, f"{session_id}:continue", request_id, {}, prepare)
|
||||||
|
if result["status"] == "resuming":
|
||||||
|
return result
|
||||||
|
return await self.start(session_id, request_id, source=source)
|
||||||
|
|
||||||
|
def save_case(self, session_id: str, value: SaveCaseRequest) -> dict:
|
||||||
|
return self.repository.save_case(
|
||||||
|
session_id, value.case, value.expected_version, value.request_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def demonstrate(self, session_id: str, request_id: str, source: str | None = None) -> dict:
|
||||||
|
session = self.repository.get(session_id)
|
||||||
|
if session["status"] == "finalizing":
|
||||||
|
raise ExplorationConflict("正在提炼用例,请完成后继续探索再示范。")
|
||||||
|
source = source or (
|
||||||
|
"computer"
|
||||||
|
if session["task_config"].get("execution_target") == "computer"
|
||||||
|
else "android"
|
||||||
|
)
|
||||||
|
if source not in {"android", "computer", "both"}:
|
||||||
|
raise ValueError("无效的示范来源")
|
||||||
|
demo = Demonstrations(self.repository).request(session_id, source, request_id)
|
||||||
|
if demo["status"] == "requested":
|
||||||
|
request_pause(demo["run_id"], reason="demonstration")
|
||||||
|
if read_state(demo["run_id"]).get("status") == "task_paused":
|
||||||
|
request_resume(demo["run_id"])
|
||||||
|
return demo
|
||||||
|
|
||||||
|
def demonstration_command(
|
||||||
|
self, session_id: str, demo_id: str, command: str, request_id: str, *, seconds: int = 0
|
||||||
|
) -> dict:
|
||||||
|
return Demonstrations(self.repository).command(
|
||||||
|
session_id, demo_id, command, request_id, seconds=seconds
|
||||||
|
)
|
||||||
|
|
||||||
|
async def generate_case(self, session_id: str, value) -> dict:
|
||||||
|
from artemis.exploration.synthesis import generate_case, _generation
|
||||||
|
from apps.admin_console.services.automation_service import spawn
|
||||||
|
|
||||||
|
generation = _generation(
|
||||||
|
self.repository,
|
||||||
|
session_id,
|
||||||
|
value.request_id,
|
||||||
|
value.source_run_id,
|
||||||
|
value.expected_version,
|
||||||
|
)
|
||||||
|
if generation["status"] == "completed":
|
||||||
|
return {"status": "completed", "id": generation["id"], "case": generation["case"]}
|
||||||
|
|
||||||
|
async def work():
|
||||||
|
try:
|
||||||
|
await generate_case(
|
||||||
|
self.repository,
|
||||||
|
session_id,
|
||||||
|
value.request_id,
|
||||||
|
value.source_run_id,
|
||||||
|
value.expected_version,
|
||||||
|
generation=generation,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.getLogger(__name__).exception("Exploration synthesis request failed")
|
||||||
|
|
||||||
|
spawn(work())
|
||||||
|
return {"status": "running", "id": generation["id"]}
|
||||||
|
|
||||||
|
def create_case_batch(self, session_id: str, value) -> dict:
|
||||||
|
from apps.admin_console.routers.automation import BatchRequest
|
||||||
|
from apps.admin_console.services.automation_service import create_batch
|
||||||
|
from artemis.exploration.store import _digest
|
||||||
|
|
||||||
|
case = self.repository.case(session_id, value.version)
|
||||||
|
if not case:
|
||||||
|
raise KeyError("用例版本不存在")
|
||||||
|
session = self.repository.get(session_id)
|
||||||
|
options = {
|
||||||
|
k: v
|
||||||
|
for k, v in session["task_config"].items()
|
||||||
|
if k not in {"model", "reasoning_effort", "profile", "timeout_seconds"}
|
||||||
|
}
|
||||||
|
options.update(TaskOptions.model_validate(value.task_config).model_dump(exclude_none=True))
|
||||||
|
options["profile"] = value.profile
|
||||||
|
payload = BatchRequest(
|
||||||
|
name=value.name,
|
||||||
|
profile=value.profile,
|
||||||
|
request_id="explore-" + _digest([session_id, value.request_id]),
|
||||||
|
cases=[
|
||||||
|
{
|
||||||
|
"name": case["name"],
|
||||||
|
"goal": exploration_case_prompt(case),
|
||||||
|
"preconditions": case["preconditions"],
|
||||||
|
"case_id": f"{session_id}-v{value.version}",
|
||||||
|
"task_config": options,
|
||||||
|
"category": "探索用例",
|
||||||
|
"subcategory": f"版本{value.version}",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
).model_dump()
|
||||||
|
return create_batch(payload)
|
||||||
|
|
||||||
|
|
||||||
|
exploration_service = ExplorationService()
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Per-object export coordination and content-aware artifact reuse."""
|
||||||
|
|
||||||
|
from contextlib import contextmanager, ExitStack
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import threading
|
||||||
|
from weakref import WeakValueDictionary
|
||||||
|
|
||||||
|
_guard = threading.Lock()
|
||||||
|
_locks: WeakValueDictionary = WeakValueDictionary()
|
||||||
|
TERMINAL = {"completed", "success", "failed", "cancelled", "interrupted", "skipped"}
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def export_lock(*keys: str):
|
||||||
|
# Retain strong references until every lock is released. Unrelated exports
|
||||||
|
# run concurrently; nested session exports can reacquire their own RLock.
|
||||||
|
with _guard:
|
||||||
|
locks = []
|
||||||
|
for key in sorted(set(keys)):
|
||||||
|
lock = _locks.get(key)
|
||||||
|
if lock is None:
|
||||||
|
lock = threading.RLock()
|
||||||
|
_locks[key] = lock
|
||||||
|
locks.append(lock)
|
||||||
|
with ExitStack() as stack:
|
||||||
|
for lock in locks:
|
||||||
|
stack.enter_context(lock)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint(report: dict, files=(), extra=None) -> str:
|
||||||
|
metadata = []
|
||||||
|
for path, name in files:
|
||||||
|
stat = path.stat()
|
||||||
|
metadata.append((name, str(path), stat.st_size, stat.st_mtime_ns))
|
||||||
|
payload = json.dumps([2, report, metadata, extra], sort_keys=True, default=str)
|
||||||
|
return hashlib.sha256(payload.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def reusable(path: Path, signature: str, status: str) -> bool:
|
||||||
|
if status not in TERMINAL or not path.is_file():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return path.with_suffix(path.suffix + ".fingerprint").read_text() == signature
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def remember(path: Path, signature: str) -> None:
|
||||||
|
marker = path.with_suffix(path.suffix + ".fingerprint")
|
||||||
|
temporary = marker.with_suffix(".tmp")
|
||||||
|
temporary.write_text(signature, encoding="ascii")
|
||||||
|
temporary.replace(marker)
|
||||||
|
|
||||||
|
|
||||||
|
def compression(path: Path) -> int:
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
return (
|
||||||
|
zipfile.ZIP_STORED
|
||||||
|
if path.suffix.lower()
|
||||||
|
in {".mp4", ".mkv", ".webm", ".docx", ".xlsx", ".png", ".jpg", ".jpeg", ".zip"}
|
||||||
|
else zipfile.ZIP_DEFLATED
|
||||||
|
)
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
"""Independent favorite snapshots dispatched through the existing task services."""
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import NAMESPACE_URL, uuid5
|
||||||
|
from weakref import WeakValueDictionary
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||||
|
from apps.admin_console.services.automation_store import AutomationStore, identifier, store
|
||||||
|
from apps.admin_console.services.execution_config import execution_config
|
||||||
|
from apps.admin_console.services.model_service import model_service
|
||||||
|
from apps.admin_console.services.task_import import TaskCase, TaskOptions, execution_title
|
||||||
|
from artemis.config.exploration import ExplorationConfig
|
||||||
|
from artemis.exploration.case_prompt import require_resolved_case
|
||||||
|
from artemis.exploration.store import ExplorationStore
|
||||||
|
|
||||||
|
|
||||||
|
class FavoriteCreate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
source_kind: Literal["task", "batch", "exploration", "exploration_case"] = "task"
|
||||||
|
source_id: str = Field(pattern=r"^[A-Za-z0-9_-]{1,100}$")
|
||||||
|
version: int | None = Field(default=None, ge=1)
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class FavoriteUpdate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
task_config: TaskOptions | None = None
|
||||||
|
case_configs: dict[int, TaskOptions] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FavoriteRun(FavoriteUpdate):
|
||||||
|
request_id: str = Field(pattern=r"^[A-Za-z0-9_-]{1,100}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_options(target: dict, updates: dict) -> None:
|
||||||
|
if isinstance(updates.get("recording"), dict):
|
||||||
|
updates = {
|
||||||
|
**updates,
|
||||||
|
"recording": {**(target.get("recording") or {}), **updates["recording"]},
|
||||||
|
}
|
||||||
|
target.update(updates)
|
||||||
|
|
||||||
|
|
||||||
|
class FavoriteService:
|
||||||
|
def __init__(self, storage: AutomationStore = store):
|
||||||
|
self.store = storage
|
||||||
|
self._run_locks: WeakValueDictionary = WeakValueDictionary()
|
||||||
|
|
||||||
|
def get(self, key: str) -> dict:
|
||||||
|
value = self.store.get("favorite", identifier(key))
|
||||||
|
if value is None:
|
||||||
|
raise KeyError("收藏不存在")
|
||||||
|
if value.get("config_version") != 1:
|
||||||
|
# Complete legacy snapshots once and preserve their list position.
|
||||||
|
# Re-read under the write lock so a concurrent edit cannot be lost.
|
||||||
|
with self.store.connection() as conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='favorite' AND id=?", (key,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise KeyError("收藏不存在")
|
||||||
|
value = self._complete(json.loads(row[0]))
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE automation_records SET payload=? WHERE kind='favorite' AND id=?",
|
||||||
|
(json.dumps(value, ensure_ascii=False), key),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return value
|
||||||
|
|
||||||
|
def list(self) -> list[dict]:
|
||||||
|
return [
|
||||||
|
self.get(item["id"]) if item.get("config_version") != 1 else item
|
||||||
|
for item in self.store.records("favorite", 10000)
|
||||||
|
]
|
||||||
|
|
||||||
|
def _complete(self, favorite: dict) -> dict:
|
||||||
|
result = deepcopy(favorite)
|
||||||
|
if result.get("config_version") == 1:
|
||||||
|
return result
|
||||||
|
snapshot = result["snapshot"]
|
||||||
|
if result["kind"] == "batch":
|
||||||
|
for case in snapshot["cases"]:
|
||||||
|
case["task_config"] = execution_config(
|
||||||
|
{
|
||||||
|
"profile": snapshot.get("profile"),
|
||||||
|
"device_serial": snapshot.get("device_serial"),
|
||||||
|
"runtime_overrides": {
|
||||||
|
"timeout_seconds": snapshot.get("timeout_seconds"),
|
||||||
|
**(case.get("task_config") or {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)["runtime_overrides"]
|
||||||
|
elif result["kind"] == "exploration":
|
||||||
|
config = ExplorationConfig.model_validate(snapshot.get("config") or {}).model_dump()
|
||||||
|
snapshot["config"] = config
|
||||||
|
snapshot["task_config"] = execution_config(
|
||||||
|
{
|
||||||
|
"runtime_overrides": {
|
||||||
|
**(snapshot.get("task_config") or {}),
|
||||||
|
**{
|
||||||
|
key: config[key]
|
||||||
|
for key in ("model", "reasoning_effort", "timeout_seconds")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)["runtime_overrides"]
|
||||||
|
else:
|
||||||
|
snapshot.update(execution_config(snapshot))
|
||||||
|
result["config_version"] = 1
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _task(self, session_id: str, *, fallback: dict | None = None) -> tuple[dict, list[str]]:
|
||||||
|
job = self.store.job(session_id) or {}
|
||||||
|
if snapshot := job.get("execution_config"):
|
||||||
|
return execution_config(snapshot), []
|
||||||
|
row = SessionRepository(self.store.path).get_session_by_id(session_id) or {}
|
||||||
|
submission = job.get("submission") or fallback or {}
|
||||||
|
options = {
|
||||||
|
**(job.get("case") or {}).get("task_config", {}),
|
||||||
|
**(submission.get("runtime_overrides") or {}),
|
||||||
|
}
|
||||||
|
options = {key: value for key, value in options.items() if value is not None}
|
||||||
|
try:
|
||||||
|
device = row.get("device_info") or {}
|
||||||
|
device = json.loads(device) if isinstance(device, str) else device
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
device = {}
|
||||||
|
device = device if isinstance(device, dict) else {}
|
||||||
|
profile = (
|
||||||
|
options.get("profile")
|
||||||
|
or submission.get("profile")
|
||||||
|
or model_service.resolve_session_profile(row)
|
||||||
|
or "flash"
|
||||||
|
)
|
||||||
|
options["profile"] = profile
|
||||||
|
serial = (
|
||||||
|
options.get("device_serial")
|
||||||
|
or submission.get("device_serial")
|
||||||
|
or job.get("device_serial")
|
||||||
|
or device.get("serial")
|
||||||
|
)
|
||||||
|
if serial:
|
||||||
|
options["device_serial"] = serial
|
||||||
|
if serial == "computer:local" or device.get("execution_target") == "computer":
|
||||||
|
options["execution_target"] = "computer"
|
||||||
|
# Historical diagnostics describe the actual model, unlike display metadata
|
||||||
|
# which may reflect today's defaults. Read only execution model roles.
|
||||||
|
with self.store.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT payload FROM traces WHERE session_id=? AND type='llm_diagnostic' ORDER BY timestamp DESC",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
for trace in rows:
|
||||||
|
try:
|
||||||
|
data = json.loads(trace[0])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if str(data.get("role", "")).lower() in {
|
||||||
|
"operator",
|
||||||
|
"flashrunner",
|
||||||
|
"reactiverunner",
|
||||||
|
"planner",
|
||||||
|
}:
|
||||||
|
for key in ("model", "reasoning_effort"):
|
||||||
|
if data.get(key):
|
||||||
|
options.setdefault(key, data[key])
|
||||||
|
if options.get("model"):
|
||||||
|
break
|
||||||
|
missing = [
|
||||||
|
key
|
||||||
|
for key in (
|
||||||
|
"model",
|
||||||
|
"reasoning_effort",
|
||||||
|
"record_video",
|
||||||
|
"recording",
|
||||||
|
"analyze_video",
|
||||||
|
"request_timeout_seconds",
|
||||||
|
"max_retries",
|
||||||
|
"retry_interval_seconds",
|
||||||
|
"timeout_seconds",
|
||||||
|
)
|
||||||
|
if key not in options
|
||||||
|
]
|
||||||
|
snapshot = execution_config(
|
||||||
|
{**submission, "profile": profile, "runtime_overrides": options}
|
||||||
|
)
|
||||||
|
return snapshot, missing
|
||||||
|
|
||||||
|
def create(self, value: FavoriteCreate) -> dict:
|
||||||
|
token = f"{value.source_kind}:{value.source_id}:{value.version or ''}"
|
||||||
|
key = hashlib.sha256(token.encode()).hexdigest()[:32]
|
||||||
|
if self.store.get("favorite", key):
|
||||||
|
return self.get(key)
|
||||||
|
snapshot, missing, kind, version = {}, [], value.source_kind, None
|
||||||
|
source_id = value.source_id
|
||||||
|
repo = ExplorationStore(self.store)
|
||||||
|
if kind == "task":
|
||||||
|
run = repo.run(source_id)
|
||||||
|
if run and run["kind"] == "exploration":
|
||||||
|
source_id = run["exploration_id"]
|
||||||
|
kind = "exploration_case" if repo.case(source_id) else "exploration"
|
||||||
|
if kind in {"exploration", "exploration_case"}:
|
||||||
|
session = repo.get(source_id)
|
||||||
|
options = deepcopy(session["task_config"])
|
||||||
|
if kind == "exploration":
|
||||||
|
snapshot = {
|
||||||
|
"goal": session["goal"],
|
||||||
|
"config": session["config"],
|
||||||
|
"task_config": options,
|
||||||
|
}
|
||||||
|
name = session["goal"][:120]
|
||||||
|
else:
|
||||||
|
case = repo.case(source_id, value.version)
|
||||||
|
if not case:
|
||||||
|
raise KeyError("确认用例不存在")
|
||||||
|
version = case["version"]
|
||||||
|
validations = [
|
||||||
|
r
|
||||||
|
for r in repo.runs(source_id)
|
||||||
|
if r["kind"] == "validation" and r.get("case_version") == version
|
||||||
|
]
|
||||||
|
if validations:
|
||||||
|
config, missing = self._task(
|
||||||
|
validations[-1]["id"],
|
||||||
|
fallback={"runtime_overrides": validations[-1]["task_config"]},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
options = {
|
||||||
|
k: v
|
||||||
|
for k, v in options.items()
|
||||||
|
if k not in {"profile", "model", "reasoning_effort", "timeout_seconds"}
|
||||||
|
}
|
||||||
|
config = execution_config({"profile": "flash", "runtime_overrides": options})
|
||||||
|
missing = ["首次用例复跑,默认 Flash"]
|
||||||
|
snapshot = {"goal": require_resolved_case(case), **config}
|
||||||
|
name = case["name"]
|
||||||
|
elif kind == "batch":
|
||||||
|
batch = self.store.get("batch", source_id)
|
||||||
|
if not batch or batch.get("status") == "deleting":
|
||||||
|
raise KeyError("批次不存在")
|
||||||
|
cases, seen = [], set()
|
||||||
|
for job in self.store.jobs(source_id):
|
||||||
|
position = job.get("case_position", job["position"])
|
||||||
|
if position in seen:
|
||||||
|
continue
|
||||||
|
seen.add(position)
|
||||||
|
case = deepcopy(job["case"])
|
||||||
|
config, inherited = self._task(
|
||||||
|
job["session_id"],
|
||||||
|
fallback={
|
||||||
|
"profile": batch["profile"],
|
||||||
|
"device_serial": batch.get("device_serial"),
|
||||||
|
"runtime_overrides": {
|
||||||
|
"timeout_seconds": batch.get("timeout_seconds"),
|
||||||
|
**case.get("task_config", {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
case["task_config"] = config["runtime_overrides"]
|
||||||
|
cases.append(TaskCase.model_validate(case).model_dump(exclude_none=True))
|
||||||
|
missing.extend(inherited)
|
||||||
|
if not cases:
|
||||||
|
raise ValueError("批次没有可收藏的用例")
|
||||||
|
snapshot = {
|
||||||
|
key: batch[key]
|
||||||
|
for key in (
|
||||||
|
"name",
|
||||||
|
"formats",
|
||||||
|
"profile",
|
||||||
|
"device_serial",
|
||||||
|
"timeout_seconds",
|
||||||
|
"stop_on_failure",
|
||||||
|
"repeat_count",
|
||||||
|
)
|
||||||
|
if key in batch
|
||||||
|
}
|
||||||
|
snapshot["cases"] = cases
|
||||||
|
name = batch["name"]
|
||||||
|
else:
|
||||||
|
row = SessionRepository(self.store.path).get_session_by_id(source_id) or {}
|
||||||
|
job = self.store.job(source_id) or {}
|
||||||
|
goal = (
|
||||||
|
(job.get("case") or {}).get("goal")
|
||||||
|
or row.get("initial_goal")
|
||||||
|
or (job.get("submission") or {}).get("goal")
|
||||||
|
)
|
||||||
|
if not goal:
|
||||||
|
raise KeyError("任务不存在或没有可复用的提示词")
|
||||||
|
config, missing = self._task(source_id)
|
||||||
|
snapshot = {"goal": goal, **config}
|
||||||
|
name = execution_title(job) or goal[:120]
|
||||||
|
favorite = {
|
||||||
|
"id": key,
|
||||||
|
"name": (value.name or name).strip(),
|
||||||
|
"kind": kind,
|
||||||
|
"source_kind": value.source_kind,
|
||||||
|
"source_id": value.source_id,
|
||||||
|
"case_version": version,
|
||||||
|
"snapshot": snapshot,
|
||||||
|
"inherited_fields": sorted(set(missing)),
|
||||||
|
"created_at": time.time(),
|
||||||
|
"updated_at": time.time(),
|
||||||
|
}
|
||||||
|
if not favorite["name"]:
|
||||||
|
raise ValueError("收藏名称不能为空")
|
||||||
|
return self.store.put("favorite", key, self._complete(favorite))
|
||||||
|
|
||||||
|
def updated(self, favorite: dict, value: FavoriteUpdate) -> dict:
|
||||||
|
result = self._complete(favorite)
|
||||||
|
if value.name is not None:
|
||||||
|
if not value.name.strip():
|
||||||
|
raise ValueError("收藏名称不能为空")
|
||||||
|
result["name"] = value.name.strip()
|
||||||
|
snapshot = result["snapshot"]
|
||||||
|
options = value.task_config.model_dump(exclude_none=True) if value.task_config else {}
|
||||||
|
if result["kind"] == "batch":
|
||||||
|
for index, case in enumerate(snapshot["cases"]):
|
||||||
|
if override := value.case_configs.get(index):
|
||||||
|
_merge_options(case["task_config"], override.model_dump(exclude_none=True))
|
||||||
|
_merge_options(case["task_config"], options)
|
||||||
|
if set(value.case_configs) - set(range(len(snapshot["cases"]))):
|
||||||
|
raise ValueError("批次用例下标不存在")
|
||||||
|
if options.get("device_serial"):
|
||||||
|
snapshot["device_serial"] = options["device_serial"]
|
||||||
|
elif result["kind"] == "exploration":
|
||||||
|
config = snapshot["config"]
|
||||||
|
config.update(
|
||||||
|
{
|
||||||
|
k: options[k]
|
||||||
|
for k in ("model", "reasoning_effort", "timeout_seconds")
|
||||||
|
if k in options
|
||||||
|
}
|
||||||
|
)
|
||||||
|
snapshot["config"] = ExplorationConfig.model_validate(config).model_dump()
|
||||||
|
_merge_options(snapshot["task_config"], options)
|
||||||
|
else:
|
||||||
|
_merge_options(snapshot["runtime_overrides"], options)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def update(self, key: str, value: FavoriteUpdate) -> dict:
|
||||||
|
favorite = self.updated(self.get(key), value)
|
||||||
|
favorite["updated_at"] = time.time()
|
||||||
|
return self.store.put("favorite", key, favorite)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
with self.store.connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM automation_records WHERE kind='favorite' AND id=?", (identifier(key),)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
async def run(self, key: str, value: FavoriteRun) -> dict:
|
||||||
|
lock = self._run_locks.setdefault(value.request_id, asyncio.Lock())
|
||||||
|
async with lock:
|
||||||
|
return await self._run(key, value)
|
||||||
|
|
||||||
|
async def _run(self, key: str, value: FavoriteRun) -> dict:
|
||||||
|
key = identifier(key)
|
||||||
|
request = {"favorite_id": key, **value.model_dump(exclude_none=True)}
|
||||||
|
signature = hashlib.sha256(json.dumps(request, sort_keys=True).encode()).hexdigest()
|
||||||
|
# Reserve a durable snapshot before admission. Retries survive restarts,
|
||||||
|
# edits/deletions of the favorite, and lost HTTP responses.
|
||||||
|
with self.store.connection() as conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='favorite_run' AND id=?",
|
||||||
|
(value.request_id,),
|
||||||
|
).fetchone()
|
||||||
|
record = json.loads(row[0]) if row else None
|
||||||
|
if record and record["signature"] != signature:
|
||||||
|
raise ValueError("request_id 已用于其他内容")
|
||||||
|
if record and record.get("result"):
|
||||||
|
return record["result"]
|
||||||
|
if record is None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='favorite' AND id=?", (key,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise KeyError("收藏不存在")
|
||||||
|
record = {
|
||||||
|
"signature": signature,
|
||||||
|
"favorite": self.updated(json.loads(row[0]), value),
|
||||||
|
}
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO automation_records VALUES (?,?,?,?)",
|
||||||
|
(
|
||||||
|
"favorite_run",
|
||||||
|
value.request_id,
|
||||||
|
json.dumps(record, ensure_ascii=False),
|
||||||
|
time.time(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
favorite = record["favorite"]
|
||||||
|
snapshot = favorite["snapshot"]
|
||||||
|
run_id = str(uuid5(NAMESPACE_URL, "artemis-favorite:" + value.request_id))
|
||||||
|
if favorite["kind"] == "batch":
|
||||||
|
from apps.admin_console.services.automation_service import create_batch, batch_action
|
||||||
|
|
||||||
|
batch = create_batch(
|
||||||
|
{**snapshot, "name": favorite["name"], "request_id": "favorite-" + run_id}
|
||||||
|
)
|
||||||
|
if batch["status"] == "draft":
|
||||||
|
batch = batch_action(batch["id"], "resume")
|
||||||
|
result = {"kind": "batch", "batch_id": batch["id"], "status": batch["status"]}
|
||||||
|
elif favorite["kind"] == "exploration":
|
||||||
|
from apps.admin_console.services.exploration_service import exploration_service
|
||||||
|
from artemis.exploration.models import CreateExploration
|
||||||
|
|
||||||
|
session = exploration_service.create(CreateExploration(request_id=run_id, **snapshot))
|
||||||
|
admission = await exploration_service.start(session["id"], run_id)
|
||||||
|
run = admission.get("run", admission)
|
||||||
|
rejection = admission.get("admission", {})
|
||||||
|
outcome = run.get("outcome") or {}
|
||||||
|
if (
|
||||||
|
rejection.get("status") in {"error", "rejected"}
|
||||||
|
or outcome.get("status") == "rejected"
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
rejection.get("error")
|
||||||
|
or rejection.get("message")
|
||||||
|
or outcome.get("error")
|
||||||
|
or "探索未入队"
|
||||||
|
)
|
||||||
|
result = {
|
||||||
|
"kind": "exploration",
|
||||||
|
"session_id": run["id"],
|
||||||
|
"exploration_id": session["id"],
|
||||||
|
"admission": admission,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
from apps.admin_console.routers.tasks import run_task
|
||||||
|
from apps.admin_console.schemas.task_schema import RunRequest
|
||||||
|
|
||||||
|
admission = await run_task(
|
||||||
|
RunRequest(
|
||||||
|
goal=snapshot["goal"],
|
||||||
|
profile=snapshot["runtime_overrides"].get("profile", "flash"),
|
||||||
|
runtime_overrides=TaskOptions.model_validate(snapshot["runtime_overrides"]),
|
||||||
|
session_id=run_id,
|
||||||
|
ingress="favorite",
|
||||||
|
expected_output=snapshot.get("expected_output"),
|
||||||
|
enable_outputter=snapshot.get("enable_outputter"),
|
||||||
|
app_path=snapshot.get("app_path"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not admission.get("tasks"):
|
||||||
|
raise ValueError(admission.get("error") or admission.get("message") or "任务未入队")
|
||||||
|
result = {"kind": "task", "session_id": run_id, "admission": admission}
|
||||||
|
record["result"] = result
|
||||||
|
self.store.put("favorite_run", value.request_id, record)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
favorites = FavoriteService()
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ 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-sol",
|
||||||
|
"gpt-6-astra",
|
||||||
"mimo-v2.6-pro",
|
"mimo-v2.6-pro",
|
||||||
],
|
],
|
||||||
"env_path": str(get_env_file()),
|
"env_path": str(get_env_file()),
|
||||||
@@ -175,6 +175,7 @@ class CapabilitySettings(BaseModel):
|
|||||||
)
|
)
|
||||||
video_enabled: bool = False
|
video_enabled: bool = False
|
||||||
recording_enabled: bool = False
|
recording_enabled: bool = False
|
||||||
|
recording: dict = Field(default_factory=dict)
|
||||||
outputter_enabled: bool = True
|
outputter_enabled: bool = True
|
||||||
step_summarizer_enabled: bool = True
|
step_summarizer_enabled: bool = True
|
||||||
screenshot_limit_enabled: bool = True
|
screenshot_limit_enabled: bool = True
|
||||||
@@ -204,6 +205,7 @@ 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,
|
||||||
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,
|
||||||
@@ -212,6 +214,12 @@ 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_AUTO_CONNECT": update.adb_auto_connect,
|
||||||
"ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS": update.adb_discovery_interval_seconds,
|
"ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS": update.adb_discovery_interval_seconds,
|
||||||
@@ -229,6 +237,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,15 @@ from typing import Callable, cast
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import threading
|
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from apps.admin_console.services.export_cache import (
|
||||||
|
compression,
|
||||||
|
export_lock,
|
||||||
|
fingerprint,
|
||||||
|
remember,
|
||||||
|
reusable,
|
||||||
|
)
|
||||||
|
from apps.admin_console.services.request_diagnostics import diagnostics_json
|
||||||
|
|
||||||
from docx import Document
|
from docx import Document
|
||||||
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
|
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
|
||||||
@@ -27,7 +34,7 @@ from artemis.runtime.case_result import RESULT_LABELS, resolve_case_result
|
|||||||
from apps.admin_console.services.case_review import reviewed_result
|
from apps.admin_console.services.case_review import reviewed_result
|
||||||
|
|
||||||
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
|
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
|
||||||
EXPORT_LOCK = threading.RLock()
|
BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-2"
|
||||||
STATUS = {
|
STATUS = {
|
||||||
"completed": "执行完成",
|
"completed": "执行完成",
|
||||||
"success": "执行完成",
|
"success": "执行完成",
|
||||||
@@ -156,6 +163,8 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def session_report(session_id: str, db_path=None) -> dict:
|
def session_report(session_id: str, db_path=None) -> dict:
|
||||||
|
from apps.admin_console.services.exploration_evidence import exploration_metadata
|
||||||
|
|
||||||
identifier(session_id)
|
identifier(session_id)
|
||||||
meta = store.job(session_id) or {}
|
meta = store.job(session_id) or {}
|
||||||
repo = SessionRepository(db_path) if db_path else SessionRepository()
|
repo = SessionRepository(db_path) if db_path else SessionRepository()
|
||||||
@@ -202,6 +211,7 @@ def session_report(session_id: str, db_path=None) -> dict:
|
|||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"id": session_id,
|
"id": session_id,
|
||||||
|
"exploration": exploration_metadata(session_id),
|
||||||
"name": execution_title(meta) or session.get("initial_goal", session_id)[:120],
|
"name": execution_title(meta) or session.get("initial_goal", session_id)[:120],
|
||||||
"iteration": meta.get("iteration", 1),
|
"iteration": meta.get("iteration", 1),
|
||||||
"repeat_count": meta.get("repeat_count", 1),
|
"repeat_count": meta.get("repeat_count", 1),
|
||||||
@@ -237,6 +247,21 @@ def time_text(value) -> str:
|
|||||||
def metadata(report: dict) -> list[tuple]:
|
def metadata(report: dict) -> list[tuple]:
|
||||||
return [
|
return [
|
||||||
("任务编号", report["id"]),
|
("任务编号", report["id"]),
|
||||||
|
*(
|
||||||
|
(
|
||||||
|
[
|
||||||
|
("探索会话", report["exploration"]["id"]),
|
||||||
|
(
|
||||||
|
"探索关联",
|
||||||
|
"用例 v" + str(report["exploration"]["case_version"]) + " 复跑"
|
||||||
|
if report["exploration"]["kind"] == "validation"
|
||||||
|
else "探索执行(不等于独立复跑通过)",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if report.get("exploration")
|
||||||
|
else []
|
||||||
|
),
|
||||||
("用例编号", report.get("case_id") or "—"),
|
("用例编号", report.get("case_id") or "—"),
|
||||||
("检测项目", report.get("category") or "—"),
|
("检测项目", report.get("category") or "—"),
|
||||||
("测试子项", report.get("subcategory") or "—"),
|
("测试子项", report.get("subcategory") or "—"),
|
||||||
@@ -450,6 +475,65 @@ def add_markdown_content(doc, content: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_operations(steps: list[dict]) -> list[dict]:
|
||||||
|
operations = []
|
||||||
|
for step in steps:
|
||||||
|
for trace in step.get("generic_tools", []):
|
||||||
|
payload = trace.get("payload") or {}
|
||||||
|
if isinstance(payload, str):
|
||||||
|
try:
|
||||||
|
payload = json.loads(payload)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if not isinstance(payload, dict) or not payload.get("plugin_id"):
|
||||||
|
continue
|
||||||
|
result = payload.get("result") or {}
|
||||||
|
if isinstance(result, str):
|
||||||
|
try:
|
||||||
|
result = json.loads(result)
|
||||||
|
except ValueError:
|
||||||
|
result = {}
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
result = {}
|
||||||
|
content = "\n".join(
|
||||||
|
str(block.get("text", ""))
|
||||||
|
for block in result.get("content", [])
|
||||||
|
if isinstance(block, dict) and block.get("type") == "text"
|
||||||
|
)
|
||||||
|
source = result.get("source") or payload.get("source") or {}
|
||||||
|
evidence = []
|
||||||
|
if result.get("call_id"):
|
||||||
|
evidence.append("调用:" + str(result["call_id"]))
|
||||||
|
if source:
|
||||||
|
evidence.append(
|
||||||
|
f"来源:{source.get('kind', '')} · {source.get('label') or source.get('target_id', '')}"
|
||||||
|
)
|
||||||
|
if result.get("execution"):
|
||||||
|
evidence.append(
|
||||||
|
f"执行:{result['execution']};副作用:{result.get('side_effect', 'unknown')};错误码:{result.get('error_code') or '无'}"
|
||||||
|
)
|
||||||
|
for attachment in result.get("attachments") or []:
|
||||||
|
evidence.append(
|
||||||
|
f"文件:{attachment.get('name')};SHA-256:{attachment.get('sha256')}"
|
||||||
|
)
|
||||||
|
if evidence:
|
||||||
|
content = "\n".join([content or result.get("message") or "", *evidence])
|
||||||
|
operations.append(
|
||||||
|
{
|
||||||
|
"step": step.get("step_number"),
|
||||||
|
"plugin": payload["plugin_id"],
|
||||||
|
"version": payload.get("plugin_version", ""),
|
||||||
|
"tool": result.get("tool") or trace.get("name"),
|
||||||
|
"resources": ", ".join(payload.get("resource_ids") or []),
|
||||||
|
"status": result.get("status") or trace.get("status", "unknown"),
|
||||||
|
"content": (
|
||||||
|
content or payload.get("error") or result.get("message") or "未返回文字证据"
|
||||||
|
)[:6000],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return operations
|
||||||
|
|
||||||
|
|
||||||
def word_report(report: dict, path: Path):
|
def word_report(report: dict, path: Path):
|
||||||
doc = word_styles(Document())
|
doc = word_styles(Document())
|
||||||
doc.add_paragraph(report["name"], style="Title")
|
doc.add_paragraph(report["name"], style="Title")
|
||||||
@@ -490,6 +574,15 @@ def word_report(report: dict, path: Path):
|
|||||||
f"步骤 {step['step_number']} · {time_text(step.get('timestamp'))}\n"
|
f"步骤 {step['step_number']} · {time_text(step.get('timestamp'))}\n"
|
||||||
).bold = True
|
).bold = True
|
||||||
paragraph.add_run(step.get("summary") or "已记录操作")
|
paragraph.add_run(step.get("summary") or "已记录操作")
|
||||||
|
operations = plugin_operations(report["steps"])
|
||||||
|
if operations:
|
||||||
|
doc.add_heading("扩展插件操作", 2)
|
||||||
|
for operation in operations:
|
||||||
|
doc.add_paragraph(
|
||||||
|
f"步骤 {operation['step']} · {operation['plugin']} v{operation['version']} · {operation['tool']} · {operation['status']}"
|
||||||
|
)
|
||||||
|
doc.add_paragraph("资源:" + operation["resources"])
|
||||||
|
doc.add_paragraph(operation["content"])
|
||||||
evidence = report["steps"][:1] + report["steps"][-1:]
|
evidence = report["steps"][:1] + report["steps"][-1:]
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
images: list[tuple[Path, int]] = []
|
images: list[tuple[Path, int]] = []
|
||||||
@@ -630,12 +723,36 @@ def excel_report(report: dict, path: Path):
|
|||||||
],
|
],
|
||||||
(10, 22, 68, 26, 24),
|
(10, 22, 68, 26, 24),
|
||||||
)
|
)
|
||||||
|
operations = plugin_operations(report["steps"])
|
||||||
|
if operations:
|
||||||
|
sheet_rows(
|
||||||
|
wb.create_sheet("插件操作"),
|
||||||
|
[
|
||||||
|
("步骤", "插件", "版本", "工具", "资源", "调用状态", "观察与结果"),
|
||||||
|
*[
|
||||||
|
(
|
||||||
|
op["step"],
|
||||||
|
op["plugin"],
|
||||||
|
op["version"],
|
||||||
|
op["tool"],
|
||||||
|
op["resources"],
|
||||||
|
op["status"],
|
||||||
|
op["content"],
|
||||||
|
)
|
||||||
|
for op in operations
|
||||||
|
],
|
||||||
|
],
|
||||||
|
(10, 24, 14, 26, 24, 16, 80),
|
||||||
|
)
|
||||||
wb.save(path)
|
wb.save(path)
|
||||||
|
|
||||||
|
|
||||||
def export_session(session_id: str, formats: list[str] | None = None) -> list[Path]:
|
def export_session(
|
||||||
with EXPORT_LOCK:
|
session_id: str, formats: list[str] | None = None, *, report: dict | None = None
|
||||||
report = session_report(session_id)
|
) -> list[Path]:
|
||||||
|
with export_lock("session:" + identifier(session_id)):
|
||||||
|
report = report if report is not None else session_report(session_id)
|
||||||
|
signature = fingerprint(report, session_screenshots(report))
|
||||||
directory = EXPORT_ROOT / "sessions" / identifier(session_id)
|
directory = EXPORT_ROOT / "sessions" / identifier(session_id)
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
outputs = []
|
outputs = []
|
||||||
@@ -643,9 +760,13 @@ def export_session(session_id: str, formats: list[str] | None = None) -> list[Pa
|
|||||||
if fmt not in {"docx", "xlsx"}:
|
if fmt not in {"docx", "xlsx"}:
|
||||||
raise ValueError("不支持的报告格式")
|
raise ValueError("不支持的报告格式")
|
||||||
path = directory / f"任务报告.{fmt}"
|
path = directory / f"任务报告.{fmt}"
|
||||||
|
if reusable(path, signature, report["status"]):
|
||||||
|
outputs.append(path)
|
||||||
|
continue
|
||||||
temporary = directory / f"生成中.{fmt}"
|
temporary = directory / f"生成中.{fmt}"
|
||||||
(word_report if fmt == "docx" else excel_report)(report, temporary)
|
(word_report if fmt == "docx" else excel_report)(report, temporary)
|
||||||
temporary.replace(path)
|
temporary.replace(path)
|
||||||
|
remember(path, signature)
|
||||||
outputs.append(path)
|
outputs.append(path)
|
||||||
return outputs
|
return outputs
|
||||||
|
|
||||||
@@ -663,6 +784,30 @@ def session_screenshots(report: dict) -> list[tuple[Path, str]]:
|
|||||||
f"{index:04d}_步骤{int(step.get('step_number') or index):04d}_{phase}{source.suffix.lower()}",
|
f"{index:04d}_步骤{int(step.get('step_number') or index):04d}_{phase}{source.suffix.lower()}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# Plugin observations are attached to their own tool traces rather than
|
||||||
|
# replacing a phone's step image in a mixed task.
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for tool_index, trace in enumerate(step.get("generic_tools", []), 1):
|
||||||
|
payload = trace.get("payload") or {}
|
||||||
|
if isinstance(payload, str):
|
||||||
|
try:
|
||||||
|
payload = json.loads(payload)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if not isinstance(payload, dict) or not payload.get("plugin_id"):
|
||||||
|
continue
|
||||||
|
plugin_id = re.sub(r"[^a-zA-Z0-9_-]", "_", str(payload["plugin_id"]))[:48]
|
||||||
|
names = payload.get("image_names") or [payload.get("post_image_name")]
|
||||||
|
for image_index, name in enumerate(names, 1):
|
||||||
|
source = image_path(name)
|
||||||
|
if source and (plugin_id, str(name)) not in seen:
|
||||||
|
seen.add((plugin_id, str(name)))
|
||||||
|
screenshots.append(
|
||||||
|
(
|
||||||
|
source,
|
||||||
|
f"{index:04d}_插件_{plugin_id}_{tool_index:03d}_{image_index:02d}{source.suffix.lower()}",
|
||||||
|
)
|
||||||
|
)
|
||||||
return screenshots
|
return screenshots
|
||||||
|
|
||||||
|
|
||||||
@@ -697,48 +842,129 @@ def session_videos(report: dict) -> list[tuple[Path, str]]:
|
|||||||
return [(path, f"{i:04d}_{path.parent.name}_{path.name}") for i, path in enumerate(paths, 1)]
|
return [(path, f"{i:04d}_{path.parent.name}_{path.name}") for i, path in enumerate(paths, 1)]
|
||||||
|
|
||||||
|
|
||||||
def export_session_media(session_id: str, kind: str) -> Path:
|
def session_artifacts(report: dict) -> list[tuple[Path, str]]:
|
||||||
|
"""Only completed, task-owned Android files; never follow external links."""
|
||||||
|
traces = TRACES_PATH.resolve()
|
||||||
|
root = traces / identifier(report["id"]) / "artifacts" / "android"
|
||||||
|
if not root.is_dir() or root.resolve() != root:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
(path, path.name)
|
||||||
|
for path in sorted(root.iterdir())
|
||||||
|
if path.is_file()
|
||||||
|
and not path.is_symlink()
|
||||||
|
and not path.name.startswith(".artemis-")
|
||||||
|
and path.resolve().parent == root.resolve()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def session_download(session_id: str, kind: str) -> tuple[Path, str]:
|
||||||
|
with export_lock("session:" + identifier(session_id)):
|
||||||
|
report = session_report(session_id)
|
||||||
|
return export_session_media(session_id, kind, report=report), archive_case_name(report)
|
||||||
|
|
||||||
|
|
||||||
|
def export_session_media(session_id: str, kind: str, *, report: dict | None = None) -> Path:
|
||||||
|
from apps.admin_console.services.evidence_service import export_index, plugin_files
|
||||||
|
|
||||||
if kind not in {"screenshots", "video", "all"}:
|
if kind not in {"screenshots", "video", "all"}:
|
||||||
raise ValueError("不支持的下载类型")
|
raise ValueError("不支持的下载类型")
|
||||||
with EXPORT_LOCK:
|
with export_lock("session:" + identifier(session_id)):
|
||||||
report = session_report(session_id)
|
report = report if report is not None else session_report(session_id)
|
||||||
target_dir = EXPORT_ROOT / "sessions" / identifier(session_id)
|
target_dir = EXPORT_ROOT / "sessions" / identifier(session_id)
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
images = session_screenshots(report) if kind in {"screenshots", "all"} else []
|
images = session_screenshots(report) if kind in {"screenshots", "all"} else []
|
||||||
videos = session_videos(report) if kind in {"video", "all"} else []
|
videos = session_videos(report) if kind in {"video", "all"} else []
|
||||||
if kind == "video" and not videos:
|
artifacts = session_artifacts(report) if kind == "all" else []
|
||||||
|
extras = plugin_files(session_id) if kind in {"video", "all"} else []
|
||||||
|
if kind == "video":
|
||||||
|
extras = [
|
||||||
|
(path, name)
|
||||||
|
for path, name in extras
|
||||||
|
if path.suffix.lower() in {".mp4", ".mkv", ".webm"}
|
||||||
|
]
|
||||||
|
if kind == "video" and not videos and not extras:
|
||||||
raise ValueError("此任务没有已保存的录屏")
|
raise ValueError("此任务没有已保存的录屏")
|
||||||
if kind == "screenshots" and not images:
|
if kind == "screenshots" and not images:
|
||||||
raise ValueError("此任务没有可下载的步骤截图")
|
raise ValueError("此任务没有可下载的步骤截图")
|
||||||
target = target_dir / f"{kind}.zip"
|
target = target_dir / f"{kind}.zip"
|
||||||
temporary = target_dir / f".{kind}.tmp"
|
temporary = target_dir / f".{kind}.tmp"
|
||||||
|
files = images + videos + artifacts + extras
|
||||||
|
diagnostics = diagnostics_json(session_id) if kind == "all" else None
|
||||||
|
diagnostic_state = json.loads(diagnostics) if diagnostics else None
|
||||||
|
if isinstance(diagnostic_state, dict):
|
||||||
|
diagnostic_state.pop("collected_at", None)
|
||||||
|
signature = fingerprint(report, files, diagnostic_state)
|
||||||
|
if reusable(target, signature, report["status"]):
|
||||||
|
return target
|
||||||
manifest = {
|
manifest = {
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"name": report["name"],
|
"name": report["name"],
|
||||||
"status": report["status"],
|
"status": report["status"],
|
||||||
"verdict": report["verdict"],
|
"verdict": report["verdict"],
|
||||||
"screenshots": [name for _, name in images],
|
"screenshots": [name for _, name in images],
|
||||||
"videos": [name for _, name in videos],
|
"videos": ["任务录屏/安卓/" + name for _, name in videos]
|
||||||
|
+ [name for path, name in extras if path.suffix.lower() in {".mp4", ".mkv", ".webm"}],
|
||||||
|
"artifacts": [name for _, name in artifacts],
|
||||||
"note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
|
"note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
|
||||||
|
"snapshot": report["status"]
|
||||||
|
not in {"completed", "success", "failed", "cancelled", "interrupted", "skipped"},
|
||||||
}
|
}
|
||||||
prefix = archive_case_name(report) + "/" if kind == "all" else ""
|
prefix = archive_case_name(report) + "/" if kind == "all" else ""
|
||||||
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
|
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
if kind == "all":
|
if kind == "all":
|
||||||
for path in export_session(session_id, ["docx", "xlsx"]):
|
for path in export_session(session_id, ["docx", "xlsx"], report=report):
|
||||||
archive.write(path, prefix + path.name)
|
archive.write(path, prefix + path.name, compress_type=compression(path))
|
||||||
for source, name in images:
|
for source, name in images:
|
||||||
archive.write(source, f"{prefix}步骤截图/{name}")
|
archive.write(source, f"{prefix}步骤截图/{name}", compress_type=compression(source))
|
||||||
for source, name in videos:
|
for source, name in videos:
|
||||||
archive.write(source, f"{prefix}任务录屏/{name}")
|
archive.write(
|
||||||
|
source, f"{prefix}任务录屏/安卓/{name}", compress_type=zipfile.ZIP_STORED
|
||||||
|
)
|
||||||
|
for source, name in artifacts:
|
||||||
|
archive.write(
|
||||||
|
source, f"{prefix}任务文件/安卓/{name}", compress_type=compression(source)
|
||||||
|
)
|
||||||
|
for source, name in extras:
|
||||||
|
archive.write(
|
||||||
|
source,
|
||||||
|
prefix + name,
|
||||||
|
compress_type=compression(source),
|
||||||
|
)
|
||||||
|
if kind == "all":
|
||||||
|
archive.writestr(prefix + "模型请求诊断.json", diagnostics or "{}")
|
||||||
|
archive.writestr(
|
||||||
|
prefix + "证据索引.json",
|
||||||
|
json.dumps(
|
||||||
|
export_index(
|
||||||
|
session_id,
|
||||||
|
report,
|
||||||
|
videos,
|
||||||
|
images=images,
|
||||||
|
artifacts=artifacts,
|
||||||
|
extras=extras,
|
||||||
|
),
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
default=str,
|
||||||
|
),
|
||||||
|
)
|
||||||
archive.writestr(
|
archive.writestr(
|
||||||
prefix + "文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2)
|
prefix + "文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2)
|
||||||
)
|
)
|
||||||
temporary.replace(target)
|
temporary.replace(target)
|
||||||
|
if fingerprint(report, files, diagnostic_state) == signature:
|
||||||
|
remember(target, signature)
|
||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
def export_batch(batch_id: str, 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("批次不存在")
|
||||||
@@ -776,20 +1002,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, BATCH_ARCHIVE_SIGNATURE)
|
||||||
return target
|
return target
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Read/export model diagnostics from the task's existing trace database."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from apps.admin_console.database.connection import db_session
|
||||||
|
|
||||||
|
|
||||||
|
def request_diagnostics(
|
||||||
|
session_id: str, *, db_path=None, limit: int | None = None, offset: int = 0
|
||||||
|
) -> dict:
|
||||||
|
with db_session(db_path) as conn:
|
||||||
|
session = conn.execute(
|
||||||
|
"SELECT status FROM sessions WHERE session_id = ?", (session_id,)
|
||||||
|
).fetchone()
|
||||||
|
sql = (
|
||||||
|
"SELECT payload FROM traces WHERE session_id = ? AND type = 'llm_diagnostic' "
|
||||||
|
"ORDER BY timestamp DESC"
|
||||||
|
)
|
||||||
|
params: tuple = (session_id,)
|
||||||
|
total = conn.execute(
|
||||||
|
"SELECT count(*) FROM traces WHERE session_id=? AND type='llm_diagnostic'",
|
||||||
|
params,
|
||||||
|
).fetchone()[0]
|
||||||
|
if limit is not None:
|
||||||
|
sql += " LIMIT ? OFFSET ?"
|
||||||
|
params += (limit, offset)
|
||||||
|
rows = conn.execute(sql, params).fetchall()
|
||||||
|
review_rows = conn.execute(
|
||||||
|
"SELECT payload FROM traces WHERE session_id = ? "
|
||||||
|
"AND type = 'final_review_diagnostic' ORDER BY timestamp DESC"
|
||||||
|
+ (" LIMIT ?" if limit is not None else ""),
|
||||||
|
(session_id, limit) if limit is not None else (session_id,),
|
||||||
|
).fetchall()
|
||||||
|
requests = []
|
||||||
|
seen = set()
|
||||||
|
running = bool(session and session["status"] in {"running", "resuming"})
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
value = json.loads(row["payload"])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
request_id = value.get("request_id")
|
||||||
|
value["active"] = (
|
||||||
|
running
|
||||||
|
and offset == 0
|
||||||
|
and request_id not in seen
|
||||||
|
and value.get("phase")
|
||||||
|
in {
|
||||||
|
"waiting_response",
|
||||||
|
"waiting_output",
|
||||||
|
"receiving_output",
|
||||||
|
"generating_tool_arguments",
|
||||||
|
"retry_wait",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
seen.add(request_id)
|
||||||
|
value["timestamps_utc"] = {
|
||||||
|
field: datetime.fromtimestamp(value[field], UTC).isoformat()
|
||||||
|
for field in (
|
||||||
|
"started_at",
|
||||||
|
"headers_at",
|
||||||
|
"first_output_at",
|
||||||
|
"last_output_at",
|
||||||
|
"ended_at",
|
||||||
|
"retry_at",
|
||||||
|
)
|
||||||
|
if isinstance(value.get(field), (int, float))
|
||||||
|
}
|
||||||
|
requests.append(value)
|
||||||
|
final_reviews = []
|
||||||
|
for row in review_rows:
|
||||||
|
try:
|
||||||
|
value = json.loads(row["payload"])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
value["active"] = (
|
||||||
|
running and offset == 0 and not final_reviews and value.get("status") == "running"
|
||||||
|
)
|
||||||
|
if value["active"] and isinstance(value.get("started_at"), (int, float)):
|
||||||
|
value["elapsed_seconds"] = round(max(0.0, time.time() - value["started_at"]), 3)
|
||||||
|
value["remaining_seconds"] = round(
|
||||||
|
max(0.0, value.get("total_seconds", 0.0) - value["elapsed_seconds"]), 3
|
||||||
|
)
|
||||||
|
value["budget_observed_at"] = time.time()
|
||||||
|
value["timestamps_utc"] = {
|
||||||
|
field: datetime.fromtimestamp(value[field], UTC).isoformat()
|
||||||
|
for field in ("started_at", "deadline_at", "ended_at")
|
||||||
|
if isinstance(value.get(field), (int, float))
|
||||||
|
}
|
||||||
|
final_reviews.append(value)
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"session_id": session_id,
|
||||||
|
"session_status": session["status"] if session else "unknown",
|
||||||
|
"collected_at": time.time(),
|
||||||
|
"requests": requests,
|
||||||
|
"final_reviews": final_reviews,
|
||||||
|
"total": total,
|
||||||
|
"offset": offset,
|
||||||
|
"next_offset": offset + limit if limit is not None and offset + limit < total else None,
|
||||||
|
"note": "时间戳为 Unix 秒;timestamps_utc 为 UTC。HTTP 200 不代表完整输出。usage=null 表示服务未报告用量。历史任务没有记录的字段无法补推。",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def diagnostics_json(session_id: str) -> str:
|
||||||
|
return json.dumps(request_diagnostics(session_id), ensure_ascii=False, indent=2)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Read the impact of deleting a task without deleting or stopping anything."""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from artemis.exploration.store import ExplorationConflict, explorations
|
||||||
|
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||||
|
from apps.admin_console.services.automation_store import store
|
||||||
|
from apps.admin_console.services.exploration_deletion import _check_runs
|
||||||
|
|
||||||
|
|
||||||
|
def deletion_preview(session_id: str) -> dict:
|
||||||
|
session_id = str(UUID(session_id))
|
||||||
|
run = explorations.run(session_id)
|
||||||
|
if run and run["kind"] == "exploration":
|
||||||
|
return exploration_deletion_preview(run["exploration_id"])
|
||||||
|
if not session_repo.get_session_by_id(session_id) and not store.job(session_id):
|
||||||
|
raise KeyError("任务不存在")
|
||||||
|
_check_runs([{"id": session_id}])
|
||||||
|
job = store.job(session_id) or {}
|
||||||
|
if job.get("status") in {"pending", "queued", "running", "task_paused", "resuming"}:
|
||||||
|
raise ExplorationConflict("任务仍在执行或排队,请先停止并等待结束")
|
||||||
|
return {
|
||||||
|
"session_ids": [session_id],
|
||||||
|
"preserved_session_ids": [],
|
||||||
|
"removes": ["任务历史", "报告", "截图、录像及附件"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def exploration_deletion_preview(exploration_id: str) -> dict:
|
||||||
|
session = explorations.get(exploration_id)
|
||||||
|
runs = explorations.runs(exploration_id)
|
||||||
|
_check_runs(runs)
|
||||||
|
if session.get("active_run_id") or session.get("active_demonstration_id"):
|
||||||
|
raise ExplorationConflict("探索尚有活动任务或示范,请先停止并等待结束")
|
||||||
|
return {
|
||||||
|
"exploration_id": exploration_id,
|
||||||
|
"session_ids": [run["id"] for run in runs if run["kind"] == "exploration"],
|
||||||
|
"preserved_session_ids": [run["id"] for run in runs if run["kind"] != "exploration"],
|
||||||
|
"removes": ["探索对话", "用例版本", "探索执行历史及证据"],
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@ import zipfile
|
|||||||
from openpyxl import Workbook, load_workbook
|
from openpyxl import Workbook, load_workbook
|
||||||
from openpyxl.styles import Alignment, Font, PatternFill
|
from openpyxl.styles import Alignment, Font, PatternFill
|
||||||
from openpyxl.utils import get_column_letter
|
from openpyxl.utils import get_column_letter
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, field_serializer
|
||||||
|
from artemis.plugins.models import ExecutionTarget, PluginSelection
|
||||||
|
from artemis.config.agent import RecordingConfig
|
||||||
|
|
||||||
HEADERS = (
|
HEADERS = (
|
||||||
"用例编号",
|
"用例编号",
|
||||||
@@ -37,6 +39,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 +50,13 @@ 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 = None
|
||||||
|
|
||||||
|
@field_serializer("recording", when_used="unless-none")
|
||||||
|
def serialize_recording(self, value: RecordingConfig) -> dict:
|
||||||
|
# A per-task partial override must not replace other saved recording defaults.
|
||||||
|
return value.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
|
||||||
class TaskCase(BaseModel):
|
class TaskCase(BaseModel):
|
||||||
@@ -93,7 +103,7 @@ def template() -> bytes:
|
|||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"profile": "pro",
|
"profile": "pro",
|
||||||
"model": "mimo-v2.6-flash",
|
"model": "gpt-6-luna",
|
||||||
"reasoning_effort": "high",
|
"reasoning_effort": "high",
|
||||||
"verification_level": "final",
|
"verification_level": "final",
|
||||||
"record_video": True,
|
"record_video": True,
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""Bounded, read-only task diagnostics shared by MCP and HTTP clients."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||||
|
from apps.admin_console.services import evidence_service, report_export
|
||||||
|
from apps.admin_console.services.automation_store import identifier, store
|
||||||
|
from apps.admin_console.services.request_diagnostics import request_diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
_SECRET = re.compile(
|
||||||
|
r"(?i)^(api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|password|secret|client_secret|token_key)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def public_value(value, *, max_chars: int = 60000):
|
||||||
|
"""Remove common credential forms and bound text without inventing missing evidence."""
|
||||||
|
remaining = max_chars
|
||||||
|
remaining_nodes = 2000
|
||||||
|
truncated = False
|
||||||
|
secrets = [
|
||||||
|
v
|
||||||
|
for k, v in os.environ.items()
|
||||||
|
if len(v) >= 8 and re.search(r"(?i)(api_?key|password|secret|token)$", k)
|
||||||
|
]
|
||||||
|
|
||||||
|
def clean(item, depth=0):
|
||||||
|
nonlocal remaining, remaining_nodes, truncated
|
||||||
|
remaining_nodes -= 1
|
||||||
|
if depth > 12:
|
||||||
|
truncated = True
|
||||||
|
return "[嵌套内容已截断]"
|
||||||
|
if isinstance(item, dict):
|
||||||
|
result = {}
|
||||||
|
for k, v in item.items():
|
||||||
|
if remaining_nodes <= 0 or len(result) >= 100:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
key = clean(str(k), depth + 1)
|
||||||
|
result[key] = "[REDACTED]" if _SECRET.fullmatch(str(k)) else clean(v, depth + 1)
|
||||||
|
return result
|
||||||
|
if isinstance(item, list):
|
||||||
|
result = []
|
||||||
|
for v in item:
|
||||||
|
if remaining_nodes <= 0 or len(result) >= 100:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
result.append(clean(v, depth + 1))
|
||||||
|
return result
|
||||||
|
if not isinstance(item, str):
|
||||||
|
return item
|
||||||
|
for secret in secrets:
|
||||||
|
item = item.replace(secret, "[REDACTED]")
|
||||||
|
item = re.sub(r"(?i)\bBearer\s+[^\s\"',;]+", "Bearer [REDACTED]", item)
|
||||||
|
item = re.sub(r"\bsk-[A-Za-z0-9_-]+", "[REDACTED]", item)
|
||||||
|
item = re.sub(r"(https?://)[^\s/@]+:[^\s/@]+@", r"\1[REDACTED]@", item)
|
||||||
|
item = re.sub(
|
||||||
|
r"""(?ix)((?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|password|secret|token_key)["']?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s&,;]+)""",
|
||||||
|
r"\1[REDACTED]",
|
||||||
|
item,
|
||||||
|
)
|
||||||
|
allowance = min(8000, remaining)
|
||||||
|
if len(item) > allowance:
|
||||||
|
truncated = True
|
||||||
|
item = item[:allowance] + "…[已截断;按步骤或关键词缩小范围]"
|
||||||
|
remaining = max(0, remaining - min(len(item), allowance))
|
||||||
|
return item
|
||||||
|
|
||||||
|
result = clean(value)
|
||||||
|
return {**result, "text_truncated": truncated, "content_truncated": truncated}
|
||||||
|
|
||||||
|
|
||||||
|
def page(items: list, offset: int, limit: int) -> dict:
|
||||||
|
return {
|
||||||
|
"items": items[offset : offset + limit],
|
||||||
|
"total": len(items),
|
||||||
|
"offset": offset,
|
||||||
|
"limit": limit,
|
||||||
|
"next_offset": offset + limit if offset + limit < len(items) else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_task(session_id: str, view: str, *, offset: int = 0, limit: int = 20) -> dict:
|
||||||
|
identifier(session_id)
|
||||||
|
if offset < 0 or not 1 <= limit <= 100:
|
||||||
|
raise ValueError("offset 必须非负,limit 必须在 1–100 之间")
|
||||||
|
if not session_repo.get_session_by_id(session_id) and not store.job(session_id):
|
||||||
|
raise LookupError("任务记录不存在")
|
||||||
|
if view == "usage":
|
||||||
|
result = session_repo.get_session_usage(session_id)
|
||||||
|
result["cache_hit_rate"] = (
|
||||||
|
result["cached_tokens"] / result["prompt_tokens"] if result["prompt_tokens"] else None
|
||||||
|
)
|
||||||
|
result["note"] = (
|
||||||
|
"缓存率=已记录 cached_tokens / prompt_tokens;无用量不等于零消耗,未上报的请求无法补推。"
|
||||||
|
)
|
||||||
|
elif view == "requests":
|
||||||
|
result = request_diagnostics(session_id, limit=limit, offset=offset)
|
||||||
|
elif view == "logs":
|
||||||
|
root = evidence_service.task_root(session_id)
|
||||||
|
result = {"logs": []}
|
||||||
|
for name in ("stdout.log", "stderr.log"):
|
||||||
|
path = evidence_service.evidence_file(root, name)
|
||||||
|
if path is None:
|
||||||
|
result["logs"].append({"name": name, "available": False})
|
||||||
|
continue
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
size = path.stat().st_size
|
||||||
|
start = max(0, size - 128 * 1024)
|
||||||
|
stream.seek(start)
|
||||||
|
raw = stream.read(128 * 1024)
|
||||||
|
lines = raw.decode("utf-8", errors="replace").splitlines()
|
||||||
|
if start and lines:
|
||||||
|
lines = lines[1:]
|
||||||
|
# Offset counts backward from the newest line within the bounded tail.
|
||||||
|
end = max(0, len(lines) - offset)
|
||||||
|
result["logs"].append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"available": True,
|
||||||
|
"bytes": size,
|
||||||
|
"lines": lines[max(0, end - limit) : end],
|
||||||
|
"offset": offset,
|
||||||
|
"next_offset": offset + limit if end > limit else None,
|
||||||
|
"older_bytes_omitted": start,
|
||||||
|
"order": "oldest_to_newest",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif view in {"report", "evidence"}:
|
||||||
|
report = report_export.session_report(session_id)
|
||||||
|
if view == "report":
|
||||||
|
result = {**report, "steps": page(report.get("steps", []), offset, limit)}
|
||||||
|
else:
|
||||||
|
index = evidence_service.export_index(
|
||||||
|
session_id, report, report_export.session_videos(report)
|
||||||
|
)
|
||||||
|
result = {
|
||||||
|
**index,
|
||||||
|
**{
|
||||||
|
key: page(index.get(key, []), offset, limit)
|
||||||
|
for key in ("steps", "calls", "recordings", "android_videos", "files")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise ValueError("view 需为 report / usage / requests / logs / evidence")
|
||||||
|
return public_value({"session_id": session_id, **result})
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -24,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
|
||||||
@@ -275,6 +270,11 @@ class TaskQueueService:
|
|||||||
]
|
]
|
||||||
for item in removed_items:
|
for item in removed_items:
|
||||||
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
|
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
|
||||||
|
from artemis.plugins.store import PluginStore
|
||||||
|
|
||||||
|
# A stop request may already have removed this row. The worker finalizer
|
||||||
|
# must still release the server-owned version reservation after exit.
|
||||||
|
PluginStore().release(str(session_id))
|
||||||
state.queue_items = [
|
state.queue_items = [
|
||||||
t
|
t
|
||||||
for t in state.queue_items
|
for t in state.queue_items
|
||||||
@@ -417,6 +417,9 @@ class TaskQueueService:
|
|||||||
busy_devices = state.busy_device_ids | {
|
busy_devices = state.busy_device_ids | {
|
||||||
cls._task_target(i).lock_key for i in in_flight if i.get("device_serial")
|
cls._task_target(i).lock_key for i in in_flight if i.get("device_serial")
|
||||||
}
|
}
|
||||||
|
busy_resources = {r for item in in_flight for r in item.get("plugin_resources", [])}
|
||||||
|
for owner in DeviceExecutionLock.get_active_owners().values():
|
||||||
|
busy_resources.update(owner.resource_ids)
|
||||||
dispatched_any = False
|
dispatched_any = False
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
for item in list(state.queue_items):
|
for item in list(state.queue_items):
|
||||||
@@ -429,6 +432,9 @@ class TaskQueueService:
|
|||||||
|
|
||||||
device = item.get("device_serial")
|
device = item.get("device_serial")
|
||||||
target = cls._task_target(item)
|
target = cls._task_target(item)
|
||||||
|
resources = set(item.get("plugin_resources", []))
|
||||||
|
if resources & busy_resources:
|
||||||
|
continue
|
||||||
if limit == 0:
|
if limit == 0:
|
||||||
# A task without a resolved device may bind to any serial, so it
|
# A task without a resolved device may bind to any serial, so it
|
||||||
# only launches on an otherwise idle scheduler; the device lock
|
# only launches on an otherwise idle scheduler; the device lock
|
||||||
@@ -446,6 +452,7 @@ class TaskQueueService:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
item["status"] = "running"
|
item["status"] = "running"
|
||||||
|
busy_resources.update(resources)
|
||||||
dispatched_any = True
|
dispatched_any = True
|
||||||
if device is not None:
|
if device is not None:
|
||||||
busy_devices.add(target.lock_key)
|
busy_devices.add(target.lock_key)
|
||||||
@@ -572,7 +579,17 @@ class TaskQueueService:
|
|||||||
env["ARTEMIS_SESSION_ID"] = str(sess_id)
|
env["ARTEMIS_SESSION_ID"] = str(sess_id)
|
||||||
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
|
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
|
||||||
env["ARTEMIS_TASK_WORKER"] = "1"
|
env["ARTEMIS_TASK_WORKER"] = "1"
|
||||||
|
from artemis.exploration.store import explorations
|
||||||
|
|
||||||
|
exploration_run = explorations.run(str(sess_id)) if sess_id else None
|
||||||
|
env.pop("ARTEMIS_EXPLORATION_ID", None)
|
||||||
|
if exploration_run and exploration_run["kind"] == "exploration":
|
||||||
|
env["ARTEMIS_EXPLORATION_ID"] = exploration_run["exploration_id"]
|
||||||
overrides = task_item.get("runtime_overrides") or {}
|
overrides = task_item.get("runtime_overrides") or {}
|
||||||
|
if overrides.get("recording") is not None:
|
||||||
|
env["ARTEMIS_TASK_RECORDING_CONFIG"] = json.dumps(overrides["recording"])
|
||||||
|
else:
|
||||||
|
env.pop("ARTEMIS_TASK_RECORDING_CONFIG", None)
|
||||||
for option, variable in {
|
for option, variable in {
|
||||||
"model": "OPENAI_MODEL",
|
"model": "OPENAI_MODEL",
|
||||||
"reasoning_effort": "OPENAI_REASONING_EFFORT",
|
"reasoning_effort": "OPENAI_REASONING_EFFORT",
|
||||||
@@ -593,6 +610,11 @@ class TaskQueueService:
|
|||||||
else str(overrides[option])
|
else str(overrides[option])
|
||||||
)
|
)
|
||||||
target.endpoint.apply_to_environment(env)
|
target.endpoint.apply_to_environment(env)
|
||||||
|
if "plugins" in overrides:
|
||||||
|
env["ARTEMIS_TASK_PLUGINS"] = json.dumps(overrides["plugins"])
|
||||||
|
else:
|
||||||
|
env.pop("ARTEMIS_TASK_PLUGINS", None)
|
||||||
|
env["ARTEMIS_EXECUTION_TARGET"] = overrides.get("execution_target") or "android"
|
||||||
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
|
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
|
||||||
queue_ticket = task_item.get("queue_ticket")
|
queue_ticket = task_item.get("queue_ticket")
|
||||||
if queue_ticket:
|
if queue_ticket:
|
||||||
@@ -623,7 +645,7 @@ class TaskQueueService:
|
|||||||
if app_path:
|
if app_path:
|
||||||
cmd.extend(["--app-path", str(app_path)])
|
cmd.extend(["--app-path", str(app_path)])
|
||||||
device_serial = task_item.get("device_serial")
|
device_serial = task_item.get("device_serial")
|
||||||
if device_serial:
|
if device_serial and env["ARTEMIS_EXECUTION_TARGET"] != "computer":
|
||||||
cmd.extend(["--device-serial", str(device_serial)])
|
cmd.extend(["--device-serial", str(device_serial)])
|
||||||
env["ADB_DEVICE_SERIAL"] = str(device_serial)
|
env["ADB_DEVICE_SERIAL"] = str(device_serial)
|
||||||
from apps.admin_console.services.automation_store import store
|
from apps.admin_console.services.automation_store import store
|
||||||
@@ -647,6 +669,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 +686,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 +700,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 +884,10 @@ class TaskQueueService:
|
|||||||
registered at recording start; remuxing that file is all that is
|
registered at recording start; remuxing that file is all that is
|
||||||
needed to publish the full video.
|
needed to publish the full video.
|
||||||
"""
|
"""
|
||||||
|
from apps.admin_console.services.evidence_service import task_root
|
||||||
|
from artemis.plugins.recording import recover_interrupted_recordings
|
||||||
|
|
||||||
|
await recover_interrupted_recordings(task_root(str(sess_id)))
|
||||||
rec_info = session_repo.get_video_recording_for_session(sess_id)
|
rec_info = session_repo.get_video_recording_for_session(sess_id)
|
||||||
rec_status = (rec_info or {}).get("status")
|
rec_status = (rec_info or {}).get("status")
|
||||||
if rec_status == "ready":
|
if rec_status == "ready":
|
||||||
@@ -996,6 +1033,9 @@ class TaskQueueService:
|
|||||||
if not isinstance(goal, str) or not goal.strip():
|
if not isinstance(goal, str) or not goal.strip():
|
||||||
raise ValueError("Queued task must contain a non-empty string goal.")
|
raise ValueError("Queued task must contain a non-empty string goal.")
|
||||||
cls._begin_task_run(task_item, run_key, sess_id, goal, profile)
|
cls._begin_task_run(task_item, run_key, sess_id, goal, profile)
|
||||||
|
from apps.admin_console.services.execution_config import persist_execution_config
|
||||||
|
|
||||||
|
await asyncio.to_thread(persist_execution_config, task_item)
|
||||||
|
|
||||||
target = cls._task_target(task_item)
|
target = cls._task_target(task_item)
|
||||||
cmd, env = cls._build_worker_invocation(
|
cmd, env = cls._build_worker_invocation(
|
||||||
@@ -1051,8 +1091,34 @@ class TaskQueueService:
|
|||||||
f"[QueueWorker] Could not persist failure status for [{sess_id}]"
|
f"[QueueWorker] Could not persist failure status for [{sess_id}]"
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
try:
|
||||||
await cls._finish_output_forwarder(output_task)
|
await cls._finish_output_forwarder(output_task)
|
||||||
# 5. Clean up the finished task and release this run's scheduling slot
|
if sess_id:
|
||||||
|
from artemis.exploration.store import explorations
|
||||||
|
from artemis.runtime.task_result import read_worker_outcome
|
||||||
|
|
||||||
|
exploration_run = explorations.run(str(sess_id))
|
||||||
|
if exploration_run and exploration_run.get("outcome") is None:
|
||||||
|
from artemis.runtime.case_result import read_case_result
|
||||||
|
|
||||||
|
outcome = read_worker_outcome(str(sess_id)) or {}
|
||||||
|
session = session_repo.get_session_by_id(str(sess_id)) or {}
|
||||||
|
status = (
|
||||||
|
outcome.get("task_status") or session.get("status") or "interrupted"
|
||||||
|
)
|
||||||
|
explorations.finish_run(
|
||||||
|
str(sess_id),
|
||||||
|
{
|
||||||
|
**outcome,
|
||||||
|
"status": status,
|
||||||
|
"test_result": outcome.get("test_result")
|
||||||
|
or read_case_result(str(sess_id), status),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Cannot finalize exploration metadata for %s", sess_id)
|
||||||
|
finally:
|
||||||
|
# Metadata/storage failure must never leak a scheduling slot.
|
||||||
cls._release_run_slot(sess_id, run_key, proc)
|
cls._release_run_slot(sess_id, run_key, proc)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1237,11 +1303,40 @@ class TaskQueueService:
|
|||||||
if duplicate_response is not None:
|
if duplicate_response is not None:
|
||||||
return duplicate_response
|
return duplicate_response
|
||||||
|
|
||||||
rejection_response = await cls._reject_unavailable_device(device_serial)
|
execution_target = (runtime_overrides or {}).get("execution_target") or "android"
|
||||||
|
from pydantic import TypeAdapter
|
||||||
|
from artemis.plugins.models import PluginSelection
|
||||||
|
from artemis.plugins.store import PluginStore
|
||||||
|
|
||||||
|
choices = (runtime_overrides or {}).get("plugins")
|
||||||
|
try:
|
||||||
|
pinned = PluginStore().pin(
|
||||||
|
TypeAdapter(list[PluginSelection]).validate_python(choices)
|
||||||
|
if choices is not None
|
||||||
|
else None,
|
||||||
|
execution_target,
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "插件未就绪、未授权或不支持所选任务目标,请检查插件配置",
|
||||||
|
}
|
||||||
|
runtime_overrides = {
|
||||||
|
**(runtime_overrides or {}),
|
||||||
|
"execution_target": execution_target,
|
||||||
|
"plugins": [p.model_dump() for p in pinned],
|
||||||
|
}
|
||||||
|
rejection_response = (
|
||||||
|
await cls._reject_unavailable_device(device_serial)
|
||||||
|
if execution_target != "computer"
|
||||||
|
else None
|
||||||
|
)
|
||||||
if rejection_response is not None:
|
if rejection_response is not None:
|
||||||
return rejection_response
|
return rejection_response
|
||||||
|
|
||||||
single_session_id = session_id if (session_id and len(goals) == 1) else None
|
single_session_id = session_id if (session_id and len(goals) == 1) else None
|
||||||
|
if execution_target == "computer":
|
||||||
|
device_serial = "computer:local"
|
||||||
if not device_serial:
|
if not device_serial:
|
||||||
# Device enumeration may block on ADB.
|
# Device enumeration may block on ADB.
|
||||||
from artemis.runtime import device_pool
|
from artemis.runtime import device_pool
|
||||||
@@ -1250,6 +1345,21 @@ class TaskQueueService:
|
|||||||
device_serial = await device_pool.select_device_async()
|
device_serial = await device_pool.select_device_async()
|
||||||
except Exception:
|
except Exception:
|
||||||
device_serial = None
|
device_serial = None
|
||||||
|
from apps.admin_console.services.execution_config import execution_config
|
||||||
|
|
||||||
|
config_snapshot = execution_config(
|
||||||
|
{
|
||||||
|
"profile": profile,
|
||||||
|
"device_serial": device_serial,
|
||||||
|
"expected_output": expected_output,
|
||||||
|
"enable_outputter": enable_outputter,
|
||||||
|
"verification_level": verification_level,
|
||||||
|
"explorer_mode": explorer_mode,
|
||||||
|
"locked_app_package": locked_app_package,
|
||||||
|
"app_path": app_path,
|
||||||
|
"runtime_overrides": runtime_overrides,
|
||||||
|
}
|
||||||
|
)
|
||||||
for i, goal in enumerate(goals):
|
for i, goal in enumerate(goals):
|
||||||
task_item = cls._create_queue_item(
|
task_item = cls._create_queue_item(
|
||||||
goal,
|
goal,
|
||||||
@@ -1270,6 +1380,20 @@ class TaskQueueService:
|
|||||||
)
|
)
|
||||||
if runtime_overrides:
|
if runtime_overrides:
|
||||||
task_item["runtime_overrides"] = runtime_overrides
|
task_item["runtime_overrides"] = runtime_overrides
|
||||||
|
task_item["execution_config"] = config_snapshot
|
||||||
|
if pinned:
|
||||||
|
try:
|
||||||
|
task_item["plugin_resources"] = PluginStore().reserve(
|
||||||
|
task_item["session_id"], pinned, execution_target
|
||||||
|
)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
DeviceExecutionLock.cancel_reservation(task_item.get("queue_ticket"))
|
||||||
|
for previous in enqueued_tasks:
|
||||||
|
cls._remove_task(previous["session_id"])
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "插件配置在入队前发生变化,请检查后重新提交",
|
||||||
|
}
|
||||||
if display_name:
|
if display_name:
|
||||||
task_item["display_name"] = display_name
|
task_item["display_name"] = display_name
|
||||||
state.queue_items.append(task_item)
|
state.queue_items.append(task_item)
|
||||||
@@ -1380,6 +1504,7 @@ class TaskQueueService:
|
|||||||
"""Terminate all active device owners and clear pending queue submissions."""
|
"""Terminate all active device owners and clear pending queue submissions."""
|
||||||
from apps.admin_console.services.automation_store import store
|
from apps.admin_console.services.automation_store import store
|
||||||
from apps.admin_console.services.automation_service import on_terminal, spawn
|
from apps.admin_console.services.automation_service import on_terminal, spawn
|
||||||
|
from artemis.plugins.store import PluginStore
|
||||||
|
|
||||||
for batch in store.records("batch"):
|
for batch in store.records("batch"):
|
||||||
if batch["status"] == "running":
|
if batch["status"] == "running":
|
||||||
@@ -1389,6 +1514,7 @@ class TaskQueueService:
|
|||||||
for item in state.queue_items:
|
for item in state.queue_items:
|
||||||
if isinstance(item, dict) and item.get("status") != "running":
|
if isinstance(item, dict) and item.get("status") != "running":
|
||||||
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
|
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
|
||||||
|
PluginStore().release(str(item.get("session_id")))
|
||||||
if store.job(str(item.get("session_id"))):
|
if store.job(str(item.get("session_id"))):
|
||||||
spawn(on_terminal(item, "cancelled"))
|
spawn(on_terminal(item, "cancelled"))
|
||||||
state.clear_queue()
|
state.clear_queue()
|
||||||
@@ -1712,7 +1838,9 @@ class TaskQueueService:
|
|||||||
if stopped_item and stopped_item.get("status") != "running":
|
if stopped_item and stopped_item.get("status") != "running":
|
||||||
from apps.admin_console.services.automation_store import store
|
from apps.admin_console.services.automation_store import store
|
||||||
from apps.admin_console.services.automation_service import on_terminal, spawn
|
from apps.admin_console.services.automation_service import on_terminal, spawn
|
||||||
|
from artemis.plugins.store import PluginStore
|
||||||
|
|
||||||
|
PluginStore().release(str(stopped_session_id))
|
||||||
if store.job(str(stopped_session_id)):
|
if store.job(str(stopped_session_id)):
|
||||||
spawn(on_terminal(stopped_item, "cancelled"))
|
spawn(on_terminal(stopped_item, "cancelled"))
|
||||||
if stopped_item and not reservation_cancelled:
|
if stopped_item and not reservation_cancelled:
|
||||||
@@ -1826,6 +1954,14 @@ class TaskQueueService:
|
|||||||
recordings published.
|
recordings published.
|
||||||
"""
|
"""
|
||||||
recovered = 0
|
recovered = 0
|
||||||
|
from artemis.config import TRACES_PATH
|
||||||
|
from artemis.plugins.recording import recover_interrupted_recordings
|
||||||
|
|
||||||
|
# This hook runs in the startup worker thread, outside the server event loop.
|
||||||
|
for manifest in TRACES_PATH.glob("*/evidence/recordings/recordings.json"):
|
||||||
|
root = manifest.parent.parent.parent
|
||||||
|
if asyncio.run(recover_interrupted_recordings(root)):
|
||||||
|
recovered += 1
|
||||||
try:
|
try:
|
||||||
pending = session_repo.get_unfinalized_video_recordings()
|
pending = session_repo.get_unfinalized_video_recordings()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.5.1",
|
"version": "2.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.5.1",
|
"version": "2.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.1.5",
|
"@angular/cdk": "^22.1.5",
|
||||||
"@angular/common": "^22.1.4",
|
"@angular/common": "^22.1.4",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.5.1",
|
"version": "2.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve --proxy-config proxy.conf.json",
|
"start": "ng serve --proxy-config proxy.conf.json",
|
||||||
|
|||||||
@@ -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: '' }
|
||||||
];
|
];
|
||||||
@@ -62,6 +62,7 @@ limitations under the License.
|
|||||||
<span>任务队列</span>
|
<span>任务队列</span>
|
||||||
</div>
|
</div>
|
||||||
<app-task-batch-filter />
|
<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>
|
||||||
@@ -81,6 +82,7 @@ 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>
|
||||||
@@ -88,6 +90,7 @@ limitations under the License.
|
|||||||
{{ getTaskStatus(session) | uiLabel }}
|
{{ getTaskStatus(session) | uiLabel }}
|
||||||
</span>
|
</span>
|
||||||
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
|
<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)">
|
||||||
@@ -99,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"
|
||||||
@@ -110,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"
|
||||||
@@ -180,6 +183,9 @@ limitations under the License.
|
|||||||
<!-- Rich Notes Content Viewer -->
|
<!-- Rich Notes Content Viewer -->
|
||||||
@if (noteKeys().length > 0) {
|
@if (noteKeys().length > 0) {
|
||||||
<div class="dropdown-notes-content">
|
<div class="dropdown-notes-content">
|
||||||
|
@if (agentService.selectedNoteKey() !== 'task_plan.md') {
|
||||||
|
<app-report-markdown [content]="currentNoteContent()" />
|
||||||
|
} @else {
|
||||||
@if (parsedNote().title) {
|
@if (parsedNote().title) {
|
||||||
<h3 class="note-title">{{ parsedNote().title }}</h3>
|
<h3 class="note-title">{{ parsedNote().title }}</h3>
|
||||||
}
|
}
|
||||||
@@ -330,6 +336,7 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -399,7 +406,7 @@ limitations under the License.
|
|||||||
@if (runInfoContextPercent() !== null) {
|
@if (runInfoContextPercent() !== null) {
|
||||||
<span class="run-info-value">
|
<span class="run-info-value">
|
||||||
{{ runInfoContextPercent() }}%
|
{{ runInfoContextPercent() }}%
|
||||||
<span class="run-info-sub-inline">/ {{ formatRunTokens(runUsage()?.operator_context_window_tokens) }}</span>
|
<span class="run-info-sub-inline">/ {{ formatRunTokens(runInfoContextWindow()) }}</span>
|
||||||
</span>
|
</span>
|
||||||
} @else {
|
} @else {
|
||||||
<span class="run-info-value is-muted">—</span>
|
<span class="run-info-value is-muted">—</span>
|
||||||
@@ -423,7 +430,9 @@ limitations under the License.
|
|||||||
<span class="run-info-label">屏幕识别</span>
|
<span class="run-info-label">屏幕识别</span>
|
||||||
<span class="run-info-value">{{ runTuningLabel('explore', runInfoTuning()?.explorer_mode) }}</span>
|
<span class="run-info-value">{{ runTuningLabel('explore', runInfoTuning()?.explorer_mode) }}</span>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else if (runInfoMode() === 'explore') {
|
||||||
|
<div class="run-info-note">探索支持对话指导与人工示范,模型和时限使用独立的探索设置。</div>
|
||||||
|
} @else if (runInfoMode() === 'flash') {
|
||||||
<div class="run-info-note">Flash 无需额外设置。</div>
|
<div class="run-info-note">Flash 无需额外设置。</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -504,6 +513,8 @@ limitations under the License.
|
|||||||
|
|
||||||
<!-- Event Logs Section -->
|
<!-- Event Logs Section -->
|
||||||
<main class="stream-logs-content">
|
<main class="stream-logs-content">
|
||||||
|
<ng-content select="[task-summary]"></ng-content>
|
||||||
|
<app-llm-request-status [sessionId]="agentService.currentSessionId()" [running]="agentService.isCurrentSessionRunning()" (activeChange)="hasModelRequest.set($event)" />
|
||||||
@if (agentService.isSessionContentLoading()) {
|
@if (agentService.isSessionContentLoading()) {
|
||||||
<div class="session-loading-state" aria-live="polite">
|
<div class="session-loading-state" aria-live="polite">
|
||||||
<span class="material-symbols-outlined spin-animation">progress_activity</span>
|
<span class="material-symbols-outlined spin-animation">progress_activity</span>
|
||||||
@@ -511,7 +522,7 @@ limitations under the License.
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!agentService.isSessionContentLoading() && consolidatedBlocks().length === 0 && !showPlanningLoader() && startupWorkItems().length === 0) {
|
@if (!agentService.isSessionContentLoading() && phases().length === 0 && !showPlanningLoader() && !hasModelRequest() && startupWorkItems().length === 0) {
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div>
|
<div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div>
|
||||||
<h3>尚未选择执行记录</h3>
|
<h3>尚未选择执行记录</h3>
|
||||||
@@ -550,6 +561,19 @@ 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) && phase.blocks[0]?.data?.phase !== 'outcome') {
|
@if (hasVisibleBlocks(phase) && phase.blocks[0]?.data?.phase !== 'outcome') {
|
||||||
<div
|
<div
|
||||||
@@ -751,10 +775,8 @@ limitations under the License.
|
|||||||
<!-- Compact preview body (when collapsed, matches standard action card) -->
|
<!-- Compact preview body (when collapsed, matches standard action card) -->
|
||||||
@if (!isActionExpanded('action-' + block.id + '-' + itemIdx) && getReportStatusExplanation(item.data)) {
|
@if (!isActionExpanded('action-' + block.id + '-' + itemIdx) && getReportStatusExplanation(item.data)) {
|
||||||
<div class="action-card-body">
|
<div class="action-card-body">
|
||||||
<div class="action-detail-row">
|
<app-report-markdown [content]="getReportStatusExplanation(item.data)" [compact]="true" />
|
||||||
<span class="detail-label">摘要:</span>
|
<button type="button" class="report-expand-button" (click)="toggleActionExpanded('action-' + block.id + '-' + itemIdx, $event)">查看完整报告</button>
|
||||||
<span class="detail-value">"{{ getReportStatusExplanation(item.data) }}"</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Detailed expanded view (when clicked, matches standard action card structure) -->
|
<!-- Detailed expanded view (when clicked, matches standard action card structure) -->
|
||||||
@@ -764,7 +786,7 @@ limitations under the License.
|
|||||||
@if (getReportStatusExplanation(item.data); as expText) {
|
@if (getReportStatusExplanation(item.data); as expText) {
|
||||||
<div class="grid-item full-width">
|
<div class="grid-item full-width">
|
||||||
<span class="grid-label">报告与发现</span>
|
<span class="grid-label">报告与发现</span>
|
||||||
<div class="grid-value report-markdown-body" [innerHTML]="renderMarkdown(expText, 'report-' + block.id + '-' + itemIdx)"></div>
|
<app-report-markdown [content]="expText" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -807,6 +829,7 @@ limitations under the License.
|
|||||||
<div class="action-card-header" (click)="toggleActionExpanded('action-' + block.id + '-' + itemIdx, $event)">
|
<div class="action-card-header" (click)="toggleActionExpanded('action-' + block.id + '-' + itemIdx, $event)">
|
||||||
<span class="material-symbols-outlined action-icon" [class.is-failed]="isActionFailed(item.data, block.data)">{{ getActionIcon(item.data) }}</span>
|
<span class="material-symbols-outlined action-icon" [class.is-failed]="isActionFailed(item.data, block.data)">{{ getActionIcon(item.data) }}</span>
|
||||||
<span class="action-title">{{ getActionTitle(item.data) }}</span>
|
<span class="action-title">{{ getActionTitle(item.data) }}</span>
|
||||||
|
<button class="evidence-source" [title]="evidenceSourceLabel(item.data) + ' · 查看对应录像'" (click)="openEvidenceVideo(item.data, block.data, $event)">{{ evidenceSourceLabel(item.data) }}</button>
|
||||||
@if (isActionFailed(item.data, block.data)) {
|
@if (isActionFailed(item.data, block.data)) {
|
||||||
<span class="action-status-badge is-failed">
|
<span class="action-status-badge is-failed">
|
||||||
<span class="material-symbols-outlined status-icon">error</span>
|
<span class="material-symbols-outlined status-icon">error</span>
|
||||||
@@ -1048,7 +1071,7 @@ limitations under the License.
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Generic Tool Calls (Text format for note tools, video analyzer, and other sub-agents) -->
|
<!-- Generic Tool Calls (Text format for note tools, video analyzer, and other sub-agents) -->
|
||||||
@if (shouldShowTool(item.data, block.data) && !isDeviceActionTool(item.data)) {
|
@if (shouldShowTool(item.data, block.data) && !isEvidenceTool(item.data)) {
|
||||||
<div class="step-generic-tool"
|
<div class="step-generic-tool"
|
||||||
[class.is-running]="isToolLineRunning(item, block)"
|
[class.is-running]="isToolLineRunning(item, block)"
|
||||||
[class.is-note-tool]="isNoteTool(item.data)"
|
[class.is-note-tool]="isNoteTool(item.data)"
|
||||||
@@ -1089,9 +1112,11 @@ limitations under the License.
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Action Card format (For Device Actions & ADB Commands) -->
|
<!-- Device actions and plugin calls share evidence cards with explicit sources. -->
|
||||||
@if (shouldShowTool(item.data, block.data) && isDeviceActionTool(item.data)) {
|
@if (shouldShowTool(item.data, block.data) && isEvidenceTool(item.data)) {
|
||||||
<div class="step-android-action-card tool-action-card"
|
<div class="step-android-action-card tool-action-card"
|
||||||
|
[class.plugin-tool-card]="getPluginToolView(item.data)"
|
||||||
|
[class.is-auxiliary]="getPluginToolView(item.data)?.auxiliary"
|
||||||
[class.is-running]="isItemRunning(item, block)"
|
[class.is-running]="isItemRunning(item, block)"
|
||||||
[class.has-no-details]="isAdbCommandTool(item.data) ? !getAdbCommandLine(item.data) : (!getToolTargetText(item.data) && !getToolInputText(item.data) && !getToolCoords(item.data) && !getToolAnalysisText(item.data) && !getToolGenericDetails(item.data) && getToolExtraParams(item.data).length === 0)"
|
[class.has-no-details]="isAdbCommandTool(item.data) ? !getAdbCommandLine(item.data) : (!getToolTargetText(item.data) && !getToolInputText(item.data) && !getToolCoords(item.data) && !getToolAnalysisText(item.data) && !getToolGenericDetails(item.data) && getToolExtraParams(item.data).length === 0)"
|
||||||
[class.is-failed]="isToolFailed(item.data)"
|
[class.is-failed]="isToolFailed(item.data)"
|
||||||
@@ -1100,6 +1125,10 @@ limitations under the License.
|
|||||||
<div class="action-card-header" (click)="toggleActionExpanded('tool-' + block.id + '-' + itemIdx, $event)">
|
<div class="action-card-header" (click)="toggleActionExpanded('tool-' + block.id + '-' + itemIdx, $event)">
|
||||||
<span class="material-symbols-outlined action-icon" [class.is-failed]="isToolFailed(item.data)">{{ getToolIcon(item.data) }}</span>
|
<span class="material-symbols-outlined action-icon" [class.is-failed]="isToolFailed(item.data)">{{ getToolIcon(item.data) }}</span>
|
||||||
<span class="action-title">{{ getToolTitle(item.data) }}</span>
|
<span class="action-title">{{ getToolTitle(item.data) }}</span>
|
||||||
|
<button class="evidence-source" [title]="evidenceSourceLabel(item.data) + ' · 查看对应证据'" (click)="openEvidenceVideo(item.data, block.data, $event)">{{ evidenceSourceLabel(item.data) }}</button>
|
||||||
|
@if (getPluginToolView(item.data); as plugin) {
|
||||||
|
<span class="plugin-call-kind">{{ plugin.kind }} · {{ plugin.status }}</span>
|
||||||
|
}
|
||||||
@if (isToolFailed(item.data)) {
|
@if (isToolFailed(item.data)) {
|
||||||
<span class="action-status-badge is-failed">
|
<span class="action-status-badge is-failed">
|
||||||
<span class="material-symbols-outlined status-icon">error</span>
|
<span class="material-symbols-outlined status-icon">error</span>
|
||||||
@@ -1112,7 +1141,7 @@ limitations under the License.
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Compact preview body (when collapsed) -->
|
<!-- Compact preview body (when collapsed) -->
|
||||||
@if (!isActionExpanded('tool-' + block.id + '-' + itemIdx) && (isAdbCommandTool(item.data) ? getAdbCommandLine(item.data) : (getToolTargetText(item.data) || getToolInputText(item.data) || getToolCoords(item.data) || getToolAnalysisText(item.data) || getToolGenericDetails(item.data)))) {
|
@if (!getPluginToolView(item.data) && !isActionExpanded('tool-' + block.id + '-' + itemIdx) && (isAdbCommandTool(item.data) ? getAdbCommandLine(item.data) : (getToolTargetText(item.data) || getToolInputText(item.data) || getToolCoords(item.data) || getToolAnalysisText(item.data) || getToolGenericDetails(item.data)))) {
|
||||||
<div class="action-card-body">
|
<div class="action-card-body">
|
||||||
<!-- ADB command details -->
|
<!-- ADB command details -->
|
||||||
@if (isAdbCommandTool(item.data)) {
|
@if (isAdbCommandTool(item.data)) {
|
||||||
@@ -1194,7 +1223,21 @@ limitations under the License.
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if (!isAdbCommandTool(item.data)) {
|
@if (getPluginToolView(item.data); as plugin) {
|
||||||
|
@for (param of plugin.args; track param.key) {
|
||||||
|
<div class="grid-item">
|
||||||
|
<span class="grid-label">{{ param.key }}</span>
|
||||||
|
<span class="grid-value code-pill">{{ param.value }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (plugin.result) {
|
||||||
|
<div class="grid-item full-width">
|
||||||
|
<span class="grid-label">实际结果</span>
|
||||||
|
<span class="grid-value plugin-result">{{ plugin.result }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (!isAdbCommandTool(item.data) && !getPluginToolView(item.data)) {
|
||||||
@if (getToolTargetText(item.data)) {
|
@if (getToolTargetText(item.data)) {
|
||||||
<div class="grid-item">
|
<div class="grid-item">
|
||||||
<span class="grid-label">目标</span>
|
<span class="grid-label">目标</span>
|
||||||
@@ -1249,7 +1292,7 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
@if (getStepPostImageUrl(block.data, item.data)) {
|
@if (getStepPostImageUrl(block.data, item.data)) {
|
||||||
<div class="screenshot-column">
|
<div class="screenshot-column">
|
||||||
<span class="screenshot-label">操作后</span>
|
<span class="screenshot-label">{{ getPluginToolView(item.data)?.auxiliary ? '观察截图' : '操作后' }}</span>
|
||||||
<div class="image-wrapper" (click)="openImageModal(getStepPostImageUrl(block.data, item.data)!, null, $event)">
|
<div class="image-wrapper" (click)="openImageModal(getStepPostImageUrl(block.data, item.data)!, null, $event)">
|
||||||
<div class="img-relative-box">
|
<div class="img-relative-box">
|
||||||
<img [src]="getStepPostImageUrl(block.data, item.data)" alt="操作后截图" />
|
<img [src]="getStepPostImageUrl(block.data, item.data)" alt="操作后截图" />
|
||||||
@@ -1301,7 +1344,8 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (awaitingGuidance().length) {
|
}
|
||||||
|
@if (!conversationMessages().length && awaitingGuidance().length) {
|
||||||
<div class="guidance-queue" aria-label="待处理用户指导">
|
<div class="guidance-queue" aria-label="待处理用户指导">
|
||||||
@for (item of awaitingGuidance(); track item.id) {
|
@for (item of awaitingGuidance(); track item.id) {
|
||||||
<div class="guidance-queue-item">
|
<div class="guidance-queue-item">
|
||||||
@@ -1334,62 +1378,7 @@ limitations under the License.
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="synthesis-card-preview">
|
<div class="synthesis-card-preview">
|
||||||
@if (parsedOutputReport(); as parsedOut) {
|
<app-report-markdown [content]="outReport" />
|
||||||
<div class="parsed-note-container">
|
|
||||||
@if (parsedOut.title) {
|
|
||||||
<h3 class="parsed-note-title">{{ parsedOut.title }}</h3>
|
|
||||||
}
|
|
||||||
@if (parsedOut.milestones.length > 0) {
|
|
||||||
<div class="parsed-milestones">
|
|
||||||
@for (m of parsedOut.milestones; track trackMilestone($index, m)) {
|
|
||||||
<div class="milestone-item" [class]="'status-' + m.type">
|
|
||||||
<span class="material-symbols-outlined status-icon">
|
|
||||||
{{ m.type === 'checked' ? 'check_circle' : (m.type === 'progress' ? 'pending' : 'radio_button_unchecked') }}
|
|
||||||
</span>
|
|
||||||
<span class="milestone-text">
|
|
||||||
@for (seg of m.segments; track seg) {
|
|
||||||
@if (seg.bold) {
|
|
||||||
<strong>{{ seg.text }}</strong>
|
|
||||||
}
|
|
||||||
@if (seg.code) {
|
|
||||||
<code>{{ seg.text }}</code>
|
|
||||||
}
|
|
||||||
@if (!seg.bold && !seg.code) {
|
|
||||||
<span>{{ seg.text }}</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (parsedOut.otherLines.length > 0) {
|
|
||||||
<div class="parsed-other-lines">
|
|
||||||
@for (line of parsedOut.otherLines; track trackMarkdownLine($index, line)) {
|
|
||||||
<p class="other-line" [class]="'line-' + line.type">
|
|
||||||
@if (line.type === 'verify') {
|
|
||||||
<span class="verify-badge" [class.assert]="line.checkKind === 'assert'">{{ line.checkKind || 'verify' }}{{ line.atEnd ? '@end' : '' }}</span>
|
|
||||||
}
|
|
||||||
@if (line.type === 'finding') {
|
|
||||||
<span class="finding-badge">finding</span>
|
|
||||||
}
|
|
||||||
@for (seg of line.segments; track seg) {
|
|
||||||
@if (seg.bold) {
|
|
||||||
<strong>{{ seg.text }}</strong>
|
|
||||||
}
|
|
||||||
@if (seg.code) {
|
|
||||||
<code>{{ seg.text }}</code>
|
|
||||||
}
|
|
||||||
@if (!seg.bold && !seg.code) {
|
|
||||||
<span>{{ seg.text }}</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1404,6 +1393,7 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<ng-content select="[task-result]"></ng-content>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Fullscreen Image Zoom Lightbox Modal -->
|
<!-- Fullscreen Image Zoom Lightbox Modal -->
|
||||||
|
|||||||
@@ -3694,10 +3694,13 @@ $stream-row-height: 25px;
|
|||||||
.item-header {
|
.item-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
|
.task-status-group { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
||||||
|
|
||||||
.task-badge {
|
.task-badge {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -3790,6 +3793,7 @@ $stream-row-height: 25px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.item-header-actions {
|
.item-header-actions {
|
||||||
|
margin-left: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -4030,6 +4034,19 @@ $stream-row-height: 25px;
|
|||||||
p { margin: 6px 0 10px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
|
p { margin: 6px 0 10px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
button { border: 0; background: transparent; padding: 0; color: #be123c; font: inherit; font-weight: 600; cursor: pointer; }
|
button { border: 0; background: transparent; padding: 0; color: #be123c; font: inherit; font-weight: 600; cursor: pointer; }
|
||||||
}
|
}
|
||||||
|
.report-expand-button {
|
||||||
|
align-self: flex-start;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover { background: #f1f5f9; }
|
||||||
|
&:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
|
||||||
|
}
|
||||||
:host ::ng-deep .report-markdown-body {
|
:host ::ng-deep .report-markdown-body {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -4049,3 +4066,26 @@ $stream-row-height: 25px;
|
|||||||
th { background: #dbeafe; }
|
th { background: #dbeafe; }
|
||||||
.md-para-gap { height: 8px; }
|
.md-para-gap { height: 8px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.evidence-source { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 2px 7px; border: 1px solid #cbd5e1; border-radius: 6px; background: #f1f5f9; color: #475569; font-size: 11px; cursor: pointer; }
|
||||||
|
|
||||||
|
.plugin-tool-card {
|
||||||
|
.action-card-header { flex-wrap: wrap; }
|
||||||
|
.plugin-call-kind { font-size: 10px; color: #64748b; }
|
||||||
|
.plugin-result { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
&.is-auxiliary:not(.is-expanded):not(.is-failed):not(.is-running) {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-logs-content {padding-bottom:24px}
|
||||||
|
.exploration-message {padding:12px 16px;border:1px solid #e2e8f0;border-radius:12px;margin:14px 0;background:#fff;color:#334155;font-size:13px;line-height:1.7}
|
||||||
|
.exploration-message.user {background:#eff6ff;border-color:#dbeafe;margin-left:28px}.exploration-message.system {color:#64748b;background:#f8fafc;font-size:12px}
|
||||||
|
.exploration-message small {color:#64748b;font-size:11px}.exploration-message p {white-space:pre-wrap;overflow-wrap:anywhere;margin:6px 0}.exploration-message button {font:inherit;padding:4px 10px;border:1px solid #cbd5e1;border-radius:6px;background:#fff;color:#334155;cursor:pointer}
|
||||||
|
|
||||||
|
.exploration-message video {width:100%;max-height:300px;background:#0f172a}.exploration-message summary {cursor:pointer}.exploration-message .error {color:#b91c1c}
|
||||||
|
.exploration-message.assistant {border-left:3px solid #818cf8;background:#fafaff}
|
||||||
|
.exploration-message.question {border:1px solid #f4c97a;border-left:3px solid #f59e0b;background:#fffbeb}
|
||||||
|
.exploration-message small {display:flex;align-items:center;gap:6px;font-size:11px;margin-bottom:8px}.exploration-message small .material-symbols-outlined {font-size:16px;color:#6366f1}.exploration-message small b {margin-left:auto;color:#92400e;background:#fef3c7;border-radius:12px;padding:2px 8px}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import { ExplorationMessage, Demonstration } from '../../services/exploration.service';
|
||||||
|
import { mergeConversation } from '../../utils/exploration-timeline.util';
|
||||||
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||||
import { TaskHistoryComponent } from '../task-history/task-history.component';
|
import { TaskHistoryComponent } from '../task-history/task-history.component';
|
||||||
|
import { LlmRequestStatusComponent } from '../llm-request-status/llm-request-status.component';
|
||||||
|
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
|
||||||
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
|
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
|
||||||
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
||||||
/**
|
/**
|
||||||
@@ -18,7 +22,7 @@ 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';
|
||||||
@@ -33,6 +37,8 @@ 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,
|
||||||
@@ -162,6 +168,7 @@ import {
|
|||||||
extractCheckerResult,
|
extractCheckerResult,
|
||||||
renderMarkdownToHtml
|
renderMarkdownToHtml
|
||||||
} from '../../utils/markdown-parser.util';
|
} from '../../utils/markdown-parser.util';
|
||||||
|
import { getPluginToolView } from '../../utils/plugin-tool-view.util';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getActionObject,
|
getActionObject,
|
||||||
@@ -182,7 +189,8 @@ import {
|
|||||||
getStepPostImageUrl,
|
getStepPostImageUrl,
|
||||||
isReportStatusAction,
|
isReportStatusAction,
|
||||||
getReportStatusValue,
|
getReportStatusValue,
|
||||||
getReportStatusExplanation
|
getReportStatusExplanation,
|
||||||
|
actionEvidenceSource
|
||||||
} from '../../utils/action-formatter.util';
|
} from '../../utils/action-formatter.util';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -240,12 +248,19 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-agent-stream',
|
selector: 'app-agent-stream',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
|
imports: [ReportMarkdownComponent, LlmRequestStatusComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
|
||||||
templateUrl: './agent-stream.component.html',
|
templateUrl: './agent-stream.component.html',
|
||||||
styleUrl: './agent-stream.component.scss',
|
styleUrl: './agent-stream.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class AgentStreamComponent implements AfterViewInit {
|
export class AgentStreamComponent implements AfterViewInit {
|
||||||
|
conversationMessages = input<ExplorationMessage[]>([]);
|
||||||
|
demonstrations = input<Demonstration[]>([]);
|
||||||
|
pendingQuestion = input('');
|
||||||
|
demoMediaUrl(runId: string, path: string) { return '/videos/' + [runId, ...path.split('/')].map(encodeURIComponent).join('/'); }
|
||||||
|
demoLabel(status: string) { return ({requested:'准备录制',recording:'录制中',analyzing:'分析中',completed:'已分析',failed:'分析失败',cancelled:'已取消',timed_out:'到时已保存'} as Record<string,string>)[status] || status; }
|
||||||
|
useRecommendation = output<string>();
|
||||||
|
|
||||||
public agentService = inject(AgentService);
|
public agentService = inject(AgentService);
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
@@ -279,6 +294,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
public isTaskDropdownOpen = signal<boolean>(false);
|
public isTaskDropdownOpen = signal<boolean>(false);
|
||||||
public isNotesDropdownOpen = signal<boolean>(false);
|
public isNotesDropdownOpen = signal<boolean>(false);
|
||||||
public isAutomationOpen = signal(false);
|
public isAutomationOpen = signal(false);
|
||||||
|
public deleteError = signal('');
|
||||||
|
|
||||||
// Run info popover (opened from the Flash / Pro chip): elapsed time, token
|
// Run info popover (opened from the Flash / Pro chip): elapsed time, token
|
||||||
// usage, executor context share and the Pro tuning the run started with.
|
// usage, executor context share and the Pro tuning the run started with.
|
||||||
@@ -286,7 +302,9 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
public runUsage = signal<SessionUsage | null>(null);
|
public runUsage = signal<SessionUsage | null>(null);
|
||||||
private runInfoClock = signal<number>(Date.now());
|
private runInfoClock = signal<number>(Date.now());
|
||||||
|
|
||||||
public runInfoIsPro = computed(() => this.getModelClass(this.agentService.viewedModel()?.name) === 'is-pro');
|
public runInfoMode = computed(() => runMode(this.agentService.currentSession(), this.agentService.viewedModel()));
|
||||||
|
public runInfoIsPro = computed(() => this.runInfoMode() === 'pro');
|
||||||
|
public runInfoContextWindow = computed(() => contextWindow(this.runUsage()?.operator_context_window_tokens));
|
||||||
|
|
||||||
public runInfoElapsed = computed(() => {
|
public runInfoElapsed = computed(() => {
|
||||||
const session = this.agentService.currentSession();
|
const session = this.agentService.currentSession();
|
||||||
@@ -381,6 +399,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
public expandedActionCards = signal<Set<string>>(new Set<string>());
|
public expandedActionCards = signal<Set<string>>(new Set<string>());
|
||||||
public collapsedActionCards = signal<Set<string>>(new Set<string>());
|
public collapsedActionCards = signal<Set<string>>(new Set<string>());
|
||||||
public retryClock = signal<number>(Date.now());
|
public retryClock = signal<number>(Date.now());
|
||||||
|
public hasModelRequest = signal(false);
|
||||||
|
|
||||||
// Performance caches for parameter and event extractions
|
// Performance caches for parameter and event extractions
|
||||||
private actionParamsCache = new WeakMap<any, ActionParam[]>();
|
private actionParamsCache = new WeakMap<any, ActionParam[]>();
|
||||||
@@ -391,7 +410,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
public activeQueue = computed(() => {
|
public activeQueue = computed(() => {
|
||||||
const list = this.agentService.sessions().filter((s) => {
|
const list = this.agentService.sessions().filter((s) => {
|
||||||
const status = this.getTaskStatus(s);
|
const status = this.getTaskStatus(s);
|
||||||
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s);
|
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);
|
||||||
@@ -428,13 +447,6 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
return notes['output.md'] || null;
|
return notes['output.md'] || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Memoized so the Task Report card is not re-parsed (and its DOM rebuilt)
|
|
||||||
// on every change-detection pass while streams are typing.
|
|
||||||
public parsedOutputReport = computed<ParsedNote | null>(() => {
|
|
||||||
const report = this.outputterReport();
|
|
||||||
return report ? parseNote(report) : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
public parsedNote = computed<ParsedNote>(() => {
|
public parsedNote = computed<ParsedNote>(() => {
|
||||||
return this.getParsedNote(this.currentNoteContent());
|
return this.getParsedNote(this.currentNoteContent());
|
||||||
});
|
});
|
||||||
@@ -455,6 +467,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
public showPlanningLoader = computed(() => {
|
public showPlanningLoader = computed(() => {
|
||||||
|
if (this.hasModelRequest()) return false;
|
||||||
const isRunning = this.agentService.agentStatus() === 'running';
|
const isRunning = this.agentService.agentStatus() === 'running';
|
||||||
const currentSessionId = this.agentService.currentSessionId();
|
const currentSessionId = this.agentService.currentSessionId();
|
||||||
const runningSessionId = this.agentService.runningSessionId();
|
const runningSessionId = this.agentService.runningSessionId();
|
||||||
@@ -499,9 +512,9 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录';
|
return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录';
|
||||||
}
|
}
|
||||||
|
|
||||||
public phases = computed<PhaseBlock[]>(() => {
|
public phases = computed(() => {
|
||||||
const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId());
|
const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId());
|
||||||
return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0);
|
return mergeConversation(groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0), this.conversationMessages(), this.demonstrations());
|
||||||
});
|
});
|
||||||
|
|
||||||
public startupWorkItems = computed(() => {
|
public startupWorkItems = computed(() => {
|
||||||
@@ -566,6 +579,12 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
const conversationRevision = computed(() => JSON.stringify([
|
||||||
|
this.conversationMessages().map(message => message.id),
|
||||||
|
this.demonstrations().map(demo => [demo.id,demo.status])
|
||||||
|
]));
|
||||||
|
effect(() => { conversationRevision(); untracked(() => this.scheduleAutoScroll(false)); });
|
||||||
|
|
||||||
effect((onCleanup) => {
|
effect((onCleanup) => {
|
||||||
const id = this.agentService.currentSessionId();
|
const id = this.agentService.currentSessionId();
|
||||||
const status = this.selectedTerminalStatus();
|
const status = this.selectedTerminalStatus();
|
||||||
@@ -922,6 +941,9 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getTaskStatus(session: Session): string {
|
public getTaskStatus(session: Session): string {
|
||||||
|
const exploration = this.agentService.explorationQueueStatus(session);
|
||||||
|
if (exploration) return exploration as 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled';
|
||||||
|
|
||||||
if (session.status) {
|
if (session.status) {
|
||||||
const s = session.status.toLowerCase();
|
const s = session.status.toLowerCase();
|
||||||
if (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(s)) {
|
if (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(s)) {
|
||||||
@@ -976,12 +998,13 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
|
|
||||||
public deleteTask(sessionId: string, event?: Event): void {
|
public deleteTask(sessionId: string, event?: Event): void {
|
||||||
if (event) event.stopPropagation();
|
if (event) event.stopPropagation();
|
||||||
if (!confirm("确定删除此任务?此操作无法撤销。")) {
|
if (!confirm(this.agentService.deletionPrompt(sessionId))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.deleteError.set('');
|
||||||
this.agentService.deleteSession(sessionId).subscribe({
|
this.agentService.deleteSession(sessionId).subscribe({
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error(`删除会话失败:${sessionId}:`, err);
|
this.deleteError.set(err.error?.detail || "删除失败,请稍后重试");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1612,6 +1635,17 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public evidenceSourceLabel(item: any): string { return actionEvidenceSource(item).label; }
|
||||||
|
|
||||||
|
public openEvidenceVideo(item: any, step: any, event: Event): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
const started = this.agentService.currentSession()?.start_time;
|
||||||
|
const value = item?.timestamp || item?.start_time || step?.timestamp;
|
||||||
|
const timestamp = typeof value === 'number' ? value : Date.parse(value) / 1000;
|
||||||
|
if (!started || !Number.isFinite(timestamp)) return;
|
||||||
|
this.agentService.openVideoPlayer(undefined, undefined, undefined, Math.max(0, timestamp - started), undefined, actionEvidenceSource(item).id);
|
||||||
|
}
|
||||||
|
|
||||||
public getScreenRecordingButtonTitle(): string {
|
public getScreenRecordingButtonTitle(): string {
|
||||||
if (this.agentService.isCurrentSessionRunning()) {
|
if (this.agentService.isCurrentSessionRunning()) {
|
||||||
return this.agentService.currentSession()?.recording_status === 'recording' ? "任务正在运行(录屏中)" : "任务正在运行";
|
return this.agentService.currentSession()?.recording_status === 'recording' ? "任务正在运行(录屏中)" : "任务正在运行";
|
||||||
@@ -1637,6 +1671,12 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
return isDeviceActionTool(tool);
|
return isDeviceActionTool(tool);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getPluginToolView = getPluginToolView;
|
||||||
|
|
||||||
|
public isEvidenceTool(tool: any): boolean {
|
||||||
|
return isDeviceActionTool(tool) || getPluginToolView(tool) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
public isFailureAnalyzerActionTool(tool: any): boolean {
|
public isFailureAnalyzerActionTool(tool: any): boolean {
|
||||||
return isDeviceActionTool(tool);
|
return isDeviceActionTool(tool);
|
||||||
}
|
}
|
||||||
@@ -2033,11 +2073,12 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getToolIcon(tool: any): string {
|
public getToolIcon(tool: any): string {
|
||||||
return getToolIcon(tool);
|
const plugin = getPluginToolView(tool);
|
||||||
|
return plugin ? (plugin.auxiliary ? 'visibility' : 'extension') : getToolIcon(tool);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getToolTitle(tool: any): string {
|
public getToolTitle(tool: any): string {
|
||||||
return getToolTitle(tool);
|
return getPluginToolView(tool)?.title || getToolTitle(tool);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getToolTargetText(tool: any): string {
|
public getToolTargetText(tool: any): string {
|
||||||
@@ -2213,6 +2254,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
public getModelIcon(name: string | undefined | null): string {
|
public getModelIcon(name: string | undefined | null): string {
|
||||||
if (!name) return 'bolt';
|
if (!name) return 'bolt';
|
||||||
const lower = name.toLowerCase();
|
const lower = name.toLowerCase();
|
||||||
|
if (lower === '探索') return 'explore';
|
||||||
if (lower.includes('pro')) return 'diamond';
|
if (lower.includes('pro')) return 'diamond';
|
||||||
if (lower.includes('flash')) return 'bolt';
|
if (lower.includes('flash')) return 'bolt';
|
||||||
return 'smart_toy';
|
return 'smart_toy';
|
||||||
@@ -2228,6 +2270,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
|
|
||||||
public getArchitectureTooltip(model?: ModelInfo | null): string {
|
public getArchitectureTooltip(model?: ModelInfo | null): string {
|
||||||
if (!model) return "执行模式:ARTEMIS Flash(快速响应)";
|
if (!model) return "执行模式:ARTEMIS Flash(快速响应)";
|
||||||
|
if (model.architecture === 'explore') return `探索模式(可交流与人工示范) · LLM: ${model.id}`;
|
||||||
const name = this.getModelDisplayName(model.name);
|
const name = this.getModelDisplayName(model.name);
|
||||||
const isPro = name.toLowerCase().includes('pro');
|
const isPro = name.toLowerCase().includes('pro');
|
||||||
const archDesc = isPro
|
const archDesc = isPro
|
||||||
|
|||||||
+6
-5
@@ -14,7 +14,7 @@
|
|||||||
<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><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></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.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> }
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<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">
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</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>
|
||||||
@@ -70,12 +70,13 @@
|
|||||||
@if(!validRepetitions()){<p class="error">循环次数须为正整数,执行总数不超过 {{maxExecutions}}。</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 || !selectedCount() || !validRepetitions() || !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">
|
||||||
|
<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="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>
|
||||||
@@ -106,6 +107,6 @@
|
|||||||
</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" [class.selected]="agent.currentSessionId() === j.session_id"><button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button><span>{{label(j.status)}}</span><app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />@if(j.error){<small>{{j.error}}</small>}</div>}
|
@for(j of b.jobs; track j.session_id){<div class="job" [class.selected]="agent.currentSessionId() === j.session_id"><app-favorite-button [sourceId]="j.session_id" /><button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button><span>{{label(j.status)}}</span><app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />@if(j.error){<small>{{j.error}}</small>}</div>}
|
||||||
</section>}
|
</section>}
|
||||||
}
|
}
|
||||||
+2
-1
@@ -26,7 +26,7 @@ input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:
|
|||||||
.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;}
|
.case-goal{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;white-space:pre-wrap;margin:5px 0;font-size:12px;}
|
||||||
.execution-summary{padding:9px 12px;border-radius:8px;background:#eff6ff;color:#1d4ed8;font-weight:500;}
|
.execution-summary{padding:9px 12px;border-radius:8px;background:#eff6ff;color:#1d4ed8;font-weight:500;}
|
||||||
.record{display:flex;width:100%;text-align:left;align-items:flex-start;flex-direction:column;gap:4px;margin:8px 0 0;padding:12px;overflow-wrap:anywhere;}.record span{color:#64748b;font-size:12px;font-weight:400;}.record.selected{border-color:#93c5fd;background:#eff6ff;}.record.selected strong{color:#1d4ed8;}
|
.record{display:flex;width:100%;text-align:left;align-items:flex-start;flex-direction:column;gap:4px;margin:8px 0 0;padding:12px;overflow-wrap:anywhere;}.record span{color:#64748b;font-size:12px;font-weight:400;}.record.selected{border-color:#93c5fd;background:#eff6ff;}.record.selected strong{color:#1d4ed8;}
|
||||||
.batch-record{display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record .record{flex:1;min-width:0;width:auto;margin:0;}.batch-record .delete-batch{flex:0 0 40px;min-width:40px;padding:0;color:#b91c1c;}.batch-record .delete-batch:hover:not(:disabled){background:#fff1f2;border-color:#fda4af;}.batch-record .delete-batch .material-symbols-outlined{font-size:19px;}
|
.batch-record{position:relative;display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record>app-favorite-button{position:absolute;left:9px;top:9px;z-index:1}.batch-record .record{padding-left:44px;flex:1;min-width:0;width:auto;margin:0;}.batch-record .delete-batch{flex:0 0 40px;min-width:40px;padding:0;color:#b91c1c;}.batch-record .delete-batch:hover:not(:disabled){background:#fff1f2;border-color:#fda4af;}.batch-record .delete-batch .material-symbols-outlined{font-size:19px;}
|
||||||
.archive-progress{margin:12px 0;padding:11px 13px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;color:#1e40af;}
|
.archive-progress{margin:12px 0;padding:11px 13px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;color:#1e40af;}
|
||||||
.archive-progress-heading{display:flex;align-items:center;gap:8px;}
|
.archive-progress-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:#475569;}
|
||||||
@@ -41,6 +41,7 @@ input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:
|
|||||||
.task-guidance{margin-top:16px;padding:14px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;}
|
.task-guidance{margin-top:16px;padding:14px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;}
|
||||||
.task-guidance label{margin:8px 0 4px;}
|
.task-guidance label{margin:8px 0 4px;}
|
||||||
.report-overview{padding:14px;border:1px solid #dbeafe;border-radius:10px;background:#f8fbff;}
|
.report-overview{padding:14px;border:1px solid #dbeafe;border-radius:10px;background:#f8fbff;}
|
||||||
|
.report-heading{display:flex;align-items:flex-start;gap:8px;}.report-heading .report-title{flex:1;min-width:0;}
|
||||||
.report-title{display:block;font-size:14px;line-height:1.5;color:#0f172a;overflow-wrap:anywhere;}
|
.report-title{display:block;font-size:14px;line-height:1.5;color:#0f172a;overflow-wrap:anywhere;}
|
||||||
.report-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px;span{padding:3px 8px;border:1px solid #bfdbfe;border-radius:20px;background:#eff6ff;color:#1e40af;font-size:12px;&.failed{background:#fff1f2;border-color:#fecdd3;color:#be123c;}}}
|
.report-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px;span{padding:3px 8px;border:1px solid #bfdbfe;border-radius:20px;background:#eff6ff;color:#1e40af;font-size:12px;&.failed{background:#fff1f2;border-color:#fecdd3;color:#be123c;}}}
|
||||||
.report-error{margin-top:11px;padding:10px 12px;background:#fff1f2;border-left:3px solid #e11d48;border-radius:6px;color:#9f1239;strong{font-size:12px;}p{margin:4px 0 0;color:#9f1239;white-space:pre-wrap;}}
|
.report-error{margin-top:11px;padding:10px 12px;background:#fff1f2;border-left:3px solid #e11d48;border-radius:6px;color:#9f1239;strong{font-size:12px;}p{margin:4px 0 0;color:#9f1239;white-space:pre-wrap;}}
|
||||||
|
|||||||
+1
-1
@@ -129,7 +129,7 @@ describe('AutomationPanelComponent batch deletion', () => {
|
|||||||
{session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}}
|
{session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}}
|
||||||
]});
|
]});
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job button')];
|
const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job > button')];
|
||||||
jobs[0].click(); jobs[1].click();
|
jobs[0].click(); jobs[1].click();
|
||||||
expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]);
|
expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]);
|
||||||
expect(component.tab()).toBe('batches');
|
expect(component.tab()).toBe('batches');
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { Component, DestroyRef, computed, effect, inject, output, 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 { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
||||||
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
|
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
|
||||||
import { CaseReviewResponse } from '../../core/models/session.model';
|
import { CaseReviewResponse } from '../../core/models/session.model';
|
||||||
|
import { TaskPluginsComponent, TaskPluginOptions } from '../task-plugins/task-plugins.component';
|
||||||
|
|
||||||
interface ArchiveProgress {
|
interface ArchiveProgress {
|
||||||
status: 'idle' | 'preparing' | 'ready' | 'failed';
|
status: 'idle' | 'preparing' | 'ready' | 'failed';
|
||||||
@@ -21,10 +23,11 @@ interface ArchiveProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent],
|
selector: 'app-automation-panel', standalone: true, imports: [FavoriteButtonComponent, ReportMarkdownComponent, CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent],
|
||||||
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
|
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
|
||||||
})
|
})
|
||||||
export class AutomationPanelComponent {
|
export class AutomationPanelComponent {
|
||||||
|
taskPlugins: TaskPluginOptions = {};
|
||||||
queueRequested = output<void>();
|
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);
|
||||||
@@ -59,7 +62,6 @@ export class AutomationPanelComponent {
|
|||||||
}
|
}
|
||||||
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})); }
|
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); }
|
openBatchTask(id: string) { this.agent.selectSession(id, true); }
|
||||||
showBatchQueue(id: string) { this.agent.batchFilter.set(id); this.agent.activeTab.set('tasks'); this.queueRequested.emit(); }
|
showBatchQueue(id: string) { this.agent.batchFilter.set(id); this.agent.activeTab.set('tasks'); this.queueRequested.emit(); }
|
||||||
@@ -138,9 +140,9 @@ export class AutomationPanelComponent {
|
|||||||
if (!this.preview()?.valid) throw Error('请先导入并修正所有错误');
|
if (!this.preview()?.valid) throw Error('请先导入并修正所有错误');
|
||||||
if (!this.selectedCount()) throw Error('请至少勾选一个用例');
|
if (!this.selectedCount()) throw Error('请至少勾选一个用例');
|
||||||
if (!this.validRepetitions()) throw Error(`循环次数必须为正整数,执行总数不能超过 ${this.maxExecutions}`);
|
if (!this.validRepetitions()) throw Error(`循环次数必须为正整数,执行总数不能超过 ${this.maxExecutions}`);
|
||||||
if (!this.device) throw Error('请选择执行设备');
|
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))}}));
|
||||||
const cases = this.preview().tasks.filter((t: any) => this.selectedRows().has(t.row)).map(({row, ...c}: any) => c);
|
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,profile:this.profile,stop_on_failure:this.stopOnFailure,timeout_seconds:this.timeout,formats:this.formats()}));
|
const created: any = await firstValueFrom(this.http.post('/api/automation/batches',{name:this.name.trim(),cases,repeat_count:this.repeatCount,device_serial:this.device || 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();
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ limitations under the License.
|
|||||||
<!-- Tasks Tab Content -->
|
<!-- Tasks Tab Content -->
|
||||||
@if (agentService.activeTab() === 'tasks') {
|
@if (agentService.activeTab() === 'tasks') {
|
||||||
<app-task-batch-filter />
|
<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 }})
|
||||||
@@ -77,13 +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>
|
||||||
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
|
@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)">
|
||||||
@@ -95,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"
|
||||||
@@ -106,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"
|
||||||
@@ -120,9 +125,12 @@ 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.display_name || 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" />
|
||||||
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
|
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
|
||||||
|
|||||||
@@ -329,11 +329,15 @@ $text-muted: #a1a1aa;
|
|||||||
|
|
||||||
.task-card-header {
|
.task-card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
|
.task-status-group { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; }
|
||||||
|
.task-origin-badge { display: inline-flex; align-items: center; box-sizing: border-box; height: 20px; padding: 0 6px; border: 1px solid #e2e8f0; border-radius: 5px; background: #f8fafc; color: #64748b; font-size: 11px; line-height: 14px; white-space: nowrap; }
|
||||||
|
|
||||||
.task-badge {
|
.task-badge {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -413,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;
|
||||||
@@ -554,6 +559,16 @@ $text-muted: #a1a1aa;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
app-favorite-button { margin-top: -5px; }
|
||||||
|
.task-goal { flex: 1; min-width: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
.task-goal {
|
.task-goal {
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||||
|
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
|
||||||
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
|
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
|
||||||
import { TaskHistoryComponent } from '../task-history/task-history.component';
|
import { TaskHistoryComponent } from '../task-history/task-history.component';
|
||||||
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
||||||
@@ -24,6 +25,7 @@ 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 { 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 { 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';
|
||||||
@@ -33,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, TestResultBadgeComponent, TaskBatchFilterComponent],
|
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
|
||||||
@@ -56,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 ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s);
|
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);
|
||||||
@@ -161,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);
|
||||||
@@ -182,6 +184,9 @@ export class ChatInterfaceComponent {
|
|||||||
* Determine the current task execution status
|
* Determine the current task execution status
|
||||||
*/
|
*/
|
||||||
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
|
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
|
||||||
|
const exploration = this.agentService.explorationQueueStatus(session);
|
||||||
|
if (exploration) return exploration as 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled';
|
||||||
|
|
||||||
if (session.status) {
|
if (session.status) {
|
||||||
const s = session.status.toLowerCase();
|
const s = session.status.toLowerCase();
|
||||||
if (s === 'completed' || s === 'success' || s === 'failed' || s === 'cancelled') {
|
if (s === 'completed' || s === 'success' || s === 'failed' || s === 'cancelled') {
|
||||||
|
|||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
@if(session(); as s) {
|
||||||
|
<app-agent-stream [conversationMessages]="messages()" [demonstrations]="s.demonstrations" [pendingQuestion]="s.pending_question || ''" (useRecommendation)="text = $event; send()">
|
||||||
|
<section task-summary class="session-summary">
|
||||||
|
<div class="row"><strong><span class="material-symbols-outlined">explore</span> {{activeValidation() ? '用例复跑中' : label(s.status)}}</strong><small>{{s.config.model}} / {{s.config.reasoning_effort}} · 续接 {{s.continuation_count}} 次</small></div>
|
||||||
|
<div class="exploration-goal"><app-favorite-button sourceKind="exploration" [sourceId]="s.id" label="收藏为重新探索" /><p>{{s.goal}}</p></div>
|
||||||
|
@if(s.pause_reason === 'execution_budget'){<p class="feedback">本段执行到时,进度已保存。可继续探索,不计为测试失败。</p>}
|
||||||
|
@if(s.pause_reason === 'device_observation'){<p class="feedback" role="status">设备画面暂时不可用,探索已暂停。指导已保留,请确认设备恢复响应后点击“继续探索”。</p>}
|
||||||
|
<details class="execution-history"><summary>执行记录({{s.runs.length}}) · 当前展示所选轮次的操作步骤</summary>@for(run of s.runs; track run.id){<div class="run"><button class="run-link" (click)="agent.selectSession(run.id,true)">{{run.kind === 'validation' ? '用例 v' + run.case_version + ' 复跑' : '探索执行'}} · {{label(run.status)}}{{agent.currentSessionId() === run.id ? ' · 当前查看' : ''}}</button><app-test-result-badge [result]="run.test_result" [executionStatus]="run.status" [compact]="true" /></div>}</details>
|
||||||
|
</section>
|
||||||
|
<div task-result>
|
||||||
|
@if(showCase()) {
|
||||||
|
<section class="case-editor" aria-label="可复用用例"><div class="row"><strong class="case-title"><span class="material-symbols-outlined">task_alt</span>{{s.case ? '探索成果 · 可复用用例' : '本轮探索已结束'}}</strong><button (click)="generate()" [disabled]="busy() || !!s.active_run_id || generating() || dirty">{{generating() ? '正在提炼…' : s.case ? '重新提炼' : '提炼用例'}}</button></div>
|
||||||
|
@if(!s.case){<p class="muted">探索验收通过后自动生成;也可以先编辑并保存草稿。</p><button (click)="editorOpen.set(true)">编辑草稿</button>}
|
||||||
|
@if(s.generations[0]?.status === 'failed'){<p class="error">上次提炼未完成,执行结果保留。可重试提炼。</p>}
|
||||||
|
@if(s.case){<div class="case-compact"><h3><app-favorite-button sourceKind="exploration_case" [sourceId]="s.id" [version]="s.case.version" label="收藏此版本用例" /><span>{{s.case.name}} <small>v{{s.case.version}}</small></span></h3><p class="muted">已整理为一个可复用用例。你可以查看修改,或交给 Flash / Pro 独立复跑。</p><div class="actions"><button class="primary" (click)="copyCase()"><span class="material-symbols-outlined">content_copy</span>复制任务提示词</button><button (click)="editorOpen.set(!editorOpen())">{{editorOpen() ? '收起编辑' : '查看 / 编辑'}}</button><button (click)="rerunOpen.set(!rerunOpen())">复跑 / 批次</button></div></div>}
|
||||||
|
@if(editorOpen()) {
|
||||||
|
@if(s.case){<label>查看版本<select [ngModel]="editVersion" (ngModelChange)="selectVersion($event)" [disabled]="dirty">@for(version of versions(); track version){<option [value]="version">v{{version}}</option>}</select></label>}
|
||||||
|
@if(dirty && editVersion !== s.case_version){<p class="error">已有新版本;当前编辑仍保留,请先复制修改内容再加载最新版本。</p>}
|
||||||
|
<label>用例名称<input [(ngModel)]="editName" (ngModelChange)="dirty = true" maxlength="200" /></label><label>前置条件<textarea [(ngModel)]="editPreconditions" (ngModelChange)="dirty = true" rows="2" maxlength="10000"></textarea></label><label>任务提示词<textarea [(ngModel)]="editGoal" (ngModelChange)="dirty = true" rows="7" maxlength="10000"></textarea></label>
|
||||||
|
<div class="actions"><button (click)="saveCase()" [disabled]="busy() || !dirty || !editName.trim() || !editGoal.trim()">保存新版本</button><button (click)="copyCase()" [disabled]="!editGoal.trim()">复制提示词</button></div>
|
||||||
|
}
|
||||||
|
@if(rerunOpen()) {<div class="rerun-options"><datalist id="exploration-models">@for(model of models;track model){<option [value]="model"></option>}</datalist><strong>复跑与批次</strong><p class="muted">固定使用所选已保存版本。复跑通过与探索通过分别记录。</p><label>执行模式<select [(ngModel)]="rerunProfile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label><label>复跑模型<input [(ngModel)]="rerunModel" list="exploration-models" placeholder="留空使用 Flash / Pro 默认模型" /></label><label>思考强度<select [(ngModel)]="rerunReasoning">@for(effort of efforts; track effort){<option [value]="effort">{{effort}}</option>}</select></label><div class="actions"><button (click)="rerun()" [disabled]="busy() || dirty || !editVersion || !!s.active_run_id">复跑此版本</button><button (click)="rerun('batch')" [disabled]="busy() || dirty || !editVersion">加入新批次草稿</button></div></div>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</app-agent-stream>
|
||||||
|
<section class="composer" [class.needs-answer]="!!s.pending_question" aria-label="指导当前探索">
|
||||||
|
@if(error()){<p role="alert" class="feedback error">{{error()}} <button (click)="refresh()">重试读取</button></p>}
|
||||||
|
@if(notice()){<p role="status" class="feedback">{{notice()}}</p>}
|
||||||
|
@if(s.pending_question) {
|
||||||
|
<div class="question-banner" role="status" aria-live="polite"><span class="material-symbols-outlined">chat_bubble</span><div><strong>探索已暂停,等待你回答</strong><p>{{pendingQuestion()?.text || '探索助手有一个问题需要你确认,请查看上方对话。'}}</p>@if(pendingQuestion()?.recommendation; as suggestion){<button (click)="text = suggestion; send()" [disabled]="busy()">采用建议并继续</button>}</div></div>
|
||||||
|
} @else if(finalizing()) {
|
||||||
|
<div class="feedback" role="status">设备操作已结束,正在根据已验证的步骤提炼用例。完成后会在上方展示。</div>
|
||||||
|
}
|
||||||
|
@if(activeDemo(); as demo) {
|
||||||
|
<section class="demo" [class.warning]="demo.status === 'recording' && secondsLeft(demo) <= demo.warning_seconds" aria-live="polite"><strong><span class="record-dot" [class.recording]="demo.status === 'recording'"></span>{{label(demo.status)}}</strong>
|
||||||
|
@if(demo.status === 'recording'){<p>现在可以操作设备。剩余 {{secondsLeft(demo)}} 秒;AI 已暂停操作。</p><div class="actions"><button class="primary" (click)="demoAction('finish')" [disabled]="busy()">操作完成,开始分析</button><button (click)="demoAction('extend')" [disabled]="busy()">延长示范</button></div>}
|
||||||
|
@else {<p>{{demo.status === 'requested' ? '正在等待当前动作安全结束,请等“录制中”出现后再操作。' : '正在保存并分析本次示范录像。'}}</p>}
|
||||||
|
@if(['requested','recording'].includes(demo.status)){<button (click)="demoAction('cancel')" [disabled]="busy()">取消示范</button>}
|
||||||
|
</section>
|
||||||
|
} @else if(demoOptionsOpen() && s.active_run_id && !activeValidation() && ['running','waiting_user'].includes(s.status)) {
|
||||||
|
<div class="manual-control"><label>示范来源<select [(ngModel)]="source"><option value="android">安卓</option><option value="computer">电脑</option><option value="both">安卓 + 电脑</option></select></label><button (click)="action('demonstrations', {source})" [disabled]="busy()"><span class="material-symbols-outlined">touch_app</span>开始示范</button></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if(activeValidation()){<p class="feedback">用例正在独立复跑,请进入复跑任务查看或指导。<button (click)="agent.selectSession(s.active_run_id!,true)">查看当前复跑</button></p>}
|
||||||
|
<div class="composer-heading"><label for="exploration-message">{{s.pending_question ? '回答探索助手' : '与探索助手交流'}}</label><span class="composer-status" [class.waiting]="!!s.pending_question">{{label(s.status)}}</span></div>
|
||||||
|
<textarea id="exploration-message" [(ngModel)]="text" (keydown)="onKeyDown($event)" rows="2" maxlength="10000" [placeholder]="s.pending_question ? '在这里回答,发送后继续探索…' : '告诉我下一步怎么做,或补充你的想法…'" [disabled]="busy() || activeValidation() || finalizing()"></textarea>
|
||||||
|
<div class="actions composer-actions">
|
||||||
|
@if(!activeDemo() && s.active_run_id && !activeValidation() && ['running','waiting_user'].includes(s.status)){<button class="tool-button" (click)="demoOptionsOpen.set(!demoOptionsOpen())" [attr.aria-expanded]="demoOptionsOpen()"><span class="material-symbols-outlined">touch_app</span>我来操作</button>}
|
||||||
|
@if(!activeValidation() && !s.pending_question && (!s.active_run_id || s.status === 'paused')){<button (click)="action(s.status === 'draft' ? 'start' : 'continue')" [disabled]="busy()">{{s.status === 'draft' ? '开始探索' : '继续探索'}}</button>}
|
||||||
|
@if(s.active_run_id && !activeValidation() && !finalizing()){<button class="tool-button" (click)="action('pause')" [disabled]="busy() || ['paused','waiting_user','demonstrating'].includes(s.status)">暂停</button><button class="tool-button" (click)="action('stop')" [disabled]="busy()">停止</button>}
|
||||||
|
<small class="send-hint">Enter 发送 · Shift+Enter 换行</small>
|
||||||
|
<button class="primary send-button" (click)="send()" [disabled]="busy() || activeValidation() || finalizing() || !text.trim()" [attr.aria-label]="s.pending_question ? '回答并继续' : '发送'"><span>{{s.pending_question ? '回答并继续' : '发送'}}</span><span class="material-symbols-outlined">arrow_upward</span></button>
|
||||||
|
</div>
|
||||||
|
@if(!s.active_run_id){<p class="composer-footnote">可在此讨论和修订用例;继续操作设备请点击“继续探索”。</p>}
|
||||||
|
</section>
|
||||||
|
} @else {<div class="empty" role="status">{{loading() ? '正在读取探索记录…' : '探索记录暂时无法读取'}} @if(error()){<p class="error">{{error()}}</p><button (click)="refresh()">重试</button>}</div>}
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
:host { display:flex; flex-direction:column; min-height:0; height:100%; overflow:hidden; color:#334155; font-size:13px; }
|
||||||
|
.exploration-panel { padding:18px; display:flex; flex-direction:column; gap:16px; }
|
||||||
|
header,.row,.actions,.manual-control { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||||
|
header,.row { justify-content:space-between; }
|
||||||
|
header strong { font-size:15px; color:#0f172a; }
|
||||||
|
p { margin:8px 0; line-height:1.65; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||||
|
header p,.muted,small { color:#64748b; font-size:12px; }
|
||||||
|
button,input,select,textarea { font:inherit; box-sizing:border-box; }
|
||||||
|
button { display:inline-flex; align-items:center; justify-content:center; gap:4px; border:1px solid #dbe3ec; border-radius:8px; background:white; color:#334155; padding:6px 10px; min-height:32px; cursor:pointer; }
|
||||||
|
button:hover:enabled { background:#f1f5f9; border-color:#94a3b8; }
|
||||||
|
button:disabled { opacity:.5; cursor:default; }
|
||||||
|
button.primary { background:#0f172a; color:white; border-color:#0f172a; }
|
||||||
|
.material-symbols-outlined { font-size:18px; }
|
||||||
|
label { display:flex; flex-direction:column; gap:6px; color:#475569; margin:8px 0; }
|
||||||
|
input,select,textarea { width:100%; min-width:0; border:1px solid #cbd5e1; border-radius:8px; padding:8px 10px; background:white; color:#0f172a; }
|
||||||
|
textarea { resize:vertical; line-height:1.6; }
|
||||||
|
input:focus,select:focus,textarea:focus { outline:2px solid #94a3b8; outline-offset:1px; }
|
||||||
|
details { border:1px solid #e2e8f0; border-radius:10px; padding:10px 12px; }
|
||||||
|
summary { cursor:pointer; font-size:12px; color:#475569; }
|
||||||
|
.session-summary,.case-editor { padding:12px; border:1px solid #e2e8f0; border-radius:12px; background:#f8fafc; }
|
||||||
|
.session-summary .actions { margin-top:10px; }
|
||||||
|
.conversation { display:flex; flex-direction:column; gap:12px; }
|
||||||
|
.message { padding:10px 12px; border-radius:12px; background:#f1f5f9; border:1px solid transparent; }
|
||||||
|
.message.user { margin-left:20px; background:#ecfdf5; border-color:#d1fae5; }
|
||||||
|
.message.system { padding:4px 8px; background:transparent; color:#64748b; font-size:12px; }
|
||||||
|
.recommendation { border-left:2px solid #94a3b8; padding-left:10px; line-height:1.6; }
|
||||||
|
.recommendation button { margin:6px 0 0 8px; }
|
||||||
|
.composer { border-top:1px solid #e2e8f0; padding-top:4px; }
|
||||||
|
.composer > button { margin-top:8px; }
|
||||||
|
.manual-control { padding:10px 12px; border:1px solid #dbe3ec; border-radius:10px; background:#fafcfd; }
|
||||||
|
.manual-control label { flex:1; margin:0; }
|
||||||
|
.manual-control button { align-self:flex-end; }
|
||||||
|
.demo { border:1px solid #bbf7d0; border-radius:12px; padding:12px; background:#f0fdf4; }
|
||||||
|
.demo.warning { background:#fffbeb; border-color:#fcd34d; }
|
||||||
|
.record-dot { display:inline-block; width:7px; height:7px; border-radius:50%; background:#94a3b8; margin-right:6px; }
|
||||||
|
.record-dot.recording { background:#dc2626; animation:pulse 1.8s infinite; }
|
||||||
|
@keyframes pulse { 50% { opacity:.4; } }
|
||||||
|
@media(prefers-reduced-motion:reduce) { .record-dot.recording { animation:none; } }
|
||||||
|
.feedback { padding:9px 12px; background:#f0fdf4; border-radius:8px; font-size:12px; }
|
||||||
|
.error { color:#b91c1c; }
|
||||||
|
.feedback.error { background:#fef2f2; }
|
||||||
|
.run,.demo-history { padding:10px 0; border-bottom:1px solid #e2e8f0; }
|
||||||
|
.run-link { border:none; padding-left:0; background:transparent; text-align:left; }
|
||||||
|
video { width:100%; max-height:260px; margin-top:8px; background:#0f172a; }
|
||||||
|
a { color:#0f766e; font-size:12px; }
|
||||||
|
.empty { color:#64748b; text-align:center; padding:25px 10px; }
|
||||||
|
.empty .material-symbols-outlined { font-size:32px; }
|
||||||
|
.settings-fields,.case-editor { display:flex; flex-direction:column; gap:3px; }
|
||||||
|
|
||||||
|
app-agent-stream {flex:1;min-height:0;display:block}
|
||||||
|
.composer {flex:none;margin:0 20px 16px;padding:12px 16px;background:#fff;border:1px solid #dbe3ec;border-radius:16px;box-shadow:0 4px 18px #0f172a06;max-height:45%;overflow:auto}
|
||||||
|
.composer label {margin:0 0 6px}.composer textarea {resize:none}.composer-actions {margin-top:8px}.composer-actions small {flex:1}
|
||||||
|
.session-summary {margin-bottom:18px;background:#fff}.session-summary p {margin:6px 0}.session-summary .row strong {display:flex;align-items:center;gap:6px}
|
||||||
|
.execution-history {margin-top:8px;border:0;padding:4px 0}.case-editor {margin:16px 0;background:#fff}.case-compact {display:flex;flex-wrap:wrap;align-items:center;gap:8px}.case-compact .actions {margin-left:auto}
|
||||||
|
.manual-control {padding:0;margin-bottom:8px;border:0;background:transparent;justify-content:flex-end}.manual-control label {flex:none;flex-direction:row;align-items:center}.manual-control select {width:auto}.demo {padding:8px 10px;margin-bottom:8px}.demo p {margin:4px 0}
|
||||||
|
|
||||||
|
.composer {margin:0 24px 18px;padding:14px 18px;border-radius:22px;box-shadow:0 6px 24px #0f172a08;max-height:50%;box-sizing:border-box;min-width:0}
|
||||||
|
.composer-heading {display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:4px}
|
||||||
|
.composer-heading label {margin:0;font-size:12px;color:#64748b}
|
||||||
|
.composer-status {font-size:11px;border-radius:20px;padding:3px 8px;background:#eef2ff;color:#4f46e5}
|
||||||
|
.composer-status.waiting {color:#92400e;background:#fef3c7}
|
||||||
|
.composer textarea {border:0;border-radius:0;padding:10px 0;background:transparent;min-height:66px;max-height:140px;font-size:14px;box-shadow:none}
|
||||||
|
.composer textarea:focus {outline:none}
|
||||||
|
.composer:focus-within {border-color:#a5b4fc;box-shadow:0 0 0 3px #eef2ff}
|
||||||
|
.composer-actions {gap:6px;flex-wrap:wrap;border-top:1px solid #f1f5f9;padding-top:10px}
|
||||||
|
.composer-actions .tool-button {border:0;background:#f8fafc;color:#64748b;border-radius:10px;padding:6px 9px}
|
||||||
|
.composer-actions .send-hint {flex:1;text-align:right;font-size:10px;color:#94a3b8;min-width:100px}
|
||||||
|
.send-button {min-height:36px;border-radius:12px;padding:7px 12px;background:#4f46e5!important;border-color:#4f46e5!important;gap:6px}
|
||||||
|
.composer-footnote {font-size:11px;color:#94a3b8;margin:8px 0 0}
|
||||||
|
.composer.needs-answer {border-color:#f4c97a}
|
||||||
|
.question-banner {display:flex;gap:10px;background:#fffbeb;border:1px solid #fde68a;border-radius:12px;padding:12px;margin-bottom:12px;color:#92400e}
|
||||||
|
.question-banner > .material-symbols-outlined {font-size:22px;margin-top:2px}.question-banner p {margin:4px 0;font-size:13px}.question-banner strong {font-size:13px}.question-banner button {margin-top:5px;background:#fff7d6;border-color:#f4c97a;color:#92400e}
|
||||||
|
.case-editor {border-color:#c7d2fe;background:linear-gradient(135deg,#f5f7ff,#fff);padding:20px;border-radius:18px}
|
||||||
|
.case-title {display:flex;align-items:center;gap:8px;color:#4f46e5;font-size:14px}.case-title .material-symbols-outlined {font-size:22px}
|
||||||
|
.case-compact {display:block}.case-compact h3 {font-size:18px;line-height:1.5;color:#1e293b;margin:14px 0 6px}.case-compact small {font-size:11px;font-weight:400;background:#e0e7ff;color:#4338ca;padding:3px 6px;border-radius:5px;white-space:nowrap}.case-compact .actions {margin:16px 0 0}.case-compact .primary {background:#4f46e5;border-color:#4f46e5}
|
||||||
|
.session-summary {padding:12px 16px;border-radius:14px}.exploration-goal{display:flex;align-items:flex-start;gap:6px}.session-summary .exploration-goal p{flex:1;min-width:0;margin:4px 0}.case-compact h3{display:flex;align-items:flex-start;gap:6px}.case-compact h3>span{min-width:0;overflow-wrap:anywhere}.session-summary p {color:#64748b;font-size:12px}
|
||||||
|
@media(max-width:600px) {.composer {margin:0 12px 12px;padding:12px}.send-hint {display:none}.send-button {margin-left:auto}.question-banner {max-height:160px;overflow:auto}.case-editor {padding:14px}}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
import { Component, input, output, signal } from '@angular/core';
|
||||||
|
import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
|
||||||
|
import { ExplorationPanelComponent } from './exploration-panel.component';
|
||||||
|
import { ExplorationService, ExplorationSession } from '../../services/exploration.service';
|
||||||
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
|
||||||
|
import { AgentStreamComponent } from '../agent-stream/agent-stream.component';
|
||||||
|
@Component({selector:'app-agent-stream',standalone:true,template:'<ng-content></ng-content>'}) class StreamStub {conversationMessages=input<any[]>([]);demonstrations=input<any[]>([]);pendingQuestion=input('');useRecommendation=output<string>();}
|
||||||
|
|
||||||
|
describe('Exploration conversation', () => {
|
||||||
|
const config = {model: 'gpt-6.1-sol', reasoning_effort: 'high', timeout_seconds: 1800, demonstration_timeout_seconds: 900, demonstration_warning_seconds: 60};
|
||||||
|
let current: ExplorationSession;
|
||||||
|
let api: any;
|
||||||
|
beforeEach(() => {
|
||||||
|
current = {id:'s', goal:'检查设置', status:'running', config, active_run_id:'run', active_demonstration_id:null, case_version:0, message_count:0, elapsed_seconds:10, continuation_count:0, messages:[], runs:[], case:null, demonstrations:[], generations:[]};
|
||||||
|
api = {drafts: new Map(),caseDrafts:new Map(),requests:new Map(),settings: jasmine.createSpy().and.resolveTo(config), list:jasmine.createSpy().and.resolveTo([]), get:jasmine.createSpy().and.callFake(async () => structuredClone(current)), act:jasmine.createSpy().and.resolveTo({}), version:jasmine.createSpy(), saveSettings:jasmine.createSpy().and.resolveTo(config)};
|
||||||
|
TestBed.configureTestingModule({imports:[ExplorationPanelComponent], providers:[
|
||||||
|
{provide:ExplorationService,useValue:api},
|
||||||
|
{provide:AgentService,useValue:{selectSession:jasmine.createSpy(),fetchSessions:jasmine.createSpy(),fetchStatus:jasmine.createSpy(),currentSessionId:signal('run')}}
|
||||||
|
]}).overrideComponent(ExplorationPanelComponent,{remove:{imports:[AgentStreamComponent]},add:{imports:[StreamStub]}});
|
||||||
|
});
|
||||||
|
function render() {
|
||||||
|
const fixture = TestBed.createComponent(ExplorationPanelComponent);
|
||||||
|
fixture.componentRef.setInput('sessionId','s');
|
||||||
|
fixture.detectChanges(); flushMicrotasks(); fixture.detectChanges();
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
it('highlights unanswered questions beside the composer and hides unfinished cases', fakeAsync(() => {
|
||||||
|
current.status='waiting_user'; current.pending_question='q';
|
||||||
|
current.messages=[{id:'q',sequence:1,role:'assistant',kind:'question',text:'下一步是否回到主页?',created_at:1}];
|
||||||
|
current.case={version:1,name:'旧用例',goal:'旧目标',preconditions:'',evidence:[]};current.case_version=1;
|
||||||
|
const fixture=render();
|
||||||
|
expect(fixture.nativeElement.querySelector('.question-banner').textContent).toContain('下一步是否回到主页');
|
||||||
|
expect(fixture.nativeElement.querySelector('[aria-label="可复用用例"]')).toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('继续探索');
|
||||||
|
current.active_run_id=null;current.status='completed';current.pending_question=undefined;
|
||||||
|
void fixture.componentInstance.refresh();flushMicrotasks();fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('[aria-label="可复用用例"]').textContent).toContain('旧用例');
|
||||||
|
expect(fixture.nativeElement.querySelector('.question-banner')).toBeNull();fixture.destroy();
|
||||||
|
}));
|
||||||
|
it('keeps a draft and disables guidance while the verified run is being synthesized', fakeAsync(() => {
|
||||||
|
current.status='finalizing';
|
||||||
|
const fixture=render();fixture.componentInstance.text='返回主页';
|
||||||
|
void fixture.componentInstance.send();flushMicrotasks();
|
||||||
|
expect(api.act).not.toHaveBeenCalled();expect(fixture.componentInstance.text).toBe('返回主页');
|
||||||
|
expect(fixture.nativeElement.querySelector('textarea').disabled).toBeTrue();fixture.destroy();
|
||||||
|
}));
|
||||||
|
it('only invites manual input when the recorder is ready, and warns before its deadline', fakeAsync(() => {
|
||||||
|
current.active_demonstration_id='demo';
|
||||||
|
current.demonstrations=[{id:'demo',run_id:'run',source:'android',status:'requested',warning_seconds:60,media:[]}];
|
||||||
|
const fixture=render();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('请等“录制中”出现后再操作');
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('现在可以操作设备');
|
||||||
|
current.demonstrations[0]={...current.demonstrations[0],status:'recording',deadline:Date.now()/1000+30};
|
||||||
|
void fixture.componentInstance.refresh(); flushMicrotasks(); fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('现在可以操作设备');
|
||||||
|
expect(fixture.nativeElement.querySelector('.demo.warning')).not.toBeNull();
|
||||||
|
fixture.destroy();
|
||||||
|
}));
|
||||||
|
it('shows budget exhaustion as a resumable pause, never a failed test', fakeAsync(() => {
|
||||||
|
current.status='paused'; current.pause_reason='execution_budget'; current.active_run_id=null;
|
||||||
|
current.case={version:1,name:'旧用例',goal:'旧目标',preconditions:'',evidence:[]};
|
||||||
|
const fixture=render();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('不计为测试失败');
|
||||||
|
expect(fixture.nativeElement.querySelector('[aria-label="可复用用例"]')).toBeNull();
|
||||||
|
const button=[...fixture.nativeElement.querySelectorAll('button')].find((b:any)=>b.textContent.includes('继续探索')) as HTMLButtonElement;
|
||||||
|
button.click(); flushMicrotasks();
|
||||||
|
expect(api.act.calls.mostRecent().args[1]).toBe('continue');
|
||||||
|
fixture.destroy();
|
||||||
|
}));
|
||||||
|
it('retains the request ID and unsent text when a send response is lost', fakeAsync(() => {
|
||||||
|
const fixture=render();
|
||||||
|
api.act.and.rejectWith(new Error('network'));
|
||||||
|
fixture.componentInstance.text='改用右上角入口';
|
||||||
|
void fixture.componentInstance.send(); flushMicrotasks();
|
||||||
|
const first=api.act.calls.mostRecent().args[3];
|
||||||
|
expect(fixture.componentInstance.text).toBe('改用右上角入口');
|
||||||
|
api.act.and.resolveTo({});
|
||||||
|
void fixture.componentInstance.send(); flushMicrotasks();
|
||||||
|
expect(api.act.calls.mostRecent().args[3]).toBe(first);
|
||||||
|
expect(fixture.componentInstance.text).toBe('');
|
||||||
|
fixture.destroy();
|
||||||
|
}));
|
||||||
|
it('preserves unsaved edits across polling, but clears them when choosing another conversation', fakeAsync(() => {
|
||||||
|
current.case_version=1; current.case={version:1,name:'检查',goal:'旧目标',preconditions:'',evidence:[]};
|
||||||
|
const fixture=render();
|
||||||
|
fixture.componentInstance.editGoal='用户草稿'; fixture.componentInstance.dirty=true;
|
||||||
|
current.case={...current.case,version:2,goal:'其他窗口保存'}; current.case_version=2;
|
||||||
|
void fixture.componentInstance.refresh(); flushMicrotasks();
|
||||||
|
expect(fixture.componentInstance.editGoal).toBe('用户草稿');
|
||||||
|
current={...current,id:'other',case:null,case_version:0}; fixture.componentRef.setInput('sessionId','other');
|
||||||
|
fixture.detectChanges(); flushMicrotasks(); fixture.detectChanges();
|
||||||
|
expect(fixture.componentInstance.editGoal).toBe('');
|
||||||
|
expect(api.caseDrafts.get('s')?.goal).toBe('用户草稿');
|
||||||
|
fixture.destroy();
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { Component, ChangeDetectionStrategy, DestroyRef, effect, inject, signal, untracked, input } from '@angular/core';
|
||||||
|
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { AgentStreamComponent } from '../agent-stream/agent-stream.component';
|
||||||
|
import { ExplorationService, ExplorationSession, ExplorationMessage, Demonstration } from '../../services/exploration.service';
|
||||||
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-exploration-panel', standalone: true,
|
||||||
|
imports: [FormsModule, AgentStreamComponent, TestResultBadgeComponent, FavoriteButtonComponent],
|
||||||
|
templateUrl: './exploration-panel.component.html', styleUrl: './exploration-panel.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ExplorationPanelComponent {
|
||||||
|
api = inject(ExplorationService);
|
||||||
|
agent = inject(AgentService);
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
session = signal<ExplorationSession | null>(null);
|
||||||
|
messages = signal<ExplorationMessage[]>([]);
|
||||||
|
loading = signal(false);
|
||||||
|
busy = signal(false);
|
||||||
|
error = signal('');
|
||||||
|
notice = signal('');
|
||||||
|
now = signal(Date.now() / 1000);
|
||||||
|
sessionId = input.required<string>();
|
||||||
|
editorOpen = signal(false);
|
||||||
|
rerunOpen = signal(false);
|
||||||
|
demoOptionsOpen = signal(false);
|
||||||
|
get text() { return this.api.drafts.get(this.sessionId()) || ''; }
|
||||||
|
set text(value: string) { this.api.drafts.set(this.sessionId(), value); }
|
||||||
|
source = 'android';
|
||||||
|
editName = '';
|
||||||
|
editGoal = '';
|
||||||
|
editPreconditions = '';
|
||||||
|
editVersion = 0;
|
||||||
|
dirty = false;
|
||||||
|
rerunProfile = 'flash';
|
||||||
|
rerunModel = '';
|
||||||
|
rerunReasoning = 'high';
|
||||||
|
private polling = false;
|
||||||
|
private refreshSerial = 0;
|
||||||
|
private followsLatest = true;
|
||||||
|
private requests = this.api.requests;
|
||||||
|
private displayedId: string | null = null;
|
||||||
|
readonly models = ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-luna', 'mimo-v2.6-pro', 'mimo-v2.6-flash'];
|
||||||
|
readonly efforts = ['default', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const id = this.sessionId();
|
||||||
|
untracked(() => {
|
||||||
|
this.rememberDraft(); this.displayedId = id;
|
||||||
|
this.session.set(null); this.messages.set([]); this.dirty = false; this.editVersion = 0; this.followsLatest = true;
|
||||||
|
this.editName = ''; this.editGoal = ''; this.editPreconditions = '';
|
||||||
|
this.editorOpen.set(false); this.rerunOpen.set(false); this.error.set(''); this.notice.set('');
|
||||||
|
const draft = this.api.caseDrafts.get(id);
|
||||||
|
if (draft) { this.loadCase(draft); this.dirty = true; this.editorOpen.set(true); }
|
||||||
|
if (id) void this.refresh(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
this.now.set(Date.now() / 1000);
|
||||||
|
const id = this.sessionId();
|
||||||
|
if (id && !this.polling) void this.refresh(id, false);
|
||||||
|
}, 2000);
|
||||||
|
this.destroyRef.onDestroy(() => { this.rememberDraft(); this.refreshSerial++; clearInterval(interval); });
|
||||||
|
}
|
||||||
|
|
||||||
|
private rememberDraft() {
|
||||||
|
if (!this.displayedId) return;
|
||||||
|
if (this.dirty) this.api.caseDrafts.set(this.displayedId, {name:this.editName,goal:this.editGoal,preconditions:this.editPreconditions,version:this.editVersion});
|
||||||
|
else this.api.caseDrafts.delete(this.displayedId);
|
||||||
|
}
|
||||||
|
async refresh(id = this.sessionId(), initial = true) {
|
||||||
|
if (!id || this.destroyRef.destroyed) return;
|
||||||
|
const serial = ++this.refreshSerial;
|
||||||
|
if (initial) this.loading.set(true);
|
||||||
|
this.polling = true;
|
||||||
|
try {
|
||||||
|
const current = this.session();
|
||||||
|
const after = current?.id === id ? (this.messages().at(-1)?.sequence || 0) : 0;
|
||||||
|
const value = await this.api.get(id, after);
|
||||||
|
if (id !== this.sessionId() || serial !== this.refreshSerial) return;
|
||||||
|
const combined = new Map((current?.id === id ? this.messages() : []).map(m => [m.id, m]));
|
||||||
|
value.messages.forEach(m => combined.set(m.id, m));
|
||||||
|
this.messages.set([...combined.values()].sort((a, b) => a.sequence - b.sequence));
|
||||||
|
this.session.set(value);
|
||||||
|
if (!current) this.source = value.task_config?.['execution_target'] === 'computer' ? 'computer' : 'android';
|
||||||
|
if (value.case && this.followsLatest && !this.dirty && this.editVersion !== value.case.version) this.loadCase(value.case);
|
||||||
|
} catch (e) { if (id === this.sessionId()) this.showError(e); }
|
||||||
|
finally { if (serial === this.refreshSerial) { this.polling = false; this.loading.set(false); } }
|
||||||
|
}
|
||||||
|
private loadCase(value: {name: string; goal: string; preconditions: string; version: number}) {
|
||||||
|
this.editName = value.name; this.editGoal = value.goal; this.editPreconditions = value.preconditions;
|
||||||
|
this.editVersion = value.version; this.dirty = false;
|
||||||
|
}
|
||||||
|
async selectVersion(version: string) {
|
||||||
|
const id = this.sessionId(); if (!id || this.dirty) return;
|
||||||
|
this.followsLatest = Number(version) === this.session()?.case_version;
|
||||||
|
try { const value = await this.api.version(id, Number(version)); if (id === this.sessionId()) this.loadCase(value); }
|
||||||
|
catch(e) { this.showError(e); }
|
||||||
|
}
|
||||||
|
versions() { return Array.from({length: this.session()?.case_version || 0}, (_, i) => i + 1); }
|
||||||
|
label(status: string) {
|
||||||
|
return ({draft:'未开始', queued:'等待执行', reserved:'等待执行', running:'探索中', waiting_user:'等待你回答', demonstrating:'人工示范', analyzing_demo:'分析示范', finalizing:'正在提炼用例', paused:'已暂停,可继续', completed:'探索已结束', cancelled:'已取消', failed:'执行失败', interrupted:'已中断,可继续', requested:'等待安全接管', recording:'录制中', analyzing:'分析中', timed_out:'示范到时,已保存'} as Record<string,string>)[status] || status;
|
||||||
|
}
|
||||||
|
pendingQuestion() { return this.messages().find(m => m.id === this.session()?.pending_question); }
|
||||||
|
finalizing() { return this.session()?.status === 'finalizing'; }
|
||||||
|
showCase() { const s = this.session(); return !!s && !s.active_run_id && ['completed', 'cancelled'].includes(s.status); }
|
||||||
|
activeDemo() { const s = this.session(); return s?.demonstrations.find(d => d.id === s.active_demonstration_id); }
|
||||||
|
activeValidation() { const s = this.session(); return s?.runs.some(r => r.id === s.active_run_id && r.kind === 'validation') || false; }
|
||||||
|
secondsLeft(demo: Demonstration) { return Math.max(0, Math.ceil((demo.deadline || this.now()) - this.now())); }
|
||||||
|
async action(action: string, payload: Record<string, unknown> = {}): Promise<Record<string, unknown> | null> {
|
||||||
|
const id = this.sessionId(); if (!id || this.busy()) return null;
|
||||||
|
const selectedRun = this.agent.currentSessionId();
|
||||||
|
const key = JSON.stringify([id, action, payload]);
|
||||||
|
const requestId = this.requests.get(key) || crypto.randomUUID(); this.requests.set(key, requestId);
|
||||||
|
this.busy.set(true); this.error.set(''); this.notice.set('');
|
||||||
|
try {
|
||||||
|
const value = await this.api.act(id, action, payload, requestId);
|
||||||
|
this.requests.delete(key);
|
||||||
|
await this.refresh(id, false);
|
||||||
|
const run = value['run'] as {id?: string} | undefined;
|
||||||
|
if (id === this.sessionId() && selectedRun === this.agent.currentSessionId() && run?.id) { this.agent.selectSession(run.id, true); }
|
||||||
|
this.agent.fetchSessions(); this.agent.fetchStatus();
|
||||||
|
return id === this.sessionId() ? value : null;
|
||||||
|
} catch (e) { if (id === this.sessionId()) this.showError(e); return null; }
|
||||||
|
finally { this.busy.set(false); }
|
||||||
|
}
|
||||||
|
async send() {
|
||||||
|
const text = this.text.trim(), id = this.sessionId(); if (!text || this.activeValidation() || this.finalizing()) return;
|
||||||
|
if (await this.action('messages', {text}) && this.api.drafts.get(id)?.trim() === text) this.api.drafts.delete(id);
|
||||||
|
}
|
||||||
|
demoAction(action: string) {
|
||||||
|
const demo = this.activeDemo();
|
||||||
|
if (demo) void this.action(`demonstrations/${demo.id}/${action}`, action === 'extend' ? {seconds: this.session()?.config.demonstration_timeout_seconds || 900} : {});
|
||||||
|
}
|
||||||
|
onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); void this.send(); }
|
||||||
|
}
|
||||||
|
async saveCase() {
|
||||||
|
if (!this.editName.trim() || !this.editGoal.trim()) return;
|
||||||
|
const result = await this.action('case', {expected_version: this.editVersion, case: {name: this.editName, goal: this.editGoal, preconditions: this.editPreconditions, evidence: this.session()?.case?.evidence || []}});
|
||||||
|
if (result) { this.dirty = false; this.followsLatest = true; await this.refresh(); this.notice.set('用例已保存为新版本'); }
|
||||||
|
}
|
||||||
|
generate() {
|
||||||
|
const source = [...(this.session()?.runs || [])].reverse().find(r => r.kind === 'exploration' && r.test_result?.status === 'passed' && r.status === 'completed');
|
||||||
|
if (source) void this.action('generate', {source_run_id: source.id, expected_version: this.session()?.case_version || 0});
|
||||||
|
else this.error.set('尚无验证通过的探索执行,请继续探索;也可以手动保存草稿。');
|
||||||
|
}
|
||||||
|
generating() { return this.session()?.generations.some(g => g.status === 'running') || false; }
|
||||||
|
rerun(action = 'validate') {
|
||||||
|
const task_config: Record<string, unknown> = {};
|
||||||
|
if (this.rerunModel.trim()) { task_config['model'] = this.rerunModel.trim(); task_config['reasoning_effort'] = this.rerunReasoning; }
|
||||||
|
const payload: Record<string, unknown> = {version: this.editVersion, profile: this.rerunProfile, task_config};
|
||||||
|
if (action === 'batch') payload['name'] = this.editName;
|
||||||
|
void this.action(action, payload).then(value => { if (value && action === 'batch') this.notice.set('已创建批次草稿,可在“报告与批量”中查看并启动。'); });
|
||||||
|
}
|
||||||
|
async copyCase() {
|
||||||
|
try { await navigator.clipboard.writeText(`${this.editPreconditions ? '前置条件:' + this.editPreconditions + '\n\n' : ''}${this.editGoal}`); this.notice.set('用例提示词已复制'); }
|
||||||
|
catch { this.error.set('剪贴板不可用,请选中下方提示词复制'); }
|
||||||
|
}
|
||||||
|
mediaUrl(runId: string, path: string) { return '/videos/' + [runId, ...path.split('/')].map(encodeURIComponent).join('/'); }
|
||||||
|
private showError(error: unknown) { const e = error as {error?: {detail?: unknown}; message?: string}; this.error.set(typeof e.error?.detail === 'string' ? e.error.detail : e.message || '操作失败,请刷新状态后重试'); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Component, ChangeDetectionStrategy, computed, inject, input, signal } from '@angular/core';
|
||||||
|
import { FavoriteKind, FavoritesService } from '../../services/favorites.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-favorite-button', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `<button type="button" [class.saved]="!!favorite()" [disabled]="busy() || !api.loaded() || !sourceId()"
|
||||||
|
[attr.aria-pressed]="!!favorite()" [attr.aria-label]="favorite() ? '取消收藏' : label()" [title]="favorite() ? '取消收藏' : label()"
|
||||||
|
(click)="toggle($event)"><span class="material-symbols-outlined" aria-hidden="true">{{favorite() ? 'star' : 'star_outline'}}</span></button>
|
||||||
|
@if(error()){<span class="feedback" role="alert">{{error()}}</span>}
|
||||||
|
@if(api.error() && !api.loaded()){<button type="button" class="retry" (click)="$event.stopPropagation(); api.load(true)" title="重试加载收藏">重试</button>}`,
|
||||||
|
styles: [`:host{display:inline-flex;align-items:center;position:relative;flex-shrink:0}button{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:#64748b;cursor:pointer}button:hover{background:#fef3c7;color:#b45309}button.saved{color:#c47b0b}button.saved span{font-variation-settings:'FILL' 1}button:focus-visible{outline:2px solid #818cf8;outline-offset:2px}button:disabled{opacity:.45;cursor:default}.material-symbols-outlined{font-size:19px}.feedback{position:absolute;left:0;top:30px;min-width:170px;max-width:260px;padding:8px 10px;z-index:20;border:1px solid #fecaca;border-radius:8px;background:#fff7f7;color:#b91c1c;font-size:12px;line-height:1.5;box-shadow:0 4px 16px #0f172a12}.retry{font-size:10px;width:auto}`]
|
||||||
|
})
|
||||||
|
export class FavoriteButtonComponent {
|
||||||
|
api = inject(FavoritesService);
|
||||||
|
sourceId = input.required<string>();
|
||||||
|
sourceKind = input<FavoriteKind>('task');
|
||||||
|
version = input<number>();
|
||||||
|
label = input('收藏到精选任务');
|
||||||
|
busy = signal(false); error = signal('');
|
||||||
|
favorite = computed(() => this.api.items().find(i => i.source_kind === this.sourceKind() && i.source_id === this.sourceId() && (!this.version() || i.case_version === this.version())));
|
||||||
|
constructor() { void this.api.load(); }
|
||||||
|
async toggle(event: Event) {
|
||||||
|
event.stopPropagation(); if (this.busy()) return;
|
||||||
|
this.busy.set(true); this.error.set('');
|
||||||
|
try {
|
||||||
|
const saved = this.favorite();
|
||||||
|
if (saved) await this.api.remove(saved.id);
|
||||||
|
else await this.api.create(this.sourceKind(), this.sourceId(), this.version());
|
||||||
|
} catch(e) { this.error.set((e as any)?.error?.detail || '收藏操作失败,请重试'); }
|
||||||
|
finally { this.busy.set(false); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<section class="featured" aria-label="精选任务">
|
||||||
|
<div class="heading"><div><h2><span class="material-symbols-outlined" aria-hidden="true">star</span>精选任务</h2><p>收藏常用流程,沿用原配置一键复跑。</p></div><input type="search" aria-label="搜索精选任务" placeholder="搜索收藏…" [ngModel]="query()" (ngModelChange)="query.set($event)"></div>
|
||||||
|
<div class="filters" aria-label="精选任务类型">@for(tab of filters; track tab.id){<button type="button" [class.selected]="filter() === tab.id" [attr.aria-pressed]="filter() === tab.id" (click)="filter.set(tab.id)">{{tab.label}}</button>}</div>
|
||||||
|
@if(error() || api.error()){<p class="error" role="alert">{{error() || api.error()}} @if(api.error()){<button (click)="api.load(true)">重试</button>}</p>}
|
||||||
|
@if(notice()){<p class="notice" role="status">{{notice()}}</p>}
|
||||||
|
@if(api.loading() && !api.loaded()){<p class="empty" role="status">正在读取精选任务…</p>}
|
||||||
|
@else if(!api.items().length && !api.error()){<div class="empty"><span class="material-symbols-outlined" aria-hidden="true">star_outline</span><strong>把常用测试留在这里</strong><p>在任务队列、历史记录或批次旁点击星标,即可收藏。删除原记录不会影响收藏。</p></div>}
|
||||||
|
@else if(!filtered().length){<p class="empty">没有匹配的精选任务。</p>}
|
||||||
|
<div class="cards">@for(item of filtered(); track item.id){
|
||||||
|
<article class="card" [class.expanded]="expanded() === item.id">
|
||||||
|
<div class="card-overview">
|
||||||
|
<div class="card-heading"><span class="mode" [class.pro]="options(item)['profile'] === 'pro'" [class.explore]="item.kind === 'exploration'">{{mode(item)}}</span>@if(item.case_version){<span class="case-version">用例 v{{item.case_version}}</span>}</div>
|
||||||
|
<div class="card-title"><button class="star" type="button" (click)="remove(item)" [disabled]="!!busy()" aria-label="取消收藏" title="取消收藏"><span class="material-symbols-outlined" aria-hidden="true">star</span></button><h3 [title]="item.name">{{item.name}}</h3></div><p class="model-summary">{{summary(item)}}</p>
|
||||||
|
<div class="card-actions"><button type="button" class="configure" (click)="toggle(item)" [attr.aria-expanded]="expanded() === item.id"><span class="material-symbols-outlined" aria-hidden="true">tune</span>配置<span class="material-symbols-outlined" aria-hidden="true">{{expanded() === item.id ? 'expand_less' : 'expand_more'}}</span></button><button type="button" class="primary" (click)="run(item)" [disabled]="!!busy()"><span class="material-symbols-outlined" aria-hidden="true">play_arrow</span>{{busy() === item.id ? '处理中…' : item.kind === 'exploration' ? '重新探索' : '运行'}}</button></div>
|
||||||
|
</div>
|
||||||
|
@if(expanded() === item.id){@let d = draft(item);
|
||||||
|
<div class="configuration"><label>收藏名称<input [(ngModel)]="d.name" maxlength="200" aria-label="收藏名称"></label>
|
||||||
|
@if(item.kind === 'batch'){
|
||||||
|
<label class="checkbox"><input type="checkbox" [(ngModel)]="d.uniform">本次统一模型和思考强度</label>
|
||||||
|
@if(d.uniform){<app-model-selector label="整批模型" [(value)]="d.model" /><app-setting-slider label="整批思考强度" [(value)]="d.reasoning_effort" [values]="efforts" [labels]="effortLabels" />}
|
||||||
|
@for(c of item.snapshot.cases; track $index; let i = $index){<details><summary>{{i + 1}}. {{c.name}}</summary>
|
||||||
|
<label>执行模式<select [(ngModel)]="d.cases[i].profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label>
|
||||||
|
<app-model-selector [label]="'用例 ' + (i + 1) + ' 模型'" [(value)]="d.cases[i].model" [disabled]="d.uniform" />
|
||||||
|
<app-setting-slider label="思考强度" [(value)]="d.cases[i].reasoning_effort" [values]="efforts" [labels]="effortLabels" [disabled]="d.uniform" />
|
||||||
|
@if(!computer(c.task_config)){<label>执行设备<select [(ngModel)]="d.cases[i].device_serial"><option value="">请选择在线设备</option>@if(d.cases[i].device_serial){<option [value]="d.cases[i].device_serial">原选择:{{d.cases[i].device_serial}}</option>}@for(device of system.connectedDevices(); track device.serial){<option [value]="device.serial">{{device.serial}}</option>}</select></label>}
|
||||||
|
</details>}
|
||||||
|
}@else{
|
||||||
|
@if(item.kind !== 'exploration'){<label>执行模式<select [(ngModel)]="d.profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label>}
|
||||||
|
<app-model-selector label="复跑模型" [(value)]="d.model" />
|
||||||
|
<app-setting-slider label="思考强度" [(value)]="d.reasoning_effort" [values]="efforts" [labels]="effortLabels" />
|
||||||
|
@if(!computer(options(item))){<label>执行设备<select [(ngModel)]="d.device_serial" aria-label="复跑设备"><option value="">请选择在线设备</option>@if(d.device_serial){<option [value]="d.device_serial">原选择:{{d.device_serial}}</option>}@for(device of system.connectedDevices(); track device.serial){<option [value]="device.serial">{{device.serial}}</option>}</select></label>}
|
||||||
|
}
|
||||||
|
<p class="hint">调整仅用于本次运行;保存后才会改变收藏的默认配置。</p><button class="save" (click)="save(item)" [disabled]="!!busy() || !d.name.trim() || (d.uniform && !d.model.trim())">保存为默认配置</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
:host { display: block; width: 100%; color: #334155; }
|
||||||
|
.featured { margin-top: 26px; }
|
||||||
|
.heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||||
|
h2 { display: flex; align-items: center; gap: 7px; margin: 0; font-size: 16px; font-weight: 650; color: #1e293b; }
|
||||||
|
h2 span { font-size: 20px; color: #d29a32; }
|
||||||
|
.heading p { font-size: 12px; color: #64748b; margin: 6px 0 0; }
|
||||||
|
.heading > input { max-width: 210px; width: 40%; border-radius: 20px; }
|
||||||
|
.filters { display: flex; gap: 5px; flex-wrap: wrap; margin: 17px 0; }
|
||||||
|
.filters button { border: 0; padding: 6px 13px; border-radius: 18px; font-size: 12px; background: transparent; color: #64748b; }
|
||||||
|
.filters button.selected { background: #e0e7ff; color: #4338ca; font-weight: 600; }
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 14px; align-items: start; }
|
||||||
|
.card { padding: 16px; border: 1px solid #e2e8f0; border-radius: 16px; background: #fff; min-width: 0; transition: border-color .15s, box-shadow .15s; }
|
||||||
|
.card:hover { border-color: #c7d2fe; box-shadow: 0 4px 16px #33415508; }
|
||||||
|
.card.expanded { border-color: #a5b4fc; }
|
||||||
|
.card-overview { display: flex; flex-direction: column; min-height: 178px; }
|
||||||
|
.card-heading { display: flex; align-items: center; justify-content: space-between; gap: 6px; min-height: 22px; }
|
||||||
|
.mode { padding: 3px 8px; background: #f1f5f9; color: #475569; border-radius: 6px; font-size: 11px; line-height: 16px; }
|
||||||
|
.mode.pro { background: #ede9fe; color: #6d28d9; }
|
||||||
|
.mode.explore { background: #e0f2fe; color: #0369a1; }
|
||||||
|
.case-version { font-size: 11px; color: #94a3b8; white-space: nowrap; }
|
||||||
|
.card-title { display: flex; align-items: flex-start; gap: 6px; margin: 13px 0 7px; min-height: 42px; }
|
||||||
|
.card-title h3 { flex: 1; min-width: 0; margin: 0; font-size: 14px; line-height: 21px; overflow-wrap: anywhere; color: #1e293b;
|
||||||
|
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
|
||||||
|
.star { display: flex; flex-shrink: 0; width: 24px; height: 24px; align-items: center; justify-content: center; padding: 0; border: 0; background: transparent; color: #c58b23; }
|
||||||
|
.star span { font-size: 19px; font-variation-settings: 'FILL' 1; }
|
||||||
|
.model-summary { font-size: 12px; line-height: 18px; color: #64748b; margin: 0 0 12px; min-height: 36px; overflow-wrap: anywhere;
|
||||||
|
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
|
||||||
|
.card-actions { display: flex; align-items: center; justify-content: space-between; margin-top: auto; gap: 10px; }
|
||||||
|
.card-actions button { display: flex; align-items: center; gap: 4px; min-height: 34px; padding: 7px 10px; border-radius: 8px; font-size: 12px; white-space: nowrap; }
|
||||||
|
.card-actions span { font-size: 16px; }
|
||||||
|
.configure { color: #64748b; border: 0; background: transparent; }
|
||||||
|
.primary { border: 1px solid #6366f1; background: #6366f1; color: white; }
|
||||||
|
.primary:hover { background: #4f46e5; }
|
||||||
|
.configuration { border-top: 1px solid #e2e8f0; margin-top: 14px; padding-top: 14px; display: grid; gap: 12px; }
|
||||||
|
.configuration label { display: grid; gap: 6px; font-size: 12px; color: #475569; }
|
||||||
|
.configuration .checkbox { display: flex; align-items: center; }
|
||||||
|
.checkbox input { width: auto; min-height: auto; }
|
||||||
|
input, select { box-sizing: border-box; min-width: 0; width: 100%; min-height: 40px; padding: 10px 12px; border: 1px solid #dbe3ec; border-radius: 10px; color: #334155; background: white; font: inherit; font-size: 13px; }
|
||||||
|
option { color: #334155; background: #fff; }
|
||||||
|
.hint { font-size: 11px; color: #64748b; line-height: 1.5; margin: 7px 0 0; }
|
||||||
|
.save { justify-self: start; padding: 7px 10px; border: 1px solid #cbd5e1; background: #fff; color: #475569; border-radius: 8px; font-size: 12px; }
|
||||||
|
details { border: 1px solid #e2e8f0; padding: 10px; border-radius: 9px; }
|
||||||
|
details summary { font-size: 12px; cursor: pointer; overflow-wrap: anywhere; }
|
||||||
|
details[open] > label, details[open] > app-setting-slider { margin-top: 12px; display: block; }
|
||||||
|
details[open] > app-model-selector { margin-top: 12px; }
|
||||||
|
button { font-family: inherit; cursor: pointer; }
|
||||||
|
button:disabled { opacity: .5; cursor: default; }
|
||||||
|
button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible { outline: 2px solid #818cf8; outline-offset: 2px; }
|
||||||
|
.empty { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 28px 18px; border: 1px dashed #cbd5e1; border-radius: 16px; color: #64748b; font-size: 12px; line-height: 1.7; }
|
||||||
|
.empty > span { font-size: 28px; color: #c5a365; margin-bottom: 9px; }
|
||||||
|
.empty strong { font-size: 14px; color: #475569; }
|
||||||
|
.empty p { max-width: 440px; margin: 8px 0 0; }
|
||||||
|
.error, .notice { padding: 10px 12px; border-radius: 8px; font-size: 12px; line-height: 1.6; }
|
||||||
|
.error { background: #fff1f2; color: #b91c1c; }
|
||||||
|
.notice { background: #f0fdf4; color: #166534; }
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.heading { align-items: flex-start; flex-direction: column; }
|
||||||
|
.heading > input { width: 100%; max-width: none; }
|
||||||
|
.cards { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { FeaturedTasksComponent } from './featured-tasks.component';
|
||||||
|
import { FavoritesService, Favorite } from '../../services/favorites.service';
|
||||||
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
import { SystemService } from '../../services/system.service';
|
||||||
|
|
||||||
|
describe('Featured tasks',()=>{
|
||||||
|
let api:any, agent:any, system:any, router:any;
|
||||||
|
const favorite:Favorite={id:'favorite',name:'检查页面',kind:'task',source_kind:'task',source_id:'source',inherited_fields:[],snapshot:{goal:'检查主页',runtime_overrides:{profile:'pro',model:'original-model',reasoning_effort:'high',device_serial:'device'}}};
|
||||||
|
beforeEach(()=>{
|
||||||
|
api={items:signal([structuredClone(favorite)]),loaded:signal(true),loading:signal(false),error:signal(''),load:jasmine.createSpy().and.resolveTo(),run:jasmine.createSpy().and.resolveTo({kind:'task',session_id:'new'}),update:jasmine.createSpy().and.resolveTo(favorite),remove:jasmine.createSpy().and.resolveTo()};
|
||||||
|
agent={fetchSessions:jasmine.createSpy(),selectSession:jasmine.createSpy(),batchFilter:signal(''),activeTab:signal('tasks')};system={connectedDevices:signal([{serial:'device',state:'device'}])};router={navigate:jasmine.createSpy().and.resolveTo(true)};
|
||||||
|
TestBed.configureTestingModule({providers:[{provide:FavoritesService,useValue:api},{provide:AgentService,useValue:agent},{provide:SystemService,useValue:system},{provide:Router,useValue:router}]});
|
||||||
|
});
|
||||||
|
it('runs a card with original settings and keeps configuration folded initially',fakeAsync(()=>{
|
||||||
|
const fixture=TestBed.createComponent(FeaturedTasksComponent);fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.configuration')).toBeNull();
|
||||||
|
fixture.nativeElement.querySelector('.primary').click();flushMicrotasks();
|
||||||
|
expect(api.run).toHaveBeenCalledWith('favorite',{});expect(api.update).not.toHaveBeenCalled();expect(agent.selectSession).toHaveBeenCalledWith('new',true);expect(router.navigate).toHaveBeenCalledWith(['/workspace']);
|
||||||
|
}));
|
||||||
|
it('blocks unavailable original devices and opens a device chooser without submitting',fakeAsync(()=>{
|
||||||
|
system.connectedDevices.set([{serial:'other',state:'device'}]);const fixture=TestBed.createComponent(FeaturedTasksComponent);fixture.detectChanges();fixture.nativeElement.querySelector('.primary').click();flushMicrotasks();fixture.detectChanges();
|
||||||
|
expect(api.run).not.toHaveBeenCalled();expect(fixture.nativeElement.querySelector('[aria-label="复跑设备"]')).not.toBeNull();expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('原设备不在线');
|
||||||
|
}));
|
||||||
|
it('applies model changes only to this run unless defaults are explicitly saved',fakeAsync(()=>{
|
||||||
|
const fixture=TestBed.createComponent(FeaturedTasksComponent);const c=fixture.componentInstance;c.draft(favorite).model='temporary';void c.run(favorite);flushMicrotasks();
|
||||||
|
expect(api.run).toHaveBeenCalledWith('favorite',{task_config:{model:'temporary'}});expect(api.update).not.toHaveBeenCalled();expect(favorite.snapshot.runtime_overrides!['model']).toBe('original-model');
|
||||||
|
void c.save(favorite);flushMicrotasks();expect(api.update).toHaveBeenCalledWith('favorite',{task_config:{model:'temporary'},name:'检查页面'});
|
||||||
|
}));
|
||||||
|
it('uses the shared model picker and automatically keeps completed legacy settings without a warning',fakeAsync(()=>{
|
||||||
|
api.items.set([{...structuredClone(favorite),inherited_fields:['record_video','recording']}]);
|
||||||
|
const fixture=TestBed.createComponent(FeaturedTasksComponent);fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('旧任务部分配置缺失');
|
||||||
|
fixture.nativeElement.querySelector('.configure').click();fixture.detectChanges();flushMicrotasks();
|
||||||
|
const select:HTMLSelectElement=fixture.nativeElement.querySelector('app-model-selector select');
|
||||||
|
expect(Array.from(select.options,option=>option.value)).toEqual(['gpt-6-luna','gpt-6.1-sol','gpt-6-astra','mimo-v2.6-pro','__custom__']);
|
||||||
|
expect(select.value).toBe('__custom__');
|
||||||
|
expect(fixture.nativeElement.querySelector('input[aria-label="复跑模型(自定义)"]').value).toBe('original-model');
|
||||||
|
select.value='gpt-6-luna';select.dispatchEvent(new Event('change'));fixture.detectChanges();
|
||||||
|
fixture.nativeElement.querySelector('.primary').click();flushMicrotasks();
|
||||||
|
expect(api.run).toHaveBeenCalledWith('favorite',{task_config:{model:'gpt-6-luna'}});
|
||||||
|
expect(api.update).not.toHaveBeenCalled();
|
||||||
|
}));
|
||||||
|
it('computer-only favorites need no Android device and can be filtered by name',fakeAsync(()=>{
|
||||||
|
const pc:Favorite={...structuredClone(favorite),kind:'exploration_case',case_version:1,snapshot:{goal:'检查电脑',runtime_overrides:{profile:'flash',model:'original-model',reasoning_effort:'high',execution_target:'computer'}}};api.items.set([pc]);system.connectedDevices.set([]);
|
||||||
|
const fixture=TestBed.createComponent(FeaturedTasksComponent),c=fixture.componentInstance;c.query.set('不存在');expect(c.filtered()).toEqual([]);c.query.set('页面');expect(c.filtered().length).toBe(1);void c.run(pc);flushMicrotasks();expect(api.run).toHaveBeenCalled();
|
||||||
|
}));
|
||||||
|
it('batch defaults keep each case and repetition count, with explicit uniform override',()=>{
|
||||||
|
const batch:Favorite={...structuredClone(favorite),kind:'batch',snapshot:{repeat_count:3,cases:[{name:'甲',goal:'检查甲',task_config:{profile:'flash',model:'a',reasoning_effort:'high',device_serial:'device'}},{name:'乙',goal:'检查乙',task_config:{profile:'pro',model:'b',reasoning_effort:'max',device_serial:'device'}}]}};
|
||||||
|
const fixture=TestBed.createComponent(FeaturedTasksComponent),c=fixture.componentInstance;expect(c.payload(batch)).toEqual({});const d=c.draft(batch);expect(c.payload(batch)).toEqual({case_configs:{}});d.uniform=true;d.model='uniform';d.reasoning_effort='high';expect(c.payload(batch)['task_config']).toEqual({model:'uniform',reasoning_effort:'high'});expect(batch.snapshot.repeat_count).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Component, ChangeDetectionStrategy, computed, inject, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { FavoritesService, Favorite } from '../../services/favorites.service';
|
||||||
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
import { SystemService } from '../../services/system.service';
|
||||||
|
import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from '../setting-slider/setting-slider.component';
|
||||||
|
import { ModelSelectorComponent } from '../model-selector/model-selector.component';
|
||||||
|
|
||||||
|
interface ModelDraft {profile: string; model: string; reasoning_effort: string; device_serial: string;}
|
||||||
|
interface Draft extends ModelDraft {name: string; uniform: boolean; cases: ModelDraft[];}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-featured-tasks', standalone: true, imports: [FormsModule, SettingSliderComponent, ModelSelectorComponent],
|
||||||
|
templateUrl: './featured-tasks.component.html', styleUrl: './featured-tasks.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class FeaturedTasksComponent {
|
||||||
|
api = inject(FavoritesService); system = inject(SystemService); private agent = inject(AgentService); private router = inject(Router);
|
||||||
|
query = signal(''); filter = signal('all'); expanded = signal<string | null>(null);
|
||||||
|
busy = signal<string | null>(null); error = signal(''); notice = signal('');
|
||||||
|
drafts = new Map<string, Draft>();
|
||||||
|
readonly efforts = REASONING_VALUES; readonly effortLabels = REASONING_LABELS;
|
||||||
|
readonly filters = [{id:'all',label:'全部'},{id:'flash',label:'Flash'},{id:'pro',label:'Pro'},{id:'exploration',label:'探索'},{id:'batch',label:'批量'}];
|
||||||
|
filtered = computed(() => this.api.items().filter(item => {
|
||||||
|
const filter = this.filter(), query = this.query().trim().toLowerCase();
|
||||||
|
return (filter === 'all' || (filter === 'batch' ? item.kind === 'batch' : filter === 'exploration' ? ['exploration','exploration_case'].includes(item.kind) : item.kind !== 'batch' && this.options(item)['profile'] === filter)) && (!query || `${item.name} ${item.snapshot.goal || ''}`.toLowerCase().includes(query));
|
||||||
|
}));
|
||||||
|
constructor() { void this.api.load(); }
|
||||||
|
options(item: Favorite): Record<string, any> { return item.kind === 'exploration' ? {...item.snapshot.task_config,...item.snapshot.config,profile:'exploration'} : item.snapshot.runtime_overrides || {}; }
|
||||||
|
mode(item: Favorite) { return item.kind === 'batch' ? '批量' : item.kind === 'exploration' ? '重新探索' : (this.options(item)['profile'] === 'pro' ? 'Pro' : 'Flash') + (item.kind === 'exploration_case' ? ' · 用例复跑' : ''); }
|
||||||
|
summary(item: Favorite) { return item.kind === 'batch' ? `${item.snapshot.cases?.length || 0} 个用例 · ${item.snapshot.repeat_count || 1} 轮 · 保留各项配置` : `${this.options(item)['model'] || '当前默认'} / ${this.options(item)['reasoning_effort'] || '当前默认'}`; }
|
||||||
|
computer(options: Record<string, any>) { return options['execution_target'] === 'computer'; }
|
||||||
|
modelDraft(options: Record<string, any>): ModelDraft { return {profile:options['profile'] || 'flash', model:options['model'] || '', reasoning_effort:options['reasoning_effort'] || 'default', device_serial:this.computer(options) ? '' : options['device_serial'] || ''}; }
|
||||||
|
draft(item: Favorite): Draft {
|
||||||
|
let draft = this.drafts.get(item.id);
|
||||||
|
if (!draft) { draft = {...this.modelDraft(item.kind === 'batch' ? item.snapshot.cases?.[0]?.task_config || {} : this.options(item)), name:item.name, uniform:false, cases:(item.snapshot.cases || []).map(c => this.modelDraft(c.task_config))}; this.drafts.set(item.id,draft); }
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
toggle(item: Favorite) { this.expanded.set(this.expanded() === item.id ? null : item.id); }
|
||||||
|
private changes(current: ModelDraft, original: Record<string, any>) {
|
||||||
|
const baseline = this.modelDraft(original), result: Record<string, unknown> = {};
|
||||||
|
for (const key of ['profile','model','reasoning_effort','device_serial'] as const) {
|
||||||
|
if (current[key] !== baseline[key] && current[key]) result[key] = current[key];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
payload(item: Favorite): Record<string, unknown> {
|
||||||
|
const draft = this.drafts.get(item.id); if (!draft) return {};
|
||||||
|
if (item.kind === 'batch') {
|
||||||
|
const case_configs: Record<number, unknown> = {};
|
||||||
|
draft.cases.forEach((c,i) => { const changes = this.changes(c,item.snapshot.cases![i].task_config); if(Object.keys(changes).length)case_configs[i]=changes; });
|
||||||
|
return {case_configs, ...(draft.uniform ? {task_config:{model:draft.model,reasoning_effort:draft.reasoning_effort}} : {})};
|
||||||
|
}
|
||||||
|
return {task_config:this.changes(draft,this.options(item))};
|
||||||
|
}
|
||||||
|
private deviceMissing(item: Favorite) {
|
||||||
|
const devices = this.system.connectedDevices();
|
||||||
|
const cases = item.kind === 'batch' ? item.snapshot.cases!.map((c,i) => ({...c.task_config,...(this.payload(item)['case_configs'] as any)?.[i]})) : [{...this.options(item),...(this.payload(item)['task_config'] as any)}];
|
||||||
|
return cases.some(options => !this.computer(options) && (!options['device_serial'] || !devices.some(d => d.serial === options['device_serial'] && d.state === 'device')));
|
||||||
|
}
|
||||||
|
async save(item: Favorite) {
|
||||||
|
if(!this.validModels(item)){this.error.set('模型名称不能为空');return;}
|
||||||
|
if(this.busy())return;this.busy.set(item.id);this.error.set('');this.notice.set('');
|
||||||
|
try { const saved = await this.api.update(item.id,{...this.payload(item),name:this.draft(item).name});this.drafts.delete(item.id);this.draft(saved);this.notice.set('已保存收藏的默认配置'); }
|
||||||
|
catch(e){this.error.set((e as any)?.error?.detail || '保存失败,请重试');}finally{this.busy.set(null);}
|
||||||
|
}
|
||||||
|
async remove(item: Favorite) {
|
||||||
|
if(this.busy())return;this.busy.set(item.id);this.error.set('');
|
||||||
|
try{await this.api.remove(item.id);this.drafts.delete(item.id);}catch(e){this.error.set((e as any)?.error?.detail || '取消收藏失败');}finally{this.busy.set(null);}
|
||||||
|
}
|
||||||
|
async run(item: Favorite) {
|
||||||
|
if(this.busy())return;this.error.set('');this.notice.set('');
|
||||||
|
if(!this.validModels(item)){this.error.set('模型名称不能为空');return;}
|
||||||
|
if(this.deviceMissing(item)){this.expanded.set(item.id);this.error.set('原设备不在线,请在配置中选择可用设备后运行。');return;}
|
||||||
|
const draft=this.drafts.get(item.id);if(draft?.uniform && !draft.model.trim()){this.error.set('请填写统一使用的模型');return;}
|
||||||
|
this.busy.set(item.id);
|
||||||
|
try{
|
||||||
|
const result=await this.api.run(item.id,this.payload(item));
|
||||||
|
this.agent.fetchSessions();
|
||||||
|
if(result.batch_id){this.agent.batchFilter.set(result.batch_id);this.agent.activeTab.set('tasks');}
|
||||||
|
else{const id=result.session_id || result.admission?.run?.id;if(id)this.agent.selectSession(id,true);}
|
||||||
|
await this.router.navigate(['/workspace']);
|
||||||
|
}catch(e){this.error.set((e as any)?.error?.detail || '运行请求失败;重试将沿用相同请求编号,避免重复执行。');}
|
||||||
|
finally{this.busy.set(null);}
|
||||||
|
}
|
||||||
|
private validModels(item: Favorite) {
|
||||||
|
const draft=this.drafts.get(item.id);if(!draft)return true;
|
||||||
|
return item.kind === 'batch' ? draft.uniform ? !!draft.model.trim() : draft.cases.every(c => !!c.model.trim()) : !!draft.model.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-3
@@ -29,6 +29,7 @@ limitations under the License.
|
|||||||
[class.theater]="isTheaterMode()"
|
[class.theater]="isTheaterMode()"
|
||||||
[style.left.px]="!isTheaterMode() ? posX() : null"
|
[style.left.px]="!isTheaterMode() ? posX() : null"
|
||||||
[style.top.px]="!isTheaterMode() ? posY() : null"
|
[style.top.px]="!isTheaterMode() ? posY() : null"
|
||||||
|
[style.--player-top]="posY() + 'px'"
|
||||||
>
|
>
|
||||||
<!-- Window Header (Draggable Handle) -->
|
<!-- Window Header (Draggable Handle) -->
|
||||||
<header class="window-header" (mousedown)="startDrag($event)">
|
<header class="window-header" (mousedown)="startDrag($event)">
|
||||||
@@ -95,12 +96,31 @@ limitations under the License.
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
@if (sources().length && !agentService.isVideoMinimized()) {
|
||||||
|
<div class="recording-sources">
|
||||||
|
<label for="recording-source">录像来源</label>
|
||||||
|
<select id="recording-source" [ngModel]="agentService.selectedRecordingSource()" (ngModelChange)="selectSource($event)">
|
||||||
|
@for (source of sources(); track source.id) {
|
||||||
|
<option [value]="source.id">{{ source.label }}{{ source.status === 'failed' ? '(录制异常)' : '' }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
@if (agentService.recordingPlaybackMessage()) {
|
||||||
|
<div class="recording-notice" role="status">{{ agentService.recordingPlaybackMessage() }}</div>
|
||||||
|
}
|
||||||
|
@if (!agentService.activeVideoUrl()) {
|
||||||
|
<div class="recording-notice">此来源暂无可播放录像,请查看步骤截图或日志。</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (!agentService.isVideoMinimized() && agentService.playerMode() === 'video' && missingRecordingLabels().length) {
|
||||||
|
<div class="recording-notice" role="status">{{ missingRecordingLabels().join('、') }}步骤没有对应录像,可切换「步骤」查看已有截图。</div>
|
||||||
|
}
|
||||||
<!-- Video Main Content Area -->
|
<!-- Video Main Content Area -->
|
||||||
@if (!agentService.isVideoMinimized()) {
|
@if (!agentService.isVideoMinimized()) {
|
||||||
<div class="video-body">
|
<div class="video-body">
|
||||||
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
|
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
|
||||||
@if (agentService.recordingPlaybackStatus() === 'live') {
|
@if (agentService.recordingPlaybackStatus() === 'live') {
|
||||||
@if (!liveStreamError()) {
|
@if (!liveStreamError() && pageVisible()) {
|
||||||
<div class="live-stream-container">
|
<div class="live-stream-container">
|
||||||
<img
|
<img
|
||||||
class="live-screen-img"
|
class="live-screen-img"
|
||||||
@@ -332,8 +352,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 +373,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 +392,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 +430,7 @@ limitations under the License.
|
|||||||
</button>
|
</button>
|
||||||
<!-- Time Display -->
|
<!-- Time Display -->
|
||||||
<span class="time-display">
|
<span class="time-display">
|
||||||
{{ formatTime(currentTime()) }} / {{ formatTime(duration()) }}
|
<span [title]="agentService.usesSessionTimeline() ? '任务时间;蓝色区间有录像,空白区间未录制' : '录像时间'">{{ formatTime(currentTime()) }} / {{ formatTime(duration()) }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls-right">
|
<div class="controls-right">
|
||||||
@@ -425,6 +461,9 @@ limitations under the License.
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@if (skippedInterval()) {
|
||||||
|
<div class="recording-skip-notice" role="status">{{ skippedInterval() }}</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+87
@@ -31,6 +31,7 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
width: 400px;
|
width: 400px;
|
||||||
max-width: calc(100vw - 32px);
|
max-width: calc(100vw - 32px);
|
||||||
|
max-height: calc(100dvh - var(--player-top, 60px) - 12px);
|
||||||
background: rgba(255, 255, 255, 0.84);
|
background: rgba(255, 255, 255, 0.84);
|
||||||
backdrop-filter: blur(24px) saturate(180%);
|
backdrop-filter: blur(24px) saturate(180%);
|
||||||
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||||
@@ -298,6 +299,7 @@
|
|||||||
|
|
||||||
/* --- Video Body Area --- */
|
/* --- Video Body Area --- */
|
||||||
.video-body {
|
.video-body {
|
||||||
|
min-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -655,6 +657,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.video-container {
|
.video-container {
|
||||||
|
min-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -664,6 +667,7 @@
|
|||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
|
|
||||||
video {
|
video {
|
||||||
|
min-height: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 520px;
|
max-height: 520px;
|
||||||
@@ -1017,3 +1021,86 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.recording-sources {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
select {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-notice {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-gap {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: #475569;
|
||||||
|
background: #f1f5f9;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-coverage-summary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 12px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-skip-notice {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-recording-gaps {
|
||||||
|
.scrubber-progress-fill { opacity: 0; }
|
||||||
|
|
||||||
|
.recording-coverage {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
span {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #0284c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 3px;
|
||||||
|
height: 8px;
|
||||||
|
background: #0f172a;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+268
-4
@@ -1,6 +1,6 @@
|
|||||||
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 +20,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,14 +29,30 @@ 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),
|
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(() => {
|
||||||
@@ -58,6 +74,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 +89,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 +98,24 @@ describe('FloatingVideoPlayerComponent video markers', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it('removes the live image while hidden and reconnects it on return', () => {
|
||||||
|
const hidden = spyOnProperty(document, 'hidden', 'get').and.returnValue(false);
|
||||||
|
agentService.recordingPlaybackStatus.set('live');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('img.live-screen-img')).not.toBeNull();
|
||||||
|
|
||||||
|
hidden.and.returnValue(true);
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('img.live-screen-img')).toBeNull();
|
||||||
|
|
||||||
|
hidden.and.returnValue(false);
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('img.live-screen-img')).not.toBeNull();
|
||||||
|
expect(component.liveStreamError()).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
function playButton(): HTMLButtonElement {
|
function playButton(): HTMLButtonElement {
|
||||||
return fixture.nativeElement.querySelector('.play-btn');
|
return fixture.nativeElement.querySelector('.play-btn');
|
||||||
}
|
}
|
||||||
@@ -189,6 +225,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 +238,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;
|
||||||
|
|||||||
+177
-29
@@ -25,6 +25,7 @@ import {
|
|||||||
ViewChild,
|
ViewChild,
|
||||||
HostListener,
|
HostListener,
|
||||||
effect,
|
effect,
|
||||||
|
untracked,
|
||||||
OnDestroy
|
OnDestroy
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ 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({
|
||||||
@@ -61,8 +62,29 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
public isMuted = signal<boolean>(false);
|
public isMuted = signal<boolean>(false);
|
||||||
public isTheaterMode = signal<boolean>(false);
|
public isTheaterMode = signal<boolean>(false);
|
||||||
public videoLoadError = signal<boolean>(false);
|
public videoLoadError = signal<boolean>(false);
|
||||||
|
public gapMessage = signal('');
|
||||||
|
public skippedInterval = signal('');
|
||||||
|
private sourceSwitch: { time: number; play: boolean } | null = null;
|
||||||
|
private lastHandledVideoSeekRequestId: number | null = null;
|
||||||
|
private gapCanAdvance = true;
|
||||||
|
public sources = computed(() => this.agentService.recordingSources?.() || []);
|
||||||
public liveStreamUrl = signal<string>('/api/stream/device-live');
|
public liveStreamUrl = signal<string>('/api/stream/device-live');
|
||||||
public liveStreamError = signal<boolean>(false);
|
public liveStreamError = signal<boolean>(false);
|
||||||
|
public pageVisible = signal(!document.hidden);
|
||||||
|
|
||||||
|
@HostListener('document:visibilitychange')
|
||||||
|
@HostListener('window:pageshow')
|
||||||
|
onPageVisibilityChange(): void {
|
||||||
|
this.pageVisible.set(!document.hidden);
|
||||||
|
if (!document.hidden && this.agentService.recordingPlaybackStatus() === 'live') {
|
||||||
|
this.retryLiveStream();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('window:pagehide')
|
||||||
|
onPageHide(): void {
|
||||||
|
this.pageVisible.set(false);
|
||||||
|
}
|
||||||
public activeSegmentIndex = signal<number>(0);
|
public activeSegmentIndex = signal<number>(0);
|
||||||
public currentVideoUrl = computed(() => {
|
public currentVideoUrl = computed(() => {
|
||||||
const segments = this.agentService.activeVideoSegments();
|
const segments = this.agentService.activeVideoSegments();
|
||||||
@@ -71,13 +93,14 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
private pendingLocalTime: number | null = null;
|
private pendingLocalTime: number | null = null;
|
||||||
private pendingAutoplay = false;
|
private pendingAutoplay = false;
|
||||||
private pendingAbsoluteSeek: number | null = null;
|
private pendingAbsoluteSeek: number | null = null;
|
||||||
|
private waitingForMetadata = true;
|
||||||
private seekPlaybackIntent: boolean | null = null;
|
private 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 +109,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 +180,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 +213,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
if (curSessionId !== lastTrackedSessionId) {
|
if (curSessionId !== lastTrackedSessionId) {
|
||||||
lastTrackedSessionId = curSessionId;
|
lastTrackedSessionId = curSessionId;
|
||||||
this.activeStepIndex.set(0);
|
this.activeStepIndex.set(0);
|
||||||
this.viewMode.set('pre');
|
this.viewMode.set('post');
|
||||||
this.pauseStepPlay();
|
this.pauseStepPlay();
|
||||||
} else if (total > 0 && this.activeStepIndex() >= total) {
|
} else if (total > 0 && this.activeStepIndex() >= total) {
|
||||||
this.activeStepIndex.set(total - 1);
|
this.activeStepIndex.set(total - 1);
|
||||||
@@ -186,15 +242,42 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const request = this.agentService.videoSeekRequest();
|
const request = this.agentService.videoSeekRequest();
|
||||||
if (!request) return;
|
const status = this.agentService.recordingPlaybackStatus();
|
||||||
|
const sources = this.sources();
|
||||||
|
if (!request || status !== 'ready' || request.requestId === this.lastHandledVideoSeekRequestId) return;
|
||||||
|
// A seek is a command, not a binding to playback/source state. Reading
|
||||||
|
// those signals in this effect would replay the command after play/pause
|
||||||
|
// or a manual source selection.
|
||||||
|
untracked(() => {
|
||||||
|
this.lastHandledVideoSeekRequestId = request.requestId;
|
||||||
this.pendingAbsoluteSeek = request.seconds;
|
this.pendingAbsoluteSeek = request.seconds;
|
||||||
if (this.agentService.recordingPlaybackStatus() !== 'ready') return;
|
if (request.sourceId && sources.length) {
|
||||||
const video = this.videoRef?.nativeElement;
|
const source = sources.find(s => s.id === request.sourceId || s.target_ids.includes(request.sourceId!));
|
||||||
if (video && video.readyState >= 1) {
|
if (!source) {
|
||||||
const target = this.pendingAbsoluteSeek;
|
|
||||||
this.pendingAbsoluteSeek = null;
|
this.pendingAbsoluteSeek = null;
|
||||||
this.seekToSessionTime(target, true);
|
this.pendingLocalTime = null;
|
||||||
|
this.pendingAutoplay = false;
|
||||||
|
this.seekPlaybackIntent = null;
|
||||||
|
this.playRequestId++;
|
||||||
|
this.gapCanAdvance = false;
|
||||||
|
this.currentTime.set(request.seconds);
|
||||||
|
this.gapMessage.set('此步骤的来源没有录像,请查看步骤截图或日志。');
|
||||||
|
this.videoRef?.nativeElement.pause();
|
||||||
|
this.isPlaying.set(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (source.id !== this.agentService.selectedRecordingSource?.()) {
|
||||||
|
this.sourceSwitch = { time: request.seconds, play: this.isPlaying() };
|
||||||
|
this.agentService.selectRecordingSource(source.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const video = this.videoRef?.nativeElement;
|
||||||
|
if (video && video.readyState >= 1 && !this.waitingForMetadata) {
|
||||||
|
this.pendingAbsoluteSeek = null;
|
||||||
|
this.seekToSessionTime(request.seconds, this.isPlaying());
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -305,6 +388,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
public onLoadedMetadata(): void {
|
public onLoadedMetadata(): void {
|
||||||
const v = this.videoRef?.nativeElement;
|
const v = this.videoRef?.nativeElement;
|
||||||
if (v) {
|
if (v) {
|
||||||
|
this.waitingForMetadata = false;
|
||||||
const segments = this.agentService.activeVideoSegments();
|
const segments = this.agentService.activeVideoSegments();
|
||||||
if (!segments.length) {
|
if (!segments.length) {
|
||||||
this.duration.set(v.duration || 0);
|
this.duration.set(v.duration || 0);
|
||||||
@@ -313,14 +397,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 +404,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 +419,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 +461,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 +496,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 +561,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 +573,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 +595,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 +631,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 +640,22 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
public onScrub(event: Event): void {
|
public onScrub(event: Event): void {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const targetTime = parseFloat(input.value);
|
const targetTime = parseFloat(input.value);
|
||||||
|
const segments = this.agentService.activeVideoSegments();
|
||||||
|
if (this.agentService.usesSessionTimeline?.() && segments.length && !locateCoveredSessionTime(segments, targetTime)) {
|
||||||
|
const next = segments.find(segment => segment.start > targetTime);
|
||||||
|
if (next) {
|
||||||
|
this.seek(next.start);
|
||||||
|
this.showSkippedInterval(targetTime, next.start);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
this.seek(targetTime);
|
this.seek(targetTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private showSkippedInterval(from: number, to: number): void {
|
||||||
|
this.skippedInterval.set(`已跳过未录制区间 ${this.formatTime(from)}–${this.formatTime(to)}`);
|
||||||
|
}
|
||||||
|
|
||||||
public setSpeed(rate: number): void {
|
public setSpeed(rate: number): void {
|
||||||
this.playbackRate.set(rate);
|
this.playbackRate.set(rate);
|
||||||
const v = this.videoRef?.nativeElement;
|
const v = this.videoRef?.nativeElement;
|
||||||
|
|||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
@if (sessionId()) {
|
||||||
|
<section class="model-requests" aria-label="模型请求状态">
|
||||||
|
@for (review of activeReviews(); track review.review_id) {
|
||||||
|
<div class="request-live review-live" [class.stale]="unavailable()" aria-live="polite">
|
||||||
|
<div class="request-heading">
|
||||||
|
<span class="status-dot" [class.waiting]="!unavailable()"></span>
|
||||||
|
<strong>{{ unavailable() ? '验收状态暂时无法更新' : reviewStage(review) }}</strong>
|
||||||
|
<span class="elapsed">验收已用 {{ reviewElapsed(review) }} 秒</span>
|
||||||
|
</div>
|
||||||
|
@if (!unavailable()) {
|
||||||
|
<p>完整结论确认前,测试仍为待判定。剩余验收预算 {{ reviewRemaining(review) }} 秒 / 总计 {{ review.total_seconds }} 秒。</p>
|
||||||
|
<p class="notice">已发起 {{ review.request_count }} 次模型请求,已重试 {{ review.retry_count }} 次;重试复用已有证据。</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@for (request of active(); track request.attempt_id) {
|
||||||
|
<div class="request-live" [class.retrying]="request.phase === 'retry_wait'" [class.stale]="unavailable()">
|
||||||
|
<div class="request-heading" aria-live="polite">
|
||||||
|
<span class="status-dot" [class.waiting]="!unavailable()"></span>
|
||||||
|
<strong>{{ unavailable() ? '请求状态暂时无法更新' : phase(request) }}</strong>
|
||||||
|
<span class="model">{{ request.model || '模型未记录' }} · {{ role(request) }}{{ request.is_fallback ? ' · 兜底模型' : '' }}</span>
|
||||||
|
<span class="elapsed">已用 {{ elapsed(request) }} 秒</span>
|
||||||
|
</div>
|
||||||
|
@if (!unavailable()) {
|
||||||
|
<p>{{ hint(request, now()) }} @if (request.phase !== 'retry_wait') { 本次请求最长等待 {{ request.timeout_seconds }} 秒。 }</p>
|
||||||
|
}
|
||||||
|
@if (request.error) { <p class="error">{{ request.error }}</p> }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (unavailable()) { <p class="notice" role="status">诊断信息暂时不可用;显示的是上次记录,不能据此判断任务是否卡住。</p> }
|
||||||
|
<details>
|
||||||
|
<summary>模型请求诊断 @if (requests().length) { <span>· 最近 {{ requests().length }} 次尝试</span> }</summary>
|
||||||
|
<div class="diagnostics-header"><span>时间按本机时区显示;下载文件同时包含 UTC 时间。</span><a [href]="downloadUrl()" download>下载完整诊断</a></div>
|
||||||
|
@if (loading()) { <p class="notice">正在加载请求记录…</p> }
|
||||||
|
@if (!loading() && !unavailable() && !requests().length) { <p class="notice">暂无请求诊断。旧任务没有记录的时间和用量无法补推。</p> }
|
||||||
|
<div class="request-history">
|
||||||
|
@for (review of reviews(); track review.review_id) {
|
||||||
|
<details class="request-detail review-detail">
|
||||||
|
<summary>{{ review.started_at * 1000 | date:'HH:mm:ss' }} · 最终验收 · {{ reviewStage(review) }}</summary>
|
||||||
|
<dl>
|
||||||
|
<dt>验收编号</dt><dd>{{ review.review_id }} · {{ review.attempt_id }}</dd>
|
||||||
|
<dt>验收预算</dt><dd>总计 {{ review.total_seconds }} 秒 · 已用 {{ reviewElapsed(review) }} 秒</dd>
|
||||||
|
<dt>模型请求</dt><dd>{{ review.request_count }} 次,实际重试 {{ review.retry_count }} 次;每个请求最多重试 {{ review.max_retries }} 次,总预算内执行</dd>
|
||||||
|
<dt>开始时间</dt><dd>{{ review.started_at * 1000 | date:'yyyy-MM-dd HH:mm:ss.SSS Z' }}</dd>
|
||||||
|
<dt>预算截止</dt><dd>{{ review.deadline_at * 1000 | date:'yyyy-MM-dd HH:mm:ss.SSS Z' }}</dd>
|
||||||
|
<dt>结束时间</dt><dd>{{ review.ended_at ? (review.ended_at * 1000 | date:'HH:mm:ss.SSS') : '未记录' }}</dd>
|
||||||
|
@if (review.last_failure_category) { <dt>最近错误类别</dt><dd>{{ review.last_failure_category }}</dd> }
|
||||||
|
@if (review.status === 'budget_exhausted') { <dt>结束原因</dt><dd class="error">取证、模型请求与重试已耗尽验收总预算;未确认通过。</dd> }
|
||||||
|
@if (review.status === 'request_retries_exhausted') { <dt>结束原因</dt><dd class="error">验收模型请求重试已用尽;未确认通过,没有重跑设备操作。</dd> }
|
||||||
|
@if (review.status === 'cancelled') { <dt>结束原因</dt><dd>验收被停止或指导中断,不作为请求超时重试。</dd> }
|
||||||
|
</dl>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
@for (request of requests(); track request.attempt_id) {
|
||||||
|
<details class="request-detail">
|
||||||
|
<summary><span>{{ request.started_at * 1000 | date:'HH:mm:ss' }}</span> {{ request.model || '模型' }} · {{ role(request) }} · {{ phase(request) }} @if (!request.active && !request.ended_at) { <span>(未记录结束,当前未在执行)</span> } @if (request.is_fallback) { <span class="fallback">兜底</span> }</summary>
|
||||||
|
<dl>
|
||||||
|
<dt>本地请求编号</dt><dd>{{ request.request_id }}</dd>
|
||||||
|
<dt>本次尝试编号</dt><dd>{{ request.attempt_id }}</dd>
|
||||||
|
<dt>服务端请求编号</dt><dd>{{ request.server_request_id || '服务未提供' }}</dd>
|
||||||
|
<dt>模型配置</dt><dd>{{ request.model || '未记录' }} / {{ request.reasoning_effort || '默认' }} · 第 {{ request.attempt }} 次请求,最多重试 {{ request.max_retries }} 次</dd>
|
||||||
|
@if (request.final_review; as review) { <dt>所属最终验收</dt><dd>{{ review.review_id }} · {{ reviewStage(review) }} · 请求记录时剩余 {{ review.remaining_seconds }} 秒</dd> }
|
||||||
|
<dt>输出 Token 上限</dt><dd>{{ request.max_output_tokens || '未设置(由模型服务决定)' }}</dd>
|
||||||
|
<dt>有效输出片段</dt><dd>{{ request.output_chunks ?? '未记录' }}(客户端计数,与服务端 SSE 事件数口径不同)</dd>
|
||||||
|
<dt>工具参数</dt><dd>{{ request.tool_argument_chars ?? '未记录' }} 字符 @for (tool of request.tools || []; track $index) { <span class="image-size">{{ tool.name || '工具名未提供' }} · {{ tool.argument_chars }} 字符</span> }</dd>
|
||||||
|
<dt>请求开始</dt><dd>{{ request.started_at * 1000 | date:'yyyy-MM-dd HH:mm:ss.SSS Z' }}</dd>
|
||||||
|
<dt>收到响应头</dt><dd>{{ request.headers_at ? (request.headers_at * 1000 | date:'HH:mm:ss.SSS') : '未记录' }} · HTTP {{ request.http_status || '未知' }}</dd>
|
||||||
|
<dt>首个有效输出</dt><dd>{{ request.first_output_at ? (request.first_output_at * 1000 | date:'HH:mm:ss.SSS') : '未收到/未记录' }}</dd>
|
||||||
|
<dt>最后有效输出</dt><dd>{{ request.last_output_at ? (request.last_output_at * 1000 | date:'HH:mm:ss.SSS') : '未收到/未记录' }}</dd>
|
||||||
|
<dt>结束时间</dt><dd>{{ request.ended_at ? (request.ended_at * 1000 | date:'HH:mm:ss.SSS') : '尚未记录' }}</dd>
|
||||||
|
<dt>输入图片</dt><dd>{{ request.images.length }} 张 @for (image of request.images; track $index) { <span class="image-size">{{ image.width && image.height ? image.width + ' × ' + image.height : '尺寸未知' }}</span> }</dd>
|
||||||
|
<dt>Token 用量</dt><dd>{{ request.usage ? '输入 ' + request.usage.prompt_tokens + ' / 输出 ' + request.usage.completion_tokens + ' / 合计 ' + request.usage.total_tokens : '未知(服务未报告)' }}</dd>
|
||||||
|
@if (request.error) { <dt>失败原因</dt><dd class="error">{{ request.error_type }} · {{ request.error }} · 阶段 {{ phase(request.failure_phase || '') }} @if (request.category) { · {{ request.category }} }</dd> }
|
||||||
|
</dl>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
:host { display: block; }
|
||||||
|
.model-requests { margin: 10px 0 16px; color: #475569; font-size: 12px; }
|
||||||
|
.request-live { padding: 12px 14px; border: 1px solid #dbeafe; border-radius: 10px; background: #f8fbff; margin-bottom: 8px; }
|
||||||
|
.request-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||||
|
strong { color: #334155; font-size: 13px; }
|
||||||
|
.model { color: #64748b; overflow-wrap: anywhere; }
|
||||||
|
.elapsed { margin-left: auto; color: #1d4ed8; font-variant-numeric: tabular-nums; }
|
||||||
|
.status-dot { height: 7px; width: 7px; border-radius: 50%; background: #64748b; flex: 0 0 auto; }
|
||||||
|
.waiting { background: #3b82f6; animation: pulse 1.5s ease-in-out infinite; }
|
||||||
|
.retrying { border-color: #fde68a; background: #fffbeb; .elapsed { color: #92400e; } .waiting { background: #d97706; } }
|
||||||
|
.stale { border-color: #cbd5e1; background: #f8fafc; }
|
||||||
|
p { margin: 6px 0 0; line-height: 1.7; }
|
||||||
|
.error { color: #b45309; overflow-wrap: anywhere; }
|
||||||
|
summary { cursor: pointer; padding: 5px 0; line-height: 1.7; } summary:hover { color: #2563eb; }
|
||||||
|
summary:focus-visible, a:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
|
||||||
|
.diagnostics-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin: 8px 0; color: #64748b; }
|
||||||
|
a { color: #2563eb; text-decoration: none; border: 1px solid #dbeafe; border-radius: 6px; padding: 4px 9px; }
|
||||||
|
.request-history { max-height: 360px; overflow-y: auto; }
|
||||||
|
.request-detail { border-top: 1px solid #e2e8f0; padding: 4px 0; }
|
||||||
|
dl { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 7px 10px; margin: 8px 0 12px; line-height: 1.6; }
|
||||||
|
dt { color: #64748b; } dd { margin: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
||||||
|
.image-size, .fallback { display: inline-block; padding: 0 5px; margin: 2px 3px; background: #f1f5f9; border-radius: 4px; }
|
||||||
|
.notice { color: #64748b; }
|
||||||
|
@keyframes pulse { 50% { opacity: .35; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .waiting { animation: none; } }
|
||||||
|
@media (max-width: 480px) { dl { grid-template-columns: 90px minmax(0, 1fr); } .elapsed { margin-left: 15px; } }
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { FinalReview, LlmRequestStatusComponent, ModelRequest, requestHint, reviewStage } from './llm-request-status.component';
|
||||||
|
|
||||||
|
const waiting: ModelRequest = {
|
||||||
|
request_id: 'call-1', attempt_id: 'attempt-1', model: 'gpt-6-luna', role: 'FlashRunner',
|
||||||
|
is_fallback: false, phase: 'waiting_output', active: true, attempt: 1, max_retries: 5,
|
||||||
|
timeout_seconds: 300, elapsed_seconds: 42, started_at: Date.now() / 1000 - 42,
|
||||||
|
http_status: 200, usage: null, images: []
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('模型请求诊断', () => {
|
||||||
|
beforeEach(() => TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] }));
|
||||||
|
afterEach(() => TestBed.inject(HttpTestingController).verify());
|
||||||
|
|
||||||
|
it('收到200仍显示等待,未知用量不当作0;完成后取消轮询', fakeAsync(() => {
|
||||||
|
const fixture = TestBed.createComponent(LlmRequestStatusComponent);
|
||||||
|
fixture.componentRef.setInput('sessionId', 'task-one');
|
||||||
|
fixture.componentRef.setInput('running', true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const http = TestBed.inject(HttpTestingController);
|
||||||
|
http.expectOne('/api/sessions/task-one/request-diagnostics').flush({ requests: [waiting] });
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.request-live').textContent).toContain('等待模型输出');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('未知(服务未报告)');
|
||||||
|
expect(fixture.nativeElement.querySelector('details').open).toBeFalse();
|
||||||
|
tick(3000);
|
||||||
|
http.expectOne('/api/sessions/task-one/request-diagnostics').flush({ requests: [{ ...waiting, active: false, phase: 'completed', ended_at: Date.now() / 1000 }] });
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.request-live')).toBeNull();
|
||||||
|
fixture.destroy(); tick(5000);
|
||||||
|
http.expectNone('/api/sessions/task-one/request-diagnostics');
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('切换会话取消旧请求,历史失败不会显示正在重试', () => {
|
||||||
|
const fixture = TestBed.createComponent(LlmRequestStatusComponent);
|
||||||
|
const http = TestBed.inject(HttpTestingController);
|
||||||
|
fixture.componentRef.setInput('sessionId', 'old'); fixture.detectChanges();
|
||||||
|
const old = http.expectOne('/api/sessions/old/request-diagnostics');
|
||||||
|
fixture.componentRef.setInput('sessionId', 'new'); fixture.detectChanges();
|
||||||
|
expect(old.cancelled).toBeTrue();
|
||||||
|
http.expectOne('/api/sessions/new/request-diagnostics').flush({ requests: [{ ...waiting, phase: 'retry_wait' }] });
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.request-live')).toBeNull();
|
||||||
|
expect(fixture.nativeElement.querySelector('a').href).toContain('/new/request-diagnostics?download=true');
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('诊断接口失败时明确提示状态未知', () => {
|
||||||
|
const fixture = TestBed.createComponent(LlmRequestStatusComponent);
|
||||||
|
fixture.componentRef.setInput('sessionId', 'task'); fixture.detectChanges();
|
||||||
|
TestBed.inject(HttpTestingController).expectOne('/api/sessions/task/request-diagnostics').flush({}, { status: 503, statusText: 'Unavailable' });
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('不能据此判断任务是否卡住');
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('重试倒计时到0后不谎称已发送请求', () => {
|
||||||
|
expect(requestHint({ ...waiting, phase: 'retry_wait', retry_at: 110 }, 105)).toContain('5 秒后进行第 1/5 次重试');
|
||||||
|
expect(requestHint({ ...waiting, phase: 'retry_wait', retry_at: 110 }, 111)).toContain('等待下一次请求状态');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('持续生成工具参数时展示工具名和字符数,不误报工具已执行', () => {
|
||||||
|
const fixture = TestBed.createComponent(LlmRequestStatusComponent);
|
||||||
|
fixture.componentRef.setInput('sessionId', 'tool-task');
|
||||||
|
fixture.componentRef.setInput('running', true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
TestBed.inject(HttpTestingController).expectOne('/api/sessions/tool-task/request-diagnostics').flush({ requests: [{
|
||||||
|
...waiting, phase: 'generating_tool_arguments', tool_argument_chars: 15000,
|
||||||
|
tools: [{ name: 'click', argument_chars: 15000 }], output_chunks: 200
|
||||||
|
}] });
|
||||||
|
fixture.detectChanges();
|
||||||
|
const text = fixture.nativeElement.querySelector('.request-live').textContent;
|
||||||
|
expect(text).toContain('正在生成工具参数');
|
||||||
|
expect(text).toContain('click');
|
||||||
|
expect(text).toContain((15000).toLocaleString());
|
||||||
|
expect(text).toContain('本次工具尚未执行');
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无模型请求时仍展示验收阶段与总预算,耗尽后保留明确原因', fakeAsync(() => {
|
||||||
|
const fixture = TestBed.createComponent(LlmRequestStatusComponent);
|
||||||
|
const http = TestBed.inject(HttpTestingController);
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
const review: FinalReview = {
|
||||||
|
review_id: 'review-1', attempt_id: 'final#1', status: 'running', stage: 'reading_evidence', active: true,
|
||||||
|
started_at: now - 20, deadline_at: now + 80, total_seconds: 100, elapsed_seconds: 20, remaining_seconds: 80,
|
||||||
|
request_count: 2, retry_count: 1, max_retries: 5
|
||||||
|
};
|
||||||
|
fixture.componentRef.setInput('sessionId', 'review-task');
|
||||||
|
fixture.componentRef.setInput('running', true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
http.expectOne('/api/sessions/review-task/request-diagnostics').flush({ requests: [], final_reviews: [review] });
|
||||||
|
fixture.detectChanges();
|
||||||
|
const text = fixture.nativeElement.querySelector('.review-live').textContent;
|
||||||
|
expect(text).toContain('正在读取验收证据');
|
||||||
|
expect(text).toContain('测试仍为待判定');
|
||||||
|
expect(text).toContain('剩余验收预算 80 秒');
|
||||||
|
expect(text).toContain('已重试 1 次');
|
||||||
|
tick(1000); fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.review-live').textContent).toContain('剩余验收预算 79 秒');
|
||||||
|
tick(2000);
|
||||||
|
http.expectOne('/api/sessions/review-task/request-diagnostics').flush({ requests: [], final_reviews: [{
|
||||||
|
...review, active: false, status: 'budget_exhausted', ended_at: now + 3, elapsed_seconds: 100
|
||||||
|
}] });
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.review-live')).toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('验收总预算已用尽');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('未确认通过');
|
||||||
|
fixture.destroy(); tick(3000);
|
||||||
|
http.expectNone('/api/sessions/review-task/request-diagnostics');
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('区分重试耗尽、验收中断与完成,不把完成当成通过', () => {
|
||||||
|
const review = { status: 'request_retries_exhausted' } as FinalReview;
|
||||||
|
expect(reviewStage(review)).toBe('验收请求重试已用尽');
|
||||||
|
expect(reviewStage({ ...review, status: 'cancelled' })).toBe('验收已中断');
|
||||||
|
expect(reviewStage({ ...review, status: 'completed' })).toBe('验收完成');
|
||||||
|
});
|
||||||
|
});
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, output, signal } from '@angular/core';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
|
export interface ModelRequest {
|
||||||
|
request_id: string; attempt_id: string; model?: string; reasoning_effort?: string;
|
||||||
|
role: string; is_fallback: boolean; phase: string; active: boolean;
|
||||||
|
attempt: number; max_retries: number; timeout_seconds: number; elapsed_seconds: number;
|
||||||
|
started_at: number; headers_at?: number; first_output_at?: number; last_output_at?: number;
|
||||||
|
ended_at?: number; retry_at?: number; http_status?: number; server_request_id?: string;
|
||||||
|
error?: string; error_type?: string; failure_phase?: string;
|
||||||
|
output_chunks?: number; tool_argument_chars?: number; max_output_tokens?: number | null;
|
||||||
|
tools?: { name: string | null; argument_chars: number }[];
|
||||||
|
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | null;
|
||||||
|
images: { width: number | null; height: number | null }[];
|
||||||
|
final_review?: FinalReview;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinalReview {
|
||||||
|
review_id: string; attempt_id: string; status: string; stage: string; active: boolean;
|
||||||
|
started_at: number; deadline_at: number; ended_at?: number;
|
||||||
|
total_seconds: number; elapsed_seconds: number; remaining_seconds: number;
|
||||||
|
request_count: number; retry_count: number; request_attempt?: number; max_retries: number;
|
||||||
|
request_phase?: string; last_failure_category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reviewStage(review: FinalReview): string {
|
||||||
|
if (review.status !== 'running') {
|
||||||
|
return ({ completed: '验收完成', budget_exhausted: '验收总预算已用尽', request_retries_exhausted: '验收请求重试已用尽', cancelled: '验收已中断', failed: '验收未完成' } as Record<string, string>)[review.status] || '验收状态待核实';
|
||||||
|
}
|
||||||
|
return ({ preparing_evidence: '正在准备验收证据', reviewing_evidence: '正在核对证据与结论', reading_evidence: '正在读取验收证据', structuring_verdict: '正在整理验收结论', validating_verdict: '正在校验完整结论' } as Record<string, string>)[review.stage] || '正在进行最终验收';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestPhase(request: ModelRequest | string): string {
|
||||||
|
const phase = typeof request === 'string' ? request : request.phase;
|
||||||
|
return ({ waiting_response: '等待模型响应', waiting_output: '等待模型输出', receiving_output: '正在接收模型输出', generating_tool_arguments: '正在生成工具参数', retry_wait: '等待重试', completed: '请求完成', failed: '请求失败', cancelled: '请求已取消' } as Record<string, string>)[phase] || '请求状态待核实';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestHint(request: ModelRequest, now: number): string {
|
||||||
|
if (request.phase === 'retry_wait') {
|
||||||
|
const remaining = Math.max(0, Math.ceil((request.retry_at || now) - now));
|
||||||
|
return remaining ? `${remaining} 秒后进行第 ${request.attempt}/${request.max_retries} 次重试。` : '重试等待已结束,正在等待下一次请求状态。';
|
||||||
|
}
|
||||||
|
if (request.phase === 'waiting_output') return '已收到服务响应,尚未收到有效模型输出。';
|
||||||
|
if (request.phase === 'generating_tool_arguments') {
|
||||||
|
const names = (request.tools || []).map(tool => tool.name).filter(Boolean).join('、');
|
||||||
|
return `${names ? names + ' · ' : ''}已接收 ${(request.tool_argument_chars || 0).toLocaleString()} 个参数字符,最近输出在 ${Math.max(0, Math.floor(now - (request.last_output_at || now)))} 秒前;本次工具尚未执行。`;
|
||||||
|
}
|
||||||
|
if (request.phase === 'receiving_output') return `最近一次输出在 ${Math.max(0, Math.floor(now - (request.last_output_at || now)))} 秒前;完整响应仍在接收中。`;
|
||||||
|
return '正在等待服务响应;尚不能确认模型是否开始生成。';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-llm-request-status', standalone: true, imports: [DatePipe],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
templateUrl: './llm-request-status.component.html', styleUrl: './llm-request-status.component.scss'
|
||||||
|
})
|
||||||
|
export class LlmRequestStatusComponent {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
sessionId = input<string | null>(null);
|
||||||
|
running = input(false);
|
||||||
|
activeChange = output<boolean>();
|
||||||
|
requests = signal<ModelRequest[]>([]);
|
||||||
|
reviews = signal<FinalReview[]>([]);
|
||||||
|
now = signal(Date.now() / 1000);
|
||||||
|
unavailable = signal(false);
|
||||||
|
loading = signal(false);
|
||||||
|
active = computed(() => this.running() ? this.requests().filter(r => r.active) : []);
|
||||||
|
activeReviews = computed(() => this.running() ? this.reviews().filter(r => r.active) : []);
|
||||||
|
downloadUrl = computed(() => `/api/sessions/${encodeURIComponent(this.sessionId() || '')}/request-diagnostics?download=true`);
|
||||||
|
phase = requestPhase;
|
||||||
|
hint = requestHint;
|
||||||
|
reviewStage = reviewStage;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => this.activeChange.emit(this.active().length > 0 || this.activeReviews().length > 0));
|
||||||
|
effect(onCleanup => {
|
||||||
|
const id = this.sessionId();
|
||||||
|
const running = this.running();
|
||||||
|
this.requests.set([]);
|
||||||
|
this.reviews.set([]);
|
||||||
|
this.unavailable.set(false);
|
||||||
|
this.loading.set(!!id);
|
||||||
|
if (!id) return;
|
||||||
|
let stopped = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let subscription: Subscription | undefined;
|
||||||
|
const schedule = () => { if (running && !stopped) timer = setTimeout(load, 3000); };
|
||||||
|
const load = () => {
|
||||||
|
subscription = this.http.get<{ requests: ModelRequest[]; final_reviews?: FinalReview[] }>(`/api/sessions/${encodeURIComponent(id)}/request-diagnostics`).subscribe({
|
||||||
|
next: data => {
|
||||||
|
this.requests.set(data.requests || []);
|
||||||
|
this.reviews.set(data.final_reviews || []);
|
||||||
|
this.unavailable.set(false);
|
||||||
|
this.loading.set(false);
|
||||||
|
schedule();
|
||||||
|
},
|
||||||
|
error: () => { this.unavailable.set(true); this.loading.set(false); schedule(); }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const clock = running ? setInterval(() => this.now.set(Date.now() / 1000), 1000) : undefined;
|
||||||
|
onCleanup(() => { stopped = true; clearTimeout(timer); clearInterval(clock); subscription?.unsubscribe(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed(request: ModelRequest): number {
|
||||||
|
return Math.max(0, Math.floor(request.ended_at ? request.elapsed_seconds : this.now() - request.started_at));
|
||||||
|
}
|
||||||
|
|
||||||
|
reviewElapsed(review: FinalReview): number {
|
||||||
|
return Math.max(0, Math.floor(review.ended_at ? review.elapsed_seconds : this.now() - review.started_at));
|
||||||
|
}
|
||||||
|
|
||||||
|
reviewRemaining(review: FinalReview): number {
|
||||||
|
return Math.max(0, Math.ceil(review.deadline_at - this.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
role(request: ModelRequest): string {
|
||||||
|
return ({ FlashRunner: '任务执行', operator: '任务执行', planner: '规划', checker: '结果检查', outputter: '报告整理', step_summarizer: '步骤整理' } as Record<string, string>)[request.role] || request.role;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ModelSelectorComponent } from './model-selector.component';
|
||||||
|
|
||||||
|
describe('Shared model selector', () => {
|
||||||
|
it('preserves a saved custom or retired model without silently replacing it', () => {
|
||||||
|
const fixture = TestBed.createComponent(ModelSelectorComponent);
|
||||||
|
fixture.componentRef.setInput('value', 'mimo-v2.6-flash');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('select').value).toBe('__custom__');
|
||||||
|
expect(fixture.nativeElement.querySelector('input').value).toBe('mimo-v2.6-flash');
|
||||||
|
expect(Array.from(fixture.nativeElement.querySelectorAll('option'), (o: any) => o.value))
|
||||||
|
.not.toContain('mimo-v2.6-flash');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows editing custom names even when the typed name matches a preset', () => {
|
||||||
|
const fixture = TestBed.createComponent(ModelSelectorComponent);
|
||||||
|
fixture.componentRef.setInput('value', 'gpt-6-luna');
|
||||||
|
fixture.detectChanges();
|
||||||
|
const select: HTMLSelectElement = fixture.nativeElement.querySelector('select');
|
||||||
|
select.value = '__custom__'; select.dispatchEvent(new Event('change')); fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('input').value).toBe('gpt-6-luna');
|
||||||
|
fixture.componentRef.setInput('value', 'gpt-6.1-sol'); fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('input').value).toBe('gpt-6.1-sol');
|
||||||
|
fixture.componentRef.setInput('disabled', true); fixture.detectChanges();
|
||||||
|
expect(select.disabled).toBeTrue();
|
||||||
|
expect(fixture.nativeElement.querySelector('input').disabled).toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core';
|
||||||
|
import { MODEL_PRESETS } from '../../core/model-presets';
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-model-selector',
|
||||||
|
standalone: true,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<label [for]="id">{{label()}}</label>
|
||||||
|
<div class="selector">
|
||||||
|
<select [id]="id" [value]="selection()" [disabled]="disabled()"
|
||||||
|
(change)="choose($any($event.target).value)">
|
||||||
|
@for (preset of presets(); track preset) { <option [value]="preset">{{preset}}</option> }
|
||||||
|
<option value="__custom__">自定义模型…</option>
|
||||||
|
</select>
|
||||||
|
<span class="material-symbols-outlined" aria-hidden="true">expand_more</span>
|
||||||
|
</div>
|
||||||
|
@if (selection() === '__custom__') {
|
||||||
|
<input type="text" [value]="value()" (input)="valueChange.emit($any($event.target).value)"
|
||||||
|
[attr.aria-label]="label() + '(自定义)'" [disabled]="disabled()" required maxlength="200"
|
||||||
|
autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="输入接口支持的模型名称" />
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
:host { display: grid; gap: 7px; min-width: 0; color: #334155; font-size: 13px; }
|
||||||
|
label { font-weight: 600; line-height: 1.5; }
|
||||||
|
.selector { position: relative; min-width: 0; }
|
||||||
|
select, input { box-sizing: border-box; width: 100%; min-width: 0; min-height: 40px;
|
||||||
|
padding: 10px 12px; border: 1px solid #dbe3ec; border-radius: 10px; background: #fff;
|
||||||
|
color: inherit; font: inherit; }
|
||||||
|
select { appearance: none; padding-right: 36px; cursor: pointer; text-overflow: ellipsis; }
|
||||||
|
.selector > span { position: absolute; right: 11px; top: 50%; transform: translateY(-50%);
|
||||||
|
pointer-events: none; font-size: 19px; color: #64748b; }
|
||||||
|
select:hover:not(:disabled), input:hover:not(:disabled) { border-color: #a5b4fc; }
|
||||||
|
select:focus-visible, input:focus-visible { outline: 2px solid #a5b4fc; outline-offset: 2px; }
|
||||||
|
select:disabled, input:disabled { opacity: .5; cursor: default; }
|
||||||
|
option { background: #fff; color: #334155; }
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class ModelSelectorComponent {
|
||||||
|
readonly id = `model-selector-${nextId++}`;
|
||||||
|
label = input('模型');
|
||||||
|
value = input.required<string>();
|
||||||
|
presets = input<string[]>(MODEL_PRESETS);
|
||||||
|
disabled = input(false);
|
||||||
|
valueChange = output<string>();
|
||||||
|
private custom = signal(false);
|
||||||
|
selection = computed(() => this.custom() || !this.presets().includes(this.value()) ? '__custom__' : this.value());
|
||||||
|
|
||||||
|
choose(value: string): void {
|
||||||
|
this.custom.set(value === '__custom__');
|
||||||
|
if (value !== '__custom__') this.valueChange.emit(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-3
@@ -1,9 +1,11 @@
|
|||||||
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_auto_connect: boolean;
|
||||||
adb_discovery_interval_seconds: number;
|
adb_discovery_interval_seconds: number;
|
||||||
adb_discovery_timeout_seconds: number;
|
adb_discovery_timeout_seconds: number;
|
||||||
@@ -33,6 +35,20 @@ interface Capabilities {
|
|||||||
<span>视频分析<small>允许智能体分析任务画面。运行期间会临时录制,完成后不保留录像(除非开启录屏)。</small></span></label>
|
<span>视频分析<small>允许智能体分析任务画面。运行期间会临时录制,完成后不保留录像(除非开启录屏)。</small></span></label>
|
||||||
<label><input type="checkbox" role="switch" [(ngModel)]="config.recording_enabled" [disabled]="busy() || loading() || !loaded()" />
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.recording_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
<span>保存录屏<small>保留任务录像以便复盘,可单独开启,不要求启用视频分析。</small></span></label>
|
<span>保存录屏<small>保留任务录像以便复盘,可单独开启,不要求启用视频分析。</small></span></label>
|
||||||
|
@if (config.recording; as recording) {
|
||||||
|
<details class="execution-settings">
|
||||||
|
<summary>录像留档设置</summary>
|
||||||
|
<p>默认低码率留档,不影响模型观察截图。仅录实际使用的来源;屏幕模式包含该屏幕其他可见内容。</p>
|
||||||
|
<div class="number-fields">
|
||||||
|
<label>编码<select [(ngModel)]="recording.codec" [disabled]="busy() || !loaded()"><option value="auto">自动选择兼容编码</option><option value="h265">优先 H.265</option><option value="h264">H.264</option></select></label>
|
||||||
|
<label>电脑录制范围<select [(ngModel)]="recording.computer_scope" [disabled]="busy() || !loaded()"><option value="screen">当前控制的屏幕</option><option value="window">仅目标窗口</option></select></label>
|
||||||
|
<label>最长边(像素)<input type="number" min="320" max="4096" [(ngModel)]="recording.max_long_edge" [disabled]="busy() || !loaded()" /></label>
|
||||||
|
<label>最高帧率<input type="number" min="5" max="60" [(ngModel)]="recording.fps" [disabled]="busy() || !loaded()" /></label>
|
||||||
|
<label>H.265 目标码率(kbps)<input type="number" min="128" max="10000" [(ngModel)]="recording.h265_kbps" [disabled]="busy() || !loaded()" /></label>
|
||||||
|
<label>H.264 目标码率(kbps)<input type="number" min="128" max="20000" [(ngModel)]="recording.h264_kbps" [disabled]="busy() || !loaded()" /></label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
<label><input type="checkbox" role="switch" [(ngModel)]="config.outputter_enabled" [disabled]="busy() || loading() || !loaded()" />
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.outputter_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
|
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
|
||||||
<label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" />
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
@@ -68,7 +84,13 @@ interface Capabilities {
|
|||||||
summary { cursor: pointer; font-weight: 600; color: #334155; }
|
summary { cursor: pointer; font-weight: 600; color: #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: #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 #cbd5e1; border-radius: 8px; font: inherit; }
|
||||||
|
section {font-size:13px;border-color:#e2e8f0}h3 {font-size:15px;color:#334155}
|
||||||
|
section > label {align-items:center;flex-direction:row-reverse;justify-content:space-between;border-bottom:1px solid #f1f5f9;gap:20px}section > label > span {flex:1}small {font-size:12px}
|
||||||
|
input[role=switch] {appearance:none;position:relative;width:36px;height:22px;border:1px solid #cbd5e1;border-radius:20px;background:#e2e8f0;cursor:pointer;transition:background .15s}
|
||||||
|
input[role=switch]::after {content:'';position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 3px #0f172a25;transition:transform .15s}
|
||||||
|
input[role=switch]:checked {background:#6366f1;border-color:#6366f1}input[role=switch]:checked::after {transform:translateX(14px)}input[role=switch]:focus-visible {outline:2px solid #a5b4fc;outline-offset:3px}input:disabled {opacity:.5;cursor:default}
|
||||||
|
button {background:#4f46e5;border-color:#4f46e5;border-radius:12px;font-weight:600}.execution-settings {border-color:#e2e8f0;border-radius:14px;padding:14px 16px}.number-fields input,.number-fields select {background:#fff;border-color:#dbe3ec}
|
||||||
@media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }`]
|
@media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }`]
|
||||||
})
|
})
|
||||||
export class CapabilitySettingsComponent implements OnInit {
|
export class CapabilitySettingsComponent implements OnInit {
|
||||||
@@ -77,12 +99,15 @@ export class CapabilitySettingsComponent implements OnInit {
|
|||||||
config: Capabilities = { adb_auto_connect: true, adb_discovery_interval_seconds: 10, adb_discovery_timeout_seconds: 5, video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
|
config: Capabilities = { adb_auto_connect: true, adb_discovery_interval_seconds: 10, adb_discovery_timeout_seconds: 5, video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
|
||||||
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
|
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.http.get<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
|
this.http.get<Capabilities>('/api/system/capability-settings').pipe(timeout(UI_READ_TIMEOUT_MS), finalize(() => this.loading.set(false))).subscribe({
|
||||||
next: config => { this.config = config; this.loaded.set(true); this.changed.emit(config); }, error: () => this.error.set('功能配置加载失败,请刷新页面重试。')
|
next: config => { this.config = config; this.loaded.set(true); this.changed.emit(config); }, error: () => this.error.set('功能配置加载失败,请刷新页面重试。')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
save(): void {
|
save(): void {
|
||||||
if (this.busy() || this.loading() || !this.loaded() || !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.config = config; this.changed.emit(config); this.message.set('功能设置已保存,新任务将使用此配置。'); },
|
||||||
@@ -91,6 +116,12 @@ export class CapabilitySettingsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
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
|
||||||
|
|||||||
+38
-30
@@ -1,4 +1,5 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||||
|
import { UI_READ_TIMEOUT_MS } from '../../core/http-timeouts';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { ModelSettingsComponent } from './model-settings.component';
|
import { ModelSettingsComponent } from './model-settings.component';
|
||||||
@@ -6,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-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('成功');
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -113,18 +93,20 @@ interface ModelSettings {
|
|||||||
.actions { display: flex; gap: 12px; margin-top: 8px; }
|
.actions { display: flex; gap: 12px; margin-top: 8px; }
|
||||||
button { padding: 11px 20px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; cursor: pointer; font: inherit; }
|
button { padding: 11px 20px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; cursor: pointer; font: inherit; }
|
||||||
button:disabled { opacity: .5; cursor: default; } .success { color: #047857; } .error { color: #be123c; }
|
button:disabled { opacity: .5; cursor: default; } .success { color: #047857; } .error { color: #be123c; }
|
||||||
.model-presets { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
||||||
.model-presets button { padding: 8px 12px; background: #f8fafc; color: #475569; border-color: #cbd5e1; font-size: 14px; }
|
|
||||||
.model-presets button:hover:not(:disabled) { background: #e0f2fe; border-color: #38bdf8; }
|
|
||||||
.model-presets button[aria-pressed="true"] { background: #e0f2fe; border-color: #0284c7; color: #075985; }
|
|
||||||
button:focus-visible { outline: 2px solid #0284c7; outline-offset: 2px; }
|
button:focus-visible { outline: 2px solid #0284c7; outline-offset: 2px; }
|
||||||
small { overflow-wrap: anywhere; } @media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }
|
small { overflow-wrap: anywhere; } @media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }
|
||||||
|
.model-settings {gap:12px;font-size:13px} label {margin-top:6px}input,select {background:#fff;border-color:#dbe3ec;padding:11px 12px}
|
||||||
|
input:focus,select:focus {outline:2px solid #a5b4fc;outline-offset:2px}
|
||||||
|
.actions {border-top:1px solid #e2e8f0;padding-top:16px;align-items:center}.actions button {min-height:40px;background:#4f46e5;border-color:#4f46e5;font-weight:600;border-radius:12px}.actions button[type=button] {background:#fff;border-color:#dbe3ec;color:#475569}
|
||||||
|
.recovery-settings {border-color:#e2e8f0;border-radius:14px;padding:14px 16px}.recovery-settings summary {font-size:13px}
|
||||||
`]
|
`]
|
||||||
})
|
})
|
||||||
export class ModelSettingsComponent implements OnInit {
|
export class ModelSettingsComponent implements OnInit {
|
||||||
|
readonly reasoningValues = REASONING_VALUES;
|
||||||
|
readonly reasoningLabels = REASONING_LABELS;
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
saved = output<void>();
|
saved = output<void>();
|
||||||
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('配置加载失败,请刷新页面重试。')
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ import { version } from '../../../../package.json';
|
|||||||
<span class="material-symbols-outlined tab-icon">space_dashboard</span>
|
<span class="material-symbols-outlined tab-icon">space_dashboard</span>
|
||||||
<span class="tab-label">工作区</span>
|
<span class="tab-label">工作区</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a routerLink="/plugins" routerLinkActive="active" class="nav-tab-btn" title="管理扩展插件">
|
||||||
|
<span class="material-symbols-outlined tab-icon">extension</span><span class="tab-label">插件</span>
|
||||||
|
</a>
|
||||||
<span class="app-version" [title]="'ARTEMIS 版本 ' + version" [attr.aria-label]="'ARTEMIS 版本 ' + version">v{{version}}</span>
|
<span class="app-version" [title]="'ARTEMIS 版本 ' + version" [attr.aria-label]="'ARTEMIS 版本 ' + version">v{{version}}</span>
|
||||||
</nav>
|
</nav>
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ReportMarkdownComponent } from './report-markdown.component';
|
||||||
|
|
||||||
|
describe('报告 Markdown 展示', () => {
|
||||||
|
it('renders report headings, emphasis, lists, tables and code in both preview and full view', () => {
|
||||||
|
const fixture = TestBed.createComponent(ReportMarkdownComponent);
|
||||||
|
fixture.componentRef.setInput('content', '### 双端布局报告\n- **手机**:`/zh`\n\n| 区域 | 结论 |\n| --- | --- |\n| 页底 | 已确认 |\n\n```text\n<原始证据>\n```');
|
||||||
|
fixture.componentRef.setInput('compact', true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const view: HTMLElement = fixture.nativeElement;
|
||||||
|
expect(view.querySelector('h3')?.textContent).toBe('双端布局报告');
|
||||||
|
expect(view.querySelector('li strong')?.textContent).toBe('手机');
|
||||||
|
expect(view.querySelector('li code')?.textContent).toBe('/zh');
|
||||||
|
expect(view.querySelector('td')?.textContent).toBe('页底');
|
||||||
|
expect(view.querySelector('pre code')?.textContent).toBe('<原始证据>');
|
||||||
|
expect(view.querySelector('.compact')).not.toBeNull();
|
||||||
|
fixture.componentRef.setInput('compact', false);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(view.querySelector('.compact')).toBeNull();
|
||||||
|
expect(view.querySelectorAll('td').length).toBe(2);
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps HTML in model output inert and updates when the selected report changes', () => {
|
||||||
|
const fixture = TestBed.createComponent(ReportMarkdownComponent);
|
||||||
|
fixture.componentRef.setInput('content', '<img src=x onerror=alert(1)>\n<script>alert(1)</script>');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('img,script')).toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('<img');
|
||||||
|
fixture.componentRef.setInput('content', '## 新报告\n**待确认**');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('h2').textContent).toBe('新报告');
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('alert');
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Component, computed, input } from '@angular/core';
|
||||||
|
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
|
||||||
|
|
||||||
|
/** Shared, sanitized presentation for report prose (not the editable task plan). */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-report-markdown',
|
||||||
|
standalone: true,
|
||||||
|
template: `<div class="report-prose" [class.compact]="compact()" [innerHTML]="html()"></div>`,
|
||||||
|
styles: [`
|
||||||
|
:host { display: block; min-width: 0; max-width: 100%; }
|
||||||
|
.report-prose { color: inherit; font-size: 13px; font-weight: 400; line-height: 1.7; white-space: normal; overflow-wrap: anywhere; word-break: normal; }
|
||||||
|
.compact { max-height: 170px; overflow: hidden; }
|
||||||
|
:host ::ng-deep .report-prose {
|
||||||
|
> div, p { margin: 4px 0; }
|
||||||
|
> :first-child { margin-top: 0; }
|
||||||
|
> :last-child { margin-bottom: 0; }
|
||||||
|
h1,h2,h3,h4,h5,h6 { margin: 16px 0 6px; font-weight: 600; line-height: 1.5; color: #0f172a; }
|
||||||
|
h1 { font-size: 18px; } h2 { font-size: 16px; } h3,h4,h5,h6 { font-size: 14px; }
|
||||||
|
strong { font-weight: 600; }
|
||||||
|
ul,ol { margin: 6px 0; padding-left: 24px; }
|
||||||
|
li { margin: 4px 0; }
|
||||||
|
blockquote { margin: 10px 0; padding: 6px 12px; border-left: 3px solid #93c5fd; background: #f8fafc; }
|
||||||
|
code { font-family: Consolas, monospace; font-size: .92em; border-radius: 4px; padding: 1px 4px; background: #f1f5f9; }
|
||||||
|
pre { max-width: 100%; overflow: auto; padding: 10px 12px; border-radius: 6px; background: #f1f5f9; white-space: pre; }
|
||||||
|
pre code { padding: 0; background: none; }
|
||||||
|
.md-table-scroll { max-width: 100%; overflow-x: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
th,td { border: 1px solid #cbd5e1; padding: 7px 9px; text-align: left; vertical-align: top; }
|
||||||
|
th { background: #eff6ff; font-weight: 600; }
|
||||||
|
.md-para-gap { height: 4px; margin: 0; }
|
||||||
|
hr { border: 0; border-top: 1px solid #e2e8f0; margin: 12px 0; }
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class ReportMarkdownComponent {
|
||||||
|
content = input('');
|
||||||
|
compact = input(false);
|
||||||
|
html = computed(() => renderMarkdownToHtml(this.content()));
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from './setting-slider.component';
|
||||||
|
|
||||||
|
describe('Setting slider', () => {
|
||||||
|
it('maps the selected position to the exact request value and exposes its readable label', () => {
|
||||||
|
const f=TestBed.createComponent(SettingSliderComponent);
|
||||||
|
f.componentRef.setInput('label','思考强度');f.componentRef.setInput('values',REASONING_VALUES);f.componentRef.setInput('labels',REASONING_LABELS);f.componentRef.setInput('value','high');f.detectChanges();
|
||||||
|
const slider=f.nativeElement.querySelector('input');expect(slider.getAttribute('aria-valuetext')).toBe('高 · high');
|
||||||
|
const emit=spyOn(f.componentInstance.valueChange,'emit');slider.value='7';slider.dispatchEvent(new Event('input'));
|
||||||
|
expect(emit).toHaveBeenCalledWith('max');f.destroy();
|
||||||
|
});
|
||||||
|
it('preserves a saved custom time until the slider is actually changed', () => {
|
||||||
|
const f=TestBed.createComponent(SettingSliderComponent);const emit=spyOn(f.componentInstance.valueChange,'emit');
|
||||||
|
f.componentRef.setInput('label','执行时限');f.componentRef.setInput('values',[60,300,1800]);f.componentRef.setInput('value',125);f.detectChanges();
|
||||||
|
expect(f.nativeElement.querySelector('output').textContent).toBe('125 秒');expect(emit).not.toHaveBeenCalled();f.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-setting-slider', standalone: true,
|
||||||
|
template: `
|
||||||
|
<div class="slider-heading"><span>{{label()}}</span><output>{{display()}}</output></div>
|
||||||
|
<input type="range" min="0" [max]="values().length - 1" step="1" [value]="index()"
|
||||||
|
[disabled]="disabled()" [attr.aria-label]="label()" [attr.aria-valuetext]="display()"
|
||||||
|
[style.--fill]="(index() / (values().length - 1) * 100) + '%'"
|
||||||
|
(input)="change($any($event.target).value)" />
|
||||||
|
<div class="slider-ends"><span>{{labels()[0] || values()[0]}}</span><span>{{labels().at(-1) || values().at(-1)}}</span></div>
|
||||||
|
@if(hint()){<p>{{hint()}}</p>}
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
:host{display:block;min-width:0;padding:14px 16px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:14px;font-size:13px;color:#334155}
|
||||||
|
.slider-heading,.slider-ends{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
||||||
|
.slider-heading{font-weight:600}output{color:#4f46e5;font-size:12px;white-space:nowrap}.slider-ends{font-size:11px;color:#64748b}
|
||||||
|
input{appearance:none;display:block;box-sizing:border-box;width:100%;height:6px;border:0;border-radius:10px;margin:20px 0 12px;background:linear-gradient(to right,#6366f1 var(--fill),#dbe3ee var(--fill));cursor:pointer}
|
||||||
|
input::-webkit-slider-thumb{appearance:none;width:18px;height:18px;border-radius:50%;background:#fff;border:2px solid #6366f1;box-shadow:0 2px 6px #33415526}
|
||||||
|
input::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#fff;border:2px solid #6366f1}
|
||||||
|
input:focus-visible{outline:2px solid #a5b4fc;outline-offset:5px}input:disabled{opacity:.5;cursor:default}p{margin:10px 0 0;font-size:12px;line-height:1.6;color:#64748b}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class SettingSliderComponent {
|
||||||
|
label = input.required<string>();
|
||||||
|
value = input.required<string | number>();
|
||||||
|
values = input.required<(string | number)[]>();
|
||||||
|
labels = input<string[]>([]);
|
||||||
|
hint = input('');
|
||||||
|
disabled = input(false);
|
||||||
|
valueChange = output<any>();
|
||||||
|
index = computed(() => {
|
||||||
|
const exact = this.values().indexOf(this.value());
|
||||||
|
if(exact >= 0) return exact;
|
||||||
|
if(typeof this.value() === 'number') return this.values().reduce<number>((best, v, i) => Math.abs(Number(v)-Number(this.value())) < Math.abs(Number(this.values()[best])-Number(this.value())) ? i : best, 0);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
display = computed(() => {
|
||||||
|
const exact = this.values().indexOf(this.value());
|
||||||
|
return exact >= 0 ? this.labels()[exact] || String(this.value()) : String(this.value()) + (typeof this.value() === 'number' ? ' 秒' : '');
|
||||||
|
});
|
||||||
|
change(index: string) { this.valueChange.emit(this.values()[Number(index)]); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REASONING_VALUES = ['default','none','minimal','low','medium','high','xhigh','max'];
|
||||||
|
export const REASONING_LABELS = ['模型默认','关闭 · none','最低 · minimal','低 · low','中 · medium','高 · high','极高 · xhigh','最高 · max'];
|
||||||
+2
-2
@@ -14,8 +14,8 @@ describe('队列批次筛选', () => {
|
|||||||
http.expectOne('/api/automation/batches').flush([{id:'batch-1',name:'第一轮冒烟',created_at:100},{id:'batch-2',name:'第二轮冒烟',created_at:200}]);
|
http.expectOne('/api/automation/batches').flush([{id:'batch-1',name:'第一轮冒烟',created_at:100},{id:'batch-2',name:'第二轮冒烟',created_at:200}]);
|
||||||
fixture.detectChanges(); tick(); fixture.detectChanges();
|
fixture.detectChanges(); tick(); fixture.detectChanges();
|
||||||
const select: HTMLSelectElement = fixture.nativeElement.querySelector('select');
|
const select: HTMLSelectElement = fixture.nativeElement.querySelector('select');
|
||||||
expect(select.value).toBe('batch-1'); expect(select.options.length).toBe(5);
|
expect(select.value).toBe('batch-1'); expect(select.options.length).toBe(6);
|
||||||
for(const value of ['__batch__','__single__','batch-2']){
|
for(const value of ['__batch__','__single__','__exploration__','batch-2']){
|
||||||
select.value=value; select.dispatchEvent(new Event('change')); tick(); fixture.detectChanges();
|
select.value=value; select.dispatchEvent(new Event('change')); tick(); fixture.detectChanges();
|
||||||
expect(agent.batchFilter()).toBe(value);
|
expect(agent.batchFilter()).toBe(value);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -7,9 +7,9 @@ import { AgentService } from '../../services/agent.service';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-task-batch-filter', standalone: true, imports: [FormsModule, DatePipe],
|
selector: 'app-task-batch-filter', standalone: true, imports: [FormsModule, DatePipe],
|
||||||
template: `<div class="batch-filter"><label>任务范围<select aria-label="按批次筛选任务" [ngModel]="agent.batchFilter()" (ngModelChange)="agent.batchFilter.set($event)">
|
template: `<div class="batch-filter"><label>任务范围<select aria-label="按批次筛选任务" [ngModel]="agent.batchFilter()" (ngModelChange)="agent.batchFilter.set($event)">
|
||||||
<option value="">全部任务</option><option value="__batch__">全部批量任务</option><option value="__single__">单独任务</option>
|
<option value="">全部任务</option><option value="__batch__">全部批量任务</option><option value="__single__">单独任务</option><option value="__exploration__">探索任务</option>
|
||||||
@for(batch of batches(); track batch.id){<option [value]="batch.id">{{batch.name}} · {{batch.created_at * 1000 | date:'MM-dd HH:mm'}}</option>}
|
@for(batch of batches(); track batch.id){<option [value]="batch.id">{{batch.name}} · {{batch.created_at * 1000 | date:'MM-dd HH:mm'}}</option>}
|
||||||
@if(agent.batchFilter() && !['__batch__','__single__'].includes(agent.batchFilter()) && !hasSelectedBatch()){<option [value]="agent.batchFilter()">当前选定批次</option>}
|
@if(agent.batchFilter() && !['__batch__','__single__','__exploration__'].includes(agent.batchFilter()) && !hasSelectedBatch()){<option [value]="agent.batchFilter()">当前选定批次</option>}
|
||||||
</select></label>@if(agent.batchFilter()){<button type="button" (click)="agent.batchFilter.set('')">清除筛选</button>}</div>
|
</select></label>@if(agent.batchFilter()){<button type="button" (click)="agent.batchFilter.set('')">清除筛选</button>}</div>
|
||||||
@if(error()){<div class="filter-error" role="alert">{{error()}} <button type="button" (click)="load()">重试</button></div>}`,
|
@if(error()){<div class="filter-error" role="alert">{{error()}} <button type="button" (click)="load()">重试</button></div>}`,
|
||||||
styles: [`:host{display:block;margin:0 0 12px}.batch-filter{display:flex;align-items:flex-end;gap:8px;color:#475569;font-size:12px}label{flex:1;min-width:0}select{display:block;box-sizing:border-box;width:100%;height:34px;margin-top:5px;border:1px solid #cbd5e1;border-radius:7px;padding:0 9px;background:#fff;color:#1e293b;font:inherit}option{background:#fff;color:#1e293b}button{height:34px;white-space:nowrap;border:1px solid #cbd5e1;border-radius:7px;padding:0 9px;background:#fff;color:#475569;font:inherit;cursor:pointer}select:focus-visible,button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}.filter-error{font-size:12px;color:#b91c1c;margin-top:5px}.filter-error button{height:auto;padding:2px 6px}`]
|
styles: [`:host{display:block;margin:0 0 12px}.batch-filter{display:flex;align-items:flex-end;gap:8px;color:#475569;font-size:12px}label{flex:1;min-width:0}select{display:block;box-sizing:border-box;width:100%;height:34px;margin-top:5px;border:1px solid #cbd5e1;border-radius:7px;padding:0 9px;background:#fff;color:#1e293b;font:inherit}option{background:#fff;color:#1e293b}button{height:34px;white-space:nowrap;border:1px solid #cbd5e1;border-radius:7px;padding:0 9px;background:#fff;color:#475569;font:inherit;cursor:pointer}select:focus-visible,button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}.filter-error{font-size:12px;color:#b91c1c;margin-top:5px}.filter-error button{height:auto;padding:2px 6px}`]
|
||||||
|
|||||||
@@ -1,20 +1,75 @@
|
|||||||
import { signal } from '@angular/core';
|
import { signal } from '@angular/core';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
import { Clipboard } from '@angular/cdk/clipboard';
|
import { Clipboard } from '@angular/cdk/clipboard';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { AgentService } from '../../services/agent.service';
|
import { AgentService } from '../../services/agent.service';
|
||||||
import { TaskHistoryComponent } from './task-history.component';
|
import { TaskHistoryComponent } from './task-history.component';
|
||||||
|
import { FavoritesService } from '../../services/favorites.service';
|
||||||
|
|
||||||
describe('历史任务检索', () => {
|
describe('历史任务检索', () => {
|
||||||
let http: HttpTestingController;
|
let http: HttpTestingController;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting(),
|
TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting(),
|
||||||
{provide: AgentService, useValue: {currentSessionId: signal(null), batchFilter: signal(''), resultRevision: signal(0), selectSession: jasmine.createSpy()}}]});
|
{provide: AgentService, useValue: {currentSessionId: signal(null), batchFilter: signal(''), resultRevision: signal(0), selectSession: jasmine.createSpy(), deleteSession: jasmine.createSpy().and.returnValue(of({})), canDeleteSession: AgentService.prototype.canDeleteSession, deletionPrompt: (_id: string, kind: string) => kind === 'exploration' ? '删除探索及全部探索记录,复跑与批次保留' : '删除历史任务'}}]});
|
||||||
http = TestBed.inject(HttpTestingController);
|
http = TestBed.inject(HttpTestingController);
|
||||||
|
TestBed.inject(FavoritesService).loaded.set(true);
|
||||||
});
|
});
|
||||||
afterEach(() => http.verify());
|
afterEach(() => http.verify());
|
||||||
|
|
||||||
|
it('已终止探索可删除,确认范围后刷新,取消不发请求', () => {
|
||||||
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
|
const row = {session_id:'stopped', status:'cancelled', exploration_kind:'exploration', exploration_status:'paused', start_time:100};
|
||||||
|
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items:[row]});
|
||||||
|
fixture.detectChanges();
|
||||||
|
const button = fixture.nativeElement.querySelector('.delete-history');
|
||||||
|
expect(button).not.toBeNull();
|
||||||
|
const confirm = spyOn(window, 'confirm').and.returnValue(false);
|
||||||
|
button.click();
|
||||||
|
expect(TestBed.inject(AgentService).deleteSession).not.toHaveBeenCalled();
|
||||||
|
expect(confirm.calls.mostRecent().args[0]).toContain('复跑与批次保留');
|
||||||
|
confirm.and.returnValue(true);
|
||||||
|
button.click();
|
||||||
|
expect(TestBed.inject(AgentService).deleteSession).toHaveBeenCalledOnceWith('stopped');
|
||||||
|
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.delete-history')).toBeNull();
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('服务端拒绝删除时保留记录并显示具体原因', () => {
|
||||||
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
|
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items:[
|
||||||
|
{session_id:'stopped',status:'cancelled',exploration_kind:'exploration',start_time:100}
|
||||||
|
]});
|
||||||
|
spyOn(window, 'confirm').and.returnValue(true);
|
||||||
|
(TestBed.inject(AgentService).deleteSession as jasmine.Spy).and.returnValue(throwError(() => ({error:{detail:'探索正在分析,请稍后删除'}})));
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.nativeElement.querySelector('.delete-history').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('探索正在分析');
|
||||||
|
expect(fixture.nativeElement.querySelector('.delete-history')).not.toBeNull();
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('完成任务分别显示探索、Flash、Pro,模式不受通过或失败颜色影响', () => {
|
||||||
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
|
http.expectOne(r => r.url === '/api/automation/history').flush({total:3,items:[
|
||||||
|
{session_id:'e',status:'completed',start_time:100,profile:'pro',exploration_kind:'exploration'},
|
||||||
|
{session_id:'f',status:'failed',start_time:100,profile:'flash',exploration_kind:'validation',case_version:2},
|
||||||
|
{session_id:'p',status:'completed',start_time:100,profile:'pro'}
|
||||||
|
]});
|
||||||
|
fixture.detectChanges();
|
||||||
|
const badges = fixture.nativeElement.querySelectorAll('.mode-badge');
|
||||||
|
expect(badges.length).toBe(3);
|
||||||
|
expect(badges[0].textContent).toContain('探索');
|
||||||
|
expect(badges[1].textContent).toContain('Flash');
|
||||||
|
expect(badges[2].textContent).toContain('Pro');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('用例复跑 · v2');
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
it('更换批次时保留搜索条件并回到第一页,复验后立即刷新', () => {
|
it('更换批次时保留搜索条件并回到第一页,复验后立即刷新', () => {
|
||||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
|
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
|
||||||
@@ -82,6 +137,127 @@ describe('历史任务检索', () => {
|
|||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('定时刷新不插入加载行或禁用翻页,并保留滚动、焦点、折叠和选中项', fakeAsync(() => {
|
||||||
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
|
const now = Date.now()/1000;
|
||||||
|
const items = Array.from({length:8}, (_,i) => ({session_id:`task-${i}`, name:`历史用例 ${i}`, status:'completed', start_time:now}));
|
||||||
|
items.push({session_id:'older', name:'前一天的用例', status:'completed', start_time:now-86400});
|
||||||
|
TestBed.inject(AgentService).currentSessionId.set('task-0');
|
||||||
|
http.expectOne(r => r.url === '/api/automation/history').flush({total:60,items});
|
||||||
|
fixture.detectChanges();
|
||||||
|
tick();
|
||||||
|
const host: HTMLElement = fixture.nativeElement;
|
||||||
|
host.style.cssText = 'display:block;width:360px;height:400px;overflow:auto';
|
||||||
|
const group = host.querySelectorAll('details')[1];
|
||||||
|
group.open = false;
|
||||||
|
group.dispatchEvent(new Event('toggle'));
|
||||||
|
const search = host.querySelector('input')!;
|
||||||
|
search.focus({preventScroll:true});
|
||||||
|
host.scrollTop = 100;
|
||||||
|
const row = host.querySelector('.row.selected')!;
|
||||||
|
const before = {height:host.scrollHeight, scroll:host.scrollTop, top:row.getBoundingClientRect().top};
|
||||||
|
expect(before.scroll).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
tick(15000);
|
||||||
|
const refresh = http.expectOne(r => r.url === '/api/automation/history');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(host.textContent).not.toContain('正在查询');
|
||||||
|
expect(host.querySelectorAll<HTMLButtonElement>('.pager button')[1].disabled).toBeFalse();
|
||||||
|
expect(host.scrollHeight).toBe(before.height);
|
||||||
|
expect(host.scrollTop).toBe(before.scroll);
|
||||||
|
expect(row.getBoundingClientRect().top).toBe(before.top);
|
||||||
|
refresh.flush({total:60,items:items.map(item => ({...item,name:item.name.replace('历史','更新')}))});
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(host.querySelector('.row.selected')).toBe(row);
|
||||||
|
expect(row.textContent).toContain('更新用例 0');
|
||||||
|
expect(host.querySelectorAll('details')[1]).toBe(group);
|
||||||
|
expect(group.open).toBeFalse();
|
||||||
|
expect(document.activeElement).toBe(search);
|
||||||
|
expect(host.scrollTop).toBe(before.scroll);
|
||||||
|
fixture.destroy();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('空历史在后台刷新期间保持空状态,收到新任务后正常更新', fakeAsync(() => {
|
||||||
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
|
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
|
||||||
|
fixture.detectChanges();
|
||||||
|
const empty = fixture.nativeElement.querySelector('p');
|
||||||
|
tick(15000);
|
||||||
|
const refresh = http.expectOne(r => r.url === '/api/automation/history');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('p')).toBe(empty);
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('正在查询');
|
||||||
|
refresh.flush({total:1,items:[{session_id:'new',name:'新完成任务',status:'completed',start_time:Date.now()/1000}]});
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('新完成任务');
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('没有符合条件');
|
||||||
|
fixture.destroy();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('慢刷新不叠加请求,搜索优先且旧刷新不能覆盖搜索结果', fakeAsync(() => {
|
||||||
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
|
const first = http.expectOne(r => r.url === '/api/automation/history');
|
||||||
|
tick(15000);
|
||||||
|
http.expectNone(r => r.url === '/api/automation/history');
|
||||||
|
first.flush({total:0,items:[]});
|
||||||
|
tick(15000);
|
||||||
|
const refresh = http.expectOne(r => r.url === '/api/automation/history');
|
||||||
|
tick(15000);
|
||||||
|
http.expectNone(r => r.url === '/api/automation/history');
|
||||||
|
fixture.componentInstance.query = '设置';
|
||||||
|
fixture.componentInstance.search();
|
||||||
|
tick(250);
|
||||||
|
const search = http.expectOne(r => r.params.get('q') === '设置');
|
||||||
|
search.flush({total:1,items:[{session_id:'settings',name:'设置',status:'completed',start_time:100}]});
|
||||||
|
refresh.flush({total:99,items:[]});
|
||||||
|
expect(fixture.componentInstance.total()).toBe(1);
|
||||||
|
expect(fixture.componentInstance.groups()[0].rows[0].session_id).toBe('settings');
|
||||||
|
fixture.destroy();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('轮询和结果通知不抢先提交仍在输入的搜索条件', fakeAsync(() => {
|
||||||
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
|
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
|
||||||
|
fixture.detectChanges();
|
||||||
|
tick(14900);
|
||||||
|
fixture.componentInstance.query = '新条件';
|
||||||
|
fixture.componentInstance.search();
|
||||||
|
TestBed.inject(AgentService).resultRevision.update(value => value+1);
|
||||||
|
fixture.detectChanges();
|
||||||
|
tick(100);
|
||||||
|
http.expectNone(r => r.url === '/api/automation/history');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('h3 [role="status"]').textContent).toContain('正在查询');
|
||||||
|
tick(150);
|
||||||
|
http.expectOne(r => r.params.get('q') === '新条件').flush({total:0,items:[]});
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('h3 [role="status"]').textContent).toBe('');
|
||||||
|
fixture.destroy();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('刷新失败保留列表和错误提示,重试期间不反复移除提示', fakeAsync(() => {
|
||||||
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
|
const items = [{session_id:'kept',name:'已有历史',status:'completed',start_time:100}];
|
||||||
|
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items});
|
||||||
|
fixture.detectChanges();
|
||||||
|
const row = fixture.nativeElement.querySelector('.row');
|
||||||
|
tick(15000);
|
||||||
|
http.expectOne(r => r.url === '/api/automation/history').flush({}, {status:503,statusText:'Unavailable'});
|
||||||
|
fixture.detectChanges();
|
||||||
|
const alert = fixture.nativeElement.querySelector('[role="alert"]');
|
||||||
|
expect(alert.textContent).toContain('历史查询失败');
|
||||||
|
tick(15000);
|
||||||
|
const retry = http.expectOne(r => r.url === '/api/automation/history');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('[role="alert"]')).toBe(alert);
|
||||||
|
expect(fixture.nativeElement.querySelector('.row')).toBe(row);
|
||||||
|
retry.flush({total:1,items});
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('[role="alert"]')).toBeNull();
|
||||||
|
expect(fixture.nativeElement.querySelector('.row')).toBe(row);
|
||||||
|
fixture.destroy();
|
||||||
|
}));
|
||||||
|
|
||||||
it('显示明确的结果、完成时间与总耗时,标题悬停显示完整提示词', () => {
|
it('显示明确的结果、完成时间与总耗时,标题悬停显示完整提示词', () => {
|
||||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
http.expectOne(r => r.url === '/api/automation/history').flush({total: 1, items: [
|
http.expectOne(r => r.url === '/api/automation/history').flush({total: 1, items: [
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
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 { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-badge.component';
|
||||||
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
||||||
import { Component, DestroyRef, effect, inject, signal, untracked } from '@angular/core';
|
import { Component, DestroyRef, effect, inject, signal, untracked } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
@@ -7,53 +9,57 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
|||||||
import { AgentService } from '../../services/agent.service';
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent],
|
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent,TaskModeBadgeComponent,FavoriteButtonComponent],
|
||||||
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small></h3>
|
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small><small class="query-status" role="status">{{loading() ? '正在查询…' : ''}}</small></h3>
|
||||||
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
|
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
|
||||||
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
|
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
|
||||||
@if(error()){<p role="alert">{{error()}}</p>}
|
@if(error()){<p role="alert">{{error()}}</p>}
|
||||||
@if(loading()){<small>正在查询…</small>}
|
|
||||||
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
|
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
|
||||||
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
|
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><div class="history-heading"><app-favorite-button [sourceId]="row.session_id" /><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button></div><app-task-id-copy [taskId]="row.session_id" />@if(agent.canDeleteSession(row)){<button class="delete-history" (click)="remove(row.session_id, row.exploration_kind)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>}
|
||||||
</details>}
|
</details>}
|
||||||
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
|
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
|
||||||
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
|
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
|
||||||
</section>`,
|
</section>`,
|
||||||
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;line-height:14px;border-radius:5px;padding:0 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
|
styles:[`:host{display:block;font-size:12px;color:inherit}h3{display:flex;align-items:center;gap:6px;font-size:14px;margin:14px 0 8px}.query-status{margin-left:auto;font-size:11px;font-weight:400;white-space:nowrap}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-heading{display:flex;align-items:flex-start;gap:5px;min-width:0}.history-heading>app-favorite-button{margin-top:-5px}.history-heading>.row{flex:1;min-width:0;width:auto}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;line-height:14px;border-radius:5px;padding:0 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
|
||||||
})
|
})
|
||||||
export class TaskHistoryComponent {
|
export class TaskHistoryComponent {
|
||||||
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
|
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
|
||||||
total=signal(0);groups=signal<any[]>([]);loading=signal(false);error=signal('');collapsed=new Set<string>();
|
total=signal(0);groups=signal<any[]>([]);loading=signal(false);error=signal('');collapsed=new Set<string>();
|
||||||
private revision=0;private debounce: ReturnType<typeof setTimeout>|undefined;
|
private revision=0;private pendingRequest=0;private debounce: ReturnType<typeof setTimeout>|undefined;
|
||||||
constructor(){
|
constructor(){
|
||||||
let filter=this.agent.batchFilter(), resultRevision=this.agent.resultRevision();
|
let filter=this.agent.batchFilter(), resultRevision=this.agent.resultRevision();
|
||||||
this.load();
|
this.load();
|
||||||
effect(()=>{
|
effect(()=>{
|
||||||
const nextFilter=this.agent.batchFilter(), nextRevision=this.agent.resultRevision();
|
const nextFilter=this.agent.batchFilter(), nextRevision=this.agent.resultRevision();
|
||||||
if(nextFilter!==filter || nextRevision!==resultRevision){
|
if(nextFilter!==filter || nextRevision!==resultRevision){
|
||||||
if(nextFilter!==filter){this.page=1;clearTimeout(this.debounce);}
|
const filterChanged=nextFilter!==filter;
|
||||||
filter=nextFilter;resultRevision=nextRevision;untracked(()=>this.load());
|
if(filterChanged)this.page=1;
|
||||||
|
filter=nextFilter;resultRevision=nextRevision;untracked(()=>this.load(!filterChanged));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});
|
const timer=setInterval(()=>this.load(true),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});
|
||||||
}
|
}
|
||||||
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});}
|
remove(id:string, kind?:string){if(!confirm(this.agent.deletionPrompt(id,kind)))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:e=>this.error.set(e.error?.detail || "删除失败,请稍后重试")});}
|
||||||
pages(){return Math.max(1,Math.ceil(this.total()/30));}
|
pages(){return Math.max(1,Math.ceil(this.total()/30));}
|
||||||
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已终止',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
|
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已终止',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
|
||||||
duration(start:number,end?:number){if(!start||!end)return '—';const seconds=Math.max(0,Math.floor(end-start));const hours=Math.floor(seconds/3600);const minutes=Math.floor((seconds%3600)/60);return hours?`${hours}时${minutes}分`:minutes?`${minutes}分${seconds%60}秒`:`${seconds}秒`;}
|
duration(start:number,end?:number){if(!start||!end)return '—';const seconds=Math.max(0,Math.floor(end-start));const hours=Math.floor(seconds/3600);const minutes=Math.floor((seconds%3600)/60);return hours?`${hours}时${minutes}分`:minutes?`${minutes}分${seconds%60}秒`:`${seconds}秒`;}
|
||||||
toggle(key:string,event:Event){if((event.target as HTMLDetailsElement).open)this.collapsed.delete(key);else this.collapsed.add(key);}
|
toggle(key:string,event:Event){if((event.target as HTMLDetailsElement).open)this.collapsed.delete(key);else this.collapsed.add(key);}
|
||||||
search(){this.page=1;this.revision++;clearTimeout(this.debounce);this.debounce=setTimeout(()=>this.load(),250);}
|
search(){this.page=1;this.revision++;this.loading.set(true);clearTimeout(this.debounce);this.debounce=setTimeout(()=>this.load(),250);}
|
||||||
load(){
|
load(background=false){
|
||||||
const revision=++this.revision;this.loading.set(true);this.error.set('');
|
// Polling must not displace rows, toggle pagination, or supersede a user's query.
|
||||||
|
if(background && (this.pendingRequest || this.debounce!==undefined))return;
|
||||||
|
if(!background){clearTimeout(this.debounce);this.debounce=undefined;this.loading.set(true);}
|
||||||
|
const revision=++this.revision;this.pendingRequest=revision;
|
||||||
let params=new HttpParams().set('q',this.query).set('page',this.page).set('status',this.status);
|
let params=new HttpParams().set('q',this.query).set('page',this.page).set('status',this.status);
|
||||||
if(this.agent.batchFilter())params=params.set('batch_id',this.agent.batchFilter());
|
if(this.agent.batchFilter())params=params.set('batch_id',this.agent.batchFilter());
|
||||||
if(this.start)params=params.set('start',new Date(this.start+'T00:00:00').getTime()/1000);
|
if(this.start)params=params.set('start',new Date(this.start+'T00:00:00').getTime()/1000);
|
||||||
if(this.end){const d=new Date(this.end+'T00:00:00');d.setDate(d.getDate()+1);params=params.set('end',d.getTime()/1000);}
|
if(this.end){const d=new Date(this.end+'T00:00:00');d.setDate(d.getDate()+1);params=params.set('end',d.getTime()/1000);}
|
||||||
this.http.get<any>('/api/automation/history',{params}).subscribe({next:r=>{
|
this.http.get<any>('/api/automation/history',{params}).subscribe({next:r=>{
|
||||||
if(revision!==this.revision)return;
|
if(revision!==this.revision)return;
|
||||||
|
this.pendingRequest=0;this.error.set('');
|
||||||
this.total.set(r.total);const map=new Map<string,any>();const today=new Date();const yesterday=new Date();yesterday.setDate(today.getDate()-1);
|
this.total.set(r.total);const map=new Map<string,any>();const today=new Date();const yesterday=new Date();yesterday.setDate(today.getDate()-1);
|
||||||
for(const row of r.items){const d=new Date(row.start_time*1000);const key=d.toDateString();if(!map.has(key))map.set(key,{key,title:key===today.toDateString()?'今天':key===yesterday.toDateString()?'昨天':d.toLocaleDateString('zh-CN'),rows:[]});map.get(key).rows.push(row);}
|
for(const row of r.items){const d=new Date(row.start_time*1000);const key=d.toDateString();if(!map.has(key))map.set(key,{key,title:key===today.toDateString()?'今天':key===yesterday.toDateString()?'昨天':d.toLocaleDateString('zh-CN'),rows:[]});map.get(key).rows.push(row);}
|
||||||
this.groups.set([...map.values()]);this.loading.set(false);
|
this.groups.set([...map.values()]);this.loading.set(false);
|
||||||
},error:e=>{if(revision===this.revision){this.loading.set(false);this.error.set(e.error?.detail || '历史查询失败,请稍后重试');}}});
|
},error:e=>{if(revision===this.revision){this.pendingRequest=0;this.loading.set(false);this.error.set(e.error?.detail || '历史查询失败,请稍后重试');}}});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { TaskModeBadgeComponent } from './task-mode-badge.component';
|
||||||
|
|
||||||
|
describe('执行模式徽标', () => {
|
||||||
|
it('探索优先于底层 Pro,复跑显示实际使用的 Flash / Pro', () => {
|
||||||
|
const fixture = TestBed.createComponent(TaskModeBadgeComponent);
|
||||||
|
const check = (session: object, mode: string, text: string) => {
|
||||||
|
fixture.componentRef.setInput('session', session);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const badge = fixture.nativeElement.querySelector('.mode-badge');
|
||||||
|
expect(badge.classList.contains(mode)).toBeTrue();
|
||||||
|
expect(badge.textContent).toContain(text);
|
||||||
|
expect(badge.title).toBe('执行模式:' + text);
|
||||||
|
};
|
||||||
|
check({exploration_kind:'exploration',profile:'pro'}, 'explore', '探索');
|
||||||
|
check({exploration_kind:'validation',profile:'flash'}, 'flash', 'Flash');
|
||||||
|
check({profile:'pro'}, 'pro', 'Pro');
|
||||||
|
check({model_info:{architecture:'ARTEMIS Flash'}}, 'flash', 'Flash');
|
||||||
|
check({model_info:{id:'mimo-v2.6-pro'}}, 'unknown', '模式未记录');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Component, computed, input } from '@angular/core';
|
||||||
|
import { Session } from '../../core/models/session.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-task-mode-badge',
|
||||||
|
standalone: true,
|
||||||
|
template: `<span class="mode-badge" [class]="'mode-badge ' + mode()" [title]="'执行模式:' + label()"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
|
||||||
|
styles: [`
|
||||||
|
:host{display:inline-flex;vertical-align:middle}
|
||||||
|
.mode-badge{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;gap:3px;padding:0 6px;border:1px solid #e2e8f0;border-radius:5px;background:#f8fafc;color:#475569;font-size:11px;font-weight:600;line-height:14px;white-space:nowrap}
|
||||||
|
.material-symbols-outlined{font-size:13px;line-height:1}
|
||||||
|
.explore{background:#eef2ff;border-color:#c7d2fe;color:#4338ca}
|
||||||
|
.flash{background:#fff7ed;border-color:#fed7aa;color:#9a3412}
|
||||||
|
.pro{background:#eff6ff;border-color:#bfdbfe;color:#1d4ed8}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class TaskModeBadgeComponent {
|
||||||
|
session = input.required<Partial<Session>>();
|
||||||
|
mode = computed<'explore' | 'flash' | 'pro' | 'unknown'>(() => {
|
||||||
|
const s = this.session();
|
||||||
|
if (s.exploration_kind === 'exploration') return 'explore';
|
||||||
|
const profile = (s.profile || s.model_info?.architecture || '').toLowerCase().replace(/^artemis\s+/, '');
|
||||||
|
return profile === 'explore' || profile === 'flash' || profile === 'pro' ? profile : 'unknown';
|
||||||
|
});
|
||||||
|
label = computed(() => ({explore:'探索',flash:'Flash',pro:'Pro',unknown:'模式未记录'})[this.mode()]);
|
||||||
|
icon = computed(() => ({explore:'explore',flash:'bolt',pro:'psychology',unknown:'help_outline'})[this.mode()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<button class="plugin-toggle" type="button" (click)="toggle()" [attr.aria-expanded]="open()">
|
||||||
|
<span class="material-symbols-outlined" aria-hidden="true">extension</span>
|
||||||
|
<span>扩展插件</span><span class="mode">{{ summary() }}</span>
|
||||||
|
<span class="material-symbols-outlined chevron" aria-hidden="true">{{ open() ? 'expand_less' : 'expand_more' }}</span>
|
||||||
|
</button>
|
||||||
|
@if (open()) {
|
||||||
|
<section class="plugin-options" aria-label="扩展插件设置">
|
||||||
|
<p>照常描述安卓测试任务。需要操作电脑、浏览器等外部步骤时,AI 会按需调用已启用并授权的插件。</p>
|
||||||
|
<label class="auto-option"><input type="checkbox" [(ngModel)]="automatic" (ngModelChange)="emit()">自动使用可用插件</label>
|
||||||
|
@if (loading()) { <p role="status">正在读取插件…</p> }
|
||||||
|
@else if (error()) { <p class="error" role="alert">{{ error() }} <button type="button" class="text-button" (click)="load()">重试</button></p> }
|
||||||
|
@else if (!automatic) {
|
||||||
|
@for (plugin of available(); track plugin.manifest.id) {
|
||||||
|
<div class="choice">
|
||||||
|
<label><input type="checkbox" [checked]="selected[plugin.manifest.id] !== undefined" (change)="choose(plugin, $event)">{{ plugin.manifest.name }}</label>
|
||||||
|
@if (authorizedProfiles(plugin).length > 1) {
|
||||||
|
<select [disabled]="selected[plugin.manifest.id] === undefined" [ngModel]="selected[plugin.manifest.id] || ''" (ngModelChange)="selected[plugin.manifest.id] = $event; emit()" [attr.aria-label]="plugin.manifest.name + '使用设置'">
|
||||||
|
<option value="" disabled>选择要使用的设置</option>
|
||||||
|
@for (profile of authorizedProfiles(plugin); track profile.name) { <option [value]="profile.name">{{ profile.label }}</option> }
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @empty { <p>还没有可用插件,请先安装、授权并启用。</p> }
|
||||||
|
<p class="hint">不勾选任何插件时,本次任务只使用手机能力。</p>
|
||||||
|
} @else {
|
||||||
|
<p class="hint">{{ available().length ? 'AI 会按需使用可用插件,无需逐次选择。关闭自动使用后,可指定本次允许使用的插件。' : '还没有可用插件,手机测试不受影响。' }}</p>
|
||||||
|
}
|
||||||
|
<a routerLink="/plugins" class="manage-link">管理插件 <span class="material-symbols-outlined" aria-hidden="true">arrow_forward</span></a>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
:host { display: block; width: 100%; color: #475569; font-size: 12px; }
|
||||||
|
.plugin-toggle { display: inline-flex; align-items: center; gap: 6px; border: 1px solid #e2e8f0; border-radius: 20px; padding: 5px 10px; background: #f8fafc; color: #64748b; font: inherit; cursor: pointer; transition: background .15s, border-color .15s; }
|
||||||
|
.plugin-toggle:hover, .plugin-toggle[aria-expanded=true] { background: #eff6ff; border-color: #bfdbfe; color: #2563eb; }
|
||||||
|
.material-symbols-outlined { font-size: 16px; }
|
||||||
|
.mode { color: #2563eb; padding-left: 6px; border-left: 1px solid #dce5ef; }
|
||||||
|
.chevron { font-size: 15px; }
|
||||||
|
.plugin-options { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 14px; padding: 14px 16px; margin-top: 8px; display: grid; gap: 12px; }
|
||||||
|
p { margin: 0; line-height: 1.65; }
|
||||||
|
label { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.auto-option { font-weight: 500; color: #334155; }
|
||||||
|
input[type=checkbox] { accent-color: #2563eb; margin: 0; width: 14px; height: 14px; flex-shrink: 0; }
|
||||||
|
.choice { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||||
|
select { font: inherit; color: #334155; background: #fff; border: 1px solid #dce3eb; border-radius: 8px; padding: 6px 10px; max-width: 100%; }
|
||||||
|
select:disabled { opacity: .5; }
|
||||||
|
.hint { color: #64748b; font-size: 11px; }
|
||||||
|
.error { color: #b91c1c; }
|
||||||
|
.text-button, .manage-link { color: #2563eb; font: inherit; text-decoration: none; }
|
||||||
|
.text-button { background: none; border: 0; padding: 0 6px; cursor: pointer; }
|
||||||
|
.manage-link { display: inline-flex; align-items: center; gap: 4px; justify-self: start; }
|
||||||
|
:focus-visible { outline: 2px solid #3b82f6; outline-offset: 3px; }
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { TaskPluginsComponent } from './task-plugins.component';
|
||||||
|
|
||||||
|
describe('TaskPluginsComponent', () => {
|
||||||
|
let http: HttpTestingController;
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({ imports: [TaskPluginsComponent], providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()] });
|
||||||
|
http = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
afterEach(() => http.verify());
|
||||||
|
|
||||||
|
it('starts collapsed and automatic without an execution target selector', async () => {
|
||||||
|
const fixture = TestBed.createComponent(TaskPluginsComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
http.expectNone('/api/plugins');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('自动');
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('执行目标');
|
||||||
|
expect(fixture.nativeElement.querySelector('select')).toBeNull();
|
||||||
|
const emitted = jasmine.createSpy('changed');
|
||||||
|
fixture.componentInstance.changed.subscribe(emitted);
|
||||||
|
fixture.nativeElement.querySelector('button').click(); fixture.detectChanges();
|
||||||
|
http.expectOne('/api/plugins').flush({ plugins: [] });
|
||||||
|
await fixture.whenStable(); fixture.detectChanges();
|
||||||
|
fixture.nativeElement.querySelector('input').click(); fixture.detectChanges();
|
||||||
|
expect(emitted).toHaveBeenCalledWith({ plugins: [] });
|
||||||
|
fixture.nativeElement.querySelector('input').click(); fixture.detectChanges();
|
||||||
|
expect(emitted).toHaveBeenCalledWith({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the single authorized setting without exposing a connection selector', async () => {
|
||||||
|
const fixture = TestBed.createComponent(TaskPluginsComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.toggle();
|
||||||
|
const plugin = { enabled: true, manifest: { id: 'desktop', name: '电脑控制', targets: ['computer', 'mixed'] }, profiles: [
|
||||||
|
{ name: 'default', label: '旧配置', authorized: false },
|
||||||
|
{ name: 'lab', label: '实验室', authorized: true, is_default: true }
|
||||||
|
] };
|
||||||
|
http.expectOne('/api/plugins').flush({ plugins: [plugin, { ...plugin, enabled: false, manifest: { ...plugin.manifest, id: 'disabled' } }] });
|
||||||
|
component.automatic = false; fixture.detectChanges(); await fixture.whenStable();
|
||||||
|
const emitted = jasmine.createSpy('changed'); component.changed.subscribe(emitted);
|
||||||
|
const checkbox = fixture.nativeElement.querySelector('.choice input');
|
||||||
|
checkbox.click(); fixture.detectChanges(); await fixture.whenStable();
|
||||||
|
expect(emitted).toHaveBeenCalledWith({ plugins: [{ id: 'desktop', profile: 'lab' }] });
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('.choice').length).toBe(1);
|
||||||
|
expect(fixture.nativeElement.querySelector('select')).toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('连接配置');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows choosing among multiple authorized settings and excludes unauthorized ones', async () => {
|
||||||
|
const fixture = TestBed.createComponent(TaskPluginsComponent); const component = fixture.componentInstance;
|
||||||
|
component.toggle();
|
||||||
|
http.expectOne('/api/plugins').flush({ plugins: [{ enabled: true,
|
||||||
|
manifest: { id: 'desktop', name: '电脑控制', targets: ['mixed'] }, profiles: [
|
||||||
|
{ name: 'old', label: '旧配置', authorized: false },
|
||||||
|
{ name: 'lab', label: '实验室', authorized: true, is_default: true },
|
||||||
|
{ name: 'office', label: '办公室', authorized: true }
|
||||||
|
] }] });
|
||||||
|
component.automatic = false; fixture.detectChanges(); await fixture.whenStable();
|
||||||
|
const emitted = jasmine.createSpy('changed'); component.changed.subscribe(emitted);
|
||||||
|
fixture.nativeElement.querySelector('.choice input').click(); fixture.detectChanges(); await fixture.whenStable();
|
||||||
|
const select: HTMLSelectElement = fixture.nativeElement.querySelector('select');
|
||||||
|
expect(select.value).toBe('lab'); expect(select.textContent).not.toContain('旧配置');
|
||||||
|
select.value = 'office'; select.dispatchEvent(new Event('change')); fixture.detectChanges(); await fixture.whenStable();
|
||||||
|
expect(emitted).toHaveBeenCalledWith({ plugins: [{ id: 'desktop', profile: 'office' }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a retryable error and keeps automatic mode on lookup failure', () => {
|
||||||
|
const fixture = TestBed.createComponent(TaskPluginsComponent);
|
||||||
|
fixture.componentInstance.toggle();
|
||||||
|
http.expectOne('/api/plugins').flush({}, { status: 503, statusText: 'Unavailable' });
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('重试');
|
||||||
|
expect(fixture.componentInstance.automatic).toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Component, inject, output, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { InstalledPlugin, PluginService } from '../../services/plugin.service';
|
||||||
|
|
||||||
|
export interface TaskPluginOptions {
|
||||||
|
plugins?: { id: string; profile: string; version?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-task-plugins', standalone: true, imports: [FormsModule, RouterLink],
|
||||||
|
templateUrl: './task-plugins.component.html', styleUrl: './task-plugins.component.scss'
|
||||||
|
})
|
||||||
|
export class TaskPluginsComponent {
|
||||||
|
changed = output<TaskPluginOptions>();
|
||||||
|
private api = inject(PluginService);
|
||||||
|
automatic = true;
|
||||||
|
selected: Record<string, string> = {};
|
||||||
|
open = signal(false);
|
||||||
|
loading = signal(false);
|
||||||
|
error = signal('');
|
||||||
|
plugins = signal<InstalledPlugin[]>([]);
|
||||||
|
|
||||||
|
summary() {
|
||||||
|
return this.automatic ? '自动' : Object.keys(this.selected).length ? `已选 ${Object.keys(this.selected).length} 个` : '本次停用';
|
||||||
|
}
|
||||||
|
available() {
|
||||||
|
return this.plugins().filter(p => p.enabled && p.profiles.some(profile => profile.authorized) &&
|
||||||
|
p.manifest.targets.some(target => target === 'android' || target === 'mixed'));
|
||||||
|
}
|
||||||
|
authorizedProfiles(plugin: InstalledPlugin) { return plugin.profiles.filter(p => p.authorized); }
|
||||||
|
toggle() {
|
||||||
|
this.open.update(v => !v);
|
||||||
|
if (this.open()) this.load();
|
||||||
|
}
|
||||||
|
load() {
|
||||||
|
this.loading.set(true); this.error.set('');
|
||||||
|
this.api.list().subscribe({
|
||||||
|
next: value => { this.plugins.set(value.plugins); this.loading.set(false); },
|
||||||
|
error: () => { this.error.set('暂时无法读取插件。'); this.loading.set(false); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
choose(plugin: InstalledPlugin, event: Event) {
|
||||||
|
if ((event.target as HTMLInputElement).checked) {
|
||||||
|
const profiles = this.authorizedProfiles(plugin);
|
||||||
|
this.selected[plugin.manifest.id] = (profiles.find(p => p.is_default) || profiles[0]).name;
|
||||||
|
} else delete this.selected[plugin.manifest.id];
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
emit() {
|
||||||
|
this.changed.emit(this.automatic ? {} : {
|
||||||
|
plugins: Object.entries(this.selected).map(([id, profile]) => ({ id, profile }))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,18 +3,19 @@ import { DatePipe } 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 { CaseReviewResponse, CaseTestResult } from '../../core/models/session.model';
|
import { CaseReviewResponse, CaseTestResult } from '../../core/models/session.model';
|
||||||
|
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
|
||||||
import { TestResultBadgeComponent } from './test-result-badge.component';
|
import { TestResultBadgeComponent } from './test-result-badge.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-test-result-card',
|
selector: 'app-test-result-card',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [TestResultBadgeComponent, DatePipe, FormsModule],
|
imports: [ReportMarkdownComponent, TestResultBadgeComponent, DatePipe, FormsModule],
|
||||||
template: `<section [class]="'test-result-card ' + result().status" aria-label="用例测试结果" role="status">
|
template: `<section [class]="'test-result-card ' + result().status" aria-label="用例测试结果" role="status">
|
||||||
<header><span class="caption">用例测试结果 <small>{{result().source === 'human' ? '人工确认' : 'AI确认'}}</small></span><app-test-result-badge [result]="result()" [prominent]="true" /></header>
|
<header><span class="caption">用例测试结果 <small>{{result().source === 'human' ? '人工确认' : 'AI确认'}}</small></span><app-test-result-badge [result]="result()" [prominent]="true" /></header>
|
||||||
<p class="reason">{{result().reason}}</p>
|
<app-report-markdown class="reason" [content]="result().reason" />
|
||||||
@if(result().reviewed_at){<p class="review-time">复验于 {{result().reviewed_at! * 1000 | date:'yyyy-MM-dd HH:mm:ss'}}</p>}
|
@if(result().reviewed_at){<p class="review-time">复验于 {{result().reviewed_at! * 1000 | date:'yyyy-MM-dd HH:mm:ss'}}</p>}
|
||||||
@if(result().ai_result; as original){<details><summary>原 AI 结论 · {{original.status === 'passed' ? '测试通过' : original.status === 'failed' ? '测试不通过' : original.status === 'blocked' ? '测试阻塞' : '未验证'}}</summary><p class="reason">{{original.reason}}</p>@if(original.evidence.length){<ol>@for(item of original.evidence; track $index){<li>{{item}}</li>}</ol>}</details>}
|
@if(result().ai_result; as original){<details><summary>原 AI 结论 · {{original.status === 'passed' ? '测试通过' : original.status === 'failed' ? '测试不通过' : original.status === 'blocked' ? '测试阻塞' : '未验证'}}</summary><app-report-markdown class="reason" [content]="original.reason" />@if(original.evidence.length){<ol>@for(item of original.evidence; track $index){<li><app-report-markdown [content]="item" /></li>}</ol>}</details>}
|
||||||
@if(result().evidence.length){<details><summary>查看验收证据 · {{result().evidence.length}} 条</summary><ol>@for(item of result().evidence; track $index){<li>{{item}}</li>}</ol></details>}
|
@if(result().evidence.length){<details><summary>查看验收证据 · {{result().evidence.length}} 条</summary><ol>@for(item of result().evidence; track $index){<li><app-report-markdown [content]="item" /></li>}</ol></details>}
|
||||||
@if(canReview()){
|
@if(canReview()){
|
||||||
<div class="review-toolbar"><button type="button" [disabled]="saving()" (click)="toggleReview()" [attr.aria-expanded]="reviewing()">{{result().source === 'human' ? '修改人工复验' : '人工复验'}}</button>@if(notice()){<span role="status">{{notice()}}</span>}</div>
|
<div class="review-toolbar"><button type="button" [disabled]="saving()" (click)="toggleReview()" [attr.aria-expanded]="reviewing()">{{result().source === 'human' ? '修改人工复验' : '人工复验'}}</button>@if(notice()){<span role="status">{{notice()}}</span>}</div>
|
||||||
@if(reviewing()){
|
@if(reviewing()){
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Local metadata reads must also time out when the browser is waiting for a
|
||||||
|
// connection. This is independent of model execution and long-running actions.
|
||||||
|
export const UI_READ_TIMEOUT_MS = 15_000;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const MODEL_PRESETS = ['gpt-6-luna', 'gpt-6.1-sol', 'gpt-6-astra', 'mimo-v2.6-pro'];
|
||||||
@@ -50,6 +50,13 @@ export interface TaskQueueItem {
|
|||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
|
profile?: string | null;
|
||||||
|
exploration_id?: string;
|
||||||
|
exploration_latest_run_id?: string;
|
||||||
|
exploration_status?: string;
|
||||||
|
exploration_run_count?: number;
|
||||||
|
exploration_kind?: 'exploration' | 'validation';
|
||||||
|
case_version?: number | null;
|
||||||
batch_id?: string | null;
|
batch_id?: string | null;
|
||||||
initial_goal: string;
|
initial_goal: string;
|
||||||
start_time: number;
|
start_time: number;
|
||||||
|
|||||||
@@ -225,6 +225,8 @@ export interface CheckerResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StepReplayFrame {
|
export interface StepReplayFrame {
|
||||||
|
sourceId?: string;
|
||||||
|
sourceLabel?: string;
|
||||||
index: number;
|
index: number;
|
||||||
stepNumber: number;
|
stepNumber: number;
|
||||||
rawStepNumber?: number;
|
rawStepNumber?: number;
|
||||||
|
|||||||
@@ -1379,6 +1379,7 @@
|
|||||||
|
|
||||||
<!-- Task composer -->
|
<!-- Task composer -->
|
||||||
<div class="composer-card" [class.is-pro-active]="selectedProfile() === 'pro'">
|
<div class="composer-card" [class.is-pro-active]="selectedProfile() === 'pro'">
|
||||||
|
<app-task-plugins (changed)="taskPlugins.set($event)" />
|
||||||
|
|
||||||
<!-- Smart Intent Recommendation Strip (if detected) -->
|
<!-- Smart Intent Recommendation Strip (if detected) -->
|
||||||
@if (showIntentSuggestion()) {
|
@if (showIntentSuggestion()) {
|
||||||
@@ -1422,8 +1423,10 @@
|
|||||||
<span class="mode-name">Flash</span>
|
<span class="mode-name">Flash</span>
|
||||||
<span class="bullet">·</span>
|
<span class="bullet">·</span>
|
||||||
<span>节省 Token,快速响应 <strong>每步 3–5 秒</strong> 直接根据实时屏幕执行操作,适合路径明确的日常任务。</span>
|
<span>节省 Token,快速响应 <strong>每步 3–5 秒</strong> 直接根据实时屏幕执行操作,适合路径明确的日常任务。</span>
|
||||||
<span class="limitation-tag">(不规划、不检查结果、不执行 ADB 命令)</span>
|
<span class="limitation-tag">(轻量执行,结束时判断测试结果)</span>
|
||||||
</p>
|
</p>
|
||||||
|
} @else if(selectedProfile() === 'exploration') {
|
||||||
|
<p class="desc-line"><span class="mode-name">探索</span><span class="bullet">·</span><span>边执行边交流,支持人工示范,成功后生成可复用用例。</span></p>
|
||||||
} @else {
|
} @else {
|
||||||
<p class="desc-line">
|
<p class="desc-line">
|
||||||
<span class="mode-name">Pro</span>
|
<span class="mode-name">Pro</span>
|
||||||
@@ -1467,6 +1470,9 @@
|
|||||||
<span class="material-symbols-outlined btn-icon">psychology</span>
|
<span class="material-symbols-outlined btn-icon">psychology</span>
|
||||||
<span class="btn-text">Pro</span>
|
<span class="btn-text">Pro</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="model-toggle-btn btn-pro" [class.active]="selectedProfile() === 'exploration'" (click)="setProfile('exploration')" title="探索:边执行边交流,可人工示范,完成后生成可复用用例">
|
||||||
|
<span class="material-symbols-outlined btn-icon">explore</span><span class="btn-text">探索</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1487,13 +1493,36 @@
|
|||||||
@if (isSubmitting()) {
|
@if (isSubmitting()) {
|
||||||
<span class="spinner"></span><span>正在启动…</span>
|
<span class="spinner"></span><span>正在启动…</span>
|
||||||
} @else {
|
} @else {
|
||||||
<span class="material-symbols-outlined run-icon">play_arrow</span><span>执行任务</span>
|
<span class="material-symbols-outlined run-icon">play_arrow</span><span>{{selectedProfile() === 'exploration' ? '开始探索' : '执行任务'}}</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if(selectedProfile() === 'exploration') {
|
||||||
|
<section class="exploration-settings">
|
||||||
|
<div class="exploration-intro"><span class="material-symbols-outlined">explore</span><div><strong>一起探索复杂用例</strong><p>描述大概流程,边执行边交流。可以纠正方向或亲自示范,结束后生成可复用的用例。</p></div></div>
|
||||||
|
@if(explorationConfig(); as config) {
|
||||||
|
<button type="button" class="exploration-settings-toggle" (click)="explorationSettingsOpen.set(!explorationSettingsOpen())" [attr.aria-expanded]="explorationSettingsOpen()"><span class="material-symbols-outlined">tune</span><span>{{config.model}} · {{config.reasoning_effort}}</span><span class="settings-toggle-label">调整探索设置</span><span class="material-symbols-outlined">{{explorationSettingsOpen() ? 'expand_less' : 'expand_more'}}</span></button>
|
||||||
|
@if(explorationSettingsOpen()) {
|
||||||
|
<div class="exploration-settings-body">
|
||||||
|
<label class="model-label">探索模型<input list="explore-models" [(ngModel)]="config.model" [disabled]="explorationSettingsBusy()" /></label>
|
||||||
|
<datalist id="explore-models"><option value="gpt-6.1-sol"></option><option value="gpt-6-astra"></option><option value="gpt-6-luna"></option><option value="mimo-v2.6-pro"></option></datalist>
|
||||||
|
<div class="exploration-fields">
|
||||||
|
<app-setting-slider label="思考强度" [(value)]="config.reasoning_effort" [values]="reasoningValues" [labels]="reasoningLabels" hint="越高越适合复杂推理,也可能等待更久。" [disabled]="explorationSettingsBusy()" />
|
||||||
|
<app-setting-slider label="每段执行时限" [(value)]="config.timeout_seconds" [values]="executionTimes" [labels]="executionTimeLabels" hint="到时暂停并保存进度,点击继续即可接着探索。" [disabled]="explorationSettingsBusy()" />
|
||||||
|
<app-setting-slider label="人工示范时限" [(value)]="config.demonstration_timeout_seconds" [values]="demoTimes" [labels]="demoTimeLabels" hint="点击“我来操作”后,允许录制的示范时长。" [disabled]="explorationSettingsBusy()" />
|
||||||
|
<app-setting-slider label="示范提前提醒" [(value)]="config.demonstration_warning_seconds" [values]="warningTimes" [labels]="warningLabels" hint="示范即将到时时提醒,可手动延长。" [disabled]="explorationSettingsBusy()" />
|
||||||
|
</div>
|
||||||
|
<p class="settings-note">等待回答和人工示范不占执行时间。这组设置只用于探索。</p>
|
||||||
|
@if(!explorationConfigValid()){<p role="alert" class="settings-error">请填写模型,并将提前提醒设为小于示范时限。</p>}
|
||||||
|
<div class="settings-footer"><button type="button" class="save-settings" (click)="saveExplorationSettings()" [disabled]="explorationSettingsBusy() || !explorationConfigValid()">{{explorationSettingsBusy() ? '正在保存…' : '记住此配置'}}</button><span role="status">{{explorationNotice()}}</span></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} @else {<button (click)="loadExplorationSettings()" [disabled]="explorationSettingsBusy()">{{explorationSettingsBusy() ? '正在读取配置…' : '重新读取探索配置'}}</button>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
<!-- Pro Mode Standalone Expected Output Card (Clean & Minimalist) -->
|
<!-- Pro Mode Standalone Expected Output Card (Clean & Minimalist) -->
|
||||||
@if (selectedProfile() === 'pro') {
|
@if (selectedProfile() === 'pro') {
|
||||||
<div class="pro-outputter-standalone-card">
|
<div class="pro-outputter-standalone-card">
|
||||||
@@ -1639,121 +1668,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Recommended Tasks Hub (Clean & Direct) -->
|
<app-featured-tasks />
|
||||||
<div class="smart-suggestions-container">
|
|
||||||
<!-- Header Bar: Clean Category Tabs & Shuffle -->
|
|
||||||
<div class="suggestions-header-bar">
|
|
||||||
<div class="header-left">
|
|
||||||
<div class="section-label">
|
|
||||||
<span class="material-symbols-outlined section-icon">auto_awesome</span>
|
|
||||||
<span class="label-text">推荐任务</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filter Tabs -->
|
|
||||||
<div class="category-tabs-group">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="cat-tab-btn"
|
|
||||||
[class.active]="selectedCategory() === 'all'"
|
|
||||||
(click)="setCategory('all')"
|
|
||||||
>
|
|
||||||
<span>全部</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="cat-tab-btn cat-flash"
|
|
||||||
[class.active]="selectedCategory() === 'flash'"
|
|
||||||
(click)="setCategory('flash')"
|
|
||||||
>
|
|
||||||
<span class="material-symbols-outlined tab-mini-icon">bolt</span>
|
|
||||||
<span>Flash</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="cat-tab-btn cat-pro"
|
|
||||||
[class.active]="selectedCategory() === 'pro'"
|
|
||||||
(click)="setCategory('pro')"
|
|
||||||
>
|
|
||||||
<span class="material-symbols-outlined tab-mini-icon">psychology</span>
|
|
||||||
<span>Pro</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="cat-tab-btn"
|
|
||||||
[class.active]="selectedCategory() === 'cross_app'"
|
|
||||||
(click)="setCategory('cross_app')"
|
|
||||||
>
|
|
||||||
<span class="material-symbols-outlined tab-mini-icon">sync_alt</span>
|
|
||||||
<span>跨应用</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="cat-tab-btn"
|
|
||||||
[class.active]="selectedCategory() === 'monitor'"
|
|
||||||
(click)="setCategory('monitor')"
|
|
||||||
>
|
|
||||||
<span class="material-symbols-outlined tab-mini-icon">monitoring</span>
|
|
||||||
<span>监控</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="header-right">
|
|
||||||
<button type="button" class="btn-shuffle-suggestions" (click)="shuffleSuggestions()" title="查看更多任务">
|
|
||||||
<span class="material-symbols-outlined shuffle-icon">shuffle</span>
|
|
||||||
<span>换一批</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Suggestions Grid -->
|
|
||||||
<div class="suggestions-cards-grid">
|
|
||||||
@for (item of filteredSuggestions(); track item.id) {
|
|
||||||
<div
|
|
||||||
class="suggestion-card"
|
|
||||||
[class.is-pro-card]="item.profile === 'pro'"
|
|
||||||
[class.is-flash-card]="item.profile === 'flash'"
|
|
||||||
(click)="applySuggestion(item)"
|
|
||||||
>
|
|
||||||
<!-- Top Row: App Icon + Name + Mode Badge -->
|
|
||||||
<div class="card-top-row">
|
|
||||||
<div class="app-icons-cluster">
|
|
||||||
@for (app of item.apps; track app.name) {
|
|
||||||
<div class="app-icon-badge" [title]="app.name">
|
|
||||||
<span class="material-symbols-outlined">{{ app.icon }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<span class="app-names-label">{{ getAppNamesDisplay(item.apps) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="mode-badge" [class.mode-pro]="item.profile === 'pro'" [class.mode-flash]="item.profile === 'flash'"
|
|
||||||
[title]="item.profile === 'pro' ? '使用 ARTEMIS Pro:多智能体协作' : '使用 ARTEMIS Flash:快速执行'">
|
|
||||||
{{ item.profile === 'pro' ? 'Pro' : 'Flash' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Middle Content: Title + Description -->
|
|
||||||
<div class="card-body">
|
|
||||||
<h4 class="card-title">{{ item.title }}</h4>
|
|
||||||
<p class="card-desc">{{ item.description }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bottom Footer Row: Tag & Action -->
|
|
||||||
<div class="card-footer-row">
|
|
||||||
<span class="tag-pill">{{ item.tag }}</span>
|
|
||||||
<div class="use-prompt-action">
|
|
||||||
<span>使用</span>
|
|
||||||
<span class="material-symbols-outlined action-arrow">arrow_forward</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4065,12 +4065,13 @@ $text-light: #94a3b8;
|
|||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
border: 1px solid rgba(226, 232, 240, 0.88);
|
border: 1px solid rgba(226, 232, 240, 0.88);
|
||||||
width: 172px;
|
width: max-content;
|
||||||
|
flex-shrink: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
.model-toggle-btn {
|
.model-toggle-btn {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
width: 50%;
|
min-width: 78px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
@@ -5166,3 +5167,21 @@ $text-light: #94a3b8;
|
|||||||
transform: translateY(0) scale(1);
|
transform: translateY(0) scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composer-card > app-task-plugins { margin-bottom: 8px; }
|
||||||
|
|
||||||
|
.exploration-settings {
|
||||||
|
margin:18px 0 24px; padding:18px 20px; background:#fff; border:1px solid #e2e8f0; border-radius:20px; font-size:13px; color:#475569;
|
||||||
|
p {line-height:1.7;margin:4px 0 0}
|
||||||
|
.exploration-intro {display:flex;gap:12px;align-items:flex-start; strong {color:#1e293b;font-size:14px} > .material-symbols-outlined {color:#6366f1;background:#eef2ff;border-radius:12px;padding:8px;font-size:22px}}
|
||||||
|
button,input {font:inherit;box-sizing:border-box} button {cursor:pointer;transition:background .15s} button:disabled {opacity:.5;cursor:default}
|
||||||
|
button:focus-visible,input:focus-visible {outline:2px solid #818cf8;outline-offset:3px}
|
||||||
|
.exploration-settings-toggle {display:flex;align-items:center;gap:8px;width:100%;border:0;background:#f8fafc;color:#475569;border-radius:12px;padding:10px 12px;margin-top:14px;text-align:left; .material-symbols-outlined {font-size:18px} .settings-toggle-label {margin-left:auto;font-size:12px;color:#64748b} &:hover {background:#eef2ff}}
|
||||||
|
.exploration-settings-body {padding-top:18px}
|
||||||
|
.model-label {display:flex;align-items:center;gap:16px;font-weight:600;input {flex:1;min-width:0;border:1px solid #dbe3ec;border-radius:10px;padding:10px 12px;color:#1e293b;background:#fff}}
|
||||||
|
.exploration-fields {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}
|
||||||
|
.settings-note {font-size:12px;color:#64748b}.settings-error {color:#b91c1c}
|
||||||
|
.settings-footer {display:flex;align-items:center;gap:12px;margin-top:14px;span {font-size:12px;color:#047857}}
|
||||||
|
.save-settings {border:0;background:#4f46e5;color:#fff;border-radius:10px;padding:9px 16px;font-weight:600;&:hover:enabled {background:#4338ca}}
|
||||||
|
@media(max-width:600px) {.exploration-fields {grid-template-columns:1fr}.settings-toggle-label {display:none}.exploration-settings-toggle {flex-wrap:wrap}}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { HomeComponent } from './home.component';
|
||||||
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
import { ExplorationService } from '../../services/exploration.service';
|
||||||
|
import { SystemService } from '../../services/system.service';
|
||||||
|
import { TaskRecommendationService } from '../../core/services/task-recommendation.service';
|
||||||
|
|
||||||
|
describe('Homepage exploration entry',()=>{
|
||||||
|
let api:any,agent:any,router:any;
|
||||||
|
const config={model:'gpt-6.1-sol',reasoning_effort:'high',timeout_seconds:1800,demonstration_timeout_seconds:900,demonstration_warning_seconds:60};
|
||||||
|
beforeEach(()=>{
|
||||||
|
api={settings:jasmine.createSpy().and.resolveTo(config),create:jasmine.createSpy().and.resolveTo({id:'conversation'}),act:jasmine.createSpy().and.resolveTo({run:{id:'run'}})};
|
||||||
|
agent={runTask:jasmine.createSpy(),selectSession:jasmine.createSpy(),fetchSessions:jasmine.createSpy(),fetchStatus:jasmine.createSpy()};router={navigate:jasmine.createSpy().and.resolveTo(true)};
|
||||||
|
TestBed.configureTestingModule({providers:[{provide:AgentService,useValue:agent},{provide:ExplorationService,useValue:api},{provide:SystemService,useValue:{isReady:()=>true}},{provide:TaskRecommendationService,useValue:{allTasks:signal([])}},{provide:Router,useValue:router}]});
|
||||||
|
});
|
||||||
|
it('creates and starts exploration using independent settings and opens its run',fakeAsync(()=>{
|
||||||
|
const home=TestBed.runInInjectionContext(()=>new HomeComponent());home.setProfile('exploration');flushMicrotasks();home.taskGoal.set('探索流程');home.runTask();flushMicrotasks();
|
||||||
|
expect(api.create).toHaveBeenCalledWith('探索流程',config,{},jasmine.any(String));expect(api.act).toHaveBeenCalledWith('conversation','start',{},jasmine.any(String));
|
||||||
|
expect(agent.runTask).not.toHaveBeenCalled();expect(agent.selectSession).toHaveBeenCalledWith('run',true);expect(router.navigate).toHaveBeenCalledWith(['/workspace']);expect(home.isSubmitting()).toBeFalse();
|
||||||
|
localStorage.removeItem('artemis.taskProfile');
|
||||||
|
}));
|
||||||
|
it('reuses create and start IDs after a lost admission response',fakeAsync(()=>{
|
||||||
|
const home=TestBed.runInInjectionContext(()=>new HomeComponent());home.selectedProfile.set('exploration');home.explorationConfig.set(config);home.taskGoal.set('探索流程');api.act.and.rejectWith(new Error('offline'));home.runTask();flushMicrotasks();
|
||||||
|
const createId=api.create.calls.mostRecent().args[3],startId=api.act.calls.mostRecent().args[3];expect(home.errorMessage()).toContain('失败');
|
||||||
|
api.act.and.resolveTo({run:{id:'run'}});home.runTask();flushMicrotasks();expect(api.create.calls.mostRecent().args[3]).toBe(createId);expect(api.act.calls.mostRecent().args[3]).toBe(startId);
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { CapabilitySettingsComponent } from '../../components/model-settings/capability-settings.component';
|
import { CapabilitySettingsComponent } from '../../components/model-settings/capability-settings.component';
|
||||||
|
import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from '../../components/setting-slider/setting-slider.component';
|
||||||
import { ModelSettingsComponent } from '../../components/model-settings/model-settings.component';
|
import { ModelSettingsComponent } from '../../components/model-settings/model-settings.component';
|
||||||
|
import { FeaturedTasksComponent } from '../../components/featured-tasks/featured-tasks.component';
|
||||||
/**
|
/**
|
||||||
* Copyright 2026 Google LLC
|
* Copyright 2026 Google LLC
|
||||||
*
|
*
|
||||||
@@ -18,7 +20,9 @@ import { ModelSettingsComponent } from '../../components/model-settings/model-se
|
|||||||
|
|
||||||
import { Component, signal, computed, inject, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core';
|
import { Component, signal, computed, inject, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { TaskPluginsComponent, TaskPluginOptions } from '../../components/task-plugins/task-plugins.component';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
import { ExplorationService, ExplorationConfig } from '../../services/exploration.service';
|
||||||
import { AgentService } from '../../services/agent.service';
|
import { AgentService } from '../../services/agent.service';
|
||||||
import { SystemService, formatAdbRestartFeedback } from '../../services/system.service';
|
import { SystemService, formatAdbRestartFeedback } from '../../services/system.service';
|
||||||
import {
|
import {
|
||||||
@@ -105,7 +109,7 @@ type AdbGuideTab = 'emulator' | 'usb' | 'wifi' | 'remote';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [FormsModule, ModelSettingsComponent, CapabilitySettingsComponent],
|
imports: [FormsModule, ModelSettingsComponent, CapabilitySettingsComponent, TaskPluginsComponent, SettingSliderComponent, FeaturedTasksComponent],
|
||||||
templateUrl: './home.component.html',
|
templateUrl: './home.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
changeDetection: ChangeDetectionStrategy.Eager,
|
||||||
styleUrl: './home.component.scss'
|
styleUrl: './home.component.scss'
|
||||||
@@ -167,7 +171,7 @@ export class HomeComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Task execution parameters
|
// Task execution parameters
|
||||||
public selectedProfile = signal<'flash' | 'pro'>('flash');
|
public selectedProfile = signal<'flash' | 'pro' | 'exploration'>('flash');
|
||||||
public taskGoal = signal<string>('');
|
public taskGoal = signal<string>('');
|
||||||
public isSubmitting = signal<boolean>(false);
|
public isSubmitting = signal<boolean>(false);
|
||||||
public errorMessage = signal<string | null>(null);
|
public errorMessage = signal<string | null>(null);
|
||||||
@@ -319,6 +323,7 @@ export class HomeComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Computed helper states delegating to SystemService
|
// Computed helper states delegating to SystemService
|
||||||
|
taskPlugins = signal<TaskPluginOptions>({});
|
||||||
public isReady = computed(() => this.systemService.isReady());
|
public isReady = computed(() => this.systemService.isReady());
|
||||||
public hasReadinessReport = computed(() => this.systemService.hasReadinessReport());
|
public hasReadinessReport = computed(() => this.systemService.hasReadinessReport());
|
||||||
public isLoading = computed(() => this.systemService.isLoading());
|
public isLoading = computed(() => this.systemService.isLoading());
|
||||||
@@ -475,6 +480,8 @@ export class HomeComponent implements OnInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
const savedProfile = localStorage.getItem('artemis.taskProfile');
|
||||||
|
if (savedProfile === 'exploration' || savedProfile === 'flash' || savedProfile === 'pro') this.setProfile(savedProfile);
|
||||||
|
|
||||||
this.loadProTuningDefaults();
|
this.loadProTuningDefaults();
|
||||||
// Initial fetch of system readiness & model configuration
|
// Initial fetch of system readiness & model configuration
|
||||||
@@ -749,8 +756,10 @@ export class HomeComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public setProfile(profile: 'flash' | 'pro'): void {
|
public setProfile(profile: 'flash' | 'pro' | 'exploration'): void {
|
||||||
this.selectedProfile.set(profile);
|
this.selectedProfile.set(profile);
|
||||||
|
localStorage.setItem('artemis.taskProfile', profile);
|
||||||
|
if (profile === 'exploration' && !this.explorationConfig()) void this.loadExplorationSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
public setCategory(cat: SuggestionCategory): void {
|
public setCategory(cat: SuggestionCategory): void {
|
||||||
@@ -784,7 +793,58 @@ export class HomeComponent implements OnInit, OnDestroy {
|
|||||||
this.activeTab.set('launcher');
|
this.activeTab.set('launcher');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exploration = inject(ExplorationService);
|
||||||
|
readonly reasoningValues = REASONING_VALUES;
|
||||||
|
readonly reasoningLabels = REASONING_LABELS;
|
||||||
|
readonly executionTimes = [30,60,300,600,900,1800,3600,7200,14400,28800,86400];
|
||||||
|
readonly executionTimeLabels = ['30 秒','1 分钟','5 分钟','10 分钟','15 分钟','30 分钟','1 小时','2 小时','4 小时','8 小时','24 小时'];
|
||||||
|
readonly demoTimes = this.executionTimes.slice(1);
|
||||||
|
readonly demoTimeLabels = this.executionTimeLabels.slice(1);
|
||||||
|
readonly warningTimes = [10,30,60,120,300,600];
|
||||||
|
readonly warningLabels = ['10 秒','30 秒','1 分钟','2 分钟','5 分钟','10 分钟'];
|
||||||
|
explorationSettingsOpen = signal(false);
|
||||||
|
explorationConfigValid() {
|
||||||
|
const c = this.explorationConfig();
|
||||||
|
return !!c?.model.trim() && c.demonstration_warning_seconds < c.demonstration_timeout_seconds;
|
||||||
|
}
|
||||||
|
explorationConfig = signal<ExplorationConfig | null>(null);
|
||||||
|
explorationSettingsBusy = signal(false);
|
||||||
|
explorationNotice = signal('');
|
||||||
|
private explorationRequests = new Map<string, {create: string; start: string}>();
|
||||||
|
async loadExplorationSettings() {
|
||||||
|
this.explorationSettingsBusy.set(true);
|
||||||
|
try { this.explorationConfig.set(await this.exploration.settings()); }
|
||||||
|
catch { this.errorMessage.set('探索配置读取失败,请重试。'); }
|
||||||
|
finally { this.explorationSettingsBusy.set(false); }
|
||||||
|
}
|
||||||
|
async saveExplorationSettings() {
|
||||||
|
const config = this.explorationConfig(); if (!config || this.explorationSettingsBusy() || !this.explorationConfigValid()) return;
|
||||||
|
this.explorationSettingsBusy.set(true); this.explorationNotice.set('');
|
||||||
|
try { this.explorationConfig.set(await this.exploration.saveSettings(config)); this.explorationNotice.set('已记住探索配置'); }
|
||||||
|
catch(e) { this.errorMessage.set((e as any)?.error?.detail || '配置保存失败'); }
|
||||||
|
finally { this.explorationSettingsBusy.set(false); }
|
||||||
|
}
|
||||||
|
private async startExploration(goal: string) {
|
||||||
|
const config = this.explorationConfig();
|
||||||
|
if (!config) { this.errorMessage.set('请先读取探索配置。'); this.isSubmitting.set(false); return; }
|
||||||
|
if (!this.explorationConfigValid()) { this.errorMessage.set('示范提前提醒须小于人工示范时限。'); this.isSubmitting.set(false); return; }
|
||||||
|
const taskConfig = {...this.taskPlugins()};
|
||||||
|
const key = JSON.stringify([goal, config, taskConfig]);
|
||||||
|
const ids = this.explorationRequests.get(key) || {create: crypto.randomUUID(), start: crypto.randomUUID()};
|
||||||
|
this.explorationRequests.set(key, ids);
|
||||||
|
try {
|
||||||
|
const conversation = await this.exploration.create(goal, config, taskConfig, ids.create);
|
||||||
|
const result = await this.exploration.act<{run: {id: string}}>(conversation.id, 'start', {}, ids.start);
|
||||||
|
this.explorationRequests.delete(key);
|
||||||
|
this.agentService.selectSession(result.run.id, true);
|
||||||
|
this.agentService.fetchSessions(); this.agentService.fetchStatus();
|
||||||
|
await this.router.navigate(['/workspace']);
|
||||||
|
} catch(e) { this.errorMessage.set((e as any)?.error?.detail || '探索启动失败,可重试。'); }
|
||||||
|
finally { this.isSubmitting.set(false); }
|
||||||
|
}
|
||||||
|
|
||||||
public runTask(): void {
|
public runTask(): void {
|
||||||
|
if (this.isSubmitting()) return;
|
||||||
const goal = this.taskGoal().trim();
|
const goal = this.taskGoal().trim();
|
||||||
if (!goal) {
|
if (!goal) {
|
||||||
this.errorMessage.set("请先输入任务目标。");
|
this.errorMessage.set("请先输入任务目标。");
|
||||||
@@ -800,17 +860,20 @@ export class HomeComponent implements OnInit, OnDestroy {
|
|||||||
this.isSubmitting.set(true);
|
this.isSubmitting.set(true);
|
||||||
this.errorMessage.set(null);
|
this.errorMessage.set(null);
|
||||||
|
|
||||||
|
const profile = this.selectedProfile();
|
||||||
|
if (profile === 'exploration') { void this.startExploration(goal); return; }
|
||||||
this.agentService
|
this.agentService
|
||||||
.runTask(
|
.runTask(
|
||||||
goal,
|
goal,
|
||||||
this.selectedProfile(),
|
profile,
|
||||||
this.selectedProfile() === 'pro' && this.expectedOutput().trim()
|
this.selectedProfile() === 'pro' && this.expectedOutput().trim()
|
||||||
? this.expectedOutput().trim()
|
? this.expectedOutput().trim()
|
||||||
: undefined,
|
: undefined,
|
||||||
undefined,
|
undefined,
|
||||||
this.selectedProfile() === 'pro'
|
this.selectedProfile() === 'pro'
|
||||||
? { verificationLevel: this.verificationLevel().id, explorerMode: this.explorerMode().id }
|
? { verificationLevel: this.verificationLevel().id, explorerMode: this.explorerMode().id }
|
||||||
: undefined
|
: undefined,
|
||||||
|
{ ...this.taskPlugins() }
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<main class="plugins-page">
|
||||||
|
<header><div><span class="eyebrow">ARTEMIS · 扩展能力</span><h1>插件</h1><p>为安卓测试扩展电脑等操作能力,AI 按任务需要自主调用。</p></div>
|
||||||
|
<button (click)="refresh(); refreshCatalog()" [disabled]="busy() || loading() || catalogLoading()">刷新</button></header>
|
||||||
|
@if (error()) { <div class="message error" role="alert">{{error()}}</div> }
|
||||||
|
@if (notice()) { <div class="message success" role="status">{{notice()}}</div> }
|
||||||
|
@for (resource of blocked(); track resource[0]) {
|
||||||
|
<section class="message error" role="alert"><strong>资源保护 · {{resource[0]}}</strong><p>{{resource[1]}}</p><button (click)="confirmingResource.set(resource[0])" [disabled]="busy()">核对并解除保护</button></section>
|
||||||
|
}
|
||||||
|
@if (progress(); as job) {
|
||||||
|
<section class="install-progress" aria-live="polite"><div><span>{{job.status === 'completed' ? '安装完成,请点击启用。' : job.stage}}</span><strong>{{job.progress}}%</strong></div>
|
||||||
|
<progress max="100" [value]="job.progress" [attr.aria-label]="job.stage"></progress></section>
|
||||||
|
}
|
||||||
|
<div class="tabs" role="tablist" aria-label="插件来源">
|
||||||
|
<button role="tab" [attr.aria-selected]="tab() === 'installed'" [class.active]="tab() === 'installed'" (click)="tab.set('installed')">已安装 <span>{{installed().length}}</span></button>
|
||||||
|
<button role="tab" [attr.aria-selected]="tab() === 'market'" [class.active]="tab() === 'market'" (click)="tab.set('market')">插件市场</button>
|
||||||
|
</div>
|
||||||
|
@if (tab() === 'installed') {
|
||||||
|
@if (loading()) { <p role="status" class="empty">正在读取插件…</p> }
|
||||||
|
@else if (!installed().length) { <section class="empty"><span class="material-symbols-outlined">extension</span><h2>还没有安装插件</h2><p>从市场安装经过验证的扩展,或在下方导入自己的插件。</p><button class="primary" (click)="tab.set('market')">浏览插件市场</button></section> }
|
||||||
|
<div class="cards">@for (plugin of installed(); track plugin.manifest.id) {
|
||||||
|
<article class="card"><div class="card-top"><div class="plugin-icon"><span class="material-symbols-outlined">extension</span></div><div><h2>{{plugin.manifest.name}}</h2><span class="meta">v{{plugin.manifest.version}} · {{plugin.source === 'marketplace' ? '插件市场' : '本地 / 自定义'}}</span></div><span class="badge" [class.enabled]="plugin.enabled">{{plugin.enabled ? '已启用' : '未启用'}}</span></div>
|
||||||
|
<p>{{plugin.manifest.description || '暂无描述'}}</p><div class="meta">{{hasSettings(plugin) ? '可在设置中调整插件参数。' : '无需填写参数,启用后即可使用。'}}</div>
|
||||||
|
<footer><button [class.primary]="!plugin.enabled" (click)="toggle(plugin)" [disabled]="busy()">{{plugin.enabled ? '停用' : '启用'}}</button><button (click)="diagnose(plugin)" [disabled]="busy() || !plugin.enabled">检查插件</button><button (click)="edit(plugin)" [disabled]="busy()">{{hasSettings(plugin) ? '设置' : '高级设置'}}</button><button class="text-button" (click)="removing.set(plugin)" [disabled]="busy()">卸载</button></footer>
|
||||||
|
</article>
|
||||||
|
}</div>
|
||||||
|
<details class="advanced"><summary>高级接入 · 本地插件与自定义 MCP</summary><p>本地插件需要包含 plugin.json;导入后点击启用,按提示完成设置。</p>
|
||||||
|
<div class="inline"><input aria-label="本地插件路径" [(ngModel)]="localPath" placeholder="插件 ZIP 文件或目录的完整路径"><button (click)="install({local_path: localPath})" [disabled]="busy() || !localPath.trim()">导入</button></div>
|
||||||
|
<details><summary>添加自定义 MCP / Skill</summary><label>插件清单 JSON<textarea rows="12" [(ngModel)]="customManifest" spellcheck="false"></textarea></label><label>Skill 内容(可选)<textarea rows="4" [(ngModel)]="customSkill"></textarea></label><button (click)="addCustom()" [disabled]="busy()">添加插件</button></details>
|
||||||
|
</details>
|
||||||
|
} @else {
|
||||||
|
@if (catalogLoading()) { <section class="empty" role="status"><span class="material-symbols-outlined loading-icon" aria-hidden="true">progress_activity</span><h2>正在加载插件市场</h2><p>正在获取插件与验证记录…</p></section> }
|
||||||
|
@else if (catalogError()) { <div class="message error" role="alert">{{catalogError()}} <button (click)="refreshCatalog()">重试</button></div> }
|
||||||
|
@else if (!market().length) { <section class="empty"><h2>暂无可安装插件</h2><p>当前市场尚未收录安装包,可稍后刷新。</p></section> }
|
||||||
|
<div class="cards">@for (entry of market(); track entry.manifest.id + entry.manifest.version) {
|
||||||
|
<article class="card"><div class="card-top"><div class="plugin-icon"><span class="material-symbols-outlined">verified</span></div><div><h2>{{entry.manifest.name}}</h2><span class="meta">v{{entry.manifest.version}} · {{entry.manifest.platforms.join(' / ')}}</span></div>@if (entry.release_stage === 'preview') { <span class="badge preview">开发预览</span> }</div><p>{{entry.manifest.description}}</p><details><summary>验证范围与限制</summary><p>{{entry.verification}}</p></details><footer><button class="primary" (click)="install({plugin_id: entry.manifest.id, version: entry.manifest.version})" [disabled]="busy() || installedVersion(entry.manifest.id) === entry.manifest.version">{{installedVersion(entry.manifest.id) === entry.manifest.version ? '已安装' : installedVersion(entry.manifest.id) ? '安装此版本' : '安装插件'}}</button></footer></article>
|
||||||
|
}</div>
|
||||||
|
}
|
||||||
|
</main>
|
||||||
|
@if (confirmingEnable(); as plugin) {
|
||||||
|
<div class="modal-backdrop"><section class="dialog small" role="dialog" aria-modal="true" aria-labelledby="plugin-enable-title">
|
||||||
|
<h2 id="plugin-enable-title">启用{{plugin.manifest.name}}?</h2>
|
||||||
|
@if (plugin.manifest.resources.includes('desktop:local')) {
|
||||||
|
<p>允许 ARTEMIS 在测试需要时查看本机窗口,并操作桌面应用和浏览器。</p>
|
||||||
|
} @else { <p>允许 ARTEMIS 在测试需要时使用此插件提供的工具。</p> }
|
||||||
|
<p class="meta">授权会保存在本机,后续任务无需重复确认。你可以随时停用插件,或在高级设置中限制允许的操作。</p>
|
||||||
|
@if (error()) { <p class="message error" role="alert">{{error()}}</p> }
|
||||||
|
<footer><button (click)="confirmingEnable.set(null)" [disabled]="busy()">取消</button><button class="primary" (click)="confirmEnable()" [disabled]="busy()">{{busy() ? '启用中…' : '允许并启用'}}</button></footer>
|
||||||
|
</section></div>
|
||||||
|
}
|
||||||
|
@if (confirmingResource(); as resource) {
|
||||||
|
<div class="modal-backdrop"><section class="dialog small" role="dialog" aria-modal="true" aria-labelledby="resource-confirm-title"><h2 id="resource-confirm-title">确认 {{resource}} 已停止操作</h2><p>请先核对目标设备或远程服务,确认之前的插件动作已经停止,并检查实际状态。解除后任务才可再次操作此资源。</p><footer><button (click)="confirmingResource.set(null)" [disabled]="busy()">取消</button><button class="primary" (click)="confirmStopped()" [disabled]="busy()">已核对,解除保护</button></footer></section></div>
|
||||||
|
}
|
||||||
|
@if (editing(); as plugin) {
|
||||||
|
<div class="modal-backdrop"><section class="dialog" role="dialog" aria-modal="true" aria-labelledby="plugin-config-title"><header><div><h2 id="plugin-config-title">{{plugin.manifest.name}} · 设置</h2><p>{{hasSettings(plugin) ? '填写插件需要的信息,高级选项通常无需修改。' : '此插件无需填写参数,高级选项通常无需修改。'}}</p></div><button aria-label="关闭配置" (click)="editing.set(null)" [disabled]="busy()">×</button></header>
|
||||||
|
@for (entry of fields(); track entry[0]) { <label>{{entry[1].label}}{{entry[1].required ? ' *' : ''}}
|
||||||
|
@if (entry[1].secret) { <input type="password" autocomplete="new-password" [(ngModel)]="profile.secrets![entry[0]]" [placeholder]="profile.configured_secrets?.includes(entry[0]) ? '已保存,留空保留' : '仅保存在本机'"> }
|
||||||
|
@else { <input [(ngModel)]="profile.values[entry[0]]" [placeholder]="entry[1].default"> }
|
||||||
|
@if (entry[1].description) { <small>{{entry[1].description}}</small> }
|
||||||
|
</label> }
|
||||||
|
<details [open]="enableAfterSave && !defaultProfile(plugin)"><summary>高级设置 · 多套参数与控制范围</summary>
|
||||||
|
<p>仅在需要切换不同设备、账号或限制操作范围时使用。</p>
|
||||||
|
@if (enableAfterSave && !defaultProfile(plugin)) { <p>请选择一套设置,并勾选「默认使用这套设置」,以便 AI 自动调用。</p> }
|
||||||
|
<div class="inline"><select aria-label="选择参数设置" [ngModel]="profile.name" (ngModelChange)="selectProfile($event)">@for (p of plugin.profiles; track p.name) { <option [value]="p.name">{{p.label}}</option> }</select><button (click)="newProfile()">新增一套</button></div>
|
||||||
|
<div class="two-columns"><label>设置标识<input [(ngModel)]="profile.name" placeholder="如 lab-router"></label><label>显示名称<input [(ngModel)]="profile.label" placeholder="如实验室路由器"></label></div>
|
||||||
|
<label class="checkbox"><input type="checkbox" [(ngModel)]="profile.is_default">默认使用这套设置</label>
|
||||||
|
<label>允许工具(每行一个,留空使用全部工具)<textarea rows="3" [(ngModel)]="allowedTools"></textarea></label><label>参数约束 JSON<textarea rows="4" [(ngModel)]="constraints" placeholder="按工具名称配置允许的参数值"></textarea></label><label>共享资源(同一目标使用相同标识,每行一个)<textarea rows="2" [(ngModel)]="resources" placeholder="router:lab"></textarea></label>
|
||||||
|
</details>
|
||||||
|
<label class="checkbox"><input type="checkbox" [(ngModel)]="profile.authorized">允许 ARTEMIS 在测试中使用此插件</label><p class="meta">插件进程使用当前用户的系统权限。工具与参数范围由 ARTEMIS 检查。</p>
|
||||||
|
@if (error()) { <p class="message error" role="alert">{{error()}}</p> }
|
||||||
|
<footer><button (click)="editing.set(null)" [disabled]="busy()">取消</button><button class="primary" (click)="save()" [disabled]="busy() || !profile.name.trim() || (enableAfterSave && !profile.authorized)">{{busy() ? '保存中…' : enableAfterSave ? '保存并启用' : '保存设置'}}</button></footer>
|
||||||
|
</section></div>
|
||||||
|
}
|
||||||
|
@if (removing(); as plugin) { <div class="modal-backdrop"><section class="dialog small" role="dialog" aria-modal="true" aria-labelledby="remove-title"><h2 id="remove-title">卸载 {{plugin.manifest.name}}?</h2><p>此插件及其保存的设置将不再供新任务使用。</p><footer><button (click)="removing.set(null)" [disabled]="busy()">取消</button><button class="danger" (click)="remove()" [disabled]="busy()">确认卸载</button></footer></section></div> }
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
:host { display: block; color: #1e293b; font-size: 14px; }
|
||||||
|
.plugins-page { max-width: 1120px; padding: 88px 32px 56px; margin: auto; }
|
||||||
|
header, .card-top, footer, .inline, .install-progress > div { display: flex; align-items: center; gap: 12px; }
|
||||||
|
header { justify-content: space-between; margin-bottom: 28px; }
|
||||||
|
h1 { margin: 8px 0; font-size: 30px; letter-spacing: -.6px; } h2 { margin: 0 0 6px; font-size: 17px; }
|
||||||
|
p { color: #64748b; line-height: 1.7; white-space: pre-wrap; margin: 8px 0 16px; }
|
||||||
|
.eyebrow { color: #3b6e9c; font-size: 11px; letter-spacing: 1.4px; font-weight: 600; }
|
||||||
|
button { border: 1px solid #dce3eb; border-radius: 20px; background: #fff; color: #334155; padding: 8px 16px; font-size: 13px; cursor: pointer; white-space: nowrap; }
|
||||||
|
button:hover:not(:disabled) { border-color: #9fb7d3; background: #f0f6ff; } button:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
button.primary { color: #fff; background: #2563eb; border-color: #2563eb; } button.danger { color: #b91c1c; border-color: #fecaca; }
|
||||||
|
.text-button { border: 0; margin-left: auto; color: #64748b; } :focus-visible { outline: 2px solid #3b82f6; outline-offset: 3px; }
|
||||||
|
.tabs { display: flex; gap: 4px; width: fit-content; margin-bottom: 24px; background: #f1f5f9; padding: 4px; border-radius: 24px; }
|
||||||
|
.tabs button { border: 0; background: transparent; }.tabs button.active { color: #1d4ed8; background: #fff; box-shadow: 0 1px 4px #0f172a0d; font-weight: 600; }.tabs span { margin-left: 8px; }
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); gap: 18px; }
|
||||||
|
.card { padding: 22px; background: #fff; border: 1px solid #e1e7ef; border-radius: 14px; }.card > p { min-height: 48px; margin-top: 18px; }.card footer { margin-top: 20px; flex-wrap: wrap; }
|
||||||
|
.plugin-icon { display: grid; place-items: center; width: 44px; height: 44px; flex-shrink: 0; color: #4477ba; background: #eef4ff; border-radius: 12px; }
|
||||||
|
.meta, small { color: #64748b; font-size: 12px; line-height: 1.6; }.badge { margin-left: auto; white-space: nowrap; padding: 4px 9px; border-radius: 20px; background: #f1f5f9; font-size: 11px; color: #64748b; }.badge.enabled { background: #ecfdf5; color: #047857; }
|
||||||
|
.empty { padding: 55px 24px; text-align: center; border: 1px dashed #cbd5e1; border-radius: 14px; background: #ffffff80; margin-bottom: 24px; }.empty > span { font-size: 36px; color: #94a3b8; margin-bottom: 16px; }
|
||||||
|
.message { padding: 12px 16px; border-radius: 8px; margin-bottom: 18px; line-height: 1.6; }.error { color: #b91c1c; background: #fff1f2; }.success { color: #047857; background: #ecfdf5; }
|
||||||
|
.install-progress { background: #fff; border: 1px solid #dbeafe; padding: 18px; border-radius: 12px; margin-bottom: 20px; }.install-progress > div { justify-content: space-between; }progress { width: 100%; height: 8px; margin-top: 12px; accent-color: #2563eb; }
|
||||||
|
details { margin: 16px 0; }summary { cursor: pointer; color: #475569; padding: 8px 0; }.advanced { padding: 18px 22px; border: 1px solid #e2e8f0; border-radius: 12px; margin-top: 24px; }
|
||||||
|
label { display: flex; flex-direction: column; gap: 7px; margin: 16px 0; font-size: 13px; }input, select, textarea { border: 1px solid #d5dde7; background: #fff; border-radius: 8px; padding: 10px 12px; color: #1e293b; font-size: 13px; min-width: 0; width: 100%; }textarea { resize: vertical; font-family: ui-monospace, 'Microsoft YaHei', monospace; line-height: 1.6; }.inline > input, .inline > select { flex: 1; }
|
||||||
|
.modal-backdrop { position: fixed; inset: 0; background: #0f172a50; backdrop-filter: blur(3px); display: grid; place-items: center; z-index: 1300; padding: 24px; }.dialog { max-width: 620px; width: 100%; max-height: 90vh; overflow-y: auto; background: #fff; padding: 28px; border-radius: 16px; box-shadow: 0 24px 80px #0f172a30; }.dialog.small { max-width: 430px; }.dialog header { margin-bottom: 12px; }.dialog footer { justify-content: flex-end; margin-top: 24px; }.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }.checkbox { flex-direction: row; align-items: center; gap: 10px; }.checkbox input { width: auto; }
|
||||||
|
@media (max-width: 640px) { .plugins-page { padding: 80px 16px 32px; }.card { padding: 18px; }.dialog { padding: 20px; }.two-columns { grid-template-columns: 1fr; gap: 0; }.card-top { gap: 8px; }h2 { font-size: 15px; } }
|
||||||
|
|
||||||
|
.loading-icon { animation: spin 1s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .loading-icon { animation: none; } }
|
||||||
|
|
||||||
|
.badge.preview { background: #fffbeb; color: #92400e; }
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { PluginsComponent } from './plugins.component';
|
||||||
|
import { InstalledPlugin } from '../../services/plugin.service';
|
||||||
|
|
||||||
|
describe('PluginsComponent', () => {
|
||||||
|
let http: HttpTestingController;
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({ imports: [PluginsComponent], providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||||
|
http = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
afterEach(() => http.verify());
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const fixture = TestBed.createComponent(PluginsComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
http.expectOne('/api/plugins').flush({ plugins: [], blocked_resources: { 'router:lab': '操作结果不确定' } });
|
||||||
|
http.expectOne('/api/plugins/catalog').flush({ plugins: [] });
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function desktopPlugin(): InstalledPlugin {
|
||||||
|
return { enabled: false, source: 'marketplace', manifest: {
|
||||||
|
id: 'desktop', name: '电脑控制', version: '1.0.0', description: '操作桌面与浏览器',
|
||||||
|
artemis_version: '>=2.0.0', platforms: ['win32'], source: '', config: {},
|
||||||
|
resources: ['desktop:local'], targets: ['computer', 'mixed']
|
||||||
|
}, profiles: [{ name: 'default', label: '默认配置', is_default: true, values: {},
|
||||||
|
configured_secrets: [], allowed_tools: null, argument_constraints: {}, resources: [], authorized: false }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickButton(fixture: ReturnType<typeof setup>, text: string) {
|
||||||
|
const button = Array.from(fixture.nativeElement.querySelectorAll('button') as NodeListOf<HTMLButtonElement>)
|
||||||
|
.find(button => button.textContent?.trim() === text);
|
||||||
|
expect(button).toBeDefined(); button!.click(); fixture.detectChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('offers a simple first-enable confirmation without requiring connection setup, and cancel writes nothing', async () => {
|
||||||
|
const fixture = setup(); await fixture.whenStable();
|
||||||
|
const plugin = desktopPlugin(); fixture.componentInstance.installed.set([plugin]); fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('无需填写参数');
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('连接配置');
|
||||||
|
clickButton(fixture, '启用');
|
||||||
|
expect(fixture.nativeElement.querySelector('[role="dialog"]').textContent).toContain('操作桌面应用和浏览器');
|
||||||
|
expect(fixture.nativeElement.querySelector('[role="dialog"] input')).toBeNull();
|
||||||
|
http.expectNone('/api/plugins/desktop/profiles'); http.expectNone('/api/plugins/desktop/enabled');
|
||||||
|
clickButton(fixture, '取消');
|
||||||
|
expect(fixture.nativeElement.querySelector('[role="dialog"]')).toBeNull();
|
||||||
|
expect(plugin.profiles[0].authorized).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authorizes only the default settings, preserves restrictions and saved secrets, then enables', async () => {
|
||||||
|
const fixture = setup(); await fixture.whenStable(); const component = fixture.componentInstance;
|
||||||
|
const plugin = desktopPlugin(); const profile = plugin.profiles[0];
|
||||||
|
profile.allowed_tools = ['observe']; profile.argument_constraints = { observe: { target: ['lab'] } };
|
||||||
|
profile.resources = ['desktop:lab']; profile.values = { target: 'lab' }; profile.configured_secrets = ['token'];
|
||||||
|
plugin.manifest.config = { token: { label: '密钥', secret: true, required: true, default: '', description: '' } };
|
||||||
|
plugin.profiles.unshift({ ...structuredClone(profile), name: 'other', is_default: false });
|
||||||
|
await component.toggle(plugin); const operation = component.confirmEnable();
|
||||||
|
const request = http.expectOne('/api/plugins/desktop/profiles');
|
||||||
|
expect(request.request.body).toEqual({ name: 'default', label: '默认配置', is_default: true,
|
||||||
|
authorized: true, values: { target: 'lab' }, secrets: {}, allowed_tools: ['observe'],
|
||||||
|
argument_constraints: { observe: { target: ['lab'] } }, resources: ['desktop:lab'] });
|
||||||
|
http.expectNone('/api/plugins/desktop/enabled');
|
||||||
|
request.flush({}); await Promise.resolve();
|
||||||
|
const enable = http.expectOne('/api/plugins/desktop/enabled'); expect(enable.request.body).toEqual({ enabled: true });
|
||||||
|
enable.flush({}); await Promise.resolve();
|
||||||
|
http.expectOne('/api/plugins').flush({ plugins: [{ ...plugin, enabled: true }] });
|
||||||
|
await operation; fixture.detectChanges();
|
||||||
|
expect(component.confirmingEnable()).toBeNull(); expect(component.notice()).toContain('插件已启用');
|
||||||
|
expect(component.installed()[0].enabled).toBeTrue();
|
||||||
|
expect(plugin.profiles.every(p => !p.authorized)).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const failure of ['profiles', 'enabled']) {
|
||||||
|
it(`keeps first-enable confirmation retryable when ${failure} fails`, async () => {
|
||||||
|
const fixture = setup(); await fixture.whenStable(); const component = fixture.componentInstance;
|
||||||
|
await component.toggle(desktopPlugin()); const operation = component.confirmEnable();
|
||||||
|
const save = http.expectOne('/api/plugins/desktop/profiles');
|
||||||
|
if (failure === 'profiles') {
|
||||||
|
save.flush({ detail: '保存失败' }, { status: 409, statusText: 'Conflict' });
|
||||||
|
http.expectNone('/api/plugins/desktop/enabled');
|
||||||
|
} else {
|
||||||
|
save.flush({}); await Promise.resolve();
|
||||||
|
http.expectOne('/api/plugins/desktop/enabled').flush({ detail: '启用失败' }, { status: 409, statusText: 'Conflict' });
|
||||||
|
}
|
||||||
|
await operation; fixture.detectChanges();
|
||||||
|
expect(component.confirmingEnable()).not.toBeNull(); expect(component.busy()).toBeFalse();
|
||||||
|
expect(component.notice()).toBe(''); expect(component.error()).toContain('失败');
|
||||||
|
expect(fixture.nativeElement.querySelector('[role="dialog"] [role="alert"]').textContent).toContain('失败');
|
||||||
|
http.expectNone('/api/plugins');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('re-enables already authorized settings without asking or saving again', async () => {
|
||||||
|
const fixture = setup(); await fixture.whenStable(); const component = fixture.componentInstance;
|
||||||
|
const plugin = desktopPlugin(); plugin.profiles[0].authorized = true;
|
||||||
|
const operation = component.toggle(plugin);
|
||||||
|
expect(component.confirmingEnable()).toBeNull(); expect(component.editing()).toBeNull();
|
||||||
|
http.expectNone('/api/plugins/desktop/profiles');
|
||||||
|
http.expectOne('/api/plugins/desktop/enabled').flush({}); await Promise.resolve();
|
||||||
|
http.expectOne('/api/plugins').flush({ plugins: [{ ...plugin, enabled: true }] });
|
||||||
|
await operation;
|
||||||
|
expect(component.notice()).toContain('插件已启用');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens required parameters with advanced settings collapsed, then saves and enables', async () => {
|
||||||
|
const fixture = setup(); await fixture.whenStable(); const component = fixture.componentInstance;
|
||||||
|
const plugin = desktopPlugin(); plugin.manifest.config = {
|
||||||
|
address: { label: '服务地址', required: true, secret: false, default: '', description: '' }
|
||||||
|
};
|
||||||
|
await component.toggle(plugin); fixture.detectChanges(); await fixture.whenStable();
|
||||||
|
expect(component.confirmingEnable()).toBeNull(); expect(component.editing()).toBe(plugin);
|
||||||
|
expect(fixture.nativeElement.querySelector('[role="dialog"] details').open).toBeFalse();
|
||||||
|
const address: HTMLInputElement = fixture.nativeElement.querySelector('[role="dialog"] label input');
|
||||||
|
address.value = 'http://localhost:1234'; address.dispatchEvent(new Event('input'));
|
||||||
|
const authorize: HTMLInputElement = fixture.nativeElement.querySelector('[role="dialog"] > .checkbox input');
|
||||||
|
authorize.click(); fixture.detectChanges(); await fixture.whenStable();
|
||||||
|
const operation = component.save(); const save = http.expectOne('/api/plugins/desktop/profiles');
|
||||||
|
expect(save.request.body.values).toEqual({ address: 'http://localhost:1234' });
|
||||||
|
expect(save.request.body.authorized).toBeTrue(); save.flush({}); await Promise.resolve();
|
||||||
|
http.expectOne('/api/plugins/desktop/enabled').flush({}); await Promise.resolve();
|
||||||
|
http.expectOne('/api/plugins').flush({ plugins: [{ ...plugin, enabled: true }] }); await operation;
|
||||||
|
expect(component.editing()).toBeNull(); expect(component.notice()).toContain('插件已启用');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires an explicit default when no default exists, rather than silently authorizing another account', async () => {
|
||||||
|
const fixture = setup(); await fixture.whenStable(); const component = fixture.componentInstance;
|
||||||
|
const plugin = desktopPlugin(); plugin.profiles[0].name = 'lab'; plugin.profiles[0].is_default = false;
|
||||||
|
await component.toggle(plugin); fixture.detectChanges();
|
||||||
|
expect(component.confirmingEnable()).toBeNull();
|
||||||
|
expect(fixture.nativeElement.querySelector('[role="dialog"] details').open).toBeTrue();
|
||||||
|
component.profile.authorized = true; await component.save();
|
||||||
|
http.expectNone('/api/plugins/desktop/profiles'); http.expectNone('/api/plugins/desktop/enabled');
|
||||||
|
expect(component.error()).toContain('默认使用这套设置');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('distinguishes market loading, failure, empty and available states', async () => {
|
||||||
|
const fixture = TestBed.createComponent(PluginsComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.componentInstance.tab.set('market'); fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('正在加载插件市场');
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('暂无可安装插件');
|
||||||
|
http.expectOne('/api/plugins').flush({ plugins: [] });
|
||||||
|
http.expectOne('/api/plugins/catalog').flush({ detail: '暂时无法读取插件市场' }, { status: 502, statusText: 'Bad Gateway' });
|
||||||
|
await fixture.whenStable(); fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('暂时无法读取插件市场');
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('暂无可安装插件');
|
||||||
|
const retry = fixture.componentInstance.refreshCatalog();
|
||||||
|
http.expectOne('/api/plugins/catalog').flush({ plugins: [] });
|
||||||
|
await retry; fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('暂无可安装插件');
|
||||||
|
const refresh = fixture.componentInstance.refreshCatalog();
|
||||||
|
http.expectOne('/api/plugins/catalog').flush({ plugins: [{ manifest: { id: 'desktop', name: '电脑控制', version: '1.0.0', platforms: ['win32'], description: '操作桌面及浏览器' }, verification: '开发预览:已验证基础表单操作' }] });
|
||||||
|
await refresh; fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('电脑控制');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('开发预览');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('安装插件');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state and requires explicit confirmation before releasing a protected resource', async () => {
|
||||||
|
const fixture = setup();
|
||||||
|
await fixture.whenStable(); fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('还没有安装插件');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('router:lab');
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
await component.confirmStopped();
|
||||||
|
http.expectNone('/api/plugins/resources/confirm-stopped');
|
||||||
|
component.confirmingResource.set('router:lab');
|
||||||
|
const operation = component.confirmStopped();
|
||||||
|
const request = http.expectOne('/api/plugins/resources/confirm-stopped');
|
||||||
|
expect(request.request.body).toEqual({ resource: 'router:lab', confirmed_stopped: true });
|
||||||
|
request.flush({ ok: true });
|
||||||
|
await Promise.resolve();
|
||||||
|
http.expectOne('/api/plugins').flush({ plugins: [], blocked_resources: {} });
|
||||||
|
await operation;
|
||||||
|
expect(component.blocked()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps saved secrets write-only and preserves the configuration form on failure', async () => {
|
||||||
|
const fixture = setup(); await fixture.whenStable();
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.edit({ manifest: { id: 'demo', config: {} }, profiles: [{ name: 'default', label: '默认', values: {}, configured_secrets: ['token'], allowed_tools: null, argument_constraints: {}, resources: [], authorized: true }] } as unknown as InstalledPlugin);
|
||||||
|
const operation = component.save();
|
||||||
|
const request = http.expectOne('/api/plugins/demo/profiles');
|
||||||
|
expect(request.request.body.secrets).toEqual({});
|
||||||
|
expect(request.request.body.configured_secrets).toBeUndefined();
|
||||||
|
request.flush({ detail: '插件正被任务使用' }, { status: 409, statusText: 'Conflict' });
|
||||||
|
await operation;
|
||||||
|
expect(component.editing()).not.toBeNull();
|
||||||
|
expect(component.error()).toContain('任务使用');
|
||||||
|
expect(component.busy()).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows installation failure without pretending that the package was installed', async () => {
|
||||||
|
const fixture = setup(); await fixture.whenStable();
|
||||||
|
const operation = fixture.componentInstance.install({ plugin_id: 'demo' });
|
||||||
|
http.expectOne('/api/plugins/install').flush({ job_id: 'job-1' });
|
||||||
|
await Promise.resolve();
|
||||||
|
http.expectOne('/api/plugins/install/job-1').flush({ id: 'job-1', status: 'failed', stage: '校验失败', progress: 75 });
|
||||||
|
await operation;
|
||||||
|
expect(fixture.componentInstance.error()).toBe('校验失败');
|
||||||
|
expect(fixture.componentInstance.busy()).toBeFalse();
|
||||||
|
expect(fixture.componentInstance.installed()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 373 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user