diff --git a/.env.example b/.env.example index fbf4cf5..c097bc7 100644 --- a/.env.example +++ b/.env.example @@ -75,3 +75,13 @@ ARTEMIS_DEFAULT_PROFILE=flash ARTEMIS_IMPORT_MAX_ROWS=1000 ARTEMIS_BATCH_MAX_EXECUTIONS=10000 ARTEMIS_IMPORT_MAX_BYTES=10485760 + +# 插件连接与安装预算(修改后重启服务) +ARTEMIS_PLUGIN_MARKETPLACE_URL=https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json +ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS=30 +ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS=60 +ARTEMIS_PLUGIN_SHUTDOWN_TIMEOUT_SECONDS=15 +ARTEMIS_PLUGIN_DOWNLOAD_TIMEOUT_SECONDS=300 +ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES=134217728 +ARTEMIS_PLUGIN_EXTRACT_MAX_BYTES=536870912 +ARTEMIS_PLUGIN_RESULT_MAX_BYTES=16777216 diff --git a/README.md b/README.md index c2b9989..9b7e509 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ uv run artemis mcp --install codex 1.1.1 及更新版本的发布 ZIP 含浅层 Git 工作树;包含独立升级入口的新版源码包可直接用上述脚本升级。1.1.0 及更早 ZIP 不含 Git 信息,需改用 Git 克隆或新版源码包。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。 +2.0.0 开发分支增加了插件页面、电脑/混合执行目标、命名连接配置及共享资源保护。首发电脑插件仍在实测,市场索引暂不收录未完成验证的包。配置和开发约定见 [插件与跨端任务](docs/plugins.md)。 + 更多说明:[批量任务与报告](docs/automation-workspace.md) · [AI 接入](docs/ai-automation.md) · [开发协作指南](AGENTS.md) 基于上游 ARTEMIS 开发,遵循 [Apache-2.0 许可证](LICENSE)。 diff --git a/apps/admin_console/database/repositories/step_repository.py b/apps/admin_console/database/repositories/step_repository.py index 515a987..097669a 100644 --- a/apps/admin_console/database/repositories/step_repository.py +++ b/apps/admin_console/database/repositories/step_repository.py @@ -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", diff --git a/apps/admin_console/routers/automation.py b/apps/admin_console/routers/automation.py index e03d9d8..80e66d6 100644 --- a/apps/admin_console/routers/automation.py +++ b/apps/admin_console/routers/automation.py @@ -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) diff --git a/apps/admin_console/routers/plugins.py b/apps/admin_console/routers/plugins.py new file mode 100644 index 0000000..b9e105d --- /dev/null +++ b/apps/admin_console/routers/plugins.py @@ -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 diff --git a/apps/admin_console/routers/tasks.py b/apps/admin_console/routers/tasks.py index ee2af24..deb1822 100644 --- a/apps/admin_console/routers/tasks.py +++ b/apps/admin_console/routers/tasks.py @@ -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 diff --git a/apps/admin_console/server.py b/apps/admin_console/server.py index f5cb9c8..d0841d4 100644 --- a/apps/admin_console/server.py +++ b/apps/admin_console/server.py @@ -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 diff --git a/apps/admin_console/services/automation_service.py b/apps/admin_console/services/automation_service.py index a7ba0b4..ae5d33c 100644 --- a/apps/admin_console/services/automation_service.py +++ b/apps/admin_console/services/automation_service.py @@ -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: diff --git a/apps/admin_console/services/report_export.py b/apps/admin_console/services/report_export.py index 3dd56ed..1fd5a03 100644 --- a/apps/admin_console/services/report_export.py +++ b/apps/admin_console/services/report_export.py @@ -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 diff --git a/apps/admin_console/services/task_import.py b/apps/admin_console/services/task_import.py index f1f3eab..ab24b36 100644 --- a/apps/admin_console/services/task_import.py +++ b/apps/admin_console/services/task_import.py @@ -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): diff --git a/apps/admin_console/services/task_queue_service.py b/apps/admin_console/services/task_queue_service.py index d55349c..bafcd43 100644 --- a/apps/admin_console/services/task_queue_service.py +++ b/apps/admin_console/services/task_queue_service.py @@ -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: diff --git a/apps/showcase_ui/src/app/app.routes.ts b/apps/showcase_ui/src/app/app.routes.ts index 3c163ec..b22b10f 100644 --- a/apps/showcase_ui/src/app/app.routes.ts +++ b/apps/showcase_ui/src/app/app.routes.ts @@ -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: '' } ]; diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html index ba3ba53..7d8f2eb 100644 --- a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html +++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html @@ -43,7 +43,7 @@ } @if (tab() !== 'reports') { -
+
} @if (tab() === 'batches') {

导入任务

每行一个用例,仅「Artemis提示词」作为任务正文;「任务配置(JSON)」可设置每项模型、模式、超时和录屏。「前置约束提取」仅供查看与搜索。示例在模板的「填写说明」页。

@@ -70,7 +70,7 @@ @if(!validRepetitions()){

循环次数须为正整数,执行总数不超过 {{maxExecutions}}。

} - + }

批次记录

diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.spec.ts b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.spec.ts index 11e6ad2..551eb9a 100644 --- a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.spec.ts +++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.spec.ts @@ -68,7 +68,7 @@ describe('AutomationPanelComponent batch deletion', () => { await new Promise(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' })); }); diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts index 435f246..bbc57a9 100644 --- a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts +++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts @@ -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(); 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(); diff --git a/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.ts b/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.ts index 206f9d5..c6239e9 100644 --- a/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.ts +++ b/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.ts @@ -44,6 +44,9 @@ import { version } from '../../../../package.json'; space_dashboard 工作区 + + extension插件 + v{{version}} `, diff --git a/apps/showcase_ui/src/app/components/task-target/task-target.component.spec.ts b/apps/showcase_ui/src/app/components/task-target/task-target.component.spec.ts new file mode 100644 index 0000000..585f7b9 --- /dev/null +++ b/apps/showcase_ui/src/app/components/task-target/task-target.component.spec.ts @@ -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(); + }); +}); diff --git a/apps/showcase_ui/src/app/components/task-target/task-target.component.ts b/apps/showcase_ui/src/app/components/task-target/task-target.component.ts new file mode 100644 index 0000000..9ceec76 --- /dev/null +++ b/apps/showcase_ui/src/app/components/task-target/task-target.component.ts @@ -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: ` +
+ + +
+ @if (open()) { +
+ + @if (loading()) {

正在读取插件…

} + @if (error()) {

{{ error() }}

} + @if (!automatic) { + @for (plugin of available(); track plugin.manifest.id) { + + } @empty {

没有适用的已启用插件,请先在插件页面安装并授权。

} + } +

手机任务默认不占电脑资源。电脑任务无需连接 Android;混合任务会同时占用所需资源。

+
+ } + `, + 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(); + private api = inject(PluginService); + target: TaskTargetOptions['execution_target'] = 'android'; + automatic = true; selected: Record = {}; + open = signal(false); loading = signal(false); error = signal(''); + plugins = signal([]); + 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})) }) }); } +} diff --git a/apps/showcase_ui/src/app/pages/home/home.component.html b/apps/showcase_ui/src/app/pages/home/home.component.html index de582d0..0ed9969 100644 --- a/apps/showcase_ui/src/app/pages/home/home.component.html +++ b/apps/showcase_ui/src/app/pages/home/home.component.html @@ -1379,6 +1379,7 @@
+ @if (showIntentSuggestion()) { diff --git a/apps/showcase_ui/src/app/pages/home/home.component.ts b/apps/showcase_ui/src/app/pages/home/home.component.ts index ff427f3..81a66e5 100644 --- a/apps/showcase_ui/src/app/pages/home/home.component.ts +++ b/apps/showcase_ui/src/app/pages/home/home.component.ts @@ -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({ 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: () => { diff --git a/apps/showcase_ui/src/app/pages/plugins/plugins.component.html b/apps/showcase_ui/src/app/pages/plugins/plugins.component.html new file mode 100644 index 0000000..cbee4bc --- /dev/null +++ b/apps/showcase_ui/src/app/pages/plugins/plugins.component.html @@ -0,0 +1,57 @@ +
+
ARTEMIS · 扩展能力

插件

为测试接入电脑和其他工具,统一执行并保留证据。

+
+ @if (error()) { } + @if (notice()) {
{{notice()}}
} + @for (resource of blocked(); track resource[0]) { + + } + @if (progress(); as job) { +
{{job.stage}}{{job.progress}}%
+
+ } +
+ + +
+ @if (tab() === 'installed') { + @if (loading()) {

正在读取插件…

} + @else if (!installed().length) {
extension

还没有安装插件

从市场安装经过验证的扩展,或在下方导入自己的插件。

} +
@for (plugin of installed(); track plugin.manifest.id) { +
extension

{{plugin.manifest.name}}

v{{plugin.manifest.version}} · {{plugin.source === 'marketplace' ? '已验证市场插件' : '本地 / 自定义'}}
{{plugin.enabled ? '已启用' : '未启用'}}
+

{{plugin.manifest.description || '暂无描述'}}

{{plugin.profiles.length}} 套连接配置
+
+
+ }
+
高级接入 · 本地插件与自定义 MCP

本地插件需要包含 plugin.json;导入后配置授权范围,再手动启用。

+
+
添加自定义 MCP / Skill
+
+ } @else { + @if (catalogError()) { } + @else if (!market().length) {

首批插件验证中

验证通过的插件会出现在这里;不会自动安装未经验证的版本。

} +
@for (entry of market(); track entry.manifest.id + entry.manifest.version) { +
verified

{{entry.manifest.name}}

v{{entry.manifest.version}} · {{entry.manifest.platforms.join(' / ')}}

{{entry.manifest.description}}

验证记录

{{entry.verification}}

+ }
+ } +
+@if (confirmingResource(); as resource) { + +} +@if (editing(); as plugin) { + +} +@if (removing(); as plugin) { } diff --git a/apps/showcase_ui/src/app/pages/plugins/plugins.component.scss b/apps/showcase_ui/src/app/pages/plugins/plugins.component.scss new file mode 100644 index 0000000..1b5f184 --- /dev/null +++ b/apps/showcase_ui/src/app/pages/plugins/plugins.component.scss @@ -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; } } diff --git a/apps/showcase_ui/src/app/pages/plugins/plugins.component.spec.ts b/apps/showcase_ui/src/app/pages/plugins/plugins.component.spec.ts new file mode 100644 index 0000000..e2eaa12 --- /dev/null +++ b/apps/showcase_ui/src/app/pages/plugins/plugins.component.spec.ts @@ -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([]); + }); +}); diff --git a/apps/showcase_ui/src/app/pages/plugins/plugins.component.ts b/apps/showcase_ui/src/app/pages/plugins/plugins.component.ts new file mode 100644 index 0000000..665e223 --- /dev/null +++ b/apps/showcase_ui/src/app/pages/plugins/plugins.component.ts @@ -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([]); + market = signal([]); + loading = signal(true); + busy = signal(false); + error = signal(''); + catalogError = signal(''); + notice = signal(''); + blocked = signal<[string, string][]>([]); + confirmingResource = signal(null); + tab = signal<'installed' | 'market'>('installed'); + progress = signal(null); + editing = signal(null); + removing = signal(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; + 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) { + 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); } + } +} diff --git a/apps/showcase_ui/src/app/pages/workspace/workspace.component.html b/apps/showcase_ui/src/app/pages/workspace/workspace.component.html index 4cd4265..9a03b2d 100644 --- a/apps/showcase_ui/src/app/pages/workspace/workspace.component.html +++ b/apps/showcase_ui/src/app/pages/workspace/workspace.component.html @@ -185,6 +185,7 @@ limitations under the License.
+