Compare commits

...
6 Commits
Author SHA1 Message Date
chenmingxuan 8522fa3ca3 fix: ship updatable 1.1.1 source archives
Package a clean shallow main checkout with credential-free Gitea remote, localize the China launcher, and set the new-install Responses endpoint.
2026-09-24 10:49:57 +08:00
chenmingxuan bfcd828fa8 feat: release 1.1.0 with responsive task control 2026-09-24 03:31:17 +08:00
chenmingxuan 057f0c3050 feat: add prompt-first batches and pausable task guidance 2026-09-23 22:59:48 +08:00
chenmingxuan cec9898e4c chore: release 1.0.1 2026-09-23 02:04:42 +08:00
chenmingxuan 60ac066859 fix: make task IDs selectable and copyable with feedback 2026-09-23 01:53:06 +08:00
chenmingxuan 9d6833f092 fix: normalize Pro action targets and bound no-progress recovery 2026-09-23 01:34:10 +08:00
86 changed files with 3499 additions and 452 deletions

No files matched your search

+4 -1
View File
@@ -2,7 +2,7 @@
# 所有模型调用仅使用 OpenAI Responses API;环境变量优先于 .env。
OPENAI_API_KEY=
# 接口根地址:通常以 /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
# 单次模型 HTTP 请求超时(秒)
OPENAI_TIMEOUT_SECONDS=300
@@ -14,8 +14,11 @@ OPENAI_REASONING_EFFORT=high
# 功能开关:保存后对新任务生效
ARTEMIS_VIDEO_ENABLED=false
ARTEMIS_RECORDING_ENABLED=false
ARTEMIS_OUTPUTTER_ENABLED=true
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
# Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3
# 视频分段、重试与单次分析超时(秒)
ARTEMIS_VIDEO_CHUNK_SECONDS=60
+8 -8
View File
@@ -6,13 +6,13 @@
- **自然语言任务**:Flash 适合明确的操作流程,Pro 适合需要计划、分支探索和验证的任务。
- **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。
- **批量测试**:下载 Excel 模板、校验导入、顺序执行,支持暂停、继续和复制失败项。
- **报告与历史**:导出 Word、Excel 和批次 ZIP;历史按日期分组,支持搜索和筛选。
- **批量测试**:按新版 Excel 模板导入提示词,每项可独立指定模型、模式和参数;任务可暂停、补充指导并继续。
- **报告与历史**:导出 Word、Excel、步骤截图、录屏和任务资料 ZIP;历史按日期分组,支持搜索和筛选。
- **AI 接入**:随源码提供 MCP 与 Skill,让 AI 完成设备诊断、任务执行、历史分析和报告交付。
## 安装与启动
准备一台开启 USB 调试并完成授权的 Android 手机,或已启动的 Android 模拟器。首次安装需要联网,建议使用 Git 克隆以支持后续更新。
准备一台开启 USB 调试并完成授权的 Android 手机,或已启动的 Android 模拟器。首次安装需要联网。可使用 Git 克隆或下载 1.1.1 及更新版本的发布源码包;新版 ZIP 解压后也保留 Git 更新能力。
### Windows
@@ -24,7 +24,7 @@ cd Artemis-Eapil
.\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**。
常用选项:
@@ -34,7 +34,7 @@ cd Artemis-Eapil
.\start.bat -SkipUpdate # 跳过 Git 源码更新检查
```
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。scrcpy 默认从 GitHub 下载,无法访问时可配置可信的 scrcpy_url 或预先安装。
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。国内脚本使用山东大学镜像安装 scrcpy,并校验官方 SHA256;也可预先安装或自定义 HTTPS 下载地址。
### macOS / Linux
@@ -49,7 +49,7 @@ cd Artemis-Eapil
预设模型为 `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`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。
@@ -69,9 +69,9 @@ uv run artemis mcp --install codex
## 更新与排错
Windows 用户再次运行一键脚本时,会检查 Git 上游、项目依赖、前端构建和已有 MCP 配置,仅对源码做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。更新后需在 AI 客户端重新加载 MCP。
Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建和已有 MCP 配置。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。更新后需在 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)
+1 -1
View File
@@ -136,7 +136,7 @@ class ServerState:
return {
str(run.get("lock_key") or run["device_id"])
for run in self.active_runs.values()
if run.get("device_id")
if run.get("device_id") and not run.get("parked")
}
@property
@@ -422,7 +422,7 @@ class SessionRepository:
return count
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
forced server stop: live workers, including workers owned by another
@@ -442,14 +442,14 @@ class SessionRepository:
cursor.execute(
"UPDATE sessions SET status = ?, end_time = ? "
"WHERE session_id = ? AND status = ?",
("failed", now, row["session_id"], "running"),
("interrupted", now, row["session_id"], "running"),
)
count += cursor.rowcount
try:
if trace_store.read_status(str(row["session_id"])):
trace_store.update_trace_status(
str(row["session_id"]),
"failed",
"interrupted",
error="Process terminated prior to server startup.",
)
except OSError as exc:
+32 -5
View File
@@ -121,6 +121,20 @@ def batch_detail(batch_id: str):
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 store.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}")
def batch_action(batch_id: str, action: Literal["pause", "resume", "retry_failed"]):
require("batch", batch_id)
@@ -146,8 +160,13 @@ def report(session_id: str):
@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:
if fmt in {"screenshots", "video", "all"}:
path = await asyncio.to_thread(report_export.export_session_media, session_id, fmt)
return FileResponse(path, filename=f"任务{session_id[:8]}-{fmt}.zip")
paths = await asyncio.to_thread(report_export.export_session, session_id, [fmt])
return FileResponse(paths[0], filename=f"任务报告-{session_id[:8]}.{fmt}")
except ValueError as exc:
@@ -167,21 +186,29 @@ def history(
query = """WITH history AS (
SELECT s.session_id, s.initial_goal, s.start_time, s.end_time,
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
FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id
UNION ALL
SELECT j.session_id,json_extract(j.payload,'$.case.goal'),
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'),
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'),'')
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')"
args = []
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("_", "\\_") + "%"
args.extend([escaped] * 4)
args.extend([escaped] * 8)
for condition, value in [
("status = ?", status or None),
("start_time >= ?", start),
+73
View File
@@ -18,8 +18,17 @@ import json
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from artemis.core.diagnostics import readiness_engine
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:
from admin_console.core.state import state
@@ -42,6 +51,59 @@ except ImportError:
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")
async def get_task_presets(
category: str = "recommended",
@@ -285,6 +347,17 @@ async def get_status():
}
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 = (
(global_owner.session_id if global_owner else None)
+5 -3
View File
@@ -152,7 +152,7 @@ async def on_startup():
cleaned = session_repo.cleanup_orphans_on_startup()
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
# recordings; publish whatever raw files they left behind.
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 = {
str(item["session_id"])
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
@@ -212,7 +214,7 @@ async def on_shutdown():
state.current_process = None
state.queue_items.clear()
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
@@ -41,7 +41,7 @@ def register_submission(item: dict, case: dict | None = None, formats=None, **ex
def recover_batches():
"""Recovery never silently repeats a potentially side-effecting interrupted task."""
for job in store.jobs():
if job["status"] in {"running", "queued", "finalizing"}:
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
job.update(
status=job.get("terminal_status", "interrupted"),
error=job.get("error")
@@ -64,7 +64,7 @@ def recover_batches():
status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务"
)
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(
status="interrupted", error="服务中断,需手动重跑", ended_at=time.time()
)
@@ -89,11 +89,15 @@ async def on_terminal(item: dict, status: str):
try:
job["status"] = status
report = await asyncio.to_thread(session_report, sid)
if report["verdict"] == "验证失败" or report["outcome"].get("task_status") in {
"blocked",
"partial",
"failed",
}:
if status != "interrupted" and (
report["verdict"] == "验证失败"
or report["outcome"].get("task_status")
in {
"blocked",
"partial",
"failed",
}
):
job["status"] = "failed"
job["error"] = "任务执行结束,但业务检查未通过"
# Persist terminal classification before export, but the coordinator waits for finalization.
@@ -121,11 +125,18 @@ async def tick_batches():
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
store.put("batch", batch["id"], batch)
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 (
job["status"] != "finalizing"
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)
store.save_job(job)
@@ -142,6 +153,11 @@ async def tick_batches():
job.update(status="skipped", error="本批次设置为失败即停止", ended_at=time.time())
store.save_job(job)
pending = []
if not pending and any(
j["status"] in {"task_paused", "resuming", "running", "queued", "finalizing"}
for j in jobs
):
continue
if not pending:
batch.update(status="completed", ended_at=time.time())
try:
@@ -156,7 +172,9 @@ async def tick_batches():
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
store.put("batch", batch["id"], batch)
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()
rejection = (
None
@@ -169,26 +187,28 @@ async def tick_batches():
continue
if (store.get("batch", batch["id"]) or {}).get("status") != "running":
continue
job = pending[0]
job.update(status="queued", started_at=time.time())
store.save_job(job)
try:
response = await task_queue_service.enqueue_tasks(
[case_goal(job["case"])],
profile=batch.get("profile", "flash"),
expected_output="用简体中文报告实际结果与证据。明确区分执行完成、验证通过、验证失败和前置条件未满足。"
+ job["case"].get("expected", ""),
profile=options.get("profile") or batch.get("profile", "flash"),
expected_output="用简体中文报告实际结果与证据。明确区分执行完成、验证通过、验证失败和前置条件未满足。",
enable_outputter=True,
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"],
ingress="batch",
)
if response.get("status") == "rejected" or not response.get("tasks"):
raise ValueError(response.get("error") or "队列拒绝接收任务")
bound = response["tasks"][0].get("device_serial")
if bound and not serial:
batch["device_serial"] = bound
store.put("batch", batch["id"], batch)
if bound:
job["device_serial"] = bound
store.save_job(job)
except Exception as exc:
job.update(status="pending", error=str(exc)[:500])
store.save_job(job)
@@ -164,6 +164,36 @@ class AutomationStore:
conn.commit()
return batch
def delete_batch(self, batch_id: str) -> bool:
"""Remove a batch record; retain executed job metadata and its reports."""
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])
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("批次仍有任务执行中或排队中,请等待完成后删除")
conn.execute(
"DELETE FROM automation_jobs WHERE batch_id=? AND status='pending'",
(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):
with self.connection() as conn:
if session_id:
@@ -16,7 +16,7 @@ _write_lock = threading.Lock()
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)
api_key: SecretStr | None = None # Omitted/blank keeps the existing key.
timeout_seconds: float = Field(default=300, gt=0, le=3600)
@@ -41,7 +41,7 @@ class ModelSettingsUpdate(BaseModel):
):
raise ValueError("请输入有效的 HTTP(S) 接口根地址,不要包含密钥、查询参数或片段")
if value.endswith(("/responses", "/chat/completions")):
raise ValueError("请输入接口根地址,例如 https://api.openai.com/v1")
raise ValueError("请输入接口根地址,例如 https://api-n-cd.playones.com/v1")
return value
@field_validator("model")
@@ -57,7 +57,7 @@ def get_model_settings() -> dict:
return {
"provider": "openai",
"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,
"api_key_configured": bool(settings.OPENAI_API_KEY),
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
@@ -129,6 +129,7 @@ def persist_environment(values: dict[str, str]) -> None:
class CapabilitySettings(BaseModel):
video_enabled: bool = False
recording_enabled: bool = False
outputter_enabled: bool = True
step_summarizer_enabled: bool = True
@@ -136,6 +137,7 @@ class CapabilitySettings(BaseModel):
def get_capability_settings() -> CapabilitySettings:
return CapabilitySettings(
video_enabled=settings.ARTEMIS_VIDEO_ENABLED,
recording_enabled=settings.ARTEMIS_RECORDING_ENABLED,
outputter_enabled=settings.ARTEMIS_OUTPUTTER_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:
values = {
"ARTEMIS_VIDEO_ENABLED": update.video_enabled,
"ARTEMIS_RECORDING_ENABLED": update.recording_enabled,
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
}
+440 -72
View File
@@ -2,7 +2,6 @@
from datetime import datetime
from typing import cast
from docx.styles.style import ParagraphStyle
import json
from pathlib import Path
import re
@@ -10,10 +9,14 @@ import threading
import zipfile
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.shared import Cm, Pt, RGBColor
from docx.styles.style import ParagraphStyle
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 apps.admin_console.database.repositories.session_repository import SessionRepository
@@ -32,6 +35,8 @@ STATUS = {
"pending": "待执行",
"queued": "已排队",
"running": "执行中",
"task_paused": "等待指导",
"resuming": "等待设备恢复",
}
@@ -58,6 +63,42 @@ def verdict(outcome: dict | None) -> str:
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 ""
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))
return "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
def session_report(session_id: str, db_path=None) -> dict:
identifier(session_id)
meta = store.job(session_id) or {}
@@ -87,32 +128,44 @@ def session_report(session_id: str, db_path=None) -> dict:
if not outcome:
outcome = {"task_status": args.get("status"), "tests": {}}
break
if not content:
content = "本任务没有保存最终结论。以下内容仅来自已记录的执行状态和证据。"
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 {
"id": session_id,
"name": case.get("name") or session.get("initial_goal", session_id)[:120],
"goal": case.get("goal") or session.get("initial_goal", ""),
"expected": case.get("expected", ""),
"status": meta.get("terminal_status")
or meta.get("status")
or session.get("status", "unknown"),
"verdict": verdict(outcome),
"status": status,
"verdict": verification,
"outcome": outcome,
"content": content,
"steps": steps,
"start": session.get("start_time") or meta.get("started_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 ""),
"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:
return (
datetime.fromtimestamp(float(value)).astimezone().isoformat(timespec="seconds")
datetime.fromtimestamp(float(value)).astimezone().strftime("%Y-%m-%d %H:%M:%S")
if value
else "—"
)
@@ -121,6 +174,11 @@ def time_text(value) -> str:
def metadata(report: dict) -> list[tuple]:
return [
("任务编号", 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["expected"] or "未指定"),
("执行状态", STATUS.get(report["status"], report["status"])),
@@ -132,6 +190,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:
if not name:
return None
@@ -149,10 +217,14 @@ def image_path(name: str | None) -> Path | None:
def markdown_blocks(content: str):
lines = content.splitlines()
index = 0
in_code = False
while index < len(lines):
line = lines[index]
if line.strip().startswith("```"):
in_code = not in_code
if (
"|" in line
not in_code
and "|" in line
and index + 1 < len(lines)
and re.fullmatch(r"[\s|:\-]+", lines[index + 1])
and "-" in lines[index + 1]
@@ -168,81 +240,296 @@ def markdown_blocks(content: str):
index += 1
def word_report(report: dict, path: Path):
doc = Document()
style = cast(ParagraphStyle, doc.styles["Normal"])
style.font.name = "Microsoft YaHei"
style.font.size = Pt(10)
style.element.rPr.rFonts.set(qn("w:eastAsia"), "微软雅黑")
doc.add_heading(report["name"], 0)
table = doc.add_table(rows=0, cols=2)
table.columns[0].width = Inches(1.3)
table.columns[1].width = Inches(5.2)
table.style = "Light Shading Accent 1"
for label, value in metadata(report):
def word_styles(doc, *, landscape: bool = False):
section = doc.sections[0]
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.size = Pt(size)
style.font.color.rgb = RGBColor.from_string(color)
style.element.get_or_add_rPr().get_or_add_rFonts().set(qn("w:eastAsia"), "微软雅黑")
style.paragraph_format.space_after = Pt(7 if name == "Normal" else 9)
if name == "Normal":
style.paragraph_format.line_spacing = 1.3
if name == "Title":
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[0].text, cells[1].text = label, text(value)
cells[0].width, cells[1].width = Inches(1.3), Inches(5.2)
doc.add_heading("结论与任务专属结果", 1)
for kind, value in markdown_blocks(text(report["content"])):
if header and index == 0:
table.rows[-1]._tr.get_or_add_trPr().append(OxmlElement("w:tblHeader"))
for position, cell in enumerate(cells):
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":
table = doc.add_table(rows=0, cols=max(map(len, value)))
table.style = "Light Shading Accent 1"
for row in value:
cells = table.add_row().cells
for cell, item in zip(cells, row):
cell.text = item
elif isinstance(value, str) and value.startswith("#"):
doc.add_heading(value.lstrip("# "), min(len(value) - len(value.lstrip("#")), 3))
columns = max(map(len, value))
if columns:
word_table(doc, value, tuple(16 / columns for _ in range(columns)), header=True)
continue
line = str(value)
if line.strip().startswith("```"):
in_code = not in_code
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:
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)
for step in report["steps"]:
doc.add_paragraph(
f"步骤 {step['step_number']} · {time_text(step.get('timestamp'))} · {step.get('summary') or '已记录操作'}"
)
evidence = report["steps"][:1] + report["steps"][-3:]
seen = set()
paragraph = doc.add_paragraph()
paragraph.paragraph_format.keep_together = True
paragraph.add_run(
f"步骤 {step['step_number']} · {time_text(step.get('timestamp'))}\n"
).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:
name = step.get("post_image_name") or step.get("pre_image_name")
photo = image_path(name)
if photo and name not in seen:
seen.add(name)
doc.add_paragraph(f"证据:步骤 {step['step_number']} / {name}")
doc.add_picture(str(photo), width=Inches(2.6))
images.append((photo, step["step_number"]))
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.save(str(path))
def sheet_rows(ws, rows):
for row in rows:
def sheet_rows(ws, rows, widths: tuple[int, ...] = ()):
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])
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.
cell.data_type = "s"
cell.alignment = Alignment(wrap_text=True, vertical="top")
ws.freeze_panes = "A2"
if ws.max_row:
cell.alignment = Alignment(wrap_text=True, vertical="center" if index == 1 else "top")
cell.font = Font(
name="微软雅黑",
size=10,
bold=index == 1,
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")
elif index % 2 == 1 or column == 1:
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
for cell in ws[1]:
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill("solid", fgColor="245C78")
for column in ws.columns:
ws.column_dimensions[column[0].column_letter].width = 32
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):
wb = Workbook()
assert wb.active is not None
wb.active.title = "任务结果"
sheet_rows(wb.active, [("项目", "内容"), *metadata(report)])
sheet_rows(
wb.create_sheet("结论"),
[("最终结论",), *[(line,) for line in report["content"].splitlines()]],
)
for number, (kind, rows) in enumerate(markdown_blocks(report["content"])):
sheet_rows(wb.active, [("项目", "内容"), *metadata(report)], (22, 82))
sheet_rows(wb.create_sheet("结论"), conclusion_rows(report["content"]), (15, 96))
table_number = 0
for kind, rows in markdown_blocks(report["content"]):
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(
wb.create_sheet("执行证据"),
[
@@ -252,12 +539,15 @@ def excel_report(report: dict, path: Path):
s["step_number"],
time_text(s.get("timestamp")),
s.get("summary"),
text(s.get("last_execution_result")),
s.get("post_image_name") or s.get("pre_image_name"),
execution_result_label(s.get("last_execution_result")),
"见步骤截图资料包"
if s.get("post_image_name") or s.get("pre_image_name")
else "无截图",
)
for s in report["steps"]
],
],
(10, 22, 68, 26, 24),
)
wb.save(path)
@@ -279,6 +569,88 @@ def export_session(session_id: str, formats: list[str] | None = None) -> list[Pa
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": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
}
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, path.name)
for source, name in images:
archive.write(source, f"步骤截图/{name}")
for source, name in videos:
archive.write(source, f"任务录屏/{name}")
archive.writestr("文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2))
temporary.replace(target)
return target
def export_batch(batch_id: str) -> Path:
with EXPORT_LOCK:
batch = store.get("batch", identifier(batch_id))
@@ -298,21 +670,17 @@ def export_batch(batch_id: str) -> Path:
wb = Workbook()
assert wb.active is not None
wb.active.title = "批次结果"
sheet_rows(wb.active, rows)
sheet_rows(wb.active, rows, (40, 48, 16, 28, 70))
path = directory / "批次结果.xlsx"
wb.save(path)
outputs.append(path)
if "docx" in formats:
doc = Document()
doc.add_heading(batch["name"], 0)
doc = word_styles(Document(), landscape=True)
doc.add_paragraph(batch["name"], style="Title")
doc.add_paragraph(
f"批次编号:{batch_id};共 {len(records)} 个任务。验证通过与执行完成分别统计。"
)
table = doc.add_table(rows=0, cols=5)
table.style = "Light Shading Accent 1"
for row in rows:
for cell, value in zip(table.add_row().cells, row):
cell.text = str(value)
word_table(doc, rows, (3.5, 5.4, 2.5, 3.5, 7.3), header=True)
path = directory / "批次汇总.docx"
doc.save(str(path))
outputs.append(path)
+100 -48
View File
@@ -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
import json
@@ -6,33 +6,53 @@ import os
import zipfile
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
from openpyxl.styles import Font, PatternFill, Alignment
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
HEADERS = {
"任务名称": "name",
"任务目标": "goal",
"预期结果": "expected",
"目标应用": "app",
"前置条件": "preconditions",
"输入参数(JSON)": "parameters",
"标签": "tags",
}
HEADERS = (
"用例编号",
"检测项目",
"测试子项",
"测试用例",
"前置约束提取",
"Artemis提示词",
"任务配置(JSON)",
)
MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000"))
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):
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)
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)
parameters: dict[str, str | int | float | bool] = Field(default_factory=dict)
tags: str = Field(default="", max_length=500)
goal: str = Field(min_length=1, max_length=10000)
task_config: TaskOptions = Field(default_factory=TaskOptions)
def template() -> bytes:
@@ -40,27 +60,54 @@ def template() -> bytes:
ws = wb.active
assert ws is not None
ws.title = "任务"
ws.append(list(HEADERS))
ws.append(HEADERS)
ws.freeze_panes = "A2"
ws.auto_filter.ref = "A1:G1"
for cell in ws[1]:
cell.font = Font(bold=True, color="FFFFFF")
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("填写说明")
for line in [
"模板版本:1",
"每行一个独立任务;任务名称、任务目标必填。",
"预期结果决定要验证的业务条件。未填写时只报告任务完成情况。",
"前置条件描述实际用例起点;不会统一重启或清空应用数据。",
'输入参数使用 JSON 对象,例如 {"关键词":"天气"};目标可引用 {{关键词}}。',
"目标应用可填名称或包名;空白时由任务决定。",
"不要填写 Excel 公式、宏或外部链接。导入仅预览,点击开始后执行。",
]:
help_sheet.append([line])
help_sheet.column_dimensions["A"].width = 105
help_sheet.append(
["模板版本:2。任务表每行一项,Artemis提示词必填;只把这一列作为 AI 任务正文。"]
)
help_sheet.append(["前置约束提取及其他描述列仅作查看、搜索和报告元数据,不追加到提示词。"])
help_sheet.append(["任务配置(JSON)可留空,空字段继承批次或全局配置;禁止密钥与未知字段。"])
help_sheet.append(["示例仅供参考,不会导入执行。请在“任务”页填写自己的用例。"])
help_sheet.append(HEADERS)
help_sheet.append(
[
"示例-001",
"示例模块",
"示例检查",
"检查示例页面",
"设备已经打开目标应用",
"观察当前页面并报告实际结果,不要假设检查已通过。",
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:
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()
wb.save(stream)
return stream.getvalue()
@@ -90,7 +137,7 @@ def parse_workbook(data: bytes) -> dict:
rows = iter(wb["任务"].iter_rows())
headers = [str(cell.value or "").strip() for cell in next(rows, [])]
if headers != list(HEADERS):
raise ValueError("列名或顺序与模板不一致,请重新下载模板")
raise ValueError("列名或顺序与新版模板不一致,请重新下载模板")
tasks, errors = [], []
for number, row in enumerate(rows, 2):
if number > MAX_ROWS + 1:
@@ -100,28 +147,33 @@ def parse_workbook(data: bytes) -> dict:
try:
if any(cell.data_type == "f" for cell in row):
raise ValueError("不允许公式,请粘贴为纯文本")
values = [str(cell.value or "").strip() for cell in row[: len(HEADERS)]]
item = dict(zip(HEADERS.values(), values))
item["parameters"] = json.loads(item.get("parameters") or "{}")
case = TaskCase.model_validate(item)
values = [
str(cell.value if cell.value is not None else "").strip() for cell in row[:7]
]
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()})
except (ValueError, TypeError) as exc:
errors.append({"row": number, "message": str(exc)[:500]})
if not tasks and not errors:
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:
wb.close()
def case_goal(case: dict) -> str:
goal = case["goal"]
for key, value in case.get("parameters", {}).items():
goal = goal.replace("{{" + key + "}}", str(value))
return (
f"任务名称:{case['name']}\n任务目标:{goal}\n目标应用:{case.get('app') or '按目标判断'}\n"
f"前置条件:{case.get('preconditions') or '根据实际用例判断合适起点'}\n"
"先观察手机并恢复到本用例需要的起始位置,记录准备过程。不要固定重启或清空数据。"
"如果无法建立前置条件,明确报告前置条件未满足,不继续正文操作。\n"
f"预期结果:{case.get('expected') or '仅确认任务完成情况,不宣称测试通过'}"
)
"""Execution gets the prompt verbatim; metadata stays outside model input."""
return case["goal"]
@@ -302,7 +302,7 @@ class TaskQueueService:
normalized = current_status.lower().strip() if isinstance(current_status, str) else None
if normalized == "success":
return "completed", True
if normalized in {"completed", "failed", "cancelled"}:
if normalized in {"completed", "failed", "cancelled", "interrupted"}:
return normalized, False
return ("completed" if returncode == 0 else "failed"), True
@@ -388,10 +388,9 @@ class TaskQueueService:
@classmethod
def _dispatch_pending_tasks(cls) -> None:
"""Launch every pending task admissible under the current concurrency limit."""
cls._sync_control_states()
limit = cls._concurrency_limit()
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
# "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
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:
return
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
# only launches on an otherwise idle scheduler; the device lock
# 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
if device is not None and target.lock_key in busy_devices:
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.
continue
@@ -454,6 +459,42 @@ class TaskQueueService:
if capacity <= 0:
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
def _begin_task_run(
cls,
@@ -531,6 +572,22 @@ class TaskQueueService:
env["ARTEMIS_SESSION_ID"] = str(sess_id)
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
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)
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
queue_ticket = task_item.get("queue_ticket")
@@ -664,6 +721,12 @@ class TaskQueueService:
returncode,
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 session_repo.update_session_status(sess_id, new_status, time.time()):
print(f"[QueueWorker] Updated session {sess_id} status to '{new_status}'")
@@ -1069,6 +1132,7 @@ class TaskQueueService:
conversation_id: str | None = None,
verification_level: str | None = None,
explorer_mode: str | None = None,
runtime_overrides: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Enqueues one or more goals and wakes up the background worker.
@@ -1123,6 +1187,8 @@ class TaskQueueService:
verification_level=verification_level,
explorer_mode=explorer_mode,
)
if runtime_overrides:
task_item["runtime_overrides"] = runtime_overrides
state.queue_items.append(task_item)
enqueued_tasks.append(task_item)
cls._broadcast_startup_progress(
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "artemis-showcase-ui",
"version": "1.0.0",
"version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "artemis-showcase-ui",
"version": "1.0.0",
"version": "1.1.1",
"dependencies": {
"@angular/cdk": "^22.1.5",
"@angular/common": "^22.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "artemis-showcase-ui",
"version": "1.0.0",
"version": "1.1.1",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
@@ -124,7 +124,7 @@ limitations under the License.
{{ session.initial_goal || '未命名任务' }}
</div>
<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>
</div>
</div>
@@ -331,7 +331,7 @@ limitations under the License.
</header>
<!-- 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">
<!-- Active Architecture Chip: click opens the run info popover -->
@if (agentService.viewedModel(); as model) {
@@ -492,6 +492,7 @@ limitations under the License.
</span>
}
</button>
<ng-content select="[task-guidance]"></ng-content>
</div>
}
@@ -568,6 +569,13 @@ limitations under the License.
>
<!-- Unified Step / Stream 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
and how each is judged; the chronological stream follows, and the
result rows close the block (see check-result below). -->
@@ -746,7 +754,7 @@ limitations under the License.
@if (getReportStatusExplanation(item.data); as expText) {
<div class="grid-item full-width">
<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>
@@ -1283,6 +1291,23 @@ limitations under the License.
}
</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) -->
@if (outputterReport(); as outReport) {
<div class="outputter-synthesis-stream-card">
@@ -1921,6 +1921,8 @@ $stream-row-height: 25px;
}
&.task-report-card {
width: min(680px, 100%);
box-sizing: border-box;
background: #f0fdf4;
border: 1px solid #bbf7d0;
@@ -1967,12 +1969,13 @@ $stream-row-height: 25px;
}
.report-markdown-body {
font-size: 12px;
line-height: 1.5;
font-size: 14px;
line-height: 1.7;
color: #1f2937;
font-weight: 400;
user-select: text;
word-break: break-word;
word-break: normal;
overflow-wrap: anywhere;
p {
margin: 3px 0;
@@ -2227,11 +2230,19 @@ $stream-row-height: 25px;
color: #64748b;
}
.grid-value {
font-size: 12px;
font-weight: 600;
color: #1e293b;
word-break: break-all;
.grid-value {
font-size: 12px;
font-weight: 600;
color: #1e293b;
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 {
font-family: 'Roboto Mono', 'Fira Code', Consolas, monospace;
@@ -3971,3 +3982,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 { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
import { UiLabelPipe } from '../../utils/ui-label.pipe';
@@ -22,7 +23,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
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 { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay';
@@ -236,7 +237,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
@Component({
selector: 'app-agent-stream',
standalone: true,
imports: [UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent],
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent],
templateUrl: './agent-stream.component.html',
styleUrl: './agent-stream.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -246,6 +247,11 @@ export class AgentStreamComponent implements AfterViewInit {
private http = inject(HttpClient);
private destroyRef = inject(DestroyRef);
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
public isUserAtBottom = true;
@@ -473,6 +479,23 @@ export class AgentStreamComponent implements AfterViewInit {
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[]>(() => {
const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId());
return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0);
@@ -540,6 +563,17 @@ export class AgentStreamComponent implements AfterViewInit {
}
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
// changes, and keep refreshing while that session is still running.
effect((onCleanup) => {
@@ -1326,6 +1360,11 @@ export class AgentStreamComponent implements AfterViewInit {
return isAndroidAction(action);
}
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 {
return getActionIcon(action);
}
@@ -1812,6 +1851,7 @@ export class AgentStreamComponent implements AfterViewInit {
public hasVisibleContent(block: any): boolean {
if (!block) return false;
if (block.type === 'checker') return true;
if (block.data?.extra_metadata?.injected_instruction) return true;
const hasNative = Boolean(this.getNativeThinking(block));
const hasRaw = Boolean(this.getRawThinking(block));
const hasReset = this.hasBlockResets(block, false) || this.hasBlockResets(block, true);
@@ -12,23 +12,44 @@
</section>
<section><h3>当前任务报告</h3>
@if (report(); as r) {
<strong>{{r.name}}</strong><p>{{label(r.status)}} · {{r.verdict}}</p>
<div class="actions"><a [href]="'/api/automation/reports/'+r.id+'/docx'">下载 Word</a><a [href]="'/api/automation/reports/'+r.id+'/xlsx'">下载 Excel</a></div>
<div class="report-overview">
<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>
<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> }
@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>
}
@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>
}
@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>
@if(preview(); as 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>}
<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>
<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>
@@ -37,17 +58,22 @@
}
</section>
<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>}
</section>
@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 === '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>}
<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>
@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')">{{j.position}}. {{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
</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;}
.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;}
.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;}
.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;}
.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;}
}
@@ -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 { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { AgentService } from '../../services/agent.service';
import { SystemService } from '../../services/system.service';
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
@Component({
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);
batches = signal<any[]>([]); preview = 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;
private destroyed = false;
constructor() {
@@ -24,12 +33,13 @@ export class AutomationPanelComponent {
const timer = setInterval(() => void this.refresh(), 5000);
destroy.onDestroy(() => { this.destroyed = true; clearInterval(timer); });
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: () => {} });
});
}
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:'正在生成报告'} as Record<string,string>)[s] || s; }
renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
async action(fn: () => Promise<void>) {
if (this.busy()) return;
this.busy.set(true); this.error.set(''); this.notice.set('');
@@ -62,5 +72,28 @@ export class AutomationPanelComponent {
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}`))); }); }
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.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(); }); }
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 || '未命名任务' }}
</div>
<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>
</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 { TaskHistoryComponent } from '../task-history/task-history.component';
import { UiLabelPipe } from '../../utils/ui-label.pipe';
@@ -30,7 +31,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
@Component({
selector: 'app-chat-interface',
standalone: true,
imports: [UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent],
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent],
templateUrl: './chat-interface.component.html',
styleUrl: './chat-interface.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -5,6 +5,7 @@ import { finalize } from 'rxjs';
interface Capabilities {
video_enabled: boolean;
recording_enabled: boolean;
outputter_enabled: boolean;
step_summarizer_enabled: boolean;
}
@@ -16,7 +17,9 @@ interface Capabilities {
<h3>功能开关</h3>
<p>按需启用,保存后对新任务生效。</p>
<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()" />
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
<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 {
changed = output<Capabilities>();
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('');
ngOnInit(): void {
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');
});
it('保存关闭视频分析的配置,重新打开时恢复关闭状态', () => {
const capabilities = { video_enabled: true, outputter_enabled: true, step_summarizer_enabled: false };
it('录屏与视频分析独立保存,重新打开后保留各自状态', () => {
const capabilities = { video_enabled: true, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: false };
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
fixture.detectChanges();
http.expectOne('/api/system/capability-settings').flush(capabilities);
fixture.componentInstance.config.video_enabled = false;
fixture.componentInstance.config.recording_enabled = true;
fixture.componentInstance.save();
const request = http.expectOne('/api/system/capability-settings');
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);
fixture.destroy();
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
reopened.detectChanges();
http.expectOne('/api/system/capability-settings').flush(saved);
expect(reopened.componentInstance.config.video_enabled).toBeFalse();
expect(reopened.componentInstance.config.recording_enabled).toBeTrue();
});
});
@@ -24,7 +24,7 @@ interface ModelSettings {
<p>统一使用 OpenAI Responses。保存后新任务立即生效,重启后自动加载。</p>
<label for="model-base-url">接口地址</label>
<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>
<label for="model-name">模型名称</label>
<div class="model-presets" role="group" aria-label="预设模型">
@@ -1,4 +1,5 @@
import { signal } from '@angular/core';
import { Clipboard } from '@angular/cdk/clipboard';
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
@@ -14,6 +15,25 @@ describe('历史任务检索', () => {
});
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('迟到的旧查询不会覆盖最新搜索结果', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
const first = http.expectOne(r => r.url === '/api/automation/history');
@@ -1,3 +1,4 @@
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
import { Component, DestroyRef, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
@@ -5,19 +6,19 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { AgentService } from '../../services/agent.service';
@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>
<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>
@if(error()){<p role="alert">{{error()}}</p>}
@if(loading()){<small>正在查询…</small>}
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
@for(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"><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></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>}
@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>
</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: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;border:1px solid #64748b55;border-radius:6px;margin:5px 0;padding:8px}.history-item:has(.row.selected){border-color:#60a5fa;background:#3b82f615}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.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}`]
})
export class TaskHistoryComponent {
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
@@ -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);
}
}
@@ -17,7 +17,43 @@ limitations under the License.
<div class="workspace-container" [class.is-resizing]="isDragging()">
<!-- Left Main Panel: Raw Agent Log Stream -->
<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 -->
<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) */
/* ========================================================================= */
@@ -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 { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
import { AgentService } from '../../services/agent.service';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'app-workspace',
@@ -37,6 +39,7 @@ import { AgentService } from '../../services/agent.service';
})
export class WorkspaceComponent implements OnInit {
public agentService = inject(AgentService);
private http = inject(HttpClient);
private zone = inject(NgZone);
private destroyRef = inject(DestroyRef);
@@ -56,6 +59,33 @@ export class WorkspaceComponent implements OnInit {
public isSubmitting = signal<boolean>(false);
public errorMessage = signal<string | null>(null);
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)
public isHoveringCard = signal<boolean>(false);
@@ -103,6 +133,69 @@ export class WorkspaceComponent implements OnInit {
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
*/
@@ -54,6 +54,15 @@ export interface StartupProgressEvent {
timestamp: number;
}
export interface GuidanceEvent {
id: string;
instruction: string;
source: string;
timestamp: number;
status: 'pending' | 'consumed';
consumed_at?: number;
}
interface SessionVideoResponse {
session_id: string;
status?: 'processing' | 'ready' | 'failed' | 'unavailable';
@@ -103,7 +112,9 @@ export class AgentService {
let sStatus = s.status;
if (!isTerminal) {
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 {
sStatus = (s.status === 'success') ? 'completed' : s.status;
}
@@ -138,7 +149,7 @@ export class AgentService {
|| !!activeMatch;
const finalPending: Session = {
...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
};
sessionMap.set(p.session_id, finalPending);
@@ -156,7 +167,7 @@ export class AgentService {
session_id: sid,
initial_goal: at.goal || goal || '',
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,
device_serial: at.device_id || null
};
@@ -199,6 +210,8 @@ export class AgentService {
public currentSessionId = signal<string | null>(null);
public sessionLogs = signal<any[]>([]); // Dynamic array of all raw events received
public guidanceEvents = signal<GuidanceEvent[]>([]);
private guidanceSignature = '';
public isSessionContentLoading = signal<boolean>(false);
public agentStatus = signal<string>('idle'); // Status of the agent runner process
public runningSessionId = signal<string | null>(null);
@@ -723,6 +736,8 @@ export class AgentService {
this.isSessionContentLoading.set(false);
this.currentSessionId.set(null);
this.sessionLogs.set([]);
this.guidanceEvents.set([]);
this.guidanceSignature = '';
this.currentNotes.set({});
return;
}
@@ -738,6 +753,9 @@ export class AgentService {
this.isSessionContentLoading.set(true);
this.currentSessionId.set(sessionId);
this.sessionLogs.set([]); // Reset logs for new session selection
this.guidanceEvents.set([]);
this.guidanceSignature = '';
this.fetchGuidance(sessionId);
this.activePauseCardKey = null;
this.isPaused.set(false);
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 {
try {
const cached = localStorage.getItem(SESSION_CACHE_KEY);
@@ -1486,6 +1519,8 @@ export class AgentService {
// 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) {
this.fetchSessions();
const selected = this.currentSessionId();
if (selected) this.fetchGuidance(selected);
}
}, 2000);
});
@@ -67,4 +67,12 @@ describe('markdown-parser.util verification & check lines', () => {
expect(html).toContain('<span class="verify-badge">verify</span>');
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) {
// End code block
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;
codeBlockLines = [];
codeBlockLang = '';
@@ -290,6 +290,26 @@ export function renderMarkdownToHtml(text: string): string {
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 ######
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
@@ -375,7 +395,7 @@ export function renderMarkdownToHtml(text: string): string {
if (inCodeBlock) {
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();
+4 -1
View File
@@ -37,6 +37,7 @@ from artemis.agents.explorer.tiers import SUBMIT_TOOL
from artemis.agents.explorer.tool_declarations import UNIVERSAL_EXPLORER_TOOLS
from artemis.graph.state import State
from artemis.services.llm import get_llm
from artemis.config.settings import settings
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@@ -120,7 +121,9 @@ class UniversalRunnerMixin:
else:
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)
tool_calls = getattr(response, "tool_calls", None) or []
+22 -1
View File
@@ -37,7 +37,6 @@ active window until its turn is chunked, whose user lines then carry it on.
"""
import asyncio
from artemis.config.settings import settings
import base64
from dataclasses import dataclass, field
import json
@@ -45,6 +44,9 @@ from pathlib import Path
import re
import uuid
from artemis.config.settings import settings
from artemis.runtime.task_control import TaskControl
from jinja2 import Template
from langchain_core.messages import (
BaseMessage,
@@ -1060,6 +1062,25 @@ class FlashRunner:
pending_notices: list[str] = []
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
logger.info(f"--- Artemis Flash Turn {turns}{f'/{limit}' if limit else ''} ---")
+15 -1
View File
@@ -18,7 +18,7 @@ from pathlib import Path
from typing import Annotated, Any
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from langchain_core.tools import BaseTool, StructuredTool
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, ValidationError
class _CyFunctionDetectorMeta(type):
@@ -89,6 +89,20 @@ class LogAnalyzerNode:
original_coroutine = tool.coroutine
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":
# Intercept read_logs to cache raw logs
lines = kwargs.get("lines", 200)
+103 -5
View File
@@ -13,14 +13,17 @@
# limitations under the License.
import json
from collections.abc import Awaitable
from typing import Any
from uuid import uuid4
from langchain_core.messages import SystemMessage, ToolMessage
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.core.tool_failure import is_tool_failure
from artemis.data_engine.trace import (
TraceSpan,
trace,
@@ -30,6 +33,7 @@ from artemis.graph.state import State
from artemis.graph.visibility import strict_state
from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool
from artemis.llm.usage import usage_from_message
from artemis.runtime.task_control import TaskControl
from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message
from artemis.tools.command_tool import (
analyze_task_output_wrapper,
@@ -57,6 +61,11 @@ from artemis.utils.visualization import format_minimal_list_with_elements
logger = get_logger(__name__)
class OperatorNoProgressError(RuntimeError):
"""The model repeatedly returned no usable tool call; stop without device actions."""
DEFERRING_TOOLS = {
"ask_diagnoser",
"video_analyzer",
@@ -71,6 +80,8 @@ DEFERRING_TOOLS = {
"ask_explorer",
}
INTERRUPTIBLE_HELPERS = {"ask_diagnoser", "ask_explorer", "video_analyzer", "analyze_task_output"}
# Bare key names press_key accepts (case-insensitive, optional KEYCODE_ prefix).
SUPPORTED_PRESS_KEYS = ("ENTER", "BACK", "HOME", "APP_SWITCH")
@@ -471,6 +482,16 @@ class OperatorNode:
except Exception as 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(
self,
base_llm,
@@ -485,8 +506,17 @@ class OperatorNode:
native_thoughts = []
tool_limit_exceeded = 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):
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:
logger.warning(
"Operator has reached the maximum number of tool calls. Adding reminder."
@@ -503,7 +533,12 @@ class OperatorNode:
)
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
usage = usage_from_message(response)
# The ledger's compaction thresholds read the operator's own
@@ -568,8 +603,27 @@ class OperatorNode:
logger.error(f"Failed to parse function_call fallback: {e}")
if not response.tool_calls:
logger.warning("LLM stopped without calling any tool. Encouraging action.")
break
no_progress_attempts += 1
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)
@@ -627,12 +681,42 @@ class OperatorNode:
"\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,
args=args,
tool_call_id=tc["id"],
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)
# Failure is structural (a ToolMessage status or a
@@ -840,9 +924,20 @@ class OperatorNode:
break
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.")
continue
no_progress_attempts = 0
else:
if no_progress_attempts:
raise OperatorNoProgressError(
"模型在本轮工具调用预算内未恢复有效调用,已停止任务以避免空转。"
)
tool_limit_exceeded = True
raw_thinking = "\n".join(raw_thoughts) if raw_thoughts else None
@@ -1122,6 +1217,9 @@ class OperatorNode:
straight from the indexed list); a coordinate pair carries only the
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)):
try:
target_int = int(target)
+4 -1
View File
@@ -95,7 +95,7 @@ class Settings(BaseSettings):
API_KEY: SecretStr | None = None
# 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_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
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_SETTLEMENT_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0)
ARTEMIS_VIDEO_ENABLED: bool = False
ARTEMIS_RECORDING_ENABLED: bool = False
ARTEMIS_OUTPUTTER_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
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
+2
View File
@@ -46,6 +46,7 @@ except ImportError:
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
from artemis.utils.video import detect_video_tools_enabled
from artemis.config.settings import settings
from artemis.config import ExplorerConfig, LLMConfig, OutputterConfig
@@ -108,6 +109,7 @@ class ExecutionSetup(BaseModel):
enable_remote_tracing: bool = False
app_lock_status: AppLaunchResult | None = None
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
"""Legacy master switch (compat alias): ``True`` disables BOTH the midway
checkpoints and the final check, regardless of the individual gates below.
+14
View File
@@ -372,6 +372,20 @@ class StorageManager:
)
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):
"""Update video filepath across session and video_recordings tables."""
with self._get_connection() as conn:
+10 -12
View File
@@ -23,6 +23,7 @@ from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.data_engine.trace import trace
from artemis.graph.state import 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.ocr_api import is_ocr_configured, perform_ocr
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
`release_loop` stop signal (bool) declared by the injecting caller.
"""
instruction_path = Path(base_dir) / "injected_instruction.json"
if instruction_path.exists():
with open(instruction_path, encoding="utf-8") as f:
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
from artemis.runtime.task_control import consume_instructions
return consume_instructions(base_dir)
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")
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:
ctx.data_engine.allocate_step_id()
+202
View File
@@ -0,0 +1,202 @@
"""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 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]:
"""Park a task during a long model/helper call, then discard its stale result.
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 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)
+68 -10
View File
@@ -540,6 +540,7 @@ class Agent:
last_state: State | None = None
last_state_snapshot: dict | None = None
output = None
task_control = None
effective_mode = getattr(self._config, "concurrency_mode", "per_device")
effective_max = getattr(self._config, "max_concurrency", None)
sess_id = (
@@ -599,6 +600,11 @@ class Agent:
# publish a new active session while the current task is still
# finishing.
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)
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
if self._ui_adb_client is not None:
@@ -612,16 +618,46 @@ class Agent:
await self._prepare_app_lock(task=task, context=context)
async with context:
recording_started = False
if self._config.video_recording_tools_enabled:
recording_parts = 1
controller = get_controller(context)
output_dir = None
if context.execution_setup and context.execution_setup.traces_path:
output_dir = (
self._tmp_traces_dir / context.execution_setup.trace_name
).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:
controller = get_controller(context)
output_dir = None
if context.execution_setup and context.execution_setup.traces_path:
output_dir = (
self._tmp_traces_dir / context.execution_setup.trace_name
).resolve()
logger.info(f"[{task_name}] Starting automated screen recording...")
start_res = await controller.start_video_recording(
output_dir=output_dir
@@ -840,6 +876,25 @@ class Agent:
except Exception as e:
logger.warning(f"[{task_name}] Failed to stop background ADB tasks: {e}")
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)
finally:
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
@@ -847,7 +902,9 @@ class Agent:
if self._ui_adb_client is not None:
await asyncio.to_thread(self._ui_adb_client.disconnect)
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)
async with self._task_lock:
@@ -1074,6 +1131,7 @@ class Agent:
if task.request.record_trace
else False,
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_midway_checks=self._config.disable_midway_checks,
disable_final_check=self._config.disable_final_check,
@@ -609,6 +609,7 @@ class AgentConfigBuilder:
servers=self._servers,
graph_config_callbacks=self._graph_config_callbacks,
video_recording_tools_enabled=self._video_recording_tools_enabled,
record_video_enabled=settings.ARTEMIS_RECORDING_ENABLED,
force_web_accessibility=self._force_web_accessibility,
disable_checker=self._disable_checker,
disable_midway_checks=self._disable_midway_checks,
+2
View File
@@ -29,6 +29,7 @@ from artemis.config import (
from artemis.context import DevicePlatform
from artemis.sdk.types.task import AgentProfile, TaskRequestCommon
from artemis.utils.video import detect_video_tools_enabled
from artemis.config.settings import settings
from pydantic import BaseModel, Field
@@ -113,6 +114,7 @@ class AgentConfig(BaseModel):
servers: ServerConfig
graph_config_callbacks: Callbacks = None
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
disable_checker: bool = False
disable_midway_checks: bool = True
+3 -3
View File
@@ -115,9 +115,9 @@ async def validate_api_key(
return False, f"Vision OCR API verification failed ({resp.status_code}): {err_msg}"
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(
f"{base}/responses",
headers={"Authorization": f"Bearer {clean_key}"},
+2 -1
View File
@@ -6,7 +6,8 @@
"python_download_mirror": "https://mirrors.nju.edu.cn/github-release/astral-sh/python-build-standalone",
"npm_registry": "https://registry.npmmirror.com",
"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": {
"python_index": "",
+5 -5
View File
@@ -22,17 +22,17 @@
| `mobile_workspace` | 设备列表与统一队列状态 |
| `mobile_config` | 读取或明确保存非敏感模型、功能、报告默认设置 |
| `mobile_run_task` | 单任务执行;`model=flash/pro` 表示执行模式 |
| `mobile_manage_task` | 状态、指导和停止单任务 |
| `mobile_manage_task` | 单任务状态、安全暂停、排队恢复、指导提交/领取记录和停止 |
| `mobile_get_device_state` | 设备截图、界面层级 |
| `mobile_task_file` | XLSX 模板与导入校验 |
| `mobile_batch` | JSON 用例建批次、开始、暂停、继续、复制失败项 |
| `mobile_batch` | JSON 用例建批次、开始、暂停、继续、复制失败项、删除非活跃批次 |
| `mobile_search_history` | 全库搜索、日期/状态筛选和分页 |
| `mobile_inspect_trace` | 只读步骤、截图和执行证据 |
| `mobile_export_report` | Word、Excel 和批次 ZIP,返回本地绝对路径 |
| `mobile_export_report` | Word、Excel、步骤截图、录屏片段、单任务资料及批次 ZIP,返回本地绝对路径 |
新增工具通过统一后台服务运行,与网页共享配置、队列、历史和报告;不另外启动独立调度器。`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` 和构建,成功后才记录指纹。国内镜像选项继续生效。检测到正在执行/排队的任务时,自动更新启动会停止,让原任务继续。
@@ -42,6 +42,6 @@ MCP 自动刷新只处理已存在且指向当前项目的标准 Python 模块
报告导出到 `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` 单独恢复。
保存全局配置要求用户明确指令;只读配置不包含密钥,MCP 不接收密钥。密钥仍在网页或 `.env` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。
+6 -6
View File
@@ -4,7 +4,7 @@
## 导出结论报告
选择已有任务,在“结论报告”中下载 Word 或 Excel。默认同时生成两种格式;勾选所需格式并保存,后续提交使用新的默认值。
选择已有任务,在“报告与质量”中下载 Word、Excel、按步骤排序的截图 ZIP、录屏分段 ZIP,或一键下载包含上述文件和清单的资料 ZIP。只有启用并成功保存录屏的任务才有录屏可下载。默认报告格式为 Word 和 Excel;勾选所需格式并保存,后续提交使用新的默认值。
报告包含目标、预期结果、执行状态、验证结论、实际输出和步骤证据。Word 内嵌关键截图,Excel 包含结果与证据工作表;任务输出中的 Markdown 表格会转换为真正的文档表格和 Excel 工作表。
@@ -15,16 +15,16 @@
## Excel 批量导入
1. 点击“下载 Excel 模板”,保留“任务”工作表及表头顺序。
2. 每行填写一个独立用例。必填“任务名称”“任务目标”;其余列是预期结果、目标应用、前置条件、输入参数(JSON)、标签。
3. 参数示例为 `{"关键词":"天气"}`,在任务目标中使用 `{{关键词}}`。不要填写公式、宏或外部链接。
2. 标准列按顺序为“用例编号”“检测项目”“测试子项”“测试用例”“前置约束提取”“Artemis提示词”“任务配置(JSON)”。任务表留空,示例行在“填写说明”页。
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. 上传文件,查看逐行校验结果。所有错误修正后才能提交。
5. 明确选择设备、执行模式和超时时间,然后点击“确认导入并开始执行”。
同一批次按顺序执行。默认失败后继续且不自动重试,也可以选择失败即停止。设备不可用、模型暂停或任务超时会暂停后续派发。暂停批次让当前任务继续;全局停止会暂停批次并停止当前执行。
同一批次按顺序派发。默认失败后继续且不自动重试,也可以选择失败即停止。设备不可用、模型暂停或任务超时会暂停后续派发。暂停批次只停止后续派发。单个任务可在步骤间安全暂停,释放设备;批次下一项可继续,暂停的任务待提交指导或点击继续后在设备队列末尾恢复。运行中提交指导在下一步执行前生效,不打断当前步骤。暂停等待和重新排队时间不计入该任务执行时限。
服务重启后保留批次,等待用户继续未开始的用例。中断任务不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。
服务重启后保留批次,等待用户继续未开始的用例。正在执行或暂停的任务标记中断,不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。
每个任务结束后生成所选格式报告。批次结束后生成汇总及每项任务报告的 ZIP;失败、跳过和中断任务也会列入。暂停时下载的是当前快照。
每个任务结束后生成所选格式报告。批次结束后生成汇总及每项任务报告的 ZIP;失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。
## AI 使用
+26
View File
@@ -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 模式在计划全部完成后,仍可能反复输出完成说明、调用笔记或观察工具,导致额外收尾耗时。本版的连续无有效调用保护不能消除所有此类循环。
+24
View File
@@ -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` 校验。执行完成仍不等于测试验证通过,检查实际截图、步骤和验证摘要。
+13
View File
@@ -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` 用于校验源码包。
+25 -8
View File
@@ -176,7 +176,9 @@ async def mobile_task_file(
@mcp.tool()
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,
request_id: str | None = None,
batch: dict[str, Any] | None = None,
@@ -184,7 +186,10 @@ async def mobile_batch(
"""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,
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
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
@@ -193,6 +198,8 @@ async def mobile_batch(
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
transport-idempotent, so after an uncertain response use list instead of retrying.
delete removes a non-active batch record after an explicit user request; it keeps
already executed task sessions and reports. Confirm the batch ID with get first.
Restarted batches remain paused. No automatic failed-case retries or action replay.
"""
if action == "create":
@@ -207,6 +214,8 @@ async def mobile_batch(
route = f"/api/automation/batches/{_id(batch_id)}"
if action == "get":
return await _request("GET", route)
if action == "delete":
return await _request("DELETE", route)
if action in {"start", "resume"}:
current = await _request("GET", route)
if current["status"] == "running":
@@ -225,7 +234,7 @@ async def mobile_search_history(
page: int = 1,
size: int = 30,
) -> 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
before calling. Status: completed/success/failed/cancelled/interrupted/skipped.
@@ -243,11 +252,12 @@ async def mobile_search_history(
async def mobile_export_report(
session_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:
"""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;
screenshots/video/all produce ZIPs; video requires recording to have been enabled.
batch ZIP uses its frozen formats and includes summary + individual case reports.
Returns absolute paths, download URLs and sizes; task results also include structured
verdict/evidence. Running exports are snapshots. 'completed' is not proof of PASS;
@@ -269,11 +279,18 @@ async def mobile_export_report(
if formats is not None
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):
raise ValueError("formats 至少选择 docx 或 xlsx,最多两项")
if (
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}")
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"task-{sid}-{fmt}.zip",
)
for fmt in dict.fromkeys(chosen)
]
return {"report": report, "files": files}
+112 -25
View File
@@ -34,6 +34,14 @@ from artemis.runtime import (
trace_store,
)
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__)
@@ -51,6 +59,34 @@ def _find_data_engine_db() -> str | 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:
if not pid:
return False
@@ -143,8 +179,8 @@ def _reconcile_task_state(
liveness_failed = (
current_status == "failed" and status_data.get("error") == _LIVENESS_FAILURE_ERROR
)
if db_status in ("completed", "success", "failed", "cancelled") and (
current_status in ("running", "pending") or liveness_failed
if db_status in ("completed", "success", "failed", "cancelled", "interrupted") and (
current_status in ("running", "pending", "task_paused", "resuming") or liveness_failed
):
canonical = "completed" if db_status in ("completed", "success") else db_status
if current_status != canonical:
@@ -156,7 +192,7 @@ def _reconcile_task_state(
status_data["end_time"] = time.time()
dirty = True
is_alive = False
elif current_status in ("running", "pending"):
elif current_status in ("running", "pending", "task_paused", "resuming"):
if not pid:
# No pid recorded (daemon dispatch never writes one): liveness cannot be
# 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.
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
can also steer a subagent that is stuck or off-track, or abort a task.
### Actions
- **'status'**: Returns `trace_id`, `status` ('running'/'completed'/'failed'/
'cancelled'), `device_serial` (which phone owns this task in multi-device
- **'status'**: Returns `trace_id`, `status` ('running'/'pausing'/'task_paused'/
'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
the run declared verification check items: machine-readable
passed/failed/inconclusive/unchecked counts plus failed-item details —
@@ -239,6 +275,12 @@ def mobile_manage_task(
current turn, latest thought/action — intervene if turns climb without
progress or the thought indicates it is stuck; Pro: the active task plan
— 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
subagent errs, stalls, or loops. Applied at the start of the next
planning turn (Pro) or the next reactive loop (Flash). Requires
@@ -247,6 +289,8 @@ def mobile_manage_task(
`release_loop=True` — this is the only signal that unlocks the
milestone's completion; natural-language "please stop" phrasing in
`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
device interactions and releasing the device. Use when the task is done,
irreparably broken, or running out of control. Prefer
@@ -254,15 +298,16 @@ def mobile_manage_task(
should wind down cleanly instead of being killed.
Args:
action: `"status"`, `"inject_instruction"`, or `"stop"`.
trace_id: The task's session identifier from `mobile_run_task`.
action: `"status"`, `"pause"`, `"resume"`, `"inject_instruction"`,
`"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`
(optional when `release_loop=True`), omit otherwise.
release_loop: With `inject_instruction`: explicit user stop signal
that authorizes the subagent to complete continuous monitoring
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:
return {
"trace_id": trace_id,
@@ -271,6 +316,15 @@ def mobile_manage_task(
}
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)
@@ -368,7 +422,7 @@ def mobile_manage_task(
pass
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] = {}
project_root = env_utils.get_project_root()
@@ -483,8 +537,47 @@ def mobile_manage_task(
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":
if current_status not in ("running", "pending"):
if current_status not in ("running", "pending", "pausing", "task_paused", "resuming"):
return {
"trace_id": trace_id,
"status": current_status,
@@ -579,7 +672,7 @@ def mobile_manage_task(
"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 {
"trace_id": trace_id,
"status": current_status,
@@ -593,19 +686,13 @@ def mobile_manage_task(
"message": f"Trace directory not found: {trace_dir}",
}
instruction_path = os.path.join(trace_dir, "injected_instruction.json")
try:
with open(instruction_path, "w", encoding="utf-8") as f:
json.dump(
{
"instruction": instruction,
"release_loop": bool(release_loop),
"timestamp": time.time(),
"status": "pending",
},
f,
indent=2,
)
add_instruction(trace_id, instruction, release_loop=release_loop, source="mcp")
if (
current_status == "task_paused"
or (control_dir(trace_id) / "pause.request").exists()
):
request_resume(trace_id)
suffix = " (with release_loop stop signal)" if release_loop else ""
return {
"trace_id": trace_id,
@@ -623,5 +710,5 @@ def mobile_manage_task(
return {
"trace_id": trace_id,
"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
View File
@@ -14,7 +14,7 @@
[project]
name = "artemis"
version = "1.0.0"
version = "1.1.1"
description = "Autonomous AI Assistant for Mobile Automation"
authors = [
+27 -21
View File
@@ -1,42 +1,48 @@
# Python bootstrap shared by the Windows launcher and its isolated checks.
# Keep this file ASCII for Windows PowerShell 5.1.
# Python bootstrap shared by the Windows launcher and its isolated checks.
# 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 {
param([Parameter(Mandatory=$true)][string]$ProjectRoot)
$ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot -ErrorAction Stop).Path
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'))) {
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 ($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
} 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')
try {
$installerUrl = if ($ArtemisDownloads) { $ArtemisDownloads.uv_installer_url } else { 'https://astral.sh/uv/install.ps1' }
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
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 {
Remove-Item -LiteralPath $installer -Force -ErrorAction SilentlyContinue
}
}
Update-EnvironmentPath
} 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
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'
$pythonExe = Join-Path $venvDir 'Scripts\python.exe'
@@ -45,18 +51,18 @@ function Initialize-ArtemisPython {
Push-Location -LiteralPath $ProjectRoot
try {
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
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 {
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)) {
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
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.
@@ -64,19 +70,19 @@ function Initialize-ArtemisPython {
$lockOption = if ($ArtemisDownloads -and $ArtemisDownloads.python_index) { '--frozen' } else { '--locked' }
& uv sync --check $lockOption --no-dev --inexact --quiet 2>$null | Out-Host
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 {
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) {
Install-ArtemisLockedDependencies -PythonExe $pythonExe -IndexUrl $ArtemisDownloads.python_index
} else {
& 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
if ($LASTEXITCODE -ne 0) { throw 'Python dependency verification failed. The UI will not be started.' }
Write-Host ' [OK] Python environment is ready.' -ForegroundColor Green
if ($LASTEXITCODE -ne 0) { throw (Get-BootstrapText 'Python dependency verification failed. The UI will not be started.' 'Python 依赖验证失败,网页不会启动。') }
Write-Host (Get-BootstrapText ' [OK] Python environment is ready.' ' [完成] Python 环境已就绪。') -ForegroundColor Green
return $pythonExe
} finally {
Pop-Location
+9 -3
View File
@@ -1,5 +1,5 @@
# Download settings are process-local; never edit global pip/npm/uv settings.
# Keep this file ASCII for Windows PowerShell 5.1.
# Download settings are process-local; never edit global pip/npm/uv settings.
# Save as UTF-8 with BOM so Windows PowerShell 5.1 reads Chinese messages correctly.
function Initialize-ArtemisDownloads {
param([string]$ProjectRoot, [string]$Mode)
@@ -39,8 +39,14 @@ function Initialize-ArtemisDownloads {
$env:npm_config_fetch_timeout = [string]($timeout * 1000)
$values.mode = $Mode
$values.timeout_seconds = $timeout
# Official scrcpy v4.1 win64 release digest. Verify every mirror/override equally.
$values.scrcpy_sha256 = '5b12172b3264b2889f4583ee64752ce832e29bc8b1089dca81093459697165db'
$script:ArtemisDownloads = [pscustomobject]$values
Write-Host " [INFO] Download mode: $Mode (config/downloads.json)." -ForegroundColor Cyan
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
}
}
function Install-ArtemisUvFromIndex {
+111 -11
View File
@@ -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
from datetime import datetime
import hashlib
import os
from pathlib import Path
import subprocess
import tempfile
from urllib.parse import urlsplit
import zipfile
@@ -83,6 +86,9 @@ EXCLUDED_SUFFIXES = {
".swo",
}
GIT_METADATA_FILES = {"HEAD", "config", "index", "shallow", "packed-refs"}
GIT_METADATA_DIRS = {"objects", "refs"}
def excluded(path: Path) -> bool:
parts = tuple(part.lower() for part in path.parts)
@@ -116,22 +122,116 @@ def source_files(root: 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]:
root = root.resolve()
output = output.resolve()
branch, remote_url = release_remote(root)
revision = git(root, "rev-parse", "HEAD")
output.parent.mkdir(parents=True, exist_ok=True)
temporary = output.with_suffix(".zip.tmp")
files = [p for p in source_files(root) if p.resolve() not in {output, temporary}]
try:
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
for path in files:
archive.write(path, Path("artemis") / path.relative_to(root))
with zipfile.ZipFile(temporary) as archive:
bad_file = archive.testzip()
if bad_file:
raise RuntimeError(f"ZIP integrity check failed: {bad_file}")
if any(excluded(Path(name).relative_to("artemis")) for name in archive.namelist()):
raise RuntimeError("An excluded file was found in the source archive")
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:
for path in files:
archive.write(path, Path("artemis") / path.relative_to(checkout))
with zipfile.ZipFile(temporary) as archive:
bad_file = archive.testzip()
if bad_file or set(archive.namelist()) != names:
raise RuntimeError(
f"ZIP integrity check failed: {bad_file or 'file list mismatch'}"
)
temporary.replace(output)
finally:
temporary.unlink(missing_ok=True)
+49 -38
View File
@@ -1,4 +1,4 @@
# Copyright 2026 Google LLC
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -24,6 +24,12 @@ param(
$ErrorActionPreference = "Continue"
[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)
try {
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
@@ -49,7 +55,7 @@ if ((-not $SkipUpdate) -and (Test-Path -LiteralPath $existingPython)) {
exit $LASTEXITCODE
}
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
}
}
@@ -58,12 +64,12 @@ if ((-not $SkipUpdate) -and (Test-Path -LiteralPath $existingPython)) {
try {
Initialize-ArtemisDownloads -ProjectRoot $RootDir -Mode $Mirror
} catch {
Write-Host " [ERROR] Invalid download settings: $_" -ForegroundColor Red
Write-Host (Get-LaunchText " [ERROR] Invalid download settings: $_" " [错误] 下载配置无效:$_") -ForegroundColor Red
exit 1
}
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 ""
@@ -181,7 +187,7 @@ function Install-PortablePlatformTools {
$env:PATH = "$ptDir;$env:PATH"
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 {
$zipPath = "$env:TEMP\platform-tools-windows.zip"
if (Invoke-DownloadFile -Uri $ArtemisDownloads.adb_url -OutFile $zipPath) {
@@ -190,12 +196,12 @@ function Install-PortablePlatformTools {
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
if (Test-Path "$ptDir\adb.exe") {
$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
}
}
} 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
}
@@ -217,13 +223,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/')) {
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 {
$zipPath = "$env:TEMP\scrcpy-win64.zip"
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"
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue }
New-Item -ItemType Directory -Path $extractDir -Force | Out-Null
@@ -239,12 +250,12 @@ function Install-PortableScrcpy {
Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path "$scrcpyDir\scrcpy.exe") {
$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
}
}
} 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
}
@@ -290,7 +301,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 {
$nodeVer = "v22.23.2"
$arch = if ([System.Environment]::Is64BitOperatingSystem) {
@@ -314,12 +325,12 @@ function Install-PortableNode {
Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path "$nodeDir\node.exe") {
$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
}
}
} 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
}
@@ -339,16 +350,16 @@ try {
if (-not (Test-Path -LiteralPath (Join-Path $RootDir '.env'))) {
if (Test-Path -LiteralPath (Join-Path $RootDir '.env.example')) {
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
Write-Host (Get-LaunchText ' [CHECK] Refreshing already-configured MCP clients...' ' [检查] 正在检查并更新已有的 MCP 客户端配置...') -ForegroundColor Cyan
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp
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 ' [WARN] MCP refresh failed. Existing client settings were not replaced; check the errors above.' ' [提示] MCP 配置更新失败,原配置仍保留;请查看上方错误。') -ForegroundColor Yellow
}
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
}
@@ -366,8 +377,8 @@ if ($missingCore.Count -gt 0) {
$useWinGet = $false
if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "winget")) {
if ([Console]::IsInputRedirected -eq $false) {
Write-Host " [INFO] Missing components detected: $($missingCore -join ', ')." -ForegroundColor Cyan
$ans = Read-Host " Install system packages via WinGet? [Y/n]"
Write-Host (Get-LaunchText " [INFO] Missing components detected: $($missingCore -join ', ')." " [检查] 缺少组件:$($missingCore -join ', ')。") -ForegroundColor Cyan
$ans = Read-Host (Get-LaunchText " Install system packages via WinGet? [Y/n]" " 通过 WinGet 安装?[Y/n]")
if ($ans -eq "" -or $ans -match "^[Yy]") {
$useWinGet = $true
}
@@ -377,7 +388,7 @@ if ($missingCore.Count -gt 0) {
}
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") {
winget install --id Google.PlatformTools -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null
}
@@ -396,7 +407,7 @@ if ($missingCore.Count -gt 0) {
}
if (-not (Test-CommandExists "scrcpy")) {
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 +421,19 @@ $ShowcaseIndexAlt1 = "$RootDir\apps\showcase_ui\dist\browser\index.html"
$ShowcaseIndexAlt2 = "$RootDir\apps\showcase_ui\dist\index.html"
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') frontend-check
$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 (-not (Test-NodeCompatible)) {
if (Test-CommandExists "node") {
$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 {
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
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 use 22.23.2 | Out-Null
Update-EnvironmentPath
@@ -433,8 +444,8 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
$useWinGetNode = $false
if ($ArtemisDownloads.mode -ne 'China' -and (Test-CommandExists "winget")) {
if ([Console]::IsInputRedirected -eq $false) {
Write-Host " [INFO] Node.js >= v22.22.0 required for Showcase UI." -ForegroundColor Cyan
$ans = Read-Host " Install/Upgrade Node.js via WinGet (may require Administrator)? [Y/n]"
Write-Host (Get-LaunchText " [INFO] Node.js >= v22.22.0 required for Showcase UI." " [检查] 网页需要 Node.js v22.22.0 或更新版本。") -ForegroundColor Cyan
$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]") {
$useWinGetNode = $true
}
@@ -444,7 +455,7 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
}
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
Update-EnvironmentPath
}
@@ -458,36 +469,36 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
if (Test-NodeCompatible) {
$nodeVer = (& node -v 2>$null)
Write-Host " [OK] Node.js $nodeVer is ready." -ForegroundColor Green
Write-Host " [INFO] Showcase UI sources/dependencies changed or build missing. Compiling..." -ForegroundColor Yellow
Write-Host (Get-LaunchText " [OK] Node.js $nodeVer is ready." " [完成] Node.js $nodeVer 已就绪。") -ForegroundColor Green
Write-Host (Get-LaunchText " [INFO] Showcase UI sources/dependencies changed or build missing. Compiling..." " [构建] 前端源码/依赖有变化或缺少构建结果,准备重新构建...") -ForegroundColor Yellow
Push-Location "$RootDir\apps\showcase_ui"
try {
$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
if ($LASTEXITCODE -ne 0) {
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
if ($LASTEXITCODE -ne 0) { throw "Frontend build failed (exit $LASTEXITCODE)." }
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)) {
Write-Host " [OK] Showcase UI compiled successfully." -ForegroundColor Green
Write-Host (Get-LaunchText " [OK] Showcase UI compiled successfully." " [完成] 网页构建成功。") -ForegroundColor Green
} else {
throw 'Frontend build did not produce index.html.'
}
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') frontend-mark
if ($LASTEXITCODE -ne 0) { throw 'Could not save frontend build fingerprint.' }
} 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
} finally {
Pop-Location
}
} 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
}
}
@@ -500,7 +511,7 @@ Start-LocalAdbServer
# 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.
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) {
& $ArtemisPython -m artemis ui --port $Port --no-open
} else {
+61 -13
View File
@@ -19,6 +19,10 @@ from urllib.parse import urlsplit
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:
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "Never"}
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"))
):
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
except (OSError, ValueError):
pass
if not shutil.which("git"):
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
if not (root / ".git").exists():
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
try:
@@ -79,32 +92,49 @@ def update_source(root: Path, port: int = 8000) -> int:
raise RuntimeError(
"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)
target = git(root, "rev-parse", "FETCH_HEAD")
current = git(root, "rev-parse", "HEAD")
if current == target:
print("[OK] Source is up to date.")
print(launch_text("[OK] Source is up to date.", "[完成] 源码已是最新版。"))
return 0
if git(root, "status", "--porcelain"):
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
ancestor = git(root, "merge-base", "HEAD", target)
if ancestor != current:
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
git(root, "merge", "--ff-only", target, timeout=30)
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
except (RuntimeError, subprocess.TimeoutExpired) as exc:
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
@@ -275,14 +305,32 @@ def refresh_mcp(root: Path) -> None:
else refresh_codex(path, root, sys.executable, ARTEMIS_MCP_TOOLS)
)
if updated:
print(f"[UPDATED] Existing MCP configuration: {path}")
print(
launch_text(
f"[UPDATED] Existing MCP configuration: {path}",
f"[已更新] 已有 MCP 配置:{path}",
)
)
changed += 1
except (OSError, ValueError, TypeError, AttributeError):
print(f"[WARN] Could not safely refresh {path}; original configuration preserved.")
print(
launch_text(
f"[WARN] Could not safely refresh {path}; original configuration preserved.",
f"[提示] 无法安全更新 {path};原配置已保留。",
)
)
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"[OK] MCP source is current; refreshed {changed} existing client configuration(s). Reload the MCP connection in your AI client after a source update.",
f"[完成] MCP 源码已就绪;更新了 {changed} 个已有客户端配置。源码升级后请在 AI 客户端重新加载 MCP。",
)
)
print(
launch_text(
"[TIP] For a new client run: uv run artemis mcp --install <client>",
"[提示] 新客户端可执行:uv run artemis mcp --install <client>",
)
)
print("[TIP] For a new client run: uv run artemis mcp --install <client>")
def frontend_fingerprint(root: Path) -> str:
+5 -5
View File
@@ -13,13 +13,13 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
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 日志。
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=绝对路径)`,修正所有行错误后提交。
6. 单任务返回 `trace_id` 后,用 `mobile_manage_task(action="status", trace_id=...)` 跟踪;批次用 `mobile_batch(action="get", batch_id=...)`。尊重工具建议的等待周期,等待期间可以整理已得证据,避免连续高频轮询。停滞或失败先诊断;不把同一业务任务自动重新提交来解决模型错误。
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=...)`。需要人工补充时用 `pause`,其返回的 `pausing` 只表示请求已接收;轮询到 `task_paused` 才表示设备已释放。Pro 等待模型回复或只读诊断时可中断,已开始的设备动作会先安全结束。`inject_instruction` 可在运行中指导下一步,暂停中提交则恢复;`guidance_history` 的 `pending/consumed` 区分提交和执行端领取,无新指导用 `resume`。恢复进入设备 FIFO 队尾并重新观察手机状态。尊重工具建议的等待周期,避免连续高频轮询;停滞或失败先诊断,不把同一业务任务自动重提。
## 结果、证据与交付
- 执行状态与业务判定分开:`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。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。
- 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。
- `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 秒;默认关闭视频分析与录屏。不根据模型名称猜测网关能力。
- 强度选 `default` 表示不发送该参数,和 `none`(显式要求关闭)不同。接口拒绝强度参数时明确呈现失败,先询问用户是否改用 `default`,不能静默降级或换模型。
- 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 思考耗时当作确定性等待。
+19 -10
View File
@@ -2,7 +2,7 @@
## 直接提交用例
用户要求执行明确的测试集合时,将每个独立目标转成一个 case。`parameters` 是简单值字典,`tags` 是字符串,不能填列表;目标中的 `{{参数名}}` 会替换成参数值。前置条件用自然语言描述真实起点。
用户要求执行明确的测试集合时,将每个独立提示词转成一个 case。`goal` 是唯一发送给 ARTEMIS 的任务正文;`case_id`、`category`、`subcategory`、`name`、`preconditions` 只作元数据。`task_config` 可覆盖该项执行配置,不影响其他项。
先生成唯一 `request_id`(例如 UUID),保存在当前任务记录中,然后调用:
@@ -19,13 +19,21 @@
"timeout_seconds": 1800,
"cases": [
{
"case_id": "DEMO-001",
"category": "系统设置",
"subcategory": "首页",
"name": "设置首页可达",
"goal": "打开系统设置,确认设置首页可见,不修改任何设置。",
"expected": "能看到设置首页及至少一个设置分类,记录实际文字与截图证据。",
"app": "com.android.settings",
"preconditions": "设备已解锁。",
"parameters": {},
"tags": "冒烟,只读"
"goal": "打开系统设置,确认设置首页可见,不修改任何设置;记录实际文字与截图证据。",
"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 导入
`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` 提交。不要跳过错误行却声称全部导入成功。
## 状态与异常
- 批次 `draft` 等待开始;`running` 调度中;`paused` 等待处理;`completed` 表示所有项进入终态,不代表全部通过。
- 单项 `pending/queued/running/finalizing` 尚未结束,`completed/failed/cancelled/interrupted/skipped` 是终态。`finalizing` 在生成报告,不能提前宣称报告已完成。
- `pause` 保留当前执行。设备断开、模型持续失败或任务达到批次时限会暂停后续派发;用诊断确认恢复后再响应用户的继续指令。
- 单项 `pending/queued/running/pausing/task_paused/resuming/finalizing` 尚未结束,`completed/failed/cancelled/interrupted/skipped` 是终态。`pausing` 是请求已发出而设备仍被占用,`task_paused` 才表示设备已释放;`finalizing` 在生成报告,不能提前宣称报告已完成。
- 批次 `pause` 保留当前执行,只暂停后续派发;单项 `mobile_manage_task(pause)` 会在安全边界释放设备,Pro 的模型等待与只读诊断可中断。恢复后重新观察画面,并排到设备队尾,不阻塞后续项。`mobile_manage_task(guidance_history)` 可核对指导是否从 `pending` 变为 `consumed`,但仍需检查后续操作是否符合指导。设备断开、模型持续失败或任务达到批次时限会暂停后续派发;用诊断确认恢复后再响应用户的继续指令。
- `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 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。
+29 -3
View File
@@ -1,6 +1,32 @@
@echo off
rem Use China mirrors for first setup and subsequent launches.
rem All start.bat arguments, including -SetupOnly, are supported.
chcp 65001 >nul 2>&1
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 %*
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 -Help 显示本说明
echo.
echo 国内镜像设置:config\downloads.json
echo 首次运行需联网;后续运行会检查源码、依赖和已有 MCP 配置。
endlocal & exit /b 0
+3 -3
View File
@@ -15,12 +15,12 @@ rem limitations under the License.
chcp 65001 >nul 2>&1
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
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.
@@ -33,7 +33,7 @@ set "ARTEMIS_EXIT_CODE=%ERRORLEVEL%"
if not "%ARTEMIS_EXIT_CODE%"=="0" (
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
)
popd
+164 -4
View File
@@ -6,6 +6,7 @@ import time
from unittest.mock import AsyncMock
import zipfile
from docx import Document
from fastapi import FastAPI
from fastapi.testclient import TestClient
from openpyxl import load_workbook
@@ -64,9 +65,9 @@ def workbook_bytes(rows):
def test_template_preview_rejects_formula_and_bad_json():
data = workbook_bytes(
[
["正常", "打开设置", "", "", "", '{"name":"测试"}', "冒烟"],
["公式", "=SUM(1,2)"],
["参数错误", "测试", "", "", "", "{"],
["正常", "设置", "冒烟", "打开设置", "", "观察设置", '{"profile":"flash"}'],
["公式", "", "", "", "", "=SUM(1,2)"],
["参数错误", "", "", "", "", "测试", "{"],
]
)
result = task_import.parse_workbook(data)
@@ -75,6 +76,30 @@ def test_template_preview_rejects_formula_and_bad_json():
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():
with pytest.raises(ValueError):
task_import.parse_workbook(b"not a zip")
@@ -84,7 +109,9 @@ def test_invalid_archive_and_empty_template():
def test_preview_does_not_enqueue(client, isolated):
response = client.post(
"/api/automation/import/preview",
files={"file": ("tasks.xlsx", workbook_bytes([["案例", "观察页面"]]))},
files={
"file": ("tasks.xlsx", workbook_bytes([["案例", "", "", "观察页面", "", "观察页面"]]))
},
)
assert response.status_code == 200
assert response.json()["valid"]
@@ -98,6 +125,39 @@ def test_preferences_and_required_device(client):
assert client.post("/api/automation/batches", json=payload(device_serial="")).status_code == 422
def test_batch_delete_retains_executed_task_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"})
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"])["case"]["name"] == "用例甲"
assert isolated.job(second["session_id"]) is None
assert reports.session_report(first["session_id"])["name"] == "用例甲"
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_restart_never_repeats_started_work(isolated):
batch = service.create_batch(payload())
service.batch_action(batch["id"], "resume")
@@ -137,6 +197,30 @@ async def test_batch_admission_idempotent_and_bound_device(isolated, monkeypatch
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
async def test_device_outage_pauses_without_consuming_pending_case(isolated, monkeypatch):
batch = service.create_batch(payload())
@@ -193,6 +277,82 @@ def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
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"
)
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]
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 any(name.endswith(".docx") for name in files)
assert any(name.endswith(".xlsx") for name in files)
assert files.index("步骤截图/0001_步骤0001_操作前.png") < files.index(
"步骤截图/0002_步骤0002_操作后.png"
)
assert archive.read("任务录屏/0001_recording.mp4") == b"recording.mp4"
manifest = json.loads(archive.read("文件清单.json"))
assert manifest["session_id"] == job["session_id"]
def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated):
batch = service.create_batch(payload())
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"
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(
"url",
[
@@ -159,7 +159,7 @@ async def test_shutdown_marks_only_ui_owned_running_sessions():
):
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 == []
state.active_session_id = None
state.is_shutting_down = False
@@ -25,7 +25,7 @@ def test_startup_cleanup_keeps_live_cross_process_session(tmp_path):
assert repository.cleanup_orphans_on_startup() == 1
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):
@@ -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
then finishes normally and must still be reported as completed.
"""
state.is_shutting_down = False
ended_payloads: dict[str, dict] = {}
def capture(event_type, data):
+42
View File
@@ -24,6 +24,8 @@ import pytest
from artemis.agents.log_analyzer.log_analyzer import LogAnalyzerNode
from artemis.context import ArtemisContext
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 = [
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].status == expected_status
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)
+17 -7
View File
@@ -410,7 +410,7 @@ async def test_operator_node_multiple_actions():
@pytest.mark.asyncio
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 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):
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()
update_dict = node_update
assert "structured_decisions" in update_dict
@pytest.mark.asyncio
async def test_operator_dynamic_prompt():
@@ -472,7 +470,13 @@ async def test_operator_dynamic_prompt():
mock_llm = 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):
messages = args[0]
@@ -574,7 +578,13 @@ async def test_operator_history_compression():
mock_llm = 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
def mock_read_text(path_self, encoding=None):
@@ -69,8 +69,14 @@ def _state():
def _llm(captured: list, usage_metadata=None):
mock_llm = MagicMock()
response = MagicMock()
response.tool_calls = []
response.content = "no action this turn"
response.tool_calls = [
{
"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
# (the default) reads as "no usage reported".
if usage_metadata is not None:
+204
View File
@@ -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"]
+14 -8
View File
@@ -141,11 +141,17 @@ def _transcript_state(**overrides):
return state
def _no_action_llm(captured: list):
def _action_llm(captured: list):
mock_llm = MagicMock()
mock_response = MagicMock()
mock_response.tool_calls = []
mock_response.content = "no action this turn"
mock_response.tool_calls = [
{
"name": "click",
"args": {"target": [50, 50], "target_description": "button"},
"id": "call_click",
}
]
mock_response.content = "Click the button."
async def mock_ainvoke(*args, **kwargs):
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.get_agent_friendly_steps.return_value = []
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):
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()
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):
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True))
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():
ctx = _transcript_ctx()
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):
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 = []
mock_llm = _no_action_llm(captured)
mock_llm = _action_llm(captured)
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
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():
ctx = _transcript_ctx()
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):
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=False))
+9
View File
@@ -60,6 +60,15 @@ async def test_running_batch_start_is_idempotent(monkeypatch):
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
async def test_download_writes_actual_binary_atomically(monkeypatch, tmp_path):
monkeypatch.setattr(tools, "EXPORT_DIR", tmp_path)
+41
View File
@@ -279,6 +279,47 @@ def test_mobile_manage_task_unknown_trace(temp_trace_env):
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):
trace_id = str(uuid.uuid4())
trace_store.init_trace(trace_id, "Test task", "Flash", "conv-1", device_serial="device-xyz")
+152
View File
@@ -0,0 +1,152 @@
"""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")
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"
+8 -15
View File
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# 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
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)
def test_powershell_script_is_pure_ascii(script):
def test_powershell_script_has_windows_compatible_encoding(script):
raw = script.read_bytes()
offending = []
line_no = 1
for index, byte in enumerate(raw):
if byte == 0x0A:
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."
)
if any(byte > 0x7F for byte in raw):
assert raw.startswith(b"\xef\xbb\xbf"), (
f"{script.name} contains non-ASCII bytes without a UTF-8 BOM; "
"Windows PowerShell 5.1 would use the system ANSI code page."
)
raw.decode("utf-8-sig")
+9
View File
@@ -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 ($env:npm_config_registry -ne 'https://registry.npmmirror.com') { throw 'npm mirror 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:UV_PYTHON_INSTALL_MIRROR
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
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])
def test_uv_mirror_bootstrap_verifies_wheel_without_python(tmp_path, corrupt):
with zipfile.ZipFile(tmp_path / "fixture.zip", "w") as wheel:
+112 -14
View File
@@ -1,36 +1,134 @@
import importlib.util
from pathlib import Path
import shutil
import subprocess
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"
spec = importlib.util.spec_from_file_location("package_source", script)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
SCRIPT = Path(__file__).resolve().parents[2] / "scripts/package_source.py"
spec = importlib.util.spec_from_file_location("package_source", SCRIPT)
module = importlib.util.module_from_spec(spec)
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"]
tracked_builds = [
"artemis/resources/showcase_ui/main.js",
"packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk",
"packages/artemis-accessibility-helper/debug.keystore",
]
omit = [
".env",
".env.local",
".venv/pyvenv.cfg",
"nested/node_modules/foo.js",
"apps/showcase_ui/dist/main.js",
"artemis/resources/showcase_ui/main.js",
"__pycache__/a.pyc",
"traces/data.db",
"helper.apk",
"debug.keystore",
".git/config",
".roo/mcp.json",
]
for name in keep + omit:
target = tmp_path / name
for name in keep + tracked_builds + omit:
target = root / name
target.parent.mkdir(parents=True, exist_ok=True)
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"
count, digest = module.package_source(tmp_path, output)
assert count == len(keep)
count, digest = module.package_source(root, output)
assert count > len(keep)
assert len(digest) == 64
with zipfile.ZipFile(output) as archive:
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")
Generated
+1 -1
View File
@@ -206,7 +206,7 @@ wheels = [
[[package]]
name = "artemis"
version = "1.0"
version = "1.1.1"
source = { editable = "." }
dependencies = [
{ name = "adbutils" },