feat: add plugin runtime and cross-device task orchestration
This commit is contained in:
1 parent
3ee4a5f7a8
commit
6663233ab8
82 files changed
+4147
-124
No files matched your search
@@ -183,6 +183,13 @@ class StepRepository:
|
||||
if not isinstance(payload_obj, dict):
|
||||
return None
|
||||
|
||||
action = payload_obj.get("action")
|
||||
if payload_obj.get("plugin_id") or (isinstance(action, dict) and action.get("plugin_id")):
|
||||
# Plugin payloads contain already-redacted evidence and explicit image
|
||||
# references. The legacy compact-tool formatter would discard their
|
||||
# provenance and make a desktop image inherit the phone's step source.
|
||||
return self._clean_value(payload_obj)
|
||||
|
||||
if trace_type == "llm_call":
|
||||
allowed_keys = (
|
||||
"error",
|
||||
|
||||
@@ -34,7 +34,7 @@ class BatchRequest(ReportPreferences):
|
||||
request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,100}$")
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
cases: list[task_import.TaskCase] = Field(min_length=1, max_length=task_import.MAX_ROWS)
|
||||
device_serial: str = Field(min_length=1, max_length=200)
|
||||
device_serial: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
profile: Literal["flash", "pro"] = "flash"
|
||||
stop_on_failure: bool = False
|
||||
repeat_count: int = Field(default=1, ge=1, strict=True)
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Plugin management for the local console; credentials never leave this API."""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import io
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Literal
|
||||
import uuid
|
||||
import zipfile
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.routing import APIRoute
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from artemis.plugins.installer import download, install_archive, install_local, load_catalog
|
||||
from artemis.plugins.models import PluginManifest, PluginProfile, PluginSelection
|
||||
from artemis.plugins.runtime import PluginRuntime
|
||||
from artemis.plugins.store import PluginStore
|
||||
|
||||
|
||||
class PluginRoute(APIRoute):
|
||||
def get_route_handler(self):
|
||||
handler = super().get_route_handler()
|
||||
|
||||
async def sanitized(request):
|
||||
try:
|
||||
return await handler(request)
|
||||
except RequestValidationError:
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={
|
||||
"detail": "插件配置格式不正确,请检查字段类型和清单;输入内容不会回显。"
|
||||
},
|
||||
)
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/plugins", tags=["plugins"], route_class=PluginRoute)
|
||||
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="plugin-installer")
|
||||
_jobs: dict[str, dict] = {}
|
||||
_lock = Lock()
|
||||
|
||||
|
||||
def get_store() -> PluginStore:
|
||||
return PluginStore()
|
||||
|
||||
|
||||
class InstallRequest(BaseModel):
|
||||
plugin_id: str | None = None
|
||||
version: str | None = None
|
||||
local_path: str | None = None
|
||||
|
||||
|
||||
class EnableRequest(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class DiagnoseRequest(BaseModel):
|
||||
profile: str = "default"
|
||||
|
||||
|
||||
class CustomRequest(BaseModel):
|
||||
manifest: PluginManifest
|
||||
skill_content: str = Field(default="", max_length=262144)
|
||||
|
||||
|
||||
class ResourceConfirmation(BaseModel):
|
||||
resource: str = Field(min_length=1, max_length=256)
|
||||
confirmed_stopped: Literal[True]
|
||||
|
||||
|
||||
def public_error(exc: Exception) -> str:
|
||||
return "插件清单或配置格式不正确" if isinstance(exc, ValidationError) else str(exc)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_plugins():
|
||||
store = get_store()
|
||||
return {"plugins": store.list(), "blocked_resources": store.blocked_resources()}
|
||||
|
||||
|
||||
@router.post("/resources/confirm-stopped")
|
||||
def confirm_resource_stopped(request: ResourceConfirmation):
|
||||
from artemis.runtime.device_lock import DeviceExecutionLock
|
||||
|
||||
if any(
|
||||
request.resource in owner.resource_ids
|
||||
for owner in DeviceExecutionLock.get_active_owners().values()
|
||||
):
|
||||
raise HTTPException(409, "此资源仍被任务占用,请先停止相关任务并确认插件动作已停止")
|
||||
get_store().confirm_resource_stopped(request.resource)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/catalog")
|
||||
def marketplace():
|
||||
try:
|
||||
return load_catalog().model_dump()
|
||||
except Exception:
|
||||
raise HTTPException(502, "暂时无法读取插件市场,请稍后重试;已安装插件不受影响") from None
|
||||
|
||||
|
||||
@router.post("/install", status_code=202)
|
||||
def install_plugin(request: InstallRequest):
|
||||
if bool(request.local_path) == bool(request.plugin_id):
|
||||
raise HTTPException(400, "请选择一个市场插件或本地安装包")
|
||||
with _lock:
|
||||
if any(j["status"] in {"queued", "running"} for j in _jobs.values()):
|
||||
raise HTTPException(409, "已有插件正在安装,请等待安装结束")
|
||||
for key in list(_jobs)[:-30]:
|
||||
del _jobs[key]
|
||||
job_id = str(uuid.uuid4())
|
||||
_jobs[job_id] = {"id": job_id, "status": "queued", "stage": "等待安装", "progress": 0}
|
||||
|
||||
def progress(stage, percent):
|
||||
with _lock:
|
||||
_jobs[job_id].update(status="running", stage=stage, progress=percent)
|
||||
|
||||
def run():
|
||||
try:
|
||||
store = get_store()
|
||||
if request.local_path:
|
||||
installed = install_local(store, Path(request.local_path), progress)
|
||||
else:
|
||||
progress("获取市场索引", 0)
|
||||
candidates = [
|
||||
p
|
||||
for p in load_catalog().plugins
|
||||
if p.manifest.id == request.plugin_id
|
||||
and (request.version is None or p.manifest.version == request.version)
|
||||
]
|
||||
from packaging.version import Version
|
||||
|
||||
compatible = []
|
||||
for entry in candidates:
|
||||
try:
|
||||
entry.manifest.check_compatible()
|
||||
compatible.append(entry)
|
||||
except ValueError:
|
||||
continue
|
||||
if not compatible:
|
||||
raise ValueError("没有已验证且兼容的插件版本")
|
||||
entry = max(compatible, key=lambda p: Version(p.manifest.version))
|
||||
installed = install_archive(
|
||||
store, download(entry.archive_url, progress), entry=entry, progress=progress
|
||||
)
|
||||
with _lock:
|
||||
_jobs[job_id].update(
|
||||
status="completed",
|
||||
stage="安装完成,请配置并启用",
|
||||
progress=100,
|
||||
plugin_id=installed["manifest"]["id"],
|
||||
)
|
||||
except Exception as exc:
|
||||
with _lock:
|
||||
# HTTP exceptions may contain signed URLs; expose only our validation messages.
|
||||
message = (
|
||||
public_error(exc)
|
||||
if isinstance(exc, (ValueError, FileNotFoundError))
|
||||
else "下载或安装失败,请检查网络及安装包"
|
||||
)
|
||||
_jobs[job_id].update(status="failed", stage=message)
|
||||
|
||||
_executor.submit(run)
|
||||
return {"job_id": job_id}
|
||||
|
||||
|
||||
@router.get("/install/{job_id}")
|
||||
def install_progress(job_id: str):
|
||||
with _lock:
|
||||
if job_id not in _jobs:
|
||||
raise HTTPException(404, "安装记录不存在,服务可能已重启;请刷新插件列表")
|
||||
return dict(_jobs[job_id])
|
||||
|
||||
|
||||
@router.post("/custom")
|
||||
def register_custom(request: CustomRequest):
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as archive:
|
||||
archive.writestr("plugin.json", request.manifest.model_dump_json())
|
||||
if request.manifest.skill:
|
||||
archive.writestr(request.manifest.skill, request.skill_content)
|
||||
try:
|
||||
return install_archive(get_store(), buffer.getvalue())
|
||||
except (ValueError, OSError) as exc:
|
||||
raise HTTPException(400, public_error(exc)) from None
|
||||
|
||||
|
||||
@router.put("/{plugin_id}/profiles")
|
||||
def save_profile(plugin_id: str, profile: PluginProfile):
|
||||
try:
|
||||
get_store().save_profile(plugin_id, profile)
|
||||
return get_store().get(plugin_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, public_error(exc)) from None
|
||||
|
||||
|
||||
@router.put("/{plugin_id}/enabled")
|
||||
def enable_plugin(plugin_id: str, request: EnableRequest):
|
||||
try:
|
||||
get_store().set_enabled(plugin_id, request.enabled)
|
||||
return get_store().get(plugin_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, public_error(exc)) from None
|
||||
|
||||
|
||||
@router.post("/{plugin_id}/diagnose")
|
||||
async def diagnose_plugin(plugin_id: str, request: DiagnoseRequest):
|
||||
runtime = None
|
||||
result = {"ok": False, "message": "插件连接失败,请检查启用状态、授权、命令和配置"}
|
||||
try:
|
||||
store = get_store()
|
||||
manifest = PluginManifest.model_validate(store.get(plugin_id)["manifest"])
|
||||
runtime = PluginRuntime(
|
||||
[PluginSelection(id=plugin_id, profile=request.profile)],
|
||||
store=store,
|
||||
target=manifest.targets[0],
|
||||
)
|
||||
loaded = await runtime.load(plugin_id)
|
||||
result = {
|
||||
"ok": loaded.status == "success",
|
||||
"tools": loaded.structured_content or {},
|
||||
"message": "插件连接正常",
|
||||
}
|
||||
except Exception:
|
||||
result = {"ok": False, "message": "插件连接失败,请检查启用状态、授权、命令和配置"}
|
||||
finally:
|
||||
if runtime:
|
||||
try:
|
||||
await runtime.aclose()
|
||||
except RuntimeError:
|
||||
result = {"ok": False, "message": "插件未确认停止,请检查受保护的资源"}
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{plugin_id}")
|
||||
def uninstall_plugin(plugin_id: str):
|
||||
try:
|
||||
get_store().uninstall(plugin_id)
|
||||
return {"ok": True}
|
||||
except (ValueError, OSError) as exc:
|
||||
raise HTTPException(409, public_error(exc)) from None
|
||||
@@ -183,7 +183,10 @@ async def run_task(request: RunRequest):
|
||||
# never be selected. Only a successful, non-empty enumeration may reject:
|
||||
# an indeterminate one (adb blip, startup) lets the submission queue and
|
||||
# fail downstream with a clear error instead.
|
||||
if request.device_serial:
|
||||
execution_target = (
|
||||
request.runtime_overrides.execution_target if request.runtime_overrides else "android"
|
||||
)
|
||||
if request.device_serial and execution_target != "computer":
|
||||
try:
|
||||
rejection = await device_pool.validate_explicit_serial_async(request.device_serial)
|
||||
except Exception:
|
||||
@@ -207,7 +210,11 @@ async def run_task(request: RunRequest):
|
||||
target_serial = (
|
||||
request.runtime_overrides.device_serial if request.runtime_overrides else None
|
||||
) or request.device_serial
|
||||
device_probe = await readiness_engine.run_device_submission_probe(target_serial=target_serial)
|
||||
device_probe = (
|
||||
await readiness_engine.run_device_submission_probe(target_serial=target_serial)
|
||||
if execution_target != "computer"
|
||||
else None
|
||||
)
|
||||
if device_probe and device_probe.summary in {"Device Locked", "Lock State Unknown"}:
|
||||
locked_serial = (
|
||||
device_probe.metadata.get("active_device", {}).get("serial") or target_serial or ""
|
||||
@@ -251,6 +258,8 @@ async def run_task(request: RunRequest):
|
||||
|
||||
from apps.admin_console.services.automation_service import register_submission
|
||||
|
||||
if response.get("status") == "error":
|
||||
raise HTTPException(400, response.get("message", "任务参数无效"))
|
||||
for item in response.get("tasks", []):
|
||||
register_submission(item)
|
||||
return response
|
||||
|
||||
@@ -242,6 +242,10 @@ from apps.admin_console.routers.automation import router as automation_router
|
||||
|
||||
app.include_router(automation_router)
|
||||
|
||||
from apps.admin_console.routers.plugins import router as plugins_router
|
||||
|
||||
app.include_router(plugins_router)
|
||||
|
||||
# Mount cloud gateway router for Frappe / Cloud integration if present
|
||||
try:
|
||||
from cloud_service.gateway import cloud_router
|
||||
|
||||
@@ -270,10 +270,12 @@ async def tick_batches():
|
||||
job = pending[0]
|
||||
options = job["case"].get("task_config") or {}
|
||||
serial = options.get("device_serial") or batch.get("device_serial")
|
||||
devices = await device_pool.try_list_devices_async()
|
||||
computer = options.get("execution_target") == "computer"
|
||||
devices = await device_pool.try_list_devices_async() if not computer else []
|
||||
rejection = (
|
||||
None
|
||||
if serial and any(d.serial == serial and d.state == "device" for d in devices or [])
|
||||
if computer
|
||||
or (serial and any(d.serial == serial and d.state == "device" for d in devices or []))
|
||||
else "无法确认所选设备在线且已授权,请恢复设备连接后继续批次"
|
||||
)
|
||||
if rejection:
|
||||
|
||||
@@ -450,6 +450,47 @@ def add_markdown_content(doc, content: str):
|
||||
)
|
||||
|
||||
|
||||
def plugin_operations(steps: list[dict]) -> list[dict]:
|
||||
operations = []
|
||||
for step in steps:
|
||||
for trace in step.get("generic_tools", []):
|
||||
payload = trace.get("payload") or {}
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
payload = json.loads(payload)
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(payload, dict) or not payload.get("plugin_id"):
|
||||
continue
|
||||
result = payload.get("result") or {}
|
||||
if isinstance(result, str):
|
||||
try:
|
||||
result = json.loads(result)
|
||||
except ValueError:
|
||||
result = {}
|
||||
if not isinstance(result, dict):
|
||||
result = {}
|
||||
content = "\n".join(
|
||||
str(block.get("text", ""))
|
||||
for block in result.get("content", [])
|
||||
if isinstance(block, dict) and block.get("type") == "text"
|
||||
)
|
||||
operations.append(
|
||||
{
|
||||
"step": step.get("step_number"),
|
||||
"plugin": payload["plugin_id"],
|
||||
"version": payload.get("plugin_version", ""),
|
||||
"tool": result.get("tool") or trace.get("name"),
|
||||
"resources": ", ".join(payload.get("resource_ids") or []),
|
||||
"status": result.get("status") or trace.get("status", "unknown"),
|
||||
"content": (
|
||||
content or payload.get("error") or result.get("message") or "未返回文字证据"
|
||||
)[:6000],
|
||||
}
|
||||
)
|
||||
return operations
|
||||
|
||||
|
||||
def word_report(report: dict, path: Path):
|
||||
doc = word_styles(Document())
|
||||
doc.add_paragraph(report["name"], style="Title")
|
||||
@@ -490,6 +531,15 @@ def word_report(report: dict, path: Path):
|
||||
f"步骤 {step['step_number']} · {time_text(step.get('timestamp'))}\n"
|
||||
).bold = True
|
||||
paragraph.add_run(step.get("summary") or "已记录操作")
|
||||
operations = plugin_operations(report["steps"])
|
||||
if operations:
|
||||
doc.add_heading("扩展插件操作", 2)
|
||||
for operation in operations:
|
||||
doc.add_paragraph(
|
||||
f"步骤 {operation['step']} · {operation['plugin']} v{operation['version']} · {operation['tool']} · {operation['status']}"
|
||||
)
|
||||
doc.add_paragraph("资源:" + operation["resources"])
|
||||
doc.add_paragraph(operation["content"])
|
||||
evidence = report["steps"][:1] + report["steps"][-1:]
|
||||
seen: set[str] = set()
|
||||
images: list[tuple[Path, int]] = []
|
||||
@@ -630,6 +680,27 @@ def excel_report(report: dict, path: Path):
|
||||
],
|
||||
(10, 22, 68, 26, 24),
|
||||
)
|
||||
operations = plugin_operations(report["steps"])
|
||||
if operations:
|
||||
sheet_rows(
|
||||
wb.create_sheet("插件操作"),
|
||||
[
|
||||
("步骤", "插件", "版本", "工具", "资源", "调用状态", "观察与结果"),
|
||||
*[
|
||||
(
|
||||
op["step"],
|
||||
op["plugin"],
|
||||
op["version"],
|
||||
op["tool"],
|
||||
op["resources"],
|
||||
op["status"],
|
||||
op["content"],
|
||||
)
|
||||
for op in operations
|
||||
],
|
||||
],
|
||||
(10, 24, 14, 26, 24, 16, 80),
|
||||
)
|
||||
wb.save(path)
|
||||
|
||||
|
||||
@@ -663,6 +734,30 @@ def session_screenshots(report: dict) -> list[tuple[Path, str]]:
|
||||
f"{index:04d}_步骤{int(step.get('step_number') or index):04d}_{phase}{source.suffix.lower()}",
|
||||
)
|
||||
)
|
||||
# Plugin observations are attached to their own tool traces rather than
|
||||
# replacing a phone's step image in a mixed task.
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for tool_index, trace in enumerate(step.get("generic_tools", []), 1):
|
||||
payload = trace.get("payload") or {}
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
payload = json.loads(payload)
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(payload, dict) or not payload.get("plugin_id"):
|
||||
continue
|
||||
plugin_id = re.sub(r"[^a-zA-Z0-9_-]", "_", str(payload["plugin_id"]))[:48]
|
||||
names = payload.get("image_names") or [payload.get("post_image_name")]
|
||||
for image_index, name in enumerate(names, 1):
|
||||
source = image_path(name)
|
||||
if source and (plugin_id, str(name)) not in seen:
|
||||
seen.add((plugin_id, str(name)))
|
||||
screenshots.append(
|
||||
(
|
||||
source,
|
||||
f"{index:04d}_插件_{plugin_id}_{tool_index:03d}_{image_index:02d}{source.suffix.lower()}",
|
||||
)
|
||||
)
|
||||
return screenshots
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.styles import Alignment, Font, PatternFill
|
||||
from openpyxl.utils import get_column_letter
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from artemis.plugins.models import ExecutionTarget, PluginSelection
|
||||
|
||||
HEADERS = (
|
||||
"用例编号",
|
||||
@@ -47,6 +48,8 @@ class TaskOptions(BaseModel):
|
||||
device_command_timeout_seconds: float | None = Field(default=None, gt=0, le=60)
|
||||
action_failure_attempts: int | None = Field(default=None, ge=1, le=10)
|
||||
worker_cleanup_timeout_seconds: float | None = Field(default=None, gt=0, le=300)
|
||||
plugins: list[PluginSelection] | None = None
|
||||
execution_target: ExecutionTarget | None = None
|
||||
|
||||
|
||||
class TaskCase(BaseModel):
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
# limitations under the License.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import os
|
||||
@@ -275,6 +276,11 @@ class TaskQueueService:
|
||||
]
|
||||
for item in removed_items:
|
||||
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
|
||||
from artemis.plugins.store import PluginStore
|
||||
|
||||
# A stop request may already have removed this row. The worker finalizer
|
||||
# must still release the server-owned version reservation after exit.
|
||||
PluginStore().release(str(session_id))
|
||||
state.queue_items = [
|
||||
t
|
||||
for t in state.queue_items
|
||||
@@ -417,6 +423,9 @@ class TaskQueueService:
|
||||
busy_devices = state.busy_device_ids | {
|
||||
cls._task_target(i).lock_key for i in in_flight if i.get("device_serial")
|
||||
}
|
||||
busy_resources = {r for item in in_flight for r in item.get("plugin_resources", [])}
|
||||
for owner in DeviceExecutionLock.get_active_owners().values():
|
||||
busy_resources.update(owner.resource_ids)
|
||||
dispatched_any = False
|
||||
loop = asyncio.get_running_loop()
|
||||
for item in list(state.queue_items):
|
||||
@@ -429,6 +438,9 @@ class TaskQueueService:
|
||||
|
||||
device = item.get("device_serial")
|
||||
target = cls._task_target(item)
|
||||
resources = set(item.get("plugin_resources", []))
|
||||
if resources & busy_resources:
|
||||
continue
|
||||
if limit == 0:
|
||||
# A task without a resolved device may bind to any serial, so it
|
||||
# only launches on an otherwise idle scheduler; the device lock
|
||||
@@ -446,6 +458,7 @@ class TaskQueueService:
|
||||
continue
|
||||
|
||||
item["status"] = "running"
|
||||
busy_resources.update(resources)
|
||||
dispatched_any = True
|
||||
if device is not None:
|
||||
busy_devices.add(target.lock_key)
|
||||
@@ -593,6 +606,11 @@ class TaskQueueService:
|
||||
else str(overrides[option])
|
||||
)
|
||||
target.endpoint.apply_to_environment(env)
|
||||
if "plugins" in overrides:
|
||||
env["ARTEMIS_TASK_PLUGINS"] = json.dumps(overrides["plugins"])
|
||||
else:
|
||||
env.pop("ARTEMIS_TASK_PLUGINS", None)
|
||||
env["ARTEMIS_EXECUTION_TARGET"] = overrides.get("execution_target") or "android"
|
||||
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
|
||||
queue_ticket = task_item.get("queue_ticket")
|
||||
if queue_ticket:
|
||||
@@ -623,7 +641,7 @@ class TaskQueueService:
|
||||
if app_path:
|
||||
cmd.extend(["--app-path", str(app_path)])
|
||||
device_serial = task_item.get("device_serial")
|
||||
if device_serial:
|
||||
if device_serial and env["ARTEMIS_EXECUTION_TARGET"] != "computer":
|
||||
cmd.extend(["--device-serial", str(device_serial)])
|
||||
env["ADB_DEVICE_SERIAL"] = str(device_serial)
|
||||
from apps.admin_console.services.automation_store import store
|
||||
@@ -1237,11 +1255,40 @@ class TaskQueueService:
|
||||
if duplicate_response is not None:
|
||||
return duplicate_response
|
||||
|
||||
rejection_response = await cls._reject_unavailable_device(device_serial)
|
||||
execution_target = (runtime_overrides or {}).get("execution_target") or "android"
|
||||
from pydantic import TypeAdapter
|
||||
from artemis.plugins.models import PluginSelection
|
||||
from artemis.plugins.store import PluginStore
|
||||
|
||||
choices = (runtime_overrides or {}).get("plugins")
|
||||
try:
|
||||
pinned = PluginStore().pin(
|
||||
TypeAdapter(list[PluginSelection]).validate_python(choices)
|
||||
if choices is not None
|
||||
else None,
|
||||
execution_target,
|
||||
)
|
||||
except ValueError:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "插件未就绪、未授权或不支持所选任务目标,请检查插件配置",
|
||||
}
|
||||
runtime_overrides = {
|
||||
**(runtime_overrides or {}),
|
||||
"execution_target": execution_target,
|
||||
"plugins": [p.model_dump() for p in pinned],
|
||||
}
|
||||
rejection_response = (
|
||||
await cls._reject_unavailable_device(device_serial)
|
||||
if execution_target != "computer"
|
||||
else None
|
||||
)
|
||||
if rejection_response is not None:
|
||||
return rejection_response
|
||||
|
||||
single_session_id = session_id if (session_id and len(goals) == 1) else None
|
||||
if execution_target == "computer":
|
||||
device_serial = "computer:local"
|
||||
if not device_serial:
|
||||
# Device enumeration may block on ADB.
|
||||
from artemis.runtime import device_pool
|
||||
@@ -1270,6 +1317,19 @@ class TaskQueueService:
|
||||
)
|
||||
if runtime_overrides:
|
||||
task_item["runtime_overrides"] = runtime_overrides
|
||||
if pinned:
|
||||
try:
|
||||
task_item["plugin_resources"] = PluginStore().reserve(
|
||||
task_item["session_id"], pinned, execution_target
|
||||
)
|
||||
except (ValueError, OSError):
|
||||
DeviceExecutionLock.cancel_reservation(task_item.get("queue_ticket"))
|
||||
for previous in enqueued_tasks:
|
||||
cls._remove_task(previous["session_id"])
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "插件配置在入队前发生变化,请检查后重新提交",
|
||||
}
|
||||
if display_name:
|
||||
task_item["display_name"] = display_name
|
||||
state.queue_items.append(task_item)
|
||||
@@ -1380,6 +1440,7 @@ class TaskQueueService:
|
||||
"""Terminate all active device owners and clear pending queue submissions."""
|
||||
from apps.admin_console.services.automation_store import store
|
||||
from apps.admin_console.services.automation_service import on_terminal, spawn
|
||||
from artemis.plugins.store import PluginStore
|
||||
|
||||
for batch in store.records("batch"):
|
||||
if batch["status"] == "running":
|
||||
@@ -1389,6 +1450,7 @@ class TaskQueueService:
|
||||
for item in state.queue_items:
|
||||
if isinstance(item, dict) and item.get("status") != "running":
|
||||
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
|
||||
PluginStore().release(str(item.get("session_id")))
|
||||
if store.job(str(item.get("session_id"))):
|
||||
spawn(on_terminal(item, "cancelled"))
|
||||
state.clear_queue()
|
||||
@@ -1712,7 +1774,9 @@ class TaskQueueService:
|
||||
if stopped_item and stopped_item.get("status") != "running":
|
||||
from apps.admin_console.services.automation_store import store
|
||||
from apps.admin_console.services.automation_service import on_terminal, spawn
|
||||
from artemis.plugins.store import PluginStore
|
||||
|
||||
PluginStore().release(str(stopped_session_id))
|
||||
if store.job(str(stopped_session_id)):
|
||||
spawn(on_terminal(stopped_item, "cancelled"))
|
||||
if stopped_item and not reservation_cancelled:
|
||||
|
||||
@@ -22,6 +22,7 @@ import { LegacyWorkspaceComponent } from './pages/legacy-workspace/legacy-worksp
|
||||
export const routes: Routes = [
|
||||
{ path: '', component: HomeComponent },
|
||||
{ path: 'workspace', component: WorkspaceComponent },
|
||||
{ path: 'plugins', loadComponent: () => import('./pages/plugins/plugins.component').then(m => m.PluginsComponent) },
|
||||
{ path: 'check', component: LegacyWorkspaceComponent },
|
||||
{ path: '**', redirectTo: '' }
|
||||
];
|
||||
+2
-2
@@ -43,7 +43,7 @@
|
||||
</section>
|
||||
}
|
||||
@if (tab() !== 'reports') {
|
||||
<section class="device"><label>执行设备<select [(ngModel)]="device"><option value="">请选择设备</option>@for(d of system.connectedDevices(); track d.serial){<option [value]="d.serial">{{d.serial}}</option>}</select></label></section>
|
||||
<app-task-target (changed)="taskTarget = $event" /><section class="device"><label>执行设备<select [(ngModel)]="device"><option value="">请选择设备</option>@for(d of system.connectedDevices(); track d.serial){<option [value]="d.serial">{{d.serial}}</option>}</select></label></section>
|
||||
}
|
||||
@if (tab() === 'batches') {
|
||||
<section><h3>导入任务</h3><p>每行一个用例,仅「Artemis提示词」作为任务正文;「任务配置(JSON)」可设置每项模型、模式、超时和录屏。「前置约束提取」仅供查看与搜索。示例在模板的「填写说明」页。</p>
|
||||
@@ -70,7 +70,7 @@
|
||||
@if(!validRepetitions()){<p class="error">循环次数须为正整数,执行总数不超过 {{maxExecutions}}。</p>}
|
||||
<label><input type="checkbox" [(ngModel)]="stopOnFailure"> 失败即停止后续任务(默认继续)</label>
|
||||
<label><input type="checkbox" [(ngModel)]="word"> Word</label><label><input type="checkbox" [(ngModel)]="excel"> Excel</label>
|
||||
<button class="primary" [disabled]="busy() || !p.valid || !selectedCount() || !validRepetitions() || !device || !name.trim() || !formats().length" (click)="startBatch()">确认导入并开始执行</button>
|
||||
<button class="primary" [disabled]="busy() || !p.valid || !selectedCount() || !validRepetitions() || !name.trim() || !formats().length" (click)="startBatch()">确认导入并开始执行</button>
|
||||
}
|
||||
</section>
|
||||
<section><h3>批次记录</h3>
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ describe('AutomationPanelComponent batch deletion', () => {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
const request = http.post.calls.allArgs().find(args => args[0] === '/api/automation/batches');
|
||||
expect(request?.[1]).toEqual(jasmine.objectContaining({
|
||||
cases: [{ case_id: '1.1', name: '打开设置', goal: '观察设置', task_config: { model: 'mimo-v2.6-pro' } }],
|
||||
cases: [{ case_id: '1.1', name: '打开设置', goal: '观察设置', task_config: { execution_target: 'android', model: 'mimo-v2.6-pro' } }],
|
||||
repeat_count: 3, device_serial: 'test-device'
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
|
||||
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
||||
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
|
||||
import { CaseReviewResponse } from '../../core/models/session.model';
|
||||
import { TaskTargetComponent, TaskTargetOptions } from '../task-target/task-target.component';
|
||||
|
||||
interface ArchiveProgress {
|
||||
status: 'idle' | 'preparing' | 'ready' | 'failed';
|
||||
@@ -21,10 +22,11 @@ interface ArchiveProgress {
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent],
|
||||
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskTargetComponent],
|
||||
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
|
||||
})
|
||||
export class AutomationPanelComponent {
|
||||
taskTarget: TaskTargetOptions = { execution_target: 'android' };
|
||||
queueRequested = output<void>();
|
||||
agent = inject(AgentService); system = inject(SystemService); http = inject(HttpClient);
|
||||
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
|
||||
@@ -138,9 +140,9 @@ export class AutomationPanelComponent {
|
||||
if (!this.preview()?.valid) throw Error('请先导入并修正所有错误');
|
||||
if (!this.selectedCount()) throw Error('请至少勾选一个用例');
|
||||
if (!this.validRepetitions()) throw Error(`循环次数必须为正整数,执行总数不能超过 ${this.maxExecutions}`);
|
||||
if (!this.device) throw Error('请选择执行设备');
|
||||
const cases = this.preview().tasks.filter((t: any) => this.selectedRows().has(t.row)).map(({row, ...c}: any) => c);
|
||||
const created: any = await firstValueFrom(this.http.post('/api/automation/batches',{name:this.name.trim(),cases,repeat_count:this.repeatCount,device_serial:this.device,profile:this.profile,stop_on_failure:this.stopOnFailure,timeout_seconds:this.timeout,formats:this.formats()}));
|
||||
const cases = this.preview().tasks.filter((t: any) => this.selectedRows().has(t.row)).map(({row, ...c}: any) => ({...c, task_config: {...this.taskTarget, ...Object.fromEntries(Object.entries(c.task_config || {}).filter(([,v]) => v !== null && v !== undefined))}}));
|
||||
if (!this.device && cases.some((c: any) => c.task_config.execution_target !== 'computer' && !c.task_config.device_serial)) throw Error('请选择执行设备');
|
||||
const created: any = await firstValueFrom(this.http.post('/api/automation/batches',{name:this.name.trim(),cases,repeat_count:this.repeatCount,device_serial:this.device || null,profile:this.profile,stop_on_failure:this.stopOnFailure,timeout_seconds:this.timeout,formats:this.formats()}));
|
||||
this.batch.set(created);
|
||||
await firstValueFrom(this.http.post(`/api/automation/batches/${created.id}/resume`,{}));
|
||||
this.preview.set(null); this.notice.set('批次已开始,可暂停或查看每项结果'); await this.refresh();
|
||||
|
||||
@@ -44,6 +44,9 @@ import { version } from '../../../../package.json';
|
||||
<span class="material-symbols-outlined tab-icon">space_dashboard</span>
|
||||
<span class="tab-label">工作区</span>
|
||||
</a>
|
||||
<a routerLink="/plugins" routerLinkActive="active" class="nav-tab-btn" title="管理扩展插件">
|
||||
<span class="material-symbols-outlined tab-icon">extension</span><span class="tab-label">插件</span>
|
||||
</a>
|
||||
<span class="app-version" [title]="'ARTEMIS 版本 ' + version" [attr.aria-label]="'ARTEMIS 版本 ' + version">v{{version}}</span>
|
||||
</nav>
|
||||
`,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { TaskTargetComponent } from './task-target.component';
|
||||
|
||||
describe('TaskTargetComponent', () => {
|
||||
it('defaults to Android without fetching plugins, and distinguishes auto selection from no plugins', () => {
|
||||
TestBed.configureTestingModule({ imports: [TaskTargetComponent], providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||
const fixture = TestBed.createComponent(TaskTargetComponent);
|
||||
const http = TestBed.inject(HttpTestingController);
|
||||
const component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
http.expectNone('/api/plugins');
|
||||
const emitted = jasmine.createSpy('changed');
|
||||
component.changed.subscribe(emitted);
|
||||
component.target = 'computer'; component.targetChanged();
|
||||
expect(emitted).toHaveBeenCalledWith({ execution_target: 'computer' });
|
||||
component.automatic = false; component.emit();
|
||||
expect(emitted).toHaveBeenCalledWith({ execution_target: 'computer', plugins: [] });
|
||||
component.toggle(); http.expectOne('/api/plugins').flush({ plugins: [] });
|
||||
expect(component.loading()).toBeFalse();
|
||||
http.verify();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Component, inject, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { InstalledPlugin, PluginService } from '../../services/plugin.service';
|
||||
|
||||
export interface TaskTargetOptions {
|
||||
execution_target: 'android' | 'computer' | 'mixed';
|
||||
plugins?: { id: string; profile: string; version?: string }[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-task-target', standalone: true, imports: [FormsModule],
|
||||
template: `
|
||||
<div class="target-row">
|
||||
<label>执行目标 <select aria-label="执行目标" [(ngModel)]="target" (ngModelChange)="targetChanged()">
|
||||
<option value="android">Android 手机</option><option value="computer">电脑</option><option value="mixed">手机 + 扩展插件</option>
|
||||
</select></label>
|
||||
<button type="button" (click)="toggle()" [attr.aria-expanded]="open()">插件配置 {{ open() ? '收起' : '展开' }}</button>
|
||||
</div>
|
||||
@if (open()) {
|
||||
<div class="plugin-choices">
|
||||
<label><input type="checkbox" [(ngModel)]="automatic" (ngModelChange)="emit()"> 自动选择已启用且支持此目标的插件</label>
|
||||
@if (loading()) { <p role="status">正在读取插件…</p> }
|
||||
@if (error()) { <p role="alert">{{ error() }}</p> }
|
||||
@if (!automatic) {
|
||||
@for (plugin of available(); track plugin.manifest.id) {
|
||||
<label class="choice"><input type="checkbox" [checked]="selected[plugin.manifest.id] !== undefined" (change)="choose(plugin, $event)">
|
||||
{{ plugin.manifest.name }}
|
||||
<select [disabled]="selected[plugin.manifest.id] === undefined" [ngModel]="selected[plugin.manifest.id] || 'default'" (ngModelChange)="selected[plugin.manifest.id] = $event; emit()" [attr.aria-label]="plugin.manifest.name + '连接配置'">
|
||||
@for (profile of plugin.profiles; track profile.name) { @if (profile.authorized) { <option [value]="profile.name">{{ profile.label }}</option> } }
|
||||
</select>
|
||||
</label>
|
||||
} @empty { <p>没有适用的已启用插件,请先在插件页面安装并授权。</p> }
|
||||
}
|
||||
<p class="hint">手机任务默认不占电脑资源。电脑任务无需连接 Android;混合任务会同时占用所需资源。</p>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [`:host{display:block;width:100%;font-size:12px;color:var(--text-secondary,#a0a5b4)}.target-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.target-row label,.choice{display:flex;align-items:center;gap:8px}select,button{font:inherit;color:inherit;background:var(--bg-tertiary,#252935);border:1px solid var(--border-color,#414653);border-radius:6px;padding:5px 8px}button{cursor:pointer}.plugin-choices{padding:10px 0;display:grid;gap:9px}.choice{justify-content:space-between}p{margin:0;line-height:1.5}.hint{opacity:.8}input{accent-color:#72b9d6}`]
|
||||
})
|
||||
export class TaskTargetComponent {
|
||||
changed = output<TaskTargetOptions>();
|
||||
private api = inject(PluginService);
|
||||
target: TaskTargetOptions['execution_target'] = 'android';
|
||||
automatic = true; selected: Record<string, string> = {};
|
||||
open = signal(false); loading = signal(false); error = signal('');
|
||||
plugins = signal<InstalledPlugin[]>([]);
|
||||
available() { return this.plugins().filter(p => p.enabled && p.manifest.targets.includes(this.target)); }
|
||||
toggle() { this.open.update(v => !v); if (this.open()) this.load(); }
|
||||
targetChanged() { this.selected = {}; this.emit(); if (this.open()) this.load(); }
|
||||
load() { this.loading.set(true); this.error.set(''); this.api.list().subscribe({
|
||||
next: value => { this.plugins.set(value.plugins); this.loading.set(false); },
|
||||
error: () => { this.error.set('插件列表暂不可用,请重新展开重试。'); this.loading.set(false); }
|
||||
}); }
|
||||
choose(plugin: InstalledPlugin, event: Event) {
|
||||
if ((event.target as HTMLInputElement).checked) this.selected[plugin.manifest.id] = plugin.profiles.find(p => p.authorized)?.name || 'default';
|
||||
else delete this.selected[plugin.manifest.id];
|
||||
this.emit();
|
||||
}
|
||||
emit() { this.changed.emit({ execution_target: this.target,
|
||||
...(this.automatic ? {} : { plugins: Object.entries(this.selected).map(([id, profile]) => ({id, profile})) }) }); }
|
||||
}
|
||||
@@ -1379,6 +1379,7 @@
|
||||
|
||||
<!-- Task composer -->
|
||||
<div class="composer-card" [class.is-pro-active]="selectedProfile() === 'pro'">
|
||||
<app-task-target (changed)="taskTarget.set($event)" />
|
||||
|
||||
<!-- Smart Intent Recommendation Strip (if detected) -->
|
||||
@if (showIntentSuggestion()) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ModelSettingsComponent } from '../../components/model-settings/model-se
|
||||
|
||||
import { Component, signal, computed, inject, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TaskTargetComponent, TaskTargetOptions } from '../../components/task-target/task-target.component';
|
||||
import { Router } from '@angular/router';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { SystemService, formatAdbRestartFeedback } from '../../services/system.service';
|
||||
@@ -105,7 +106,7 @@ type AdbGuideTab = 'emulator' | 'usb' | 'wifi' | 'remote';
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
standalone: true,
|
||||
imports: [FormsModule, ModelSettingsComponent, CapabilitySettingsComponent],
|
||||
imports: [FormsModule, ModelSettingsComponent, CapabilitySettingsComponent, TaskTargetComponent],
|
||||
templateUrl: './home.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrl: './home.component.scss'
|
||||
@@ -319,7 +320,8 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
// Computed helper states delegating to SystemService
|
||||
public isReady = computed(() => this.systemService.isReady());
|
||||
taskTarget = signal<TaskTargetOptions>({ execution_target: 'android' });
|
||||
public isReady = computed(() => this.taskTarget().execution_target === 'computer' || this.systemService.isReady());
|
||||
public hasReadinessReport = computed(() => this.systemService.hasReadinessReport());
|
||||
public isLoading = computed(() => this.systemService.isLoading());
|
||||
public isRestartingAdb = computed(() => this.systemService.isRestartingAdb());
|
||||
@@ -810,7 +812,8 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
undefined,
|
||||
this.selectedProfile() === 'pro'
|
||||
? { verificationLevel: this.verificationLevel().id, explorerMode: this.explorerMode().id }
|
||||
: undefined
|
||||
: undefined,
|
||||
{ ...this.taskTarget() }
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<main class="plugins-page">
|
||||
<header><div><span class="eyebrow">ARTEMIS · 扩展能力</span><h1>插件</h1><p>为测试接入电脑和其他工具,统一执行并保留证据。</p></div>
|
||||
<button (click)="refresh(); refreshCatalog()" [disabled]="busy()">刷新</button></header>
|
||||
@if (error()) { <div class="message error" role="alert">{{error()}}</div> }
|
||||
@if (notice()) { <div class="message success" role="status">{{notice()}}</div> }
|
||||
@for (resource of blocked(); track resource[0]) {
|
||||
<section class="message error" role="alert"><strong>资源保护 · {{resource[0]}}</strong><p>{{resource[1]}}</p><button (click)="confirmingResource.set(resource[0])" [disabled]="busy()">核对并解除保护</button></section>
|
||||
}
|
||||
@if (progress(); as job) {
|
||||
<section class="install-progress" aria-live="polite"><div><span>{{job.stage}}</span><strong>{{job.progress}}%</strong></div>
|
||||
<progress max="100" [value]="job.progress" [attr.aria-label]="job.stage"></progress></section>
|
||||
}
|
||||
<div class="tabs" role="tablist" aria-label="插件来源">
|
||||
<button role="tab" [attr.aria-selected]="tab() === 'installed'" [class.active]="tab() === 'installed'" (click)="tab.set('installed')">已安装 <span>{{installed().length}}</span></button>
|
||||
<button role="tab" [attr.aria-selected]="tab() === 'market'" [class.active]="tab() === 'market'" (click)="tab.set('market')">插件市场</button>
|
||||
</div>
|
||||
@if (tab() === 'installed') {
|
||||
@if (loading()) { <p role="status" class="empty">正在读取插件…</p> }
|
||||
@else if (!installed().length) { <section class="empty"><span class="material-symbols-outlined">extension</span><h2>还没有安装插件</h2><p>从市场安装经过验证的扩展,或在下方导入自己的插件。</p><button class="primary" (click)="tab.set('market')">浏览插件市场</button></section> }
|
||||
<div class="cards">@for (plugin of installed(); track plugin.manifest.id) {
|
||||
<article class="card"><div class="card-top"><div class="plugin-icon"><span class="material-symbols-outlined">extension</span></div><div><h2>{{plugin.manifest.name}}</h2><span class="meta">v{{plugin.manifest.version}} · {{plugin.source === 'marketplace' ? '已验证市场插件' : '本地 / 自定义'}}</span></div><span class="badge" [class.enabled]="plugin.enabled">{{plugin.enabled ? '已启用' : '未启用'}}</span></div>
|
||||
<p>{{plugin.manifest.description || '暂无描述'}}</p><div class="meta">{{plugin.profiles.length}} 套连接配置</div>
|
||||
<footer><button (click)="edit(plugin)" [disabled]="busy()">配置</button><button (click)="diagnose(plugin)" [disabled]="busy() || !plugin.enabled">检查连接</button><button [class.primary]="!plugin.enabled" (click)="toggle(plugin)" [disabled]="busy()">{{plugin.enabled ? '停用' : '启用'}}</button><button class="text-button" (click)="removing.set(plugin)" [disabled]="busy()">卸载</button></footer>
|
||||
</article>
|
||||
}</div>
|
||||
<details class="advanced"><summary>高级接入 · 本地插件与自定义 MCP</summary><p>本地插件需要包含 plugin.json;导入后配置授权范围,再手动启用。</p>
|
||||
<div class="inline"><input aria-label="本地插件路径" [(ngModel)]="localPath" placeholder="插件 ZIP 文件或目录的完整路径"><button (click)="install({local_path: localPath})" [disabled]="busy() || !localPath.trim()">导入</button></div>
|
||||
<details><summary>添加自定义 MCP / Skill</summary><label>插件清单 JSON<textarea rows="12" [(ngModel)]="customManifest" spellcheck="false"></textarea></label><label>Skill 内容(可选)<textarea rows="4" [(ngModel)]="customSkill"></textarea></label><button (click)="addCustom()" [disabled]="busy()">添加插件</button></details>
|
||||
</details>
|
||||
} @else {
|
||||
@if (catalogError()) { <div class="message error" role="alert">{{catalogError()}} <button (click)="refreshCatalog()">重试</button></div> }
|
||||
@else if (!market().length) { <section class="empty"><h2>首批插件验证中</h2><p>验证通过的插件会出现在这里;不会自动安装未经验证的版本。</p></section> }
|
||||
<div class="cards">@for (entry of market(); track entry.manifest.id + entry.manifest.version) {
|
||||
<article class="card"><div class="card-top"><div class="plugin-icon"><span class="material-symbols-outlined">verified</span></div><div><h2>{{entry.manifest.name}}</h2><span class="meta">v{{entry.manifest.version}} · {{entry.manifest.platforms.join(' / ')}}</span></div></div><p>{{entry.manifest.description}}</p><details><summary>验证记录</summary><p>{{entry.verification}}</p></details><footer><button class="primary" (click)="install({plugin_id: entry.manifest.id, version: entry.manifest.version})" [disabled]="busy() || installedVersion(entry.manifest.id) === entry.manifest.version">{{installedVersion(entry.manifest.id) === entry.manifest.version ? '已安装' : installedVersion(entry.manifest.id) ? '安装此版本' : '安装插件'}}</button></footer></article>
|
||||
}</div>
|
||||
}
|
||||
</main>
|
||||
@if (confirmingResource(); as resource) {
|
||||
<div class="modal-backdrop"><section class="dialog small" role="dialog" aria-modal="true" aria-labelledby="resource-confirm-title"><h2 id="resource-confirm-title">确认 {{resource}} 已停止操作</h2><p>请先核对目标设备或远程服务,确认之前的插件动作已经停止,并检查实际状态。解除后任务才可再次操作此资源。</p><footer><button (click)="confirmingResource.set(null)" [disabled]="busy()">取消</button><button class="primary" (click)="confirmStopped()" [disabled]="busy()">已核对,解除保护</button></footer></section></div>
|
||||
}
|
||||
@if (editing(); as plugin) {
|
||||
<div class="modal-backdrop"><section class="dialog" role="dialog" aria-modal="true" aria-labelledby="plugin-config-title"><header><div><h2 id="plugin-config-title">{{plugin.manifest.name}}</h2><p>配置连接与允许控制的范围</p></div><button aria-label="关闭配置" (click)="editing.set(null)" [disabled]="busy()">×</button></header>
|
||||
<div class="inline"><select aria-label="选择连接配置" [ngModel]="profile.name" (ngModelChange)="selectProfile($event)">@for (p of plugin.profiles; track p.name) { <option [value]="p.name">{{p.label}}</option> }</select><button (click)="newProfile()">新增配置</button></div>
|
||||
<div class="two-columns"><label>配置标识<input [(ngModel)]="profile.name" placeholder="如 lab-router"></label><label>显示名称<input [(ngModel)]="profile.label" placeholder="如实验室路由器"></label></div>
|
||||
@for (entry of fields(); track entry[0]) { <label>{{entry[1].label}}{{entry[1].required ? ' *' : ''}}
|
||||
@if (entry[1].secret) { <input type="password" autocomplete="new-password" [(ngModel)]="profile.secrets![entry[0]]" [placeholder]="profile.configured_secrets?.includes(entry[0]) ? '已保存,留空保留' : '仅保存在本机'"> }
|
||||
@else { <input [(ngModel)]="profile.values[entry[0]]" [placeholder]="entry[1].default"> }
|
||||
@if (entry[1].description) { <small>{{entry[1].description}}</small> }
|
||||
</label> }
|
||||
<details><summary>控制范围与共享资源</summary><label>允许工具(每行一个,留空使用全部工具)<textarea rows="3" [(ngModel)]="allowedTools"></textarea></label><label>参数约束 JSON<textarea rows="4" [(ngModel)]="constraints" placeholder="按工具名称配置允许的参数值"></textarea></label><label>共享资源(同一目标使用相同标识,每行一个)<textarea rows="2" [(ngModel)]="resources" placeholder="router:lab"></textarea></label></details>
|
||||
<label class="checkbox"><input type="checkbox" [(ngModel)]="profile.is_default">作为自动选择插件时的默认连接配置</label>
|
||||
<label class="checkbox"><input type="checkbox" [(ngModel)]="profile.authorized">允许 ARTEMIS 使用此配置执行插件工具</label><p class="meta">插件进程使用当前用户的系统权限。工具与参数范围由 ARTEMIS 检查。</p>
|
||||
@if (error()) { <p class="message error" role="alert">{{error()}}</p> }
|
||||
<footer><button (click)="editing.set(null)" [disabled]="busy()">取消</button><button class="primary" (click)="save()" [disabled]="busy() || !profile.name.trim()">{{busy() ? '保存中…' : '保存配置'}}</button></footer>
|
||||
</section></div>
|
||||
}
|
||||
@if (removing(); as plugin) { <div class="modal-backdrop"><section class="dialog small" role="dialog" aria-modal="true" aria-labelledby="remove-title"><h2 id="remove-title">卸载 {{plugin.manifest.name}}?</h2><p>此插件及其连接配置将不再供新任务使用。</p><footer><button (click)="removing.set(null)" [disabled]="busy()">取消</button><button class="danger" (click)="remove()" [disabled]="busy()">确认卸载</button></footer></section></div> }
|
||||
@@ -0,0 +1,24 @@
|
||||
:host { display: block; color: #1e293b; font-size: 14px; }
|
||||
.plugins-page { max-width: 1120px; padding: 88px 32px 56px; margin: auto; }
|
||||
header, .card-top, footer, .inline, .install-progress > div { display: flex; align-items: center; gap: 12px; }
|
||||
header { justify-content: space-between; margin-bottom: 28px; }
|
||||
h1 { margin: 8px 0; font-size: 30px; letter-spacing: -.6px; } h2 { margin: 0 0 6px; font-size: 17px; }
|
||||
p { color: #64748b; line-height: 1.7; white-space: pre-wrap; margin: 8px 0 16px; }
|
||||
.eyebrow { color: #3b6e9c; font-size: 11px; letter-spacing: 1.4px; font-weight: 600; }
|
||||
button { border: 1px solid #dce3eb; border-radius: 8px; background: #fff; color: #334155; padding: 8px 14px; font-size: 13px; cursor: pointer; white-space: nowrap; }
|
||||
button:hover:not(:disabled) { border-color: #9fb7d3; background: #f0f6ff; } button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
button.primary { color: #fff; background: #2563eb; border-color: #2563eb; } button.danger { color: #b91c1c; border-color: #fecaca; }
|
||||
.text-button { border: 0; margin-left: auto; color: #64748b; } :focus-visible { outline: 2px solid #3b82f6; outline-offset: 3px; }
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 24px; border-bottom: 1px solid #e2e8f0; padding-bottom: 12px; }
|
||||
.tabs button { border: 0; background: transparent; }.tabs button.active { color: #1d4ed8; background: #eaf1ff; font-weight: 600; }.tabs span { margin-left: 8px; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); gap: 18px; }
|
||||
.card { padding: 22px; background: #fff; border: 1px solid #e1e7ef; border-radius: 14px; }.card > p { min-height: 48px; margin-top: 18px; }.card footer { margin-top: 20px; flex-wrap: wrap; }
|
||||
.plugin-icon { display: grid; place-items: center; width: 44px; height: 44px; flex-shrink: 0; color: #4477ba; background: #eef4ff; border-radius: 12px; }
|
||||
.meta, small { color: #64748b; font-size: 12px; line-height: 1.6; }.badge { margin-left: auto; white-space: nowrap; padding: 4px 9px; border-radius: 20px; background: #f1f5f9; font-size: 11px; color: #64748b; }.badge.enabled { background: #ecfdf5; color: #047857; }
|
||||
.empty { padding: 55px 24px; text-align: center; border: 1px dashed #cbd5e1; border-radius: 14px; background: #ffffff80; margin-bottom: 24px; }.empty > span { font-size: 36px; color: #94a3b8; margin-bottom: 16px; }
|
||||
.message { padding: 12px 16px; border-radius: 8px; margin-bottom: 18px; line-height: 1.6; }.error { color: #b91c1c; background: #fff1f2; }.success { color: #047857; background: #ecfdf5; }
|
||||
.install-progress { background: #fff; border: 1px solid #dbeafe; padding: 18px; border-radius: 12px; margin-bottom: 20px; }.install-progress > div { justify-content: space-between; }progress { width: 100%; height: 8px; margin-top: 12px; accent-color: #2563eb; }
|
||||
details { margin: 16px 0; }summary { cursor: pointer; color: #475569; padding: 8px 0; }.advanced { padding: 18px 22px; border: 1px solid #e2e8f0; border-radius: 12px; margin-top: 24px; }
|
||||
label { display: flex; flex-direction: column; gap: 7px; margin: 16px 0; font-size: 13px; }input, select, textarea { border: 1px solid #d5dde7; background: #fff; border-radius: 8px; padding: 10px 12px; color: #1e293b; font-size: 13px; min-width: 0; width: 100%; }textarea { resize: vertical; font-family: ui-monospace, 'Microsoft YaHei', monospace; line-height: 1.6; }.inline > input, .inline > select { flex: 1; }
|
||||
.modal-backdrop { position: fixed; inset: 0; background: #0f172a50; backdrop-filter: blur(3px); display: grid; place-items: center; z-index: 1300; padding: 24px; }.dialog { max-width: 620px; width: 100%; max-height: 90vh; overflow-y: auto; background: #fff; padding: 28px; border-radius: 16px; box-shadow: 0 24px 80px #0f172a30; }.dialog.small { max-width: 430px; }.dialog header { margin-bottom: 12px; }.dialog footer { justify-content: flex-end; margin-top: 24px; }.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }.checkbox { flex-direction: row; align-items: center; gap: 10px; }.checkbox input { width: auto; }
|
||||
@media (max-width: 640px) { .plugins-page { padding: 80px 16px 32px; }.card { padding: 18px; }.dialog { padding: 20px; }.two-columns { grid-template-columns: 1fr; gap: 0; }.card-top { gap: 8px; }h2 { font-size: 15px; } }
|
||||
@@ -0,0 +1,68 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { PluginsComponent } from './plugins.component';
|
||||
import { InstalledPlugin } from '../../services/plugin.service';
|
||||
|
||||
describe('PluginsComponent', () => {
|
||||
let http: HttpTestingController;
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({ imports: [PluginsComponent], providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
afterEach(() => http.verify());
|
||||
|
||||
function setup() {
|
||||
const fixture = TestBed.createComponent(PluginsComponent);
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/plugins').flush({ plugins: [], blocked_resources: { 'router:lab': '操作结果不确定' } });
|
||||
http.expectOne('/api/plugins/catalog').flush({ plugins: [] });
|
||||
return fixture;
|
||||
}
|
||||
|
||||
it('shows empty state and requires explicit confirmation before releasing a protected resource', async () => {
|
||||
const fixture = setup();
|
||||
await fixture.whenStable(); fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('还没有安装插件');
|
||||
expect(fixture.nativeElement.textContent).toContain('router:lab');
|
||||
const component = fixture.componentInstance;
|
||||
await component.confirmStopped();
|
||||
http.expectNone('/api/plugins/resources/confirm-stopped');
|
||||
component.confirmingResource.set('router:lab');
|
||||
const operation = component.confirmStopped();
|
||||
const request = http.expectOne('/api/plugins/resources/confirm-stopped');
|
||||
expect(request.request.body).toEqual({ resource: 'router:lab', confirmed_stopped: true });
|
||||
request.flush({ ok: true });
|
||||
await Promise.resolve();
|
||||
http.expectOne('/api/plugins').flush({ plugins: [], blocked_resources: {} });
|
||||
await operation;
|
||||
expect(component.blocked()).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps saved secrets write-only and preserves the configuration form on failure', async () => {
|
||||
const fixture = setup(); await fixture.whenStable();
|
||||
const component = fixture.componentInstance;
|
||||
component.edit({ manifest: { id: 'demo', config: {} }, profiles: [{ name: 'default', label: '默认', values: {}, configured_secrets: ['token'], allowed_tools: null, argument_constraints: {}, resources: [], authorized: true }] } as unknown as InstalledPlugin);
|
||||
const operation = component.save();
|
||||
const request = http.expectOne('/api/plugins/demo/profiles');
|
||||
expect(request.request.body.secrets).toEqual({});
|
||||
expect(request.request.body.configured_secrets).toBeUndefined();
|
||||
request.flush({ detail: '插件正被任务使用' }, { status: 409, statusText: 'Conflict' });
|
||||
await operation;
|
||||
expect(component.editing()).not.toBeNull();
|
||||
expect(component.error()).toContain('任务使用');
|
||||
expect(component.busy()).toBeFalse();
|
||||
});
|
||||
|
||||
it('shows installation failure without pretending that the package was installed', async () => {
|
||||
const fixture = setup(); await fixture.whenStable();
|
||||
const operation = fixture.componentInstance.install({ plugin_id: 'demo' });
|
||||
http.expectOne('/api/plugins/install').flush({ job_id: 'job-1' });
|
||||
await Promise.resolve();
|
||||
http.expectOne('/api/plugins/install/job-1').flush({ id: 'job-1', status: 'failed', stage: '校验失败', progress: 75 });
|
||||
await operation;
|
||||
expect(fixture.componentInstance.error()).toBe('校验失败');
|
||||
expect(fixture.componentInstance.busy()).toBeFalse();
|
||||
expect(fixture.componentInstance.installed()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { InstalledPlugin, InstallProgress, MarketPlugin, PluginProfile, PluginService } from '../../services/plugin.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-plugins', standalone: true, imports: [FormsModule],
|
||||
templateUrl: './plugins.component.html', styleUrl: './plugins.component.scss'
|
||||
})
|
||||
export class PluginsComponent implements OnInit, OnDestroy {
|
||||
private api = inject(PluginService);
|
||||
installed = signal<InstalledPlugin[]>([]);
|
||||
market = signal<MarketPlugin[]>([]);
|
||||
loading = signal(true);
|
||||
busy = signal(false);
|
||||
error = signal('');
|
||||
catalogError = signal('');
|
||||
notice = signal('');
|
||||
blocked = signal<[string, string][]>([]);
|
||||
confirmingResource = signal<string | null>(null);
|
||||
tab = signal<'installed' | 'market'>('installed');
|
||||
progress = signal<InstallProgress | null>(null);
|
||||
editing = signal<InstalledPlugin | null>(null);
|
||||
removing = signal<InstalledPlugin | null>(null);
|
||||
profile: PluginProfile = this.emptyProfile();
|
||||
resources = ''; allowedTools = ''; constraints = '{}'; localPath = '';
|
||||
customManifest = JSON.stringify({ schema_version: 1, id: 'my-plugin', name: '我的插件', version: '1.0.0',
|
||||
description: '插件用途', platforms: ['win32'], server: { transport: 'stdio', command: '程序绝对路径', args: [] } }, null, 2);
|
||||
customSkill = '';
|
||||
private timer?: ReturnType<typeof setTimeout>;
|
||||
private destroyed = false;
|
||||
|
||||
ngOnInit() { void this.refresh(); void this.refreshCatalog(); }
|
||||
ngOnDestroy() { this.destroyed = true; clearTimeout(this.timer); }
|
||||
emptyProfile(): PluginProfile { return { name: 'default', label: '默认配置', values: {}, secrets: {},
|
||||
allowed_tools: null, argument_constraints: {}, resources: [], authorized: false }; }
|
||||
private message(error: any): string { return typeof error?.error?.detail === 'string' ? error.error.detail : '操作失败,请检查连接后重试。'; }
|
||||
async refresh() {
|
||||
this.loading.set(true);
|
||||
try { const result = await firstValueFrom(this.api.list()); this.installed.set(result.plugins); this.blocked.set(Object.entries(result.blocked_resources || {})); }
|
||||
catch (error) { this.error.set(this.message(error)); }
|
||||
finally { this.loading.set(false); }
|
||||
}
|
||||
async refreshCatalog() {
|
||||
this.catalogError.set('');
|
||||
try { this.market.set((await firstValueFrom(this.api.catalog())).plugins); }
|
||||
catch (error) { this.catalogError.set(this.message(error)); }
|
||||
}
|
||||
installedVersion(id: string) { return this.installed().find(p => p.manifest.id === id)?.manifest.version; }
|
||||
fields() { return Object.entries(this.editing()?.manifest.config || {}); }
|
||||
edit(plugin: InstalledPlugin) { this.editing.set(plugin); this.selectProfile(plugin.profiles[0]?.name || 'default'); }
|
||||
selectProfile(name: string) {
|
||||
const selected = this.editing()?.profiles.find(p => p.name === name);
|
||||
this.profile = selected ? { ...structuredClone(selected), secrets: {} } : this.emptyProfile();
|
||||
this.resources = this.profile.resources.join('\n');
|
||||
this.allowedTools = this.profile.allowed_tools?.join('\n') || '';
|
||||
this.constraints = JSON.stringify(this.profile.argument_constraints, null, 2);
|
||||
}
|
||||
newProfile() { this.profile = { ...this.emptyProfile(), name: '', label: '新配置' }; this.resources = ''; this.allowedTools = ''; this.constraints = '{}'; }
|
||||
async save() {
|
||||
const plugin = this.editing(); if (!plugin || this.busy()) return;
|
||||
this.busy.set(true); this.error.set('');
|
||||
try {
|
||||
const profile = { ...this.profile, resources: this.resources.split('\n').map(v => v.trim()).filter(Boolean),
|
||||
allowed_tools: this.allowedTools.trim() ? this.allowedTools.split('\n').map(v => v.trim()).filter(Boolean) : null,
|
||||
argument_constraints: JSON.parse(this.constraints) };
|
||||
await firstValueFrom(this.api.save(plugin.manifest.id, profile));
|
||||
this.editing.set(null); this.profile = this.emptyProfile(); await this.refresh();
|
||||
this.notice.set('配置已保存。启用后可供后续任务使用。');
|
||||
} catch (error) { this.error.set(error instanceof SyntaxError ? '参数约束不是有效的 JSON。' : this.message(error)); }
|
||||
finally { this.busy.set(false); }
|
||||
}
|
||||
async toggle(plugin: InstalledPlugin) { await this.act(async () => { await firstValueFrom(this.api.enable(plugin.manifest.id, !plugin.enabled)); await this.refresh(); }); }
|
||||
async diagnose(plugin: InstalledPlugin) {
|
||||
await this.act(async () => { const result = await firstValueFrom(this.api.diagnose(plugin.manifest.id, plugin.profiles[0]?.name || 'default'));
|
||||
if (result.ok) this.notice.set(result.message); else this.error.set(result.message); });
|
||||
}
|
||||
async remove() {
|
||||
const plugin = this.removing(); if (!plugin) return;
|
||||
await this.act(async () => { await firstValueFrom(this.api.uninstall(plugin.manifest.id)); this.removing.set(null); await this.refresh(); });
|
||||
}
|
||||
async confirmStopped() {
|
||||
const resource = this.confirmingResource(); if (!resource) return;
|
||||
await this.act(async () => { await firstValueFrom(this.api.confirmStopped(resource)); this.confirmingResource.set(null); await this.refresh(); this.notice.set('已解除保护。继续任务前将重新观察目标状态。'); });
|
||||
}
|
||||
async install(payload: { plugin_id?: string; version?: string; local_path?: string }) {
|
||||
if (this.busy()) return;
|
||||
this.busy.set(true); this.error.set(''); this.notice.set('');
|
||||
this.progress.set({ id: '', status: 'queued', stage: '准备安装', progress: 0 });
|
||||
try { const { job_id } = await firstValueFrom(this.api.install(payload)); await this.poll(job_id); }
|
||||
catch (error) { this.error.set(this.message(error)); this.busy.set(false); this.progress.set(null); }
|
||||
}
|
||||
private async poll(id: string) {
|
||||
if (this.destroyed) return;
|
||||
try {
|
||||
const progress = await firstValueFrom(this.api.progress(id)); if (this.destroyed) return;
|
||||
this.progress.set(progress);
|
||||
if (progress.status === 'failed') { this.error.set(progress.stage); this.busy.set(false); return; }
|
||||
if (progress.status === 'completed') { this.busy.set(false); this.tab.set('installed'); await this.refresh(); return; }
|
||||
this.timer = setTimeout(() => void this.poll(id), 750);
|
||||
} catch (error) { this.error.set(this.message(error)); this.busy.set(false); }
|
||||
}
|
||||
async addCustom() {
|
||||
await this.act(async () => { let manifest: unknown;
|
||||
try { manifest = JSON.parse(this.customManifest); } catch { this.error.set('插件清单不是有效的 JSON。'); return; }
|
||||
await firstValueFrom(this.api.custom(manifest, this.customSkill)); await this.refresh(); this.notice.set('已添加。请配置授权范围并手动启用。'); });
|
||||
}
|
||||
private async act(operation: () => Promise<void>) {
|
||||
if (this.busy()) return;
|
||||
this.busy.set(true); this.error.set(''); this.notice.set('');
|
||||
try { await operation(); } catch (error) { this.error.set(this.message(error)); } finally { this.busy.set(false); }
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,7 @@ limitations under the License.
|
||||
</div>
|
||||
|
||||
<!-- Bottom Segmented Controls Row -->
|
||||
<app-task-target (changed)="taskTarget = $event" />
|
||||
<div class="card-footer-row">
|
||||
<div class="footer-left-group">
|
||||
<!-- Segmented Architecture Toggle (Flash / Pro) -->
|
||||
|
||||
@@ -23,12 +23,14 @@ import { FloatingVideoPlayerComponent } from '../../components/floating-video-pl
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { TaskTargetComponent, TaskTargetOptions } from '../../components/task-target/task-target.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace',
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
TaskTargetComponent,
|
||||
AgentStreamComponent,
|
||||
ChatInterfaceComponent,
|
||||
FloatingVideoPlayerComponent
|
||||
@@ -38,6 +40,7 @@ import { firstValueFrom } from 'rxjs';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class WorkspaceComponent implements OnInit {
|
||||
taskTarget: TaskTargetOptions = { execution_target: 'android' };
|
||||
public agentService = inject(AgentService);
|
||||
private http = inject(HttpClient);
|
||||
private zone = inject(NgZone);
|
||||
@@ -325,7 +328,7 @@ export class WorkspaceComponent implements OnInit {
|
||||
}
|
||||
this.isInputFocused.set(false);
|
||||
|
||||
this.agentService.runTask(goal, this.selectedProfile()).subscribe({
|
||||
this.agentService.runTask(goal, this.selectedProfile(), undefined, undefined, undefined, { ...this.taskTarget }).subscribe({
|
||||
next: (res) => {
|
||||
this.taskInput = '';
|
||||
if (this.dockInputRef?.nativeElement) {
|
||||
|
||||
@@ -454,7 +454,8 @@ export class AgentService {
|
||||
profile: string = 'flash',
|
||||
expectedOutput?: string,
|
||||
enableOutputter?: boolean,
|
||||
proTuning?: ProTuningOptions
|
||||
proTuning?: ProTuningOptions,
|
||||
runtimeOverrides?: Record<string, unknown>
|
||||
): Observable<any> {
|
||||
return new Observable((obs) => {
|
||||
const submittedEvent: StartupProgressEvent = {
|
||||
@@ -464,6 +465,7 @@ export class AgentService {
|
||||
};
|
||||
this.pendingStartupProgress.set([submittedEvent]);
|
||||
const payload: any = { goal, profile };
|
||||
if (runtimeOverrides) payload.runtime_overrides = runtimeOverrides;
|
||||
if (expectedOutput && expectedOutput.trim()) {
|
||||
payload.expected_output = expectedOutput.trim();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
export interface PluginManifest {
|
||||
id: string; name: string; version: string; description: string;
|
||||
artemis_version: string; platforms: string[]; source: string;
|
||||
config: Record<string, { label: string; description: string; secret: boolean; required: boolean; default: string }>;
|
||||
resources: string[];
|
||||
targets: ('android' | 'computer' | 'mixed')[];
|
||||
}
|
||||
export interface PluginProfile {
|
||||
name: string; label: string; is_default?: boolean; values: Record<string, string>;
|
||||
secrets?: Record<string, string>; configured_secrets?: string[];
|
||||
allowed_tools: string[] | null;
|
||||
argument_constraints: Record<string, Record<string, string[]>>;
|
||||
resources: string[]; authorized: boolean;
|
||||
}
|
||||
export interface InstalledPlugin { manifest: PluginManifest; enabled: boolean; source: string; profiles: PluginProfile[] }
|
||||
export interface MarketPlugin { manifest: PluginManifest; verification: string }
|
||||
export interface InstallProgress { id: string; status: 'queued' | 'running' | 'completed' | 'failed'; stage: string; progress: number }
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PluginService {
|
||||
private http = inject(HttpClient);
|
||||
list() { return this.http.get<{ plugins: InstalledPlugin[]; blocked_resources?: Record<string, string> }>('/api/plugins'); }
|
||||
confirmStopped(resource: string) { return this.http.post('/api/plugins/resources/confirm-stopped', { resource, confirmed_stopped: true }); }
|
||||
catalog() { return this.http.get<{ plugins: MarketPlugin[] }>('/api/plugins/catalog'); }
|
||||
install(payload: { plugin_id?: string; version?: string; local_path?: string }) {
|
||||
return this.http.post<{ job_id: string }>('/api/plugins/install', payload);
|
||||
}
|
||||
progress(id: string) { return this.http.get<InstallProgress>(`/api/plugins/install/${encodeURIComponent(id)}`); }
|
||||
save(id: string, profile: PluginProfile) {
|
||||
const { configured_secrets, ...payload } = profile;
|
||||
return this.http.put<InstalledPlugin>(`/api/plugins/${encodeURIComponent(id)}/profiles`, payload);
|
||||
}
|
||||
enable(id: string, enabled: boolean) { return this.http.put<InstalledPlugin>(`/api/plugins/${encodeURIComponent(id)}/enabled`, { enabled }); }
|
||||
diagnose(id: string, profile: string) { return this.http.post<{ ok: boolean; message: string }>(`/api/plugins/${encodeURIComponent(id)}/diagnose`, { profile }); }
|
||||
uninstall(id: string) { return this.http.delete(`/api/plugins/${encodeURIComponent(id)}`); }
|
||||
custom(manifest: unknown, skill_content: string) { return this.http.post('/api/plugins/custom', { manifest, skill_content }); }
|
||||
}
|
||||
@@ -9,6 +9,20 @@ import {
|
||||
} from './action-formatter.util';
|
||||
|
||||
describe('action-formatter.util screenshot chaining', () => {
|
||||
it('keeps plugin observations separate from the phone image chain', () => {
|
||||
const desktop = { type: 'tool', trace_id: 'desktop', name: 'plug_observe', timestamp: 1001,
|
||||
payload: { plugin_id: 'desktop-control', post_image_name: 'desktop-screen' } };
|
||||
const missingDesktopImage = { type: 'tool', trace_id: 'desktop-missing', name: 'plug_observe', timestamp: 1002,
|
||||
payload: { plugin_id: 'desktop-control' } };
|
||||
const phone = { action: 'tap', timestamp: 1000 };
|
||||
const step = { step_id: 'mixed', pre_image_name: 'phone-before', post_image_name: 'phone-after',
|
||||
action_taken: phone, generic_tools: [desktop, missingDesktopImage] };
|
||||
expect(getStepPreImageUrl(step, desktop)).toBeNull();
|
||||
expect(getStepPostImageUrl(step, desktop)).toBe('/images/desktop-screen');
|
||||
expect(getStepPostImageUrl(step, missingDesktopImage)).toBeNull();
|
||||
expect(getStepPreImageUrl(step, phone)).toBe('/images/phone-before');
|
||||
expect(getStepPostImageUrl(step, phone)).toBe('/images/phone-after');
|
||||
});
|
||||
it('should chain pre/post images correctly in FailureAnalyzer multi-step recovery', () => {
|
||||
const stepData = {
|
||||
step_id: 'step-06',
|
||||
|
||||
@@ -606,6 +606,12 @@ interface ResolvedImageEntry {
|
||||
|
||||
const stepImageMapCache = new WeakMap<any, { key: string; map: Map<any, ResolvedImageEntry> }>();
|
||||
|
||||
function pluginSource(item: any): string | null {
|
||||
const act = getActionObject(item);
|
||||
const payload = safeParseJson(act?.payload);
|
||||
const result = safeParseJson(act?.result || payload?.result);
|
||||
return act?.plugin_id || payload?.plugin_id || payload?.action?.plugin_id || result?.plugin_id || null;
|
||||
}
|
||||
|
||||
export function extractItemPreImage(item: any): string | null {
|
||||
const act = getActionObject(item);
|
||||
@@ -754,13 +760,17 @@ export function resolveStepImageMap(stepData: any): Map<any, ResolvedImageEntry>
|
||||
switchedToFailureShot = true;
|
||||
}
|
||||
|
||||
const resolvedPre = explicitPre || currentPreImage;
|
||||
const plugin = pluginSource(act);
|
||||
const resolvedPre = explicitPre || (plugin ? null : currentPreImage);
|
||||
let resolvedPost: string | null = null;
|
||||
let hasExplicitPost = false;
|
||||
|
||||
if (explicitPost) {
|
||||
resolvedPost = explicitPost;
|
||||
hasExplicitPost = true;
|
||||
} else if (plugin) {
|
||||
// A phone step image is never evidence of a plugin/desktop operation.
|
||||
resolvedPost = null;
|
||||
} else if (e.isPrimary && !hadPrimaryFailure) {
|
||||
resolvedPost = stepData.post_image_name || stepData.post_screenshot || null;
|
||||
} else if (e.item === lastActionItem && !hadPrimaryFailure) {
|
||||
@@ -770,7 +780,7 @@ export function resolveStepImageMap(stepData: any): Map<any, ResolvedImageEntry>
|
||||
}
|
||||
|
||||
// "The next pre-action state is the previous post-action state"
|
||||
if (resolvedPost) {
|
||||
if (resolvedPost && !plugin) {
|
||||
currentPreImage = resolvedPost;
|
||||
}
|
||||
|
||||
@@ -782,7 +792,7 @@ export function resolveStepImageMap(stepData: any): Map<any, ResolvedImageEntry>
|
||||
}
|
||||
|
||||
// Ensure stepData.action_taken cross-resolves to the action images
|
||||
if (stepData.action_taken && lastActionItem) {
|
||||
if (stepData.action_taken && lastActionItem && pluginSource(stepData.action_taken) === pluginSource(lastActionItem)) {
|
||||
const lastEntry = map.get(lastActionItem);
|
||||
if (lastEntry) {
|
||||
map.set(stepData.action_taken, lastEntry);
|
||||
@@ -815,6 +825,9 @@ function lookupResolvedImages(stepData: any, actionData?: any): ResolvedImageEnt
|
||||
|
||||
const explicitPre = extractItemPreImage(act);
|
||||
const explicitPost = extractItemPostImage(act);
|
||||
if (pluginSource(act)) {
|
||||
return { pre: explicitPre, post: explicitPost, hasExplicitPost: Boolean(explicitPost) };
|
||||
}
|
||||
const pre = explicitPre || stepData?.pre_image_name || stepData?.pre_screenshot || null;
|
||||
const post = explicitPost || (isGenericToolTrace(actionData, stepData) ? null : (stepData?.post_image_name || stepData?.post_screenshot || null));
|
||||
return { pre, post, hasExplicitPost: Boolean(explicitPost) };
|
||||
|
||||
Reference in new issue
Block a user