feat: add plugin runtime and cross-device task orchestration

This commit is contained in:
chenmingxuan committed 2026-10-14 20:21:44 +08:00
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",
+1 -1
View File
@@ -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)
+245
View File
@@ -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
+11 -2
View File
@@ -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
+4
View File
@@ -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:
+1
View File
@@ -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: '' }
];
@@ -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>
@@ -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) };