feat: enable full AI automation and safe startup updates
This commit is contained in:
1 parent
2ddec53954
commit
7b1a0755e2
62 files changed
+3123
-240
No files matched your search
+13
-5
@@ -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
@@ -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 函数调用并在本地校验。音频输入是否可用取决于端点能力,拒绝音频时视频分析会尝试仅分析画面。
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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",
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
"",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+56
-19
@@ -99,6 +99,9 @@ def _begin_llm_request(provider: str | None) -> Token:
|
||||
# behind this call (set by with_fallback). When a fallback exists, exhausting
|
||||
# retries hands over to it immediately instead of pausing the whole task.
|
||||
_FALLBACK_AVAILABLE: ContextVar[bool] = ContextVar("llm_fallback_available", default=False)
|
||||
_FAILED_ENDPOINTS: ContextVar[dict[str, Exception] | None] = ContextVar(
|
||||
"llm_failed_endpoints", default=None
|
||||
)
|
||||
|
||||
# Shared circuit breaker keyed by "provider:model". Transient failures open
|
||||
# it; while open, concurrent calls wait out the cooldown instead of hammering
|
||||
@@ -109,9 +112,6 @@ _ENDPOINT_BREAKER = CircuitBreaker(threshold=3, cooldown_seconds=30.0)
|
||||
# remembered so subsequent calls go straight to non-streaming invocation.
|
||||
_NON_STREAMING_ENDPOINTS: set[str] = set()
|
||||
|
||||
# Total retry limit when failures switch categories within one recovery cycle.
|
||||
_MAX_TOTAL_RETRY_ATTEMPTS = 8
|
||||
|
||||
|
||||
def _get_current_data_engine():
|
||||
if _CURRENT_DATA_ENGINE is not None:
|
||||
@@ -331,6 +331,8 @@ async def _run_with_recovery[T](
|
||||
*,
|
||||
provider: str | None = None,
|
||||
endpoint_key: str | None = None,
|
||||
request_timeout: float | None = None,
|
||||
max_retries: int | None = None,
|
||||
) -> T:
|
||||
"""Execute one LLM call under the classified retry / pause / breaker policy.
|
||||
|
||||
@@ -353,10 +355,22 @@ async def _run_with_recovery[T](
|
||||
pause_file: Path | None = None
|
||||
try:
|
||||
while True:
|
||||
if endpoint_key:
|
||||
failed_endpoints = _FAILED_ENDPOINTS.get()
|
||||
previous = (
|
||||
failed_endpoints.get(endpoint_key)
|
||||
if failed_endpoints is not None and endpoint_key
|
||||
else None
|
||||
)
|
||||
if previous is not None:
|
||||
last_error, last_failure = previous, classify_failure(previous)
|
||||
if not last_failure.retryable:
|
||||
raise LLMPermanentError(str(previous), failure=last_failure, cause=previous)
|
||||
break
|
||||
if endpoint_key and not attempts:
|
||||
await _wait_for_breaker(endpoint_key)
|
||||
try:
|
||||
result = await call_once()
|
||||
async with asyncio.timeout(request_timeout or settings.OPENAI_TIMEOUT_SECONDS):
|
||||
result = await call_once()
|
||||
except (KeyboardInterrupt, asyncio.CancelledError):
|
||||
raise
|
||||
except LLMCallError:
|
||||
@@ -369,6 +383,8 @@ async def _run_with_recovery[T](
|
||||
if endpoint_key:
|
||||
_ENDPOINT_BREAKER.record_failure(endpoint_key, failure)
|
||||
if not failure.retryable:
|
||||
if failed_endpoints is not None and endpoint_key:
|
||||
failed_endpoints[endpoint_key] = e
|
||||
llm_logger.warning(
|
||||
f"LLM call failed permanently ({failure.category.value}): {e}"
|
||||
)
|
||||
@@ -386,21 +402,18 @@ async def _run_with_recovery[T](
|
||||
attempt = attempts.get(failure.category, 0) + 1
|
||||
attempts[failure.category] = attempt
|
||||
policy = retry_policy_for(failure.category)
|
||||
if attempt >= policy.max_attempts:
|
||||
break
|
||||
total_attempts = sum(attempts.values())
|
||||
if total_attempts >= _MAX_TOTAL_RETRY_ATTEMPTS:
|
||||
llm_logger.warning(
|
||||
"LLM retry total across failure categories reached"
|
||||
f" {total_attempts}; treating as exhausted."
|
||||
)
|
||||
max_attempts = (
|
||||
max_retries + 1 if max_retries is not None else policy.max_attempts
|
||||
)
|
||||
if total_attempts >= max_attempts:
|
||||
break
|
||||
delay = policy.delay_for(attempt)
|
||||
_record_llm_retry(
|
||||
str(e),
|
||||
delay,
|
||||
attempt=attempt,
|
||||
max_retries=policy.max_attempts,
|
||||
attempt=total_attempts,
|
||||
max_retries=max_attempts - 1,
|
||||
provider=provider,
|
||||
)
|
||||
llm_logger.warning(
|
||||
@@ -414,6 +427,8 @@ async def _run_with_recovery[T](
|
||||
|
||||
# Retryable attempts exhausted.
|
||||
if _FALLBACK_AVAILABLE.get():
|
||||
if failed_endpoints is not None and endpoint_key:
|
||||
failed_endpoints[endpoint_key] = last_error
|
||||
_record_llm_event(
|
||||
"llm_gave_up",
|
||||
{
|
||||
@@ -445,6 +460,8 @@ async def _run_with_recovery[T](
|
||||
) from last_error
|
||||
|
||||
llm_logger.info("Resume signal received, retrying LLM call...")
|
||||
if failed_endpoints is not None and endpoint_key:
|
||||
failed_endpoints.pop(endpoint_key, None)
|
||||
current_engine = _get_current_data_engine()
|
||||
if current_engine:
|
||||
current_engine._publish("task_resumed", {})
|
||||
@@ -540,8 +557,8 @@ class RobustChatModelWrapper:
|
||||
|
||||
def _endpoint_key(self) -> str:
|
||||
if self.endpoint is not None:
|
||||
return f"{self._provider_value()}:{self.endpoint.model_name}"
|
||||
return type(self.base_model).__name__
|
||||
return f"{self._provider_value()}:{self.endpoint.model_name}:{self.endpoint.api_base or settings.OPENAI_BASE_URL}:{self.endpoint.reasoning_effort}"
|
||||
return f"{type(self.base_model).__name__}:{id(self.base_model)}"
|
||||
|
||||
def _traced_call(self, args: tuple, kwargs: dict) -> tuple[tuple, dict, Any]:
|
||||
trace_id = None
|
||||
@@ -564,6 +581,8 @@ class RobustChatModelWrapper:
|
||||
functools.partial(self.base_model.ainvoke, *args, **kwargs),
|
||||
provider=self._provider_value(),
|
||||
endpoint_key=self._endpoint_key(),
|
||||
request_timeout=self.endpoint.timeout_seconds if self.endpoint else None,
|
||||
max_retries=self.endpoint.max_retries if self.endpoint else None,
|
||||
)
|
||||
self._meter_usage(response)
|
||||
return response
|
||||
@@ -584,6 +603,8 @@ class RobustChatModelWrapper:
|
||||
functools.partial(self._complete_attempt, emit_deltas, *args, **kwargs),
|
||||
provider=self._provider_value(),
|
||||
endpoint_key=self._endpoint_key(),
|
||||
request_timeout=self.endpoint.timeout_seconds if self.endpoint else None,
|
||||
max_retries=self.endpoint.max_retries if self.endpoint else None,
|
||||
)
|
||||
self._meter_usage(response)
|
||||
return response
|
||||
@@ -783,7 +804,7 @@ async def acomplete_structured(
|
||||
async def invoke_llm_with_timeout_message[T](
|
||||
llm_call: Coroutine[Any, Any, T],
|
||||
timeout_seconds: int = 10,
|
||||
hard_timeout: int = 180,
|
||||
hard_timeout: int | None = None,
|
||||
) -> T:
|
||||
"""Send an LLM call and display a countdown / timeout message if delayed."""
|
||||
llm_task = asyncio.create_task(llm_call)
|
||||
@@ -810,7 +831,7 @@ async def invoke_llm_with_timeout_message[T](
|
||||
continue
|
||||
|
||||
elapsed = asyncio.get_event_loop().time() - start_time
|
||||
if elapsed > max(0, hard_timeout - timeout_seconds):
|
||||
if hard_timeout is not None and elapsed > max(0, hard_timeout - timeout_seconds):
|
||||
user_messages_logger.error(f"LLM call timed out after {hard_timeout} seconds.")
|
||||
raise TimeoutError(f"LLM call timed out after {hard_timeout} seconds.")
|
||||
except BaseException:
|
||||
@@ -855,7 +876,9 @@ def get_cached_raw_model(
|
||||
model_name=model_name or settings.OPENAI_MODEL,
|
||||
temperature=temperature,
|
||||
timeout_seconds=timeout if timeout is not None else settings.OPENAI_TIMEOUT_SECONDS,
|
||||
reasoning_effort=reasoning_effort,
|
||||
reasoning_effort=reasoning_effort
|
||||
if reasoning_effort is not None
|
||||
else settings.OPENAI_REASONING_EFFORT,
|
||||
enable_grounding=enable_grounding,
|
||||
)
|
||||
return ModelFactory.get_model(ep)
|
||||
@@ -963,6 +986,19 @@ async def with_fallback[T](
|
||||
main_call: Callable[[], Awaitable[T]],
|
||||
fallback_call: Callable[[], Awaitable[T]],
|
||||
none_should_fallback: bool = True,
|
||||
) -> T:
|
||||
"""Share failed endpoint identities so an identical fallback cannot repeat retries."""
|
||||
token = _FAILED_ENDPOINTS.set({})
|
||||
try:
|
||||
return await _with_fallback(main_call, fallback_call, none_should_fallback)
|
||||
finally:
|
||||
_FAILED_ENDPOINTS.reset(token)
|
||||
|
||||
|
||||
async def _with_fallback[T](
|
||||
main_call: Callable[[], Awaitable[T]],
|
||||
fallback_call: Callable[[], Awaitable[T]],
|
||||
none_should_fallback: bool = True,
|
||||
) -> T:
|
||||
"""Run main_call, switching to fallback_call only when it can actually help.
|
||||
|
||||
@@ -995,6 +1031,7 @@ async def with_fallback[T](
|
||||
try:
|
||||
result = await main_call()
|
||||
except (KeyboardInterrupt, asyncio.CancelledError):
|
||||
_FALLBACK_AVAILABLE.reset(fallback_token)
|
||||
raise
|
||||
except LLMCallError as e:
|
||||
_FALLBACK_AVAILABLE.reset(fallback_token)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。
|
||||
@@ -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` 表。清空任务历史同时清除批次与执行元数据;已导出的文件保留。
|
||||
@@ -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,以刷新工具白名单。
|
||||
@@ -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, "
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。
|
||||
@@ -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" %*
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user