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":
raise HTTPException(409, "报告正在打包,请等待准备完成")
path = archive_service.archive_path(batch_id)
if (
not path.is_file()
or not batch.get("report_ready")
and archive_service.status(batch_id, batch)["status"] != "ready"
):
if not path.is_file() or archive_service.status(batch_id, batch)["status"] != "ready":
path = await asyncio.to_thread(report_export.export_batch, batch_id)
return FileResponse(path, filename=f"批次报告-{batch_id[:8]}.zip")
@@ -205,8 +201,7 @@ async def download_report(
):
try:
if fmt in {"screenshots", "video", "all"}:
path = await asyncio.to_thread(report_export.export_session_media, session_id, fmt)
label = report_export.archive_case_name(report_export.session_report(session_id))
path, label = await asyncio.to_thread(report_export.session_download, session_id, fmt)
return FileResponse(path, filename=f"{label}-{fmt}.zip")
paths = await asyncio.to_thread(report_export.export_session, session_id, [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"
):
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)
if current["status"] not in {
"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
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
@@ -36,6 +36,22 @@ def register_submission(item: dict, case: dict | None = None, formats=None, **ex
"status": "queued",
"created_at": time.time(),
"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"],
**extra,
}
@@ -391,7 +407,9 @@ def delete_batch(batch_id: str) -> bool:
return False
storage = StorageManager(store.path, report_export.TRACES_PATH)
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:
session_id = str(UUID(job["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"
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:
elapsed = max(0, (state.get("finished_at") or time.monotonic()) - state["started_at"])
completed, total = state["completed"], state["total"]
@@ -37,7 +43,7 @@ def status(batch_id: str, batch: dict) -> dict:
state = _states.get(batch_id)
if state:
return _snapshot(state)
if batch.get("report_ready") and archive_path(batch_id).is_file():
if cached(batch_id, batch):
return {
"status": "ready",
"stage": "报告包已就绪",
@@ -63,7 +69,7 @@ def prepare(batch_id: str, batch: dict) -> dict:
current = _states.get(batch_id)
if current and current["status"] == "preparing":
return _snapshot(current)
if batch.get("report_ready") and archive_path(batch_id).is_file():
if cached(batch_id, batch):
_states.pop(batch_id, None)
return status_from_cache()
state = {
@@ -81,9 +87,9 @@ def prepare(batch_id: str, batch: dict) -> dict:
def run() -> None:
try:
with report_export.EXPORT_LOCK:
with report_export.export_lock("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)
with _lock:
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())
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.exploration_evidence import (
exploration_index,
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)
root = task_root(session_id)
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"]:
segment["file"] = paths.get((root / segment["file"]).resolve())
segment.pop("url", None)
images = session_screenshots(report)
artifacts = session_artifacts(report)
images = session_screenshots(report) if images is None else images
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 android_videos]
files += [(path, "任务录屏/安卓/" + name) for path, name in android_videos]
files += [(path, "任务文件/安卓/" + name) for path, name in artifacts]
files += extras
calls = []
@@ -151,7 +153,7 @@ def export_index(session_id: str, report: dict, android_videos: list) -> dict:
"steps": report.get("steps", []),
"calls": calls,
"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},
"files": [{"path": name, "bytes": path.stat().st_size} for path, name in files],
"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.
deleted = [run["id"] for run in runs if run["kind"] == "exploration"]
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:
safe_id = str(UUID(run_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
from pathlib import Path
import re
import threading
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 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
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
EXPORT_LOCK = threading.RLock()
BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-2"
STATUS = {
"completed": "执行完成",
"success": "执行完成",
@@ -741,9 +747,12 @@ def excel_report(report: dict, path: Path):
wb.save(path)
def export_session(session_id: str, formats: list[str] | None = None) -> list[Path]:
with EXPORT_LOCK:
report = session_report(session_id)
def export_session(
session_id: str, formats: list[str] | None = None, *, report: dict | None = None
) -> 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.mkdir(parents=True, exist_ok=True)
outputs = []
@@ -751,9 +760,13 @@ def export_session(session_id: str, formats: list[str] | None = None) -> list[Pa
if fmt not in {"docx", "xlsx"}:
raise ValueError("不支持的报告格式")
path = directory / f"任务报告.{fmt}"
if reusable(path, signature, report["status"]):
outputs.append(path)
continue
temporary = directory / f"生成中.{fmt}"
(word_report if fmt == "docx" else excel_report)(report, temporary)
temporary.replace(path)
remember(path, signature)
outputs.append(path)
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
if kind not in {"screenshots", "video", "all"}:
raise ValueError("不支持的下载类型")
with EXPORT_LOCK:
report = session_report(session_id)
with export_lock("session:" + identifier(session_id)):
report = report if report is not None else session_report(session_id)
target_dir = EXPORT_ROOT / "sessions" / identifier(session_id)
target_dir.mkdir(parents=True, exist_ok=True)
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("此任务没有可下载的步骤截图")
target = target_dir / f"{kind}.zip"
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 = {
"session_id": session_id,
"name": report["name"],
"status": report["status"],
"verdict": report["verdict"],
"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"}],
"artifacts": [name for _, name in artifacts],
"note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
"snapshot": report["status"]
not in {"completed", "success", "failed", "cancelled", "interrupted", "skipped"},
}
prefix = archive_case_name(report) + "/" if kind == "all" else ""
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
if kind == "all":
for path in export_session(session_id, ["docx", "xlsx"]):
archive.write(path, prefix + path.name)
for path in export_session(session_id, ["docx", "xlsx"], report=report):
archive.write(path, prefix + path.name, compress_type=compression(path))
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:
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:
archive.write(source, f"{prefix}任务文件/安卓/{name}")
archive.write(
source, f"{prefix}任务文件/安卓/{name}", compress_type=compression(source)
)
for source, name in extras:
archive.write(
source,
prefix + name,
compress_type=zipfile.ZIP_STORED
if source.suffix.lower() in {".mp4", ".mkv", ".webm"}
else zipfile.ZIP_DEFLATED,
compress_type=compression(source),
)
if kind == "all":
archive.writestr(prefix + "模型请求诊断.json", diagnostics_json(session_id))
archive.writestr(prefix + "模型请求诊断.json", diagnostics or "{}")
archive.writestr(
prefix + "证据索引.json",
json.dumps(
export_index(session_id, report, videos),
export_index(
session_id,
report,
videos,
images=images,
artifacts=artifacts,
extras=extras,
),
ensure_ascii=False,
indent=2,
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)
)
temporary.replace(target)
if fingerprint(report, files, diagnostic_state) == signature:
remember(target, signature)
return target
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
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))
if not batch:
raise ValueError("批次不存在")
@@ -959,34 +1002,47 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
temporary = directory / "批次报告.tmp"
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
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):
if progress:
progress(number - 1, len(records), f"正在打包任务 {number}/{len(records)}")
prefix = f"任务报告/{number:04d}_{archive_case_name(report)}"
for path in export_session(report["id"], formats):
archive.write(path, f"{prefix}/{path.name}")
for source, name in session_screenshots(report):
archive.write(source, f"{prefix}/步骤截图/{name}")
for source, name in session_videos(report):
images = session_screenshots(report)
videos = session_videos(report)
artifacts = session_artifacts(report)
extras = plugin_files(report["id"])
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(
source, f"{prefix}/任务录屏/{name}", compress_type=zipfile.ZIP_STORED
source, f"{prefix}/步骤截图/{name}", compress_type=compression(source)
)
for source, name in session_artifacts(report):
archive.write(source, f"{prefix}/任务文件/安卓/{name}")
for source, name in plugin_files(report["id"]):
for source, name in videos:
archive.write(
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(
source,
f"{prefix}/{name}",
compress_type=zipfile.ZIP_STORED
if source.suffix.lower() in {".mp4", ".mkv", ".webm"}
else zipfile.ZIP_DEFLATED,
compress_type=compression(source),
)
archive.writestr(f"{prefix}/模型请求诊断.json", diagnostics_json(report["id"]))
archive.writestr(
f"{prefix}/证据索引.json",
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,
indent=2,
default=str,
@@ -997,4 +1053,5 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
if progress:
progress(len(records), len(records), "正在完成压缩包")
temporary.replace(target)
remember(target, BATCH_ARCHIVE_SIGNATURE)
return target
@@ -1033,6 +1033,9 @@ class TaskQueueService:
if not isinstance(goal, str) or not goal.strip():
raise ValueError("Queued task must contain a non-empty string goal.")
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)
cmd, env = cls._build_worker_invocation(
@@ -1342,6 +1345,21 @@ class TaskQueueService:
device_serial = await device_pool.select_device_async()
except Exception:
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):
task_item = cls._create_queue_item(
goal,
@@ -1362,6 +1380,7 @@ class TaskQueueService:
)
if runtime_overrides:
task_item["runtime_overrides"] = runtime_overrides
task_item["execution_config"] = config_snapshot
if pinned:
try:
task_item["plugin_resources"] = PluginStore().reserve(
@@ -14,7 +14,7 @@
<section><h3>当前任务报告</h3>
@if (report(); as r) {
<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>
@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> }
@@ -76,6 +76,7 @@
<section><h3>批次记录</h3>
@for(b of batches(); track b.id){
<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="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>
@@ -106,6 +107,6 @@
</div>
}
@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>}
}
@@ -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;}
.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;}
.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-heading{display:flex;align-items:center;gap:8px;}
.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 label{margin:8px 0 4px;}
.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-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;}}
@@ -129,7 +129,7 @@ describe('AutomationPanelComponent batch deletion', () => {
{session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}}
]});
fixture.detectChanges();
const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job button')];
const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job > button')];
jobs[0].click(); jobs[1].click();
expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]);
expect(component.tab()).toBe('batches');
@@ -1,4 +1,5 @@
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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
@@ -22,7 +23,7 @@ interface ArchiveProgress {
}
@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'
})
export class AutomationPanelComponent {
@@ -125,8 +125,11 @@ limitations under the License.
}
</div>
</div>
<div class="task-goal" title="{{ session.initial_goal }}">
{{ session.display_name || session.initial_goal || '未命名任务' }}
<div class="task-title-row">
<app-favorite-button [sourceId]="session.session_id" />
<div class="task-goal" title="{{ session.initial_goal }}">
{{ session.display_name || session.initial_goal || '未命名任务' }}
</div>
</div>
<div class="task-card-footer">
<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 {
font-size: 12.5px;
font-weight: 500;
@@ -1,4 +1,5 @@
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 { TaskHistoryComponent } from '../task-history/task-history.component';
import { UiLabelPipe } from '../../utils/ui-label.pipe';
@@ -34,7 +35,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
@Component({
selector: 'app-chat-interface',
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',
styleUrl: './chat-interface.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -2,7 +2,7 @@
<app-agent-stream [conversationMessages]="messages()" [demonstrations]="s.demonstrations" [pendingQuestion]="s.pending_question || ''" (useRecommendation)="text = $event; send()">
<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>
<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 === '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>
@@ -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>
@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.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(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>}
@@ -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-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}
.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}}
@@ -1,4 +1,5 @@
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 { AgentStreamComponent } from '../agent-stream/agent-stream.component';
import { ExplorationService, ExplorationSession, ExplorationMessage, Demonstration } from '../../services/exploration.service';
@@ -7,7 +8,7 @@ import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge
@Component({
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',
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 { AgentService } from '../../services/agent.service';
import { TaskHistoryComponent } from './task-history.component';
import { FavoritesService } from '../../services/favorites.service';
describe('历史任务检索', () => {
let http: HttpTestingController;
@@ -13,6 +14,7 @@ describe('历史任务检索', () => {
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' ? '删除探索及全部探索记录,复跑与批次保留' : '删除历史任务'}}]});
http = TestBed.inject(HttpTestingController);
TestBed.inject(FavoritesService).loaded.set(true);
});
afterEach(() => http.verify());
@@ -1,4 +1,5 @@
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 { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
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';
@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>
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
@if(error()){<p role="alert">{{error()}}</p>}
@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>}
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
</section>`,
styles:[`:host{display:block;font-size:12px;color:inherit}h3{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 {
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
@@ -1668,121 +1668,7 @@
</div>
}
<!-- Recommended Tasks Hub (Clean & Direct) -->
<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>
<app-featured-tasks />
</section>
@@ -1,6 +1,7 @@
import { CapabilitySettingsComponent } from '../../components/model-settings/capability-settings.component';
import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from '../../components/setting-slider/setting-slider.component';
import { ModelSettingsComponent } from '../../components/model-settings/model-settings.component';
import { FeaturedTasksComponent } from '../../components/featured-tasks/featured-tasks.component';
/**
* Copyright 2026 Google LLC
*
@@ -108,7 +109,7 @@ type AdbGuideTab = 'emulator' | 'usb' | 'wifi' | 'remote';
@Component({
selector: 'app-home',
standalone: true,
imports: [FormsModule, ModelSettingsComponent, CapabilitySettingsComponent, TaskPluginsComponent, SettingSliderComponent],
imports: [FormsModule, ModelSettingsComponent, CapabilitySettingsComponent, TaskPluginsComponent, SettingSliderComponent, FeaturedTasksComponent],
templateUrl: './home.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
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;
}
}
}