diff --git a/.env.example b/.env.example index b6ffe89..0f99ff5 100644 --- a/.env.example +++ b/.env.example @@ -3,14 +3,17 @@ OPENAI_API_KEY= # 接口根地址:通常以 /v1 结尾,不要添加 /responses 或 /chat/completions。 OPENAI_BASE_URL=https://api.openai.com/v1 -OPENAI_MODEL=gpt-5-mini +OPENAI_MODEL=mimo-v2.6-flash # 单次模型 HTTP 请求超时(秒) -OPENAI_TIMEOUT_SECONDS=120 -# SDK 内部重试次数;默认 0,由 Artemis 的分类恢复机制处理重试。 -OPENAI_MAX_RETRIES=0 +OPENAI_TIMEOUT_SECONDS=300 +# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。 +OPENAI_MAX_RETRIES=5 +OPENAI_RETRY_INTERVAL_SECONDS=10 +# default 表示不发送思考强度参数;接口不支持所选值时明确报错。 +OPENAI_REASONING_EFFORT=high # 功能开关:保存后对新任务生效 -ARTEMIS_VIDEO_ENABLED=true +ARTEMIS_VIDEO_ENABLED=false ARTEMIS_OUTPUTTER_ENABLED=true ARTEMIS_STEP_SUMMARIZER_ENABLED=true @@ -37,3 +40,8 @@ ARTEMIS_KEEP_DEVICE_AWAKE=true ARTEMIS_HIERARCHY_BACKEND=auto ARTEMIS_HELPER_AUTO_INSTALL=true ARTEMIS_DEFAULT_PROFILE=flash + +# 批量任务与 Excel 导入限制 +ARTEMIS_BATCH_TASK_TIMEOUT=1800 +ARTEMIS_IMPORT_MAX_ROWS=1000 +ARTEMIS_IMPORT_MAX_BYTES=10485760 diff --git a/README_CN.md b/README_CN.md index 031c709..e4fe5d2 100644 --- a/README_CN.md +++ b/README_CN.md @@ -1,3 +1,7 @@ +> AI 接入:[全流程 MCP 与可分发 Skill](docs/ai-automation.md)。 + +> 新增工作区功能:[Word/Excel 报告、Excel 批量任务与历史检索](docs/automation-workspace.md)。 +

ARTEMIS Banner

@@ -151,9 +155,15 @@ cd artemis > **提示**:启动后将自动在默认浏览器中打开 Web 控制台(`http://localhost:8000`),提供设备连接向导、实时投屏、任务演练与状态回放面板。你也可以通过命令行直接运行:`uv run artemis run "打开系统设置,找到电池选项并告诉我当前电量" --profile flash`。 +### 已安装用户自动更新 + +再次运行 `start.bat`(或国内镜像 `start-cn.bat`)会检查 Git 上游,安全快进到最新源码,再检查 Python 依赖、前端源码/包锁文件和已有 MCP 配置。更新后需要在 AI 客户端重新加载 MCP。源码有未提交修改、分支分叉或网络不可用时保留本地版本;正在执行任务时先停止更新启动,不打断原任务。 + +ZIP 解压目录没有 `.git`,会提示改用 Git 克隆以获得自动源码更新,并继续使用当前源码;依赖和已有 MCP 配置仍会检查。`start.bat -SkipUpdate` 仅跳过 Git 检查。完整行为见 [AI 接入与更新说明](docs/ai-automation.md)。 + ### 网页模型配置与功能开关 -Web 控制台使用简体中文。在首页打开「系统设置与运行准备」,可以修改接口地址、模型名称、API Key、请求超时和重试次数。点击「测试连接」会使用当前表单发送一次真实的 Responses 请求;点击「保存配置」才会写入工作目录的 `.env`。密钥留空会保留现有值,网页不会读取或显示已有密钥。 +Web 控制台使用简体中文。在首页打开「系统设置与运行准备」,可以修改接口地址、模型名称、API Key、思考强度、请求超时、重试次数和间隔。点击「测试连接」会使用当前表单发送一次真实的 Responses 请求;点击「保存配置」才会写入工作目录的 `.env`。密钥留空会保留现有值,网页不会读取或显示已有密钥。 所有模型调用统一使用 **OpenAI Responses API**,不再提供 Gemini、Anthropic 或 Chat Completions 调用路径。自定义网关必须实现 `/responses`,接口地址填写根地址(通常以 `/v1` 结尾)。例如: @@ -161,11 +171,16 @@ Web 控制台使用简体中文。在首页打开「系统设置与运行准备 OPENAI_API_KEY=你的密钥 OPENAI_BASE_URL=https://你的网关/v1 OPENAI_MODEL=mimo-v2.6-flash -OPENAI_TIMEOUT_SECONDS=120 -OPENAI_MAX_RETRIES=0 +OPENAI_TIMEOUT_SECONDS=300 +OPENAI_MAX_RETRIES=5 +OPENAI_RETRY_INTERVAL_SECONDS=10 +OPENAI_REASONING_EFFORT=high +ARTEMIS_VIDEO_ENABLED=false ``` -「功能开关」提供视频分析与录屏、最终报告生成、Flash 步骤摘要压缩,可分别保存。设置对**新任务**生效,重启后保留。环境变量优先于 `.env`;在网页保存时会同时更新当前服务进程的配置,服务的启动环境若显式定义了同名变量,后续重启仍以启动环境为准。手动配置的节点模型或任务级选项优先于全局默认值。 +模型可选择 `gpt-5.6-luna`、`mimo-v2.6-flash` 或输入自定义名称。思考强度默认 high,接口不支持时明确报错,可手动选择“由模型默认”。 + +「功能开关」提供默认关闭的视频分析与录屏、最终报告生成、Flash 步骤摘要压缩,可分别保存。设置对**新任务**生效,重启后保留。环境变量优先于 `.env`;在网页保存时会同时更新当前服务进程的配置,服务的启动环境若显式定义了同名变量,后续重启仍以启动环境为准。手动配置的节点模型或任务级选项优先于全局默认值。 视频分析通过抽取画面帧调用模型,因此需要支持图片输入的模型。`mimo-v2.6-flash` 已验证文本、图片、工具调用、流式输出及结构化函数调用;对于不支持 `json_schema` 的网关,结构化结果使用 Responses 函数调用并在本地校验。音频输入是否可用取决于端点能力,拒绝音频时视频分析会尝试仅分析画面。 diff --git a/apps/admin_console/routers/automation.py b/apps/admin_console/routers/automation.py new file mode 100644 index 0000000..f34988c --- /dev/null +++ b/apps/admin_console/routers/automation.py @@ -0,0 +1,202 @@ +"""Reports, validated spreadsheet batches and indexed history.""" + +import asyncio +import os +from typing import Literal + +from fastapi import APIRouter, HTTPException, Query, UploadFile +from fastapi.responses import FileResponse, Response +from pydantic import BaseModel, Field + +from apps.admin_console.services.automation_store import identifier, store +from apps.admin_console.services import automation_service as service +from apps.admin_console.services import report_export, task_import + +router = APIRouter(prefix="/api/automation", tags=["automation"]) + + +class ReportPreferences(BaseModel): + formats: list[Literal["docx", "xlsx"]] = Field( + default=["docx", "xlsx"], min_length=1, max_length=2 + ) + + +class BatchRequest(ReportPreferences): + request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,100}$") + name: str = Field(min_length=1, max_length=200) + cases: list[task_import.TaskCase] = Field(min_length=1, max_length=task_import.MAX_ROWS) + device_serial: str = Field(min_length=1, max_length=200) + profile: Literal["flash", "pro"] = "flash" + stop_on_failure: bool = False + timeout_seconds: int = Field( + default_factory=lambda: int(os.getenv("ARTEMIS_BATCH_TASK_TIMEOUT", "1800")), + ge=30, + le=86400, + ) + + +def require(kind, key): + try: + value = store.get(kind, identifier(key)) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + if value is None: + raise HTTPException(404, "记录不存在") + return value + + +@router.get("/preferences") +def preferences(): + return { + **store.preferences(), + "batch_timeout_seconds": int(os.getenv("ARTEMIS_BATCH_TASK_TIMEOUT", "1800")), + } + + +@router.put("/preferences") +def save_preferences(value: ReportPreferences): + return store.put("settings", "reports", value.model_dump()) + + +@router.get("/template") +def template(): + return Response( + task_import.template(), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": 'attachment; filename="artemis-tasks.xlsx"'}, + ) + + +@router.post("/import/preview") +async def preview(file: UploadFile): + data = await file.read(task_import.MAX_BYTES + 1) + try: + return await asyncio.to_thread(task_import.parse_workbook, data) + except (ValueError, OSError) as exc: + raise HTTPException(400, str(exc)) from exc + + +@router.post("/batches") +def create_batch(value: BatchRequest): + try: + payload = value.model_dump() + if "formats" not in value.model_fields_set: + payload["formats"] = store.preferences()["formats"] + return service.create_batch(payload) + except ValueError as exc: + raise HTTPException(409, str(exc)) from exc + + +@router.get("/batches") +def batches(): + return [ + { + **b, + "counts": { + s: sum(j["status"] == s for j in store.jobs(b["id"])) + for s in ( + "pending", + "queued", + "running", + "completed", + "failed", + "interrupted", + "skipped", + "cancelled", + ) + }, + } + for b in store.records("batch") + ] + + +@router.get("/batches/by-request/{request_id}") +def batch_by_request(request_id: str): + record = require("batch_request", request_id) + return batch_detail(record["batch_id"]) + + +@router.get("/batches/{batch_id}") +def batch_detail(batch_id: str): + return {**require("batch", batch_id), "jobs": store.jobs(batch_id)} + + +@router.post("/batches/{batch_id}/{action}") +def batch_action(batch_id: str, action: Literal["pause", "resume", "retry_failed"]): + require("batch", batch_id) + try: + return service.batch_action(batch_id, action) + except ValueError as exc: + raise HTTPException(409, str(exc)) from exc + + +@router.get("/batches/{batch_id}/archive") +async def batch_archive(batch_id: str): + require("batch", batch_id) + path = await asyncio.to_thread(report_export.export_batch, batch_id) + return FileResponse(path, filename=f"批次报告-{batch_id[:8]}.zip") + + +@router.get("/reports/{session_id}") +def report(session_id: str): + try: + return report_export.session_report(session_id) + except ValueError as exc: + raise HTTPException(404, str(exc)) from exc + + +@router.get("/reports/{session_id}/{fmt}") +async def download_report(session_id: str, fmt: Literal["docx", "xlsx"]): + try: + 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: + raise HTTPException(404, str(exc)) from exc + + +@router.get("/history") +def history( + q: str = Query(default="", max_length=200), + status: str = "", + start: float | None = None, + end: float | None = None, + page: int = Query(default=1, ge=1), + size: int = Query(default=30, ge=1, le=100), +): + # SQL filtering/pagination searches the entire persisted history, not a UI cache. + 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 + 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'),'') + 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 '\\')" + escaped = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%" + args.extend([escaped] * 4) + for condition, value in [ + ("status = ?", status or None), + ("start_time >= ?", start), + ("start_time < ?", end), + ]: + if value is not None: + where += " AND " + condition + args.append(value) + with store.connection() as conn: + total = conn.execute(query + "SELECT count(*) FROM history" + where, args).fetchone()[0] + rows = conn.execute( + query + + "SELECT * FROM history" + + where + + " ORDER BY start_time DESC,session_id LIMIT ? OFFSET ?", + [*args, size, (page - 1) * size], + ).fetchall() + return {"items": [dict(r) for r in rows], "total": total, "page": page, "size": size} diff --git a/apps/admin_console/routers/sessions.py b/apps/admin_console/routers/sessions.py index 6d828b7..4dde2e6 100644 --- a/apps/admin_console/routers/sessions.py +++ b/apps/admin_console/routers/sessions.py @@ -216,6 +216,9 @@ async def cleanup_history_endpoint(): storage = StorageManager(DB_PATH, TRACES_PATH) storage.clear_all_data() + from apps.admin_console.services.automation_store import store + + store.delete_history() return { "status": "success", "message": "History cleaned up successfully", @@ -231,6 +234,9 @@ async def delete_session_endpoint(session_id: str): storage = StorageManager(DB_PATH, TRACES_PATH) storage.delete_session(UUID(session_id)) + from apps.admin_console.services.automation_store import store + + store.delete_history(session_id) return { "status": "success", "message": f"Session {session_id} deleted successfully", diff --git a/apps/admin_console/routers/tasks.py b/apps/admin_console/routers/tasks.py index 26fca46..2eae37c 100644 --- a/apps/admin_console/routers/tasks.py +++ b/apps/admin_console/routers/tasks.py @@ -165,7 +165,7 @@ async def run_task(request: RunRequest): if verified_serial and not request.device_serial: target_serial = verified_serial - return await task_queue_service.enqueue_tasks( + response = await task_queue_service.enqueue_tasks( incoming_goals, profile=request.profile or "flash", expected_output=request.expected_output, @@ -180,6 +180,12 @@ async def run_task(request: RunRequest): conversation_id=request.conversation_id, ) + from apps.admin_console.services.automation_service import register_submission + + for item in response.get("tasks", []): + register_submission(item) + return response + @router.get("/api/run/defaults") async def get_run_defaults(): diff --git a/apps/admin_console/server.py b/apps/admin_console/server.py index 151bb82..895b1c1 100644 --- a/apps/admin_console/server.py +++ b/apps/admin_console/server.py @@ -159,12 +159,19 @@ async def on_startup(): await ipc_service.start_server() state.worker_task = asyncio.create_task(task_queue_service.queue_worker()) + from apps.admin_console.services.automation_service import batch_loop + + app.state.batch_worker = asyncio.create_task(batch_loop()) async def on_shutdown(): """Stop task and IPC children before the UI server exits.""" state.is_shutting_down = True state.shutdown_event.set() + batch_worker = getattr(app.state, "batch_worker", None) + if batch_worker: + batch_worker.cancel() + await asyncio.gather(batch_worker, return_exceptions=True) task_queue_service._broadcast_event("server_shutdown", {"status": "stopping"}) owned_session_ids = { str(item["session_id"]) @@ -207,6 +214,10 @@ async def on_shutdown(): for session_id in owned_session_ids: session_repo.update_session_status(session_id, "cancelled") + from apps.admin_console.services.automation_service import background_tasks + + if background_tasks: + await asyncio.gather(*list(background_tasks), return_exceptions=True) await ipc_service.stop_server() state.ipc_subscribers.clear() await asyncio.to_thread(shutdown_awake_service) @@ -224,6 +235,9 @@ app.include_router(steps.router) app.include_router(tasks.router) app.include_router(replay.router) app.include_router(system.router) +from apps.admin_console.routers.automation import router as automation_router + +app.include_router(automation_router) # Mount cloud gateway router for Frappe / Cloud integration if present try: diff --git a/apps/admin_console/services/automation_service.py b/apps/admin_console/services/automation_service.py new file mode 100644 index 0000000..c22b7a2 --- /dev/null +++ b/apps/admin_console/services/automation_service.py @@ -0,0 +1,276 @@ +"""Persistent batch coordinator layered on the existing device queue.""" + +import asyncio +import logging +import os +import time + +from apps.admin_console.core.state import state +from apps.admin_console.services.automation_store import store, new_id +from apps.admin_console.services.task_import import case_goal + +logger = logging.getLogger(__name__) +TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"} +background_tasks: set[asyncio.Task] = set() + + +def spawn(coro): + task = asyncio.create_task(coro) + background_tasks.add(task) + task.add_done_callback(background_tasks.discard) + return task + + +def register_submission(item: dict, case: dict | None = None, formats=None, **extra): + sid = str(item["session_id"]) + if store.job(sid): + return + store.save_job( + { + "session_id": sid, + "case": case or {"name": item.get("goal", sid)[:120], "goal": item.get("goal", sid)}, + "status": "queued", + "created_at": time.time(), + "device_serial": item.get("device_serial"), + "formats": formats if formats is not None else store.preferences()["formats"], + **extra, + } + ) + + +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"}: + job.update( + status=job.get("terminal_status", "interrupted"), + error=job.get("error") + or ("" if job.get("terminal_status") else "服务中断;未完成任务需手动重跑"), + ended_at=job.get("ended_at") or time.time(), + ) + store.save_job(job) + for batch in store.records("batch"): + if has_legacy_replay(batch["id"]): + for job in store.jobs(batch["id"]): + if job["status"] not in TERMINAL: + job.update(status="cancelled", error="操作回放功能已移除", ended_at=time.time()) + store.save_job(job) + if batch["status"] not in TERMINAL: + batch.update(status="cancelled", reason="操作回放功能已移除;保留历史供查看") + store.put("batch", batch["id"], batch) + continue + if batch["status"] in {"running", "pausing", "paused"}: + batch.update( + status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务" + ) + for job in store.jobs(batch["id"]): + if job["status"] in {"running", "queued", "finalizing"}: + job.update( + status="interrupted", error="服务中断,需手动重跑", ended_at=time.time() + ) + store.save_job(job) + store.put("batch", batch["id"], batch) + + +async def on_terminal(item: dict, status: str): + from apps.admin_console.services.report_export import export_session, session_report + + sid = str(item["session_id"]) + job = store.job(sid) + if not job: + register_submission(item) + job = store.job(sid) + if job is None: + raise RuntimeError("任务元数据写入失败") + job.update(status="finalizing", ended_at=time.time()) + if job.get("timed_out"): + status = "failed" + store.save_job(job) + 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", + }: + job["status"] = "failed" + job["error"] = "任务执行结束,但业务检查未通过" + # Persist terminal classification before export, but the coordinator waits for finalization. + store.save_job({**job, "status": "finalizing", "terminal_status": job["status"]}) + await asyncio.to_thread(export_session, sid, job["formats"]) + job["report_ready"] = True + except Exception as exc: + logger.exception("Report finalization failed for %s", sid) + job["report_error"] = str(exc)[:500] + store.save_job(job) + + +async def tick_batches(): + from apps.admin_console.server import task_queue_service + from artemis.runtime import device_pool + from apps.admin_console.services.report_export import export_batch + + for batch in store.records("batch"): + if batch["status"] not in {"running", "paused"}: + continue + jobs = store.jobs(batch["id"]) + active = [j for j in jobs if j["status"] in {"running", "queued", "finalizing"}] + if active: + if state.is_paused: + batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次") + store.put("batch", batch["id"], batch) + for job in active: + limit = int(batch.get("timeout_seconds", 1800)) + if ( + job["status"] != "finalizing" + and not job.get("timed_out") + and time.time() - job.get("started_at", time.time()) > limit + ): + job.update(error="任务达到批次配置的执行时限", timed_out=True) + store.save_job(job) + task_queue_service.stop_tasks(session_id=job["session_id"]) + batch.update(status="paused", reason="任务超时,已暂停剩余任务;请确认设备状态") + store.put("batch", batch["id"], batch) + continue + if batch["status"] != "running": + continue + pending = [j for j in jobs if j["status"] == "pending"] + failed = [j for j in jobs if j["status"] in {"failed", "cancelled", "interrupted"}] + if batch.get("stop_on_failure") and failed and pending: + for job in pending: + job.update(status="skipped", error="本批次设置为失败即停止", ended_at=time.time()) + store.save_job(job) + pending = [] + if not pending: + batch.update(status="completed", ended_at=time.time()) + try: + await asyncio.to_thread(export_batch, batch["id"]) + batch["report_ready"] = True + except Exception as exc: + logger.exception("Batch report generation failed") + batch["report_error"] = str(exc)[:500] + store.put("batch", batch["id"], batch) + continue + if state.is_paused: + batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次") + store.put("batch", batch["id"], batch) + continue + serial = batch.get("device_serial") + devices = await device_pool.try_list_devices_async() + rejection = ( + None + if serial and any(d.serial == serial and d.state == "device" for d in devices or []) + else "无法确认所选设备在线且已授权,请恢复设备连接后继续批次" + ) + if rejection: + batch.update(status="paused", reason=str(rejection)) + store.put("batch", batch["id"], batch) + 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", ""), + enable_outputter=True, + device_serial=serial, + 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) + except Exception as exc: + job.update(status="pending", error=str(exc)[:500]) + store.save_job(job) + batch.update(status="paused", reason=str(exc)[:500]) + store.put("batch", batch["id"], batch) + + +async def batch_loop(): + recover_batches() + while True: + try: + await tick_batches() + except Exception: + logger.exception("Batch coordinator failed; persisted work is retained") + await asyncio.sleep(1) + + +def has_legacy_replay(batch_id: str) -> bool: + return any( + j.get("workflow_id") or j.get("execution_mode") == "strict" or j.get("adaptive_source") + for j in store.jobs(batch_id) + ) + + +def create_batch(payload: dict) -> dict: + bid = new_id() + batch = { + "id": bid, + "name": payload["name"], + "status": "draft", + "created_at": time.time(), + "formats": payload.get("formats") or store.preferences()["formats"], + "profile": payload.get("profile", "flash"), + "device_serial": payload.get("device_serial"), + "timeout_seconds": payload.get("timeout_seconds") + or int(os.getenv("ARTEMIS_BATCH_TASK_TIMEOUT", "1800")), + "stop_on_failure": payload.get("stop_on_failure", False), + "request_id": payload.get("request_id"), + } + jobs = [] + for number, case in enumerate(payload["cases"], 1): + jobs.append( + { + "session_id": new_id(), + "batch_id": bid, + "position": number, + "case": case, + "status": "pending", + "formats": batch["formats"], + "created_at": time.time(), + "device_serial": batch["device_serial"], + } + ) + return store.create_batch(batch, jobs, payload) + + +def batch_action(batch_id: str, action: str) -> dict: + batch = store.get("batch", batch_id) + if not batch: + raise ValueError("批次不存在") + if has_legacy_replay(batch_id): + raise ValueError("操作回放功能已移除;历史记录仅供查看") + if action == "pause": + if batch["status"] not in {"running", "paused"}: + raise ValueError("只能暂停正在执行的批次") + batch.update(status="paused", reason="用户暂停;当前任务继续,后续任务等待") + elif action == "resume": + if batch["status"] not in {"draft", "paused"}: + raise ValueError("只能开始草稿或继续已暂停批次") + batch.update(status="running", reason="") + elif action == "retry_failed": + if batch["status"] == "running": + raise ValueError("请先暂停批次") + failed = [ + j for j in store.jobs(batch_id) if j["status"] in {"failed", "cancelled", "interrupted"} + ] + if not failed: + raise ValueError("没有可重跑的失败或中断任务") + revised = create_batch( + {**batch, "name": batch["name"] + " · 失败项重跑", "cases": [j["case"] for j in failed]} + ) + return revised + else: + raise ValueError("不支持的批次操作") + return store.put("batch", batch_id, batch) diff --git a/apps/admin_console/services/automation_store.py b/apps/admin_console/services/automation_store.py new file mode 100644 index 0000000..a2264b4 --- /dev/null +++ b/apps/admin_console/services/automation_store.py @@ -0,0 +1,177 @@ +"""Durable batch, execution and versioned workflow metadata; no device side effects.""" + +from contextlib import contextmanager +import json +import hashlib +from pathlib import Path +import re +import sqlite3 +import time +import uuid + +from artemis.config import DB_PATH + + +def identifier(value: str) -> str: + if not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", value): + raise ValueError("无效的记录编号") + return value + + +def new_id() -> str: + return str(uuid.uuid4()) + + +class AutomationStore: + def __init__(self, path: Path = DB_PATH): + self.path = Path(path) + + @contextmanager + def connection(self): + self.path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(self.path, timeout=15) + try: + conn.row_factory = sqlite3.Row + conn.executescript(""" + CREATE TABLE IF NOT EXISTS automation_records ( + kind TEXT NOT NULL, id TEXT NOT NULL, payload TEXT NOT NULL, + updated_at REAL NOT NULL, PRIMARY KEY(kind,id)); + CREATE TABLE IF NOT EXISTS automation_jobs ( + session_id TEXT PRIMARY KEY, batch_id TEXT, position INTEGER NOT NULL, + status TEXT NOT NULL, payload TEXT NOT NULL, updated_at REAL NOT NULL); + CREATE INDEX IF NOT EXISTS automation_batch_jobs ON automation_jobs(batch_id,position); + """) + yield conn + finally: + conn.close() + + def get(self, kind: str, key: str) -> dict | None: + with self.connection() as conn: + row = conn.execute( + "SELECT payload FROM automation_records WHERE kind=? AND id=?", (kind, key) + ).fetchone() + return json.loads(row[0]) if row else None + + def put(self, kind: str, key: str, payload: dict) -> dict: + with self.connection() as conn: + conn.execute( + "INSERT INTO automation_records VALUES (?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at", + (kind, key, json.dumps(payload, ensure_ascii=False), time.time()), + ) + conn.commit() + return payload + + def records(self, kind: str, limit: int = 500) -> list[dict]: + with self.connection() as conn: + rows = conn.execute( + "SELECT payload FROM automation_records WHERE kind=? ORDER BY updated_at DESC LIMIT ?", + (kind, limit), + ).fetchall() + return [json.loads(row[0]) for row in rows] + + def job(self, session_id: str) -> dict | None: + with self.connection() as conn: + row = conn.execute( + "SELECT payload,status FROM automation_jobs WHERE session_id=?", (session_id,) + ).fetchone() + return {**json.loads(row[0]), "status": row[1]} if row else None + + def save_job(self, payload: dict) -> dict: + with self.connection() as conn: + conn.execute( + "INSERT INTO automation_jobs VALUES (?,?,?,?,?,?) ON CONFLICT(session_id) DO UPDATE SET payload=excluded.payload,status=excluded.status,updated_at=excluded.updated_at", + ( + payload["session_id"], + payload.get("batch_id"), + payload.get("position", 0), + payload["status"], + json.dumps(payload, ensure_ascii=False), + time.time(), + ), + ) + conn.commit() + return payload + + def jobs(self, batch_id: str | None = None) -> list[dict]: + with self.connection() as conn: + rows = ( + conn.execute( + "SELECT payload,status FROM automation_jobs WHERE batch_id=? ORDER BY position", + (batch_id,), + ).fetchall() + if batch_id + else conn.execute( + "SELECT payload,status FROM automation_jobs ORDER BY updated_at DESC LIMIT 10000" + ).fetchall() + ) + return [{**json.loads(row[0]), "status": row[1]} for row in rows] + + def preferences(self) -> dict: + return self.get("settings", "reports") or {"formats": ["docx", "xlsx"]} + + def create_batch(self, batch: dict, jobs: list[dict], payload: dict) -> dict: + """Commit a batch and all its jobs atomically; repeated request IDs never duplicate work.""" + request_id = payload.get("request_id") + fingerprint = hashlib.sha256( + json.dumps(payload, sort_keys=True, ensure_ascii=False).encode() + ).hexdigest() + with self.connection() as conn: + conn.execute("BEGIN IMMEDIATE") + if request_id: + identifier(request_id) + row = conn.execute( + "SELECT payload FROM automation_records WHERE kind='batch_request' AND id=?", + (request_id,), + ).fetchone() + if row: + previous = json.loads(row[0]) + if previous["fingerprint"] != fingerprint: + raise ValueError("请求编号已用于不同内容,请使用新的 request_id") + existing = conn.execute( + "SELECT payload FROM automation_records WHERE kind='batch' AND id=?", + (previous["batch_id"],), + ).fetchone() + if not existing: + raise ValueError("该请求对应的历史批次已删除,不能自动重新执行") + return json.loads(existing[0]) + now = time.time() + conn.execute( + "INSERT INTO automation_records VALUES (?,?,?,?)", + ("batch", batch["id"], json.dumps(batch, ensure_ascii=False), now), + ) + for job in jobs: + conn.execute( + "INSERT INTO automation_jobs VALUES (?,?,?,?,?,?)", + ( + job["session_id"], + batch["id"], + job["position"], + job["status"], + json.dumps(job, ensure_ascii=False), + now, + ), + ) + if request_id: + conn.execute( + "INSERT INTO automation_records VALUES (?,?,?,?)", + ( + "batch_request", + request_id, + json.dumps({"batch_id": batch["id"], "fingerprint": fingerprint}), + now, + ), + ) + conn.commit() + return batch + + def delete_history(self, session_id: str | None = None): + with self.connection() as conn: + if session_id: + conn.execute("DELETE FROM automation_jobs WHERE session_id=?", (session_id,)) + else: + conn.execute("DELETE FROM automation_jobs") + conn.execute("DELETE FROM automation_records WHERE kind='batch'") + conn.commit() + + +store = AutomationStore() diff --git a/apps/admin_console/services/model_settings_service.py b/apps/admin_console/services/model_settings_service.py index 2564280..5ca57df 100644 --- a/apps/admin_console/services/model_settings_service.py +++ b/apps/admin_console/services/model_settings_service.py @@ -4,6 +4,7 @@ import os import tempfile import threading from pathlib import Path +from typing import Literal from urllib.parse import urlsplit from pydantic import BaseModel, Field, SecretStr, field_validator @@ -18,8 +19,12 @@ class ModelSettingsUpdate(BaseModel): base_url: str = "https://api.openai.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=120, gt=0, le=3600) - max_retries: int = Field(default=0, ge=0, le=10) + timeout_seconds: float = Field(default=300, gt=0, le=3600) + max_retries: int = Field(default=5, ge=0, le=10) + retry_interval_seconds: float = Field(default=10, ge=0, le=300) + reasoning_effort: Literal["default", "none", "minimal", "low", "medium", "high", "xhigh"] = ( + "high" + ) @field_validator("base_url") @classmethod @@ -57,6 +62,9 @@ def get_model_settings() -> dict: "api_key_configured": bool(settings.OPENAI_API_KEY), "timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS, "max_retries": settings.OPENAI_MAX_RETRIES, + "retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS, + "reasoning_effort": settings.OPENAI_REASONING_EFFORT, + "model_presets": ["gpt-5.6-luna", "mimo-v2.6-flash"], "env_path": str(get_env_file()), } @@ -69,6 +77,8 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict: "OPENAI_MODEL": update.model, "OPENAI_TIMEOUT_SECONDS": str(update.timeout_seconds), "OPENAI_MAX_RETRIES": str(update.max_retries), + "OPENAI_RETRY_INTERVAL_SECONDS": str(update.retry_interval_seconds), + "OPENAI_REASONING_EFFORT": update.reasoning_effort, } if update.api_key and update.api_key.get_secret_value().strip(): values["OPENAI_API_KEY"] = update.api_key.get_secret_value().strip() @@ -77,6 +87,8 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict: settings.OPENAI_MODEL = update.model settings.OPENAI_TIMEOUT_SECONDS = update.timeout_seconds settings.OPENAI_MAX_RETRIES = update.max_retries + settings.OPENAI_RETRY_INTERVAL_SECONDS = update.retry_interval_seconds + settings.OPENAI_REASONING_EFFORT = update.reasoning_effort if "OPENAI_API_KEY" in values: settings.OPENAI_API_KEY = SecretStr(values["OPENAI_API_KEY"]) from artemis.llm.router import ModelFactory @@ -116,7 +128,7 @@ def persist_environment(values: dict[str, str]) -> None: class CapabilitySettings(BaseModel): - video_enabled: bool = True + video_enabled: bool = False outputter_enabled: bool = True step_summarizer_enabled: bool = True @@ -152,7 +164,7 @@ async def test_model_settings(update: ModelSettingsUpdate) -> dict: api_base=update.base_url, timeout_seconds=update.timeout_seconds, max_retries=0, - max_tokens=64, + reasoning_effort=None if update.reasoning_effort == "default" else update.reasoning_effort, ) model = ModelFactory.create_model(endpoint) result = await model.ainvoke([HumanMessage(content="Reply with OK only.")]) diff --git a/apps/admin_console/services/report_export.py b/apps/admin_console/services/report_export.py new file mode 100644 index 0000000..69abdab --- /dev/null +++ b/apps/admin_console/services/report_export.py @@ -0,0 +1,328 @@ +"""Evidence-backed Word/Excel artifacts, independent of the language model runtime.""" + +from datetime import datetime +from typing import cast +from docx.styles.style import ParagraphStyle +import json +from pathlib import Path +import re +import threading +import zipfile + +from docx import Document +from docx.shared import Inches, Pt +from docx.oxml.ns import qn +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment + +from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT +from apps.admin_console.database.repositories.session_repository import SessionRepository +from apps.admin_console.database.repositories.step_repository import StepRepository +from apps.admin_console.services.automation_store import store, identifier + +EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports" +EXPORT_LOCK = threading.RLock() +STATUS = { + "completed": "执行完成", + "success": "执行完成", + "failed": "执行失败", + "cancelled": "已取消", + "interrupted": "执行中断", + "skipped": "已跳过", + "pending": "待执行", + "queued": "已排队", + "running": "执行中", +} + + +def read_json(path: Path, fallback=None): + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return fallback + + +def text(value) -> str: + raw = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str) + return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", raw) + + +def verdict(outcome: dict | None) -> str: + tests = (outcome or {}).get("tests") or {} + if tests.get("failed", 0): + return "验证失败" + if tests.get("unchecked", 0) or tests.get("inconclusive", 0): + return "验证不完整" + if tests.get("passed", 0): + return "验证通过" + return "未验证(执行完成不等于测试通过)" + + +def session_report(session_id: str, db_path=None) -> dict: + identifier(session_id) + meta = store.job(session_id) or {} + repo = SessionRepository(db_path) if db_path else SessionRepository() + session = repo.get_session_by_id(session_id) or {} + if not session and not meta: + raise ValueError("任务记录不存在") + steps = StepRepository(db_path).get_session_steps(session_id) if session else [] + steps = [s for s in steps if s.get("step_id") != "pre-planning"] + directory = TRACES_PATH / session_id + outcome = read_json(directory / "run_outcome.json", {}) + content = "" + for name in ("output.md", "output.txt", "output.json"): + path = directory / "notes" / name + if path.is_file(): + content = path.read_text(encoding="utf-8") + break + result = read_json(directory / "result.json") + if not content and result is not None: + content = text(result) + for step in reversed(steps): + action = step.get("action_taken") or {} + if isinstance(action, dict) and action.get("action") == "report_task_status": + args = action.get("args") or {} + if not content: + content = args.get("explanation") or "" + if not outcome: + outcome = {"task_status": args.get("status"), "tests": {}} + break + if not content: + content = "本任务没有保存最终结论。以下内容仅来自已记录的执行状态和证据。" + case = meta.get("case") or {} + 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), + "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", ""), + "device": meta.get("device_serial") or text(session.get("device_info") or ""), + "tags": case.get("tags", ""), + } + + +def time_text(value) -> str: + return ( + datetime.fromtimestamp(float(value)).astimezone().isoformat(timespec="seconds") + if value + else "—" + ) + + +def metadata(report: dict) -> list[tuple]: + return [ + ("任务编号", report["id"]), + ("任务目标", report["goal"]), + ("预期结果", report["expected"] or "未指定"), + ("执行状态", STATUS.get(report["status"], report["status"])), + ("验证结论", report["verdict"]), + ("开始时间", time_text(report["start"])), + ("结束时间", time_text(report["end"])), + ("设备", report["device"]), + ("异常原因", report["error"] or "无独立异常记录"), + ] + + +def image_path(name: str | None) -> Path | None: + if not name: + return None + root = Path(IMAGES_DIR).resolve() + path = (root / (name if Path(name).suffix else name + ".jpg")).resolve() + return ( + path + if path.is_relative_to(root) + and path.is_file() + and path.suffix.lower() in {".jpg", ".png", ".jpeg"} + else None + ) + + +def markdown_blocks(content: str): + lines = content.splitlines() + index = 0 + while index < len(lines): + line = lines[index] + if ( + "|" in line + and index + 1 < len(lines) + and re.fullmatch(r"[\s|:\-]+", lines[index + 1]) + and "-" in lines[index + 1] + ): + rows = [[c.strip() for c in line.strip().strip("|").split("|")]] + index += 2 + while index < len(lines) and "|" in lines[index] and lines[index].strip(): + rows.append([c.strip() for c in lines[index].strip().strip("|").split("|")]) + index += 1 + yield "table", rows + else: + yield "text", line + 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): + 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 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)) + else: + doc.add_paragraph(str(value)) + 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() + 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)) + doc.add_paragraph(f"追溯编号:{report['id']}。未观察、未检查的项目不计为验证通过。") + doc.save(str(path)) + + +def sheet_rows(ws, rows): + for row in rows: + ws.append([text(value)[:32700] if value is not None else "" for value in row]) + for cell in ws[ws.max_row]: + # 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: + 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 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"])): + if kind == "table": + sheet_rows(wb.create_sheet(f"结果表{number + 1}"), rows) + sheet_rows( + wb.create_sheet("执行证据"), + [ + ("步骤", "时间", "说明", "结果", "截图引用"), + *[ + ( + 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"), + ) + for s in report["steps"] + ], + ], + ) + wb.save(path) + + +def export_session(session_id: str, formats: list[str] | None = None) -> list[Path]: + with EXPORT_LOCK: + report = session_report(session_id) + directory = EXPORT_ROOT / "sessions" / identifier(session_id) + directory.mkdir(parents=True, exist_ok=True) + outputs = [] + for fmt in formats if formats is not None else store.preferences()["formats"]: + if fmt not in {"docx", "xlsx"}: + raise ValueError("不支持的报告格式") + path = directory / f"任务报告.{fmt}" + temporary = directory / f"生成中.{fmt}" + (word_report if fmt == "docx" else excel_report)(report, temporary) + temporary.replace(path) + outputs.append(path) + return outputs + + +def export_batch(batch_id: str) -> Path: + with EXPORT_LOCK: + batch = store.get("batch", identifier(batch_id)) + if not batch: + raise ValueError("批次不存在") + directory = EXPORT_ROOT / "batches" / batch_id + directory.mkdir(parents=True, exist_ok=True) + formats = batch["formats"] + records = [session_report(job["session_id"]) for job in store.jobs(batch_id)] + rows = [("任务编号", "任务名称", "执行状态", "验证结论", "异常原因")] + rows += [ + (r["id"], r["name"], STATUS.get(r["status"], r["status"]), r["verdict"], r["error"]) + for r in records + ] + outputs = [] + if "xlsx" in formats: + wb = Workbook() + assert wb.active is not None + wb.active.title = "批次结果" + sheet_rows(wb.active, rows) + path = directory / "批次结果.xlsx" + wb.save(path) + outputs.append(path) + if "docx" in formats: + doc = Document() + doc.add_heading(batch["name"], 0) + 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) + path = directory / "批次汇总.docx" + doc.save(str(path)) + outputs.append(path) + target = directory / "批次报告.zip" + temporary = directory / "批次报告.tmp" + with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive: + for path in outputs: + archive.write(path, path.name) + for number, report in enumerate(records, 1): + for path in export_session(report["id"], formats): + archive.write(path, f"任务报告/{number:04d}_{report['id']}/{path.name}") + temporary.replace(target) + return target diff --git a/apps/admin_console/services/task_import.py b/apps/admin_console/services/task_import.py new file mode 100644 index 0000000..181af22 --- /dev/null +++ b/apps/admin_console/services/task_import.py @@ -0,0 +1,127 @@ +"""Versioned Excel task template and bounded, non-executing import validation.""" + +from io import BytesIO +import json +import os +import zipfile + +from openpyxl import Workbook, load_workbook +from openpyxl.utils import get_column_letter +from openpyxl.styles import Font, PatternFill, Alignment +from pydantic import BaseModel, Field, ConfigDict + + +HEADERS = { + "任务名称": "name", + "任务目标": "goal", + "预期结果": "expected", + "目标应用": "app", + "前置条件": "preconditions", + "输入参数(JSON)": "parameters", + "标签": "tags", +} +MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000")) +MAX_BYTES = int(os.getenv("ARTEMIS_IMPORT_MAX_BYTES", "10485760")) + + +class TaskCase(BaseModel): + model_config = ConfigDict(extra="forbid") + 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) + + +def template() -> bytes: + wb = Workbook() + ws = wb.active + assert ws is not None + ws.title = "任务" + ws.append(list(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 + help_sheet = wb.create_sheet("填写说明") + for line in [ + "模板版本:1", + "每行一个独立任务;任务名称、任务目标必填。", + "预期结果决定要验证的业务条件。未填写时只报告任务完成情况。", + "前置条件描述实际用例起点;不会统一重启或清空应用数据。", + '输入参数使用 JSON 对象,例如 {"关键词":"天气"};目标可引用 {{关键词}}。', + "目标应用可填名称或包名;空白时由任务决定。", + "不要填写 Excel 公式、宏或外部链接。导入仅预览,点击开始后执行。", + ]: + help_sheet.append([line]) + help_sheet.column_dimensions["A"].width = 105 + for row in help_sheet: + row[0].alignment = Alignment(wrap_text=True) + stream = BytesIO() + wb.save(stream) + return stream.getvalue() + + +def parse_workbook(data: bytes) -> dict: + if len(data) > MAX_BYTES: + raise ValueError("文件过大,请拆分批次后导入") + try: + with zipfile.ZipFile(BytesIO(data)) as archive: + if ( + len(archive.infolist()) > 2000 + or sum(i.file_size for i in archive.infolist()) > MAX_BYTES * 10 + ): + raise ValueError("Excel 解压体积超过限制") + if any( + "vbaproject" in i.filename.lower() or "externallinks" in i.filename.lower() + for i in archive.infolist() + ): + raise ValueError("不支持宏或外部链接") + wb = load_workbook(BytesIO(data), read_only=True, data_only=False, keep_links=False) + except (zipfile.BadZipFile, KeyError, OSError) as exc: + raise ValueError("请上传有效的 .xlsx 模板文件") from exc + try: + if "任务" not in wb.sheetnames: + raise ValueError("缺少“任务”工作表,请使用下载的模板") + rows = iter(wb["任务"].iter_rows()) + headers = [str(cell.value or "").strip() for cell in next(rows, [])] + if headers != list(HEADERS): + raise ValueError("列名或顺序与模板不一致,请重新下载模板") + tasks, errors = [], [] + for number, row in enumerate(rows, 2): + if number > MAX_ROWS + 1: + raise ValueError(f"单批最多 {MAX_ROWS} 行任务") + if not any(cell.value is not None for cell in row): + continue + 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) + 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} + 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 '仅确认任务完成情况,不宣称测试通过'}" + ) diff --git a/apps/admin_console/services/task_queue_service.py b/apps/admin_console/services/task_queue_service.py index b99b4c9..0bf4256 100644 --- a/apps/admin_console/services/task_queue_service.py +++ b/apps/admin_console/services/task_queue_service.py @@ -466,6 +466,12 @@ class TaskQueueService: """Mark the task as running and announce the launch to subscribers.""" task_item["status"] = "running" task_item["start_time"] = time.time() + from apps.admin_console.services.automation_store import store + + job = store.job(str(sess_id)) if sess_id else None + if job: + job.update(status="running", started_at=time.time()) + store.save_job(job) # A fresh launch clears a stale stop request left over for this run # key from a previous task. Manual stops are tracked per run in @@ -559,6 +565,12 @@ class TaskQueueService: if device_serial: cmd.extend(["--device-serial", str(device_serial)]) env["ADB_DEVICE_SERIAL"] = str(device_serial) + from apps.admin_console.services.automation_store import store + from artemis.config import TRACES_PATH + + job = store.job(str(sess_id)) if sess_id else None + if job: + env["RESULTS_OUTPUT_PATH"] = str(TRACES_PATH / str(sess_id) / "result.json") return cmd, env @classmethod @@ -768,6 +780,11 @@ class TaskQueueService: manual_stop: bool, ) -> None: """Broadcast session_ended and dispatch the external completion notification.""" + from apps.admin_console.services.automation_store import store + from apps.admin_console.services.automation_service import spawn, on_terminal + + if sess_id and store.job(str(sess_id)): + spawn(on_terminal(task_item, new_status)) state.active_connections.pop(sess_id, None) cls._broadcast_event( "session_ended", @@ -938,7 +955,7 @@ class TaskQueueService: } # 2. Debounce duplicate rapid submissions (e.g. UI double-click or network retry within 1s) - if len(goals) == 1: + if len(goals) == 1 and not session_id: first_goal = goals[0] recent_duplicate = next( ( @@ -1212,10 +1229,19 @@ class TaskQueueService: @classmethod def _stop_all_tasks(cls) -> bool: """Terminate all active device owners and clear pending queue submissions.""" + from apps.admin_console.services.automation_store import store + from apps.admin_console.services.automation_service import on_terminal, spawn + + for batch in store.records("batch"): + if batch["status"] == "running": + batch.update(status="paused", reason="已停止全部任务,等待手动继续") + store.put("batch", batch["id"], batch) # 1. Cancel local queue reservations for item in state.queue_items: if isinstance(item, dict) and item.get("status") != "running": DeviceExecutionLock.cancel_reservation(item.get("queue_ticket")) + if store.job(str(item.get("session_id"))): + spawn(on_terminal(item, "cancelled")) state.clear_queue() # 2. Terminate all active owners across all devices @@ -1534,6 +1560,12 @@ class TaskQueueService: ), None, ) + if stopped_item and stopped_item.get("status") != "running": + from apps.admin_console.services.automation_store import store + from apps.admin_console.services.automation_service import on_terminal, spawn + + if store.job(str(stopped_session_id)): + spawn(on_terminal(stopped_item, "cancelled")) if stopped_item and not reservation_cancelled: DeviceExecutionLock.cancel_reservation(stopped_item.get("queue_ticket")) state.queue_items = [ diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html index ca4590c..3cf0e3d 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html @@ -42,7 +42,9 @@ limitations under the License. description 笔记与计划 + +
报告与自动化
@if (activeQueue().length === 0) { } - - @if (historyTasks().length === 0) { - - } - + @@ -483,7 +427,7 @@ limitations under the License. + + +@if (error()) { } +@if (notice()) {

{{notice()}}

} +@if (tab() === 'reports') { +

默认报告格式

任务结束后自动生成。批次报告包同时包含汇总和每个任务的报告。

+
+
+ +
+

当前任务报告

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

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

+
下载 Word下载 Excel
+

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

+
{{r.content}}
+ } @else {

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

} +
+} +@if (tab() !== 'reports') { +
+} +@if (tab() === 'batches') { +

导入任务

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

+
下载 Excel 模板
+ @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(b of batches(); track b.id){} + @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 +

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

+ @if(b.report_error){

报告生成失败:{{b.report_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 new file mode 100644 index 0000000..a007c7e --- /dev/null +++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.scss @@ -0,0 +1,22 @@ +:host { display:block;min-width:0;color:#1e293b;font-size:13px;line-height:1.65; } +.tabs {display:flex;gap:4px;padding:4px;margin-bottom:18px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:20px; + button {flex:1;padding:7px 8px;border:0;border-radius:16px;color:#475569;background:transparent;white-space:nowrap; + &.selected {color:#2563eb;background:#fff;box-shadow:0 1px 4px #0f172a12;} } +} +section {padding:16px;margin-bottom:14px;border:1px solid #e2e8f0;border-radius:12px;background:#fff;&:last-child{margin-bottom:0;}} +h3 {color:#0f172a;font-size:14px;font-weight:600;margin:0 0 8px;} +p {margin:8px 0 12px;color:#475569;overflow-wrap:anywhere;}strong{font-weight:600;}.muted,small{color:#64748b;font-size:12px;} +.error,.notice{padding:10px 12px;border-radius:8px;margin:0 0 14px;}.error{color:#b91c1c;background:#fff1f2;border:1px solid #fecdd3;}.notice{color:#047857;background:#ecfdf5;border:1px solid #a7f3d0;} +button,a,.upload {display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:34px;border:1px solid #cbd5e1;border-radius:8px;padding:7px 12px;background:#fff;color:#334155;text-decoration:none;cursor:pointer;font:inherit;font-weight:500;line-height:1.5;transition:background .15s,border-color .15s; + &:hover{background:#f8fafc;border-color:#94a3b8;}&:focus-visible{outline:2px solid #3b82f6;outline-offset:2px;}&:disabled{color:#94a3b8;background:#f8fafc;border-color:#e2e8f0;cursor:not-allowed;}} +a{color:#2563eb;border-color:#bfdbfe;background:#eff6ff;}button.primary{background:#2563eb;color:white;border-color:#2563eb;width:100%;margin-top:10px;}button.primary:hover{background:#1d4ed8;}button.primary:disabled{background:#e2e8f0;color:#64748b;border-color:#e2e8f0;} +.actions,.fields,.format-options{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}.actions{margin:12px 0 4px;}.fields>*{flex:1;min-width:130px;}.format-options{margin:12px 0;}.format-options label{flex:1;min-width:120px;margin:0;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;background:#f8fafc;} +label{display:block;margin:12px 0;color:#334155;font-weight:500;} +input:not([type=checkbox]):not([type=file]),select,textarea{box-sizing:border-box;display:block;width:100%;margin-top:6px;border:1px solid #cbd5e1;border-radius:8px;padding:9px 10px;background:#fff;color:#1e293b;font:inherit;font-weight:400;&:focus{outline:2px solid #bfdbfe;border-color:#3b82f6;}} +input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:-3px;margin-right:6px;} +.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;} +.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;} 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 new file mode 100644 index 0000000..00d8902 --- /dev/null +++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts @@ -0,0 +1,66 @@ +import { Component, DestroyRef, 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'; + +@Component({ + selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule], + templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss' +}) +export class AutomationPanelComponent { + agent = inject(AgentService); system = inject(SystemService); http = inject(HttpClient); + tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false); + batches = signal([]); preview = signal(null); + batch = signal(null); report = signal(null); + word = true; excel = true; name = ''; device = ''; profile = 'flash'; stopOnFailure = false; timeout = 1800; + private destroyed = false; + constructor() { + const destroy = inject(DestroyRef); + this.http.get('/api/automation/preferences').subscribe(p => { this.word = p.formats.includes('docx'); this.excel = p.formats.includes('xlsx'); this.timeout = p.batch_timeout_seconds || 1800; }); + void this.refresh(); + const timer = setInterval(() => void this.refresh(), 5000); + destroy.onDestroy(() => { this.destroyed = true; clearInterval(timer); }); + effect(() => { + const sid = this.agent.currentSessionId(); this.report.set(null); + 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; } + async action(fn: () => Promise) { + if (this.busy()) return; + this.busy.set(true); this.error.set(''); this.notice.set(''); + try { await fn(); } catch (e: any) { const d = e.error?.detail; this.error.set(typeof d === 'string' ? d : d ? JSON.stringify(d) : e.message || '操作失败'); } + finally { this.busy.set(false); } + } + async refresh() { + try { + const b = await firstValueFrom(this.http.get('/api/automation/batches')); + if (this.destroyed) return; + 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); } + 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. */ } + } + savePreferences() { void this.action(async () => { if (!this.formats().length) throw Error('请至少选择一种报告格式'); await firstValueFrom(this.http.put('/api/automation/preferences',{formats:this.formats()})); this.notice.set('已保存,后续任务和批次使用此默认格式'); }); } + upload(event: Event) { + const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; + void this.action(async () => { this.preview.set(null); const data = new FormData(); data.append('file',file); this.preview.set(await firstValueFrom(this.http.post('/api/automation/import/preview',data))); this.name = file.name.replace(/\.xlsx$/i,''); }); + } + startBatch() { void this.action(async () => { + if (!this.preview()?.valid) throw Error('请先导入并修正所有错误'); + if (!this.device) throw Error('请选择执行设备'); + const cases = this.preview().tasks.map(({row, ...c}: any) => c); + const created: any = await firstValueFrom(this.http.post('/api/automation/batches',{name:this.name.trim(),cases,device_serial:this.device,profile:this.profile,stop_on_failure:this.stopOnFailure,timeout_seconds:this.timeout,formats:this.formats()})); + this.batch.set(created); + await firstValueFrom(this.http.post(`/api/automation/batches/${created.id}/resume`,{})); + 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}`))); }); } + 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(); }); } +} diff --git a/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html b/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html index e28452d..eeb472a 100644 --- a/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html +++ b/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html @@ -35,6 +35,7 @@ limitations under the License. description 笔记与计划 +
@@ -54,11 +55,12 @@ limitations under the License.
+ @if (agentService.activeTab() === 'automation') { } @if (agentService.activeTab() === 'tasks') {
- bolt Active Queue ({{ activeQueue().length }}) + bolt 当前队列 ({{ activeQueue().length }})
@if (activeQueue().length === 0) {
@@ -125,68 +127,7 @@ limitations under the License.
} - -
- - history Past Runs & History ({{ historyTasks().length }}) - -
- @if (historyTasks().length === 0) { -
- - 暂无历史任务。 -
- } - @for (session of historyTasks(); track trackSession($index, session)) { -
-
- - @if (getTaskStatus(session) === 'running') { - - } - {{ getTaskStatus(session) | uiLabel }} - -
- @if (getDeviceSerial(session)) { - - phone_android - {{ getDeviceSerial(session) }} - - } - @if (getDeviceSerial(session)) { - · - } - {{ session.start_time * 1000 | date:'shortTime' }} - @if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') { - - } -
-
-
- {{ session.initial_goal || '未命名任务' }} -
- -
- } -
-
+ } diff --git a/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts b/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts index 4504c88..eb1c818 100644 --- a/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts +++ b/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts @@ -1,3 +1,5 @@ +import { AutomationPanelComponent } from '../automation-panel/automation-panel.component'; +import { TaskHistoryComponent } from '../task-history/task-history.component'; import { UiLabelPipe } from '../../utils/ui-label.pipe'; /** * Copyright 2026 Google LLC @@ -28,7 +30,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote }; @Component({ selector: 'app-chat-interface', standalone: true, - imports: [UiLabelPipe, CommonModule, FormsModule], + imports: [UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent], templateUrl: './chat-interface.component.html', styleUrl: './chat-interface.component.scss', changeDetection: ChangeDetectionStrategy.OnPush 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 e80f62a..905a2bf 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 @@ -36,7 +36,7 @@ interface Capabilities { export class CapabilitySettingsComponent implements OnInit { changed = output(); private http = inject(HttpClient); - config: Capabilities = { video_enabled: true, outputter_enabled: true, step_summarizer_enabled: true }; + config: Capabilities = { video_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('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({ diff --git a/apps/showcase_ui/src/app/components/model-settings/model-settings.component.spec.ts b/apps/showcase_ui/src/app/components/model-settings/model-settings.component.spec.ts index dbb45f8..82d6282 100644 --- a/apps/showcase_ui/src/app/components/model-settings/model-settings.component.spec.ts +++ b/apps/showcase_ui/src/app/components/model-settings/model-settings.component.spec.ts @@ -7,7 +7,7 @@ import { CapabilitySettingsComponent } from './capability-settings.component'; describe('持久化模型与功能设置', () => { let http: HttpTestingController; const config = { base_url: 'https://example.test/v1', model: 'mimo-v2.6-flash', - api_key_configured: true, timeout_seconds: 120, max_retries: 0, env_path: '/app/.env' }; + api_key_configured: true, timeout_seconds: 300, max_retries: 5, retry_interval_seconds: 10, reasoning_effort: 'high', env_path: '/app/.env' }; beforeEach(() => { TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] }); http = TestBed.inject(HttpTestingController); @@ -26,6 +26,10 @@ describe('持久化模型与功能设置', () => { const request = http.expectOne('/api/system/model-settings'); expect(request.request.method).toBe('POST'); expect(request.request.body.model).toBe('another-model'); + expect(request.request.body.reasoning_effort).toBe('high'); + expect(request.request.body.timeout_seconds).toBe(300); + expect(request.request.body.max_retries).toBe(5); + expect(request.request.body.retry_interval_seconds).toBe(10); expect(request.request.body.api_key).toBeUndefined(); request.flush({ ...config, model: 'another-model' }); expect(component.busy()).toBeNull(); diff --git a/apps/showcase_ui/src/app/components/model-settings/model-settings.component.ts b/apps/showcase_ui/src/app/components/model-settings/model-settings.component.ts index 5beb007..36ed7d9 100644 --- a/apps/showcase_ui/src/app/components/model-settings/model-settings.component.ts +++ b/apps/showcase_ui/src/app/components/model-settings/model-settings.component.ts @@ -9,6 +9,8 @@ interface ModelSettings { api_key_configured: boolean; timeout_seconds: number; max_retries: number; + retry_interval_seconds: number; + reasoning_effort: string; env_path: string; message?: string; } @@ -25,16 +27,25 @@ interface ModelSettings { placeholder="https://api.openai.com/v1" [disabled]="busy() || loading()" /> 填写接口根地址,通常包含 /v1,无需添加 /responses。 - + + + + 接口需支持所选强度;不支持时会明确报错,可手动选择“由模型默认”。
- +
@@ -52,9 +63,9 @@ interface ModelSettings { .model-settings { display: grid; gap: 10px; color: #1e293b; } p, small { margin: 0; line-height: 1.6; color: #64748b; } label { font-weight: 600; } label span { font-weight: 400; font-size: 12px; } - input { box-sizing: border-box; width: 100%; padding: 12px; border-radius: 10px; border: 1px solid #64748b66; + input, select { box-sizing: border-box; width: 100%; padding: 12px; border-radius: 10px; border: 1px solid #64748b66; background: #f8fafc; color: inherit; font: inherit; } - input:focus { outline: 2px solid #38bdf8; } + input:focus, select:focus { outline: 2px solid #38bdf8; } .number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 8px; } .number-fields input { margin-top: 8px; } .actions { display: flex; gap: 12px; margin-top: 8px; } @@ -66,7 +77,7 @@ interface ModelSettings { export class ModelSettingsComponent implements OnInit { private http = inject(HttpClient); saved = output(); - baseUrl = ''; model = ''; apiKey = ''; timeout = 120; retries = 0; + baseUrl = ''; model = ''; apiKey = ''; timeout = 300; retries = 5; retryInterval = 10; reasoning = 'high'; busy = signal<'save' | 'test' | null>(null); loading = signal(true); keyConfigured = signal(false); envPath = signal(''); message = signal(''); error = signal(''); @@ -79,15 +90,15 @@ export class ModelSettingsComponent implements OnInit { } valid(): boolean { return !!this.baseUrl.trim() && !!this.model.trim() && (!!this.apiKey.trim() || this.keyConfigured()) - && this.timeout > 0 && this.timeout <= 3600 && Number.isInteger(this.retries) && this.retries >= 0 && this.retries <= 10; + && this.timeout > 0 && this.timeout <= 3600 && Number.isInteger(this.retries) && this.retries >= 0 && this.retries <= 10 && this.retryInterval >= 0 && this.retryInterval <= 300; } private payload() { return { base_url: this.baseUrl.trim(), model: this.model.trim(), api_key: this.apiKey.trim() || undefined, - timeout_seconds: this.timeout, max_retries: this.retries }; + timeout_seconds: this.timeout, max_retries: this.retries, retry_interval_seconds: this.retryInterval, reasoning_effort: this.reasoning }; } private apply(config: ModelSettings): void { this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds; - this.retries = config.max_retries; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path); + this.retries = config.max_retries; this.retryInterval = config.retry_interval_seconds ?? 10; this.reasoning = config.reasoning_effort ?? 'high'; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path); } save(): void { if (this.busy() || !this.valid()) return; diff --git a/apps/showcase_ui/src/app/components/task-history/task-history.component.spec.ts b/apps/showcase_ui/src/app/components/task-history/task-history.component.spec.ts new file mode 100644 index 0000000..98fdd14 --- /dev/null +++ b/apps/showcase_ui/src/app/components/task-history/task-history.component.spec.ts @@ -0,0 +1,46 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { AgentService } from '../../services/agent.service'; +import { TaskHistoryComponent } from './task-history.component'; + +describe('历史任务检索', () => { + let http: HttpTestingController; + beforeEach(() => { + TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting(), + {provide: AgentService, useValue: {currentSessionId: signal(null), selectSession: jasmine.createSpy()}}]}); + http = TestBed.inject(HttpTestingController); + }); + afterEach(() => http.verify()); + + it('迟到的旧查询不会覆盖最新搜索结果', () => { + const fixture = TestBed.createComponent(TaskHistoryComponent); + const first = http.expectOne(r => r.url === '/api/automation/history'); + const component = fixture.componentInstance; + component.query = '新用例'; + component.load(); + const latest = http.expectOne(r => r.params.get('q') === '新用例'); + latest.flush({total: 1, items: [{session_id:'new', name:'新用例', status:'completed', start_time:Date.now()/1000}]}); + first.flush({total: 99, items: []}); + expect(component.total()).toBe(1); + expect(component.groups()[0].rows[0].session_id).toBe('new'); + expect(component.groups()[0].title).toBe('今天'); + fixture.destroy(); + }); + + it('日期筛选用本地零点到次日零点,后端分页参数独立传递', () => { + const fixture = TestBed.createComponent(TaskHistoryComponent); + http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]}); + const component = fixture.componentInstance; + component.start = '2026-09-21'; component.end = '2026-09-22'; component.page = 3; + component.load(); + const req = http.expectOne(r => r.url === '/api/automation/history'); + expect(req.request.params.get('start')).toBe(String(new Date('2026-09-21T00:00:00').getTime()/1000)); + expect(req.request.params.get('end')).toBe(String(new Date('2026-09-23T00:00:00').getTime()/1000)); + expect(req.request.params.get('page')).toBe('3'); + req.flush({total: 80, items: []}); + expect(component.pages()).toBe(3); + fixture.destroy(); + }); +}); diff --git a/apps/showcase_ui/src/app/components/task-history/task-history.component.ts b/apps/showcase_ui/src/app/components/task-history/task-history.component.ts new file mode 100644 index 0000000..8bdcf12 --- /dev/null +++ b/apps/showcase_ui/src/app/components/task-history/task-history.component.ts @@ -0,0 +1,44 @@ +import { Component, DestroyRef, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { AgentService } from '../../services/agent.service'; + +@Component({ + selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule], + template:`

历史任务 {{total()}} 条

+ +
+ @if(error()){

{{error()}}

} + @if(loading()){正在查询…} + @for(group of groups(); track group.key){
{{group.title}} 本页 {{group.rows.length}} 条 + @for(row of group.rows; track row.session_id){
} +
} + @if(!loading() && !total()){

没有符合条件的历史任务。

} +
{{page}} / {{pages()}}
+
`, + 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}`] +}) +export class TaskHistoryComponent { + agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1; + total=signal(0);groups=signal([]);loading=signal(false);error=signal('');collapsed=new Set(); + private revision=0;private debounce: ReturnType|undefined; + constructor(){this.load();const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});} + remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});} + pages(){return Math.max(1,Math.ceil(this.total()/30));} + label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已取消',interrupted:'中断',skipped:'跳过'} as Record)[s] || s;} + toggle(key:string,event:Event){if((event.target as HTMLDetailsElement).open)this.collapsed.delete(key);else this.collapsed.add(key);} + search(){this.page=1;this.revision++;clearTimeout(this.debounce);this.debounce=setTimeout(()=>this.load(),250);} + load(){ + const revision=++this.revision;this.loading.set(true);this.error.set(''); + let params=new HttpParams().set('q',this.query).set('page',this.page).set('status',this.status); + if(this.start)params=params.set('start',new Date(this.start+'T00:00:00').getTime()/1000); + if(this.end){const d=new Date(this.end+'T00:00:00');d.setDate(d.getDate()+1);params=params.set('end',d.getTime()/1000);} + this.http.get('/api/automation/history',{params}).subscribe({next:r=>{ + if(revision!==this.revision)return; + this.total.set(r.total);const map=new Map();const today=new Date();const yesterday=new Date();yesterday.setDate(today.getDate()-1); + for(const row of r.items){const d=new Date(row.start_time*1000);const key=d.toDateString();if(!map.has(key))map.set(key,{key,title:key===today.toDateString()?'今天':key===yesterday.toDateString()?'昨天':d.toLocaleDateString('zh-CN'),rows:[]});map.get(key).rows.push(row);} + this.groups.set([...map.values()]);this.loading.set(false); + },error:e=>{if(revision===this.revision){this.loading.set(false);this.error.set(e.error?.detail || '历史查询失败,请稍后重试');}}}); + } +} diff --git a/apps/showcase_ui/src/app/services/agent.service.ts b/apps/showcase_ui/src/app/services/agent.service.ts index a49807c..99fd932 100644 --- a/apps/showcase_ui/src/app/services/agent.service.ts +++ b/apps/showcase_ui/src/app/services/agent.service.ts @@ -231,7 +231,7 @@ export class AgentService { // Notes and Tab States public currentNotes = signal>({}); public selectedNoteKey = signal('task_plan.md'); - public activeTab = signal<'tasks' | 'notes'>('tasks'); + public activeTab = signal<'tasks' | 'notes' | 'automation'>('tasks'); // Video Replay Floating Window States public isVideoWindowOpen = signal(false); diff --git a/artemis/agents/flash/runner.py b/artemis/agents/flash/runner.py index ece8633f..75d26a0 100644 --- a/artemis/agents/flash/runner.py +++ b/artemis/agents/flash/runner.py @@ -892,7 +892,7 @@ class FlashRunner: # retries, and pause/resume are all owned by the LLM gateway # (acomplete); a typed LLMCallError propagates if it gives up. return await invoke_llm_with_timeout_message( - acomplete(bound_llm, messages), timeout_seconds=10, hard_timeout=180 + acomplete(bound_llm, messages), timeout_seconds=10 ) async def _process_tool_calls( diff --git a/artemis/config/agent.py b/artemis/config/agent.py index 4d30278..2c27448 100644 --- a/artemis/config/agent.py +++ b/artemis/config/agent.py @@ -38,7 +38,7 @@ class VideoAnalyzerConfig(BaseModel): """Configuration specific to VideoAnalyzer and screen recording.""" enabled: bool | None = Field( - default=None, + default_factory=lambda: settings.ARTEMIS_VIDEO_ENABLED, description=( "Whether continuous screen video recording and video analyzer tools " "are enabled. If None, auto-detected based on scrcpy & ffmpeg availability." diff --git a/artemis/config/constants.py b/artemis/config/constants.py index 232c9bc..ba3c820 100644 --- a/artemis/config/constants.py +++ b/artemis/config/constants.py @@ -104,7 +104,7 @@ DEFAULT_ACTION_TIMEOUT = 30.0 DEFAULT_STREAM_PING_INTERVAL = 15.0 DEFAULT_PROFILE = "pro" -DEFAULT_MODEL = "gpt-5-mini" +DEFAULT_MODEL = "mimo-v2.6-flash" DEFAULT_EXPLORER_VERSION: Literal["flash", "pro", "ultra"] = "flash" diff --git a/artemis/config/llm.py b/artemis/config/llm.py index 958ebc3..7858f85 100644 --- a/artemis/config/llm.py +++ b/artemis/config/llm.py @@ -66,7 +66,9 @@ class LLM(BaseModel): max_retries: int = Field(default_factory=lambda: settings.OPENAI_MAX_RETRIES, ge=0) max_tokens: int | None = Field(default=None, gt=0) temperature: float | None = None - reasoning_effort: Literal["none", "low", "medium", "high"] | None = None + reasoning_effort: ( + Literal["default", "none", "minimal", "low", "medium", "high", "xhigh"] | None + ) = Field(default_factory=lambda: settings.OPENAI_REASONING_EFFORT) enable_grounding: bool | None = None def validate_provider(self, name: str) -> None: diff --git a/artemis/config/settings.py b/artemis/config/settings.py index 93effb2..06d1b9e 100644 --- a/artemis/config/settings.py +++ b/artemis/config/settings.py @@ -96,9 +96,11 @@ class Settings(BaseSettings): # Custom Provider Endpoints OPENAI_BASE_URL: str | None = None - OPENAI_MODEL: str = Field(default="gpt-5-mini", min_length=1) - OPENAI_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0) - OPENAI_MAX_RETRIES: int = Field(default=0, ge=0) + 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) + OPENAI_RETRY_INTERVAL_SECONDS: float = Field(default=10.0, ge=0, le=300) + OPENAI_REASONING_EFFORT: str = "high" ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS: float = Field(default=12.0, gt=0) ARTEMIS_MODEL_CACHE_TTL_SECONDS: float = Field(default=10.0, ge=0) ARTEMIS_VIDEO_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0) @@ -106,7 +108,7 @@ class Settings(BaseSettings): ARTEMIS_VIDEO_CHUNK_SECONDS: float = Field(default=60.0, gt=0) ARTEMIS_CHECKPOINT_TIMEOUT_SECONDS: float = Field(default=180.0, gt=0) ARTEMIS_SETTLEMENT_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0) - ARTEMIS_VIDEO_ENABLED: bool = True + ARTEMIS_VIDEO_ENABLED: bool = False ARTEMIS_OUTPUTTER_ENABLED: bool = True ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True diff --git a/artemis/interfaces/cli/commands/init.py b/artemis/interfaces/cli/commands/init.py index 3c2dca6..278c25b 100644 --- a/artemis/interfaces/cli/commands/init.py +++ b/artemis/interfaces/cli/commands/init.py @@ -112,7 +112,12 @@ def init_command() -> None: "", "# Execution Defaults", "ARTEMIS_DEFAULT_PROFILE=pro", - "OPENAI_MODEL=gpt-5-mini", + "OPENAI_MODEL=mimo-v2.6-flash", + "OPENAI_REASONING_EFFORT=high", + "OPENAI_TIMEOUT_SECONDS=300", + "OPENAI_MAX_RETRIES=5", + "OPENAI_RETRY_INTERVAL_SECONDS=10", + "ARTEMIS_VIDEO_ENABLED=false", "ARTEMIS_TRACES_DIR=./traces", "", ] diff --git a/artemis/interfaces/cli/commands/mcp.py b/artemis/interfaces/cli/commands/mcp.py index 6a6ca35..efaffde 100644 --- a/artemis/interfaces/cli/commands/mcp.py +++ b/artemis/interfaces/cli/commands/mcp.py @@ -42,6 +42,12 @@ ARTEMIS_MCP_TOOLS = ( "mobile_get_device_state", "mobile_inspect_trace", "mobile_diagnose", + "mobile_workspace", + "mobile_config", + "mobile_task_file", + "mobile_batch", + "mobile_search_history", + "mobile_export_report", ) diff --git a/artemis/llm/reliability.py b/artemis/llm/reliability.py index c35ba6b..0f3fe6a 100644 --- a/artemis/llm/reliability.py +++ b/artemis/llm/reliability.py @@ -30,6 +30,7 @@ from __future__ import annotations from dataclasses import dataclass from enum import StrEnum import random +from functools import lru_cache import threading import time from typing import Any @@ -144,9 +145,12 @@ class RetryPolicy: max_attempts: int base_delay: float max_delay: float + fixed: bool = False def delay_for(self, attempt: int) -> float: """Backoff delay before retry number ``attempt`` (1-based).""" + if self.fixed: + return self.base_delay delay = min(self.max_delay, self.base_delay * (2 ** max(0, attempt - 1))) return delay + random.uniform(0.1, 0.5) @@ -166,6 +170,11 @@ _DEFAULT_RETRY_POLICIES: dict[FailureCategory, RetryPolicy] = { } +@lru_cache(maxsize=32) +def _configured_policy(retries: int, interval: float) -> RetryPolicy: + return RetryPolicy(retries + 1, interval, interval, fixed=True) + + def retry_policy_for(category: FailureCategory | str) -> RetryPolicy: """Look up the retry policy for a failure category. @@ -179,7 +188,15 @@ def retry_policy_for(category: FailureCategory | str) -> RetryPolicy: category = FailureCategory(str(category)) except ValueError: category = FailureCategory.UNKNOWN - return _DEFAULT_RETRY_POLICIES.get(category, _DEFAULT_RETRY_POLICIES[FailureCategory.UNKNOWN]) + if category in { + FailureCategory.AUTHENTICATION, + FailureCategory.BAD_REQUEST, + FailureCategory.CANCELLED, + }: + return _DEFAULT_RETRY_POLICIES[category] + from artemis.config.settings import settings + + return _configured_policy(settings.OPENAI_MAX_RETRIES, settings.OPENAI_RETRY_INTERVAL_SECONDS) # --------------------------------------------------------------------------- diff --git a/artemis/llm/router.py b/artemis/llm/router.py index 42e9397..574c219 100644 --- a/artemis/llm/router.py +++ b/artemis/llm/router.py @@ -45,7 +45,7 @@ class ModelEndpoint(BaseModel): timeout_seconds: float = Field(default_factory=lambda: settings.OPENAI_TIMEOUT_SECONDS, gt=0) max_retries: int = Field(default_factory=lambda: settings.OPENAI_MAX_RETRIES, ge=0) is_multimodal: bool = True - reasoning_effort: str | None = None + reasoning_effort: str | None = Field(default_factory=lambda: settings.OPENAI_REASONING_EFFORT) enable_grounding: bool = False def cache_key(self) -> tuple: @@ -98,10 +98,14 @@ class ModelFactory: "api_key": api_key, "base_url": endpoint.api_base or settings.OPENAI_BASE_URL or None, "timeout": endpoint.timeout_seconds, - "max_retries": endpoint.max_retries, + "stream_chunk_timeout": endpoint.timeout_seconds, + # Artemis owns the retry budget; SDK retries would multiply it. + "max_retries": 0, "temperature": endpoint.temperature, "max_tokens": endpoint.max_tokens, - "reasoning_effort": endpoint.reasoning_effort, + "reasoning_effort": None + if endpoint.reasoning_effort == "default" + else endpoint.reasoning_effort, "use_responses_api": True, "output_version": "responses/v1", "store": False, diff --git a/artemis/resources/config/artemis.jsonc b/artemis/resources/config/artemis.jsonc index 44e5d6c..7ce1092 100644 --- a/artemis/resources/config/artemis.jsonc +++ b/artemis/resources/config/artemis.jsonc @@ -2,12 +2,12 @@ { "default": { "provider": "openai", - "model": "${env:OPENAI_MODEL:-gpt-5-mini}", - "timeout": "${env:OPENAI_TIMEOUT_SECONDS:-120}", - "max_retries": "${env:OPENAI_MAX_RETRIES:-0}", + "model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}", + "timeout": "${env:OPENAI_TIMEOUT_SECONDS:-300}", + "max_retries": "${env:OPENAI_MAX_RETRIES:-5}", "fallback": { "provider": "openai", - "model": "${env:OPENAI_MODEL:-gpt-5-mini}" + "model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}" } }, "presets": {}, @@ -32,7 +32,7 @@ "explorer_mode": "flash", "step_summarizer": { "enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}", - "model": "${env:OPENAI_MODEL:-gpt-5-mini}", + "model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}", "prune_history_xml": true } }, @@ -61,7 +61,7 @@ "device_probes": true }, "video_analyzer": { - "enabled": "${env:ARTEMIS_VIDEO_ENABLED:-true}", + "enabled": "${env:ARTEMIS_VIDEO_ENABLED:-false}", "enable_ledger": true } }, @@ -89,7 +89,7 @@ "max_steps": 12, "min_steps": 3, "target_source_tokens": 2000, - "model": "${env:OPENAI_MODEL:-gpt-5-mini}", + "model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}", "max_chunks": 8 }, "recall": { @@ -104,7 +104,7 @@ } }, "video_analyzer": { - "enabled": "${env:ARTEMIS_VIDEO_ENABLED:-true}", + "enabled": "${env:ARTEMIS_VIDEO_ENABLED:-false}", "enable_ledger": true }, "outputter": { diff --git a/artemis/services/llm.py b/artemis/services/llm.py index 1087707..c1266d6 100644 --- a/artemis/services/llm.py +++ b/artemis/services/llm.py @@ -99,6 +99,9 @@ def _begin_llm_request(provider: str | None) -> Token: # behind this call (set by with_fallback). When a fallback exists, exhausting # retries hands over to it immediately instead of pausing the whole task. _FALLBACK_AVAILABLE: ContextVar[bool] = ContextVar("llm_fallback_available", default=False) +_FAILED_ENDPOINTS: ContextVar[dict[str, Exception] | None] = ContextVar( + "llm_failed_endpoints", default=None +) # Shared circuit breaker keyed by "provider:model". Transient failures open # it; while open, concurrent calls wait out the cooldown instead of hammering @@ -109,9 +112,6 @@ _ENDPOINT_BREAKER = CircuitBreaker(threshold=3, cooldown_seconds=30.0) # remembered so subsequent calls go straight to non-streaming invocation. _NON_STREAMING_ENDPOINTS: set[str] = set() -# Total retry limit when failures switch categories within one recovery cycle. -_MAX_TOTAL_RETRY_ATTEMPTS = 8 - def _get_current_data_engine(): if _CURRENT_DATA_ENGINE is not None: @@ -331,6 +331,8 @@ async def _run_with_recovery[T]( *, provider: str | None = None, endpoint_key: str | None = None, + request_timeout: float | None = None, + max_retries: int | None = None, ) -> T: """Execute one LLM call under the classified retry / pause / breaker policy. @@ -353,10 +355,22 @@ async def _run_with_recovery[T]( pause_file: Path | None = None try: while True: - if endpoint_key: + failed_endpoints = _FAILED_ENDPOINTS.get() + previous = ( + failed_endpoints.get(endpoint_key) + if failed_endpoints is not None and endpoint_key + else None + ) + if previous is not None: + last_error, last_failure = previous, classify_failure(previous) + if not last_failure.retryable: + raise LLMPermanentError(str(previous), failure=last_failure, cause=previous) + break + if endpoint_key and not attempts: await _wait_for_breaker(endpoint_key) try: - result = await call_once() + async with asyncio.timeout(request_timeout or settings.OPENAI_TIMEOUT_SECONDS): + result = await call_once() except (KeyboardInterrupt, asyncio.CancelledError): raise except LLMCallError: @@ -369,6 +383,8 @@ async def _run_with_recovery[T]( if endpoint_key: _ENDPOINT_BREAKER.record_failure(endpoint_key, failure) if not failure.retryable: + if failed_endpoints is not None and endpoint_key: + failed_endpoints[endpoint_key] = e llm_logger.warning( f"LLM call failed permanently ({failure.category.value}): {e}" ) @@ -386,21 +402,18 @@ async def _run_with_recovery[T]( attempt = attempts.get(failure.category, 0) + 1 attempts[failure.category] = attempt policy = retry_policy_for(failure.category) - if attempt >= policy.max_attempts: - break total_attempts = sum(attempts.values()) - if total_attempts >= _MAX_TOTAL_RETRY_ATTEMPTS: - llm_logger.warning( - "LLM retry total across failure categories reached" - f" {total_attempts}; treating as exhausted." - ) + max_attempts = ( + max_retries + 1 if max_retries is not None else policy.max_attempts + ) + if total_attempts >= max_attempts: break delay = policy.delay_for(attempt) _record_llm_retry( str(e), delay, - attempt=attempt, - max_retries=policy.max_attempts, + attempt=total_attempts, + max_retries=max_attempts - 1, provider=provider, ) llm_logger.warning( @@ -414,6 +427,8 @@ async def _run_with_recovery[T]( # Retryable attempts exhausted. if _FALLBACK_AVAILABLE.get(): + if failed_endpoints is not None and endpoint_key: + failed_endpoints[endpoint_key] = last_error _record_llm_event( "llm_gave_up", { @@ -445,6 +460,8 @@ async def _run_with_recovery[T]( ) from last_error llm_logger.info("Resume signal received, retrying LLM call...") + if failed_endpoints is not None and endpoint_key: + failed_endpoints.pop(endpoint_key, None) current_engine = _get_current_data_engine() if current_engine: current_engine._publish("task_resumed", {}) @@ -540,8 +557,8 @@ class RobustChatModelWrapper: def _endpoint_key(self) -> str: if self.endpoint is not None: - return f"{self._provider_value()}:{self.endpoint.model_name}" - return type(self.base_model).__name__ + return f"{self._provider_value()}:{self.endpoint.model_name}:{self.endpoint.api_base or settings.OPENAI_BASE_URL}:{self.endpoint.reasoning_effort}" + return f"{type(self.base_model).__name__}:{id(self.base_model)}" def _traced_call(self, args: tuple, kwargs: dict) -> tuple[tuple, dict, Any]: trace_id = None @@ -564,6 +581,8 @@ class RobustChatModelWrapper: functools.partial(self.base_model.ainvoke, *args, **kwargs), provider=self._provider_value(), endpoint_key=self._endpoint_key(), + request_timeout=self.endpoint.timeout_seconds if self.endpoint else None, + max_retries=self.endpoint.max_retries if self.endpoint else None, ) self._meter_usage(response) return response @@ -584,6 +603,8 @@ class RobustChatModelWrapper: functools.partial(self._complete_attempt, emit_deltas, *args, **kwargs), provider=self._provider_value(), endpoint_key=self._endpoint_key(), + request_timeout=self.endpoint.timeout_seconds if self.endpoint else None, + max_retries=self.endpoint.max_retries if self.endpoint else None, ) self._meter_usage(response) return response @@ -783,7 +804,7 @@ async def acomplete_structured( async def invoke_llm_with_timeout_message[T]( llm_call: Coroutine[Any, Any, T], timeout_seconds: int = 10, - hard_timeout: int = 180, + hard_timeout: int | None = None, ) -> T: """Send an LLM call and display a countdown / timeout message if delayed.""" llm_task = asyncio.create_task(llm_call) @@ -810,7 +831,7 @@ async def invoke_llm_with_timeout_message[T]( continue elapsed = asyncio.get_event_loop().time() - start_time - if elapsed > max(0, hard_timeout - timeout_seconds): + if hard_timeout is not None and elapsed > max(0, hard_timeout - timeout_seconds): user_messages_logger.error(f"LLM call timed out after {hard_timeout} seconds.") raise TimeoutError(f"LLM call timed out after {hard_timeout} seconds.") except BaseException: @@ -855,7 +876,9 @@ def get_cached_raw_model( model_name=model_name or settings.OPENAI_MODEL, temperature=temperature, timeout_seconds=timeout if timeout is not None else settings.OPENAI_TIMEOUT_SECONDS, - reasoning_effort=reasoning_effort, + reasoning_effort=reasoning_effort + if reasoning_effort is not None + else settings.OPENAI_REASONING_EFFORT, enable_grounding=enable_grounding, ) return ModelFactory.get_model(ep) @@ -963,6 +986,19 @@ async def with_fallback[T]( main_call: Callable[[], Awaitable[T]], fallback_call: Callable[[], Awaitable[T]], none_should_fallback: bool = True, +) -> T: + """Share failed endpoint identities so an identical fallback cannot repeat retries.""" + token = _FAILED_ENDPOINTS.set({}) + try: + return await _with_fallback(main_call, fallback_call, none_should_fallback) + finally: + _FAILED_ENDPOINTS.reset(token) + + +async def _with_fallback[T]( + main_call: Callable[[], Awaitable[T]], + fallback_call: Callable[[], Awaitable[T]], + none_should_fallback: bool = True, ) -> T: """Run main_call, switching to fallback_call only when it can actually help. @@ -995,6 +1031,7 @@ async def with_fallback[T]( try: result = await main_call() except (KeyboardInterrupt, asyncio.CancelledError): + _FALLBACK_AVAILABLE.reset(fallback_token) raise except LLMCallError as e: _FALLBACK_AVAILABLE.reset(fallback_token) diff --git a/artemis/utils/video.py b/artemis/utils/video.py index a069d12..2b0a0f2 100644 --- a/artemis/utils/video.py +++ b/artemis/utils/video.py @@ -664,7 +664,9 @@ def is_scrcpy_installed() -> bool: def detect_video_tools_enabled() -> bool: """Check if both scrcpy and ffmpeg are available to enable automated video features.""" - return is_ffmpeg_installed() and is_scrcpy_installed() + from artemis.config.settings import settings + + return settings.ARTEMIS_VIDEO_ENABLED and is_ffmpeg_installed() and is_scrcpy_installed() class FFmpegNotInstalledError(Exception): diff --git a/config/artemis.jsonc b/config/artemis.jsonc index 44e5d6c..7ce1092 100644 --- a/config/artemis.jsonc +++ b/config/artemis.jsonc @@ -2,12 +2,12 @@ { "default": { "provider": "openai", - "model": "${env:OPENAI_MODEL:-gpt-5-mini}", - "timeout": "${env:OPENAI_TIMEOUT_SECONDS:-120}", - "max_retries": "${env:OPENAI_MAX_RETRIES:-0}", + "model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}", + "timeout": "${env:OPENAI_TIMEOUT_SECONDS:-300}", + "max_retries": "${env:OPENAI_MAX_RETRIES:-5}", "fallback": { "provider": "openai", - "model": "${env:OPENAI_MODEL:-gpt-5-mini}" + "model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}" } }, "presets": {}, @@ -32,7 +32,7 @@ "explorer_mode": "flash", "step_summarizer": { "enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}", - "model": "${env:OPENAI_MODEL:-gpt-5-mini}", + "model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}", "prune_history_xml": true } }, @@ -61,7 +61,7 @@ "device_probes": true }, "video_analyzer": { - "enabled": "${env:ARTEMIS_VIDEO_ENABLED:-true}", + "enabled": "${env:ARTEMIS_VIDEO_ENABLED:-false}", "enable_ledger": true } }, @@ -89,7 +89,7 @@ "max_steps": 12, "min_steps": 3, "target_source_tokens": 2000, - "model": "${env:OPENAI_MODEL:-gpt-5-mini}", + "model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}", "max_chunks": 8 }, "recall": { @@ -104,7 +104,7 @@ } }, "video_analyzer": { - "enabled": "${env:ARTEMIS_VIDEO_ENABLED:-true}", + "enabled": "${env:ARTEMIS_VIDEO_ENABLED:-false}", "enable_ledger": true }, "outputter": { diff --git a/docs/ai-automation.md b/docs/ai-automation.md new file mode 100644 index 0000000..fffcd41 --- /dev/null +++ b/docs/ai-automation.md @@ -0,0 +1,47 @@ +# 使用 AI 完成 Android 自动化 + +源码内的 `skills/artemis-automation/` 是可分发 Skill,不依赖本机 Codex 的私有能力。将整个目录复制到宿主支持的 Skill 目录即可;Codex 为 `$CODEX_HOME/skills/`(未配置时 `~/.codex/skills/`)。仓库包含 Skill,启动程序不会修改用户的全局 Skill 目录。 + +先完成项目环境和模型配置,然后在项目目录执行 `uv run artemis mcp --install codex` 安装/更新 MCP 配置。其他客户端将 `codex` 换成对应客户端;`uv run artemis mcp --help` 可查看支持列表。更新后重新加载宿主 MCP;已有配置若设置了工具白名单,需要重新安装配置或加入新增工具。复制 Skill 后按宿主方式重新加载技能。 + +可以向 AI 这样下达任务: + +> 使用 artemis-automation。在我指定的模拟器上验证系统设置首页能打开,不修改设置,输出 Word 和 Excel 报告。 + +> 校验这份 Excel 的测试用例,指出所有错误;先不执行。 + +> 在 emulator-5554 执行已校验用例,失败继续,完成后交付批次报告 ZIP。 + +> 搜索昨天包含“登录”的失败任务,根据截图与步骤说明失败原因,不重跑。 + +## MCP 能力 + +| 工具 | 功能 | +| --- | --- | +| `mobile_diagnose` | 环境、凭据、设备诊断和自带修复 | +| `mobile_workspace` | 设备列表与统一队列状态 | +| `mobile_config` | 读取或明确保存非敏感模型、功能、报告默认设置 | +| `mobile_run_task` | 单任务执行;`model=flash/pro` 表示执行模式 | +| `mobile_manage_task` | 状态、指导和停止单任务 | +| `mobile_get_device_state` | 设备截图、界面层级 | +| `mobile_task_file` | XLSX 模板与导入校验 | +| `mobile_batch` | JSON 用例建批次、开始、暂停、继续、复制失败项 | +| `mobile_search_history` | 全库搜索、日期/状态筛选和分页 | +| `mobile_inspect_trace` | 只读步骤、截图和执行证据 | +| `mobile_export_report` | Word、Excel 和批次 ZIP,返回本地绝对路径 | + +新增工具通过统一后台服务运行,与网页共享配置、队列、历史和报告;不另外启动独立调度器。`ARTEMIS_STANDALONE` 模式不支持这些工作区工具。 + +Windows 已安装用户再次运行 `start.bat` / `start-cn.bat` 时会检查当前分支的 Git 上游,仅做快进更新;拉取后重新加载新版启动脚本。没有 Git、ZIP 解压安装、网络/认证失败、有本地修改或分支分叉时保留现有源码并给出提示,不重置或覆盖。ZIP 用户可继续运行本地版本;需要自动源码升级时请改用 Git 克隆安装。`-SkipUpdate` 可跳过源码更新检查。 + +Python 每次按 `uv.lock` 检查并同步缺失/变化的依赖;前端依据源码、资源及包锁文件指纹决定是否重新执行 `npm ci` 和构建,成功后才记录指纹。国内镜像选项继续生效。检测到正在执行/排队的任务时,自动更新启动会停止,让原任务继续。 + +MCP 自动刷新只处理已存在且指向当前项目的标准 Python 模块配置,更新解释器路径和已有工具白名单。保留用户环境变量、禁用状态、禁用工具及其他服务;JSON/TOML 无法安全解析、其他项目和自定义启动包装器保持原样。首次修改保留备份,未接入的客户端不会自动安装。源码更新后需在 AI 客户端重新加载 MCP 连接以使用新实现;脚本不会强制重启你的 IDE。 + +创建批次时必须提供唯一 `request_id`;请求超时后可以按编号查询或用相同内容重发,避免重复用例。创建只保存草稿,`start` 才执行。AI 无需先制造 Excel,也可直接提交 JSON;字段与示例见 [Skill 批量任务参考](../skills/artemis-automation/references/batches.md)。 + +报告导出到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。 + +模型默认使用 `mimo-v2.6-flash`,也预设 `gpt-5.6-luna` 并支持自定义。思考强度默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,每次等待 10 秒,SDK 不叠加重试。耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏默认关闭。 + +保存全局配置要求用户明确指令;只读配置不包含密钥,MCP 不接收密钥。密钥仍在网页或 `.env` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。 diff --git a/docs/automation-workspace.md b/docs/automation-workspace.md new file mode 100644 index 0000000..9ba76e7 --- /dev/null +++ b/docs/automation-workspace.md @@ -0,0 +1,37 @@ +# 报告、批量任务与历史检索 + +工作区右侧或顶部的 **报告与自动化** 入口包含结论报告、批量任务。历史任务在任务队列中按浏览器本地日期分组,支持名称、目标、编号和标签搜索,以及日期、状态筛选和分页。 + +## 导出结论报告 + +选择已有任务,在“结论报告”中下载 Word 或 Excel。默认同时生成两种格式;勾选所需格式并保存,后续提交使用新的默认值。 + +报告包含目标、预期结果、执行状态、验证结论、实际输出和步骤证据。Word 内嵌关键截图,Excel 包含结果与证据工作表;任务输出中的 Markdown 表格会转换为真正的文档表格和 Excel 工作表。 + +“执行完成”与“验证通过”分开记录。没有实际检查记录时显示“未验证”,不会把模型结束执行当作业务测试通过。正在执行的任务也可以下载,但文件只代表下载时的快照。 + +报告由项目自己的 `python-docx`、`openpyxl` 生成,不依赖 Codex 客户端的私有工具。输出保存在 `artifacts/reports/`。 + +## Excel 批量导入 + +1. 点击“下载 Excel 模板”,保留“任务”工作表及表头顺序。 +2. 每行填写一个独立用例。必填“任务名称”“任务目标”;其余列是预期结果、目标应用、前置条件、输入参数(JSON)、标签。 +3. 参数示例为 `{"关键词":"天气"}`,在任务目标中使用 `{{关键词}}`。不要填写公式、宏或外部链接。 +4. 上传文件,查看逐行校验结果。所有错误修正后才能提交。 +5. 明确选择设备、执行模式和超时时间,然后点击“确认导入并开始执行”。 + +同一批次按顺序执行。默认失败后继续且不自动重试,也可以选择失败即停止。设备不可用、模型暂停或任务超时会暂停后续派发。暂停批次让当前任务继续;全局停止会暂停批次并停止当前执行。 + +服务重启后保留批次,等待用户继续未开始的用例。中断任务不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。 + +每个任务结束后生成所选格式报告。批次结束后生成汇总及每项任务报告的 ZIP;失败、跳过和中断任务也会列入。暂停时下载的是当前快照。 + +## AI 使用 + +通过 MCP 可直接提交结构化批次、检索历史和导出报告;见 [AI 自动化与 Skill](ai-automation.md)。操作回放已移除,旧记录仅供历史查看,不能继续或重新执行。 + +## 环境配置 + +`.env` 可配置 `ARTEMIS_BATCH_TASK_TIMEOUT`(默认 1800 秒)、`ARTEMIS_IMPORT_MAX_ROWS`(默认 1000 行)和 `ARTEMIS_IMPORT_MAX_BYTES`(默认 10 MiB)。修改后重启服务。网页的单批次超时设置优先于环境默认值。 + +批次、任务元数据和报告偏好保存在现有 SQLite 数据库的 `automation_records`、`automation_jobs` 表。清空任务历史同时清除批次与执行元数据;已导出的文件保留。 diff --git a/mcp_server/README.md b/mcp_server/README.md index 6855f13..b3db8a2 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -109,3 +109,7 @@ uv run artemis mcp --install antigravity #### Manual JSON Config Generation Run `uv run artemis mcp --generate-config antigravity` (or `all`) to output ready-to-use configuration JSON with resolved `.venv` Python and project paths. + +## 完整 AI 自动化 + +新增 `mobile_workspace`、`mobile_config`、`mobile_task_file`、`mobile_batch`、`mobile_search_history`、`mobile_export_report`,与网页共用后台服务。参见 [接入说明](../docs/ai-automation.md) 和 [Skill](../skills/artemis-automation/SKILL.md)。更新已有客户端后重新安装/加载 MCP,以刷新工具白名单。 diff --git a/mcp_server/base.py b/mcp_server/base.py index 051f517..e61b151 100644 --- a/mcp_server/base.py +++ b/mcp_server/base.py @@ -34,6 +34,10 @@ mcp = FastMCP( "artemis", instructions=( "ARTEMIS is an autonomous mobile AI agent and Android UI automation engine. " + "For the full workflow use mobile_workspace (devices/queue), mobile_config (non-secret saved defaults), " + "mobile_task_file (XLSX template/preview), mobile_batch (JSON cases + execution), " + "mobile_search_history and mobile_export_report (Word/Excel/ZIP). " + "Task action replay is unavailable; trace inspection is read-only. " "Use mobile_run_task to launch autonomous UI workflows on connected Android devices or emulators, " "mobile_manage_task to check status or steer execution, " "mobile_get_device_state to inspect real-time device screen/hierarchy, " diff --git a/mcp_server/rules.md b/mcp_server/rules.md index f774fe7..f2e5301 100644 --- a/mcp_server/rules.md +++ b/mcp_server/rules.md @@ -66,3 +66,7 @@ Deeply understand and select between ARTEMIS's dual execution models (**ARTEMIS - **Credentials never go through the chat**: If the LLM key is missing, ask the user to add it to the `.env` file at the path the report shows (`host.env_file`) or to the MCP server's `env` block, then to restart the MCP server. `artemis init` is interactive and cannot be driven from a tool call. - **Restart after configuration changes**: Settings are read when the server starts. After editing `.env`, `config/artemis.jsonc`, or the MCP client configuration, reload the MCP server in the IDE before calling `mobile_diagnose` again; otherwise it reports the old state. - **Failed tasks**: `logs.last_failed_task` gives the trace id, its `stderr_log`, and `recent_errors` (the error lines from that stderr, so you need not open the file); read them before re-running the task. Repeat `mobile_diagnose` until `verdict` is `ready` or `degraded` before delegating tasks again (`degraded` means tasks can run but something optional is off: video tools, the OCR key, a secondary credential, or a host warning such as an interpreter mismatch or a squatted daemon port; relay its `[OPTIONAL]` steps to the user, do not loop on them). + +## Full Workflow Tools + +For AI-operated batches, configuration, history search and Word/Excel reports, use the distributed `skills/artemis-automation/SKILL.md`. `mobile_workspace` lists devices/queue; `mobile_config` reads defaults and only saves non-secret changes on an explicit user request. `mobile_task_file` previews XLSX; `mobile_batch` accepts JSON cases directly (create a draft with a stable request_id, then start when execution is authorized). `mobile_search_history` searches all terminal tasks; `mobile_export_report` returns real file paths. Flash/Pro are execution profiles, not API model names. Video recording/analysis default off. Action replay is removed; trace inspection remains read-only. diff --git a/mcp_server/tools/__init__.py b/mcp_server/tools/__init__.py index 3cb2825..27bb916 100644 --- a/mcp_server/tools/__init__.py +++ b/mcp_server/tools/__init__.py @@ -26,4 +26,19 @@ __all__ = [ "mobile_get_device_state", "mobile_inspect_trace", "mobile_diagnose", + "mobile_workspace", + "mobile_config", + "mobile_task_file", + "mobile_batch", + "mobile_search_history", + "mobile_export_report", ] + +from mcp_server.tools.automation import ( + mobile_workspace, + mobile_config, + mobile_task_file, + mobile_batch, + mobile_search_history, + mobile_export_report, +) diff --git a/mcp_server/tools/automation.py b/mcp_server/tools/automation.py new file mode 100644 index 0000000..ad8ba17 --- /dev/null +++ b/mcp_server/tools/automation.py @@ -0,0 +1,279 @@ +"""AI workflow tools backed by the same daemon, queue and persistence as the Web UI.""" + +import asyncio +from pathlib import Path +import re +from typing import Any, Literal +from uuid import uuid4 + +import httpx + +from artemis.config.paths import ROOT_DIR +from artemis.runtime.daemon_client import ensure_daemon_running +from mcp_server.base import mcp + +EXPORT_DIR = ROOT_DIR / "artifacts" / "mcp_exports" +MODEL_FIELDS = { + "base_url", + "model", + "timeout_seconds", + "max_retries", + "retry_interval_seconds", + "reasoning_effort", +} +CAPABILITY_FIELDS = {"video_enabled", "outputter_enabled", "step_summarizer_enabled"} + + +def _id(value: str | None) -> str: + if not value or not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", value): + raise ValueError("请提供有效的记录编号(字母、数字、下划线或连字符)") + return value + + +async def _base_url() -> str: + ready, url = await asyncio.to_thread(ensure_daemon_running, timeout=10, wait_ready=True) + if not ready or not url: + raise ValueError( + "无法连接统一任务服务,请先调用 mobile_diagnose;批次功能需要 daemon,不能在独立模式运行" + ) + return url + + +def _check(response: httpx.Response) -> None: + if response.is_error: + try: + detail = response.json().get("detail") + except ValueError: + detail = None + # Validation payloads may include user input; do not echo raw bodies. + message = ( + detail + if isinstance(detail, str) + else "参数校验失败或服务不可用,请调用 mobile_diagnose 并检查输入" + ) + raise ValueError(f"HTTP {response.status_code}: {message}") + + +async def _request(method: str, route: str, **kwargs) -> Any: + url = await _base_url() + async with httpx.AsyncClient(base_url=url, timeout=30, trust_env=False) as client: + try: + response = await client.request(method, route, **kwargs) + except httpx.HTTPError as exc: + raise ValueError( + "任务服务连接中断;写入结果可能已生效,请先查询状态。创建批次重试必须沿用同一 request_id" + ) from exc + _check(response) + return response.json() + + +async def _download(route: str, filename: str) -> dict: + url = await _base_url() + EXPORT_DIR.mkdir(parents=True, exist_ok=True) + path = EXPORT_DIR / f"{uuid4().hex[:12]}-{filename}" + partial = path.with_suffix(path.suffix + ".part") + try: + async with httpx.AsyncClient(base_url=url, timeout=120, trust_env=False) as client: + async with client.stream("GET", route) as response: + if response.is_error: + await response.aread() + _check(response) + with partial.open("wb") as stream: + async for chunk in response.aiter_bytes(): + stream.write(chunk) + partial.replace(path) + finally: + partial.unlink(missing_ok=True) + return { + "path": str(path.resolve()), + "filename": filename, + "size_bytes": path.stat().st_size, + "download_url": url + route, + } + + +@mcp.tool() +async def mobile_workspace() -> dict: + """List connected Android devices and the unified running/queued task state. + + Select the user's device; if multiple devices exist and none is chosen, ask first. + This does not submit tasks. On connection/device errors use mobile_diagnose. + """ + devices = await _request("GET", "/api/devices") + status = await _request("GET", "/api/status") + return {"devices": devices.get("devices", []), "queue": status} + + +@mcp.tool() +async def mobile_config( + section: Literal["model", "capabilities", "reports"] = "model", + changes: dict[str, Any] | None = None, + persist_defaults: bool = False, +) -> dict: + """Read configuration or explicitly save non-secret defaults for FUTURE tasks. + + Only set persist_defaults=true when the user requests saved/global configuration. + model changes: model (preset or custom), base_url, reasoning_effort + (default/none/minimal/low/medium/high/xhigh), timeout_seconds, max_retries, + retry_interval_seconds. 'default' omits reasoning; unsupported values fail visibly. + capabilities: video_enabled (recording + analysis), outputter_enabled, + step_summarizer_enabled. reports: formats=['docx','xlsx'] (at least one). + API keys are never accepted or returned: use the Web UI or .env for credentials. + Saving settings does not change an already running task. Flash/Pro are execution + profiles, not model names. Model retries do not repeat device test cases. + """ + route, allowed = { + "model": ("/api/system/model-settings", MODEL_FIELDS), + "capabilities": ("/api/system/capability-settings", CAPABILITY_FIELDS), + "reports": ("/api/automation/preferences", {"formats"}), + }[section] + if changes is not None: + if not persist_defaults: + raise ValueError("保存全局配置需要 persist_defaults=true,且应来自用户的明确要求") + if set(changes) - allowed: + raise ValueError("包含不支持的配置字段;密钥请通过网页或 .env 设置") + current = await _request("GET", route) + if changes is None: + return current + payload = {key: value for key, value in current.items() if key in allowed} + payload.update(changes) + return await _request("PUT" if section == "reports" else "POST", route, json=payload) + + +@mcp.tool() +async def mobile_task_file( + action: Literal["template", "preview"], + path: str | None = None, +) -> dict: + """Download the standard XLSX task template, or validate a local XLSX without executing it. + + preview requires an absolute .xlsx path on the MCP host. Returns tasks, row errors, + and valid. Correct all errors, strip preview-only 'row', then submit tasks via + mobile_batch(create). Creating XLSX is optional: AI can submit JSON cases directly. + """ + if action == "template": + return await _download("/api/automation/template", "artemis-tasks.xlsx") + source = Path(path or "") + if not source.is_absolute() or source.suffix.lower() != ".xlsx" or not source.is_file(): + raise ValueError("请提供本机现有 .xlsx 文件的绝对路径") + from apps.admin_console.services.task_import import MAX_BYTES + + if source.stat().st_size > MAX_BYTES: + raise ValueError("文件超过 Excel 导入大小限制") + with source.open("rb") as stream: + return await _request( + "POST", + "/api/automation/import/preview", + files={ + "file": ( + source.name, + stream, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + }, + ) + + +@mcp.tool() +async def mobile_batch( + action: Literal["create", "list", "get", "lookup", "start", "pause", "resume", "retry_failed"], + batch_id: str | None = None, + request_id: str | None = None, + batch: dict[str, Any] | None = None, +) -> Any: + """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, + 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 + a new ID for the same submission. lookup(request_id) resolves an uncertain outcome. + get returns per-case status. pause stops future dispatch, not the active task; + 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. + Restarted batches remain paused. No automatic failed-case retries or action replay. + """ + if action == "create": + rid = _id(request_id) + if not batch: + raise ValueError("create 需要 batch 内容") + return await _request("POST", "/api/automation/batches", json={**batch, "request_id": rid}) + if action == "list": + return await _request("GET", "/api/automation/batches") + if action == "lookup": + return await _request("GET", f"/api/automation/batches/by-request/{_id(request_id)}") + route = f"/api/automation/batches/{_id(batch_id)}" + if action == "get": + return await _request("GET", route) + if action in {"start", "resume"}: + current = await _request("GET", route) + if current["status"] == "running": + return current + return await _request( + "POST", route + "/" + ("resume" if action == "start" else action), json={} + ) + + +@mcp.tool() +async def mobile_search_history( + query: str = "", + status: str = "", + start: float | None = None, + end: float | None = None, + page: int = 1, + size: int = 30, +) -> dict: + """Search ALL persisted terminal tasks by name, goal, ID or tags, with pagination. + + start/end are Unix seconds, inclusive/exclusive; convert the user's local dates + before calling. Status: completed/success/failed/cancelled/interrupted/skipped. + Return items,total,page,size; inspect evidence/report before claiming a test passed. + """ + params = {"q": query, "status": status, "page": page, "size": size} + if start is not None: + params["start"] = start + if end is not None: + params["end"] = end + return await _request("GET", "/api/automation/history", params=params) + + +@mcp.tool() +async def mobile_export_report( + session_id: str | None = None, + batch_id: str | None = None, + formats: list[Literal["docx", "xlsx"]] | None = None, +) -> dict: + """Export a task's Word/Excel reports or a batch ZIP to local files on the MCP host. + + Provide exactly one of session_id/batch_id. Task formats omitted uses saved defaults; + 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; + report 'unverified' when checks/evidence are missing. Never fabricate test results. + """ + if bool(session_id) == bool(batch_id): + raise ValueError("请且仅请提供 session_id 或 batch_id") + if batch_id: + bid = _id(batch_id) + return { + "batch_id": bid, + "files": [ + await _download(f"/api/automation/batches/{bid}/archive", f"batch-{bid}.zip") + ], + } + sid = _id(session_id) + chosen = ( + formats + 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,最多两项") + report = await _request("GET", f"/api/automation/reports/{sid}") + files = [ + await _download(f"/api/automation/reports/{sid}/{fmt}", f"task-{sid}.{fmt}") + for fmt in dict.fromkeys(chosen) + ] + return {"report": report, "files": files} diff --git a/mcp_server/tools/inspect_trace.py b/mcp_server/tools/inspect_trace.py index 35d6dd9..fbad4b0 100644 --- a/mcp_server/tools/inspect_trace.py +++ b/mcp_server/tools/inspect_trace.py @@ -78,7 +78,7 @@ async def mobile_inspect_trace( when the action failed/was intercepted, else null), and `action_overlay_screenshot` (the action visually marked — e.g. red circle for taps — key for verifying the agent tapped the right element). - - **'view_step_details'**: Full replay of one step exactly as the agent's + - **'view_step_details'**: Stored details of one step exactly as the agent's own context showed it: what the screen showed, its reasoning, every tool call it made (name, arguments, result — e.g. what the explorer or OCR reported), the planned action, any safety-net interception and the diff --git a/pyproject.toml b/pyproject.toml index cd49112..4547f64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,8 @@ dependencies = [ "uuid-utils>=0.9.0", "jupyter-client>=8.6.0", "ipykernel>=6.29.0", + "python-docx>=1.2.0", + "openpyxl>=3.1.5", ] [project.optional-dependencies] diff --git a/scripts/start.ps1 b/scripts/start.ps1 index c6e2aa6..27ea0ce 100644 --- a/scripts/start.ps1 +++ b/scripts/start.ps1 @@ -17,6 +17,7 @@ param( [int]$Port = 8000, [switch]$NoOpen = $false, [switch]$SetupOnly = $false, + [switch]$SkipUpdate = $false, [ValidateSet('Official', 'China')][string]$Mirror ) @@ -32,6 +33,27 @@ $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition $RootDir = Split-Path -Parent $ScriptDir Set-Location $RootDir +# Existing installations check source BEFORE importing bootstrap helpers. +$existingPython = Join-Path $RootDir '.venv\Scripts\python.exe' +if ((-not $SkipUpdate) -and (Test-Path -LiteralPath $existingPython)) { + & $existingPython (Join-Path $ScriptDir 'update_installation.py') source --port $Port + $updateExit = $LASTEXITCODE + if ($updateExit -eq 20) { exit 1 } + if ($updateExit -eq 10) { + # Reparse the freshly updated launcher; do not continue running stale functions. + $reloadArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $ScriptDir 'start.ps1'), '-Port', $Port, '-SkipUpdate') + if ($NoOpen) { $reloadArgs += '-NoOpen' } + if ($SetupOnly) { $reloadArgs += '-SetupOnly' } + if ($Mirror) { $reloadArgs += @('-Mirror', $Mirror) } + & powershell.exe @reloadArgs + 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 + exit 1 + } +} + . (Join-Path $ScriptDir 'download_config.ps1') try { Initialize-ArtemisDownloads -ProjectRoot $RootDir -Mode $Mirror @@ -320,6 +342,11 @@ if (-not (Test-Path -LiteralPath (Join-Path $RootDir '.env'))) { Write-Host ' [OK] Created .env from .env.example. Configure your model in the web UI.' -ForegroundColor Green } } +Write-Host ' [CHECK] Refreshing already-configured MCP clients...' -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 +} if ($SetupOnly) { Write-Host ' [OK] Setup complete. Run start.bat to launch Artemis.' -ForegroundColor Green exit 0 @@ -381,7 +408,10 @@ Start-LocalAdbServer $ShowcaseIndex = "$RootDir\apps\showcase_ui\dist\frontend\browser\index.html" $ShowcaseIndexAlt1 = "$RootDir\apps\showcase_ui\dist\browser\index.html" $ShowcaseIndexAlt2 = "$RootDir\apps\showcase_ui\dist\index.html" -if ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Path $ShowcaseIndexAlt1)) -and (-not (Test-Path $ShowcaseIndexAlt2))) { +& $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 ($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) @@ -429,7 +459,7 @@ if ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Path $ShowcaseIndexAlt1)) if (Test-NodeCompatible) { $nodeVer = (& node -v 2>$null) Write-Host " [OK] Node.js $nodeVer is ready." -ForegroundColor Green - Write-Host " [INFO] Showcase UI build not found. Compiling Angular Showcase UI..." -ForegroundColor Yellow + Write-Host " [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" } @@ -443,11 +473,13 @@ if ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Path $ShowcaseIndexAlt1)) if ($LASTEXITCODE -ne 0) { throw "Frontend build failed (exit $LASTEXITCODE)." } if (Test-Path $ShowcaseIndex) { Write-Host " [OK] Showcase UI compiled successfully." -ForegroundColor Green - } elseif (Test-Path $ShowcaseIndexAlt1) { + } elseif ((Test-Path $ShowcaseIndexAlt1) -or (Test-Path $ShowcaseIndexAlt2)) { Write-Host " [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 exit 1 @@ -460,33 +492,7 @@ if ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Path $ShowcaseIndexAlt1)) } } -# 7. Optionally configure MCP for detected AI IDEs -Write-Host "" -Write-Host " [INFO] Would you like to configure ARTEMIS MCP & testing rules for your AI IDEs?" -ForegroundColor Cyan -Write-Host " (Supported: Antigravity, Cursor, Claude Code/Desktop, Codex, OpenClaw, Windsurf)" -$installMcp = "Y" -if ([Console]::IsInputRedirected -eq $false) { - $response = Read-Host " Install MCP configuration & rules now? [Y/n]" - if ($response -ne "") { - $installMcp = $response - } -} else { - $installMcp = "N" -} - -if ($installMcp -match "^[Yy]") { - Write-Host " Installing MCP server configuration & testing rules..." -ForegroundColor Cyan - & $ArtemisPython -m artemis mcp --install all - if ($LASTEXITCODE -eq 0) { - Write-Host " [OK] MCP configuration and rules installed successfully." -ForegroundColor Green - Write-Host " [TIP] You can update or re-install anytime with: uv run artemis mcp --install all" -ForegroundColor Cyan - } else { - Write-Host " [WARN] MCP installation failed. Review the error above and retry with: uv run artemis mcp --install all" -ForegroundColor Yellow - } -} else { - Write-Host " [SKIP] You can install MCP anytime later with: uv run artemis mcp --install all" -ForegroundColor Yellow -} -Write-Host "" +# MCP clients were refreshed after Python dependency sync above. # 8. Launch unified Showcase UI & open browser # Ensure local ADB daemon is warm & listening diff --git a/scripts/update_installation.py b/scripts/update_installation.py new file mode 100644 index 0000000..392f43b --- /dev/null +++ b/scripts/update_installation.py @@ -0,0 +1,325 @@ +"""Check an existing checkout and refresh only already-configured local MCP clients.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import tomllib +import urllib.request +from urllib.parse import urlsplit + +ROOT = Path(__file__).resolve().parents[1] + + +def git(root: Path, *args: str, timeout: int = 15) -> str: + env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "Never"} + result = subprocess.run( + ["git", "-C", str(root), *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=env, + timeout=timeout, + ) + if result.returncode: + # Remote diagnostics can contain URLs/userinfo. Keep them out of launcher logs. + raise RuntimeError( + f"git {args[0]} failed (exit {result.returncode}); check network, credentials or repository state in Git." + ) + return result.stdout.strip() + + +def update_source(root: Path, port: int = 8000) -> int: + """Return 10 after a fast-forward (caller reloads launcher); never overwrite local work.""" + try: + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + with opener.open(f"http://127.0.0.1:{port}/api/status", timeout=2) as response: + status = json.load(response) + if ( + status.get("active_tasks") + or status.get("queue") + or (status.get("status") == "running" and status.get("pid")) + ): + print( + "[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." + ) + 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." + ) + return 0 + try: + 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 remote.startswith("-") or not merge_ref.startswith("refs/heads/"): + raise RuntimeError( + "No remote tracking branch; configure an upstream before enabling source updates." + ) + url = git(root, "remote", "get-url", remote) + parsed = urlsplit(url) + if parsed.scheme in {"http", "https"} and (parsed.username or parsed.password): + raise RuntimeError( + "Use a clean Git remote URL and a credential manager, not embedded credentials." + ) + print("[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.") + return 0 + if git(root, "status", "--porcelain"): + print( + "[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." + ) + return 0 + git(root, "merge", "--ff-only", target, timeout=30) + print( + f"[UPDATED] Source {current[:12]} -> {target[:12]}. Reloading launcher and checking dependencies." + ) + 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." + ) + return 0 + + +def _atomic_write(path: Path, text: str) -> None: + if path.read_text(encoding="utf-8") == text: + return + backup = path.with_name(path.name + ".artemis-update.bak") + if not backup.exists(): + shutil.copy2(path, backup) + fd, name = tempfile.mkstemp(prefix=".artemis-update-", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream: + stream.write(text) + os.replace(name, path) + finally: + Path(name).unlink(missing_ok=True) + + +def _belongs_to_project(config: dict, root: Path) -> bool: + if config.get("url"): + return False + args = config.get("args", []) + if args[:2] not in (["-m", "mcp_server"], ["-m", "mcp_server.server"]): + # A hand-written wrapper/uv/SSE command may need custom arguments; leave it alone. + return False + candidates = [config.get("cwd"), config.get("env", {}).get("PYTHONPATH")] + for value in candidates: + if isinstance(value, str) and Path(value).resolve() == root.resolve(): + return True + command = config.get("command") + return ( + isinstance(command, str) + and Path(command).resolve().parent == (root / ".venv" / "Scripts").resolve() + ) + + +def _tool_updates(config: dict, names: tuple[str, ...]) -> dict: + updates = {} + if "enabled_tools" in config: + updates["enabled_tools"] = list(dict.fromkeys([*config["enabled_tools"], *names])) + if isinstance(config.get("tools"), dict): + updates["tools"] = {**{name: {"eager": True} for name in names}, **config["tools"]} + return updates + + +def refresh_json( + path: Path, keys: tuple[str, ...], root: Path, python: str, names: tuple[str, ...] +) -> bool: + if not path.is_file(): + return False + raw = path.read_text(encoding="utf-8") + data = json.loads(raw) + parent = data + for key in keys: + parent = parent.get(key, {}) + config = parent.get("artemis") + if not isinstance(config, dict) or not _belongs_to_project(config, root): + return False + updated = {**config, "command": python, **_tool_updates(config, names)} + if updated == config: + return False + parent["artemis"] = updated + _atomic_write(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n") + return True + + +def refresh_codex(path: Path, root: Path, python: str, names: tuple[str, ...]) -> bool: + if not path.is_file(): + return False + raw = path.read_text(encoding="utf-8") + config = tomllib.loads(raw).get("mcp_servers", {}).get("artemis") + if not isinstance(config, dict) or not _belongs_to_project(config, root): + return False + wanted = {"command": python, **_tool_updates(config, names)} + wanted = {key: value for key, value in wanted.items() if config.get(key) != value} + if not wanted: + return False + lines = raw.splitlines(keepends=True) + inside = False + found = set() + output = [] + + def append_missing(): + for key, value in wanted.items(): + if key not in found: + output.append(f"{key} = {json.dumps(value, ensure_ascii=False)}\n") + found.add(key) + + # Replace complete assignment spans, including multiline arrays, using TOML parsing. + index = 0 + while index < len(lines): + line = lines[index] + if line.lstrip().startswith("["): + if inside: + append_missing() + header = re.sub(r"[\s\"']", "", line.split("#", 1)[0]).strip() + inside = header == "[mcp_servers.artemis]" + match = re.match(r"\s*(\w+)\s*=", line) if inside else None + if match and match[1] in wanted: + key = match[1] + original = line + while True: + try: + tomllib.loads(original) + break + except tomllib.TOMLDecodeError: + index += 1 + if index >= len(lines): + raise ValueError("Cannot safely parse existing MCP setting") + original += lines[index] + output.append(f"{key} = {json.dumps(wanted[key], ensure_ascii=False)}\n") + found.add(key) + else: + output.append(line) + index += 1 + if inside: + append_missing() + updated = "".join(output) + parsed = tomllib.loads(updated) + if any(parsed["mcp_servers"]["artemis"].get(k) != v for k, v in wanted.items()): + raise ValueError("Could not locate the existing Artemis table safely") + _atomic_write(path, updated) + return updated != raw + + +def refresh_mcp(root: Path) -> None: + sys.path.insert(0, str(root)) + from artemis.interfaces.cli.commands.mcp import ARTEMIS_MCP_TOOLS, _get_vscode_user_dir + + home = Path.home() + vscode = _get_vscode_user_dir() + appdata = Path(os.environ.get("APPDATA", home / "AppData/Roaming")) + paths = [ + (home / ".gemini/config/mcp_config.json", ("mcpServers",)), + (home / ".gemini/jetski/mcp_config.json", ("mcpServers",)), + (home / ".gemini/antigravity/mcp_config.json", ("mcpServers",)), + (home / ".cursor/mcp.json", ("mcpServers",)), + (home / ".codeium/windsurf/mcp_config.json", ("mcpServers",)), + (home / ".claude.json", ("mcpServers",)), + (appdata / "Claude/claude_desktop_config.json", ("mcpServers",)), + (vscode / "mcp.json", ("servers",)), + (home / ".copilot/mcp-config.json", ("servers",)), + (home / ".cline/data/settings/cline_mcp_settings.json", ("mcpServers",)), + (home / ".cline/mcp.json", ("mcpServers",)), + ( + vscode / "globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", + ("mcpServers",), + ), + ( + vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json", + ("mcpServers",), + ), + ( + vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json", + ("mcpServers",), + ), + (root / ".roo/mcp.json", ("mcpServers",)), + (home / ".openclaw/openclaw.json", ("mcp", "servers")), + ] + changed = 0 + codex = Path(os.environ.get("CODEX_HOME", home / ".codex")) / "config.toml" + for path, keys in [*paths, (codex, ())]: + try: + updated = ( + refresh_json(path, keys, root, sys.executable, ARTEMIS_MCP_TOOLS) + if keys + else refresh_codex(path, root, sys.executable, ARTEMIS_MCP_TOOLS) + ) + if updated: + print(f"[UPDATED] Existing MCP configuration: {path}") + changed += 1 + except (OSError, ValueError, TypeError, AttributeError): + print(f"[WARN] Could not safely refresh {path}; original configuration preserved.") + 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." + ) + print("[TIP] For a new client run: uv run artemis mcp --install ") + + +def frontend_fingerprint(root: Path) -> str: + ui = root / "apps/showcase_ui" + files = list((ui / "src").rglob("*")) + list((ui / "public").rglob("*")) + files += [ + ui / name + for name in ( + "package.json", + "package-lock.json", + "angular.json", + "tsconfig.json", + "tsconfig.app.json", + ) + ] + digest = hashlib.sha256() + for path in sorted(p for p in files if p.is_file()): + digest.update(path.relative_to(ui).as_posix().encode()) + digest.update(path.read_bytes()) + return digest.hexdigest() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=["source", "mcp", "frontend-check", "frontend-mark"]) + parser.add_argument("--root", type=Path, default=ROOT) + parser.add_argument("--port", type=int, default=8000) + args = parser.parse_args() + if args.action == "source": + sys.exit(update_source(args.root, args.port)) + elif args.action == "mcp": + refresh_mcp(args.root) + else: + marker = args.root / "apps/showcase_ui/node_modules/.artemis-source-hash" + current = frontend_fingerprint(args.root) + if args.action == "frontend-mark": + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text(current, encoding="ascii") + elif not marker.is_file() or marker.read_text(encoding="ascii").strip() != current: + sys.exit(10) diff --git a/skills/artemis-automation/SKILL.md b/skills/artemis-automation/SKILL.md new file mode 100644 index 0000000..fea30d3 --- /dev/null +++ b/skills/artemis-automation/SKILL.md @@ -0,0 +1,37 @@ +--- +name: artemis-automation +description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语言测试、批量用例执行、失败诊断、历史检索与 Word/Excel 报告交付。适用于用户希望 AI 操作或测试 Android 应用、导入测试用例或分析 ARTEMIS 执行结果。 +--- + +# ARTEMIS 自动化 + +使用项目提供的 MCP 工具操作 Android。报告由 ARTEMIS 自己生成,不要求宿主 AI 具备 Codex 私有工具、Word 或 Excel 插件。该 Skill 不提供桌面/浏览器自动化,也不提供历史动作脚本回放。 + +## 从需求到执行 + +1. 明确目标、预期结果、目标应用和必要前置条件。用户已授权执行且信息足够时直接开展,不逐步索要批准;缺少会改变业务操作的条件时再询问。用例起点按实际需求安排,不默认清数据、重装、重启应用或退出登录。 +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=...)`。尊重工具建议的等待周期,等待期间可以整理已得证据,避免连续高频轮询。停滞或失败先诊断;不把同一业务任务自动重新提交来解决模型错误。 + +## 结果、证据与交付 + +- 执行状态与业务判定分开:`completed` 只说明执行结束,不等于测试通过。检查工具返回的验证摘要、预期/实际结果,以及 `mobile_inspect_trace` 的步骤/截图。证据不足标记“未验证”;前置条件不满足说明阻塞原因,不能编造通过结论。 +- 用 `mobile_export_report(session_id=...)` 获取单任务报告;批次传 `batch_id` 获取汇总和每项任务报告的 ZIP。未指定格式时使用保存的 Word/Excel 偏好。执行中的导出只是当前快照。 +- 工具返回 MCP 主机上的绝对文件路径和下载 URL。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。 +- 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。 +- `mobile_inspect_trace` 是只读步骤证据查看;其中历史上下文的 replay 术语不表示重新执行设备动作。 + +## 配置和恢复边界 + +- `mobile_config` 读取非敏感设置。只有用户明确要求保存或修改默认配置时,才传 `changes` 与 `persist_defaults=true`;这些设置影响后续任务,不能为一次临时需求擅自改变以后所有任务。 +- 模型名称支持预设 `gpt-5.6-luna`、`mimo-v2.6-flash` 和自定义名称,取决于用户配置的 Responses 网关。默认思考强度 `high`、单次请求 300 秒、失败后最多重试 5 次、固定间隔 10 秒;默认关闭视频分析与录屏。不根据模型名称猜测网关能力。 +- 强度选 `default` 表示不发送该参数,和 `none`(显式要求关闭)不同。接口拒绝强度参数时明确呈现失败,先询问用户是否改用 `default`,不能静默降级或换模型。 +- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。 +- 当前视频开关仅提供保存的全局设置,没有单任务覆盖或独立视频文件分析接口。用户只要求临时分析且默认关闭时,明确说明这个限制;不能用“先开全局、执行后再关”影响同期任务。只有用户改为要求保存启用后,才修改配置并提交新任务。 +- 请求重试是模型调用恢复,不是测试用例重跑。批次默认失败继续、不自动重跑;服务重启后保留状态并暂停,用户明确要求后才继续或复制失败项。 +- 批次暂停只停止后续派发;要停止当前任务使用 `mobile_manage_task(action="stop", trace_id=...)`。停止可能保留已经产生的业务效果,不声称自动回滚。 + +需要编写外部可执行测试代码时,先通过 ARTEMIS 实际探索目标应用,确认页面、定位器和转换。优先动态定位,框架支持时才以已验证坐标兜底;在最终脚本中明确等待条件,不将 AI 思考耗时当作确定性等待。 diff --git a/skills/artemis-automation/agents/openai.yaml b/skills/artemis-automation/agents/openai.yaml new file mode 100644 index 0000000..a3f6b2a --- /dev/null +++ b/skills/artemis-automation/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "ARTEMIS Android 自动化" + short_description: "通过 MCP 完成 Android 单次与批量测试、失败诊断和报告交付" + default_prompt: "使用 $artemis-automation 检查设备,执行我指定的 Android 测试用例并交付有证据的报告。" diff --git a/skills/artemis-automation/references/batches.md b/skills/artemis-automation/references/batches.md new file mode 100644 index 0000000..9ae9085 --- /dev/null +++ b/skills/artemis-automation/references/batches.md @@ -0,0 +1,56 @@ +# 批量任务与报告交付 + +## 直接提交用例 + +用户要求执行明确的测试集合时,将每个独立目标转成一个 case。`parameters` 是简单值字典,`tags` 是字符串,不能填列表;目标中的 `{{参数名}}` 会替换成参数值。前置条件用自然语言描述真实起点。 + +先生成唯一 `request_id`(例如 UUID),保存在当前任务记录中,然后调用: + +```json +{ + "action": "create", + "request_id": "52c3e95d-7e31-4e41-b42d-27aa3774fb2d", + "batch": { + "name": "系统设置冒烟测试", + "device_serial": "用户已选择的设备编号", + "profile": "pro", + "formats": ["docx", "xlsx"], + "stop_on_failure": false, + "timeout_seconds": 1800, + "cases": [ + { + "name": "设置首页可达", + "goal": "打开系统设置,确认设置首页可见,不修改任何设置。", + "expected": "能看到设置首页及至少一个设置分类,记录实际文字与截图证据。", + "app": "com.android.settings", + "preconditions": "设备已解锁。", + "parameters": {}, + "tags": "冒烟,只读" + } + ] + } +} +``` + +`create` 只保存已校验的草稿。检查返回批次编号和范围,用户已经授权执行时接着 `mobile_batch(action="start", batch_id=...)`;用户只要求准备方案时留在草稿,不启动。保存 `batch_id`,用 `get` 跟踪各项状态。 + +创建请求超时可能已经落库。先 `lookup(request_id=...)`;需要重发时保留**相同 request_id 和相同内容**。服务会返回原批次,内容不同会拒绝。不要通过换编号“修复超时”,否则会形成重复批次。 + +## Excel 导入 + +`mobile_task_file(action="template")` 返回空白模板的本地路径。标准列为任务名称、任务目标、预期结果、目标应用、前置条件、输入参数(JSON)、标签。每行独立,不支持公式、宏、外部链接。 + +`preview` 返回 `valid/tasks/errors`,不会执行。所有错误处理完后,将 `tasks` 中每项的预览字段 `row` 去掉,作为 `batch.cases` 提交。不要跳过错误行却声称全部导入成功。 + +## 状态与异常 + +- 批次 `draft` 等待开始;`running` 调度中;`paused` 等待处理;`completed` 表示所有项进入终态,不代表全部通过。 +- 单项 `pending/queued/running/finalizing` 尚未结束,`completed/failed/cancelled/interrupted/skipped` 是终态。`finalizing` 在生成报告,不能提前宣称报告已完成。 +- `pause` 保留当前执行。设备断开、模型持续失败或任务达到批次时限会暂停后续派发;用诊断确认恢复后再响应用户的继续指令。 +- `resume` 只继续未开始项;`retry_failed` 复制失败、取消和中断项为新草稿,不修改原结果。必须来自用户的明确重跑请求。该动作不提供创建去重,响应不确定时先 `list` 查找新批次,不自动重复调用。 + +## 报告 + +`mobile_export_report(batch_id=...)` 下载批次 ZIP,包含汇总和各项报告,格式由创建批次时的设置决定。单任务传 `session_id`(单任务 MCP 的 `trace_id`)以及可选 `formats=["docx"]`。默认格式通过 `mobile_config(section="reports")` 查看。 + +结果文件在 MCP 主机的 `artifacts/mcp_exports/`。不要把 Office 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。 diff --git a/start.bat b/start.bat index e1105a6..82e8c5b 100644 --- a/start.bat +++ b/start.bat @@ -24,7 +24,8 @@ echo Artemis Autonomous Mobile Agent UI echo ====================================================== echo. -rem First run installs uv/Python/.venv as needed; later runs reuse them. +rem First run installs uv/Python/.venv; later runs check Git, dependencies and existing MCP clients. +rem Use -SkipUpdate to skip the Git source check and run local code. rem Use start.bat -SetupOnly to prepare Python without launching the UI. rem Use start-cn.bat or start.bat -Mirror China for China download mirrors. powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\start.ps1" %* diff --git a/tests/unit/admin_console/test_automation.py b/tests/unit/admin_console/test_automation.py new file mode 100644 index 0000000..6ddd485 --- /dev/null +++ b/tests/unit/admin_console/test_automation.py @@ -0,0 +1,252 @@ +"""Automation acceptance checks with isolated persistence and no real device side effects.""" + +from io import BytesIO +import json +import time +from unittest.mock import AsyncMock +import zipfile + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from openpyxl import load_workbook +import pytest + +from apps.admin_console.routers import automation as api +from apps.admin_console.services import automation_service as service +from apps.admin_console.services import report_export as reports +from apps.admin_console.services import task_import +from apps.admin_console.services.automation_store import AutomationStore +from artemis.data_engine.storage import StorageManager + + +@pytest.fixture +def isolated(tmp_path, monkeypatch): + db = tmp_path / "data.db" + StorageManager(db_path=db, base_trace_dir=tmp_path) + store = AutomationStore(db) + for module in (api, service, reports): + monkeypatch.setattr(module, "store", store) + monkeypatch.setattr(reports, "TRACES_PATH", tmp_path / "traces") + monkeypatch.setattr(reports, "EXPORT_ROOT", tmp_path / "exports") + from apps.admin_console.database.repositories.session_repository import SessionRepository + from apps.admin_console.database.repositories.step_repository import StepRepository + + monkeypatch.setattr(reports, "SessionRepository", lambda *a: SessionRepository(db)) + monkeypatch.setattr(reports, "StepRepository", lambda *a: StepRepository(db)) + return store + + +@pytest.fixture +def client(isolated): + app = FastAPI() + app.include_router(api.router) + return TestClient(app) + + +def payload(**extra): + return { + "name": "批次", + "cases": [{"name": "用例甲", "goal": "观察设置"}, {"name": "用例乙", "goal": "观察首页"}], + "device_serial": "test-device", + **extra, + } + + +def workbook_bytes(rows): + wb = load_workbook(BytesIO(task_import.template())) + for row in rows: + wb["任务"].append(row) + output = BytesIO() + wb.save(output) + return output.getvalue() + + +def test_template_preview_rejects_formula_and_bad_json(): + data = workbook_bytes( + [ + ["正常", "打开设置", "", "", "", '{"name":"测试"}', "冒烟"], + ["公式", "=SUM(1,2)"], + ["参数错误", "测试", "", "", "", "{"], + ] + ) + result = task_import.parse_workbook(data) + assert len(result["tasks"]) == 1 + assert [e["row"] for e in result["errors"]] == [3, 4] + assert not result["valid"] + + +def test_invalid_archive_and_empty_template(): + with pytest.raises(ValueError): + task_import.parse_workbook(b"not a zip") + assert not task_import.parse_workbook(task_import.template())["valid"] + + +def test_preview_does_not_enqueue(client, isolated): + response = client.post( + "/api/automation/import/preview", + files={"file": ("tasks.xlsx", workbook_bytes([["案例", "观察页面"]]))}, + ) + assert response.status_code == 200 + assert response.json()["valid"] + assert not isolated.jobs() + + +def test_preferences_and_required_device(client): + assert client.get("/api/automation/preferences").json()["formats"] == ["docx", "xlsx"] + assert client.put("/api/automation/preferences", json={"formats": ["xlsx"]}).status_code == 200 + assert client.put("/api/automation/preferences", json={"formats": []}).status_code == 422 + assert client.post("/api/automation/batches", json=payload(device_serial="")).status_code == 422 + + +def test_restart_never_repeats_started_work(isolated): + batch = service.create_batch(payload()) + service.batch_action(batch["id"], "resume") + job = isolated.jobs(batch["id"])[0] + isolated.save_job({**job, "status": "queued"}) + service.recover_batches() + assert isolated.get("batch", batch["id"])["status"] == "paused" + assert [j["status"] for j in isolated.jobs(batch["id"])] == ["interrupted", "pending"] + retry = service.batch_action(batch["id"], "retry_failed") + assert retry["status"] == "draft" + assert len(isolated.jobs(retry["id"])) == 1 + assert isolated.jobs(retry["id"])[0]["session_id"] != job["session_id"] + + +@pytest.mark.asyncio +async def test_batch_admission_idempotent_and_bound_device(isolated, monkeypatch): + from apps.admin_console import server + + batch = service.create_batch(payload()) + service.batch_action(batch["id"], "resume") + monkeypatch.setattr(service, "state", type("State", (), {"is_paused": False})()) + enqueue = AsyncMock(return_value={"tasks": [{"device_serial": "test-device"}]}) + monkeypatch.setattr(server.task_queue_service, "enqueue_tasks", enqueue) + monkeypatch.setattr( + "artemis.runtime.device_pool.validate_explicit_serial_async", AsyncMock(return_value=None) + ) + from artemis.runtime.device_pool import DeviceStatus + + monkeypatch.setattr( + "artemis.runtime.device_pool.try_list_devices_async", + AsyncMock(return_value=[DeviceStatus(serial="test-device", state="device")]), + ) + await service.tick_batches() + await service.tick_batches() + assert enqueue.await_count == 1 + assert enqueue.call_args.kwargs["device_serial"] == "test-device" + assert isolated.jobs(batch["id"])[1]["status"] == "pending" + + +@pytest.mark.asyncio +async def test_device_outage_pauses_without_consuming_pending_case(isolated, monkeypatch): + batch = service.create_batch(payload()) + service.batch_action(batch["id"], "resume") + monkeypatch.setattr(service, "state", type("State", (), {"is_paused": False})()) + monkeypatch.setattr( + "artemis.runtime.device_pool.try_list_devices_async", AsyncMock(return_value=[]) + ) + await service.tick_batches() + assert isolated.get("batch", batch["id"])["status"] == "paused" + assert all(job["status"] == "pending" for job in isolated.jobs(batch["id"])) + + +@pytest.mark.asyncio +async def test_fail_fast_includes_skipped_reports(isolated, monkeypatch): + batch = service.create_batch(payload(stop_on_failure=True)) + first = isolated.jobs(batch["id"])[0] + isolated.save_job({**first, "status": "failed"}) + service.batch_action(batch["id"], "resume") + await service.tick_batches() + assert [j["status"] for j in isolated.jobs(batch["id"])] == ["failed", "skipped"] + archive = reports.export_batch(batch["id"]) + with zipfile.ZipFile(archive) as z: + assert len(z.namelist()) == 6 # two summary files + two files per task + assert sum(n.endswith(".docx") for n in z.namelist()) == 3 + + +@pytest.mark.asyncio +async def test_report_failure_does_not_strand_job(isolated, monkeypatch): + job = isolated.jobs(service.create_batch(payload())["id"])[0] + monkeypatch.setattr( + reports, "export_session", lambda *a: (_ for _ in ()).throw(OSError("disk full")) + ) + await service.on_terminal({"session_id": job["session_id"]}, "completed") + saved = isolated.job(job["session_id"]) + assert saved["status"] == "completed" + assert "disk full" in saved["report_error"] + + +def test_reports_do_not_execute_formulas_or_invent_pass(isolated): + batch = service.create_batch(payload()) + job = isolated.jobs(batch["id"])[0] + isolated.save_job( + {**job, "case": {"name": "=1+1", "goal": '=HYPERLINK("x")'}, "status": "completed"} + ) + report = reports.session_report(job["session_id"]) + assert report["verdict"].startswith("未验证") + assert reports.verdict({"tests": {"passed": 2, "failed": 1}}) == "验证失败" + assert reports.verdict({"tests": {"passed": 2, "unchecked": 1}}) == "验证不完整" + paths = reports.export_session(job["session_id"]) + wb = load_workbook(next(p for p in paths if p.suffix == ".xlsx")) + assert wb["任务结果"]["B3"].data_type == "s" + with zipfile.ZipFile(next(p for p in paths if p.suffix == ".docx")) as doc: + assert "未验证".encode() in doc.read("word/document.xml") + + +def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated): + batch = service.create_batch(payload()) + first, second = isolated.jobs(batch["id"]) + isolated.save_job( + { + **first, + "status": "completed", + "created_at": 100, + "case": {"name": "搜索_%", "goal": "abc", "tags": "回归"}, + } + ) + isolated.save_job({**second, "status": "failed", "created_at": 200}) + assert client.get("/api/automation/history", params={"q": "_%"}).json()["total"] == 1 + assert ( + client.get("/api/automation/history", params={"q": "回归"}).json()["items"][0]["session_id"] + == first["session_id"] + ) + assert ( + client.get( + "/api/automation/history", params={"start": 150, "end": 250, "status": "failed"} + ).json()["total"] + == 1 + ) + assert ( + client.get("/api/automation/history", params={"size": 1, "page": 2}).json()["items"][0][ + "session_id" + ] + == first["session_id"] + ) + + +def test_batch_submission_is_atomic_and_idempotent(client, isolated): + value = payload(request_id="same-request") + first = client.post("/api/automation/batches", json=value) + second = client.post("/api/automation/batches", json=value) + assert first.status_code == second.status_code == 200 + assert first.json()["id"] == second.json()["id"] + assert len(isolated.jobs()) == 2 + assert len(isolated.records("batch")) == 1 + conflict = client.post("/api/automation/batches", json={**value, "name": "different"}) + assert conflict.status_code == 409 + lookup = client.get("/api/automation/batches/by-request/same-request").json() + assert lookup["id"] == first.json()["id"] + assert len(lookup["jobs"]) == 2 + + +def test_removed_replay_is_inert_but_history_survives(client, isolated): + batch = service.create_batch(payload()) + job = isolated.jobs(batch["id"])[0] + isolated.save_job({**job, "execution_mode": "strict", "workflow_id": "legacy"}) + service.recover_batches() + assert isolated.get("batch", batch["id"])["status"] == "cancelled" + assert all(j["status"] == "cancelled" for j in isolated.jobs(batch["id"])) + assert client.post(f"/api/automation/batches/{batch['id']}/resume").status_code == 409 + assert client.post(f"/api/automation/batches/{batch['id']}/retry_failed").status_code == 409 + assert client.get("/api/automation/workflows").status_code == 404 + assert client.get("/api/automation/history").json()["total"] == 2 diff --git a/tests/unit/admin_console/test_model_settings.py b/tests/unit/admin_console/test_model_settings.py index 7ba3da1..9d323c6 100644 --- a/tests/unit/admin_console/test_model_settings.py +++ b/tests/unit/admin_console/test_model_settings.py @@ -22,6 +22,8 @@ def isolated_settings(monkeypatch, tmp_path): "OPENAI_BASE_URL", "OPENAI_TIMEOUT_SECONDS", "OPENAI_MAX_RETRIES", + "OPENAI_RETRY_INTERVAL_SECONDS", + "OPENAI_REASONING_EFFORT", "ARTEMIS_VIDEO_ENABLED", "ARTEMIS_OUTPUTTER_ENABLED", "ARTEMIS_STEP_SUMMARIZER_ENABLED", @@ -85,7 +87,7 @@ def test_capabilities_persist_and_apply_to_new_tasks(isolated_settings): assert config.pro.video_analyzer.enabled is False assert config.outputter.enabled is False assert config.flash.step_summarizer.enabled is False - service.save_capability_settings(service.CapabilitySettings()) + service.save_capability_settings(service.CapabilitySettings(video_enabled=True)) assert load_agent_config().video_analyzer.enabled is True @@ -106,3 +108,34 @@ def test_key_not_in_validation_errors(isolated_settings): service.ModelSettingsUpdate(model="test", api_key="secret\nvalue") ) assert "secret" not in str(exc.value) + + +def test_reasoning_retry_settings_roundtrip(isolated_settings): + result = service.save_model_settings(service.ModelSettingsUpdate(model="gpt-5.6-luna")) + assert result["timeout_seconds"] == 300 + assert result["max_retries"] == 5 + assert result["retry_interval_seconds"] == 10 + assert result["reasoning_effort"] == "high" + saved = Settings(_env_file=isolated_settings) + assert saved.OPENAI_REASONING_EFFORT == "high" + assert parse_llm_config().operator.reasoning_effort == "high" + service.save_model_settings( + service.ModelSettingsUpdate(model="custom", reasoning_effort="default") + ) + assert parse_llm_config().operator.reasoning_effort == "default" + + +def test_responses_payload_has_reasoning_and_no_sdk_retry(monkeypatch): + from artemis.llm.router import ModelEndpoint, ModelFactory + from unittest.mock import Mock + + factory = Mock() + monkeypatch.setattr("langchain_openai.ChatOpenAI", factory) + ModelFactory.create_model( + ModelEndpoint(api_key="test-only", reasoning_effort="high", max_retries=5) + ) + assert factory.call_args.kwargs["reasoning_effort"] == "high" + assert factory.call_args.kwargs["max_retries"] == 0 + assert factory.call_args.kwargs["use_responses_api"] is True + ModelFactory.create_model(ModelEndpoint(api_key="test-only", reasoning_effort="default")) + assert "reasoning_effort" not in factory.call_args.kwargs diff --git a/tests/unit/agents/test_video_reliability.py b/tests/unit/agents/test_video_reliability.py index 77d27e2..e38b45b 100644 --- a/tests/unit/agents/test_video_reliability.py +++ b/tests/unit/agents/test_video_reliability.py @@ -241,8 +241,8 @@ async def test_invoke_with_retry_caps_unknown_failures_by_policy(): with patch.object(va.asyncio, "sleep", new=AsyncMock()): with pytest.raises(RuntimeError): await va._invoke_with_retry(op, "test-op", max_attempts=5) - # UNKNOWN failures follow the shared policy, not the caller's outer cap. - assert calls == retry_policy_for(FailureCategory.UNKNOWN).max_attempts + # Respect both the configured policy and the caller's explicit cap. + assert calls == min(5, retry_policy_for(FailureCategory.UNKNOWN).max_attempts) # ------------------------------------------------ mid-run agentic degrade diff --git a/tests/unit/mcp/test_automation_tools.py b/tests/unit/mcp/test_automation_tools.py new file mode 100644 index 0000000..abb0b26 --- /dev/null +++ b/tests/unit/mcp/test_automation_tools.py @@ -0,0 +1,95 @@ +"""MCP transport contracts: no real device actions, files and HTTP are isolated.""" + +from unittest.mock import AsyncMock +import httpx +import pytest + +from mcp_server.tools import automation as tools + + +@pytest.mark.asyncio +async def test_config_requires_explicit_persistence_and_excludes_secrets(monkeypatch): + request = AsyncMock( + return_value={ + "model": "old", + "base_url": "https://example.test/v1", + "api_key_configured": True, + "env_path": "/private/.env", + "reasoning_effort": "high", + } + ) + monkeypatch.setattr(tools, "_request", request) + with pytest.raises(ValueError, match="persist_defaults"): + await tools.mobile_config(changes={"model": "new"}) + with pytest.raises(ValueError, match="密钥"): + await tools.mobile_config(changes={"api_key": "never-sent"}, persist_defaults=True) + request.assert_not_awaited() + await tools.mobile_config(changes={"model": "new"}, persist_defaults=True) + payload = request.call_args.kwargs["json"] + assert payload == { + "model": "new", + "base_url": "https://example.test/v1", + "reasoning_effort": "high", + } + + +@pytest.mark.asyncio +async def test_preview_is_read_only_and_create_is_draft(monkeypatch, tmp_path): + source = tmp_path / "cases.xlsx" + source.write_bytes(b"test workbook") + request = AsyncMock(return_value={"valid": True, "tasks": [], "errors": []}) + monkeypatch.setattr(tools, "_request", request) + await tools.mobile_task_file("preview", str(source)) + assert request.call_args.args == ("POST", "/api/automation/import/preview") + request.reset_mock() + with pytest.raises(ValueError): + await tools.mobile_batch("create", batch={"name": "x"}) + request.assert_not_awaited() + await tools.mobile_batch("create", request_id="stable-id", batch={"name": "x"}) + assert request.await_count == 1 + assert request.call_args.kwargs["json"]["request_id"] == "stable-id" + assert request.call_args.args[1] == "/api/automation/batches" + + +@pytest.mark.asyncio +async def test_running_batch_start_is_idempotent(monkeypatch): + request = AsyncMock(return_value={"id": "batch", "status": "running"}) + monkeypatch.setattr(tools, "_request", request) + await tools.mobile_batch("start", batch_id="batch") + assert request.await_count == 1 + assert request.call_args.args[0] == "GET" + + +@pytest.mark.asyncio +async def test_download_writes_actual_binary_atomically(monkeypatch, tmp_path): + monkeypatch.setattr(tools, "EXPORT_DIR", tmp_path) + monkeypatch.setattr(tools, "_base_url", AsyncMock(return_value="http://test")) + client_type = httpx.AsyncClient + transport = httpx.MockTransport( + lambda request: httpx.Response(200, content=b"PK\x03\x04report-bytes") + ) + monkeypatch.setattr( + tools.httpx, "AsyncClient", lambda **kw: client_type(transport=transport, **kw) + ) + result = await tools._download("/api/automation/reports/test/docx", "test.docx") + from pathlib import Path + + assert Path(result["path"]).read_bytes() == b"PK\x03\x04report-bytes" + assert Path(result["path"]).is_absolute() + assert result["download_url"] == "http://test/api/automation/reports/test/docx" + assert not list(tmp_path.glob("*.part")) + + +@pytest.mark.asyncio +async def test_export_uses_saved_formats_without_mutating_execution(monkeypatch): + request = AsyncMock( + side_effect=[{"formats": ["xlsx"]}, {"verdict": "未验证", "status": "completed"}] + ) + download = AsyncMock(return_value={"path": "/exports/report.xlsx"}) + monkeypatch.setattr(tools, "_request", request) + monkeypatch.setattr(tools, "_download", download) + result = await tools.mobile_export_report(session_id="session") + assert result["report"]["verdict"] == "未验证" + assert len(result["files"]) == 1 + assert all(call.args[0] == "GET" for call in request.call_args_list) + assert download.call_args.args[0].endswith("/xlsx") diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index e3163e1..81b6c28 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -197,6 +197,12 @@ def test_cli_mcp_generate_config_antigravity(): "mobile_get_device_state", "mobile_inspect_trace", "mobile_diagnose", + "mobile_workspace", + "mobile_config", + "mobile_task_file", + "mobile_batch", + "mobile_search_history", + "mobile_export_report", } assert all(tool["eager"] is True for tool in legacy_config["tools"].values()) @@ -236,6 +242,12 @@ def test_cli_mcp_generate_config_codex(): "mobile_get_device_state", "mobile_inspect_trace", "mobile_diagnose", + "mobile_workspace", + "mobile_config", + "mobile_task_file", + "mobile_batch", + "mobile_search_history", + "mobile_export_report", ] @@ -433,6 +445,12 @@ def test_cli_mcp_install_codex_preserves_config_and_is_idempotent(tmp_path, monk "mobile_get_device_state", "mobile_inspect_trace", "mobile_diagnose", + "mobile_workspace", + "mobile_config", + "mobile_task_file", + "mobile_batch", + "mobile_search_history", + "mobile_export_report", ] assert data["mcp_servers"]["artemis"]["env"]["PYTHONPATH"] == str(tmp_path) assert config_text.count("# BEGIN ARTEMIS MCP CONFIG") == 1 diff --git a/tests/unit/test_installation_update.py b/tests/unit/test_installation_update.py new file mode 100644 index 0000000..a1e16a3 --- /dev/null +++ b/tests/unit/test_installation_update.py @@ -0,0 +1,158 @@ +"""Exercise upgrades against disposable Git repos and client configs, never the user's repo.""" + +import importlib.util +import json +from pathlib import Path +import shutil +import subprocess +import tomllib + +import pytest + +MODULE_PATH = Path(__file__).resolve().parents[2] / "scripts/update_installation.py" +spec = importlib.util.spec_from_file_location("installation_update", MODULE_PATH) +update = importlib.util.module_from_spec(spec) +spec.loader.exec_module(update) + + +def git(path, *args): + result = subprocess.run( + ["git", "-C", str(path), *args], capture_output=True, text=True, timeout=15 + ) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +@pytest.fixture +def repos(tmp_path): + if not shutil.which("git"): + pytest.skip("Git required") + remote = tmp_path / "upstream.git" + remote.mkdir() + git(remote, "init", "--bare") + author = tmp_path / "author" + author.mkdir() + git(author, "init", "-b", "main") + git(author, "config", "user.name", "Test Fixture") + git(author, "config", "user.email", "fixture@example.test") + (author / "source.txt").write_text("version1") + (author / ".gitignore").write_text(".env\n.venv/\n") + git(author, "add", "source.txt", ".gitignore") + git(author, "commit", "-m", "initial") + git(author, "remote", "add", "origin", str(remote)) + git(author, "push", "-u", "origin", "main") + installed = tmp_path / "installed with spaces" + git(tmp_path, "clone", "-b", "main", str(remote), str(installed)) + (installed / ".env").write_text("secret stays local") + (author / "source.txt").write_text("version2") + git(author, "add", "source.txt") + git(author, "commit", "-m", "upgrade") + git(author, "push") + return installed, author + + +def test_fast_forward_update_preserves_env_and_is_repeatable(repos): + installed, author = repos + assert update.update_source(installed, port=1) == 10 + assert (installed / "source.txt").read_text() == "version2" + assert (installed / ".env").read_text() == "secret stays local" + assert git(installed, "rev-parse", "HEAD") == git(author, "rev-parse", "HEAD") + assert update.update_source(installed, port=1) == 0 + + +def test_dirty_checkout_is_preserved(repos): + installed, _ = repos + before = git(installed, "rev-parse", "HEAD") + (installed / "source.txt").write_text("user edits") + assert update.update_source(installed, port=1) == 0 + assert git(installed, "rev-parse", "HEAD") == before + assert (installed / "source.txt").read_text() == "user edits" + + +def test_diverged_checkout_is_not_merged(repos): + installed, _ = repos + git(installed, "config", "user.name", "Test Fixture") + git(installed, "config", "user.email", "fixture@example.test") + (installed / "local.txt").write_text("local commit") + git(installed, "add", "local.txt") + git(installed, "commit", "-m", "local") + before = git(installed, "rev-parse", "HEAD") + assert update.update_source(installed, port=1) == 0 + assert git(installed, "rev-parse", "HEAD") == before + + +def test_zip_install_continues_without_git_mutation(tmp_path, capsys): + assert update.update_source(tmp_path, port=1) == 0 + assert not (tmp_path / ".git").exists() + assert "Git" in capsys.readouterr().out + + +def test_codex_refresh_preserves_credentials_restrictions_and_other_settings(tmp_path): + root = tmp_path / "project" + path = tmp_path / "config.toml" + original = f"""model = "custom" +[mcp_servers.artemis] +command = "old-python" +cwd = {json.dumps(str(root))} +args = ["-m", "mcp_server"] +enabled = false +enabled_tools = [ + "mobile_run_task", +] +disabled_tools = ["mobile_config"] +tool_timeout_sec = 900 +[mcp_servers.artemis.env] +OPENAI_API_KEY = "test-only-secret" +[mcp_servers.other] +command = "leave-alone" +""" + path.write_text(original, encoding="utf-8") + assert update.refresh_codex(path, root, "new-python", ("mobile_run_task", "mobile_batch")) + data = tomllib.loads(path.read_text(encoding="utf-8")) + config = data["mcp_servers"]["artemis"] + assert config["enabled"] is False + assert config["disabled_tools"] == ["mobile_config"] + assert config["tool_timeout_sec"] == 900 + assert config["env"]["OPENAI_API_KEY"] == "test-only-secret" + assert config["enabled_tools"] == ["mobile_run_task", "mobile_batch"] + assert data["mcp_servers"]["other"]["command"] == "leave-alone" + assert not update.refresh_codex(path, root, "new-python", ("mobile_run_task", "mobile_batch")) + assert path.with_name(path.name + ".artemis-update.bak").read_text(encoding="utf-8") == original + + +def test_json_only_updates_existing_matching_project(tmp_path): + path = tmp_path / "mcp.json" + root = tmp_path / "project" + config = { + "cwd": str(root), + "command": "old", + "args": ["-m", "mcp_server"], + "disabled": True, + "env": {"CUSTOM": "preserved"}, + "autoApprove": [], + } + path.write_text(json.dumps({"mcpServers": {"artemis": config, "other": {"command": "keep"}}})) + assert not update.refresh_json( + path, ("mcpServers",), tmp_path / "different", "new", ("mobile_batch",) + ) + assert update.refresh_json(path, ("mcpServers",), root, "new", ("mobile_batch",)) + result = json.loads(path.read_text())["mcpServers"] + assert result["artemis"]["env"] == {"CUSTOM": "preserved"} + assert result["artemis"]["disabled"] is True + assert result["other"] == {"command": "keep"} + missing = tmp_path / "not-installed.json" + assert not update.refresh_json(missing, ("mcpServers",), root, "new", ()) + assert not missing.exists() + + +def test_frontend_fingerprint_detects_lock_source_and_deleted_assets(tmp_path): + ui = tmp_path / "apps/showcase_ui" + (ui / "src").mkdir(parents=True) + (ui / "src/app.ts").write_text("one") + (ui / "package-lock.json").write_text("lock1") + first = update.frontend_fingerprint(tmp_path) + (ui / "package-lock.json").write_text("lock2") + second = update.frontend_fingerprint(tmp_path) + assert first != second + (ui / "src/app.ts").unlink() + assert update.frontend_fingerprint(tmp_path) != second diff --git a/tests/unit/test_llm_gateway.py b/tests/unit/test_llm_gateway.py index b2d6be9..6699d9f 100644 --- a/tests/unit/test_llm_gateway.py +++ b/tests/unit/test_llm_gateway.py @@ -239,3 +239,57 @@ async def test_mid_stream_failure_records_stream_reset_payload(monkeypatch): assert payload["reason"] == "mid_stream_failure" assert "stream_exec_id" in payload assert "lower API priority" in payload["message"] + + +@pytest.mark.asyncio +async def test_configured_retry_budget_is_shared_by_identical_fallback(monkeypatch): + from unittest.mock import AsyncMock + from artemis.llm.reliability import retry_policy_for + from artemis.llm.router import ModelEndpoint + + monkeypatch.setattr(llm_service, "retry_policy_for", retry_policy_for) + monkeypatch.setattr(llm_service.settings, "OPENAI_MAX_RETRIES", 5) + monkeypatch.setattr(llm_service.settings, "OPENAI_RETRY_INTERVAL_SECONDS", 10) + monkeypatch.setattr(llm_service, "_wait_for_resume", AsyncMock(return_value=False)) + sleep = AsyncMock() + monkeypatch.setattr(llm_service.asyncio, "sleep", sleep) + + class Fails: + def __init__(self): + self.calls = 0 + + async def ainvoke(self, *args, **kwargs): + self.calls += 1 + if self.calls % 2: + raise ConnectionError("connection reset") + raise TimeoutError("timeout") + + main, fallback = Fails(), Fails() + endpoint = ModelEndpoint(model_name="test", api_base="https://example.test/v1", max_retries=5) + primary = RobustChatModelWrapper(main, endpoint=endpoint) + secondary = RobustChatModelWrapper(fallback, endpoint=endpoint.model_copy()) + with pytest.raises(LLMExhaustedError): + await with_fallback(lambda: primary.ainvoke([]), lambda: secondary.ainvoke([])) + assert main.calls == 6 + assert fallback.calls == 0 + assert [call.args[0] for call in sleep.call_args_list] == [10] * 5 + assert llm_service._FAILED_ENDPOINTS.get() is None + + +@pytest.mark.asyncio +async def test_request_deadline_cancels_attempt_and_respects_zero_retries(monkeypatch): + import asyncio + from unittest.mock import AsyncMock + + monkeypatch.setattr(llm_service, "_wait_for_resume", AsyncMock(return_value=False)) + cancelled = asyncio.Event() + + async def pending(): + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + with pytest.raises(LLMExhaustedError): + await llm_service._run_with_recovery(pending, request_timeout=0.005, max_retries=0) + assert cancelled.is_set() diff --git a/tests/unit/test_openai_responses.py b/tests/unit/test_openai_responses.py index 13fa55a..b4ab466 100644 --- a/tests/unit/test_openai_responses.py +++ b/tests/unit/test_openai_responses.py @@ -157,7 +157,7 @@ def test_client_settings_and_cache_credentials(monkeypatch, max_retries): endpoint = ModelEndpoint(api_key="test", model_name="gpt-5-mini", reasoning_effort="low") model = ModelFactory.create_model(endpoint) assert model.use_responses_api is True - assert model.max_retries == max_retries + assert model.max_retries == 0 # Gateway owns retries, never multiply at SDK layer. assert model.reasoning_effort == "low" dynamic = ModelEndpoint() key = dynamic.cache_key() diff --git a/uv.lock b/uv.lock index 745461f..a6afad0 100644 --- a/uv.lock +++ b/uv.lock @@ -229,11 +229,13 @@ dependencies = [ { name = "mcp" }, { name = "numpy" }, { name = "opencv-python" }, + { name = "openpyxl" }, { name = "pillow" }, { name = "posthog" }, { name = "psutil" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "python-docx" }, { name = "python-dotenv" }, { name = "requests" }, { name = "retry" }, @@ -291,6 +293,7 @@ requires-dist = [ { name = "mcp", specifier = ">=1.26.0" }, { name = "numpy", specifier = ">=1.26.0" }, { name = "opencv-python", specifier = ">=4.9.0.80" }, + { name = "openpyxl", specifier = ">=3.1.5" }, { name = "pillow", specifier = ">=10.0.0" }, { name = "posthog", specifier = ">=3.5.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.5.0" }, @@ -301,6 +304,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.1" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" }, + { name = "python-docx", specifier = ">=1.2.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "requests", specifier = ">=2.31.0" }, { name = "retry", specifier = ">=0.9.2" }, @@ -934,6 +938,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/b8/6648cadf38b61045262bf2a534e526f04eb833275ad1fc77a2026e9f7e3a/editor-1.8.0-py3-none-any.whl", hash = "sha256:7d47ff88ae6c5f6c43d28c30b6f7fd59a24741175a1771ab06c969d946d7dfd0", size = 4012, upload-time = "2026-05-09T13:43:00.847Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "executing" version = "2.2.1" @@ -2378,6 +2391,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", size = 44000345, upload-time = "2026-07-02T05:49:54.971Z" }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "orjson" version = "3.12.0" @@ -3005,6 +3030,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/45/689603d04b3bb8d7faa00f25c24acef993aab7813b3dbbfc472a459ab0b5/python_discovery-1.5.2-py3-none-any.whl", hash = "sha256:3e338c2d0f15dfaeea57493f4c2c6caebe0e998ea815c30ae8bf8ee21f1112d3", size = 38350, upload-time = "2026-08-12T14:05:25.113Z" }, ] +[[package]] +name = "python-docx" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.3"