Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59f458205d | ||
|
|
4465a2e199 | ||
|
|
c818a09a49 | ||
|
|
e1e26772d9 | ||
|
|
1dc2b0c686 | ||
|
|
84e183d28d | ||
|
|
0e13bb5c29 | ||
|
|
8522fa3ca3 | ||
|
|
bfcd828fa8 | ||
|
|
057f0c3050 | ||
|
|
cec9898e4c | ||
|
|
60ac066859 | ||
|
|
9d6833f092 |
No files matched your search
+8
-1
@@ -2,10 +2,12 @@
|
|||||||
# 所有模型调用仅使用 OpenAI Responses API;环境变量优先于 .env。
|
# 所有模型调用仅使用 OpenAI Responses API;环境变量优先于 .env。
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
# 接口根地址:通常以 /v1 结尾,不要添加 /responses 或 /chat/completions。
|
# 接口根地址:通常以 /v1 结尾,不要添加 /responses 或 /chat/completions。
|
||||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
OPENAI_BASE_URL=https://api-n-cd.playones.com/v1
|
||||||
OPENAI_MODEL=mimo-v2.6-flash
|
OPENAI_MODEL=mimo-v2.6-flash
|
||||||
# 单次模型 HTTP 请求超时(秒)
|
# 单次模型 HTTP 请求超时(秒)
|
||||||
OPENAI_TIMEOUT_SECONDS=300
|
OPENAI_TIMEOUT_SECONDS=300
|
||||||
|
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
|
||||||
|
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
|
||||||
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
||||||
OPENAI_MAX_RETRIES=5
|
OPENAI_MAX_RETRIES=5
|
||||||
OPENAI_RETRY_INTERVAL_SECONDS=10
|
OPENAI_RETRY_INTERVAL_SECONDS=10
|
||||||
@@ -14,8 +16,13 @@ OPENAI_REASONING_EFFORT=high
|
|||||||
|
|
||||||
# 功能开关:保存后对新任务生效
|
# 功能开关:保存后对新任务生效
|
||||||
ARTEMIS_VIDEO_ENABLED=false
|
ARTEMIS_VIDEO_ENABLED=false
|
||||||
|
ARTEMIS_RECORDING_ENABLED=false
|
||||||
ARTEMIS_OUTPUTTER_ENABLED=true
|
ARTEMIS_OUTPUTTER_ENABLED=true
|
||||||
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
||||||
|
# Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。
|
||||||
|
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3
|
||||||
|
# Flash 连续无工具调用的最大次数;无效回复不会重复送回模型。
|
||||||
|
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS=3
|
||||||
|
|
||||||
# 视频分段、重试与单次分析超时(秒)
|
# 视频分段、重试与单次分析超时(秒)
|
||||||
ARTEMIS_VIDEO_CHUNK_SECONDS=60
|
ARTEMIS_VIDEO_CHUNK_SECONDS=60
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
|
|
||||||
- **自然语言任务**:Flash 适合明确的操作流程,Pro 适合需要计划、分支探索和验证的任务。
|
- **自然语言任务**:Flash 适合明确的操作流程,Pro 适合需要计划、分支探索和验证的任务。
|
||||||
- **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。
|
- **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。
|
||||||
- **批量测试**:下载 Excel 模板、校验导入、顺序执行,支持暂停、继续和复制失败项。
|
- **批量测试**:按新版 Excel 模板导入提示词,每项可独立指定模型、模式和参数;任务可暂停、补充指导并继续。
|
||||||
- **报告与历史**:导出 Word、Excel 和批次 ZIP;历史按日期分组,支持搜索和筛选。
|
- **报告与历史**:导出 Word、Excel、步骤截图、录屏和任务资料 ZIP;历史按日期分组,支持搜索和筛选。
|
||||||
- **AI 接入**:随源码提供 MCP 与 Skill,让 AI 完成设备诊断、任务执行、历史分析和报告交付。
|
- **AI 接入**:随源码提供 MCP 与 Skill,让 AI 完成设备诊断、任务执行、历史分析和报告交付。
|
||||||
|
|
||||||
## 安装与启动
|
## 安装与启动
|
||||||
|
|
||||||
准备一台开启 USB 调试并完成授权的 Android 手机,或已启动的 Android 模拟器。首次安装需要联网,建议使用 Git 克隆以支持后续更新。
|
准备一台开启 USB 调试并完成授权的 Android 手机,或已启动的 Android 模拟器。首次安装需要联网。可使用 Git 克隆或下载 1.1.1 及更新版本的发布源码包;新版 ZIP 解压后也保留 Git 更新能力。
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
@@ -24,17 +24,18 @@ cd Artemis-Eapil
|
|||||||
.\start.bat
|
.\start.bat
|
||||||
```
|
```
|
||||||
|
|
||||||
国内网络使用 `.\start-cn.bat`。启动脚本会检测并安装缺少的 uv、Python 和所需工具,创建或复用 `.venv`,同步依赖并构建网页。启动后访问 **http://127.0.0.1:8000**。
|
国内网络使用 `.\start-cn.bat`,支持 `-Help` 查看中文选项。启动脚本会检测并安装缺少的 uv、Python 和所需工具,创建或复用 `.venv`,同步依赖并构建网页。启动后访问 **http://127.0.0.1:8000**。
|
||||||
|
|
||||||
常用选项:
|
常用选项:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\start-cn.bat -SetupOnly # 仅准备 Python 环境并检查已有 MCP 配置
|
.\start-cn.bat -SetupOnly # 准备环境并安装/更新已检测客户端的 MCP
|
||||||
.\start.bat -Port 8001 # 指定端口
|
.\start.bat -Port 8001 # 指定端口
|
||||||
.\start.bat -SkipUpdate # 跳过 Git 源码更新检查
|
.\start.bat -SkipUpdate # 跳过 Git 源码更新检查
|
||||||
|
.\start.bat -SkipMcpSync # MCP 配置异常时,仅启动网页
|
||||||
```
|
```
|
||||||
|
|
||||||
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。scrcpy 默认从 GitHub 下载,无法访问时可配置可信的 scrcpy_url 或预先安装。
|
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。国内脚本使用山东大学镜像安装 scrcpy,并校验官方 SHA256;也可预先安装或自定义 HTTPS 下载地址。
|
||||||
|
|
||||||
### macOS / Linux
|
### macOS / Linux
|
||||||
|
|
||||||
@@ -49,19 +50,19 @@ cd Artemis-Eapil
|
|||||||
|
|
||||||
预设模型为 `gpt-5.6-luna`、`mimo-v2.6-flash`,也可填写自定义名称。默认模型是 `mimo-v2.6-flash`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**;视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。
|
预设模型为 `gpt-5.6-luna`、`mimo-v2.6-flash`,也可填写自定义名称。默认模型是 `mimo-v2.6-flash`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**;视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。
|
||||||
|
|
||||||
也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
|
也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
|
||||||
|
|
||||||
报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。
|
报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。
|
||||||
|
|
||||||
## 让 AI 使用
|
## 让 AI 使用
|
||||||
|
|
||||||
环境与模型配置完成后,例如为 Codex 接入 MCP:
|
Windows 启动脚本会为已检测到的 AI 客户端安装或更新 MCP。未被检测到的客户端,可手动安装,例如 Codex:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
uv run artemis mcp --install codex
|
uv run artemis mcp --install codex
|
||||||
```
|
```
|
||||||
|
|
||||||
在客户端重新加载 MCP,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。其他客户端可通过 `uv run artemis mcp --help` 查看接入方式。
|
安装或更新后,在客户端重新加载 MCP 或重启客户端,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。其他客户端可通过 `uv run artemis mcp --help` 查看接入方式。
|
||||||
|
|
||||||
示例指令:
|
示例指令:
|
||||||
|
|
||||||
@@ -69,9 +70,9 @@ uv run artemis mcp --install codex
|
|||||||
|
|
||||||
## 更新与排错
|
## 更新与排错
|
||||||
|
|
||||||
Windows 用户再次运行一键脚本时,会检查 Git 上游、项目依赖、前端构建和已有 MCP 配置,仅对源码做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。更新后需在 AI 客户端重新加载 MCP。
|
Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建并同步已检测客户端的 MCP。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。MCP 配置同步失败会明确停止启动,可使用 `-SkipMcpSync` 仅启动网页。更新后需在 AI 客户端重新加载 MCP。
|
||||||
|
|
||||||
ZIP 安装可继续运行;需要自动拉取源码时,请改用 Git 克隆安装。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。
|
1.1.1 及更新版本的发布 ZIP 含浅层 Git 工作树,解压后再次运行一键脚本即可检查更新。1.1.0 及更早 ZIP 不含 Git 信息,无法自行拉取;需要下载新版源码包重新解压一次,之后即可自动更新。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。
|
||||||
|
|
||||||
更多说明:[批量任务与报告](docs/automation-workspace.md) · [AI 接入](docs/ai-automation.md) · [开发协作指南](AGENTS.md)
|
更多说明:[批量任务与报告](docs/automation-workspace.md) · [AI 接入](docs/ai-automation.md) · [开发协作指南](AGENTS.md)
|
||||||
|
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ class ServerState:
|
|||||||
return {
|
return {
|
||||||
str(run.get("lock_key") or run["device_id"])
|
str(run.get("lock_key") or run["device_id"])
|
||||||
for run in self.active_runs.values()
|
for run in self.active_runs.values()
|
||||||
if run.get("device_id")
|
if run.get("device_id") and not run.get("parked")
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -422,7 +422,7 @@ class SessionRepository:
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
def reconcile_orphaned_sessions(self) -> int:
|
def reconcile_orphaned_sessions(self) -> int:
|
||||||
"""Mark running sessions with no live worker process as failed.
|
"""Mark running sessions with no live worker process as interrupted.
|
||||||
|
|
||||||
This is safe to call both during startup and immediately after a
|
This is safe to call both during startup and immediately after a
|
||||||
forced server stop: live workers, including workers owned by another
|
forced server stop: live workers, including workers owned by another
|
||||||
@@ -442,14 +442,14 @@ class SessionRepository:
|
|||||||
cursor.execute(
|
cursor.execute(
|
||||||
"UPDATE sessions SET status = ?, end_time = ? "
|
"UPDATE sessions SET status = ?, end_time = ? "
|
||||||
"WHERE session_id = ? AND status = ?",
|
"WHERE session_id = ? AND status = ?",
|
||||||
("failed", now, row["session_id"], "running"),
|
("interrupted", now, row["session_id"], "running"),
|
||||||
)
|
)
|
||||||
count += cursor.rowcount
|
count += cursor.rowcount
|
||||||
try:
|
try:
|
||||||
if trace_store.read_status(str(row["session_id"])):
|
if trace_store.read_status(str(row["session_id"])):
|
||||||
trace_store.update_trace_status(
|
trace_store.update_trace_status(
|
||||||
str(row["session_id"]),
|
str(row["session_id"]),
|
||||||
"failed",
|
"interrupted",
|
||||||
error="Process terminated prior to server startup.",
|
error="Process terminated prior to server startup.",
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
|
|||||||
@@ -121,6 +121,20 @@ def batch_detail(batch_id: str):
|
|||||||
return {**require("batch", batch_id), "jobs": store.jobs(batch_id)}
|
return {**require("batch", batch_id), "jobs": store.jobs(batch_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/batches/{batch_id}")
|
||||||
|
def delete_batch(batch_id: str):
|
||||||
|
try:
|
||||||
|
key = identifier(batch_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
try:
|
||||||
|
if not service.delete_batch(key):
|
||||||
|
raise HTTPException(404, "批次不存在")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(409, str(exc)) from exc
|
||||||
|
return {"batch_id": key, "status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/batches/{batch_id}/{action}")
|
@router.post("/batches/{batch_id}/{action}")
|
||||||
def batch_action(batch_id: str, action: Literal["pause", "resume", "retry_failed"]):
|
def batch_action(batch_id: str, action: Literal["pause", "resume", "retry_failed"]):
|
||||||
require("batch", batch_id)
|
require("batch", batch_id)
|
||||||
@@ -146,8 +160,14 @@ def report(session_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/reports/{session_id}/{fmt}")
|
@router.get("/reports/{session_id}/{fmt}")
|
||||||
async def download_report(session_id: str, fmt: Literal["docx", "xlsx"]):
|
async def download_report(
|
||||||
|
session_id: str, fmt: Literal["docx", "xlsx", "screenshots", "video", "all"]
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
|
if fmt in {"screenshots", "video", "all"}:
|
||||||
|
path = await asyncio.to_thread(report_export.export_session_media, session_id, fmt)
|
||||||
|
label = report_export.archive_case_name(report_export.session_report(session_id))
|
||||||
|
return FileResponse(path, filename=f"{label}-{fmt}.zip")
|
||||||
paths = await asyncio.to_thread(report_export.export_session, session_id, [fmt])
|
paths = await asyncio.to_thread(report_export.export_session, session_id, [fmt])
|
||||||
return FileResponse(paths[0], filename=f"任务报告-{session_id[:8]}.{fmt}")
|
return FileResponse(paths[0], filename=f"任务报告-{session_id[:8]}.{fmt}")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -165,23 +185,34 @@ def history(
|
|||||||
):
|
):
|
||||||
# SQL filtering/pagination searches the entire persisted history, not a UI cache.
|
# SQL filtering/pagination searches the entire persisted history, not a UI cache.
|
||||||
query = """WITH history AS (
|
query = """WITH history AS (
|
||||||
SELECT s.session_id, s.initial_goal, s.start_time, s.end_time,
|
SELECT s.session_id, s.initial_goal, s.start_time,
|
||||||
|
COALESCE(json_extract(j.payload,'$.ended_at'),s.end_time) end_time,
|
||||||
COALESCE(j.status,s.status) status, COALESCE(json_extract(j.payload,'$.case.name'),s.initial_goal) name,
|
COALESCE(j.status,s.status) status, COALESCE(json_extract(j.payload,'$.case.name'),s.initial_goal) name,
|
||||||
COALESCE(json_extract(j.payload,'$.case.tags'),'') tags
|
COALESCE(json_extract(j.payload,'$.case.tags'),'') tags,
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.case_id'),'') case_id,
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.category'),'') category,
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory,
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions,
|
||||||
|
COALESCE(json_extract(j.payload,'$.error'),'') error
|
||||||
FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id
|
FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT j.session_id,json_extract(j.payload,'$.case.goal'),
|
SELECT j.session_id,json_extract(j.payload,'$.case.goal'),
|
||||||
COALESCE(json_extract(j.payload,'$.started_at'),json_extract(j.payload,'$.created_at')),
|
COALESCE(json_extract(j.payload,'$.started_at'),json_extract(j.payload,'$.created_at')),
|
||||||
json_extract(j.payload,'$.ended_at'),j.status,json_extract(j.payload,'$.case.name'),
|
json_extract(j.payload,'$.ended_at'),j.status,json_extract(j.payload,'$.case.name'),
|
||||||
COALESCE(json_extract(j.payload,'$.case.tags'),'')
|
COALESCE(json_extract(j.payload,'$.case.tags'),''),
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.case_id'),''),
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.category'),''),
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.subcategory'),''),
|
||||||
|
COALESCE(json_extract(j.payload,'$.case.preconditions'),''),
|
||||||
|
COALESCE(json_extract(j.payload,'$.error'),'')
|
||||||
FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id)
|
FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id)
|
||||||
) """
|
) """
|
||||||
where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')"
|
where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')"
|
||||||
args = []
|
args = []
|
||||||
if q:
|
if q:
|
||||||
where += " AND (name LIKE ? ESCAPE '\\' OR initial_goal LIKE ? ESCAPE '\\' OR session_id LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')"
|
where += " AND (name LIKE ? ESCAPE '\\' OR initial_goal LIKE ? ESCAPE '\\' OR session_id LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\' OR case_id LIKE ? ESCAPE '\\' OR category LIKE ? ESCAPE '\\' OR subcategory LIKE ? ESCAPE '\\' OR preconditions LIKE ? ESCAPE '\\')"
|
||||||
escaped = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
escaped = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
||||||
args.extend([escaped] * 4)
|
args.extend([escaped] * 8)
|
||||||
for condition, value in [
|
for condition, value in [
|
||||||
("status = ?", status or None),
|
("status = ?", status or None),
|
||||||
("start_time >= ?", start),
|
("start_time >= ?", start),
|
||||||
@@ -199,4 +230,8 @@ def history(
|
|||||||
+ " ORDER BY start_time DESC,session_id LIMIT ? OFFSET ?",
|
+ " ORDER BY start_time DESC,session_id LIMIT ? OFFSET ?",
|
||||||
[*args, size, (page - 1) * size],
|
[*args, size, (page - 1) * size],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return {"items": [dict(r) for r in rows], "total": total, "page": page, "size": size}
|
items = [dict(row) for row in rows]
|
||||||
|
for item in items:
|
||||||
|
if item["case_id"]:
|
||||||
|
item["name"] = task_import.case_title(item)
|
||||||
|
return {"items": items, "total": total, "page": page, "size": size}
|
||||||
@@ -27,12 +27,16 @@ try:
|
|||||||
from admin_console.database.repositories.trace_repository import trace_repo
|
from admin_console.database.repositories.trace_repository import trace_repo
|
||||||
from admin_console.services.media_service import media_service
|
from admin_console.services.media_service import media_service
|
||||||
from admin_console.services.model_service import model_service
|
from admin_console.services.model_service import model_service
|
||||||
|
from admin_console.services.automation_store import store
|
||||||
|
from admin_console.services.task_import import case_title
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from apps.admin_console.core.state import state
|
from apps.admin_console.core.state import state
|
||||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||||
from apps.admin_console.database.repositories.trace_repository import trace_repo
|
from apps.admin_console.database.repositories.trace_repository import trace_repo
|
||||||
from apps.admin_console.services.media_service import media_service
|
from apps.admin_console.services.media_service import media_service
|
||||||
from apps.admin_console.services.model_service import model_service
|
from apps.admin_console.services.model_service import model_service
|
||||||
|
from apps.admin_console.services.automation_store import store
|
||||||
|
from apps.admin_console.services.task_import import case_title
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(tags=["sessions"])
|
router = APIRouter(tags=["sessions"])
|
||||||
@@ -52,6 +56,7 @@ async def list_sessions():
|
|||||||
|
|
||||||
def _list_sessions_sync():
|
def _list_sessions_sync():
|
||||||
rows = session_repo.get_all_sessions()
|
rows = session_repo.get_all_sessions()
|
||||||
|
jobs_by_id = store.jobs_for_sessions([str(row.get("session_id")) for row in rows])
|
||||||
video_rec_map = session_repo.get_video_recordings_map()
|
video_rec_map = session_repo.get_video_recordings_map()
|
||||||
latest_recordings = session_repo.get_latest_video_recordings_map()
|
latest_recordings = session_repo.get_latest_video_recordings_map()
|
||||||
agent_names_by_session = session_repo.get_agent_trace_names_map()
|
agent_names_by_session = session_repo.get_agent_trace_names_map()
|
||||||
@@ -74,6 +79,15 @@ def _list_sessions_sync():
|
|||||||
|
|
||||||
for row_dict in rows:
|
for row_dict in rows:
|
||||||
s_id = str(row_dict.get("session_id"))
|
s_id = str(row_dict.get("session_id"))
|
||||||
|
job = jobs_by_id.get(s_id)
|
||||||
|
if job:
|
||||||
|
case = job.get("case") or {}
|
||||||
|
row_dict["case_id"] = case.get("case_id") or ""
|
||||||
|
row_dict["display_name"] = case_title(case) or row_dict.get("initial_goal")
|
||||||
|
row_dict["error"] = job.get("error") or ""
|
||||||
|
if job.get("status") in {"completed", "failed", "cancelled", "interrupted", "skipped"}:
|
||||||
|
row_dict["status"] = job["status"]
|
||||||
|
row_dict["end_time"] = job.get("ended_at") or row_dict.get("end_time")
|
||||||
recording = latest_recordings.get(s_id)
|
recording = latest_recordings.get(s_id)
|
||||||
d_info_raw = row_dict.get("device_info")
|
d_info_raw = row_dict.get("device_info")
|
||||||
device_id = None
|
device_id = None
|
||||||
|
|||||||
@@ -18,8 +18,17 @@ import json
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from artemis.core.diagnostics import readiness_engine
|
from artemis.core.diagnostics import readiness_engine
|
||||||
from artemis.runtime import DeviceExecutionLock, device_pool
|
from artemis.runtime import DeviceExecutionLock, device_pool
|
||||||
|
from artemis.runtime.task_control import (
|
||||||
|
add_instruction,
|
||||||
|
control_dir,
|
||||||
|
list_instructions,
|
||||||
|
read_state,
|
||||||
|
request_pause,
|
||||||
|
request_resume,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from admin_console.core.state import state
|
from admin_console.core.state import state
|
||||||
@@ -42,6 +51,59 @@ except ImportError:
|
|||||||
router = APIRouter(tags=["tasks"])
|
router = APIRouter(tags=["tasks"])
|
||||||
|
|
||||||
|
|
||||||
|
class GuidanceRequest(BaseModel):
|
||||||
|
instruction: str = Field(min_length=1, max_length=10000)
|
||||||
|
|
||||||
|
|
||||||
|
def _live_task(session_id: str) -> dict:
|
||||||
|
from apps.admin_console.services.automation_store import identifier
|
||||||
|
|
||||||
|
identifier(session_id)
|
||||||
|
run = state.active_runs.get(session_id)
|
||||||
|
if not run or run["process"].returncode is not None:
|
||||||
|
raise HTTPException(409, "任务未在执行,无法暂停或指导")
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/tasks/{session_id}/pause")
|
||||||
|
def pause_running_task(session_id: str):
|
||||||
|
_live_task(session_id)
|
||||||
|
request_pause(session_id)
|
||||||
|
return {"session_id": session_id, "status": "pausing"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/tasks/{session_id}/guide")
|
||||||
|
def guide_running_task(session_id: str, value: GuidanceRequest):
|
||||||
|
_live_task(session_id)
|
||||||
|
guidance = add_instruction(session_id, value.instruction, source="web")
|
||||||
|
if (
|
||||||
|
read_state(session_id).get("status") == "task_paused"
|
||||||
|
or (control_dir(session_id) / "pause.request").exists()
|
||||||
|
):
|
||||||
|
request_resume(session_id)
|
||||||
|
return {"session_id": session_id, "status": "guidance_submitted", "guidance": guidance}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tasks/{session_id}/guidance")
|
||||||
|
def task_guidance_history(session_id: str):
|
||||||
|
from apps.admin_console.services.automation_store import identifier
|
||||||
|
|
||||||
|
try:
|
||||||
|
identifier(session_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
return list_instructions(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/tasks/{session_id}/continue")
|
||||||
|
def continue_running_task(session_id: str):
|
||||||
|
_live_task(session_id)
|
||||||
|
if read_state(session_id).get("status") != "task_paused":
|
||||||
|
raise HTTPException(409, "任务尚未暂停")
|
||||||
|
request_resume(session_id)
|
||||||
|
return {"session_id": session_id, "status": "resuming"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/tasks/presets")
|
@router.get("/api/tasks/presets")
|
||||||
async def get_task_presets(
|
async def get_task_presets(
|
||||||
category: str = "recommended",
|
category: str = "recommended",
|
||||||
@@ -285,6 +347,17 @@ async def get_status():
|
|||||||
}
|
}
|
||||||
for owner in active_owners.values()
|
for owner in active_owners.values()
|
||||||
]
|
]
|
||||||
|
for sid, run in state.active_runs.items():
|
||||||
|
if run.get("parked"):
|
||||||
|
active_tasks.append(
|
||||||
|
{
|
||||||
|
"session_id": sid,
|
||||||
|
"device_id": run.get("device_id"),
|
||||||
|
"goal": run.get("goal"),
|
||||||
|
"pid": getattr(run.get("process"), "pid", None),
|
||||||
|
"status": read_state(sid).get("status") or "task_paused",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
running_sid = (
|
running_sid = (
|
||||||
(global_owner.session_id if global_owner else None)
|
(global_owner.session_id if global_owner else None)
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ async def on_startup():
|
|||||||
|
|
||||||
cleaned = session_repo.cleanup_orphans_on_startup()
|
cleaned = session_repo.cleanup_orphans_on_startup()
|
||||||
if cleaned > 0:
|
if cleaned > 0:
|
||||||
print(f"[ServerStartup] Marked {cleaned} orphaned running session(s) as failed.")
|
print(f"[ServerStartup] Marked {cleaned} orphaned running session(s) as interrupted.")
|
||||||
# Workers killed together with a previous daemon never remuxed their
|
# Workers killed together with a previous daemon never remuxed their
|
||||||
# recordings; publish whatever raw files they left behind.
|
# recordings; publish whatever raw files they left behind.
|
||||||
asyncio.create_task(asyncio.to_thread(task_queue_service.recover_orphaned_recordings_on_launch))
|
asyncio.create_task(asyncio.to_thread(task_queue_service.recover_orphaned_recordings_on_launch))
|
||||||
@@ -176,7 +176,9 @@ async def on_shutdown():
|
|||||||
owned_session_ids = {
|
owned_session_ids = {
|
||||||
str(item["session_id"])
|
str(item["session_id"])
|
||||||
for item in state.queue_items
|
for item in state.queue_items
|
||||||
if isinstance(item, dict) and item.get("status") == "running" and item.get("session_id")
|
if isinstance(item, dict)
|
||||||
|
and item.get("status") in {"running", "task_paused", "resuming"}
|
||||||
|
and item.get("session_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
worker = state.worker_task
|
worker = state.worker_task
|
||||||
@@ -212,7 +214,7 @@ async def on_shutdown():
|
|||||||
state.current_process = None
|
state.current_process = None
|
||||||
state.queue_items.clear()
|
state.queue_items.clear()
|
||||||
for session_id in owned_session_ids:
|
for session_id in owned_session_ids:
|
||||||
session_repo.update_session_status(session_id, "cancelled")
|
session_repo.update_session_status(session_id, "interrupted")
|
||||||
|
|
||||||
from apps.admin_console.services.automation_service import background_tasks
|
from apps.admin_console.services.automation_service import background_tasks
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import time
|
import time
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from apps.admin_console.core.state import state
|
from apps.admin_console.core.state import state
|
||||||
from apps.admin_console.services.automation_store import store, new_id
|
from apps.admin_console.services.automation_store import store, new_id
|
||||||
from apps.admin_console.services.task_import import case_goal
|
from apps.admin_console.services.task_import import case_goal, case_title
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"}
|
TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"}
|
||||||
@@ -41,7 +43,7 @@ def register_submission(item: dict, case: dict | None = None, formats=None, **ex
|
|||||||
def recover_batches():
|
def recover_batches():
|
||||||
"""Recovery never silently repeats a potentially side-effecting interrupted task."""
|
"""Recovery never silently repeats a potentially side-effecting interrupted task."""
|
||||||
for job in store.jobs():
|
for job in store.jobs():
|
||||||
if job["status"] in {"running", "queued", "finalizing"}:
|
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
|
||||||
job.update(
|
job.update(
|
||||||
status=job.get("terminal_status", "interrupted"),
|
status=job.get("terminal_status", "interrupted"),
|
||||||
error=job.get("error")
|
error=job.get("error")
|
||||||
@@ -64,7 +66,7 @@ def recover_batches():
|
|||||||
status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务"
|
status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务"
|
||||||
)
|
)
|
||||||
for job in store.jobs(batch["id"]):
|
for job in store.jobs(batch["id"]):
|
||||||
if job["status"] in {"running", "queued", "finalizing"}:
|
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
|
||||||
job.update(
|
job.update(
|
||||||
status="interrupted", error="服务中断,需手动重跑", ended_at=time.time()
|
status="interrupted", error="服务中断,需手动重跑", ended_at=time.time()
|
||||||
)
|
)
|
||||||
@@ -82,20 +84,26 @@ async def on_terminal(item: dict, status: str):
|
|||||||
job = store.job(sid)
|
job = store.job(sid)
|
||||||
if job is None:
|
if job is None:
|
||||||
raise RuntimeError("任务元数据写入失败")
|
raise RuntimeError("任务元数据写入失败")
|
||||||
job.update(status="finalizing", ended_at=time.time())
|
|
||||||
if job.get("timed_out"):
|
if job.get("timed_out"):
|
||||||
status = "failed"
|
status = "failed"
|
||||||
|
job.update(status="finalizing", terminal_status=status, ended_at=time.time())
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
try:
|
try:
|
||||||
job["status"] = status
|
job["status"] = status
|
||||||
report = await asyncio.to_thread(session_report, sid)
|
report = await asyncio.to_thread(session_report, sid)
|
||||||
if report["verdict"] == "验证失败" or report["outcome"].get("task_status") in {
|
if status != "interrupted" and (
|
||||||
|
report["verdict"] == "验证失败"
|
||||||
|
or report["outcome"].get("task_status")
|
||||||
|
in {
|
||||||
"blocked",
|
"blocked",
|
||||||
"partial",
|
"partial",
|
||||||
"failed",
|
"failed",
|
||||||
}:
|
}
|
||||||
|
):
|
||||||
job["status"] = "failed"
|
job["status"] = "failed"
|
||||||
job["error"] = "任务执行结束,但业务检查未通过"
|
job["error"] = job.get("error") or "任务执行结束,但业务检查未通过"
|
||||||
|
elif job["status"] == "failed" and report.get("error") and not job.get("error"):
|
||||||
|
job["error"] = report["error"]
|
||||||
# Persist terminal classification before export, but the coordinator waits for finalization.
|
# Persist terminal classification before export, but the coordinator waits for finalization.
|
||||||
store.save_job({**job, "status": "finalizing", "terminal_status": job["status"]})
|
store.save_job({**job, "status": "finalizing", "terminal_status": job["status"]})
|
||||||
await asyncio.to_thread(export_session, sid, job["formats"])
|
await asyncio.to_thread(export_session, sid, job["formats"])
|
||||||
@@ -103,6 +111,7 @@ async def on_terminal(item: dict, status: str):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Report finalization failed for %s", sid)
|
logger.exception("Report finalization failed for %s", sid)
|
||||||
job["report_error"] = str(exc)[:500]
|
job["report_error"] = str(exc)[:500]
|
||||||
|
job.pop("terminal_status", None)
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
|
|
||||||
|
|
||||||
@@ -121,17 +130,30 @@ async def tick_batches():
|
|||||||
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
|
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
|
||||||
store.put("batch", batch["id"], batch)
|
store.put("batch", batch["id"], batch)
|
||||||
for job in active:
|
for job in active:
|
||||||
limit = int(batch.get("timeout_seconds", 1800))
|
# The queue records started_at at submission, then replaces it
|
||||||
|
# when the worker actually starts. Waiting for a device must
|
||||||
|
# not consume this case's execution budget.
|
||||||
|
if job["status"] != "running":
|
||||||
|
continue
|
||||||
|
limit = int(
|
||||||
|
(job.get("case", {}).get("task_config") or {}).get("timeout_seconds")
|
||||||
|
or batch.get("timeout_seconds", 1800)
|
||||||
|
)
|
||||||
|
task_state = getattr(state, "active_runs", {}).get(str(job["session_id"])) or {}
|
||||||
|
excluded = float(task_state.get("non_execution_seconds") or 0)
|
||||||
|
if task_state.get("non_execution_started_at"):
|
||||||
|
excluded += time.time() - float(task_state["non_execution_started_at"])
|
||||||
if (
|
if (
|
||||||
job["status"] != "finalizing"
|
not job.get("timed_out")
|
||||||
and not job.get("timed_out")
|
and time.time() - job.get("started_at", time.time()) - excluded > limit
|
||||||
and time.time() - job.get("started_at", time.time()) > limit
|
|
||||||
):
|
):
|
||||||
job.update(error="任务达到批次配置的执行时限", timed_out=True)
|
job.update(
|
||||||
|
error=f"任务执行超时({limit} 秒,不含暂停/排队时间)", timed_out=True
|
||||||
|
)
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
task_queue_service.stop_tasks(session_id=job["session_id"])
|
task_queue_service.stop_tasks(session_id=job["session_id"])
|
||||||
batch.update(status="paused", reason="任务超时,已暂停剩余任务;请确认设备状态")
|
# on_terminal records the failed case. The next pending
|
||||||
store.put("batch", batch["id"], batch)
|
# case then runs normally unless stop_on_failure is set.
|
||||||
continue
|
continue
|
||||||
if batch["status"] != "running":
|
if batch["status"] != "running":
|
||||||
continue
|
continue
|
||||||
@@ -142,6 +164,11 @@ async def tick_batches():
|
|||||||
job.update(status="skipped", error="本批次设置为失败即停止", ended_at=time.time())
|
job.update(status="skipped", error="本批次设置为失败即停止", ended_at=time.time())
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
pending = []
|
pending = []
|
||||||
|
if not pending and any(
|
||||||
|
j["status"] in {"task_paused", "resuming", "running", "queued", "finalizing"}
|
||||||
|
for j in jobs
|
||||||
|
):
|
||||||
|
continue
|
||||||
if not pending:
|
if not pending:
|
||||||
batch.update(status="completed", ended_at=time.time())
|
batch.update(status="completed", ended_at=time.time())
|
||||||
try:
|
try:
|
||||||
@@ -156,7 +183,9 @@ async def tick_batches():
|
|||||||
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
|
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
|
||||||
store.put("batch", batch["id"], batch)
|
store.put("batch", batch["id"], batch)
|
||||||
continue
|
continue
|
||||||
serial = batch.get("device_serial")
|
job = pending[0]
|
||||||
|
options = job["case"].get("task_config") or {}
|
||||||
|
serial = options.get("device_serial") or batch.get("device_serial")
|
||||||
devices = await device_pool.try_list_devices_async()
|
devices = await device_pool.try_list_devices_async()
|
||||||
rejection = (
|
rejection = (
|
||||||
None
|
None
|
||||||
@@ -169,26 +198,29 @@ async def tick_batches():
|
|||||||
continue
|
continue
|
||||||
if (store.get("batch", batch["id"]) or {}).get("status") != "running":
|
if (store.get("batch", batch["id"]) or {}).get("status") != "running":
|
||||||
continue
|
continue
|
||||||
job = pending[0]
|
|
||||||
job.update(status="queued", started_at=time.time())
|
job.update(status="queued", started_at=time.time())
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
try:
|
try:
|
||||||
response = await task_queue_service.enqueue_tasks(
|
response = await task_queue_service.enqueue_tasks(
|
||||||
[case_goal(job["case"])],
|
[case_goal(job["case"])],
|
||||||
profile=batch.get("profile", "flash"),
|
profile=options.get("profile") or batch.get("profile", "flash"),
|
||||||
expected_output="用简体中文报告实际结果与证据。明确区分执行完成、验证通过、验证失败和前置条件未满足。"
|
expected_output="用简体中文报告实际结果与证据。明确区分执行完成、验证通过、验证失败和前置条件未满足。",
|
||||||
+ job["case"].get("expected", ""),
|
|
||||||
enable_outputter=True,
|
enable_outputter=True,
|
||||||
device_serial=serial,
|
device_serial=serial,
|
||||||
|
verification_level=options.get("verification_level"),
|
||||||
|
explorer_mode=options.get("explorer_mode"),
|
||||||
|
locked_app_package=options.get("locked_app_package"),
|
||||||
|
runtime_overrides=options,
|
||||||
session_id=job["session_id"],
|
session_id=job["session_id"],
|
||||||
ingress="batch",
|
ingress="batch",
|
||||||
|
display_name=case_title(job["case"]),
|
||||||
)
|
)
|
||||||
if response.get("status") == "rejected" or not response.get("tasks"):
|
if response.get("status") == "rejected" or not response.get("tasks"):
|
||||||
raise ValueError(response.get("error") or "队列拒绝接收任务")
|
raise ValueError(response.get("error") or "队列拒绝接收任务")
|
||||||
bound = response["tasks"][0].get("device_serial")
|
bound = response["tasks"][0].get("device_serial")
|
||||||
if bound and not serial:
|
if bound:
|
||||||
batch["device_serial"] = bound
|
job["device_serial"] = bound
|
||||||
store.put("batch", batch["id"], batch)
|
store.save_job(job)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
job.update(status="pending", error=str(exc)[:500])
|
job.update(status="pending", error=str(exc)[:500])
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
@@ -245,6 +277,35 @@ def create_batch(payload: dict) -> dict:
|
|||||||
return store.create_batch(batch, jobs, payload)
|
return store.create_batch(batch, jobs, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_batch(batch_id: str) -> bool:
|
||||||
|
"""Delete an inactive batch, its sessions and its generated reports."""
|
||||||
|
from artemis.data_engine.storage import StorageManager
|
||||||
|
from apps.admin_console.services import report_export
|
||||||
|
|
||||||
|
jobs = store.begin_delete_batch(batch_id)
|
||||||
|
if jobs is None:
|
||||||
|
return False
|
||||||
|
storage = StorageManager(store.path, report_export.TRACES_PATH)
|
||||||
|
mcp_exports = report_export.EXPORT_ROOT.parent / "mcp_exports"
|
||||||
|
with report_export.EXPORT_LOCK:
|
||||||
|
for job in jobs:
|
||||||
|
session_id = str(UUID(job["session_id"]))
|
||||||
|
storage.delete_session(UUID(session_id))
|
||||||
|
report_dir = report_export.EXPORT_ROOT / "sessions" / session_id
|
||||||
|
if report_dir.is_dir():
|
||||||
|
shutil.rmtree(report_dir)
|
||||||
|
mcp_session_dir = mcp_exports / "sessions" / session_id
|
||||||
|
if mcp_session_dir.is_dir():
|
||||||
|
shutil.rmtree(mcp_session_dir)
|
||||||
|
batch_dir = report_export.EXPORT_ROOT / "batches" / batch_id
|
||||||
|
if batch_dir.is_dir():
|
||||||
|
shutil.rmtree(batch_dir)
|
||||||
|
mcp_batch_dir = mcp_exports / "batches" / batch_id
|
||||||
|
if mcp_batch_dir.is_dir():
|
||||||
|
shutil.rmtree(mcp_batch_dir)
|
||||||
|
return store.delete_batch(batch_id)
|
||||||
|
|
||||||
|
|
||||||
def batch_action(batch_id: str, action: str) -> dict:
|
def batch_action(batch_id: str, action: str) -> dict:
|
||||||
batch = store.get("batch", batch_id)
|
batch = store.get("batch", batch_id)
|
||||||
if not batch:
|
if not batch:
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ class AutomationStore:
|
|||||||
|
|
||||||
def put(self, kind: str, key: str, payload: dict) -> dict:
|
def put(self, kind: str, key: str, payload: dict) -> dict:
|
||||||
with self.connection() as conn:
|
with self.connection() as conn:
|
||||||
|
if kind == "batch":
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?", (key,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None or json.loads(row[0]).get("status") == "deleting":
|
||||||
|
raise ValueError("批次正在删除或已删除,不能继续更新")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO automation_records VALUES (?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at",
|
"INSERT INTO automation_records VALUES (?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at",
|
||||||
(kind, key, json.dumps(payload, ensure_ascii=False), time.time()),
|
(kind, key, json.dumps(payload, ensure_ascii=False), time.time()),
|
||||||
@@ -78,6 +85,14 @@ class AutomationStore:
|
|||||||
|
|
||||||
def save_job(self, payload: dict) -> dict:
|
def save_job(self, payload: dict) -> dict:
|
||||||
with self.connection() as conn:
|
with self.connection() as conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
if batch_id := payload.get("batch_id"):
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?",
|
||||||
|
(batch_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None or json.loads(row[0]).get("status") == "deleting":
|
||||||
|
raise ValueError("批次正在删除,不能继续派发任务")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO automation_jobs VALUES (?,?,?,?,?,?) ON CONFLICT(session_id) DO UPDATE SET payload=excluded.payload,status=excluded.status,updated_at=excluded.updated_at",
|
"INSERT INTO automation_jobs VALUES (?,?,?,?,?,?) ON CONFLICT(session_id) DO UPDATE SET payload=excluded.payload,status=excluded.status,updated_at=excluded.updated_at",
|
||||||
(
|
(
|
||||||
@@ -106,6 +121,25 @@ class AutomationStore:
|
|||||||
)
|
)
|
||||||
return [{**json.loads(row[0]), "status": row[1]} for row in rows]
|
return [{**json.loads(row[0]), "status": row[1]} for row in rows]
|
||||||
|
|
||||||
|
def jobs_for_sessions(self, session_ids: list[str]) -> dict[str, dict]:
|
||||||
|
"""Load only metadata belonging to the sessions shown in history."""
|
||||||
|
result = {}
|
||||||
|
with self.connection() as conn:
|
||||||
|
for offset in range(0, len(session_ids), 400):
|
||||||
|
chunk = session_ids[offset : offset + 400]
|
||||||
|
markers = ",".join("?" for _ in chunk)
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT session_id,payload,status FROM automation_jobs WHERE session_id IN ({markers})",
|
||||||
|
chunk,
|
||||||
|
).fetchall()
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
row["session_id"]: {**json.loads(row["payload"]), "status": row["status"]}
|
||||||
|
for row in rows
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
def preferences(self) -> dict:
|
def preferences(self) -> dict:
|
||||||
return self.get("settings", "reports") or {"formats": ["docx", "xlsx"]}
|
return self.get("settings", "reports") or {"formats": ["docx", "xlsx"]}
|
||||||
|
|
||||||
@@ -164,6 +198,58 @@ class AutomationStore:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
return batch
|
return batch
|
||||||
|
|
||||||
|
def begin_delete_batch(self, batch_id: str) -> list[dict] | None:
|
||||||
|
"""Reserve an inactive batch for cleanup before any more jobs can start."""
|
||||||
|
with self.connection() as conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?",
|
||||||
|
(batch_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
batch = json.loads(row[0])
|
||||||
|
active = conn.execute(
|
||||||
|
"SELECT 1 FROM automation_jobs WHERE batch_id=? AND status IN "
|
||||||
|
"('queued','running','task_paused','resuming','finalizing') LIMIT 1",
|
||||||
|
(batch_id,),
|
||||||
|
).fetchone()
|
||||||
|
if batch["status"] == "running" or active:
|
||||||
|
raise ValueError("批次仍有任务执行中或排队中,请等待完成后删除")
|
||||||
|
batch["status"] = "deleting"
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE automation_records SET payload=?,updated_at=? WHERE kind='batch' AND id=?",
|
||||||
|
(json.dumps(batch, ensure_ascii=False), time.time(), batch_id),
|
||||||
|
)
|
||||||
|
jobs = conn.execute(
|
||||||
|
"SELECT payload,status FROM automation_jobs WHERE batch_id=? ORDER BY position",
|
||||||
|
(batch_id,),
|
||||||
|
).fetchall()
|
||||||
|
conn.commit()
|
||||||
|
return [{**json.loads(job[0]), "status": job[1]} for job in jobs]
|
||||||
|
|
||||||
|
def delete_batch(self, batch_id: str) -> bool:
|
||||||
|
"""Remove a batch record and all of its jobs after cleanup."""
|
||||||
|
with self.connection() as conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?",
|
||||||
|
(batch_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
batch = json.loads(row[0])
|
||||||
|
if batch.get("status") != "deleting":
|
||||||
|
raise ValueError("批次尚未准备删除")
|
||||||
|
conn.execute("DELETE FROM automation_jobs WHERE batch_id=?", (batch_id,))
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM automation_records WHERE kind='batch' AND id=?",
|
||||||
|
(batch_id,),
|
||||||
|
)
|
||||||
|
# Keep the request-id tombstone so deletion cannot replay an old batch.
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
def delete_history(self, session_id: str | None = None):
|
def delete_history(self, session_id: str | None = None):
|
||||||
with self.connection() as conn:
|
with self.connection() as conn:
|
||||||
if session_id:
|
if session_id:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ _write_lock = threading.Lock()
|
|||||||
|
|
||||||
|
|
||||||
class ModelSettingsUpdate(BaseModel):
|
class ModelSettingsUpdate(BaseModel):
|
||||||
base_url: str = "https://api.openai.com/v1"
|
base_url: str = "https://api-n-cd.playones.com/v1"
|
||||||
model: str = Field(min_length=1, max_length=200)
|
model: str = Field(min_length=1, max_length=200)
|
||||||
api_key: SecretStr | None = None # Omitted/blank keeps the existing key.
|
api_key: SecretStr | None = None # Omitted/blank keeps the existing key.
|
||||||
timeout_seconds: float = Field(default=300, gt=0, le=3600)
|
timeout_seconds: float = Field(default=300, gt=0, le=3600)
|
||||||
@@ -41,7 +41,7 @@ class ModelSettingsUpdate(BaseModel):
|
|||||||
):
|
):
|
||||||
raise ValueError("请输入有效的 HTTP(S) 接口根地址,不要包含密钥、查询参数或片段")
|
raise ValueError("请输入有效的 HTTP(S) 接口根地址,不要包含密钥、查询参数或片段")
|
||||||
if value.endswith(("/responses", "/chat/completions")):
|
if value.endswith(("/responses", "/chat/completions")):
|
||||||
raise ValueError("请输入接口根地址,例如 https://api.openai.com/v1")
|
raise ValueError("请输入接口根地址,例如 https://api-n-cd.playones.com/v1")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@field_validator("model")
|
@field_validator("model")
|
||||||
@@ -57,14 +57,19 @@ def get_model_settings() -> dict:
|
|||||||
return {
|
return {
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"api_mode": "responses",
|
"api_mode": "responses",
|
||||||
"base_url": settings.OPENAI_BASE_URL or "https://api.openai.com/v1",
|
"base_url": settings.OPENAI_BASE_URL or "https://api-n-cd.playones.com/v1",
|
||||||
"model": settings.OPENAI_MODEL,
|
"model": settings.OPENAI_MODEL,
|
||||||
"api_key_configured": bool(settings.OPENAI_API_KEY),
|
"api_key_configured": bool(settings.OPENAI_API_KEY),
|
||||||
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
|
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
|
||||||
"max_retries": settings.OPENAI_MAX_RETRIES,
|
"max_retries": settings.OPENAI_MAX_RETRIES,
|
||||||
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
|
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
|
||||||
"reasoning_effort": settings.OPENAI_REASONING_EFFORT,
|
"reasoning_effort": settings.OPENAI_REASONING_EFFORT,
|
||||||
"model_presets": ["gpt-5.6-luna", "mimo-v2.6-flash"],
|
"model_presets": [
|
||||||
|
"gpt-5.6-luna",
|
||||||
|
"gpt-6-luna",
|
||||||
|
"mimo-v2.6-flash",
|
||||||
|
"mimo-v2.6-pro",
|
||||||
|
],
|
||||||
"env_path": str(get_env_file()),
|
"env_path": str(get_env_file()),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +134,7 @@ def persist_environment(values: dict[str, str]) -> None:
|
|||||||
|
|
||||||
class CapabilitySettings(BaseModel):
|
class CapabilitySettings(BaseModel):
|
||||||
video_enabled: bool = False
|
video_enabled: bool = False
|
||||||
|
recording_enabled: bool = False
|
||||||
outputter_enabled: bool = True
|
outputter_enabled: bool = True
|
||||||
step_summarizer_enabled: bool = True
|
step_summarizer_enabled: bool = True
|
||||||
|
|
||||||
@@ -136,6 +142,7 @@ class CapabilitySettings(BaseModel):
|
|||||||
def get_capability_settings() -> CapabilitySettings:
|
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,
|
||||||
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,
|
||||||
)
|
)
|
||||||
@@ -144,6 +151,7 @@ def get_capability_settings() -> CapabilitySettings:
|
|||||||
def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
|
def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
|
||||||
values = {
|
values = {
|
||||||
"ARTEMIS_VIDEO_ENABLED": update.video_enabled,
|
"ARTEMIS_VIDEO_ENABLED": update.video_enabled,
|
||||||
|
"ARTEMIS_RECORDING_ENABLED": update.recording_enabled,
|
||||||
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
|
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
|
||||||
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
|
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import cast
|
from typing import cast
|
||||||
from docx.styles.style import ParagraphStyle
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
@@ -10,15 +9,20 @@ import threading
|
|||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
from docx import Document
|
from docx import Document
|
||||||
from docx.shared import Inches, Pt
|
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
|
||||||
|
from docx.oxml import OxmlElement
|
||||||
from docx.oxml.ns import qn
|
from docx.oxml.ns import qn
|
||||||
|
from docx.shared import Cm, Pt, RGBColor
|
||||||
|
from docx.styles.style import ParagraphStyle
|
||||||
from openpyxl import Workbook
|
from openpyxl import Workbook
|
||||||
from openpyxl.styles import Font, PatternFill, Alignment
|
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
|
from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
|
||||||
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||||
from apps.admin_console.database.repositories.step_repository import StepRepository
|
from apps.admin_console.database.repositories.step_repository import StepRepository
|
||||||
from apps.admin_console.services.automation_store import store, identifier
|
from apps.admin_console.services.automation_store import store, identifier
|
||||||
|
from apps.admin_console.services.task_import import case_title
|
||||||
|
|
||||||
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
|
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
|
||||||
EXPORT_LOCK = threading.RLock()
|
EXPORT_LOCK = threading.RLock()
|
||||||
@@ -32,9 +36,19 @@ STATUS = {
|
|||||||
"pending": "待执行",
|
"pending": "待执行",
|
||||||
"queued": "已排队",
|
"queued": "已排队",
|
||||||
"running": "执行中",
|
"running": "执行中",
|
||||||
|
"task_paused": "等待指导",
|
||||||
|
"resuming": "等待设备恢复",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def archive_case_name(report: dict) -> str:
|
||||||
|
"""Keep case identifiers readable without allowing ZIP paths or Windows-invalid names."""
|
||||||
|
fallback = str(report.get("id") or "task")[:8]
|
||||||
|
candidate = str(report.get("case_id") or fallback).strip()
|
||||||
|
candidate = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", candidate).strip(" .")
|
||||||
|
return candidate[:80] or fallback
|
||||||
|
|
||||||
|
|
||||||
def read_json(path: Path, fallback=None):
|
def read_json(path: Path, fallback=None):
|
||||||
try:
|
try:
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
@@ -58,6 +72,72 @@ def verdict(outcome: dict | None) -> str:
|
|||||||
return "未验证(执行完成不等于测试通过)"
|
return "未验证(执行完成不等于测试通过)"
|
||||||
|
|
||||||
|
|
||||||
|
def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
|
||||||
|
def redact(value: str) -> str:
|
||||||
|
safe = text(value).strip()
|
||||||
|
safe = re.sub(r"sk-[A-Za-z0-9_-]{12,}", "[已隐藏密钥]", safe)
|
||||||
|
safe = re.sub(
|
||||||
|
r"(?i)(\b(?:authorization\s*:\s*bearer|bearer)\s+)[^\s,;]+",
|
||||||
|
r"\1[已隐藏密钥]",
|
||||||
|
safe,
|
||||||
|
)
|
||||||
|
safe = re.sub(
|
||||||
|
r"(?i)(\b(?:OPENAI_API_KEY|api[_-]?key)\s*[:=]\s*['\"]?)[^\s,'\";]+",
|
||||||
|
r"\1[已隐藏密钥]",
|
||||||
|
safe,
|
||||||
|
)
|
||||||
|
return safe[:500]
|
||||||
|
|
||||||
|
if recorded:
|
||||||
|
return redact(recorded)
|
||||||
|
if status not in {"failed", "interrupted"}:
|
||||||
|
return ""
|
||||||
|
state = read_json(directory / "status.json", {})
|
||||||
|
if isinstance(state, dict) and state.get("error"):
|
||||||
|
return redact(state["error"])
|
||||||
|
log_lines: list[str] = []
|
||||||
|
for name in ("stdout.log", "stderr.log"):
|
||||||
|
path = directory / name
|
||||||
|
try:
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
stream.seek(0, 2)
|
||||||
|
stream.seek(max(0, stream.tell() - 131072))
|
||||||
|
log_lines.extend(stream.read().decode("utf-8", errors="replace").splitlines())
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for line in reversed(log_lines):
|
||||||
|
match = re.search(r"Error running automation:\s*(.+)", line)
|
||||||
|
if match:
|
||||||
|
return redact(match.group(1))
|
||||||
|
for line in reversed(log_lines):
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith(("⚠", "ℹ", "WARNING", "INFO", "DEBUG")):
|
||||||
|
continue
|
||||||
|
if any(
|
||||||
|
name in line
|
||||||
|
for name in (
|
||||||
|
"VisualStepSummarizer:",
|
||||||
|
"StepMemoryService:",
|
||||||
|
"Could not clean unowned screen wake locks",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
exception_line = re.fullmatch(
|
||||||
|
r"(?:[A-Za-z_]\w*\.)*[A-Za-z_]\w*(?:Error|Exception|Timeout):\s*.*",
|
||||||
|
stripped,
|
||||||
|
)
|
||||||
|
if exception_line and not re.search(r"(?i)(?:timeout|timed out|超时)", stripped):
|
||||||
|
return redact(stripped)
|
||||||
|
if re.search(
|
||||||
|
r"(?i)(?:\b(?:TimeoutError|APITimeoutError|ReadTimeout|ConnectTimeout)\s*:|"
|
||||||
|
r"(?:模型请求|任务执行|model request|task execution).{0,100}"
|
||||||
|
r"(?:超时|timed out|deadline exceeded))",
|
||||||
|
stripped,
|
||||||
|
):
|
||||||
|
return redact("模型请求或任务执行超时:" + stripped)
|
||||||
|
return "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
|
||||||
|
|
||||||
|
|
||||||
def session_report(session_id: str, db_path=None) -> dict:
|
def session_report(session_id: str, db_path=None) -> dict:
|
||||||
identifier(session_id)
|
identifier(session_id)
|
||||||
meta = store.job(session_id) or {}
|
meta = store.job(session_id) or {}
|
||||||
@@ -87,32 +167,44 @@ def session_report(session_id: str, db_path=None) -> dict:
|
|||||||
if not outcome:
|
if not outcome:
|
||||||
outcome = {"task_status": args.get("status"), "tests": {}}
|
outcome = {"task_status": args.get("status"), "tests": {}}
|
||||||
break
|
break
|
||||||
if not content:
|
|
||||||
content = "本任务没有保存最终结论。以下内容仅来自已记录的执行状态和证据。"
|
|
||||||
case = meta.get("case") or {}
|
case = meta.get("case") or {}
|
||||||
|
status = meta.get("terminal_status") or meta.get("status") or session.get("status", "unknown")
|
||||||
|
error = failure_reason(directory, status, meta.get("error"))
|
||||||
|
verification = verdict(outcome)
|
||||||
|
if status in {"failed", "interrupted"} and verification.startswith("未验证"):
|
||||||
|
verification = "未验证(任务异常终止)"
|
||||||
|
if not content:
|
||||||
|
content = (
|
||||||
|
"本任务未生成最终结论。请查看异常原因和已记录的执行步骤。"
|
||||||
|
if status in {"failed", "interrupted"}
|
||||||
|
else "本任务没有保存最终结论。以下内容仅来自已记录的执行状态和证据。"
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"id": session_id,
|
"id": session_id,
|
||||||
"name": case.get("name") or session.get("initial_goal", session_id)[:120],
|
"name": case_title(case) or session.get("initial_goal", session_id)[:120],
|
||||||
"goal": case.get("goal") or session.get("initial_goal", ""),
|
"goal": case.get("goal") or session.get("initial_goal", ""),
|
||||||
"expected": case.get("expected", ""),
|
"expected": case.get("expected", ""),
|
||||||
"status": meta.get("terminal_status")
|
"status": status,
|
||||||
or meta.get("status")
|
"verdict": verification,
|
||||||
or session.get("status", "unknown"),
|
|
||||||
"verdict": verdict(outcome),
|
|
||||||
"outcome": outcome,
|
"outcome": outcome,
|
||||||
"content": content,
|
"content": content,
|
||||||
"steps": steps,
|
"steps": steps,
|
||||||
"start": session.get("start_time") or meta.get("started_at"),
|
"start": session.get("start_time") or meta.get("started_at"),
|
||||||
"end": session.get("end_time") or meta.get("ended_at"),
|
"end": session.get("end_time") or meta.get("ended_at"),
|
||||||
"error": meta.get("error", ""),
|
"error": error,
|
||||||
"device": meta.get("device_serial") or text(session.get("device_info") or ""),
|
"device": meta.get("device_serial") or text(session.get("device_info") or ""),
|
||||||
"tags": case.get("tags", ""),
|
"tags": case.get("tags", ""),
|
||||||
|
"case_id": case.get("case_id", ""),
|
||||||
|
"category": case.get("category", ""),
|
||||||
|
"subcategory": case.get("subcategory", ""),
|
||||||
|
"preconditions": case.get("preconditions", ""),
|
||||||
|
"task_config": case.get("task_config") or {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def time_text(value) -> str:
|
def time_text(value) -> str:
|
||||||
return (
|
return (
|
||||||
datetime.fromtimestamp(float(value)).astimezone().isoformat(timespec="seconds")
|
datetime.fromtimestamp(float(value)).astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
if value
|
if value
|
||||||
else "—"
|
else "—"
|
||||||
)
|
)
|
||||||
@@ -121,6 +213,11 @@ def time_text(value) -> str:
|
|||||||
def metadata(report: dict) -> list[tuple]:
|
def metadata(report: dict) -> list[tuple]:
|
||||||
return [
|
return [
|
||||||
("任务编号", report["id"]),
|
("任务编号", report["id"]),
|
||||||
|
("用例编号", report.get("case_id") or "—"),
|
||||||
|
("检测项目", report.get("category") or "—"),
|
||||||
|
("测试子项", report.get("subcategory") or "—"),
|
||||||
|
("前置约束提取", report.get("preconditions") or "—"),
|
||||||
|
("任务配置", config_text(report.get("task_config"))),
|
||||||
("任务目标", report["goal"]),
|
("任务目标", report["goal"]),
|
||||||
("预期结果", report["expected"] or "未指定"),
|
("预期结果", report["expected"] or "未指定"),
|
||||||
("执行状态", STATUS.get(report["status"], report["status"])),
|
("执行状态", STATUS.get(report["status"], report["status"])),
|
||||||
@@ -132,6 +229,16 @@ def metadata(report: dict) -> list[tuple]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def config_text(config) -> str:
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
return text(config or {})
|
||||||
|
return json.dumps(
|
||||||
|
{key: value for key, value in config.items() if value is not None},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def image_path(name: str | None) -> Path | None:
|
def image_path(name: str | None) -> Path | None:
|
||||||
if not name:
|
if not name:
|
||||||
return None
|
return None
|
||||||
@@ -149,10 +256,14 @@ def image_path(name: str | None) -> Path | None:
|
|||||||
def markdown_blocks(content: str):
|
def markdown_blocks(content: str):
|
||||||
lines = content.splitlines()
|
lines = content.splitlines()
|
||||||
index = 0
|
index = 0
|
||||||
|
in_code = False
|
||||||
while index < len(lines):
|
while index < len(lines):
|
||||||
line = lines[index]
|
line = lines[index]
|
||||||
|
if line.strip().startswith("```"):
|
||||||
|
in_code = not in_code
|
||||||
if (
|
if (
|
||||||
"|" in line
|
not in_code
|
||||||
|
and "|" in line
|
||||||
and index + 1 < len(lines)
|
and index + 1 < len(lines)
|
||||||
and re.fullmatch(r"[\s|:\-]+", lines[index + 1])
|
and re.fullmatch(r"[\s|:\-]+", lines[index + 1])
|
||||||
and "-" in lines[index + 1]
|
and "-" in lines[index + 1]
|
||||||
@@ -168,81 +279,296 @@ def markdown_blocks(content: str):
|
|||||||
index += 1
|
index += 1
|
||||||
|
|
||||||
|
|
||||||
def word_report(report: dict, path: Path):
|
def word_styles(doc, *, landscape: bool = False):
|
||||||
doc = Document()
|
section = doc.sections[0]
|
||||||
style = cast(ParagraphStyle, doc.styles["Normal"])
|
section.page_width, section.page_height = (
|
||||||
|
(Cm(29.7), Cm(21)) if landscape else (Cm(21), Cm(29.7))
|
||||||
|
)
|
||||||
|
section.left_margin = section.right_margin = Cm(2)
|
||||||
|
section.top_margin = section.bottom_margin = Cm(1.9)
|
||||||
|
for name, size, color in (
|
||||||
|
("Normal", 10.5, "263445"),
|
||||||
|
("Title", 20, "0F172A"),
|
||||||
|
("Heading 1", 14, "0F172A"),
|
||||||
|
("Heading 2", 11.5, "0F172A"),
|
||||||
|
("Heading 3", 10.5, "0F172A"),
|
||||||
|
("List Bullet", 10.5, "263445"),
|
||||||
|
("List Number", 10.5, "263445"),
|
||||||
|
("Quote", 10.5, "263445"),
|
||||||
|
):
|
||||||
|
style = cast(ParagraphStyle, doc.styles[name])
|
||||||
style.font.name = "Microsoft YaHei"
|
style.font.name = "Microsoft YaHei"
|
||||||
style.font.size = Pt(10)
|
style.font.size = Pt(size)
|
||||||
style.element.rPr.rFonts.set(qn("w:eastAsia"), "微软雅黑")
|
style.font.color.rgb = RGBColor.from_string(color)
|
||||||
doc.add_heading(report["name"], 0)
|
style.element.get_or_add_rPr().get_or_add_rFonts().set(qn("w:eastAsia"), "微软雅黑")
|
||||||
table = doc.add_table(rows=0, cols=2)
|
style.paragraph_format.space_after = Pt(7 if name == "Normal" else 9)
|
||||||
table.columns[0].width = Inches(1.3)
|
if name == "Normal":
|
||||||
table.columns[1].width = Inches(5.2)
|
style.paragraph_format.line_spacing = 1.3
|
||||||
table.style = "Light Shading Accent 1"
|
if name == "Title":
|
||||||
for label, value in metadata(report):
|
ppr = style.element.get_or_add_pPr()
|
||||||
|
border = ppr.find(qn("w:pBdr"))
|
||||||
|
if border is not None:
|
||||||
|
ppr.remove(border)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def shade_cell(cell, fill: str):
|
||||||
|
shading = OxmlElement("w:shd")
|
||||||
|
shading.set(qn("w:fill"), fill)
|
||||||
|
cell._tc.get_or_add_tcPr().append(shading)
|
||||||
|
|
||||||
|
|
||||||
|
def word_table(doc, rows, widths: tuple[float, ...], *, header: bool = False):
|
||||||
|
table = doc.add_table(rows=0, cols=len(widths))
|
||||||
|
table.autofit = False
|
||||||
|
table.style = "Table Grid"
|
||||||
|
borders = OxmlElement("w:tblBorders")
|
||||||
|
for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
|
||||||
|
line = OxmlElement(f"w:{edge}")
|
||||||
|
line.set(qn("w:val"), "single")
|
||||||
|
line.set(qn("w:sz"), "4")
|
||||||
|
line.set(qn("w:color"), "D9D9D9")
|
||||||
|
borders.append(line)
|
||||||
|
table._tbl.tblPr.append(borders)
|
||||||
|
for column, width in zip(table.columns, widths):
|
||||||
|
column.width = Cm(width)
|
||||||
|
for index, row in enumerate(rows):
|
||||||
cells = table.add_row().cells
|
cells = table.add_row().cells
|
||||||
cells[0].text, cells[1].text = label, text(value)
|
if header and index == 0:
|
||||||
cells[0].width, cells[1].width = Inches(1.3), Inches(5.2)
|
table.rows[-1]._tr.get_or_add_trPr().append(OxmlElement("w:tblHeader"))
|
||||||
doc.add_heading("结论与任务专属结果", 1)
|
for position, cell in enumerate(cells):
|
||||||
for kind, value in markdown_blocks(text(report["content"])):
|
cell.width = Cm(widths[position])
|
||||||
|
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
|
||||||
|
cell.text = text(row[position]) if position < len(row) else ""
|
||||||
|
margins = OxmlElement("w:tcMar")
|
||||||
|
for edge, size in (("top", "85"), ("bottom", "85"), ("left", "110"), ("right", "110")):
|
||||||
|
margin = OxmlElement(f"w:{edge}")
|
||||||
|
margin.set(qn("w:w"), size)
|
||||||
|
margin.set(qn("w:type"), "dxa")
|
||||||
|
margins.append(margin)
|
||||||
|
cell._tc.get_or_add_tcPr().append(margins)
|
||||||
|
if (header and index == 0) or (not header and position == 0):
|
||||||
|
shade_cell(cell, "DDEBF5")
|
||||||
|
for run in cell.paragraphs[0].runs:
|
||||||
|
run.bold = True
|
||||||
|
elif index % 2 == 0:
|
||||||
|
shade_cell(cell, "F6F9FC")
|
||||||
|
for paragraph in cell.paragraphs:
|
||||||
|
paragraph.paragraph_format.space_after = Pt(3)
|
||||||
|
for run in paragraph.runs:
|
||||||
|
run.font.size = Pt(8.5 if len(widths) > 2 else 9.5)
|
||||||
|
doc.add_paragraph().paragraph_format.space_after = Pt(2)
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def add_markdown_inline(paragraph, value: str):
|
||||||
|
"""Keep emphasis readable in Word without importing untrusted HTML."""
|
||||||
|
pieces = re.split(r"(\*\*[^*]+\*\*|`[^`]+`|\*[^*]+\*)", value)
|
||||||
|
for piece in pieces:
|
||||||
|
if not piece:
|
||||||
|
continue
|
||||||
|
run = paragraph.add_run(
|
||||||
|
piece.strip("*")
|
||||||
|
if piece.startswith("*")
|
||||||
|
else piece.strip("`")
|
||||||
|
if piece.startswith("`")
|
||||||
|
else piece
|
||||||
|
)
|
||||||
|
run.bold = piece.startswith("**")
|
||||||
|
run.italic = piece.startswith("*") and not piece.startswith("**")
|
||||||
|
if piece.startswith("`"):
|
||||||
|
run.font.name = "Consolas"
|
||||||
|
|
||||||
|
|
||||||
|
def add_markdown_content(doc, content: str):
|
||||||
|
in_code = False
|
||||||
|
for kind, value in markdown_blocks(text(content)):
|
||||||
if kind == "table":
|
if kind == "table":
|
||||||
table = doc.add_table(rows=0, cols=max(map(len, value)))
|
columns = max(map(len, value))
|
||||||
table.style = "Light Shading Accent 1"
|
if columns:
|
||||||
for row in value:
|
word_table(doc, value, tuple(16 / columns for _ in range(columns)), header=True)
|
||||||
cells = table.add_row().cells
|
continue
|
||||||
for cell, item in zip(cells, row):
|
line = str(value)
|
||||||
cell.text = item
|
if line.strip().startswith("```"):
|
||||||
elif isinstance(value, str) and value.startswith("#"):
|
in_code = not in_code
|
||||||
doc.add_heading(value.lstrip("# "), min(len(value) - len(value.lstrip("#")), 3))
|
continue
|
||||||
|
if in_code:
|
||||||
|
paragraph = doc.add_paragraph()
|
||||||
|
shade = OxmlElement("w:shd")
|
||||||
|
shade.set(qn("w:fill"), "F1F5F9")
|
||||||
|
paragraph._p.get_or_add_pPr().append(shade)
|
||||||
|
paragraph.add_run(line).font.name = "Consolas"
|
||||||
|
elif not line.strip():
|
||||||
|
continue
|
||||||
|
elif match := re.match(r"^(#{1,3})\s+(.+)$", line):
|
||||||
|
doc.add_heading(match.group(2), level=min(len(match.group(1)) + 1, 3))
|
||||||
|
elif match := re.match(r"^\s*([-*+] |\d+\. )(.+)$", line):
|
||||||
|
paragraph = doc.add_paragraph(
|
||||||
|
style="List Number" if match.group(1)[0].isdigit() else "List Bullet"
|
||||||
|
)
|
||||||
|
add_markdown_inline(paragraph, match.group(2))
|
||||||
else:
|
else:
|
||||||
doc.add_paragraph(str(value))
|
paragraph = doc.add_paragraph(style="Quote" if line.lstrip().startswith("> ") else None)
|
||||||
|
add_markdown_inline(
|
||||||
|
paragraph, line.lstrip("> ") if line.lstrip().startswith("> ") else line
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def word_report(report: dict, path: Path):
|
||||||
|
doc = word_styles(Document())
|
||||||
|
doc.add_paragraph(report["name"], style="Title")
|
||||||
|
doc.add_paragraph(
|
||||||
|
f"执行状态:{STATUS.get(report['status'], report['status'])} 验证结论:{report['verdict']}"
|
||||||
|
)
|
||||||
|
if report["error"]:
|
||||||
|
doc.add_heading("异常原因", 1)
|
||||||
|
doc.add_paragraph(text(report["error"]))
|
||||||
|
doc.add_heading("任务信息", 1)
|
||||||
|
details = [
|
||||||
|
(label, value)
|
||||||
|
for label, value in metadata(report)
|
||||||
|
if label in {"任务编号", "用例编号", "检测项目", "测试子项", "开始时间", "结束时间", "设备"}
|
||||||
|
]
|
||||||
|
word_table(doc, details, (3.2, 13.3))
|
||||||
|
doc.add_heading("任务目标", 2)
|
||||||
|
doc.add_paragraph(report["goal"] or "未记录")
|
||||||
|
doc.add_heading("预期结果", 2)
|
||||||
|
doc.add_paragraph(report["expected"] or "未指定")
|
||||||
|
if report.get("preconditions"):
|
||||||
|
doc.add_paragraph("前置约束:" + text(report["preconditions"]))
|
||||||
|
if report.get("task_config"):
|
||||||
|
doc.add_paragraph("任务配置:" + config_text(report["task_config"]))
|
||||||
|
doc.add_heading("结论与发现", 1)
|
||||||
|
add_markdown_content(doc, report["content"])
|
||||||
doc.add_heading("执行步骤与证据", 1)
|
doc.add_heading("执行步骤与证据", 1)
|
||||||
for step in report["steps"]:
|
for step in report["steps"]:
|
||||||
doc.add_paragraph(
|
paragraph = doc.add_paragraph()
|
||||||
f"步骤 {step['step_number']} · {time_text(step.get('timestamp'))} · {step.get('summary') or '已记录操作'}"
|
paragraph.paragraph_format.keep_together = True
|
||||||
)
|
paragraph.add_run(
|
||||||
evidence = report["steps"][:1] + report["steps"][-3:]
|
f"步骤 {step['step_number']} · {time_text(step.get('timestamp'))}\n"
|
||||||
seen = set()
|
).bold = True
|
||||||
|
paragraph.add_run(step.get("summary") or "已记录操作")
|
||||||
|
evidence = report["steps"][:1] + report["steps"][-1:]
|
||||||
|
seen: set[str] = set()
|
||||||
|
images: list[tuple[Path, int]] = []
|
||||||
for step in evidence:
|
for step in evidence:
|
||||||
name = step.get("post_image_name") or step.get("pre_image_name")
|
name = step.get("post_image_name") or step.get("pre_image_name")
|
||||||
photo = image_path(name)
|
photo = image_path(name)
|
||||||
if photo and name not in seen:
|
if photo and name not in seen:
|
||||||
seen.add(name)
|
seen.add(name)
|
||||||
doc.add_paragraph(f"证据:步骤 {step['step_number']} / {name}")
|
images.append((photo, step["step_number"]))
|
||||||
doc.add_picture(str(photo), width=Inches(2.6))
|
if images:
|
||||||
|
doc.add_page_break()
|
||||||
|
doc.add_heading("关键截图", 2)
|
||||||
|
table = doc.add_table(rows=1, cols=len(images))
|
||||||
|
table.autofit = False
|
||||||
|
for cell, (photo, step_number) in zip(table.rows[0].cells, images):
|
||||||
|
cell.width = Cm(8 if len(images) == 2 else 12)
|
||||||
|
cell.paragraphs[0].add_run(f"步骤 {step_number}").bold = True
|
||||||
|
cell.add_paragraph().add_run().add_picture(
|
||||||
|
str(photo), width=Cm(7.5 if len(images) == 2 else 8.5)
|
||||||
|
)
|
||||||
|
doc.add_paragraph("其余步骤截图可从任务资料包单独下载。")
|
||||||
doc.add_paragraph(f"追溯编号:{report['id']}。未观察、未检查的项目不计为验证通过。")
|
doc.add_paragraph(f"追溯编号:{report['id']}。未观察、未检查的项目不计为验证通过。")
|
||||||
doc.save(str(path))
|
doc.save(str(path))
|
||||||
|
|
||||||
|
|
||||||
def sheet_rows(ws, rows):
|
def sheet_rows(ws, rows, widths: tuple[int, ...] = ()):
|
||||||
for row in rows:
|
ws.sheet_view.showGridLines = False
|
||||||
|
ws.freeze_panes = "A2"
|
||||||
|
ws.sheet_properties.pageSetUpPr.fitToPage = True
|
||||||
|
ws.page_setup.fitToWidth = 1
|
||||||
|
if len(widths) > 3:
|
||||||
|
ws.page_setup.orientation = "landscape"
|
||||||
|
ws.page_setup.paperSize = ws.PAPERSIZE_A3
|
||||||
|
ws.print_options.horizontalCentered = True
|
||||||
|
for index, row in enumerate(rows, 1):
|
||||||
ws.append([text(value)[:32700] if value is not None else "" for value in row])
|
ws.append([text(value)[:32700] if value is not None else "" for value in row])
|
||||||
for cell in ws[ws.max_row]:
|
longest = 1
|
||||||
|
for column, cell in enumerate(ws[index], 1):
|
||||||
# Always store text, including leading '=': never export executable formulas.
|
# Always store text, including leading '=': never export executable formulas.
|
||||||
cell.data_type = "s"
|
cell.data_type = "s"
|
||||||
cell.alignment = Alignment(wrap_text=True, vertical="top")
|
cell.alignment = Alignment(wrap_text=True, vertical="center" if index == 1 else "top")
|
||||||
ws.freeze_panes = "A2"
|
cell.font = Font(
|
||||||
if ws.max_row:
|
name="微软雅黑",
|
||||||
ws.auto_filter.ref = ws.dimensions
|
size=10,
|
||||||
for cell in ws[1]:
|
bold=index == 1,
|
||||||
cell.font = Font(bold=True, color="FFFFFF")
|
color="FFFFFF" if index == 1 else "253244",
|
||||||
|
)
|
||||||
|
cell.border = Border(bottom=Side(style="hair", color="E2E8F0"))
|
||||||
|
if index == 1:
|
||||||
cell.fill = PatternFill("solid", fgColor="245C78")
|
cell.fill = PatternFill("solid", fgColor="245C78")
|
||||||
for column in ws.columns:
|
elif index % 2 == 1 or column == 1:
|
||||||
ws.column_dimensions[column[0].column_letter].width = 32
|
cell.fill = PatternFill("solid", fgColor="F3F7FA" if column == 1 else "F8FAFC")
|
||||||
|
width = widths[column - 1] if column <= len(widths) else 32
|
||||||
|
ws.column_dimensions[get_column_letter(column)].width = width
|
||||||
|
lines = str(cell.value or "").splitlines() or [""]
|
||||||
|
height_lines = sum(
|
||||||
|
max(1, (sum(2 if ord(char) > 255 else 1 for char in line) + width - 1) // width)
|
||||||
|
for line in lines
|
||||||
|
)
|
||||||
|
longest = max(longest, height_lines)
|
||||||
|
ws.row_dimensions[index].height = 30 if index == 1 else min(300, max(27, longest * 18 + 8))
|
||||||
|
if ws.max_row > 1:
|
||||||
|
ws.auto_filter.ref = ws.dimensions
|
||||||
|
|
||||||
|
|
||||||
|
def conclusion_rows(content: str):
|
||||||
|
rows = [("类型", "结论与发现")]
|
||||||
|
table_number = 0
|
||||||
|
in_code = False
|
||||||
|
for kind, value in markdown_blocks(content):
|
||||||
|
if kind == "table":
|
||||||
|
table_number += 1
|
||||||
|
rows.append(("表格", f"详见「结果表{table_number}」工作表"))
|
||||||
|
elif line := str(value).strip():
|
||||||
|
if line.startswith("```"):
|
||||||
|
in_code = not in_code
|
||||||
|
continue
|
||||||
|
if in_code:
|
||||||
|
rows.append(("代码", line))
|
||||||
|
continue
|
||||||
|
if match := re.match(r"^#{1,6}\s+(.*)$", line):
|
||||||
|
label, body = "小节", match.group(1)
|
||||||
|
elif match := re.match(r"^(?:[-*+] |\d+\. )(.*)$", line):
|
||||||
|
label, body = "要点", match.group(1)
|
||||||
|
else:
|
||||||
|
label, body = "正文", line.lstrip("> ")
|
||||||
|
rows.append(
|
||||||
|
(
|
||||||
|
label,
|
||||||
|
re.sub(r"\*\*([^*]+)\*\*|`([^`]+)`", lambda m: m.group(1) or m.group(2), body),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def execution_result_label(result) -> str:
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return text(result or "未记录")[:120]
|
||||||
|
status = result.get("status")
|
||||||
|
labels = {
|
||||||
|
"dispatched": "动作已发出(需核对截图)",
|
||||||
|
"success": "动作返回成功",
|
||||||
|
"failed": "动作失败",
|
||||||
|
}
|
||||||
|
label = labels.get(status, text(status or "未记录"))
|
||||||
|
if result.get("error"):
|
||||||
|
label += "\n" + text(result["error"])[:180]
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
def excel_report(report: dict, path: Path):
|
def excel_report(report: dict, path: Path):
|
||||||
wb = Workbook()
|
wb = Workbook()
|
||||||
assert wb.active is not None
|
assert wb.active is not None
|
||||||
wb.active.title = "任务结果"
|
wb.active.title = "任务结果"
|
||||||
sheet_rows(wb.active, [("项目", "内容"), *metadata(report)])
|
sheet_rows(wb.active, [("项目", "内容"), *metadata(report)], (22, 82))
|
||||||
sheet_rows(
|
sheet_rows(wb.create_sheet("结论"), conclusion_rows(report["content"]), (15, 96))
|
||||||
wb.create_sheet("结论"),
|
table_number = 0
|
||||||
[("最终结论",), *[(line,) for line in report["content"].splitlines()]],
|
for kind, rows in markdown_blocks(report["content"]):
|
||||||
)
|
|
||||||
for number, (kind, rows) in enumerate(markdown_blocks(report["content"])):
|
|
||||||
if kind == "table":
|
if kind == "table":
|
||||||
sheet_rows(wb.create_sheet(f"结果表{number + 1}"), rows)
|
table_number += 1
|
||||||
|
sheet_rows(wb.create_sheet(f"结果表{table_number}"), rows)
|
||||||
sheet_rows(
|
sheet_rows(
|
||||||
wb.create_sheet("执行证据"),
|
wb.create_sheet("执行证据"),
|
||||||
[
|
[
|
||||||
@@ -252,12 +578,15 @@ def excel_report(report: dict, path: Path):
|
|||||||
s["step_number"],
|
s["step_number"],
|
||||||
time_text(s.get("timestamp")),
|
time_text(s.get("timestamp")),
|
||||||
s.get("summary"),
|
s.get("summary"),
|
||||||
text(s.get("last_execution_result")),
|
execution_result_label(s.get("last_execution_result")),
|
||||||
s.get("post_image_name") or s.get("pre_image_name"),
|
"见步骤截图资料包"
|
||||||
|
if s.get("post_image_name") or s.get("pre_image_name")
|
||||||
|
else "无截图",
|
||||||
)
|
)
|
||||||
for s in report["steps"]
|
for s in report["steps"]
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
(10, 22, 68, 26, 24),
|
||||||
)
|
)
|
||||||
wb.save(path)
|
wb.save(path)
|
||||||
|
|
||||||
@@ -279,6 +608,93 @@ def export_session(session_id: str, formats: list[str] | None = None) -> list[Pa
|
|||||||
return outputs
|
return outputs
|
||||||
|
|
||||||
|
|
||||||
|
def session_screenshots(report: dict) -> list[tuple[Path, str]]:
|
||||||
|
"""Ordered, path-checked pre/post observations for each recorded step."""
|
||||||
|
screenshots: list[tuple[Path, str]] = []
|
||||||
|
for index, step in enumerate(report["steps"], 1):
|
||||||
|
for phase, key in (("操作前", "pre_image_name"), ("操作后", "post_image_name")):
|
||||||
|
source = image_path(step.get(key))
|
||||||
|
if source:
|
||||||
|
screenshots.append(
|
||||||
|
(
|
||||||
|
source,
|
||||||
|
f"{index:04d}_步骤{int(step.get('step_number') or index):04d}_{phase}{source.suffix.lower()}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return screenshots
|
||||||
|
|
||||||
|
|
||||||
|
def session_videos(report: dict) -> list[tuple[Path, str]]:
|
||||||
|
"""Find only verified files attached to this session's recording directory."""
|
||||||
|
session = SessionRepository().get_session_by_id(report["id"]) or {}
|
||||||
|
recording = SessionRepository().get_video_recording_for_session(report["id"]) or {}
|
||||||
|
candidate = recording.get("local_video_path") or session.get("video_filepath")
|
||||||
|
if not candidate:
|
||||||
|
return []
|
||||||
|
source = Path(str(candidate)).resolve()
|
||||||
|
trace_root = TRACES_PATH.resolve()
|
||||||
|
# Workers save recordings in their finalized web_<time>_<id>_PASS/FAIL
|
||||||
|
# folder. The recording row already ties that path to this session.
|
||||||
|
if not source.is_relative_to(trace_root):
|
||||||
|
return []
|
||||||
|
# A resumed recording lives under resume_NNNN; the initial recording is
|
||||||
|
# in its parent, with any later segments in sibling resume folders.
|
||||||
|
directory = source.parent
|
||||||
|
if directory.name.startswith("resume_"):
|
||||||
|
directory = directory.parent
|
||||||
|
if not directory.is_dir() or not directory.is_relative_to(trace_root):
|
||||||
|
return []
|
||||||
|
paths = [
|
||||||
|
p
|
||||||
|
for p in directory.rglob("*.mp4")
|
||||||
|
if p.is_file()
|
||||||
|
and p.resolve().is_relative_to(directory.resolve())
|
||||||
|
and (p.parent == directory or p.parent.name.startswith("resume_"))
|
||||||
|
]
|
||||||
|
paths.sort(key=lambda p: (0 if p.parent == directory else 1, p.parent.name, p.name))
|
||||||
|
return [(path, f"{i:04d}_{path.parent.name}_{path.name}") for i, path in enumerate(paths, 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def export_session_media(session_id: str, kind: str) -> Path:
|
||||||
|
if kind not in {"screenshots", "video", "all"}:
|
||||||
|
raise ValueError("不支持的下载类型")
|
||||||
|
with EXPORT_LOCK:
|
||||||
|
report = session_report(session_id)
|
||||||
|
target_dir = EXPORT_ROOT / "sessions" / identifier(session_id)
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
images = session_screenshots(report) if kind in {"screenshots", "all"} else []
|
||||||
|
videos = session_videos(report) if kind in {"video", "all"} else []
|
||||||
|
if kind == "video" and not videos:
|
||||||
|
raise ValueError("此任务没有已保存的录屏")
|
||||||
|
if kind == "screenshots" and not images:
|
||||||
|
raise ValueError("此任务没有可下载的步骤截图")
|
||||||
|
target = target_dir / f"{kind}.zip"
|
||||||
|
temporary = target_dir / f".{kind}.tmp"
|
||||||
|
manifest = {
|
||||||
|
"session_id": session_id,
|
||||||
|
"name": report["name"],
|
||||||
|
"status": report["status"],
|
||||||
|
"verdict": report["verdict"],
|
||||||
|
"screenshots": [name for _, name in images],
|
||||||
|
"videos": [name for _, name in videos],
|
||||||
|
"note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
|
||||||
|
}
|
||||||
|
prefix = archive_case_name(report) + "/" if kind == "all" else ""
|
||||||
|
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
if kind == "all":
|
||||||
|
for path in export_session(session_id, ["docx", "xlsx"]):
|
||||||
|
archive.write(path, prefix + path.name)
|
||||||
|
for source, name in images:
|
||||||
|
archive.write(source, f"{prefix}步骤截图/{name}")
|
||||||
|
for source, name in videos:
|
||||||
|
archive.write(source, f"{prefix}任务录屏/{name}")
|
||||||
|
archive.writestr(
|
||||||
|
prefix + "文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2)
|
||||||
|
)
|
||||||
|
temporary.replace(target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
def export_batch(batch_id: str) -> Path:
|
def export_batch(batch_id: str) -> Path:
|
||||||
with EXPORT_LOCK:
|
with EXPORT_LOCK:
|
||||||
batch = store.get("batch", identifier(batch_id))
|
batch = store.get("batch", identifier(batch_id))
|
||||||
@@ -298,21 +714,17 @@ def export_batch(batch_id: str) -> Path:
|
|||||||
wb = Workbook()
|
wb = Workbook()
|
||||||
assert wb.active is not None
|
assert wb.active is not None
|
||||||
wb.active.title = "批次结果"
|
wb.active.title = "批次结果"
|
||||||
sheet_rows(wb.active, rows)
|
sheet_rows(wb.active, rows, (40, 48, 16, 28, 70))
|
||||||
path = directory / "批次结果.xlsx"
|
path = directory / "批次结果.xlsx"
|
||||||
wb.save(path)
|
wb.save(path)
|
||||||
outputs.append(path)
|
outputs.append(path)
|
||||||
if "docx" in formats:
|
if "docx" in formats:
|
||||||
doc = Document()
|
doc = word_styles(Document(), landscape=True)
|
||||||
doc.add_heading(batch["name"], 0)
|
doc.add_paragraph(batch["name"], style="Title")
|
||||||
doc.add_paragraph(
|
doc.add_paragraph(
|
||||||
f"批次编号:{batch_id};共 {len(records)} 个任务。验证通过与执行完成分别统计。"
|
f"批次编号:{batch_id};共 {len(records)} 个任务。验证通过与执行完成分别统计。"
|
||||||
)
|
)
|
||||||
table = doc.add_table(rows=0, cols=5)
|
word_table(doc, rows, (3.5, 5.4, 2.5, 3.5, 7.3), header=True)
|
||||||
table.style = "Light Shading Accent 1"
|
|
||||||
for row in rows:
|
|
||||||
for cell, value in zip(table.add_row().cells, row):
|
|
||||||
cell.text = str(value)
|
|
||||||
path = directory / "批次汇总.docx"
|
path = directory / "批次汇总.docx"
|
||||||
doc.save(str(path))
|
doc.save(str(path))
|
||||||
outputs.append(path)
|
outputs.append(path)
|
||||||
@@ -323,6 +735,8 @@ def export_batch(batch_id: str) -> Path:
|
|||||||
archive.write(path, path.name)
|
archive.write(path, path.name)
|
||||||
for number, report in enumerate(records, 1):
|
for number, report in enumerate(records, 1):
|
||||||
for path in export_session(report["id"], formats):
|
for path in export_session(report["id"], formats):
|
||||||
archive.write(path, f"任务报告/{number:04d}_{report['id']}/{path.name}")
|
archive.write(
|
||||||
|
path, f"任务报告/{number:04d}_{archive_case_name(report)}/{path.name}"
|
||||||
|
)
|
||||||
temporary.replace(target)
|
temporary.replace(target)
|
||||||
return target
|
return target
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Versioned Excel task template and bounded, non-executing import validation."""
|
"""Validate the prompt-first batch workbook without executing its contents."""
|
||||||
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import json
|
import json
|
||||||
@@ -6,33 +6,53 @@ import os
|
|||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
from openpyxl import Workbook, load_workbook
|
from openpyxl import Workbook, load_workbook
|
||||||
|
from openpyxl.styles import Alignment, Font, PatternFill
|
||||||
from openpyxl.utils import get_column_letter
|
from openpyxl.utils import get_column_letter
|
||||||
from openpyxl.styles import Font, PatternFill, Alignment
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from pydantic import BaseModel, Field, ConfigDict
|
|
||||||
|
|
||||||
|
HEADERS = (
|
||||||
HEADERS = {
|
"用例编号",
|
||||||
"任务名称": "name",
|
"检测项目",
|
||||||
"任务目标": "goal",
|
"测试子项",
|
||||||
"预期结果": "expected",
|
"测试用例",
|
||||||
"目标应用": "app",
|
"前置约束提取",
|
||||||
"前置条件": "preconditions",
|
"Artemis提示词",
|
||||||
"输入参数(JSON)": "parameters",
|
"任务配置(JSON)",
|
||||||
"标签": "tags",
|
)
|
||||||
}
|
|
||||||
MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000"))
|
MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000"))
|
||||||
MAX_BYTES = int(os.getenv("ARTEMIS_IMPORT_MAX_BYTES", "10485760"))
|
MAX_BYTES = int(os.getenv("ARTEMIS_IMPORT_MAX_BYTES", "10485760"))
|
||||||
|
|
||||||
|
|
||||||
|
class TaskOptions(BaseModel):
|
||||||
|
"""Allowlisted per-case overrides; credentials are never accepted in a workbook."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
profile: str | None = Field(default=None, pattern="^(flash|pro)$")
|
||||||
|
model: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
reasoning_effort: str | None = Field(
|
||||||
|
default=None, pattern="^(default|none|minimal|low|medium|high|xhigh|max)$"
|
||||||
|
)
|
||||||
|
device_serial: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
verification_level: str | None = Field(default=None, pattern="^(off|final|checkpoints|strict)$")
|
||||||
|
explorer_mode: str | None = Field(default=None, pattern="^(flash|pro|ultra)$")
|
||||||
|
locked_app_package: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
record_video: bool | None = None
|
||||||
|
analyze_video: bool | None = None
|
||||||
|
request_timeout_seconds: float | None = Field(default=None, gt=0, le=3600)
|
||||||
|
max_retries: int | None = Field(default=None, ge=0, le=10)
|
||||||
|
retry_interval_seconds: float | None = Field(default=None, ge=0, le=300)
|
||||||
|
timeout_seconds: int | None = Field(default=None, ge=30, le=86400)
|
||||||
|
|
||||||
|
|
||||||
class TaskCase(BaseModel):
|
class TaskCase(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
case_id: str = Field(default="", max_length=200)
|
||||||
|
category: str = Field(default="", max_length=200)
|
||||||
|
subcategory: str = Field(default="", max_length=200)
|
||||||
name: str = Field(min_length=1, max_length=200)
|
name: str = Field(min_length=1, max_length=200)
|
||||||
goal: str = Field(min_length=1, max_length=10000)
|
|
||||||
expected: str = Field(default="", max_length=10000)
|
|
||||||
app: str = Field(default="", max_length=200)
|
|
||||||
preconditions: str = Field(default="", max_length=10000)
|
preconditions: str = Field(default="", max_length=10000)
|
||||||
parameters: dict[str, str | int | float | bool] = Field(default_factory=dict)
|
goal: str = Field(min_length=1, max_length=10000)
|
||||||
tags: str = Field(default="", max_length=500)
|
task_config: TaskOptions = Field(default_factory=TaskOptions)
|
||||||
|
|
||||||
|
|
||||||
def template() -> bytes:
|
def template() -> bytes:
|
||||||
@@ -40,27 +60,54 @@ def template() -> bytes:
|
|||||||
ws = wb.active
|
ws = wb.active
|
||||||
assert ws is not None
|
assert ws is not None
|
||||||
ws.title = "任务"
|
ws.title = "任务"
|
||||||
ws.append(list(HEADERS))
|
ws.append(HEADERS)
|
||||||
ws.freeze_panes = "A2"
|
ws.freeze_panes = "A2"
|
||||||
ws.auto_filter.ref = "A1:G1"
|
ws.auto_filter.ref = "A1:G1"
|
||||||
for cell in ws[1]:
|
for cell in ws[1]:
|
||||||
cell.font = Font(bold=True, color="FFFFFF")
|
cell.font = Font(bold=True, color="FFFFFF")
|
||||||
cell.fill = PatternFill("solid", fgColor="245C78")
|
cell.fill = PatternFill("solid", fgColor="245C78")
|
||||||
ws.column_dimensions[get_column_letter(int(cell.column or 1))].width = 32
|
ws.column_dimensions[get_column_letter(int(cell.column or 1))].width = (
|
||||||
|
65 if cell.column in {6, 7} else 28
|
||||||
|
)
|
||||||
help_sheet = wb.create_sheet("填写说明")
|
help_sheet = wb.create_sheet("填写说明")
|
||||||
for line in [
|
help_sheet.append(
|
||||||
"模板版本:1",
|
["模板版本:2。任务表每行一项,Artemis提示词必填;只把这一列作为 AI 任务正文。"]
|
||||||
"每行一个独立任务;任务名称、任务目标必填。",
|
)
|
||||||
"预期结果决定要验证的业务条件。未填写时只报告任务完成情况。",
|
help_sheet.append(["前置约束提取及其他描述列仅作查看、搜索和报告元数据,不追加到提示词。"])
|
||||||
"前置条件描述实际用例起点;不会统一重启或清空应用数据。",
|
help_sheet.append(["任务配置(JSON)可留空,空字段继承批次或全局配置;禁止密钥与未知字段。"])
|
||||||
'输入参数使用 JSON 对象,例如 {"关键词":"天气"};目标可引用 {{关键词}}。',
|
help_sheet.append(["示例仅供参考,不会导入执行。请在“任务”页填写自己的用例。"])
|
||||||
"目标应用可填名称或包名;空白时由任务决定。",
|
help_sheet.append(HEADERS)
|
||||||
"不要填写 Excel 公式、宏或外部链接。导入仅预览,点击开始后执行。",
|
help_sheet.append(
|
||||||
]:
|
[
|
||||||
help_sheet.append([line])
|
"示例-001",
|
||||||
help_sheet.column_dimensions["A"].width = 105
|
"示例模块",
|
||||||
|
"示例检查",
|
||||||
|
"检查示例页面",
|
||||||
|
"设备已经打开目标应用",
|
||||||
|
"观察当前页面并报告实际结果,不要假设检查已通过。",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"profile": "pro",
|
||||||
|
"model": "mimo-v2.6-flash",
|
||||||
|
"reasoning_effort": "high",
|
||||||
|
"verification_level": "final",
|
||||||
|
"record_video": True,
|
||||||
|
"analyze_video": False,
|
||||||
|
"request_timeout_seconds": 300,
|
||||||
|
"max_retries": 5,
|
||||||
|
"retry_interval_seconds": 10,
|
||||||
|
"timeout_seconds": 1800,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for column in range(1, 8):
|
||||||
|
help_sheet.column_dimensions[get_column_letter(column)].width = 70 if column > 5 else 26
|
||||||
for row in help_sheet:
|
for row in help_sheet:
|
||||||
row[0].alignment = Alignment(wrap_text=True)
|
for cell in row:
|
||||||
|
cell.alignment = Alignment(wrap_text=True, vertical="top")
|
||||||
|
help_sheet.row_dimensions[6].height = 110
|
||||||
stream = BytesIO()
|
stream = BytesIO()
|
||||||
wb.save(stream)
|
wb.save(stream)
|
||||||
return stream.getvalue()
|
return stream.getvalue()
|
||||||
@@ -90,7 +137,7 @@ def parse_workbook(data: bytes) -> dict:
|
|||||||
rows = iter(wb["任务"].iter_rows())
|
rows = iter(wb["任务"].iter_rows())
|
||||||
headers = [str(cell.value or "").strip() for cell in next(rows, [])]
|
headers = [str(cell.value or "").strip() for cell in next(rows, [])]
|
||||||
if headers != list(HEADERS):
|
if headers != list(HEADERS):
|
||||||
raise ValueError("列名或顺序与模板不一致,请重新下载模板")
|
raise ValueError("列名或顺序与新版模板不一致,请重新下载模板")
|
||||||
tasks, errors = [], []
|
tasks, errors = [], []
|
||||||
for number, row in enumerate(rows, 2):
|
for number, row in enumerate(rows, 2):
|
||||||
if number > MAX_ROWS + 1:
|
if number > MAX_ROWS + 1:
|
||||||
@@ -100,28 +147,40 @@ def parse_workbook(data: bytes) -> dict:
|
|||||||
try:
|
try:
|
||||||
if any(cell.data_type == "f" for cell in row):
|
if any(cell.data_type == "f" for cell in row):
|
||||||
raise ValueError("不允许公式,请粘贴为纯文本")
|
raise ValueError("不允许公式,请粘贴为纯文本")
|
||||||
values = [str(cell.value or "").strip() for cell in row[: len(HEADERS)]]
|
values = [
|
||||||
item = dict(zip(HEADERS.values(), values))
|
str(cell.value if cell.value is not None else "").strip() for cell in row[:7]
|
||||||
item["parameters"] = json.loads(item.get("parameters") or "{}")
|
]
|
||||||
case = TaskCase.model_validate(item)
|
config = json.loads(values[6] or "{}")
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
raise ValueError("任务配置必须为 JSON 对象")
|
||||||
|
case = TaskCase.model_validate(
|
||||||
|
{
|
||||||
|
"case_id": values[0],
|
||||||
|
"category": values[1],
|
||||||
|
"subcategory": values[2],
|
||||||
|
"name": values[3] or values[2] or values[0] or values[5][:120],
|
||||||
|
"preconditions": values[4],
|
||||||
|
"goal": values[5],
|
||||||
|
"task_config": config,
|
||||||
|
}
|
||||||
|
)
|
||||||
tasks.append({"row": number, **case.model_dump()})
|
tasks.append({"row": number, **case.model_dump()})
|
||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError) as exc:
|
||||||
errors.append({"row": number, "message": str(exc)[:500]})
|
errors.append({"row": number, "message": str(exc)[:500]})
|
||||||
if not tasks and not errors:
|
if not tasks and not errors:
|
||||||
errors.append({"row": 2, "message": "没有任务,请填写模板后重新导入"})
|
errors.append({"row": 2, "message": "没有任务,请填写模板后重新导入"})
|
||||||
return {"version": 1, "tasks": tasks, "errors": errors, "valid": bool(tasks) and not errors}
|
return {"version": 2, "tasks": tasks, "errors": errors, "valid": bool(tasks) and not errors}
|
||||||
finally:
|
finally:
|
||||||
wb.close()
|
wb.close()
|
||||||
|
|
||||||
|
|
||||||
def case_goal(case: dict) -> str:
|
def case_goal(case: dict) -> str:
|
||||||
goal = case["goal"]
|
"""Execution gets the prompt verbatim; metadata stays outside model input."""
|
||||||
for key, value in case.get("parameters", {}).items():
|
return case["goal"]
|
||||||
goal = goal.replace("{{" + key + "}}", str(value))
|
|
||||||
return (
|
|
||||||
f"任务名称:{case['name']}\n任务目标:{goal}\n目标应用:{case.get('app') or '按目标判断'}\n"
|
def case_title(case: dict) -> str:
|
||||||
f"前置条件:{case.get('preconditions') or '根据实际用例判断合适起点'}\n"
|
"""Human-facing title; never prepend metadata to the model prompt."""
|
||||||
"先观察手机并恢复到本用例需要的起始位置,记录准备过程。不要固定重启或清空数据。"
|
case_id = str(case.get("case_id") or "").strip()
|
||||||
"如果无法建立前置条件,明确报告前置条件未满足,不继续正文操作。\n"
|
name = str(case.get("name") or "").strip()
|
||||||
f"预期结果:{case.get('expected') or '仅确认任务完成情况,不宣称测试通过'}"
|
return " ".join(part for part in (case_id, name) if part)
|
||||||
)
|
|
||||||
@@ -302,7 +302,7 @@ class TaskQueueService:
|
|||||||
normalized = current_status.lower().strip() if isinstance(current_status, str) else None
|
normalized = current_status.lower().strip() if isinstance(current_status, str) else None
|
||||||
if normalized == "success":
|
if normalized == "success":
|
||||||
return "completed", True
|
return "completed", True
|
||||||
if normalized in {"completed", "failed", "cancelled"}:
|
if normalized in {"completed", "failed", "cancelled", "interrupted"}:
|
||||||
return normalized, False
|
return normalized, False
|
||||||
return ("completed" if returncode == 0 else "failed"), True
|
return ("completed" if returncode == 0 else "failed"), True
|
||||||
|
|
||||||
@@ -388,10 +388,9 @@ class TaskQueueService:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _dispatch_pending_tasks(cls) -> None:
|
def _dispatch_pending_tasks(cls) -> None:
|
||||||
"""Launch every pending task admissible under the current concurrency limit."""
|
"""Launch every pending task admissible under the current concurrency limit."""
|
||||||
|
cls._sync_control_states()
|
||||||
limit = cls._concurrency_limit()
|
limit = cls._concurrency_limit()
|
||||||
state.prune_finished_runs()
|
state.prune_finished_runs()
|
||||||
if limit == 1 and state.is_running:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Dispatched-but-not-yet-spawned runs are only visible as queue items in
|
# Dispatched-but-not-yet-spawned runs are only visible as queue items in
|
||||||
# "running" state, so admission must count those too -- active_runs alone
|
# "running" state, so admission must count those too -- active_runs alone
|
||||||
@@ -410,7 +409,9 @@ class TaskQueueService:
|
|||||||
and item.get("pid") not in active_pids
|
and item.get("pid") not in active_pids
|
||||||
for item in in_flight
|
for item in in_flight
|
||||||
)
|
)
|
||||||
capacity = limit - len(state.active_runs) - starting
|
capacity = (
|
||||||
|
limit - sum(not r.get("parked") for r in state.active_runs.values()) - starting
|
||||||
|
)
|
||||||
if capacity <= 0:
|
if capacity <= 0:
|
||||||
return
|
return
|
||||||
busy_devices = state.busy_device_ids | {
|
busy_devices = state.busy_device_ids | {
|
||||||
@@ -432,11 +433,15 @@ class TaskQueueService:
|
|||||||
# A task without a resolved device may bind to any serial, so it
|
# A task without a resolved device may bind to any serial, so it
|
||||||
# only launches on an otherwise idle scheduler; the device lock
|
# only launches on an otherwise idle scheduler; the device lock
|
||||||
# then allocates freely without contending against active runs.
|
# then allocates freely without contending against active runs.
|
||||||
if device is None and (state.active_runs or in_flight or dispatched_any):
|
if device is None and (
|
||||||
|
any(not r.get("parked") for r in state.active_runs.values())
|
||||||
|
or in_flight
|
||||||
|
or dispatched_any
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
if device is not None and target.lock_key in busy_devices:
|
if device is not None and target.lock_key in busy_devices:
|
||||||
continue
|
continue
|
||||||
elif limit > 1 and device is not None and target.lock_key in busy_devices:
|
elif limit >= 1 and device is not None and target.lock_key in busy_devices:
|
||||||
# A second worker for this device would wait on its lock.
|
# A second worker for this device would wait on its lock.
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -454,6 +459,42 @@ class TaskQueueService:
|
|||||||
if capacity <= 0:
|
if capacity <= 0:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _sync_control_states(cls) -> None:
|
||||||
|
from artemis.runtime.task_control import read_state
|
||||||
|
from apps.admin_console.services.automation_store import store
|
||||||
|
|
||||||
|
for item in state.queue_items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
sid = str(item.get("session_id") or "")
|
||||||
|
run = state.active_runs.get(sid)
|
||||||
|
if not run:
|
||||||
|
continue
|
||||||
|
status = read_state(sid).get("status")
|
||||||
|
if status not in {"running", "task_paused", "resuming"}:
|
||||||
|
continue
|
||||||
|
old = item.get("status")
|
||||||
|
if status == old:
|
||||||
|
continue
|
||||||
|
parked = status in {"task_paused", "resuming"}
|
||||||
|
now = time.time()
|
||||||
|
if parked and not run.get("parked"):
|
||||||
|
run["non_execution_started_at"] = now
|
||||||
|
elif not parked and run.get("parked"):
|
||||||
|
run["non_execution_seconds"] = (
|
||||||
|
float(run.get("non_execution_seconds") or 0)
|
||||||
|
+ now
|
||||||
|
- float(run.pop("non_execution_started_at", now))
|
||||||
|
)
|
||||||
|
run["parked"] = parked
|
||||||
|
item["status"] = status
|
||||||
|
job = store.job(sid)
|
||||||
|
if job and job.get("status") != "finalizing":
|
||||||
|
job["status"] = status
|
||||||
|
store.save_job(job)
|
||||||
|
cls._broadcast_event("task_status", {"session_id": sid, "status": status})
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _begin_task_run(
|
def _begin_task_run(
|
||||||
cls,
|
cls,
|
||||||
@@ -531,6 +572,22 @@ class TaskQueueService:
|
|||||||
env["ARTEMIS_SESSION_ID"] = str(sess_id)
|
env["ARTEMIS_SESSION_ID"] = str(sess_id)
|
||||||
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
|
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
|
||||||
env["ARTEMIS_TASK_WORKER"] = "1"
|
env["ARTEMIS_TASK_WORKER"] = "1"
|
||||||
|
overrides = task_item.get("runtime_overrides") or {}
|
||||||
|
for option, variable in {
|
||||||
|
"model": "OPENAI_MODEL",
|
||||||
|
"reasoning_effort": "OPENAI_REASONING_EFFORT",
|
||||||
|
"request_timeout_seconds": "OPENAI_TIMEOUT_SECONDS",
|
||||||
|
"max_retries": "OPENAI_MAX_RETRIES",
|
||||||
|
"retry_interval_seconds": "OPENAI_RETRY_INTERVAL_SECONDS",
|
||||||
|
"record_video": "ARTEMIS_RECORDING_ENABLED",
|
||||||
|
"analyze_video": "ARTEMIS_VIDEO_ENABLED",
|
||||||
|
}.items():
|
||||||
|
if overrides.get(option) is not None:
|
||||||
|
env[variable] = (
|
||||||
|
str(overrides[option]).lower()
|
||||||
|
if isinstance(overrides[option], bool)
|
||||||
|
else str(overrides[option])
|
||||||
|
)
|
||||||
target.endpoint.apply_to_environment(env)
|
target.endpoint.apply_to_environment(env)
|
||||||
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
|
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
|
||||||
queue_ticket = task_item.get("queue_ticket")
|
queue_ticket = task_item.get("queue_ticket")
|
||||||
@@ -664,6 +721,12 @@ class TaskQueueService:
|
|||||||
returncode,
|
returncode,
|
||||||
manual_stop,
|
manual_stop,
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
state.is_shutting_down
|
||||||
|
and not manual_stop
|
||||||
|
and current_status not in {"completed", "success", "failed", "interrupted"}
|
||||||
|
):
|
||||||
|
new_status, should_persist = "interrupted", True
|
||||||
if should_persist:
|
if should_persist:
|
||||||
if session_repo.update_session_status(sess_id, new_status, time.time()):
|
if session_repo.update_session_status(sess_id, new_status, time.time()):
|
||||||
print(f"[QueueWorker] Updated session {sess_id} status to '{new_status}'")
|
print(f"[QueueWorker] Updated session {sess_id} status to '{new_status}'")
|
||||||
@@ -1069,6 +1132,8 @@ class TaskQueueService:
|
|||||||
conversation_id: str | None = None,
|
conversation_id: str | None = None,
|
||||||
verification_level: str | None = None,
|
verification_level: str | None = None,
|
||||||
explorer_mode: str | None = None,
|
explorer_mode: str | None = None,
|
||||||
|
runtime_overrides: dict[str, Any] | None = None,
|
||||||
|
display_name: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Enqueues one or more goals and wakes up the background worker.
|
"""Enqueues one or more goals and wakes up the background worker.
|
||||||
|
|
||||||
@@ -1123,6 +1188,10 @@ class TaskQueueService:
|
|||||||
verification_level=verification_level,
|
verification_level=verification_level,
|
||||||
explorer_mode=explorer_mode,
|
explorer_mode=explorer_mode,
|
||||||
)
|
)
|
||||||
|
if runtime_overrides:
|
||||||
|
task_item["runtime_overrides"] = runtime_overrides
|
||||||
|
if display_name:
|
||||||
|
task_item["display_name"] = display_name
|
||||||
state.queue_items.append(task_item)
|
state.queue_items.append(task_item)
|
||||||
enqueued_tasks.append(task_item)
|
enqueued_tasks.append(task_item)
|
||||||
cls._broadcast_startup_progress(
|
cls._broadcast_startup_progress(
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.0.0",
|
"version": "1.2.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.0.0",
|
"version": "1.2.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.1.5",
|
"@angular/cdk": "^22.1.5",
|
||||||
"@angular/common": "^22.1.4",
|
"@angular/common": "^22.1.4",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.0.0",
|
"version": "1.2.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve --proxy-config proxy.conf.json",
|
"start": "ng serve --proxy-config proxy.conf.json",
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ limitations under the License.
|
|||||||
class="dropdown-item-card"
|
class="dropdown-item-card"
|
||||||
[class.selected]="agentService.currentSessionId() === session.session_id"
|
[class.selected]="agentService.currentSessionId() === session.session_id"
|
||||||
[class.running]="getTaskStatus(session) === 'running'"
|
[class.running]="getTaskStatus(session) === 'running'"
|
||||||
|
[ngClass]="getTaskStatus(session)"
|
||||||
(click)="selectTask(session.session_id, $event)"
|
(click)="selectTask(session.session_id, $event)"
|
||||||
>
|
>
|
||||||
<div class="item-header">
|
<div class="item-header">
|
||||||
@@ -96,7 +97,7 @@ limitations under the License.
|
|||||||
<span class="item-time-divider">·</span>
|
<span class="item-time-divider">·</span>
|
||||||
}
|
}
|
||||||
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
||||||
@if (getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused') {
|
@if (['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-stop-card"
|
class="btn-stop-card"
|
||||||
@@ -107,7 +108,7 @@ limitations under the License.
|
|||||||
<span class="material-symbols-outlined">stop_circle</span>
|
<span class="material-symbols-outlined">stop_circle</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') {
|
@if (!['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-delete-card"
|
class="btn-delete-card"
|
||||||
@@ -120,12 +121,15 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-goal" title="{{ session.initial_goal }}">
|
<div class="item-goal" [title]="session.initial_goal || ''">
|
||||||
{{ session.initial_goal || '未命名任务' }}
|
{{ session.display_name || session.initial_goal || '未命名任务' }}
|
||||||
</div>
|
</div>
|
||||||
|
@if (session.error && getTaskStatus(session) === 'failed') {
|
||||||
|
<div class="item-error" [title]="session.error">{{session.error}}</div>
|
||||||
|
}
|
||||||
<div class="item-footer">
|
<div class="item-footer">
|
||||||
<span class="item-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
|
<app-task-id-copy [taskId]="session.session_id" />
|
||||||
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
|
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }} · 已用 {{ formatTaskDuration(session.start_time, session.end_time) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -331,7 +335,7 @@ limitations under the License.
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Floating Top-Right Badges & Toolbar (Pinned overlay, hidden in empty/initial state) -->
|
<!-- Floating Top-Right Badges & Toolbar (Pinned overlay, hidden in empty/initial state) -->
|
||||||
@if (consolidatedBlocks().length > 0 || showPlanningLoader()) {
|
@if (consolidatedBlocks().length > 0 || showPlanningLoader() || agentService.currentSession()) {
|
||||||
<div class="stream-floating-toolbar">
|
<div class="stream-floating-toolbar">
|
||||||
<!-- Active Architecture Chip: click opens the run info popover -->
|
<!-- Active Architecture Chip: click opens the run info popover -->
|
||||||
@if (agentService.viewedModel(); as model) {
|
@if (agentService.viewedModel(); as model) {
|
||||||
@@ -492,6 +496,7 @@ limitations under the License.
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
<ng-content select="[task-guidance]"></ng-content>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,6 +573,13 @@ limitations under the License.
|
|||||||
>
|
>
|
||||||
<!-- Unified Step / Stream Body -->
|
<!-- Unified Step / Stream Body -->
|
||||||
<div class="step-body">
|
<div class="step-body">
|
||||||
|
@if (block.data?.extra_metadata?.injected_instruction; as guidance) {
|
||||||
|
<div class="guidance-step-note">
|
||||||
|
<div class="guidance-step-title"><span class="material-symbols-outlined">forum</span>用户指导 · 已进入第 {{block.data.step_number}} 步</div>
|
||||||
|
<p>{{ guidance }}</p>
|
||||||
|
<small>{{ hasRecordedDeviceAction(block.data.action_taken) ? '本步设备动作见下方;是否完成目标需以实际屏幕为准。' : '本步没有记录设备动作。' }}</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<!-- Check block (verifier lane): header, then the declared check items
|
<!-- Check block (verifier lane): header, then the declared check items
|
||||||
and how each is judged; the chronological stream follows, and the
|
and how each is judged; the chronological stream follows, and the
|
||||||
result rows close the block (see check-result below). -->
|
result rows close the block (see check-result below). -->
|
||||||
@@ -746,7 +758,7 @@ limitations under the License.
|
|||||||
@if (getReportStatusExplanation(item.data); as expText) {
|
@if (getReportStatusExplanation(item.data); as expText) {
|
||||||
<div class="grid-item full-width">
|
<div class="grid-item full-width">
|
||||||
<span class="grid-label">报告与发现</span>
|
<span class="grid-label">报告与发现</span>
|
||||||
<span class="grid-value report-markdown-body" [innerHTML]="renderMarkdown(expText, 'report-' + block.id + '-' + itemIdx)"></span>
|
<div class="grid-value report-markdown-body" [innerHTML]="renderMarkdown(expText, 'report-' + block.id + '-' + itemIdx)"></div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -1283,6 +1295,23 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (awaitingGuidance().length) {
|
||||||
|
<div class="guidance-queue" aria-label="待处理用户指导">
|
||||||
|
@for (item of awaitingGuidance(); track item.id) {
|
||||||
|
<div class="guidance-queue-item">
|
||||||
|
<strong>用户指导 · {{ guidanceStatusLabel(item) }}</strong>
|
||||||
|
<p>{{ item.instruction }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (terminalFailureReason(); as reason) {
|
||||||
|
<div class="session-failure-summary" role="status">
|
||||||
|
<strong>任务执行失败 · 原因</strong>
|
||||||
|
<p>{{ reason }}</p>
|
||||||
|
<button type="button" (click)="isAutomationOpen.set(true)">查看完整报告</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<!-- Outputter Post-Execution Synthesis Card (Rendered ONCE at the end of stream) -->
|
<!-- Outputter Post-Execution Synthesis Card (Rendered ONCE at the end of stream) -->
|
||||||
@if (outputterReport(); as outReport) {
|
@if (outputterReport(); as outReport) {
|
||||||
<div class="outputter-synthesis-stream-card">
|
<div class="outputter-synthesis-stream-card">
|
||||||
|
|||||||
@@ -1921,6 +1921,8 @@ $stream-row-height: 25px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.task-report-card {
|
&.task-report-card {
|
||||||
|
width: min(680px, 100%);
|
||||||
|
box-sizing: border-box;
|
||||||
background: #f0fdf4;
|
background: #f0fdf4;
|
||||||
border: 1px solid #bbf7d0;
|
border: 1px solid #bbf7d0;
|
||||||
|
|
||||||
@@ -1967,12 +1969,13 @@ $stream-row-height: 25px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.report-markdown-body {
|
.report-markdown-body {
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.7;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
user-select: text;
|
user-select: text;
|
||||||
word-break: break-word;
|
word-break: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 3px 0;
|
margin: 3px 0;
|
||||||
@@ -2233,6 +2236,14 @@ $stream-row-height: 25px;
|
|||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
|
||||||
|
&.report-markdown-body {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.7;
|
||||||
|
word-break: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
&.code-pill {
|
&.code-pill {
|
||||||
font-family: 'Roboto Mono', 'Fira Code', Consolas, monospace;
|
font-family: 'Roboto Mono', 'Fira Code', Consolas, monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -3673,6 +3684,12 @@ $stream-row-height: 25px;
|
|||||||
&.running {
|
&.running {
|
||||||
border-left: 3.5px solid #2563eb;
|
border-left: 3.5px solid #2563eb;
|
||||||
}
|
}
|
||||||
|
&.paused, &.task_paused { border-left: 3.5px solid #d97706; background: #fffbeb; }
|
||||||
|
&.resuming, &.queued { border-left: 3.5px solid #7c3aed; }
|
||||||
|
&.failed { border-left: 3.5px solid #dc2626; background: #fff7f7; }
|
||||||
|
&.completed { border-left: 3.5px solid #16a34a; }
|
||||||
|
&.cancelled, &.interrupted { border-left: 3.5px solid #ea580c; }
|
||||||
|
.item-error { color: #b91c1c; font-size: 11px; line-height: 1.4; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
.item-header {
|
.item-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3718,12 +3735,24 @@ $stream-row-height: 25px;
|
|||||||
border-color: #fdba74;
|
border-color: #fdba74;
|
||||||
&::before { background-color: #f97316; }
|
&::before { background-color: #f97316; }
|
||||||
}
|
}
|
||||||
|
&.task_paused {
|
||||||
|
background: #ffedd5;
|
||||||
|
color: #9a3412;
|
||||||
|
border-color: #fb923c;
|
||||||
|
&::before { background-color: #ea580c; }
|
||||||
|
}
|
||||||
|
&.resuming, &.queued {
|
||||||
|
background: #f3e8ff;
|
||||||
|
color: #6b21a8;
|
||||||
|
border-color: #c4b5fd;
|
||||||
|
&::before { background-color: #7c3aed; }
|
||||||
|
}
|
||||||
&.completed {
|
&.completed {
|
||||||
background: #fafafa;
|
background: #dcfce7;
|
||||||
color: #1f1f1f;
|
color: #166534;
|
||||||
border-color: #e2e8f0;
|
border-color: #86efac;
|
||||||
&::before {
|
&::before {
|
||||||
background-color: #1f1f1f;
|
background-color: #16a34a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.pending {
|
&.pending {
|
||||||
@@ -3735,21 +3764,22 @@ $stream-row-height: 25px;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.failed {
|
&.failed {
|
||||||
background: #fafafa;
|
background: #fee2e2;
|
||||||
color: #ef4444;
|
color: #991b1b;
|
||||||
border-color: #fee2e2;
|
border-color: #fca5a5;
|
||||||
&::before {
|
&::before {
|
||||||
background-color: #ef4444;
|
background-color: #ef4444;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.cancelled {
|
&.cancelled {
|
||||||
background: #fafafa;
|
background: #ffedd5;
|
||||||
color: #d97706;
|
color: #9a3412;
|
||||||
border-color: #fef3c7;
|
border-color: #fdba74;
|
||||||
&::before {
|
&::before {
|
||||||
background-color: #f59e0b;
|
background-color: #ea580c;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
&.interrupted { background: #fef3c7; color: #92400e; border-color: #fbbf24; &::before { background-color: #d97706; } }
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -3971,3 +4001,48 @@ $stream-row-height: 25px;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.guidance-step-note, .guidance-queue-item {
|
||||||
|
padding: 10px 13px;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1e3a8a;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
.guidance-step-title { display: flex; align-items: center; gap: 6px; font-weight: 650; }
|
||||||
|
.material-symbols-outlined { font-size: 17px; }
|
||||||
|
p { margin: 5px 0; white-space: pre-wrap; color: #1e293b; }
|
||||||
|
small { color: #475569; }
|
||||||
|
}
|
||||||
|
.guidance-queue { display: grid; gap: 8px; padding: 12px 16px; }
|
||||||
|
.guidance-queue-item strong { font-size: 12px; }
|
||||||
|
.session-failure-summary {
|
||||||
|
margin: 12px 16px 28px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #fecdd3;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff7f8;
|
||||||
|
color: #881337;
|
||||||
|
strong { font-size: 13px; }
|
||||||
|
p { margin: 6px 0 10px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
button { border: 0; background: transparent; padding: 0; color: #be123c; font: inherit; font-weight: 600; cursor: pointer; }
|
||||||
|
}
|
||||||
|
:host ::ng-deep .report-markdown-body {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.7;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: normal;
|
||||||
|
> div { margin: 3px 0; }
|
||||||
|
h1, h2, h3 { margin: 12px 0 6px; font-size: 14px; color: #0f172a; }
|
||||||
|
ul, ol { padding-left: 22px; margin: 8px 0; }
|
||||||
|
li { margin: 4px 0; }
|
||||||
|
pre { overflow: auto; white-space: pre-wrap; padding: 10px; border-radius: 7px; background: #e2e8f0; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { padding: 7px 9px; border: 1px solid #cbd5e1; text-align: left; vertical-align: top; }
|
||||||
|
th { background: #dbeafe; }
|
||||||
|
.md-para-gap { height: 8px; }
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||||
import { TaskHistoryComponent } from '../task-history/task-history.component';
|
import { TaskHistoryComponent } from '../task-history/task-history.component';
|
||||||
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
|
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
|
||||||
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
||||||
@@ -22,7 +23,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { AgentService, StartupProgressEvent } from '../../services/agent.service';
|
import { AgentService, GuidanceEvent, StartupProgressEvent } from '../../services/agent.service';
|
||||||
import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model';
|
import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model';
|
||||||
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
|
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
|
||||||
import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay';
|
import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay';
|
||||||
@@ -236,7 +237,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-agent-stream',
|
selector: 'app-agent-stream',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent],
|
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent],
|
||||||
templateUrl: './agent-stream.component.html',
|
templateUrl: './agent-stream.component.html',
|
||||||
styleUrl: './agent-stream.component.scss',
|
styleUrl: './agent-stream.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -246,6 +247,11 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
private zone = inject(NgZone);
|
private zone = inject(NgZone);
|
||||||
|
public terminalFailureReason = signal('');
|
||||||
|
private selectedTerminalStatus = computed(() => {
|
||||||
|
const id = this.agentService.currentSessionId();
|
||||||
|
return this.agentService.sessions().find(session => session.session_id === id)?.status || '';
|
||||||
|
});
|
||||||
|
|
||||||
// Auto-scroll state tracking
|
// Auto-scroll state tracking
|
||||||
public isUserAtBottom = true;
|
public isUserAtBottom = true;
|
||||||
@@ -382,13 +388,13 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
public activeQueue = computed(() => {
|
public activeQueue = computed(() => {
|
||||||
const list = this.agentService.sessions().filter((s) => {
|
const list = this.agentService.sessions().filter((s) => {
|
||||||
const status = this.getTaskStatus(s);
|
const status = this.getTaskStatus(s);
|
||||||
return status === 'running' || status === 'paused' || status === 'pending';
|
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status);
|
||||||
});
|
});
|
||||||
return list.sort((a, b) => {
|
return list.sort((a, b) => {
|
||||||
const statusA = this.getTaskStatus(a);
|
const statusA = this.getTaskStatus(a);
|
||||||
const statusB = this.getTaskStatus(b);
|
const statusB = this.getTaskStatus(b);
|
||||||
const isRunA = statusA === 'running' || statusA === 'paused';
|
const isRunA = ['running', 'paused', 'task_paused', 'resuming'].includes(statusA);
|
||||||
const isRunB = statusB === 'running' || statusB === 'paused';
|
const isRunB = ['running', 'paused', 'task_paused', 'resuming'].includes(statusB);
|
||||||
if (isRunA && !isRunB) return -1;
|
if (isRunA && !isRunB) return -1;
|
||||||
if (!isRunA && isRunB) return 1;
|
if (!isRunA && isRunB) return 1;
|
||||||
return (a.start_time || 0) - (b.start_time || 0);
|
return (a.start_time || 0) - (b.start_time || 0);
|
||||||
@@ -398,7 +404,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
public historyTasks = computed(() => {
|
public historyTasks = computed(() => {
|
||||||
return this.agentService.sessions().filter((s) => {
|
return this.agentService.sessions().filter((s) => {
|
||||||
const status = this.getTaskStatus(s);
|
const status = this.getTaskStatus(s);
|
||||||
return status === 'completed' || status === 'failed' || status === 'cancelled';
|
return ['completed', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(status);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -473,6 +479,23 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
return consolidateLogsToBlocks(this.filteredLogs());
|
return consolidateLogsToBlocks(this.filteredLogs());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
public awaitingGuidance = computed<GuidanceEvent[]>(() => {
|
||||||
|
const steps = this.consolidatedBlocks().filter(block => block.type === 'step');
|
||||||
|
return (this.agentService.guidanceEvents?.() || []).filter(event => !steps.some(block =>
|
||||||
|
new Date(block.timestamp).getTime() >= event.timestamp * 1000
|
||||||
|
&& String(block.data?.extra_metadata?.injected_instruction || '').includes(event.instruction)
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
public guidanceStatusLabel(event: GuidanceEvent): string {
|
||||||
|
const terminal = ['completed', 'failed', 'cancelled', 'interrupted']
|
||||||
|
.includes(this.agentService.currentSession()?.status || '');
|
||||||
|
if (event.status === 'pending') {
|
||||||
|
return terminal ? '任务结束前未进入模型' : '已接收,正在打断当前模型步骤';
|
||||||
|
}
|
||||||
|
return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录';
|
||||||
|
}
|
||||||
|
|
||||||
public phases = computed<PhaseBlock[]>(() => {
|
public phases = computed<PhaseBlock[]>(() => {
|
||||||
const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId());
|
const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId());
|
||||||
return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0);
|
return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0);
|
||||||
@@ -540,6 +563,17 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
effect((onCleanup) => {
|
||||||
|
const id = this.agentService.currentSessionId();
|
||||||
|
const status = this.selectedTerminalStatus();
|
||||||
|
this.terminalFailureReason.set('');
|
||||||
|
if (!id || !['failed', 'interrupted'].includes(status || '')) return;
|
||||||
|
const request = this.http.get<{error?: string}>(`/api/automation/reports/${encodeURIComponent(id)}`).subscribe({
|
||||||
|
next: report => this.terminalFailureReason.set(report.error || '任务未完成;请查看已记录的执行步骤。'),
|
||||||
|
error: () => this.terminalFailureReason.set('任务未完成,暂时无法读取异常详情。')
|
||||||
|
});
|
||||||
|
onCleanup(() => request.unsubscribe());
|
||||||
|
});
|
||||||
// Run info popover: fetch usage when opened or when the viewed session
|
// Run info popover: fetch usage when opened or when the viewed session
|
||||||
// changes, and keep refreshing while that session is still running.
|
// changes, and keep refreshing while that session is still running.
|
||||||
effect((onCleanup) => {
|
effect((onCleanup) => {
|
||||||
@@ -878,14 +912,14 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
return tuningLabel(kind, id);
|
return tuningLabel(kind, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
|
public getTaskStatus(session: Session): string {
|
||||||
if (session.status) {
|
if (session.status) {
|
||||||
const s = session.status.toLowerCase();
|
const s = session.status.toLowerCase();
|
||||||
if (s === 'completed' || s === 'success' || s === 'failed' || s === 'cancelled') {
|
if (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(s)) {
|
||||||
return (s === 'success' ? 'completed' : s) as any;
|
return s === 'success' ? 'completed' : s;
|
||||||
}
|
}
|
||||||
if (s === 'running' || s === 'paused' || s === 'pending') {
|
if (['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(s)) {
|
||||||
return s as any;
|
return s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
|
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
|
||||||
@@ -1326,6 +1360,19 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
return isAndroidAction(action);
|
return isAndroidAction(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public formatTaskDuration(start?: number, end?: number): string {
|
||||||
|
if (!start || !Number.isFinite(start)) return '—';
|
||||||
|
const seconds = Math.max(0, Math.floor((end || Date.now() / 1000) - start));
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
return hours ? `${hours}时${minutes}分` : minutes ? `${minutes}分${seconds % 60}秒` : `${seconds}秒`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasRecordedDeviceAction(action: any): boolean {
|
||||||
|
const actions = Array.isArray(action) ? action : action ? [action] : [];
|
||||||
|
return actions.some(item => this.isAndroidAction(item) && !this.isReportStatusAction(item));
|
||||||
|
}
|
||||||
|
|
||||||
public getActionIcon(action: any): string {
|
public getActionIcon(action: any): string {
|
||||||
return getActionIcon(action);
|
return getActionIcon(action);
|
||||||
}
|
}
|
||||||
@@ -1812,6 +1859,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
public hasVisibleContent(block: any): boolean {
|
public hasVisibleContent(block: any): boolean {
|
||||||
if (!block) return false;
|
if (!block) return false;
|
||||||
if (block.type === 'checker') return true;
|
if (block.type === 'checker') return true;
|
||||||
|
if (block.data?.extra_metadata?.injected_instruction) return true;
|
||||||
const hasNative = Boolean(this.getNativeThinking(block));
|
const hasNative = Boolean(this.getNativeThinking(block));
|
||||||
const hasRaw = Boolean(this.getRawThinking(block));
|
const hasRaw = Boolean(this.getRawThinking(block));
|
||||||
const hasReset = this.hasBlockResets(block, false) || this.hasBlockResets(block, true);
|
const hasReset = this.hasBlockResets(block, false) || this.hasBlockResets(block, true);
|
||||||
|
|||||||
+35
-9
@@ -12,23 +12,44 @@
|
|||||||
</section>
|
</section>
|
||||||
<section><h3>当前任务报告</h3>
|
<section><h3>当前任务报告</h3>
|
||||||
@if (report(); as r) {
|
@if (report(); as r) {
|
||||||
<strong>{{r.name}}</strong><p>{{label(r.status)}} · {{r.verdict}}</p>
|
<div class="report-overview">
|
||||||
<div class="actions"><a [href]="'/api/automation/reports/'+r.id+'/docx'">下载 Word</a><a [href]="'/api/automation/reports/'+r.id+'/xlsx'">下载 Excel</a></div>
|
<strong class="report-title">{{r.name}}</strong>
|
||||||
|
<div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><span>{{r.verdict}}</span></div>
|
||||||
|
@if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> }
|
||||||
|
</div>
|
||||||
|
<div class="report-meta"><div><strong>任务目标</strong><p>{{r.goal || '未记录'}}</p></div><div><strong>预期结果</strong><p>{{r.expected || '未指定'}}</p></div></div>
|
||||||
|
<div class="actions"><a [href]="'/api/automation/reports/'+r.id+'/docx'">下载 Word</a><a [href]="'/api/automation/reports/'+r.id+'/xlsx'">下载 Excel</a>
|
||||||
|
<a [href]="'/api/automation/reports/'+r.id+'/screenshots'">步骤截图 ZIP</a>
|
||||||
|
<a [href]="'/api/automation/reports/'+r.id+'/video'">分段录屏 ZIP</a>
|
||||||
|
<a [href]="'/api/automation/reports/'+r.id+'/all'">下载全部资料</a></div>
|
||||||
<p class="muted">报告包含目标、预期、实际结论、步骤与截图证据。执行中的下载为当前快照。</p>
|
<p class="muted">报告包含目标、预期、实际结论、步骤与截图证据。执行中的下载为当前快照。</p>
|
||||||
<pre class="preview">{{r.content}}</pre>
|
<div class="report-content"><h3>结论与发现</h3><div class="report-body" [innerHTML]="renderReport(r.content)"></div></div>
|
||||||
} @else { <p>从任务队列或历史记录中选择任务,即可下载报告。</p> }
|
} @else { <p>从任务队列或历史记录中选择任务,即可下载报告。</p> }
|
||||||
|
@if (liveTask() && agent.currentSessionId()) {
|
||||||
|
<div class="task-guidance">
|
||||||
|
<h3>任务指导 · {{label(taskStatus())}}</h3>
|
||||||
|
<p>{{ taskStatus() === 'paused' ? '模型请求已暂停;提交指导后请在执行日志中恢复模型调用。' : '运行中提交会立即打断模型思考并重新观察设备;正在执行的设备动作会先安全结束。' }}</p>
|
||||||
|
<label for="task-guidance-text">补充信息或调整方向</label>
|
||||||
|
<textarea id="task-guidance-text" [(ngModel)]="guidance" maxlength="10000" rows="3" placeholder="描述已观察到的情况和下一步方向"></textarea>
|
||||||
|
<div class="actions">
|
||||||
|
@if(taskStatus() === 'running'){<button [disabled]="busy()" (click)="pauseTask(agent.currentSessionId()!)">暂停当前任务</button>}
|
||||||
|
<button [disabled]="busy() || !guidance.trim()" (click)="guideTask(agent.currentSessionId()!)">提交指导{{taskStatus() === 'task_paused' ? '并恢复' : ''}}</button>
|
||||||
|
@if(taskStatus() === 'task_paused'){<button [disabled]="busy()" (click)="continueTask(agent.currentSessionId()!)">直接继续</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
@if (tab() !== 'reports') {
|
@if (tab() !== 'reports') {
|
||||||
<section class="device"><label>执行设备<select [(ngModel)]="device"><option value="">请选择设备</option>@for(d of system.connectedDevices(); track d.serial){<option [value]="d.serial">{{d.serial}}</option>}</select></label></section>
|
<section class="device"><label>执行设备<select [(ngModel)]="device"><option value="">请选择设备</option>@for(d of system.connectedDevices(); track d.serial){<option [value]="d.serial">{{d.serial}}</option>}</select></label></section>
|
||||||
}
|
}
|
||||||
@if (tab() === 'batches') {
|
@if (tab() === 'batches') {
|
||||||
<section><h3>导入任务</h3><p>每行一个用例,填写名称与目标。预期结果、应用、前置条件、参数和标签可选。</p>
|
<section><h3>导入任务</h3><p>每行一个用例,仅「Artemis提示词」作为任务正文;「任务配置(JSON)」可设置每项模型、模式、超时和录屏。「前置约束提取」仅供查看与搜索。示例在模板的「填写说明」页。</p>
|
||||||
<div class="actions"><a href="/api/automation/template">下载 Excel 模板</a><label class="upload">导入 Excel<input type="file" accept=".xlsx" [disabled]="busy()" (change)="upload($event)"></label></div>
|
<div class="actions"><a href="/api/automation/template">下载 Excel 模板</a><label class="upload">导入 Excel<input type="file" accept=".xlsx" [disabled]="busy()" (change)="upload($event)"></label></div>
|
||||||
@if(preview(); as p) {
|
@if(preview(); as p) {
|
||||||
<p>有效任务 {{p.tasks.length}} 条 · 错误 {{p.errors.length}} 条</p>
|
<p>有效任务 {{p.tasks.length}} 条 · 错误 {{p.errors.length}} 条</p>
|
||||||
@for(e of p.errors; track e.row){<p class="error">第 {{e.row}} 行:{{e.message}}</p>}
|
@for(e of p.errors; track e.row){<p class="error">第 {{e.row}} 行:{{e.message}}</p>}
|
||||||
<div class="table-wrap"><table><thead><tr><th>行</th><th>名称</th><th>目标</th></tr></thead><tbody>@for(t of p.tasks; track t.row){<tr><td>{{t.row}}</td><td>{{t.name}}</td><td>{{t.goal}}</td></tr>}</tbody></table></div>
|
<div class="table-wrap"><table><thead><tr><th>行</th><th>用例</th><th>提示词</th><th>任务配置</th></tr></thead><tbody>@for(t of p.tasks; track t.row){<tr><td>{{t.row}}</td><td>{{t.name}}</td><td>{{t.goal}}</td><td>{{t.task_config | json}}</td></tr>}</tbody></table></div>
|
||||||
<label>批次名称<input [(ngModel)]="name" maxlength="200"></label>
|
<label>批次名称<input [(ngModel)]="name" maxlength="200"></label>
|
||||||
<div class="fields"><label>执行模式<select [(ngModel)]="profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label><label>单任务时限(秒)<input type="number" min="30" max="86400" [(ngModel)]="timeout"></label></div>
|
<div class="fields"><label>执行模式<select [(ngModel)]="profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label><label>单任务时限(秒)<input type="number" min="30" max="86400" [(ngModel)]="timeout"></label></div>
|
||||||
<label><input type="checkbox" [(ngModel)]="stopOnFailure"> 失败即停止后续任务(默认继续)</label>
|
<label><input type="checkbox" [(ngModel)]="stopOnFailure"> 失败即停止后续任务(默认继续)</label>
|
||||||
@@ -37,17 +58,22 @@
|
|||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
<section><h3>批次记录</h3>
|
<section><h3>批次记录</h3>
|
||||||
@for(b of batches(); track b.id){<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}}</span></button>}
|
@for(b of batches(); track b.id){
|
||||||
|
<div class="batch-record">
|
||||||
|
<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}}</span></button>
|
||||||
|
<button class="delete-batch" type="button" [disabled]="busy() || b.status === 'running'" (click)="deleteBatch(b)" [attr.aria-label]="'删除批次:' + b.name" [title]="b.status === 'running' ? '执行中的批次不能删除' : '删除批次及其任务、报告和文件'"><span class="material-symbols-outlined">delete_outline</span></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if(!batches().length){<p>暂无批次。</p>}
|
@if(!batches().length){<p>暂无批次。</p>}
|
||||||
</section>
|
</section>
|
||||||
@if(batch(); as b){<section><h3>{{b.name}}</h3><p>{{label(b.status)}} · {{b.reason}}</p>
|
@if(batch(); as b){<section><h3>{{b.name}}</h3><p>{{label(b.status)}} · {{b.reason}}</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@if(b.status === 'running'){<button [disabled]="busy()" (click)="batchAction('pause')">暂停后续任务</button>}
|
@if(b.status === 'running'){<button [disabled]="busy()" (click)="batchAction('pause')">暂停后续任务</button>}
|
||||||
@if(b.status === 'draft' || b.status === 'paused'){<button [disabled]="busy()" (click)="batchAction('resume')">{{b.status === 'draft' ? '开始执行' : '继续未开始任务'}}</button>}
|
@if(b.status === 'draft' || b.status === 'paused'){<button [disabled]="busy()" (click)="batchAction('resume')">{{b.status === 'draft' ? '开始执行' : '继续未开始任务'}}</button>}
|
||||||
@if(b.status !== 'running'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
|
@if(b.status !== 'running' && b.status !== 'deleting'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
|
||||||
<a [href]="'/api/automation/batches/'+b.id+'/archive'">下载全部报告 ZIP</a>
|
<a [href]="'/api/automation/batches/'+b.id+'/archive'">下载批次报告 ZIP</a>
|
||||||
</div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p>
|
</div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p>
|
||||||
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
|
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
|
||||||
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true)">{{j.position}}. {{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
|
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true); tab.set('reports')" [title]="j.case.goal">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
|
||||||
</section>}
|
</section>}
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,26 @@ input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:
|
|||||||
.upload{position:relative;margin:0;color:#fff;background:#2563eb;border-color:#2563eb;}.upload:hover{background:#1d4ed8;border-color:#1d4ed8;}.upload input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;}
|
.upload{position:relative;margin:0;color:#fff;background:#2563eb;border-color:#2563eb;}.upload:hover{background:#1d4ed8;border-color:#1d4ed8;}.upload input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;}
|
||||||
.device{background:#f8fafc;padding:12px 16px;}.device label{margin:0;}
|
.device{background:#f8fafc;padding:12px 16px;}.device label{margin:0;}
|
||||||
.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;}
|
||||||
.code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:#f8fafc;}.table-wrap{max-height:260px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid #e2e8f0;text-align:left;overflow-wrap:anywhere;}th{background:#f8fafc;color:#475569;font-weight:500;}
|
.code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:#f8fafc;}.table-wrap{max-height:260px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid #e2e8f0;text-align:left;overflow-wrap:anywhere;}th{background:#f8fafc;color:#475569;font-weight:500;}
|
||||||
.preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:#334155;background:#f8fafc;border-radius:8px;padding:12px;border:1px solid #e2e8f0;}
|
.preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:#334155;background:#f8fafc;border-radius:8px;padding:12px;border:1px solid #e2e8f0;}
|
||||||
.job{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 0;border-bottom:1px solid #f1f5f9;}.job:last-child{border-bottom:0;}.job button{flex:1;justify-content:flex-start;text-align:left;min-width:150px;}.job span{font-size:12px;background:#f1f5f9;color:#475569;border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:#475569;cursor:pointer;padding:8px 0;}
|
.job{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 0;border-bottom:1px solid #f1f5f9;}.job:last-child{border-bottom:0;}.job button{flex:1;justify-content:flex-start;text-align:left;min-width:150px;}.job span{font-size:12px;background:#f1f5f9;color:#475569;border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:#475569;cursor:pointer;padding:8px 0;}
|
||||||
|
.task-guidance{margin-top:16px;padding:14px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;}
|
||||||
|
.task-guidance label{margin:8px 0 4px;}
|
||||||
|
.report-overview{padding:14px;border:1px solid #dbeafe;border-radius:10px;background:#f8fbff;}
|
||||||
|
.report-title{display:block;font-size:14px;line-height:1.5;color:#0f172a;overflow-wrap:anywhere;}
|
||||||
|
.report-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px;span{padding:3px 8px;border:1px solid #bfdbfe;border-radius:20px;background:#eff6ff;color:#1e40af;font-size:12px;&.failed{background:#fff1f2;border-color:#fecdd3;color:#be123c;}}}
|
||||||
|
.report-error{margin-top:11px;padding:10px 12px;background:#fff1f2;border-left:3px solid #e11d48;border-radius:6px;color:#9f1239;strong{font-size:12px;}p{margin:4px 0 0;color:#9f1239;white-space:pre-wrap;}}
|
||||||
|
.report-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin:12px 0;div{min-width:0;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;background:#fff;}strong{font-size:12px;color:#475569;}p{margin:5px 0 0;white-space:pre-wrap;color:#1e293b;}}
|
||||||
|
.report-content{margin-top:14px;padding:14px;border:1px solid #e2e8f0;border-radius:10px;background:#fff;h3{margin-bottom:12px;}}
|
||||||
|
.report-body{overflow-x:auto;overflow-wrap:anywhere;color:#1e293b;line-height:1.75;}
|
||||||
|
:host ::ng-deep .report-body, :host ::ng-deep .report-markdown-body {
|
||||||
|
h1,h2,h3,h4{margin:18px 0 8px;color:#0f172a;line-height:1.4;}h1{font-size:18px;}h2{font-size:16px;}h3,h4{font-size:14px;}
|
||||||
|
> div{margin:3px 0;}
|
||||||
|
ul,ol{margin:8px 0;padding-left:24px;}li{margin:4px 0;}
|
||||||
|
blockquote{margin:10px 0;padding:8px 12px;border-left:3px solid #93c5fd;background:#f8fafc;}
|
||||||
|
pre{max-width:100%;overflow:auto;padding:10px 12px;background:#f1f5f9;border-radius:6px;white-space:pre-wrap;}
|
||||||
|
code{font-family:Consolas,monospace;background:#f1f5f9;padding:1px 3px;border-radius:3px;}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:12px;}th,td{padding:7px 9px;border:1px solid #e2e8f0;vertical-align:top;}th{background:#eff6ff;color:#1e3a8a;}
|
||||||
|
.md-para-gap{height:8px;}
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
import { SystemService } from '../../services/system.service';
|
||||||
|
import { AutomationPanelComponent } from './automation-panel.component';
|
||||||
|
|
||||||
|
describe('AutomationPanelComponent batch deletion', () => {
|
||||||
|
const agent = {
|
||||||
|
currentSessionId: signal<string | null>(null),
|
||||||
|
runningSessionId: signal<string | null>(null),
|
||||||
|
agentStatus: signal('idle'),
|
||||||
|
sessions: signal<any[]>([])
|
||||||
|
};
|
||||||
|
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'delete']);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
(http.get.and as any).callFake((url: string) => of(url.endsWith('/preferences')
|
||||||
|
? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : []));
|
||||||
|
http.delete.and.returnValue(of({ status: 'deleted' }));
|
||||||
|
http.delete.calls.reset();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AutomationPanelComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: AgentService, useValue: agent },
|
||||||
|
{ provide: SystemService, useValue: { connectedDevices: signal([]) } },
|
||||||
|
{ provide: HttpClient, useValue: http }
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirms deletion and removes the batch from the visible list', async () => {
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
fixture.componentInstance.tab.set('batches');
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
fixture.componentInstance.batches.set([
|
||||||
|
{ id: 'batch-1', name: '批量冒烟', status: 'completed', created_at: 100 }
|
||||||
|
]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('[aria-label="删除批次:批量冒烟"]')).not.toBeNull();
|
||||||
|
|
||||||
|
spyOn(window, 'confirm').and.returnValue(true);
|
||||||
|
const button = fixture.nativeElement.querySelector('.delete-batch') as HTMLButtonElement;
|
||||||
|
button.click();
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(http.delete).toHaveBeenCalledWith('/api/automation/batches/batch-1');
|
||||||
|
expect(fixture.componentInstance.batches()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a running batch protected from the delete control', async () => {
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
fixture.componentInstance.tab.set('batches');
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
fixture.componentInstance.batches.set([
|
||||||
|
{ id: 'batch-2', name: '进行中', status: 'running', created_at: 100 }
|
||||||
|
]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect((fixture.nativeElement.querySelector('.delete-batch') as HTMLButtonElement).disabled).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the failure reason and renders Chinese report structure with line breaks', () => {
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.componentInstance.report.set({
|
||||||
|
id: 'task-1', name: '系统设置检查', status: 'failed', verdict: '未验证',
|
||||||
|
error: '模型连续 3 次未提供可执行工具调用', goal: '查看设置', expected: '',
|
||||||
|
content: '## 结果\n第一行\n第二行\n\n| 项目 | 状态 |\n| --- | --- |\n| 设置 | 未验证 |'
|
||||||
|
});
|
||||||
|
fixture.detectChanges();
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(root.querySelector('.report-error')?.textContent).toContain('连续 3 次');
|
||||||
|
expect(root.querySelector('.report-body h2')?.textContent).toBe('结果');
|
||||||
|
expect(root.querySelector('.report-body')?.textContent).toContain('第一行');
|
||||||
|
expect(root.querySelector('.report-body')?.textContent).toContain('第二行');
|
||||||
|
expect(root.querySelector('.report-body table td')?.textContent).toBe('设置');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Component, DestroyRef, effect, inject, signal } from '@angular/core';
|
import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { AgentService } from '../../services/agent.service';
|
import { AgentService } from '../../services/agent.service';
|
||||||
import { SystemService } from '../../services/system.service';
|
import { SystemService } from '../../services/system.service';
|
||||||
|
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule],
|
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule],
|
||||||
@@ -15,6 +16,14 @@ export class AutomationPanelComponent {
|
|||||||
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
|
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
|
||||||
batches = signal<any[]>([]); preview = signal<any>(null);
|
batches = signal<any[]>([]); preview = signal<any>(null);
|
||||||
batch = signal<any>(null); report = signal<any>(null);
|
batch = signal<any>(null); report = signal<any>(null);
|
||||||
|
guidance = '';
|
||||||
|
taskStatus = computed(() => {
|
||||||
|
const sid = this.agent.currentSessionId();
|
||||||
|
if (!sid) return '';
|
||||||
|
return this.agent.sessions().find(s => s.session_id === sid)?.status
|
||||||
|
|| (this.agent.runningSessionId() === sid ? this.agent.agentStatus() : '') || '';
|
||||||
|
});
|
||||||
|
liveTask = computed(() => ['running', 'paused', 'task_paused', 'resuming'].includes(this.taskStatus()));
|
||||||
word = true; excel = true; name = ''; device = ''; profile = 'flash'; stopOnFailure = false; timeout = 1800;
|
word = true; excel = true; name = ''; device = ''; profile = 'flash'; stopOnFailure = false; timeout = 1800;
|
||||||
private destroyed = false;
|
private destroyed = false;
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -24,12 +33,13 @@ export class AutomationPanelComponent {
|
|||||||
const timer = setInterval(() => void this.refresh(), 5000);
|
const timer = setInterval(() => void this.refresh(), 5000);
|
||||||
destroy.onDestroy(() => { this.destroyed = true; clearInterval(timer); });
|
destroy.onDestroy(() => { this.destroyed = true; clearInterval(timer); });
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const sid = this.agent.currentSessionId(); this.report.set(null);
|
const sid = this.agent.currentSessionId(); this.report.set(null); this.guidance = '';
|
||||||
if (sid) this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`).subscribe({ next: r => { if (this.agent.currentSessionId() === sid) this.report.set(r); }, error: () => {} });
|
if (sid) this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`).subscribe({ next: r => { if (this.agent.currentSessionId() === sid) this.report.set(r); }, error: () => {} });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
|
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
|
||||||
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告'} as Record<string,string>)[s] || s; }
|
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告',deleting:'删除中,可重试清理'} as Record<string,string>)[s] || s; }
|
||||||
|
renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
|
||||||
async action(fn: () => Promise<void>) {
|
async action(fn: () => Promise<void>) {
|
||||||
if (this.busy()) return;
|
if (this.busy()) return;
|
||||||
this.busy.set(true); this.error.set(''); this.notice.set('');
|
this.busy.set(true); this.error.set(''); this.notice.set('');
|
||||||
@@ -62,5 +72,29 @@ export class AutomationPanelComponent {
|
|||||||
this.preview.set(null); this.notice.set('批次已开始,可暂停或查看每项结果'); await this.refresh();
|
this.preview.set(null); this.notice.set('批次已开始,可暂停或查看每项结果'); await this.refresh();
|
||||||
}); }
|
}); }
|
||||||
selectBatch(id: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.get(`/api/automation/batches/${id}`))); }); }
|
selectBatch(id: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.get(`/api/automation/batches/${id}`))); }); }
|
||||||
|
deleteBatch(item: { id: string; name: string }) {
|
||||||
|
if (!window.confirm(`删除批次“${item.name}”及其全部任务记录、报告和文件?此操作无法撤销。`)) return;
|
||||||
|
void this.action(async () => {
|
||||||
|
await firstValueFrom(this.http.delete(`/api/automation/batches/${encodeURIComponent(item.id)}`));
|
||||||
|
if (this.batch()?.id === item.id) this.batch.set(null);
|
||||||
|
await this.refresh();
|
||||||
|
this.agent.fetchSessions();
|
||||||
|
this.notice.set('批次及关联任务、报告和文件已删除');
|
||||||
|
});
|
||||||
|
}
|
||||||
batchAction(action: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.post(`/api/automation/batches/${this.batch().id}/${action}`,{}))); await this.refresh(); }); }
|
batchAction(action: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.post(`/api/automation/batches/${this.batch().id}/${action}`,{}))); await this.refresh(); }); }
|
||||||
|
pauseTask(id: string) { void this.action(async () => { await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/pause`,{})); this.notice.set('已请求暂停:当前步骤结束后释放设备'); await this.refresh(); }); }
|
||||||
|
guideTask(id: string) { void this.action(async () => {
|
||||||
|
if (!this.guidance.trim()) throw Error('请先填写指导内容');
|
||||||
|
const status = this.taskStatus();
|
||||||
|
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/guide`,{instruction:this.guidance.trim()}));
|
||||||
|
this.guidance = '';
|
||||||
|
this.agent.fetchGuidance(id);
|
||||||
|
if (status === 'paused') this.notice.set('模型请求暂停,请在执行日志中恢复模型调用');
|
||||||
|
await this.refresh();
|
||||||
|
}); }
|
||||||
|
continueTask(id: string) { void this.action(async () => {
|
||||||
|
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/continue`,{}));
|
||||||
|
this.notice.set('任务已申请恢复;设备忙时等待排队'); await this.refresh();
|
||||||
|
}); }
|
||||||
}
|
}
|
||||||
@@ -122,7 +122,7 @@ limitations under the License.
|
|||||||
{{ session.initial_goal || '未命名任务' }}
|
{{ session.initial_goal || '未命名任务' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="task-card-footer">
|
<div class="task-card-footer">
|
||||||
<span class="task-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
|
<app-task-id-copy [taskId]="session.session_id" />
|
||||||
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
|
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||||
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
|
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
|
||||||
import { TaskHistoryComponent } from '../task-history/task-history.component';
|
import { TaskHistoryComponent } from '../task-history/task-history.component';
|
||||||
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
||||||
@@ -30,7 +31,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-interface',
|
selector: 'app-chat-interface',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent],
|
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent],
|
||||||
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
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { finalize } from 'rxjs';
|
|||||||
|
|
||||||
interface Capabilities {
|
interface Capabilities {
|
||||||
video_enabled: boolean;
|
video_enabled: boolean;
|
||||||
|
recording_enabled: boolean;
|
||||||
outputter_enabled: boolean;
|
outputter_enabled: boolean;
|
||||||
step_summarizer_enabled: boolean;
|
step_summarizer_enabled: boolean;
|
||||||
}
|
}
|
||||||
@@ -16,7 +17,9 @@ interface Capabilities {
|
|||||||
<h3>功能开关</h3>
|
<h3>功能开关</h3>
|
||||||
<p>按需启用,保存后对新任务生效。</p>
|
<p>按需启用,保存后对新任务生效。</p>
|
||||||
<label><input type="checkbox" role="switch" [(ngModel)]="config.video_enabled" [disabled]="busy() || loading() || !loaded()" />
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.video_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
<span>视频分析与录屏<small>录制设备画面,并允许智能体分析录像;关闭可减少资源占用。</small></span></label>
|
<span>视频分析<small>允许智能体分析任务画面。运行期间会临时录制,完成后不保留录像(除非开启录屏)。</small></span></label>
|
||||||
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.recording_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
|
<span>保存录屏<small>保留任务录像以便复盘,可单独开启,不要求启用视频分析。</small></span></label>
|
||||||
<label><input type="checkbox" role="switch" [(ngModel)]="config.outputter_enabled" [disabled]="busy() || loading() || !loaded()" />
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.outputter_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
|
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
|
||||||
<label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" />
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
@@ -36,7 +39,7 @@ interface Capabilities {
|
|||||||
export class CapabilitySettingsComponent implements OnInit {
|
export class CapabilitySettingsComponent implements OnInit {
|
||||||
changed = output<Capabilities>();
|
changed = output<Capabilities>();
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
config: Capabilities = { video_enabled: false, outputter_enabled: true, step_summarizer_enabled: true };
|
config: Capabilities = { video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true };
|
||||||
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
|
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.http.get<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
|
this.http.get<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
|
||||||
|
|||||||
+19
-5
@@ -7,7 +7,8 @@ import { CapabilitySettingsComponent } from './capability-settings.component';
|
|||||||
describe('持久化模型与功能设置', () => {
|
describe('持久化模型与功能设置', () => {
|
||||||
let http: HttpTestingController;
|
let http: HttpTestingController;
|
||||||
const config = { base_url: 'https://example.test/v1', model: 'mimo-v2.6-flash',
|
const config = { base_url: 'https://example.test/v1', model: 'mimo-v2.6-flash',
|
||||||
api_key_configured: true, timeout_seconds: 300, max_retries: 5, retry_interval_seconds: 10, reasoning_effort: 'high', env_path: '/app/.env' };
|
api_key_configured: true, timeout_seconds: 300, max_retries: 5, retry_interval_seconds: 10, reasoning_effort: 'high',
|
||||||
|
model_presets: ['gpt-5.6-luna', 'gpt-6-luna', 'mimo-v2.6-flash', 'mimo-v2.6-pro'], env_path: '/app/.env' };
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||||
http = TestBed.inject(HttpTestingController);
|
http = TestBed.inject(HttpTestingController);
|
||||||
@@ -44,13 +45,23 @@ describe('持久化模型与功能设置', () => {
|
|||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
const root: HTMLElement = fixture.nativeElement;
|
const root: HTMLElement = fixture.nativeElement;
|
||||||
const presets = root.querySelectorAll<HTMLButtonElement>('.model-presets button');
|
const presets = root.querySelectorAll<HTMLButtonElement>('.model-presets button');
|
||||||
expect(presets.length).toBe(2);
|
expect(Array.from(presets, button => button.textContent?.trim())).toEqual([
|
||||||
|
'gpt-5.6-luna', 'gpt-6-luna', 'mimo-v2.6-flash', 'mimo-v2.6-pro'
|
||||||
|
]);
|
||||||
presets[0].click();
|
presets[0].click();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
const input = root.querySelector<HTMLInputElement>('#model-name')!;
|
const input = root.querySelector<HTMLInputElement>('#model-name')!;
|
||||||
expect(input.value).toBe('gpt-5.6-luna');
|
expect(input.value).toBe('gpt-5.6-luna');
|
||||||
expect(presets[0].getAttribute('aria-pressed')).toBe('true');
|
expect(presets[0].getAttribute('aria-pressed')).toBe('true');
|
||||||
|
presets[1].click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(input.value).toBe('gpt-6-luna');
|
||||||
|
presets[3].click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(input.value).toBe('mimo-v2.6-pro');
|
||||||
expect(input.getAttribute('list')).toBeNull();
|
expect(input.getAttribute('list')).toBeNull();
|
||||||
expect(input.autocomplete).toBe('off');
|
expect(input.autocomplete).toBe('off');
|
||||||
input.value = 'custom/model-v2';
|
input.value = 'custom/model-v2';
|
||||||
@@ -98,21 +109,24 @@ describe('持久化模型与功能设置', () => {
|
|||||||
http.expectNone('/api/system/capability-settings');
|
http.expectNone('/api/system/capability-settings');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('保存关闭视频分析的配置,重新打开时恢复关闭状态', () => {
|
it('录屏与视频分析独立保存,重新打开后保留各自状态', () => {
|
||||||
const capabilities = { video_enabled: true, outputter_enabled: true, step_summarizer_enabled: false };
|
const capabilities = { video_enabled: true, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: false };
|
||||||
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
http.expectOne('/api/system/capability-settings').flush(capabilities);
|
http.expectOne('/api/system/capability-settings').flush(capabilities);
|
||||||
fixture.componentInstance.config.video_enabled = false;
|
fixture.componentInstance.config.video_enabled = false;
|
||||||
|
fixture.componentInstance.config.recording_enabled = true;
|
||||||
fixture.componentInstance.save();
|
fixture.componentInstance.save();
|
||||||
const request = http.expectOne('/api/system/capability-settings');
|
const request = http.expectOne('/api/system/capability-settings');
|
||||||
expect(request.request.body.video_enabled).toBeFalse();
|
expect(request.request.body.video_enabled).toBeFalse();
|
||||||
const saved = { ...capabilities, video_enabled: false };
|
expect(request.request.body.recording_enabled).toBeTrue();
|
||||||
|
const saved = { ...capabilities, video_enabled: false, recording_enabled: true };
|
||||||
request.flush(saved);
|
request.flush(saved);
|
||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
|
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
|
||||||
reopened.detectChanges();
|
reopened.detectChanges();
|
||||||
http.expectOne('/api/system/capability-settings').flush(saved);
|
http.expectOne('/api/system/capability-settings').flush(saved);
|
||||||
expect(reopened.componentInstance.config.video_enabled).toBeFalse();
|
expect(reopened.componentInstance.config.video_enabled).toBeFalse();
|
||||||
|
expect(reopened.componentInstance.config.recording_enabled).toBeTrue();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -11,6 +11,7 @@ interface ModelSettings {
|
|||||||
max_retries: number;
|
max_retries: number;
|
||||||
retry_interval_seconds: number;
|
retry_interval_seconds: number;
|
||||||
reasoning_effort: string;
|
reasoning_effort: string;
|
||||||
|
model_presets: string[];
|
||||||
env_path: string;
|
env_path: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
@@ -24,7 +25,7 @@ interface ModelSettings {
|
|||||||
<p>统一使用 OpenAI Responses。保存后新任务立即生效,重启后自动加载。</p>
|
<p>统一使用 OpenAI Responses。保存后新任务立即生效,重启后自动加载。</p>
|
||||||
<label for="model-base-url">接口地址</label>
|
<label for="model-base-url">接口地址</label>
|
||||||
<input id="model-base-url" name="baseUrl" type="url" [(ngModel)]="baseUrl" required
|
<input id="model-base-url" name="baseUrl" type="url" [(ngModel)]="baseUrl" required
|
||||||
placeholder="https://api.openai.com/v1" [disabled]="busy() || loading()" />
|
placeholder="https://api-n-cd.playones.com/v1" [disabled]="busy() || loading()" />
|
||||||
<small>填写接口根地址,通常包含 /v1,无需添加 /responses。</small>
|
<small>填写接口根地址,通常包含 /v1,无需添加 /responses。</small>
|
||||||
<label for="model-name">模型名称</label>
|
<label for="model-name">模型名称</label>
|
||||||
<div class="model-presets" role="group" aria-label="预设模型">
|
<div class="model-presets" role="group" aria-label="预设模型">
|
||||||
@@ -90,7 +91,7 @@ interface ModelSettings {
|
|||||||
export class ModelSettingsComponent implements OnInit {
|
export class ModelSettingsComponent implements OnInit {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
saved = output<void>();
|
saved = output<void>();
|
||||||
readonly modelPresets = ['gpt-5.6-luna', 'mimo-v2.6-flash'];
|
modelPresets: string[] = [];
|
||||||
baseUrl = ''; model = ''; apiKey = ''; timeout = 300; retries = 5; retryInterval = 10; reasoning = 'high';
|
baseUrl = ''; model = ''; apiKey = ''; timeout = 300; retries = 5; retryInterval = 10; reasoning = 'high';
|
||||||
busy = signal<'save' | 'test' | null>(null);
|
busy = signal<'save' | 'test' | null>(null);
|
||||||
loading = signal(true); keyConfigured = signal(false); envPath = signal('');
|
loading = signal(true); keyConfigured = signal(false); envPath = signal('');
|
||||||
@@ -112,6 +113,7 @@ export class ModelSettingsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
private apply(config: ModelSettings): void {
|
private apply(config: ModelSettings): void {
|
||||||
this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds;
|
this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds;
|
||||||
|
this.modelPresets = config.model_presets;
|
||||||
this.retries = config.max_retries; this.retryInterval = config.retry_interval_seconds ?? 10; this.reasoning = config.reasoning_effort ?? 'high'; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path);
|
this.retries = config.max_retries; this.retryInterval = config.retry_interval_seconds ?? 10; this.reasoning = config.reasoning_effort ?? 'high'; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path);
|
||||||
}
|
}
|
||||||
save(): void {
|
save(): void {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { signal } from '@angular/core';
|
import { signal } from '@angular/core';
|
||||||
|
import { Clipboard } from '@angular/cdk/clipboard';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
@@ -14,6 +15,25 @@ describe('历史任务检索', () => {
|
|||||||
});
|
});
|
||||||
afterEach(() => http.verify());
|
afterEach(() => http.verify());
|
||||||
|
|
||||||
|
it('历史 ID 独立于打开任务按钮,点击复制不会打开任务', async () => {
|
||||||
|
spyOn(navigator.clipboard, 'writeText').and.rejectWith(new Error('blocked'));
|
||||||
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
|
http.expectOne(r => r.url === '/api/automation/history').flush({total: 1, items: [
|
||||||
|
{session_id:'completed-task', name:'已完成任务', status:'completed', start_time:Date.now()/1000}
|
||||||
|
]});
|
||||||
|
const copy = spyOn(TestBed.inject(Clipboard), 'copy').and.returnValue(true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const id: HTMLElement = fixture.nativeElement.querySelector('app-task-id-copy [role="button"]');
|
||||||
|
expect(id.closest('button.row')).toBeNull();
|
||||||
|
id.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(copy).toHaveBeenCalledOnceWith('completed-task');
|
||||||
|
expect(TestBed.inject(AgentService).selectSession).not.toHaveBeenCalled();
|
||||||
|
fixture.nativeElement.querySelector('button.row').click();
|
||||||
|
expect(TestBed.inject(AgentService).selectSession).toHaveBeenCalledWith('completed-task', true);
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
it('迟到的旧查询不会覆盖最新搜索结果', () => {
|
it('迟到的旧查询不会覆盖最新搜索结果', () => {
|
||||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
const first = http.expectOne(r => r.url === '/api/automation/history');
|
const first = http.expectOne(r => r.url === '/api/automation/history');
|
||||||
@@ -29,6 +49,21 @@ describe('历史任务检索', () => {
|
|||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('显示明确的结果、完成时间与总耗时,标题悬停显示完整提示词', () => {
|
||||||
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
|
http.expectOne(r => r.url === '/api/automation/history').flush({total: 1, items: [
|
||||||
|
{session_id:'case-1', name:'1.1 打开并核对设置', initial_goal:'请打开设置首页并核对两个真实分类', status:'failed', start_time:100, end_time:165, error:'任务执行超时(60 秒)'}
|
||||||
|
]});
|
||||||
|
fixture.detectChanges();
|
||||||
|
const row: HTMLButtonElement = fixture.nativeElement.querySelector('button.row');
|
||||||
|
expect(row.getAttribute('title')).toBe('请打开设置首页并核对两个真实分类');
|
||||||
|
expect(row.textContent).toContain('1.1 打开并核对设置');
|
||||||
|
expect(row.textContent).toContain('总耗时 1分5秒');
|
||||||
|
expect(row.textContent).toContain('任务执行超时');
|
||||||
|
expect(fixture.nativeElement.querySelector('.status-failed .history-status').textContent).toContain('失败');
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
it('日期筛选用本地零点到次日零点,后端分页参数独立传递', () => {
|
it('日期筛选用本地零点到次日零点,后端分页参数独立传递', () => {
|
||||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
|
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||||
import { Component, DestroyRef, inject, signal } from '@angular/core';
|
import { Component, DestroyRef, inject, signal } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
@@ -5,19 +6,19 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
|||||||
import { AgentService } from '../../services/agent.service';
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule],
|
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent],
|
||||||
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small></h3>
|
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small></h3>
|
||||||
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
|
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
|
||||||
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
|
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
|
||||||
@if(error()){<p role="alert">{{error()}}</p>}
|
@if(error()){<p role="alert">{{error()}}</p>}
|
||||||
@if(loading()){<small>正在查询…</small>}
|
@if(loading()){<small>正在查询…</small>}
|
||||||
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
|
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
|
||||||
@for(row of group.rows; track row.session_id){<div class="history-item"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)"><strong>{{row.name || row.initial_goal}}</strong><span>{{label(row.status)}} · {{row.start_time*1000 | date:'HH:mm:ss'}} · {{row.tags}}</span><small>{{row.session_id}}</small></button><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
|
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><b class="history-status">{{label(row.status)}}</b> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
|
||||||
</details>}
|
</details>}
|
||||||
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
|
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
|
||||||
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
|
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
|
||||||
</section>`,
|
</section>`,
|
||||||
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:4px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:500}.row span{opacity:.75}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative}.history-item .row{padding-right:35px}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
|
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-block;border-radius:5px;padding:2px 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
|
||||||
})
|
})
|
||||||
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;
|
||||||
@@ -26,7 +27,8 @@ export class TaskHistoryComponent {
|
|||||||
constructor(){this.load();const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});}
|
constructor(){this.load();const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});}
|
||||||
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});}
|
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});}
|
||||||
pages(){return Math.max(1,Math.ceil(this.total()/30));}
|
pages(){return Math.max(1,Math.ceil(this.total()/30));}
|
||||||
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已取消',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
|
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已终止',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
|
||||||
|
duration(start:number,end?:number){if(!start||!end)return '—';const seconds=Math.max(0,Math.floor(end-start));const hours=Math.floor(seconds/3600);const minutes=Math.floor((seconds%3600)/60);return hours?`${hours}时${minutes}分`:minutes?`${minutes}分${seconds%60}秒`:`${seconds}秒`;}
|
||||||
toggle(key:string,event:Event){if((event.target as HTMLDetailsElement).open)this.collapsed.delete(key);else this.collapsed.add(key);}
|
toggle(key:string,event:Event){if((event.target as HTMLDetailsElement).open)this.collapsed.delete(key);else this.collapsed.add(key);}
|
||||||
search(){this.page=1;this.revision++;clearTimeout(this.debounce);this.debounce=setTimeout(()=>this.load(),250);}
|
search(){this.page=1;this.revision++;clearTimeout(this.debounce);this.debounce=setTimeout(()=>this.load(),250);}
|
||||||
load(){
|
load(){
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Clipboard } from '@angular/cdk/clipboard';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { TaskIdCopyComponent } from './task-id-copy.component';
|
||||||
|
|
||||||
|
describe('任务 ID 复制', () => {
|
||||||
|
let clipboard: jasmine.SpyObj<Clipboard>;
|
||||||
|
beforeEach(() => {
|
||||||
|
spyOn(navigator.clipboard, 'writeText').and.rejectWith(new Error('blocked'));
|
||||||
|
clipboard = jasmine.createSpyObj('Clipboard', ['copy']);
|
||||||
|
TestBed.configureTestingModule({ providers: [{ provide: Clipboard, useValue: clipboard }] });
|
||||||
|
});
|
||||||
|
afterEach(() => window.getSelection()?.removeAllRanges());
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const fixture = TestBed.createComponent(TaskIdCopyComponent);
|
||||||
|
fixture.componentRef.setInput('taskId', 'task-123');
|
||||||
|
fixture.detectChanges();
|
||||||
|
const target: HTMLElement = fixture.nativeElement.querySelector('[role="button"]');
|
||||||
|
return { fixture, target };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('点击复制完整 ID,并提示成功,不触发父卡片', async () => {
|
||||||
|
clipboard.copy.and.returnValue(true);
|
||||||
|
const { fixture, target } = setup();
|
||||||
|
const parentClick = jasmine.createSpy('parentClick');
|
||||||
|
fixture.nativeElement.addEventListener('click', parentClick);
|
||||||
|
target.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(clipboard.copy).toHaveBeenCalledOnceWith('task-123');
|
||||||
|
expect(fixture.nativeElement.querySelector('[role="status"]').textContent).toContain('已复制');
|
||||||
|
expect(parentClick).not.toHaveBeenCalled();
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('优先使用浏览器剪贴板,并在写入完成后提示成功', async () => {
|
||||||
|
const write = navigator.clipboard.writeText as jasmine.Spy;
|
||||||
|
let complete!: () => void;
|
||||||
|
write.and.returnValue(new Promise<void>(resolve => complete = resolve));
|
||||||
|
const { fixture, target } = setup();
|
||||||
|
target.click();
|
||||||
|
expect(fixture.componentInstance.feedback()).toBe('');
|
||||||
|
complete();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(write).toHaveBeenCalledOnceWith('task-123');
|
||||||
|
expect(clipboard.copy).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.componentInstance.feedback()).toBe('任务 ID 已复制');
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制失败不会提示成功,ID 仍可手动选择', async () => {
|
||||||
|
clipboard.copy.and.returnValue(false);
|
||||||
|
const { fixture, target } = setup();
|
||||||
|
target.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('复制失败,请选中任务 ID 手动复制');
|
||||||
|
expect(getComputedStyle(target.querySelector('.value')!).userSelect).toBe('text');
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拖选 ID 后的点击不会覆盖剪贴板或清空选区', () => {
|
||||||
|
const { fixture, target } = setup();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(target.querySelector('.value')!);
|
||||||
|
const selection = window.getSelection()!;
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
target.click();
|
||||||
|
expect(clipboard.copy).not.toHaveBeenCalled();
|
||||||
|
expect(selection.toString()).toContain('task-123');
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('支持键盘 Enter 复制', async () => {
|
||||||
|
clipboard.copy.and.returnValue(true);
|
||||||
|
const { fixture, target } = setup();
|
||||||
|
target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(clipboard.copy).toHaveBeenCalledOnceWith('task-123');
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Clipboard } from '@angular/cdk/clipboard';
|
||||||
|
import { Component, DestroyRef, Input, inject, signal } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-task-id-copy',
|
||||||
|
standalone: true,
|
||||||
|
template: `
|
||||||
|
<span class="copy-target" role="button" tabindex="0"
|
||||||
|
[attr.aria-label]="'复制任务 ID:' + taskId" title="点击复制任务 ID,也可选中文字复制"
|
||||||
|
(click)="copy($event)" (keydown.enter)="copy($event)" (keydown.space)="copy($event)">
|
||||||
|
<span class="value">ID: {{ taskId }}</span>
|
||||||
|
<span class="material-symbols-outlined" aria-hidden="true">content_copy</span>
|
||||||
|
</span>
|
||||||
|
<span class="feedback" role="status" aria-live="polite">{{ feedback() }}</span>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
:host { display: block; min-width: 0; font-size: 11px; color: inherit; user-select: text; }
|
||||||
|
.copy-target { display: inline-flex; align-items: center; gap: 5px; max-width: 100%;
|
||||||
|
padding: 3px 0; cursor: pointer; user-select: text; border-radius: 3px; }
|
||||||
|
.value { min-width: 0; overflow-wrap: anywhere; user-select: text; }
|
||||||
|
.material-symbols-outlined { flex-shrink: 0; font-size: 14px; user-select: none; }
|
||||||
|
.copy-target:hover { color: #2563eb; }
|
||||||
|
.copy-target:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; }
|
||||||
|
.feedback { display: block; line-height: 1.6; user-select: text; }
|
||||||
|
.feedback:empty { display: none; }
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class TaskIdCopyComponent {
|
||||||
|
@Input({ required: true }) taskId = '';
|
||||||
|
readonly feedback = signal('');
|
||||||
|
private readonly clipboard = inject(Clipboard);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.destroyRef.onDestroy(() => clearTimeout(this.timer));
|
||||||
|
}
|
||||||
|
|
||||||
|
async copy(event: Event): Promise<void> {
|
||||||
|
event.stopPropagation();
|
||||||
|
// A drag selection must remain available for the browser's normal copy action.
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (event.type === 'click' && selection && !selection.isCollapsed
|
||||||
|
&& (event.currentTarget as HTMLElement).contains(selection.anchorNode)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
let copied = false;
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(this.taskId);
|
||||||
|
copied = true;
|
||||||
|
} else {
|
||||||
|
copied = this.clipboard.copy(this.taskId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
copied = this.clipboard.copy(this.taskId);
|
||||||
|
} catch {
|
||||||
|
// Keep manual selection available when the browser blocks both clipboard APIs.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.destroyRef.destroyed) return;
|
||||||
|
this.feedback.set(copied ? '任务 ID 已复制' : '复制失败,请选中任务 ID 手动复制');
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = setTimeout(() => this.feedback.set(''), 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,9 @@ export interface Session {
|
|||||||
start_time: number;
|
start_time: number;
|
||||||
end_time?: number;
|
end_time?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
display_name?: string;
|
||||||
|
case_id?: string;
|
||||||
|
error?: string;
|
||||||
video_url?: string;
|
video_url?: string;
|
||||||
recording_status?: 'recording' | 'finalizing' | 'processing' | 'ready' | 'failed' | 'unavailable';
|
recording_status?: 'recording' | 'finalizing' | 'processing' | 'ready' | 'failed' | 'unavailable';
|
||||||
model_info?: ModelInfo;
|
model_info?: ModelInfo;
|
||||||
|
|||||||
@@ -17,7 +17,43 @@ limitations under the License.
|
|||||||
<div class="workspace-container" [class.is-resizing]="isDragging()">
|
<div class="workspace-container" [class.is-resizing]="isDragging()">
|
||||||
<!-- Left Main Panel: Raw Agent Log Stream -->
|
<!-- Left Main Panel: Raw Agent Log Stream -->
|
||||||
<main class="left-panel glass-panel">
|
<main class="left-panel glass-panel">
|
||||||
<app-agent-stream></app-agent-stream>
|
<app-agent-stream>
|
||||||
|
@if (guidanceStatus(); as status) {
|
||||||
|
<div task-guidance class="task-guidance-control">
|
||||||
|
<button type="button" class="guidance-trigger" (click)="toggleGuidance()"
|
||||||
|
[attr.aria-expanded]="guidanceOpenId() === agentService.currentSessionId()"
|
||||||
|
aria-controls="workspace-guidance-panel" aria-label="指导当前任务" title="暂停或指导当前任务">
|
||||||
|
<span class="material-symbols-outlined">edit_note</span>
|
||||||
|
指导任务
|
||||||
|
</button>
|
||||||
|
@if (guidanceOpenId() === agentService.currentSessionId()) {
|
||||||
|
<section id="workspace-guidance-panel" class="task-guidance-panel" aria-label="当前任务指导">
|
||||||
|
<div class="guidance-heading">
|
||||||
|
<strong>指导当前任务 · {{ status === 'running' ? '执行中' : status === 'resuming' ? '等待设备恢复' : status === 'paused' ? '模型调用暂停' : '等待指导' }}</strong>
|
||||||
|
<span [title]="agentService.currentSession()?.initial_goal || ''">{{ agentService.currentSession()?.initial_goal || agentService.currentSessionId() }}</span>
|
||||||
|
</div>
|
||||||
|
<label for="workspace-task-guidance">补充信息或调整方向</label>
|
||||||
|
<textarea id="workspace-task-guidance" [(ngModel)]="taskGuidance" rows="2" maxlength="10000"
|
||||||
|
[disabled]="guidanceBusy()" placeholder="输入指导;将中断当前模型思考并立即重新规划"></textarea>
|
||||||
|
<div class="guidance-actions">
|
||||||
|
<span>{{ status === 'paused' ? '提交指导后请在执行日志中恢复模型调用' : '下方输入框用于开始新任务' }}</span>
|
||||||
|
@if (status === 'running') {
|
||||||
|
<button type="button" (click)="pauseCurrentTask()"
|
||||||
|
[disabled]="guidanceBusy() || pauseRequestId() === agentService.currentSessionId()">
|
||||||
|
{{ pauseRequestId() === agentService.currentSessionId() ? '等待暂停…' : '暂停当前任务' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (status === 'task_paused') {
|
||||||
|
<button type="button" (click)="continueCurrentTask()" [disabled]="guidanceBusy()">直接继续</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="guidance-submit" (click)="submitGuidance()"
|
||||||
|
[disabled]="guidanceBusy() || !taskGuidance.trim()">{{ status === 'task_paused' ? '提交并恢复' : '提交指导' }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</app-agent-stream>
|
||||||
|
|
||||||
<!-- Bottom Floating Command Bar Dock -->
|
<!-- Bottom Floating Command Bar Dock -->
|
||||||
<div class="workspace-floating-bar-wrapper" [class.is-expanded]="isBarExpanded()" [class.is-dormant]="!isBarExpanded()"
|
<div class="workspace-floating-bar-wrapper" [class.is-expanded]="isBarExpanded()" [class.is-dormant]="!isBarExpanded()"
|
||||||
|
|||||||
@@ -108,6 +108,110 @@ $rose-dark: #dc2626;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-guidance-control {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
.guidance-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border: 1px solid rgba(226, 232, 240, .85);
|
||||||
|
border-radius: 15px;
|
||||||
|
background: rgba(255, 255, 255, .72);
|
||||||
|
box-shadow: 0 2px 6px -1px rgba(15, 23, 42, .05);
|
||||||
|
color: #334155;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
.material-symbols-outlined { font-size: 16px; }
|
||||||
|
&:hover { background: rgba(255, 255, 255, .92); border-color: #cbd5e1; }
|
||||||
|
&[aria-expanded='true'] { background: #eff6ff; border-color: #bfdbfe; color: #1d4ed8; }
|
||||||
|
&:focus-visible { outline: 2px solid #93c5fd; outline-offset: 2px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.guidance-trigger {
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0;
|
||||||
|
.material-symbols-outlined { font-size: 16px; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-guidance-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 9px);
|
||||||
|
right: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: min(420px, calc(100vw - 40px));
|
||||||
|
padding: 15px;
|
||||||
|
border: 1px solid #dbe4ef;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 18px 48px rgba(15, 23, 42, .16);
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
.guidance-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
strong { flex-shrink: 0; color: #1d4ed8; font-size: 13px; }
|
||||||
|
span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #475569; }
|
||||||
|
}
|
||||||
|
|
||||||
|
label { display: block; margin-bottom: 6px; font-weight: 600; }
|
||||||
|
textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 62px;
|
||||||
|
max-height: 160px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0f172a;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
&:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.guidance-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
span { flex: 1 0 100%; color: #64748b; }
|
||||||
|
button {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 5px 11px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover:not(:disabled) { background: #eff6ff; border-color: #93c5fd; }
|
||||||
|
&:disabled { opacity: .55; cursor: not-allowed; }
|
||||||
|
&.guidance-submit { background: #2563eb; border-color: #2563eb; color: #fff; }
|
||||||
|
&.guidance-submit:hover:not(:disabled) { background: #1d4ed8; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ========================================================================= */
|
/* ========================================================================= */
|
||||||
/* BOTTOM FLOATING COMMAND BAR (Proximity Reveal & Minimalist Glassmorphism) */
|
/* BOTTOM FLOATING COMMAND BAR (Proximity Reveal & Minimalist Glassmorphism) */
|
||||||
/* ========================================================================= */
|
/* ========================================================================= */
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { Component, computed, signal } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
import { AgentStreamComponent } from '../../components/agent-stream/agent-stream.component';
|
||||||
|
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
|
||||||
|
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
|
||||||
|
import { WorkspaceComponent } from './workspace.component';
|
||||||
|
|
||||||
|
@Component({ selector: 'app-agent-stream', standalone: true,
|
||||||
|
template: '<div class="stream-floating-toolbar"><ng-content select="[task-guidance]"></ng-content></div>' })
|
||||||
|
class StreamStub {}
|
||||||
|
@Component({ selector: 'app-chat-interface', standalone: true, template: '' })
|
||||||
|
class ChatStub {}
|
||||||
|
@Component({ selector: 'app-floating-video-player', standalone: true, template: '' })
|
||||||
|
class VideoStub {}
|
||||||
|
|
||||||
|
describe('Workspace task guidance', () => {
|
||||||
|
const currentId = signal<string | null>('running-task');
|
||||||
|
const session = signal({ session_id: 'running-task', status: 'running', initial_goal: '观察设置' });
|
||||||
|
const agent = {
|
||||||
|
currentSessionId: currentId,
|
||||||
|
currentSession: computed(() => currentId() === session().session_id ? session() : null),
|
||||||
|
runningSessionId: signal<string | null>('running-task'),
|
||||||
|
agentStatus: signal('running'),
|
||||||
|
isCurrentSessionRunning: computed(() => ['running', 'paused'].includes(session().status)),
|
||||||
|
fetchStatus: jasmine.createSpy('fetchStatus'),
|
||||||
|
fetchSessions: jasmine.createSpy('fetchSessions'),
|
||||||
|
fetchGuidance: jasmine.createSpy('fetchGuidance')
|
||||||
|
};
|
||||||
|
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['post']);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
currentId.set('running-task');
|
||||||
|
session.set({ session_id: 'running-task', status: 'running', initial_goal: '观察设置' });
|
||||||
|
agent.runningSessionId.set('running-task');
|
||||||
|
http.post.and.returnValue(of({}));
|
||||||
|
http.post.calls.reset();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [WorkspaceComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: AgentService, useValue: agent },
|
||||||
|
{ provide: HttpClient, useValue: http }
|
||||||
|
]
|
||||||
|
}).overrideComponent(WorkspaceComponent, {
|
||||||
|
remove: { imports: [AgentStreamComponent, ChatInterfaceComponent, FloatingVideoPlayerComponent] },
|
||||||
|
add: { imports: [StreamStub, ChatStub, VideoStub] }
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows pause and guidance for the selected running task, then hides them on cancellation', async () => {
|
||||||
|
const fixture = TestBed.createComponent(WorkspaceComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
|
||||||
|
expect(fixture.nativeElement.querySelector('.stream-floating-toolbar .guidance-trigger')).not.toBeNull();
|
||||||
|
expect(fixture.nativeElement.querySelector('.workspace-floating-bar-wrapper .guidance-trigger')).toBeNull();
|
||||||
|
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const panel = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
|
||||||
|
expect(panel.textContent).toContain('指导当前任务');
|
||||||
|
expect(panel.textContent).toContain('下方输入框用于开始新任务');
|
||||||
|
|
||||||
|
const pause = Array.from(panel.querySelectorAll('button')).find(b => b.textContent?.includes('暂停当前任务'))!;
|
||||||
|
pause.click();
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/pause', {});
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const reopened = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
|
||||||
|
const input = reopened.querySelector('textarea')!;
|
||||||
|
input.value = '先确认设置首页';
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const submit = Array.from(reopened.querySelectorAll('button')).find(b => b.textContent?.includes('提交指导'))!;
|
||||||
|
submit.click();
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/guide', { instruction: '先确认设置首页' });
|
||||||
|
expect(agent.fetchGuidance).toHaveBeenCalledWith('running-task');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('指导已提交');
|
||||||
|
|
||||||
|
session.set({ session_id: 'running-task', status: 'cancelled', initial_goal: '观察设置' });
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
|
||||||
|
expect(fixture.nativeElement.querySelector('.guidance-trigger')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues a paused task without creating a new task', async () => {
|
||||||
|
session.set({ session_id: 'running-task', status: 'task_paused', initial_goal: '观察设置' });
|
||||||
|
const fixture = TestBed.createComponent(WorkspaceComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const button = Array.from<HTMLElement>(fixture.nativeElement.querySelectorAll('.task-guidance-panel button'))
|
||||||
|
.find(b => b.textContent?.includes('直接继续'))!;
|
||||||
|
button.click();
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/continue', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer task resume for a paused model request', () => {
|
||||||
|
session.set({ session_id: 'running-task', status: 'paused', initial_goal: '观察设置' });
|
||||||
|
const fixture = TestBed.createComponent(WorkspaceComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const panel = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
|
||||||
|
expect(panel.textContent).toContain('模型调用暂停');
|
||||||
|
expect(panel.textContent).toContain('请在执行日志中恢复模型调用');
|
||||||
|
expect(panel.textContent).not.toContain('直接继续');
|
||||||
|
expect(panel.textContent).not.toContain('提交并恢复');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,8 @@ import { AgentStreamComponent } from '../../components/agent-stream/agent-stream
|
|||||||
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
|
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
|
||||||
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
|
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
|
||||||
import { AgentService } from '../../services/agent.service';
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-workspace',
|
selector: 'app-workspace',
|
||||||
@@ -37,6 +39,7 @@ import { AgentService } from '../../services/agent.service';
|
|||||||
})
|
})
|
||||||
export class WorkspaceComponent implements OnInit {
|
export class WorkspaceComponent implements OnInit {
|
||||||
public agentService = inject(AgentService);
|
public agentService = inject(AgentService);
|
||||||
|
private http = inject(HttpClient);
|
||||||
private zone = inject(NgZone);
|
private zone = inject(NgZone);
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
@@ -56,6 +59,33 @@ export class WorkspaceComponent implements OnInit {
|
|||||||
public isSubmitting = signal<boolean>(false);
|
public isSubmitting = signal<boolean>(false);
|
||||||
public errorMessage = signal<string | null>(null);
|
public errorMessage = signal<string | null>(null);
|
||||||
public selectedProfile = signal<'flash' | 'pro'>('flash');
|
public selectedProfile = signal<'flash' | 'pro'>('flash');
|
||||||
|
private guidanceDrafts = new Map<string, string>();
|
||||||
|
public guidanceBusy = signal(false);
|
||||||
|
public guidanceOpenId = signal<string | null>(null);
|
||||||
|
public pauseRequestId = signal<string | null>(null);
|
||||||
|
|
||||||
|
public get taskGuidance(): string {
|
||||||
|
const id = this.agentService.currentSessionId();
|
||||||
|
return id ? this.guidanceDrafts.get(id) || '' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public set taskGuidance(value: string) {
|
||||||
|
const id = this.agentService.currentSessionId();
|
||||||
|
if (id) this.guidanceDrafts.set(id, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public guidanceStatus = computed(() => {
|
||||||
|
const id = this.agentService.currentSessionId();
|
||||||
|
if (!id) return null;
|
||||||
|
const status = this.agentService.currentSession()?.status
|
||||||
|
|| (this.agentService.runningSessionId() === id ? this.agentService.agentStatus() : '');
|
||||||
|
return ['running', 'paused', 'task_paused', 'resuming'].includes(status || '') ? status : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
public toggleGuidance(): void {
|
||||||
|
const id = this.agentService.currentSessionId();
|
||||||
|
this.guidanceOpenId.set(id && this.guidanceOpenId() !== id ? id : null);
|
||||||
|
}
|
||||||
|
|
||||||
// Expand States (Signals for 0-latency reactivity)
|
// Expand States (Signals for 0-latency reactivity)
|
||||||
public isHoveringCard = signal<boolean>(false);
|
public isHoveringCard = signal<boolean>(false);
|
||||||
@@ -103,6 +133,69 @@ export class WorkspaceComponent implements OnInit {
|
|||||||
return this.agentService.isCurrentSessionRunning();
|
return this.agentService.isCurrentSessionRunning();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
public async pauseCurrentTask(): Promise<void> {
|
||||||
|
const id = this.agentService.currentSessionId();
|
||||||
|
if (!id || this.guidanceStatus() !== 'running' || this.guidanceBusy()) return;
|
||||||
|
this.guidanceBusy.set(true);
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
try {
|
||||||
|
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/pause`, {}));
|
||||||
|
this.pauseRequestId.set(id);
|
||||||
|
this.guidanceOpenId.set(null);
|
||||||
|
this.agentService.fetchStatus();
|
||||||
|
this.agentService.fetchSessions();
|
||||||
|
} catch (error) {
|
||||||
|
this.showGuidanceError(error);
|
||||||
|
} finally {
|
||||||
|
this.guidanceBusy.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async submitGuidance(): Promise<void> {
|
||||||
|
const id = this.agentService.currentSessionId();
|
||||||
|
const instruction = this.taskGuidance.trim();
|
||||||
|
const status = this.guidanceStatus();
|
||||||
|
if (!id || !status || !instruction || this.guidanceBusy()) return;
|
||||||
|
this.guidanceBusy.set(true);
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
try {
|
||||||
|
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/guide`, { instruction }));
|
||||||
|
this.guidanceDrafts.delete(id);
|
||||||
|
this.pauseRequestId.set(null);
|
||||||
|
this.guidanceOpenId.set(null);
|
||||||
|
this.agentService.fetchGuidance(id);
|
||||||
|
this.agentService.fetchStatus();
|
||||||
|
this.agentService.fetchSessions();
|
||||||
|
} catch (error) {
|
||||||
|
this.showGuidanceError(error);
|
||||||
|
} finally {
|
||||||
|
this.guidanceBusy.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async continueCurrentTask(): Promise<void> {
|
||||||
|
const id = this.agentService.currentSessionId();
|
||||||
|
if (!id || this.guidanceStatus() !== 'task_paused' || this.guidanceBusy()) return;
|
||||||
|
this.guidanceBusy.set(true);
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
try {
|
||||||
|
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/continue`, {}));
|
||||||
|
this.pauseRequestId.set(null);
|
||||||
|
this.guidanceOpenId.set(null);
|
||||||
|
this.agentService.fetchStatus();
|
||||||
|
this.agentService.fetchSessions();
|
||||||
|
} catch (error) {
|
||||||
|
this.showGuidanceError(error);
|
||||||
|
} finally {
|
||||||
|
this.guidanceBusy.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private showGuidanceError(error: unknown): void {
|
||||||
|
const response = error as { error?: { detail?: string }; message?: string };
|
||||||
|
this.errorMessage.set(response.error?.detail || response.message || '操作失败,请刷新任务状态后重试');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dynamic tooltip and label indicating which task will be stopped
|
* Dynamic tooltip and label indicating which task will be stopped
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ export interface StartupProgressEvent {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GuidanceEvent {
|
||||||
|
id: string;
|
||||||
|
instruction: string;
|
||||||
|
source: string;
|
||||||
|
timestamp: number;
|
||||||
|
status: 'pending' | 'consumed';
|
||||||
|
consumed_at?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface SessionVideoResponse {
|
interface SessionVideoResponse {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
status?: 'processing' | 'ready' | 'failed' | 'unavailable';
|
status?: 'processing' | 'ready' | 'failed' | 'unavailable';
|
||||||
@@ -99,11 +108,13 @@ export class AgentService {
|
|||||||
const pendingMatch = pendingById.get(s.session_id);
|
const pendingMatch = pendingById.get(s.session_id);
|
||||||
const isCurrentActive = ((status === 'running' || status === 'paused') && runId === s.session_id)
|
const isCurrentActive = ((status === 'running' || status === 'paused') && runId === s.session_id)
|
||||||
|| !!activeMatch;
|
|| !!activeMatch;
|
||||||
const isTerminal = s.status === 'completed' || s.status === 'success' || s.status === 'failed' || s.status === 'cancelled';
|
const isTerminal = ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(s.status || '');
|
||||||
let sStatus = s.status;
|
let sStatus = s.status;
|
||||||
if (!isTerminal) {
|
if (!isTerminal) {
|
||||||
const isPending = !!pendingMatch && !isCurrentActive;
|
const isPending = !!pendingMatch && !isCurrentActive;
|
||||||
sStatus = isCurrentActive ? (status === 'paused' ? 'paused' : 'running') : (isPending ? 'pending' : s.status);
|
sStatus = isCurrentActive
|
||||||
|
? (activeMatch?.status || (status === 'paused' ? 'paused' : 'running'))
|
||||||
|
: (isPending ? 'pending' : s.status);
|
||||||
} else {
|
} else {
|
||||||
sStatus = (s.status === 'success') ? 'completed' : s.status;
|
sStatus = (s.status === 'success') ? 'completed' : s.status;
|
||||||
}
|
}
|
||||||
@@ -138,7 +149,7 @@ export class AgentService {
|
|||||||
|| !!activeMatch;
|
|| !!activeMatch;
|
||||||
const finalPending: Session = {
|
const finalPending: Session = {
|
||||||
...p,
|
...p,
|
||||||
status: isCurrentRunning ? (status === 'paused' ? 'paused' : 'running') : (p.status || 'pending'),
|
status: isCurrentRunning ? (activeMatch?.status || (status === 'paused' ? 'paused' : 'running')) : (p.status || 'pending'),
|
||||||
device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null
|
device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null
|
||||||
};
|
};
|
||||||
sessionMap.set(p.session_id, finalPending);
|
sessionMap.set(p.session_id, finalPending);
|
||||||
@@ -156,7 +167,7 @@ export class AgentService {
|
|||||||
session_id: sid,
|
session_id: sid,
|
||||||
initial_goal: at.goal || goal || '',
|
initial_goal: at.goal || goal || '',
|
||||||
start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
|
start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
|
||||||
status: 'running',
|
status: at.status || 'running',
|
||||||
model_info: this.activeModel() || undefined,
|
model_info: this.activeModel() || undefined,
|
||||||
device_serial: at.device_id || null
|
device_serial: at.device_id || null
|
||||||
};
|
};
|
||||||
@@ -199,6 +210,8 @@ export class AgentService {
|
|||||||
|
|
||||||
public currentSessionId = signal<string | null>(null);
|
public currentSessionId = signal<string | null>(null);
|
||||||
public sessionLogs = signal<any[]>([]); // Dynamic array of all raw events received
|
public sessionLogs = signal<any[]>([]); // Dynamic array of all raw events received
|
||||||
|
public guidanceEvents = signal<GuidanceEvent[]>([]);
|
||||||
|
private guidanceSignature = '';
|
||||||
public isSessionContentLoading = signal<boolean>(false);
|
public isSessionContentLoading = signal<boolean>(false);
|
||||||
public agentStatus = signal<string>('idle'); // Status of the agent runner process
|
public agentStatus = signal<string>('idle'); // Status of the agent runner process
|
||||||
public runningSessionId = signal<string | null>(null);
|
public runningSessionId = signal<string | null>(null);
|
||||||
@@ -723,6 +736,8 @@ export class AgentService {
|
|||||||
this.isSessionContentLoading.set(false);
|
this.isSessionContentLoading.set(false);
|
||||||
this.currentSessionId.set(null);
|
this.currentSessionId.set(null);
|
||||||
this.sessionLogs.set([]);
|
this.sessionLogs.set([]);
|
||||||
|
this.guidanceEvents.set([]);
|
||||||
|
this.guidanceSignature = '';
|
||||||
this.currentNotes.set({});
|
this.currentNotes.set({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -738,6 +753,9 @@ export class AgentService {
|
|||||||
this.isSessionContentLoading.set(true);
|
this.isSessionContentLoading.set(true);
|
||||||
this.currentSessionId.set(sessionId);
|
this.currentSessionId.set(sessionId);
|
||||||
this.sessionLogs.set([]); // Reset logs for new session selection
|
this.sessionLogs.set([]); // Reset logs for new session selection
|
||||||
|
this.guidanceEvents.set([]);
|
||||||
|
this.guidanceSignature = '';
|
||||||
|
this.fetchGuidance(sessionId);
|
||||||
this.activePauseCardKey = null;
|
this.activePauseCardKey = null;
|
||||||
this.isPaused.set(false);
|
this.isPaused.set(false);
|
||||||
this.pausedError.set(null);
|
this.pausedError.set(null);
|
||||||
@@ -1277,6 +1295,21 @@ export class AgentService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public fetchGuidance(sessionId: string): void {
|
||||||
|
if (this.currentSessionId() !== sessionId) return;
|
||||||
|
this.http.get<GuidanceEvent[]>(`/api/tasks/${encodeURIComponent(sessionId)}/guidance`).subscribe({
|
||||||
|
next: (items) => {
|
||||||
|
if (this.currentSessionId() !== sessionId) return;
|
||||||
|
const signature = JSON.stringify(items);
|
||||||
|
if (signature !== this.guidanceSignature) {
|
||||||
|
this.guidanceSignature = signature;
|
||||||
|
this.guidanceEvents.set(items);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => { /* Older runners have no guidance history; steps remain authoritative. */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private restoreSessionsCache(): void {
|
private restoreSessionsCache(): void {
|
||||||
try {
|
try {
|
||||||
const cached = localStorage.getItem(SESSION_CACHE_KEY);
|
const cached = localStorage.getItem(SESSION_CACHE_KEY);
|
||||||
@@ -1482,6 +1515,8 @@ export class AgentService {
|
|||||||
return; // Paused while the tab is in the background; resumed by visibilitychange.
|
return; // Paused while the tab is in the background; resumed by visibilitychange.
|
||||||
}
|
}
|
||||||
this.fetchStatus();
|
this.fetchStatus();
|
||||||
|
const selected = this.currentSessionId();
|
||||||
|
if (selected) this.fetchGuidance(selected);
|
||||||
this.pollCounter++;
|
this.pollCounter++;
|
||||||
// Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications
|
// Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications
|
||||||
if (this.pollCounter % 3 === 0) {
|
if (this.pollCounter % 3 === 0) {
|
||||||
|
|||||||
@@ -67,4 +67,12 @@ describe('markdown-parser.util verification & check lines', () => {
|
|||||||
expect(html).toContain('<span class="verify-badge">verify</span>');
|
expect(html).toContain('<span class="verify-badge">verify</span>');
|
||||||
expect(html).toContain('status is ok');
|
expect(html).toContain('status is ok');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders Chinese report tables and preserves separate lines safely', () => {
|
||||||
|
const html = renderMarkdownToHtml('## 结论\n第一行\n第二行\n\n| 分类 | 结果 |\n| --- | --- |\n| 网络 | **通过** |');
|
||||||
|
expect(html).toContain('<h2>结论</h2>');
|
||||||
|
expect(html).toContain('<div>第一行</div>\n<div>第二行</div>');
|
||||||
|
expect(html).toContain('<th>分类</th>');
|
||||||
|
expect(html).toContain('<td><strong>通过</strong></td>');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -272,7 +272,7 @@ export function renderMarkdownToHtml(text: string): string {
|
|||||||
if (inCodeBlock) {
|
if (inCodeBlock) {
|
||||||
// End code block
|
// End code block
|
||||||
const codeContent = codeBlockLines.join('\n');
|
const codeContent = codeBlockLines.join('\n');
|
||||||
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang}">${codeContent}</code></pre>`);
|
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang.replace(/[^a-zA-Z0-9_-]/g, '')}">${codeContent}</code></pre>`);
|
||||||
inCodeBlock = false;
|
inCodeBlock = false;
|
||||||
codeBlockLines = [];
|
codeBlockLines = [];
|
||||||
codeBlockLang = '';
|
codeBlockLang = '';
|
||||||
@@ -290,6 +290,26 @@ export function renderMarkdownToHtml(text: string): string {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Markdown result tables need real cells to remain readable in reports.
|
||||||
|
if (trimmed.includes('|') && i + 1 < rawLines.length
|
||||||
|
&& /^\s*\|?\s*:?-{3,}:?(?:\s*\|\s*:?-{3,}:?)*\s*\|?\s*$/.test(rawLines[i + 1])) {
|
||||||
|
closeList();
|
||||||
|
const cells = (line: string) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
||||||
|
const headings = cells(rawLine);
|
||||||
|
result.push('<div class="md-table-scroll"><table><thead><tr>'
|
||||||
|
+ headings.map(cell => `<th>${formatInlineMarkdown(cell.trim())}</th>`).join('')
|
||||||
|
+ '</tr></thead><tbody>');
|
||||||
|
i += 2;
|
||||||
|
while (i < rawLines.length && rawLines[i].trim() && rawLines[i].includes('|')) {
|
||||||
|
const row = cells(rawLines[i]);
|
||||||
|
result.push('<tr>' + headings.map((_, index) => `<td>${formatInlineMarkdown((row[index] || '').trim())}</td>`).join('') + '</tr>');
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
result.push('</tbody></table></div>');
|
||||||
|
i--;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Headings: # to ######
|
// 2. Headings: # to ######
|
||||||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
||||||
if (headingMatch) {
|
if (headingMatch) {
|
||||||
@@ -375,7 +395,7 @@ export function renderMarkdownToHtml(text: string): string {
|
|||||||
|
|
||||||
if (inCodeBlock) {
|
if (inCodeBlock) {
|
||||||
const codeContent = codeBlockLines.join('\n');
|
const codeContent = codeBlockLines.join('\n');
|
||||||
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang}">${codeContent}</code></pre>`);
|
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang.replace(/[^a-zA-Z0-9_-]/g, '')}">${codeContent}</code></pre>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
closeList();
|
closeList();
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core';
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
|
|
||||||
const LABELS: Record<string, string> = {
|
const LABELS: Record<string, string> = {
|
||||||
running: '执行中', queued: '排队中', pending: '等待中', completed: '已完成',
|
running: '执行中', queued: '排队中', pending: '等待中', completed: '执行完成',
|
||||||
success: '成功', succeeded: '成功', failed: '失败', error: '错误', paused: '已暂停',
|
success: '成功', succeeded: '成功', failed: '失败', error: '错误', paused: '已暂停',
|
||||||
cancelled: '已取消', canceled: '已取消', stopped: '已停止', unknown: '未知',
|
cancelled: '已终止', canceled: '已终止', stopped: '已终止', interrupted: '中断',
|
||||||
|
task_paused: '等待指导', resuming: '恢复排队', skipped: '已跳过', unknown: '未知',
|
||||||
idle: '空闲', starting: '启动中', launching: '启动中', ready: '就绪',
|
idle: '空闲', starting: '启动中', launching: '启动中', ready: '就绪',
|
||||||
pass: '通过', fail: '失败', warn: '警告', warning: '警告', blocked: '受阻',
|
pass: '通过', fail: '失败', warn: '警告', warning: '警告', blocked: '受阻',
|
||||||
done: '完成', active: '当前', waiting: '等待中', processing: '处理中',
|
done: '完成', active: '当前', waiting: '等待中', processing: '处理中',
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from artemis.agents.explorer.tiers import SUBMIT_TOOL
|
|||||||
from artemis.agents.explorer.tool_declarations import UNIVERSAL_EXPLORER_TOOLS
|
from artemis.agents.explorer.tool_declarations import UNIVERSAL_EXPLORER_TOOLS
|
||||||
from artemis.graph.state import State
|
from artemis.graph.state import State
|
||||||
from artemis.services.llm import get_llm
|
from artemis.services.llm import get_llm
|
||||||
|
from artemis.config.settings import settings
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -120,7 +121,9 @@ class UniversalRunnerMixin:
|
|||||||
else:
|
else:
|
||||||
current_llm = bound_llm
|
current_llm = bound_llm
|
||||||
|
|
||||||
response = await asyncio.wait_for(current_llm.ainvoke(messages), timeout=180)
|
response = await asyncio.wait_for(
|
||||||
|
current_llm.ainvoke(messages), timeout=settings.OPENAI_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
messages.append(response)
|
messages.append(response)
|
||||||
|
|
||||||
tool_calls = getattr(response, "tool_calls", None) or []
|
tool_calls = getattr(response, "tool_calls", None) or []
|
||||||
|
|||||||
@@ -55,5 +55,5 @@ Keep in mind that the mobile device is an asynchronous, constantly evolving envi
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 6. TERMINATION PROTOCOL (`report_task_status`)
|
# 6. TERMINATION PROTOCOL (`report_task_status`)
|
||||||
- **Completion (`status="completed"`)**: ONLY call `report_task_status(status="completed", ...)` after you have **visually verified** from the latest screen that the objective is fully achieved.
|
- **Completion (`status="completed"`)**: ONLY call `report_task_status(status="completed", ...)` after the latest screenshot and UI elements support the claimed result. If those sources disagree during a transition, wait and observe again before concluding. Tie each claimed page title or value to an observed screenshot or UI element; mark any missing evidence as unverified.
|
||||||
- **Failure (`status="failed"`)**: If the task is genuinely impossible due to paywalls, unrecoverable crashes, or hitting an absolute dead end after sensible fallbacks, call `report_task_status(status="failed", ...)` with the exact blocking reason.
|
- **Failure (`status="failed"`)**: If the task is genuinely impossible due to paywalls, unrecoverable crashes, or hitting an absolute dead end after sensible fallbacks, call `report_task_status(status="failed", ...)` with the exact blocking reason.
|
||||||
@@ -24,6 +24,7 @@ You are the Step Summarizer for an Android UI automation agent. Your task is to
|
|||||||
- **Target & Action**: Identify what control/button was targeted (referencing the red visual indicator on the BEFORE screen) and the physical action performed.
|
- **Target & Action**: Identify what control/button was targeted (referencing the red visual indicator on the BEFORE screen) and the physical action performed.
|
||||||
- **Objective Transition**: Describe the physical screen change (e.g., page transitioned, modal dialog opened, list scrolled revealing new items, checkbox/toggle toggled, keyboard appeared).
|
- **Objective Transition**: Describe the physical screen change (e.g., page transitioned, modal dialog opened, list scrolled revealing new items, checkbox/toggle toggled, keyboard appeared).
|
||||||
- **Preserve Critical Data & Verifications**: Faithfully transcribe visible text, toast alerts, error banners, verification codes, prices, account names, or tracking numbers that appeared on the AFTER screen.
|
- **Preserve Critical Data & Verifications**: Faithfully transcribe visible text, toast alerts, error banners, verification codes, prices, account names, or tracking numbers that appeared on the AFTER screen.
|
||||||
|
3. **Reconcile both observation sources**: The optional AFTER ACTION UI ELEMENTS block is device hierarchy evidence from the same observation request. During a page animation its contents may be newer than the screenshot. If the image still shows the old page but the hierarchy names the new page, state both observations and that the transition is not yet visually settled; never conclude that the tap did nothing. Do not invent details absent from both sources. A controller's "Tapped" outcome only confirms input dispatch.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ active window until its turn is chunked, whose user lines then carry it on.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from artemis.config.settings import settings
|
|
||||||
import base64
|
import base64
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
import json
|
import json
|
||||||
@@ -45,6 +44,9 @@ from pathlib import Path
|
|||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
from artemis.runtime.task_control import TaskControl, has_pending_instruction
|
||||||
|
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
from langchain_core.messages import (
|
from langchain_core.messages import (
|
||||||
BaseMessage,
|
BaseMessage,
|
||||||
@@ -76,6 +78,7 @@ from artemis.controllers.unified_controller import UnifiedMobileController
|
|||||||
from artemis.data_engine.trace import trace
|
from artemis.data_engine.trace import trace
|
||||||
from artemis.graph.perception import _check_injected_instruction_file
|
from artemis.graph.perception import _check_injected_instruction_file
|
||||||
from artemis.graph.state import State
|
from artemis.graph.state import State
|
||||||
|
from artemis.llm.router import ModelEndpoint
|
||||||
from artemis.llm.structured import ParseFailure, parse_structured
|
from artemis.llm.structured import ParseFailure, parse_structured
|
||||||
from artemis.mcp.action_executor import McpActionExecutor
|
from artemis.mcp.action_executor import McpActionExecutor
|
||||||
from artemis.mcp.observation import observe
|
from artemis.mcp.observation import observe
|
||||||
@@ -165,6 +168,7 @@ class FlashRunner:
|
|||||||
ctx,
|
ctx,
|
||||||
model_name=self.step_summarizer_cfg.model,
|
model_name=self.step_summarizer_cfg.model,
|
||||||
retry_limit=self.memory_runtime_cfg.retry_limit,
|
retry_limit=self.memory_runtime_cfg.retry_limit,
|
||||||
|
timeout_seconds=self.step_summarizer_cfg.timeout_seconds,
|
||||||
max_concurrency=self.memory_runtime_cfg.max_concurrency,
|
max_concurrency=self.memory_runtime_cfg.max_concurrency,
|
||||||
flush_timeout_s=self.memory_runtime_cfg.flush_timeout_s,
|
flush_timeout_s=self.memory_runtime_cfg.flush_timeout_s,
|
||||||
)
|
)
|
||||||
@@ -285,7 +289,9 @@ class FlashRunner:
|
|||||||
logger.warning(f"Failed to get operator LLM from config, using default: {e}")
|
logger.warning(f"Failed to get operator LLM from config, using default: {e}")
|
||||||
|
|
||||||
return RobustChatModelWrapper(
|
return RobustChatModelWrapper(
|
||||||
get_openai_llm(model_name=settings.OPENAI_MODEL), self.ctx
|
get_openai_llm(model_name=settings.OPENAI_MODEL),
|
||||||
|
self.ctx,
|
||||||
|
endpoint=ModelEndpoint(model_name=settings.OPENAI_MODEL),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _render_system_prompt(self, tools_declaration: list) -> str:
|
def _render_system_prompt(self, tools_declaration: list) -> str:
|
||||||
@@ -836,6 +842,8 @@ class FlashRunner:
|
|||||||
pre_img_bytes=pre_screenshot_bytes,
|
pre_img_bytes=pre_screenshot_bytes,
|
||||||
post_img_bytes=post_img_bytes,
|
post_img_bytes=post_img_bytes,
|
||||||
exec_outcome=exec_result.text_summary,
|
exec_outcome=exec_result.text_summary,
|
||||||
|
post_ui_elements_text=exec_result.ui_elements_text,
|
||||||
|
observation_settled=(exec_result.metadata or {}).get("observation_settled"),
|
||||||
action_key=str(tc_id),
|
action_key=str(tc_id),
|
||||||
data_engine_step_id=recorded_step_id,
|
data_engine_step_id=recorded_step_id,
|
||||||
# The model's own reasoning for this turn steers the lens's
|
# The model's own reasoning for this turn steers the lens's
|
||||||
@@ -935,6 +943,9 @@ class FlashRunner:
|
|||||||
action_names = self.executor.action_tool_names
|
action_names = self.executor.action_tool_names
|
||||||
index_elements = self._turn_index_snapshot(state)
|
index_elements = self._turn_index_snapshot(state)
|
||||||
for tc in tool_calls:
|
for tc in tool_calls:
|
||||||
|
control = getattr(self.ctx, "task_control", None)
|
||||||
|
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
|
||||||
|
return None, pre_screenshot_bytes, xml_list, action_sequence
|
||||||
name = tc["name"].split(":")[-1] if ":" in tc["name"] else tc["name"]
|
name = tc["name"].split(":")[-1] if ":" in tc["name"] else tc["name"]
|
||||||
args = tc.get("args") or {}
|
args = tc.get("args") or {}
|
||||||
tc_id = tc.get("id") or str(uuid.uuid4())
|
tc_id = tc.get("id") or str(uuid.uuid4())
|
||||||
@@ -1058,8 +1069,29 @@ class FlashRunner:
|
|||||||
current_xml_list = xml_list
|
current_xml_list = xml_list
|
||||||
previous_turn: _TurnRecord | None = None
|
previous_turn: _TurnRecord | None = None
|
||||||
pending_notices: list[str] = []
|
pending_notices: list[str] = []
|
||||||
|
no_tool_attempts = 0
|
||||||
|
no_tool_limit = settings.ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS
|
||||||
|
|
||||||
while limit is None or turns < limit:
|
while limit is None or turns < limit:
|
||||||
|
control = getattr(self.ctx, "task_control", None)
|
||||||
|
if isinstance(control, TaskControl):
|
||||||
|
resumed = await control.checkpoint(
|
||||||
|
on_pause=getattr(self.ctx, "on_task_pause", None),
|
||||||
|
on_resume=getattr(self.ctx, "on_task_resume", None),
|
||||||
|
)
|
||||||
|
if resumed:
|
||||||
|
(
|
||||||
|
shot_path,
|
||||||
|
current_pre_screenshot_bytes,
|
||||||
|
current_xml_list,
|
||||||
|
) = await capture_screenshot_and_parse_ui(
|
||||||
|
self.ctx, state, self.controller, skip_settling=False
|
||||||
|
)
|
||||||
|
state.latest_screenshot = shot_path
|
||||||
|
pending_notices.append(
|
||||||
|
"设备在暂停期间可能被其他任务操作过。已重新观察画面;"
|
||||||
|
"请根据当前状态和新指导重新判断操作,勿沿用旧坐标。"
|
||||||
|
)
|
||||||
turns += 1
|
turns += 1
|
||||||
logger.info(f"--- Artemis Flash Turn {turns}{f'/{limit}' if limit else ''} ---")
|
logger.info(f"--- Artemis Flash Turn {turns}{f'/{limit}' if limit else ''} ---")
|
||||||
|
|
||||||
@@ -1097,7 +1129,36 @@ class FlashRunner:
|
|||||||
turn_base = len(messages) - 1
|
turn_base = len(messages) - 1
|
||||||
current_tools = report_only_tools if is_final else tools_declaration
|
current_tools = report_only_tools if is_final else tools_declaration
|
||||||
|
|
||||||
response = await self._invoke_model(llm, current_tools, messages)
|
if isinstance(control, TaskControl):
|
||||||
|
interrupted, response = await control.run_interruptibly(
|
||||||
|
self._invoke_model(llm, current_tools, messages),
|
||||||
|
on_pause=getattr(self.ctx, "on_task_pause", None),
|
||||||
|
on_resume=getattr(self.ctx, "on_task_resume", None),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
interrupted, response = (
|
||||||
|
False,
|
||||||
|
await self._invoke_model(llm, current_tools, messages),
|
||||||
|
)
|
||||||
|
|
||||||
|
if interrupted or (
|
||||||
|
isinstance(control, TaskControl) and has_pending_instruction(control.directory)
|
||||||
|
):
|
||||||
|
# Do not dispatch a stale action or final answer from a prompt
|
||||||
|
# assembled before the instruction arrived.
|
||||||
|
(
|
||||||
|
shot_path,
|
||||||
|
current_pre_screenshot_bytes,
|
||||||
|
current_xml_list,
|
||||||
|
) = await capture_screenshot_and_parse_ui(
|
||||||
|
self.ctx, state, self.controller, skip_settling=False
|
||||||
|
)
|
||||||
|
state.latest_screenshot = shot_path
|
||||||
|
pending_notices.append(
|
||||||
|
"新指导已打断旧模型步骤。请重新观察画面并按最新指导决定下一步。"
|
||||||
|
)
|
||||||
|
turns -= 1
|
||||||
|
continue
|
||||||
|
|
||||||
if response is None:
|
if response is None:
|
||||||
break
|
break
|
||||||
@@ -1124,6 +1185,18 @@ class FlashRunner:
|
|||||||
self._record_llm_trace(step_token_usage, raw_text)
|
self._record_llm_trace(step_token_usage, raw_text)
|
||||||
|
|
||||||
tool_calls = self._resolve_tool_calls(response, raw_text)
|
tool_calls = self._resolve_tool_calls(response, raw_text)
|
||||||
|
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
|
||||||
|
(
|
||||||
|
shot_path,
|
||||||
|
current_pre_screenshot_bytes,
|
||||||
|
current_xml_list,
|
||||||
|
) = await capture_screenshot_and_parse_ui(
|
||||||
|
self.ctx, state, self.controller, skip_settling=False
|
||||||
|
)
|
||||||
|
state.latest_screenshot = shot_path
|
||||||
|
pending_notices.append("新指导已收到;忽略旧模型动作,重新观察画面。")
|
||||||
|
turns -= 1
|
||||||
|
continue
|
||||||
|
|
||||||
if not tool_calls:
|
if not tool_calls:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1133,11 +1206,24 @@ class FlashRunner:
|
|||||||
if is_final:
|
if is_final:
|
||||||
final_report = {"status": "failed", "explanation": raw_text}
|
final_report = {"status": "failed", "explanation": raw_text}
|
||||||
break
|
break
|
||||||
|
no_tool_attempts += 1
|
||||||
|
if no_tool_attempts >= no_tool_limit:
|
||||||
|
final_report = {
|
||||||
|
"status": "failed",
|
||||||
|
"explanation": (
|
||||||
|
f"模型连续 {no_tool_attempts} 次未调用工具,"
|
||||||
|
"已停止任务以避免空转和重复计费;未执行新的设备动作。"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
break
|
||||||
pending_notices.append(_NO_TOOL_CALL_NOTICE)
|
pending_notices.append(_NO_TOOL_CALL_NOTICE)
|
||||||
ledger.stage_turn(messages[turn_base:])
|
# Keep the observation and any injected user instruction, but
|
||||||
|
# do not feed the model its own unproductive prose on retry.
|
||||||
|
ledger.stage_turn(messages[turn_base:-1])
|
||||||
previous_turn = _TurnRecord()
|
previous_turn = _TurnRecord()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
no_tool_attempts = 0
|
||||||
# Process tool calls
|
# Process tool calls
|
||||||
turn = _TurnRecord()
|
turn = _TurnRecord()
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from jinja2 import Template
|
|||||||
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
||||||
|
|
||||||
from artemis.context import ArtemisContext
|
from artemis.context import ArtemisContext
|
||||||
|
from artemis.llm.messages import adapt_responses_messages
|
||||||
from artemis.memory.step_memory import JobKey, StepMemoryService
|
from artemis.memory.step_memory import JobKey, StepMemoryService
|
||||||
from artemis.services.llm import RobustChatModelWrapper, get_openai_llm, get_llm
|
from artemis.services.llm import RobustChatModelWrapper, get_openai_llm, get_llm
|
||||||
from artemis.services.token_meter import record_llm_usage
|
from artemis.services.token_meter import record_llm_usage
|
||||||
@@ -151,6 +152,7 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
ctx: ArtemisContext,
|
ctx: ArtemisContext,
|
||||||
model_name: str | None = None,
|
model_name: str | None = None,
|
||||||
retry_limit: int = 3,
|
retry_limit: int = 3,
|
||||||
|
timeout_seconds: float = 120.0,
|
||||||
*,
|
*,
|
||||||
max_concurrency: int = 1,
|
max_concurrency: int = 1,
|
||||||
flush_timeout_s: float = 30.0,
|
flush_timeout_s: float = 30.0,
|
||||||
@@ -159,8 +161,11 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
ctx,
|
ctx,
|
||||||
max_concurrency=max_concurrency,
|
max_concurrency=max_concurrency,
|
||||||
retry_limit=retry_limit,
|
retry_limit=retry_limit,
|
||||||
flush_timeout_s=flush_timeout_s,
|
# A pending request must be allowed to reach its own timeout when
|
||||||
|
# the task finishes; otherwise the shorter flush cancels it first.
|
||||||
|
flush_timeout_s=max(flush_timeout_s, timeout_seconds + 1.0),
|
||||||
)
|
)
|
||||||
|
self._timeout_seconds = timeout_seconds
|
||||||
|
|
||||||
# Initialize lightweight VLM: prioritize explicit model_name
|
# Initialize lightweight VLM: prioritize explicit model_name
|
||||||
target_model = model_name or settings.OPENAI_MODEL
|
target_model = model_name or settings.OPENAI_MODEL
|
||||||
@@ -224,6 +229,8 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
action_key: str | None = None,
|
action_key: str | None = None,
|
||||||
data_engine_step_id: UUID | str | None = None,
|
data_engine_step_id: UUID | str | None = None,
|
||||||
focus: dict[str, str] | None = None,
|
focus: dict[str, str] | None = None,
|
||||||
|
post_ui_elements_text: str | None = None,
|
||||||
|
observation_settled: bool | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Dispatches an asynchronous summarization task without blocking the caller.
|
"""Dispatches an asynchronous summarization task without blocking the caller.
|
||||||
|
|
||||||
@@ -254,6 +261,8 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
"exec_outcome": exec_outcome,
|
"exec_outcome": exec_outcome,
|
||||||
"data_engine_step_id": data_engine_step_id,
|
"data_engine_step_id": data_engine_step_id,
|
||||||
"focus": focus,
|
"focus": focus,
|
||||||
|
"post_ui_elements_text": post_ui_elements_text,
|
||||||
|
"observation_settled": observation_settled,
|
||||||
}
|
}
|
||||||
self.submit(key, payload, aliases=aliases)
|
self.submit(key, payload, aliases=aliases)
|
||||||
|
|
||||||
@@ -406,6 +415,10 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
if verbatim and not self._phrase_carries(phrase, verbatim):
|
if verbatim and not self._phrase_carries(phrase, verbatim):
|
||||||
lead_lines.append(f"Action arguments (verbatim): {action_name}({verbatim})")
|
lead_lines.append(f"Action arguments (verbatim): {action_name}({verbatim})")
|
||||||
lead_lines.append(f"Controller Outcome: {exec_outcome}")
|
lead_lines.append(f"Controller Outcome: {exec_outcome}")
|
||||||
|
if input_data.get("observation_settled") is False:
|
||||||
|
lead_lines.append(
|
||||||
|
"Observation status: screen still changing; page state is not confirmed."
|
||||||
|
)
|
||||||
focus = input_data.get("focus")
|
focus = input_data.get("focus")
|
||||||
if isinstance(focus, dict) and focus:
|
if isinstance(focus, dict) and focus:
|
||||||
lead_lines.extend(["", render_focus_block(focus, dual=dual)])
|
lead_lines.extend(["", render_focus_block(focus, dual=dual)])
|
||||||
@@ -454,12 +467,24 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
post_ui = input_data.get("post_ui_elements_text")
|
||||||
|
if post_bytes and isinstance(post_ui, str) and post_ui.strip():
|
||||||
|
content_blocks.append(
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "--- [3] AFTER ACTION UI ELEMENTS ---\n" + post_ui[:8000],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
messages: list[BaseMessage] = [
|
messages: list[BaseMessage] = [
|
||||||
SystemMessage(content=rendered_prompt),
|
SystemMessage(content=rendered_prompt),
|
||||||
HumanMessage(content=content_blocks),
|
HumanMessage(content=content_blocks),
|
||||||
]
|
]
|
||||||
|
|
||||||
response = await asyncio.wait_for(self._llm.ainvoke(messages), timeout=25.0)
|
response = await asyncio.wait_for(
|
||||||
|
self._llm.ainvoke(adapt_responses_messages(messages, self._model_name)),
|
||||||
|
timeout=self._timeout_seconds,
|
||||||
|
)
|
||||||
self._meter_lens_call(response)
|
self._meter_lens_call(response)
|
||||||
summary_raw = response.content if isinstance(response.content, str) else ""
|
summary_raw = response.content if isinstance(response.content, str) else ""
|
||||||
if isinstance(response.content, list):
|
if isinstance(response.content, list):
|
||||||
@@ -505,8 +530,14 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
logger.debug(f"DataEngine step summary update skipped: {de_err}")
|
logger.debug(f"DataEngine step summary update skipped: {de_err}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
f"VisualStepSummarizer: Step {step_number} summary request timed out "
|
||||||
|
f"(limit {self._timeout_seconds:.1f}s)"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"VisualStepSummarizer: Error generating summary for step {step_number}: {e}"
|
f"VisualStepSummarizer: Error generating summary for step {step_number}: "
|
||||||
|
f"{type(e).__name__}: {e}"
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
@@ -18,7 +18,7 @@ from pathlib import Path
|
|||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
|
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
|
||||||
from langchain_core.tools import BaseTool, StructuredTool
|
from langchain_core.tools import BaseTool, StructuredTool
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
|
|
||||||
|
|
||||||
class _CyFunctionDetectorMeta(type):
|
class _CyFunctionDetectorMeta(type):
|
||||||
@@ -89,6 +89,20 @@ class LogAnalyzerNode:
|
|||||||
original_coroutine = tool.coroutine
|
original_coroutine = tool.coroutine
|
||||||
|
|
||||||
async def cached_coroutine(**kwargs):
|
async def cached_coroutine(**kwargs):
|
||||||
|
# The injection runner calls the coroutine directly, bypassing
|
||||||
|
# StructuredTool's normal Pydantic argument coercion. Normalize
|
||||||
|
# model-supplied numeric strings before caching or searching logs.
|
||||||
|
if tool.name in ("read_logs", "search_logs") and tool.args_schema:
|
||||||
|
try:
|
||||||
|
validated = tool.args_schema.model_validate(kwargs)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return ToolFailure(f"Invalid {tool.name} arguments: {exc}")
|
||||||
|
kwargs.update(validated.model_dump(exclude_unset=True))
|
||||||
|
if kwargs.get("lines") is None:
|
||||||
|
kwargs["lines"] = 200 if tool.name == "read_logs" else 10000
|
||||||
|
if tool.name == "search_logs" and kwargs.get("context_lines") is None:
|
||||||
|
kwargs["context_lines"] = 0
|
||||||
|
|
||||||
if tool.name == "read_logs":
|
if tool.name == "read_logs":
|
||||||
# Intercept read_logs to cache raw logs
|
# Intercept read_logs to cache raw logs
|
||||||
lines = kwargs.get("lines", 200)
|
lines = kwargs.get("lines", 200)
|
||||||
|
|||||||
@@ -13,14 +13,17 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from collections.abc import Awaitable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from langchain_core.messages import SystemMessage, ToolMessage
|
from langchain_core.messages import SystemMessage, ToolMessage
|
||||||
from langchain_core.tools import BaseTool
|
from langchain_core.tools import BaseTool
|
||||||
|
|
||||||
from artemis.core.tool_failure import is_tool_failure
|
from artemis.agents.validator.tool_declarations import normalize_coordinate_target
|
||||||
|
from artemis.config.settings import settings
|
||||||
from artemis.context import ArtemisContext
|
from artemis.context import ArtemisContext
|
||||||
|
from artemis.core.tool_failure import is_tool_failure
|
||||||
from artemis.data_engine.trace import (
|
from artemis.data_engine.trace import (
|
||||||
TraceSpan,
|
TraceSpan,
|
||||||
trace,
|
trace,
|
||||||
@@ -30,6 +33,7 @@ from artemis.graph.state import State
|
|||||||
from artemis.graph.visibility import strict_state
|
from artemis.graph.visibility import strict_state
|
||||||
from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool
|
from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool
|
||||||
from artemis.llm.usage import usage_from_message
|
from artemis.llm.usage import usage_from_message
|
||||||
|
from artemis.runtime.task_control import TaskControl, has_pending_instruction
|
||||||
from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message
|
from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message
|
||||||
from artemis.tools.command_tool import (
|
from artemis.tools.command_tool import (
|
||||||
analyze_task_output_wrapper,
|
analyze_task_output_wrapper,
|
||||||
@@ -57,6 +61,11 @@ from artemis.utils.visualization import format_minimal_list_with_elements
|
|||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OperatorNoProgressError(RuntimeError):
|
||||||
|
"""The model repeatedly returned no usable tool call; stop without device actions."""
|
||||||
|
|
||||||
|
|
||||||
DEFERRING_TOOLS = {
|
DEFERRING_TOOLS = {
|
||||||
"ask_diagnoser",
|
"ask_diagnoser",
|
||||||
"video_analyzer",
|
"video_analyzer",
|
||||||
@@ -71,6 +80,8 @@ DEFERRING_TOOLS = {
|
|||||||
"ask_explorer",
|
"ask_explorer",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
INTERRUPTIBLE_HELPERS = {"ask_diagnoser", "ask_explorer", "video_analyzer", "analyze_task_output"}
|
||||||
|
|
||||||
# Bare key names press_key accepts (case-insensitive, optional KEYCODE_ prefix).
|
# Bare key names press_key accepts (case-insensitive, optional KEYCODE_ prefix).
|
||||||
SUPPORTED_PRESS_KEYS = ("ENTER", "BACK", "HOME", "APP_SWITCH")
|
SUPPORTED_PRESS_KEYS = ("ENTER", "BACK", "HOME", "APP_SWITCH")
|
||||||
|
|
||||||
@@ -471,6 +482,16 @@ class OperatorNode:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to stage transcript turn: {e}")
|
logger.error(f"Failed to stage transcript turn: {e}")
|
||||||
|
|
||||||
|
async def _await_or_pause[T](self, operation: Awaitable[T]) -> tuple[bool, T | None]:
|
||||||
|
control = getattr(self.ctx, "task_control", None)
|
||||||
|
if isinstance(control, TaskControl):
|
||||||
|
return await control.run_interruptibly(
|
||||||
|
operation,
|
||||||
|
on_pause=getattr(self.ctx, "on_task_pause", None),
|
||||||
|
on_resume=getattr(self.ctx, "on_task_resume", None),
|
||||||
|
)
|
||||||
|
return False, await operation
|
||||||
|
|
||||||
async def _invoke_llm_loop(
|
async def _invoke_llm_loop(
|
||||||
self,
|
self,
|
||||||
base_llm,
|
base_llm,
|
||||||
@@ -485,8 +506,17 @@ class OperatorNode:
|
|||||||
native_thoughts = []
|
native_thoughts = []
|
||||||
tool_limit_exceeded = False
|
tool_limit_exceeded = False
|
||||||
plan_gate_bounced = False
|
plan_gate_bounced = False
|
||||||
|
no_progress_attempts = 0
|
||||||
|
no_progress_limit = settings.ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS
|
||||||
|
|
||||||
for iteration in range(max_iterations):
|
for iteration in range(max_iterations):
|
||||||
|
control = getattr(self.ctx, "task_control", None)
|
||||||
|
if isinstance(control, TaskControl) and await control.checkpoint(
|
||||||
|
on_pause=getattr(self.ctx, "on_task_pause", None),
|
||||||
|
on_resume=getattr(self.ctx, "on_task_resume", None),
|
||||||
|
):
|
||||||
|
# The device may have changed while another task held its lease.
|
||||||
|
return None, None, None, False
|
||||||
if iteration == max_iterations - 1:
|
if iteration == max_iterations - 1:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Operator has reached the maximum number of tool calls. Adding reminder."
|
"Operator has reached the maximum number of tool calls. Adding reminder."
|
||||||
@@ -503,7 +533,15 @@ class OperatorNode:
|
|||||||
)
|
)
|
||||||
|
|
||||||
bound_llm = base_llm.bind_tools(tools=traced_tools)
|
bound_llm = base_llm.bind_tools(tools=traced_tools)
|
||||||
response = await invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages))
|
interrupted, response = await self._await_or_pause(
|
||||||
|
invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages))
|
||||||
|
)
|
||||||
|
if interrupted:
|
||||||
|
return None, None, None, False
|
||||||
|
assert response is not None
|
||||||
|
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
|
||||||
|
# The model answered a prompt that predates the user's new direction.
|
||||||
|
return None, None, None, False
|
||||||
|
|
||||||
usage = usage_from_message(response)
|
usage = usage_from_message(response)
|
||||||
# The ledger's compaction thresholds read the operator's own
|
# The ledger's compaction thresholds read the operator's own
|
||||||
@@ -568,8 +606,27 @@ class OperatorNode:
|
|||||||
logger.error(f"Failed to parse function_call fallback: {e}")
|
logger.error(f"Failed to parse function_call fallback: {e}")
|
||||||
|
|
||||||
if not response.tool_calls:
|
if not response.tool_calls:
|
||||||
logger.warning("LLM stopped without calling any tool. Encouraging action.")
|
no_progress_attempts += 1
|
||||||
break
|
if no_progress_attempts >= no_progress_limit:
|
||||||
|
raise OperatorNoProgressError(
|
||||||
|
f"模型连续 {no_progress_attempts} 次未提供可执行的工具调用,"
|
||||||
|
"已停止任务以避免空转;未执行新的设备动作。"
|
||||||
|
)
|
||||||
|
logger.warning("LLM returned no tool call; requesting a concrete tool call.")
|
||||||
|
current_messages.append(response)
|
||||||
|
current_messages.append(
|
||||||
|
SystemMessage(
|
||||||
|
content=(
|
||||||
|
"No tool call was returned, so no device action occurred. "
|
||||||
|
"Use the available tool interface now, not a prose promise or JSON "
|
||||||
|
"inside text. Use an index from the current element list or "
|
||||||
|
"normalized coordinates with target_description. If blocked, "
|
||||||
|
"use an observation/diagnostic tool; do not invent a click or "
|
||||||
|
"claim success. This recovery has a limited retry budget."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
current_messages.append(response)
|
current_messages.append(response)
|
||||||
|
|
||||||
@@ -627,12 +684,42 @@ class OperatorNode:
|
|||||||
"\n".join(native_thoughts) if native_thoughts else None
|
"\n".join(native_thoughts) if native_thoughts else None
|
||||||
)
|
)
|
||||||
|
|
||||||
result_obj = await invoke_tool_with_injection(
|
tool_call = invoke_tool_with_injection(
|
||||||
tool=tool_to_run,
|
tool=tool_to_run,
|
||||||
args=args,
|
args=args,
|
||||||
tool_call_id=tc["id"],
|
tool_call_id=tc["id"],
|
||||||
state=state,
|
state=state,
|
||||||
)
|
)
|
||||||
|
if normalize_name(tool_name) in INTERRUPTIBLE_HELPERS:
|
||||||
|
interrupted, result_obj = await self._await_or_pause(tool_call)
|
||||||
|
if interrupted:
|
||||||
|
span.result = "任务被暂停或指导打断;重新观察屏幕后继续"
|
||||||
|
results.append((span.result, "error"))
|
||||||
|
for remaining in other_calls[len(results) :]:
|
||||||
|
results.append(("任务已打断,未执行该工具", "error"))
|
||||||
|
for completed, (content, status) in zip(
|
||||||
|
other_calls, results
|
||||||
|
):
|
||||||
|
current_messages.extend(
|
||||||
|
tool_result_messages(
|
||||||
|
completed["id"],
|
||||||
|
content,
|
||||||
|
name=normalize_name(completed["name"]),
|
||||||
|
status=status,
|
||||||
|
llm=base_llm,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for action in action_calls:
|
||||||
|
current_messages.append(
|
||||||
|
ToolMessage(
|
||||||
|
tool_call_id=action["id"],
|
||||||
|
content="任务已打断,未执行屏幕操作",
|
||||||
|
status="error",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return None, None, None, False
|
||||||
|
else:
|
||||||
|
result_obj = await tool_call
|
||||||
|
|
||||||
content = get_tool_result_content(result_obj)
|
content = get_tool_result_content(result_obj)
|
||||||
# Failure is structural (a ToolMessage status or a
|
# Failure is structural (a ToolMessage status or a
|
||||||
@@ -743,6 +830,9 @@ class OperatorNode:
|
|||||||
|
|
||||||
action_calls = []
|
action_calls = []
|
||||||
|
|
||||||
|
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
|
||||||
|
return None, None, None, False
|
||||||
|
|
||||||
if action_calls:
|
if action_calls:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Operator requested {len(action_calls)} action(s)."
|
f"Operator requested {len(action_calls)} action(s)."
|
||||||
@@ -840,9 +930,20 @@ class OperatorNode:
|
|||||||
break
|
break
|
||||||
|
|
||||||
if validation_errors:
|
if validation_errors:
|
||||||
|
no_progress_attempts += 1
|
||||||
|
if no_progress_attempts >= no_progress_limit:
|
||||||
|
raise OperatorNoProgressError(
|
||||||
|
f"模型连续 {no_progress_attempts} 次未提供可执行的工具调用,"
|
||||||
|
"工具参数校验仍失败;已停止任务,未执行被拒绝的设备动作。"
|
||||||
|
)
|
||||||
logger.warning("Some action calls failed validation. Feeding back to LLM.")
|
logger.warning("Some action calls failed validation. Feeding back to LLM.")
|
||||||
continue
|
continue
|
||||||
|
no_progress_attempts = 0
|
||||||
else:
|
else:
|
||||||
|
if no_progress_attempts:
|
||||||
|
raise OperatorNoProgressError(
|
||||||
|
"模型在本轮工具调用预算内未恢复有效调用,已停止任务以避免空转。"
|
||||||
|
)
|
||||||
tool_limit_exceeded = True
|
tool_limit_exceeded = True
|
||||||
|
|
||||||
raw_thinking = "\n".join(raw_thoughts) if raw_thoughts else None
|
raw_thinking = "\n".join(raw_thoughts) if raw_thoughts else None
|
||||||
@@ -1122,6 +1223,9 @@ class OperatorNode:
|
|||||||
straight from the indexed list); a coordinate pair carries only the
|
straight from the indexed list); a coordinate pair carries only the
|
||||||
model's own ``target_description``. The two never mix.
|
model's own ``target_description``. The two never mix.
|
||||||
"""
|
"""
|
||||||
|
target = normalize_coordinate_target(target)
|
||||||
|
if isinstance(target, bool):
|
||||||
|
return None, "Error: Boolean values are not valid target indices."
|
||||||
if isinstance(target, (int, float)):
|
if isinstance(target, (int, float)):
|
||||||
try:
|
try:
|
||||||
target_int = int(target)
|
target_int = int(target)
|
||||||
|
|||||||
@@ -370,6 +370,11 @@ class StepSummarizerConfig(BaseModel):
|
|||||||
default_factory=lambda: settings.OPENAI_MODEL,
|
default_factory=lambda: settings.OPENAI_MODEL,
|
||||||
description="Lightweight model used for background step state summarization.",
|
description="Lightweight model used for background step state summarization.",
|
||||||
)
|
)
|
||||||
|
timeout_seconds: float = Field(
|
||||||
|
default=120.0,
|
||||||
|
gt=0,
|
||||||
|
description="Timeout for one optional background visual-summary model request.",
|
||||||
|
)
|
||||||
prune_history_xml: bool = Field(
|
prune_history_xml: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
description="Whether to prune outdated heavy UI XML trees from historical turns.",
|
description="Whether to prune outdated heavy UI XML trees from historical turns.",
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class Settings(BaseSettings):
|
|||||||
API_KEY: SecretStr | None = None
|
API_KEY: SecretStr | None = None
|
||||||
|
|
||||||
# Custom Provider Endpoints
|
# Custom Provider Endpoints
|
||||||
OPENAI_BASE_URL: str | None = None
|
OPENAI_BASE_URL: str | None = "https://api-n-cd.playones.com/v1"
|
||||||
OPENAI_MODEL: str = Field(default="mimo-v2.6-flash", min_length=1)
|
OPENAI_MODEL: str = Field(default="mimo-v2.6-flash", min_length=1)
|
||||||
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
|
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
|
||||||
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
|
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
|
||||||
@@ -109,8 +109,13 @@ class Settings(BaseSettings):
|
|||||||
ARTEMIS_CHECKPOINT_TIMEOUT_SECONDS: float = Field(default=180.0, gt=0)
|
ARTEMIS_CHECKPOINT_TIMEOUT_SECONDS: float = Field(default=180.0, gt=0)
|
||||||
ARTEMIS_SETTLEMENT_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0)
|
ARTEMIS_SETTLEMENT_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0)
|
||||||
ARTEMIS_VIDEO_ENABLED: bool = False
|
ARTEMIS_VIDEO_ENABLED: bool = False
|
||||||
|
ARTEMIS_RECORDING_ENABLED: bool = False
|
||||||
ARTEMIS_OUTPUTTER_ENABLED: bool = True
|
ARTEMIS_OUTPUTTER_ENABLED: bool = True
|
||||||
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
|
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
|
||||||
|
# Consecutive Pro responses without a valid tool call before failing the task.
|
||||||
|
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
|
||||||
|
# Consecutive Flash responses without any tool call before failing the task.
|
||||||
|
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
|
||||||
|
|
||||||
# Android ADB Connectivity
|
# Android ADB Connectivity
|
||||||
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
|
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ except ImportError:
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
|
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
|
||||||
from artemis.utils.video import detect_video_tools_enabled
|
from artemis.utils.video import detect_video_tools_enabled
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
from artemis.config import ExplorerConfig, LLMConfig, OutputterConfig
|
from artemis.config import ExplorerConfig, LLMConfig, OutputterConfig
|
||||||
@@ -108,6 +109,7 @@ class ExecutionSetup(BaseModel):
|
|||||||
enable_remote_tracing: bool = False
|
enable_remote_tracing: bool = False
|
||||||
app_lock_status: AppLaunchResult | None = None
|
app_lock_status: AppLaunchResult | None = None
|
||||||
video_recording_tools_enabled: bool = Field(default_factory=detect_video_tools_enabled)
|
video_recording_tools_enabled: bool = Field(default_factory=detect_video_tools_enabled)
|
||||||
|
record_video_enabled: bool = Field(default_factory=lambda: settings.ARTEMIS_RECORDING_ENABLED)
|
||||||
disable_checker: bool = False
|
disable_checker: bool = False
|
||||||
"""Legacy master switch (compat alias): ``True`` disables BOTH the midway
|
"""Legacy master switch (compat alias): ``True`` disables BOTH the midway
|
||||||
checkpoints and the final check, regardless of the individual gates below.
|
checkpoints and the final check, regardless of the individual gates below.
|
||||||
|
|||||||
@@ -372,6 +372,20 @@ class StorageManager:
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
def discard_session_recordings(self, session_id: str) -> None:
|
||||||
|
"""Remove references to temporary analysis-only captures after the run."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE video_recordings SET local_video_path = NULL, status = 'unavailable' "
|
||||||
|
"WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sessions SET video_filepath = NULL WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
def update_session_video_path(self, session_id: UUID, video_path: str):
|
def update_session_video_path(self, session_id: UUID, video_path: str):
|
||||||
"""Update video filepath across session and video_recordings tables."""
|
"""Update video filepath across session and video_recordings tables."""
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
@@ -1144,11 +1158,21 @@ class StorageManager:
|
|||||||
"""Delete all data associated with a session, including files on disk."""
|
"""Delete all data associated with a session, including files on disk."""
|
||||||
session_id_str = str(session_id)
|
session_id_str = str(session_id)
|
||||||
|
|
||||||
# 1. Get video paths before deleting from DB
|
# 1. Get files referenced by this session before deleting from DB.
|
||||||
video_paths = []
|
video_paths = []
|
||||||
video_ids: list[str] = []
|
video_ids: list[str] = []
|
||||||
|
image_names: set[str] = set()
|
||||||
try:
|
try:
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
|
image_names = {
|
||||||
|
str(name)
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT pre_image_name, post_image_name FROM steps WHERE session_id = ?",
|
||||||
|
(session_id_str,),
|
||||||
|
)
|
||||||
|
for name in row
|
||||||
|
if name
|
||||||
|
}
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"SELECT video_id, local_video_path FROM video_recordings WHERE session_id = ?",
|
"SELECT video_id, local_video_path FROM video_recordings WHERE session_id = ?",
|
||||||
(session_id_str,),
|
(session_id_str,),
|
||||||
@@ -1159,9 +1183,11 @@ class StorageManager:
|
|||||||
if row["local_video_path"]:
|
if row["local_video_path"]:
|
||||||
video_paths.append(Path(row["local_video_path"]))
|
video_paths.append(Path(row["local_video_path"]))
|
||||||
except sqlite3.OperationalError as e:
|
except sqlite3.OperationalError as e:
|
||||||
logger.warning(f"Could not query video_recordings: {e}")
|
logger.warning(f"Could not query session media: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
# 2. Delete from DB tables
|
# 2. Delete from DB tables
|
||||||
|
orphaned_images: list[str] = []
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
conn.execute("PRAGMA foreign_keys = OFF")
|
conn.execute("PRAGMA foreign_keys = OFF")
|
||||||
board_keys = [f"session:{session_id_str}"] + [
|
board_keys = [f"session:{session_id_str}"] + [
|
||||||
@@ -1193,13 +1219,26 @@ class StorageManager:
|
|||||||
(session_id_str,),
|
(session_id_str,),
|
||||||
)
|
)
|
||||||
except sqlite3.OperationalError as e:
|
except sqlite3.OperationalError as e:
|
||||||
logger.warning(f"Failed to delete from table {table}: {e}")
|
logger.error(f"Failed to delete from table {table}: {e}")
|
||||||
conn.commit()
|
raise
|
||||||
conn.execute("VACUUM")
|
for name in image_names:
|
||||||
|
if not conn.execute(
|
||||||
|
"SELECT 1 FROM steps WHERE pre_image_name=? OR post_image_name=? LIMIT 1",
|
||||||
|
(name, name),
|
||||||
|
).fetchone():
|
||||||
|
conn.execute("DELETE FROM images WHERE image_name=?", (name,))
|
||||||
|
orphaned_images.append(name)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
logger.info(f"Database records for session {session_id} cleared.")
|
logger.info(f"Database records for session {session_id} cleared.")
|
||||||
|
|
||||||
|
images_root = (self.base_trace_dir / "images").resolve()
|
||||||
|
for name in orphaned_images:
|
||||||
|
if len(name) == 64 and all(c in "0123456789abcdef" for c in name):
|
||||||
|
image_path = (images_root / f"{name}.jpg").resolve()
|
||||||
|
if image_path.is_relative_to(images_root):
|
||||||
|
image_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
# 3. Delete session directory (notes, etc.)
|
# 3. Delete session directory (notes, etc.)
|
||||||
session_dir = self.base_trace_dir / session_id_str
|
session_dir = self.base_trace_dir / session_id_str
|
||||||
if session_dir.exists() and session_dir.is_dir():
|
if session_dir.exists() and session_dir.is_dir():
|
||||||
@@ -1208,38 +1247,15 @@ class StorageManager:
|
|||||||
logger.info(f"Deleted session directory: {session_dir}")
|
logger.info(f"Deleted session directory: {session_dir}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete session directory {session_dir}: {e}")
|
logger.error(f"Failed to delete session directory {session_dir}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
# 4. Delete video files and their containing folders (which might have been renamed)
|
# 4. Never let a persisted recording path remove a sibling session.
|
||||||
|
trace_root = self.base_trace_dir.resolve()
|
||||||
for video_path in video_paths:
|
for video_path in video_paths:
|
||||||
# video_path is likely: /path/to/traces/task_name/recording.mp4
|
resolved = video_path.resolve()
|
||||||
# If it was renamed, it might be: /path/to/traces/task_name_PASS_timestamp/recording.mp4
|
if not resolved.is_relative_to(trace_root):
|
||||||
|
continue
|
||||||
task_name = video_path.parent.name
|
relative = resolved.relative_to(trace_root)
|
||||||
|
if not relative.parts or relative.parts[0] != session_id_str:
|
||||||
if task_name and task_name != "traces" and task_name != "..":
|
continue
|
||||||
# Search for directories starting with task_name in base_trace_dir
|
resolved.unlink(missing_ok=True)
|
||||||
try:
|
|
||||||
for path in self.base_trace_dir.iterdir():
|
|
||||||
if path.is_dir() and path.name.startswith(task_name):
|
|
||||||
try:
|
|
||||||
shutil.rmtree(path)
|
|
||||||
logger.info(f"Deleted trace/video directory: {path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete directory {path}: {e}")
|
|
||||||
elif path.is_file() and path.name.startswith(task_name):
|
|
||||||
try:
|
|
||||||
path.unlink()
|
|
||||||
logger.info(f"Deleted trace/video file: {path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete file {path}: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error iterating base_trace_dir for cleanup: {e}")
|
|
||||||
|
|
||||||
if video_path.exists():
|
|
||||||
try:
|
|
||||||
video_path.unlink()
|
|
||||||
logger.info(f"Deleted video file directly: {video_path}")
|
|
||||||
if video_path.parent.exists() and not any(video_path.parent.iterdir()):
|
|
||||||
video_path.parent.rmdir()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete video file directly {video_path}: {e}")
|
|
||||||
+10
-12
@@ -23,6 +23,7 @@ from artemis.controllers.unified_controller import UnifiedMobileController
|
|||||||
from artemis.data_engine.trace import trace
|
from artemis.data_engine.trace import trace
|
||||||
from artemis.graph.state import State
|
from artemis.graph.state import State
|
||||||
from artemis.graph.visibility import strict_state
|
from artemis.graph.visibility import strict_state
|
||||||
|
from artemis.runtime.task_control import TaskControl
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
from artemis.utils.ocr_api import is_ocr_configured, perform_ocr
|
from artemis.utils.ocr_api import is_ocr_configured, perform_ocr
|
||||||
from artemis.utils.ocr_xml_fusion import (
|
from artemis.utils.ocr_xml_fusion import (
|
||||||
@@ -78,18 +79,9 @@ def _check_injected_instruction_file(base_dir: str) -> dict | None:
|
|||||||
Returns the payload dict: `instruction` (str | None) plus the explicit
|
Returns the payload dict: `instruction` (str | None) plus the explicit
|
||||||
`release_loop` stop signal (bool) declared by the injecting caller.
|
`release_loop` stop signal (bool) declared by the injecting caller.
|
||||||
"""
|
"""
|
||||||
instruction_path = Path(base_dir) / "injected_instruction.json"
|
from artemis.runtime.task_control import consume_instructions
|
||||||
if instruction_path.exists():
|
|
||||||
with open(instruction_path, encoding="utf-8") as f:
|
return consume_instructions(base_dir)
|
||||||
data = json.load(f)
|
|
||||||
instruction_path.unlink()
|
|
||||||
payload = {
|
|
||||||
"instruction": data.get("instruction"),
|
|
||||||
"release_loop": bool(data.get("release_loop")),
|
|
||||||
}
|
|
||||||
logger.info(f"Read and deleted injected instruction payload: {payload}")
|
|
||||||
return payload
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _unprotect_current_check_lines(ctx: ArtemisContext) -> None:
|
def _unprotect_current_check_lines(ctx: ArtemisContext) -> None:
|
||||||
@@ -122,6 +114,12 @@ async def perception_node(state: State, ctx: ArtemisContext) -> dict:
|
|||||||
"""
|
"""
|
||||||
state = strict_state(state, "perception")
|
state = strict_state(state, "perception")
|
||||||
logger.info("Entering Perception Node...")
|
logger.info("Entering Perception Node...")
|
||||||
|
control = getattr(ctx, "task_control", None)
|
||||||
|
if isinstance(control, TaskControl):
|
||||||
|
await control.checkpoint(
|
||||||
|
on_pause=getattr(ctx, "on_task_pause", None),
|
||||||
|
on_resume=getattr(ctx, "on_task_resume", None),
|
||||||
|
)
|
||||||
|
|
||||||
if not ctx.data_engine.current_step_id:
|
if not ctx.data_engine.current_step_id:
|
||||||
ctx.data_engine.allocate_step_id()
|
ctx.data_engine.allocate_step_id()
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Adapt LangChain messages for Responses-compatible model families."""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
||||||
|
|
||||||
|
|
||||||
|
_USER_ROLE_MODELS = {"mimo-v2.6-pro", "mimo-v2.6-flash"}
|
||||||
|
|
||||||
|
|
||||||
|
def adapt_responses_messages(
|
||||||
|
messages: Sequence[BaseMessage], model_name: str | None
|
||||||
|
) -> Sequence[BaseMessage]:
|
||||||
|
"""Send selected models user-role instructions without changing stored history.
|
||||||
|
|
||||||
|
Full Artemis requests to GPT models can fail with system-role input. The
|
||||||
|
equivalent user-role request works with GPT and the two tested Mimo models.
|
||||||
|
Other models keep their existing message roles.
|
||||||
|
"""
|
||||||
|
if not model_name:
|
||||||
|
return messages
|
||||||
|
model = model_name.strip().lower()
|
||||||
|
if not (model.startswith("gpt") or model in _USER_ROLE_MODELS):
|
||||||
|
return messages
|
||||||
|
if not any(isinstance(message, SystemMessage) for message in messages):
|
||||||
|
return messages
|
||||||
|
|
||||||
|
adapted: list[BaseMessage] = []
|
||||||
|
pending_blocks: list[dict[str, Any]] = []
|
||||||
|
for message in messages:
|
||||||
|
if isinstance(message, SystemMessage):
|
||||||
|
content = message.content
|
||||||
|
if isinstance(content, str):
|
||||||
|
pending_blocks.append({"type": "text", "text": content})
|
||||||
|
else:
|
||||||
|
pending_blocks.extend(
|
||||||
|
{"type": "text", "text": block} if isinstance(block, str) else block
|
||||||
|
for block in content
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if pending_blocks:
|
||||||
|
if isinstance(message, HumanMessage):
|
||||||
|
content = message.content
|
||||||
|
blocks = (
|
||||||
|
[{"type": "text", "text": content}]
|
||||||
|
if isinstance(content, str)
|
||||||
|
else list(content)
|
||||||
|
)
|
||||||
|
adapted.append(
|
||||||
|
message.model_copy(
|
||||||
|
update={
|
||||||
|
"content": [
|
||||||
|
*pending_blocks,
|
||||||
|
*blocks,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pending_blocks = []
|
||||||
|
continue
|
||||||
|
adapted.append(HumanMessage(content=pending_blocks))
|
||||||
|
pending_blocks = []
|
||||||
|
adapted.append(message)
|
||||||
|
|
||||||
|
if pending_blocks:
|
||||||
|
adapted.append(HumanMessage(content=pending_blocks))
|
||||||
|
return adapted
|
||||||
@@ -44,6 +44,7 @@ from artemis.mcp.action_manifest import OPTIONAL_ACTIONS, REQUIRED_ACTIONS
|
|||||||
from artemis.mcp.action_specs import exception_prefix
|
from artemis.mcp.action_specs import exception_prefix
|
||||||
from artemis.mcp.action_session import ActionSession, get_action_session
|
from artemis.mcp.action_session import ActionSession, get_action_session
|
||||||
from artemis.mcp.action_types import ActionCode, ActionResult
|
from artemis.mcp.action_types import ActionCode, ActionResult
|
||||||
|
from artemis.mcp.action_types import ObserveResult
|
||||||
from artemis.mcp.actuators.adb import AdbActuator
|
from artemis.mcp.actuators.adb import AdbActuator
|
||||||
from artemis.agents.validator.tool_declarations import (
|
from artemis.agents.validator.tool_declarations import (
|
||||||
ToolExecutionResult,
|
ToolExecutionResult,
|
||||||
@@ -56,6 +57,7 @@ from artemis.utils.coordinates import (
|
|||||||
parse_swipe_parameters,
|
parse_swipe_parameters,
|
||||||
)
|
)
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
|
from artemis.utils.image_hash import dhash_hex, hamming_distance_hex
|
||||||
from artemis.utils.notes import (
|
from artemis.utils.notes import (
|
||||||
format_list_notes_failure,
|
format_list_notes_failure,
|
||||||
format_list_notes_success,
|
format_list_notes_success,
|
||||||
@@ -76,6 +78,9 @@ AGENT_TOOL_NAMES: frozenset[str] = (
|
|||||||
|
|
||||||
#: Actions whose ``target`` is a single point: an element index or an [x, y] pair.
|
#: Actions whose ``target`` is a single point: an element index or an [x, y] pair.
|
||||||
_POINT_TARGET_ACTIONS: frozenset[str] = frozenset({"click", "long_press", "input_text"})
|
_POINT_TARGET_ACTIONS: frozenset[str] = frozenset({"click", "long_press", "input_text"})
|
||||||
|
_SCREEN_CHANGING_ACTIONS: frozenset[str] = frozenset(
|
||||||
|
{"click", "click_sequence", "long_press", "input_text", "press_key", "swipe", "manage_app"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _ArgError(ValueError):
|
class _ArgError(ValueError):
|
||||||
@@ -111,6 +116,99 @@ class McpActionExecutor:
|
|||||||
self._session = await get_action_session(self.ctx, actuator=self.actuator)
|
self._session = await get_action_session(self.ctx, actuator=self.actuator)
|
||||||
return self._session
|
return self._session
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _image_hash(observation: ObserveResult) -> str | None:
|
||||||
|
path = observation.screenshot_path
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return dhash_hex(Path(path).read_bytes())
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _same_screen(cls, first: ObserveResult, second: ObserveResult) -> bool:
|
||||||
|
"""Compare two observations without treating a dispatched tap as navigation."""
|
||||||
|
if not first.ok or not second.ok:
|
||||||
|
return False
|
||||||
|
checks: list[bool] = []
|
||||||
|
first_hash, second_hash = cls._image_hash(first), cls._image_hash(second)
|
||||||
|
distance = hamming_distance_hex(first_hash, second_hash)
|
||||||
|
if distance is not None:
|
||||||
|
checks.append(distance <= 4)
|
||||||
|
if first.hierarchy_ok and second.hierarchy_ok:
|
||||||
|
checks.append(first.elements_text == second.elements_text)
|
||||||
|
return bool(checks) and all(checks)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _element_signature(elements: list[dict[str, Any]]) -> list[tuple[Any, Any, Any]]:
|
||||||
|
return [(e.get("text"), e.get("resource_id"), e.get("bounds")) for e in elements]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _stale_image_with_new_hierarchy(
|
||||||
|
cls,
|
||||||
|
observation: ObserveResult,
|
||||||
|
previous_hash: str | None,
|
||||||
|
previous_elements: list[dict[str, Any]] | None,
|
||||||
|
) -> bool:
|
||||||
|
if previous_hash is None or previous_elements is None or not observation.hierarchy_ok:
|
||||||
|
return False
|
||||||
|
distance = hamming_distance_hex(previous_hash, cls._image_hash(observation))
|
||||||
|
return (
|
||||||
|
distance is not None
|
||||||
|
and distance <= 4
|
||||||
|
and cls._element_signature(previous_elements)
|
||||||
|
!= cls._element_signature(observation.elements)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _observe_after_action(
|
||||||
|
self,
|
||||||
|
session: ActionSession,
|
||||||
|
raw_name: str,
|
||||||
|
previous_screenshot: str | None,
|
||||||
|
previous_elements: list[dict[str, Any]] | None = None,
|
||||||
|
) -> tuple[ObserveResult, bool]:
|
||||||
|
"""Prefer a settled image/tree pair after actions that can change screens.
|
||||||
|
|
||||||
|
The Android helper captures its bitmap and hierarchy concurrently. During a
|
||||||
|
transition the bitmap can still show the old page while the hierarchy already
|
||||||
|
describes the new one. Bounded re-observation waits for adjacent stable
|
||||||
|
frames and rejects a new hierarchy paired with the previous page's bitmap.
|
||||||
|
"""
|
||||||
|
first = await session.observe(settle_ms=400)
|
||||||
|
if not first.ok or raw_name not in _SCREEN_CHANGING_ACTIONS:
|
||||||
|
return first, first.ok
|
||||||
|
|
||||||
|
second = await session.observe(settle_ms=250)
|
||||||
|
if not second.ok:
|
||||||
|
return first, False
|
||||||
|
previous_hash = None
|
||||||
|
if previous_screenshot:
|
||||||
|
try:
|
||||||
|
previous_hash = dhash_hex(Path(previous_screenshot).read_bytes())
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if self._same_screen(first, second):
|
||||||
|
distance = hamming_distance_hex(previous_hash, self._image_hash(second))
|
||||||
|
if (distance is None or distance > 4) and not self._stale_image_with_new_hierarchy(
|
||||||
|
second, previous_hash, previous_elements
|
||||||
|
):
|
||||||
|
return second, True
|
||||||
|
|
||||||
|
third = await session.observe(settle_ms=250)
|
||||||
|
if not third.ok:
|
||||||
|
return second, False
|
||||||
|
if self._same_screen(second, third) and not self._stale_image_with_new_hierarchy(
|
||||||
|
third, previous_hash, previous_elements
|
||||||
|
):
|
||||||
|
return third, True
|
||||||
|
fourth = await session.observe(settle_ms=250)
|
||||||
|
if not fourth.ok:
|
||||||
|
return third, False
|
||||||
|
return fourth, self._same_screen(
|
||||||
|
third, fourth
|
||||||
|
) and not self._stale_image_with_new_hierarchy(fourth, previous_hash, previous_elements)
|
||||||
|
|
||||||
# --- Public entry ----------------------------------------------------------------
|
# --- Public entry ----------------------------------------------------------------
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
@@ -171,6 +269,7 @@ class McpActionExecutor:
|
|||||||
xml_list: str | None = None
|
xml_list: str | None = None
|
||||||
target_semantics: dict[str, Any] = {}
|
target_semantics: dict[str, Any] = {}
|
||||||
target_coordinates: list[int] | None = None
|
target_coordinates: list[int] | None = None
|
||||||
|
observation_settled: bool | None = None
|
||||||
with span:
|
with span:
|
||||||
try:
|
try:
|
||||||
session = await self._session_or_start()
|
session = await self._session_or_start()
|
||||||
@@ -196,7 +295,12 @@ class McpActionExecutor:
|
|||||||
message = finalize(res) if finalize else res.message
|
message = finalize(res) if finalize else res.message
|
||||||
|
|
||||||
if res.ok or self._observe_despite_failure(raw_name, res):
|
if res.ok or self._observe_despite_failure(raw_name, res):
|
||||||
obs = await session.observe(settle_ms=400)
|
obs, observation_settled = await self._observe_after_action(
|
||||||
|
session,
|
||||||
|
raw_name,
|
||||||
|
getattr(state, "latest_screenshot", None),
|
||||||
|
getattr(state, "indexed_elements", None),
|
||||||
|
)
|
||||||
if obs.ok:
|
if obs.ok:
|
||||||
shot_path = obs.screenshot_path
|
shot_path = obs.screenshot_path
|
||||||
xml_list = obs.elements_text
|
xml_list = obs.elements_text
|
||||||
@@ -217,6 +321,8 @@ class McpActionExecutor:
|
|||||||
|
|
||||||
if shot_path:
|
if shot_path:
|
||||||
state.latest_screenshot = shot_path
|
state.latest_screenshot = shot_path
|
||||||
|
if observation_settled is False:
|
||||||
|
message = f"{message} Post-action screen is still changing; observe again before concluding."
|
||||||
|
|
||||||
status = "success" if res.ok else "error"
|
status = "success" if res.ok else "error"
|
||||||
post_image_name = None
|
post_image_name = None
|
||||||
@@ -247,6 +353,7 @@ class McpActionExecutor:
|
|||||||
"normalized_coordinates": res.normalized_coordinates,
|
"normalized_coordinates": res.normalized_coordinates,
|
||||||
"target_semantics": target_semantics,
|
"target_semantics": target_semantics,
|
||||||
"target_coordinates": target_coordinates,
|
"target_coordinates": target_coordinates,
|
||||||
|
"observation_settled": observation_settled,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ def ensure_step_memory(ctx):
|
|||||||
"retry_limit": cfg.memory.runtime.retry_limit,
|
"retry_limit": cfg.memory.runtime.retry_limit,
|
||||||
"max_concurrency": cfg.memory.runtime.max_concurrency,
|
"max_concurrency": cfg.memory.runtime.max_concurrency,
|
||||||
"flush_timeout_s": cfg.memory.runtime.flush_timeout_s,
|
"flush_timeout_s": cfg.memory.runtime.flush_timeout_s,
|
||||||
|
"timeout_seconds": cfg.flash.step_summarizer.timeout_seconds,
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
||||||
|
|
||||||
|
from artemis.llm.messages import adapt_responses_messages
|
||||||
from artemis.memory.step_memory import JobKey, StepLens, StepMemoryService
|
from artemis.memory.step_memory import JobKey, StepLens, StepMemoryService
|
||||||
from artemis.memory.transcript import format_session_offset
|
from artemis.memory.transcript import format_session_offset
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
@@ -503,7 +504,8 @@ class StepCapsuleLens(StepLens):
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
async def _invoke(self, llm: Any, messages: list[BaseMessage], model_label: str):
|
async def _invoke(self, llm: Any, messages: list[BaseMessage], model_label: str):
|
||||||
response = await asyncio.wait_for(llm.ainvoke(messages), timeout=90.0)
|
request_messages = adapt_responses_messages(messages, model_label)
|
||||||
|
response = await asyncio.wait_for(llm.ainvoke(request_messages), timeout=90.0)
|
||||||
# Raw-model bypass metering (gateway-wrapped models meter themselves);
|
# Raw-model bypass metering (gateway-wrapped models meter themselves);
|
||||||
# capsule prompts are small and are kept out of the session meter's
|
# capsule prompts are small and are kept out of the session meter's
|
||||||
# last_prompt_tokens (the compaction base itself is operator-owned on
|
# last_prompt_tokens (the compaction base itself is operator-owned on
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"step_summarizer": {
|
"step_summarizer": {
|
||||||
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
||||||
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
||||||
|
"timeout_seconds": "${env:ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS:-120}",
|
||||||
"prune_history_xml": true
|
"prune_history_xml": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -113,22 +113,28 @@ class ScreenAwakeLease:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _mutex(self):
|
def _mutex(self, *, wait_timeout_seconds: float | None = None):
|
||||||
self.mutex_path.parent.mkdir(parents=True, exist_ok=True)
|
self.mutex_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
started_at = time.monotonic()
|
started_at = time.monotonic()
|
||||||
|
timeout = (
|
||||||
|
self._MUTEX_TIMEOUT_SECONDS
|
||||||
|
if wait_timeout_seconds is None
|
||||||
|
else max(0.0, wait_timeout_seconds)
|
||||||
|
)
|
||||||
acquired = False
|
acquired = False
|
||||||
while not acquired:
|
while not acquired:
|
||||||
try:
|
try:
|
||||||
fd = os.open(self.mutex_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
fd = os.open(self.mutex_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||||
except FileExistsError:
|
except FileExistsError:
|
||||||
|
if time.monotonic() - started_at >= timeout:
|
||||||
|
raise TimeoutError("Timed out acquiring the Artemis awake-lease mutex.")
|
||||||
if self._path_is_stale(self.mutex_path):
|
if self._path_is_stale(self.mutex_path):
|
||||||
try:
|
try:
|
||||||
self.mutex_path.unlink(missing_ok=True)
|
self.mutex_path.unlink(missing_ok=True)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
else:
|
||||||
continue
|
continue
|
||||||
if time.monotonic() - started_at >= self._MUTEX_TIMEOUT_SECONDS:
|
|
||||||
raise TimeoutError("Timed out acquiring the Artemis awake-lease mutex.")
|
|
||||||
time.sleep(self._MUTEX_POLL_SECONDS)
|
time.sleep(self._MUTEX_POLL_SECONDS)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
@@ -274,10 +280,15 @@ class ScreenAwakeLease:
|
|||||||
def cleanup_unowned_references(self) -> bool:
|
def cleanup_unowned_references(self) -> bool:
|
||||||
"""Drain legacy shell wake locks only when no live old host owns them."""
|
"""Drain legacy shell wake locks only when no live old host owns them."""
|
||||||
try:
|
try:
|
||||||
with self._mutex():
|
# Migration cleanup is optional. A busy owner must not delay every
|
||||||
|
# queued task by the normal 10-second lease-acquisition deadline.
|
||||||
|
with self._mutex(wait_timeout_seconds=1.0):
|
||||||
if self._live_leases():
|
if self._live_leases():
|
||||||
return False
|
return False
|
||||||
return self._drain_references()
|
return self._drain_references()
|
||||||
|
except TimeoutError:
|
||||||
|
logger.debug(f"Skipped optional wake-lock cleanup on {self.device_id}: mutex busy")
|
||||||
|
return False
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"Could not clean unowned screen wake locks on {self.device_id}: {exc}")
|
logger.warning(f"Could not clean unowned screen wake locks on {self.device_id}: {exc}")
|
||||||
return False
|
return False
|
||||||
@@ -95,8 +95,9 @@ def _configure_usb_stay_awake(device_id: str) -> str | None:
|
|||||||
if not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
|
if not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Migration cleanup: previous Artemis versions used a persistent shell
|
# Migration cleanup belongs to the long-lived host. A queued task worker
|
||||||
# wake lock. Drain it only after every live legacy owner has exited.
|
# must not wait on another process's optional legacy wake-lock cleanup.
|
||||||
|
if os.environ.get("ARTEMIS_TASK_WORKER") != "1":
|
||||||
ScreenAwakeLease(device_id).cleanup_unowned_references()
|
ScreenAwakeLease(device_id).cleanup_unowned_references()
|
||||||
|
|
||||||
commands = (
|
commands = (
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Cooperative, session-scoped task guidance and device lease parking."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Awaitable
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from artemis.config import TRACES_PATH
|
||||||
|
from artemis.runtime.device_lock import DeviceExecutionLock
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def control_dir(session_id: str) -> Path:
|
||||||
|
if not session_id or not all(c.isalnum() or c in "-_" for c in session_id):
|
||||||
|
raise ValueError("无效的任务编号")
|
||||||
|
return TRACES_PATH / session_id
|
||||||
|
|
||||||
|
|
||||||
|
def _write_atomic(path: Path, data: dict) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
||||||
|
temporary.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||||
|
os.replace(temporary, path)
|
||||||
|
|
||||||
|
|
||||||
|
def read_state(session_id: str) -> dict:
|
||||||
|
try:
|
||||||
|
return json.loads(
|
||||||
|
(control_dir(session_id) / "task_control_state.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def request_pause(session_id: str) -> None:
|
||||||
|
(control_dir(session_id) / "resume.request").unlink(missing_ok=True)
|
||||||
|
_write_atomic(control_dir(session_id) / "pause.request", {"created_at": time.time()})
|
||||||
|
|
||||||
|
|
||||||
|
def request_resume(session_id: str) -> None:
|
||||||
|
_write_atomic(control_dir(session_id) / "resume.request", {"created_at": time.time()})
|
||||||
|
|
||||||
|
|
||||||
|
def add_instruction(
|
||||||
|
session_id: str, instruction: str, *, release_loop: bool = False, source: str = "user"
|
||||||
|
) -> dict:
|
||||||
|
if not instruction.strip() or len(instruction) > 10000:
|
||||||
|
raise ValueError("指导内容必须在 1–10000 字之间")
|
||||||
|
directory = control_dir(session_id) / "instructions"
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Unique names make simultaneous web and MCP submissions additive, never last-write-wins.
|
||||||
|
path = directory / f"{time.time_ns():020d}-{uuid.uuid4().hex}.json"
|
||||||
|
record = {
|
||||||
|
"id": path.stem,
|
||||||
|
"instruction": instruction.strip(),
|
||||||
|
"release_loop": bool(release_loop),
|
||||||
|
"source": source,
|
||||||
|
"timestamp": time.time(),
|
||||||
|
}
|
||||||
|
_write_atomic(path, record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def list_instructions(session_id: str) -> list[dict]:
|
||||||
|
"""Show submissions separately from guidance the runner actually consumed."""
|
||||||
|
directory = control_dir(session_id) / "instructions"
|
||||||
|
records = []
|
||||||
|
for folder, status in ((directory, "pending"), (directory / "consumed", "consumed")):
|
||||||
|
for path in folder.glob("*.json") if folder.exists() else []:
|
||||||
|
try:
|
||||||
|
record = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(record, dict) and record.get("instruction"):
|
||||||
|
records.append(
|
||||||
|
{**record, "id": record.get("id") or path.stem, "status": status}
|
||||||
|
)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
return sorted(records, key=lambda record: (record.get("timestamp", 0), record["id"]))
|
||||||
|
|
||||||
|
|
||||||
|
def has_pending_instruction(base_dir: str | Path) -> bool:
|
||||||
|
directory = Path(base_dir) / "instructions"
|
||||||
|
return any(directory.glob("*.json")) or (Path(base_dir) / "injected_instruction.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def consume_instructions(base_dir: str | Path) -> dict | None:
|
||||||
|
directory = Path(base_dir) / "instructions"
|
||||||
|
found = []
|
||||||
|
for path in sorted(directory.glob("*.json")) if directory.exists() else []:
|
||||||
|
try:
|
||||||
|
claimed = path.with_suffix(".reading")
|
||||||
|
path.rename(claimed)
|
||||||
|
record = json.loads(claimed.read_text(encoding="utf-8"))
|
||||||
|
record["consumed_at"] = time.time()
|
||||||
|
_write_atomic(directory / "consumed" / path.name, record)
|
||||||
|
claimed.unlink()
|
||||||
|
found.append(record)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
legacy = Path(base_dir) / "injected_instruction.json"
|
||||||
|
if legacy.exists():
|
||||||
|
try:
|
||||||
|
claimed = legacy.with_suffix(".reading")
|
||||||
|
legacy.rename(claimed)
|
||||||
|
found.append(json.loads(claimed.read_text(encoding="utf-8")))
|
||||||
|
claimed.unlink()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
if not found:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"instruction": "\n\n".join(str(item.get("instruction") or "") for item in found).strip(),
|
||||||
|
"release_loop": any(bool(item.get("release_loop")) for item in found),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TaskControl:
|
||||||
|
"""Yield a device lease at the next safe turn and reacquire at the FIFO tail."""
|
||||||
|
|
||||||
|
def __init__(self, session_id: str, device_lock: DeviceExecutionLock):
|
||||||
|
self.session_id = session_id
|
||||||
|
self.device_lock = device_lock
|
||||||
|
self.directory = control_dir(session_id)
|
||||||
|
|
||||||
|
def _state(self, status: str) -> None:
|
||||||
|
_write_atomic(
|
||||||
|
self.directory / "task_control_state.json",
|
||||||
|
{"status": status, "updated_at": time.time(), "pid": os.getpid()},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def checkpoint(self, *, on_pause=None, on_resume=None) -> bool:
|
||||||
|
pause = self.directory / "pause.request"
|
||||||
|
if not pause.exists():
|
||||||
|
return False
|
||||||
|
pause.unlink(missing_ok=True)
|
||||||
|
if on_pause is not None:
|
||||||
|
await on_pause()
|
||||||
|
self.device_lock.release()
|
||||||
|
self._state("task_paused")
|
||||||
|
resume = self.directory / "resume.request"
|
||||||
|
while not resume.exists():
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
resume.unlink(missing_ok=True)
|
||||||
|
self._state("resuming")
|
||||||
|
previous = self.device_lock
|
||||||
|
self.device_lock = DeviceExecutionLock(
|
||||||
|
previous.device_id,
|
||||||
|
description=previous.description,
|
||||||
|
max_concurrency=previous.max_concurrency,
|
||||||
|
concurrency_mode=previous.concurrency_mode,
|
||||||
|
session_id=self.session_id,
|
||||||
|
ingress=previous.ingress,
|
||||||
|
lock_scope=previous.lock_scope,
|
||||||
|
)
|
||||||
|
cancelled = threading.Event()
|
||||||
|
acquiring = asyncio.create_task(
|
||||||
|
asyncio.to_thread(self.device_lock.acquire, cancel_event=cancelled)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.shield(acquiring)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
cancelled.set()
|
||||||
|
try:
|
||||||
|
await asyncio.shield(acquiring)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Resume acquisition ended during cancellation", exc_info=True)
|
||||||
|
self.device_lock.release()
|
||||||
|
raise
|
||||||
|
if on_resume is not None:
|
||||||
|
await on_resume()
|
||||||
|
self._state("running")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def run_interruptibly[T](
|
||||||
|
self, operation: Awaitable[T], *, on_pause=None, on_resume=None
|
||||||
|
) -> tuple[bool, T | None]:
|
||||||
|
"""Interrupt a stale model/helper call for pause or fresh user guidance.
|
||||||
|
|
||||||
|
Only use for operations whose cancellation cannot leave a device action
|
||||||
|
half executed. The caller must return to fresh perception after resume.
|
||||||
|
"""
|
||||||
|
running = asyncio.ensure_future(operation)
|
||||||
|
pause = self.directory / "pause.request"
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if pause.exists():
|
||||||
|
running.cancel()
|
||||||
|
await asyncio.gather(running, return_exceptions=True)
|
||||||
|
await self.checkpoint(on_pause=on_pause, on_resume=on_resume)
|
||||||
|
return True, None
|
||||||
|
if has_pending_instruction(self.directory):
|
||||||
|
running.cancel()
|
||||||
|
await asyncio.gather(running, return_exceptions=True)
|
||||||
|
return True, None
|
||||||
|
if running.done():
|
||||||
|
return False, running.result()
|
||||||
|
await asyncio.wait({running}, timeout=0.2)
|
||||||
|
finally:
|
||||||
|
if not running.done():
|
||||||
|
running.cancel()
|
||||||
|
await asyncio.gather(running, return_exceptions=True)
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
self.device_lock.release()
|
||||||
|
(self.directory / "task_control_state.json").unlink(missing_ok=True)
|
||||||
+62
-4
@@ -540,6 +540,7 @@ class Agent:
|
|||||||
last_state: State | None = None
|
last_state: State | None = None
|
||||||
last_state_snapshot: dict | None = None
|
last_state_snapshot: dict | None = None
|
||||||
output = None
|
output = None
|
||||||
|
task_control = None
|
||||||
effective_mode = getattr(self._config, "concurrency_mode", "per_device")
|
effective_mode = getattr(self._config, "concurrency_mode", "per_device")
|
||||||
effective_max = getattr(self._config, "max_concurrency", None)
|
effective_max = getattr(self._config, "max_concurrency", None)
|
||||||
sess_id = (
|
sess_id = (
|
||||||
@@ -599,6 +600,11 @@ class Agent:
|
|||||||
# publish a new active session while the current task is still
|
# publish a new active session while the current task is still
|
||||||
# finishing.
|
# finishing.
|
||||||
self._prepare_tracing(task=task, context=context)
|
self._prepare_tracing(task=task, context=context)
|
||||||
|
if device_lock is not None and os.environ.get("ARTEMIS_TASK_WORKER") == "1":
|
||||||
|
from artemis.runtime.task_control import TaskControl
|
||||||
|
|
||||||
|
task_control = TaskControl(str(sess_id), device_lock)
|
||||||
|
context.task_control = task_control
|
||||||
self._prepare_output_files(task=task)
|
self._prepare_output_files(task=task)
|
||||||
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
||||||
if self._ui_adb_client is not None:
|
if self._ui_adb_client is not None:
|
||||||
@@ -612,16 +618,46 @@ class Agent:
|
|||||||
await self._prepare_app_lock(task=task, context=context)
|
await self._prepare_app_lock(task=task, context=context)
|
||||||
async with context:
|
async with context:
|
||||||
recording_started = False
|
recording_started = False
|
||||||
if self._config.video_recording_tools_enabled:
|
recording_parts = 1
|
||||||
try:
|
|
||||||
controller = get_controller(context)
|
controller = get_controller(context)
|
||||||
|
|
||||||
output_dir = None
|
output_dir = None
|
||||||
if context.execution_setup and context.execution_setup.traces_path:
|
if context.execution_setup and context.execution_setup.traces_path:
|
||||||
output_dir = (
|
output_dir = (
|
||||||
self._tmp_traces_dir / context.execution_setup.trace_name
|
self._tmp_traces_dir / context.execution_setup.trace_name
|
||||||
).resolve()
|
).resolve()
|
||||||
|
|
||||||
|
async def pause_capture():
|
||||||
|
nonlocal recording_started
|
||||||
|
if recording_started:
|
||||||
|
try:
|
||||||
|
await controller.stop_video_recording()
|
||||||
|
finally:
|
||||||
|
recording_started = False
|
||||||
|
from artemis.tools.command_tool import shutdown_adb_background_tasks
|
||||||
|
|
||||||
|
await shutdown_adb_background_tasks(context)
|
||||||
|
|
||||||
|
async def resume_capture():
|
||||||
|
nonlocal recording_started, recording_parts
|
||||||
|
if not (
|
||||||
|
self._config.video_recording_tools_enabled
|
||||||
|
or self._config.record_video_enabled
|
||||||
|
):
|
||||||
|
return
|
||||||
|
recording_parts += 1
|
||||||
|
target = (
|
||||||
|
output_dir / f"resume_{recording_parts:04d}" if output_dir else None
|
||||||
|
)
|
||||||
|
result = await controller.start_video_recording(output_dir=target)
|
||||||
|
recording_started = bool(result and result.success)
|
||||||
|
|
||||||
|
context.on_task_pause = pause_capture
|
||||||
|
context.on_task_resume = resume_capture
|
||||||
|
if (
|
||||||
|
self._config.video_recording_tools_enabled
|
||||||
|
or self._config.record_video_enabled
|
||||||
|
):
|
||||||
|
try:
|
||||||
logger.info(f"[{task_name}] Starting automated screen recording...")
|
logger.info(f"[{task_name}] Starting automated screen recording...")
|
||||||
start_res = await controller.start_video_recording(
|
start_res = await controller.start_video_recording(
|
||||||
output_dir=output_dir
|
output_dir=output_dir
|
||||||
@@ -840,6 +876,25 @@ class Agent:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[{task_name}] Failed to stop background ADB tasks: {e}")
|
logger.warning(f"[{task_name}] Failed to stop background ADB tasks: {e}")
|
||||||
try:
|
try:
|
||||||
|
if (
|
||||||
|
self._config.video_recording_tools_enabled
|
||||||
|
and not self._config.record_video_enabled
|
||||||
|
):
|
||||||
|
# Video analysis used a temporary capture during execution.
|
||||||
|
# Keep no recording after the final model/report step.
|
||||||
|
trace_dir = (self._tmp_traces_dir / task_name).resolve()
|
||||||
|
root = self._tmp_traces_dir.resolve()
|
||||||
|
if trace_dir.is_relative_to(root) and trace_dir.is_dir():
|
||||||
|
for recording in trace_dir.rglob("*"):
|
||||||
|
if recording.is_file() and (
|
||||||
|
recording.suffix.lower() in {".mkv", ".mp4", ".webm"}
|
||||||
|
or recording.name == "recording.json"
|
||||||
|
):
|
||||||
|
recording.unlink(missing_ok=True)
|
||||||
|
if context.data_engine and context.data_engine.current_session_id:
|
||||||
|
context.data_engine.storage.discard_session_recordings(
|
||||||
|
str(context.data_engine.current_session_id)
|
||||||
|
)
|
||||||
await self._finalize_tracing_safely(task=task, context=context)
|
await self._finalize_tracing_safely(task=task, context=context)
|
||||||
finally:
|
finally:
|
||||||
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
||||||
@@ -847,7 +902,9 @@ class Agent:
|
|||||||
if self._ui_adb_client is not None:
|
if self._ui_adb_client is not None:
|
||||||
await asyncio.to_thread(self._ui_adb_client.disconnect)
|
await asyncio.to_thread(self._ui_adb_client.disconnect)
|
||||||
finally:
|
finally:
|
||||||
if device_lock is not None:
|
if task_control is not None:
|
||||||
|
await asyncio.to_thread(task_control.release)
|
||||||
|
elif device_lock is not None:
|
||||||
await asyncio.to_thread(device_lock.release)
|
await asyncio.to_thread(device_lock.release)
|
||||||
|
|
||||||
async with self._task_lock:
|
async with self._task_lock:
|
||||||
@@ -1074,6 +1131,7 @@ class Agent:
|
|||||||
if task.request.record_trace
|
if task.request.record_trace
|
||||||
else False,
|
else False,
|
||||||
video_recording_tools_enabled=self._config.video_recording_tools_enabled,
|
video_recording_tools_enabled=self._config.video_recording_tools_enabled,
|
||||||
|
record_video_enabled=self._config.record_video_enabled,
|
||||||
disable_checker=self._config.disable_checker,
|
disable_checker=self._config.disable_checker,
|
||||||
disable_midway_checks=self._config.disable_midway_checks,
|
disable_midway_checks=self._config.disable_midway_checks,
|
||||||
disable_final_check=self._config.disable_final_check,
|
disable_final_check=self._config.disable_final_check,
|
||||||
|
|||||||
@@ -609,6 +609,7 @@ class AgentConfigBuilder:
|
|||||||
servers=self._servers,
|
servers=self._servers,
|
||||||
graph_config_callbacks=self._graph_config_callbacks,
|
graph_config_callbacks=self._graph_config_callbacks,
|
||||||
video_recording_tools_enabled=self._video_recording_tools_enabled,
|
video_recording_tools_enabled=self._video_recording_tools_enabled,
|
||||||
|
record_video_enabled=settings.ARTEMIS_RECORDING_ENABLED,
|
||||||
force_web_accessibility=self._force_web_accessibility,
|
force_web_accessibility=self._force_web_accessibility,
|
||||||
disable_checker=self._disable_checker,
|
disable_checker=self._disable_checker,
|
||||||
disable_midway_checks=self._disable_midway_checks,
|
disable_midway_checks=self._disable_midway_checks,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from artemis.config import (
|
|||||||
from artemis.context import DevicePlatform
|
from artemis.context import DevicePlatform
|
||||||
from artemis.sdk.types.task import AgentProfile, TaskRequestCommon
|
from artemis.sdk.types.task import AgentProfile, TaskRequestCommon
|
||||||
from artemis.utils.video import detect_video_tools_enabled
|
from artemis.utils.video import detect_video_tools_enabled
|
||||||
|
from artemis.config.settings import settings
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
@@ -113,6 +114,7 @@ class AgentConfig(BaseModel):
|
|||||||
servers: ServerConfig
|
servers: ServerConfig
|
||||||
graph_config_callbacks: Callbacks = None
|
graph_config_callbacks: Callbacks = None
|
||||||
video_recording_tools_enabled: bool = Field(default_factory=detect_video_tools_enabled)
|
video_recording_tools_enabled: bool = Field(default_factory=detect_video_tools_enabled)
|
||||||
|
record_video_enabled: bool = Field(default_factory=lambda: settings.ARTEMIS_RECORDING_ENABLED)
|
||||||
force_web_accessibility: bool = False
|
force_web_accessibility: bool = False
|
||||||
disable_checker: bool = False
|
disable_checker: bool = False
|
||||||
disable_midway_checks: bool = True
|
disable_midway_checks: bool = True
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ from artemis.llm.reliability import (
|
|||||||
classify_failure,
|
classify_failure,
|
||||||
retry_policy_for,
|
retry_policy_for,
|
||||||
)
|
)
|
||||||
|
from artemis.llm.messages import adapt_responses_messages
|
||||||
from artemis.llm.router import ModelEndpoint, ModelFactory, ModelProvider
|
from artemis.llm.router import ModelEndpoint, ModelFactory, ModelProvider
|
||||||
from artemis.services.token_meter import record_llm_usage
|
from artemis.services.token_meter import record_llm_usage
|
||||||
from artemis.llm.structured import (
|
from artemis.llm.structured import (
|
||||||
@@ -568,6 +569,25 @@ class RobustChatModelWrapper:
|
|||||||
args, kwargs = _inject_parent_trace_id(trace_id, *args, **kwargs)
|
args, kwargs = _inject_parent_trace_id(trace_id, *args, **kwargs)
|
||||||
return args, kwargs, trace_id
|
return args, kwargs, trace_id
|
||||||
|
|
||||||
|
def _prepare_model_call(self, args: tuple, kwargs: dict) -> tuple[tuple, dict]:
|
||||||
|
model_name = self.endpoint.model_name if self.endpoint else None
|
||||||
|
if (
|
||||||
|
args
|
||||||
|
and isinstance(args[0], (list, tuple))
|
||||||
|
and all(isinstance(message, BaseMessage) for message in args[0])
|
||||||
|
):
|
||||||
|
return (adapt_responses_messages(args[0], model_name), *args[1:]), kwargs
|
||||||
|
if (
|
||||||
|
"input" in kwargs
|
||||||
|
and isinstance(kwargs["input"], (list, tuple))
|
||||||
|
and all(isinstance(message, BaseMessage) for message in kwargs["input"])
|
||||||
|
):
|
||||||
|
return args, {
|
||||||
|
**kwargs,
|
||||||
|
"input": adapt_responses_messages(kwargs["input"], model_name),
|
||||||
|
}
|
||||||
|
return args, kwargs
|
||||||
|
|
||||||
def _meter_usage(self, response) -> None:
|
def _meter_usage(self, response) -> None:
|
||||||
"""Record measured token usage for this call (M0 metering, best-effort)."""
|
"""Record measured token usage for this call (M0 metering, best-effort)."""
|
||||||
engine = self.ctx.data_engine if self.ctx else None
|
engine = self.ctx.data_engine if self.ctx else None
|
||||||
@@ -576,6 +596,7 @@ class RobustChatModelWrapper:
|
|||||||
record_llm_usage(engine, response, source=self._endpoint_key())
|
record_llm_usage(engine, response, source=self._endpoint_key())
|
||||||
|
|
||||||
async def ainvoke(self, *args, **kwargs):
|
async def ainvoke(self, *args, **kwargs):
|
||||||
|
args, kwargs = self._prepare_model_call(args, kwargs)
|
||||||
args, kwargs, _ = self._traced_call(args, kwargs)
|
args, kwargs, _ = self._traced_call(args, kwargs)
|
||||||
response = await _run_with_recovery(
|
response = await _run_with_recovery(
|
||||||
functools.partial(self.base_model.ainvoke, *args, **kwargs),
|
functools.partial(self.base_model.ainvoke, *args, **kwargs),
|
||||||
@@ -597,6 +618,7 @@ class RobustChatModelWrapper:
|
|||||||
signals the UI to drop it, and retries the whole call per policy;
|
signals the UI to drop it, and retries the whole call per policy;
|
||||||
partial or duplicated chunks can never reach message history.
|
partial or duplicated chunks can never reach message history.
|
||||||
"""
|
"""
|
||||||
|
args, kwargs = self._prepare_model_call(args, kwargs)
|
||||||
args, kwargs, _ = self._traced_call(args, kwargs)
|
args, kwargs, _ = self._traced_call(args, kwargs)
|
||||||
emit_deltas = bool(self.ctx and self.ctx.data_engine)
|
emit_deltas = bool(self.ctx and self.ctx.data_engine)
|
||||||
response = await _run_with_recovery(
|
response = await _run_with_recovery(
|
||||||
|
|||||||
@@ -115,9 +115,9 @@ async def validate_api_key(
|
|||||||
return False, f"Vision OCR API verification failed ({resp.status_code}): {err_msg}"
|
return False, f"Vision OCR API verification failed ({resp.status_code}): {err_msg}"
|
||||||
|
|
||||||
elif clean_provider == "openai":
|
elif clean_provider == "openai":
|
||||||
base = (base_url or settings.OPENAI_BASE_URL or "https://api.openai.com/v1").rstrip(
|
base = (
|
||||||
"/"
|
base_url or settings.OPENAI_BASE_URL or "https://api-n-cd.playones.com/v1"
|
||||||
)
|
).rstrip("/")
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{base}/responses",
|
f"{base}/responses",
|
||||||
headers={"Authorization": f"Bearer {clean_key}"},
|
headers={"Authorization": f"Bearer {clean_key}"},
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"step_summarizer": {
|
"step_summarizer": {
|
||||||
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
||||||
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
||||||
|
"timeout_seconds": "${env:ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS:-120}",
|
||||||
"prune_history_xml": true
|
"prune_history_xml": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"python_download_mirror": "https://mirrors.nju.edu.cn/github-release/astral-sh/python-build-standalone",
|
"python_download_mirror": "https://mirrors.nju.edu.cn/github-release/astral-sh/python-build-standalone",
|
||||||
"npm_registry": "https://registry.npmmirror.com",
|
"npm_registry": "https://registry.npmmirror.com",
|
||||||
"node_mirror": "https://registry.npmmirror.com/-/binary/node",
|
"node_mirror": "https://registry.npmmirror.com/-/binary/node",
|
||||||
"adb_url": "https://mirrors.cloud.tencent.com/AndroidSDK/platform-tools-latest-windows.zip"
|
"adb_url": "https://mirrors.cloud.tencent.com/AndroidSDK/platform-tools-latest-windows.zip",
|
||||||
|
"scrcpy_url": "https://mirrors.sdu.edu.cn/github-release/Genymobile_scrcpy/v4.1/scrcpy-win64-v4.1.zip"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"python_index": "",
|
"python_index": "",
|
||||||
|
|||||||
+10
-8
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
源码内的 `skills/artemis-automation/` 是可分发 Skill,不依赖本机 Codex 的私有能力。将整个目录复制到宿主支持的 Skill 目录即可;Codex 为 `$CODEX_HOME/skills/`(未配置时 `~/.codex/skills/`)。仓库包含 Skill,启动程序不会修改用户的全局 Skill 目录。
|
源码内的 `skills/artemis-automation/` 是可分发 Skill,不依赖本机 Codex 的私有能力。将整个目录复制到宿主支持的 Skill 目录即可;Codex 为 `$CODEX_HOME/skills/`(未配置时 `~/.codex/skills/`)。仓库包含 Skill,启动程序不会修改用户的全局 Skill 目录。
|
||||||
|
|
||||||
先完成项目环境和模型配置,然后在项目目录执行 `uv run artemis mcp --install codex` 安装/更新 MCP 配置。其他客户端将 `codex` 换成对应客户端;`uv run artemis mcp --help` 可查看支持列表。更新后重新加载宿主 MCP;已有配置若设置了工具白名单,需要重新安装配置或加入新增工具。复制 Skill 后按宿主方式重新加载技能。
|
Windows 一键脚本会为检测到的客户端自动安装或更新 MCP 配置。未检测到客户端时,在项目目录执行 `uv run artemis mcp --install codex`;其他客户端将 `codex` 换成对应名称,`uv run artemis mcp --help` 可查看支持列表。更新后重新加载宿主 MCP;复制 Skill 后按宿主方式重新加载技能。
|
||||||
|
|
||||||
可以向 AI 这样下达任务:
|
可以向 AI 这样下达任务:
|
||||||
|
|
||||||
@@ -22,26 +22,28 @@
|
|||||||
| `mobile_workspace` | 设备列表与统一队列状态 |
|
| `mobile_workspace` | 设备列表与统一队列状态 |
|
||||||
| `mobile_config` | 读取或明确保存非敏感模型、功能、报告默认设置 |
|
| `mobile_config` | 读取或明确保存非敏感模型、功能、报告默认设置 |
|
||||||
| `mobile_run_task` | 单任务执行;`model=flash/pro` 表示执行模式 |
|
| `mobile_run_task` | 单任务执行;`model=flash/pro` 表示执行模式 |
|
||||||
| `mobile_manage_task` | 状态、指导和停止单任务 |
|
| `mobile_manage_task` | 单任务状态、安全暂停、排队恢复、指导提交/领取记录和停止 |
|
||||||
| `mobile_get_device_state` | 设备截图、界面层级 |
|
| `mobile_get_device_state` | 设备截图、界面层级 |
|
||||||
| `mobile_task_file` | XLSX 模板与导入校验 |
|
| `mobile_task_file` | XLSX 模板与导入校验 |
|
||||||
| `mobile_batch` | JSON 用例建批次、开始、暂停、继续、复制失败项 |
|
| `mobile_batch` | JSON 用例建批次、开始、暂停、继续、复制失败项、删除非活跃批次 |
|
||||||
| `mobile_search_history` | 全库搜索、日期/状态筛选和分页 |
|
| `mobile_search_history` | 全库搜索、日期/状态筛选和分页 |
|
||||||
| `mobile_inspect_trace` | 只读步骤、截图和执行证据 |
|
| `mobile_inspect_trace` | 只读步骤、截图和执行证据 |
|
||||||
| `mobile_export_report` | Word、Excel 和批次 ZIP,返回本地绝对路径 |
|
| `mobile_export_report` | Word、Excel、步骤截图、录屏片段、单任务资料及批次 ZIP,返回本地绝对路径 |
|
||||||
|
|
||||||
新增工具通过统一后台服务运行,与网页共享配置、队列、历史和报告;不另外启动独立调度器。`ARTEMIS_STANDALONE` 模式不支持这些工作区工具。
|
新增工具通过统一后台服务运行,与网页共享配置、队列、历史和报告;不另外启动独立调度器。`ARTEMIS_STANDALONE` 模式不支持这些工作区工具。
|
||||||
|
|
||||||
Windows 已安装用户再次运行 `start.bat` / `start-cn.bat` 时会检查当前分支的 Git 上游,仅做快进更新;拉取后重新加载新版启动脚本。没有 Git、ZIP 解压安装、网络/认证失败、有本地修改或分支分叉时保留现有源码并给出提示,不重置或覆盖。ZIP 用户可继续运行本地版本;需要自动源码升级时请改用 Git 克隆安装。`-SkipUpdate` 可跳过源码更新检查。
|
Windows 已安装用户再次运行 `start.bat` / `start-cn.bat` 时会检查当前分支的 Git 上游,仅做快进更新;拉取后重新加载新版启动脚本。1.1.1 及更新版本的发布 ZIP 是精简的 Git 工作树,解压后也能在后续运行时检查更新。1.1.0 及更早 ZIP 缺少 `.git`,需手动下载新版源码包一次。没有 Git、网络/认证失败、有本地修改或分支分叉时保留现有源码并给出提示,不重置或覆盖。`-SkipUpdate` 可跳过源码更新检查。
|
||||||
|
|
||||||
Python 每次按 `uv.lock` 检查并同步缺失/变化的依赖;前端依据源码、资源及包锁文件指纹决定是否重新执行 `npm ci` 和构建,成功后才记录指纹。国内镜像选项继续生效。检测到正在执行/排队的任务时,自动更新启动会停止,让原任务继续。
|
Python 每次按 `uv.lock` 检查并同步缺失/变化的依赖;前端依据源码、资源及包锁文件指纹决定是否重新执行 `npm ci` 和构建,成功后才记录指纹。国内镜像选项继续生效。检测到正在执行/排队的任务时,自动更新启动会停止,让原任务继续。
|
||||||
|
|
||||||
MCP 自动刷新只处理已存在且指向当前项目的标准 Python 模块配置,更新解释器路径和已有工具白名单。保留用户环境变量、禁用状态、禁用工具及其他服务;JSON/TOML 无法安全解析、其他项目和自定义启动包装器保持原样。首次修改保留备份,未接入的客户端不会自动安装。源码更新后需在 AI 客户端重新加载 MCP 连接以使用新实现;脚本不会强制重启你的 IDE。
|
MCP 同步会识别已安装客户端的配置目录,为缺少 ARTEMIS 条目的客户端安装当前源码的标准配置;已有标准配置即使指向旧解压目录,也会更新解释器、项目目录、环境路径和工具清单。保留用户额外环境变量、禁用状态及其他服务;自定义启动包装器保持原样并提示。无法安全解析配置时停止启动,原文件不被覆盖;可使用 `-SkipMcpSync` 仅启动网页。首次修改保留备份。源码和配置同步后需在 AI 客户端重新加载 MCP 连接或重启客户端,脚本无法替宿主重启已加载的 MCP 进程。
|
||||||
|
|
||||||
创建批次时必须提供唯一 `request_id`;请求超时后可以按编号查询或用相同内容重发,避免重复用例。创建只保存草稿,`start` 才执行。AI 无需先制造 Excel,也可直接提交 JSON;字段与示例见 [Skill 批量任务参考](../skills/artemis-automation/references/batches.md)。
|
创建批次时必须提供唯一 `request_id`;请求超时后可以按编号查询或用相同内容重发,避免重复用例。创建只保存草稿,`start` 才执行。AI 无需先制造 Excel,也可直接提交 JSON;字段与示例见 [Skill 批量任务参考](../skills/artemis-automation/references/batches.md)。
|
||||||
|
|
||||||
报告导出到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。
|
报告按任务或批次归档到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接;删除批次时会清理对应导出副本。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。
|
||||||
|
|
||||||
模型默认使用 `mimo-v2.6-flash`,也预设 `gpt-5.6-luna` 并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,每次等待 10 秒,SDK 不叠加重试。耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏默认关闭。
|
模型默认使用 `mimo-v2.6-flash`,也预设 `gpt-5.6-luna` 并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,每次等待 10 秒,SDK 不叠加重试。耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停先返回 `pausing`,确认状态为 `task_paused` 才表示设备已释放;Pro 等待模型回复或只读诊断时可及时中断,恢复后重新观察设备。AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复;运行中的模型思考会中断并重新观察设备,已经开始的设备动作会安全结束。用 `guidance_history` 核对指导是否被领取、在任务流程中核对实际执行情况,或用 `resume` 单独恢复。
|
||||||
|
|
||||||
|
批次任务在队列中使用“用例编号 + 用例名称”显示,提示词仍是模型实际收到的内容。任务历史展示失败原因、完成时间和总耗时;导出全部资料时以用例编号命名目录。删除非活跃批次会同步清理其任务记录、步骤、截图、录屏及生成的报告,删除后不能通过旧 `request_id` 重复执行。
|
||||||
|
|
||||||
保存全局配置要求用户明确指令;只读配置不包含密钥,MCP 不接收密钥。密钥仍在网页或 `.env` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。
|
保存全局配置要求用户明确指令;只读配置不包含密钥,MCP 不接收密钥。密钥仍在网页或 `.env` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 导出结论报告
|
## 导出结论报告
|
||||||
|
|
||||||
选择已有任务,在“结论报告”中下载 Word 或 Excel。默认同时生成两种格式;勾选所需格式并保存,后续提交使用新的默认值。
|
选择已有任务,在“报告与质量”中下载 Word、Excel、按步骤排序的截图 ZIP、录屏分段 ZIP,或一键下载包含上述文件和清单的资料 ZIP。只有启用并成功保存录屏的任务才有录屏可下载。默认报告格式为 Word 和 Excel;勾选所需格式并保存,后续提交使用新的默认值。
|
||||||
|
|
||||||
报告包含目标、预期结果、执行状态、验证结论、实际输出和步骤证据。Word 内嵌关键截图,Excel 包含结果与证据工作表;任务输出中的 Markdown 表格会转换为真正的文档表格和 Excel 工作表。
|
报告包含目标、预期结果、执行状态、验证结论、实际输出和步骤证据。Word 内嵌关键截图,Excel 包含结果与证据工作表;任务输出中的 Markdown 表格会转换为真正的文档表格和 Excel 工作表。
|
||||||
|
|
||||||
@@ -15,16 +15,18 @@
|
|||||||
## Excel 批量导入
|
## Excel 批量导入
|
||||||
|
|
||||||
1. 点击“下载 Excel 模板”,保留“任务”工作表及表头顺序。
|
1. 点击“下载 Excel 模板”,保留“任务”工作表及表头顺序。
|
||||||
2. 每行填写一个独立用例。必填“任务名称”“任务目标”;其余列是预期结果、目标应用、前置条件、输入参数(JSON)、标签。
|
2. 标准列按顺序为“用例编号”“检测项目”“测试子项”“测试用例”“前置约束提取”“Artemis提示词”“任务配置(JSON)”。任务表留空,示例行在“填写说明”页。
|
||||||
3. 参数示例为 `{"关键词":"天气"}`,在任务目标中使用 `{{关键词}}`。不要填写公式、宏或外部链接。
|
3. 每行的“Artemis提示词”必填,且是唯一提交给 AI 的任务正文;其他文本列用于显示、搜索和报告,不自动拼入提示词。任务配置可留空,或填写 JSON 对象,如 `{"profile":"pro","model":"mimo-v2.6-flash","reasoning_effort":"high","record_video":true,"analyze_video":false,"request_timeout_seconds":300}`。支持单项覆盖设备、验证级别、观察模式、目标应用包名、重试及单任务时限;未设置字段继承批次或全局设置。不接受密钥和未知字段。不要填写公式、宏或外部链接。
|
||||||
4. 上传文件,查看逐行校验结果。所有错误修正后才能提交。
|
4. 上传文件,查看逐行校验结果。所有错误修正后才能提交。
|
||||||
5. 明确选择设备、执行模式和超时时间,然后点击“确认导入并开始执行”。
|
5. 明确选择设备、执行模式和超时时间,然后点击“确认导入并开始执行”。
|
||||||
|
|
||||||
同一批次按顺序执行。默认失败后继续且不自动重试,也可以选择失败即停止。设备不可用、模型暂停或任务超时会暂停后续派发。暂停批次让当前任务继续;全局停止会暂停批次并停止当前执行。
|
同一批次按顺序派发。默认失败后继续且不自动重试,也可以选择失败即停止。设备不可用、模型暂停或任务超时会暂停后续派发。暂停批次只停止后续派发。单个任务可在安全边界暂停并释放设备;批次下一项可继续,暂停的任务待提交指导或点击继续后在设备队列末尾恢复。运行中提交指导会中断当前模型思考或只读诊断,重新观察画面;已经开始的设备动作会安全结束。网页流程会显示指导的提交与领取状态,仍应检查后续动作是否符合指导。暂停等待和重新排队时间不计入该任务执行时限。
|
||||||
|
|
||||||
服务重启后保留批次,等待用户继续未开始的用例。中断任务不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。
|
服务重启后保留批次,等待用户继续未开始的用例。正在执行或暂停的任务标记中断,不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。
|
||||||
|
|
||||||
每个任务结束后生成所选格式报告。批次结束后生成汇总及每项任务报告的 ZIP;失败、跳过和中断任务也会列入。暂停时下载的是当前快照。
|
每个任务结束后生成所选格式报告。批次结束后生成汇总及每项任务报告的 ZIP;失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。
|
||||||
|
|
||||||
|
队列中批量任务按“用例编号 + 测试用例”显示,悬停可看完整提示词;失败原因、完成时间及总耗时在历史中可查看。单任务“下载全部资料”以用例编号命名压缩包内目录。删除已结束的批次会同步删除关联任务记录、步骤、截图、录屏、报告和 MCP 导出副本;正在执行或排队的批次不能删除。
|
||||||
|
|
||||||
## AI 使用
|
## AI 使用
|
||||||
|
|
||||||
@@ -34,4 +36,4 @@
|
|||||||
|
|
||||||
`.env` 可配置 `ARTEMIS_BATCH_TASK_TIMEOUT`(默认 1800 秒)、`ARTEMIS_IMPORT_MAX_ROWS`(默认 1000 行)和 `ARTEMIS_IMPORT_MAX_BYTES`(默认 10 MiB)。修改后重启服务。网页的单批次超时设置优先于环境默认值。
|
`.env` 可配置 `ARTEMIS_BATCH_TASK_TIMEOUT`(默认 1800 秒)、`ARTEMIS_IMPORT_MAX_ROWS`(默认 1000 行)和 `ARTEMIS_IMPORT_MAX_BYTES`(默认 10 MiB)。修改后重启服务。网页的单批次超时设置优先于环境默认值。
|
||||||
|
|
||||||
批次、任务元数据和报告偏好保存在现有 SQLite 数据库的 `automation_records`、`automation_jobs` 表。清空任务历史同时清除批次与执行元数据;已导出的文件保留。
|
批次、任务元数据和报告偏好保存在现有 SQLite 数据库的 `automation_records`、`automation_jobs` 表。全局“清空任务历史”会清除批次与执行元数据;它与上文的单批次删除不同,已导出的文件会保留。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# ARTEMIS-Eapil 1.0.1
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- Pro 模式兼容模型返回的字符串元素编号和字符串坐标,修复点击、长按及文本输入被错误拒绝的问题;保留越界检查和坐标目标描述校验。
|
||||||
|
- 模型未调用工具时提供纠正提示;连续无有效调用或参数校验失败默认最多 3 次,耗尽后明确失败,避免持续空转。可通过 `ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS` 调整。
|
||||||
|
- 历史任务和当前队列的任务 ID 支持选中文字、点击复制及键盘复制;成功或失败均有提示,复制不再触发打开任务。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 后端默认测试范围共 2146 项通过、2 项跳过;路径测试在普通环境单独执行,其余测试使用隔离运行目录。
|
||||||
|
- 141 项前端测试通过,生产构建通过。
|
||||||
|
- Ruff、质量基线、核心类型检查通过;内置浏览器验证任务 ID 拖选、复制提示和实际剪贴板内容。
|
||||||
|
|
||||||
|
## 安装与升级
|
||||||
|
|
||||||
|
Windows 解压源码包后运行 `start.bat`,国内网络使用 `start-cn.bat`。首次运行会安装依赖并构建网页。
|
||||||
|
|
||||||
|
Git 安装用户可通过启动脚本检查更新。升级后刷新网页;需要更新 AI 客户端的 MCP 时重新加载 MCP。已启动的任务不会热更新代码,新任务加载新版实现。
|
||||||
|
|
||||||
|
源码包不包含 `.env`、虚拟环境、依赖目录、编译产物或任务数据。`SHA256SUMS.txt` 提供附件校验值。
|
||||||
|
|
||||||
|
## 已知问题
|
||||||
|
|
||||||
|
- 部分 Responses 网关返回的最终校验 JSON 未被结构化结果接收环节采纳,可能出现模型文本报告通过、系统记录为 `inconclusive` 的不一致。本版尚未修复,执行完成不等于验证通过。
|
||||||
|
- Pro 模式在计划全部完成后,仍可能反复输出完成说明、调用笔记或观察工具,导致额外收尾耗时。本版的连续无有效调用保护不能消除所有此类循环。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# ARTEMIS-Eapil 1.1.0
|
||||||
|
|
||||||
|
## 新增与改进
|
||||||
|
|
||||||
|
- 批量任务按新版 Excel 模板读取 `Artemis提示词` 和逐项 `任务配置(JSON)`;`前置约束提取` 用于记录元数据,不拼接到提示词。可在网页查看、暂停、恢复或删除非活跃批次。
|
||||||
|
- 运行中的任务可提交指导;指导入口移至工作区顶部,默认收起。任务流程区区分“已提交、待领取”和“执行端已领取”,避免误把提交当作操作完成。
|
||||||
|
- 报告页面改进失败原因与 Markdown 排版;Word/Excel 使用更适合中文阅读的样式。可分别下载有序截图、录屏片段或整份任务资料。
|
||||||
|
- MCP 同步支持 `pausing` 状态、指导领取记录和批次删除;随源码分发的 Skill 说明如何核对实际暂停、指导效果及设备排队恢复。
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- Pro 的模型等待与只读诊断现在能响应暂停:取消当前等待,释放设备;恢复后重新观察页面,不执行暂停前规划的点击。已开始的设备动作仍须先安全结束。
|
||||||
|
- 日志分析工具校验并归一化模型给出的数字字符串,避免 `read_logs`、`search_logs` 因参数类型错误反复失败。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 后端默认测试 2162 项通过、6 项跳过、8 项未纳入默认范围;前端 148 项通过,生产构建成功。
|
||||||
|
- Ruff 格式及静态检查、质量基线、核心类型检查、Skill 校验通过。暂停/恢复与 MCP 状态进行了隔离回归;没有重新启动已取消的真实设备任务。
|
||||||
|
|
||||||
|
## 安装与升级
|
||||||
|
|
||||||
|
Windows 用户运行 `start.bat`,国内网络使用 `start-cn.bat`。Git 克隆安装在无本地冲突时按启动脚本快进更新;ZIP 安装继续使用解压目录。升级后刷新网页并重新加载 AI 客户端的 MCP 连接,新工具签名及 Skill 文件才会生效。已启动的任务不会热更新。
|
||||||
|
|
||||||
|
源码附件不含 `.env`、虚拟环境、依赖目录、编译产物或任务数据,使用 `SHA256SUMS.txt` 校验。执行完成仍不等于测试验证通过,检查实际截图、步骤和验证摘要。
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# ARTEMIS-Eapil 1.1.1
|
||||||
|
|
||||||
|
## 修复与改进
|
||||||
|
|
||||||
|
- 发布源码包现在包含干净的浅层 Git 工作树,跟踪 Gitea 的 `main`。解压后再次运行 Windows 启动脚本可快进拉取更新;构建输出、虚拟环境、个人配置和本机 Git 凭据不随包发布。
|
||||||
|
- `start-cn.bat` 提供中文帮助、启动说明和安装/更新关键提示,继续使用国内镜像。可运行 `start-cn.bat -Help` 查看参数。
|
||||||
|
- 新安装默认 Responses 接口地址改为 `https://api-n-cd.playones.com/v1`。已经保存到 `.env` 的地址和密钥保持原样。
|
||||||
|
|
||||||
|
## 安装与升级
|
||||||
|
|
||||||
|
下载本版源码 ZIP 并解压,运行 `start.bat` 或 `start-cn.bat`。1.1.0 及更早的 ZIP 本身没有 `.git`,无法自动升级;请下载本版源码包一次,之后可用启动脚本检查更新。首次运行会创建 `.venv`、安装依赖并构建前端,重新加载 AI 客户端的 MCP 后使用新版实现。
|
||||||
|
|
||||||
|
更新采用 Git 快进;本地修改、分支分叉或任务运行中均不会覆盖。附件提供 `SHA256SUMS.txt` 用于校验源码包。
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# ARTEMIS-Eapil 1.1.2
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- Windows 一键脚本在依赖同步后,为已检测到的 AI 客户端安装或更新 ARTEMIS MCP。旧配置指向其他解压目录时,会迁移到当前目录,并更新 Python、启动参数、环境路径和工具清单。
|
||||||
|
- 同步保留已有密钥、禁用状态、其他 MCP 服务及自定义设置。自定义启动命令会提示跳过;配置无法安全解析时明确停止启动,原文件保持不变。仅需网页时可用 `-SkipMcpSync`。
|
||||||
|
- 源码升级后会重新载入新版启动脚本再同步 MCP。客户端需重新加载 MCP 或重启,才能让已运行的旧 MCP 进程退出。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
下载发布附件 `artemis-eapil-1.1.2-source.zip`,解压后运行 `start.bat` 或国内镜像入口 `start-cn.bat`。附件包含精简 Git 仓库,后续可通过启动脚本检查更新。Gitea 自动生成的“源代码 ZIP”没有 `.git`,不能通过脚本拉取更新。
|
||||||
|
|
||||||
|
后端默认测试 2170 项通过、6 项跳过;Ruff、质量基线、核心类型检查通过。附件同时提供 SHA-256 校验文件。
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# ARTEMIS-Eapil 1.2.0
|
||||||
|
|
||||||
|
## 工作区改进
|
||||||
|
|
||||||
|
- 批量任务在队列和历史中显示“用例编号 + 测试用例”;悬停标题可查看完整提示词。任务状态、完成时间、总耗时和失败原因更易辨认。
|
||||||
|
- 超时失败会写明原因和批次时限。单任务资料 ZIP 以用例编号命名目录。
|
||||||
|
- 删除已结束的批次会清理关联任务、步骤、截图、录屏和报告,包括 MCP 导出副本;仍被其他任务引用的截图会保留。正在执行或排队的批次不能删除。
|
||||||
|
- 网页或 MCP 提交指导时,中断当前模型思考并重新观察设备。已开始的设备动作安全结束;流程显示指导提交、领取和后续动作。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
下载附件 `artemis-eapil-1.2.0-source.zip`,解压后运行 `start.bat`;国内网络可运行 `start-cn.bat`。附件包含精简 Git 仓库,可供后续启动脚本检查更新。Gitea 自动生成的源码 ZIP 不含 `.git`,请使用发布附件。
|
||||||
|
|
||||||
|
升级后刷新网页,并在 AI 客户端重新加载 MCP。验证:Python 默认测试 2177 项通过、6 项跳过;前端 149 项通过且生产构建成功;Ruff、质量基线和核心类型检查通过。
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# ARTEMIS-Eapil 1.2.1
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- 批量任务达到执行超时后,该用例记录为失败,不再自动暂停整批。当前用例结算后继续执行下一项;开启“失败即停止”的批次仍会跳过剩余项。排队等待不计入执行超时。
|
||||||
|
- Flash 连续收到无工具调用的回复时,最多尝试 3 次并给出明确失败原因,避免持续空转和重复计费。重试会保留设备观察与用户指导,但不把模型的无效回复送回上下文。可通过 `ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS` 调整上限。
|
||||||
|
|
||||||
|
模型请求中的相同缓存 token 数量通常是提示词前缀缓存命中,不能据此判断缓存造成循环。本版针对无工具调用的执行循环建立退出条件。
|
||||||
|
|
||||||
|
## 安装与验证
|
||||||
|
|
||||||
|
请下载附件 `artemis-eapil-1.2.1-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。附件包含精简 Git 仓库,可供后续启动脚本检查更新。Gitea 自动生成的源码 ZIP 不含 `.git`,请使用发布附件。升级后重启 ARTEMIS 服务、刷新网页并重新加载 AI 客户端的 MCP。
|
||||||
|
|
||||||
|
验证:Python 默认测试 2182 项通过、6 项跳过;Ruff、质量基线和核心类型检查通过。
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# ARTEMIS-Eapil 1.2.2
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- 任务报告区分实际执行失败与可恢复的诊断警告。屏幕唤醒锁清理超时、步骤视觉摘要重试不再被误写为任务或模型请求超时;真实终止异常仍会显示。
|
||||||
|
- 旧屏幕唤醒锁清理遇到设备锁繁忙时快速跳过,避免队列任务因清理动作等待或连续失败。长驻服务负责后续清理。
|
||||||
|
- Flash 步骤视觉摘要使用独立超时,默认 25 秒,可通过 `ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS` 设置,不再占用主模型请求的 300 秒超时额度。
|
||||||
|
|
||||||
|
## 安装与验证
|
||||||
|
|
||||||
|
下载附件 `artemis-eapil-1.2.2-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。附件含精简 Git 仓库,后续可由启动脚本检查更新。升级后重启 ARTEMIS 服务、刷新网页并重新加载 AI 客户端的 MCP。
|
||||||
|
|
||||||
|
验证:Python 默认测试 2191 项通过、2 项跳过、8 项按标记排除;Ruff、质量基线和核心类型检查通过。两项真实模拟器 Flash 批量任务均完成;干净环境未复现原先的锁争用,锁繁忙与报告归因通过回归测试覆盖。
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# ARTEMIS-Eapil 1.2.3
|
||||||
|
|
||||||
|
## 更新
|
||||||
|
|
||||||
|
- 视觉步骤摘要的单次模型请求默认超时从 25 秒延长至 120 秒;任务收尾不会再在 30 秒时提前取消仍在进行的摘要。可通过 `ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS` 调整。
|
||||||
|
- Pro 与 Flash 共用的步骤摘要服务现在都会读取该超时配置。
|
||||||
|
- 本版本还包含尚未发布的 Responses 消息与视频导出修复,以及设备观察稳定性改进。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
下载 `artemis-eapil-1.2.3-source.zip`,解压后运行 `start.bat`,国内网络使用 `start-cn.bat`。附件包含可继续更新的 Git 仓库。已有安装可按原方式运行启动脚本更新;升级后重启 ARTEMIS 服务,并重新加载 MCP。
|
||||||
|
|
||||||
|
验证:Python 默认测试 2207 项通过、2 项跳过、8 项按标记排除;Ruff、质量基线、核心类型检查、前端 149 项测试及构建均通过。
|
||||||
@@ -67,10 +67,16 @@ async def _request(method: str, route: str, **kwargs) -> Any:
|
|||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
async def _download(route: str, filename: str) -> dict:
|
async def _download(
|
||||||
|
route: str,
|
||||||
|
filename: str,
|
||||||
|
*,
|
||||||
|
owner_kind: Literal["sessions", "batches"] | None = None,
|
||||||
|
owner_id: str | None = None,
|
||||||
|
) -> dict:
|
||||||
url = await _base_url()
|
url = await _base_url()
|
||||||
EXPORT_DIR.mkdir(parents=True, exist_ok=True)
|
directory = EXPORT_DIR / owner_kind / _id(owner_id) if owner_kind else EXPORT_DIR
|
||||||
path = EXPORT_DIR / f"{uuid4().hex[:12]}-{filename}"
|
path = directory / f"{uuid4().hex[:12]}-{filename}"
|
||||||
partial = path.with_suffix(path.suffix + ".part")
|
partial = path.with_suffix(path.suffix + ".part")
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(base_url=url, timeout=120, trust_env=False) as client:
|
async with httpx.AsyncClient(base_url=url, timeout=120, trust_env=False) as client:
|
||||||
@@ -78,6 +84,7 @@ async def _download(route: str, filename: str) -> dict:
|
|||||||
if response.is_error:
|
if response.is_error:
|
||||||
await response.aread()
|
await response.aread()
|
||||||
_check(response)
|
_check(response)
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
with partial.open("wb") as stream:
|
with partial.open("wb") as stream:
|
||||||
async for chunk in response.aiter_bytes():
|
async for chunk in response.aiter_bytes():
|
||||||
stream.write(chunk)
|
stream.write(chunk)
|
||||||
@@ -176,7 +183,9 @@ async def mobile_task_file(
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def mobile_batch(
|
async def mobile_batch(
|
||||||
action: Literal["create", "list", "get", "lookup", "start", "pause", "resume", "retry_failed"],
|
action: Literal[
|
||||||
|
"create", "list", "get", "lookup", "start", "pause", "resume", "retry_failed", "delete"
|
||||||
|
],
|
||||||
batch_id: str | None = None,
|
batch_id: str | None = None,
|
||||||
request_id: str | None = None,
|
request_id: str | None = None,
|
||||||
batch: dict[str, Any] | None = None,
|
batch: dict[str, Any] | None = None,
|
||||||
@@ -184,7 +193,10 @@ async def mobile_batch(
|
|||||||
"""Create, execute and manage durable batches on the unified device queue.
|
"""Create, execute and manage durable batches on the unified device queue.
|
||||||
|
|
||||||
create requires a unique request_id and batch={name,device_serial,cases:[{name,goal,
|
create requires a unique request_id and batch={name,device_serial,cases:[{name,goal,
|
||||||
expected?,app?,preconditions?,parameters?:{key:primitive},tags?}],profile?:flash|pro,
|
case_id?,category?,subcategory?,preconditions?,task_config?:{profile?,model?,
|
||||||
|
reasoning_effort?,device_serial?,verification_level?,explorer_mode?,
|
||||||
|
locked_app_package?,record_video?,analyze_video?,request_timeout_seconds?,
|
||||||
|
max_retries?,retry_interval_seconds?,timeout_seconds?}}],profile?:flash|pro,
|
||||||
formats?:[docx,xlsx],stop_on_failure?:false,timeout_seconds?:1800}. Required device
|
formats?:[docx,xlsx],stop_on_failure?:false,timeout_seconds?:1800}. Required device
|
||||||
must be user-selected. Creation validates and saves a DRAFT; start executes it.
|
must be user-selected. Creation validates and saves a DRAFT; start executes it.
|
||||||
Reuse the same request_id and exact payload after a connection failure; never invent
|
Reuse the same request_id and exact payload after a connection failure; never invent
|
||||||
@@ -193,6 +205,8 @@ async def mobile_batch(
|
|||||||
use mobile_manage_task(stop) to stop that task. resume never repeats finished cases.
|
use mobile_manage_task(stop) to stop that task. resume never repeats finished cases.
|
||||||
retry_failed creates a new draft only after an explicit rerun request; it is not
|
retry_failed creates a new draft only after an explicit rerun request; it is not
|
||||||
transport-idempotent, so after an uncertain response use list instead of retrying.
|
transport-idempotent, so after an uncertain response use list instead of retrying.
|
||||||
|
delete removes a non-active batch and its task sessions, reports and files after
|
||||||
|
an explicit user request. Confirm the batch ID with get first; deletion is irreversible.
|
||||||
Restarted batches remain paused. No automatic failed-case retries or action replay.
|
Restarted batches remain paused. No automatic failed-case retries or action replay.
|
||||||
"""
|
"""
|
||||||
if action == "create":
|
if action == "create":
|
||||||
@@ -207,6 +221,8 @@ async def mobile_batch(
|
|||||||
route = f"/api/automation/batches/{_id(batch_id)}"
|
route = f"/api/automation/batches/{_id(batch_id)}"
|
||||||
if action == "get":
|
if action == "get":
|
||||||
return await _request("GET", route)
|
return await _request("GET", route)
|
||||||
|
if action == "delete":
|
||||||
|
return await _request("DELETE", route)
|
||||||
if action in {"start", "resume"}:
|
if action in {"start", "resume"}:
|
||||||
current = await _request("GET", route)
|
current = await _request("GET", route)
|
||||||
if current["status"] == "running":
|
if current["status"] == "running":
|
||||||
@@ -225,7 +241,7 @@ async def mobile_search_history(
|
|||||||
page: int = 1,
|
page: int = 1,
|
||||||
size: int = 30,
|
size: int = 30,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Search ALL persisted terminal tasks by name, goal, ID or tags, with pagination.
|
"""Search ALL persisted terminal tasks by name, prompt, ID or case metadata, with pagination.
|
||||||
|
|
||||||
start/end are Unix seconds, inclusive/exclusive; convert the user's local dates
|
start/end are Unix seconds, inclusive/exclusive; convert the user's local dates
|
||||||
before calling. Status: completed/success/failed/cancelled/interrupted/skipped.
|
before calling. Status: completed/success/failed/cancelled/interrupted/skipped.
|
||||||
@@ -243,11 +259,12 @@ async def mobile_search_history(
|
|||||||
async def mobile_export_report(
|
async def mobile_export_report(
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
batch_id: str | None = None,
|
batch_id: str | None = None,
|
||||||
formats: list[Literal["docx", "xlsx"]] | None = None,
|
formats: list[Literal["docx", "xlsx", "screenshots", "video", "all"]] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Export a task's Word/Excel reports or a batch ZIP to local files on the MCP host.
|
"""Export Word/Excel, ordered screenshots, saved recording segments or all task material.
|
||||||
|
|
||||||
Provide exactly one of session_id/batch_id. Task formats omitted uses saved defaults;
|
Provide exactly one of session_id/batch_id. Task formats omitted uses saved defaults;
|
||||||
|
screenshots/video/all produce ZIPs; video requires recording to have been enabled.
|
||||||
batch ZIP uses its frozen formats and includes summary + individual case reports.
|
batch ZIP uses its frozen formats and includes summary + individual case reports.
|
||||||
Returns absolute paths, download URLs and sizes; task results also include structured
|
Returns absolute paths, download URLs and sizes; task results also include structured
|
||||||
verdict/evidence. Running exports are snapshots. 'completed' is not proof of PASS;
|
verdict/evidence. Running exports are snapshots. 'completed' is not proof of PASS;
|
||||||
@@ -260,7 +277,12 @@ async def mobile_export_report(
|
|||||||
return {
|
return {
|
||||||
"batch_id": bid,
|
"batch_id": bid,
|
||||||
"files": [
|
"files": [
|
||||||
await _download(f"/api/automation/batches/{bid}/archive", f"batch-{bid}.zip")
|
await _download(
|
||||||
|
f"/api/automation/batches/{bid}/archive",
|
||||||
|
f"batch-{bid}.zip",
|
||||||
|
owner_kind="batches",
|
||||||
|
owner_id=bid,
|
||||||
|
)
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
sid = _id(session_id)
|
sid = _id(session_id)
|
||||||
@@ -269,11 +291,23 @@ async def mobile_export_report(
|
|||||||
if formats is not None
|
if formats is not None
|
||||||
else (await _request("GET", "/api/automation/preferences"))["formats"]
|
else (await _request("GET", "/api/automation/preferences"))["formats"]
|
||||||
)
|
)
|
||||||
if not chosen or len(chosen) > 2 or any(fmt not in {"docx", "xlsx"} for fmt in chosen):
|
if (
|
||||||
raise ValueError("formats 至少选择 docx 或 xlsx,最多两项")
|
not chosen
|
||||||
|
or len(chosen) > 5
|
||||||
|
or any(fmt not in {"docx", "xlsx", "screenshots", "video", "all"} for fmt in chosen)
|
||||||
|
):
|
||||||
|
raise ValueError("formats 需选择 docx、xlsx、screenshots、video 或 all")
|
||||||
report = await _request("GET", f"/api/automation/reports/{sid}")
|
report = await _request("GET", f"/api/automation/reports/{sid}")
|
||||||
|
from apps.admin_console.services.report_export import archive_case_name
|
||||||
|
|
||||||
|
label = archive_case_name(report)
|
||||||
files = [
|
files = [
|
||||||
await _download(f"/api/automation/reports/{sid}/{fmt}", f"task-{sid}.{fmt}")
|
await _download(
|
||||||
|
f"/api/automation/reports/{sid}/{fmt}",
|
||||||
|
f"task-{sid}.{fmt}" if fmt in {"docx", "xlsx"} else f"{label}-{fmt}.zip",
|
||||||
|
owner_kind="sessions",
|
||||||
|
owner_id=sid,
|
||||||
|
)
|
||||||
for fmt in dict.fromkeys(chosen)
|
for fmt in dict.fromkeys(chosen)
|
||||||
]
|
]
|
||||||
return {"report": report, "files": files}
|
return {"report": report, "files": files}
|
||||||
@@ -34,6 +34,14 @@ from artemis.runtime import (
|
|||||||
trace_store,
|
trace_store,
|
||||||
)
|
)
|
||||||
from artemis.runtime.process_probe import pid_is_alive
|
from artemis.runtime.process_probe import pid_is_alive
|
||||||
|
from artemis.runtime.task_control import (
|
||||||
|
add_instruction,
|
||||||
|
control_dir,
|
||||||
|
list_instructions,
|
||||||
|
read_state,
|
||||||
|
request_pause,
|
||||||
|
request_resume,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -51,6 +59,34 @@ def _find_data_engine_db() -> str | None:
|
|||||||
return db_path if os.path.exists(db_path) else None
|
return db_path if os.path.exists(db_path) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _web_session_status(trace_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Web submissions have a session row but no MCP launcher status.json."""
|
||||||
|
db_path = _find_data_engine_db()
|
||||||
|
if not db_path:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT status, pid, start_time, end_time, device_info "
|
||||||
|
"FROM sessions WHERE session_id = ? ORDER BY start_time DESC LIMIT 1",
|
||||||
|
(trace_id,),
|
||||||
|
).fetchone()
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
logger.warning("Could not read web session %s: %s", trace_id, exc)
|
||||||
|
return None
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
data = dict(row)
|
||||||
|
if data.get("device_info"):
|
||||||
|
try:
|
||||||
|
device = json.loads(data["device_info"])
|
||||||
|
data["device_serial"] = device.get("device_id") if isinstance(device, dict) else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def _pid_alive(pid: int | None) -> bool:
|
def _pid_alive(pid: int | None) -> bool:
|
||||||
if not pid:
|
if not pid:
|
||||||
return False
|
return False
|
||||||
@@ -143,8 +179,8 @@ def _reconcile_task_state(
|
|||||||
liveness_failed = (
|
liveness_failed = (
|
||||||
current_status == "failed" and status_data.get("error") == _LIVENESS_FAILURE_ERROR
|
current_status == "failed" and status_data.get("error") == _LIVENESS_FAILURE_ERROR
|
||||||
)
|
)
|
||||||
if db_status in ("completed", "success", "failed", "cancelled") and (
|
if db_status in ("completed", "success", "failed", "cancelled", "interrupted") and (
|
||||||
current_status in ("running", "pending") or liveness_failed
|
current_status in ("running", "pending", "task_paused", "resuming") or liveness_failed
|
||||||
):
|
):
|
||||||
canonical = "completed" if db_status in ("completed", "success") else db_status
|
canonical = "completed" if db_status in ("completed", "success") else db_status
|
||||||
if current_status != canonical:
|
if current_status != canonical:
|
||||||
@@ -156,7 +192,7 @@ def _reconcile_task_state(
|
|||||||
status_data["end_time"] = time.time()
|
status_data["end_time"] = time.time()
|
||||||
dirty = True
|
dirty = True
|
||||||
is_alive = False
|
is_alive = False
|
||||||
elif current_status in ("running", "pending"):
|
elif current_status in ("running", "pending", "task_paused", "resuming"):
|
||||||
if not pid:
|
if not pid:
|
||||||
# No pid recorded (daemon dispatch never writes one): liveness cannot be
|
# No pid recorded (daemon dispatch never writes one): liveness cannot be
|
||||||
# inferred, so the task is assumed alive -- matching the historical
|
# inferred, so the task is assumed alive -- matching the historical
|
||||||
@@ -225,13 +261,13 @@ def mobile_manage_task(
|
|||||||
"""Manages the lifecycle and retrieves the status of a background mobile automation task.
|
"""Manages the lifecycle and retrieves the status of a background mobile automation task.
|
||||||
|
|
||||||
This is your primary diagnostic and control tool for tasks started by
|
This is your primary diagnostic and control tool for tasks started by
|
||||||
`mobile_run_task`. You MUST use it to poll the task status when your
|
`mobile_run_task` or the web workspace. You MUST use it to poll the task status when your
|
||||||
1-minute fallback timer triggers (as required by `mobile_run_task`). You
|
1-minute fallback timer triggers (as required by `mobile_run_task`). You
|
||||||
can also steer a subagent that is stuck or off-track, or abort a task.
|
can also steer a subagent that is stuck or off-track, or abort a task.
|
||||||
|
|
||||||
### Actions
|
### Actions
|
||||||
- **'status'**: Returns `trace_id`, `status` ('running'/'completed'/'failed'/
|
- **'status'**: Returns `trace_id`, `status` ('running'/'pausing'/'task_paused'/
|
||||||
'cancelled'), `device_serial` (which phone owns this task in multi-device
|
'resuming'/'completed'/'failed'/'cancelled'/'interrupted'), `device_serial` (which phone owns this task in multi-device
|
||||||
setups), `task_desc`, `model`, `elapsed_seconds`, a `test_summary` (when
|
setups), `task_desc`, `model`, `elapsed_seconds`, a `test_summary` (when
|
||||||
the run declared verification check items: machine-readable
|
the run declared verification check items: machine-readable
|
||||||
passed/failed/inconclusive/unchecked counts plus failed-item details —
|
passed/failed/inconclusive/unchecked counts plus failed-item details —
|
||||||
@@ -239,14 +275,24 @@ def mobile_manage_task(
|
|||||||
current turn, latest thought/action — intervene if turns climb without
|
current turn, latest thought/action — intervene if turns climb without
|
||||||
progress or the thought indicates it is stuck; Pro: the active task plan
|
progress or the thought indicates it is stuck; Pro: the active task plan
|
||||||
— check it still aligns with your goal).
|
— check it still aligns with your goal).
|
||||||
|
- **'pause'**: Requests a pause, returning `pausing` until the runner releases
|
||||||
|
the device. Pro can interrupt a pending model response or read-only diagnostic;
|
||||||
|
a device action already in progress must finish safely first. Check status
|
||||||
|
for `task_paused` before assuming the device is free.
|
||||||
|
- **'resume'**: Queues a paused task for its device again; optional `instruction`
|
||||||
|
provides guidance before the next action.
|
||||||
- **'inject_instruction'**: Injects real-time guidance mid-flight when the
|
- **'inject_instruction'**: Injects real-time guidance mid-flight when the
|
||||||
subagent errs, stalls, or loops. Applied at the start of the next
|
subagent errs, stalls, or loops. An in-flight model response or read-only
|
||||||
planning turn (Pro) or the next reactive loop (Flash). Requires
|
helper is cancelled immediately, then the runner observes the device again
|
||||||
|
and consumes the new guidance. An already-started device action finishes
|
||||||
|
safely before the runner changes direction. Requires
|
||||||
`instruction` (unless `release_loop=True`). To end a continuous
|
`instruction` (unless `release_loop=True`). To end a continuous
|
||||||
monitoring task ([Loop:continuous] milestone) gracefully, you MUST pass
|
monitoring task ([Loop:continuous] milestone) gracefully, you MUST pass
|
||||||
`release_loop=True` — this is the only signal that unlocks the
|
`release_loop=True` — this is the only signal that unlocks the
|
||||||
milestone's completion; natural-language "please stop" phrasing in
|
milestone's completion; natural-language "please stop" phrasing in
|
||||||
`instruction` is NOT interpreted as a stop signal.
|
`instruction` is NOT interpreted as a stop signal.
|
||||||
|
- **'guidance_history'**: Lists submitted instructions with `pending` or
|
||||||
|
`consumed` status; a submitted instruction is not proof it was applied.
|
||||||
- **'stop'**: Forcefully terminates the subagent, immediately halting
|
- **'stop'**: Forcefully terminates the subagent, immediately halting
|
||||||
device interactions and releasing the device. Use when the task is done,
|
device interactions and releasing the device. Use when the task is done,
|
||||||
irreparably broken, or running out of control. Prefer
|
irreparably broken, or running out of control. Prefer
|
||||||
@@ -254,15 +300,16 @@ def mobile_manage_task(
|
|||||||
should wind down cleanly instead of being killed.
|
should wind down cleanly instead of being killed.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
action: `"status"`, `"inject_instruction"`, or `"stop"`.
|
action: `"status"`, `"pause"`, `"resume"`, `"inject_instruction"`,
|
||||||
trace_id: The task's session identifier from `mobile_run_task`.
|
`"guidance_history"`, or `"stop"`.
|
||||||
|
trace_id: The task's session identifier from `mobile_run_task` or the web UI.
|
||||||
instruction: Guidance string; required for `inject_instruction`
|
instruction: Guidance string; required for `inject_instruction`
|
||||||
(optional when `release_loop=True`), omit otherwise.
|
(optional when `release_loop=True`), omit otherwise.
|
||||||
release_loop: With `inject_instruction`: explicit user stop signal
|
release_loop: With `inject_instruction`: explicit user stop signal
|
||||||
that authorizes the subagent to complete continuous monitoring
|
that authorizes the subagent to complete continuous monitoring
|
||||||
loops and finish the task gracefully.
|
loops and finish the task gracefully.
|
||||||
"""
|
"""
|
||||||
status_data = trace_store.read_status(trace_id)
|
status_data = trace_store.read_status(trace_id) or _web_session_status(trace_id)
|
||||||
if not status_data:
|
if not status_data:
|
||||||
return {
|
return {
|
||||||
"trace_id": trace_id,
|
"trace_id": trace_id,
|
||||||
@@ -271,6 +318,15 @@ def mobile_manage_task(
|
|||||||
}
|
}
|
||||||
|
|
||||||
current_status, pid, is_alive = _reconcile_task_state(trace_id, status_data)
|
current_status, pid, is_alive = _reconcile_task_state(trace_id, status_data)
|
||||||
|
control_status = read_state(trace_id).get("status") if is_alive else None
|
||||||
|
if control_status in ("task_paused", "resuming") and current_status in ("running", "pending"):
|
||||||
|
current_status = control_status
|
||||||
|
elif (
|
||||||
|
is_alive
|
||||||
|
and current_status in ("running", "pending")
|
||||||
|
and (control_dir(trace_id) / "pause.request").exists()
|
||||||
|
):
|
||||||
|
current_status = "pausing"
|
||||||
|
|
||||||
trace_dir = trace_store.get_trace_dir(trace_id)
|
trace_dir = trace_store.get_trace_dir(trace_id)
|
||||||
|
|
||||||
@@ -368,7 +424,7 @@ def mobile_manage_task(
|
|||||||
pass
|
pass
|
||||||
break
|
break
|
||||||
|
|
||||||
if current_status in ("running", "pending") and is_alive:
|
if current_status in ("running", "pending", "task_paused", "resuming") and is_alive:
|
||||||
progress: dict[str, Any] = {}
|
progress: dict[str, Any] = {}
|
||||||
|
|
||||||
project_root = env_utils.get_project_root()
|
project_root = env_utils.get_project_root()
|
||||||
@@ -483,8 +539,47 @@ def mobile_manage_task(
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
elif action == "guidance_history":
|
||||||
|
return {"trace_id": trace_id, "instructions": list_instructions(trace_id)}
|
||||||
|
|
||||||
|
elif action == "pause":
|
||||||
|
if current_status == "pausing":
|
||||||
|
return {
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"status": "pausing",
|
||||||
|
"message": "Pause is still pending; check status for task_paused.",
|
||||||
|
}
|
||||||
|
if current_status not in ("running", "pending", "resuming") or not is_alive:
|
||||||
|
return {
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"status": current_status,
|
||||||
|
"message": "Task is not running.",
|
||||||
|
}
|
||||||
|
request_pause(trace_id)
|
||||||
|
return {
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"status": "pausing",
|
||||||
|
"message": "Pause requested; wait for task_paused and device release.",
|
||||||
|
}
|
||||||
|
|
||||||
|
elif action == "resume":
|
||||||
|
if current_status != "task_paused" or not is_alive:
|
||||||
|
return {
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"status": current_status,
|
||||||
|
"message": "Task is not paused.",
|
||||||
|
}
|
||||||
|
if instruction:
|
||||||
|
add_instruction(trace_id, instruction, source="mcp")
|
||||||
|
request_resume(trace_id)
|
||||||
|
return {
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"status": "resuming",
|
||||||
|
"message": "Queued for the device again.",
|
||||||
|
}
|
||||||
|
|
||||||
elif action == "stop":
|
elif action == "stop":
|
||||||
if current_status not in ("running", "pending"):
|
if current_status not in ("running", "pending", "pausing", "task_paused", "resuming"):
|
||||||
return {
|
return {
|
||||||
"trace_id": trace_id,
|
"trace_id": trace_id,
|
||||||
"status": current_status,
|
"status": current_status,
|
||||||
@@ -579,7 +674,7 @@ def mobile_manage_task(
|
|||||||
"message": "Missing required argument 'instruction' for action 'inject_instruction'.",
|
"message": "Missing required argument 'instruction' for action 'inject_instruction'.",
|
||||||
}
|
}
|
||||||
|
|
||||||
if current_status not in ("running", "pending"):
|
if current_status not in ("running", "pending", "pausing", "task_paused", "resuming"):
|
||||||
return {
|
return {
|
||||||
"trace_id": trace_id,
|
"trace_id": trace_id,
|
||||||
"status": current_status,
|
"status": current_status,
|
||||||
@@ -593,24 +688,18 @@ def mobile_manage_task(
|
|||||||
"message": f"Trace directory not found: {trace_dir}",
|
"message": f"Trace directory not found: {trace_dir}",
|
||||||
}
|
}
|
||||||
|
|
||||||
instruction_path = os.path.join(trace_dir, "injected_instruction.json")
|
|
||||||
try:
|
try:
|
||||||
with open(instruction_path, "w", encoding="utf-8") as f:
|
add_instruction(trace_id, instruction, release_loop=release_loop, source="mcp")
|
||||||
json.dump(
|
if (
|
||||||
{
|
current_status == "task_paused"
|
||||||
"instruction": instruction,
|
or (control_dir(trace_id) / "pause.request").exists()
|
||||||
"release_loop": bool(release_loop),
|
):
|
||||||
"timestamp": time.time(),
|
request_resume(trace_id)
|
||||||
"status": "pending",
|
|
||||||
},
|
|
||||||
f,
|
|
||||||
indent=2,
|
|
||||||
)
|
|
||||||
suffix = " (with release_loop stop signal)" if release_loop else ""
|
suffix = " (with release_loop stop signal)" if release_loop else ""
|
||||||
return {
|
return {
|
||||||
"trace_id": trace_id,
|
"trace_id": trace_id,
|
||||||
"status": current_status,
|
"status": current_status,
|
||||||
"message": f"Successfully injected instruction{suffix}: '{instruction}'",
|
"message": f"Guidance accepted and current model step interrupted{suffix}; check guidance_history for consumption.",
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
@@ -623,5 +712,5 @@ def mobile_manage_task(
|
|||||||
return {
|
return {
|
||||||
"trace_id": trace_id,
|
"trace_id": trace_id,
|
||||||
"status": current_status,
|
"status": current_status,
|
||||||
"message": f"Invalid action '{action}'. Supported actions are 'status', 'stop', and 'inject_instruction'.",
|
"message": f"Invalid action '{action}'. Supported actions are 'status', 'pause', 'resume', 'stop', and 'inject_instruction'.",
|
||||||
}
|
}
|
||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "artemis"
|
name = "artemis"
|
||||||
version = "1.0.0"
|
version = "1.2.3"
|
||||||
description = "Autonomous AI Assistant for Mobile Automation"
|
description = "Autonomous AI Assistant for Mobile Automation"
|
||||||
|
|
||||||
authors = [
|
authors = [
|
||||||
|
|||||||
@@ -1,42 +1,48 @@
|
|||||||
# Python bootstrap shared by the Windows launcher and its isolated checks.
|
# Python bootstrap shared by the Windows launcher and its isolated checks.
|
||||||
# Keep this file ASCII for Windows PowerShell 5.1.
|
# Save as UTF-8 with BOM so Windows PowerShell 5.1 reads Chinese messages correctly.
|
||||||
|
|
||||||
|
function Get-BootstrapText {
|
||||||
|
param([string]$English, [string]$Chinese)
|
||||||
|
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN') { return $Chinese }
|
||||||
|
return $English
|
||||||
|
}
|
||||||
|
|
||||||
function Initialize-ArtemisPython {
|
function Initialize-ArtemisPython {
|
||||||
param([Parameter(Mandatory=$true)][string]$ProjectRoot)
|
param([Parameter(Mandatory=$true)][string]$ProjectRoot)
|
||||||
|
|
||||||
$ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot -ErrorAction Stop).Path
|
$ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot -ErrorAction Stop).Path
|
||||||
if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'pyproject.toml'))) {
|
if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'pyproject.toml'))) {
|
||||||
throw 'pyproject.toml is missing. Extract the complete source archive first.'
|
throw (Get-BootstrapText 'pyproject.toml is missing. Extract the complete source archive first.' '缺少 pyproject.toml;请重新解压完整源码包。')
|
||||||
}
|
}
|
||||||
if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'uv.lock'))) {
|
if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'uv.lock'))) {
|
||||||
throw 'uv.lock is missing. Extract the complete source archive first.'
|
throw (Get-BootstrapText 'uv.lock is missing. Extract the complete source archive first.' '缺少 uv.lock;请重新解压完整源码包。')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not (Test-CommandExists 'uv')) {
|
if (-not (Test-CommandExists 'uv')) {
|
||||||
if ($ArtemisDownloads -and $ArtemisDownloads.python_index) {
|
if ($ArtemisDownloads -and $ArtemisDownloads.python_index) {
|
||||||
Write-Host ' [INSTALL] Installing uv from the selected Python mirror...' -ForegroundColor Cyan
|
Write-Host (Get-BootstrapText ' [INSTALL] Installing uv from the selected Python mirror...' ' [安装] 正在从选定的 Python 镜像安装 uv...') -ForegroundColor Cyan
|
||||||
Install-ArtemisUvFromIndex -IndexUrl $ArtemisDownloads.python_index
|
Install-ArtemisUvFromIndex -IndexUrl $ArtemisDownloads.python_index
|
||||||
} else {
|
} else {
|
||||||
Write-Host ' [INSTALL] uv was not found. Installing from the configured installer...' -ForegroundColor Cyan
|
Write-Host (Get-BootstrapText ' [INSTALL] uv was not found. Installing from the configured installer...' ' [安装] 未找到 uv,正在下载安装...') -ForegroundColor Cyan
|
||||||
$installer = Join-Path ([IO.Path]::GetTempPath()) ('artemis-uv-' + [guid]::NewGuid().ToString('N') + '.ps1')
|
$installer = Join-Path ([IO.Path]::GetTempPath()) ('artemis-uv-' + [guid]::NewGuid().ToString('N') + '.ps1')
|
||||||
try {
|
try {
|
||||||
$installerUrl = if ($ArtemisDownloads) { $ArtemisDownloads.uv_installer_url } else { 'https://astral.sh/uv/install.ps1' }
|
$installerUrl = if ($ArtemisDownloads) { $ArtemisDownloads.uv_installer_url } else { 'https://astral.sh/uv/install.ps1' }
|
||||||
if (-not (Invoke-DownloadFile -Uri $installerUrl -OutFile $installer)) {
|
if (-not (Invoke-DownloadFile -Uri $installerUrl -OutFile $installer)) {
|
||||||
throw 'Could not download uv. Check your network/proxy and retry.'
|
throw (Get-BootstrapText 'Could not download uv. Check your network/proxy and retry.' 'uv 下载失败;请检查网络、代理或镜像后重试。')
|
||||||
}
|
}
|
||||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installer | Out-Host
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installer | Out-Host
|
||||||
if ($LASTEXITCODE -ne 0) { throw "uv installation failed (exit $LASTEXITCODE)." }
|
if ($LASTEXITCODE -ne 0) { throw (Get-BootstrapText "uv installation failed (exit $LASTEXITCODE)." "uv 安装失败(退出码 $LASTEXITCODE)。") }
|
||||||
} finally {
|
} finally {
|
||||||
Remove-Item -LiteralPath $installer -Force -ErrorAction SilentlyContinue
|
Remove-Item -LiteralPath $installer -Force -ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Update-EnvironmentPath
|
Update-EnvironmentPath
|
||||||
} else {
|
} else {
|
||||||
Write-Host ' [SKIP] uv is already installed.' -ForegroundColor Green
|
Write-Host (Get-BootstrapText ' [SKIP] uv is already installed.' ' [复用] uv 已安装,跳过下载。') -ForegroundColor Green
|
||||||
}
|
}
|
||||||
if (-not (Test-CommandExists 'uv')) { throw 'uv is still unavailable. Install it from https://astral.sh/uv/ and retry.' }
|
if (-not (Test-CommandExists 'uv')) { throw (Get-BootstrapText 'uv is still unavailable. Install it from https://astral.sh/uv/ and retry.' '仍无法运行 uv;请检查安装并重试。') }
|
||||||
& uv --version | Out-Host
|
& uv --version | Out-Host
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'The installed uv could not run. Repair its installation and retry.' }
|
if ($LASTEXITCODE -ne 0) { throw (Get-BootstrapText 'The installed uv could not run. Repair its installation and retry.' '已安装的 uv 无法运行;请修复安装后重试。') }
|
||||||
|
|
||||||
$venvDir = Join-Path $ProjectRoot '.venv'
|
$venvDir = Join-Path $ProjectRoot '.venv'
|
||||||
$pythonExe = Join-Path $venvDir 'Scripts\python.exe'
|
$pythonExe = Join-Path $venvDir 'Scripts\python.exe'
|
||||||
@@ -45,18 +51,18 @@ function Initialize-ArtemisPython {
|
|||||||
Push-Location -LiteralPath $ProjectRoot
|
Push-Location -LiteralPath $ProjectRoot
|
||||||
try {
|
try {
|
||||||
if (-not (Test-Path -LiteralPath $venvDir)) {
|
if (-not (Test-Path -LiteralPath $venvDir)) {
|
||||||
Write-Host ' [INSTALL] Creating .venv with Python 3.12 (downloaded automatically if needed)...' -ForegroundColor Cyan
|
Write-Host (Get-BootstrapText ' [INSTALL] Creating .venv with Python 3.12 (downloaded automatically if needed)...' ' [安装] 正在创建 Python 3.12 虚拟环境 .venv;缺少 Python 时自动下载...') -ForegroundColor Cyan
|
||||||
& uv venv --python 3.12 $venvDir | Out-Host
|
& uv venv --python 3.12 $venvDir | Out-Host
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'Could not create .venv. Check the download errors above and retry.' }
|
if ($LASTEXITCODE -ne 0) { throw (Get-BootstrapText 'Could not create .venv. Check the download errors above and retry.' '.venv 创建失败;请查看上方下载错误并重试。') }
|
||||||
} else {
|
} else {
|
||||||
Write-Host ' [SKIP] Reusing the existing project .venv.' -ForegroundColor Green
|
Write-Host (Get-BootstrapText ' [SKIP] Reusing the existing project .venv.' ' [复用] 已有项目 .venv,跳过创建。') -ForegroundColor Green
|
||||||
}
|
}
|
||||||
if (-not (Test-Path -LiteralPath $pythonExe)) {
|
if (-not (Test-Path -LiteralPath $pythonExe)) {
|
||||||
throw 'The existing .venv is incomplete. Rename it as a backup, then rerun start.bat.'
|
throw (Get-BootstrapText 'The existing .venv is incomplete. Rename it as a backup, then rerun start.bat.' '已有 .venv 不完整。先重命名备份,再运行 start-cn.bat。')
|
||||||
}
|
}
|
||||||
& $pythonExe -c 'import sys; sys.exit(0 if sys.version_info >= (3, 12) and sys.prefix != sys.base_prefix else 1)' | Out-Host
|
& $pythonExe -c 'import sys; sys.exit(0 if sys.version_info >= (3, 12) and sys.prefix != sys.base_prefix else 1)' | Out-Host
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
throw 'The existing .venv is unusable or older than Python 3.12. Rename it as a backup, then rerun start.bat.'
|
throw (Get-BootstrapText 'The existing .venv is unusable or older than Python 3.12. Rename it as a backup, then rerun start.bat.' '已有 .venv 无法使用或 Python 低于 3.12。先重命名备份,再运行 start-cn.bat。')
|
||||||
}
|
}
|
||||||
|
|
||||||
# A cheap check skips installation when the lock and installed packages agree.
|
# A cheap check skips installation when the lock and installed packages agree.
|
||||||
@@ -64,19 +70,19 @@ function Initialize-ArtemisPython {
|
|||||||
$lockOption = if ($ArtemisDownloads -and $ArtemisDownloads.python_index) { '--frozen' } else { '--locked' }
|
$lockOption = if ($ArtemisDownloads -and $ArtemisDownloads.python_index) { '--frozen' } else { '--locked' }
|
||||||
& uv sync --check $lockOption --no-dev --inexact --quiet 2>$null | Out-Host
|
& uv sync --check $lockOption --no-dev --inexact --quiet 2>$null | Out-Host
|
||||||
if ($LASTEXITCODE -eq 0) {
|
if ($LASTEXITCODE -eq 0) {
|
||||||
Write-Host ' [SKIP] Python dependencies are already ready.' -ForegroundColor Green
|
Write-Host (Get-BootstrapText ' [SKIP] Python dependencies are already ready.' ' [复用] Python 依赖已就绪。') -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Host ' [INSTALL] Synchronizing dependencies from uv.lock...' -ForegroundColor Cyan
|
Write-Host (Get-BootstrapText ' [INSTALL] Synchronizing dependencies from uv.lock...' ' [安装] 正在按 uv.lock 同步 Python 依赖...') -ForegroundColor Cyan
|
||||||
if ($ArtemisDownloads -and $ArtemisDownloads.python_index) {
|
if ($ArtemisDownloads -and $ArtemisDownloads.python_index) {
|
||||||
Install-ArtemisLockedDependencies -PythonExe $pythonExe -IndexUrl $ArtemisDownloads.python_index
|
Install-ArtemisLockedDependencies -PythonExe $pythonExe -IndexUrl $ArtemisDownloads.python_index
|
||||||
} else {
|
} else {
|
||||||
& uv sync --locked --no-dev --inexact | Out-Host
|
& uv sync --locked --no-dev --inexact | Out-Host
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed. Fix the error above and rerun start.bat.' }
|
if ($LASTEXITCODE -ne 0) { throw (Get-BootstrapText 'Dependency installation failed. Fix the error above and rerun start.bat.' '依赖安装失败;请处理上方错误,再运行 start-cn.bat。') }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
& $pythonExe -c 'import artemis, uvicorn, fastapi, langchain_openai' | Out-Host
|
& $pythonExe -c 'import artemis, uvicorn, fastapi, langchain_openai' | Out-Host
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'Python dependency verification failed. The UI will not be started.' }
|
if ($LASTEXITCODE -ne 0) { throw (Get-BootstrapText 'Python dependency verification failed. The UI will not be started.' 'Python 依赖验证失败,网页不会启动。') }
|
||||||
Write-Host ' [OK] Python environment is ready.' -ForegroundColor Green
|
Write-Host (Get-BootstrapText ' [OK] Python environment is ready.' ' [完成] Python 环境已就绪。') -ForegroundColor Green
|
||||||
return $pythonExe
|
return $pythonExe
|
||||||
} finally {
|
} finally {
|
||||||
Pop-Location
|
Pop-Location
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Download settings are process-local; never edit global pip/npm/uv settings.
|
# Download settings are process-local; never edit global pip/npm/uv settings.
|
||||||
# Keep this file ASCII for Windows PowerShell 5.1.
|
# Save as UTF-8 with BOM so Windows PowerShell 5.1 reads Chinese messages correctly.
|
||||||
|
|
||||||
function Initialize-ArtemisDownloads {
|
function Initialize-ArtemisDownloads {
|
||||||
param([string]$ProjectRoot, [string]$Mode)
|
param([string]$ProjectRoot, [string]$Mode)
|
||||||
@@ -39,8 +39,14 @@ function Initialize-ArtemisDownloads {
|
|||||||
$env:npm_config_fetch_timeout = [string]($timeout * 1000)
|
$env:npm_config_fetch_timeout = [string]($timeout * 1000)
|
||||||
$values.mode = $Mode
|
$values.mode = $Mode
|
||||||
$values.timeout_seconds = $timeout
|
$values.timeout_seconds = $timeout
|
||||||
|
# Official scrcpy v4.1 win64 release digest. Verify every mirror/override equally.
|
||||||
|
$values.scrcpy_sha256 = '5b12172b3264b2889f4583ee64752ce832e29bc8b1089dca81093459697165db'
|
||||||
$script:ArtemisDownloads = [pscustomobject]$values
|
$script:ArtemisDownloads = [pscustomobject]$values
|
||||||
|
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN') {
|
||||||
|
Write-Host " [镜像] 使用国内下载源(config/downloads.json)。" -ForegroundColor Cyan
|
||||||
|
} else {
|
||||||
Write-Host " [INFO] Download mode: $Mode (config/downloads.json)." -ForegroundColor Cyan
|
Write-Host " [INFO] Download mode: $Mode (config/downloads.json)." -ForegroundColor Cyan
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Install-ArtemisUvFromIndex {
|
function Install-ArtemisUvFromIndex {
|
||||||
|
|||||||
+107
-7
@@ -1,10 +1,13 @@
|
|||||||
"""Create a portable source ZIP without environments, builds, or local credentials."""
|
"""Create a clean, shallow Git checkout ZIP that can fast-forward on launch."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from urllib.parse import urlsplit
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
|
|
||||||
@@ -83,6 +86,9 @@ EXCLUDED_SUFFIXES = {
|
|||||||
".swo",
|
".swo",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GIT_METADATA_FILES = {"HEAD", "config", "index", "shallow", "packed-refs"}
|
||||||
|
GIT_METADATA_DIRS = {"objects", "refs"}
|
||||||
|
|
||||||
|
|
||||||
def excluded(path: Path) -> bool:
|
def excluded(path: Path) -> bool:
|
||||||
parts = tuple(part.lower() for part in path.parts)
|
parts = tuple(part.lower() for part in path.parts)
|
||||||
@@ -116,22 +122,116 @@ def source_files(root: Path):
|
|||||||
yield path
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
def git(root: Path, *args: str) -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", str(root), *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=90,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError(f"git {args[0]} failed (exit {result.returncode})")
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def git_metadata_files(root: Path):
|
||||||
|
metadata = root / ".git"
|
||||||
|
for name in sorted(GIT_METADATA_FILES):
|
||||||
|
path = metadata / name
|
||||||
|
if path.is_file():
|
||||||
|
yield path
|
||||||
|
for name in sorted(GIT_METADATA_DIRS):
|
||||||
|
directory = metadata / name
|
||||||
|
if directory.exists():
|
||||||
|
yield from source_files_without_filter(directory)
|
||||||
|
for name in ("exclude", "sparse-checkout"):
|
||||||
|
path = metadata / "info" / name
|
||||||
|
if path.is_file():
|
||||||
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
def source_files_without_filter(root: Path):
|
||||||
|
for directory, dirs, files in os.walk(root, followlinks=False):
|
||||||
|
current = Path(directory)
|
||||||
|
dirs[:] = sorted(name for name in dirs if not (current / name).is_symlink())
|
||||||
|
for name in sorted(files):
|
||||||
|
path = current / name
|
||||||
|
if not path.is_symlink():
|
||||||
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
def release_remote(root: Path) -> tuple[str, str]:
|
||||||
|
if not (root / ".git").exists():
|
||||||
|
raise ValueError("Source packaging requires a Git checkout")
|
||||||
|
if git(root, "status", "--porcelain", "--untracked-files=no"):
|
||||||
|
raise ValueError("Commit tracked changes before packaging a release")
|
||||||
|
branch = git(root, "symbolic-ref", "--quiet", "--short", "HEAD")
|
||||||
|
remote = git(root, "config", "--get", f"branch.{branch}.remote")
|
||||||
|
merge_ref = git(root, "config", "--get", f"branch.{branch}.merge")
|
||||||
|
if remote == "." or merge_ref != f"refs/heads/{branch}":
|
||||||
|
raise ValueError("The current branch must track the same branch on a remote")
|
||||||
|
url = git(root, "remote", "get-url", remote)
|
||||||
|
parsed = urlsplit(url)
|
||||||
|
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
|
||||||
|
raise ValueError("The release upstream must be a credential-free HTTPS URL")
|
||||||
|
if parsed.query or parsed.fragment:
|
||||||
|
raise ValueError("The release upstream must not contain a query or fragment")
|
||||||
|
return branch, url
|
||||||
|
|
||||||
|
|
||||||
def package_source(root: Path, output: Path) -> tuple[int, str]:
|
def package_source(root: Path, output: Path) -> tuple[int, str]:
|
||||||
root = root.resolve()
|
root = root.resolve()
|
||||||
output = output.resolve()
|
output = output.resolve()
|
||||||
|
branch, remote_url = release_remote(root)
|
||||||
|
revision = git(root, "rev-parse", "HEAD")
|
||||||
output.parent.mkdir(parents=True, exist_ok=True)
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
temporary = output.with_suffix(".zip.tmp")
|
temporary = output.with_suffix(".zip.tmp")
|
||||||
files = [p for p in source_files(root) if p.resolve() not in {output, temporary}]
|
|
||||||
try:
|
try:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="artemis-release-") as work:
|
||||||
|
checkout = Path(work) / "artemis"
|
||||||
|
git(
|
||||||
|
root,
|
||||||
|
"clone",
|
||||||
|
"--depth",
|
||||||
|
"1",
|
||||||
|
"--single-branch",
|
||||||
|
"--branch",
|
||||||
|
branch,
|
||||||
|
root.as_uri(),
|
||||||
|
str(checkout),
|
||||||
|
)
|
||||||
|
git(checkout, "remote", "set-url", "origin", remote_url)
|
||||||
|
# Generated assets are tracked upstream but must not appear in the
|
||||||
|
# unpacked source. Sparse checkout keeps the Git index clean.
|
||||||
|
omitted = [
|
||||||
|
name for name in git(checkout, "ls-files").splitlines() if excluded(Path(name))
|
||||||
|
]
|
||||||
|
patterns = ["/*", *(f"!/{name}" for name in omitted)]
|
||||||
|
git(checkout, "sparse-checkout", "set", "--no-cone", *patterns)
|
||||||
|
# Rebuilt assets in sparse paths must not look like user edits.
|
||||||
|
info_exclude = checkout / ".git" / "info" / "exclude"
|
||||||
|
with info_exclude.open("a", encoding="utf-8") as stream:
|
||||||
|
stream.write("\n".join(["", *(f"/{name}" for name in omitted), ""]))
|
||||||
|
if git(checkout, "rev-parse", "HEAD") != revision or git(
|
||||||
|
checkout, "status", "--porcelain"
|
||||||
|
):
|
||||||
|
raise RuntimeError("The packaged checkout is not clean at the requested revision")
|
||||||
|
files = [*source_files(checkout), *git_metadata_files(checkout)]
|
||||||
|
names = {
|
||||||
|
str(Path("artemis") / path.relative_to(checkout)).replace("\\", "/")
|
||||||
|
for path in files
|
||||||
|
}
|
||||||
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
|
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
|
||||||
for path in files:
|
for path in files:
|
||||||
archive.write(path, Path("artemis") / path.relative_to(root))
|
archive.write(path, Path("artemis") / path.relative_to(checkout))
|
||||||
with zipfile.ZipFile(temporary) as archive:
|
with zipfile.ZipFile(temporary) as archive:
|
||||||
bad_file = archive.testzip()
|
bad_file = archive.testzip()
|
||||||
if bad_file:
|
if bad_file or set(archive.namelist()) != names:
|
||||||
raise RuntimeError(f"ZIP integrity check failed: {bad_file}")
|
raise RuntimeError(
|
||||||
if any(excluded(Path(name).relative_to("artemis")) for name in archive.namelist()):
|
f"ZIP integrity check failed: {bad_file or 'file list mismatch'}"
|
||||||
raise RuntimeError("An excluded file was found in the source archive")
|
)
|
||||||
temporary.replace(output)
|
temporary.replace(output)
|
||||||
finally:
|
finally:
|
||||||
temporary.unlink(missing_ok=True)
|
temporary.unlink(missing_ok=True)
|
||||||
|
|||||||
+58
-40
@@ -1,4 +1,4 @@
|
|||||||
# Copyright 2026 Google LLC
|
# Copyright 2026 Google LLC
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -18,12 +18,19 @@ param(
|
|||||||
[switch]$NoOpen = $false,
|
[switch]$NoOpen = $false,
|
||||||
[switch]$SetupOnly = $false,
|
[switch]$SetupOnly = $false,
|
||||||
[switch]$SkipUpdate = $false,
|
[switch]$SkipUpdate = $false,
|
||||||
|
[switch]$SkipMcpSync = $false,
|
||||||
[ValidateSet('Official', 'China')][string]$Mirror
|
[ValidateSet('Official', 'China')][string]$Mirror
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Continue"
|
$ErrorActionPreference = "Continue"
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
function Get-LaunchText {
|
||||||
|
param([string]$English, [string]$Chinese)
|
||||||
|
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN') { return $Chinese }
|
||||||
|
return $English
|
||||||
|
}
|
||||||
|
|
||||||
# Ensure modern TLS protocols are enabled for downloads (GitHub releases, nodejs.org, Google repositories)
|
# Ensure modern TLS protocols are enabled for downloads (GitHub releases, nodejs.org, Google repositories)
|
||||||
try {
|
try {
|
||||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
|
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
|
||||||
@@ -44,12 +51,13 @@ if ((-not $SkipUpdate) -and (Test-Path -LiteralPath $existingPython)) {
|
|||||||
$reloadArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $ScriptDir 'start.ps1'), '-Port', $Port, '-SkipUpdate')
|
$reloadArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $ScriptDir 'start.ps1'), '-Port', $Port, '-SkipUpdate')
|
||||||
if ($NoOpen) { $reloadArgs += '-NoOpen' }
|
if ($NoOpen) { $reloadArgs += '-NoOpen' }
|
||||||
if ($SetupOnly) { $reloadArgs += '-SetupOnly' }
|
if ($SetupOnly) { $reloadArgs += '-SetupOnly' }
|
||||||
|
if ($SkipMcpSync) { $reloadArgs += '-SkipMcpSync' }
|
||||||
if ($Mirror) { $reloadArgs += @('-Mirror', $Mirror) }
|
if ($Mirror) { $reloadArgs += @('-Mirror', $Mirror) }
|
||||||
& powershell.exe @reloadArgs
|
& powershell.exe @reloadArgs
|
||||||
exit $LASTEXITCODE
|
exit $LASTEXITCODE
|
||||||
}
|
}
|
||||||
if ($updateExit -ne 0) {
|
if ($updateExit -ne 0) {
|
||||||
Write-Host ' [ERROR] Source update check failed. Resolve the error above, or use -SkipUpdate for local startup.' -ForegroundColor Red
|
Write-Host (Get-LaunchText ' [ERROR] Source update check failed. Resolve the error above, or use -SkipUpdate for local startup.' ' [错误] 源码更新检查失败。请处理上方错误;要运行当前源码可传入 -SkipUpdate。') -ForegroundColor Red
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,12 +66,12 @@ if ((-not $SkipUpdate) -and (Test-Path -LiteralPath $existingPython)) {
|
|||||||
try {
|
try {
|
||||||
Initialize-ArtemisDownloads -ProjectRoot $RootDir -Mode $Mirror
|
Initialize-ArtemisDownloads -ProjectRoot $RootDir -Mode $Mirror
|
||||||
} catch {
|
} catch {
|
||||||
Write-Host " [ERROR] Invalid download settings: $_" -ForegroundColor Red
|
Write-Host (Get-LaunchText " [ERROR] Invalid download settings: $_" " [错误] 下载配置无效:$_") -ForegroundColor Red
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "======================================================" -ForegroundColor Cyan
|
Write-Host "======================================================" -ForegroundColor Cyan
|
||||||
Write-Host " Artemis Autonomous Mobile Agent UI " -ForegroundColor Cyan
|
Write-Host (Get-LaunchText " Artemis Autonomous Mobile Agent UI " " ARTEMIS 手机自动化测试平台") -ForegroundColor Cyan
|
||||||
Write-Host "======================================================" -ForegroundColor Cyan
|
Write-Host "======================================================" -ForegroundColor Cyan
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
@@ -181,7 +189,7 @@ function Install-PortablePlatformTools {
|
|||||||
$env:PATH = "$ptDir;$env:PATH"
|
$env:PATH = "$ptDir;$env:PATH"
|
||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
Write-Host " [INFO] Installing Android platform-tools (adb) in user space..." -ForegroundColor Cyan
|
Write-Host (Get-LaunchText " [INFO] Installing Android platform-tools (adb) in user space..." " [安装] 正在安装 Android 调试工具 adb(当前用户)...") -ForegroundColor Cyan
|
||||||
try {
|
try {
|
||||||
$zipPath = "$env:TEMP\platform-tools-windows.zip"
|
$zipPath = "$env:TEMP\platform-tools-windows.zip"
|
||||||
if (Invoke-DownloadFile -Uri $ArtemisDownloads.adb_url -OutFile $zipPath) {
|
if (Invoke-DownloadFile -Uri $ArtemisDownloads.adb_url -OutFile $zipPath) {
|
||||||
@@ -190,12 +198,12 @@ function Install-PortablePlatformTools {
|
|||||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||||
if (Test-Path "$ptDir\adb.exe") {
|
if (Test-Path "$ptDir\adb.exe") {
|
||||||
$env:PATH = "$ptDir;$env:PATH"
|
$env:PATH = "$ptDir;$env:PATH"
|
||||||
Write-Host " [OK] adb installed in user space." -ForegroundColor Green
|
Write-Host (Get-LaunchText " [OK] adb installed in user space." " [完成] adb 已安装。") -ForegroundColor Green
|
||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Write-Host " [WARN] Failed to install portable platform-tools: $_" -ForegroundColor DarkYellow
|
Write-Host (Get-LaunchText " [WARN] Failed to install portable platform-tools: $_" " [提示] adb 安装失败:$_") -ForegroundColor DarkYellow
|
||||||
}
|
}
|
||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
@@ -217,13 +225,18 @@ function Install-PortableScrcpy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host " [INFO] Installing portable scrcpy in user space..." -ForegroundColor Cyan
|
Write-Host (Get-LaunchText " [INFO] Installing portable scrcpy in user space..." " [安装] 正在安装 scrcpy(当前用户)...") -ForegroundColor Cyan
|
||||||
if ($ArtemisDownloads.mode -eq 'China' -and $ArtemisDownloads.scrcpy_url.StartsWith('https://github.com/')) {
|
if ($ArtemisDownloads.mode -eq 'China' -and $ArtemisDownloads.scrcpy_url.StartsWith('https://github.com/')) {
|
||||||
Write-Host ' [INFO] scrcpy uses GitHub. If unreachable, set overrides.scrcpy_url in config/downloads.json.' -ForegroundColor Yellow
|
Write-Host (Get-LaunchText ' [WARN] China mode has no non-GitHub scrcpy source. Configure overrides.scrcpy_url or install scrcpy manually.' ' [提示] 国内镜像中没有可用的 scrcpy 下载地址;请设置 config/downloads.json 的 overrides.scrcpy_url,或自行安装。') -ForegroundColor Yellow
|
||||||
|
return $false
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$zipPath = "$env:TEMP\scrcpy-win64.zip"
|
$zipPath = "$env:TEMP\scrcpy-win64.zip"
|
||||||
if (Invoke-DownloadFile -Uri $ArtemisDownloads.scrcpy_url -OutFile $zipPath) {
|
if (Invoke-DownloadFile -Uri $ArtemisDownloads.scrcpy_url -OutFile $zipPath) {
|
||||||
|
$actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
if ($actualHash -ne $ArtemisDownloads.scrcpy_sha256) {
|
||||||
|
throw 'Downloaded scrcpy ZIP failed official SHA256 verification.'
|
||||||
|
}
|
||||||
$extractDir = "$env:TEMP\scrcpy_extract"
|
$extractDir = "$env:TEMP\scrcpy_extract"
|
||||||
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue }
|
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue }
|
||||||
New-Item -ItemType Directory -Path $extractDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $extractDir -Force | Out-Null
|
||||||
@@ -239,12 +252,12 @@ function Install-PortableScrcpy {
|
|||||||
Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
if (Test-Path "$scrcpyDir\scrcpy.exe") {
|
if (Test-Path "$scrcpyDir\scrcpy.exe") {
|
||||||
$env:PATH = "$scrcpyDir;$env:PATH"
|
$env:PATH = "$scrcpyDir;$env:PATH"
|
||||||
Write-Host " [OK] Portable scrcpy installed in user space." -ForegroundColor Green
|
Write-Host (Get-LaunchText " [OK] Portable scrcpy installed in user space." " [完成] scrcpy 已安装。") -ForegroundColor Green
|
||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Write-Host " [WARN] Failed to install portable scrcpy: $_" -ForegroundColor DarkYellow
|
Write-Host (Get-LaunchText " [WARN] Failed to install portable scrcpy: $_" " [提示] scrcpy 安装失败:$_") -ForegroundColor DarkYellow
|
||||||
}
|
}
|
||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
@@ -290,7 +303,7 @@ function Install-PortableNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host " [INFO] Installing portable Node.js LTS (v22.23.2) in user space..." -ForegroundColor Cyan
|
Write-Host (Get-LaunchText " [INFO] Installing portable Node.js LTS (v22.23.2) in user space..." " [安装] 正在安装 Node.js LTS v22.23.2(当前用户)...") -ForegroundColor Cyan
|
||||||
try {
|
try {
|
||||||
$nodeVer = "v22.23.2"
|
$nodeVer = "v22.23.2"
|
||||||
$arch = if ([System.Environment]::Is64BitOperatingSystem) {
|
$arch = if ([System.Environment]::Is64BitOperatingSystem) {
|
||||||
@@ -314,12 +327,12 @@ function Install-PortableNode {
|
|||||||
Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
if (Test-Path "$nodeDir\node.exe") {
|
if (Test-Path "$nodeDir\node.exe") {
|
||||||
$env:PATH = "$nodeDir;$env:PATH"
|
$env:PATH = "$nodeDir;$env:PATH"
|
||||||
Write-Host " [OK] Portable Node.js $nodeVer installed in user space." -ForegroundColor Green
|
Write-Host (Get-LaunchText " [OK] Portable Node.js $nodeVer installed in user space." " [完成] Node.js $nodeVer 已安装。") -ForegroundColor Green
|
||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Write-Host " [WARN] Failed to install portable Node.js: $_" -ForegroundColor DarkYellow
|
Write-Host (Get-LaunchText " [WARN] Failed to install portable Node.js: $_" " [提示] Node.js 安装失败:$_") -ForegroundColor DarkYellow
|
||||||
}
|
}
|
||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
@@ -339,16 +352,21 @@ try {
|
|||||||
if (-not (Test-Path -LiteralPath (Join-Path $RootDir '.env'))) {
|
if (-not (Test-Path -LiteralPath (Join-Path $RootDir '.env'))) {
|
||||||
if (Test-Path -LiteralPath (Join-Path $RootDir '.env.example')) {
|
if (Test-Path -LiteralPath (Join-Path $RootDir '.env.example')) {
|
||||||
Copy-Item -LiteralPath (Join-Path $RootDir '.env.example') -Destination (Join-Path $RootDir '.env') -ErrorAction Stop
|
Copy-Item -LiteralPath (Join-Path $RootDir '.env.example') -Destination (Join-Path $RootDir '.env') -ErrorAction Stop
|
||||||
Write-Host ' [OK] Created .env from .env.example. Configure your model in the web UI.' -ForegroundColor Green
|
Write-Host (Get-LaunchText ' [OK] Created .env from .env.example. Configure your model in the web UI.' ' [完成] 已按模板创建 .env;请在网页中配置模型。') -ForegroundColor Green
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Write-Host ' [CHECK] Refreshing already-configured MCP clients...' -ForegroundColor Cyan
|
if (-not $SkipMcpSync) {
|
||||||
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp
|
Write-Host (Get-LaunchText ' [CHECK] Installing/updating MCP for detected AI clients...' ' [检查] 正在为检测到的 AI 客户端安装或更新 MCP...') -ForegroundColor Cyan
|
||||||
if ($LASTEXITCODE -ne 0) {
|
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp
|
||||||
Write-Host ' [WARN] MCP refresh failed. Existing client settings were not replaced; check the errors above.' -ForegroundColor Yellow
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host (Get-LaunchText ' [ERROR] MCP synchronization failed. Fix the configuration above, or use -SkipMcpSync to run the web UI without MCP.' ' [错误] MCP 同步失败。请修复上方配置;仅需启动网页时可使用 -SkipMcpSync。') -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host (Get-LaunchText ' [SKIP] MCP synchronization was skipped.' ' [跳过] 已跳过 MCP 同步。') -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
if ($SetupOnly) {
|
if ($SetupOnly) {
|
||||||
Write-Host ' [OK] Setup complete. Run start.bat to launch Artemis.' -ForegroundColor Green
|
Write-Host (Get-LaunchText ' [OK] Setup complete. Run start.bat to launch Artemis.' ' [完成] 环境已准备好。运行 start-cn.bat 启动 ARTEMIS。') -ForegroundColor Green
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,8 +384,8 @@ if ($missingCore.Count -gt 0) {
|
|||||||
$useWinGet = $false
|
$useWinGet = $false
|
||||||
if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "winget")) {
|
if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "winget")) {
|
||||||
if ([Console]::IsInputRedirected -eq $false) {
|
if ([Console]::IsInputRedirected -eq $false) {
|
||||||
Write-Host " [INFO] Missing components detected: $($missingCore -join ', ')." -ForegroundColor Cyan
|
Write-Host (Get-LaunchText " [INFO] Missing components detected: $($missingCore -join ', ')." " [检查] 缺少组件:$($missingCore -join ', ')。") -ForegroundColor Cyan
|
||||||
$ans = Read-Host " Install system packages via WinGet? [Y/n]"
|
$ans = Read-Host (Get-LaunchText " Install system packages via WinGet? [Y/n]" " 通过 WinGet 安装?[Y/n]")
|
||||||
if ($ans -eq "" -or $ans -match "^[Yy]") {
|
if ($ans -eq "" -or $ans -match "^[Yy]") {
|
||||||
$useWinGet = $true
|
$useWinGet = $true
|
||||||
}
|
}
|
||||||
@@ -377,7 +395,7 @@ if ($missingCore.Count -gt 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($useWinGet) {
|
if ($useWinGet) {
|
||||||
Write-Host " [INFO] Auto-installing missing components ($($missingCore -join ', '))..." -ForegroundColor DarkYellow
|
Write-Host (Get-LaunchText " [INFO] Auto-installing missing components ($($missingCore -join ', '))..." " [安装] 正在安装缺少的组件:$($missingCore -join ', ')...") -ForegroundColor DarkYellow
|
||||||
if ($missingCore -contains "adb") {
|
if ($missingCore -contains "adb") {
|
||||||
winget install --id Google.PlatformTools -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null
|
winget install --id Google.PlatformTools -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null
|
||||||
}
|
}
|
||||||
@@ -396,7 +414,7 @@ if ($missingCore.Count -gt 0) {
|
|||||||
}
|
}
|
||||||
if (-not (Test-CommandExists "scrcpy")) {
|
if (-not (Test-CommandExists "scrcpy")) {
|
||||||
if (-not (Install-PortableScrcpy)) {
|
if (-not (Install-PortableScrcpy)) {
|
||||||
Write-Host ' [WARN] scrcpy download failed. Set overrides.scrcpy_url in config/downloads.json to a reachable release ZIP, then retry.' -ForegroundColor Yellow
|
Write-Host (Get-LaunchText ' [WARN] scrcpy download failed. Set overrides.scrcpy_url in config/downloads.json to a reachable release ZIP, then retry.' ' [提示] scrcpy 下载失败。请在 config/downloads.json 中设置可访问的 overrides.scrcpy_url,然后重试。') -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -410,19 +428,19 @@ $ShowcaseIndexAlt1 = "$RootDir\apps\showcase_ui\dist\browser\index.html"
|
|||||||
$ShowcaseIndexAlt2 = "$RootDir\apps\showcase_ui\dist\index.html"
|
$ShowcaseIndexAlt2 = "$RootDir\apps\showcase_ui\dist\index.html"
|
||||||
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') frontend-check
|
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') frontend-check
|
||||||
$frontendChanged = $LASTEXITCODE -eq 10
|
$frontendChanged = $LASTEXITCODE -eq 10
|
||||||
if ($LASTEXITCODE -notin @(0, 10)) { Write-Host ' [ERROR] Could not check frontend sources.' -ForegroundColor Red; exit 1 }
|
if ($LASTEXITCODE -notin @(0, 10)) { Write-Host (Get-LaunchText ' [ERROR] Could not check frontend sources.' ' [错误] 无法检查前端源码。') -ForegroundColor Red; exit 1 }
|
||||||
if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Path $ShowcaseIndexAlt1)) -and (-not (Test-Path $ShowcaseIndexAlt2)))) {
|
if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Path $ShowcaseIndexAlt1)) -and (-not (Test-Path $ShowcaseIndexAlt2)))) {
|
||||||
if (-not (Test-NodeCompatible)) {
|
if (-not (Test-NodeCompatible)) {
|
||||||
if (Test-CommandExists "node") {
|
if (Test-CommandExists "node") {
|
||||||
$curVer = (& node -v 2>$null)
|
$curVer = (& node -v 2>$null)
|
||||||
Write-Host " [INFO] Detected Node.js $curVer, but Angular CLI requires Node.js >= v22.22.0." -ForegroundColor Yellow
|
Write-Host (Get-LaunchText " [INFO] Detected Node.js $curVer, but Angular CLI requires Node.js >= v22.22.0." " [检查] 检测到 Node.js $curVer;前端需要 v22.22.0 或更新版本。") -ForegroundColor Yellow
|
||||||
} else {
|
} else {
|
||||||
Write-Host " [INFO] Node.js/npm not found (required for Showcase UI)." -ForegroundColor Cyan
|
Write-Host (Get-LaunchText " [INFO] Node.js/npm not found (required for Showcase UI)." " [检查] 未找到 Node.js/npm,网页构建需要安装。") -ForegroundColor Cyan
|
||||||
}
|
}
|
||||||
|
|
||||||
# 1. Try nvm if available on Windows
|
# 1. Try nvm if available on Windows
|
||||||
if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "nvm")) {
|
if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "nvm")) {
|
||||||
Write-Host " [INFO] Installing Node.js 22 LTS via nvm..." -ForegroundColor Cyan
|
Write-Host (Get-LaunchText " [INFO] Installing Node.js 22 LTS via nvm..." " [安装] 正在通过 nvm 安装 Node.js 22 LTS...") -ForegroundColor Cyan
|
||||||
& nvm install 22.23.2 | Out-Null
|
& nvm install 22.23.2 | Out-Null
|
||||||
& nvm use 22.23.2 | Out-Null
|
& nvm use 22.23.2 | Out-Null
|
||||||
Update-EnvironmentPath
|
Update-EnvironmentPath
|
||||||
@@ -433,8 +451,8 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
|
|||||||
$useWinGetNode = $false
|
$useWinGetNode = $false
|
||||||
if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "winget")) {
|
if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "winget")) {
|
||||||
if ([Console]::IsInputRedirected -eq $false) {
|
if ([Console]::IsInputRedirected -eq $false) {
|
||||||
Write-Host " [INFO] Node.js >= v22.22.0 required for Showcase UI." -ForegroundColor Cyan
|
Write-Host (Get-LaunchText " [INFO] Node.js >= v22.22.0 required for Showcase UI." " [检查] 网页需要 Node.js v22.22.0 或更新版本。") -ForegroundColor Cyan
|
||||||
$ans = Read-Host " Install/Upgrade Node.js via WinGet (may require Administrator)? [Y/n]"
|
$ans = Read-Host (Get-LaunchText " Install/Upgrade Node.js via WinGet (may require Administrator)? [Y/n]" " 通过 WinGet 安装/升级 Node.js(可能需要管理员权限)?[Y/n]")
|
||||||
if ($ans -eq "" -or $ans -match "^[Yy]") {
|
if ($ans -eq "" -or $ans -match "^[Yy]") {
|
||||||
$useWinGetNode = $true
|
$useWinGetNode = $true
|
||||||
}
|
}
|
||||||
@@ -444,7 +462,7 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($useWinGetNode) {
|
if ($useWinGetNode) {
|
||||||
Write-Host " [INFO] Installing/Upgrading Node.js LTS via WinGet..." -ForegroundColor Yellow
|
Write-Host (Get-LaunchText " [INFO] Installing/Upgrading Node.js LTS via WinGet..." " [安装] 正在通过 WinGet 安装/升级 Node.js LTS...") -ForegroundColor Yellow
|
||||||
winget install --id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null
|
winget install --id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null
|
||||||
Update-EnvironmentPath
|
Update-EnvironmentPath
|
||||||
}
|
}
|
||||||
@@ -458,36 +476,36 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
|
|||||||
|
|
||||||
if (Test-NodeCompatible) {
|
if (Test-NodeCompatible) {
|
||||||
$nodeVer = (& node -v 2>$null)
|
$nodeVer = (& node -v 2>$null)
|
||||||
Write-Host " [OK] Node.js $nodeVer is ready." -ForegroundColor Green
|
Write-Host (Get-LaunchText " [OK] Node.js $nodeVer is ready." " [完成] Node.js $nodeVer 已就绪。") -ForegroundColor Green
|
||||||
Write-Host " [INFO] Showcase UI sources/dependencies changed or build missing. Compiling..." -ForegroundColor Yellow
|
Write-Host (Get-LaunchText " [INFO] Showcase UI sources/dependencies changed or build missing. Compiling..." " [构建] 前端源码/依赖有变化或缺少构建结果,准备重新构建...") -ForegroundColor Yellow
|
||||||
Push-Location "$RootDir\apps\showcase_ui"
|
Push-Location "$RootDir\apps\showcase_ui"
|
||||||
try {
|
try {
|
||||||
$npmExec = if (Test-CommandExists "npm.cmd") { "npm.cmd" } else { "npm" }
|
$npmExec = if (Test-CommandExists "npm.cmd") { "npm.cmd" } else { "npm" }
|
||||||
Write-Host " [INFO] Installing frontend npm dependencies..." -ForegroundColor Cyan
|
Write-Host (Get-LaunchText " [INFO] Installing frontend npm dependencies..." " [安装] 正在安装前端 npm 依赖...") -ForegroundColor Cyan
|
||||||
& $npmExec ci --no-audit --no-fund --loglevel=error
|
& $npmExec ci --no-audit --no-fund --loglevel=error
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
throw "npm ci failed (exit $LASTEXITCODE). Check the network and retry."
|
throw "npm ci failed (exit $LASTEXITCODE). Check the network and retry."
|
||||||
}
|
}
|
||||||
Write-Host " [INFO] Building Angular frontend application..." -ForegroundColor Cyan
|
Write-Host (Get-LaunchText " [INFO] Building Angular frontend application..." " [构建] 正在构建 Angular 网页...") -ForegroundColor Cyan
|
||||||
& $npmExec run build
|
& $npmExec run build
|
||||||
if ($LASTEXITCODE -ne 0) { throw "Frontend build failed (exit $LASTEXITCODE)." }
|
if ($LASTEXITCODE -ne 0) { throw "Frontend build failed (exit $LASTEXITCODE)." }
|
||||||
if (Test-Path $ShowcaseIndex) {
|
if (Test-Path $ShowcaseIndex) {
|
||||||
Write-Host " [OK] Showcase UI compiled successfully." -ForegroundColor Green
|
Write-Host (Get-LaunchText " [OK] Showcase UI compiled successfully." " [完成] 网页构建成功。") -ForegroundColor Green
|
||||||
} elseif ((Test-Path $ShowcaseIndexAlt1) -or (Test-Path $ShowcaseIndexAlt2)) {
|
} elseif ((Test-Path $ShowcaseIndexAlt1) -or (Test-Path $ShowcaseIndexAlt2)) {
|
||||||
Write-Host " [OK] Showcase UI compiled successfully." -ForegroundColor Green
|
Write-Host (Get-LaunchText " [OK] Showcase UI compiled successfully." " [完成] 网页构建成功。") -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
throw 'Frontend build did not produce index.html.'
|
throw 'Frontend build did not produce index.html.'
|
||||||
}
|
}
|
||||||
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') frontend-mark
|
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') frontend-mark
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'Could not save frontend build fingerprint.' }
|
if ($LASTEXITCODE -ne 0) { throw 'Could not save frontend build fingerprint.' }
|
||||||
} catch {
|
} catch {
|
||||||
Write-Host " [ERROR] Failed to build Showcase UI: $_" -ForegroundColor Red
|
Write-Host (Get-LaunchText " [ERROR] Failed to build Showcase UI: $_" " [错误] 网页构建失败:$_") -ForegroundColor Red
|
||||||
exit 1
|
exit 1
|
||||||
} finally {
|
} finally {
|
||||||
Pop-Location
|
Pop-Location
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Write-Host " [ERROR] A compatible Node.js/npm could not be installed. Install Node.js LTS and retry." -ForegroundColor Red
|
Write-Host (Get-LaunchText " [ERROR] A compatible Node.js/npm could not be installed. Install Node.js LTS and retry." " [错误] 无法安装兼容的 Node.js/npm。请安装 Node.js LTS 后重试。") -ForegroundColor Red
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -500,7 +518,7 @@ Start-LocalAdbServer
|
|||||||
|
|
||||||
# Launch via `python -m artemis` (not the `artemis.exe` console-script shim) so the
|
# Launch via `python -m artemis` (not the `artemis.exe` console-script shim) so the
|
||||||
# long-running server never locks .venv\Scripts\artemis.exe against `uv sync` reinstalls.
|
# long-running server never locks .venv\Scripts\artemis.exe against `uv sync` reinstalls.
|
||||||
Write-Host " [INFO] Launching Artemis Showcase UI & Admin Console..." -ForegroundColor Green
|
Write-Host (Get-LaunchText " [INFO] Launching Artemis Showcase UI & Admin Console..." " [启动] 正在启动 ARTEMIS 网页与管理服务...") -ForegroundColor Green
|
||||||
if ($NoOpen) {
|
if ($NoOpen) {
|
||||||
& $ArtemisPython -m artemis ui --port $Port --no-open
|
& $ArtemisPython -m artemis ui --port $Port --no-open
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+332
-106
@@ -1,4 +1,4 @@
|
|||||||
"""Check an existing checkout and refresh only already-configured local MCP clients."""
|
"""Check an existing checkout and synchronize installed local MCP clients."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -19,6 +19,10 @@ from urllib.parse import urlsplit
|
|||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def launch_text(english: str, chinese: str) -> str:
|
||||||
|
return chinese if os.environ.get("ARTEMIS_LAUNCH_LANG") == "zh-CN" else english
|
||||||
|
|
||||||
|
|
||||||
def git(root: Path, *args: str, timeout: int = 15) -> str:
|
def git(root: Path, *args: str, timeout: int = 15) -> str:
|
||||||
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "Never"}
|
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "Never"}
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -50,19 +54,28 @@ def update_source(root: Path, port: int = 8000) -> int:
|
|||||||
or (status.get("status") == "running" and status.get("pid"))
|
or (status.get("status") == "running" and status.get("pid"))
|
||||||
):
|
):
|
||||||
print(
|
print(
|
||||||
"[WAIT] Tasks are active. Finish/stop them before updating dependencies or restarting."
|
launch_text(
|
||||||
|
"[WAIT] Tasks are active. Finish/stop them before updating dependencies or restarting.",
|
||||||
|
"[等待] 有任务正在执行或排队。请先处理任务,再更新依赖或重启。",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return 20
|
return 20
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
pass
|
pass
|
||||||
if not shutil.which("git"):
|
if not shutil.which("git"):
|
||||||
print(
|
print(
|
||||||
"[SKIP] Git is not installed. Continuing with local source; install Git for automatic source updates."
|
launch_text(
|
||||||
|
"[SKIP] Git is not installed. Continuing with local source; install Git for automatic source updates.",
|
||||||
|
"[跳过] 未安装 Git,继续使用当前源码;安装 Git 后可自动检查更新。",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
if not (root / ".git").exists():
|
if not (root / ".git").exists():
|
||||||
print(
|
print(
|
||||||
"[SKIP] This source ZIP has no Git metadata. Use a Git clone for automatic source updates; local files are preserved."
|
launch_text(
|
||||||
|
"[SKIP] This older source ZIP has no Git metadata. Use a new release ZIP or Git clone for automatic updates; local files are preserved.",
|
||||||
|
"[跳过] 旧版源码包不含 Git 信息。请使用新版源码包或 Git 克隆;当前文件保持不变。",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
try:
|
try:
|
||||||
@@ -79,41 +92,58 @@ def update_source(root: Path, port: int = 8000) -> int:
|
|||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Use a clean Git remote URL and a credential manager, not embedded credentials."
|
"Use a clean Git remote URL and a credential manager, not embedded credentials."
|
||||||
)
|
)
|
||||||
print("[CHECK] Checking the current branch's configured upstream...")
|
print(
|
||||||
|
launch_text(
|
||||||
|
"[CHECK] Checking the current branch's configured upstream...",
|
||||||
|
"[检查] 正在检查当前分支的远程更新...",
|
||||||
|
)
|
||||||
|
)
|
||||||
git(root, "fetch", "--no-tags", remote, merge_ref, timeout=45)
|
git(root, "fetch", "--no-tags", remote, merge_ref, timeout=45)
|
||||||
target = git(root, "rev-parse", "FETCH_HEAD")
|
target = git(root, "rev-parse", "FETCH_HEAD")
|
||||||
current = git(root, "rev-parse", "HEAD")
|
current = git(root, "rev-parse", "HEAD")
|
||||||
if current == target:
|
if current == target:
|
||||||
print("[OK] Source is up to date.")
|
print(launch_text("[OK] Source is up to date.", "[完成] 源码已是最新版。"))
|
||||||
return 0
|
return 0
|
||||||
if git(root, "status", "--porcelain"):
|
if git(root, "status", "--porcelain"):
|
||||||
print(
|
print(
|
||||||
"[SKIP] Upstream differs, but local changes exist. Commit/back up your work before updating; continuing locally."
|
launch_text(
|
||||||
|
"[SKIP] Upstream differs, but local changes exist. Commit/back up your work before updating; continuing locally.",
|
||||||
|
"[跳过] 发现本地修改;请先提交或备份。此次继续使用本地源码。",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
ancestor = git(root, "merge-base", "HEAD", target)
|
ancestor = git(root, "merge-base", "HEAD", target)
|
||||||
if ancestor != current:
|
if ancestor != current:
|
||||||
print(
|
print(
|
||||||
"[SKIP] Local branch is ahead or diverged. No automatic merge/rebase; continuing locally."
|
launch_text(
|
||||||
|
"[SKIP] Local branch is ahead or diverged. No automatic merge/rebase; continuing locally.",
|
||||||
|
"[跳过] 本地分支领先或分叉,不自动合并;继续使用本地源码。",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
git(root, "merge", "--ff-only", target, timeout=30)
|
git(root, "merge", "--ff-only", target, timeout=30)
|
||||||
print(
|
print(
|
||||||
f"[UPDATED] Source {current[:12]} -> {target[:12]}. Reloading launcher and checking dependencies."
|
launch_text(
|
||||||
|
f"[UPDATED] Source {current[:12]} -> {target[:12]}. Reloading launcher and checking dependencies.",
|
||||||
|
f"[已更新] 源码 {current[:12]} -> {target[:12]},重新载入启动脚本并检查依赖。",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return 10
|
return 10
|
||||||
except (RuntimeError, subprocess.TimeoutExpired) as exc:
|
except (RuntimeError, subprocess.TimeoutExpired) as exc:
|
||||||
print(
|
print(
|
||||||
f"[WARN] {exc if isinstance(exc, RuntimeError) else 'Git update timed out.'} Continuing with local source."
|
launch_text(
|
||||||
|
f"[WARN] {exc if isinstance(exc, RuntimeError) else 'Git update timed out.'} Continuing with local source.",
|
||||||
|
"[提示] Git 更新失败或超时;请检查网络、凭据和仓库状态。继续使用当前源码。",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _atomic_write(path: Path, text: str) -> None:
|
def _atomic_write(path: Path, text: str) -> None:
|
||||||
if path.read_text(encoding="utf-8") == text:
|
if path.is_file() and path.read_text(encoding="utf-8") == text:
|
||||||
return
|
return
|
||||||
backup = path.with_name(path.name + ".artemis-update.bak")
|
backup = path.with_name(path.name + ".artemis-update.bak")
|
||||||
if not backup.exists():
|
if path.is_file() and not backup.exists():
|
||||||
shutil.copy2(path, backup)
|
shutil.copy2(path, backup)
|
||||||
fd, name = tempfile.mkstemp(prefix=".artemis-update-", dir=path.parent)
|
fd, name = tempfile.mkstemp(prefix=".artemis-update-", dir=path.parent)
|
||||||
try:
|
try:
|
||||||
@@ -124,87 +154,120 @@ def _atomic_write(path: Path, text: str) -> None:
|
|||||||
Path(name).unlink(missing_ok=True)
|
Path(name).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def _belongs_to_project(config: dict, root: Path) -> bool:
|
def _is_standard_local_artemis(config: dict) -> bool:
|
||||||
if config.get("url"):
|
if config.get("url") or config.get("type") not in (None, "stdio"):
|
||||||
return False
|
return False
|
||||||
args = config.get("args", [])
|
args = config.get("args", [])
|
||||||
if args[:2] not in (["-m", "mcp_server"], ["-m", "mcp_server.server"]):
|
# Running this launcher selects this checkout, including after a user moves
|
||||||
# A hand-written wrapper/uv/SSE command may need custom arguments; leave it alone.
|
# a release ZIP to a new directory. Do not take over custom wrappers/SSE.
|
||||||
return False
|
return args in (["-m", "mcp_server"], ["-m", "mcp_server.server"])
|
||||||
candidates = [config.get("cwd"), config.get("env", {}).get("PYTHONPATH")]
|
|
||||||
for value in candidates:
|
|
||||||
if isinstance(value, str) and Path(value).resolve() == root.resolve():
|
|
||||||
return True
|
|
||||||
command = config.get("command")
|
|
||||||
return (
|
|
||||||
isinstance(command, str)
|
|
||||||
and Path(command).resolve().parent == (root / ".venv" / "Scripts").resolve()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _tool_updates(config: dict, names: tuple[str, ...]) -> dict:
|
def _updated_entry(config: dict, template: dict, root: Path, names: tuple[str, ...]) -> dict:
|
||||||
updates = {}
|
updated = {**config, "command": template["command"], "args": template["args"]}
|
||||||
if "enabled_tools" in config:
|
if "cwd" in template or "cwd" in config:
|
||||||
updates["enabled_tools"] = list(dict.fromkeys([*config["enabled_tools"], *names]))
|
updated["cwd"] = str(root)
|
||||||
if isinstance(config.get("tools"), dict):
|
environment = config.get("env", {})
|
||||||
updates["tools"] = {**{name: {"eager": True} for name in names}, **config["tools"]}
|
if not isinstance(environment, dict):
|
||||||
return updates
|
raise ValueError("The existing Artemis MCP environment is not an object")
|
||||||
|
updated["env"] = {**environment, **template["env"]}
|
||||||
|
if "enabled_tools" in template or "enabled_tools" in config:
|
||||||
|
updated["enabled_tools"] = list(names)
|
||||||
|
if "tools" in template or "tools" in config:
|
||||||
|
current_tools = config.get("tools", {})
|
||||||
|
if not isinstance(current_tools, dict):
|
||||||
|
raise ValueError("The existing Artemis MCP tools setting is not an object")
|
||||||
|
updated["tools"] = {name: {"eager": True, **current_tools.get(name, {})} for name in names}
|
||||||
|
for key in ("type", "required", "startup_timeout_sec", "connectionTimeoutMs"):
|
||||||
|
if key in template and key not in updated:
|
||||||
|
updated[key] = template[key]
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
def refresh_json(
|
def refresh_json(
|
||||||
path: Path, keys: tuple[str, ...], root: Path, python: str, names: tuple[str, ...]
|
path: Path,
|
||||||
) -> bool:
|
keys: tuple[str, ...],
|
||||||
if not path.is_file():
|
root: Path,
|
||||||
|
python: str,
|
||||||
|
names: tuple[str, ...],
|
||||||
|
template: dict | None = None,
|
||||||
|
install_missing: bool = False,
|
||||||
|
) -> bool | None:
|
||||||
|
if not path.is_file() and not install_missing:
|
||||||
return False
|
return False
|
||||||
raw = path.read_text(encoding="utf-8")
|
raw = path.read_text(encoding="utf-8") if path.is_file() else "{}"
|
||||||
data = json.loads(raw)
|
data = json.loads(raw)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("MCP configuration must be a JSON object")
|
||||||
parent = data
|
parent = data
|
||||||
for key in keys:
|
for key in keys:
|
||||||
parent = parent.get(key, {})
|
child = parent.get(key)
|
||||||
|
if child is None and install_missing:
|
||||||
|
child = parent[key] = {}
|
||||||
|
if not isinstance(child, dict):
|
||||||
|
raise ValueError(f"Invalid MCP configuration section: {key}")
|
||||||
|
parent = child
|
||||||
config = parent.get("artemis")
|
config = parent.get("artemis")
|
||||||
if not isinstance(config, dict) or not _belongs_to_project(config, root):
|
if config is None and install_missing:
|
||||||
return False
|
if template is None:
|
||||||
updated = {**config, "command": python, **_tool_updates(config, names)}
|
raise ValueError("A standard MCP template is required for installation")
|
||||||
|
updated = template
|
||||||
|
elif isinstance(config, dict) and _is_standard_local_artemis(config):
|
||||||
|
if template is None:
|
||||||
|
template = {
|
||||||
|
"command": python,
|
||||||
|
"args": ["-m", "mcp_server"],
|
||||||
|
"env": {"PYTHONPATH": str(root)},
|
||||||
|
}
|
||||||
|
updated = _updated_entry(config, template, root, names)
|
||||||
|
elif config is not None and not isinstance(config, dict):
|
||||||
|
raise ValueError("The Artemis MCP configuration is not an object")
|
||||||
|
else:
|
||||||
|
return None
|
||||||
if updated == config:
|
if updated == config:
|
||||||
return False
|
return False
|
||||||
parent["artemis"] = updated
|
parent["artemis"] = updated
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
_atomic_write(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
_atomic_write(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def refresh_codex(path: Path, root: Path, python: str, names: tuple[str, ...]) -> bool:
|
def _toml_value(value: object) -> str:
|
||||||
if not path.is_file():
|
if isinstance(value, bool):
|
||||||
return False
|
return "true" if value else "false"
|
||||||
raw = path.read_text(encoding="utf-8")
|
return json.dumps(value, ensure_ascii=False)
|
||||||
config = tomllib.loads(raw).get("mcp_servers", {}).get("artemis")
|
|
||||||
if not isinstance(config, dict) or not _belongs_to_project(config, root):
|
|
||||||
return False
|
def _toml_inline_table(values: dict) -> str:
|
||||||
wanted = {"command": python, **_tool_updates(config, names)}
|
return "{ " + ", ".join(f"{key} = {_toml_value(value)}" for key, value in values.items()) + " }"
|
||||||
wanted = {key: value for key, value in wanted.items() if config.get(key) != value}
|
|
||||||
if not wanted:
|
|
||||||
return False
|
def _replace_toml_tables(raw: str, assignments: dict[str, dict]) -> str:
|
||||||
|
"""Update only Artemis TOML tables; preserve every unrelated section and setting."""
|
||||||
lines = raw.splitlines(keepends=True)
|
lines = raw.splitlines(keepends=True)
|
||||||
inside = False
|
output: list[str] = []
|
||||||
found = set()
|
section = ""
|
||||||
output = []
|
found: dict[str, set[str]] = {name: set() for name in assignments}
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
def append_missing():
|
def append_missing() -> None:
|
||||||
for key, value in wanted.items():
|
if section in assignments:
|
||||||
if key not in found:
|
for key, value in assignments[section].items():
|
||||||
output.append(f"{key} = {json.dumps(value, ensure_ascii=False)}\n")
|
if key not in found[section]:
|
||||||
found.add(key)
|
rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
|
||||||
|
output.append(f"{key} = {rendered}\n")
|
||||||
|
found[section].add(key)
|
||||||
|
|
||||||
# Replace complete assignment spans, including multiline arrays, using TOML parsing.
|
|
||||||
index = 0
|
index = 0
|
||||||
while index < len(lines):
|
while index < len(lines):
|
||||||
line = lines[index]
|
line = lines[index]
|
||||||
if line.lstrip().startswith("["):
|
if line.lstrip().startswith("["):
|
||||||
if inside:
|
|
||||||
append_missing()
|
append_missing()
|
||||||
header = re.sub(r"[\s\"']", "", line.split("#", 1)[0]).strip()
|
header = re.sub(r"[\s\"']", "", line.split("#", 1)[0]).strip()
|
||||||
inside = header == "[mcp_servers.artemis]"
|
section = header[1:-1] if header.startswith("[") and header.endswith("]") else ""
|
||||||
match = re.match(r"\s*(\w+)\s*=", line) if inside else None
|
seen.add(section)
|
||||||
if match and match[1] in wanted:
|
match = re.match(r"\s*(\w+)\s*=", line) if section in assignments else None
|
||||||
|
if match and match[1] in assignments[section]:
|
||||||
key = match[1]
|
key = match[1]
|
||||||
original = line
|
original = line
|
||||||
while True:
|
while True:
|
||||||
@@ -216,73 +279,236 @@ def refresh_codex(path: Path, root: Path, python: str, names: tuple[str, ...]) -
|
|||||||
if index >= len(lines):
|
if index >= len(lines):
|
||||||
raise ValueError("Cannot safely parse existing MCP setting")
|
raise ValueError("Cannot safely parse existing MCP setting")
|
||||||
original += lines[index]
|
original += lines[index]
|
||||||
output.append(f"{key} = {json.dumps(wanted[key], ensure_ascii=False)}\n")
|
value = assignments[section][key]
|
||||||
found.add(key)
|
rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
|
||||||
|
output.append(f"{key} = {rendered}\n")
|
||||||
|
found[section].add(key)
|
||||||
else:
|
else:
|
||||||
output.append(line)
|
output.append(line)
|
||||||
index += 1
|
index += 1
|
||||||
if inside:
|
|
||||||
append_missing()
|
append_missing()
|
||||||
updated = "".join(output)
|
for name, values in assignments.items():
|
||||||
parsed = tomllib.loads(updated)
|
if name not in seen:
|
||||||
if any(parsed["mcp_servers"]["artemis"].get(k) != v for k, v in wanted.items()):
|
if output and not output[-1].endswith("\n"):
|
||||||
raise ValueError("Could not locate the existing Artemis table safely")
|
output.append("\n")
|
||||||
_atomic_write(path, updated)
|
output.append(f"\n[{name}]\n")
|
||||||
return updated != raw
|
for key, value in values.items():
|
||||||
|
rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
|
||||||
|
output.append(f"{key} = {rendered}\n")
|
||||||
|
return "".join(output)
|
||||||
|
|
||||||
|
|
||||||
def refresh_mcp(root: Path) -> None:
|
def refresh_codex(
|
||||||
|
path: Path,
|
||||||
|
root: Path,
|
||||||
|
python: str,
|
||||||
|
names: tuple[str, ...],
|
||||||
|
template: dict | None = None,
|
||||||
|
install_missing: bool = False,
|
||||||
|
) -> bool | None:
|
||||||
|
if not path.is_file() and not install_missing:
|
||||||
|
return False
|
||||||
|
raw = path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||||
|
parsed = tomllib.loads(raw)
|
||||||
|
config = parsed.get("mcp_servers", {}).get("artemis")
|
||||||
|
if config is None and install_missing:
|
||||||
|
if template is None:
|
||||||
|
raise ValueError("A standard MCP template is required for installation")
|
||||||
|
updated = template
|
||||||
|
elif isinstance(config, dict) and _is_standard_local_artemis(config):
|
||||||
|
if template is None:
|
||||||
|
template = {
|
||||||
|
"command": python,
|
||||||
|
"args": ["-m", "mcp_server"],
|
||||||
|
"cwd": str(root),
|
||||||
|
"env": {"PYTHONPATH": str(root)},
|
||||||
|
"enabled_tools": list(names),
|
||||||
|
}
|
||||||
|
updated = _updated_entry(config, template, root, names)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
top = {
|
||||||
|
key: value
|
||||||
|
for key, value in updated.items()
|
||||||
|
if key != "env" and (not config or config.get(key) != value)
|
||||||
|
}
|
||||||
|
environment = updated["env"]
|
||||||
|
if config and config.get("env") == environment:
|
||||||
|
env_updates = {}
|
||||||
|
else:
|
||||||
|
env_updates = {
|
||||||
|
key: value
|
||||||
|
for key, value in environment.items()
|
||||||
|
if not config or config.get("env", {}).get(key) != value
|
||||||
|
}
|
||||||
|
env_header = "mcp_servers.artemis.env"
|
||||||
|
has_env_table = re.search(r"(?m)^\s*\[mcp_servers\.artemis\.env\]\s*(?:#.*)?$", raw) is not None
|
||||||
|
if config and "env" in config and not has_env_table and env_updates:
|
||||||
|
top["env"] = environment
|
||||||
|
env_updates = {}
|
||||||
|
assignments = {"mcp_servers.artemis": top}
|
||||||
|
if env_updates:
|
||||||
|
assignments[env_header] = env_updates
|
||||||
|
if not top and not env_updates:
|
||||||
|
return False
|
||||||
|
result = _replace_toml_tables(raw, assignments)
|
||||||
|
actual = tomllib.loads(result)["mcp_servers"]["artemis"]
|
||||||
|
if any(actual.get(key) != value for key, value in top.items()) or any(
|
||||||
|
actual.get("env", {}).get(key) != value for key, value in env_updates.items()
|
||||||
|
):
|
||||||
|
raise ValueError("Could not safely refresh the Artemis MCP table")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_atomic_write(path, result)
|
||||||
|
return result != raw
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_mcp(root: Path) -> int:
|
||||||
sys.path.insert(0, str(root))
|
sys.path.insert(0, str(root))
|
||||||
from artemis.interfaces.cli.commands.mcp import ARTEMIS_MCP_TOOLS, _get_vscode_user_dir
|
from artemis.interfaces.cli.commands.mcp import (
|
||||||
|
ARTEMIS_MCP_TOOLS,
|
||||||
|
_get_config_snippet,
|
||||||
|
_get_vscode_user_dir,
|
||||||
|
)
|
||||||
|
|
||||||
home = Path.home()
|
home = Path.home()
|
||||||
vscode = _get_vscode_user_dir()
|
vscode = _get_vscode_user_dir()
|
||||||
appdata = Path(os.environ.get("APPDATA", home / "AppData/Roaming"))
|
appdata = Path(os.environ.get("APPDATA", home / "AppData/Roaming"))
|
||||||
|
cline_store = vscode / "globalStorage/saoudrizwan.claude-dev"
|
||||||
|
roo_store = vscode / "globalStorage/rooveterinaryinc.roo-cline"
|
||||||
paths = [
|
paths = [
|
||||||
(home / ".gemini/config/mcp_config.json", ("mcpServers",)),
|
(home / ".gemini/config/mcp_config.json", ("mcpServers",), "antigravity", home / ".gemini"),
|
||||||
(home / ".gemini/jetski/mcp_config.json", ("mcpServers",)),
|
(home / ".gemini/jetski/mcp_config.json", ("mcpServers",), "jetski", home / ".gemini"),
|
||||||
(home / ".gemini/antigravity/mcp_config.json", ("mcpServers",)),
|
|
||||||
(home / ".cursor/mcp.json", ("mcpServers",)),
|
|
||||||
(home / ".codeium/windsurf/mcp_config.json", ("mcpServers",)),
|
|
||||||
(home / ".claude.json", ("mcpServers",)),
|
|
||||||
(appdata / "Claude/claude_desktop_config.json", ("mcpServers",)),
|
|
||||||
(vscode / "mcp.json", ("servers",)),
|
|
||||||
(home / ".copilot/mcp-config.json", ("servers",)),
|
|
||||||
(home / ".cline/data/settings/cline_mcp_settings.json", ("mcpServers",)),
|
|
||||||
(home / ".cline/mcp.json", ("mcpServers",)),
|
|
||||||
(
|
(
|
||||||
vscode / "globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
|
home / ".gemini/antigravity/mcp_config.json",
|
||||||
("mcpServers",),
|
("mcpServers",),
|
||||||
|
"antigravity",
|
||||||
|
home / ".gemini",
|
||||||
|
),
|
||||||
|
(home / ".cursor/mcp.json", ("mcpServers",), "cursor", home / ".cursor"),
|
||||||
|
(
|
||||||
|
home / ".codeium/windsurf/mcp_config.json",
|
||||||
|
("mcpServers",),
|
||||||
|
"windsurf",
|
||||||
|
home / ".codeium/windsurf",
|
||||||
|
),
|
||||||
|
(home / ".claude.json", ("mcpServers",), "claude", home / ".claude"),
|
||||||
|
(
|
||||||
|
appdata / "Claude/claude_desktop_config.json",
|
||||||
|
("mcpServers",),
|
||||||
|
"claude",
|
||||||
|
appdata / "Claude",
|
||||||
|
),
|
||||||
|
(vscode / "mcp.json", ("servers",), "vscode", vscode),
|
||||||
|
(home / ".copilot/mcp-config.json", ("servers",), "vscode", home / ".copilot"),
|
||||||
|
(
|
||||||
|
home / ".cline/data/settings/cline_mcp_settings.json",
|
||||||
|
("mcpServers",),
|
||||||
|
"cline",
|
||||||
|
home / ".cline",
|
||||||
|
),
|
||||||
|
(home / ".cline/mcp.json", ("mcpServers",), "cline", home / ".cline"),
|
||||||
|
(
|
||||||
|
cline_store / "settings/cline_mcp_settings.json",
|
||||||
|
("mcpServers",),
|
||||||
|
"cline",
|
||||||
|
cline_store,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
|
roo_store / "settings/mcp_settings.json",
|
||||||
("mcpServers",),
|
("mcpServers",),
|
||||||
|
"roo",
|
||||||
|
roo_store,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json",
|
roo_store / "settings/cline_mcp_settings.json",
|
||||||
("mcpServers",),
|
("mcpServers",),
|
||||||
|
"roo",
|
||||||
|
roo_store,
|
||||||
),
|
),
|
||||||
(root / ".roo/mcp.json", ("mcpServers",)),
|
(root / ".roo/mcp.json", ("mcpServers",), "roo", root / ".roo"),
|
||||||
(home / ".openclaw/openclaw.json", ("mcp", "servers")),
|
(home / ".openclaw/openclaw.json", ("mcp", "servers"), "openclaw", home / ".openclaw"),
|
||||||
]
|
]
|
||||||
changed = 0
|
codex_home = Path(os.environ.get("CODEX_HOME", home / ".codex"))
|
||||||
codex = Path(os.environ.get("CODEX_HOME", home / ".codex")) / "config.toml"
|
paths.append((codex_home / "config.toml", (), "codex", codex_home))
|
||||||
for path, keys in [*paths, (codex, ())]:
|
installed = updated_count = ready = skipped = errors = 0
|
||||||
|
for path, keys, client, marker in paths:
|
||||||
|
if not path.is_file() and not marker.is_dir():
|
||||||
|
continue
|
||||||
|
existed = path.is_file()
|
||||||
|
template = _get_config_snippet(client, sys.executable, str(root))
|
||||||
|
for key in keys:
|
||||||
|
template = template[key]
|
||||||
|
template = template["artemis"] if keys else template["mcp_servers"]["artemis"]
|
||||||
try:
|
try:
|
||||||
updated = (
|
changed = (
|
||||||
refresh_json(path, keys, root, sys.executable, ARTEMIS_MCP_TOOLS)
|
refresh_json(
|
||||||
|
path,
|
||||||
|
keys,
|
||||||
|
root,
|
||||||
|
sys.executable,
|
||||||
|
ARTEMIS_MCP_TOOLS,
|
||||||
|
template=template,
|
||||||
|
install_missing=True,
|
||||||
|
)
|
||||||
if keys
|
if keys
|
||||||
else refresh_codex(path, root, sys.executable, ARTEMIS_MCP_TOOLS)
|
else refresh_codex(
|
||||||
|
path,
|
||||||
|
root,
|
||||||
|
sys.executable,
|
||||||
|
ARTEMIS_MCP_TOOLS,
|
||||||
|
template=template,
|
||||||
|
install_missing=True,
|
||||||
)
|
)
|
||||||
if updated:
|
)
|
||||||
print(f"[UPDATED] Existing MCP configuration: {path}")
|
if changed:
|
||||||
changed += 1
|
|
||||||
except (OSError, ValueError, TypeError, AttributeError):
|
|
||||||
print(f"[WARN] Could not safely refresh {path}; original configuration preserved.")
|
|
||||||
print(
|
print(
|
||||||
f"[OK] MCP source is current; refreshed {changed} existing client configuration(s). Reload the MCP connection in your AI client after a source update."
|
launch_text(
|
||||||
|
f"[{'UPDATED' if existed else 'INSTALLED'}] MCP configuration: {path}",
|
||||||
|
f"[{'已更新' if existed else '已安装'}] MCP 配置:{path}",
|
||||||
)
|
)
|
||||||
print("[TIP] For a new client run: uv run artemis mcp --install <client>")
|
)
|
||||||
|
if existed:
|
||||||
|
updated_count += 1
|
||||||
|
else:
|
||||||
|
installed += 1
|
||||||
|
elif changed is None:
|
||||||
|
print(
|
||||||
|
launch_text(
|
||||||
|
f"[SKIP] Custom Artemis MCP entry in {path}; use the manual installer for this client.",
|
||||||
|
f"[跳过] {path} 使用自定义 ARTEMIS MCP 启动方式;请手动检查此客户端。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
ready += 1
|
||||||
|
except (OSError, ValueError, TypeError, AttributeError):
|
||||||
|
print(
|
||||||
|
launch_text(
|
||||||
|
f"[ERROR] Could not safely synchronize {path}; original configuration preserved.",
|
||||||
|
f"[错误] 无法安全同步 {path};原配置已保留。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
errors += 1
|
||||||
|
print(
|
||||||
|
launch_text(
|
||||||
|
f"[CHECK] MCP clients: installed {installed}, updated {updated_count}, already current {ready}, custom skipped {skipped}, errors {errors}.",
|
||||||
|
f"[检查] MCP 客户端:新装 {installed} 个、更新 {updated_count} 个、已是最新 {ready} 个、自定义跳过 {skipped} 个、失败 {errors} 个。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
launch_text(
|
||||||
|
"[ACTION] Reload or restart your AI client to load the current MCP process and tool schemas.",
|
||||||
|
"[操作] 请在 AI 客户端重新加载 MCP 或重启客户端,使新版进程和工具签名生效。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not (installed or updated_count or ready or skipped or errors):
|
||||||
|
print(
|
||||||
|
launch_text(
|
||||||
|
"[TIP] No supported client detected; install one with: uv run artemis mcp --install <client>",
|
||||||
|
"[提示] 未检测到受支持的客户端;可执行 uv run artemis mcp --install <client>。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 1 if errors else 0
|
||||||
|
|
||||||
|
|
||||||
def frontend_fingerprint(root: Path) -> str:
|
def frontend_fingerprint(root: Path) -> str:
|
||||||
@@ -314,7 +540,7 @@ if __name__ == "__main__":
|
|||||||
if args.action == "source":
|
if args.action == "source":
|
||||||
sys.exit(update_source(args.root, args.port))
|
sys.exit(update_source(args.root, args.port))
|
||||||
elif args.action == "mcp":
|
elif args.action == "mcp":
|
||||||
refresh_mcp(args.root)
|
sys.exit(refresh_mcp(args.root))
|
||||||
else:
|
else:
|
||||||
marker = args.root / "apps/showcase_ui/node_modules/.artemis-source-hash"
|
marker = args.root / "apps/showcase_ui/node_modules/.artemis-source-hash"
|
||||||
current = frontend_fingerprint(args.root)
|
current = frontend_fingerprint(args.root)
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
|
|||||||
2. 用 `mobile_workspace` 查看设备和队列。沿用用户已指定的设备;多台可用设备且没有选择时先询问,不擅自挑选实体手机。任务参数显式传入 `device_serial`。同设备串行,不同设备可并行;本 Skill 不要求创建其他 AI 子代理。
|
2. 用 `mobile_workspace` 查看设备和队列。沿用用户已指定的设备;多台可用设备且没有选择时先询问,不擅自挑选实体手机。任务参数显式传入 `device_serial`。同设备串行,不同设备可并行;本 Skill 不要求创建其他 AI 子代理。
|
||||||
3. 首次使用、工具出错、任务拒绝或设备异常,先 `mobile_diagnose`。按 `next_steps` 的依赖顺序处理;`attempt_fix=true` 可尝试工具自带修复。只有凭据相关故障才使用 `verify_credentials=true`,观察异常才使用 `probe_device=true`。不要盲目重发任务。MCP 不可用时在项目目录运行 `uv run artemis doctor` 并检查宿主 MCP 日志。
|
3. 首次使用、工具出错、任务拒绝或设备异常,先 `mobile_diagnose`。按 `next_steps` 的依赖顺序处理;`attempt_fix=true` 可尝试工具自带修复。只有凭据相关故障才使用 `verify_credentials=true`,观察异常才使用 `probe_device=true`。不要盲目重发任务。MCP 不可用时在项目目录运行 `uv run artemis doctor` 并检查宿主 MCP 日志。
|
||||||
4. 选择执行模式:Flash 适合路径明确的界面操作;Pro 适合动态分支、持续计划、ADB/日志诊断或需要 Checker 明确校验的任务。**`mobile_run_task.model` 的 `flash`/`pro` 是执行模式,不是 API 模型名称**。步数长不自动等于需要 Pro;视频能力只有显式启用后才可用。
|
4. 选择执行模式:Flash 适合路径明确的界面操作;Pro 适合动态分支、持续计划、ADB/日志诊断或需要 Checker 明确校验的任务。**`mobile_run_task.model` 的 `flash`/`pro` 是执行模式,不是 API 模型名称**。步数长不自动等于需要 Pro;视频能力只有显式启用后才可用。
|
||||||
5. 单任务用 `mobile_run_task`;批量任务用 `mobile_batch`,读 [批量任务与交付](references/batches.md)。AI 可直接提交结构化用例,不必先创建 Excel。用户提供 Excel 时先 `mobile_task_file(action="preview", path=绝对路径)`,修正所有行错误后提交。
|
5. 单任务用 `mobile_run_task`;批量任务用 `mobile_batch`,读 [批量任务与交付](references/batches.md)。AI 可直接提交结构化用例,不必先创建 Excel。用户提供 Excel 时先 `mobile_task_file(action="preview", path=绝对路径)`,修正所有行错误后提交。提示词列原样作为目标,前置约束列只作元数据。
|
||||||
6. 单任务返回 `trace_id` 后,用 `mobile_manage_task(action="status", trace_id=...)` 跟踪;批次用 `mobile_batch(action="get", batch_id=...)`。尊重工具建议的等待周期,等待期间可以整理已得证据,避免连续高频轮询。停滞或失败先诊断;不把同一业务任务自动重新提交来解决模型错误。
|
6. 单任务返回 `trace_id` 后,用 `mobile_manage_task(action="status", trace_id=...)` 跟踪;网页任务也可用其任务编号。批次用 `mobile_batch(action="get", batch_id=...)`。需要人工补充时用 `pause`,其返回的 `pausing` 只表示请求已接收;轮询到 `task_paused` 才表示设备已释放。`inject_instruction` 会立即取消进行中的模型回复或只读诊断并重新观察手机,已开始的设备动作会先安全结束;暂停中提交则恢复。`guidance_history` 的 `pending/consumed` 区分提交和执行端领取,无新指导用 `resume`。恢复进入设备 FIFO 队尾并重新观察手机状态。尊重工具建议的等待周期,避免连续高频轮询;停滞或失败先诊断,不把同一业务任务自动重提。
|
||||||
|
|
||||||
## 结果、证据与交付
|
## 结果、证据与交付
|
||||||
|
|
||||||
- 执行状态与业务判定分开:`completed` 只说明执行结束,不等于测试通过。检查工具返回的验证摘要、预期/实际结果,以及 `mobile_inspect_trace` 的步骤/截图。证据不足标记“未验证”;前置条件不满足说明阻塞原因,不能编造通过结论。
|
- 执行状态与业务判定分开:`completed` 只说明执行结束,不等于测试通过。检查工具返回的验证摘要、预期/实际结果,以及 `mobile_inspect_trace` 的步骤/截图。证据不足标记“未验证”;前置条件不满足说明阻塞原因,不能编造通过结论。
|
||||||
- 用 `mobile_export_report(session_id=...)` 获取单任务报告;批次传 `batch_id` 获取汇总和每项任务报告的 ZIP。未指定格式时使用保存的 Word/Excel 偏好。执行中的导出只是当前快照。
|
- 用 `mobile_export_report(session_id=...)` 获取单任务报告;`formats=["screenshots"]` 获取排序截图,`["video"]` 获取保存的录屏分段,`["all"]` 获取 Word、Excel 和所有可用资料的 ZIP。批次传 `batch_id` 获取汇总和各项任务报告的 ZIP。未指定格式时使用保存的 Word/Excel 偏好。执行中的导出只是当前快照。
|
||||||
- 工具返回 MCP 主机上的绝对文件路径和下载 URL。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。
|
- 工具返回 MCP 主机上的绝对文件路径和下载 URL。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。
|
||||||
- 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。
|
- 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。
|
||||||
- `mobile_inspect_trace` 是只读步骤证据查看;其中历史上下文的 replay 术语不表示重新执行设备动作。
|
- `mobile_inspect_trace` 是只读步骤证据查看;其中历史上下文的 replay 术语不表示重新执行设备动作。
|
||||||
@@ -30,8 +30,8 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
|
|||||||
- 模型名称支持预设 `gpt-5.6-luna`、`mimo-v2.6-flash` 和自定义名称,取决于用户配置的 Responses 网关。思考强度最高支持 `max`,默认 `high`、单次请求 300 秒、失败后最多重试 5 次、固定间隔 10 秒;默认关闭视频分析与录屏。不根据模型名称猜测网关能力。
|
- 模型名称支持预设 `gpt-5.6-luna`、`mimo-v2.6-flash` 和自定义名称,取决于用户配置的 Responses 网关。思考强度最高支持 `max`,默认 `high`、单次请求 300 秒、失败后最多重试 5 次、固定间隔 10 秒;默认关闭视频分析与录屏。不根据模型名称猜测网关能力。
|
||||||
- 强度选 `default` 表示不发送该参数,和 `none`(显式要求关闭)不同。接口拒绝强度参数时明确呈现失败,先询问用户是否改用 `default`,不能静默降级或换模型。
|
- 强度选 `default` 表示不发送该参数,和 `none`(显式要求关闭)不同。接口拒绝强度参数时明确呈现失败,先询问用户是否改用 `default`,不能静默降级或换模型。
|
||||||
- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。
|
- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。
|
||||||
- 当前视频开关仅提供保存的全局设置,没有单任务覆盖或独立视频文件分析接口。用户只要求临时分析且默认关闭时,明确说明这个限制;不能用“先开全局、执行后再关”影响同期任务。只有用户改为要求保存启用后,才修改配置并提交新任务。
|
- 录屏与 AI 视频分析分别配置。批量任务的 `task_config` 可覆盖单项,不修改全局设置;只开视频分析时录制内容不作为可下载文件保存。暂停时停止录屏,恢复后按片段继续,不能把其他任务画面混入该任务资料。
|
||||||
- 请求重试是模型调用恢复,不是测试用例重跑。批次默认失败继续、不自动重跑;服务重启后保留状态并暂停,用户明确要求后才继续或复制失败项。
|
- 请求重试是模型调用恢复,不是测试用例重跑。批次默认失败继续、不自动重跑;服务重启后保留状态并暂停,用户明确要求后才继续或复制失败项。
|
||||||
- 批次暂停只停止后续派发;要停止当前任务使用 `mobile_manage_task(action="stop", trace_id=...)`。停止可能保留已经产生的业务效果,不声称自动回滚。
|
- 批次暂停只停止后续派发;当前任务等待指导时用 `mobile_manage_task(action="pause", trace_id=...)`,真正取消用 `stop`。用户明确要求删除批次记录时先 `mobile_batch(action="get")` 核对编号,再用 `delete`;执行中/排队中的批次不能删,删除会清除该批次的任务历史、报告和文件。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。
|
||||||
|
|
||||||
需要编写外部可执行测试代码时,先通过 ARTEMIS 实际探索目标应用,确认页面、定位器和转换。优先动态定位,框架支持时才以已验证坐标兜底;在最终脚本中明确等待条件,不将 AI 思考耗时当作确定性等待。
|
需要编写外部可执行测试代码时,先通过 ARTEMIS 实际探索目标应用,确认页面、定位器和转换。优先动态定位,框架支持时才以已验证坐标兜底;在最终脚本中明确等待条件,不将 AI 思考耗时当作确定性等待。
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 直接提交用例
|
## 直接提交用例
|
||||||
|
|
||||||
用户要求执行明确的测试集合时,将每个独立目标转成一个 case。`parameters` 是简单值字典,`tags` 是字符串,不能填列表;目标中的 `{{参数名}}` 会替换成参数值。前置条件用自然语言描述真实起点。
|
用户要求执行明确的测试集合时,将每个独立提示词转成一个 case。`goal` 是唯一发送给 ARTEMIS 的任务正文;`case_id`、`category`、`subcategory`、`name`、`preconditions` 只作元数据。`task_config` 可覆盖该项执行配置,不影响其他项。
|
||||||
|
|
||||||
先生成唯一 `request_id`(例如 UUID),保存在当前任务记录中,然后调用:
|
先生成唯一 `request_id`(例如 UUID),保存在当前任务记录中,然后调用:
|
||||||
|
|
||||||
@@ -19,13 +19,21 @@
|
|||||||
"timeout_seconds": 1800,
|
"timeout_seconds": 1800,
|
||||||
"cases": [
|
"cases": [
|
||||||
{
|
{
|
||||||
|
"case_id": "DEMO-001",
|
||||||
|
"category": "系统设置",
|
||||||
|
"subcategory": "首页",
|
||||||
"name": "设置首页可达",
|
"name": "设置首页可达",
|
||||||
"goal": "打开系统设置,确认设置首页可见,不修改任何设置。",
|
|
||||||
"expected": "能看到设置首页及至少一个设置分类,记录实际文字与截图证据。",
|
|
||||||
"app": "com.android.settings",
|
|
||||||
"preconditions": "设备已解锁。",
|
"preconditions": "设备已解锁。",
|
||||||
"parameters": {},
|
"goal": "打开系统设置,确认设置首页可见,不修改任何设置;记录实际文字与截图证据。",
|
||||||
"tags": "冒烟,只读"
|
"task_config": {
|
||||||
|
"profile": "pro",
|
||||||
|
"model": "mimo-v2.6-flash",
|
||||||
|
"reasoning_effort": "high",
|
||||||
|
"locked_app_package": "com.android.settings",
|
||||||
|
"record_video": true,
|
||||||
|
"analyze_video": false,
|
||||||
|
"request_timeout_seconds": 300
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -38,19 +46,20 @@
|
|||||||
|
|
||||||
## Excel 导入
|
## Excel 导入
|
||||||
|
|
||||||
`mobile_task_file(action="template")` 返回空白模板的本地路径。标准列为任务名称、任务目标、预期结果、目标应用、前置条件、输入参数(JSON)、标签。每行独立,不支持公式、宏、外部链接。
|
`mobile_task_file(action="template")` 返回空白模板的本地路径。“任务”页七列依次为:用例编号、检测项目、测试子项、测试用例、前置约束提取、Artemis提示词、任务配置(JSON)。仅提示词必填;说明页有一条示例,不会导入执行。JSON 支持 `profile`、`model`、`reasoning_effort`、`device_serial`、`verification_level`、`explorer_mode`、`locked_app_package`、`record_video`、`analyze_video`、`request_timeout_seconds`、`max_retries`、`retry_interval_seconds`、`timeout_seconds`。未知字段或密钥被拒绝。每行独立,不支持公式、宏、外部链接。
|
||||||
|
|
||||||
`preview` 返回 `valid/tasks/errors`,不会执行。所有错误处理完后,将 `tasks` 中每项的预览字段 `row` 去掉,作为 `batch.cases` 提交。不要跳过错误行却声称全部导入成功。
|
`preview` 返回 `valid/tasks/errors`,不会执行。所有错误处理完后,将 `tasks` 中每项的预览字段 `row` 去掉,作为 `batch.cases` 提交。不要跳过错误行却声称全部导入成功。
|
||||||
|
|
||||||
## 状态与异常
|
## 状态与异常
|
||||||
|
|
||||||
- 批次 `draft` 等待开始;`running` 调度中;`paused` 等待处理;`completed` 表示所有项进入终态,不代表全部通过。
|
- 批次 `draft` 等待开始;`running` 调度中;`paused` 等待处理;`completed` 表示所有项进入终态,不代表全部通过。
|
||||||
- 单项 `pending/queued/running/finalizing` 尚未结束,`completed/failed/cancelled/interrupted/skipped` 是终态。`finalizing` 在生成报告,不能提前宣称报告已完成。
|
- 单项 `pending/queued/running/pausing/task_paused/resuming/finalizing` 尚未结束,`completed/failed/cancelled/interrupted/skipped` 是终态。`pausing` 是请求已发出而设备仍被占用,`task_paused` 才表示设备已释放;`finalizing` 在生成报告,不能提前宣称报告已完成。
|
||||||
- `pause` 保留当前执行。设备断开、模型持续失败或任务达到批次时限会暂停后续派发;用诊断确认恢复后再响应用户的继续指令。
|
- 批次 `pause` 保留当前执行,只暂停后续派发;单项 `mobile_manage_task(pause)` 会在安全边界释放设备,Pro 的模型等待与只读诊断可中断。恢复后重新观察画面,并排到设备队尾,不阻塞后续项。`mobile_manage_task(guidance_history)` 可核对指导是否从 `pending` 变为 `consumed`,但仍需检查后续操作是否符合指导。设备断开、模型持续失败或任务达到批次时限会暂停后续派发;用诊断确认恢复后再响应用户的继续指令。
|
||||||
- `resume` 只继续未开始项;`retry_failed` 复制失败、取消和中断项为新草稿,不修改原结果。必须来自用户的明确重跑请求。该动作不提供创建去重,响应不确定时先 `list` 查找新批次,不自动重复调用。
|
- `resume` 只继续未开始项;`retry_failed` 复制失败、取消和中断项为新草稿,不修改原结果。必须来自用户的明确重跑请求。该动作不提供创建去重,响应不确定时先 `list` 查找新批次,不自动重复调用。
|
||||||
|
- 用户明确要求删除批次记录时,先 `get` 核对批次编号与状态,再 `delete`;正在执行、排队或等待指导的批次不能删除。删除会同时移除该批次任务历史、报告和文件,无法撤销。
|
||||||
|
|
||||||
## 报告
|
## 报告
|
||||||
|
|
||||||
`mobile_export_report(batch_id=...)` 下载批次 ZIP,包含汇总和各项报告,格式由创建批次时的设置决定。单任务传 `session_id`(单任务 MCP 的 `trace_id`)以及可选 `formats=["docx"]`。默认格式通过 `mobile_config(section="reports")` 查看。
|
`mobile_export_report(batch_id=...)` 下载批次 ZIP,包含汇总和各项报告,格式由创建批次时的设置决定。单任务传 `session_id`(单任务 MCP 的 `trace_id`)以及可选 `formats=["docx"]`、`["screenshots"]`、`["video"]` 或 `["all"]`。默认格式通过 `mobile_config(section="reports")` 查看。录屏与 AI 视频分析独立,只有保存了录屏才可下载片段。
|
||||||
|
|
||||||
结果文件在 MCP 主机的 `artifacts/mcp_exports/`。不要把 Office 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。
|
结果文件在 MCP 主机的 `artifacts/mcp_exports/`。不要把 Office 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。
|
||||||
+30
-3
@@ -1,6 +1,33 @@
|
|||||||
@echo off
|
@echo off
|
||||||
rem Use China mirrors for first setup and subsequent launches.
|
chcp 65001 >nul 2>&1
|
||||||
rem All start.bat arguments, including -SetupOnly, are supported.
|
|
||||||
setlocal DisableDelayedExpansion
|
setlocal DisableDelayedExpansion
|
||||||
|
set "ARTEMIS_LAUNCH_LANG=zh-CN"
|
||||||
|
title ARTEMIS 国内镜像启动
|
||||||
|
|
||||||
|
if /i "%~1"=="-Help" goto help
|
||||||
|
if /i "%~1"=="--help" goto help
|
||||||
|
|
||||||
|
echo ======================================================
|
||||||
|
echo ARTEMIS 中文启动 - 使用国内镜像
|
||||||
|
echo ======================================================
|
||||||
|
echo 正在检查已有环境、项目更新及依赖;首次启动可能需要下载组件。
|
||||||
|
echo 镜像地址和下载超时可在 config\downloads.json 中调整。
|
||||||
|
echo.
|
||||||
call "%~dp0start.bat" -Mirror China %*
|
call "%~dp0start.bat" -Mirror China %*
|
||||||
exit /b %ERRORLEVEL%
|
set "ARTEMIS_CN_EXIT_CODE=%ERRORLEVEL%"
|
||||||
|
if not "%ARTEMIS_CN_EXIT_CODE%"=="0" echo 启动未完成;请查看上方错误信息,并检查网络或镜像设置。
|
||||||
|
endlocal & exit /b %ARTEMIS_CN_EXIT_CODE%
|
||||||
|
|
||||||
|
:help
|
||||||
|
echo 用法:start-cn.bat [选项]
|
||||||
|
echo.
|
||||||
|
echo -SetupOnly 安装或检查环境,不启动网页
|
||||||
|
echo -Port 8001 使用指定端口,默认 8000
|
||||||
|
echo -NoOpen 启动服务后不自动打开浏览器
|
||||||
|
echo -SkipUpdate 跳过 Git 源码更新检查
|
||||||
|
echo -SkipMcpSync 仅启动网页,跳过 MCP 安装和更新
|
||||||
|
echo -Help 显示本说明
|
||||||
|
echo.
|
||||||
|
echo 国内镜像设置:config\downloads.json
|
||||||
|
echo 首次运行需联网;后续运行会检查源码、依赖,并同步已安装 AI 客户端的 MCP。
|
||||||
|
endlocal & exit /b 0
|
||||||
@@ -15,12 +15,12 @@ rem limitations under the License.
|
|||||||
|
|
||||||
chcp 65001 >nul 2>&1
|
chcp 65001 >nul 2>&1
|
||||||
setlocal DisableDelayedExpansion
|
setlocal DisableDelayedExpansion
|
||||||
title Artemis Mobile Agent UI
|
if /i "%ARTEMIS_LAUNCH_LANG%"=="zh-CN" (title ARTEMIS 手机自动化测试) else (title Artemis Mobile Agent UI)
|
||||||
|
|
||||||
pushd "%~dp0" || exit /b 1
|
pushd "%~dp0" || exit /b 1
|
||||||
|
|
||||||
echo ======================================================
|
echo ======================================================
|
||||||
echo Artemis Autonomous Mobile Agent UI
|
if /i "%ARTEMIS_LAUNCH_LANG%"=="zh-CN" (echo ARTEMIS 手机自动化测试) else (echo Artemis Autonomous Mobile Agent UI)
|
||||||
echo ======================================================
|
echo ======================================================
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ set "ARTEMIS_EXIT_CODE=%ERRORLEVEL%"
|
|||||||
|
|
||||||
if not "%ARTEMIS_EXIT_CODE%"=="0" (
|
if not "%ARTEMIS_EXIT_CODE%"=="0" (
|
||||||
echo.
|
echo.
|
||||||
echo An error occurred while starting Artemis.
|
if /i "%ARTEMIS_LAUNCH_LANG%"=="zh-CN" (echo ARTEMIS 启动失败,请查看上方详细信息。) else (echo An error occurred while starting Artemis.)
|
||||||
if not defined ARTEMIS_NO_PAUSE pause
|
if not defined ARTEMIS_NO_PAUSE pause
|
||||||
)
|
)
|
||||||
popd
|
popd
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import json
|
import json
|
||||||
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
from uuid import UUID
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
@@ -64,9 +67,9 @@ def workbook_bytes(rows):
|
|||||||
def test_template_preview_rejects_formula_and_bad_json():
|
def test_template_preview_rejects_formula_and_bad_json():
|
||||||
data = workbook_bytes(
|
data = workbook_bytes(
|
||||||
[
|
[
|
||||||
["正常", "打开设置", "", "", "", '{"name":"测试"}', "冒烟"],
|
["正常", "设置", "冒烟", "打开设置", "", "观察设置", '{"profile":"flash"}'],
|
||||||
["公式", "=SUM(1,2)"],
|
["公式", "", "", "", "", "=SUM(1,2)"],
|
||||||
["参数错误", "测试", "", "", "", "{"],
|
["参数错误", "", "", "", "", "测试", "{"],
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
result = task_import.parse_workbook(data)
|
result = task_import.parse_workbook(data)
|
||||||
@@ -75,6 +78,30 @@ def test_template_preview_rejects_formula_and_bad_json():
|
|||||||
assert not result["valid"]
|
assert not result["valid"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_only_and_per_row_config_validation():
|
||||||
|
goal = "先查看屏幕,再按实际画面判断。"
|
||||||
|
data = workbook_bytes(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"CASE-7",
|
||||||
|
"检查项目",
|
||||||
|
"子项",
|
||||||
|
"用例名称",
|
||||||
|
"仅作索引的约束",
|
||||||
|
goal,
|
||||||
|
'{"profile":"pro","model":"mimo-v2.6-flash","record_video":true}',
|
||||||
|
],
|
||||||
|
["CASE-8", "", "", "", "", "观察屏幕", '{"OPENAI_API_KEY":"never"}'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
preview = task_import.parse_workbook(data)
|
||||||
|
assert [e["row"] for e in preview["errors"]] == [3]
|
||||||
|
case = preview["tasks"][0]
|
||||||
|
assert task_import.case_goal(case) == goal
|
||||||
|
assert case["preconditions"] == "仅作索引的约束"
|
||||||
|
assert case["task_config"]["record_video"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_archive_and_empty_template():
|
def test_invalid_archive_and_empty_template():
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
task_import.parse_workbook(b"not a zip")
|
task_import.parse_workbook(b"not a zip")
|
||||||
@@ -84,7 +111,9 @@ def test_invalid_archive_and_empty_template():
|
|||||||
def test_preview_does_not_enqueue(client, isolated):
|
def test_preview_does_not_enqueue(client, isolated):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/automation/import/preview",
|
"/api/automation/import/preview",
|
||||||
files={"file": ("tasks.xlsx", workbook_bytes([["案例", "观察页面"]]))},
|
files={
|
||||||
|
"file": ("tasks.xlsx", workbook_bytes([["案例", "", "", "观察页面", "", "观察页面"]]))
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["valid"]
|
assert response.json()["valid"]
|
||||||
@@ -98,6 +127,107 @@ def test_preferences_and_required_device(client):
|
|||||||
assert client.post("/api/automation/batches", json=payload(device_serial="")).status_code == 422
|
assert client.post("/api/automation/batches", json=payload(device_serial="")).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_delete_removes_tasks_and_files_and_prevents_request_replay(client, isolated):
|
||||||
|
created = client.post(
|
||||||
|
"/api/automation/batches", json=payload(request_id="delete-example")
|
||||||
|
).json()
|
||||||
|
first, second = isolated.jobs(created["id"])
|
||||||
|
isolated.save_job({**first, "status": "completed"})
|
||||||
|
trace_dir = reports.TRACES_PATH / first["session_id"]
|
||||||
|
trace_dir.mkdir(parents=True)
|
||||||
|
(trace_dir / "result.json").write_text("{}", encoding="utf-8")
|
||||||
|
report_dir = reports.EXPORT_ROOT / "sessions" / first["session_id"]
|
||||||
|
report_dir.mkdir(parents=True)
|
||||||
|
(report_dir / "任务报告.docx").write_bytes(b"report")
|
||||||
|
batch_dir = reports.EXPORT_ROOT / "batches" / created["id"]
|
||||||
|
batch_dir.mkdir(parents=True)
|
||||||
|
(batch_dir / "批次报告.zip").write_bytes(b"zip")
|
||||||
|
mcp_task_dir = reports.EXPORT_ROOT.parent / "mcp_exports" / "sessions" / first["session_id"]
|
||||||
|
mcp_task_dir.mkdir(parents=True)
|
||||||
|
(mcp_task_dir / "报告.docx").write_bytes(b"report")
|
||||||
|
mcp_batch_dir = reports.EXPORT_ROOT.parent / "mcp_exports" / "batches" / created["id"]
|
||||||
|
mcp_batch_dir.mkdir(parents=True)
|
||||||
|
(mcp_batch_dir / "批次.zip").write_bytes(b"zip")
|
||||||
|
unrelated = reports.TRACES_PATH / "unrelated-task"
|
||||||
|
unrelated.mkdir()
|
||||||
|
(unrelated / "keep.txt").write_text("keep", encoding="utf-8")
|
||||||
|
|
||||||
|
response = client.delete(f"/api/automation/batches/{created['id']}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert client.get(f"/api/automation/batches/{created['id']}").status_code == 404
|
||||||
|
assert isolated.job(first["session_id"]) is None
|
||||||
|
assert isolated.job(second["session_id"]) is None
|
||||||
|
assert not trace_dir.exists()
|
||||||
|
assert not report_dir.exists()
|
||||||
|
assert not batch_dir.exists()
|
||||||
|
assert not mcp_task_dir.exists()
|
||||||
|
assert not mcp_batch_dir.exists()
|
||||||
|
assert (unrelated / "keep.txt").read_text(encoding="utf-8") == "keep"
|
||||||
|
with pytest.raises(ValueError, match="不存在"):
|
||||||
|
reports.session_report(first["session_id"])
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/automation/batches", json=payload(request_id="delete-example")
|
||||||
|
).status_code
|
||||||
|
== 409
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", ["queued", "running", "task_paused", "resuming", "finalizing"])
|
||||||
|
def test_batch_delete_rejects_active_jobs(client, isolated, status):
|
||||||
|
batch = service.create_batch(payload())
|
||||||
|
first = isolated.jobs(batch["id"])[0]
|
||||||
|
isolated.save_job({**first, "status": status})
|
||||||
|
|
||||||
|
response = client.delete(f"/api/automation/batches/{batch['id']}")
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert isolated.get("batch", batch["id"]) is not None
|
||||||
|
assert len(isolated.jobs(batch["id"])) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_deletion_reservation_blocks_new_dispatch(isolated):
|
||||||
|
batch = service.create_batch(payload())
|
||||||
|
job = isolated.jobs(batch["id"])[0]
|
||||||
|
|
||||||
|
isolated.begin_delete_batch(batch["id"])
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="正在删除"):
|
||||||
|
isolated.save_job({**job, "status": "queued"})
|
||||||
|
with pytest.raises(ValueError, match="正在删除"):
|
||||||
|
isolated.put("batch", batch["id"], {**batch, "status": "running"})
|
||||||
|
assert isolated.jobs_for_sessions([job["session_id"]])[job["session_id"]]["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_deletion_preserves_shared_screenshot_until_last_task(client, isolated):
|
||||||
|
first_batch = service.create_batch(payload())
|
||||||
|
second_batch = service.create_batch(payload(name="另一批次"))
|
||||||
|
first = isolated.jobs(first_batch["id"])[0]
|
||||||
|
other = isolated.jobs(second_batch["id"])[0]
|
||||||
|
shared_hash = "a" * 64
|
||||||
|
storage = StorageManager(isolated.path, reports.TRACES_PATH)
|
||||||
|
image_file = reports.TRACES_PATH / "images" / f"{shared_hash}.jpg"
|
||||||
|
image_file.parent.mkdir(parents=True)
|
||||||
|
image_file.write_bytes(b"shared image")
|
||||||
|
with sqlite3.connect(isolated.path) as conn:
|
||||||
|
conn.execute("INSERT INTO images(image_name) VALUES (?)", (shared_hash,))
|
||||||
|
for job in (first, other):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions(session_id,initial_goal,start_time,status,device_info) "
|
||||||
|
"VALUES (?,?,?,?,?)",
|
||||||
|
(job["session_id"], "观察设置", 1.0, "completed", "{}"),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO steps(step_id,session_id,pre_image_name) VALUES (?,?,?)",
|
||||||
|
(job["session_id"], job["session_id"], shared_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client.delete(f"/api/automation/batches/{first_batch['id']}").status_code == 200
|
||||||
|
assert image_file.read_bytes() == b"shared image"
|
||||||
|
assert storage.get_session(UUID(other["session_id"])) is not None
|
||||||
|
assert client.delete(f"/api/automation/batches/{second_batch['id']}").status_code == 200
|
||||||
|
assert not image_file.exists()
|
||||||
|
|
||||||
|
|
||||||
def test_restart_never_repeats_started_work(isolated):
|
def test_restart_never_repeats_started_work(isolated):
|
||||||
batch = service.create_batch(payload())
|
batch = service.create_batch(payload())
|
||||||
service.batch_action(batch["id"], "resume")
|
service.batch_action(batch["id"], "resume")
|
||||||
@@ -137,6 +267,30 @@ async def test_batch_admission_idempotent_and_bound_device(isolated, monkeypatch
|
|||||||
assert isolated.jobs(batch["id"])[1]["status"] == "pending"
|
assert isolated.jobs(batch["id"])[1]["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_paused_case_yields_batch_slot_to_next_case(isolated, monkeypatch):
|
||||||
|
from apps.admin_console import server
|
||||||
|
from artemis.runtime.device_pool import DeviceStatus
|
||||||
|
|
||||||
|
batch = service.create_batch(payload())
|
||||||
|
service.batch_action(batch["id"], "resume")
|
||||||
|
first = isolated.jobs(batch["id"])[0]
|
||||||
|
isolated.save_job({**first, "status": "task_paused", "started_at": time.time()})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service, "state", type("State", (), {"is_paused": False, "active_runs": {}})()
|
||||||
|
)
|
||||||
|
enqueue = AsyncMock(return_value={"tasks": [{"device_serial": "test-device"}]})
|
||||||
|
monkeypatch.setattr(server.task_queue_service, "enqueue_tasks", enqueue)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"artemis.runtime.device_pool.try_list_devices_async",
|
||||||
|
AsyncMock(return_value=[DeviceStatus(serial="test-device", state="device")]),
|
||||||
|
)
|
||||||
|
await service.tick_batches()
|
||||||
|
assert enqueue.await_count == 1
|
||||||
|
assert enqueue.call_args.args[0] == ["观察首页"]
|
||||||
|
assert [job["status"] for job in isolated.jobs(batch["id"])] == ["task_paused", "queued"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_device_outage_pauses_without_consuming_pending_case(isolated, monkeypatch):
|
async def test_device_outage_pauses_without_consuming_pending_case(isolated, monkeypatch):
|
||||||
batch = service.create_batch(payload())
|
batch = service.create_batch(payload())
|
||||||
@@ -176,6 +330,123 @@ async def test_report_failure_does_not_strand_job(isolated, monkeypatch):
|
|||||||
assert "disk full" in saved["report_error"]
|
assert "disk full" in saved["report_error"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_batch_timeout_stays_visible_in_job_report_and_history(client, isolated):
|
||||||
|
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||||
|
isolated.save_job(
|
||||||
|
{**job, "status": "running", "timed_out": True, "error": "任务执行超时(30 秒)"}
|
||||||
|
)
|
||||||
|
await service.on_terminal({"session_id": job["session_id"]}, "cancelled")
|
||||||
|
saved = isolated.job(job["session_id"])
|
||||||
|
assert saved["status"] == "failed"
|
||||||
|
assert "超时" in saved["error"]
|
||||||
|
assert "超时" in reports.session_report(job["session_id"])["error"]
|
||||||
|
history = client.get("/api/automation/history").json()["items"]
|
||||||
|
assert any(
|
||||||
|
item["session_id"] == job["session_id"] and "超时" in item["error"] for item in history
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("stop_on_failure", [False, True])
|
||||||
|
async def test_timed_out_case_releases_next_batch_case(isolated, monkeypatch, stop_on_failure):
|
||||||
|
from apps.admin_console import server
|
||||||
|
from artemis.runtime.device_pool import DeviceStatus
|
||||||
|
|
||||||
|
batch = service.create_batch(payload(timeout_seconds=1, stop_on_failure=stop_on_failure))
|
||||||
|
service.batch_action(batch["id"], "resume")
|
||||||
|
first = isolated.jobs(batch["id"])[0]
|
||||||
|
isolated.save_job({**first, "status": "running", "started_at": time.time() - 2})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service, "state", type("State", (), {"is_paused": False, "active_runs": {}})()
|
||||||
|
)
|
||||||
|
stop = Mock(return_value=True)
|
||||||
|
enqueue = AsyncMock(return_value={"tasks": [{"device_serial": "test-device"}]})
|
||||||
|
monkeypatch.setattr(server.task_queue_service, "stop_tasks", stop)
|
||||||
|
monkeypatch.setattr(server.task_queue_service, "enqueue_tasks", enqueue)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"artemis.runtime.device_pool.try_list_devices_async",
|
||||||
|
AsyncMock(return_value=[DeviceStatus(serial="test-device", state="device")]),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(reports, "export_batch", Mock())
|
||||||
|
|
||||||
|
await service.tick_batches()
|
||||||
|
assert isolated.get("batch", batch["id"])["status"] == "running"
|
||||||
|
assert "超时" in isolated.job(first["session_id"])["error"]
|
||||||
|
stop.assert_called_once_with(session_id=first["session_id"])
|
||||||
|
await service.tick_batches()
|
||||||
|
stop.assert_called_once()
|
||||||
|
|
||||||
|
await service.on_terminal({"session_id": first["session_id"]}, "cancelled")
|
||||||
|
await service.tick_batches()
|
||||||
|
statuses = [job["status"] for job in isolated.jobs(batch["id"])]
|
||||||
|
assert statuses == (["failed", "skipped"] if stop_on_failure else ["failed", "queued"])
|
||||||
|
assert enqueue.await_count == (0 if stop_on_failure else 1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_queued_case_does_not_consume_execution_timeout(isolated, monkeypatch):
|
||||||
|
from apps.admin_console import server
|
||||||
|
|
||||||
|
batch = service.create_batch(payload(timeout_seconds=1))
|
||||||
|
service.batch_action(batch["id"], "resume")
|
||||||
|
first = isolated.jobs(batch["id"])[0]
|
||||||
|
isolated.save_job({**first, "status": "queued", "started_at": time.time() - 30})
|
||||||
|
monkeypatch.setattr(service, "state", type("State", (), {"is_paused": False})())
|
||||||
|
stop = Mock()
|
||||||
|
monkeypatch.setattr(server.task_queue_service, "stop_tasks", stop)
|
||||||
|
|
||||||
|
await service.tick_batches()
|
||||||
|
stop.assert_not_called()
|
||||||
|
assert isolated.job(first["session_id"])["status"] == "queued"
|
||||||
|
assert isolated.get("batch", batch["id"])["status"] == "running"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_model_timeout_from_runner_log_reaches_failed_task_and_report(isolated):
|
||||||
|
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||||
|
folder = reports.TRACES_PATH / job["session_id"]
|
||||||
|
folder.mkdir(parents=True)
|
||||||
|
(folder / "stderr.log").write_text(
|
||||||
|
"TimeoutError: model request timed out after 300 seconds\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.on_terminal({"session_id": job["session_id"]}, "failed")
|
||||||
|
|
||||||
|
saved = isolated.job(job["session_id"])
|
||||||
|
assert saved["status"] == "failed"
|
||||||
|
assert "超时" in saved["error"]
|
||||||
|
assert reports.session_report(job["session_id"])["status"] == "failed"
|
||||||
|
assert "超时" in reports.session_report(job["session_id"])["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_optional_timeout_warnings_are_not_reported_as_task_failure(tmp_path):
|
||||||
|
(tmp_path / "stdout.log").write_text(
|
||||||
|
"⚠ Could not clean unowned screen wake locks on emulator-5554: "
|
||||||
|
"Timed out acquiring the Artemis awake-lease mutex.\n"
|
||||||
|
"⚠ VisualStepSummarizer: Error generating summary for step 3: \n"
|
||||||
|
"ℹ StepMemoryService: Retrying Step 3 (attempt 4) after 1.0s\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert reports.failure_reason(tmp_path, "failed", None) == (
|
||||||
|
"任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
|
||||||
|
)
|
||||||
|
|
||||||
|
(tmp_path / "stderr.log").write_text(
|
||||||
|
"TimeoutError: model request timed out after 300 seconds\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
reason = reports.failure_reason(tmp_path, "failed", None)
|
||||||
|
assert "model request timed out" in reason
|
||||||
|
assert "awake-lease" not in reason
|
||||||
|
|
||||||
|
(tmp_path / "stderr.log").write_text(
|
||||||
|
"DeviceBusyError: device queue reservation was cancelled\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert reports.failure_reason(tmp_path, "failed", None) == (
|
||||||
|
"DeviceBusyError: device queue reservation was cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
|
def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
|
||||||
batch = service.create_batch(payload())
|
batch = service.create_batch(payload())
|
||||||
job = isolated.jobs(batch["id"])[0]
|
job = isolated.jobs(batch["id"])[0]
|
||||||
@@ -193,6 +464,123 @@ def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
|
|||||||
assert "未验证".encode() in doc.read("word/document.xml")
|
assert "未验证".encode() in doc.read("word/document.xml")
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_report_exposes_logged_reason_and_formats_chinese_markdown(isolated):
|
||||||
|
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||||
|
isolated.save_job({**job, "status": "failed"})
|
||||||
|
folder = reports.TRACES_PATH / job["session_id"]
|
||||||
|
(folder / "notes").mkdir(parents=True)
|
||||||
|
(folder / "stdout.log").write_text(
|
||||||
|
"❌ [run] Error running automation: 模型连续 3 次未提供可执行的工具调用\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(folder / "notes" / "output.md").write_text(
|
||||||
|
"## 结果\n- **网络**正常\n\n| 分类 | 状态 |\n| --- | --- |\n| 系统 | 未确认 |",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
report = reports.session_report(job["session_id"])
|
||||||
|
assert "连续 3 次" in report["error"]
|
||||||
|
assert report["verdict"] == "未验证(任务异常终止)"
|
||||||
|
assert "abc123secret" not in reports.failure_reason(
|
||||||
|
folder, "failed", "认证失败 OPENAI_API_KEY=abc123secret Authorization: Bearer abc123secret"
|
||||||
|
)
|
||||||
|
assert "超时" not in reports.failure_reason(folder, "failed", "LLMExhaustedError: 额度不足")
|
||||||
|
paths = reports.export_session(job["session_id"])
|
||||||
|
doc = Document(next(path for path in paths if path.suffix == ".docx"))
|
||||||
|
assert doc.styles["Normal"].font.name == "Microsoft YaHei"
|
||||||
|
assert any("异常原因" in paragraph.text for paragraph in doc.paragraphs)
|
||||||
|
assert any(
|
||||||
|
run.bold and "网络" in run.text for paragraph in doc.paragraphs for run in paragraph.runs
|
||||||
|
)
|
||||||
|
assert any(
|
||||||
|
cell.text == "分类" for table in doc.tables for row in table.rows for cell in row.cells
|
||||||
|
)
|
||||||
|
wb = load_workbook(next(path for path in paths if path.suffix == ".xlsx"))
|
||||||
|
assert "连续 3 次" in wb["任务结果"]["B15"].value
|
||||||
|
assert wb["结论"]["B2"].value == "结果"
|
||||||
|
assert wb["结果表1"]["A2"].value == "系统"
|
||||||
|
assert wb["执行证据"]["A1"].font.name == "微软雅黑"
|
||||||
|
assert wb["执行证据"].page_setup.orientation == "landscape"
|
||||||
|
assert reports.execution_result_label({"status": "dispatched"}) == "动作已发出(需核对截图)"
|
||||||
|
assert reports.conclusion_rows("## 结果\n- **通过**检查\n```txt\n| 不是 | 表格 |\n```") == [
|
||||||
|
("类型", "结论与发现"),
|
||||||
|
("小节", "结果"),
|
||||||
|
("要点", "通过检查"),
|
||||||
|
("代码", "| 不是 | 表格 |"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_material_zip_contains_ordered_evidence_and_reports(
|
||||||
|
client, isolated, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||||
|
isolated.save_job({**job, "case": {**job["case"], "case_id": "1.1"}})
|
||||||
|
evidence = tmp_path / "evidence"
|
||||||
|
evidence.mkdir()
|
||||||
|
first = evidence / "first.png"
|
||||||
|
second = evidence / "second.png"
|
||||||
|
video = evidence / "recording.mp4"
|
||||||
|
for path in (first, second, video):
|
||||||
|
path.write_bytes(path.name.encode())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
reports,
|
||||||
|
"session_screenshots",
|
||||||
|
lambda report: [(first, "0001_步骤0001_操作前.png"), (second, "0002_步骤0002_操作后.png")],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(reports, "session_videos", lambda report: [(video, "0001_recording.mp4")])
|
||||||
|
|
||||||
|
response = client.get(f"/api/automation/reports/{job['session_id']}/all")
|
||||||
|
assert response.status_code == 200
|
||||||
|
with zipfile.ZipFile(BytesIO(response.content)) as archive:
|
||||||
|
files = archive.namelist()
|
||||||
|
assert all(name.startswith("1.1/") for name in files)
|
||||||
|
assert any(name.endswith(".docx") for name in files)
|
||||||
|
assert any(name.endswith(".xlsx") for name in files)
|
||||||
|
assert files.index("1.1/步骤截图/0001_步骤0001_操作前.png") < files.index(
|
||||||
|
"1.1/步骤截图/0002_步骤0002_操作后.png"
|
||||||
|
)
|
||||||
|
assert archive.read("1.1/任务录屏/0001_recording.mp4") == b"recording.mp4"
|
||||||
|
manifest = json.loads(archive.read("1.1/文件清单.json"))
|
||||||
|
assert manifest["session_id"] == job["session_id"]
|
||||||
|
assert "1.1-all.zip" in response.headers["content-disposition"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_export_accepts_finalized_worker_trace_and_rejects_external_path(
|
||||||
|
isolated, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||||
|
session_id = job["session_id"]
|
||||||
|
recording_dir = reports.TRACES_PATH / f"web_123_{session_id[:8]}_PASS_2026-09-28"
|
||||||
|
recording_dir.mkdir(parents=True)
|
||||||
|
first = recording_dir / "recording.mp4"
|
||||||
|
first.write_bytes(b"first segment")
|
||||||
|
resumed = recording_dir / "resume_0001"
|
||||||
|
resumed.mkdir()
|
||||||
|
second = resumed / "recording.mp4"
|
||||||
|
second.write_bytes(b"second segment")
|
||||||
|
|
||||||
|
repository = Mock()
|
||||||
|
repository.get_video_recording_for_session.return_value = {"local_video_path": str(first)}
|
||||||
|
repository.get_session_by_id.return_value = {"video_filepath": str(first)}
|
||||||
|
monkeypatch.setattr(reports, "SessionRepository", lambda: repository)
|
||||||
|
|
||||||
|
archive_path = reports.export_session_media(session_id, "video")
|
||||||
|
with zipfile.ZipFile(archive_path) as archive:
|
||||||
|
names = archive.namelist()
|
||||||
|
assert names == [
|
||||||
|
"任务录屏/0001_web_123_" + session_id[:8] + "_PASS_2026-09-28_recording.mp4",
|
||||||
|
"任务录屏/0002_resume_0001_recording.mp4",
|
||||||
|
"文件清单.json",
|
||||||
|
]
|
||||||
|
assert archive.read(names[0]) == b"first segment"
|
||||||
|
assert archive.read(names[1]) == b"second segment"
|
||||||
|
|
||||||
|
external = tmp_path / "outside" / "recording.mp4"
|
||||||
|
external.parent.mkdir()
|
||||||
|
external.write_bytes(b"unrelated")
|
||||||
|
repository.get_video_recording_for_session.return_value = {"local_video_path": str(external)}
|
||||||
|
assert reports.session_videos({"id": session_id}) == []
|
||||||
|
|
||||||
|
|
||||||
def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated):
|
def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated):
|
||||||
batch = service.create_batch(payload())
|
batch = service.create_batch(payload())
|
||||||
first, second = isolated.jobs(batch["id"])
|
first, second = isolated.jobs(batch["id"])
|
||||||
|
|||||||
@@ -59,6 +59,23 @@ def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
|
|||||||
assert dotenv_values(isolated_settings)["OPENAI_API_KEY"] == "new-test-secret"
|
assert dotenv_values(isolated_settings)["OPENAI_API_KEY"] == "new-test-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_install_uses_playones_endpoint_without_overriding_saved_url(monkeypatch):
|
||||||
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||||
|
expected = "https://api-n-cd.playones.com/v1"
|
||||||
|
assert Settings(_env_file=None).OPENAI_BASE_URL == expected
|
||||||
|
assert service.ModelSettingsUpdate(model="test").base_url == expected
|
||||||
|
monkeypatch.setattr(settings, "OPENAI_BASE_URL", None)
|
||||||
|
assert service.get_model_settings()["base_url"] == expected
|
||||||
|
monkeypatch.setattr(settings, "OPENAI_BASE_URL", "https://saved.example/v1")
|
||||||
|
assert service.get_model_settings()["base_url"] == "https://saved.example/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_presets_include_flash_pro_and_luna_without_restricting_custom_names():
|
||||||
|
presets = service.get_model_settings()["model_presets"]
|
||||||
|
assert presets == ["gpt-5.6-luna", "gpt-6-luna", "mimo-v2.6-flash", "mimo-v2.6-pro"]
|
||||||
|
assert service.ModelSettingsUpdate(model="custom/model-v2").model == "custom/model-v2"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"url",
|
"url",
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ async def test_shutdown_marks_only_ui_owned_running_sessions():
|
|||||||
):
|
):
|
||||||
await on_shutdown()
|
await on_shutdown()
|
||||||
|
|
||||||
update_status.assert_called_once_with("ui-running", "cancelled")
|
update_status.assert_called_once_with("ui-running", "interrupted")
|
||||||
assert state.queue_items == []
|
assert state.queue_items == []
|
||||||
state.active_session_id = None
|
state.active_session_id = None
|
||||||
state.is_shutting_down = False
|
state.is_shutting_down = False
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ import pytest
|
|||||||
from apps.admin_console.routers import sessions as sessions_router
|
from apps.admin_console.routers import sessions as sessions_router
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolate_jobs(monkeypatch):
|
||||||
|
mocked = MagicMock()
|
||||||
|
mocked.jobs_for_sessions.return_value = {}
|
||||||
|
monkeypatch.setattr(sessions_router, "store", mocked)
|
||||||
|
return mocked
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_sessions_uses_batched_history_metadata(monkeypatch):
|
async def test_list_sessions_uses_batched_history_metadata(monkeypatch):
|
||||||
repo = MagicMock()
|
repo = MagicMock()
|
||||||
@@ -96,3 +104,42 @@ async def test_list_sessions_extracts_device_serial(monkeypatch):
|
|||||||
assert result[0]["device_id"] == "63191FDKX00062"
|
assert result[0]["device_id"] == "63191FDKX00062"
|
||||||
assert result[1]["device_serial"] == "emulator-5554"
|
assert result[1]["device_serial"] == "emulator-5554"
|
||||||
assert result[1]["device_id"] == "emulator-5554"
|
assert result[1]["device_id"] == "emulator-5554"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_sessions_uses_batch_title_and_timeout_error(monkeypatch, isolate_jobs):
|
||||||
|
repo = MagicMock()
|
||||||
|
repo.get_all_sessions.return_value = [
|
||||||
|
{
|
||||||
|
"session_id": "session-case",
|
||||||
|
"status": "completed",
|
||||||
|
"start_time": 1.0,
|
||||||
|
"initial_goal": "打开设置并检查页面",
|
||||||
|
"device_info": '{"profile":"flash"}',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
repo.get_video_recordings_map.return_value = {}
|
||||||
|
repo.get_latest_video_recordings_map.return_value = {}
|
||||||
|
repo.get_agent_trace_names_map.return_value = {}
|
||||||
|
monkeypatch.setattr(sessions_router, "session_repo", repo)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sessions_router.media_service, "build_video_index", MagicMock(return_value={})
|
||||||
|
)
|
||||||
|
isolate_jobs.jobs_for_sessions.return_value = {
|
||||||
|
"session-case": {
|
||||||
|
"session_id": "session-case",
|
||||||
|
"status": "failed",
|
||||||
|
"ended_at": 65.0,
|
||||||
|
"error": "任务执行超时(60 秒)",
|
||||||
|
"case": {"case_id": "1.1", "name": "打开并核对设置", "goal": "打开设置并检查页面"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await sessions_router.list_sessions()
|
||||||
|
|
||||||
|
isolate_jobs.jobs_for_sessions.assert_called_once_with(["session-case"])
|
||||||
|
assert result[0]["display_name"] == "1.1 打开并核对设置"
|
||||||
|
assert result[0]["initial_goal"] == "打开设置并检查页面"
|
||||||
|
assert result[0]["status"] == "failed"
|
||||||
|
assert result[0]["end_time"] == 65.0
|
||||||
|
assert "超时" in result[0]["error"]
|
||||||
@@ -25,7 +25,7 @@ def test_startup_cleanup_keeps_live_cross_process_session(tmp_path):
|
|||||||
assert repository.cleanup_orphans_on_startup() == 1
|
assert repository.cleanup_orphans_on_startup() == 1
|
||||||
|
|
||||||
assert repository.get_session_status("live") == "running"
|
assert repository.get_session_status("live") == "running"
|
||||||
assert repository.get_session_status("dead") == "failed"
|
assert repository.get_session_status("dead") == "interrupted"
|
||||||
|
|
||||||
|
|
||||||
def test_session_list_harvest_rechecks_worker_liveness(tmp_path):
|
def test_session_list_harvest_rechecks_worker_liveness(tmp_path):
|
||||||
|
|||||||
@@ -987,6 +987,7 @@ async def test_manual_stop_of_one_run_does_not_pollute_concurrent_run():
|
|||||||
on different devices: device A's task is stopped manually, device B's task
|
on different devices: device A's task is stopped manually, device B's task
|
||||||
then finishes normally and must still be reported as completed.
|
then finishes normally and must still be reported as completed.
|
||||||
"""
|
"""
|
||||||
|
state.is_shutting_down = False
|
||||||
ended_payloads: dict[str, dict] = {}
|
ended_payloads: dict[str, dict] = {}
|
||||||
|
|
||||||
def capture(event_type, data):
|
def capture(event_type, data):
|
||||||
|
|||||||
@@ -60,20 +60,21 @@ REFRESHED = [_el(1, "Back", 100, 100), _el(2, "Wi-Fi toggle", 900, 300)]
|
|||||||
|
|
||||||
|
|
||||||
class _FakeSession:
|
class _FakeSession:
|
||||||
"""An action session whose observations replay a scripted list sequence."""
|
"""An action session whose screen advances after each device action."""
|
||||||
|
|
||||||
started = True
|
started = True
|
||||||
|
|
||||||
def __init__(self, observations: list[list[dict]]):
|
def __init__(self, observations: list[list[dict]]):
|
||||||
self.calls: list[tuple[str, dict]] = []
|
self.calls: list[tuple[str, dict]] = []
|
||||||
self._observations = list(observations)
|
self._observations = observations
|
||||||
|
|
||||||
async def call(self, name, args):
|
async def call(self, name, args):
|
||||||
self.calls.append((name, dict(args)))
|
self.calls.append((name, dict(args)))
|
||||||
return ActionResult.success(name, f"{name} dispatched")
|
return ActionResult.success(name, f"{name} dispatched")
|
||||||
|
|
||||||
async def observe(self, settle_ms=0):
|
async def observe(self, settle_ms=0):
|
||||||
elements = self._observations.pop(0) if self._observations else []
|
index = min(len(self.calls) - 1, len(self._observations) - 1)
|
||||||
|
elements = self._observations[index] if self._observations else []
|
||||||
return ObserveResult(
|
return ObserveResult(
|
||||||
ok=True,
|
ok=True,
|
||||||
elements_text="\n".join(f"[{e['index']}] {e['text']}" for e in elements),
|
elements_text="\n".join(f"[{e['index']}] {e['text']}" for e in elements),
|
||||||
|
|||||||
@@ -636,6 +636,32 @@ async def test_run_no_tool_call_turn_is_nudged_not_terminated(mock_context):
|
|||||||
and any(EXECUTION_RESULT_MARKER in b.get("text", "") for b in m.content)
|
and any(EXECUTION_RESULT_MARKER in b.get("text", "") for b in m.content)
|
||||||
for m in turn2_messages
|
for m in turn2_messages
|
||||||
)
|
)
|
||||||
|
assert not any(
|
||||||
|
isinstance(m, AIMessage) and m.content == "thinking only" for m in turn2_messages
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("attempts", [1, 3])
|
||||||
|
async def test_flash_stops_repeated_no_tool_responses(mock_context, monkeypatch, attempts):
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS", attempts)
|
||||||
|
responses = [AIMessage(content="I will click now.", tool_calls=[]) for _ in range(attempts)]
|
||||||
|
with (
|
||||||
|
patch("artemis.controllers.unified_controller.get_driver"),
|
||||||
|
patch(
|
||||||
|
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
|
||||||
|
AsyncMock(return_value=_INITIAL_OBSERVATION),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
runner = _make_runner(mock_context, responses, max_turns=0)
|
||||||
|
result = await runner.run(State(initial_goal="Open Settings"))
|
||||||
|
|
||||||
|
assert result["status"] == "failed"
|
||||||
|
assert f"连续 {attempts} 次未调用工具" in result["explanation"]
|
||||||
|
assert runner._invoke_model.await_count == attempts
|
||||||
|
runner.executor.execute.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
"""Unit tests for VisualStepSummarizer and Context Compressor in Flash profile."""
|
"""Unit tests for VisualStepSummarizer and Context Compressor in Flash profile."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from unittest.mock import ANY, AsyncMock, Mock, call
|
from unittest.mock import ANY, AsyncMock, Mock, call
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
import pytest
|
import pytest
|
||||||
@@ -38,10 +39,20 @@ def mock_context():
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def _user_role_prompt(messages):
|
||||||
|
"""Return the instruction text and observation blocks sent to the model."""
|
||||||
|
assert len(messages) == 1
|
||||||
|
assert isinstance(messages[0], HumanMessage)
|
||||||
|
blocks = messages[0].content
|
||||||
|
return blocks[0]["text"], blocks[1:]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_summarizer_dispatch_and_caching(mock_context):
|
async def test_summarizer_dispatch_and_caching(mock_context):
|
||||||
"""Verify non-blocking dispatch and summary generation."""
|
"""Verify non-blocking dispatch and summary generation."""
|
||||||
summarizer = VisualStepSummarizer(mock_context)
|
summarizer = VisualStepSummarizer(mock_context)
|
||||||
|
assert summarizer._timeout_seconds == 120.0
|
||||||
|
assert summarizer._flush_timeout_s > summarizer._timeout_seconds
|
||||||
|
|
||||||
mock_llm_response = AIMessage(
|
mock_llm_response = AIMessage(
|
||||||
content="[Step 1 State: Tapped 'Login' button. Home view opened.]"
|
content="[Step 1 State: Tapped 'Login' button. Home view opened.]"
|
||||||
@@ -106,6 +117,29 @@ async def test_summarizer_retries_independently(mock_context):
|
|||||||
assert attempt_count == 2
|
assert attempt_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visual_summary_uses_its_own_configured_timeout(mock_context):
|
||||||
|
summarizer = VisualStepSummarizer(mock_context, retry_limit=0, timeout_seconds=0.01)
|
||||||
|
|
||||||
|
async def slow_response(_messages):
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
summarizer._llm = Mock()
|
||||||
|
summarizer._llm.ainvoke = AsyncMock(side_effect=slow_response)
|
||||||
|
summarizer.dispatch(
|
||||||
|
step_number=1,
|
||||||
|
action_name="click",
|
||||||
|
action_args={"target": [100, 100]},
|
||||||
|
pre_img_bytes=b"pre",
|
||||||
|
post_img_bytes=b"post",
|
||||||
|
exec_outcome="Clicked",
|
||||||
|
)
|
||||||
|
|
||||||
|
await summarizer.flush()
|
||||||
|
assert summarizer.has_failed(1)
|
||||||
|
assert summarizer._llm.ainvoke.await_count == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_summarizer_bounded_retry_marks_failed(mock_context):
|
async def test_summarizer_bounded_retry_marks_failed(mock_context):
|
||||||
"""Persistent failures exit after 1 + retry_limit attempts with an explicit failed state."""
|
"""Persistent failures exit after 1 + retry_limit attempts with an explicit failed state."""
|
||||||
@@ -401,8 +435,8 @@ async def test_single_frame_dispatch_uses_single_image_prompt_variant(mock_conte
|
|||||||
await summarizer.flush()
|
await summarizer.flush()
|
||||||
|
|
||||||
assert summarizer.has_summary(1)
|
assert summarizer.has_summary(1)
|
||||||
system_text = str(captured[0][0].content)
|
system_text, human_blocks = _user_role_prompt(captured[0])
|
||||||
human_text = str(captured[0][1].content)
|
human_text = str(human_blocks)
|
||||||
assert "Exactly ONE screenshot" in system_text
|
assert "Exactly ONE screenshot" in system_text
|
||||||
assert "no independent post-action evidence" in system_text
|
assert "no independent post-action evidence" in system_text
|
||||||
assert "DECISION FRAME" in human_text
|
assert "DECISION FRAME" in human_text
|
||||||
@@ -436,15 +470,21 @@ async def test_dual_frame_dispatch_keeps_transition_prompt(mock_context):
|
|||||||
pre_img_bytes=b"pre",
|
pre_img_bytes=b"pre",
|
||||||
post_img_bytes=b"post",
|
post_img_bytes=b"post",
|
||||||
exec_outcome="executed",
|
exec_outcome="executed",
|
||||||
|
post_ui_elements_text="[1] Text: '存储空间' | Bounds: [0,0][100,100]",
|
||||||
|
observation_settled=False,
|
||||||
)
|
)
|
||||||
await summarizer.flush()
|
await summarizer.flush()
|
||||||
|
|
||||||
assert summarizer.has_summary(1)
|
assert summarizer.has_summary(1)
|
||||||
system_text = str(captured[0][0].content)
|
system_text, human_blocks = _user_role_prompt(captured[0])
|
||||||
human_text = str(captured[0][1].content)
|
human_text = str(human_blocks)
|
||||||
assert "Before/After screenshots" in system_text
|
assert "Before/After screenshots" in system_text
|
||||||
assert "--- [1] BEFORE ACTION SCREEN (Action Marked Visually in Red) ---" in human_text
|
assert "--- [1] BEFORE ACTION SCREEN (Action Marked Visually in Red) ---" in human_text
|
||||||
assert "--- [2] AFTER ACTION SCREEN ---" in human_text
|
assert "--- [2] AFTER ACTION SCREEN ---" in human_text
|
||||||
|
assert "--- [3] AFTER ACTION UI ELEMENTS ---" in human_text
|
||||||
|
assert "Text: '存储空间'" in human_text
|
||||||
|
assert "screen still changing" in human_text
|
||||||
|
assert "hierarchy names the new page" in system_text
|
||||||
assert "DECISION FRAME" not in human_text
|
assert "DECISION FRAME" not in human_text
|
||||||
|
|
||||||
|
|
||||||
@@ -604,8 +644,8 @@ async def test_focus_block_and_provenance_marked_action_phrase_reach_the_lens(mo
|
|||||||
await summarizer.flush()
|
await summarizer.flush()
|
||||||
|
|
||||||
assert summarizer.has_summary(3)
|
assert summarizer.has_summary(3)
|
||||||
system_text = str(captured[0][0].content)
|
system_text, human_blocks = _user_role_prompt(captured[0])
|
||||||
lead_text = captured[0][1].content[0]["text"]
|
lead_text = human_blocks[0]["text"]
|
||||||
assert "# OPERATOR FOCUS" in system_text
|
assert "# OPERATOR FOCUS" in system_text
|
||||||
assert "Absence is a fact" in system_text
|
assert "Absence is a fact" in system_text
|
||||||
assert lead_text.startswith(
|
assert lead_text.startswith(
|
||||||
@@ -619,7 +659,7 @@ async def test_focus_block_and_provenance_marked_action_phrase_reach_the_lens(mo
|
|||||||
assert "User instruction at this step: Do not join any network yet." in lead_text
|
assert "User instruction at this step: Do not join any network yet." in lead_text
|
||||||
assert "expect it to read On." in lead_text
|
assert "expect it to read On." in lead_text
|
||||||
# The focus precedes the frames: context first, evidence second.
|
# The focus precedes the frames: context first, evidence second.
|
||||||
assert captured[0][1].content[1]["text"].startswith("--- [1] BEFORE ACTION SCREEN")
|
assert human_blocks[1]["text"].startswith("--- [1] BEFORE ACTION SCREEN")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -648,7 +688,8 @@ async def test_dispatch_without_focus_carries_no_focus_block(mock_context):
|
|||||||
)
|
)
|
||||||
await summarizer.flush()
|
await summarizer.flush()
|
||||||
|
|
||||||
lead_text = captured[0][1].content[0]["text"]
|
_, human_blocks = _user_role_prompt(captured[0])
|
||||||
|
lead_text = human_blocks[0]["text"]
|
||||||
assert lead_text.startswith("Step 1 Physical Action: Tapped 'Search' at [500, 600]\n")
|
assert lead_text.startswith("Step 1 Physical Action: Tapped 'Search' at [500, 600]\n")
|
||||||
assert "(self-described)" not in lead_text
|
assert "(self-described)" not in lead_text
|
||||||
assert FOCUS_BLOCK_HEADER not in lead_text
|
assert FOCUS_BLOCK_HEADER not in lead_text
|
||||||
@@ -682,7 +723,8 @@ async def test_single_frame_focus_label_forbids_describing_the_outcome(mock_cont
|
|||||||
)
|
)
|
||||||
await summarizer.flush()
|
await summarizer.flush()
|
||||||
|
|
||||||
lead_text = captured[0][1].content[0]["text"]
|
_, human_blocks = _user_role_prompt(captured[0])
|
||||||
|
lead_text = human_blocks[0]["text"]
|
||||||
assert "the expected outcome is NOT in this frame and must not be described" in lead_text
|
assert "the expected outcome is NOT in this frame and must not be described" in lead_text
|
||||||
assert "what screen change it expected" not in lead_text
|
assert "what screen change it expected" not in lead_text
|
||||||
# The focus text is released once the summary landed, like the frames.
|
# The focus text is released once the summary landed, like the frames.
|
||||||
@@ -774,7 +816,8 @@ async def test_verbatim_argument_line_appears_only_when_the_phrase_folds_argumen
|
|||||||
|
|
||||||
lead_by_step = {}
|
lead_by_step = {}
|
||||||
for messages in captured:
|
for messages in captured:
|
||||||
text = messages[1].content[0]["text"]
|
_, human_blocks = _user_role_prompt(messages)
|
||||||
|
text = human_blocks[0]["text"]
|
||||||
lead_by_step[text.split(" ")[1]] = text
|
lead_by_step[text.split(" ")[1]] = text
|
||||||
launch = lead_by_step["1"]
|
launch = lead_by_step["1"]
|
||||||
assert launch.startswith("Step 1 Physical Action: Launched app 'Settings'\nController Outcome:")
|
assert launch.startswith("Step 1 Physical Action: Launched app 'Settings'\nController Outcome:")
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import pytest
|
|||||||
from artemis.agents.log_analyzer.log_analyzer import LogAnalyzerNode
|
from artemis.agents.log_analyzer.log_analyzer import LogAnalyzerNode
|
||||||
from artemis.context import ArtemisContext
|
from artemis.context import ArtemisContext
|
||||||
from artemis.core.tool_failure import ToolFailure
|
from artemis.core.tool_failure import ToolFailure
|
||||||
|
from artemis.tools.mobile.read_logs import ReadLogsArgs
|
||||||
|
from artemis.tools.mobile.search_logs import SearchLogsArgs
|
||||||
|
|
||||||
_STATUS_CASES = [
|
_STATUS_CASES = [
|
||||||
pytest.param(ToolFailure("Error searching logs: device gone"), "error", id="tool_failure"),
|
pytest.param(ToolFailure("Error searching logs: device gone"), "error", id="tool_failure"),
|
||||||
@@ -76,3 +78,43 @@ async def test_agent_loop_tool_message_status_is_structural(result, expected_sta
|
|||||||
assert tool_msgs[0].tool_call_id == "c1"
|
assert tool_msgs[0].tool_call_id == "c1"
|
||||||
assert tool_msgs[0].status == expected_status
|
assert tool_msgs[0].status == expected_status
|
||||||
assert tool_msgs[0].content == str(result)
|
assert tool_msgs[0].content == str(result)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_log_cache_normalizes_numeric_tool_arguments_before_running_tools():
|
||||||
|
node = LogAnalyzerNode(ctx=MagicMock(spec=ArtemisContext))
|
||||||
|
read_calls = []
|
||||||
|
|
||||||
|
async def read_raw_logs(lines=200, **kwargs):
|
||||||
|
read_calls.append(lines)
|
||||||
|
return "before\nE/Settings: failed\nafter"
|
||||||
|
|
||||||
|
async def unused_search(**kwargs):
|
||||||
|
pytest.fail("Cached raw logs should be searched locally")
|
||||||
|
|
||||||
|
read_tool = node._wrap_with_caching(
|
||||||
|
StructuredTool.from_function(
|
||||||
|
coroutine=read_raw_logs, name="read_logs", description="read", args_schema=ReadLogsArgs
|
||||||
|
)
|
||||||
|
)
|
||||||
|
search_tool = node._wrap_with_caching(
|
||||||
|
StructuredTool.from_function(
|
||||||
|
coroutine=unused_search,
|
||||||
|
name="search_logs",
|
||||||
|
description="search",
|
||||||
|
args_schema=SearchLogsArgs,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await read_tool.coroutine(lines="200") == "before\nE/Settings: failed\nafter"
|
||||||
|
assert await read_tool.coroutine(lines=200) == "before\nE/Settings: failed\nafter"
|
||||||
|
assert await read_tool.coroutine(lines=None) == "before\nE/Settings: failed\nafter"
|
||||||
|
assert read_calls == [200]
|
||||||
|
|
||||||
|
result = await search_tool.coroutine(keyword="Settings", lines="200", context_lines="1")
|
||||||
|
assert "before" in result and "after" in result
|
||||||
|
assert "E/Settings: failed" in result
|
||||||
|
assert "before" not in await search_tool.coroutine(
|
||||||
|
keyword="Settings", lines="200", context_lines=None
|
||||||
|
)
|
||||||
|
assert isinstance(await search_tool.coroutine(keyword="Settings", lines="many"), ToolFailure)
|
||||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user