From 057f0c3050d07298d036acfff3ff7604ad72b52d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E9=93=AD=E8=BD=A9?= Date: Wed, 23 Sep 2026 22:59:48 +0800 Subject: [PATCH] feat: add prompt-first batches and pausable task guidance --- .env.example | 1 + README.md | 8 +- apps/admin_console/core/state.py | 2 +- .../repositories/session_repository.py | 6 +- apps/admin_console/routers/automation.py | 23 ++- apps/admin_console/routers/tasks.py | 61 +++++++ apps/admin_console/server.py | 8 +- .../services/automation_service.py | 54 ++++-- .../services/model_settings_service.py | 3 + apps/admin_console/services/report_export.py | 94 +++++++++++ apps/admin_console/services/task_import.py | 148 +++++++++++------ .../services/task_queue_service.py | 78 ++++++++- .../automation-panel.component.html | 28 +++- .../automation-panel.component.scss | 2 + .../automation-panel.component.ts | 22 ++- .../capability-settings.component.ts | 7 +- .../model-settings.component.spec.ts | 9 +- .../src/app/services/agent.service.ts | 8 +- artemis/agents/explorer/universal_runner.py | 5 +- artemis/agents/flash/runner.py | 23 ++- artemis/config/settings.py | 1 + artemis/context.py | 2 + artemis/data_engine/storage.py | 14 ++ artemis/graph/perception.py | 22 ++- artemis/runtime/task_control.py | 156 ++++++++++++++++++ artemis/sdk/agent.py | 78 +++++++-- artemis/sdk/builders/agent_config_builder.py | 1 + artemis/sdk/types/agent.py | 2 + config/downloads.json | 3 +- docs/ai-automation.md | 6 +- docs/automation-workspace.md | 12 +- mcp_server/tools/automation.py | 25 ++- mcp_server/tools/task_manager.py | 115 ++++++++++--- scripts/download_config.ps1 | 2 + scripts/start.ps1 | 7 +- skills/artemis-automation/SKILL.md | 10 +- .../artemis-automation/references/batches.md | 28 ++-- tests/unit/admin_console/test_automation.py | 90 +++++++++- .../admin_console/test_server_lifecycle.py | 2 +- .../test_session_repository_liveness.py | 2 +- .../admin_console/test_task_queue_service.py | 1 + tests/unit/mcp/test_mcp_tools.py | 39 +++++ tests/unit/runtime/test_task_control.py | 54 ++++++ tests/unit/test_download_mirrors.py | 9 + 44 files changed, 1080 insertions(+), 191 deletions(-) create mode 100644 artemis/runtime/task_control.py create mode 100644 tests/unit/runtime/test_task_control.py diff --git a/.env.example b/.env.example index 7becb93..0ecd44f 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ OPENAI_REASONING_EFFORT=high # 功能开关:保存后对新任务生效 ARTEMIS_VIDEO_ENABLED=false +ARTEMIS_RECORDING_ENABLED=false ARTEMIS_OUTPUTTER_ENABLED=true ARTEMIS_STEP_SUMMARIZER_ENABLED=true # Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。 diff --git a/README.md b/README.md index 73c291b..355894b 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ - **自然语言任务**:Flash 适合明确的操作流程,Pro 适合需要计划、分支探索和验证的任务。 - **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。 -- **批量测试**:下载 Excel 模板、校验导入、顺序执行,支持暂停、继续和复制失败项。 -- **报告与历史**:导出 Word、Excel 和批次 ZIP;历史按日期分组,支持搜索和筛选。 +- **批量测试**:按新版 Excel 模板导入提示词,每项可独立指定模型、模式和参数;任务可暂停、补充指导并继续。 +- **报告与历史**:导出 Word、Excel、步骤截图、录屏和任务资料 ZIP;历史按日期分组,支持搜索和筛选。 - **AI 接入**:随源码提供 MCP 与 Skill,让 AI 完成设备诊断、任务执行、历史分析和报告交付。 ## 安装与启动 @@ -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 @@ -69,7 +69,7 @@ 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`。 diff --git a/apps/admin_console/core/state.py b/apps/admin_console/core/state.py index cc2c4a3..0bb42e2 100644 --- a/apps/admin_console/core/state.py +++ b/apps/admin_console/core/state.py @@ -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 diff --git a/apps/admin_console/database/repositories/session_repository.py b/apps/admin_console/database/repositories/session_repository.py index a12e869..198450f 100644 --- a/apps/admin_console/database/repositories/session_repository.py +++ b/apps/admin_console/database/repositories/session_repository.py @@ -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: diff --git a/apps/admin_console/routers/automation.py b/apps/admin_console/routers/automation.py index f34988c..cb5d5e2 100644 --- a/apps/admin_console/routers/automation.py +++ b/apps/admin_console/routers/automation.py @@ -146,8 +146,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 +172,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), diff --git a/apps/admin_console/routers/tasks.py b/apps/admin_console/routers/tasks.py index 2eae37c..25e8ce1 100644 --- a/apps/admin_console/routers/tasks.py +++ b/apps/admin_console/routers/tasks.py @@ -18,8 +18,16 @@ 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, + read_state, + request_pause, + request_resume, +) try: from admin_console.core.state import state @@ -42,6 +50,48 @@ 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) + 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"} + + +@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 +335,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) diff --git a/apps/admin_console/server.py b/apps/admin_console/server.py index 895b1c1..47d7c52 100644 --- a/apps/admin_console/server.py +++ b/apps/admin_console/server.py @@ -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 diff --git a/apps/admin_console/services/automation_service.py b/apps/admin_console/services/automation_service.py index c22b7a2..ddaa69e 100644 --- a/apps/admin_console/services/automation_service.py +++ b/apps/admin_console/services/automation_service.py @@ -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) diff --git a/apps/admin_console/services/model_settings_service.py b/apps/admin_console/services/model_settings_service.py index fa761dc..f81bbc6 100644 --- a/apps/admin_console/services/model_settings_service.py +++ b/apps/admin_console/services/model_settings_service.py @@ -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, } diff --git a/apps/admin_console/services/report_export.py b/apps/admin_console/services/report_export.py index 69abdab..18914db 100644 --- a/apps/admin_console/services/report_export.py +++ b/apps/admin_console/services/report_export.py @@ -32,6 +32,8 @@ STATUS = { "pending": "待执行", "queued": "已排队", "running": "执行中", + "task_paused": "等待指导", + "resuming": "等待设备恢复", } @@ -107,6 +109,11 @@ def session_report(session_id: str, db_path=None) -> dict: "error": meta.get("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 {}, } @@ -121,6 +128,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 "—"), + ("任务配置", text(report.get("task_config") or {})), ("任务目标", report["goal"]), ("预期结果", report["expected"] or "未指定"), ("执行状态", STATUS.get(report["status"], report["status"])), @@ -279,6 +291,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)) diff --git a/apps/admin_console/services/task_import.py b/apps/admin_console/services/task_import.py index 181af22..f612044 100644 --- a/apps/admin_console/services/task_import.py +++ b/apps/admin_console/services/task_import.py @@ -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"] diff --git a/apps/admin_console/services/task_queue_service.py b/apps/admin_console/services/task_queue_service.py index 0bf4256..2389fff 100644 --- a/apps/admin_console/services/task_queue_service.py +++ b/apps/admin_console/services/task_queue_service.py @@ -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( diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html index 85b4b5a..8124d82 100644 --- a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html +++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html @@ -13,7 +13,23 @@

当前任务报告

@if (report(); as r) { {{r.name}}

{{label(r.status)}} · {{r.verdict}}

- + + @if (liveTask()) { +
+

任务指导 · {{label(taskStatus())}}

+

运行中提交将在下一步执行前生效;暂停后会释放设备,提交指导即可重新排队恢复。

+ + +
+ @if(taskStatus() === 'running'){} + + @if(taskStatus() === 'task_paused'){} +
+
+ }

报告包含目标、预期、实际结论、步骤与截图证据。执行中的下载为当前快照。

{{r.content}}
} @else {

从任务队列或历史记录中选择任务,即可下载报告。

} @@ -23,12 +39,12 @@
} @if (tab() === 'batches') { -

导入任务

每行一个用例,填写名称与目标。预期结果、应用、前置条件、参数和标签可选。

+

导入任务

每行一个用例,仅「Artemis提示词」作为任务正文;「任务配置(JSON)」可设置每项模型、模式、超时和录屏。「前置约束提取」仅供查看与搜索。示例在模板的「填写说明」页。

@if(preview(); as p) {

有效任务 {{p.tasks.length}} 条 · 错误 {{p.errors.length}} 条

@for(e of p.errors; track e.row){

第 {{e.row}} 行:{{e.message}}

} -
@for(t of p.tasks; track t.row){}
行名称目标
{{t.row}}{{t.name}}{{t.goal}}
+
@for(t of p.tasks; track t.row){}
行用例提示词任务配置
{{t.row}}{{t.name}}{{t.goal}}{{t.task_config | json}}
@@ -41,13 +57,13 @@ @if(!batches().length){

暂无批次。

}
@if(batch(); as b){

{{b.name}}

{{label(b.status)}} · {{b.reason}}

-
+
@if(b.status === 'running'){} @if(b.status === 'draft' || b.status === 'paused'){} @if(b.status !== 'running'){} - 下载全部报告 ZIP + 下载批次报告 ZIP

服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。

@if(b.report_error){

报告生成失败:{{b.report_error}}

} - @for(j of b.jobs; track j.session_id){
{{label(j.status)}}@if(j.error){{{j.error}}}
} + @for(j of b.jobs; track j.session_id){
{{label(j.status)}}@if(j.error){{{j.error}}}
}
} } diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.scss b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.scss index a007c7e..7670be5 100644 --- a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.scss +++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.scss @@ -20,3 +20,5 @@ input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align: .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;} diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts index 00d8902..0ae3143 100644 --- a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts +++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts @@ -15,6 +15,9 @@ export class AutomationPanelComponent { tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false); batches = signal([]); preview = signal(null); batch = signal(null); report = signal(null); + guidance = ''; + taskStatus = signal(''); + liveTask = signal(false); word = true; excel = true; name = ''; device = ''; profile = 'flash'; stopOnFailure = false; timeout = 1800; private destroyed = false; constructor() { @@ -24,12 +27,12 @@ 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(`/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)[s] || s; } + label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告'} as Record)[s] || s; } async action(fn: () => Promise) { if (this.busy()) return; this.busy.set(true); this.error.set(''); this.notice.set(''); @@ -43,6 +46,11 @@ export class AutomationPanelComponent { this.batches.set(b); const sid = this.agent.currentSessionId(); if (sid && this.tab() === 'reports') { const r = await firstValueFrom(this.http.get(`/api/automation/reports/${encodeURIComponent(sid)}`)); if (this.agent.currentSessionId() === sid) this.report.set(r); } + if (sid) { + const current = this.agent.sessions().find(s => s.session_id === sid); + this.taskStatus.set(current?.status || this.report()?.status || ''); + this.liveTask.set(['running','task_paused','resuming'].includes(current?.status || '')); + } else { this.taskStatus.set(''); this.liveTask.set(false); } const id = this.batch()?.id; if (id) { const item = await firstValueFrom(this.http.get(`/api/automation/batches/${id}`)); if (this.batch()?.id === id) this.batch.set(item); } } catch { /* User initiated actions expose errors; polling retries on the next interval. */ } @@ -63,4 +71,14 @@ export class AutomationPanelComponent { }); } selectBatch(id: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.get(`/api/automation/batches/${id}`))); }); } 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('请先填写指导内容'); + await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/guide`,{instruction:this.guidance.trim()})); + this.guidance = ''; 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(); + }); } } diff --git a/apps/showcase_ui/src/app/components/model-settings/capability-settings.component.ts b/apps/showcase_ui/src/app/components/model-settings/capability-settings.component.ts index 905a2bf..d5bede4 100644 --- a/apps/showcase_ui/src/app/components/model-settings/capability-settings.component.ts +++ b/apps/showcase_ui/src/app/components/model-settings/capability-settings.component.ts @@ -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 {

功能开关

按需启用,保存后对新任务生效。

+ 视频分析允许智能体分析任务画面。运行期间会临时录制,完成后不保留录像(除非开启录屏)。 +