Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
606ed94160 | ||
|
|
8dbe054a8c | ||
|
|
868f611c36 | ||
|
|
d31eb2a19d | ||
|
|
ab066733b8 |
No files matched your search
@@ -11,6 +11,11 @@ ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
|
|||||||
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
||||||
OPENAI_MAX_RETRIES=5
|
OPENAI_MAX_RETRIES=5
|
||||||
OPENAI_RETRY_INTERVAL_SECONDS=10
|
OPENAI_RETRY_INTERVAL_SECONDS=10
|
||||||
|
OPENAI_RATE_LIMIT_RETRY_SECONDS=60
|
||||||
|
OPENAI_TOOL_FALLBACK_MODEL=gpt-6-luna
|
||||||
|
OPENAI_TOOL_FALLBACK_REASONING_EFFORT=high
|
||||||
|
OPENAI_TOOL_FALLBACK_MAX_RETRIES=3
|
||||||
|
ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS=3
|
||||||
# default 表示不发送思考强度参数;接口不支持所选值时明确报错。
|
# default 表示不发送思考强度参数;接口不支持所选值时明确报错。
|
||||||
OPENAI_REASONING_EFFORT=high
|
OPENAI_REASONING_EFFORT=high
|
||||||
|
|
||||||
@@ -19,6 +24,8 @@ ARTEMIS_VIDEO_ENABLED=false
|
|||||||
ARTEMIS_RECORDING_ENABLED=false
|
ARTEMIS_RECORDING_ENABLED=false
|
||||||
ARTEMIS_OUTPUTTER_ENABLED=true
|
ARTEMIS_OUTPUTTER_ENABLED=true
|
||||||
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
||||||
|
# 默认将任务截图长边压至 2048;false 保留设备原始截图。长边值可在 config/artemis.jsonc 的 agent.screenshot 中覆盖。
|
||||||
|
ARTEMIS_SCREENSHOT_LIMIT_ENABLED=true
|
||||||
# Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。
|
# Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。
|
||||||
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3
|
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3
|
||||||
# Flash 连续无工具调用的最大次数;无效回复不会重复送回模型。
|
# Flash 连续无工具调用的最大次数;无效回复不会重复送回模型。
|
||||||
|
|||||||
@@ -48,10 +48,12 @@ cd Artemis-Eapil
|
|||||||
3. 输入任务目标,选择 Flash 或 Pro 后执行;在工作区查看步骤、截图和结果。
|
3. 输入任务目标,选择 Flash 或 Pro 后执行;在工作区查看步骤、截图和结果。
|
||||||
4. 通过“报告与批量”导出报告或导入 Excel 用例;任务队列支持历史搜索。
|
4. 通过“报告与批量”导出报告或导入 Excel 用例;任务队列支持历史搜索。
|
||||||
|
|
||||||
预设模型为 `gpt-5.6-luna`、`mimo-v2.6-flash`,也可填写自定义名称。默认模型是 `mimo-v2.6-flash`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**;视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。
|
预设模型为 `gpt-5.6-luna`、`gpt-6-luna`、`mimo-v2.6-flash`、`mimo-v2.6-pro`,也可填写自定义名称。默认模型是 `mimo-v2.6-flash`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**。遇到 429 请求过多时改为每次等待 **60 秒**。连续 3 次没有有效工具调用时,本步骤改用 `gpt-6-luna`(high),兜底模型最多再尝试 3 次;下一步恢复首选模型。视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。
|
||||||
|
|
||||||
也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
|
也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
|
||||||
|
|
||||||
|
“功能开关”默认将任务截图长边限制为 2048 像素;关闭后保留设备原始分辨率。可在 [config/artemis.jsonc](config/artemis.jsonc) 的 `agent.screenshot` 中覆盖 `enabled` 和 `max_long_edge`,重启后对新任务生效。
|
||||||
|
|
||||||
报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。
|
报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。
|
||||||
|
|
||||||
## 让 AI 使用
|
## 让 AI 使用
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from apps.admin_console.services.automation_store import identifier, store
|
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 automation_service as service
|
||||||
|
from apps.admin_console.services import batch_archive as archive_service
|
||||||
from apps.admin_console.services import report_export, task_import
|
from apps.admin_console.services import report_export, task_import
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/automation", tags=["automation"])
|
router = APIRouter(prefix="/api/automation", tags=["automation"])
|
||||||
@@ -128,10 +129,13 @@ def delete_batch(batch_id: str):
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(400, str(exc)) from exc
|
raise HTTPException(400, str(exc)) from exc
|
||||||
try:
|
try:
|
||||||
|
if archive_service.status(key, require("batch", key))["status"] == "preparing":
|
||||||
|
raise HTTPException(409, "报告正在打包,请完成后再删除批次")
|
||||||
if not service.delete_batch(key):
|
if not service.delete_batch(key):
|
||||||
raise HTTPException(404, "批次不存在")
|
raise HTTPException(404, "批次不存在")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(409, str(exc)) from exc
|
raise HTTPException(409, str(exc)) from exc
|
||||||
|
archive_service.forget(key)
|
||||||
return {"batch_id": key, "status": "deleted"}
|
return {"batch_id": key, "status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
@@ -144,9 +148,29 @@ def batch_action(batch_id: str, action: Literal["pause", "resume", "retry_failed
|
|||||||
raise HTTPException(409, str(exc)) from exc
|
raise HTTPException(409, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batches/{batch_id}/archive/prepare")
|
||||||
|
def prepare_batch_archive(batch_id: str):
|
||||||
|
batch = require("batch", batch_id)
|
||||||
|
return archive_service.prepare(batch_id, batch)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/batches/{batch_id}/archive/status")
|
||||||
|
def batch_archive_status(batch_id: str):
|
||||||
|
batch = require("batch", batch_id)
|
||||||
|
return archive_service.status(batch_id, batch)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/batches/{batch_id}/archive")
|
@router.get("/batches/{batch_id}/archive")
|
||||||
async def batch_archive(batch_id: str):
|
async def batch_archive(batch_id: str):
|
||||||
require("batch", batch_id)
|
batch = require("batch", batch_id)
|
||||||
|
if archive_service.status(batch_id, batch)["status"] == "preparing":
|
||||||
|
raise HTTPException(409, "报告正在打包,请等待准备完成")
|
||||||
|
path = archive_service.archive_path(batch_id)
|
||||||
|
if (
|
||||||
|
not path.is_file()
|
||||||
|
or not batch.get("report_ready")
|
||||||
|
and archive_service.status(batch_id, batch)["status"] != "ready"
|
||||||
|
):
|
||||||
path = await asyncio.to_thread(report_export.export_batch, batch_id)
|
path = await asyncio.to_thread(report_export.export_batch, batch_id)
|
||||||
return FileResponse(path, filename=f"批次报告-{batch_id[:8]}.zip")
|
return FileResponse(path, filename=f"批次报告-{batch_id[:8]}.zip")
|
||||||
|
|
||||||
|
|||||||
@@ -517,9 +517,12 @@ async def get_model_config_and_env():
|
|||||||
|
|
||||||
@router.get("/server-status")
|
@router.get("/server-status")
|
||||||
async def get_server_runtime_status():
|
async def get_server_runtime_status():
|
||||||
"""Retrieve runtime status, PID, port, and uptime of the Artemis server."""
|
"""Return this server's status without scanning listening ports."""
|
||||||
import os
|
import os
|
||||||
from artemis.runtime.server_lifecycle import get_server_status
|
import time
|
||||||
|
|
||||||
|
from artemis.runtime.process_probe import pid_is_alive
|
||||||
|
from artemis.runtime.server_lifecycle import read_server_info
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from apps.admin_console.core.state import state
|
from apps.admin_console.core.state import state
|
||||||
@@ -527,18 +530,34 @@ async def get_server_runtime_status():
|
|||||||
from admin_console.core.state import state
|
from admin_console.core.state import state
|
||||||
|
|
||||||
port = getattr(state, "port", 8000)
|
port = getattr(state, "port", 8000)
|
||||||
status = get_server_status(port=port)
|
current_pid = os.getpid()
|
||||||
|
pids = {current_pid}
|
||||||
|
started_at = None
|
||||||
|
info = read_server_info()
|
||||||
|
if info and info.get("port") == port:
|
||||||
|
saved_pid = info.get("pid")
|
||||||
|
if isinstance(saved_pid, int) and saved_pid != current_pid and pid_is_alive(saved_pid):
|
||||||
|
pids.add(saved_pid)
|
||||||
|
if saved_pid == current_pid and isinstance(info.get("started_at"), (int, float)):
|
||||||
|
started_at = float(info["started_at"])
|
||||||
|
if started_at is None:
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
started_at = psutil.Process(current_pid).create_time()
|
||||||
|
except Exception:
|
||||||
|
started_at = None
|
||||||
# Explicit DTO: the raw metadata file additionally holds the lifecycle
|
# Explicit DTO: the raw metadata file additionally holds the lifecycle
|
||||||
# token, cmdline, and filesystem paths, none of which belong on the wire.
|
# token, cmdline, and filesystem paths, none of which belong on the wire.
|
||||||
return {
|
return {
|
||||||
"running": status["running"],
|
"running": True,
|
||||||
"port": status["port"],
|
"port": port,
|
||||||
"pids": status["pids"],
|
"pids": sorted(pids),
|
||||||
"active_pid": status["active_pid"],
|
"active_pid": current_pid,
|
||||||
"uptime_seconds": status["uptime_seconds"],
|
"uptime_seconds": max(0.0, time.time() - started_at) if started_at else None,
|
||||||
"url": status["url"],
|
"url": f"http://localhost:{port}",
|
||||||
"admin_url": status["admin_url"],
|
"admin_url": f"http://localhost:{port}/admin",
|
||||||
"current_pid": os.getpid(),
|
"current_pid": current_pid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Background preparation and observable progress for batch report downloads."""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from apps.admin_console.services import report_export
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_states: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def archive_path(batch_id: str):
|
||||||
|
return report_export.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip"
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot(state: dict) -> dict:
|
||||||
|
elapsed = max(0, (state.get("finished_at") or time.monotonic()) - state["started_at"])
|
||||||
|
completed, total = state["completed"], state["total"]
|
||||||
|
eta = (
|
||||||
|
max(0, round(elapsed / completed * (total - completed)))
|
||||||
|
if completed and total > completed
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": state["status"],
|
||||||
|
"stage": state["stage"],
|
||||||
|
"completed": completed,
|
||||||
|
"total": total,
|
||||||
|
"elapsed_seconds": round(elapsed),
|
||||||
|
"eta_seconds": eta,
|
||||||
|
"error": state.get("error"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def status(batch_id: str, batch: dict) -> dict:
|
||||||
|
with _lock:
|
||||||
|
state = _states.get(batch_id)
|
||||||
|
if state:
|
||||||
|
return _snapshot(state)
|
||||||
|
if batch.get("report_ready") and archive_path(batch_id).is_file():
|
||||||
|
return {
|
||||||
|
"status": "ready",
|
||||||
|
"stage": "报告包已就绪",
|
||||||
|
"completed": 0,
|
||||||
|
"total": 0,
|
||||||
|
"elapsed_seconds": 0,
|
||||||
|
"eta_seconds": None,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": "idle",
|
||||||
|
"stage": "尚未开始打包",
|
||||||
|
"completed": 0,
|
||||||
|
"total": 0,
|
||||||
|
"elapsed_seconds": 0,
|
||||||
|
"eta_seconds": None,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(batch_id: str, batch: dict) -> dict:
|
||||||
|
with _lock:
|
||||||
|
current = _states.get(batch_id)
|
||||||
|
if current and current["status"] == "preparing":
|
||||||
|
return _snapshot(current)
|
||||||
|
if batch.get("report_ready") and archive_path(batch_id).is_file():
|
||||||
|
_states.pop(batch_id, None)
|
||||||
|
return status_from_cache()
|
||||||
|
state = {
|
||||||
|
"status": "preparing",
|
||||||
|
"stage": "等待打包",
|
||||||
|
"completed": 0,
|
||||||
|
"total": len(report_export.store.jobs(batch_id)),
|
||||||
|
"started_at": time.monotonic(),
|
||||||
|
}
|
||||||
|
_states[batch_id] = state
|
||||||
|
|
||||||
|
def update(completed: int, total: int, stage: str) -> None:
|
||||||
|
with _lock:
|
||||||
|
state.update(completed=completed, total=total, stage=stage)
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
try:
|
||||||
|
with report_export.EXPORT_LOCK:
|
||||||
|
latest = report_export.store.get("batch", batch_id)
|
||||||
|
if not (latest and latest.get("report_ready") and archive_path(batch_id).is_file()):
|
||||||
|
report_export.export_batch(batch_id, progress=update)
|
||||||
|
with _lock:
|
||||||
|
state.update(status="ready", stage="报告包已就绪", finished_at=time.monotonic())
|
||||||
|
except Exception as exc:
|
||||||
|
with _lock:
|
||||||
|
state.update(
|
||||||
|
status="failed",
|
||||||
|
stage="打包失败",
|
||||||
|
error=str(exc)[:500],
|
||||||
|
finished_at=time.monotonic(),
|
||||||
|
)
|
||||||
|
|
||||||
|
threading.Thread(target=run, name=f"batch-archive-{batch_id[:8]}", daemon=True).start()
|
||||||
|
return status(batch_id, batch)
|
||||||
|
|
||||||
|
|
||||||
|
def status_from_cache() -> dict:
|
||||||
|
return {
|
||||||
|
"status": "ready",
|
||||||
|
"stage": "报告包已就绪",
|
||||||
|
"completed": 0,
|
||||||
|
"total": 0,
|
||||||
|
"elapsed_seconds": 0,
|
||||||
|
"eta_seconds": None,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def forget(batch_id: str) -> None:
|
||||||
|
with _lock:
|
||||||
|
_states.pop(batch_id, None)
|
||||||
@@ -9,6 +9,7 @@ from urllib.parse import urlsplit
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field, SecretStr, field_validator
|
from pydantic import BaseModel, Field, SecretStr, field_validator
|
||||||
|
|
||||||
|
from artemis.config.agent import load_agent_config
|
||||||
from artemis.config.paths import get_env_file
|
from artemis.config.paths import get_env_file
|
||||||
from artemis.config.settings import settings
|
from artemis.config.settings import settings
|
||||||
|
|
||||||
@@ -22,6 +23,13 @@ class ModelSettingsUpdate(BaseModel):
|
|||||||
timeout_seconds: float = Field(default=300, gt=0, le=3600)
|
timeout_seconds: float = Field(default=300, gt=0, le=3600)
|
||||||
max_retries: int = Field(default=5, 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)
|
retry_interval_seconds: float = Field(default=10, ge=0, le=300)
|
||||||
|
rate_limit_retry_seconds: float | None = Field(default=None, ge=0, le=3600)
|
||||||
|
tool_fallback_model: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
tool_fallback_reasoning_effort: (
|
||||||
|
Literal["default", "none", "minimal", "low", "medium", "high", "xhigh", "max"] | None
|
||||||
|
) = None
|
||||||
|
tool_fallback_max_retries: int | None = Field(default=None, ge=0, le=10)
|
||||||
|
tool_fallback_no_progress_attempts: int | None = Field(default=None, ge=1, le=10)
|
||||||
reasoning_effort: Literal[
|
reasoning_effort: Literal[
|
||||||
"default", "none", "minimal", "low", "medium", "high", "xhigh", "max"
|
"default", "none", "minimal", "low", "medium", "high", "xhigh", "max"
|
||||||
] = "high"
|
] = "high"
|
||||||
@@ -44,9 +52,11 @@ class ModelSettingsUpdate(BaseModel):
|
|||||||
raise ValueError("请输入接口根地址,例如 https://api-n-cd.playones.com/v1")
|
raise ValueError("请输入接口根地址,例如 https://api-n-cd.playones.com/v1")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@field_validator("model")
|
@field_validator("model", "tool_fallback_model")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_model(cls, value: str) -> str:
|
def validate_model(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
value = value.strip()
|
value = value.strip()
|
||||||
if not value or any(c in value for c in "\r\n\x00"):
|
if not value or any(c in value for c in "\r\n\x00"):
|
||||||
raise ValueError("模型名称不能为空或包含换行")
|
raise ValueError("模型名称不能为空或包含换行")
|
||||||
@@ -63,6 +73,11 @@ def get_model_settings() -> dict:
|
|||||||
"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,
|
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
|
||||||
|
"rate_limit_retry_seconds": settings.OPENAI_RATE_LIMIT_RETRY_SECONDS,
|
||||||
|
"tool_fallback_model": settings.OPENAI_TOOL_FALLBACK_MODEL,
|
||||||
|
"tool_fallback_reasoning_effort": settings.OPENAI_TOOL_FALLBACK_REASONING_EFFORT,
|
||||||
|
"tool_fallback_max_retries": settings.OPENAI_TOOL_FALLBACK_MAX_RETRIES,
|
||||||
|
"tool_fallback_no_progress_attempts": settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS,
|
||||||
"reasoning_effort": settings.OPENAI_REASONING_EFFORT,
|
"reasoning_effort": settings.OPENAI_REASONING_EFFORT,
|
||||||
"model_presets": [
|
"model_presets": [
|
||||||
"gpt-5.6-luna",
|
"gpt-5.6-luna",
|
||||||
@@ -85,6 +100,20 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
|
|||||||
"OPENAI_RETRY_INTERVAL_SECONDS": str(update.retry_interval_seconds),
|
"OPENAI_RETRY_INTERVAL_SECONDS": str(update.retry_interval_seconds),
|
||||||
"OPENAI_REASONING_EFFORT": update.reasoning_effort,
|
"OPENAI_REASONING_EFFORT": update.reasoning_effort,
|
||||||
}
|
}
|
||||||
|
optional_settings = {
|
||||||
|
"rate_limit_retry_seconds": ("OPENAI_RATE_LIMIT_RETRY_SECONDS", float),
|
||||||
|
"tool_fallback_model": ("OPENAI_TOOL_FALLBACK_MODEL", str),
|
||||||
|
"tool_fallback_reasoning_effort": ("OPENAI_TOOL_FALLBACK_REASONING_EFFORT", str),
|
||||||
|
"tool_fallback_max_retries": ("OPENAI_TOOL_FALLBACK_MAX_RETRIES", int),
|
||||||
|
"tool_fallback_no_progress_attempts": (
|
||||||
|
"ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS",
|
||||||
|
int,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for field_name, (env_name, expected_type) in optional_settings.items():
|
||||||
|
value = getattr(update, field_name)
|
||||||
|
if value is not None:
|
||||||
|
values[env_name] = str(value)
|
||||||
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()
|
||||||
persist_environment(values)
|
persist_environment(values)
|
||||||
@@ -94,6 +123,10 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
|
|||||||
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_RETRY_INTERVAL_SECONDS = update.retry_interval_seconds
|
||||||
settings.OPENAI_REASONING_EFFORT = update.reasoning_effort
|
settings.OPENAI_REASONING_EFFORT = update.reasoning_effort
|
||||||
|
for field_name, (env_name, expected_type) in optional_settings.items():
|
||||||
|
value = getattr(update, field_name)
|
||||||
|
if value is not None:
|
||||||
|
setattr(settings, env_name, expected_type(value))
|
||||||
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
|
||||||
@@ -137,14 +170,19 @@ class CapabilitySettings(BaseModel):
|
|||||||
recording_enabled: bool = False
|
recording_enabled: bool = False
|
||||||
outputter_enabled: bool = True
|
outputter_enabled: bool = True
|
||||||
step_summarizer_enabled: bool = True
|
step_summarizer_enabled: bool = True
|
||||||
|
screenshot_limit_enabled: bool = True
|
||||||
|
screenshot_max_long_edge: int = 2048
|
||||||
|
|
||||||
|
|
||||||
def get_capability_settings() -> CapabilitySettings:
|
def get_capability_settings() -> CapabilitySettings:
|
||||||
|
screenshot = load_agent_config().screenshot
|
||||||
return CapabilitySettings(
|
return CapabilitySettings(
|
||||||
video_enabled=settings.ARTEMIS_VIDEO_ENABLED,
|
video_enabled=settings.ARTEMIS_VIDEO_ENABLED,
|
||||||
recording_enabled=settings.ARTEMIS_RECORDING_ENABLED,
|
recording_enabled=settings.ARTEMIS_RECORDING_ENABLED,
|
||||||
outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED,
|
outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED,
|
||||||
step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED,
|
step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED,
|
||||||
|
screenshot_limit_enabled=screenshot.enabled,
|
||||||
|
screenshot_max_long_edge=screenshot.max_long_edge,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -154,6 +192,7 @@ def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
|
|||||||
"ARTEMIS_RECORDING_ENABLED": update.recording_enabled,
|
"ARTEMIS_RECORDING_ENABLED": update.recording_enabled,
|
||||||
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
|
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
|
||||||
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
|
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
|
||||||
|
"ARTEMIS_SCREENSHOT_LIMIT_ENABLED": update.screenshot_limit_enabled,
|
||||||
}
|
}
|
||||||
persist_environment({key: str(value).lower() for key, value in values.items()})
|
persist_environment({key: str(value).lower() for key, value in values.items()})
|
||||||
for key, value in values.items():
|
for key, value in values.items():
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Evidence-backed Word/Excel artifacts, independent of the language model runtime."""
|
"""Evidence-backed Word/Excel artifacts, independent of the language model runtime."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import cast
|
from typing import Callable, cast
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
@@ -695,7 +695,7 @@ def export_session_media(session_id: str, kind: str) -> Path:
|
|||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
def export_batch(batch_id: str) -> Path:
|
def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None = None) -> Path:
|
||||||
with EXPORT_LOCK:
|
with EXPORT_LOCK:
|
||||||
batch = store.get("batch", identifier(batch_id))
|
batch = store.get("batch", identifier(batch_id))
|
||||||
if not batch:
|
if not batch:
|
||||||
@@ -704,6 +704,8 @@ def export_batch(batch_id: str) -> Path:
|
|||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
formats = batch["formats"]
|
formats = batch["formats"]
|
||||||
records = [session_report(job["session_id"]) for job in store.jobs(batch_id)]
|
records = [session_report(job["session_id"]) for job in store.jobs(batch_id)]
|
||||||
|
if progress:
|
||||||
|
progress(0, len(records), "正在生成批次汇总")
|
||||||
rows = [("任务编号", "任务名称", "执行状态", "验证结论", "异常原因")]
|
rows = [("任务编号", "任务名称", "执行状态", "验证结论", "异常原因")]
|
||||||
rows += [
|
rows += [
|
||||||
(r["id"], r["name"], STATUS.get(r["status"], r["status"]), r["verdict"], r["error"])
|
(r["id"], r["name"], STATUS.get(r["status"], r["status"]), r["verdict"], r["error"])
|
||||||
@@ -734,9 +736,18 @@ def export_batch(batch_id: str) -> Path:
|
|||||||
for path in outputs:
|
for path in outputs:
|
||||||
archive.write(path, path.name)
|
archive.write(path, path.name)
|
||||||
for number, report in enumerate(records, 1):
|
for number, report in enumerate(records, 1):
|
||||||
|
if progress:
|
||||||
|
progress(number - 1, len(records), f"正在打包任务 {number}/{len(records)}")
|
||||||
|
prefix = f"任务报告/{number:04d}_{archive_case_name(report)}"
|
||||||
for path in export_session(report["id"], formats):
|
for path in export_session(report["id"], formats):
|
||||||
archive.write(
|
archive.write(path, f"{prefix}/{path.name}")
|
||||||
path, f"任务报告/{number:04d}_{archive_case_name(report)}/{path.name}"
|
for source, name in session_screenshots(report):
|
||||||
)
|
archive.write(source, f"{prefix}/步骤截图/{name}")
|
||||||
|
for source, name in session_videos(report):
|
||||||
|
archive.write(source, f"{prefix}/任务录屏/{name}")
|
||||||
|
if progress:
|
||||||
|
progress(number, len(records), f"已打包任务 {number}/{len(records)}")
|
||||||
|
if progress:
|
||||||
|
progress(len(records), len(records), "正在完成压缩包")
|
||||||
temporary.replace(target)
|
temporary.replace(target)
|
||||||
return target
|
return target
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.2.3",
|
"version": "1.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.2.3",
|
"version": "1.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.1.5",
|
"@angular/cdk": "^22.1.5",
|
||||||
"@angular/common": "^22.1.4",
|
"@angular/common": "^22.1.4",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.2.3",
|
"version": "1.3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve --proxy-config proxy.conf.json",
|
"start": "ng serve --proxy-config proxy.conf.json",
|
||||||
|
|||||||
+14
-1
@@ -71,8 +71,21 @@
|
|||||||
@if(b.status === 'running'){<button [disabled]="busy()" (click)="batchAction('pause')">暂停后续任务</button>}
|
@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 === 'draft' || b.status === 'paused'){<button [disabled]="busy()" (click)="batchAction('resume')">{{b.status === 'draft' ? '开始执行' : '继续未开始任务'}}</button>}
|
||||||
@if(b.status !== 'running' && b.status !== 'deleting'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
|
@if(b.status !== 'running' && b.status !== 'deleting'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
|
||||||
<a [href]="'/api/automation/batches/'+b.id+'/archive'">下载批次报告 ZIP</a>
|
<button type="button" [disabled]="archiveBusy()" (click)="downloadBatchArchive(b.id)">{{archiveBusy() && archiveBatchId() === b.id ? '正在准备报告包…' : '下载批次报告 ZIP'}}</button>
|
||||||
</div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p>
|
</div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p>
|
||||||
|
@if(archiveBatchId() === b.id && archiveProgress(); as progress){
|
||||||
|
<div class="archive-progress" role="status" aria-live="polite" [class.archive-failed]="progress.status === 'failed'">
|
||||||
|
<div class="archive-progress-heading">
|
||||||
|
@if(progress.status === 'preparing'){<span class="archive-spinner" aria-hidden="true"></span>}
|
||||||
|
<strong>{{progress.stage}}</strong>
|
||||||
|
</div>
|
||||||
|
@if(progress.status === 'preparing'){
|
||||||
|
<div class="archive-track" role="progressbar" [attr.aria-valuenow]="progress.total ? progress.completed : null" [attr.aria-valuemin]="progress.total ? 0 : null" [attr.aria-valuemax]="progress.total || null" [attr.aria-label]="'批次报告打包进度:' + progress.stage"><span [style.width.%]="progress.total ? progress.completed / progress.total * 100 : 8"></span></div>
|
||||||
|
<p>已处理 {{progress.completed}} / {{progress.total}} 个任务 · 已用 {{duration(progress.elapsed_seconds)}}@if(progress.eta_seconds !== null){ · 预计还需约 {{duration(progress.eta_seconds)}}}</p>
|
||||||
|
} @else if(progress.status === 'failed'){<p>{{progress.error}}。可点击下载按钮重试。</p>}
|
||||||
|
@else if(progress.status === 'ready'){<p>打包完成,下载已开始。</p>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</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); tab.set('reports')" [title]="j.case.goal">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
|
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true); tab.set('reports')" [title]="j.case.goal">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
|
||||||
</section>}
|
</section>}
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:
|
|||||||
.device{background:#f8fafc;padding:12px 16px;}.device label{margin:0;}
|
.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;}
|
.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;}
|
||||||
.batch-record{display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record .record{flex:1;min-width:0;width:auto;margin:0;}.batch-record .delete-batch{flex:0 0 40px;min-width:40px;padding:0;color:#b91c1c;}.batch-record .delete-batch:hover:not(:disabled){background:#fff1f2;border-color:#fda4af;}.batch-record .delete-batch .material-symbols-outlined{font-size:19px;}
|
.batch-record{display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record .record{flex:1;min-width:0;width:auto;margin:0;}.batch-record .delete-batch{flex:0 0 40px;min-width:40px;padding:0;color:#b91c1c;}.batch-record .delete-batch:hover:not(:disabled){background:#fff1f2;border-color:#fda4af;}.batch-record .delete-batch .material-symbols-outlined{font-size:19px;}
|
||||||
|
.archive-progress{margin:12px 0;padding:11px 13px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;color:#1e40af;}
|
||||||
|
.archive-progress-heading{display:flex;align-items:center;gap:8px;}
|
||||||
|
.archive-progress p{margin:7px 0 0;font-size:12px;color:#475569;}
|
||||||
|
.archive-progress.archive-failed{border-color:#fecdd3;background:#fff1f2;color:#be123c;}
|
||||||
|
.archive-track{height:7px;margin-top:10px;border-radius:999px;background:#dbeafe;overflow:hidden;}
|
||||||
|
.archive-track span{display:block;height:100%;border-radius:inherit;background:#3b82f6;transition:width .3s ease;}
|
||||||
|
.archive-spinner{width:13px;height:13px;border:2px solid #bfdbfe;border-top-color:#2563eb;border-radius:50%;animation:archive-spin .8s linear infinite;}
|
||||||
|
@keyframes archive-spin{to{transform:rotate(360deg);}}
|
||||||
.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;}
|
.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;}
|
.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;}
|
.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;}
|
||||||
|
|||||||
+55
-1
@@ -14,12 +14,13 @@ describe('AutomationPanelComponent batch deletion', () => {
|
|||||||
agentStatus: signal('idle'),
|
agentStatus: signal('idle'),
|
||||||
sessions: signal<any[]>([])
|
sessions: signal<any[]>([])
|
||||||
};
|
};
|
||||||
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'delete']);
|
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'post', 'delete']);
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
(http.get.and as any).callFake((url: string) => of(url.endsWith('/preferences')
|
(http.get.and as any).callFake((url: string) => of(url.endsWith('/preferences')
|
||||||
? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : []));
|
? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : []));
|
||||||
http.delete.and.returnValue(of({ status: 'deleted' }));
|
http.delete.and.returnValue(of({ status: 'deleted' }));
|
||||||
|
http.post.and.returnValue(of({ status: 'ready', stage: '报告包已就绪', completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null, error: null }));
|
||||||
http.delete.calls.reset();
|
http.delete.calls.reset();
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [AutomationPanelComponent],
|
imports: [AutomationPanelComponent],
|
||||||
@@ -77,4 +78,57 @@ describe('AutomationPanelComponent batch deletion', () => {
|
|||||||
expect(root.querySelector('.report-body')?.textContent).toContain('第二行');
|
expect(root.querySelector('.report-body')?.textContent).toContain('第二行');
|
||||||
expect(root.querySelector('.report-body table td')?.textContent).toBe('设置');
|
expect(root.querySelector('.report-body table td')?.textContent).toBe('设置');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prepares a batch archive before starting the browser download', async () => {
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.tab.set('batches');
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
component.batch.set({ id: 'batch-3', name: '批量测试', status: 'completed', jobs: [] });
|
||||||
|
const click = spyOn(HTMLAnchorElement.prototype, 'click');
|
||||||
|
fixture.detectChanges();
|
||||||
|
(fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).click();
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(http.post).toHaveBeenCalledWith('/api/automation/batches/batch-3/archive/prepare', {});
|
||||||
|
expect(click).toHaveBeenCalled();
|
||||||
|
expect(fixture.nativeElement.querySelector('.archive-progress')?.textContent).toContain('下载已开始');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows archive errors and lets the user retry', async () => {
|
||||||
|
http.post.and.returnValue(of({ status: 'failed', stage: '打包失败', completed: 1, total: 2, elapsed_seconds: 3, eta_seconds: null, error: '截图文件无法读取' }));
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.tab.set('batches');
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
component.batch.set({ id: 'batch-4', name: '批量测试', status: 'completed', jobs: [] });
|
||||||
|
fixture.detectChanges();
|
||||||
|
(fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).click();
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.archive-progress')?.textContent).toContain('截图文件无法读取');
|
||||||
|
expect((fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).disabled).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows live archive progress and estimated remaining time while preparing', async () => {
|
||||||
|
http.post.and.returnValue(of({ status: 'preparing', stage: '正在打包任务 2/4', completed: 1, total: 4, elapsed_seconds: 12, eta_seconds: 36, error: null }));
|
||||||
|
(http.get.and as any).callFake((url: string) => of(url.endsWith('/status')
|
||||||
|
? { status: 'ready', stage: '报告包已就绪', completed: 4, total: 4, elapsed_seconds: 13, eta_seconds: null, error: null }
|
||||||
|
: url.endsWith('/preferences') ? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : []));
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.tab.set('batches');
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
component.batch.set({ id: 'batch-5', name: '批量测试', status: 'completed', jobs: [] });
|
||||||
|
spyOn(HTMLAnchorElement.prototype, 'click');
|
||||||
|
fixture.detectChanges();
|
||||||
|
(fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).click();
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const progress = fixture.nativeElement.querySelector('.archive-progress') as HTMLElement;
|
||||||
|
expect(progress.textContent).toContain('已处理 1 / 4 个任务');
|
||||||
|
expect(progress.textContent).toContain('预计还需约 36 秒');
|
||||||
|
expect(progress.querySelector('.archive-spinner')).not.toBeNull();
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -7,6 +7,16 @@ import { AgentService } from '../../services/agent.service';
|
|||||||
import { SystemService } from '../../services/system.service';
|
import { SystemService } from '../../services/system.service';
|
||||||
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
|
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
|
||||||
|
|
||||||
|
interface ArchiveProgress {
|
||||||
|
status: 'idle' | 'preparing' | 'ready' | 'failed';
|
||||||
|
stage: string;
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
elapsed_seconds: number;
|
||||||
|
eta_seconds: number | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule],
|
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule],
|
||||||
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
|
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
|
||||||
@@ -16,6 +26,8 @@ export class AutomationPanelComponent {
|
|||||||
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
|
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
|
||||||
batches = signal<any[]>([]); preview = signal<any>(null);
|
batches = signal<any[]>([]); preview = signal<any>(null);
|
||||||
batch = signal<any>(null); report = signal<any>(null);
|
batch = signal<any>(null); report = signal<any>(null);
|
||||||
|
archiveProgress = signal<ArchiveProgress | null>(null);
|
||||||
|
archiveBatchId = signal(''); archiveBusy = signal(false);
|
||||||
guidance = '';
|
guidance = '';
|
||||||
taskStatus = computed(() => {
|
taskStatus = computed(() => {
|
||||||
const sid = this.agent.currentSessionId();
|
const sid = this.agent.currentSessionId();
|
||||||
@@ -40,6 +52,38 @@ export class AutomationPanelComponent {
|
|||||||
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
|
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
|
||||||
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告',deleting:'删除中,可重试清理'} as Record<string,string>)[s] || s; }
|
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告',deleting:'删除中,可重试清理'} as Record<string,string>)[s] || s; }
|
||||||
renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
|
renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
|
||||||
|
duration(seconds: number) { const minutes = Math.floor(seconds / 60); return minutes ? `${minutes} 分 ${seconds % 60} 秒` : `${seconds} 秒`; }
|
||||||
|
downloadBatchArchive(id: string) { void this.prepareBatchArchive(id); }
|
||||||
|
private async prepareBatchArchive(id: string) {
|
||||||
|
if (this.archiveBusy()) return;
|
||||||
|
this.archiveBusy.set(true); this.archiveBatchId.set(id);
|
||||||
|
this.archiveProgress.set({ status: 'preparing', stage: '正在请求打包', completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null, error: null });
|
||||||
|
const base = `/api/automation/batches/${encodeURIComponent(id)}/archive`;
|
||||||
|
try {
|
||||||
|
let progress = await firstValueFrom(this.http.post<ArchiveProgress>(`${base}/prepare`, {}));
|
||||||
|
while (!this.destroyed && progress.status === 'preparing') {
|
||||||
|
this.archiveProgress.set(progress);
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 1000));
|
||||||
|
if (this.destroyed) return;
|
||||||
|
progress = await firstValueFrom(this.http.get<ArchiveProgress>(`${base}/status`));
|
||||||
|
}
|
||||||
|
if (this.destroyed) return;
|
||||||
|
this.archiveProgress.set(progress);
|
||||||
|
if (progress.status === 'failed') throw Error(progress.error || '批次报告打包失败');
|
||||||
|
if (progress.status !== 'ready') throw Error('打包任务已中断,请重试');
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = base;
|
||||||
|
link.download = `批次报告-${id.slice(0, 8)}.zip`;
|
||||||
|
document.body.appendChild(link); link.click(); link.remove();
|
||||||
|
this.notice.set('报告包已就绪,正在开始下载');
|
||||||
|
} catch (e: any) {
|
||||||
|
const detail = e.error?.detail;
|
||||||
|
this.archiveProgress.set({
|
||||||
|
...(this.archiveProgress() || { completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null }),
|
||||||
|
status: 'failed', stage: '打包失败', error: typeof detail === 'string' ? detail : e.message || '下载失败'
|
||||||
|
});
|
||||||
|
} finally { this.archiveBusy.set(false); }
|
||||||
|
}
|
||||||
async action(fn: () => Promise<void>) {
|
async action(fn: () => Promise<void>) {
|
||||||
if (this.busy()) return;
|
if (this.busy()) return;
|
||||||
this.busy.set(true); this.error.set(''); this.notice.set('');
|
this.busy.set(true); this.error.set(''); this.notice.set('');
|
||||||
|
|||||||
+17
@@ -354,6 +354,7 @@ limitations under the License.
|
|||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
class="scrubber-range"
|
class="scrubber-range"
|
||||||
|
aria-label="录像进度"
|
||||||
[min]="0"
|
[min]="0"
|
||||||
[max]="duration() || 100"
|
[max]="duration() || 100"
|
||||||
[step]="0.1"
|
[step]="0.1"
|
||||||
@@ -361,6 +362,22 @@ limitations under the License.
|
|||||||
(input)="onScrub($event)"
|
(input)="onScrub($event)"
|
||||||
/>
|
/>
|
||||||
<div class="scrubber-progress-fill" [style.width.%]="progressPercent()"></div>
|
<div class="scrubber-progress-fill" [style.width.%]="progressPercent()"></div>
|
||||||
|
@if (videoStepMarkers().length > 0) {
|
||||||
|
<div class="video-markers" aria-label="录像关键步骤">
|
||||||
|
@for (marker of videoStepMarkers(); track marker.time) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="video-marker"
|
||||||
|
[class.passed]="currentTime() >= marker.time"
|
||||||
|
[class.failed]="marker.failed"
|
||||||
|
[style.left.%]="marker.percent"
|
||||||
|
[title]="videoMarkerLabel(marker)"
|
||||||
|
[attr.aria-label]="'跳转到' + videoMarkerLabel(marker)"
|
||||||
|
(click)="seekToVideoMarker(marker)"
|
||||||
|
></button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<!-- Bottom Controls Row -->
|
<!-- Bottom Controls Row -->
|
||||||
<div class="controls-row">
|
<div class="controls-row">
|
||||||
|
|||||||
+32
@@ -724,6 +724,38 @@
|
|||||||
box-shadow: 0 0 8px rgba(2, 132, 199, 0.4);
|
box-shadow: 0 0 8px rgba(2, 132, 199, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-markers {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 6;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.video-marker {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
padding: 0;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #64748b;
|
||||||
|
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.25);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: transform 0.15s ease, background 0.15s ease;
|
||||||
|
|
||||||
|
&.passed { background: #0284c7; }
|
||||||
|
&.failed { background: #ef4444; }
|
||||||
|
&:hover, &:focus-visible {
|
||||||
|
z-index: 1;
|
||||||
|
transform: translate(-50%, -50%) scale(1.35);
|
||||||
|
outline: 2px solid #38bdf8;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.step-ticks-wrapper {
|
.step-ticks-wrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
import { StepReplayFrame } from '../../core/models/stream.model';
|
||||||
|
import { FloatingVideoPlayerComponent } from './floating-video-player.component';
|
||||||
|
|
||||||
|
describe('FloatingVideoPlayerComponent video markers', () => {
|
||||||
|
it('shows a recorded step on the video timeline and seeks when clicked', () => {
|
||||||
|
const start = 1_800_000_000;
|
||||||
|
const frame: StepReplayFrame = {
|
||||||
|
index: 0, stepNumber: 1, title: '步骤 1: 点击设置', imageUrl: 'step.png',
|
||||||
|
actionText: '点击设置', timestamp: start + 5
|
||||||
|
};
|
||||||
|
const agentService = {
|
||||||
|
isVideoWindowOpen: signal(true),
|
||||||
|
isVideoMinimized: signal(false),
|
||||||
|
recordingPlaybackStatus: signal('ready'),
|
||||||
|
playerMode: signal('video'),
|
||||||
|
hasCurrentSessionStepFrames: signal(true),
|
||||||
|
activeVideoTitle: signal('测试录像'),
|
||||||
|
isVideoLoading: signal(false),
|
||||||
|
activeVideoUrl: signal('/test.mp4'),
|
||||||
|
activeVideoSegments: signal([{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 }]),
|
||||||
|
currentSessionStepFrames: signal([frame]),
|
||||||
|
currentSession: signal({ session_id: 'test', start_time: start }),
|
||||||
|
currentSessionId: signal('test'),
|
||||||
|
videoSeekRequest: signal(null),
|
||||||
|
stepSeekRequest: signal(null)
|
||||||
|
};
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [FloatingVideoPlayerComponent],
|
||||||
|
providers: [{ provide: AgentService, useValue: agentService }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(FloatingVideoPlayerComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const marker = fixture.nativeElement.querySelector('.video-marker') as HTMLButtonElement;
|
||||||
|
expect(marker).not.toBeNull();
|
||||||
|
expect(marker.getAttribute('aria-label')).toContain('步骤 1');
|
||||||
|
expect(marker.style.left).toBe('25%');
|
||||||
|
|
||||||
|
const seek = spyOn(fixture.componentInstance, 'seek');
|
||||||
|
marker.click();
|
||||||
|
expect(seek).toHaveBeenCalledOnceWith(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
+20
@@ -34,6 +34,7 @@ import { StepReplayFrame } from '../../core/models/stream.model';
|
|||||||
import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util';
|
import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util';
|
||||||
import { getActionIcon } from '../../utils/action-formatter.util';
|
import { getActionIcon } from '../../utils/action-formatter.util';
|
||||||
import { locateTimelineTime, sessionTimeToTimelineTime } from '../../utils/recording-timeline.util';
|
import { locateTimelineTime, sessionTimeToTimelineTime } from '../../utils/recording-timeline.util';
|
||||||
|
import { buildVideoStepMarkers, VideoStepMarker } from '../../utils/video-step-markers.util';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-floating-video-player',
|
selector: 'app-floating-video-player',
|
||||||
@@ -83,6 +84,12 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
private stepTimer: any = null;
|
private stepTimer: any = null;
|
||||||
|
|
||||||
public stepFrames = computed(() => this.agentService.currentSessionStepFrames());
|
public stepFrames = computed(() => this.agentService.currentSessionStepFrames());
|
||||||
|
public videoStepMarkers = computed(() => buildVideoStepMarkers(
|
||||||
|
this.stepFrames(),
|
||||||
|
this.agentService.currentSession()?.start_time,
|
||||||
|
this.agentService.activeVideoSegments(),
|
||||||
|
this.duration()
|
||||||
|
));
|
||||||
public totalStepFrames = computed(() => this.stepFrames().length);
|
public totalStepFrames = computed(() => this.stepFrames().length);
|
||||||
public currentStepFrame = computed<StepReplayFrame | null>(() => {
|
public currentStepFrame = computed<StepReplayFrame | null>(() => {
|
||||||
const frames = this.stepFrames();
|
const frames = this.stepFrames();
|
||||||
@@ -394,6 +401,19 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
this.seek(sessionTimeToTimelineTime(segments, sessionSeconds), autoplay);
|
this.seek(sessionTimeToTimelineTime(segments, sessionSeconds), autoplay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public seekToVideoMarker(marker: VideoStepMarker): void {
|
||||||
|
this.seek(marker.time);
|
||||||
|
}
|
||||||
|
|
||||||
|
public videoMarkerLabel(marker: VideoStepMarker): string {
|
||||||
|
const first = marker.steps[0];
|
||||||
|
const count = marker.steps.length;
|
||||||
|
const action = first.actionText || first.title;
|
||||||
|
return count > 1
|
||||||
|
? `步骤 ${first.stepNumber} 等 ${count} 步 · ${this.formatTime(marker.time)} · ${action}`
|
||||||
|
: `步骤 ${first.stepNumber} · ${this.formatTime(marker.time)} · ${action}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Seek on the back-to-back playlist axis used by the scrubber. */
|
/** Seek on the back-to-back playlist axis used by the scrubber. */
|
||||||
public seek(seconds: number, autoplay = false): void {
|
public seek(seconds: number, autoplay = false): void {
|
||||||
const v = this.videoRef?.nativeElement;
|
const v = this.videoRef?.nativeElement;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ interface Capabilities {
|
|||||||
recording_enabled: boolean;
|
recording_enabled: boolean;
|
||||||
outputter_enabled: boolean;
|
outputter_enabled: boolean;
|
||||||
step_summarizer_enabled: boolean;
|
step_summarizer_enabled: boolean;
|
||||||
|
screenshot_limit_enabled: boolean;
|
||||||
|
screenshot_max_long_edge: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -24,6 +26,8 @@ interface Capabilities {
|
|||||||
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
|
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
|
||||||
<label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" />
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
<span>步骤摘要压缩<small>Flash 模式下在后台生成摘要,减少长任务的上下文占用。</small></span></label>
|
<span>步骤摘要压缩<small>Flash 模式下在后台生成摘要,减少长任务的上下文占用。</small></span></label>
|
||||||
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.screenshot_limit_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
|
<span>限制截图分辨率<small>开启时长边最多 {{ config.screenshot_max_long_edge }} 像素;关闭后使用设备原始分辨率。JSON 配置可覆盖开关和上限。</small></span></label>
|
||||||
<button type="button" (click)="save()" [disabled]="busy() || loading() || !loaded()">{{ busy() ? '正在保存…' : '保存功能设置' }}</button>
|
<button type="button" (click)="save()" [disabled]="busy() || loading() || !loaded()">{{ busy() ? '正在保存…' : '保存功能设置' }}</button>
|
||||||
@if (message()) { <p role="status">{{ message() }}</p> }
|
@if (message()) { <p role="status">{{ message() }}</p> }
|
||||||
@if (error()) { <p role="alert" class="error">{{ error() }}</p> }
|
@if (error()) { <p role="alert" class="error">{{ error() }}</p> }
|
||||||
@@ -39,7 +43,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: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true };
|
config: Capabilities = { video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048 };
|
||||||
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({
|
||||||
|
|||||||
+33
-2
@@ -8,6 +8,8 @@ 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: 300, max_retries: 5, retry_interval_seconds: 10, reasoning_effort: 'high',
|
api_key_configured: true, timeout_seconds: 300, max_retries: 5, retry_interval_seconds: 10, reasoning_effort: 'high',
|
||||||
|
rate_limit_retry_seconds: 60, tool_fallback_model: 'gpt-6-luna', tool_fallback_reasoning_effort: 'high',
|
||||||
|
tool_fallback_max_retries: 3, tool_fallback_no_progress_attempts: 3,
|
||||||
model_presets: ['gpt-5.6-luna', 'gpt-6-luna', 'mimo-v2.6-flash', 'mimo-v2.6-pro'], env_path: '/app/.env' };
|
model_presets: ['gpt-5.6-luna', 'gpt-6-luna', 'mimo-v2.6-flash', 'mimo-v2.6-pro'], env_path: '/app/.env' };
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||||
@@ -31,6 +33,9 @@ describe('持久化模型与功能设置', () => {
|
|||||||
expect(request.request.body.timeout_seconds).toBe(300);
|
expect(request.request.body.timeout_seconds).toBe(300);
|
||||||
expect(request.request.body.max_retries).toBe(5);
|
expect(request.request.body.max_retries).toBe(5);
|
||||||
expect(request.request.body.retry_interval_seconds).toBe(10);
|
expect(request.request.body.retry_interval_seconds).toBe(10);
|
||||||
|
expect(request.request.body.rate_limit_retry_seconds).toBe(60);
|
||||||
|
expect(request.request.body.tool_fallback_model).toBe('gpt-6-luna');
|
||||||
|
expect(request.request.body.tool_fallback_no_progress_attempts).toBe(3);
|
||||||
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();
|
||||||
@@ -88,6 +93,29 @@ describe('持久化模型与功能设置', () => {
|
|||||||
expect(reopened.nativeElement.querySelector('#model-reasoning').value).toBe('max');
|
expect(reopened.nativeElement.querySelector('#model-reasoning').value).toBe('max');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('高级稳定性设置可编辑并保存', async () => {
|
||||||
|
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
http.expectOne('/api/system/model-settings').flush(config);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
const root: HTMLElement = fixture.nativeElement;
|
||||||
|
root.querySelector<HTMLDetailsElement>('.recovery-settings')!.open = true;
|
||||||
|
const fallback = root.querySelector<HTMLInputElement>('input[name="toolFallbackModel"]')!;
|
||||||
|
fallback.value = 'custom/fallback';
|
||||||
|
fallback.dispatchEvent(new Event('input'));
|
||||||
|
const wait = root.querySelector<HTMLInputElement>('input[name="rateLimitRetry"]')!;
|
||||||
|
wait.value = '90';
|
||||||
|
wait.dispatchEvent(new Event('input'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
root.querySelector<HTMLFormElement>('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
|
||||||
|
const request = http.expectOne('/api/system/model-settings');
|
||||||
|
expect(request.request.body.tool_fallback_model).toBe('custom/fallback');
|
||||||
|
expect(request.request.body.rate_limit_retry_seconds).toBe(90);
|
||||||
|
request.flush({ ...config, tool_fallback_model: 'custom/fallback', rate_limit_retry_seconds: 90 });
|
||||||
|
});
|
||||||
|
|
||||||
it('连接测试使用当前输入而不写入保存接口', () => {
|
it('连接测试使用当前输入而不写入保存接口', () => {
|
||||||
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -110,17 +138,19 @@ describe('持久化模型与功能设置', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('录屏与视频分析独立保存,重新打开后保留各自状态', () => {
|
it('录屏与视频分析独立保存,重新打开后保留各自状态', () => {
|
||||||
const capabilities = { video_enabled: true, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: false };
|
const capabilities = { video_enabled: true, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: false, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048 };
|
||||||
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
http.expectOne('/api/system/capability-settings').flush(capabilities);
|
http.expectOne('/api/system/capability-settings').flush(capabilities);
|
||||||
fixture.componentInstance.config.video_enabled = false;
|
fixture.componentInstance.config.video_enabled = false;
|
||||||
fixture.componentInstance.config.recording_enabled = true;
|
fixture.componentInstance.config.recording_enabled = true;
|
||||||
|
fixture.componentInstance.config.screenshot_limit_enabled = false;
|
||||||
fixture.componentInstance.save();
|
fixture.componentInstance.save();
|
||||||
const request = http.expectOne('/api/system/capability-settings');
|
const request = http.expectOne('/api/system/capability-settings');
|
||||||
expect(request.request.body.video_enabled).toBeFalse();
|
expect(request.request.body.video_enabled).toBeFalse();
|
||||||
expect(request.request.body.recording_enabled).toBeTrue();
|
expect(request.request.body.recording_enabled).toBeTrue();
|
||||||
const saved = { ...capabilities, video_enabled: false, recording_enabled: true };
|
expect(request.request.body.screenshot_limit_enabled).toBeFalse();
|
||||||
|
const saved = { ...capabilities, video_enabled: false, recording_enabled: true, screenshot_limit_enabled: false };
|
||||||
request.flush(saved);
|
request.flush(saved);
|
||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
|
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
|
||||||
@@ -128,5 +158,6 @@ describe('持久化模型与功能设置', () => {
|
|||||||
http.expectOne('/api/system/capability-settings').flush(saved);
|
http.expectOne('/api/system/capability-settings').flush(saved);
|
||||||
expect(reopened.componentInstance.config.video_enabled).toBeFalse();
|
expect(reopened.componentInstance.config.video_enabled).toBeFalse();
|
||||||
expect(reopened.componentInstance.config.recording_enabled).toBeTrue();
|
expect(reopened.componentInstance.config.recording_enabled).toBeTrue();
|
||||||
|
expect(reopened.componentInstance.config.screenshot_limit_enabled).toBeFalse();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -10,6 +10,11 @@ interface ModelSettings {
|
|||||||
timeout_seconds: number;
|
timeout_seconds: number;
|
||||||
max_retries: number;
|
max_retries: number;
|
||||||
retry_interval_seconds: number;
|
retry_interval_seconds: number;
|
||||||
|
rate_limit_retry_seconds: number;
|
||||||
|
tool_fallback_model: string;
|
||||||
|
tool_fallback_reasoning_effort: string;
|
||||||
|
tool_fallback_max_retries: number;
|
||||||
|
tool_fallback_no_progress_attempts: number;
|
||||||
reasoning_effort: string;
|
reasoning_effort: string;
|
||||||
model_presets: string[];
|
model_presets: string[];
|
||||||
env_path: string;
|
env_path: string;
|
||||||
@@ -56,6 +61,30 @@ interface ModelSettings {
|
|||||||
[(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>
|
<label>重试间隔(秒)<input name="retryInterval" type="number" min="0" max="300" required [(ngModel)]="retryInterval" [disabled]="busy() || loading()" /></label>
|
||||||
</div>
|
</div>
|
||||||
|
<details class="recovery-settings">
|
||||||
|
<summary>稳定性与兜底模型</summary>
|
||||||
|
<div class="recovery-content">
|
||||||
|
<label>429 等待重试(秒)<input name="rateLimitRetry" type="number" min="0" max="3600" required
|
||||||
|
[(ngModel)]="rateLimitRetry" [disabled]="busy() || loading()" /></label>
|
||||||
|
<label>兜底模型名称<input name="toolFallbackModel" type="text" required autocomplete="off"
|
||||||
|
autocapitalize="off" spellcheck="false" [(ngModel)]="toolFallbackModel"
|
||||||
|
[disabled]="busy() || loading()" /></label>
|
||||||
|
<label>兜底思考强度<select name="toolFallbackReasoning" [(ngModel)]="toolFallbackReasoning"
|
||||||
|
[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><option value="max">最高(max)</option>
|
||||||
|
</select></label>
|
||||||
|
<div class="number-fields">
|
||||||
|
<label>兜底请求重试次数<input name="toolFallbackRetries" type="number" min="0" max="10" required
|
||||||
|
[(ngModel)]="toolFallbackRetries" [disabled]="busy() || loading()" /></label>
|
||||||
|
<label>兜底无效调用上限<input name="toolFallbackNoProgress" type="number" min="1" max="10" required
|
||||||
|
[(ngModel)]="toolFallbackNoProgress" [disabled]="busy() || loading()" /></label>
|
||||||
|
</div>
|
||||||
|
<small>首选模型连续 3 次未形成有效工具调用后,本步骤使用兜底模型;下一步恢复首选模型。</small>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<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>
|
||||||
<button type="button" (click)="test()" [disabled]="busy() || loading() || !valid()">
|
<button type="button" (click)="test()" [disabled]="busy() || loading() || !valid()">
|
||||||
@@ -77,6 +106,10 @@ interface ModelSettings {
|
|||||||
input:focus, select: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; }
|
||||||
|
.recovery-settings { border: 1px solid #cbd5e1; border-radius: 10px; background: #f8fafc; padding: 10px 14px; }
|
||||||
|
.recovery-settings summary { cursor: pointer; font-weight: 600; color: #334155; }
|
||||||
|
.recovery-content { display: grid; gap: 10px; padding-top: 12px; }
|
||||||
|
.recovery-content label input, .recovery-content label select { margin-top: 8px; }
|
||||||
.actions { display: flex; gap: 12px; margin-top: 8px; }
|
.actions { display: flex; gap: 12px; margin-top: 8px; }
|
||||||
button { padding: 11px 20px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; cursor: pointer; font: inherit; }
|
button { padding: 11px 20px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; cursor: pointer; font: inherit; }
|
||||||
button:disabled { opacity: .5; cursor: default; } .success { color: #047857; } .error { color: #be123c; }
|
button:disabled { opacity: .5; cursor: default; } .success { color: #047857; } .error { color: #be123c; }
|
||||||
@@ -93,6 +126,8 @@ export class ModelSettingsComponent implements OnInit {
|
|||||||
saved = output<void>();
|
saved = output<void>();
|
||||||
modelPresets: string[] = [];
|
modelPresets: string[] = [];
|
||||||
baseUrl = ''; model = ''; apiKey = ''; timeout = 300; retries = 5; retryInterval = 10; reasoning = 'high';
|
baseUrl = ''; model = ''; apiKey = ''; timeout = 300; retries = 5; retryInterval = 10; reasoning = 'high';
|
||||||
|
rateLimitRetry = 60; toolFallbackModel = 'gpt-6-luna'; toolFallbackReasoning = 'high';
|
||||||
|
toolFallbackRetries = 3; toolFallbackNoProgress = 3;
|
||||||
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('');
|
||||||
@@ -105,16 +140,27 @@ 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.retryInterval >= 0 && this.retryInterval <= 300;
|
&& this.timeout > 0 && this.timeout <= 3600 && Number.isInteger(this.retries) && this.retries >= 0 && this.retries <= 10 && this.retryInterval >= 0 && this.retryInterval <= 300
|
||||||
|
&& this.rateLimitRetry >= 0 && this.rateLimitRetry <= 3600 && !!this.toolFallbackModel.trim()
|
||||||
|
&& Number.isInteger(this.toolFallbackRetries) && this.toolFallbackRetries >= 0 && this.toolFallbackRetries <= 10
|
||||||
|
&& Number.isInteger(this.toolFallbackNoProgress) && this.toolFallbackNoProgress >= 1 && this.toolFallbackNoProgress <= 10;
|
||||||
}
|
}
|
||||||
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, retry_interval_seconds: this.retryInterval, reasoning_effort: this.reasoning };
|
timeout_seconds: this.timeout, max_retries: this.retries, retry_interval_seconds: this.retryInterval, reasoning_effort: this.reasoning,
|
||||||
|
rate_limit_retry_seconds: this.rateLimitRetry, tool_fallback_model: this.toolFallbackModel.trim(),
|
||||||
|
tool_fallback_reasoning_effort: this.toolFallbackReasoning, tool_fallback_max_retries: this.toolFallbackRetries,
|
||||||
|
tool_fallback_no_progress_attempts: this.toolFallbackNoProgress };
|
||||||
}
|
}
|
||||||
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.modelPresets = config.model_presets;
|
this.modelPresets = config.model_presets;
|
||||||
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);
|
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);
|
||||||
|
this.rateLimitRetry = config.rate_limit_retry_seconds ?? 60;
|
||||||
|
this.toolFallbackModel = config.tool_fallback_model ?? 'gpt-6-luna';
|
||||||
|
this.toolFallbackReasoning = config.tool_fallback_reasoning_effort ?? 'high';
|
||||||
|
this.toolFallbackRetries = config.tool_fallback_max_retries ?? 3;
|
||||||
|
this.toolFallbackNoProgress = config.tool_fallback_no_progress_attempts ?? 3;
|
||||||
}
|
}
|
||||||
save(): void {
|
save(): void {
|
||||||
if (this.busy() || !this.valid()) return;
|
if (this.busy() || !this.valid()) return;
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { StepReplayFrame } from '../core/models/stream.model';
|
||||||
|
import { TimelineSegment } from './recording-timeline.util';
|
||||||
|
import { buildVideoStepMarkers } from './video-step-markers.util';
|
||||||
|
|
||||||
|
describe('buildVideoStepMarkers', () => {
|
||||||
|
const start = 1_800_000_000;
|
||||||
|
const segments: TimelineSegment[] = [
|
||||||
|
{ start: 0, duration: 10, offset_ms: 1000, duration_ms: 10000 },
|
||||||
|
{ start: 10, duration: 10, offset_ms: 13000, duration_ms: 10000 }
|
||||||
|
];
|
||||||
|
const frame = (stepNumber: number, seconds: number, status = 'dispatched'): StepReplayFrame => ({
|
||||||
|
index: stepNumber - 1,
|
||||||
|
stepNumber,
|
||||||
|
title: `步骤 ${stepNumber}`,
|
||||||
|
imageUrl: 'step.png',
|
||||||
|
timestamp: start + seconds,
|
||||||
|
status
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places markers on the playable timeline across recording restart gaps', () => {
|
||||||
|
const markers = buildVideoStepMarkers([frame(1, 4), frame(2, 15)], start, segments, 20);
|
||||||
|
expect(markers.map((marker) => marker.time)).toEqual([3, 12]);
|
||||||
|
expect(markers.map((marker) => marker.percent)).toEqual([15, 60]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('snaps a step in a recording gap to the following segment', () => {
|
||||||
|
const markers = buildVideoStepMarkers([frame(1, 12)], start, segments, 20);
|
||||||
|
expect(markers[0].time).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits steps outside the actual recording and without timestamps', () => {
|
||||||
|
const missing = { ...frame(4, 5), timestamp: undefined };
|
||||||
|
const markers = buildVideoStepMarkers(
|
||||||
|
[frame(1, 0), frame(2, 2), frame(3, 24), missing], start, segments, 20
|
||||||
|
);
|
||||||
|
expect(markers.map((marker) => marker.steps[0].stepNumber)).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups crowded steps and keeps failure information', () => {
|
||||||
|
const markers = buildVideoStepMarkers(
|
||||||
|
[frame(1, 4), frame(2, 4.1, 'failed'), frame(3, 8)], start, segments, 20
|
||||||
|
);
|
||||||
|
expect(markers.length).toBe(2);
|
||||||
|
expect(markers[0].steps.map((step) => step.stepNumber)).toEqual([1, 2]);
|
||||||
|
expect(markers[0].failed).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses session seconds directly for a legacy playlist or single video', () => {
|
||||||
|
expect(buildVideoStepMarkers([frame(1, 6)], start, [{ start: 0, duration: 20 }], 20)[0].time).toBe(6);
|
||||||
|
expect(buildVideoStepMarkers([frame(1, 6)], start, [], 20)[0].time).toBe(6);
|
||||||
|
expect(buildVideoStepMarkers([frame(1, 6)], undefined, [], 20)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { StepReplayFrame } from '../core/models/stream.model';
|
||||||
|
import { hasSessionOffsets, sessionTimeToTimelineTime, TimelineSegment } from './recording-timeline.util';
|
||||||
|
|
||||||
|
export interface VideoStepMarker {
|
||||||
|
time: number;
|
||||||
|
percent: number;
|
||||||
|
steps: StepReplayFrame[];
|
||||||
|
failed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Place recorded action steps on the video's gap-free playback timeline. */
|
||||||
|
export function buildVideoStepMarkers(
|
||||||
|
frames: readonly StepReplayFrame[],
|
||||||
|
sessionStart: number | undefined,
|
||||||
|
segments: readonly TimelineSegment[],
|
||||||
|
duration: number
|
||||||
|
): VideoStepMarker[] {
|
||||||
|
const start = Number(sessionStart);
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(duration) || duration <= 0) return [];
|
||||||
|
|
||||||
|
const hasOffsets = hasSessionOffsets(segments);
|
||||||
|
const firstOffset = hasOffsets ? (segments[0].offset_ms as number) / 1000 : 0;
|
||||||
|
const lastSegment = segments[segments.length - 1];
|
||||||
|
const lastOffset = hasOffsets
|
||||||
|
? ((lastSegment.offset_ms as number) + (lastSegment.duration_ms as number)) / 1000
|
||||||
|
: duration;
|
||||||
|
|
||||||
|
const positioned = frames.flatMap((frame) => {
|
||||||
|
const timestamp = Number(frame.timestamp);
|
||||||
|
if (frame.timestamp == null || !Number.isFinite(timestamp)) return [];
|
||||||
|
const sessionTime = timestamp - start;
|
||||||
|
// Do not create misleading ticks at the start/end for steps outside the recording.
|
||||||
|
if (sessionTime < firstOffset || sessionTime > lastOffset) return [];
|
||||||
|
const time = sessionTimeToTimelineTime(segments, sessionTime);
|
||||||
|
if (time < 0 || time > duration) return [];
|
||||||
|
return [{ time, frame }];
|
||||||
|
}).sort((a, b) => a.time - b.time);
|
||||||
|
|
||||||
|
const markers: VideoStepMarker[] = [];
|
||||||
|
// Nearby actions share one visible tick on a narrow player timeline.
|
||||||
|
const minGap = duration * 0.02;
|
||||||
|
for (const { time, frame } of positioned) {
|
||||||
|
const previous = markers[markers.length - 1];
|
||||||
|
if (previous && time - previous.time < minGap) {
|
||||||
|
previous.steps.push(frame);
|
||||||
|
previous.failed ||= frame.status === 'failed';
|
||||||
|
} else {
|
||||||
|
markers.push({ time, percent: (time / duration) * 100, steps: [frame], failed: frame.status === 'failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return markers;
|
||||||
|
}
|
||||||
@@ -1071,6 +1071,8 @@ class FlashRunner:
|
|||||||
pending_notices: list[str] = []
|
pending_notices: list[str] = []
|
||||||
no_tool_attempts = 0
|
no_tool_attempts = 0
|
||||||
no_tool_limit = settings.ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS
|
no_tool_limit = settings.ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS
|
||||||
|
fallback_llm = None
|
||||||
|
using_tool_fallback = False
|
||||||
|
|
||||||
while limit is None or turns < limit:
|
while limit is None or turns < limit:
|
||||||
control = getattr(self.ctx, "task_control", None)
|
control = getattr(self.ctx, "task_control", None)
|
||||||
@@ -1080,6 +1082,9 @@ class FlashRunner:
|
|||||||
on_resume=getattr(self.ctx, "on_task_resume", None),
|
on_resume=getattr(self.ctx, "on_task_resume", None),
|
||||||
)
|
)
|
||||||
if resumed:
|
if resumed:
|
||||||
|
using_tool_fallback = False
|
||||||
|
no_tool_attempts = 0
|
||||||
|
self._llm = llm
|
||||||
(
|
(
|
||||||
shot_path,
|
shot_path,
|
||||||
current_pre_screenshot_bytes,
|
current_pre_screenshot_bytes,
|
||||||
@@ -1131,14 +1136,18 @@ class FlashRunner:
|
|||||||
|
|
||||||
if isinstance(control, TaskControl):
|
if isinstance(control, TaskControl):
|
||||||
interrupted, response = await control.run_interruptibly(
|
interrupted, response = await control.run_interruptibly(
|
||||||
self._invoke_model(llm, current_tools, messages),
|
self._invoke_model(
|
||||||
|
fallback_llm if using_tool_fallback else llm, current_tools, messages
|
||||||
|
),
|
||||||
on_pause=getattr(self.ctx, "on_task_pause", None),
|
on_pause=getattr(self.ctx, "on_task_pause", None),
|
||||||
on_resume=getattr(self.ctx, "on_task_resume", None),
|
on_resume=getattr(self.ctx, "on_task_resume", None),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
interrupted, response = (
|
interrupted, response = (
|
||||||
False,
|
False,
|
||||||
await self._invoke_model(llm, current_tools, messages),
|
await self._invoke_model(
|
||||||
|
fallback_llm if using_tool_fallback else llm, current_tools, messages
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if interrupted or (
|
if interrupted or (
|
||||||
@@ -1146,6 +1155,9 @@ class FlashRunner:
|
|||||||
):
|
):
|
||||||
# Do not dispatch a stale action or final answer from a prompt
|
# Do not dispatch a stale action or final answer from a prompt
|
||||||
# assembled before the instruction arrived.
|
# assembled before the instruction arrived.
|
||||||
|
using_tool_fallback = False
|
||||||
|
no_tool_attempts = 0
|
||||||
|
self._llm = llm
|
||||||
(
|
(
|
||||||
shot_path,
|
shot_path,
|
||||||
current_pre_screenshot_bytes,
|
current_pre_screenshot_bytes,
|
||||||
@@ -1186,6 +1198,9 @@ class FlashRunner:
|
|||||||
|
|
||||||
tool_calls = self._resolve_tool_calls(response, raw_text)
|
tool_calls = self._resolve_tool_calls(response, raw_text)
|
||||||
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
|
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
|
||||||
|
using_tool_fallback = False
|
||||||
|
no_tool_attempts = 0
|
||||||
|
self._llm = llm
|
||||||
(
|
(
|
||||||
shot_path,
|
shot_path,
|
||||||
current_pre_screenshot_bytes,
|
current_pre_screenshot_bytes,
|
||||||
@@ -1207,15 +1222,27 @@ class FlashRunner:
|
|||||||
final_report = {"status": "failed", "explanation": raw_text}
|
final_report = {"status": "failed", "explanation": raw_text}
|
||||||
break
|
break
|
||||||
no_tool_attempts += 1
|
no_tool_attempts += 1
|
||||||
if no_tool_attempts >= no_tool_limit:
|
active_limit = (
|
||||||
|
settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS
|
||||||
|
if using_tool_fallback
|
||||||
|
else no_tool_limit
|
||||||
|
)
|
||||||
|
if no_tool_attempts >= active_limit:
|
||||||
|
if using_tool_fallback:
|
||||||
final_report = {
|
final_report = {
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"explanation": (
|
"explanation": (
|
||||||
f"模型连续 {no_tool_attempts} 次未调用工具,"
|
f"首选模型连续 {no_tool_limit} 次、兜底模型连续 "
|
||||||
"已停止任务以避免空转和重复计费;未执行新的设备动作。"
|
f"{no_tool_attempts} 次未调用工具,已停止任务;"
|
||||||
|
"未执行新的设备动作。"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
fallback_llm = get_llm(self.ctx, name="operator", use_fallback=True)
|
||||||
|
using_tool_fallback = True
|
||||||
|
no_tool_attempts = 0
|
||||||
|
logger.warning("Flash 首选模型未调用工具,当前步骤切换到兜底模型")
|
||||||
|
pending_notices.append("首选模型连续未调用工具;本步骤改用兜底模型。")
|
||||||
pending_notices.append(_NO_TOOL_CALL_NOTICE)
|
pending_notices.append(_NO_TOOL_CALL_NOTICE)
|
||||||
# Keep the observation and any injected user instruction, but
|
# Keep the observation and any injected user instruction, but
|
||||||
# do not feed the model its own unproductive prose on retry.
|
# do not feed the model its own unproductive prose on retry.
|
||||||
@@ -1224,6 +1251,10 @@ class FlashRunner:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
no_tool_attempts = 0
|
no_tool_attempts = 0
|
||||||
|
if using_tool_fallback:
|
||||||
|
# A tool-producing turn completes the fallback's scope. The next
|
||||||
|
# observation goes back to the configured primary model.
|
||||||
|
self._llm = fallback_llm
|
||||||
# Process tool calls
|
# Process tool calls
|
||||||
turn = _TurnRecord()
|
turn = _TurnRecord()
|
||||||
(
|
(
|
||||||
@@ -1244,6 +1275,10 @@ class FlashRunner:
|
|||||||
injected=injected,
|
injected=injected,
|
||||||
native_text=native_text,
|
native_text=native_text,
|
||||||
)
|
)
|
||||||
|
if using_tool_fallback and any(status == "success" for _, status, _ in turn.actions):
|
||||||
|
using_tool_fallback = False
|
||||||
|
self._llm = llm
|
||||||
|
pending_notices.append("兜底模型已完成本步骤;后续步骤恢复首选模型。")
|
||||||
ledger.stage_turn(messages[turn_base:])
|
ledger.stage_turn(messages[turn_base:])
|
||||||
previous_turn = turn
|
previous_turn = turn
|
||||||
if final_report_from_calls is not None:
|
if final_report_from_calls is not None:
|
||||||
|
|||||||
@@ -508,6 +508,9 @@ class OperatorNode:
|
|||||||
plan_gate_bounced = False
|
plan_gate_bounced = False
|
||||||
no_progress_attempts = 0
|
no_progress_attempts = 0
|
||||||
no_progress_limit = settings.ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS
|
no_progress_limit = settings.ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS
|
||||||
|
fallback_limit = settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS
|
||||||
|
active_llm = base_llm
|
||||||
|
using_tool_fallback = False
|
||||||
|
|
||||||
for iteration in range(max_iterations):
|
for iteration in range(max_iterations):
|
||||||
control = getattr(self.ctx, "task_control", None)
|
control = getattr(self.ctx, "task_control", None)
|
||||||
@@ -532,7 +535,7 @@ class OperatorNode:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
bound_llm = base_llm.bind_tools(tools=traced_tools)
|
bound_llm = active_llm.bind_tools(tools=traced_tools)
|
||||||
interrupted, response = await self._await_or_pause(
|
interrupted, response = await self._await_or_pause(
|
||||||
invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages))
|
invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages))
|
||||||
)
|
)
|
||||||
@@ -607,11 +610,25 @@ class OperatorNode:
|
|||||||
|
|
||||||
if not response.tool_calls:
|
if not response.tool_calls:
|
||||||
no_progress_attempts += 1
|
no_progress_attempts += 1
|
||||||
if no_progress_attempts >= no_progress_limit:
|
if no_progress_attempts >= (
|
||||||
|
fallback_limit if using_tool_fallback else no_progress_limit
|
||||||
|
):
|
||||||
|
if using_tool_fallback:
|
||||||
raise OperatorNoProgressError(
|
raise OperatorNoProgressError(
|
||||||
f"模型连续 {no_progress_attempts} 次未提供可执行的工具调用,"
|
f"首选模型连续 {no_progress_limit} 次、兜底模型连续 "
|
||||||
"已停止任务以避免空转;未执行新的设备动作。"
|
f"{no_progress_attempts} 次未提供可执行的工具调用;"
|
||||||
|
"已停止任务以避免空转。"
|
||||||
)
|
)
|
||||||
|
active_llm = get_llm(ctx=self.ctx, name="operator", use_fallback=True)
|
||||||
|
using_tool_fallback = True
|
||||||
|
no_progress_attempts = 0
|
||||||
|
logger.warning("Operator 首选模型未调用工具,当前步骤切换到兜底模型")
|
||||||
|
current_messages.append(
|
||||||
|
SystemMessage(
|
||||||
|
content="本步骤已切换兜底模型。请使用工具完成操作或明确报告阻碍。"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
logger.warning("LLM returned no tool call; requesting a concrete tool call.")
|
logger.warning("LLM returned no tool call; requesting a concrete tool call.")
|
||||||
current_messages.append(response)
|
current_messages.append(response)
|
||||||
current_messages.append(
|
current_messages.append(
|
||||||
@@ -931,11 +948,25 @@ class OperatorNode:
|
|||||||
|
|
||||||
if validation_errors:
|
if validation_errors:
|
||||||
no_progress_attempts += 1
|
no_progress_attempts += 1
|
||||||
if no_progress_attempts >= no_progress_limit:
|
if no_progress_attempts >= (
|
||||||
|
fallback_limit if using_tool_fallback else no_progress_limit
|
||||||
|
):
|
||||||
|
if using_tool_fallback:
|
||||||
raise OperatorNoProgressError(
|
raise OperatorNoProgressError(
|
||||||
f"模型连续 {no_progress_attempts} 次未提供可执行的工具调用,"
|
f"首选模型连续 {no_progress_limit} 次、兜底模型连续 "
|
||||||
"工具参数校验仍失败;已停止任务,未执行被拒绝的设备动作。"
|
f"{no_progress_attempts} 次未提供可执行的工具调用,"
|
||||||
|
"工具参数校验仍失败;已停止任务。"
|
||||||
)
|
)
|
||||||
|
active_llm = get_llm(ctx=self.ctx, name="operator", use_fallback=True)
|
||||||
|
using_tool_fallback = True
|
||||||
|
no_progress_attempts = 0
|
||||||
|
logger.warning("Operator 首选模型工具调用校验失败,当前步骤切换到兜底模型")
|
||||||
|
current_messages.append(
|
||||||
|
SystemMessage(
|
||||||
|
content="本步骤已切换兜底模型。请修正上面的工具参数后再调用。"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
logger.warning("Some action calls failed validation. Feeding back to LLM.")
|
logger.warning("Some action calls failed validation. Feeding back to LLM.")
|
||||||
continue
|
continue
|
||||||
no_progress_attempts = 0
|
no_progress_attempts = 0
|
||||||
|
|||||||
@@ -835,6 +835,13 @@ class ProProfileConfig(BaseModel):
|
|||||||
model_config = {"extra": "allow"}
|
model_config = {"extra": "allow"}
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenshotConfig(BaseModel):
|
||||||
|
"""Limit screenshots sent to agents while retaining physical device coordinates."""
|
||||||
|
|
||||||
|
enabled: bool = Field(default_factory=lambda: settings.ARTEMIS_SCREENSHOT_LIMIT_ENABLED)
|
||||||
|
max_long_edge: int = Field(default=2048, ge=256, le=8192)
|
||||||
|
|
||||||
|
|
||||||
class AgentGlobalConfig(BaseModel):
|
class AgentGlobalConfig(BaseModel):
|
||||||
"""Global configuration parsed from artemis.jsonc / agent_config.json."""
|
"""Global configuration parsed from artemis.jsonc / agent_config.json."""
|
||||||
|
|
||||||
@@ -889,6 +896,10 @@ class AgentGlobalConfig(BaseModel):
|
|||||||
default_factory=MemoryConfig,
|
default_factory=MemoryConfig,
|
||||||
description="Unified step-memory runtime and transcript scrub options.",
|
description="Unified step-memory runtime and transcript scrub options.",
|
||||||
)
|
)
|
||||||
|
screenshot: ScreenshotConfig = Field(
|
||||||
|
default_factory=ScreenshotConfig,
|
||||||
|
description="Agent screenshot size limit; device/UI coordinates remain native.",
|
||||||
|
)
|
||||||
|
|
||||||
model_config = {"extra": "allow"}
|
model_config = {"extra": "allow"}
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ class Settings(BaseSettings):
|
|||||||
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
|
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
|
||||||
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
|
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_RETRY_INTERVAL_SECONDS: float = Field(default=10.0, ge=0, le=300)
|
||||||
|
OPENAI_RATE_LIMIT_RETRY_SECONDS: float = Field(default=60.0, ge=0, le=3600)
|
||||||
|
OPENAI_TOOL_FALLBACK_MODEL: str = Field(default="gpt-6-luna", min_length=1)
|
||||||
|
OPENAI_TOOL_FALLBACK_REASONING_EFFORT: str = "high"
|
||||||
|
OPENAI_TOOL_FALLBACK_MAX_RETRIES: int = Field(default=3, ge=0, le=10)
|
||||||
|
ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=10)
|
||||||
OPENAI_REASONING_EFFORT: str = "high"
|
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)
|
||||||
@@ -112,6 +117,7 @@ class Settings(BaseSettings):
|
|||||||
ARTEMIS_RECORDING_ENABLED: bool = False
|
ARTEMIS_RECORDING_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
|
||||||
|
ARTEMIS_SCREENSHOT_LIMIT_ENABLED: bool = True
|
||||||
# Consecutive Pro responses without a valid tool call before failing the task.
|
# Consecutive Pro responses without a valid tool call before failing the task.
|
||||||
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
|
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
|
||||||
# Consecutive Flash responses without any tool call before failing the task.
|
# Consecutive Flash responses without any tool call before failing the task.
|
||||||
|
|||||||
@@ -25,12 +25,14 @@ from artemis.clients.ui_automator_client import (
|
|||||||
UIAutomatorClient,
|
UIAutomatorClient,
|
||||||
_parse_hierarchy_xml_to_elements,
|
_parse_hierarchy_xml_to_elements,
|
||||||
)
|
)
|
||||||
|
from artemis.config.agent import load_agent_config
|
||||||
from artemis.config.paths import get_temp_dir
|
from artemis.config.paths import get_temp_dir
|
||||||
from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection
|
from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection
|
||||||
from artemis.toolchain import find_ffmpeg, find_scrcpy
|
from artemis.toolchain import find_ffmpeg, find_scrcpy
|
||||||
from artemis.utils.video import build_scrcpy_record_command
|
from artemis.utils.video import build_scrcpy_record_command
|
||||||
from artemis.utils.ui_filter import filter_ui_hierarchy
|
from artemis.utils.ui_filter import filter_ui_hierarchy
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
|
from artemis.utils.screenshot_limit import limit_screenshot_long_edge
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -88,6 +90,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
self._device: AdbDevice | None = None
|
self._device: AdbDevice | None = None
|
||||||
self._recording_process: asyncio.subprocess.Process | None = None
|
self._recording_process: asyncio.subprocess.Process | None = None
|
||||||
self._recording_output_path: Path | None = None
|
self._recording_output_path: Path | None = None
|
||||||
|
self._screenshot_config = load_agent_config().screenshot
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_id(self) -> str:
|
def device_id(self) -> str:
|
||||||
@@ -215,6 +218,13 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
if isinstance(screenshot_bytes, bytes) and screenshot_bytes
|
if isinstance(screenshot_bytes, bytes) and screenshot_bytes
|
||||||
else base64.b64decode(clean_b64)
|
else base64.b64decode(clean_b64)
|
||||||
)
|
)
|
||||||
|
if self._screenshot_config.enabled:
|
||||||
|
limited_bytes = limit_screenshot_long_edge(
|
||||||
|
clean_bytes, self._screenshot_config.max_long_edge
|
||||||
|
)
|
||||||
|
if limited_bytes is not clean_bytes:
|
||||||
|
clean_bytes = limited_bytes
|
||||||
|
clean_b64 = base64.b64encode(clean_bytes).decode("utf-8")
|
||||||
|
|
||||||
return ScreenData(
|
return ScreenData(
|
||||||
screenshot_bytes=clean_bytes,
|
screenshot_bytes=clean_bytes,
|
||||||
|
|||||||
@@ -117,6 +117,11 @@ def init_command() -> None:
|
|||||||
"OPENAI_TIMEOUT_SECONDS=300",
|
"OPENAI_TIMEOUT_SECONDS=300",
|
||||||
"OPENAI_MAX_RETRIES=5",
|
"OPENAI_MAX_RETRIES=5",
|
||||||
"OPENAI_RETRY_INTERVAL_SECONDS=10",
|
"OPENAI_RETRY_INTERVAL_SECONDS=10",
|
||||||
|
"OPENAI_RATE_LIMIT_RETRY_SECONDS=60",
|
||||||
|
"OPENAI_TOOL_FALLBACK_MODEL=gpt-6-luna",
|
||||||
|
"OPENAI_TOOL_FALLBACK_REASONING_EFFORT=high",
|
||||||
|
"OPENAI_TOOL_FALLBACK_MAX_RETRIES=3",
|
||||||
|
"ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS=3",
|
||||||
"ARTEMIS_VIDEO_ENABLED=false",
|
"ARTEMIS_VIDEO_ENABLED=false",
|
||||||
"ARTEMIS_TRACES_DIR=./traces",
|
"ARTEMIS_TRACES_DIR=./traces",
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ def extract_status_code(error: BaseException) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
_RATE_LIMIT_MARKERS = ("rate limit", "quota", "resource_exhausted")
|
_RATE_LIMIT_MARKERS = ("rate limit", "too many requests", "quota", "resource_exhausted")
|
||||||
_UNAVAILABLE_MARKERS = ("unavailable", "overloaded", "high demand", "service unavailable")
|
_UNAVAILABLE_MARKERS = ("unavailable", "overloaded", "high demand", "service unavailable")
|
||||||
_AUTH_MARKERS = ("unauthorized", "forbidden", "api key", "credential")
|
_AUTH_MARKERS = ("unauthorized", "forbidden", "api key", "credential")
|
||||||
# "deadline exceeded"/"deadline_exceeded" cover google-genai SDK timeouts that
|
# "deadline exceeded"/"deadline_exceeded" cover google-genai SDK timeouts that
|
||||||
@@ -196,6 +196,10 @@ def retry_policy_for(category: FailureCategory | str) -> RetryPolicy:
|
|||||||
return _DEFAULT_RETRY_POLICIES[category]
|
return _DEFAULT_RETRY_POLICIES[category]
|
||||||
from artemis.config.settings import settings
|
from artemis.config.settings import settings
|
||||||
|
|
||||||
|
if category is FailureCategory.RATE_LIMIT:
|
||||||
|
return _configured_policy(
|
||||||
|
settings.OPENAI_MAX_RETRIES, settings.OPENAI_RATE_LIMIT_RETRY_SECONDS
|
||||||
|
)
|
||||||
return _configured_policy(settings.OPENAI_MAX_RETRIES, settings.OPENAI_RETRY_INTERVAL_SECONDS)
|
return _configured_policy(settings.OPENAI_MAX_RETRIES, settings.OPENAI_RETRY_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+24
-13
@@ -15,25 +15,36 @@
|
|||||||
"""Backward compatibility entrypoint for artemis CLI."""
|
"""Backward compatibility entrypoint for artemis CLI."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
from artemis.interfaces.cli.main import app
|
from artemis.interfaces.cli.main import app
|
||||||
|
from artemis.interfaces.cli.main import app as _typer_app
|
||||||
|
|
||||||
|
|
||||||
|
def _root_tokens() -> frozenset[str]:
|
||||||
|
"""Read command names and root flags from the registered CLI."""
|
||||||
|
group = typer.main.get_command(_typer_app)
|
||||||
|
tokens: set[str] = {"-h"}
|
||||||
|
tokens.update(getattr(group, "commands", {}) or {})
|
||||||
|
tokens.update(group.get_help_option_names(group.context_class(group)))
|
||||||
|
for param in group.params:
|
||||||
|
tokens.update(getattr(param, "opts", ()))
|
||||||
|
tokens.update(getattr(param, "secondary_opts", ()))
|
||||||
|
return frozenset(tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_argv(argv: list[str]) -> list[str]:
|
||||||
|
"""Keep implicit ``run`` for a goal while preserving registered commands."""
|
||||||
|
if len(argv) > 1 and argv[1] not in _root_tokens():
|
||||||
|
return [argv[0], "run", *argv[1:]]
|
||||||
|
return list(argv)
|
||||||
|
|
||||||
|
|
||||||
def cli():
|
def cli():
|
||||||
# If invoked directly as python -m artemis.main without subcommand 'run',
|
# If invoked directly as python -m artemis.main without subcommand 'run',
|
||||||
# check if first argument is a goal rather than a subcommand
|
# check if first argument is a goal rather than a subcommand
|
||||||
if len(sys.argv) > 1 and sys.argv[1] not in (
|
sys.argv[:] = normalize_argv(sys.argv)
|
||||||
"run",
|
|
||||||
"init",
|
|
||||||
"doctor",
|
|
||||||
"batch",
|
|
||||||
"bench",
|
|
||||||
"mcp",
|
|
||||||
"server",
|
|
||||||
"trace",
|
|
||||||
"--help",
|
|
||||||
"-h",
|
|
||||||
):
|
|
||||||
sys.argv.insert(1, "run")
|
|
||||||
app()
|
app()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,14 @@
|
|||||||
"presets": {},
|
"presets": {},
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"planner": {},
|
"planner": {},
|
||||||
"operator": {},
|
"operator": {
|
||||||
|
"fallback": {
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "${env:OPENAI_TOOL_FALLBACK_MODEL:-gpt-6-luna}",
|
||||||
|
"reasoning_effort": "${env:OPENAI_TOOL_FALLBACK_REASONING_EFFORT:-high}",
|
||||||
|
"max_retries": "${env:OPENAI_TOOL_FALLBACK_MAX_RETRIES:-3}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"video_analyzer": {},
|
"video_analyzer": {},
|
||||||
"object_detector": {},
|
"object_detector": {},
|
||||||
"explorer": {},
|
"explorer": {},
|
||||||
@@ -27,6 +34,10 @@
|
|||||||
"checker": {}
|
"checker": {}
|
||||||
},
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
|
"screenshot": {
|
||||||
|
"enabled": "${env:ARTEMIS_SCREENSHOT_LIMIT_ENABLED:-true}",
|
||||||
|
"max_long_edge": 2048
|
||||||
|
},
|
||||||
"flash": {
|
"flash": {
|
||||||
"max_turns": 0,
|
"max_turns": 0,
|
||||||
"explorer_mode": "flash",
|
"explorer_mode": "flash",
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ def is_daemon_running(
|
|||||||
def is_artemis_daemon(
|
def is_artemis_daemon(
|
||||||
host: str = DEFAULT_DAEMON_HOST,
|
host: str = DEFAULT_DAEMON_HOST,
|
||||||
port: int = DEFAULT_DAEMON_PORT,
|
port: int = DEFAULT_DAEMON_PORT,
|
||||||
timeout: float = 0.5,
|
timeout: float = 2.0,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Identity probe: is the process on ``host:port`` the Artemis daemon?
|
"""Identity probe: is the process on ``host:port`` the Artemis daemon?
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Prepare Android screenshots for model input without changing device coordinates."""
|
||||||
|
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def limit_screenshot_long_edge(image_bytes: bytes, max_long_edge: int) -> bytes:
|
||||||
|
"""Downscale oversized screenshots; keep smaller images byte-for-byte unchanged."""
|
||||||
|
try:
|
||||||
|
with Image.open(BytesIO(image_bytes)) as image:
|
||||||
|
long_edge = max(image.size)
|
||||||
|
if long_edge <= max_long_edge:
|
||||||
|
return image_bytes
|
||||||
|
scale = max_long_edge / long_edge
|
||||||
|
size = tuple(max(1, round(side * scale)) for side in image.size)
|
||||||
|
resized = image.convert("RGB").resize(size, Image.Resampling.LANCZOS)
|
||||||
|
output = BytesIO()
|
||||||
|
resized.save(output, format="JPEG", quality=80, optimize=True)
|
||||||
|
return output.getvalue()
|
||||||
|
except OSError:
|
||||||
|
# Preserve the capture path's previous behavior for invalid placeholder images.
|
||||||
|
return image_bytes
|
||||||
@@ -615,9 +615,9 @@ async def render_timeline_clip(
|
|||||||
)
|
)
|
||||||
labels.append(f"[{label}]")
|
labels.append(f"[{label}]")
|
||||||
if len(labels) == 1:
|
if len(labels) == 1:
|
||||||
filter_parts.append(f"{labels[0]}null[outv]")
|
filter_parts.append(f"{labels[0]}fps={fps}[outv]")
|
||||||
else:
|
else:
|
||||||
filter_parts.append(f"{''.join(labels)}concat=n={len(labels)}:v=1:a=0[outv]")
|
filter_parts.append(f"{''.join(labels)}concat=n={len(labels)}:v=1:a=0,fps={fps}[outv]")
|
||||||
|
|
||||||
command.extend(
|
command.extend(
|
||||||
[
|
[
|
||||||
|
|||||||
+12
-1
@@ -13,7 +13,14 @@
|
|||||||
"presets": {},
|
"presets": {},
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"planner": {},
|
"planner": {},
|
||||||
"operator": {},
|
"operator": {
|
||||||
|
"fallback": {
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "${env:OPENAI_TOOL_FALLBACK_MODEL:-gpt-6-luna}",
|
||||||
|
"reasoning_effort": "${env:OPENAI_TOOL_FALLBACK_REASONING_EFFORT:-high}",
|
||||||
|
"max_retries": "${env:OPENAI_TOOL_FALLBACK_MAX_RETRIES:-3}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"video_analyzer": {},
|
"video_analyzer": {},
|
||||||
"object_detector": {},
|
"object_detector": {},
|
||||||
"explorer": {},
|
"explorer": {},
|
||||||
@@ -27,6 +34,10 @@
|
|||||||
"checker": {}
|
"checker": {}
|
||||||
},
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
|
"screenshot": {
|
||||||
|
"enabled": "${env:ARTEMIS_SCREENSHOT_LIMIT_ENABLED:-true}",
|
||||||
|
"max_long_edge": 2048
|
||||||
|
},
|
||||||
"flash": {
|
"flash": {
|
||||||
"max_turns": 0,
|
"max_turns": 0,
|
||||||
"explorer_mode": "flash",
|
"explorer_mode": "flash",
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ MCP 同步会识别已安装客户端的配置目录,为缺少 ARTEMIS 条目
|
|||||||
|
|
||||||
报告按任务或批次归档到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接;删除批次时会清理对应导出副本。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。
|
报告按任务或批次归档到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接;删除批次时会清理对应导出副本。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。
|
||||||
|
|
||||||
模型默认使用 `mimo-v2.6-flash`,也预设 `gpt-5.6-luna` 并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,每次等待 10 秒,SDK 不叠加重试。耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停先返回 `pausing`,确认状态为 `task_paused` 才表示设备已释放;Pro 等待模型回复或只读诊断时可及时中断,恢复后重新观察设备。AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复;运行中的模型思考会中断并重新观察设备,已经开始的设备动作会安全结束。用 `guidance_history` 核对指导是否被领取、在任务流程中核对实际执行情况,或用 `resume` 单独恢复。
|
模型默认使用 `mimo-v2.6-flash`,预设 `gpt-5.6-luna`、`gpt-6-luna`、`mimo-v2.6-pro`,并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,普通错误每次等待 10 秒,429 每次等待 60 秒,SDK 不叠加重试。首选模型连续 3 次没有有效工具调用时,当前步骤改用 `gpt-6-luna`(high),最多再试 3 次;下一步重新使用首选模型。兜底模型及次数可通过 `.env.example` 中的 `OPENAI_TOOL_FALLBACK_*` 和 `ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS` 配置。请求耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停先返回 `pausing`,确认状态为 `task_paused` 才表示设备已释放;Pro 等待模型回复或只读诊断时可及时中断,恢复后重新观察设备。AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复;运行中的模型思考会中断并重新观察设备,已经开始的设备动作会安全结束。用 `guidance_history` 核对指导是否被领取、在任务流程中核对实际执行情况,或用 `resume` 单独恢复。
|
||||||
|
|
||||||
|
429 等待、兜底模型及次数也可在网页“模型配置 → 稳定性与兜底模型”或通过已授权的 `mobile_config(section="model")` 配置。批次 ZIP 还收录每项任务已保存的步骤截图和录屏。
|
||||||
|
|
||||||
批次任务在队列中使用“用例编号 + 用例名称”显示,提示词仍是模型实际收到的内容。任务历史展示失败原因、完成时间和总耗时;导出全部资料时以用例编号命名目录。删除非活跃批次会同步清理其任务记录、步骤、截图、录屏及生成的报告,删除后不能通过旧 `request_id` 重复执行。
|
批次任务在队列中使用“用例编号 + 用例名称”显示,提示词仍是模型实际收到的内容。任务历史展示失败原因、完成时间和总耗时;导出全部资料时以用例编号命名目录。删除非活跃批次会同步清理其任务记录、步骤、截图、录屏及生成的报告,删除后不能通过旧 `request_id` 重复执行。
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
服务重启后保留批次,等待用户继续未开始的用例。正在执行或暂停的任务标记中断,不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。
|
服务重启后保留批次,等待用户继续未开始的用例。正在执行或暂停的任务标记中断,不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。
|
||||||
|
|
||||||
每个任务结束后生成所选格式报告。批次结束后生成汇总及每项任务报告的 ZIP;失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。
|
每个任务结束后生成所选格式报告。批次 ZIP 包含汇总、每项任务的报告和已保存的有序步骤截图;已开启录屏且文件存在时,还会按任务收录录屏片段。失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。
|
||||||
|
|
||||||
队列中批量任务按“用例编号 + 测试用例”显示,悬停可看完整提示词;失败原因、完成时间及总耗时在历史中可查看。单任务“下载全部资料”以用例编号命名压缩包内目录。删除已结束的批次会同步删除关联任务记录、步骤、截图、录屏、报告和 MCP 导出副本;正在执行或排队的批次不能删除。
|
队列中批量任务按“用例编号 + 测试用例”显示,悬停可看完整提示词;失败原因、完成时间及总耗时在历史中可查看。单任务“下载全部资料”以用例编号命名压缩包内目录。删除已结束的批次会同步删除关联任务记录、步骤、截图、录屏、报告和 MCP 导出副本;正在执行或排队的批次不能删除。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# ARTEMIS-Eapil 1.3.0
|
||||||
|
|
||||||
|
## 更新
|
||||||
|
|
||||||
|
- 截图默认限制长边为 2048 像素,可在网页或任务 JSON 中关闭限制,改用设备原始分辨率。
|
||||||
|
- 首选模型连续 3 次未完成工具调用时,当前步骤可交给默认的 `gpt-6-luna`(high)备用模型继续;下个步骤恢复首选模型。HTTP 429 默认等待 60 秒再重试。
|
||||||
|
- 批次资料包包含各任务的报告、步骤截图和已有录屏。下载前显示打包进度、耗时和预计剩余时间;已完成的资料包直接复用。
|
||||||
|
- 录像时间轴显示可点击的关键步骤,便于直接跳转复盘。
|
||||||
|
- 优化服务状态探测、CLI 命令识别及视频拼接后的帧率处理。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
下载附件 `artemis-eapil-1.3.0-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。附件含可继续更新的 Git 仓库。已有安装可通过启动脚本更新;升级后重启服务、刷新网页,并重新加载 MCP。
|
||||||
|
|
||||||
|
验证:Python 默认测试、Ruff、质量基线、核心类型检查、前端测试及构建通过。
|
||||||
@@ -19,6 +19,11 @@ MODEL_FIELDS = {
|
|||||||
"timeout_seconds",
|
"timeout_seconds",
|
||||||
"max_retries",
|
"max_retries",
|
||||||
"retry_interval_seconds",
|
"retry_interval_seconds",
|
||||||
|
"rate_limit_retry_seconds",
|
||||||
|
"tool_fallback_model",
|
||||||
|
"tool_fallback_reasoning_effort",
|
||||||
|
"tool_fallback_max_retries",
|
||||||
|
"tool_fallback_no_progress_attempts",
|
||||||
"reasoning_effort",
|
"reasoning_effort",
|
||||||
}
|
}
|
||||||
CAPABILITY_FIELDS = {"video_enabled", "outputter_enabled", "step_summarizer_enabled"}
|
CAPABILITY_FIELDS = {"video_enabled", "outputter_enabled", "step_summarizer_enabled"}
|
||||||
@@ -122,7 +127,10 @@ async def mobile_config(
|
|||||||
Only set persist_defaults=true when the user requests saved/global configuration.
|
Only set persist_defaults=true when the user requests saved/global configuration.
|
||||||
model changes: model (preset or custom), base_url, reasoning_effort
|
model changes: model (preset or custom), base_url, reasoning_effort
|
||||||
(default/none/minimal/low/medium/high/xhigh/max), timeout_seconds, max_retries,
|
(default/none/minimal/low/medium/high/xhigh/max), timeout_seconds, max_retries,
|
||||||
retry_interval_seconds. 'default' omits reasoning; unsupported values fail visibly.
|
retry_interval_seconds, rate_limit_retry_seconds, tool_fallback_model,
|
||||||
|
tool_fallback_reasoning_effort, tool_fallback_max_retries,
|
||||||
|
tool_fallback_no_progress_attempts. 'default' omits reasoning;
|
||||||
|
unsupported values fail visibly.
|
||||||
capabilities: video_enabled (recording + analysis), outputter_enabled,
|
capabilities: video_enabled (recording + analysis), outputter_enabled,
|
||||||
step_summarizer_enabled. reports: formats=['docx','xlsx'] (at least one).
|
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.
|
API keys are never accepted or returned: use the Web UI or .env for credentials.
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "artemis"
|
name = "artemis"
|
||||||
version = "1.2.3"
|
version = "1.3.0"
|
||||||
description = "Autonomous AI Assistant for Mobile Automation"
|
description = "Autonomous AI Assistant for Mobile Automation"
|
||||||
|
|
||||||
authors = [
|
authors = [
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
|
|||||||
## 结果、证据与交付
|
## 结果、证据与交付
|
||||||
|
|
||||||
- 执行状态与业务判定分开:`completed` 只说明执行结束,不等于测试通过。检查工具返回的验证摘要、预期/实际结果,以及 `mobile_inspect_trace` 的步骤/截图。证据不足标记“未验证”;前置条件不满足说明阻塞原因,不能编造通过结论。
|
- 执行状态与业务判定分开:`completed` 只说明执行结束,不等于测试通过。检查工具返回的验证摘要、预期/实际结果,以及 `mobile_inspect_trace` 的步骤/截图。证据不足标记“未验证”;前置条件不满足说明阻塞原因,不能编造通过结论。
|
||||||
- 用 `mobile_export_report(session_id=...)` 获取单任务报告;`formats=["screenshots"]` 获取排序截图,`["video"]` 获取保存的录屏分段,`["all"]` 获取 Word、Excel 和所有可用资料的 ZIP。批次传 `batch_id` 获取汇总和各项任务报告的 ZIP。未指定格式时使用保存的 Word/Excel 偏好。执行中的导出只是当前快照。
|
- 用 `mobile_export_report(session_id=...)` 获取单任务报告;`formats=["screenshots"]` 获取排序截图,`["video"]` 获取保存的录屏分段,`["all"]` 获取 Word、Excel 和所有可用资料的 ZIP。批次传 `batch_id` 获取汇总、各项任务报告、步骤截图及已有录屏的 ZIP。未指定格式时使用保存的 Word/Excel 偏好。执行中的导出只是当前快照。
|
||||||
- 工具返回 MCP 主机上的绝对文件路径和下载 URL。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。
|
- 工具返回 MCP 主机上的绝对文件路径和下载 URL。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。
|
||||||
- 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。
|
- 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。
|
||||||
- `mobile_inspect_trace` 是只读步骤证据查看;其中历史上下文的 replay 术语不表示重新执行设备动作。
|
- `mobile_inspect_trace` 是只读步骤证据查看;其中历史上下文的 replay 术语不表示重新执行设备动作。
|
||||||
@@ -27,10 +27,10 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
|
|||||||
## 配置和恢复边界
|
## 配置和恢复边界
|
||||||
|
|
||||||
- `mobile_config` 读取非敏感设置。只有用户明确要求保存或修改默认配置时,才传 `changes` 与 `persist_defaults=true`;这些设置影响后续任务,不能为一次临时需求擅自改变以后所有任务。
|
- `mobile_config` 读取非敏感设置。只有用户明确要求保存或修改默认配置时,才传 `changes` 与 `persist_defaults=true`;这些设置影响后续任务,不能为一次临时需求擅自改变以后所有任务。
|
||||||
- 模型名称支持预设 `gpt-5.6-luna`、`mimo-v2.6-flash` 和自定义名称,取决于用户配置的 Responses 网关。思考强度最高支持 `max`,默认 `high`、单次请求 300 秒、失败后最多重试 5 次、固定间隔 10 秒;默认关闭视频分析与录屏。不根据模型名称猜测网关能力。
|
- 模型名称支持预设 `gpt-5.6-luna`、`gpt-6-luna`、`mimo-v2.6-flash`、`mimo-v2.6-pro` 和自定义名称,取决于用户配置的 Responses 网关。思考强度最高支持 `max`,默认 `high`、单次请求 300 秒、失败后最多重试 5 次;普通错误每次等待 10 秒,429 每次等待 60 秒。首选模型连续 3 次未形成有效工具调用时,当前步骤改用 `gpt-6-luna`(high),兜底最多再尝试 3 次;下一步恢复首选模型。默认关闭视频分析与录屏。不根据模型名称猜测网关能力。
|
||||||
- 强度选 `default` 表示不发送该参数,和 `none`(显式要求关闭)不同。接口拒绝强度参数时明确呈现失败,先询问用户是否改用 `default`,不能静默降级或换模型。
|
- 强度选 `default` 表示不发送该参数,和 `none`(显式要求关闭)不同。接口拒绝强度参数时明确呈现失败,先询问用户是否改用 `default`,不能静默降级或换模型。
|
||||||
- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。
|
- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。
|
||||||
- 录屏与 AI 视频分析分别配置。批量任务的 `task_config` 可覆盖单项,不修改全局设置;只开视频分析时录制内容不作为可下载文件保存。暂停时停止录屏,恢复后按片段继续,不能把其他任务画面混入该任务资料。
|
- 429 等待和兜底参数可在网页模型配置或经用户明确授权的 `mobile_config(section="model", persist_defaults=true)` 保存;当前任务不热切换。录屏与 AI 视频分析分别配置。批量任务的 `task_config` 可覆盖单项,不修改全局设置;只开视频分析时录制内容不作为可下载文件保存。暂停时停止录屏,恢复后按片段继续,不能把其他任务画面混入该任务资料。
|
||||||
- 请求重试是模型调用恢复,不是测试用例重跑。批次默认失败继续、不自动重跑;服务重启后保留状态并暂停,用户明确要求后才继续或复制失败项。
|
- 请求重试是模型调用恢复,不是测试用例重跑。批次默认失败继续、不自动重跑;服务重启后保留状态并暂停,用户明确要求后才继续或复制失败项。
|
||||||
- 批次暂停只停止后续派发;当前任务等待指导时用 `mobile_manage_task(action="pause", trace_id=...)`,真正取消用 `stop`。用户明确要求删除批次记录时先 `mobile_batch(action="get")` 核对编号,再用 `delete`;执行中/排队中的批次不能删,删除会清除该批次的任务历史、报告和文件。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。
|
- 批次暂停只停止后续派发;当前任务等待指导时用 `mobile_manage_task(action="pause", trace_id=...)`,真正取消用 `stop`。用户明确要求删除批次记录时先 `mobile_batch(action="get")` 核对编号,再用 `delete`;执行中/排队中的批次不能删,删除会清除该批次的任务历史、报告和文件。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,6 @@
|
|||||||
|
|
||||||
## 报告
|
## 报告
|
||||||
|
|
||||||
`mobile_export_report(batch_id=...)` 下载批次 ZIP,包含汇总和各项报告,格式由创建批次时的设置决定。单任务传 `session_id`(单任务 MCP 的 `trace_id`)以及可选 `formats=["docx"]`、`["screenshots"]`、`["video"]` 或 `["all"]`。默认格式通过 `mobile_config(section="reports")` 查看。录屏与 AI 视频分析独立,只有保存了录屏才可下载片段。
|
`mobile_export_report(batch_id=...)` 下载批次 ZIP,包含汇总、各项报告和有序步骤截图;任务保存了录屏时,还包含对应录屏片段。报告格式由创建批次时的设置决定。单任务传 `session_id`(单任务 MCP 的 `trace_id`)以及可选 `formats=["docx"]`、`["screenshots"]`、`["video"]` 或 `["all"]`。默认格式通过 `mobile_config(section="reports")` 查看。录屏与 AI 视频分析独立,只有保存了录屏才可下载片段。
|
||||||
|
|
||||||
结果文件在 MCP 主机的 `artifacts/mcp_exports/`。不要把 Office 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。
|
结果文件在 MCP 主机的 `artifacts/mcp_exports/`。不要把 Office 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -108,6 +109,63 @@ def test_invalid_archive_and_empty_template():
|
|||||||
assert not task_import.parse_workbook(task_import.template())["valid"]
|
assert not task_import.parse_workbook(task_import.template())["valid"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_archive_preparation_reports_progress_and_reuses_ready_file(
|
||||||
|
client, isolated, monkeypatch
|
||||||
|
):
|
||||||
|
batch = service.create_batch(payload())
|
||||||
|
batch_id = batch["id"]
|
||||||
|
started = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def slow_export(identifier, progress=None):
|
||||||
|
calls.append(identifier)
|
||||||
|
progress(0, 2, "正在打包任务 1/2")
|
||||||
|
started.set()
|
||||||
|
assert release.wait(5)
|
||||||
|
progress(2, 2, "正在完成压缩包")
|
||||||
|
path = reports.EXPORT_ROOT / "batches" / identifier / "批次报告.zip"
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(b"prepared archive")
|
||||||
|
return path
|
||||||
|
|
||||||
|
monkeypatch.setattr(reports, "export_batch", slow_export)
|
||||||
|
base = f"/api/automation/batches/{batch_id}/archive"
|
||||||
|
try:
|
||||||
|
response = client.post(base + "/prepare")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "preparing"
|
||||||
|
assert started.wait(2)
|
||||||
|
progress = client.get(base + "/status").json()
|
||||||
|
assert progress["total"] == 2
|
||||||
|
assert progress["stage"] == "正在打包任务 1/2"
|
||||||
|
assert client.get(base).status_code == 409
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
for _ in range(50):
|
||||||
|
if client.get(base + "/status").json()["status"] == "ready":
|
||||||
|
break
|
||||||
|
time.sleep(0.02)
|
||||||
|
assert client.get(base + "/status").json()["status"] == "ready"
|
||||||
|
assert client.get(base).content == b"prepared archive"
|
||||||
|
assert calls == [batch_id]
|
||||||
|
|
||||||
|
|
||||||
|
def test_completed_batch_reuses_existing_archive(client, isolated, monkeypatch):
|
||||||
|
batch = service.create_batch(payload())
|
||||||
|
batch_id = batch["id"]
|
||||||
|
isolated.put("batch", batch_id, {**batch, "status": "completed", "report_ready": True})
|
||||||
|
path = reports.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip"
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
path.write_bytes(b"existing archive")
|
||||||
|
exporter = Mock(side_effect=AssertionError("archive must not be rebuilt"))
|
||||||
|
monkeypatch.setattr(reports, "export_batch", exporter)
|
||||||
|
base = f"/api/automation/batches/{batch_id}/archive"
|
||||||
|
assert client.post(base + "/prepare").json()["status"] == "ready"
|
||||||
|
assert client.get(base).content == b"existing archive"
|
||||||
|
exporter.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_preview_does_not_enqueue(client, isolated):
|
def test_preview_does_not_enqueue(client, isolated):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/automation/import/preview",
|
"/api/automation/import/preview",
|
||||||
@@ -318,6 +376,38 @@ async def test_fail_fast_includes_skipped_reports(isolated, monkeypatch):
|
|||||||
assert sum(n.endswith(".docx") for n in z.namelist()) == 3
|
assert sum(n.endswith(".docx") for n in z.namelist()) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_archive_contains_each_task_media_when_present(isolated, monkeypatch, tmp_path):
|
||||||
|
batch = service.create_batch(payload())
|
||||||
|
first, second = isolated.jobs(batch["id"])
|
||||||
|
image1 = tmp_path / "first.png"
|
||||||
|
image2 = tmp_path / "second.png"
|
||||||
|
video = tmp_path / "recording.mp4"
|
||||||
|
image1.write_bytes(b"first screenshot")
|
||||||
|
image2.write_bytes(b"second screenshot")
|
||||||
|
video.write_bytes(b"saved recording")
|
||||||
|
images = {first["session_id"]: image1, second["session_id"]: image2}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
reports,
|
||||||
|
"session_screenshots",
|
||||||
|
lambda report: [(images[report["id"]], "0001_步骤0001_操作前.png")],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
reports,
|
||||||
|
"session_videos",
|
||||||
|
lambda report: (
|
||||||
|
[(video, "0001_recording.mp4")] if report["id"] == first["session_id"] else []
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
archive = reports.export_batch(batch["id"])
|
||||||
|
with zipfile.ZipFile(archive) as z:
|
||||||
|
names = z.namelist()
|
||||||
|
assert len([name for name in names if "/步骤截图/" in name]) == 2
|
||||||
|
assert len([name for name in names if "/任务录屏/" in name]) == 1
|
||||||
|
assert all(name.startswith("任务报告/") for name in names if "步骤截图" in name)
|
||||||
|
assert z.read(next(name for name in names if "/任务录屏/" in name)) == b"saved recording"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_report_failure_does_not_strand_job(isolated, monkeypatch):
|
async def test_report_failure_does_not_strand_job(isolated, monkeypatch):
|
||||||
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||||
|
|||||||
@@ -23,10 +23,16 @@ def isolated_settings(monkeypatch, tmp_path):
|
|||||||
"OPENAI_TIMEOUT_SECONDS",
|
"OPENAI_TIMEOUT_SECONDS",
|
||||||
"OPENAI_MAX_RETRIES",
|
"OPENAI_MAX_RETRIES",
|
||||||
"OPENAI_RETRY_INTERVAL_SECONDS",
|
"OPENAI_RETRY_INTERVAL_SECONDS",
|
||||||
|
"OPENAI_RATE_LIMIT_RETRY_SECONDS",
|
||||||
|
"OPENAI_TOOL_FALLBACK_MODEL",
|
||||||
|
"OPENAI_TOOL_FALLBACK_REASONING_EFFORT",
|
||||||
|
"OPENAI_TOOL_FALLBACK_MAX_RETRIES",
|
||||||
|
"ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS",
|
||||||
"OPENAI_REASONING_EFFORT",
|
"OPENAI_REASONING_EFFORT",
|
||||||
"ARTEMIS_VIDEO_ENABLED",
|
"ARTEMIS_VIDEO_ENABLED",
|
||||||
"ARTEMIS_OUTPUTTER_ENABLED",
|
"ARTEMIS_OUTPUTTER_ENABLED",
|
||||||
"ARTEMIS_STEP_SUMMARIZER_ENABLED",
|
"ARTEMIS_STEP_SUMMARIZER_ENABLED",
|
||||||
|
"ARTEMIS_SCREENSHOT_LIMIT_ENABLED",
|
||||||
]
|
]
|
||||||
for key in keys:
|
for key in keys:
|
||||||
monkeypatch.setattr(settings, key, getattr(settings, key))
|
monkeypatch.setattr(settings, key, getattr(settings, key))
|
||||||
@@ -42,6 +48,11 @@ def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
|
|||||||
api_key="new-test-secret",
|
api_key="new-test-secret",
|
||||||
timeout_seconds=75,
|
timeout_seconds=75,
|
||||||
max_retries=2,
|
max_retries=2,
|
||||||
|
rate_limit_retry_seconds=90,
|
||||||
|
tool_fallback_model="custom/fallback",
|
||||||
|
tool_fallback_reasoning_effort="max",
|
||||||
|
tool_fallback_max_retries=2,
|
||||||
|
tool_fallback_no_progress_attempts=4,
|
||||||
)
|
)
|
||||||
result = service.save_model_settings(update)
|
result = service.save_model_settings(update)
|
||||||
assert result["api_key_configured"]
|
assert result["api_key_configured"]
|
||||||
@@ -49,12 +60,16 @@ def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
|
|||||||
saved = dotenv_values(isolated_settings)
|
saved = dotenv_values(isolated_settings)
|
||||||
assert saved["OPENAI_API_KEY"] == "new-test-secret"
|
assert saved["OPENAI_API_KEY"] == "new-test-secret"
|
||||||
assert saved["OPENAI_MODEL"] == "my-model"
|
assert saved["OPENAI_MODEL"] == "my-model"
|
||||||
|
assert saved["OPENAI_RATE_LIMIT_RETRY_SECONDS"] == "90.0"
|
||||||
|
assert saved["OPENAI_TOOL_FALLBACK_MODEL"] == "custom/fallback"
|
||||||
|
assert saved["ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS"] == "4"
|
||||||
assert saved["UNRELATED"] == "keep"
|
assert saved["UNRELATED"] == "keep"
|
||||||
assert "# user settings" in isolated_settings.read_text(encoding="utf-8")
|
assert "# user settings" in isolated_settings.read_text(encoding="utf-8")
|
||||||
reloaded = Settings(_env_file=isolated_settings)
|
reloaded = Settings(_env_file=isolated_settings)
|
||||||
assert reloaded.OPENAI_MODEL == "my-model"
|
assert reloaded.OPENAI_MODEL == "my-model"
|
||||||
assert parse_llm_config().operator.model == "my-model"
|
assert parse_llm_config().operator.model == "my-model"
|
||||||
assert parse_llm_config().operator.timeout == 75
|
assert parse_llm_config().operator.timeout == 75
|
||||||
|
assert parse_llm_config().operator.fallback.model == "custom/fallback"
|
||||||
service.save_model_settings(update.model_copy(update={"api_key": SecretStr("")}))
|
service.save_model_settings(update.model_copy(update={"api_key": SecretStr("")}))
|
||||||
assert dotenv_values(isolated_settings)["OPENAI_API_KEY"] == "new-test-secret"
|
assert dotenv_values(isolated_settings)["OPENAI_API_KEY"] == "new-test-secret"
|
||||||
|
|
||||||
@@ -93,21 +108,38 @@ def test_rejects_invalid_base_url(url):
|
|||||||
def test_capabilities_persist_and_apply_to_new_tasks(isolated_settings):
|
def test_capabilities_persist_and_apply_to_new_tasks(isolated_settings):
|
||||||
result = service.save_capability_settings(
|
result = service.save_capability_settings(
|
||||||
service.CapabilitySettings(
|
service.CapabilitySettings(
|
||||||
video_enabled=False, outputter_enabled=False, step_summarizer_enabled=False
|
video_enabled=False,
|
||||||
|
outputter_enabled=False,
|
||||||
|
step_summarizer_enabled=False,
|
||||||
|
screenshot_limit_enabled=False,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert result.video_enabled is False
|
assert result.video_enabled is False
|
||||||
saved = dotenv_values(isolated_settings)
|
saved = dotenv_values(isolated_settings)
|
||||||
assert saved["ARTEMIS_VIDEO_ENABLED"] == "false"
|
assert saved["ARTEMIS_VIDEO_ENABLED"] == "false"
|
||||||
|
assert saved["ARTEMIS_SCREENSHOT_LIMIT_ENABLED"] == "false"
|
||||||
config = load_agent_config()
|
config = load_agent_config()
|
||||||
assert config.video_analyzer.enabled is False
|
assert config.video_analyzer.enabled is False
|
||||||
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
|
||||||
|
assert config.screenshot.enabled is False
|
||||||
service.save_capability_settings(service.CapabilitySettings(video_enabled=True))
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_screenshot_json_can_override_environment(monkeypatch, tmp_path):
|
||||||
|
config_path = tmp_path / "artemis.jsonc"
|
||||||
|
config_path.write_text(
|
||||||
|
'{"agent": {"screenshot": {"enabled": true, "max_long_edge": 1024}}}',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("ARTEMIS_SCREENSHOT_LIMIT_ENABLED", "false")
|
||||||
|
screenshot = load_agent_config(config_path).screenshot
|
||||||
|
assert screenshot.enabled is True
|
||||||
|
assert screenshot.max_long_edge == 1024
|
||||||
|
|
||||||
|
|
||||||
def test_failed_write_keeps_running_settings(monkeypatch, isolated_settings):
|
def test_failed_write_keeps_running_settings(monkeypatch, isolated_settings):
|
||||||
previous = settings.OPENAI_MODEL
|
previous = settings.OPENAI_MODEL
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|||||||
@@ -384,7 +384,7 @@ def _make_runner(mock_context, responses, max_turns=None):
|
|||||||
pending = list(responses)
|
pending = list(responses)
|
||||||
|
|
||||||
async def _invoke(llm, tools, messages):
|
async def _invoke(llm, tools, messages):
|
||||||
runner.calls.append({"tools": list(tools), "messages": list(messages)})
|
runner.calls.append({"llm": llm, "tools": list(tools), "messages": list(messages)})
|
||||||
return pending.pop(0)
|
return pending.pop(0)
|
||||||
|
|
||||||
runner._invoke_model = AsyncMock(side_effect=_invoke)
|
runner._invoke_model = AsyncMock(side_effect=_invoke)
|
||||||
@@ -647,9 +647,11 @@ async def test_flash_stops_repeated_no_tool_responses(mock_context, monkeypatch,
|
|||||||
from artemis.config.settings import settings
|
from artemis.config.settings import settings
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS", attempts)
|
monkeypatch.setattr(settings, "ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS", attempts)
|
||||||
responses = [AIMessage(content="I will click now.", tool_calls=[]) for _ in range(attempts)]
|
monkeypatch.setattr(settings, "ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS", 3)
|
||||||
|
responses = [AIMessage(content="I will click now.", tool_calls=[]) for _ in range(attempts + 3)]
|
||||||
with (
|
with (
|
||||||
patch("artemis.controllers.unified_controller.get_driver"),
|
patch("artemis.controllers.unified_controller.get_driver"),
|
||||||
|
patch("artemis.agents.flash.runner.get_llm", return_value=Mock()) as get_fallback,
|
||||||
patch(
|
patch(
|
||||||
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
|
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
|
||||||
AsyncMock(return_value=_INITIAL_OBSERVATION),
|
AsyncMock(return_value=_INITIAL_OBSERVATION),
|
||||||
@@ -659,11 +661,55 @@ async def test_flash_stops_repeated_no_tool_responses(mock_context, monkeypatch,
|
|||||||
result = await runner.run(State(initial_goal="Open Settings"))
|
result = await runner.run(State(initial_goal="Open Settings"))
|
||||||
|
|
||||||
assert result["status"] == "failed"
|
assert result["status"] == "failed"
|
||||||
assert f"连续 {attempts} 次未调用工具" in result["explanation"]
|
assert f"连续 {attempts} 次" in result["explanation"]
|
||||||
assert runner._invoke_model.await_count == attempts
|
assert "兜底模型连续 3 次" in result["explanation"]
|
||||||
|
assert runner._invoke_model.await_count == attempts + 3
|
||||||
|
get_fallback.assert_called_once_with(mock_context, name="operator", use_fallback=True)
|
||||||
runner.executor.execute.assert_not_awaited()
|
runner.executor.execute.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_flash_uses_fallback_for_one_tool_turn_then_primary(mock_context):
|
||||||
|
no_call = AIMessage(content="I will click now.")
|
||||||
|
observe = AIMessage(
|
||||||
|
content="Check the target.",
|
||||||
|
tool_calls=[
|
||||||
|
{
|
||||||
|
"name": "ask_explorer",
|
||||||
|
"args": {"question": "Find Settings"},
|
||||||
|
"id": "fallback-observe",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
click = AIMessage(
|
||||||
|
content="Tap Settings.",
|
||||||
|
tool_calls=[{"name": "click", "args": {"target": 1}, "id": "fallback-click"}],
|
||||||
|
)
|
||||||
|
fallback = Mock()
|
||||||
|
responses = [no_call, no_call, no_call, observe, click, _report()]
|
||||||
|
with (
|
||||||
|
patch("artemis.controllers.unified_controller.get_driver"),
|
||||||
|
patch("artemis.agents.flash.runner.get_llm", return_value=fallback),
|
||||||
|
patch(
|
||||||
|
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
|
||||||
|
AsyncMock(return_value=_INITIAL_OBSERVATION),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
runner = _make_runner(mock_context, responses, max_turns=0)
|
||||||
|
result = await runner.run(State(initial_goal="Open Settings"))
|
||||||
|
|
||||||
|
primary = runner.calls[0]["llm"]
|
||||||
|
assert [call["llm"] for call in runner.calls] == [
|
||||||
|
primary,
|
||||||
|
primary,
|
||||||
|
primary,
|
||||||
|
fallback,
|
||||||
|
fallback,
|
||||||
|
primary,
|
||||||
|
]
|
||||||
|
assert result["status"] == "completed"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _TurnRecord.result: the turn's execution result in the validator-report shape
|
# _TurnRecord.result: the turn's execution result in the validator-report shape
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -107,23 +107,56 @@ async def test_invalid_target_can_be_corrected_before_limit(operator, monkeypatc
|
|||||||
@pytest.mark.parametrize("limit", [1, 3])
|
@pytest.mark.parametrize("limit", [1, 3])
|
||||||
async def test_empty_responses_fail_at_configured_limit(operator, monkeypatch, limit):
|
async def test_empty_responses_fail_at_configured_limit(operator, monkeypatch, limit):
|
||||||
monkeypatch.setattr(settings, "ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS", limit)
|
monkeypatch.setattr(settings, "ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS", limit)
|
||||||
|
monkeypatch.setattr(settings, "ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS", 3)
|
||||||
|
fallback = Mock()
|
||||||
|
get_fallback = Mock(return_value=fallback)
|
||||||
|
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", get_fallback)
|
||||||
invoke = AsyncMock(return_value=AIMessage(content="I will click now."))
|
invoke = AsyncMock(return_value=AIMessage(content="I will click now."))
|
||||||
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||||
node, state = operator
|
node, state = operator
|
||||||
with pytest.raises(OperatorNoProgressError, match="未提供可执行的工具调用"):
|
with pytest.raises(OperatorNoProgressError, match="未提供可执行的工具调用"):
|
||||||
await node._invoke_llm_loop(Mock(), [], [], [], state)
|
await node._invoke_llm_loop(Mock(), [], [], [], state)
|
||||||
assert invoke.await_count == limit
|
assert invoke.await_count == limit + 3
|
||||||
|
get_fallback.assert_called_once_with(ctx=node.ctx, name="operator", use_fallback=True)
|
||||||
|
assert invoke.call_args_list[-1].args[0] is fallback.bind_tools.return_value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_operator_fallback_is_scoped_to_current_step(operator, monkeypatch):
|
||||||
|
node, state = operator
|
||||||
|
primary = Mock()
|
||||||
|
fallback = Mock()
|
||||||
|
get_fallback = Mock(return_value=fallback)
|
||||||
|
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", get_fallback)
|
||||||
|
invoke = AsyncMock(side_effect=[AIMessage(content="no tool")] * 3 + [click(), click()])
|
||||||
|
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||||
|
|
||||||
|
first = await node._invoke_llm_loop(primary, [], [], [], state)
|
||||||
|
second = await node._invoke_llm_loop(primary, [], [], [], state)
|
||||||
|
|
||||||
|
assert first[0] and second[0]
|
||||||
|
assert get_fallback.call_count == 1
|
||||||
|
assert [call.args[0] for call in invoke.call_args_list] == [
|
||||||
|
primary.bind_tools.return_value,
|
||||||
|
primary.bind_tools.return_value,
|
||||||
|
primary.bind_tools.return_value,
|
||||||
|
fallback.bind_tools.return_value,
|
||||||
|
primary.bind_tools.return_value,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_invalid_calls_and_prose_share_bounded_recovery(operator, monkeypatch):
|
async def test_invalid_calls_and_prose_share_bounded_recovery(operator, monkeypatch):
|
||||||
invoke = AsyncMock(side_effect=[click("missing"), AIMessage(content="Retry."), click("14")])
|
invoke = AsyncMock(
|
||||||
|
side_effect=[click("missing"), AIMessage(content="Retry."), click("14")] + [click("14")] * 3
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", Mock(return_value=Mock()))
|
||||||
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||||
node, state = operator
|
node, state = operator
|
||||||
messages = []
|
messages = []
|
||||||
with pytest.raises(OperatorNoProgressError, match="校验仍失败"):
|
with pytest.raises(OperatorNoProgressError, match="校验仍失败"):
|
||||||
await node._invoke_llm_loop(Mock(), messages, [], [], state)
|
await node._invoke_llm_loop(Mock(), messages, [], [], state)
|
||||||
assert invoke.await_count == 3
|
assert invoke.await_count == 6
|
||||||
assert any(isinstance(m, ToolMessage) and m.status == "error" for m in messages)
|
assert any(isinstance(m, ToolMessage) and m.status == "error" for m in messages)
|
||||||
assert not any(isinstance(m, ToolMessage) and m.content == "Action Recorded" for m in messages)
|
assert not any(isinstance(m, ToolMessage) and m.content == "Action Recorded" for m in messages)
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ async def test_config_requires_explicit_persistence_and_excludes_secrets(monkeyp
|
|||||||
"reasoning_effort": "high",
|
"reasoning_effort": "high",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await tools.mobile_config(
|
||||||
|
changes={"rate_limit_retry_seconds": 90, "tool_fallback_model": "gpt-6-luna"},
|
||||||
|
persist_defaults=True,
|
||||||
|
)
|
||||||
|
payload = request.call_args.kwargs["json"]
|
||||||
|
assert payload["rate_limit_retry_seconds"] == 90
|
||||||
|
assert payload["tool_fallback_model"] == "gpt-6-luna"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_preview_is_read_only_and_create_is_draft(monkeypatch, tmp_path):
|
async def test_preview_is_read_only_and_create_is_draft(monkeypatch, tmp_path):
|
||||||
|
|||||||
@@ -225,6 +225,20 @@ async def test_api_server_status():
|
|||||||
assert "current_pid" in data
|
assert "current_pid" in data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_server_status_does_not_scan_ports():
|
||||||
|
with patch(
|
||||||
|
"artemis.runtime.server_lifecycle.get_server_status",
|
||||||
|
side_effect=AssertionError("port scan"),
|
||||||
|
):
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app), base_url="http://localhost"
|
||||||
|
) as client:
|
||||||
|
response = await client.get("/api/system/server-status")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["running"] is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_api_restart_endpoint():
|
async def test_api_restart_endpoint():
|
||||||
"""Verify POST /api/system/restart accepts request and schedules restart."""
|
"""Verify POST /api/system/restart accepts request and schedules restart."""
|
||||||
|
|||||||
@@ -15,14 +15,18 @@
|
|||||||
"""Unit tests for Unified Base Device Driver and implementations."""
|
"""Unit tests for Unified Base Device Driver and implementations."""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
from io import BytesIO
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from artemis.drivers.base import KeyCode, ScreenData, SwipeDirection
|
from artemis.drivers.base import KeyCode, ScreenData, SwipeDirection
|
||||||
from artemis.drivers.mock.mock_driver import MockDeviceDriver
|
from artemis.drivers.mock.mock_driver import MockDeviceDriver
|
||||||
from artemis.drivers.android.adb_driver import AndroidAdbDriver
|
from artemis.drivers.android.adb_driver import AndroidAdbDriver
|
||||||
|
from artemis.config.agent import ScreenshotConfig
|
||||||
|
from artemis.utils.screenshot_limit import limit_screenshot_long_edge
|
||||||
|
|
||||||
|
|
||||||
_ONE_PIXEL_PNG = base64.b64encode(
|
_ONE_PIXEL_PNG = base64.b64encode(
|
||||||
@@ -32,6 +36,13 @@ _ONE_PIXEL_PNG = base64.b64encode(
|
|||||||
).decode("ascii")
|
).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def test_screenshot_limit_keeps_small_and_invalid_images_unchanged():
|
||||||
|
small_image = base64.b64decode(_ONE_PIXEL_PNG)
|
||||||
|
assert limit_screenshot_long_edge(small_image, 2048) is small_image
|
||||||
|
invalid_image = b"not an image"
|
||||||
|
assert limit_screenshot_long_edge(invalid_image, 2048) is invalid_image
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mock_driver_actions():
|
async def test_mock_driver_actions():
|
||||||
"""Verify MockDeviceDriver records and performs all core actions."""
|
"""Verify MockDeviceDriver records and performs all core actions."""
|
||||||
@@ -141,6 +152,36 @@ async def test_android_driver_preserves_elements_from_combined_screen_data():
|
|||||||
mock_ui_client.get_hierarchy.assert_not_called()
|
mock_ui_client.get_hierarchy.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("enabled", [True, False])
|
||||||
|
async def test_android_screenshot_limit_preserves_native_coordinates(enabled):
|
||||||
|
image = Image.new("RGB", (1200, 2600), "white")
|
||||||
|
buffer = BytesIO()
|
||||||
|
image.save(buffer, format="JPEG")
|
||||||
|
original = buffer.getvalue()
|
||||||
|
elements = [{"text": "目标", "bounds": "[100,200][300,400]"}]
|
||||||
|
ui_client = MagicMock()
|
||||||
|
ui_client.get_screen_data.return_value = SimpleNamespace(
|
||||||
|
base64=base64.b64encode(original).decode("ascii"),
|
||||||
|
hierarchy_xml="<hierarchy />",
|
||||||
|
elements=elements,
|
||||||
|
width=1200,
|
||||||
|
height=2600,
|
||||||
|
)
|
||||||
|
driver = AndroidAdbDriver("emulator-5554", MagicMock(), ui_adb_client=ui_client)
|
||||||
|
driver._screenshot_config = ScreenshotConfig(enabled=enabled, max_long_edge=2048)
|
||||||
|
|
||||||
|
screen = await driver.get_screen_data(skip_settling=True)
|
||||||
|
|
||||||
|
with Image.open(BytesIO(screen.screenshot_bytes)) as result:
|
||||||
|
assert result.size == ((945, 2048) if enabled else (1200, 2600))
|
||||||
|
assert screen.width == 1200 and screen.height == 2600
|
||||||
|
assert screen.ui_elements == elements
|
||||||
|
assert screen.screenshot_base64 == base64.b64encode(screen.screenshot_bytes).decode("ascii")
|
||||||
|
if not enabled:
|
||||||
|
assert screen.screenshot_bytes == original
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_android_driver_parses_xml_hierarchy_fallback():
|
async def test_android_driver_parses_xml_hierarchy_fallback():
|
||||||
"""XML-only clients remain supported without discarding their hierarchy."""
|
"""XML-only clients remain supported without discarding their hierarchy."""
|
||||||
|
|||||||
@@ -301,6 +301,32 @@ async def test_configured_retry_budget_is_shared_by_identical_fallback(monkeypat
|
|||||||
assert llm_service._FAILED_ENDPOINTS.get() is None
|
assert llm_service._FAILED_ENDPOINTS.get() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_429_waits_one_minute_then_retries(monkeypatch):
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from artemis.llm.reliability import retry_policy_for
|
||||||
|
|
||||||
|
class RateLimited(Exception):
|
||||||
|
status_code = 429
|
||||||
|
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def call_once():
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 1:
|
||||||
|
raise RateLimited("Too Many Requests")
|
||||||
|
return "recovered"
|
||||||
|
|
||||||
|
sleep = AsyncMock()
|
||||||
|
monkeypatch.setattr(llm_service, "retry_policy_for", retry_policy_for)
|
||||||
|
monkeypatch.setattr(llm_service.asyncio, "sleep", sleep)
|
||||||
|
monkeypatch.setattr(llm_service.settings, "OPENAI_RATE_LIMIT_RETRY_SECONDS", 60)
|
||||||
|
assert await llm_service._run_with_recovery(call_once, max_retries=1) == "recovered"
|
||||||
|
assert calls == 2
|
||||||
|
sleep.assert_awaited_once_with(60)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_request_deadline_cancels_attempt_and_respects_zero_retries(monkeypatch):
|
async def test_request_deadline_cancels_attempt_and_respects_zero_retries(monkeypatch):
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ def test_non_retryable_categories_get_single_attempt():
|
|||||||
assert retry_policy_for(FailureCategory.RATE_LIMIT).max_attempts > 1
|
assert retry_policy_for(FailureCategory.RATE_LIMIT).max_attempts > 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_too_many_requests_uses_one_minute_retry_delay(monkeypatch):
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "OPENAI_RATE_LIMIT_RETRY_SECONDS", 60)
|
||||||
|
failure = classify_failure(RuntimeError("Too Many Requests"))
|
||||||
|
assert failure.category is FailureCategory.RATE_LIMIT
|
||||||
|
assert retry_policy_for(failure.category).delay_for(1) == 60
|
||||||
|
|
||||||
|
|
||||||
def test_breaker_only_trips_on_transient_categories():
|
def test_breaker_only_trips_on_transient_categories():
|
||||||
breaker = CircuitBreaker(threshold=2, cooldown_seconds=10.0)
|
breaker = CircuitBreaker(threshold=2, cooldown_seconds=10.0)
|
||||||
auth = classify_failure(RuntimeError("401 unauthorized"))
|
auth = classify_failure(RuntimeError("401 unauthorized"))
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""The compatibility entry point follows the commands registered by Typer."""
|
||||||
|
|
||||||
|
from artemis.main import normalize_argv
|
||||||
|
|
||||||
|
|
||||||
|
def test_registered_commands_and_flags_are_not_treated_as_goals():
|
||||||
|
for token in ("ui", "status", "helper", "restart", "stop", "--help", "--version", "-v"):
|
||||||
|
assert normalize_argv(["artemis", token]) == ["artemis", token]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bare_goal_still_uses_run_command():
|
||||||
|
assert normalize_argv(["artemis", "打开设置"]) == ["artemis", "run", "打开设置"]
|
||||||
@@ -207,6 +207,30 @@ async def test_analyzer_clip_stays_continuous_across_orientation_segments(tmp_pa
|
|||||||
assert len(frames) >= 20
|
assert len(frames) >= 20
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_analyzer_clip_pins_requested_fps_across_recording_gap(tmp_path):
|
||||||
|
ffmpeg = get_ffmpeg_path()
|
||||||
|
first = tmp_path / "first.mkv"
|
||||||
|
second = tmp_path / "second.mkv"
|
||||||
|
output = tmp_path / "clip.mp4"
|
||||||
|
_make_solid_clip(ffmpeg, "red", "108x242", 1.0, first)
|
||||||
|
_make_solid_clip(ffmpeg, "blue", "242x108", 1.0, second)
|
||||||
|
assert await render_timeline_clip(
|
||||||
|
[{"path": first, "start": 0.0, "end": 1.0}, {"path": second, "start": 1.5, "end": 2.5}],
|
||||||
|
0.0,
|
||||||
|
2.5,
|
||||||
|
output,
|
||||||
|
canvas_width=180,
|
||||||
|
canvas_height=320,
|
||||||
|
fps=7,
|
||||||
|
)
|
||||||
|
capture = cv2.VideoCapture(str(output))
|
||||||
|
try:
|
||||||
|
assert capture.get(cv2.CAP_PROP_FPS) == pytest.approx(7.0, abs=0.1)
|
||||||
|
finally:
|
||||||
|
capture.release()
|
||||||
|
|
||||||
|
|
||||||
def test_timeline_pieces_fill_restart_gaps(tmp_path):
|
def test_timeline_pieces_fill_restart_gaps(tmp_path):
|
||||||
"""Holes between segments (and a window starting in one) become black gaps."""
|
"""Holes between segments (and a window starting in one) become black gaps."""
|
||||||
first = tmp_path / "recording.mkv"
|
first = tmp_path / "recording.mkv"
|
||||||
|
|||||||
Reference in new issue
Block a user