Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84e183d28d | ||
|
|
0e13bb5c29 | ||
|
|
8522fa3ca3 | ||
|
|
bfcd828fa8 | ||
|
|
057f0c3050 | ||
|
|
cec9898e4c | ||
|
|
60ac066859 | ||
|
|
9d6833f092 |
No files matched your search
+4
-1
@@ -2,7 +2,7 @@
|
|||||||
# 所有模型调用仅使用 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
|
||||||
@@ -14,8 +14,11 @@ 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
|
||||||
|
|
||||||
# 视频分段、重试与单次分析超时(秒)
|
# 视频分段、重试与单次分析超时(秒)
|
||||||
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,13 +130,22 @@ 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))
|
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"
|
job["status"] != "finalizing"
|
||||||
and not job.get("timed_out")
|
and not job.get("timed_out")
|
||||||
and time.time() - job.get("started_at", time.time()) > limit
|
and time.time() - job.get("started_at", time.time()) - excluded > limit
|
||||||
):
|
):
|
||||||
job.update(error="任务达到批次配置的执行时限", timed_out=True)
|
job.update(
|
||||||
|
error=f"任务执行超时({limit} 秒,不含暂停/排队时间)", timed_out=True
|
||||||
|
)
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
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="任务超时,已暂停剩余任务;请确认设备状态")
|
batch.update(status="paused", reason="任务超时,已暂停剩余任务;请确认设备状态")
|
||||||
@@ -142,6 +160,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 +179,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 +194,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 +273,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,7 +57,7 @@ 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,
|
||||||
@@ -129,6 +129,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 +137,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 +146,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,47 @@ 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"])
|
||||||
|
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))
|
||||||
|
lines = stream.read().decode("utf-8", errors="replace").splitlines()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for line in reversed(lines):
|
||||||
|
match = re.search(r"Error running automation:\s*(.+)", line)
|
||||||
|
if match:
|
||||||
|
return redact(match.group(1))
|
||||||
|
if re.search(r"(?i)(?:timeout|timed out|超时|deadline exceeded)", line):
|
||||||
|
return redact("模型请求或任务执行超时:" + line.strip())
|
||||||
|
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 +142,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 +188,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 +204,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 +231,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 +254,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 +553,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 +583,91 @@ 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()
|
||||||
|
session_root = (TRACES_PATH / identifier(report["id"])).resolve()
|
||||||
|
if not source.is_relative_to(session_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(session_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 +687,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 +708,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.0",
|
||||||
"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.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.1.5",
|
"@angular/cdk": "^22.1.5",
|
||||||
"@angular/common": "^22.1.4",
|
"@angular/common": "^22.1.4",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"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({
|
||||||
|
|||||||
@@ -98,21 +98,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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -24,7 +24,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="预设模型">
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -935,6 +937,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())
|
||||||
@@ -1060,6 +1065,25 @@ class FlashRunner:
|
|||||||
pending_notices: list[str] = []
|
pending_notices: list[str] = []
|
||||||
|
|
||||||
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 +1121,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 +1177,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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,11 @@ 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)
|
||||||
|
|
||||||
# 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,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
|
||||||
|
|||||||
@@ -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}"},
|
||||||
|
|||||||
@@ -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、质量基线和核心类型检查通过。
|
||||||
@@ -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.0"
|
||||||
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,9 +39,15 @@ 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 {
|
||||||
param([string]$IndexUrl, [string]$InstallDir = (Join-Path $env:USERPROFILE '.local\bin'))
|
param([string]$IndexUrl, [string]$InstallDir = (Join-Path $env:USERPROFILE '.local\bin'))
|
||||||
|
|||||||
+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)
|
||||||
|
|||||||
+56
-38
@@ -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) {
|
||||||
|
Write-Host (Get-LaunchText ' [CHECK] Installing/updating MCP for detected AI clients...' ' [检查] 正在为检测到的 AI 客户端安装或更新 MCP...') -ForegroundColor Cyan
|
||||||
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp
|
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Host ' [WARN] MCP refresh failed. Existing client settings were not replaced; check the errors above.' -ForegroundColor Yellow
|
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
|
||||||
|
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,41 @@ 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
|
||||||
|
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_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 +382,86 @@ 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_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,17 @@ 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"
|
||||||
|
|
||||||
|
|
||||||
@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):
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -410,7 +410,7 @@ async def test_operator_node_multiple_actions():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_operator_node_no_record_step():
|
async def test_operator_node_no_record_step():
|
||||||
from artemis.agents.operator.operator import OperatorNode
|
from artemis.agents.operator.operator import OperatorNode, OperatorNoProgressError
|
||||||
from artemis.context import ArtemisContext
|
from artemis.context import ArtemisContext
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
@@ -437,13 +437,11 @@ async def test_operator_node_no_record_step():
|
|||||||
|
|
||||||
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
|
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
|
||||||
node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT)
|
node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT)
|
||||||
node_update = await node(mock_state)
|
with pytest.raises(OperatorNoProgressError):
|
||||||
|
await node(mock_state)
|
||||||
|
|
||||||
mock_ctx.data_engine.record_step.assert_not_called()
|
mock_ctx.data_engine.record_step.assert_not_called()
|
||||||
|
|
||||||
update_dict = node_update
|
|
||||||
assert "structured_decisions" in update_dict
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_operator_dynamic_prompt():
|
async def test_operator_dynamic_prompt():
|
||||||
@@ -472,7 +470,13 @@ async def test_operator_dynamic_prompt():
|
|||||||
|
|
||||||
mock_llm = MagicMock()
|
mock_llm = MagicMock()
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.tool_calls = []
|
mock_response.tool_calls = [
|
||||||
|
{
|
||||||
|
"name": "click",
|
||||||
|
"args": {"target": [50, 50], "target_description": "button"},
|
||||||
|
"id": "call_click",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
async def mock_ainvoke(*args, **kwargs):
|
async def mock_ainvoke(*args, **kwargs):
|
||||||
messages = args[0]
|
messages = args[0]
|
||||||
@@ -574,7 +578,13 @@ async def test_operator_history_compression():
|
|||||||
|
|
||||||
mock_llm = MagicMock()
|
mock_llm = MagicMock()
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.tool_calls = []
|
mock_response.tool_calls = [
|
||||||
|
{
|
||||||
|
"name": "click",
|
||||||
|
"args": {"target": [50, 50], "target_description": "button"},
|
||||||
|
"id": "call_click",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
# Smarter file reading mock
|
# Smarter file reading mock
|
||||||
def mock_read_text(path_self, encoding=None):
|
def mock_read_text(path_self, encoding=None):
|
||||||
|
|||||||
@@ -69,8 +69,14 @@ def _state():
|
|||||||
def _llm(captured: list, usage_metadata=None):
|
def _llm(captured: list, usage_metadata=None):
|
||||||
mock_llm = MagicMock()
|
mock_llm = MagicMock()
|
||||||
response = MagicMock()
|
response = MagicMock()
|
||||||
response.tool_calls = []
|
response.tool_calls = [
|
||||||
response.content = "no action this turn"
|
{
|
||||||
|
"name": "click",
|
||||||
|
"args": {"target": [50, 50], "target_description": "button"},
|
||||||
|
"id": "call_click",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
response.content = "Click the button."
|
||||||
# usage_from_message only trusts a non-empty dict; a MagicMock attribute
|
# usage_from_message only trusts a non-empty dict; a MagicMock attribute
|
||||||
# (the default) reads as "no usage reported".
|
# (the default) reads as "no usage reported".
|
||||||
if usage_metadata is not None:
|
if usage_metadata is not None:
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""Regression coverage for operator recovery and task pause handling."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
|
from langchain_core.messages import AIMessage, SystemMessage, ToolMessage
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from artemis.agents.operator.operator import OperatorNode, OperatorNoProgressError
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
from artemis.runtime import task_control
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def operator(monkeypatch):
|
||||||
|
node = object.__new__(OperatorNode)
|
||||||
|
node.ctx = SimpleNamespace(transcript_ledger=None)
|
||||||
|
node._available_device_actions = lambda: {"click", "long_press", "input_text"}
|
||||||
|
node._plan_changed_this_turn = lambda: True
|
||||||
|
node._max_burst_actions = lambda: 4
|
||||||
|
state = SimpleNamespace(
|
||||||
|
operator_raw_data={"width": 1280, "height": 2856},
|
||||||
|
indexed_elements=[{"center": [310, 2610], "text": "model menu", "resource_id": "model"}]
|
||||||
|
* 13,
|
||||||
|
open_incident=None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(settings, "ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS", 3)
|
||||||
|
return node, state
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", ["click", "long_press", "input_text"])
|
||||||
|
@pytest.mark.parametrize("target", ["13", "[240, 914]", "240, 914"])
|
||||||
|
def test_quoted_targets_keep_same_action_and_grounding(operator, name, target):
|
||||||
|
node, state = operator
|
||||||
|
expected_target = 13 if target == "13" else [240, 914]
|
||||||
|
args = {"target": target, "target_description": "model menu", "text": "test"}
|
||||||
|
actions, error = node._translate_and_validate_tool({"name": name, "args": args}, state)
|
||||||
|
expected, _ = node._translate_and_validate_tool(
|
||||||
|
{"name": name, "args": {**args, "target": expected_target}}, state
|
||||||
|
)
|
||||||
|
assert error is None
|
||||||
|
assert actions == expected
|
||||||
|
if target == "13":
|
||||||
|
assert actions[0]["target_resource_id"] == "model"
|
||||||
|
else:
|
||||||
|
assert actions[0]["target_description"] == "model menu"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("target", ["0", "14", "model menu", True, "true"])
|
||||||
|
def test_invalid_index_is_not_guessed_or_dispatched(operator, target):
|
||||||
|
node, state = operator
|
||||||
|
actions, error = node._translate_and_validate_tool(
|
||||||
|
{"name": "click", "args": {"target": target}}, state
|
||||||
|
)
|
||||||
|
assert not actions
|
||||||
|
assert error
|
||||||
|
|
||||||
|
|
||||||
|
def test_quoted_coordinates_still_need_description(operator):
|
||||||
|
node, state = operator
|
||||||
|
actions, error = node._translate_and_validate_tool(
|
||||||
|
{"name": "click", "args": {"target": "[240, 914]"}}, state
|
||||||
|
)
|
||||||
|
assert not actions
|
||||||
|
assert "target_description" in error
|
||||||
|
|
||||||
|
|
||||||
|
def click(target="13"):
|
||||||
|
return AIMessage(
|
||||||
|
content="Open the model menu.",
|
||||||
|
tool_calls=[{"name": "click", "args": {"target": target}, "id": "call_click"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_loop(operator, monkeypatch, responses):
|
||||||
|
node, state = operator
|
||||||
|
invoke = AsyncMock(side_effect=responses)
|
||||||
|
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||||
|
messages = []
|
||||||
|
result = await node._invoke_llm_loop(Mock(), messages, [], [], state)
|
||||||
|
return result, messages, invoke
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prose_retry_keeps_context_and_recovers_once(operator, monkeypatch):
|
||||||
|
prose = AIMessage(content="I will click the model menu.")
|
||||||
|
result, messages, invoke = await run_loop(operator, monkeypatch, [prose, click()])
|
||||||
|
assert invoke.await_count == 2
|
||||||
|
assert len(result[0]) == 1
|
||||||
|
assert result[0][0]["coordinates"] == [310, 2610]
|
||||||
|
assert prose in messages
|
||||||
|
assert any(isinstance(m, SystemMessage) and "No tool call" in m.content for m in messages)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_target_can_be_corrected_before_limit(operator, monkeypatch):
|
||||||
|
result, messages, invoke = await run_loop(operator, monkeypatch, [click("14"), click("13")])
|
||||||
|
assert invoke.await_count == 2
|
||||||
|
assert len(result[0]) == 1
|
||||||
|
assert result[0][0]["coordinates"] == [310, 2610]
|
||||||
|
outputs = [m for m in messages if isinstance(m, ToolMessage)]
|
||||||
|
assert [m.status for m in outputs] == ["error", "success"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("limit", [1, 3])
|
||||||
|
async def test_empty_responses_fail_at_configured_limit(operator, monkeypatch, limit):
|
||||||
|
monkeypatch.setattr(settings, "ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS", limit)
|
||||||
|
invoke = AsyncMock(return_value=AIMessage(content="I will click now."))
|
||||||
|
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||||
|
node, state = operator
|
||||||
|
with pytest.raises(OperatorNoProgressError, match="未提供可执行的工具调用"):
|
||||||
|
await node._invoke_llm_loop(Mock(), [], [], [], state)
|
||||||
|
assert invoke.await_count == limit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_calls_and_prose_share_bounded_recovery(operator, monkeypatch):
|
||||||
|
invoke = AsyncMock(side_effect=[click("missing"), AIMessage(content="Retry."), click("14")])
|
||||||
|
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||||
|
node, state = operator
|
||||||
|
messages = []
|
||||||
|
with pytest.raises(OperatorNoProgressError, match="校验仍失败"):
|
||||||
|
await node._invoke_llm_loop(Mock(), messages, [], [], state)
|
||||||
|
assert invoke.await_count == 3
|
||||||
|
assert any(isinstance(m, ToolMessage) and m.status == "error" for m in messages)
|
||||||
|
assert not any(isinstance(m, ToolMessage) and m.content == "Action Recorded" for m in messages)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_iteration_cap_cannot_restart_no_progress_next_turn(operator, monkeypatch):
|
||||||
|
monkeypatch.setattr("artemis.agents.operator.operator.OPERATOR_MAX_TOOL_ITERATIONS", 2)
|
||||||
|
invoke = AsyncMock(return_value=AIMessage(content="I will click now."))
|
||||||
|
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||||
|
node, state = operator
|
||||||
|
with pytest.raises(OperatorNoProgressError, match="预算内未恢复"):
|
||||||
|
await node._invoke_llm_loop(Mock(), [], [], [], state)
|
||||||
|
assert invoke.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pause_during_diagnosis_returns_to_fresh_observation(operator, monkeypatch, tmp_path):
|
||||||
|
node, state = operator
|
||||||
|
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
||||||
|
started = asyncio.Event()
|
||||||
|
cancelled = asyncio.Event()
|
||||||
|
events = []
|
||||||
|
|
||||||
|
class Lock:
|
||||||
|
def __init__(self, device_id, **kwargs):
|
||||||
|
self.device_id = device_id
|
||||||
|
self.description = "test"
|
||||||
|
self.max_concurrency = None
|
||||||
|
self.concurrency_mode = "per_device"
|
||||||
|
self.ingress = "web"
|
||||||
|
self.lock_scope = "test"
|
||||||
|
|
||||||
|
def release(self):
|
||||||
|
events.append("released")
|
||||||
|
|
||||||
|
def acquire(self, cancel_event=None):
|
||||||
|
events.append("acquired")
|
||||||
|
|
||||||
|
monkeypatch.setattr(task_control, "DeviceExecutionLock", Lock)
|
||||||
|
node.ctx.data_engine = None
|
||||||
|
node.ctx.task_control = task_control.TaskControl("pro-task", Lock("device"))
|
||||||
|
node.ctx.on_task_pause = AsyncMock()
|
||||||
|
node.ctx.on_task_resume = AsyncMock()
|
||||||
|
|
||||||
|
async def diagnose(**kwargs):
|
||||||
|
started.set()
|
||||||
|
try:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
finally:
|
||||||
|
cancelled.set()
|
||||||
|
|
||||||
|
monkeypatch.setattr("artemis.agents.operator.operator.invoke_tool_with_injection", diagnose)
|
||||||
|
response = AIMessage(
|
||||||
|
content="Inspect the failure",
|
||||||
|
tool_calls=[
|
||||||
|
{"name": "ask_diagnoser", "args": {"query": "why"}, "id": "diagnosis"},
|
||||||
|
{"name": "click", "args": {"target": 13}, "id": "tap"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
invoke = AsyncMock(return_value=response)
|
||||||
|
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||||
|
tool = SimpleNamespace(name="ask_diagnoser")
|
||||||
|
messages = []
|
||||||
|
execution = asyncio.create_task(node._invoke_llm_loop(Mock(), messages, [tool], [], state))
|
||||||
|
await asyncio.wait_for(started.wait(), 1)
|
||||||
|
task_control.request_pause("pro-task")
|
||||||
|
for _ in range(40):
|
||||||
|
if task_control.read_state("pro-task").get("status") == "task_paused":
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
assert task_control.read_state("pro-task")["status"] == "task_paused"
|
||||||
|
assert cancelled.is_set()
|
||||||
|
assert events == ["released"]
|
||||||
|
task_control.request_resume("pro-task")
|
||||||
|
assert (await asyncio.wait_for(execution, 2))[0] is None
|
||||||
|
assert events == ["released", "acquired"]
|
||||||
|
assert invoke.await_count == 1
|
||||||
|
assert [m.status for m in messages if isinstance(m, ToolMessage)] == ["error", "error"]
|
||||||
@@ -141,11 +141,17 @@ def _transcript_state(**overrides):
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
|
|
||||||
def _no_action_llm(captured: list):
|
def _action_llm(captured: list):
|
||||||
mock_llm = MagicMock()
|
mock_llm = MagicMock()
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.tool_calls = []
|
mock_response.tool_calls = [
|
||||||
mock_response.content = "no action this turn"
|
{
|
||||||
|
"name": "click",
|
||||||
|
"args": {"target": [50, 50], "target_description": "button"},
|
||||||
|
"id": "call_click",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mock_response.content = "Click the button."
|
||||||
|
|
||||||
async def mock_ainvoke(*args, **kwargs):
|
async def mock_ainvoke(*args, **kwargs):
|
||||||
captured.append(list(args[0]))
|
captured.append(list(args[0]))
|
||||||
@@ -166,7 +172,7 @@ async def test_transcript_mode_two_turns_build_four_regions(tmp_path):
|
|||||||
ctx.data_engine.global_base_dir = str(tmp_path)
|
ctx.data_engine.global_base_dir = str(tmp_path)
|
||||||
ctx.data_engine.get_agent_friendly_steps.return_value = []
|
ctx.data_engine.get_agent_friendly_steps.return_value = []
|
||||||
captured: list = []
|
captured: list = []
|
||||||
mock_llm = _no_action_llm(captured)
|
mock_llm = _action_llm(captured)
|
||||||
|
|
||||||
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
|
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
|
||||||
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True))
|
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True))
|
||||||
@@ -237,7 +243,7 @@ async def test_transcript_tail_marks_per_turn_notices_ephemeral():
|
|||||||
|
|
||||||
ctx = _transcript_ctx()
|
ctx = _transcript_ctx()
|
||||||
captured: list = []
|
captured: list = []
|
||||||
mock_llm = _no_action_llm(captured)
|
mock_llm = _action_llm(captured)
|
||||||
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
|
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
|
||||||
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True))
|
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True))
|
||||||
await node(
|
await node(
|
||||||
@@ -278,7 +284,7 @@ async def test_transcript_tail_marks_per_turn_notices_ephemeral():
|
|||||||
async def test_transcript_actionless_turn_commits_without_validator_message():
|
async def test_transcript_actionless_turn_commits_without_validator_message():
|
||||||
ctx = _transcript_ctx()
|
ctx = _transcript_ctx()
|
||||||
captured: list = []
|
captured: list = []
|
||||||
mock_llm = _no_action_llm(captured)
|
mock_llm = _action_llm(captured)
|
||||||
|
|
||||||
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
|
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
|
||||||
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True))
|
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True))
|
||||||
@@ -315,7 +321,7 @@ async def test_transcript_cold_start_builds_restored_history_block(tmp_path):
|
|||||||
]
|
]
|
||||||
|
|
||||||
captured: list = []
|
captured: list = []
|
||||||
mock_llm = _no_action_llm(captured)
|
mock_llm = _action_llm(captured)
|
||||||
|
|
||||||
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
|
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
|
||||||
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True))
|
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True))
|
||||||
@@ -338,7 +344,7 @@ async def test_transcript_cold_start_builds_restored_history_block(tmp_path):
|
|||||||
async def test_flag_off_keeps_legacy_two_message_build_and_no_ledger():
|
async def test_flag_off_keeps_legacy_two_message_build_and_no_ledger():
|
||||||
ctx = _transcript_ctx()
|
ctx = _transcript_ctx()
|
||||||
captured: list = []
|
captured: list = []
|
||||||
mock_llm = _no_action_llm(captured)
|
mock_llm = _action_llm(captured)
|
||||||
|
|
||||||
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
|
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
|
||||||
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=False))
|
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=False))
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ async def test_running_batch_start_is_idempotent(monkeypatch):
|
|||||||
assert request.call_args.args[0] == "GET"
|
assert request.call_args.args[0] == "GET"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_batch_uses_the_same_backend_as_the_web_ui(monkeypatch):
|
||||||
|
request = AsyncMock(return_value={"batch_id": "batch", "status": "deleted"})
|
||||||
|
monkeypatch.setattr(tools, "_request", request)
|
||||||
|
|
||||||
|
assert (await tools.mobile_batch("delete", batch_id="batch"))["status"] == "deleted"
|
||||||
|
request.assert_awaited_once_with("DELETE", "/api/automation/batches/batch")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_download_writes_actual_binary_atomically(monkeypatch, tmp_path):
|
async def test_download_writes_actual_binary_atomically(monkeypatch, tmp_path):
|
||||||
monkeypatch.setattr(tools, "EXPORT_DIR", tmp_path)
|
monkeypatch.setattr(tools, "EXPORT_DIR", tmp_path)
|
||||||
@@ -71,13 +80,19 @@ async def test_download_writes_actual_binary_atomically(monkeypatch, tmp_path):
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
tools.httpx, "AsyncClient", lambda **kw: client_type(transport=transport, **kw)
|
tools.httpx, "AsyncClient", lambda **kw: client_type(transport=transport, **kw)
|
||||||
)
|
)
|
||||||
result = await tools._download("/api/automation/reports/test/docx", "test.docx")
|
result = await tools._download(
|
||||||
|
"/api/automation/reports/test/docx",
|
||||||
|
"test.docx",
|
||||||
|
owner_kind="sessions",
|
||||||
|
owner_id="test",
|
||||||
|
)
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
assert Path(result["path"]).read_bytes() == b"PK\x03\x04report-bytes"
|
assert Path(result["path"]).read_bytes() == b"PK\x03\x04report-bytes"
|
||||||
assert Path(result["path"]).is_absolute()
|
assert Path(result["path"]).is_absolute()
|
||||||
|
assert Path(result["path"]).parent == tmp_path / "sessions" / "test"
|
||||||
assert result["download_url"] == "http://test/api/automation/reports/test/docx"
|
assert result["download_url"] == "http://test/api/automation/reports/test/docx"
|
||||||
assert not list(tmp_path.glob("*.part"))
|
assert not list(tmp_path.rglob("*.part"))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -279,6 +279,47 @@ def test_mobile_manage_task_unknown_trace(temp_trace_env):
|
|||||||
assert "not found" in res["message"]
|
assert "not found" in res["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_task_can_be_paused_and_guided_from_mcp(temp_trace_env, monkeypatch):
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import artemis.runtime.task_control as control
|
||||||
|
|
||||||
|
monkeypatch.setattr(control, "TRACES_PATH", Path(temp_trace_env))
|
||||||
|
monkeypatch.setattr("mcp_server.tools.task_manager._pid_alive", lambda pid: True)
|
||||||
|
trace_id = str(uuid.uuid4())
|
||||||
|
db = Path(temp_trace_env) / "data_engine.db"
|
||||||
|
with sqlite3.connect(db) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE sessions (session_id TEXT, status TEXT, pid INTEGER, "
|
||||||
|
"start_time REAL, end_time REAL, device_info TEXT)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions VALUES (?, 'running', 17, 1, NULL, ?)",
|
||||||
|
(trace_id, '{"device_id":"test-device"}'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert mobile_manage_task(action="status", trace_id=trace_id)["device_serial"] == "test-device"
|
||||||
|
assert mobile_manage_task(action="pause", trace_id=trace_id)["status"] == "pausing"
|
||||||
|
assert (Path(temp_trace_env) / trace_id / "pause.request").exists()
|
||||||
|
assert mobile_manage_task(action="status", trace_id=trace_id)["status"] == "pausing"
|
||||||
|
control._write_atomic(
|
||||||
|
control.control_dir(trace_id) / "task_control_state.json", {"status": "task_paused"}
|
||||||
|
)
|
||||||
|
guided = mobile_manage_task(
|
||||||
|
action="inject_instruction", trace_id=trace_id, instruction="先确认当前画面"
|
||||||
|
)
|
||||||
|
assert guided["status"] == "task_paused"
|
||||||
|
assert (Path(temp_trace_env) / trace_id / "resume.request").exists()
|
||||||
|
assert (
|
||||||
|
control.consume_instructions(control.control_dir(trace_id))["instruction"]
|
||||||
|
== "先确认当前画面"
|
||||||
|
)
|
||||||
|
guidance = mobile_manage_task(action="guidance_history", trace_id=trace_id)
|
||||||
|
assert guidance["instructions"][0]["status"] == "consumed"
|
||||||
|
assert guidance["instructions"][0]["instruction"] == "先确认当前画面"
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_manage_task_status_and_stop(temp_trace_env):
|
def test_mobile_manage_task_status_and_stop(temp_trace_env):
|
||||||
trace_id = str(uuid.uuid4())
|
trace_id = str(uuid.uuid4())
|
||||||
trace_store.init_trace(trace_id, "Test task", "Flash", "conv-1", device_serial="device-xyz")
|
trace_store.init_trace(trace_id, "Test task", "Flash", "conv-1", device_serial="device-xyz")
|
||||||
@@ -332,7 +373,7 @@ def test_mobile_manage_task_inject_instruction(temp_trace_env):
|
|||||||
ok_res = mobile_manage_task(
|
ok_res = mobile_manage_task(
|
||||||
action="inject_instruction", trace_id=trace_id, instruction="Scroll down slowly"
|
action="inject_instruction", trace_id=trace_id, instruction="Scroll down slowly"
|
||||||
)
|
)
|
||||||
assert "Successfully injected" in ok_res["message"]
|
assert "current model step interrupted" in ok_res["message"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Paused tasks yield their device and resume with a fresh lease."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import artemis.runtime.task_control as task_control
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pause_releases_device_and_resume_waits_for_new_acquisition(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
||||||
|
events = []
|
||||||
|
|
||||||
|
class Lock:
|
||||||
|
def __init__(self, device_id, **kwargs):
|
||||||
|
self.device_id = device_id
|
||||||
|
self.description = kwargs.get("description", "test")
|
||||||
|
self.max_concurrency = kwargs.get("max_concurrency")
|
||||||
|
self.concurrency_mode = kwargs.get("concurrency_mode", "per_device")
|
||||||
|
self.ingress = kwargs.get("ingress", "web")
|
||||||
|
self.lock_scope = kwargs.get("lock_scope", "test")
|
||||||
|
|
||||||
|
def release(self):
|
||||||
|
events.append("released")
|
||||||
|
|
||||||
|
def acquire(self, cancel_event=None):
|
||||||
|
events.append("acquired")
|
||||||
|
|
||||||
|
monkeypatch.setattr(task_control, "DeviceExecutionLock", Lock)
|
||||||
|
original = Lock("test-device")
|
||||||
|
control = task_control.TaskControl("task-123", original)
|
||||||
|
task_control.request_pause("task-123")
|
||||||
|
waiter = asyncio.create_task(
|
||||||
|
control.checkpoint(
|
||||||
|
on_pause=lambda: _event("capture_stopped", events),
|
||||||
|
on_resume=lambda: _event("capture_started", events),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for _ in range(30):
|
||||||
|
if task_control.read_state("task-123").get("status") == "task_paused":
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
assert task_control.read_state("task-123")["status"] == "task_paused"
|
||||||
|
assert events == ["capture_stopped", "released"]
|
||||||
|
task_control.request_resume("task-123")
|
||||||
|
assert await asyncio.wait_for(waiter, 2) is True
|
||||||
|
assert events == ["capture_stopped", "released", "acquired", "capture_started"]
|
||||||
|
control.release()
|
||||||
|
assert task_control.read_state("task-123") == {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _event(name, events):
|
||||||
|
events.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pause_interrupts_long_helper_and_discards_stale_result(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
||||||
|
events = []
|
||||||
|
started = asyncio.Event()
|
||||||
|
|
||||||
|
class Lock:
|
||||||
|
def __init__(self, device_id, **kwargs):
|
||||||
|
self.device_id = device_id
|
||||||
|
self.description = "test"
|
||||||
|
self.max_concurrency = None
|
||||||
|
self.concurrency_mode = "per_device"
|
||||||
|
self.ingress = "web"
|
||||||
|
self.lock_scope = "test"
|
||||||
|
|
||||||
|
def release(self):
|
||||||
|
events.append("released")
|
||||||
|
|
||||||
|
def acquire(self, cancel_event=None):
|
||||||
|
events.append("acquired")
|
||||||
|
|
||||||
|
monkeypatch.setattr(task_control, "DeviceExecutionLock", Lock)
|
||||||
|
control = task_control.TaskControl("task-123", Lock("device"))
|
||||||
|
|
||||||
|
async def diagnostic():
|
||||||
|
started.set()
|
||||||
|
try:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
finally:
|
||||||
|
events.append("diagnostic_stopped")
|
||||||
|
|
||||||
|
result = asyncio.create_task(
|
||||||
|
control.run_interruptibly(
|
||||||
|
diagnostic(),
|
||||||
|
on_pause=lambda: _event("capture_stopped", events),
|
||||||
|
on_resume=lambda: _event("capture_started", events),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(started.wait(), 1)
|
||||||
|
task_control.request_pause("task-123")
|
||||||
|
for _ in range(30):
|
||||||
|
if task_control.read_state("task-123").get("status") == "task_paused":
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
assert task_control.read_state("task-123")["status"] == "task_paused"
|
||||||
|
assert events == ["diagnostic_stopped", "capture_stopped", "released"]
|
||||||
|
task_control.request_resume("task-123")
|
||||||
|
assert await asyncio.wait_for(result, 2) == (True, None)
|
||||||
|
assert events[-2:] == ["acquired", "capture_started"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interruptible_call_returns_normal_result_without_pause(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
||||||
|
control = task_control.TaskControl("task-123", object())
|
||||||
|
|
||||||
|
async def model_call():
|
||||||
|
return "current result"
|
||||||
|
|
||||||
|
assert await control.run_interruptibly(model_call()) == (False, "current result")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_new_guidance_cancels_stale_model_without_releasing_device(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
||||||
|
released = []
|
||||||
|
|
||||||
|
class Lock:
|
||||||
|
def release(self):
|
||||||
|
released.append(True)
|
||||||
|
|
||||||
|
control = task_control.TaskControl("task-123", Lock())
|
||||||
|
started = asyncio.Event()
|
||||||
|
cancelled = asyncio.Event()
|
||||||
|
|
||||||
|
async def stale_model():
|
||||||
|
started.set()
|
||||||
|
try:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
finally:
|
||||||
|
cancelled.set()
|
||||||
|
|
||||||
|
call = asyncio.create_task(control.run_interruptibly(stale_model()))
|
||||||
|
await asyncio.wait_for(started.wait(), 1)
|
||||||
|
task_control.add_instruction("task-123", "改为检查当前页面", source="mcp")
|
||||||
|
assert await asyncio.wait_for(call, 1) == (True, None)
|
||||||
|
assert cancelled.is_set()
|
||||||
|
assert released == []
|
||||||
|
assert task_control.consume_instructions(control.directory)["instruction"] == "改为检查当前页面"
|
||||||
|
|
||||||
|
|
||||||
|
def test_guidance_history_distinguishes_submission_from_runner_consumption(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
||||||
|
first = task_control.add_instruction("task-123", "从快捷设置进入", source="web")
|
||||||
|
second = task_control.add_instruction("task-123", "不要更改设置", source="mcp")
|
||||||
|
|
||||||
|
assert [item["status"] for item in task_control.list_instructions("task-123")] == [
|
||||||
|
"pending",
|
||||||
|
"pending",
|
||||||
|
]
|
||||||
|
consumed = task_control.consume_instructions(task_control.control_dir("task-123"))
|
||||||
|
assert consumed["instruction"] == "从快捷设置进入\n\n不要更改设置"
|
||||||
|
assert task_control.consume_instructions(task_control.control_dir("task-123")) is None
|
||||||
|
history = task_control.list_instructions("task-123")
|
||||||
|
assert [item["id"] for item in history] == [first["id"], second["id"]]
|
||||||
|
assert all(item["status"] == "consumed" and item["consumed_at"] for item in history)
|
||||||
|
|
||||||
|
|
||||||
|
def test_guidance_history_endpoint_returns_pending_and_consumed(tmp_path, monkeypatch):
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from apps.admin_console.routers.tasks import router
|
||||||
|
|
||||||
|
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
||||||
|
task_control.add_instruction("task-123", "从快捷设置进入")
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router)
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/api/tasks/task-123/guidance")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()[0]["status"] == "pending"
|
||||||
|
task_control.consume_instructions(task_control.control_dir("task-123"))
|
||||||
|
response = client.get("/api/tasks/task-123/guidance")
|
||||||
|
assert response.json()[0]["status"] == "consumed"
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
"""Keep bootstrap scripts ASCII for Windows PowerShell 5.1 compatibility.
|
"""Require ASCII or UTF-8 with BOM for Windows PowerShell 5.1 scripts.
|
||||||
|
|
||||||
BOM-less scripts are decoded using the system ANSI code page. UTF-8 symbols
|
BOM-less scripts are decoded using the system ANSI code page. UTF-8 symbols
|
||||||
can become quote delimiters under cp1252 and cause parse errors.
|
can become quote delimiters under cp1252 and cause parse errors.
|
||||||
@@ -31,18 +31,11 @@ def test_powershell_scripts_are_discovered():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("script", POWERSHELL_SCRIPTS, ids=lambda p: p.name)
|
@pytest.mark.parametrize("script", POWERSHELL_SCRIPTS, ids=lambda p: p.name)
|
||||||
def test_powershell_script_is_pure_ascii(script):
|
def test_powershell_script_has_windows_compatible_encoding(script):
|
||||||
raw = script.read_bytes()
|
raw = script.read_bytes()
|
||||||
offending = []
|
if any(byte > 0x7F for byte in raw):
|
||||||
line_no = 1
|
assert raw.startswith(b"\xef\xbb\xbf"), (
|
||||||
for index, byte in enumerate(raw):
|
f"{script.name} contains non-ASCII bytes without a UTF-8 BOM; "
|
||||||
if byte == 0x0A:
|
"Windows PowerShell 5.1 would use the system ANSI code page."
|
||||||
line_no += 1
|
|
||||||
elif byte > 0x7F:
|
|
||||||
offending.append((line_no, index, hex(byte)))
|
|
||||||
|
|
||||||
assert not offending, (
|
|
||||||
f"{script.name} contains non-ASCII bytes at (line, offset, byte) "
|
|
||||||
f"{offending[:10]}; Windows PowerShell 5.1 decodes BOM-less .ps1 files "
|
|
||||||
"with the system ANSI code page. Use ASCII status labels."
|
|
||||||
)
|
)
|
||||||
|
raw.decode("utf-8-sig")
|
||||||
@@ -58,6 +58,8 @@ if ($ArtemisDownloads.node_mirror -ne 'https://intranet.example/node') { throw '
|
|||||||
if ($ArtemisDownloads.python_index -ne 'https://intranet.example/simple') { throw 'Environment ignored' }
|
if ($ArtemisDownloads.python_index -ne 'https://intranet.example/simple') { throw 'Environment ignored' }
|
||||||
if ($env:npm_config_registry -ne 'https://registry.npmmirror.com') { throw 'npm mirror missing' }
|
if ($env:npm_config_registry -ne 'https://registry.npmmirror.com') { throw 'npm mirror missing' }
|
||||||
if ($env:UV_HTTP_TIMEOUT -ne '180') { throw 'Timeout missing' }
|
if ($env:UV_HTTP_TIMEOUT -ne '180') { throw 'Timeout missing' }
|
||||||
|
if ($ArtemisDownloads.scrcpy_url -match 'github[.]com') { throw 'China scrcpy still uses GitHub' }
|
||||||
|
if ($ArtemisDownloads.scrcpy_sha256 -notmatch '^[a-f0-9]{64}$') { throw 'scrcpy digest missing' }
|
||||||
Remove-Item Env:npm_config_registry
|
Remove-Item Env:npm_config_registry
|
||||||
Remove-Item Env:UV_PYTHON_INSTALL_MIRROR
|
Remove-Item Env:UV_PYTHON_INSTALL_MIRROR
|
||||||
Initialize-ArtemisDownloads -ProjectRoot $env:TEST_ROOT -Mode Official
|
Initialize-ArtemisDownloads -ProjectRoot $env:TEST_ROOT -Mode Official
|
||||||
@@ -69,6 +71,13 @@ if ($env:npm_config_registry) { throw 'Official mode selected a China registry'
|
|||||||
assert result.returncode == 0, result.stdout + result.stderr
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrcpy_archive_is_checked_before_extraction():
|
||||||
|
launcher = (ROOT / "scripts/start.ps1").read_text(encoding="utf-8-sig")
|
||||||
|
assert launcher.index("Get-FileHash -LiteralPath $zipPath") < launcher.index(
|
||||||
|
"Remove-Item $extractDir -Recurse"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("corrupt", [False, True])
|
@pytest.mark.parametrize("corrupt", [False, True])
|
||||||
def test_uv_mirror_bootstrap_verifies_wheel_without_python(tmp_path, corrupt):
|
def test_uv_mirror_bootstrap_verifies_wheel_without_python(tmp_path, corrupt):
|
||||||
with zipfile.ZipFile(tmp_path / "fixture.zip", "w") as wheel:
|
with zipfile.ZipFile(tmp_path / "fixture.zip", "w") as wheel:
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ command = "leave-alone"
|
|||||||
assert path.with_name(path.name + ".artemis-update.bak").read_text(encoding="utf-8") == original
|
assert path.with_name(path.name + ".artemis-update.bak").read_text(encoding="utf-8") == original
|
||||||
|
|
||||||
|
|
||||||
def test_json_only_updates_existing_matching_project(tmp_path):
|
def test_json_migrates_existing_standard_mcp_to_current_checkout(tmp_path):
|
||||||
path = tmp_path / "mcp.json"
|
path = tmp_path / "mcp.json"
|
||||||
root = tmp_path / "project"
|
root = tmp_path / "project"
|
||||||
config = {
|
config = {
|
||||||
@@ -132,19 +132,170 @@ def test_json_only_updates_existing_matching_project(tmp_path):
|
|||||||
"autoApprove": [],
|
"autoApprove": [],
|
||||||
}
|
}
|
||||||
path.write_text(json.dumps({"mcpServers": {"artemis": config, "other": {"command": "keep"}}}))
|
path.write_text(json.dumps({"mcpServers": {"artemis": config, "other": {"command": "keep"}}}))
|
||||||
assert not update.refresh_json(
|
moved_root = tmp_path / "new release"
|
||||||
path, ("mcpServers",), tmp_path / "different", "new", ("mobile_batch",)
|
assert update.refresh_json(path, ("mcpServers",), moved_root, "new", ("mobile_batch",))
|
||||||
)
|
|
||||||
assert update.refresh_json(path, ("mcpServers",), root, "new", ("mobile_batch",))
|
|
||||||
result = json.loads(path.read_text())["mcpServers"]
|
result = json.loads(path.read_text())["mcpServers"]
|
||||||
assert result["artemis"]["env"] == {"CUSTOM": "preserved"}
|
assert result["artemis"]["env"]["CUSTOM"] == "preserved"
|
||||||
|
assert result["artemis"]["env"]["PYTHONPATH"] == str(moved_root)
|
||||||
|
assert result["artemis"]["cwd"] == str(moved_root)
|
||||||
assert result["artemis"]["disabled"] is True
|
assert result["artemis"]["disabled"] is True
|
||||||
assert result["other"] == {"command": "keep"}
|
assert result["other"] == {"command": "keep"}
|
||||||
|
assert not update.refresh_json(path, ("mcpServers",), moved_root, "new", ("mobile_batch",))
|
||||||
missing = tmp_path / "not-installed.json"
|
missing = tmp_path / "not-installed.json"
|
||||||
assert not update.refresh_json(missing, ("mcpServers",), root, "new", ())
|
assert not update.refresh_json(missing, ("mcpServers",), root, "new", ())
|
||||||
assert not missing.exists()
|
assert not missing.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_sync_installs_detected_clients_and_migrates_codex(tmp_path, monkeypatch):
|
||||||
|
from artemis.interfaces.cli.commands import mcp as mcp_command
|
||||||
|
|
||||||
|
home = tmp_path / "home"
|
||||||
|
home.mkdir()
|
||||||
|
codex_home = home / ".codex"
|
||||||
|
codex_home.mkdir()
|
||||||
|
codex_config = codex_home / "config.toml"
|
||||||
|
codex_config.write_text(
|
||||||
|
'model = "my-model"\n'
|
||||||
|
"[mcp_servers.artemis]\n"
|
||||||
|
'command = "old-python"\n'
|
||||||
|
'args = ["-m", "mcp_server.server"]\n'
|
||||||
|
'cwd = "C:/old-release"\n'
|
||||||
|
'enabled_tools = ["mobile_run_task", "retired_tool"]\n'
|
||||||
|
"enabled = false\n"
|
||||||
|
"[mcp_servers.artemis.env]\n"
|
||||||
|
'PYTHONPATH = "C:/old-release"\n'
|
||||||
|
'OPENAI_API_KEY = "keep-secret"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
cursor_home = home / ".cursor"
|
||||||
|
cursor_home.mkdir()
|
||||||
|
(cursor_home / "mcp.json").write_text('{"mcpServers":{"other":{"command":"keep"}}}')
|
||||||
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
||||||
|
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
|
||||||
|
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||||
|
monkeypatch.setenv("APPDATA", str(home / "AppData"))
|
||||||
|
root = tmp_path / "new release"
|
||||||
|
assert update.refresh_mcp(root) == 0
|
||||||
|
codex = tomllib.loads(codex_config.read_text(encoding="utf-8"))
|
||||||
|
entry = codex["mcp_servers"]["artemis"]
|
||||||
|
assert codex["model"] == "my-model"
|
||||||
|
assert entry["command"] == update.sys.executable
|
||||||
|
assert entry["cwd"] == str(root)
|
||||||
|
assert entry["args"] == ["-m", "mcp_server"]
|
||||||
|
assert entry["enabled"] is False
|
||||||
|
assert entry["env"]["OPENAI_API_KEY"] == "keep-secret"
|
||||||
|
assert entry["env"]["PYTHONPATH"] == str(root)
|
||||||
|
assert entry["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
|
||||||
|
cursor = json.loads((cursor_home / "mcp.json").read_text(encoding="utf-8"))
|
||||||
|
assert cursor["mcpServers"]["artemis"]["env"]["PYTHONPATH"] == str(root)
|
||||||
|
assert cursor["mcpServers"]["other"] == {"command": "keep"}
|
||||||
|
first = codex_config.read_text(encoding="utf-8")
|
||||||
|
assert update.refresh_mcp(root) == 0
|
||||||
|
assert codex_config.read_text(encoding="utf-8") == first
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_sync_fails_on_invalid_detected_config_without_overwriting(tmp_path, monkeypatch):
|
||||||
|
from artemis.interfaces.cli.commands import mcp as mcp_command
|
||||||
|
|
||||||
|
home = tmp_path / "home"
|
||||||
|
cursor_home = home / ".cursor"
|
||||||
|
cursor_home.mkdir(parents=True)
|
||||||
|
config = cursor_home / "mcp.json"
|
||||||
|
config.write_text("{invalid json", encoding="utf-8")
|
||||||
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
||||||
|
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
|
||||||
|
monkeypatch.delenv("CODEX_HOME", raising=False)
|
||||||
|
assert update.refresh_mcp(tmp_path / "project") == 1
|
||||||
|
assert config.read_text(encoding="utf-8") == "{invalid json"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_sync_installs_codex_when_client_directory_exists(tmp_path, monkeypatch):
|
||||||
|
from artemis.interfaces.cli.commands import mcp as mcp_command
|
||||||
|
|
||||||
|
home = tmp_path / "home"
|
||||||
|
codex_home = home / ".codex"
|
||||||
|
codex_home.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
||||||
|
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
|
||||||
|
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||||
|
root = tmp_path / "project"
|
||||||
|
assert update.refresh_mcp(root) == 0
|
||||||
|
config = tomllib.loads((codex_home / "config.toml").read_text(encoding="utf-8"))
|
||||||
|
assert config["mcp_servers"]["artemis"]["cwd"] == str(root)
|
||||||
|
assert config["mcp_servers"]["artemis"]["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_codex_install_preserves_existing_user_tables(tmp_path):
|
||||||
|
path = tmp_path / "config.toml"
|
||||||
|
path.write_text(
|
||||||
|
'model = "keep"\n[mcp_servers.other]\ncommand = "other-tool"\n', encoding="utf-8"
|
||||||
|
)
|
||||||
|
template = {
|
||||||
|
"command": "new-python",
|
||||||
|
"args": ["-m", "mcp_server"],
|
||||||
|
"cwd": str(tmp_path),
|
||||||
|
"env": {"PYTHONPATH": str(tmp_path)},
|
||||||
|
"enabled_tools": ["mobile_run_task"],
|
||||||
|
}
|
||||||
|
assert update.refresh_codex(
|
||||||
|
path,
|
||||||
|
tmp_path,
|
||||||
|
"new-python",
|
||||||
|
("mobile_run_task",),
|
||||||
|
template=template,
|
||||||
|
install_missing=True,
|
||||||
|
)
|
||||||
|
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||||
|
assert data["model"] == "keep"
|
||||||
|
assert data["mcp_servers"]["other"]["command"] == "other-tool"
|
||||||
|
assert data["mcp_servers"]["artemis"]["env"]["PYTHONPATH"] == str(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_codex_inline_environment_sync_is_idempotent(tmp_path):
|
||||||
|
path = tmp_path / "config.toml"
|
||||||
|
path.write_text(
|
||||||
|
'[mcp_servers.artemis]\ncommand = "old-python"\n'
|
||||||
|
'args = ["-m", "mcp_server"]\n'
|
||||||
|
'env = { PYTHONPATH = "C:/old", OPENAI_API_KEY = "keep-secret" }\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
template = {
|
||||||
|
"command": "new-python",
|
||||||
|
"args": ["-m", "mcp_server"],
|
||||||
|
"cwd": str(tmp_path),
|
||||||
|
"env": {"PYTHONPATH": str(tmp_path)},
|
||||||
|
"enabled_tools": ["mobile_run_task"],
|
||||||
|
}
|
||||||
|
assert update.refresh_codex(
|
||||||
|
path, tmp_path, "new-python", ("mobile_run_task",), template=template
|
||||||
|
)
|
||||||
|
data = tomllib.loads(path.read_text(encoding="utf-8"))["mcp_servers"]["artemis"]
|
||||||
|
assert data["env"]["OPENAI_API_KEY"] == "keep-secret"
|
||||||
|
assert data["env"]["PYTHONPATH"] == str(tmp_path)
|
||||||
|
assert not update.refresh_codex(
|
||||||
|
path, tmp_path, "new-python", ("mobile_run_task",), template=template
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_mcp_wrapper_is_left_unchanged(tmp_path):
|
||||||
|
path = tmp_path / "mcp.json"
|
||||||
|
original = '{"mcpServers":{"artemis":{"command":"uv","args":["run","custom"]}}}'
|
||||||
|
path.write_text(original, encoding="utf-8")
|
||||||
|
assert (
|
||||||
|
update.refresh_json(
|
||||||
|
path,
|
||||||
|
("mcpServers",),
|
||||||
|
tmp_path,
|
||||||
|
"new-python",
|
||||||
|
("mobile_run_task",),
|
||||||
|
install_missing=True,
|
||||||
|
template={"command": "new-python", "args": ["-m", "mcp_server"], "env": {}},
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
assert path.read_text(encoding="utf-8") == original
|
||||||
|
|
||||||
|
|
||||||
def test_frontend_fingerprint_detects_lock_source_and_deleted_assets(tmp_path):
|
def test_frontend_fingerprint_detects_lock_source_and_deleted_assets(tmp_path):
|
||||||
ui = tmp_path / "apps/showcase_ui"
|
ui = tmp_path / "apps/showcase_ui"
|
||||||
(ui / "src").mkdir(parents=True)
|
(ui / "src").mkdir(parents=True)
|
||||||
|
|||||||
@@ -1,36 +1,134 @@
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
def test_archive_keeps_source_and_excludes_local_builds_and_secrets(tmp_path):
|
|
||||||
script = Path(__file__).resolve().parents[2] / "scripts/package_source.py"
|
SCRIPT = Path(__file__).resolve().parents[2] / "scripts/package_source.py"
|
||||||
spec = importlib.util.spec_from_file_location("package_source", script)
|
spec = importlib.util.spec_from_file_location("package_source", SCRIPT)
|
||||||
module = importlib.util.module_from_spec(spec)
|
module = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(module)
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
|
||||||
|
def git(root, *args):
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", str(root), *args], capture_output=True, text=True, timeout=30
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def repository(tmp_path):
|
||||||
|
if not shutil.which("git"):
|
||||||
|
pytest.skip("Git required")
|
||||||
|
root = tmp_path / "source"
|
||||||
|
root.mkdir()
|
||||||
|
git(root, "init", "-b", "main")
|
||||||
|
git(root, "config", "user.name", "Fixture")
|
||||||
|
git(root, "config", "user.email", "fixture@example.test")
|
||||||
keep = ["start.bat", ".env.example", "uv.lock", "apps/showcase_ui/src/main.ts"]
|
keep = ["start.bat", ".env.example", "uv.lock", "apps/showcase_ui/src/main.ts"]
|
||||||
|
tracked_builds = [
|
||||||
|
"artemis/resources/showcase_ui/main.js",
|
||||||
|
"packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk",
|
||||||
|
"packages/artemis-accessibility-helper/debug.keystore",
|
||||||
|
]
|
||||||
omit = [
|
omit = [
|
||||||
".env",
|
".env",
|
||||||
".env.local",
|
".env.local",
|
||||||
".venv/pyvenv.cfg",
|
".venv/pyvenv.cfg",
|
||||||
"nested/node_modules/foo.js",
|
"nested/node_modules/foo.js",
|
||||||
"apps/showcase_ui/dist/main.js",
|
"apps/showcase_ui/dist/main.js",
|
||||||
"artemis/resources/showcase_ui/main.js",
|
|
||||||
"__pycache__/a.pyc",
|
"__pycache__/a.pyc",
|
||||||
"traces/data.db",
|
"traces/data.db",
|
||||||
"helper.apk",
|
|
||||||
"debug.keystore",
|
|
||||||
".git/config",
|
|
||||||
".roo/mcp.json",
|
".roo/mcp.json",
|
||||||
]
|
]
|
||||||
for name in keep + omit:
|
for name in keep + tracked_builds + omit:
|
||||||
target = tmp_path / name
|
target = root / name
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
target.write_text("fixture")
|
target.write_text("fixture")
|
||||||
|
(root / ".gitignore").write_text(
|
||||||
|
".env*\n!.env.example\n.venv/\nnode_modules/\ndist/\ntraces/\n.roo/\n__pycache__/\n"
|
||||||
|
)
|
||||||
|
git(root, "add", "--", ".gitignore", *keep, *tracked_builds)
|
||||||
|
git(root, "commit", "-m", "initial")
|
||||||
|
git(root, "remote", "add", "eapil", "https://git.example.invalid/team/project.git")
|
||||||
|
git(root, "config", "branch.main.remote", "eapil")
|
||||||
|
git(root, "config", "branch.main.merge", "refs/heads/main")
|
||||||
|
return root, keep, tracked_builds, omit
|
||||||
|
|
||||||
|
|
||||||
|
def test_archive_is_updatable_clean_checkout_without_local_data(repository, tmp_path):
|
||||||
|
root, keep, tracked_builds, omit = repository
|
||||||
output = tmp_path / "artifacts/source.zip"
|
output = tmp_path / "artifacts/source.zip"
|
||||||
count, digest = module.package_source(tmp_path, output)
|
count, digest = module.package_source(root, output)
|
||||||
assert count == len(keep)
|
assert count > len(keep)
|
||||||
assert len(digest) == 64
|
assert len(digest) == 64
|
||||||
with zipfile.ZipFile(output) as archive:
|
with zipfile.ZipFile(output) as archive:
|
||||||
assert archive.testzip() is None
|
assert archive.testzip() is None
|
||||||
assert set(archive.namelist()) == {f"artemis/{name}" for name in keep}
|
names = set(archive.namelist())
|
||||||
|
assert {f"artemis/{name}" for name in keep}.issubset(names)
|
||||||
|
assert not {f"artemis/{name}" for name in tracked_builds + omit} & names
|
||||||
|
assert "artemis/.git/config" in names
|
||||||
|
assert "artemis/.git/index" in names
|
||||||
|
assert not any(name.startswith("artemis/.git/logs/") for name in names)
|
||||||
|
assert not any(name.startswith("artemis/.git/hooks/") for name in names)
|
||||||
|
archive.extractall(tmp_path / "unpacked")
|
||||||
|
extracted = tmp_path / "unpacked/artemis"
|
||||||
|
assert git(extracted, "symbolic-ref", "--short", "HEAD") == "main"
|
||||||
|
assert git(extracted, "rev-parse", "HEAD") == git(root, "rev-parse", "HEAD")
|
||||||
|
assert git(extracted, "status", "--porcelain") == ""
|
||||||
|
assert git(extracted, "remote", "get-url", "origin") == (
|
||||||
|
"https://git.example.invalid/team/project.git"
|
||||||
|
)
|
||||||
|
assert git(extracted, "config", "--get", "branch.main.remote") == "origin"
|
||||||
|
(extracted / tracked_builds[0]).parent.mkdir(parents=True)
|
||||||
|
(extracted / tracked_builds[0]).write_text("new bundle")
|
||||||
|
assert git(extracted, "status", "--porcelain") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_unpacked_archive_fast_forwards(repository, tmp_path):
|
||||||
|
root, _, tracked_builds, _ = repository
|
||||||
|
bare = tmp_path / "upstream.git"
|
||||||
|
bare.mkdir()
|
||||||
|
git(bare, "init", "--bare")
|
||||||
|
git(root, "remote", "add", "test-upstream", str(bare))
|
||||||
|
git(root, "push", "test-upstream", "main")
|
||||||
|
archive = tmp_path / "source.zip"
|
||||||
|
module.package_source(root, archive)
|
||||||
|
with zipfile.ZipFile(archive) as contents:
|
||||||
|
contents.extractall(tmp_path / "unpacked")
|
||||||
|
extracted = tmp_path / "unpacked/artemis"
|
||||||
|
git(extracted, "remote", "set-url", "origin", str(bare))
|
||||||
|
built_asset = extracted / tracked_builds[0]
|
||||||
|
built_asset.parent.mkdir(parents=True)
|
||||||
|
built_asset.write_text("generated after setup")
|
||||||
|
(root / "start.bat").write_text("updated")
|
||||||
|
git(root, "add", "--", "start.bat")
|
||||||
|
git(root, "commit", "-m", "update launcher")
|
||||||
|
git(root, "push", "test-upstream", "main")
|
||||||
|
from scripts.update_installation import update_source
|
||||||
|
|
||||||
|
assert update_source(extracted, port=1) == 10
|
||||||
|
assert (extracted / "start.bat").read_text() == "updated"
|
||||||
|
assert git(extracted, "status", "--porcelain") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_packaging_rejects_uncommitted_changes_and_credentials(repository, tmp_path):
|
||||||
|
root, _, _, _ = repository
|
||||||
|
(root / "start.bat").write_text("local change")
|
||||||
|
with pytest.raises(ValueError, match="Commit tracked changes"):
|
||||||
|
module.package_source(root, tmp_path / "source.zip")
|
||||||
|
(root / "start.bat").write_text("fixture")
|
||||||
|
git(
|
||||||
|
root,
|
||||||
|
"remote",
|
||||||
|
"set-url",
|
||||||
|
"eapil",
|
||||||
|
"https://user:secret@git.example.invalid/team/project.git",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="credential-free HTTPS"):
|
||||||
|
module.package_source(root, tmp_path / "source.zip")
|
||||||
Reference in new issue
Block a user