feat: add favorite task templates and optimize evidence exports

This commit is contained in:
chenmingxuan committed 2026-10-18 19:44:51 +08:00
1 parent e1ea099592
commit a4a5430922
46 files changed
+1714 -188

No files matched your search

+5 -8
View File
@@ -182,11 +182,7 @@ async def batch_archive(batch_id: str):
if archive_service.status(batch_id, batch)["status"] == "preparing": if archive_service.status(batch_id, batch)["status"] == "preparing":
raise HTTPException(409, "报告正在打包,请等待准备完成") raise HTTPException(409, "报告正在打包,请等待准备完成")
path = archive_service.archive_path(batch_id) path = archive_service.archive_path(batch_id)
if ( if not path.is_file() or archive_service.status(batch_id, batch)["status"] != "ready":
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")
@@ -205,8 +201,7 @@ async def download_report(
): ):
try: try:
if fmt in {"screenshots", "video", "all"}: if fmt in {"screenshots", "video", "all"}:
path = await asyncio.to_thread(report_export.export_session_media, session_id, fmt) path, label = await asyncio.to_thread(report_export.session_download, session_id, fmt)
label = report_export.archive_case_name(report_export.session_report(session_id))
return FileResponse(path, filename=f"{label}-{fmt}.zip") return FileResponse(path, filename=f"{label}-{fmt}.zip")
paths = await asyncio.to_thread(report_export.export_session, session_id, [fmt]) paths = await asyncio.to_thread(report_export.export_session, session_id, [fmt])
return FileResponse(paths[0], filename=f"任务报告-{session_id[:8]}.{fmt}") return FileResponse(paths[0], filename=f"任务报告-{session_id[:8]}.{fmt}")
@@ -224,7 +219,9 @@ def review_case(session_id: str, value: CaseReviewRequest):
and archive_service.status(batch_id, require("batch", batch_id))["status"] == "preparing" and archive_service.status(batch_id, require("batch", batch_id))["status"] == "preparing"
): ):
raise HTTPException(409, "批次报告正在打包,请完成后再保存人工复验") raise HTTPException(409, "批次报告正在打包,请完成后再保存人工复验")
with report_export.EXPORT_LOCK: with report_export.export_lock(
*(["batch:" + batch_id] if batch_id else []), "session:" + session_id
):
current = report(session_id) current = report(session_id)
if current["status"] not in { if current["status"] not in {
"completed", "completed",
+50
View File
@@ -0,0 +1,50 @@
"""Favorite templates shared by the home page and MCP."""
from fastapi import APIRouter, HTTPException
from apps.admin_console.services.favorites import (
FavoriteCreate,
FavoriteRun,
FavoriteUpdate,
favorites,
)
router = APIRouter(prefix="/api/automation/favorites", tags=["automation"])
@router.get("")
def list_favorites():
return favorites.store.records("favorite", 10000)
@router.post("")
def create_favorite(value: FavoriteCreate):
try:
return favorites.create(value)
except (KeyError, ValueError) as exc:
raise HTTPException(409, str(exc)) from exc
@router.patch("/{key}")
def update_favorite(key: str, value: FavoriteUpdate):
try:
return favorites.update(key, value)
except (KeyError, ValueError) as exc:
raise HTTPException(409, str(exc)) from exc
@router.delete("/{key}")
def delete_favorite(key: str):
try:
favorites.delete(key)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"status": "deleted"}
@router.post("/{key}/run")
async def run_favorite(key: str, value: FavoriteRun):
try:
return await favorites.run(key, value)
except (KeyError, ValueError) as exc:
raise HTTPException(409, str(exc)) from exc
+3
View File
@@ -277,6 +277,9 @@ app.include_router(system.router)
from apps.admin_console.routers.automation import router as automation_router from apps.admin_console.routers.automation import router as automation_router
app.include_router(automation_router) app.include_router(automation_router)
from apps.admin_console.routers.favorites import router as favorites_router
app.include_router(favorites_router)
from apps.admin_console.routers.plugins import router as plugins_router from apps.admin_console.routers.plugins import router as plugins_router
@@ -36,6 +36,22 @@ def register_submission(item: dict, case: dict | None = None, formats=None, **ex
"status": "queued", "status": "queued",
"created_at": time.time(), "created_at": time.time(),
"device_serial": item.get("device_serial"), "device_serial": item.get("device_serial"),
"execution_config": item.get("execution_config"),
"submission": {
key: item.get(key)
for key in (
"goal",
"profile",
"device_serial",
"runtime_overrides",
"expected_output",
"enable_outputter",
"verification_level",
"explorer_mode",
"locked_app_package",
"app_path",
)
},
"formats": formats if formats is not None else store.preferences()["formats"], "formats": formats if formats is not None else store.preferences()["formats"],
**extra, **extra,
} }
@@ -391,7 +407,9 @@ def delete_batch(batch_id: str) -> bool:
return False return False
storage = StorageManager(store.path, report_export.TRACES_PATH) storage = StorageManager(store.path, report_export.TRACES_PATH)
mcp_exports = report_export.EXPORT_ROOT.parent / "mcp_exports" mcp_exports = report_export.EXPORT_ROOT.parent / "mcp_exports"
with report_export.EXPORT_LOCK: with report_export.export_lock(
"batch:" + batch_id, *("session:" + j["session_id"] for j in jobs)
):
for job in jobs: for job in jobs:
session_id = str(UUID(job["session_id"])) session_id = str(UUID(job["session_id"]))
storage.delete_session(UUID(session_id)) storage.delete_session(UUID(session_id))
+10 -4
View File
@@ -13,6 +13,12 @@ def archive_path(batch_id: str):
return report_export.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip" return report_export.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip"
def cached(batch_id: str, batch: dict) -> bool:
return bool(batch.get("report_ready")) and report_export.reusable(
archive_path(batch_id), report_export.BATCH_ARCHIVE_SIGNATURE, "completed"
)
def _snapshot(state: dict) -> dict: def _snapshot(state: dict) -> dict:
elapsed = max(0, (state.get("finished_at") or time.monotonic()) - state["started_at"]) elapsed = max(0, (state.get("finished_at") or time.monotonic()) - state["started_at"])
completed, total = state["completed"], state["total"] completed, total = state["completed"], state["total"]
@@ -37,7 +43,7 @@ def status(batch_id: str, batch: dict) -> dict:
state = _states.get(batch_id) state = _states.get(batch_id)
if state: if state:
return _snapshot(state) return _snapshot(state)
if batch.get("report_ready") and archive_path(batch_id).is_file(): if cached(batch_id, batch):
return { return {
"status": "ready", "status": "ready",
"stage": "报告包已就绪", "stage": "报告包已就绪",
@@ -63,7 +69,7 @@ def prepare(batch_id: str, batch: dict) -> dict:
current = _states.get(batch_id) current = _states.get(batch_id)
if current and current["status"] == "preparing": if current and current["status"] == "preparing":
return _snapshot(current) return _snapshot(current)
if batch.get("report_ready") and archive_path(batch_id).is_file(): if cached(batch_id, batch):
_states.pop(batch_id, None) _states.pop(batch_id, None)
return status_from_cache() return status_from_cache()
state = { state = {
@@ -81,9 +87,9 @@ def prepare(batch_id: str, batch: dict) -> dict:
def run() -> None: def run() -> None:
try: try:
with report_export.EXPORT_LOCK: with report_export.export_lock("batch:" + batch_id):
latest = report_export.store.get("batch", batch_id) latest = report_export.store.get("batch", batch_id)
if not (latest and latest.get("report_ready") and archive_path(batch_id).is_file()): if not (latest and cached(batch_id, latest)):
report_export.export_batch(batch_id, progress=update) report_export.export_batch(batch_id, progress=update)
with _lock: with _lock:
state.update(status="ready", stage="报告包已就绪", finished_at=time.monotonic()) state.update(status="ready", stage="报告包已就绪", finished_at=time.monotonic())
@@ -105,14 +105,16 @@ def plugin_files(session_id: str) -> list[tuple[Path, str]]:
return list(files.items()) return list(files.items())
def export_index(session_id: str, report: dict, android_videos: list) -> dict: def export_index(
session_id: str, report: dict, android_videos: list, *, images=None, artifacts=None, extras=None
) -> dict:
from apps.admin_console.services.report_export import session_artifacts, session_screenshots from apps.admin_console.services.report_export import session_artifacts, session_screenshots
from apps.admin_console.services.exploration_evidence import ( from apps.admin_console.services.exploration_evidence import (
exploration_index, exploration_index,
demonstration_sources, demonstration_sources,
) )
extras = plugin_files(session_id) extras = plugin_files(session_id) if extras is None else extras
recordings = plugin_sources(session_id) + demonstration_sources(session_id) recordings = plugin_sources(session_id) + demonstration_sources(session_id)
root = task_root(session_id) root = task_root(session_id)
paths = {path.resolve(): name for path, name in extras} paths = {path.resolve(): name for path, name in extras}
@@ -120,10 +122,10 @@ def export_index(session_id: str, report: dict, android_videos: list) -> dict:
for segment in source["segments"]: for segment in source["segments"]:
segment["file"] = paths.get((root / segment["file"]).resolve()) segment["file"] = paths.get((root / segment["file"]).resolve())
segment.pop("url", None) segment.pop("url", None)
images = session_screenshots(report) images = session_screenshots(report) if images is None else images
artifacts = session_artifacts(report) artifacts = session_artifacts(report) if artifacts is None else artifacts
files = [(path, "步骤截图/" + name) for path, name in images] files = [(path, "步骤截图/" + name) for path, name in images]
files += [(path, "任务录屏/" + name) for path, name in android_videos] files += [(path, "任务录屏/安卓/" + name) for path, name in android_videos]
files += [(path, "任务文件/安卓/" + name) for path, name in artifacts] files += [(path, "任务文件/安卓/" + name) for path, name in artifacts]
files += extras files += extras
calls = [] calls = []
@@ -151,7 +153,7 @@ def export_index(session_id: str, report: dict, android_videos: list) -> dict:
"steps": report.get("steps", []), "steps": report.get("steps", []),
"calls": calls, "calls": calls,
"recordings": recordings, "recordings": recordings,
"android_videos": ["任务录屏/" + name for _, name in android_videos], "android_videos": ["任务录屏/安卓/" + name for _, name in android_videos],
"image_references": {path.stem: "步骤截图/" + name for path, name in images}, "image_references": {path.stem: "步骤截图/" + name for path, name in images},
"files": [{"path": name, "bytes": path.stat().st_size} for path, name in files], "files": [{"path": name, "bytes": path.stat().st_size} for path, name in files],
"note": "工具执行完成不等于用例通过。录像保留真实时间与缺口;各来源截图不能相互替代。", "note": "工具执行完成不等于用例通过。录像保留真实时间与缺口;各来源截图不能相互替代。",
@@ -0,0 +1,53 @@
"""Credential-free execution settings persisted for repeatable favorites."""
from apps.admin_console.services.task_import import TaskOptions
from artemis.config import load_agent_config, parse_llm_config, verification_level_for_checker
from artemis.config.settings import settings
def execution_config(item: dict) -> dict:
agent = load_agent_config()
model = parse_llm_config().operator
options = {
"profile": item.get("profile") or "flash",
"model": model.model,
"reasoning_effort": model.reasoning_effort or "default",
"request_timeout_seconds": model.timeout,
"max_retries": model.max_retries,
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
"record_video": settings.ARTEMIS_RECORDING_ENABLED,
"recording": agent.recording.model_dump(),
"analyze_video": agent.video_analyzer.enable_ledger,
"timeout_seconds": settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
"verification_level": verification_level_for_checker(agent.checker),
"explorer_mode": agent.explorer.resolve(profile=item.get("profile") or "flash"),
"observation_timeout_seconds": settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS,
"observation_max_attempts": settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS,
"device_command_timeout_seconds": settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
"action_failure_attempts": settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS,
"worker_cleanup_timeout_seconds": settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS,
}
for key in ("device_serial", "verification_level", "explorer_mode", "locked_app_package"):
if item.get(key) is not None:
options[key] = item[key]
overrides = item.get("runtime_overrides") or {}
options["recording"] = {**options["recording"], **(overrides.get("recording") or {})}
options.update({k: v for k, v in overrides.items() if k != "recording" and v is not None})
return {
"runtime_overrides": TaskOptions.model_validate(options).model_dump(exclude_none=True),
"expected_output": item.get("expected_output"),
"enable_outputter": item.get("enable_outputter")
if item.get("enable_outputter") is not None
else agent.outputter.enabled,
"app_path": item.get("app_path"),
}
def persist_execution_config(item: dict) -> None:
from apps.admin_console.services.automation_store import store
snapshot = execution_config(item)
item["execution_config"] = snapshot
if job := store.job(str(item["session_id"])):
job["execution_config"] = snapshot
store.save_job(job)
@@ -32,7 +32,7 @@ def delete_exploration(repository: ExplorationStore, exploration_id: str) -> lis
# Validation sessions and batch copies remain ordinary Flash/Pro history. # Validation sessions and batch copies remain ordinary Flash/Pro history.
deleted = [run["id"] for run in runs if run["kind"] == "exploration"] deleted = [run["id"] for run in runs if run["kind"] == "exploration"]
storage = StorageManager(repository.database.path, report_export.TRACES_PATH) storage = StorageManager(repository.database.path, report_export.TRACES_PATH)
with report_export.EXPORT_LOCK: with report_export.export_lock(*("session:" + run_id for run_id in deleted)):
for run_id in deleted: for run_id in deleted:
safe_id = str(UUID(run_id)) safe_id = str(UUID(run_id))
storage.delete_session(UUID(safe_id)) storage.delete_session(UUID(safe_id))
@@ -0,0 +1,66 @@
"""Per-object export coordination and content-aware artifact reuse."""
from contextlib import contextmanager, ExitStack
import hashlib
import json
from pathlib import Path
import threading
from weakref import WeakValueDictionary
_guard = threading.Lock()
_locks: WeakValueDictionary = WeakValueDictionary()
TERMINAL = {"completed", "success", "failed", "cancelled", "interrupted", "skipped"}
@contextmanager
def export_lock(*keys: str):
# Retain strong references until every lock is released. Unrelated exports
# run concurrently; nested session exports can reacquire their own RLock.
with _guard:
locks = []
for key in sorted(set(keys)):
lock = _locks.get(key)
if lock is None:
lock = threading.RLock()
_locks[key] = lock
locks.append(lock)
with ExitStack() as stack:
for lock in locks:
stack.enter_context(lock)
yield
def fingerprint(report: dict, files=(), extra=None) -> str:
metadata = []
for path, name in files:
stat = path.stat()
metadata.append((name, str(path), stat.st_size, stat.st_mtime_ns))
payload = json.dumps([2, report, metadata, extra], sort_keys=True, default=str)
return hashlib.sha256(payload.encode()).hexdigest()
def reusable(path: Path, signature: str, status: str) -> bool:
if status not in TERMINAL or not path.is_file():
return False
try:
return path.with_suffix(path.suffix + ".fingerprint").read_text() == signature
except OSError:
return False
def remember(path: Path, signature: str) -> None:
marker = path.with_suffix(path.suffix + ".fingerprint")
temporary = marker.with_suffix(".tmp")
temporary.write_text(signature, encoding="ascii")
temporary.replace(marker)
def compression(path: Path) -> int:
import zipfile
return (
zipfile.ZIP_STORED
if path.suffix.lower()
in {".mp4", ".mkv", ".webm", ".docx", ".xlsx", ".png", ".jpg", ".jpeg", ".zip"}
else zipfile.ZIP_DEFLATED
)
+406
View File
@@ -0,0 +1,406 @@
"""Independent favorite snapshots dispatched through the existing task services."""
from copy import deepcopy
import asyncio
import hashlib
import json
import time
from typing import Literal
from uuid import NAMESPACE_URL, uuid5
from weakref import WeakValueDictionary
from pydantic import BaseModel, ConfigDict, Field
from apps.admin_console.database.repositories.session_repository import SessionRepository
from apps.admin_console.services.automation_store import AutomationStore, identifier, store
from apps.admin_console.services.execution_config import execution_config
from apps.admin_console.services.model_service import model_service
from apps.admin_console.services.task_import import TaskCase, TaskOptions, execution_title
from artemis.config.exploration import ExplorationConfig
from artemis.exploration.case_prompt import require_resolved_case
from artemis.exploration.store import ExplorationStore
class FavoriteCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
source_kind: Literal["task", "batch", "exploration", "exploration_case"] = "task"
source_id: str = Field(pattern=r"^[A-Za-z0-9_-]{1,100}$")
version: int | None = Field(default=None, ge=1)
name: str | None = Field(default=None, min_length=1, max_length=200)
class FavoriteUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, min_length=1, max_length=200)
task_config: TaskOptions | None = None
case_configs: dict[int, TaskOptions] = Field(default_factory=dict)
class FavoriteRun(FavoriteUpdate):
request_id: str = Field(pattern=r"^[A-Za-z0-9_-]{1,100}$")
def _merge_options(target: dict, updates: dict) -> None:
if isinstance(updates.get("recording"), dict):
updates = {
**updates,
"recording": {**(target.get("recording") or {}), **updates["recording"]},
}
target.update(updates)
class FavoriteService:
def __init__(self, storage: AutomationStore = store):
self.store = storage
self._run_locks: WeakValueDictionary = WeakValueDictionary()
def get(self, key: str) -> dict:
value = self.store.get("favorite", identifier(key))
if value is None:
raise KeyError("收藏不存在")
return value
def _task(self, session_id: str, *, fallback: dict | None = None) -> tuple[dict, list[str]]:
job = self.store.job(session_id) or {}
if snapshot := job.get("execution_config"):
return deepcopy(snapshot), []
row = SessionRepository(self.store.path).get_session_by_id(session_id) or {}
submission = job.get("submission") or fallback or {}
options = {
**(job.get("case") or {}).get("task_config", {}),
**(submission.get("runtime_overrides") or {}),
}
options = {key: value for key, value in options.items() if value is not None}
try:
device = row.get("device_info") or {}
device = json.loads(device) if isinstance(device, str) else device
except (ValueError, TypeError):
device = {}
device = device if isinstance(device, dict) else {}
profile = (
options.get("profile")
or submission.get("profile")
or model_service.resolve_session_profile(row)
or "flash"
)
options["profile"] = profile
serial = (
options.get("device_serial")
or submission.get("device_serial")
or job.get("device_serial")
or device.get("serial")
)
if serial:
options["device_serial"] = serial
if serial == "computer:local" or device.get("execution_target") == "computer":
options["execution_target"] = "computer"
# Historical diagnostics describe the actual model, unlike display metadata
# which may reflect today's defaults. Read only execution model roles.
with self.store.connection() as conn:
rows = conn.execute(
"SELECT payload FROM traces WHERE session_id=? AND type='llm_diagnostic' ORDER BY timestamp DESC",
(session_id,),
).fetchall()
for trace in rows:
try:
data = json.loads(trace[0])
except (ValueError, TypeError):
continue
if str(data.get("role", "")).lower() in {
"operator",
"flashrunner",
"reactiverunner",
"planner",
}:
for key in ("model", "reasoning_effort"):
if data.get(key):
options.setdefault(key, data[key])
if options.get("model"):
break
missing = [
key
for key in (
"model",
"reasoning_effort",
"record_video",
"recording",
"analyze_video",
"request_timeout_seconds",
"max_retries",
"retry_interval_seconds",
"timeout_seconds",
)
if key not in options
]
snapshot = execution_config(
{**submission, "profile": profile, "runtime_overrides": options}
)
return snapshot, missing
def create(self, value: FavoriteCreate) -> dict:
token = f"{value.source_kind}:{value.source_id}:{value.version or ''}"
key = hashlib.sha256(token.encode()).hexdigest()[:32]
if existing := self.store.get("favorite", key):
return existing
snapshot, missing, kind, version = {}, [], value.source_kind, None
source_id = value.source_id
repo = ExplorationStore(self.store)
if kind == "task":
run = repo.run(source_id)
if run and run["kind"] == "exploration":
source_id = run["exploration_id"]
kind = "exploration_case" if repo.case(source_id) else "exploration"
if kind in {"exploration", "exploration_case"}:
session = repo.get(source_id)
options = deepcopy(session["task_config"])
if kind == "exploration":
snapshot = {
"goal": session["goal"],
"config": session["config"],
"task_config": options,
}
name = session["goal"][:120]
else:
case = repo.case(source_id, value.version)
if not case:
raise KeyError("确认用例不存在")
version = case["version"]
validations = [
r
for r in repo.runs(source_id)
if r["kind"] == "validation" and r.get("case_version") == version
]
if validations:
config, missing = self._task(
validations[-1]["id"],
fallback={"runtime_overrides": validations[-1]["task_config"]},
)
else:
options = {
k: v
for k, v in options.items()
if k not in {"profile", "model", "reasoning_effort", "timeout_seconds"}
}
config = execution_config({"profile": "flash", "runtime_overrides": options})
missing = ["首次用例复跑,默认 Flash"]
snapshot = {"goal": require_resolved_case(case), **config}
name = case["name"]
elif kind == "batch":
batch = self.store.get("batch", source_id)
if not batch or batch.get("status") == "deleting":
raise KeyError("批次不存在")
cases, seen = [], set()
for job in self.store.jobs(source_id):
position = job.get("case_position", job["position"])
if position in seen:
continue
seen.add(position)
case = deepcopy(job["case"])
config, inherited = self._task(
job["session_id"],
fallback={
"profile": batch["profile"],
"device_serial": batch.get("device_serial"),
"runtime_overrides": {
"timeout_seconds": batch.get("timeout_seconds"),
**case.get("task_config", {}),
},
},
)
case["task_config"] = config["runtime_overrides"]
cases.append(TaskCase.model_validate(case).model_dump(exclude_none=True))
missing.extend(inherited)
if not cases:
raise ValueError("批次没有可收藏的用例")
snapshot = {
key: batch[key]
for key in (
"name",
"formats",
"profile",
"device_serial",
"timeout_seconds",
"stop_on_failure",
"repeat_count",
)
if key in batch
}
snapshot["cases"] = cases
name = batch["name"]
else:
row = SessionRepository(self.store.path).get_session_by_id(source_id) or {}
job = self.store.job(source_id) or {}
goal = (
(job.get("case") or {}).get("goal")
or row.get("initial_goal")
or (job.get("submission") or {}).get("goal")
)
if not goal:
raise KeyError("任务不存在或没有可复用的提示词")
config, missing = self._task(source_id)
snapshot = {"goal": goal, **config}
name = execution_title(job) or goal[:120]
favorite = {
"id": key,
"name": (value.name or name).strip(),
"kind": kind,
"source_kind": value.source_kind,
"source_id": value.source_id,
"case_version": version,
"snapshot": snapshot,
"inherited_fields": sorted(set(missing)),
"created_at": time.time(),
"updated_at": time.time(),
}
if not favorite["name"]:
raise ValueError("收藏名称不能为空")
return self.store.put("favorite", key, favorite)
def updated(self, favorite: dict, value: FavoriteUpdate) -> dict:
result = deepcopy(favorite)
if value.name is not None:
if not value.name.strip():
raise ValueError("收藏名称不能为空")
result["name"] = value.name.strip()
snapshot = result["snapshot"]
options = value.task_config.model_dump(exclude_none=True) if value.task_config else {}
if result["kind"] == "batch":
for index, case in enumerate(snapshot["cases"]):
if override := value.case_configs.get(index):
_merge_options(case["task_config"], override.model_dump(exclude_none=True))
_merge_options(case["task_config"], options)
if set(value.case_configs) - set(range(len(snapshot["cases"]))):
raise ValueError("批次用例下标不存在")
if options.get("device_serial"):
snapshot["device_serial"] = options["device_serial"]
elif result["kind"] == "exploration":
config = snapshot["config"]
config.update(
{
k: options[k]
for k in ("model", "reasoning_effort", "timeout_seconds")
if k in options
}
)
snapshot["config"] = ExplorationConfig.model_validate(config).model_dump()
_merge_options(snapshot["task_config"], options)
else:
_merge_options(snapshot["runtime_overrides"], options)
return result
def update(self, key: str, value: FavoriteUpdate) -> dict:
favorite = self.updated(self.get(key), value)
favorite["updated_at"] = time.time()
return self.store.put("favorite", key, favorite)
def delete(self, key: str) -> None:
with self.store.connection() as conn:
conn.execute(
"DELETE FROM automation_records WHERE kind='favorite' AND id=?", (identifier(key),)
)
conn.commit()
async def run(self, key: str, value: FavoriteRun) -> dict:
lock = self._run_locks.setdefault(value.request_id, asyncio.Lock())
async with lock:
return await self._run(key, value)
async def _run(self, key: str, value: FavoriteRun) -> dict:
key = identifier(key)
request = {"favorite_id": key, **value.model_dump(exclude_none=True)}
signature = hashlib.sha256(json.dumps(request, sort_keys=True).encode()).hexdigest()
# Reserve a durable snapshot before admission. Retries survive restarts,
# edits/deletions of the favorite, and lost HTTP responses.
with self.store.connection() as conn:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind='favorite_run' AND id=?",
(value.request_id,),
).fetchone()
record = json.loads(row[0]) if row else None
if record and record["signature"] != signature:
raise ValueError("request_id 已用于其他内容")
if record and record.get("result"):
return record["result"]
if record is None:
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind='favorite' AND id=?", (key,)
).fetchone()
if row is None:
raise KeyError("收藏不存在")
record = {
"signature": signature,
"favorite": self.updated(json.loads(row[0]), value),
}
conn.execute(
"INSERT INTO automation_records VALUES (?,?,?,?)",
(
"favorite_run",
value.request_id,
json.dumps(record, ensure_ascii=False),
time.time(),
),
)
conn.commit()
favorite = record["favorite"]
snapshot = favorite["snapshot"]
run_id = str(uuid5(NAMESPACE_URL, "artemis-favorite:" + value.request_id))
if favorite["kind"] == "batch":
from apps.admin_console.services.automation_service import create_batch, batch_action
batch = create_batch(
{**snapshot, "name": favorite["name"], "request_id": "favorite-" + run_id}
)
if batch["status"] == "draft":
batch = batch_action(batch["id"], "resume")
result = {"kind": "batch", "batch_id": batch["id"], "status": batch["status"]}
elif favorite["kind"] == "exploration":
from apps.admin_console.services.exploration_service import exploration_service
from artemis.exploration.models import CreateExploration
session = exploration_service.create(CreateExploration(request_id=run_id, **snapshot))
admission = await exploration_service.start(session["id"], run_id)
run = admission.get("run", admission)
rejection = admission.get("admission", {})
outcome = run.get("outcome") or {}
if (
rejection.get("status") in {"error", "rejected"}
or outcome.get("status") == "rejected"
):
raise ValueError(
rejection.get("error")
or rejection.get("message")
or outcome.get("error")
or "探索未入队"
)
result = {
"kind": "exploration",
"session_id": run["id"],
"exploration_id": session["id"],
"admission": admission,
}
else:
from apps.admin_console.routers.tasks import run_task
from apps.admin_console.schemas.task_schema import RunRequest
admission = await run_task(
RunRequest(
goal=snapshot["goal"],
profile=snapshot["runtime_overrides"].get("profile", "flash"),
runtime_overrides=TaskOptions.model_validate(snapshot["runtime_overrides"]),
session_id=run_id,
ingress="favorite",
expected_output=snapshot.get("expected_output"),
enable_outputter=snapshot.get("enable_outputter"),
app_path=snapshot.get("app_path"),
)
)
if not admission.get("tasks"):
raise ValueError(admission.get("error") or admission.get("message") or "任务未入队")
result = {"kind": "task", "session_id": run_id, "admission": admission}
record["result"] = result
self.store.put("favorite_run", value.request_id, record)
return result
favorites = FavoriteService()
+91 -34
View File
@@ -5,8 +5,14 @@ from typing import Callable, cast
import json import json
from pathlib import Path from pathlib import Path
import re import re
import threading
import zipfile import zipfile
from apps.admin_console.services.export_cache import (
compression,
export_lock,
fingerprint,
remember,
reusable,
)
from apps.admin_console.services.request_diagnostics import diagnostics_json from apps.admin_console.services.request_diagnostics import diagnostics_json
from docx import Document from docx import Document
@@ -28,7 +34,7 @@ from artemis.runtime.case_result import RESULT_LABELS, resolve_case_result
from apps.admin_console.services.case_review import reviewed_result from apps.admin_console.services.case_review import reviewed_result
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports" EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
EXPORT_LOCK = threading.RLock() BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-2"
STATUS = { STATUS = {
"completed": "执行完成", "completed": "执行完成",
"success": "执行完成", "success": "执行完成",
@@ -741,9 +747,12 @@ def excel_report(report: dict, path: Path):
wb.save(path) wb.save(path)
def export_session(session_id: str, formats: list[str] | None = None) -> list[Path]: def export_session(
with EXPORT_LOCK: session_id: str, formats: list[str] | None = None, *, report: dict | None = None
report = session_report(session_id) ) -> list[Path]:
with export_lock("session:" + identifier(session_id)):
report = report if report is not None else session_report(session_id)
signature = fingerprint(report, session_screenshots(report))
directory = EXPORT_ROOT / "sessions" / identifier(session_id) directory = EXPORT_ROOT / "sessions" / identifier(session_id)
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
outputs = [] outputs = []
@@ -751,9 +760,13 @@ def export_session(session_id: str, formats: list[str] | None = None) -> list[Pa
if fmt not in {"docx", "xlsx"}: if fmt not in {"docx", "xlsx"}:
raise ValueError("不支持的报告格式") raise ValueError("不支持的报告格式")
path = directory / f"任务报告.{fmt}" path = directory / f"任务报告.{fmt}"
if reusable(path, signature, report["status"]):
outputs.append(path)
continue
temporary = directory / f"生成中.{fmt}" temporary = directory / f"生成中.{fmt}"
(word_report if fmt == "docx" else excel_report)(report, temporary) (word_report if fmt == "docx" else excel_report)(report, temporary)
temporary.replace(path) temporary.replace(path)
remember(path, signature)
outputs.append(path) outputs.append(path)
return outputs return outputs
@@ -845,13 +858,19 @@ def session_artifacts(report: dict) -> list[tuple[Path, str]]:
] ]
def export_session_media(session_id: str, kind: str) -> Path: def session_download(session_id: str, kind: str) -> tuple[Path, str]:
with export_lock("session:" + identifier(session_id)):
report = session_report(session_id)
return export_session_media(session_id, kind, report=report), archive_case_name(report)
def export_session_media(session_id: str, kind: str, *, report: dict | None = None) -> Path:
from apps.admin_console.services.evidence_service import export_index, plugin_files from apps.admin_console.services.evidence_service import export_index, plugin_files
if kind not in {"screenshots", "video", "all"}: if kind not in {"screenshots", "video", "all"}:
raise ValueError("不支持的下载类型") raise ValueError("不支持的下载类型")
with EXPORT_LOCK: with export_lock("session:" + identifier(session_id)):
report = session_report(session_id) report = report if report is not None else session_report(session_id)
target_dir = EXPORT_ROOT / "sessions" / identifier(session_id) target_dir = EXPORT_ROOT / "sessions" / identifier(session_id)
target_dir.mkdir(parents=True, exist_ok=True) target_dir.mkdir(parents=True, exist_ok=True)
images = session_screenshots(report) if kind in {"screenshots", "all"} else [] images = session_screenshots(report) if kind in {"screenshots", "all"} else []
@@ -870,42 +889,61 @@ def export_session_media(session_id: str, kind: str) -> Path:
raise ValueError("此任务没有可下载的步骤截图") raise ValueError("此任务没有可下载的步骤截图")
target = target_dir / f"{kind}.zip" target = target_dir / f"{kind}.zip"
temporary = target_dir / f".{kind}.tmp" temporary = target_dir / f".{kind}.tmp"
files = images + videos + artifacts + extras
diagnostics = diagnostics_json(session_id) if kind == "all" else None
diagnostic_state = json.loads(diagnostics) if diagnostics else None
if isinstance(diagnostic_state, dict):
diagnostic_state.pop("collected_at", None)
signature = fingerprint(report, files, diagnostic_state)
if reusable(target, signature, report["status"]):
return target
manifest = { manifest = {
"session_id": session_id, "session_id": session_id,
"name": report["name"], "name": report["name"],
"status": report["status"], "status": report["status"],
"verdict": report["verdict"], "verdict": report["verdict"],
"screenshots": [name for _, name in images], "screenshots": [name for _, name in images],
"videos": ["任务录屏/" + name for _, name in videos] "videos": ["任务录屏/安卓/" + name for _, name in videos]
+ [name for path, name in extras if path.suffix.lower() in {".mp4", ".mkv", ".webm"}], + [name for path, name in extras if path.suffix.lower() in {".mp4", ".mkv", ".webm"}],
"artifacts": [name for _, name in artifacts], "artifacts": [name for _, name in artifacts],
"note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。", "note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
"snapshot": report["status"]
not in {"completed", "success", "failed", "cancelled", "interrupted", "skipped"},
} }
prefix = archive_case_name(report) + "/" if kind == "all" else "" prefix = archive_case_name(report) + "/" if kind == "all" else ""
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive: with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
if kind == "all": if kind == "all":
for path in export_session(session_id, ["docx", "xlsx"]): for path in export_session(session_id, ["docx", "xlsx"], report=report):
archive.write(path, prefix + path.name) archive.write(path, prefix + path.name, compress_type=compression(path))
for source, name in images: for source, name in images:
archive.write(source, f"{prefix}步骤截图/{name}") archive.write(source, f"{prefix}步骤截图/{name}", compress_type=compression(source))
for source, name in videos: for source, name in videos:
archive.write(source, f"{prefix}任务录屏/{name}", compress_type=zipfile.ZIP_STORED) archive.write(
source, f"{prefix}任务录屏/安卓/{name}", compress_type=zipfile.ZIP_STORED
)
for source, name in artifacts: for source, name in artifacts:
archive.write(source, f"{prefix}任务文件/安卓/{name}") archive.write(
source, f"{prefix}任务文件/安卓/{name}", compress_type=compression(source)
)
for source, name in extras: for source, name in extras:
archive.write( archive.write(
source, source,
prefix + name, prefix + name,
compress_type=zipfile.ZIP_STORED compress_type=compression(source),
if source.suffix.lower() in {".mp4", ".mkv", ".webm"}
else zipfile.ZIP_DEFLATED,
) )
if kind == "all": if kind == "all":
archive.writestr(prefix + "模型请求诊断.json", diagnostics_json(session_id)) archive.writestr(prefix + "模型请求诊断.json", diagnostics or "{}")
archive.writestr( archive.writestr(
prefix + "证据索引.json", prefix + "证据索引.json",
json.dumps( json.dumps(
export_index(session_id, report, videos), export_index(
session_id,
report,
videos,
images=images,
artifacts=artifacts,
extras=extras,
),
ensure_ascii=False, ensure_ascii=False,
indent=2, indent=2,
default=str, default=str,
@@ -915,13 +953,18 @@ def export_session_media(session_id: str, kind: str) -> Path:
prefix + "文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2) prefix + "文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2)
) )
temporary.replace(target) temporary.replace(target)
if fingerprint(report, files, diagnostic_state) == signature:
remember(target, signature)
return target return target
def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None = None) -> Path: def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None = None) -> Path:
from apps.admin_console.services.evidence_service import export_index, plugin_files from apps.admin_console.services.evidence_service import export_index, plugin_files
with EXPORT_LOCK: with export_lock(
"batch:" + identifier(batch_id),
*("session:" + job["session_id"] for job in store.jobs(batch_id)),
):
batch = store.get("batch", identifier(batch_id)) batch = store.get("batch", identifier(batch_id))
if not batch: if not batch:
raise ValueError("批次不存在") raise ValueError("批次不存在")
@@ -959,34 +1002,47 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
temporary = directory / "批次报告.tmp" temporary = directory / "批次报告.tmp"
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive: with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
for path in outputs: for path in outputs:
archive.write(path, path.name) archive.write(path, path.name, compress_type=compression(path))
for number, report in enumerate(records, 1): for number, report in enumerate(records, 1):
if progress: if progress:
progress(number - 1, len(records), f"正在打包任务 {number}/{len(records)}") progress(number - 1, len(records), f"正在打包任务 {number}/{len(records)}")
prefix = f"任务报告/{number:04d}_{archive_case_name(report)}" prefix = f"任务报告/{number:04d}_{archive_case_name(report)}"
for path in export_session(report["id"], formats): images = session_screenshots(report)
archive.write(path, f"{prefix}/{path.name}") videos = session_videos(report)
for source, name in session_screenshots(report): artifacts = session_artifacts(report)
archive.write(source, f"{prefix}/步骤截图/{name}") extras = plugin_files(report["id"])
for source, name in session_videos(report): for path in export_session(report["id"], formats, report=report):
archive.write(path, f"{prefix}/{path.name}", compress_type=compression(path))
for source, name in images:
archive.write( archive.write(
source, f"{prefix}/任务录屏/{name}", compress_type=zipfile.ZIP_STORED source, f"{prefix}/步骤截图/{name}", compress_type=compression(source)
) )
for source, name in session_artifacts(report): for source, name in videos:
archive.write(source, f"{prefix}/任务文件/安卓/{name}") archive.write(
for source, name in plugin_files(report["id"]): source, f"{prefix}/任务录屏/安卓/{name}", compress_type=zipfile.ZIP_STORED
)
for source, name in artifacts:
archive.write(
source, f"{prefix}/任务文件/安卓/{name}", compress_type=compression(source)
)
for source, name in extras:
archive.write( archive.write(
source, source,
f"{prefix}/{name}", f"{prefix}/{name}",
compress_type=zipfile.ZIP_STORED compress_type=compression(source),
if source.suffix.lower() in {".mp4", ".mkv", ".webm"}
else zipfile.ZIP_DEFLATED,
) )
archive.writestr(f"{prefix}/模型请求诊断.json", diagnostics_json(report["id"])) archive.writestr(f"{prefix}/模型请求诊断.json", diagnostics_json(report["id"]))
archive.writestr( archive.writestr(
f"{prefix}/证据索引.json", f"{prefix}/证据索引.json",
json.dumps( json.dumps(
export_index(report["id"], report, session_videos(report)), export_index(
report["id"],
report,
videos,
images=images,
artifacts=artifacts,
extras=extras,
),
ensure_ascii=False, ensure_ascii=False,
indent=2, indent=2,
default=str, default=str,
@@ -997,4 +1053,5 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
if progress: if progress:
progress(len(records), len(records), "正在完成压缩包") progress(len(records), len(records), "正在完成压缩包")
temporary.replace(target) temporary.replace(target)
remember(target, BATCH_ARCHIVE_SIGNATURE)
return target return target
@@ -1033,6 +1033,9 @@ class TaskQueueService:
if not isinstance(goal, str) or not goal.strip(): if not isinstance(goal, str) or not goal.strip():
raise ValueError("Queued task must contain a non-empty string goal.") raise ValueError("Queued task must contain a non-empty string goal.")
cls._begin_task_run(task_item, run_key, sess_id, goal, profile) cls._begin_task_run(task_item, run_key, sess_id, goal, profile)
from apps.admin_console.services.execution_config import persist_execution_config
await asyncio.to_thread(persist_execution_config, task_item)
target = cls._task_target(task_item) target = cls._task_target(task_item)
cmd, env = cls._build_worker_invocation( cmd, env = cls._build_worker_invocation(
@@ -1342,6 +1345,21 @@ class TaskQueueService:
device_serial = await device_pool.select_device_async() device_serial = await device_pool.select_device_async()
except Exception: except Exception:
device_serial = None device_serial = None
from apps.admin_console.services.execution_config import execution_config
config_snapshot = execution_config(
{
"profile": profile,
"device_serial": device_serial,
"expected_output": expected_output,
"enable_outputter": enable_outputter,
"verification_level": verification_level,
"explorer_mode": explorer_mode,
"locked_app_package": locked_app_package,
"app_path": app_path,
"runtime_overrides": runtime_overrides,
}
)
for i, goal in enumerate(goals): for i, goal in enumerate(goals):
task_item = cls._create_queue_item( task_item = cls._create_queue_item(
goal, goal,
@@ -1362,6 +1380,7 @@ class TaskQueueService:
) )
if runtime_overrides: if runtime_overrides:
task_item["runtime_overrides"] = runtime_overrides task_item["runtime_overrides"] = runtime_overrides
task_item["execution_config"] = config_snapshot
if pinned: if pinned:
try: try:
task_item["plugin_resources"] = PluginStore().reserve( task_item["plugin_resources"] = PluginStore().reserve(
@@ -14,7 +14,7 @@
<section><h3>当前任务报告</h3> <section><h3>当前任务报告</h3>
@if (report(); as r) { @if (report(); as r) {
<div class="report-overview"> <div class="report-overview">
<strong class="report-title">{{r.name}}</strong> <div class="report-heading"><app-favorite-button [sourceId]="r.id" /><strong class="report-title">{{r.name}}</strong></div>
<div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></div> <div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></div>
@if(r.test_result; as result){<app-test-result-card [result]="result" [sessionId]="r.id" [executionStatus]="r.status" (reviewSaved)="reviewSaved($event)" />} @if(r.test_result; as result){<app-test-result-card [result]="result" [sessionId]="r.id" [executionStatus]="r.status" (reviewSaved)="reviewSaved($event)" />}
@if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> } @if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> }
@@ -76,6 +76,7 @@
<section><h3>批次记录</h3> <section><h3>批次记录</h3>
@for(b of batches(); track b.id){ @for(b of batches(); track b.id){
<div class="batch-record"> <div class="batch-record">
<app-favorite-button sourceKind="batch" [sourceId]="b.id" />
<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}} · {{b.repeat_count || 1}} 轮@if(b.total_executions){ · {{b.total_executions}} 次执行}</span></button> <button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}} · {{b.repeat_count || 1}} 轮@if(b.total_executions){ · {{b.total_executions}} 次执行}</span></button>
<button class="delete-batch" type="button" [disabled]="busy() || b.status === 'running'" (click)="deleteBatch(b)" [attr.aria-label]="'删除批次:' + b.name" [title]="b.status === 'running' ? '执行中的批次不能删除' : '删除批次及其任务、报告和文件'"><span class="material-symbols-outlined">delete_outline</span></button> <button class="delete-batch" type="button" [disabled]="busy() || b.status === 'running'" (click)="deleteBatch(b)" [attr.aria-label]="'删除批次:' + b.name" [title]="b.status === 'running' ? '执行中的批次不能删除' : '删除批次及其任务、报告和文件'"><span class="material-symbols-outlined">delete_outline</span></button>
</div> </div>
@@ -106,6 +107,6 @@
</div> </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" [class.selected]="agent.currentSessionId() === j.session_id"><button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button><span>{{label(j.status)}}</span><app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />@if(j.error){<small>{{j.error}}</small>}</div>} @for(j of b.jobs; track j.session_id){<div class="job" [class.selected]="agent.currentSessionId() === j.session_id"><app-favorite-button [sourceId]="j.session_id" /><button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button><span>{{label(j.status)}}</span><app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />@if(j.error){<small>{{j.error}}</small>}</div>}
</section>} </section>}
} }
@@ -26,7 +26,7 @@ input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:
.case-goal{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;white-space:pre-wrap;margin:5px 0;font-size:12px;} .case-goal{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;white-space:pre-wrap;margin:5px 0;font-size:12px;}
.execution-summary{padding:9px 12px;border-radius:8px;background:#eff6ff;color:#1d4ed8;font-weight:500;} .execution-summary{padding:9px 12px;border-radius:8px;background:#eff6ff;color:#1d4ed8;font-weight:500;}
.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{position:relative;display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record>app-favorite-button{position:absolute;left:9px;top:9px;z-index:1}.batch-record .record{padding-left:44px;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{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-heading{display:flex;align-items:center;gap:8px;}
.archive-progress p{margin:7px 0 0;font-size:12px;color:#475569;} .archive-progress p{margin:7px 0 0;font-size:12px;color:#475569;}
@@ -41,6 +41,7 @@ input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:
.task-guidance{margin-top:16px;padding:14px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;} .task-guidance{margin-top:16px;padding:14px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;}
.task-guidance label{margin:8px 0 4px;} .task-guidance label{margin:8px 0 4px;}
.report-overview{padding:14px;border:1px solid #dbeafe;border-radius:10px;background:#f8fbff;} .report-overview{padding:14px;border:1px solid #dbeafe;border-radius:10px;background:#f8fbff;}
.report-heading{display:flex;align-items:flex-start;gap:8px;}.report-heading .report-title{flex:1;min-width:0;}
.report-title{display:block;font-size:14px;line-height:1.5;color:#0f172a;overflow-wrap:anywhere;} .report-title{display:block;font-size:14px;line-height:1.5;color:#0f172a;overflow-wrap:anywhere;}
.report-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px;span{padding:3px 8px;border:1px solid #bfdbfe;border-radius:20px;background:#eff6ff;color:#1e40af;font-size:12px;&.failed{background:#fff1f2;border-color:#fecdd3;color:#be123c;}}} .report-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px;span{padding:3px 8px;border:1px solid #bfdbfe;border-radius:20px;background:#eff6ff;color:#1e40af;font-size:12px;&.failed{background:#fff1f2;border-color:#fecdd3;color:#be123c;}}}
.report-error{margin-top:11px;padding:10px 12px;background:#fff1f2;border-left:3px solid #e11d48;border-radius:6px;color:#9f1239;strong{font-size:12px;}p{margin:4px 0 0;color:#9f1239;white-space:pre-wrap;}} .report-error{margin-top:11px;padding:10px 12px;background:#fff1f2;border-left:3px solid #e11d48;border-radius:6px;color:#9f1239;strong{font-size:12px;}p{margin:4px 0 0;color:#9f1239;white-space:pre-wrap;}}
@@ -129,7 +129,7 @@ describe('AutomationPanelComponent batch deletion', () => {
{session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}} {session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}}
]}); ]});
fixture.detectChanges(); fixture.detectChanges();
const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job button')]; const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job > button')];
jobs[0].click(); jobs[1].click(); jobs[0].click(); jobs[1].click();
expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]); expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]);
expect(component.tab()).toBe('batches'); expect(component.tab()).toBe('batches');
@@ -1,4 +1,5 @@
import { Component, DestroyRef, computed, effect, inject, output, signal } from '@angular/core'; import { Component, DestroyRef, computed, effect, inject, output, signal } from '@angular/core';
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component'; import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -22,7 +23,7 @@ interface ArchiveProgress {
} }
@Component({ @Component({
selector: 'app-automation-panel', standalone: true, imports: [ReportMarkdownComponent, CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent], selector: 'app-automation-panel', standalone: true, imports: [FavoriteButtonComponent, ReportMarkdownComponent, CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent],
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss' templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
}) })
export class AutomationPanelComponent { export class AutomationPanelComponent {
@@ -125,8 +125,11 @@ limitations under the License.
} }
</div> </div>
</div> </div>
<div class="task-goal" title="{{ session.initial_goal }}"> <div class="task-title-row">
{{ session.display_name || session.initial_goal || '未命名任务' }} <app-favorite-button [sourceId]="session.session_id" />
<div class="task-goal" title="{{ session.initial_goal }}">
{{ session.display_name || session.initial_goal || '未命名任务' }}
</div>
</div> </div>
<div class="task-card-footer"> <div class="task-card-footer">
<app-task-id-copy [taskId]="session.session_id" /> <app-task-id-copy [taskId]="session.session_id" />
@@ -559,6 +559,16 @@ $text-muted: #a1a1aa;
} }
} }
.task-title-row {
display: flex;
align-items: flex-start;
gap: 5px;
min-width: 0;
app-favorite-button { margin-top: -5px; }
.task-goal { flex: 1; min-width: 0; }
}
.task-goal { .task-goal {
font-size: 12.5px; font-size: 12.5px;
font-weight: 500; font-weight: 500;
@@ -1,4 +1,5 @@
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component'; import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component'; import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
import { TaskHistoryComponent } from '../task-history/task-history.component'; import { TaskHistoryComponent } from '../task-history/task-history.component';
import { UiLabelPipe } from '../../utils/ui-label.pipe'; import { UiLabelPipe } from '../../utils/ui-label.pipe';
@@ -34,7 +35,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
@Component({ @Component({
selector: 'app-chat-interface', selector: 'app-chat-interface',
standalone: true, standalone: true,
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent, TestResultBadgeComponent, TaskModeBadgeComponent, TaskBatchFilterComponent], imports: [FavoriteButtonComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent, TestResultBadgeComponent, TaskModeBadgeComponent, TaskBatchFilterComponent],
templateUrl: './chat-interface.component.html', templateUrl: './chat-interface.component.html',
styleUrl: './chat-interface.component.scss', styleUrl: './chat-interface.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -2,7 +2,7 @@
<app-agent-stream [conversationMessages]="messages()" [demonstrations]="s.demonstrations" [pendingQuestion]="s.pending_question || ''" (useRecommendation)="text = $event; send()"> <app-agent-stream [conversationMessages]="messages()" [demonstrations]="s.demonstrations" [pendingQuestion]="s.pending_question || ''" (useRecommendation)="text = $event; send()">
<section task-summary class="session-summary"> <section task-summary class="session-summary">
<div class="row"><strong><span class="material-symbols-outlined">explore</span> {{activeValidation() ? '用例复跑中' : label(s.status)}}</strong><small>{{s.config.model}} / {{s.config.reasoning_effort}} · 续接 {{s.continuation_count}} 次</small></div> <div class="row"><strong><span class="material-symbols-outlined">explore</span> {{activeValidation() ? '用例复跑中' : label(s.status)}}</strong><small>{{s.config.model}} / {{s.config.reasoning_effort}} · 续接 {{s.continuation_count}} 次</small></div>
<p>{{s.goal}}</p> <div class="exploration-goal"><app-favorite-button sourceKind="exploration" [sourceId]="s.id" label="收藏为重新探索" /><p>{{s.goal}}</p></div>
@if(s.pause_reason === 'execution_budget'){<p class="feedback">本段执行到时,进度已保存。可继续探索,不计为测试失败。</p>} @if(s.pause_reason === 'execution_budget'){<p class="feedback">本段执行到时,进度已保存。可继续探索,不计为测试失败。</p>}
@if(s.pause_reason === 'device_observation'){<p class="feedback" role="status">设备画面暂时不可用,探索已暂停。指导已保留,请确认设备恢复响应后点击“继续探索”。</p>} @if(s.pause_reason === 'device_observation'){<p class="feedback" role="status">设备画面暂时不可用,探索已暂停。指导已保留,请确认设备恢复响应后点击“继续探索”。</p>}
<details class="execution-history"><summary>执行记录({{s.runs.length}}) · 当前展示所选轮次的操作步骤</summary>@for(run of s.runs; track run.id){<div class="run"><button class="run-link" (click)="agent.selectSession(run.id,true)">{{run.kind === 'validation' ? '用例 v' + run.case_version + ' 复跑' : '探索执行'}} · {{label(run.status)}}{{agent.currentSessionId() === run.id ? ' · 当前查看' : ''}}</button><app-test-result-badge [result]="run.test_result" [executionStatus]="run.status" [compact]="true" /></div>}</details> <details class="execution-history"><summary>执行记录({{s.runs.length}}) · 当前展示所选轮次的操作步骤</summary>@for(run of s.runs; track run.id){<div class="run"><button class="run-link" (click)="agent.selectSession(run.id,true)">{{run.kind === 'validation' ? '用例 v' + run.case_version + ' 复跑' : '探索执行'}} · {{label(run.status)}}{{agent.currentSessionId() === run.id ? ' · 当前查看' : ''}}</button><app-test-result-badge [result]="run.test_result" [executionStatus]="run.status" [compact]="true" /></div>}</details>
@@ -12,7 +12,7 @@
<section class="case-editor" aria-label="可复用用例"><div class="row"><strong class="case-title"><span class="material-symbols-outlined">task_alt</span>{{s.case ? '探索成果 · 可复用用例' : '本轮探索已结束'}}</strong><button (click)="generate()" [disabled]="busy() || !!s.active_run_id || generating() || dirty">{{generating() ? '正在提炼…' : s.case ? '重新提炼' : '提炼用例'}}</button></div> <section class="case-editor" aria-label="可复用用例"><div class="row"><strong class="case-title"><span class="material-symbols-outlined">task_alt</span>{{s.case ? '探索成果 · 可复用用例' : '本轮探索已结束'}}</strong><button (click)="generate()" [disabled]="busy() || !!s.active_run_id || generating() || dirty">{{generating() ? '正在提炼…' : s.case ? '重新提炼' : '提炼用例'}}</button></div>
@if(!s.case){<p class="muted">探索验收通过后自动生成;也可以先编辑并保存草稿。</p><button (click)="editorOpen.set(true)">编辑草稿</button>} @if(!s.case){<p class="muted">探索验收通过后自动生成;也可以先编辑并保存草稿。</p><button (click)="editorOpen.set(true)">编辑草稿</button>}
@if(s.generations[0]?.status === 'failed'){<p class="error">上次提炼未完成,执行结果保留。可重试提炼。</p>} @if(s.generations[0]?.status === 'failed'){<p class="error">上次提炼未完成,执行结果保留。可重试提炼。</p>}
@if(s.case){<div class="case-compact"><h3>{{s.case.name}} <small>v{{s.case.version}}</small></h3><p class="muted">已整理为一个可复用用例。你可以查看修改,或交给 Flash / Pro 独立复跑。</p><div class="actions"><button class="primary" (click)="copyCase()"><span class="material-symbols-outlined">content_copy</span>复制任务提示词</button><button (click)="editorOpen.set(!editorOpen())">{{editorOpen() ? '收起编辑' : '查看 / 编辑'}}</button><button (click)="rerunOpen.set(!rerunOpen())">复跑 / 批次</button></div></div>} @if(s.case){<div class="case-compact"><h3><app-favorite-button sourceKind="exploration_case" [sourceId]="s.id" [version]="s.case.version" label="收藏此版本用例" /><span>{{s.case.name}} <small>v{{s.case.version}}</small></span></h3><p class="muted">已整理为一个可复用用例。你可以查看修改,或交给 Flash / Pro 独立复跑。</p><div class="actions"><button class="primary" (click)="copyCase()"><span class="material-symbols-outlined">content_copy</span>复制任务提示词</button><button (click)="editorOpen.set(!editorOpen())">{{editorOpen() ? '收起编辑' : '查看 / 编辑'}}</button><button (click)="rerunOpen.set(!rerunOpen())">复跑 / 批次</button></div></div>}
@if(editorOpen()) { @if(editorOpen()) {
@if(s.case){<label>查看版本<select [ngModel]="editVersion" (ngModelChange)="selectVersion($event)" [disabled]="dirty">@for(version of versions(); track version){<option [value]="version">v{{version}}</option>}</select></label>} @if(s.case){<label>查看版本<select [ngModel]="editVersion" (ngModelChange)="selectVersion($event)" [disabled]="dirty">@for(version of versions(); track version){<option [value]="version">v{{version}}</option>}</select></label>}
@if(dirty && editVersion !== s.case_version){<p class="error">已有新版本;当前编辑仍保留,请先复制修改内容再加载最新版本。</p>} @if(dirty && editVersion !== s.case_version){<p class="error">已有新版本;当前编辑仍保留,请先复制修改内容再加载最新版本。</p>}
@@ -73,5 +73,5 @@ app-agent-stream {flex:1;min-height:0;display:block}
.case-editor {border-color:#c7d2fe;background:linear-gradient(135deg,#f5f7ff,#fff);padding:20px;border-radius:18px} .case-editor {border-color:#c7d2fe;background:linear-gradient(135deg,#f5f7ff,#fff);padding:20px;border-radius:18px}
.case-title {display:flex;align-items:center;gap:8px;color:#4f46e5;font-size:14px}.case-title .material-symbols-outlined {font-size:22px} .case-title {display:flex;align-items:center;gap:8px;color:#4f46e5;font-size:14px}.case-title .material-symbols-outlined {font-size:22px}
.case-compact {display:block}.case-compact h3 {font-size:18px;line-height:1.5;color:#1e293b;margin:14px 0 6px}.case-compact small {font-size:11px;font-weight:400;background:#e0e7ff;color:#4338ca;padding:3px 6px;border-radius:5px;white-space:nowrap}.case-compact .actions {margin:16px 0 0}.case-compact .primary {background:#4f46e5;border-color:#4f46e5} .case-compact {display:block}.case-compact h3 {font-size:18px;line-height:1.5;color:#1e293b;margin:14px 0 6px}.case-compact small {font-size:11px;font-weight:400;background:#e0e7ff;color:#4338ca;padding:3px 6px;border-radius:5px;white-space:nowrap}.case-compact .actions {margin:16px 0 0}.case-compact .primary {background:#4f46e5;border-color:#4f46e5}
.session-summary {padding:12px 16px;border-radius:14px}.session-summary p {color:#64748b;font-size:12px} .session-summary {padding:12px 16px;border-radius:14px}.exploration-goal{display:flex;align-items:flex-start;gap:6px}.session-summary .exploration-goal p{flex:1;min-width:0;margin:4px 0}.case-compact h3{display:flex;align-items:flex-start;gap:6px}.case-compact h3>span{min-width:0;overflow-wrap:anywhere}.session-summary p {color:#64748b;font-size:12px}
@media(max-width:600px) {.composer {margin:0 12px 12px;padding:12px}.send-hint {display:none}.send-button {margin-left:auto}.question-banner {max-height:160px;overflow:auto}.case-editor {padding:14px}} @media(max-width:600px) {.composer {margin:0 12px 12px;padding:12px}.send-hint {display:none}.send-button {margin-left:auto}.question-banner {max-height:160px;overflow:auto}.case-editor {padding:14px}}
@@ -1,4 +1,5 @@
import { Component, ChangeDetectionStrategy, DestroyRef, effect, inject, signal, untracked, input } from '@angular/core'; import { Component, ChangeDetectionStrategy, DestroyRef, effect, inject, signal, untracked, input } from '@angular/core';
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { AgentStreamComponent } from '../agent-stream/agent-stream.component'; import { AgentStreamComponent } from '../agent-stream/agent-stream.component';
import { ExplorationService, ExplorationSession, ExplorationMessage, Demonstration } from '../../services/exploration.service'; import { ExplorationService, ExplorationSession, ExplorationMessage, Demonstration } from '../../services/exploration.service';
@@ -7,7 +8,7 @@ import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge
@Component({ @Component({
selector: 'app-exploration-panel', standalone: true, selector: 'app-exploration-panel', standalone: true,
imports: [FormsModule, AgentStreamComponent, TestResultBadgeComponent], imports: [FormsModule, AgentStreamComponent, TestResultBadgeComponent, FavoriteButtonComponent],
templateUrl: './exploration-panel.component.html', styleUrl: './exploration-panel.component.scss', templateUrl: './exploration-panel.component.html', styleUrl: './exploration-panel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
@@ -0,0 +1,32 @@
import { Component, ChangeDetectionStrategy, computed, inject, input, signal } from '@angular/core';
import { FavoriteKind, FavoritesService } from '../../services/favorites.service';
@Component({
selector: 'app-favorite-button', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush,
template: `<button type="button" [class.saved]="!!favorite()" [disabled]="busy() || !api.loaded() || !sourceId()"
[attr.aria-pressed]="!!favorite()" [attr.aria-label]="favorite() ? '取消收藏' : label()" [title]="favorite() ? '取消收藏' : label()"
(click)="toggle($event)"><span class="material-symbols-outlined" aria-hidden="true">{{favorite() ? 'star' : 'star_outline'}}</span></button>
@if(error()){<span class="feedback" role="alert">{{error()}}</span>}
@if(api.error() && !api.loaded()){<button type="button" class="retry" (click)="$event.stopPropagation(); api.load(true)" title="重试加载收藏">重试</button>}`,
styles: [`:host{display:inline-flex;align-items:center;position:relative;flex-shrink:0}button{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:#64748b;cursor:pointer}button:hover{background:#fef3c7;color:#b45309}button.saved{color:#c47b0b}button.saved span{font-variation-settings:'FILL' 1}button:focus-visible{outline:2px solid #818cf8;outline-offset:2px}button:disabled{opacity:.45;cursor:default}.material-symbols-outlined{font-size:19px}.feedback{position:absolute;left:0;top:30px;min-width:170px;max-width:260px;padding:8px 10px;z-index:20;border:1px solid #fecaca;border-radius:8px;background:#fff7f7;color:#b91c1c;font-size:12px;line-height:1.5;box-shadow:0 4px 16px #0f172a12}.retry{font-size:10px;width:auto}`]
})
export class FavoriteButtonComponent {
api = inject(FavoritesService);
sourceId = input.required<string>();
sourceKind = input<FavoriteKind>('task');
version = input<number>();
label = input('收藏到精选任务');
busy = signal(false); error = signal('');
favorite = computed(() => this.api.items().find(i => i.source_kind === this.sourceKind() && i.source_id === this.sourceId() && (!this.version() || i.case_version === this.version())));
constructor() { void this.api.load(); }
async toggle(event: Event) {
event.stopPropagation(); if (this.busy()) return;
this.busy.set(true); this.error.set('');
try {
const saved = this.favorite();
if (saved) await this.api.remove(saved.id);
else await this.api.create(this.sourceKind(), this.sourceId(), this.version());
} catch(e) { this.error.set((e as any)?.error?.detail || '收藏操作失败,请重试'); }
finally { this.busy.set(false); }
}
}
@@ -0,0 +1,39 @@
<section class="featured" aria-label="精选任务">
<div class="heading"><div><h2><span class="material-symbols-outlined" aria-hidden="true">star</span>精选任务</h2><p>收藏常用流程,沿用原配置一键复跑。</p></div><input type="search" aria-label="搜索精选任务" placeholder="搜索收藏…" [ngModel]="query()" (ngModelChange)="query.set($event)"></div>
<div class="filters" aria-label="精选任务类型">@for(tab of filters; track tab.id){<button type="button" [class.selected]="filter() === tab.id" [attr.aria-pressed]="filter() === tab.id" (click)="filter.set(tab.id)">{{tab.label}}</button>}</div>
@if(error() || api.error()){<p class="error" role="alert">{{error() || api.error()}} @if(api.error()){<button (click)="api.load(true)">重试</button>}</p>}
@if(notice()){<p class="notice" role="status">{{notice()}}</p>}
@if(api.loading() && !api.loaded()){<p class="empty" role="status">正在读取精选任务…</p>}
@else if(!api.items().length && !api.error()){<div class="empty"><span class="material-symbols-outlined" aria-hidden="true">star_outline</span><strong>把常用测试留在这里</strong><p>在任务队列、历史记录或批次旁点击星标,即可收藏。删除原记录不会影响收藏。</p></div>}
@else if(!filtered().length){<p class="empty">没有匹配的精选任务。</p>}
<div class="cards">@for(item of filtered(); track item.id){
<article class="card" [class.expanded]="expanded() === item.id">
<div class="card-heading"><span class="mode" [class.pro]="options(item)['profile'] === 'pro'" [class.explore]="item.kind === 'exploration'">{{mode(item)}}</span></div>
<div class="card-title"><button class="star" type="button" (click)="remove(item)" [disabled]="!!busy()" aria-label="取消收藏" title="取消收藏"><span class="material-symbols-outlined" aria-hidden="true">star</span></button><h3>{{item.name}}</h3></div><p class="model-summary">{{summary(item)}}</p>
@if(item.case_version){<p class="hint">固定用例 v{{item.case_version}}</p>}
@if(item.inherited_fields.length){<p class="hint inherited" title="部分历史配置未记录,收藏时已采用当前默认值:{{item.inherited_fields.join('、')}}">{{item.inherited_fields[0].startsWith('首次') ? item.inherited_fields[0] : '旧任务部分配置缺失,已采用收藏时的默认值'}}</p>}
<div class="card-actions"><button type="button" class="configure" (click)="toggle(item)" [attr.aria-expanded]="expanded() === item.id"><span class="material-symbols-outlined" aria-hidden="true">tune</span>配置<span class="material-symbols-outlined" aria-hidden="true">{{expanded() === item.id ? 'expand_less' : 'expand_more'}}</span></button><button type="button" class="primary" (click)="run(item)" [disabled]="!!busy()"><span class="material-symbols-outlined" aria-hidden="true">play_arrow</span>{{busy() === item.id ? '处理中…' : item.kind === 'exploration' ? '重新探索' : '运行'}}</button></div>
@if(expanded() === item.id){@let d = draft(item);
<div class="configuration"><label>收藏名称<input [(ngModel)]="d.name" maxlength="200" aria-label="收藏名称"></label>
@if(item.kind === 'batch'){
<label class="checkbox"><input type="checkbox" [(ngModel)]="d.uniform">本次统一模型和思考强度</label>
@if(d.uniform){<label>整批模型<input [(ngModel)]="d.model" list="favorite-models" placeholder="输入模型名称"></label><app-setting-slider label="整批思考强度" [(value)]="d.reasoning_effort" [values]="efforts" [labels]="effortLabels" />}
@for(c of item.snapshot.cases; track $index; let i = $index){<details><summary>{{i + 1}}. {{c.name}}</summary>
<label>执行模式<select [(ngModel)]="d.cases[i].profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label>
<label>模型<input [(ngModel)]="d.cases[i].model" list="favorite-models" [disabled]="d.uniform"></label>
<app-setting-slider label="思考强度" [(value)]="d.cases[i].reasoning_effort" [values]="efforts" [labels]="effortLabels" [disabled]="d.uniform" />
@if(!computer(c.task_config)){<label>执行设备<select [(ngModel)]="d.cases[i].device_serial"><option value="">请选择在线设备</option>@if(d.cases[i].device_serial){<option [value]="d.cases[i].device_serial">原选择:{{d.cases[i].device_serial}}</option>}@for(device of system.connectedDevices(); track device.serial){<option [value]="device.serial">{{device.serial}}</option>}</select></label>}
</details>}
}@else{
@if(item.kind !== 'exploration'){<label>执行模式<select [(ngModel)]="d.profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label>}
<label>模型<input [(ngModel)]="d.model" list="favorite-models" aria-label="复跑模型"></label>
<app-setting-slider label="思考强度" [(value)]="d.reasoning_effort" [values]="efforts" [labels]="effortLabels" />
@if(!computer(options(item))){<label>执行设备<select [(ngModel)]="d.device_serial" aria-label="复跑设备"><option value="">请选择在线设备</option>@if(d.device_serial){<option [value]="d.device_serial">原选择:{{d.device_serial}}</option>}@for(device of system.connectedDevices(); track device.serial){<option [value]="device.serial">{{device.serial}}</option>}</select></label>}
}
<p class="hint">调整仅用于本次运行;保存后才会改变收藏的默认配置。</p><button class="save" (click)="save(item)" [disabled]="!!busy() || !d.name.trim() || (d.uniform && !d.model.trim())">保存为默认配置</button>
</div>
}
</article>
}</div>
<datalist id="favorite-models">@for(model of models; track model){<option [value]="model"></option>}</datalist>
</section>
@@ -0,0 +1 @@
:host{display:block;width:100%;color:#334155}.featured{margin-top:26px}.heading{display:flex;align-items:center;justify-content:space-between;gap:16px}h2{display:flex;align-items:center;gap:7px;margin:0;font-size:16px;font-weight:650;color:#1e293b}h2 span{font-size:20px;color:#d29a32}.heading p{font-size:12px;color:#64748b;margin:6px 0 0}.heading>input{max-width:210px;width:40%;border-radius:20px;background:#fff}.filters{display:flex;gap:5px;flex-wrap:wrap;margin:17px 0}.filters button{border:0;padding:6px 13px;border-radius:18px;font-size:12px;background:transparent;color:#64748b}.filters button.selected{background:#e0e7ff;color:#4338ca;font-weight:600}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr));gap:14px;align-items:start}.card{padding:16px;border:1px solid #e2e8f0;border-radius:16px;background:#fff;min-width:0;transition:border-color .15s,box-shadow .15s}.card:hover{border-color:#c7d2fe;box-shadow:0 4px 16px #33415508}.card.expanded{border-color:#a5b4fc}.card-heading{display:flex;align-items:center;justify-content:space-between}.mode{padding:3px 8px;background:#f1f5f9;color:#475569;border-radius:6px;font-size:11px;line-height:16px}.mode.pro{background:#ede9fe;color:#6d28d9}.mode.explore{background:#e0f2fe;color:#0369a1}.star{display:flex;padding:3px;border:0;background:transparent;color:#c58b23}.star span{font-size:19px;font-variation-settings:'FILL' 1}h3{font-size:14px;line-height:1.5;margin:13px 0 7px;overflow-wrap:anywhere;color:#1e293b}.model-summary{font-size:12px;color:#64748b;margin:0;overflow-wrap:anywhere}.card-title{display:flex;align-items:flex-start;gap:6px;margin:13px 0 7px}.card-title h3{flex:1;min-width:0;margin:0}.card-title .star{flex-shrink:0;width:24px;height:24px;align-items:center;justify-content:center;padding:0}.card-actions{display:flex;align-items:center;justify-content:space-between;margin-top:18px;gap:10px}.card-actions button{display:flex;align-items:center;gap:4px;padding:7px 10px;border-radius:8px;font-size:12px;white-space:nowrap}.card-actions span{font-size:16px}.configure{color:#64748b;border:0;background:transparent}.primary{border:1px solid #6366f1;background:#6366f1;color:white}.primary:hover{background:#4f46e5}.configuration{border-top:1px solid #e2e8f0;margin-top:14px;padding-top:14px;display:grid;gap:12px}.configuration label{display:grid;gap:6px;font-size:12px;color:#475569}.configuration .checkbox{display:flex;align-items:center}.checkbox input{width:auto}input,select{box-sizing:border-box;min-width:0;width:100%;padding:8px 10px;border:1px solid #cbd5e1;border-radius:8px;color:#334155;background:white;font:inherit;font-size:12px}option{color:#334155;background:#fff}.hint{font-size:11px;color:#64748b;line-height:1.5;margin:7px 0 0}.inherited{color:#927044}.save{justify-self:start;padding:7px 10px;border:1px solid #cbd5e1;background:#fff;color:#475569;border-radius:8px;font-size:12px}details{border:1px solid #e2e8f0;padding:10px;border-radius:9px}details summary{font-size:12px;cursor:pointer;overflow-wrap:anywhere}details[open]>label,details[open]>app-setting-slider{margin-top:12px;display:block}button{font-family:inherit;cursor:pointer}button:disabled{opacity:.5;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid #818cf8;outline-offset:2px}.empty{display:flex;flex-direction:column;align-items:center;text-align:center;padding:28px 18px;border:1px dashed #cbd5e1;border-radius:16px;color:#64748b;font-size:12px;line-height:1.7}.empty>span{font-size:28px;color:#c5a365;margin-bottom:9px}.empty strong{font-size:14px;color:#475569}.empty p{max-width:440px;margin:8px 0 0}.error,.notice{padding:10px 12px;border-radius:8px;font-size:12px;line-height:1.6}.error{background:#fff1f2;color:#b91c1c}.notice{background:#f0fdf4;color:#166534}@media(max-width:560px){.heading{align-items:flex-start;flex-direction:column}.heading>input{width:100%;max-width:none}.cards{grid-template-columns:1fr}}
@@ -0,0 +1,40 @@
import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
import { signal } from '@angular/core';
import { Router } from '@angular/router';
import { FeaturedTasksComponent } from './featured-tasks.component';
import { FavoritesService, Favorite } from '../../services/favorites.service';
import { AgentService } from '../../services/agent.service';
import { SystemService } from '../../services/system.service';
describe('Featured tasks',()=>{
let api:any, agent:any, system:any, router:any;
const favorite:Favorite={id:'favorite',name:'检查页面',kind:'task',source_kind:'task',source_id:'source',inherited_fields:[],snapshot:{goal:'检查主页',runtime_overrides:{profile:'pro',model:'original-model',reasoning_effort:'high',device_serial:'device'}}};
beforeEach(()=>{
api={items:signal([structuredClone(favorite)]),loaded:signal(true),loading:signal(false),error:signal(''),load:jasmine.createSpy().and.resolveTo(),run:jasmine.createSpy().and.resolveTo({kind:'task',session_id:'new'}),update:jasmine.createSpy().and.resolveTo(favorite),remove:jasmine.createSpy().and.resolveTo()};
agent={fetchSessions:jasmine.createSpy(),selectSession:jasmine.createSpy(),batchFilter:signal(''),activeTab:signal('tasks')};system={connectedDevices:signal([{serial:'device',state:'device'}])};router={navigate:jasmine.createSpy().and.resolveTo(true)};
TestBed.configureTestingModule({providers:[{provide:FavoritesService,useValue:api},{provide:AgentService,useValue:agent},{provide:SystemService,useValue:system},{provide:Router,useValue:router}]});
});
it('runs a card with original settings and keeps configuration folded initially',fakeAsync(()=>{
const fixture=TestBed.createComponent(FeaturedTasksComponent);fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.configuration')).toBeNull();
fixture.nativeElement.querySelector('.primary').click();flushMicrotasks();
expect(api.run).toHaveBeenCalledWith('favorite',{});expect(api.update).not.toHaveBeenCalled();expect(agent.selectSession).toHaveBeenCalledWith('new',true);expect(router.navigate).toHaveBeenCalledWith(['/workspace']);
}));
it('blocks unavailable original devices and opens a device chooser without submitting',fakeAsync(()=>{
system.connectedDevices.set([{serial:'other',state:'device'}]);const fixture=TestBed.createComponent(FeaturedTasksComponent);fixture.detectChanges();fixture.nativeElement.querySelector('.primary').click();flushMicrotasks();fixture.detectChanges();
expect(api.run).not.toHaveBeenCalled();expect(fixture.nativeElement.querySelector('[aria-label="复跑设备"]')).not.toBeNull();expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('原设备不在线');
}));
it('applies model changes only to this run unless defaults are explicitly saved',fakeAsync(()=>{
const fixture=TestBed.createComponent(FeaturedTasksComponent);const c=fixture.componentInstance;c.draft(favorite).model='temporary';void c.run(favorite);flushMicrotasks();
expect(api.run).toHaveBeenCalledWith('favorite',{task_config:{model:'temporary'}});expect(api.update).not.toHaveBeenCalled();expect(favorite.snapshot.runtime_overrides!['model']).toBe('original-model');
void c.save(favorite);flushMicrotasks();expect(api.update).toHaveBeenCalledWith('favorite',{task_config:{model:'temporary'},name:'检查页面'});
}));
it('computer-only favorites need no Android device and can be filtered by name',fakeAsync(()=>{
const pc:Favorite={...structuredClone(favorite),kind:'exploration_case',case_version:1,snapshot:{goal:'检查电脑',runtime_overrides:{profile:'flash',model:'original-model',reasoning_effort:'high',execution_target:'computer'}}};api.items.set([pc]);system.connectedDevices.set([]);
const fixture=TestBed.createComponent(FeaturedTasksComponent),c=fixture.componentInstance;c.query.set('不存在');expect(c.filtered()).toEqual([]);c.query.set('页面');expect(c.filtered().length).toBe(1);void c.run(pc);flushMicrotasks();expect(api.run).toHaveBeenCalled();
}));
it('batch defaults keep each case and repetition count, with explicit uniform override',()=>{
const batch:Favorite={...structuredClone(favorite),kind:'batch',snapshot:{repeat_count:3,cases:[{name:'甲',goal:'检查甲',task_config:{profile:'flash',model:'a',reasoning_effort:'high',device_serial:'device'}},{name:'乙',goal:'检查乙',task_config:{profile:'pro',model:'b',reasoning_effort:'max',device_serial:'device'}}]}};
const fixture=TestBed.createComponent(FeaturedTasksComponent),c=fixture.componentInstance;expect(c.payload(batch)).toEqual({});const d=c.draft(batch);expect(c.payload(batch)).toEqual({case_configs:{}});d.uniform=true;d.model='uniform';d.reasoning_effort='high';expect(c.payload(batch)['task_config']).toEqual({model:'uniform',reasoning_effort:'high'});expect(batch.snapshot.repeat_count).toBe(3);
});
});
@@ -0,0 +1,91 @@
import { Component, ChangeDetectionStrategy, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { FavoritesService, Favorite } from '../../services/favorites.service';
import { AgentService } from '../../services/agent.service';
import { SystemService } from '../../services/system.service';
import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from '../setting-slider/setting-slider.component';
interface ModelDraft {profile: string; model: string; reasoning_effort: string; device_serial: string;}
interface Draft extends ModelDraft {name: string; uniform: boolean; cases: ModelDraft[];}
@Component({
selector: 'app-featured-tasks', standalone: true, imports: [FormsModule, SettingSliderComponent],
templateUrl: './featured-tasks.component.html', styleUrl: './featured-tasks.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FeaturedTasksComponent {
api = inject(FavoritesService); system = inject(SystemService); private agent = inject(AgentService); private router = inject(Router);
query = signal(''); filter = signal('all'); expanded = signal<string | null>(null);
busy = signal<string | null>(null); error = signal(''); notice = signal('');
drafts = new Map<string, Draft>();
readonly efforts = REASONING_VALUES; readonly effortLabels = REASONING_LABELS;
readonly models = ['gpt-6.1-sol','gpt-6-astra','gpt-6-luna','mimo-v2.6-pro','mimo-v2.6-flash'];
readonly filters = [{id:'all',label:'全部'},{id:'flash',label:'Flash'},{id:'pro',label:'Pro'},{id:'exploration',label:'探索'},{id:'batch',label:'批量'}];
filtered = computed(() => this.api.items().filter(item => {
const filter = this.filter(), query = this.query().trim().toLowerCase();
return (filter === 'all' || (filter === 'batch' ? item.kind === 'batch' : filter === 'exploration' ? ['exploration','exploration_case'].includes(item.kind) : item.kind !== 'batch' && this.options(item)['profile'] === filter)) && (!query || `${item.name} ${item.snapshot.goal || ''}`.toLowerCase().includes(query));
}));
constructor() { void this.api.load(); }
options(item: Favorite): Record<string, any> { return item.kind === 'exploration' ? {...item.snapshot.task_config,...item.snapshot.config,profile:'exploration'} : item.snapshot.runtime_overrides || {}; }
mode(item: Favorite) { return item.kind === 'batch' ? '批量' : item.kind === 'exploration' ? '重新探索' : (this.options(item)['profile'] === 'pro' ? 'Pro' : 'Flash') + (item.kind === 'exploration_case' ? ' · 用例复跑' : ''); }
summary(item: Favorite) { return item.kind === 'batch' ? `${item.snapshot.cases?.length || 0} 个用例 · ${item.snapshot.repeat_count || 1} 轮 · 保留各项配置` : `${this.options(item)['model'] || '当前默认'} / ${this.options(item)['reasoning_effort'] || '当前默认'}`; }
computer(options: Record<string, any>) { return options['execution_target'] === 'computer'; }
modelDraft(options: Record<string, any>): ModelDraft { return {profile:options['profile'] || 'flash', model:options['model'] || '', reasoning_effort:options['reasoning_effort'] || 'default', device_serial:this.computer(options) ? '' : options['device_serial'] || ''}; }
draft(item: Favorite): Draft {
let draft = this.drafts.get(item.id);
if (!draft) { draft = {...this.modelDraft(item.kind === 'batch' ? item.snapshot.cases?.[0]?.task_config || {} : this.options(item)), name:item.name, uniform:false, cases:(item.snapshot.cases || []).map(c => this.modelDraft(c.task_config))}; this.drafts.set(item.id,draft); }
return draft;
}
toggle(item: Favorite) { this.expanded.set(this.expanded() === item.id ? null : item.id); }
private changes(current: ModelDraft, original: Record<string, any>) {
const baseline = this.modelDraft(original), result: Record<string, unknown> = {};
for (const key of ['profile','model','reasoning_effort','device_serial'] as const) {
if (current[key] !== baseline[key] && current[key]) result[key] = current[key];
}
return result;
}
payload(item: Favorite): Record<string, unknown> {
const draft = this.drafts.get(item.id); if (!draft) return {};
if (item.kind === 'batch') {
const case_configs: Record<number, unknown> = {};
draft.cases.forEach((c,i) => { const changes = this.changes(c,item.snapshot.cases![i].task_config); if(Object.keys(changes).length)case_configs[i]=changes; });
return {case_configs, ...(draft.uniform ? {task_config:{model:draft.model,reasoning_effort:draft.reasoning_effort}} : {})};
}
return {task_config:this.changes(draft,this.options(item))};
}
private deviceMissing(item: Favorite) {
const devices = this.system.connectedDevices();
const cases = item.kind === 'batch' ? item.snapshot.cases!.map((c,i) => ({...c.task_config,...(this.payload(item)['case_configs'] as any)?.[i]})) : [{...this.options(item),...(this.payload(item)['task_config'] as any)}];
return cases.some(options => !this.computer(options) && (!options['device_serial'] || !devices.some(d => d.serial === options['device_serial'] && d.state === 'device')));
}
async save(item: Favorite) {
if(!this.validModels(item)){this.error.set('模型名称不能为空');return;}
if(this.busy())return;this.busy.set(item.id);this.error.set('');this.notice.set('');
try { const saved = await this.api.update(item.id,{...this.payload(item),name:this.draft(item).name});this.drafts.delete(item.id);this.draft(saved);this.notice.set('已保存收藏的默认配置'); }
catch(e){this.error.set((e as any)?.error?.detail || '保存失败,请重试');}finally{this.busy.set(null);}
}
async remove(item: Favorite) {
if(this.busy())return;this.busy.set(item.id);this.error.set('');
try{await this.api.remove(item.id);this.drafts.delete(item.id);}catch(e){this.error.set((e as any)?.error?.detail || '取消收藏失败');}finally{this.busy.set(null);}
}
async run(item: Favorite) {
if(this.busy())return;this.error.set('');this.notice.set('');
if(!this.validModels(item)){this.error.set('模型名称不能为空');return;}
if(this.deviceMissing(item)){this.expanded.set(item.id);this.error.set('原设备不在线,请在配置中选择可用设备后运行。');return;}
const draft=this.drafts.get(item.id);if(draft?.uniform && !draft.model.trim()){this.error.set('请填写统一使用的模型');return;}
this.busy.set(item.id);
try{
const result=await this.api.run(item.id,this.payload(item));
this.agent.fetchSessions();
if(result.batch_id){this.agent.batchFilter.set(result.batch_id);this.agent.activeTab.set('tasks');}
else{const id=result.session_id || result.admission?.run?.id;if(id)this.agent.selectSession(id,true);}
await this.router.navigate(['/workspace']);
}catch(e){this.error.set((e as any)?.error?.detail || '运行请求失败;重试将沿用相同请求编号,避免重复执行。');}
finally{this.busy.set(null);}
}
private validModels(item: Favorite) {
const draft=this.drafts.get(item.id);if(!draft)return true;
return item.kind === 'batch' ? draft.uniform ? !!draft.model.trim() : draft.cases.every(c => !!c.model.trim()) : !!draft.model.trim();
}
}
@@ -6,6 +6,7 @@ import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { AgentService } from '../../services/agent.service'; import { AgentService } from '../../services/agent.service';
import { TaskHistoryComponent } from './task-history.component'; import { TaskHistoryComponent } from './task-history.component';
import { FavoritesService } from '../../services/favorites.service';
describe('历史任务检索', () => { describe('历史任务检索', () => {
let http: HttpTestingController; let http: HttpTestingController;
@@ -13,6 +14,7 @@ describe('历史任务检索', () => {
TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting(), TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting(),
{provide: AgentService, useValue: {currentSessionId: signal(null), batchFilter: signal(''), resultRevision: signal(0), selectSession: jasmine.createSpy(), deleteSession: jasmine.createSpy().and.returnValue(of({})), canDeleteSession: AgentService.prototype.canDeleteSession, deletionPrompt: (_id: string, kind: string) => kind === 'exploration' ? '删除探索及全部探索记录,复跑与批次保留' : '删除历史任务'}}]}); {provide: AgentService, useValue: {currentSessionId: signal(null), batchFilter: signal(''), resultRevision: signal(0), selectSession: jasmine.createSpy(), deleteSession: jasmine.createSpy().and.returnValue(of({})), canDeleteSession: AgentService.prototype.canDeleteSession, deletionPrompt: (_id: string, kind: string) => kind === 'exploration' ? '删除探索及全部探索记录,复跑与批次保留' : '删除历史任务'}}]});
http = TestBed.inject(HttpTestingController); http = TestBed.inject(HttpTestingController);
TestBed.inject(FavoritesService).loaded.set(true);
}); });
afterEach(() => http.verify()); afterEach(() => http.verify());
@@ -1,4 +1,5 @@
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component'; import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
import { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-badge.component'; import { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-badge.component';
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component'; import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
import { Component, DestroyRef, effect, inject, signal, untracked } from '@angular/core'; import { Component, DestroyRef, effect, inject, signal, untracked } from '@angular/core';
@@ -8,18 +9,18 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { AgentService } from '../../services/agent.service'; import { AgentService } from '../../services/agent.service';
@Component({ @Component({
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent,TaskModeBadgeComponent], selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent,TaskModeBadgeComponent,FavoriteButtonComponent],
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small><small class="query-status" role="status">{{loading() ? '正在查询…' : ''}}</small></h3> template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small><small class="query-status" role="status">{{loading() ? '正在查询…' : ''}}</small></h3>
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()"> <input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div> <div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
@if(error()){<p role="alert">{{error()}}</p>} @if(error()){<p role="alert">{{error()}}</p>}
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary> @for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" />@if(agent.canDeleteSession(row)){<button class="delete-history" (click)="remove(row.session_id, row.exploration_kind)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>} @for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><div class="history-heading"><app-favorite-button [sourceId]="row.session_id" /><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button></div><app-task-id-copy [taskId]="row.session_id" />@if(agent.canDeleteSession(row)){<button class="delete-history" (click)="remove(row.session_id, row.exploration_kind)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>}
</details>} </details>}
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>} @if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div> <div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
</section>`, </section>`,
styles:[`:host{display:block;font-size:12px;color:inherit}h3{display:flex;align-items:center;gap:6px;font-size:14px;margin:14px 0 8px}.query-status{margin-left:auto;font-size:11px;font-weight:400;white-space:nowrap}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;line-height:14px;border-radius:5px;padding:0 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`] styles:[`:host{display:block;font-size:12px;color:inherit}h3{display:flex;align-items:center;gap:6px;font-size:14px;margin:14px 0 8px}.query-status{margin-left:auto;font-size:11px;font-weight:400;white-space:nowrap}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-heading{display:flex;align-items:flex-start;gap:5px;min-width:0}.history-heading>app-favorite-button{margin-top:-5px}.history-heading>.row{flex:1;min-width:0;width:auto}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;line-height:14px;border-radius:5px;padding:0 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
}) })
export class TaskHistoryComponent { export class TaskHistoryComponent {
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1; agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
@@ -1668,121 +1668,7 @@
</div> </div>
} }
<!-- Recommended Tasks Hub (Clean & Direct) --> <app-featured-tasks />
<div class="smart-suggestions-container">
<!-- Header Bar: Clean Category Tabs & Shuffle -->
<div class="suggestions-header-bar">
<div class="header-left">
<div class="section-label">
<span class="material-symbols-outlined section-icon">auto_awesome</span>
<span class="label-text">推荐任务</span>
</div>
<!-- Filter Tabs -->
<div class="category-tabs-group">
<button
type="button"
class="cat-tab-btn"
[class.active]="selectedCategory() === 'all'"
(click)="setCategory('all')"
>
<span>全部</span>
</button>
<button
type="button"
class="cat-tab-btn cat-flash"
[class.active]="selectedCategory() === 'flash'"
(click)="setCategory('flash')"
>
<span class="material-symbols-outlined tab-mini-icon">bolt</span>
<span>Flash</span>
</button>
<button
type="button"
class="cat-tab-btn cat-pro"
[class.active]="selectedCategory() === 'pro'"
(click)="setCategory('pro')"
>
<span class="material-symbols-outlined tab-mini-icon">psychology</span>
<span>Pro</span>
</button>
<button
type="button"
class="cat-tab-btn"
[class.active]="selectedCategory() === 'cross_app'"
(click)="setCategory('cross_app')"
>
<span class="material-symbols-outlined tab-mini-icon">sync_alt</span>
<span>跨应用</span>
</button>
<button
type="button"
class="cat-tab-btn"
[class.active]="selectedCategory() === 'monitor'"
(click)="setCategory('monitor')"
>
<span class="material-symbols-outlined tab-mini-icon">monitoring</span>
<span>监控</span>
</button>
</div>
</div>
<div class="header-right">
<button type="button" class="btn-shuffle-suggestions" (click)="shuffleSuggestions()" title="查看更多任务">
<span class="material-symbols-outlined shuffle-icon">shuffle</span>
<span>换一批</span>
</button>
</div>
</div>
<!-- Suggestions Grid -->
<div class="suggestions-cards-grid">
@for (item of filteredSuggestions(); track item.id) {
<div
class="suggestion-card"
[class.is-pro-card]="item.profile === 'pro'"
[class.is-flash-card]="item.profile === 'flash'"
(click)="applySuggestion(item)"
>
<!-- Top Row: App Icon + Name + Mode Badge -->
<div class="card-top-row">
<div class="app-icons-cluster">
@for (app of item.apps; track app.name) {
<div class="app-icon-badge" [title]="app.name">
<span class="material-symbols-outlined">{{ app.icon }}</span>
</div>
}
<span class="app-names-label">{{ getAppNamesDisplay(item.apps) }}</span>
</div>
<span class="mode-badge" [class.mode-pro]="item.profile === 'pro'" [class.mode-flash]="item.profile === 'flash'"
[title]="item.profile === 'pro' ? '使用 ARTEMIS Pro:多智能体协作' : '使用 ARTEMIS Flash:快速执行'">
{{ item.profile === 'pro' ? 'Pro' : 'Flash' }}
</span>
</div>
<!-- Middle Content: Title + Description -->
<div class="card-body">
<h4 class="card-title">{{ item.title }}</h4>
<p class="card-desc">{{ item.description }}</p>
</div>
<!-- Bottom Footer Row: Tag & Action -->
<div class="card-footer-row">
<span class="tag-pill">{{ item.tag }}</span>
<div class="use-prompt-action">
<span>使用</span>
<span class="material-symbols-outlined action-arrow">arrow_forward</span>
</div>
</div>
</div>
}
</div>
</div>
</section> </section>
@@ -1,6 +1,7 @@
import { CapabilitySettingsComponent } from '../../components/model-settings/capability-settings.component'; import { CapabilitySettingsComponent } from '../../components/model-settings/capability-settings.component';
import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from '../../components/setting-slider/setting-slider.component'; import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from '../../components/setting-slider/setting-slider.component';
import { ModelSettingsComponent } from '../../components/model-settings/model-settings.component'; import { ModelSettingsComponent } from '../../components/model-settings/model-settings.component';
import { FeaturedTasksComponent } from '../../components/featured-tasks/featured-tasks.component';
/** /**
* Copyright 2026 Google LLC * Copyright 2026 Google LLC
* *
@@ -108,7 +109,7 @@ type AdbGuideTab = 'emulator' | 'usb' | 'wifi' | 'remote';
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
standalone: true, standalone: true,
imports: [FormsModule, ModelSettingsComponent, CapabilitySettingsComponent, TaskPluginsComponent, SettingSliderComponent], imports: [FormsModule, ModelSettingsComponent, CapabilitySettingsComponent, TaskPluginsComponent, SettingSliderComponent, FeaturedTasksComponent],
templateUrl: './home.component.html', templateUrl: './home.component.html',
changeDetection: ChangeDetectionStrategy.Eager, changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './home.component.scss' styleUrl: './home.component.scss'
@@ -0,0 +1,30 @@
import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { FavoritesService } from './favorites.service';
describe('Favorites shared service', () => {
let api: FavoritesService, http: HttpTestingController;
beforeEach(() => { TestBed.configureTestingModule({providers:[provideHttpClient(),provideHttpClientTesting()]});api=TestBed.inject(FavoritesService);http=TestBed.inject(HttpTestingController); });
afterEach(() => http.verify());
it('shares one initial load across all favorite buttons and exposes errors for retry', fakeAsync(() => {
void api.load();void api.load();
http.expectOne('/api/automation/favorites').flush('offline',{status:503,statusText:'Unavailable'});flushMicrotasks();
expect(api.loaded()).toBeFalse();expect(api.error()).toContain('重试');
void api.load(true);http.expectOne('/api/automation/favorites').flush([]);flushMicrotasks();
void api.load();expect(api.loaded()).toBeTrue();http.expectNone('/api/automation/favorites');
}));
it('reuses the submission ID after a lost reply but uses a new ID for the next intentional run',fakeAsync(() => {
void api.run('id',{}).catch(()=>{});const first=http.expectOne('/api/automation/favorites/id/run');const id=first.request.body.request_id;
first.error(new ProgressEvent('network'));flushMicrotasks();
void api.run('id',{});const retry=http.expectOne('/api/automation/favorites/id/run');expect(retry.request.body.request_id).toBe(id);retry.flush({kind:'task',session_id:'new'});flushMicrotasks();
void api.run('id',{});const next=http.expectOne('/api/automation/favorites/id/run');expect(next.request.body.request_id).not.toBe(id);next.flush({kind:'task'});flushMicrotasks();
}));
it('allows a fresh user submission after a definitive admission rejection',fakeAsync(() => {
void api.run('id',{}).catch(()=>{});
const rejected=http.expectOne('/api/automation/favorites/id/run');const id=rejected.request.body.request_id;
rejected.flush({detail:'设备离线'},{status:409,statusText:'Conflict'});flushMicrotasks();
void api.run('id',{});const next=http.expectOne('/api/automation/favorites/id/run');
expect(next.request.body.request_id).not.toBe(id);next.flush({kind:'task'});flushMicrotasks();
}));
});
@@ -0,0 +1,67 @@
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom, timeout } from 'rxjs';
import { UI_READ_TIMEOUT_MS } from '../core/http-timeouts';
export type FavoriteKind = 'task' | 'batch' | 'exploration' | 'exploration_case';
export interface Favorite {
id: string; name: string; kind: FavoriteKind; source_kind: FavoriteKind; source_id: string;
case_version?: number; inherited_fields: string[];
snapshot: { goal?: string; config?: Record<string, any>; task_config?: Record<string, any>;
runtime_overrides?: Record<string, any>; cases?: {name: string; goal: string; task_config: Record<string, any>}[];
repeat_count?: number; device_serial?: string };
}
export interface FavoriteRunResult {kind: string; session_id?: string; batch_id?: string; exploration_id?: string; admission?: any;}
@Injectable({providedIn: 'root'})
export class FavoritesService {
private http = inject(HttpClient);
private base = '/api/automation/favorites';
items = signal<Favorite[]>([]);
loaded = signal(false);
loading = signal(false);
error = signal('');
private pending?: Promise<void>;
private requests = new Map<string, string>();
load(force = false): Promise<void> {
if (this.pending) return this.pending;
if (this.loaded() && !force) return Promise.resolve();
this.loading.set(true); this.error.set('');
this.pending = firstValueFrom(this.http.get<Favorite[]>(this.base).pipe(timeout(UI_READ_TIMEOUT_MS)))
.then(items => { this.items.set(items); this.loaded.set(true); })
.catch(() => { this.error.set('精选任务读取失败,请重试'); })
.finally(() => { this.loading.set(false); this.pending = undefined; });
return this.pending;
}
async create(source_kind: FavoriteKind, source_id: string, version?: number) {
const item = await firstValueFrom(this.http.post<Favorite>(this.base, {source_kind, source_id, version}));
this.items.update(items => [item, ...items.filter(i => i.id !== item.id)]);
return item;
}
async remove(id: string) {
await firstValueFrom(this.http.delete(`${this.base}/${id}`));
this.items.update(items => items.filter(i => i.id !== id));
}
async update(id: string, value: Record<string, unknown>) {
const item = await firstValueFrom(this.http.patch<Favorite>(`${this.base}/${id}`, value));
this.items.update(items => items.map(i => i.id === id ? item : i));
return item;
}
async run(id: string, value: Record<string, unknown>): Promise<FavoriteRunResult> {
const key = JSON.stringify([id, value]);
const request_id = this.requests.get(key) || crypto.randomUUID();
this.requests.set(key, request_id);
try {
const result = await firstValueFrom(this.http.post<FavoriteRunResult>(`${this.base}/${id}/run`, {...value, request_id}));
this.requests.delete(key);
return result;
} catch(e) {
// A definitive rejection permits a new user-initiated submission after
// fixing the cause. Network/5xx errors retain the original request ID.
const status = (e as {status?: number}).status || 0;
if(status >= 400 && status < 500)this.requests.delete(key);
throw e;
}
}
}
+12
View File
@@ -199,6 +199,18 @@ class StorageManager:
) )
""") """)
# Applied to existing databases as well. Report and timeline readers
# look up traces per step/session; never scan the entire history.
conn.execute(
"CREATE INDEX IF NOT EXISTS traces_step_type_time ON traces(step_id,type,timestamp)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS traces_session_type_time ON traces(session_id,type,timestamp)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS steps_session_number ON steps(session_id,step_number)"
)
conn.execute(""" conn.execute("""
CREATE TABLE IF NOT EXISTS failed_outputs ( CREATE TABLE IF NOT EXISTS failed_outputs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
+1
View File
@@ -43,6 +43,7 @@ ARTEMIS_MCP_TOOLS = (
"mobile_config", "mobile_config",
"mobile_task_file", "mobile_task_file",
"mobile_batch", "mobile_batch",
"mobile_favorites",
"mobile_exploration", "mobile_exploration",
"mobile_search_history", "mobile_search_history",
"mobile_export_report", "mobile_export_report",
+1
View File
@@ -35,6 +35,7 @@ Flash 和 Pro 都支持任务内的安卓文件工具 `android_file`:保存原
| `mobile_manage_task` | 单任务状态、提交反查、暂停/继续、指导和停止;删除预览及删除、代录用户人工复验 | | `mobile_manage_task` | 单任务状态、提交反查、暂停/继续、指导和停止;删除预览及删除、代录用户人工复验 |
| `mobile_get_device_state` | 设备截图、界面层级 | | `mobile_get_device_state` | 设备截图、界面层级 |
| `mobile_task_file` | XLSX 模板与导入校验 | | `mobile_task_file` | XLSX 模板与导入校验 |
| `mobile_favorites` | 收藏独立任务/用例/批次模板、调整默认配置、按原配置复跑;run 使用稳定 request_id |
| `mobile_batch` | JSON 用例建批次、开始、暂停、继续、复制失败项、删除非活跃批次 | | `mobile_batch` | JSON 用例建批次、开始、暂停、继续、复制失败项、删除非活跃批次 |
| `mobile_search_history` | 全库搜索、日期/状态/批次筛选和分页 | | `mobile_search_history` | 全库搜索、日期/状态/批次筛选和分页 |
| `mobile_inspect_trace` | 只读步骤、截图和执行证据 | | `mobile_inspect_trace` | 只读步骤、截图和执行证据 |
+11
View File
@@ -79,3 +79,14 @@ Flash 跨端任务由模型通过内部 `update_task_progress` 记录短阶段
批次、任务元数据和报告偏好保存在现有 SQLite 数据库的 `automation_records`、`automation_jobs` 表。全局“清空任务历史”会清除批次与执行元数据;它与上文的单批次删除不同,已导出的文件会保留。 批次、任务元数据和报告偏好保存在现有 SQLite 数据库的 `automation_records`、`automation_jobs` 表。全局“清空任务历史”会清除批次与执行元数据;它与上文的单批次删除不同,已导出的文件会保留。
任务已经产生最终结果,但 SDK 线程或资源清理仍卡住时,默认最多再等待 30 秒后回收工作进程,避免继续占用队列。全局可用 `ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS` 或功能配置的 `worker_cleanup_timeout_seconds` 调整,任务 JSON 同名字段可覆盖。原有结果与截图保留,未完成保存的录屏尽力恢复。 任务已经产生最终结果,但 SDK 线程或资源清理仍卡住时,默认最多再等待 30 秒后回收工作进程,避免继续占用队列。全局可用 `ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS` 或功能配置的 `worker_cleanup_timeout_seconds` 调整,任务 JSON 同名字段可覆盖。原有结果与截图保留,未完成保存的录屏尽力恢复。
## 精选任务
首页的精选任务来自用户收藏。任务队列、历史、报告及批次旁的星标均可收藏;批次中的单项也可单独收藏。收藏保存独立模板,删除原记录不影响复跑。
卡片直接点击“运行”会创建新任务;展开“配置”可调整模型、思考强度、模式或执行设备。不修改时沿用原配置,修改默认仅影响本次,点击“保存为默认配置”才更新收藏。旧任务没有完整记录的配置会明确标出,收藏时采用当前默认值。原设备离线时需要选择在线设备,纯电脑任务无需安卓设备。
批次保留原顺序、循环次数和失败停止策略,默认保留各项配置,也可明确选择统一模型和思考强度。探索用例固定收藏版本,首次无复跑配置时默认 Flash;“收藏为重新探索”会创建新的探索会话并沿用原探索配置。
单任务下载全部资料时,未变化的终态报告和资料包会复用;人工复验、结果和资料变化会重新生成。批次沿用已生成的报告包,人工复验后重新打包;旧目录格式的包首次下载时更新。运行中的下载是当前快照。录像分为“安卓”“电脑”“人工示范”目录,索引路径与实际文件一致。打包优先速度,对已压缩的图片、视频和 Office 文件直接存储,保持原画质。
+2
View File
@@ -100,3 +100,5 @@ Run `uv run artemis mcp --generate-config codex` to output a configuration templ
## 完整 AI 自动化 ## 完整 AI 自动化
工作区工具与网页共用后台服务,覆盖普通与探索任务、批次、历史、诊断、人工复验及报告导出。参见 [接入说明](../docs/ai-automation.md) 和 [Skill](../skills/artemis-use/SKILL.md)。网页启动时同步官方 Skill 与 MCP 接入;已有审批策略及工具白名单保留。更新后重新加载 MCP 才能使用新版工具定义。 工作区工具与网页共用后台服务,覆盖普通与探索任务、批次、历史、诊断、人工复验及报告导出。参见 [接入说明](../docs/ai-automation.md) 和 [Skill](../skills/artemis-use/SKILL.md)。网页启动时同步官方 Skill 与 MCP 接入;已有审批策略及工具白名单保留。更新后重新加载 MCP 才能使用新版工具定义。
* **`mobile_favorites`**: Save independent task/case/batch templates, list or rename favorites, adjust their defaults, and start a new run with original settings. Per-run overrides are temporary; run retries use the same request_id. Source deletion does not delete favorites. See the [batch and favorite workflow](../skills/artemis-use/references/batches.md).
+96
View File
@@ -501,6 +501,102 @@
"type": "object" "type": "object"
} }
}, },
{
"name": "mobile_favorites",
"description": "Save independent favorite templates and run them on the existing queue.\n\nlist is read-only. create snapshots source_id (task session/batch/exploration),\nincluding original execution settings; exploration_case pins a case version.\nAn exploration task automatically favorites its confirmed case when available.\nchanges accepts name, task_config (TaskOptions), case_configs (zero-based case\nindices to TaskOptions). No credentials or arbitrary snapshot input is accepted.\nupdate saves defaults; run applies changes to this execution only and requires\na stable request_id. After an uncertain reply retry the same payload and ID.\nrun creates a new execution, never continues the source or copies its verdict.\nBatch order/repetitions and per-case settings are preserved. Missing historical\nsettings are explicitly marked inherited_fields. Offline devices are not replaced.\ndelete removes only the favorite, never the source history or previous runs.\n",
"inputSchema": {
"properties": {
"action": {
"enum": [
"list",
"create",
"update",
"delete",
"run"
],
"title": "Action",
"type": "string"
},
"favorite_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Favorite Id"
},
"source_kind": {
"default": "task",
"enum": [
"task",
"batch",
"exploration",
"exploration_case"
],
"title": "Source Kind",
"type": "string"
},
"source_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Source Id"
},
"version": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"title": "Version"
},
"request_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Request Id"
},
"changes": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"default": null,
"title": "Changes"
}
},
"required": [
"action"
],
"title": "mobile_favoritesArguments",
"type": "object"
}
},
{ {
"name": "mobile_batch", "name": "mobile_batch",
"description": "Create, execute and manage durable batches on the unified device queue.\n\ncreate requires a unique request_id and batch={name,device_serial,cases:[{name,goal,\ncase_id?,category?,subcategory?,preconditions?,task_config?:{profile?,model?,\nreasoning_effort?,device_serial?,verification_level?,explorer_mode?,\nlocked_app_package?,record_video?,analyze_video?,request_timeout_seconds?,\nmax_retries?,retry_interval_seconds?,timeout_seconds?}}],profile?:flash|pro,\nformats?:[docx,xlsx],stop_on_failure?:false,timeout_seconds?:1800,repeat_count?:1}.\nrepeat_count is a positive integer: execute all selected cases in order for each\nround, with independent session IDs, statuses and reports. The default total limit\n(cases * repeat_count) is 10000, configurable via ARTEMIS_BATCH_MAX_EXECUTIONS.\nOnly include user-selected cases; get returns iteration/repeat_count per job.\nRequired device\nmust be user-selected. Creation validates and saves a DRAFT; start executes it.\nReuse the same request_id and exact payload after a connection failure; never invent\na new ID for the same submission. lookup(request_id) resolves an uncertain outcome.\nget returns per-case status. pause stops future dispatch, not the active task;\nuse mobile_manage_task(stop) to stop that task. resume never repeats finished cases.\nretry_failed copies each failed execution once into a new draft (repeat_count=1)\nonly after an explicit rerun request; it is not\ntransport-idempotent, so after an uncertain response use list instead of retrying.\ndelete removes a non-active batch and its task sessions, reports and files after\nan explicit user request. Confirm the batch ID with get first; deletion is irreversible.\nRestarted batches remain paused. No automatic failed-case retries or action replay.\n", "description": "Create, execute and manage durable batches on the unified device queue.\n\ncreate requires a unique request_id and batch={name,device_serial,cases:[{name,goal,\ncase_id?,category?,subcategory?,preconditions?,task_config?:{profile?,model?,\nreasoning_effort?,device_serial?,verification_level?,explorer_mode?,\nlocked_app_package?,record_video?,analyze_video?,request_timeout_seconds?,\nmax_retries?,retry_interval_seconds?,timeout_seconds?}}],profile?:flash|pro,\nformats?:[docx,xlsx],stop_on_failure?:false,timeout_seconds?:1800,repeat_count?:1}.\nrepeat_count is a positive integer: execute all selected cases in order for each\nround, with independent session IDs, statuses and reports. The default total limit\n(cases * repeat_count) is 10000, configurable via ARTEMIS_BATCH_MAX_EXECUTIONS.\nOnly include user-selected cases; get returns iteration/repeat_count per job.\nRequired device\nmust be user-selected. Creation validates and saves a DRAFT; start executes it.\nReuse the same request_id and exact payload after a connection failure; never invent\na new ID for the same submission. lookup(request_id) resolves an uncertain outcome.\nget returns per-case status. pause stops future dispatch, not the active task;\nuse mobile_manage_task(stop) to stop that task. resume never repeats finished cases.\nretry_failed copies each failed execution once into a new draft (repeat_count=1)\nonly after an explicit rerun request; it is not\ntransport-idempotent, so after an uncertain response use list instead of retrying.\ndelete removes a non-active batch and its task sessions, reports and files after\nan explicit user request. Confirm the batch ID with get first; deletion is irreversible.\nRestarted batches remain paused. No automatic failed-case retries or action replay.\n",
+2
View File
@@ -33,6 +33,7 @@ __all__ = [
"mobile_batch", "mobile_batch",
"mobile_search_history", "mobile_search_history",
"mobile_export_report", "mobile_export_report",
"mobile_favorites",
"mobile_exploration", "mobile_exploration",
] ]
@@ -43,4 +44,5 @@ from mcp_server.tools.automation import (
mobile_batch, mobile_batch,
mobile_search_history, mobile_search_history,
mobile_export_report, mobile_export_report,
mobile_favorites,
) )
+67
View File
@@ -268,6 +268,73 @@ async def mobile_task_file(
) )
@mcp.tool()
async def mobile_favorites(
action: Literal["list", "create", "update", "delete", "run"],
favorite_id: str | None = None,
source_kind: Literal["task", "batch", "exploration", "exploration_case"] = "task",
source_id: str | None = None,
version: int | None = None,
request_id: str | None = None,
changes: dict[str, Any] | None = None,
) -> Any:
"""Save independent favorite templates and run them on the existing queue.
list is read-only. create snapshots source_id (task session/batch/exploration),
including original execution settings; exploration_case pins a case version.
An exploration task automatically favorites its confirmed case when available.
changes accepts name, task_config (TaskOptions), case_configs (zero-based case
indices to TaskOptions). No credentials or arbitrary snapshot input is accepted.
update saves defaults; run applies changes to this execution only and requires
a stable request_id. After an uncertain reply retry the same payload and ID.
run creates a new execution, never continues the source or copies its verdict.
Batch order/repetitions and per-case settings are preserved. Missing historical
settings are explicitly marked inherited_fields. Offline devices are not replaced.
delete removes only the favorite, never the source history or previous runs.
"""
if action == "list":
return await _request("GET", "/api/automation/favorites")
payload = changes or {}
if set(payload) - {"name", "task_config", "case_configs"}:
raise ValueError("收藏只接受名称和非敏感执行配置")
# The HTTP schema applies the complete TaskOptions allowlist. Refuse secret
# fields before making a network request, including nested case overrides.
def check(value):
if isinstance(value, dict):
for key, child in value.items():
if any(
word in str(key).lower()
for word in ("secret", "password", "api_key", "token", "authorization")
):
raise ValueError("收藏不接受密钥或授权配置")
check(child)
elif isinstance(value, list):
for child in value:
check(child)
check(payload)
if action == "create":
if set(payload) - {"name"}:
raise ValueError("创建收藏从源记录读取配置,修改默认配置请使用 update")
return await _request(
"POST",
"/api/automation/favorites",
json={
"source_kind": source_kind,
"source_id": _id(source_id),
"version": version,
**payload,
},
)
route = f"/api/automation/favorites/{_id(favorite_id)}"
if action == "update":
return await _request("PATCH", route, json=payload)
if action == "delete":
return await _request("DELETE", route)
return await _request("POST", route + "/run", json={**payload, "request_id": _id(request_id)})
@mcp.tool() @mcp.tool()
async def mobile_batch( async def mobile_batch(
action: Literal[ action: Literal[
+16
View File
@@ -66,3 +66,19 @@
`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 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。
## 精选任务与收藏复跑
`mobile_favorites(action="list")` 读取与首页相同的收藏。`create` 指定 `source_kind`(task、batch、exploration、exploration_case)和 `source_id`,从服务端原记录保存独立模板及执行配置;不传任意 snapshot,也不接受密钥。删除原任务或清理历史不影响收藏。
- task 收藏普通 Flash/Pro 任务;探索执行记录默认收藏已确认用例,无用例时保存为重新探索。
- exploration 明确保存为新的探索对话,保留原探索模型与配置;不继续旧会话。
- exploration_case 保存固定用例版本,可传 version;默认使用该版本之前的复跑配置,首次复跑默认 Flash 并明确标示。后来新版本不会覆盖已有收藏。
- batch 保存一轮用例、原顺序、循环次数及执行策略;复跑不会把已展开的执行列表再循环。
`run` 必须提供稳定的 request_id,返回新 session_id、batch_id 或 exploration_id。连接异常时沿用相同 ID 和内容重试,不能新建 ID 自动重提。changes.task_config 仅覆盖本次;批次 changes.case_configs 使用从 0 开始的用例下标,changes.task_config 统一覆盖全批。`update` 才修改收藏默认配置,可改名称。`delete` 只取消收藏,不删除历史或执行结果。
不修改时使用收藏的原配置。旧任务未记录的字段会在 inherited_fields 标明采用收藏时的默认值,不能声称全部还原。原设备离线时先让用户选择设备,不自动换机。插件仍须通过当前安装、授权和资源校验。
单任务全部资料下载按任务合并并发请求,已结束且内容未变化时复用报告和 ZIP;人工复验、结果或附件变化会重新生成。批次复用成品,人工复验后重建,旧目录格式首次下载时更新。执行中导出为当前快照。录像位于 `任务录屏/安卓/`、`任务录屏/电脑/`、`任务录屏/人工示范/`,以文件清单和证据索引为准。
+76 -3
View File
@@ -158,6 +158,7 @@ def test_completed_batch_reuses_existing_archive(client, isolated, monkeypatch):
path = reports.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip" path = reports.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip"
path.parent.mkdir(parents=True) path.parent.mkdir(parents=True)
path.write_bytes(b"existing archive") path.write_bytes(b"existing archive")
reports.remember(path, reports.BATCH_ARCHIVE_SIGNATURE)
exporter = Mock(side_effect=AssertionError("archive must not be rebuilt")) exporter = Mock(side_effect=AssertionError("archive must not be rebuilt"))
monkeypatch.setattr(reports, "export_batch", exporter) monkeypatch.setattr(reports, "export_batch", exporter)
base = f"/api/automation/batches/{batch_id}/archive" base = f"/api/automation/batches/{batch_id}/archive"
@@ -166,6 +167,21 @@ def test_completed_batch_reuses_existing_archive(client, isolated, monkeypatch):
exporter.assert_not_called() exporter.assert_not_called()
def test_legacy_batch_archive_is_rebuilt_with_current_recording_layout(client, isolated):
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"legacy archive")
base = f"/api/automation/batches/{batch_id}/archive"
assert client.get(base + "/status").json()["status"] == "idle"
response = client.get(base)
assert response.status_code == 200
assert zipfile.is_zipfile(BytesIO(response.content))
assert client.get(base + "/status").json()["status"] == "ready"
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",
@@ -1021,7 +1037,7 @@ def test_task_material_zip_contains_ordered_evidence_and_reports(
assert files.index("1.1/步骤截图/0001_步骤0001_操作前.png") < files.index( assert files.index("1.1/步骤截图/0001_步骤0001_操作前.png") < files.index(
"1.1/步骤截图/0002_步骤0002_操作后.png" "1.1/步骤截图/0002_步骤0002_操作后.png"
) )
assert archive.read("1.1/任务录屏/0001_recording.mp4") == b"recording.mp4" assert archive.read("1.1/任务录屏/安卓/0001_recording.mp4") == b"recording.mp4"
manifest = json.loads(archive.read("1.1/文件清单.json")) manifest = json.loads(archive.read("1.1/文件清单.json"))
assert manifest["session_id"] == job["session_id"] assert manifest["session_id"] == job["session_id"]
assert manifest["artifacts"] == ["安卓截图.png"] assert manifest["artifacts"] == ["安卓截图.png"]
@@ -1042,6 +1058,63 @@ def test_batch_zip_contains_each_tasks_android_artifacts(isolated):
assert {archive.read(name).decode() for name in names} == {"任务0", "任务1"} assert {archive.read(name).decode() for name in names} == {"任务0", "任务1"}
def test_all_material_cache_reuses_reports_and_invalidates_on_review_or_new_files(
isolated, monkeypatch
):
job = isolated.jobs(service.create_batch(payload())["id"])[0]
job["status"] = "completed"
isolated.save_job(job)
word = Mock(wraps=reports.word_report)
monkeypatch.setattr(reports, "word_report", word)
monkeypatch.setattr(
reports,
"diagnostics_json",
lambda sid: json.dumps({"requests": [], "collected_at": time.time()}),
)
first = reports.export_session_media(job["session_id"], "all")
first_bytes = first.read_bytes()
reports.export_session_media(job["session_id"], "all")
assert word.call_count == 1
assert first.read_bytes() == first_bytes
isolated.save_case_review(job["session_id"], "passed", "人工核对")
reports.export_session_media(job["session_id"], "all")
assert word.call_count == 2
folder = reports.TRACES_PATH / job["session_id"] / "artifacts" / "android"
folder.mkdir(parents=True)
(folder / "新资料.txt").write_text("new evidence", encoding="utf-8")
reports.export_session_media(job["session_id"], "all")
with zipfile.ZipFile(first) as archive:
assert any(name.endswith("任务文件/安卓/新资料.txt") for name in archive.namelist())
assert not json.loads(
archive.read(next(n for n in archive.namelist() if n.endswith("文件清单.json")))
)["snapshot"]
# File-only changes rebuild the ZIP while keeping unchanged documents.
assert word.call_count == 2
def test_different_tasks_export_concurrently_and_running_exports_are_snapshots(
isolated, monkeypatch
):
from concurrent.futures import ThreadPoolExecutor
jobs = isolated.jobs(service.create_batch(payload())["id"])
barrier = threading.Barrier(2)
def generate(report, path):
barrier.wait(timeout=5)
path.write_bytes(b"generated report")
monkeypatch.setattr(reports, "word_report", generate)
with ThreadPoolExecutor(max_workers=2) as pool:
exports = [pool.submit(reports.export_session, j["session_id"], ["docx"]) for j in jobs]
assert all(result.result(timeout=10)[0].is_file() for result in exports)
count = Mock(side_effect=lambda report, path: path.write_bytes(b"snapshot"))
monkeypatch.setattr(reports, "word_report", count)
for _ in range(2):
reports.export_session(jobs[0]["session_id"], ["docx"])
assert count.call_count == 2
def test_android_artifact_export_does_not_follow_other_session_links(isolated): def test_android_artifact_export_does_not_follow_other_session_links(isolated):
root = reports.TRACES_PATH / "session" / "artifacts" root = reports.TRACES_PATH / "session" / "artifacts"
root.mkdir(parents=True) root.mkdir(parents=True)
@@ -1078,8 +1151,8 @@ def test_video_export_accepts_finalized_worker_trace_and_rejects_external_path(
with zipfile.ZipFile(archive_path) as archive: with zipfile.ZipFile(archive_path) as archive:
names = archive.namelist() names = archive.namelist()
assert names == [ assert names == [
"任务录屏/0001_web_123_" + session_id[:8] + "_PASS_2026-09-28_recording.mp4", "任务录屏/安卓/0001_web_123_" + session_id[:8] + "_PASS_2026-09-28_recording.mp4",
"任务录屏/0002_resume_0001_recording.mp4", "任务录屏/安卓/0002_resume_0001_recording.mp4",
"文件清单.json", "文件清单.json",
] ]
assert archive.read(names[0]) == b"first segment" assert archive.read(names[0]) == b"first segment"
+325
View File
@@ -0,0 +1,325 @@
"""Favorite snapshots and transport retries never replay an old result or task."""
import asyncio
from copy import deepcopy
import json
from unittest.mock import AsyncMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import ValidationError
import pytest
from apps.admin_console.services import automation_service
from apps.admin_console.services.automation_store import AutomationStore
from apps.admin_console.services.favorites import (
FavoriteCreate,
FavoriteRun,
FavoriteService,
FavoriteUpdate,
)
from artemis.config.exploration import ExplorationConfig
from artemis.data_engine.storage import StorageManager
from artemis.exploration.models import CaseDraft
from artemis.exploration.store import ExplorationStore
@pytest.fixture
def favorites(tmp_path, monkeypatch):
storage = AutomationStore(tmp_path / "history.db")
StorageManager(storage.path, tmp_path / "traces")
monkeypatch.setattr(automation_service, "store", storage)
return FavoriteService(storage)
def task(service, session_id="task", **options):
config = {
"profile": "pro",
"model": "original-model",
"reasoning_effort": "high",
"device_serial": "original-device",
"record_video": True,
**options,
}
job = {
"session_id": session_id,
"status": "completed",
"case": {"name": "原用例", "goal": "检查真实页面"},
"execution_config": {
"runtime_overrides": config,
"expected_output": "保留证据",
"enable_outputter": True,
},
}
service.store.save_job(job)
return service.create(FavoriteCreate(source_id=session_id))
def test_snapshot_survives_source_deletion_and_edits_do_not_modify_source(favorites):
item = task(favorites)
updated = favorites.update(
item["id"], FavoriteUpdate(name="常用检查", task_config={"model": "new-model"})
)
assert updated["snapshot"]["runtime_overrides"]["model"] == "new-model"
assert (
favorites.store.job("task")["execution_config"]["runtime_overrides"]["model"]
== "original-model"
)
favorites.store.delete_history()
assert favorites.get(item["id"])["snapshot"]["goal"] == "检查真实页面"
assert favorites.store.job("task") is None
@pytest.mark.asyncio
async def test_rerun_preserves_settings_and_is_durably_idempotent(favorites, monkeypatch):
item = task(favorites)
enqueue = AsyncMock(return_value={"tasks": [{"session_id": "new-task"}], "status": "queued"})
monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue)
value = FavoriteRun(request_id="same-submission", task_config={"reasoning_effort": "max"})
first, second = await asyncio.gather(
favorites.run(item["id"], value), favorites.run(item["id"], value)
)
assert first == second
enqueue.assert_awaited_once()
request = enqueue.call_args.args[0]
assert request.session_id != "task"
assert request.runtime_overrides.model == "original-model"
assert request.runtime_overrides.reasoning_effort == "max"
assert request.runtime_overrides.record_video is True
assert request.runtime_overrides.device_serial == "original-device"
assert request.expected_output == "保留证据"
assert favorites.get(item["id"])["snapshot"]["runtime_overrides"]["reasoning_effort"] == "high"
favorites.delete(item["id"])
assert await FavoriteService(favorites.store).run(item["id"], value) == first
enqueue.assert_awaited_once()
with pytest.raises(ValueError, match="request_id"):
await favorites.run(
item["id"],
FavoriteRun(request_id="same-submission", task_config={"model": "different"}),
)
@pytest.mark.asyncio
async def test_lost_admission_reply_reuses_snapshot_and_session(favorites, monkeypatch):
item = task(favorites)
enqueue = AsyncMock(side_effect=[ConnectionError("reply lost"), {"tasks": [{}]}])
monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue)
value = FavoriteRun(request_id="retry")
with pytest.raises(ConnectionError):
await favorites.run(item["id"], value)
favorites.update(item["id"], FavoriteUpdate(task_config={"model": "changed"}))
await FavoriteService(favorites.store).run(item["id"], value)
requests = [call.args[0] for call in enqueue.call_args_list]
assert requests[0].session_id == requests[1].session_id
assert requests[1].runtime_overrides.model == "original-model"
@pytest.mark.asyncio
async def test_batch_favorite_keeps_one_round_and_original_repetitions(favorites):
batch = automation_service.create_batch(
{
"name": "压测",
"repeat_count": 3,
"profile": "flash",
"device_serial": "device",
"cases": [
{"name": "甲", "goal": "检查甲", "task_config": {"model": "model-a"}},
{
"name": "乙",
"goal": "检查乙",
"task_config": {"profile": "pro", "model": "model-b"},
},
],
}
)
item = favorites.create(FavoriteCreate(source_kind="batch", source_id=batch["id"]))
assert len(item["snapshot"]["cases"]) == 2
assert item["snapshot"]["repeat_count"] == 3
result = await favorites.run(
item["id"],
FavoriteRun(
request_id="batch-rerun",
task_config={"reasoning_effort": "max"},
case_configs={1: {"model": "changed-b"}},
),
)
jobs = favorites.store.jobs(result["batch_id"])
assert len(jobs) == 6
assert [j["case"]["name"] for j in jobs] == ["甲", "乙"] * 3
assert [j["case"]["task_config"]["model"] for j in jobs] == ["model-a", "changed-b"] * 3
assert all(j["case"]["task_config"]["reasoning_effort"] == "max" for j in jobs)
assert favorites.store.get("batch", result["batch_id"])["status"] == "running"
again = await favorites.run(
item["id"],
FavoriteRun(
request_id="batch-rerun",
task_config={"reasoning_effort": "max"},
case_configs={1: {"model": "changed-b"}},
),
)
assert again == result
assert len(favorites.store.records("batch")) == 2
def test_exploration_case_pins_version_without_inheriting_expensive_model(favorites):
repo = ExplorationStore(favorites.store)
session = repo.create(
"探索目标",
ExplorationConfig(model="exploration-model", reasoning_effort="max"),
{"model": "exploration-model", "device_serial": "device"},
"create",
)
repo.save_case(
session["id"],
CaseDraft(name="完成用例", goal="验证主页", preconditions="已登录"),
0,
"case-v1",
)
item = favorites.create(FavoriteCreate(source_kind="exploration_case", source_id=session["id"]))
assert item["case_version"] == 1
assert item["snapshot"]["goal"] == "前置条件:\n已登录\n\n验证主页"
assert item["snapshot"]["runtime_overrides"]["profile"] == "flash"
assert item["snapshot"]["runtime_overrides"]["model"] != "exploration-model"
repo.save_case(session["id"], CaseDraft(name="新用例", goal="新目标"), 1, "case-v2")
assert favorites.get(item["id"])["snapshot"]["goal"].endswith("验证主页")
exploration = favorites.create(
FavoriteCreate(source_kind="exploration", source_id=session["id"])
)
assert exploration["snapshot"]["config"]["model"] == "exploration-model"
def test_old_task_recovers_real_model_and_marks_missing_settings(favorites):
with favorites.store.connection() as conn:
conn.execute(
"INSERT INTO sessions(session_id,initial_goal,status,device_info) VALUES (?,?,?,?)",
(
"legacy",
"检查浏览器",
"completed",
json.dumps({"profile": "flash", "serial": "computer:local"}),
),
)
conn.execute(
"INSERT INTO traces(trace_id,session_id,type,timestamp,payload) VALUES (?,?,?,?,?)",
(
"request",
"legacy",
"llm_diagnostic",
1,
json.dumps(
{"role": "FlashRunner", "model": "actual-model", "reasoning_effort": "max"}
),
),
)
conn.commit()
item = favorites.create(FavoriteCreate(source_id="legacy"))
options = item["snapshot"]["runtime_overrides"]
assert options["model"] == "actual-model"
assert options["reasoning_effort"] == "max"
assert options["execution_target"] == "computer"
assert "model" not in item["inherited_fields"]
assert "recording" in item["inherited_fields"]
def test_api_rejects_credentials_and_arbitrary_snapshots(favorites, monkeypatch):
from apps.admin_console.routers import favorites as api
monkeypatch.setattr(api, "favorites", favorites)
app = FastAPI()
app.include_router(api.router)
client = TestClient(app)
item = task(favorites)
assert len(client.get("/api/automation/favorites").json()) == 1
assert (
client.patch(
f"/api/automation/favorites/{item['id']}", json={"task_config": {"api_key": "never"}}
).status_code
== 422
)
assert (
client.post(
"/api/automation/favorites", json={"source_id": "task", "snapshot": {}}
).status_code
== 422
)
assert (
client.patch(f"/api/automation/favorites/{item['id']}", json={"name": " "}).status_code
== 409
)
assert client.delete(f"/api/automation/favorites/{item['id']}").status_code == 200
assert favorites.store.job("task")
with pytest.raises(ValidationError):
FavoriteRun(request_id="run", task_config={"password": "never"})
def test_collection_defaults_cannot_modify_original_snapshot(favorites):
item = task(favorites)
original = deepcopy(item)
favorites.updated(item, FavoriteUpdate(task_config={"model": "temporary"}))
assert item == original
def test_partial_recording_update_keeps_other_favorite_settings(favorites):
item = task(favorites, recording={"codec": "h264", "fps": 10, "max_long_edge": 720})
updated = favorites.updated(item, FavoriteUpdate(task_config={"recording": {"codec": "h265"}}))
assert updated["snapshot"]["runtime_overrides"]["recording"] == {
"codec": "h265",
"fps": 10,
"max_long_edge": 720,
}
assert item["snapshot"]["runtime_overrides"]["recording"]["codec"] == "h264"
def test_legacy_empty_recording_configuration_uses_current_defaults():
from apps.admin_console.services.execution_config import execution_config
from artemis.config.agent import load_agent_config
snapshot = execution_config({"profile": "flash", "runtime_overrides": {"recording": None}})
assert snapshot["runtime_overrides"]["recording"] == load_agent_config().recording.model_dump()
@pytest.mark.asyncio
@pytest.mark.parametrize("rejected", [False, True])
async def test_restart_exploration_creates_new_conversation_and_reports_rejection(
favorites, monkeypatch, rejected
):
from apps.admin_console.services.exploration_service import ExplorationService
repo = ExplorationStore(favorites.store)
original = repo.create(
"探索目标",
ExplorationConfig(model="explore-model", reasoning_effort="max"),
{"device_serial": "device", "record_video": True},
"source",
)
item = favorites.create(FavoriteCreate(source_kind="exploration", source_id=original["id"]))
monkeypatch.setattr(
"apps.admin_console.services.exploration_service.exploration_service",
ExplorationService(repo),
)
enqueue = AsyncMock(
return_value=(
{"status": "rejected", "error": "设备离线"}
if rejected
else {"status": "queued", "tasks": [{}]}
)
)
monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue)
value = FavoriteRun(request_id="new-exploration", task_config={"model": "selected-model"})
if rejected:
with pytest.raises(ValueError, match="设备离线"):
await favorites.run(item["id"], value)
with pytest.raises(ValueError, match="设备离线"):
await favorites.run(item["id"], value)
else:
result = await favorites.run(item["id"], value)
assert result["exploration_id"] != original["id"]
assert repo.run(result["session_id"])["exploration_id"] == result["exploration_id"]
assert await favorites.run(item["id"], value) == result
request = enqueue.call_args.args[0]
assert request.conversation_id != original["id"]
assert request.runtime_overrides.model == "selected-model"
assert request.runtime_overrides.reasoning_effort == "max"
assert request.runtime_overrides.record_video is True
enqueue.assert_awaited_once()
assert repo.get(original["id"]) == original
+33
View File
@@ -7,6 +7,39 @@ import pytest
from mcp_server.tools import automation as tools from mcp_server.tools import automation as tools
@pytest.mark.asyncio
async def test_favorites_share_backend_preserve_request_id_and_refuse_secrets(monkeypatch):
request = AsyncMock(return_value={"id": "favorite"})
monkeypatch.setattr(tools, "_request", request)
await tools.mobile_favorites("create", source_kind="batch", source_id="source")
request.assert_awaited_once_with(
"POST",
"/api/automation/favorites",
json={"source_kind": "batch", "source_id": "source", "version": None},
)
request.reset_mock()
with pytest.raises(ValueError):
await tools.mobile_favorites("run", favorite_id="favorite")
with pytest.raises(ValueError, match="密钥"):
await tools.mobile_favorites(
"update",
favorite_id="favorite",
changes={"case_configs": {"0": {"api_key": "never-send"}}},
)
request.assert_not_awaited()
await tools.mobile_favorites(
"run",
favorite_id="favorite",
request_id="stable",
changes={"task_config": {"model": "original"}},
)
request.assert_awaited_once_with(
"POST",
"/api/automation/favorites/favorite/run",
json={"task_config": {"model": "original"}, "request_id": "stable"},
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_config_requires_explicit_persistence_and_excludes_secrets(monkeypatch): async def test_config_requires_explicit_persistence_and_excludes_secrets(monkeypatch):
request = AsyncMock( request = AsyncMock(
+2
View File
@@ -233,6 +233,7 @@ def test_cli_mcp_generate_config_codex():
"mobile_config", "mobile_config",
"mobile_task_file", "mobile_task_file",
"mobile_batch", "mobile_batch",
"mobile_favorites",
"mobile_exploration", "mobile_exploration",
"mobile_search_history", "mobile_search_history",
"mobile_export_report", "mobile_export_report",
@@ -278,6 +279,7 @@ def test_cli_mcp_install_codex_preserves_config_and_is_idempotent(tmp_path, monk
"mobile_config", "mobile_config",
"mobile_task_file", "mobile_task_file",
"mobile_batch", "mobile_batch",
"mobile_favorites",
"mobile_exploration", "mobile_exploration",
"mobile_search_history", "mobile_search_history",
"mobile_export_report", "mobile_export_report",