feat: enable full AI automation and safe startup updates
This commit is contained in:
1 parent
2ddec53954
commit
7b1a0755e2
62 files changed
+3121
-238
No files matched your search
+13
-5
@@ -3,14 +3,17 @@
|
|||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
# 接口根地址:通常以 /v1 结尾,不要添加 /responses 或 /chat/completions。
|
# 接口根地址:通常以 /v1 结尾,不要添加 /responses 或 /chat/completions。
|
||||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
OPENAI_MODEL=gpt-5-mini
|
OPENAI_MODEL=mimo-v2.6-flash
|
||||||
# 单次模型 HTTP 请求超时(秒)
|
# 单次模型 HTTP 请求超时(秒)
|
||||||
OPENAI_TIMEOUT_SECONDS=120
|
OPENAI_TIMEOUT_SECONDS=300
|
||||||
# SDK 内部重试次数;默认 0,由 Artemis 的分类恢复机制处理重试。
|
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
||||||
OPENAI_MAX_RETRIES=0
|
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_OUTPUTTER_ENABLED=true
|
||||||
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
||||||
|
|
||||||
@@ -37,3 +40,8 @@ ARTEMIS_KEEP_DEVICE_AWAKE=true
|
|||||||
ARTEMIS_HIERARCHY_BACKEND=auto
|
ARTEMIS_HIERARCHY_BACKEND=auto
|
||||||
ARTEMIS_HELPER_AUTO_INSTALL=true
|
ARTEMIS_HELPER_AUTO_INSTALL=true
|
||||||
ARTEMIS_DEFAULT_PROFILE=flash
|
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">
|
<p align="center">
|
||||||
<img src="./docs/assets/artemis-banner-cn.png?v=7" alt="ARTEMIS Banner" width="100%" />
|
<img src="./docs/assets/artemis-banner-cn.png?v=7" alt="ARTEMIS Banner" width="100%" />
|
||||||
</p>
|
</p>
|
||||||
@@ -151,9 +155,15 @@ cd artemis
|
|||||||
|
|
||||||
> **提示**:启动后将自动在默认浏览器中打开 Web 控制台(`http://localhost:8000`),提供设备连接向导、实时投屏、任务演练与状态回放面板。你也可以通过命令行直接运行:`uv run artemis run "打开系统设置,找到电池选项并告诉我当前电量" --profile flash`。
|
> **提示**:启动后将自动在默认浏览器中打开 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` 结尾)。例如:
|
所有模型调用统一使用 **OpenAI Responses API**,不再提供 Gemini、Anthropic 或 Chat Completions 调用路径。自定义网关必须实现 `/responses`,接口地址填写根地址(通常以 `/v1` 结尾)。例如:
|
||||||
|
|
||||||
@@ -161,11 +171,16 @@ Web 控制台使用简体中文。在首页打开「系统设置与运行准备
|
|||||||
OPENAI_API_KEY=你的密钥
|
OPENAI_API_KEY=你的密钥
|
||||||
OPENAI_BASE_URL=https://你的网关/v1
|
OPENAI_BASE_URL=https://你的网关/v1
|
||||||
OPENAI_MODEL=mimo-v2.6-flash
|
OPENAI_MODEL=mimo-v2.6-flash
|
||||||
OPENAI_TIMEOUT_SECONDS=120
|
OPENAI_TIMEOUT_SECONDS=300
|
||||||
OPENAI_MAX_RETRIES=0
|
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 函数调用并在本地校验。音频输入是否可用取决于端点能力,拒绝音频时视频分析会尝试仅分析画面。
|
视频分析通过抽取画面帧调用模型,因此需要支持图片输入的模型。`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 = StorageManager(DB_PATH, TRACES_PATH)
|
||||||
storage.clear_all_data()
|
storage.clear_all_data()
|
||||||
|
from apps.admin_console.services.automation_store import store
|
||||||
|
|
||||||
|
store.delete_history()
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "History cleaned up successfully",
|
"message": "History cleaned up successfully",
|
||||||
@@ -231,6 +234,9 @@ async def delete_session_endpoint(session_id: str):
|
|||||||
|
|
||||||
storage = StorageManager(DB_PATH, TRACES_PATH)
|
storage = StorageManager(DB_PATH, TRACES_PATH)
|
||||||
storage.delete_session(UUID(session_id))
|
storage.delete_session(UUID(session_id))
|
||||||
|
from apps.admin_console.services.automation_store import store
|
||||||
|
|
||||||
|
store.delete_history(session_id)
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"Session {session_id} deleted successfully",
|
"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:
|
if verified_serial and not request.device_serial:
|
||||||
target_serial = verified_serial
|
target_serial = verified_serial
|
||||||
|
|
||||||
return await task_queue_service.enqueue_tasks(
|
response = await task_queue_service.enqueue_tasks(
|
||||||
incoming_goals,
|
incoming_goals,
|
||||||
profile=request.profile or "flash",
|
profile=request.profile or "flash",
|
||||||
expected_output=request.expected_output,
|
expected_output=request.expected_output,
|
||||||
@@ -180,6 +180,12 @@ async def run_task(request: RunRequest):
|
|||||||
conversation_id=request.conversation_id,
|
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")
|
@router.get("/api/run/defaults")
|
||||||
async def get_run_defaults():
|
async def get_run_defaults():
|
||||||
|
|||||||
@@ -159,12 +159,19 @@ async def on_startup():
|
|||||||
|
|
||||||
await ipc_service.start_server()
|
await ipc_service.start_server()
|
||||||
state.worker_task = asyncio.create_task(task_queue_service.queue_worker())
|
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():
|
async def on_shutdown():
|
||||||
"""Stop task and IPC children before the UI server exits."""
|
"""Stop task and IPC children before the UI server exits."""
|
||||||
state.is_shutting_down = True
|
state.is_shutting_down = True
|
||||||
state.shutdown_event.set()
|
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"})
|
task_queue_service._broadcast_event("server_shutdown", {"status": "stopping"})
|
||||||
owned_session_ids = {
|
owned_session_ids = {
|
||||||
str(item["session_id"])
|
str(item["session_id"])
|
||||||
@@ -207,6 +214,10 @@ async def on_shutdown():
|
|||||||
for session_id in owned_session_ids:
|
for session_id in owned_session_ids:
|
||||||
session_repo.update_session_status(session_id, "cancelled")
|
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()
|
await ipc_service.stop_server()
|
||||||
state.ipc_subscribers.clear()
|
state.ipc_subscribers.clear()
|
||||||
await asyncio.to_thread(shutdown_awake_service)
|
await asyncio.to_thread(shutdown_awake_service)
|
||||||
@@ -224,6 +235,9 @@ app.include_router(steps.router)
|
|||||||
app.include_router(tasks.router)
|
app.include_router(tasks.router)
|
||||||
app.include_router(replay.router)
|
app.include_router(replay.router)
|
||||||
app.include_router(system.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
|
# Mount cloud gateway router for Frappe / Cloud integration if present
|
||||||
try:
|
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 tempfile
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, SecretStr, field_validator
|
from pydantic import BaseModel, Field, SecretStr, field_validator
|
||||||
@@ -18,8 +19,12 @@ class ModelSettingsUpdate(BaseModel):
|
|||||||
base_url: str = "https://api.openai.com/v1"
|
base_url: str = "https://api.openai.com/v1"
|
||||||
model: str = Field(min_length=1, max_length=200)
|
model: str = Field(min_length=1, max_length=200)
|
||||||
api_key: SecretStr | None = None # Omitted/blank keeps the existing key.
|
api_key: SecretStr | None = None # Omitted/blank keeps the existing key.
|
||||||
timeout_seconds: float = Field(default=120, gt=0, le=3600)
|
timeout_seconds: float = Field(default=300, gt=0, le=3600)
|
||||||
max_retries: int = Field(default=0, ge=0, le=10)
|
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")
|
@field_validator("base_url")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -57,6 +62,9 @@ def get_model_settings() -> dict:
|
|||||||
"api_key_configured": bool(settings.OPENAI_API_KEY),
|
"api_key_configured": bool(settings.OPENAI_API_KEY),
|
||||||
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
|
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
|
||||||
"max_retries": settings.OPENAI_MAX_RETRIES,
|
"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()),
|
"env_path": str(get_env_file()),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +77,8 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
|
|||||||
"OPENAI_MODEL": update.model,
|
"OPENAI_MODEL": update.model,
|
||||||
"OPENAI_TIMEOUT_SECONDS": str(update.timeout_seconds),
|
"OPENAI_TIMEOUT_SECONDS": str(update.timeout_seconds),
|
||||||
"OPENAI_MAX_RETRIES": str(update.max_retries),
|
"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():
|
if update.api_key and update.api_key.get_secret_value().strip():
|
||||||
values["OPENAI_API_KEY"] = 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_MODEL = update.model
|
||||||
settings.OPENAI_TIMEOUT_SECONDS = update.timeout_seconds
|
settings.OPENAI_TIMEOUT_SECONDS = update.timeout_seconds
|
||||||
settings.OPENAI_MAX_RETRIES = update.max_retries
|
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:
|
if "OPENAI_API_KEY" in values:
|
||||||
settings.OPENAI_API_KEY = SecretStr(values["OPENAI_API_KEY"])
|
settings.OPENAI_API_KEY = SecretStr(values["OPENAI_API_KEY"])
|
||||||
from artemis.llm.router import ModelFactory
|
from artemis.llm.router import ModelFactory
|
||||||
@@ -116,7 +128,7 @@ def persist_environment(values: dict[str, str]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
class CapabilitySettings(BaseModel):
|
class CapabilitySettings(BaseModel):
|
||||||
video_enabled: bool = True
|
video_enabled: bool = False
|
||||||
outputter_enabled: bool = True
|
outputter_enabled: bool = True
|
||||||
step_summarizer_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,
|
api_base=update.base_url,
|
||||||
timeout_seconds=update.timeout_seconds,
|
timeout_seconds=update.timeout_seconds,
|
||||||
max_retries=0,
|
max_retries=0,
|
||||||
max_tokens=64,
|
reasoning_effort=None if update.reasoning_effort == "default" else update.reasoning_effort,
|
||||||
)
|
)
|
||||||
model = ModelFactory.create_model(endpoint)
|
model = ModelFactory.create_model(endpoint)
|
||||||
result = await model.ainvoke([HumanMessage(content="Reply with OK only.")])
|
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."""
|
"""Mark the task as running and announce the launch to subscribers."""
|
||||||
task_item["status"] = "running"
|
task_item["status"] = "running"
|
||||||
task_item["start_time"] = time.time()
|
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
|
# 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
|
# key from a previous task. Manual stops are tracked per run in
|
||||||
@@ -559,6 +565,12 @@ class TaskQueueService:
|
|||||||
if device_serial:
|
if device_serial:
|
||||||
cmd.extend(["--device-serial", str(device_serial)])
|
cmd.extend(["--device-serial", str(device_serial)])
|
||||||
env["ADB_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
|
return cmd, env
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -768,6 +780,11 @@ class TaskQueueService:
|
|||||||
manual_stop: bool,
|
manual_stop: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Broadcast session_ended and dispatch the external completion notification."""
|
"""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)
|
state.active_connections.pop(sess_id, None)
|
||||||
cls._broadcast_event(
|
cls._broadcast_event(
|
||||||
"session_ended",
|
"session_ended",
|
||||||
@@ -938,7 +955,7 @@ class TaskQueueService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 2. Debounce duplicate rapid submissions (e.g. UI double-click or network retry within 1s)
|
# 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]
|
first_goal = goals[0]
|
||||||
recent_duplicate = next(
|
recent_duplicate = next(
|
||||||
(
|
(
|
||||||
@@ -1212,10 +1229,19 @@ class TaskQueueService:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _stop_all_tasks(cls) -> bool:
|
def _stop_all_tasks(cls) -> bool:
|
||||||
"""Terminate all active device owners and clear pending queue submissions."""
|
"""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
|
# 1. Cancel local queue reservations
|
||||||
for item in state.queue_items:
|
for item in state.queue_items:
|
||||||
if isinstance(item, dict) and item.get("status") != "running":
|
if isinstance(item, dict) and item.get("status") != "running":
|
||||||
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
|
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
|
||||||
|
if store.job(str(item.get("session_id"))):
|
||||||
|
spawn(on_terminal(item, "cancelled"))
|
||||||
state.clear_queue()
|
state.clear_queue()
|
||||||
|
|
||||||
# 2. Terminate all active owners across all devices
|
# 2. Terminate all active owners across all devices
|
||||||
@@ -1534,6 +1560,12 @@ class TaskQueueService:
|
|||||||
),
|
),
|
||||||
None,
|
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:
|
if stopped_item and not reservation_cancelled:
|
||||||
DeviceExecutionLock.cancel_reservation(stopped_item.get("queue_ticket"))
|
DeviceExecutionLock.cancel_reservation(stopped_item.get("queue_ticket"))
|
||||||
state.queue_items = [
|
state.queue_items = [
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ limitations under the License.
|
|||||||
<span class="material-symbols-outlined tab-icon">description</span>
|
<span class="material-symbols-outlined tab-icon">description</span>
|
||||||
<span class="tab-label">笔记与计划</span>
|
<span class="tab-label">笔记与计划</span>
|
||||||
</button>
|
</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>
|
</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 -->
|
<!-- Dropdown Popover Panel for Task Queue -->
|
||||||
<ng-template
|
<ng-template
|
||||||
@@ -61,7 +63,7 @@ limitations under the License.
|
|||||||
</div>
|
</div>
|
||||||
<div class="dropdown-section-label">
|
<div class="dropdown-section-label">
|
||||||
<span class="material-symbols-outlined icon-bolt">bolt</span>
|
<span class="material-symbols-outlined icon-bolt">bolt</span>
|
||||||
<span>Active Queue ({{ activeQueue().length }})</span>
|
<span>当前队列 ({{ activeQueue().length }})</span>
|
||||||
</div>
|
</div>
|
||||||
@if (activeQueue().length === 0) {
|
@if (activeQueue().length === 0) {
|
||||||
<div class="dropdown-empty-state">
|
<div class="dropdown-empty-state">
|
||||||
@@ -127,65 +129,7 @@ limitations under the License.
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="dropdown-section-label history-label">
|
<app-task-history />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
@@ -483,7 +427,7 @@ limitations under the License.
|
|||||||
<button
|
<button
|
||||||
class="btn-screen-recording"
|
class="btn-screen-recording"
|
||||||
[class.active]="agentService.isVideoWindowOpen()"
|
[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.processing]="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing'"
|
||||||
[class.has-video]="!agentService.isCurrentSessionRunning() && !!agentService.currentSessionVideoUrl()"
|
[class.has-video]="!agentService.isCurrentSessionRunning() && !!agentService.currentSessionVideoUrl()"
|
||||||
[class.has-steps]="!agentService.isCurrentSessionRunning() && !agentService.currentSessionVideoUrl() && agentService.hasCurrentSessionStepFrames()"
|
[class.has-steps]="!agentService.isCurrentSessionRunning() && !agentService.currentSessionVideoUrl() && agentService.hasCurrentSessionStepFrames()"
|
||||||
@@ -493,7 +437,7 @@ limitations under the License.
|
|||||||
>
|
>
|
||||||
@if (agentService.isCurrentSessionRunning()) {
|
@if (agentService.isCurrentSessionRunning()) {
|
||||||
<span class="material-symbols-outlined video-icon">
|
<span class="material-symbols-outlined video-icon">
|
||||||
videocam
|
{{ agentService.currentSession()?.recording_status === 'recording' ? 'videocam' : 'progress_activity' }}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing') {
|
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing') {
|
||||||
@@ -522,7 +466,7 @@ limitations under the License.
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@if (agentService.isCurrentSessionRunning()) {
|
@if (agentService.isCurrentSessionRunning()) {
|
||||||
<span class="btn-text">正在录制…</span>
|
<span class="btn-text">{{ agentService.currentSession()?.recording_status === 'recording' ? '正在录制…' : '任务执行中…' }}</span>
|
||||||
}
|
}
|
||||||
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing') {
|
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing') {
|
||||||
<span class="btn-text">正在准备…</span>
|
<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';
|
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
||||||
/**
|
/**
|
||||||
* Copyright 2026 Google LLC
|
* Copyright 2026 Google LLC
|
||||||
@@ -234,7 +236,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-agent-stream',
|
selector: 'app-agent-stream',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [UiLabelPipe, CommonModule, FormsModule, OverlayModule],
|
imports: [UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent],
|
||||||
templateUrl: './agent-stream.component.html',
|
templateUrl: './agent-stream.component.html',
|
||||||
styleUrl: './agent-stream.component.scss',
|
styleUrl: './agent-stream.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -267,6 +269,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
// Top Nav Dropdown States
|
// Top Nav Dropdown States
|
||||||
public isTaskDropdownOpen = signal<boolean>(false);
|
public isTaskDropdownOpen = signal<boolean>(false);
|
||||||
public isNotesDropdownOpen = signal<boolean>(false);
|
public isNotesDropdownOpen = signal<boolean>(false);
|
||||||
|
public isAutomationOpen = signal(false);
|
||||||
|
|
||||||
// Run info popover (opened from the Flash / Pro chip): elapsed time, token
|
// Run info popover (opened from the Flash / Pro chip): elapsed time, token
|
||||||
// usage, executor context share and the Pro tuning the run started with.
|
// usage, executor context share and the Pro tuning the run started with.
|
||||||
@@ -1555,7 +1558,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
|
|
||||||
public getScreenRecordingButtonTitle(): string {
|
public getScreenRecordingButtonTitle(): string {
|
||||||
if (this.agentService.isCurrentSessionRunning()) {
|
if (this.agentService.isCurrentSessionRunning()) {
|
||||||
return "任务正在运行(录屏中)";
|
return this.agentService.currentSession()?.recording_status === 'recording' ? "任务正在运行(录屏中)" : "任务正在运行";
|
||||||
}
|
}
|
||||||
if (this.agentService.currentSessionRecordingStatus() === 'processing') {
|
if (this.agentService.currentSessionRecordingStatus() === 'processing') {
|
||||||
return "正在准备屏幕录像…";
|
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="material-symbols-outlined tab-icon">description</span>
|
||||||
<span class="tab-text">笔记与计划</span>
|
<span class="tab-text">笔记与计划</span>
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
@@ -54,11 +55,12 @@ limitations under the License.
|
|||||||
|
|
||||||
<!-- Scrollable Messages/Content Area -->
|
<!-- Scrollable Messages/Content Area -->
|
||||||
<main class="chat-messages">
|
<main class="chat-messages">
|
||||||
|
@if (agentService.activeTab() === 'automation') { <app-automation-panel /> }
|
||||||
<!-- Tasks Tab Content -->
|
<!-- Tasks Tab Content -->
|
||||||
@if (agentService.activeTab() === 'tasks') {
|
@if (agentService.activeTab() === 'tasks') {
|
||||||
<!-- Active Queue Section -->
|
<!-- Active Queue Section -->
|
||||||
<div class="queue-section-header">
|
<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>
|
</div>
|
||||||
@if (activeQueue().length === 0) {
|
@if (activeQueue().length === 0) {
|
||||||
<div class="empty-section-placeholder">
|
<div class="empty-section-placeholder">
|
||||||
@@ -125,68 +127,7 @@ limitations under the License.
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Collapsible History Section -->
|
<app-task-history />
|
||||||
<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>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Notes Tab Content -->
|
<!-- 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';
|
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
||||||
/**
|
/**
|
||||||
* Copyright 2026 Google LLC
|
* Copyright 2026 Google LLC
|
||||||
@@ -28,7 +30,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-interface',
|
selector: 'app-chat-interface',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [UiLabelPipe, CommonModule, FormsModule],
|
imports: [UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent],
|
||||||
templateUrl: './chat-interface.component.html',
|
templateUrl: './chat-interface.component.html',
|
||||||
styleUrl: './chat-interface.component.scss',
|
styleUrl: './chat-interface.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ interface Capabilities {
|
|||||||
export class CapabilitySettingsComponent implements OnInit {
|
export class CapabilitySettingsComponent implements OnInit {
|
||||||
changed = output<Capabilities>();
|
changed = output<Capabilities>();
|
||||||
private http = inject(HttpClient);
|
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('');
|
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.http.get<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
|
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('持久化模型与功能设置', () => {
|
describe('持久化模型与功能设置', () => {
|
||||||
let http: HttpTestingController;
|
let http: HttpTestingController;
|
||||||
const config = { base_url: 'https://example.test/v1', model: 'mimo-v2.6-flash',
|
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(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||||
http = TestBed.inject(HttpTestingController);
|
http = TestBed.inject(HttpTestingController);
|
||||||
@@ -26,6 +26,10 @@ describe('持久化模型与功能设置', () => {
|
|||||||
const request = http.expectOne('/api/system/model-settings');
|
const request = http.expectOne('/api/system/model-settings');
|
||||||
expect(request.request.method).toBe('POST');
|
expect(request.request.method).toBe('POST');
|
||||||
expect(request.request.body.model).toBe('another-model');
|
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();
|
expect(request.request.body.api_key).toBeUndefined();
|
||||||
request.flush({ ...config, model: 'another-model' });
|
request.flush({ ...config, model: 'another-model' });
|
||||||
expect(component.busy()).toBeNull();
|
expect(component.busy()).toBeNull();
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface ModelSettings {
|
|||||||
api_key_configured: boolean;
|
api_key_configured: boolean;
|
||||||
timeout_seconds: number;
|
timeout_seconds: number;
|
||||||
max_retries: number;
|
max_retries: number;
|
||||||
|
retry_interval_seconds: number;
|
||||||
|
reasoning_effort: string;
|
||||||
env_path: string;
|
env_path: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
@@ -25,16 +27,25 @@ interface ModelSettings {
|
|||||||
placeholder="https://api.openai.com/v1" [disabled]="busy() || loading()" />
|
placeholder="https://api.openai.com/v1" [disabled]="busy() || loading()" />
|
||||||
<small>填写接口根地址,通常包含 /v1,无需添加 /responses。</small>
|
<small>填写接口根地址,通常包含 /v1,无需添加 /responses。</small>
|
||||||
<label for="model-name">模型名称</label>
|
<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()" />
|
[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>
|
<label for="model-key">API Key <span>{{ keyConfigured() ? '(已配置,留空保留)' : '(尚未配置)' }}</span></label>
|
||||||
<input id="model-key" name="apiKey" type="password" [(ngModel)]="apiKey" autocomplete="new-password"
|
<input id="model-key" name="apiKey" type="password" [(ngModel)]="apiKey" autocomplete="new-password"
|
||||||
placeholder="输入新密钥" [disabled]="busy() || loading()" />
|
placeholder="输入新密钥" [disabled]="busy() || loading()" />
|
||||||
<div class="number-fields">
|
<div class="number-fields">
|
||||||
<label>请求超时(秒)<input name="timeout" type="number" min="1" max="3600" required
|
<label>请求超时(秒)<input name="timeout" type="number" min="1" max="3600" required
|
||||||
[(ngModel)]="timeout" [disabled]="busy() || loading()" /></label>
|
[(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>
|
[(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>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit" [disabled]="busy() || loading() || !valid()">{{ busy() === 'save' ? '正在保存…' : '保存配置' }}</button>
|
<button type="submit" [disabled]="busy() || loading() || !valid()">{{ busy() === 'save' ? '正在保存…' : '保存配置' }}</button>
|
||||||
@@ -52,9 +63,9 @@ interface ModelSettings {
|
|||||||
.model-settings { display: grid; gap: 10px; color: #1e293b; }
|
.model-settings { display: grid; gap: 10px; color: #1e293b; }
|
||||||
p, small { margin: 0; line-height: 1.6; color: #64748b; }
|
p, small { margin: 0; line-height: 1.6; color: #64748b; }
|
||||||
label { font-weight: 600; } label span { font-weight: 400; font-size: 12px; }
|
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; }
|
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 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 8px; }
|
||||||
.number-fields input { margin-top: 8px; }
|
.number-fields input { margin-top: 8px; }
|
||||||
.actions { display: flex; gap: 12px; margin-top: 8px; }
|
.actions { display: flex; gap: 12px; margin-top: 8px; }
|
||||||
@@ -66,7 +77,7 @@ interface ModelSettings {
|
|||||||
export class ModelSettingsComponent implements OnInit {
|
export class ModelSettingsComponent implements OnInit {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
saved = output<void>();
|
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);
|
busy = signal<'save' | 'test' | null>(null);
|
||||||
loading = signal(true); keyConfigured = signal(false); envPath = signal('');
|
loading = signal(true); keyConfigured = signal(false); envPath = signal('');
|
||||||
message = signal(''); error = signal('');
|
message = signal(''); error = signal('');
|
||||||
@@ -79,15 +90,15 @@ export class ModelSettingsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
valid(): boolean {
|
valid(): boolean {
|
||||||
return !!this.baseUrl.trim() && !!this.model.trim() && (!!this.apiKey.trim() || this.keyConfigured())
|
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() {
|
private payload() {
|
||||||
return { base_url: this.baseUrl.trim(), model: this.model.trim(), api_key: this.apiKey.trim() || undefined,
|
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 {
|
private apply(config: ModelSettings): void {
|
||||||
this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds;
|
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 {
|
save(): void {
|
||||||
if (this.busy() || !this.valid()) return;
|
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
|
// Notes and Tab States
|
||||||
public currentNotes = signal<Record<string, string>>({});
|
public currentNotes = signal<Record<string, string>>({});
|
||||||
public selectedNoteKey = signal<string>('task_plan.md');
|
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
|
// Video Replay Floating Window States
|
||||||
public isVideoWindowOpen = signal<boolean>(false);
|
public isVideoWindowOpen = signal<boolean>(false);
|
||||||
|
|||||||
@@ -892,7 +892,7 @@ class FlashRunner:
|
|||||||
# retries, and pause/resume are all owned by the LLM gateway
|
# retries, and pause/resume are all owned by the LLM gateway
|
||||||
# (acomplete); a typed LLMCallError propagates if it gives up.
|
# (acomplete); a typed LLMCallError propagates if it gives up.
|
||||||
return await invoke_llm_with_timeout_message(
|
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(
|
async def _process_tool_calls(
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class VideoAnalyzerConfig(BaseModel):
|
|||||||
"""Configuration specific to VideoAnalyzer and screen recording."""
|
"""Configuration specific to VideoAnalyzer and screen recording."""
|
||||||
|
|
||||||
enabled: bool | None = Field(
|
enabled: bool | None = Field(
|
||||||
default=None,
|
default_factory=lambda: settings.ARTEMIS_VIDEO_ENABLED,
|
||||||
description=(
|
description=(
|
||||||
"Whether continuous screen video recording and video analyzer tools "
|
"Whether continuous screen video recording and video analyzer tools "
|
||||||
"are enabled. If None, auto-detected based on scrcpy & ffmpeg availability."
|
"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_STREAM_PING_INTERVAL = 15.0
|
||||||
|
|
||||||
DEFAULT_PROFILE = "pro"
|
DEFAULT_PROFILE = "pro"
|
||||||
DEFAULT_MODEL = "gpt-5-mini"
|
DEFAULT_MODEL = "mimo-v2.6-flash"
|
||||||
DEFAULT_EXPLORER_VERSION: Literal["flash", "pro", "ultra"] = "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_retries: int = Field(default_factory=lambda: settings.OPENAI_MAX_RETRIES, ge=0)
|
||||||
max_tokens: int | None = Field(default=None, gt=0)
|
max_tokens: int | None = Field(default=None, gt=0)
|
||||||
temperature: float | None = None
|
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
|
enable_grounding: bool | None = None
|
||||||
|
|
||||||
def validate_provider(self, name: str) -> None:
|
def validate_provider(self, name: str) -> None:
|
||||||
|
|||||||
@@ -96,9 +96,11 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Custom Provider Endpoints
|
# Custom Provider Endpoints
|
||||||
OPENAI_BASE_URL: str | None = None
|
OPENAI_BASE_URL: str | None = None
|
||||||
OPENAI_MODEL: str = Field(default="gpt-5-mini", min_length=1)
|
OPENAI_MODEL: str = Field(default="mimo-v2.6-flash", min_length=1)
|
||||||
OPENAI_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0)
|
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
|
||||||
OPENAI_MAX_RETRIES: int = Field(default=0, ge=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_CREDENTIAL_TIMEOUT_SECONDS: float = Field(default=12.0, gt=0)
|
||||||
ARTEMIS_MODEL_CACHE_TTL_SECONDS: float = Field(default=10.0, ge=0)
|
ARTEMIS_MODEL_CACHE_TTL_SECONDS: float = Field(default=10.0, ge=0)
|
||||||
ARTEMIS_VIDEO_TIMEOUT_SECONDS: float = Field(default=120.0, gt=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_VIDEO_CHUNK_SECONDS: float = Field(default=60.0, gt=0)
|
||||||
ARTEMIS_CHECKPOINT_TIMEOUT_SECONDS: float = Field(default=180.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_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_OUTPUTTER_ENABLED: bool = True
|
||||||
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
|
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,12 @@ def init_command() -> None:
|
|||||||
"",
|
"",
|
||||||
"# Execution Defaults",
|
"# Execution Defaults",
|
||||||
"ARTEMIS_DEFAULT_PROFILE=pro",
|
"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",
|
"ARTEMIS_TRACES_DIR=./traces",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ ARTEMIS_MCP_TOOLS = (
|
|||||||
"mobile_get_device_state",
|
"mobile_get_device_state",
|
||||||
"mobile_inspect_trace",
|
"mobile_inspect_trace",
|
||||||
"mobile_diagnose",
|
"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 dataclasses import dataclass
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
import random
|
import random
|
||||||
|
from functools import lru_cache
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -144,9 +145,12 @@ class RetryPolicy:
|
|||||||
max_attempts: int
|
max_attempts: int
|
||||||
base_delay: float
|
base_delay: float
|
||||||
max_delay: float
|
max_delay: float
|
||||||
|
fixed: bool = False
|
||||||
|
|
||||||
def delay_for(self, attempt: int) -> float:
|
def delay_for(self, attempt: int) -> float:
|
||||||
"""Backoff delay before retry number ``attempt`` (1-based)."""
|
"""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)))
|
delay = min(self.max_delay, self.base_delay * (2 ** max(0, attempt - 1)))
|
||||||
return delay + random.uniform(0.1, 0.5)
|
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:
|
def retry_policy_for(category: FailureCategory | str) -> RetryPolicy:
|
||||||
"""Look up the retry policy for a failure category.
|
"""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))
|
category = FailureCategory(str(category))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
category = FailureCategory.UNKNOWN
|
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)
|
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)
|
max_retries: int = Field(default_factory=lambda: settings.OPENAI_MAX_RETRIES, ge=0)
|
||||||
is_multimodal: bool = True
|
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
|
enable_grounding: bool = False
|
||||||
|
|
||||||
def cache_key(self) -> tuple:
|
def cache_key(self) -> tuple:
|
||||||
@@ -98,10 +98,14 @@ class ModelFactory:
|
|||||||
"api_key": api_key,
|
"api_key": api_key,
|
||||||
"base_url": endpoint.api_base or settings.OPENAI_BASE_URL or None,
|
"base_url": endpoint.api_base or settings.OPENAI_BASE_URL or None,
|
||||||
"timeout": endpoint.timeout_seconds,
|
"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,
|
"temperature": endpoint.temperature,
|
||||||
"max_tokens": endpoint.max_tokens,
|
"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,
|
"use_responses_api": True,
|
||||||
"output_version": "responses/v1",
|
"output_version": "responses/v1",
|
||||||
"store": False,
|
"store": False,
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
{
|
{
|
||||||
"default": {
|
"default": {
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model": "${env:OPENAI_MODEL:-gpt-5-mini}",
|
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
||||||
"timeout": "${env:OPENAI_TIMEOUT_SECONDS:-120}",
|
"timeout": "${env:OPENAI_TIMEOUT_SECONDS:-300}",
|
||||||
"max_retries": "${env:OPENAI_MAX_RETRIES:-0}",
|
"max_retries": "${env:OPENAI_MAX_RETRIES:-5}",
|
||||||
"fallback": {
|
"fallback": {
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model": "${env:OPENAI_MODEL:-gpt-5-mini}"
|
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"presets": {},
|
"presets": {},
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"explorer_mode": "flash",
|
"explorer_mode": "flash",
|
||||||
"step_summarizer": {
|
"step_summarizer": {
|
||||||
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
"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
|
"prune_history_xml": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
"device_probes": true
|
"device_probes": true
|
||||||
},
|
},
|
||||||
"video_analyzer": {
|
"video_analyzer": {
|
||||||
"enabled": "${env:ARTEMIS_VIDEO_ENABLED:-true}",
|
"enabled": "${env:ARTEMIS_VIDEO_ENABLED:-false}",
|
||||||
"enable_ledger": true
|
"enable_ledger": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
"max_steps": 12,
|
"max_steps": 12,
|
||||||
"min_steps": 3,
|
"min_steps": 3,
|
||||||
"target_source_tokens": 2000,
|
"target_source_tokens": 2000,
|
||||||
"model": "${env:OPENAI_MODEL:-gpt-5-mini}",
|
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
||||||
"max_chunks": 8
|
"max_chunks": 8
|
||||||
},
|
},
|
||||||
"recall": {
|
"recall": {
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"video_analyzer": {
|
"video_analyzer": {
|
||||||
"enabled": "${env:ARTEMIS_VIDEO_ENABLED:-true}",
|
"enabled": "${env:ARTEMIS_VIDEO_ENABLED:-false}",
|
||||||
"enable_ledger": true
|
"enable_ledger": true
|
||||||
},
|
},
|
||||||
"outputter": {
|
"outputter": {
|
||||||
|
|||||||
+54
-17
@@ -99,6 +99,9 @@ def _begin_llm_request(provider: str | None) -> Token:
|
|||||||
# behind this call (set by with_fallback). When a fallback exists, exhausting
|
# behind this call (set by with_fallback). When a fallback exists, exhausting
|
||||||
# retries hands over to it immediately instead of pausing the whole task.
|
# retries hands over to it immediately instead of pausing the whole task.
|
||||||
_FALLBACK_AVAILABLE: ContextVar[bool] = ContextVar("llm_fallback_available", default=False)
|
_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
|
# Shared circuit breaker keyed by "provider:model". Transient failures open
|
||||||
# it; while open, concurrent calls wait out the cooldown instead of hammering
|
# 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.
|
# remembered so subsequent calls go straight to non-streaming invocation.
|
||||||
_NON_STREAMING_ENDPOINTS: set[str] = set()
|
_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():
|
def _get_current_data_engine():
|
||||||
if _CURRENT_DATA_ENGINE is not None:
|
if _CURRENT_DATA_ENGINE is not None:
|
||||||
@@ -331,6 +331,8 @@ async def _run_with_recovery[T](
|
|||||||
*,
|
*,
|
||||||
provider: str | None = None,
|
provider: str | None = None,
|
||||||
endpoint_key: str | None = None,
|
endpoint_key: str | None = None,
|
||||||
|
request_timeout: float | None = None,
|
||||||
|
max_retries: int | None = None,
|
||||||
) -> T:
|
) -> T:
|
||||||
"""Execute one LLM call under the classified retry / pause / breaker policy.
|
"""Execute one LLM call under the classified retry / pause / breaker policy.
|
||||||
|
|
||||||
@@ -353,9 +355,21 @@ async def _run_with_recovery[T](
|
|||||||
pause_file: Path | None = None
|
pause_file: Path | None = None
|
||||||
try:
|
try:
|
||||||
while True:
|
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)
|
await _wait_for_breaker(endpoint_key)
|
||||||
try:
|
try:
|
||||||
|
async with asyncio.timeout(request_timeout or settings.OPENAI_TIMEOUT_SECONDS):
|
||||||
result = await call_once()
|
result = await call_once()
|
||||||
except (KeyboardInterrupt, asyncio.CancelledError):
|
except (KeyboardInterrupt, asyncio.CancelledError):
|
||||||
raise
|
raise
|
||||||
@@ -369,6 +383,8 @@ async def _run_with_recovery[T](
|
|||||||
if endpoint_key:
|
if endpoint_key:
|
||||||
_ENDPOINT_BREAKER.record_failure(endpoint_key, failure)
|
_ENDPOINT_BREAKER.record_failure(endpoint_key, failure)
|
||||||
if not failure.retryable:
|
if not failure.retryable:
|
||||||
|
if failed_endpoints is not None and endpoint_key:
|
||||||
|
failed_endpoints[endpoint_key] = e
|
||||||
llm_logger.warning(
|
llm_logger.warning(
|
||||||
f"LLM call failed permanently ({failure.category.value}): {e}"
|
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
|
attempt = attempts.get(failure.category, 0) + 1
|
||||||
attempts[failure.category] = attempt
|
attempts[failure.category] = attempt
|
||||||
policy = retry_policy_for(failure.category)
|
policy = retry_policy_for(failure.category)
|
||||||
if attempt >= policy.max_attempts:
|
|
||||||
break
|
|
||||||
total_attempts = sum(attempts.values())
|
total_attempts = sum(attempts.values())
|
||||||
if total_attempts >= _MAX_TOTAL_RETRY_ATTEMPTS:
|
max_attempts = (
|
||||||
llm_logger.warning(
|
max_retries + 1 if max_retries is not None else policy.max_attempts
|
||||||
"LLM retry total across failure categories reached"
|
|
||||||
f" {total_attempts}; treating as exhausted."
|
|
||||||
)
|
)
|
||||||
|
if total_attempts >= max_attempts:
|
||||||
break
|
break
|
||||||
delay = policy.delay_for(attempt)
|
delay = policy.delay_for(attempt)
|
||||||
_record_llm_retry(
|
_record_llm_retry(
|
||||||
str(e),
|
str(e),
|
||||||
delay,
|
delay,
|
||||||
attempt=attempt,
|
attempt=total_attempts,
|
||||||
max_retries=policy.max_attempts,
|
max_retries=max_attempts - 1,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
)
|
)
|
||||||
llm_logger.warning(
|
llm_logger.warning(
|
||||||
@@ -414,6 +427,8 @@ async def _run_with_recovery[T](
|
|||||||
|
|
||||||
# Retryable attempts exhausted.
|
# Retryable attempts exhausted.
|
||||||
if _FALLBACK_AVAILABLE.get():
|
if _FALLBACK_AVAILABLE.get():
|
||||||
|
if failed_endpoints is not None and endpoint_key:
|
||||||
|
failed_endpoints[endpoint_key] = last_error
|
||||||
_record_llm_event(
|
_record_llm_event(
|
||||||
"llm_gave_up",
|
"llm_gave_up",
|
||||||
{
|
{
|
||||||
@@ -445,6 +460,8 @@ async def _run_with_recovery[T](
|
|||||||
) from last_error
|
) from last_error
|
||||||
|
|
||||||
llm_logger.info("Resume signal received, retrying LLM call...")
|
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()
|
current_engine = _get_current_data_engine()
|
||||||
if current_engine:
|
if current_engine:
|
||||||
current_engine._publish("task_resumed", {})
|
current_engine._publish("task_resumed", {})
|
||||||
@@ -540,8 +557,8 @@ class RobustChatModelWrapper:
|
|||||||
|
|
||||||
def _endpoint_key(self) -> str:
|
def _endpoint_key(self) -> str:
|
||||||
if self.endpoint is not None:
|
if self.endpoint is not None:
|
||||||
return f"{self._provider_value()}:{self.endpoint.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 type(self.base_model).__name__
|
return f"{type(self.base_model).__name__}:{id(self.base_model)}"
|
||||||
|
|
||||||
def _traced_call(self, args: tuple, kwargs: dict) -> tuple[tuple, dict, Any]:
|
def _traced_call(self, args: tuple, kwargs: dict) -> tuple[tuple, dict, Any]:
|
||||||
trace_id = None
|
trace_id = None
|
||||||
@@ -564,6 +581,8 @@ class RobustChatModelWrapper:
|
|||||||
functools.partial(self.base_model.ainvoke, *args, **kwargs),
|
functools.partial(self.base_model.ainvoke, *args, **kwargs),
|
||||||
provider=self._provider_value(),
|
provider=self._provider_value(),
|
||||||
endpoint_key=self._endpoint_key(),
|
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)
|
self._meter_usage(response)
|
||||||
return response
|
return response
|
||||||
@@ -584,6 +603,8 @@ class RobustChatModelWrapper:
|
|||||||
functools.partial(self._complete_attempt, emit_deltas, *args, **kwargs),
|
functools.partial(self._complete_attempt, emit_deltas, *args, **kwargs),
|
||||||
provider=self._provider_value(),
|
provider=self._provider_value(),
|
||||||
endpoint_key=self._endpoint_key(),
|
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)
|
self._meter_usage(response)
|
||||||
return response
|
return response
|
||||||
@@ -783,7 +804,7 @@ async def acomplete_structured(
|
|||||||
async def invoke_llm_with_timeout_message[T](
|
async def invoke_llm_with_timeout_message[T](
|
||||||
llm_call: Coroutine[Any, Any, T],
|
llm_call: Coroutine[Any, Any, T],
|
||||||
timeout_seconds: int = 10,
|
timeout_seconds: int = 10,
|
||||||
hard_timeout: int = 180,
|
hard_timeout: int | None = None,
|
||||||
) -> T:
|
) -> T:
|
||||||
"""Send an LLM call and display a countdown / timeout message if delayed."""
|
"""Send an LLM call and display a countdown / timeout message if delayed."""
|
||||||
llm_task = asyncio.create_task(llm_call)
|
llm_task = asyncio.create_task(llm_call)
|
||||||
@@ -810,7 +831,7 @@ async def invoke_llm_with_timeout_message[T](
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
elapsed = asyncio.get_event_loop().time() - start_time
|
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.")
|
user_messages_logger.error(f"LLM call timed out after {hard_timeout} seconds.")
|
||||||
raise TimeoutError(f"LLM call timed out after {hard_timeout} seconds.")
|
raise TimeoutError(f"LLM call timed out after {hard_timeout} seconds.")
|
||||||
except BaseException:
|
except BaseException:
|
||||||
@@ -855,7 +876,9 @@ def get_cached_raw_model(
|
|||||||
model_name=model_name or settings.OPENAI_MODEL,
|
model_name=model_name or settings.OPENAI_MODEL,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
timeout_seconds=timeout if timeout is not None else settings.OPENAI_TIMEOUT_SECONDS,
|
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,
|
enable_grounding=enable_grounding,
|
||||||
)
|
)
|
||||||
return ModelFactory.get_model(ep)
|
return ModelFactory.get_model(ep)
|
||||||
@@ -963,6 +986,19 @@ async def with_fallback[T](
|
|||||||
main_call: Callable[[], Awaitable[T]],
|
main_call: Callable[[], Awaitable[T]],
|
||||||
fallback_call: Callable[[], Awaitable[T]],
|
fallback_call: Callable[[], Awaitable[T]],
|
||||||
none_should_fallback: bool = True,
|
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:
|
) -> T:
|
||||||
"""Run main_call, switching to fallback_call only when it can actually help.
|
"""Run main_call, switching to fallback_call only when it can actually help.
|
||||||
|
|
||||||
@@ -995,6 +1031,7 @@ async def with_fallback[T](
|
|||||||
try:
|
try:
|
||||||
result = await main_call()
|
result = await main_call()
|
||||||
except (KeyboardInterrupt, asyncio.CancelledError):
|
except (KeyboardInterrupt, asyncio.CancelledError):
|
||||||
|
_FALLBACK_AVAILABLE.reset(fallback_token)
|
||||||
raise
|
raise
|
||||||
except LLMCallError as e:
|
except LLMCallError as e:
|
||||||
_FALLBACK_AVAILABLE.reset(fallback_token)
|
_FALLBACK_AVAILABLE.reset(fallback_token)
|
||||||
|
|||||||
@@ -664,7 +664,9 @@ def is_scrcpy_installed() -> bool:
|
|||||||
|
|
||||||
def detect_video_tools_enabled() -> bool:
|
def detect_video_tools_enabled() -> bool:
|
||||||
"""Check if both scrcpy and ffmpeg are available to enable automated video features."""
|
"""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):
|
class FFmpegNotInstalledError(Exception):
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
{
|
{
|
||||||
"default": {
|
"default": {
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model": "${env:OPENAI_MODEL:-gpt-5-mini}",
|
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
||||||
"timeout": "${env:OPENAI_TIMEOUT_SECONDS:-120}",
|
"timeout": "${env:OPENAI_TIMEOUT_SECONDS:-300}",
|
||||||
"max_retries": "${env:OPENAI_MAX_RETRIES:-0}",
|
"max_retries": "${env:OPENAI_MAX_RETRIES:-5}",
|
||||||
"fallback": {
|
"fallback": {
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model": "${env:OPENAI_MODEL:-gpt-5-mini}"
|
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"presets": {},
|
"presets": {},
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"explorer_mode": "flash",
|
"explorer_mode": "flash",
|
||||||
"step_summarizer": {
|
"step_summarizer": {
|
||||||
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
"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
|
"prune_history_xml": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
"device_probes": true
|
"device_probes": true
|
||||||
},
|
},
|
||||||
"video_analyzer": {
|
"video_analyzer": {
|
||||||
"enabled": "${env:ARTEMIS_VIDEO_ENABLED:-true}",
|
"enabled": "${env:ARTEMIS_VIDEO_ENABLED:-false}",
|
||||||
"enable_ledger": true
|
"enable_ledger": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
"max_steps": 12,
|
"max_steps": 12,
|
||||||
"min_steps": 3,
|
"min_steps": 3,
|
||||||
"target_source_tokens": 2000,
|
"target_source_tokens": 2000,
|
||||||
"model": "${env:OPENAI_MODEL:-gpt-5-mini}",
|
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
||||||
"max_chunks": 8
|
"max_chunks": 8
|
||||||
},
|
},
|
||||||
"recall": {
|
"recall": {
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"video_analyzer": {
|
"video_analyzer": {
|
||||||
"enabled": "${env:ARTEMIS_VIDEO_ENABLED:-true}",
|
"enabled": "${env:ARTEMIS_VIDEO_ENABLED:-false}",
|
||||||
"enable_ledger": true
|
"enable_ledger": true
|
||||||
},
|
},
|
||||||
"outputter": {
|
"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
|
#### 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.
|
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",
|
"artemis",
|
||||||
instructions=(
|
instructions=(
|
||||||
"ARTEMIS is an autonomous mobile AI agent and Android UI automation engine. "
|
"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, "
|
"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_manage_task to check status or steer execution, "
|
||||||
"mobile_get_device_state to inspect real-time device screen/hierarchy, "
|
"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.
|
- **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.
|
- **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).
|
- **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_get_device_state",
|
||||||
"mobile_inspect_trace",
|
"mobile_inspect_trace",
|
||||||
"mobile_diagnose",
|
"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
|
when the action failed/was intercepted, else null), and
|
||||||
`action_overlay_screenshot` (the action visually marked — e.g. red circle
|
`action_overlay_screenshot` (the action visually marked — e.g. red circle
|
||||||
for taps — key for verifying the agent tapped the right element).
|
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
|
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
|
call it made (name, arguments, result — e.g. what the explorer or OCR
|
||||||
reported), the planned action, any safety-net interception and the
|
reported), the planned action, any safety-net interception and the
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ dependencies = [
|
|||||||
"uuid-utils>=0.9.0",
|
"uuid-utils>=0.9.0",
|
||||||
"jupyter-client>=8.6.0",
|
"jupyter-client>=8.6.0",
|
||||||
"ipykernel>=6.29.0",
|
"ipykernel>=6.29.0",
|
||||||
|
"python-docx>=1.2.0",
|
||||||
|
"openpyxl>=3.1.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
+36
-30
@@ -17,6 +17,7 @@ param(
|
|||||||
[int]$Port = 8000,
|
[int]$Port = 8000,
|
||||||
[switch]$NoOpen = $false,
|
[switch]$NoOpen = $false,
|
||||||
[switch]$SetupOnly = $false,
|
[switch]$SetupOnly = $false,
|
||||||
|
[switch]$SkipUpdate = $false,
|
||||||
[ValidateSet('Official', 'China')][string]$Mirror
|
[ValidateSet('Official', 'China')][string]$Mirror
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,6 +33,27 @@ $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
|||||||
$RootDir = Split-Path -Parent $ScriptDir
|
$RootDir = Split-Path -Parent $ScriptDir
|
||||||
Set-Location $RootDir
|
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')
|
. (Join-Path $ScriptDir 'download_config.ps1')
|
||||||
try {
|
try {
|
||||||
Initialize-ArtemisDownloads -ProjectRoot $RootDir -Mode $Mirror
|
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 ' [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) {
|
if ($SetupOnly) {
|
||||||
Write-Host ' [OK] Setup complete. Run start.bat to launch Artemis.' -ForegroundColor Green
|
Write-Host ' [OK] Setup complete. Run start.bat to launch Artemis.' -ForegroundColor Green
|
||||||
exit 0
|
exit 0
|
||||||
@@ -381,7 +408,10 @@ Start-LocalAdbServer
|
|||||||
$ShowcaseIndex = "$RootDir\apps\showcase_ui\dist\frontend\browser\index.html"
|
$ShowcaseIndex = "$RootDir\apps\showcase_ui\dist\frontend\browser\index.html"
|
||||||
$ShowcaseIndexAlt1 = "$RootDir\apps\showcase_ui\dist\browser\index.html"
|
$ShowcaseIndexAlt1 = "$RootDir\apps\showcase_ui\dist\browser\index.html"
|
||||||
$ShowcaseIndexAlt2 = "$RootDir\apps\showcase_ui\dist\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 (-not (Test-NodeCompatible)) {
|
||||||
if (Test-CommandExists "node") {
|
if (Test-CommandExists "node") {
|
||||||
$curVer = (& node -v 2>$null)
|
$curVer = (& node -v 2>$null)
|
||||||
@@ -429,7 +459,7 @@ if ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Path $ShowcaseIndexAlt1))
|
|||||||
if (Test-NodeCompatible) {
|
if (Test-NodeCompatible) {
|
||||||
$nodeVer = (& node -v 2>$null)
|
$nodeVer = (& node -v 2>$null)
|
||||||
Write-Host " [OK] Node.js $nodeVer is ready." -ForegroundColor Green
|
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"
|
Push-Location "$RootDir\apps\showcase_ui"
|
||||||
try {
|
try {
|
||||||
$npmExec = if (Test-CommandExists "npm.cmd") { "npm.cmd" } else { "npm" }
|
$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 ($LASTEXITCODE -ne 0) { throw "Frontend build failed (exit $LASTEXITCODE)." }
|
||||||
if (Test-Path $ShowcaseIndex) {
|
if (Test-Path $ShowcaseIndex) {
|
||||||
Write-Host " [OK] Showcase UI compiled successfully." -ForegroundColor Green
|
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
|
Write-Host " [OK] Showcase UI compiled successfully." -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
throw 'Frontend build did not produce index.html.'
|
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 {
|
} catch {
|
||||||
Write-Host " [ERROR] Failed to build Showcase UI: $_" -ForegroundColor Red
|
Write-Host " [ERROR] Failed to build Showcase UI: $_" -ForegroundColor Red
|
||||||
exit 1
|
exit 1
|
||||||
@@ -460,33 +492,7 @@ if ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Path $ShowcaseIndexAlt1))
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# 7. Optionally configure MCP for detected AI IDEs
|
# MCP clients were refreshed after Python dependency sync above.
|
||||||
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 ""
|
|
||||||
|
|
||||||
# 8. Launch unified Showcase UI & open browser
|
# 8. Launch unified Showcase UI & open browser
|
||||||
# Ensure local ADB daemon is warm & listening
|
# 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 ======================================================
|
||||||
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.bat -SetupOnly to prepare Python without launching the UI.
|
||||||
rem Use start-cn.bat or start.bat -Mirror China for China download mirrors.
|
rem Use start-cn.bat or start.bat -Mirror China for China download mirrors.
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\start.ps1" %*
|
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_BASE_URL",
|
||||||
"OPENAI_TIMEOUT_SECONDS",
|
"OPENAI_TIMEOUT_SECONDS",
|
||||||
"OPENAI_MAX_RETRIES",
|
"OPENAI_MAX_RETRIES",
|
||||||
|
"OPENAI_RETRY_INTERVAL_SECONDS",
|
||||||
|
"OPENAI_REASONING_EFFORT",
|
||||||
"ARTEMIS_VIDEO_ENABLED",
|
"ARTEMIS_VIDEO_ENABLED",
|
||||||
"ARTEMIS_OUTPUTTER_ENABLED",
|
"ARTEMIS_OUTPUTTER_ENABLED",
|
||||||
"ARTEMIS_STEP_SUMMARIZER_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.pro.video_analyzer.enabled is False
|
||||||
assert config.outputter.enabled is False
|
assert config.outputter.enabled is False
|
||||||
assert config.flash.step_summarizer.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
|
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")
|
service.ModelSettingsUpdate(model="test", api_key="secret\nvalue")
|
||||||
)
|
)
|
||||||
assert "secret" not in str(exc.value)
|
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 patch.object(va.asyncio, "sleep", new=AsyncMock()):
|
||||||
with pytest.raises(RuntimeError):
|
with pytest.raises(RuntimeError):
|
||||||
await va._invoke_with_retry(op, "test-op", max_attempts=5)
|
await va._invoke_with_retry(op, "test-op", max_attempts=5)
|
||||||
# UNKNOWN failures follow the shared policy, not the caller's outer cap.
|
# Respect both the configured policy and the caller's explicit cap.
|
||||||
assert calls == retry_policy_for(FailureCategory.UNKNOWN).max_attempts
|
assert calls == min(5, retry_policy_for(FailureCategory.UNKNOWN).max_attempts)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------ mid-run agentic degrade
|
# ------------------------------------------------ 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_get_device_state",
|
||||||
"mobile_inspect_trace",
|
"mobile_inspect_trace",
|
||||||
"mobile_diagnose",
|
"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())
|
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_get_device_state",
|
||||||
"mobile_inspect_trace",
|
"mobile_inspect_trace",
|
||||||
"mobile_diagnose",
|
"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_get_device_state",
|
||||||
"mobile_inspect_trace",
|
"mobile_inspect_trace",
|
||||||
"mobile_diagnose",
|
"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 data["mcp_servers"]["artemis"]["env"]["PYTHONPATH"] == str(tmp_path)
|
||||||
assert config_text.count("# BEGIN ARTEMIS MCP CONFIG") == 1
|
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 payload["reason"] == "mid_stream_failure"
|
||||||
assert "stream_exec_id" in payload
|
assert "stream_exec_id" in payload
|
||||||
assert "lower API priority" in payload["message"]
|
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")
|
endpoint = ModelEndpoint(api_key="test", model_name="gpt-5-mini", reasoning_effort="low")
|
||||||
model = ModelFactory.create_model(endpoint)
|
model = ModelFactory.create_model(endpoint)
|
||||||
assert model.use_responses_api is True
|
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"
|
assert model.reasoning_effort == "low"
|
||||||
dynamic = ModelEndpoint()
|
dynamic = ModelEndpoint()
|
||||||
key = dynamic.cache_key()
|
key = dynamic.cache_key()
|
||||||
|
|||||||
@@ -229,11 +229,13 @@ dependencies = [
|
|||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
{ name = "opencv-python" },
|
{ name = "opencv-python" },
|
||||||
|
{ name = "openpyxl" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
{ name = "posthog" },
|
{ name = "posthog" },
|
||||||
{ name = "psutil" },
|
{ name = "psutil" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "python-docx" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "retry" },
|
{ name = "retry" },
|
||||||
@@ -291,6 +293,7 @@ requires-dist = [
|
|||||||
{ name = "mcp", specifier = ">=1.26.0" },
|
{ name = "mcp", specifier = ">=1.26.0" },
|
||||||
{ name = "numpy", specifier = ">=1.26.0" },
|
{ name = "numpy", specifier = ">=1.26.0" },
|
||||||
{ name = "opencv-python", specifier = ">=4.9.0.80" },
|
{ name = "opencv-python", specifier = ">=4.9.0.80" },
|
||||||
|
{ name = "openpyxl", specifier = ">=3.1.5" },
|
||||||
{ name = "pillow", specifier = ">=10.0.0" },
|
{ name = "pillow", specifier = ">=10.0.0" },
|
||||||
{ name = "posthog", specifier = ">=3.5.0" },
|
{ name = "posthog", specifier = ">=3.5.0" },
|
||||||
{ name = "pre-commit", marker = "extra == 'dev'", 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", marker = "extra == 'dev'", specifier = ">=8.4.1" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
||||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.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 = "python-dotenv", specifier = ">=1.0.1" },
|
||||||
{ name = "requests", specifier = ">=2.31.0" },
|
{ name = "requests", specifier = ">=2.31.0" },
|
||||||
{ name = "retry", specifier = ">=0.9.2" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "executing"
|
name = "executing"
|
||||||
version = "2.2.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "orjson"
|
name = "orjson"
|
||||||
version = "3.12.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.3"
|
version = "1.2.3"
|
||||||
|
|||||||
Reference in new issue
Block a user