feat: enable full AI automation and safe startup updates

This commit is contained in:
chenmingxuan committed 2026-09-22 23:50:03 +08:00
1 parent 2ddec53954
commit 7b1a0755e2
62 files changed
+3121 -238

No files matched your search

+13 -5
View File
@@ -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
+19 -4
View File
@@ -1,3 +1,7 @@
> AI 接入:[全流程 MCP 与可分发 Skill](docs/ai-automation.md)。
> 新增工作区功能:[Word/Excel 报告、Excel 批量任务与历史检索](docs/automation-workspace.md)。
<p align="center">
<img src="./docs/assets/artemis-banner-cn.png?v=7" alt="ARTEMIS Banner" width="100%" />
</p>
@@ -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 函数调用并在本地校验。音频输入是否可用取决于端点能力,拒绝音频时视频分析会尝试仅分析画面。
+202
View File
@@ -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}
+6
View File
@@ -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",
+7 -1
View File
@@ -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():
+14
View File
@@ -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:
@@ -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)
@@ -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()
@@ -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.")])
@@ -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
+127
View File
@@ -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 '仅确认任务完成情况,不宣称测试通过'}"
)
@@ -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 = [
@@ -42,7 +42,9 @@ limitations under the License.
<span class="material-symbols-outlined tab-icon">description</span>
<span class="tab-label">笔记与计划</span>
</button>
<button #automationOrigin="cdkOverlayOrigin" cdkOverlayOrigin class="vscode-tab-btn" [class.active]="isAutomationOpen()" (click)="isAutomationOpen.set(!isAutomationOpen())" title="报告与批量" aria-label="报告与批量"><span class="material-symbols-outlined tab-icon">assignment</span><span>报告与批量</span></button>
</div>
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="automationOrigin" [cdkConnectedOverlayOpen]="isAutomationOpen()" [cdkConnectedOverlayHasBackdrop]="true" cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop" (backdropClick)="isAutomationOpen.set(false)" [cdkConnectedOverlayPositions]="dropdownPositions"><div class="vscode-dropdown-menu" style="width:min(560px,calc(100vw - 24px));max-height:85vh;overflow:auto;padding:20px"><div class="popover-title-header" style="justify-content:space-between;margin-bottom:6px"><span>报告与自动化</span><button type="button" (click)="isAutomationOpen.set(false)" aria-label="关闭报告与自动化" style="border:0;background:transparent;color:#64748b;cursor:pointer;padding:4px"><span class="material-symbols-outlined">close</span></button></div><app-automation-panel /></div></ng-template>
<!-- Dropdown Popover Panel for Task Queue -->
<ng-template
@@ -61,7 +63,7 @@ limitations under the License.
</div>
<div class="dropdown-section-label">
<span class="material-symbols-outlined icon-bolt">bolt</span>
<span>Active Queue ({{ activeQueue().length }})</span>
<span>当前队列 ({{ activeQueue().length }})</span>
</div>
@if (activeQueue().length === 0) {
<div class="dropdown-empty-state">
@@ -127,65 +129,7 @@ limitations under the License.
</div>
</div>
}
<div class="dropdown-section-label history-label">
<span class="material-symbols-outlined icon-history">history</span>
<span>Past Runs & History ({{ historyTasks().length }})</span>
</div>
@if (historyTasks().length === 0) {
<div class="dropdown-empty-state">
暂无历史任务
</div>
}
<div class="dropdown-history-list">
@for (session of historyTasks(); track trackSession($index, session)) {
<div
class="dropdown-item-card"
[class.selected]="agentService.currentSessionId() === session.session_id"
[class.running]="getTaskStatus(session) === 'running'"
(click)="selectTask(session.session_id, $event)"
>
<div class="item-header">
<span class="task-badge" [ngClass]="getTaskStatus(session)">
@if (getTaskStatus(session) === 'running') {
<span class="status-dot"></span>
}
{{ getTaskStatus(session) | uiLabel }}
</span>
<div class="item-header-actions">
@if (getDeviceSerial(session)) {
<span class="item-device" [title]="'Device: ' + getDeviceSerial(session)">
<span class="material-symbols-outlined device-icon">phone_android</span>
<span class="device-name">{{ getDeviceSerial(session) }}</span>
</span>
}
@if (getDeviceSerial(session)) {
<span class="item-time-divider">·</span>
}
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
@if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') {
<button
type="button"
class="btn-delete-card"
(click)="deleteTask(session.session_id, $event)"
title="删除此任务"
aria-label="删除此任务"
>
<span class="material-symbols-outlined">delete</span>
</button>
}
</div>
</div>
<div class="item-goal" title="{{ session.initial_goal }}">
{{ session.initial_goal || '未命名任务' }}
</div>
<div class="item-footer">
<span class="item-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
</div>
</div>
}
</div>
<app-task-history />
</div>
</ng-template>
@@ -483,7 +427,7 @@ limitations under the License.
<button
class="btn-screen-recording"
[class.active]="agentService.isVideoWindowOpen()"
[class.recording]="agentService.isCurrentSessionRunning()"
[class.recording]="agentService.isCurrentSessionRunning() && agentService.currentSession()?.recording_status === 'recording'"
[class.processing]="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing'"
[class.has-video]="!agentService.isCurrentSessionRunning() && !!agentService.currentSessionVideoUrl()"
[class.has-steps]="!agentService.isCurrentSessionRunning() && !agentService.currentSessionVideoUrl() && agentService.hasCurrentSessionStepFrames()"
@@ -493,7 +437,7 @@ limitations under the License.
>
@if (agentService.isCurrentSessionRunning()) {
<span class="material-symbols-outlined video-icon">
videocam
{{ agentService.currentSession()?.recording_status === 'recording' ? 'videocam' : 'progress_activity' }}
</span>
}
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing') {
@@ -522,7 +466,7 @@ limitations under the License.
</span>
}
@if (agentService.isCurrentSessionRunning()) {
<span class="btn-text">正在录制…</span>
<span class="btn-text">{{ agentService.currentSession()?.recording_status === 'recording' ? '正在录制…' : '任务执行中…' }}</span>
}
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing') {
<span class="btn-text">正在准备…</span>
@@ -1,3 +1,5 @@
import { TaskHistoryComponent } from '../task-history/task-history.component';
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
import { UiLabelPipe } from '../../utils/ui-label.pipe';
/**
* Copyright 2026 Google LLC
@@ -234,7 +236,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
@Component({
selector: 'app-agent-stream',
standalone: true,
imports: [UiLabelPipe, CommonModule, FormsModule, OverlayModule],
imports: [UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent],
templateUrl: './agent-stream.component.html',
styleUrl: './agent-stream.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -267,6 +269,7 @@ export class AgentStreamComponent implements AfterViewInit {
// Top Nav Dropdown States
public isTaskDropdownOpen = signal<boolean>(false);
public isNotesDropdownOpen = signal<boolean>(false);
public isAutomationOpen = signal(false);
// Run info popover (opened from the Flash / Pro chip): elapsed time, token
// usage, executor context share and the Pro tuning the run started with.
@@ -1555,7 +1558,7 @@ export class AgentStreamComponent implements AfterViewInit {
public getScreenRecordingButtonTitle(): string {
if (this.agentService.isCurrentSessionRunning()) {
return "任务正在运行(录屏中)";
return this.agentService.currentSession()?.recording_status === 'recording' ? "任务正在运行(录屏中)" : "任务正在运行";
}
if (this.agentService.currentSessionRecordingStatus() === 'processing') {
return "正在准备屏幕录像…";
@@ -0,0 +1,53 @@
<nav class="tabs" aria-label="自动化工具">
<button [class.selected]="tab() === 'reports'" (click)="tab.set('reports')">结论报告</button>
<button [class.selected]="tab() === 'batches'" (click)="tab.set('batches')">批量任务</button>
</nav>
@if (error()) { <p class="error" role="alert">{{error()}}</p> }
@if (notice()) { <p class="notice" role="status">{{notice()}}</p> }
@if (tab() === 'reports') {
<section><h3>默认报告格式</h3><p>任务结束后自动生成。批次报告包同时包含汇总和每个任务的报告。</p>
<div class="format-options"><label><input type="checkbox" [(ngModel)]="word"> Word 文档</label>
<label><input type="checkbox" [(ngModel)]="excel"> Excel 表格</label></div>
<button [disabled]="busy()" (click)="savePreferences()">保存默认设置</button>
</section>
<section><h3>当前任务报告</h3>
@if (report(); as r) {
<strong>{{r.name}}</strong><p>{{label(r.status)}} · {{r.verdict}}</p>
<div class="actions"><a [href]="'/api/automation/reports/'+r.id+'/docx'">下载 Word</a><a [href]="'/api/automation/reports/'+r.id+'/xlsx'">下载 Excel</a></div>
<p class="muted">报告包含目标、预期、实际结论、步骤与截图证据。执行中的下载为当前快照。</p>
<pre class="preview">{{r.content}}</pre>
} @else { <p>从任务队列或历史记录中选择任务,即可下载报告。</p> }
</section>
}
@if (tab() !== 'reports') {
<section class="device"><label>执行设备<select [(ngModel)]="device"><option value="">请选择设备</option>@for(d of system.connectedDevices(); track d.serial){<option [value]="d.serial">{{d.serial}}</option>}</select></label></section>
}
@if (tab() === 'batches') {
<section><h3>导入任务</h3><p>每行一个用例,填写名称与目标。预期结果、应用、前置条件、参数和标签可选。</p>
<div class="actions"><a href="/api/automation/template">下载 Excel 模板</a><label class="upload">导入 Excel<input type="file" accept=".xlsx" [disabled]="busy()" (change)="upload($event)"></label></div>
@if(preview(); as p) {
<p>有效任务 {{p.tasks.length}} 条 · 错误 {{p.errors.length}} 条</p>
@for(e of p.errors; track e.row){<p class="error">第 {{e.row}} 行:{{e.message}}</p>}
<div class="table-wrap"><table><thead><tr><th>行</th><th>名称</th><th>目标</th></tr></thead><tbody>@for(t of p.tasks; track t.row){<tr><td>{{t.row}}</td><td>{{t.name}}</td><td>{{t.goal}}</td></tr>}</tbody></table></div>
<label>批次名称<input [(ngModel)]="name" maxlength="200"></label>
<div class="fields"><label>执行模式<select [(ngModel)]="profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label><label>单任务时限(秒)<input type="number" min="30" max="86400" [(ngModel)]="timeout"></label></div>
<label><input type="checkbox" [(ngModel)]="stopOnFailure"> 失败即停止后续任务(默认继续)</label>
<label><input type="checkbox" [(ngModel)]="word"> Word</label><label><input type="checkbox" [(ngModel)]="excel"> Excel</label>
<button class="primary" [disabled]="busy() || !p.valid || !device || !name.trim() || !formats().length" (click)="startBatch()">确认导入并开始执行</button>
}
</section>
<section><h3>批次记录</h3>
@for(b of batches(); track b.id){<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}}</span></button>}
@if(!batches().length){<p>暂无批次。</p>}
</section>
@if(batch(); as b){<section><h3>{{b.name}}</h3><p>{{label(b.status)}} · {{b.reason}}</p>
<div class="actions">
@if(b.status === 'running'){<button [disabled]="busy()" (click)="batchAction('pause')">暂停后续任务</button>}
@if(b.status === 'draft' || b.status === 'paused'){<button [disabled]="busy()" (click)="batchAction('resume')">{{b.status === 'draft' ? '开始执行' : '继续未开始任务'}}</button>}
@if(b.status !== 'running'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
<a [href]="'/api/automation/batches/'+b.id+'/archive'">下载全部报告 ZIP</a>
</div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p>
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true)">{{j.position}}. {{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
</section>}
}
@@ -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;}
@@ -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<any[]>([]); preview = signal<any>(null);
batch = signal<any>(null); report = signal<any>(null);
word = true; excel = true; name = ''; device = ''; profile = 'flash'; stopOnFailure = false; timeout = 1800;
private destroyed = false;
constructor() {
const destroy = inject(DestroyRef);
this.http.get<any>('/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<any>(`/api/automation/reports/${encodeURIComponent(sid)}`).subscribe({ next: r => { if (this.agent.currentSessionId() === sid) this.report.set(r); }, error: () => {} });
});
}
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告'} as Record<string,string>)[s] || s; }
async action(fn: () => Promise<void>) {
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<any[]>('/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<any>(`/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<any>(`/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(); }); }
}
@@ -35,6 +35,7 @@ limitations under the License.
<span class="material-symbols-outlined tab-icon">description</span>
<span class="tab-text">笔记与计划</span>
</button>
<button class="tab-selector-btn" [class.active]="agentService.activeTab() === 'automation'" (click)="agentService.activeTab.set('automation')"><span class="material-symbols-outlined tab-icon">automation</span><span class="tab-text">报告与批量</span></button>
</div>
<div class="header-right">
@@ -54,11 +55,12 @@ limitations under the License.
<!-- Scrollable Messages/Content Area -->
<main class="chat-messages">
@if (agentService.activeTab() === 'automation') { <app-automation-panel /> }
<!-- Tasks Tab Content -->
@if (agentService.activeTab() === 'tasks') {
<!-- Active Queue Section -->
<div class="queue-section-header">
<span class="material-symbols-outlined section-icon">bolt</span> Active Queue ({{ activeQueue().length }})
<span class="material-symbols-outlined section-icon">bolt</span> 当前队列 ({{ activeQueue().length }})
</div>
@if (activeQueue().length === 0) {
<div class="empty-section-placeholder">
@@ -125,68 +127,7 @@ limitations under the License.
</div>
</div>
}
<!-- Collapsible History Section -->
<details class="history-section" open>
<summary class="history-summary">
<span class="material-symbols-outlined section-icon">history</span> Past Runs & History ({{ historyTasks().length }})
</summary>
<div class="history-list-wrapper">
@if (historyTasks().length === 0) {
<div class="empty-section-placeholder">
暂无历史任务。
</div>
}
@for (session of historyTasks(); track trackSession($index, session)) {
<div
class="task-card"
[class.selected]="agentService.currentSessionId() === session.session_id"
[class.running]="getTaskStatus(session) === 'running'"
(click)="selectTask(session.session_id)"
>
<div class="task-card-header">
<span class="task-badge" [ngClass]="getTaskStatus(session)">
@if (getTaskStatus(session) === 'running') {
<span class="status-dot"></span>
}
{{ getTaskStatus(session) | uiLabel }}
</span>
<div class="task-card-header-actions">
@if (getDeviceSerial(session)) {
<span class="task-device" [title]="'Device: ' + getDeviceSerial(session)">
<span class="material-symbols-outlined device-icon">phone_android</span>
<span class="device-name">{{ getDeviceSerial(session) }}</span>
</span>
}
@if (getDeviceSerial(session)) {
<span class="task-time-divider">·</span>
}
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
@if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') {
<button
type="button"
class="btn-delete-card"
(click)="deleteTask(session.session_id, $event)"
title="删除此任务"
aria-label="删除此任务"
[disabled]="isSubmitting()"
>
<span class="material-symbols-outlined">delete</span>
</button>
}
</div>
</div>
<div class="task-goal" title="{{ session.initial_goal }}">
{{ session.initial_goal || '未命名任务' }}
</div>
<div class="task-card-footer">
<span class="task-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
</div>
</div>
}
</div>
</details>
<app-task-history />
}
<!-- Notes Tab Content -->
@@ -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
@@ -36,7 +36,7 @@ interface Capabilities {
export class CapabilitySettingsComponent implements OnInit {
changed = output<Capabilities>();
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<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
@@ -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();
@@ -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()" />
<small>填写接口根地址,通常包含 /v1,无需添加 /responses。</small>
<label for="model-name">模型名称</label>
<input id="model-name" name="model" [(ngModel)]="model" required placeholder="输入接口支持的模型名"
<input id="model-name" name="model" list="model-presets" [(ngModel)]="model" required placeholder="输入接口支持的模型名"
[disabled]="busy() || loading()" />
<datalist id="model-presets"><option value="gpt-5.6-luna"></option><option value="mimo-v2.6-flash"></option></datalist>
<label for="model-reasoning">思考强度</label>
<select id="model-reasoning" name="reasoning" [(ngModel)]="reasoning" [disabled]="busy() || loading()">
<option value="default">由模型默认</option><option value="none">关闭(none)</option>
<option value="minimal">最低(minimal)</option><option value="low">低(low)</option>
<option value="medium">中(medium)</option><option value="high">高(high)</option><option value="xhigh">最高(xhigh)</option>
</select>
<small>接口需支持所选强度;不支持时会明确报错,可手动选择“由模型默认”。</small>
<label for="model-key">API Key <span>{{ keyConfigured() ? '(已配置,留空保留)' : '(尚未配置)' }}</span></label>
<input id="model-key" name="apiKey" type="password" [(ngModel)]="apiKey" autocomplete="new-password"
placeholder="输入新密钥" [disabled]="busy() || loading()" />
<div class="number-fields">
<label>请求超时(秒)<input name="timeout" type="number" min="1" max="3600" required
[(ngModel)]="timeout" [disabled]="busy() || loading()" /></label>
<label>接口重试次数<input name="retries" type="number" min="0" max="10" required
<label>失败后重试次数<input name="retries" type="number" min="0" max="10" required
[(ngModel)]="retries" [disabled]="busy() || loading()" /></label>
<label>重试间隔(秒)<input name="retryInterval" type="number" min="0" max="300" required [(ngModel)]="retryInterval" [disabled]="busy() || loading()" /></label>
</div>
<div class="actions">
<button type="submit" [disabled]="busy() || loading() || !valid()">{{ busy() === 'save' ? '正在保存…' : '保存配置' }}</button>
@@ -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<void>();
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;
@@ -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();
});
});
@@ -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:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small></h3>
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
@if(error()){<p role="alert">{{error()}}</p>}
@if(loading()){<small>正在查询…</small>}
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
@for(row of group.rows; track row.session_id){<div class="history-item"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)"><strong>{{row.name || row.initial_goal}}</strong><span>{{label(row.status)}} · {{row.start_time*1000 | date:'HH:mm:ss'}} · {{row.tags}}</span><small>{{row.session_id}}</small></button><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
</details>}
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
</section>`,
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:4px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:500}.row span{opacity:.75}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative}.history-item .row{padding-right:35px}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
})
export class TaskHistoryComponent {
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
total=signal(0);groups=signal<any[]>([]);loading=signal(false);error=signal('');collapsed=new Set<string>();
private revision=0;private debounce: ReturnType<typeof setTimeout>|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<string,string>)[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<any>('/api/automation/history',{params}).subscribe({next:r=>{
if(revision!==this.revision)return;
this.total.set(r.total);const map=new Map<string,any>();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 || '历史查询失败,请稍后重试');}}});
}
}
@@ -231,7 +231,7 @@ export class AgentService {
// Notes and Tab States
public currentNotes = signal<Record<string, string>>({});
public selectedNoteKey = signal<string>('task_plan.md');
public activeTab = signal<'tasks' | 'notes'>('tasks');
public activeTab = signal<'tasks' | 'notes' | 'automation'>('tasks');
// Video Replay Floating Window States
public isVideoWindowOpen = signal<boolean>(false);
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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."
+1 -1
View File
@@ -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"
+3 -1
View File
@@ -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:
+6 -4
View File
@@ -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
+6 -1
View File
@@ -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",
"",
]
+6
View File
@@ -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",
)
+18 -1
View File
@@ -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)
# ---------------------------------------------------------------------------
+7 -3
View File
@@ -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,
+8 -8
View File
@@ -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": {
+54 -17
View File
@@ -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,9 +355,21 @@ 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:
async with asyncio.timeout(request_timeout or settings.OPENAI_TIMEOUT_SECONDS):
result = await call_once()
except (KeyboardInterrupt, asyncio.CancelledError):
raise
@@ -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)
+3 -1
View File
@@ -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):
+8 -8
View File
@@ -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": {
+47
View File
@@ -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` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。
+37
View File
@@ -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` 表。清空任务历史同时清除批次与执行元数据;已导出的文件保留。
+4
View File
@@ -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,以刷新工具白名单。
+4
View File
@@ -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, "
+4
View File
@@ -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.
+15
View File
@@ -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,
)
+279
View File
@@ -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}
+1 -1
View File
@@ -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
+2
View File
@@ -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]
+36 -30
View File
@@ -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
+325
View File
@@ -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 <client>")
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)
+37
View File
@@ -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 思考耗时当作确定性等待。
@@ -0,0 +1,4 @@
interface:
display_name: "ARTEMIS Android 自动化"
short_description: "通过 MCP 完成 Android 单次与批量测试、失败诊断和报告交付"
default_prompt: "使用 $artemis-automation 检查设备,执行我指定的 Android 测试用例并交付有证据的报告。"
@@ -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 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。
+2 -1
View File
@@ -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" %*
+252
View File
@@ -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
@@ -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
+2 -2
View File
@@ -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
+95
View File
@@ -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")
+18
View File
@@ -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
+158
View File
@@ -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
+54
View File
@@ -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()
+1 -1
View File
@@ -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()
Generated
+38
View File
@@ -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"