From bb873ec0e0cf0e1ac88ac2059dd2d6c4e06bc8c1 Mon Sep 17 00:00:00 2001 From: chenmingxuan Date: Tue, 27 Oct 2026 21:38:43 +0800 Subject: [PATCH] feat: release 2.3.0 with workspace plugin extensions --- .env.example | 2 +- DESIGN.md | 8 + README.md | 4 +- apps/admin_console/routers/plugins.py | 3 + apps/admin_console/routers/task_extensions.py | 67 +++++ apps/admin_console/server.py | 3 + .../services/automation_store.py | 8 +- apps/admin_console/services/batch_archive.py | 21 +- apps/admin_console/services/report_export.py | 62 +++- .../admin_console/services/task_extensions.py | 162 +++++++++++ apps/showcase_ui/package-lock.json | 4 +- apps/showcase_ui/package.json | 2 +- .../floating-video-player.component.html | 10 +- .../floating-video-player.component.scss | 10 +- .../floating-video-player.component.spec.ts | 4 +- .../floating-video-player.component.ts | 15 +- .../report-markdown.component.ts | 2 +- .../task-extension-panels.component.html | 99 +++++++ .../task-extension-panels.component.scss | 54 ++++ .../task-extension-panels.component.spec.ts | 190 ++++++++++++ .../task-extension-panels.component.ts | 134 +++++++++ .../task-plugins/task-plugins.component.ts | 2 +- .../app/pages/plugins/plugins.component.html | 2 +- .../src/app/services/plugin.service.ts | 8 +- .../app/services/task-extensions.service.ts | 48 ++++ artemis/config/settings.py | 2 +- artemis/plugins/installer.py | 7 +- artemis/plugins/models.py | 13 +- artemis/plugins/store.py | 5 + artemis/plugins/workspace.py | 68 +++++ docs/plugin-development.md | 4 +- docs/plugins.md | 6 +- docs/releases/2.3.0.md | 34 +++ docs/workspace-plugins.md | 77 +++++ pyproject.toml | 2 +- scripts/check_plugin.py | 6 +- skills/artemis-use/SKILL.md | 1 + .../references/plugin-development.md | 4 +- .../artemis-use/references/plugin-manifest.md | 2 + .../references/workspace-plugins.md | 78 +++++ skills/artemis-use/scripts/check_plugin.py | 6 +- .../admin_console/test_task_extensions.py | 272 ++++++++++++++++++ tests/unit/test_plugins.py | 32 +++ uv.lock | 2 +- 44 files changed, 1497 insertions(+), 48 deletions(-) create mode 100644 apps/admin_console/routers/task_extensions.py create mode 100644 apps/admin_console/services/task_extensions.py create mode 100644 apps/showcase_ui/src/app/components/task-extension-panels/task-extension-panels.component.html create mode 100644 apps/showcase_ui/src/app/components/task-extension-panels/task-extension-panels.component.scss create mode 100644 apps/showcase_ui/src/app/components/task-extension-panels/task-extension-panels.component.spec.ts create mode 100644 apps/showcase_ui/src/app/components/task-extension-panels/task-extension-panels.component.ts create mode 100644 apps/showcase_ui/src/app/services/task-extensions.service.ts create mode 100644 artemis/plugins/workspace.py create mode 100644 docs/releases/2.3.0.md create mode 100644 docs/workspace-plugins.md create mode 100644 skills/artemis-use/references/workspace-plugins.md create mode 100644 tests/unit/admin_console/test_task_extensions.py diff --git a/.env.example b/.env.example index ef935a5..809a3a0 100644 --- a/.env.example +++ b/.env.example @@ -89,7 +89,7 @@ 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_MARKETPLACE_URL=https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS=30 ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS=60 ARTEMIS_PLUGIN_SHUTDOWN_TIMEOUT_SECONDS=15 diff --git a/DESIGN.md b/DESIGN.md index 437837a..b2142d8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -183,3 +183,11 @@ Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配 - 保留可见焦点、禁用反馈和减少动画偏好;录制红点表示实际录制,等待或执行中使用各自文案。 - 源码已更新而页面仍旧时,先核对服务目录、构建与缓存;不通过反复增加样式覆盖掩盖旧构建问题。 - 视觉变化验证代表性宽度、长标题、加载、等待回答、错误与展开状态;文档格式检查不等于渲染验证。开发检查按[前端指南](apps/showcase_ui/AGENTS.md)执行。 + +## Workspace plugin extensions + +工作区扩展使用声明式清单版本 3,宿主提供回放面板、人工表单、任务存储和报告接口;不运行插件页面脚本,不另建队列或结果库。纯工作区插件不加入模型执行选择。接口定义见 [工作区插件](docs/workspace-plugins.md)。 + +回放两侧面板默认收起、独立展开,普通浮窗和影院模式共用;插件声明期望/最小/最大宽度,宿主按视口收缩,窄屏一次展开一侧,始终保持面板位于回放的左侧或右侧。任务需求只展示本次运行最初的提示词,复用安全 Markdown 渲染。人工表单字段均可留空,不调用模型、不覆盖已有执行结论或人工复验。 + +评审按单次任务和插件 ID 保存版本历史;运行中只读,终态可编辑或清空。停用/卸载隐藏面板与新导出字段但保留记录,任务删除时同步清理。报告只采用已安装且启用的贡献,空值保持空白;修改记录或插件状态使后续报告缓存失效。 diff --git a/README.md b/README.md index d5b5079..ebeaadd 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ 基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。 -**2.2.0**:新增无需手机在线的仅 PC 测试、全局串行队列、暗色模式、Markdown 表格及可折叠复制的工具结果;修复安卓输入清空、观测恢复、排队状态与最终验收匹配。升级内容、验证范围与配套 Cua 插件见 [2.2.0 发行说明](docs/releases/2.2.0.md)。上一版本见 [2.1.0](docs/releases/2.1.0.md);Python 3.12 启动修复见 [2.0.1](docs/releases/2.0.1.md),探索、插件和精选任务等功能见 [2.0.0](docs/releases/2.0.0.md)。 +**2.3.0**:新增工作区插件接口,支持步骤回放左右侧栏、Markdown 初始需求、人工评审及报告字段扩展;配套「任务人工评审 1.0.0」从插件市场独立安装。新增 gpt-6.1-terra,探索默认 high。见 [2.3.0 发行说明](docs/releases/2.3.0.md);上一版本见 [2.2.0](docs/releases/2.2.0.md)。 ## 主要功能 - **自然语言任务**:Flash 适合明确的操作流程,Pro 适合需要计划、分支探索和验证的任务。 - **交互探索**:边执行边交流,也可录制人工示范;探索成功后生成一个可编辑、复跑的用例。默认使用独立的 `gpt-6.1-terra / high` 配置,超时暂停并保留进度。见 [探索模式](docs/exploration-mode.md)。 -- **插件扩展**:在插件市场安装并启用电脑控制等能力,AI 在安卓任务中按需使用;手机、电脑的步骤与证据统一留档。见 [插件与跨端任务](docs/plugins.md)。 +- **插件扩展**:安装并启用电脑控制等执行能力,或使用工作区插件为回放和报告增加人工评审。手机、电脑的步骤与证据统一留档。见 [插件与跨端任务](docs/plugins.md)和[工作区插件接口](docs/workspace-plugins.md)。 - **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。 - **批量测试**:按新版 Excel 模板导入提示词,每项可独立指定模型、模式和参数;任务可暂停、补充指导并继续。 - **精选任务**:收藏单任务、批次或探索用例,在首页一键复跑,默认沿用已保存的配置。 diff --git a/apps/admin_console/routers/plugins.py b/apps/admin_console/routers/plugins.py index b9e105d..69ae102 100644 --- a/apps/admin_console/routers/plugins.py +++ b/apps/admin_console/routers/plugins.py @@ -214,6 +214,9 @@ async def diagnose_plugin(plugin_id: str, request: DiagnoseRequest): try: store = get_store() manifest = PluginManifest.model_validate(store.get(plugin_id)["manifest"]) + if manifest.workspace and manifest.server is None and manifest.skill is None: + store.resolve(PluginSelection(id=plugin_id, profile=request.profile)) + return {"ok": True, "tools": {}, "message": "工作区扩展已就绪"} runtime = PluginRuntime( [PluginSelection(id=plugin_id, profile=request.profile)], store=store, diff --git a/apps/admin_console/routers/task_extensions.py b/apps/admin_console/routers/task_extensions.py new file mode 100644 index 0000000..12ad3e8 --- /dev/null +++ b/apps/admin_console/routers/task_extensions.py @@ -0,0 +1,67 @@ +"""Workspace UI extension API. No model or device actions are dispatched.""" + +from uuid import UUID + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, ConfigDict, Field + +from apps.admin_console.services import batch_archive, report_export, task_extensions +from apps.admin_console.services.automation_store import store + +router = APIRouter(prefix="/api/task-extensions", tags=["task-extensions"]) + + +class ExtensionUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + values: dict[str, str] = Field(max_length=20) + expected_revision: int = Field(ge=0) + request_id: UUID + + +@router.get("/{session_id}") +def read_extensions(session_id: str): + try: + return { + **task_extensions.task_context(session_id, store), + "plugins": task_extensions.contributions(session_id, store), + } + except ValueError as exc: + raise HTTPException(404, str(exc)) from exc + + +@router.put("/{session_id}/{plugin_id}") +def update_extension(session_id: str, plugin_id: str, value: ExtensionUpdate): + job = store.job(session_id) or {} + batch_id = job.get("batch_id") + if ( + batch_id + and batch_archive.status(batch_id, store.get("batch", batch_id) or {})["status"] + == "preparing" + ): + raise HTTPException(409, "报告正在打包,请稍后保存评审") + with ( + report_export.export_lock( + *(["batch:" + batch_id] if batch_id else []), "session:" + session_id + ), + task_extensions.plugin_store().mutation_lock, + ): + context = read_extensions(session_id) + if not context["editable"]: + raise HTTPException(409, "请在任务结束后填写评审") + manifest = next((m for m in task_extensions.active_manifests() if m.id == plugin_id), None) + if manifest is None: + raise HTTPException(409, "插件未安装或未启用,请刷新页面") + try: + task_extensions.save_values( + store, + session_id, + manifest, + value.values, + value.expected_revision, + str(value.request_id), + ) + except ValueError as exc: + raise HTTPException(409, str(exc)) from exc + if batch_id: + batch_archive.forget(batch_id) + return read_extensions(session_id) diff --git a/apps/admin_console/server.py b/apps/admin_console/server.py index 862ba6c..f0c91cb 100644 --- a/apps/admin_console/server.py +++ b/apps/admin_console/server.py @@ -284,6 +284,9 @@ app.include_router(favorites_router) from apps.admin_console.routers.plugins import router as plugins_router app.include_router(plugins_router) +from apps.admin_console.routers.task_extensions import router as task_extensions_router + +app.include_router(task_extensions_router) from apps.admin_console.routers.exploration import router as exploration_router diff --git a/apps/admin_console/services/automation_store.py b/apps/admin_console/services/automation_store.py index 5a6f0a1..4ff7933 100644 --- a/apps/admin_console/services/automation_store.py +++ b/apps/admin_console/services/automation_store.py @@ -294,7 +294,7 @@ class AutomationStore: if batch.get("status") != "deleting": raise ValueError("批次尚未准备删除") conn.execute( - "DELETE FROM automation_records WHERE kind='case_review' AND id IN " + "DELETE FROM automation_records WHERE kind IN ('case_review','task_extensions') AND id IN " "(SELECT session_id FROM automation_jobs WHERE batch_id=?)", (batch_id,), ) @@ -312,13 +312,15 @@ class AutomationStore: if session_id: conn.execute("DELETE FROM automation_jobs WHERE session_id=?", (session_id,)) conn.execute( - "DELETE FROM automation_records WHERE kind='case_review' AND id=?", + "DELETE FROM automation_records WHERE kind IN ('case_review','task_extensions') AND id=?", (session_id,), ) else: conn.execute("DELETE FROM automation_jobs") conn.execute("DELETE FROM automation_records WHERE kind='batch'") - conn.execute("DELETE FROM automation_records WHERE kind='case_review'") + conn.execute( + "DELETE FROM automation_records WHERE kind IN ('case_review','task_extensions')" + ) conn.commit() diff --git a/apps/admin_console/services/batch_archive.py b/apps/admin_console/services/batch_archive.py index 1aaa9f2..a0c291f 100644 --- a/apps/admin_console/services/batch_archive.py +++ b/apps/admin_console/services/batch_archive.py @@ -15,7 +15,7 @@ def archive_path(batch_id: str): def cached(batch_id: str, batch: dict) -> bool: return bool(batch.get("report_ready")) and report_export.reusable( - archive_path(batch_id), report_export.BATCH_ARCHIVE_SIGNATURE, "completed" + archive_path(batch_id), report_export.batch_archive_signature(batch_id), "completed" ) @@ -41,8 +41,14 @@ def _snapshot(state: dict) -> dict: def status(batch_id: str, batch: dict) -> dict: with _lock: state = _states.get(batch_id) - if state: + if state and state["status"] != "ready": return _snapshot(state) + if ( + state + and state.get("signature") == report_export.batch_archive_signature(batch_id) + and archive_path(batch_id).is_file() + ): + return _snapshot(state) if cached(batch_id, batch): return { "status": "ready", @@ -65,11 +71,12 @@ def status(batch_id: str, batch: dict) -> dict: def prepare(batch_id: str, batch: dict) -> dict: + cache_ready = cached(batch_id, batch) with _lock: current = _states.get(batch_id) if current and current["status"] == "preparing": return _snapshot(current) - if cached(batch_id, batch): + if cache_ready: _states.pop(batch_id, None) return status_from_cache() state = { @@ -88,11 +95,17 @@ def prepare(batch_id: str, batch: dict) -> dict: def run() -> None: try: with report_export.export_lock("batch:" + batch_id): + signature = report_export.batch_archive_signature(batch_id) latest = report_export.store.get("batch", batch_id) if not (latest and cached(batch_id, latest)): report_export.export_batch(batch_id, progress=update) with _lock: - state.update(status="ready", stage="报告包已就绪", finished_at=time.monotonic()) + state.update( + status="ready", + stage="报告包已就绪", + finished_at=time.monotonic(), + signature=signature, + ) except Exception as exc: with _lock: state.update( diff --git a/apps/admin_console/services/report_export.py b/apps/admin_console/services/report_export.py index bd0573f..241614f 100644 --- a/apps/admin_console/services/report_export.py +++ b/apps/admin_console/services/report_export.py @@ -15,6 +15,7 @@ from apps.admin_console.services.export_cache import ( ) from apps.admin_console.services.request_diagnostics import diagnostics_json from apps.admin_console.services.task_lifecycle import guidance_details +from apps.admin_console.services import task_extensions from docx import Document from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT @@ -36,6 +37,31 @@ from apps.admin_console.services.case_review import reviewed_result EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports" BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-2" + + +def batch_archive_signature(batch_id: str | None = None) -> str: + signature = task_extensions.installation_signature() + if batch_id: + with store.connection() as conn: + changes = conn.execute( + "SELECT r.id,r.updated_at FROM automation_records r " + "JOIN automation_jobs j ON j.session_id=r.id " + "WHERE r.kind='task_extensions' AND j.batch_id=? ORDER BY r.id", + (batch_id,), + ).fetchall() + if changes: + signature += ":" + fingerprint({"reviews": [tuple(row) for row in changes]}) + return BATCH_ARCHIVE_SIGNATURE + (":" + signature if signature else "") + + +def extension_rows(report: dict) -> list[tuple[str, str, str]]: + return [ + (section["name"], field["label"], field["value"]) + for section in report.get("task_extensions", []) + for field in section["fields"] + ] + + STATUS = { "completed": "执行完成", "success": "执行完成", @@ -239,6 +265,7 @@ def session_report(session_id: str, db_path=None) -> dict: "subcategory": case.get("subcategory", ""), "preconditions": case.get("preconditions", ""), "task_config": case.get("task_config") or {}, + "task_extensions": task_extensions.report_sections(session_id, store), } @@ -659,6 +686,11 @@ def word_report(report: dict, path: Path): doc.add_paragraph("任务配置:" + config_text(report["task_config"])) doc.add_heading("结论与发现", 1) add_markdown_content(doc, report["content"]) + if review_rows := extension_rows(report): + doc.add_heading("人工评审(插件)", 1) + word_table( + doc, [(f"{name} · {label}", value) for name, label, value in review_rows], (5.5, 11) + ) doc.add_heading("执行步骤与证据", 1) for step in report["steps"]: paragraph = doc.add_paragraph() @@ -792,6 +824,10 @@ def excel_report(report: dict, path: Path): wb.active.title = "任务结果" sheet_rows(wb.active, [("项目", "内容"), *metadata(report)], (22, 82)) sheet_rows(wb.create_sheet("结论"), conclusion_rows(report["content"]), (15, 96)) + if review_rows := extension_rows(report): + sheet_rows( + wb.create_sheet("人工评审"), [("插件", "项目", "内容"), *review_rows], (24, 30, 82) + ) table_number = 0 for kind, rows in markdown_blocks(report["content"]): if kind == "table": @@ -1064,7 +1100,9 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None directory = EXPORT_ROOT / "batches" / batch_id directory.mkdir(parents=True, exist_ok=True) formats = batch["formats"] - records = [session_report(job["session_id"]) for job in store.jobs(batch_id)] + with task_extensions.plugin_store().mutation_lock: + signature = batch_archive_signature(batch_id) + records = [session_report(job["session_id"]) for job in store.jobs(batch_id)] if progress: progress(0, len(records), "正在生成批次汇总") rows = [("任务编号", "任务名称", "执行状态", "验证结论", "异常原因")] @@ -1078,6 +1116,17 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None assert wb.active is not None wb.active.title = "批次结果" sheet_rows(wb.active, rows, (40, 48, 16, 28, 70)) + review_rows = [ + (record["id"], record["name"], *row) + for record in records + for row in extension_rows(record) + ] + if review_rows: + sheet_rows( + wb.create_sheet("人工评审"), + [("任务编号", "任务名称", "插件", "项目", "内容"), *review_rows], + (40, 40, 24, 30, 70), + ) path = directory / "批次结果.xlsx" wb.save(path) outputs.append(path) @@ -1088,6 +1137,15 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None f"批次编号:{batch_id};共 {len(records)} 个任务。验证通过与执行完成分别统计。" ) word_table(doc, rows, (3.5, 5.4, 2.5, 3.5, 7.3), header=True) + for record in records: + if review_rows := extension_rows(record): + doc.add_heading("人工评审 · " + record["name"], 1) + doc.add_paragraph("任务编号:" + record["id"]) + word_table( + doc, + [(f"{name} · {label}", value) for name, label, value in review_rows], + (7, 15.2), + ) path = directory / "批次汇总.docx" doc.save(str(path)) outputs.append(path) @@ -1146,5 +1204,5 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None if progress: progress(len(records), len(records), "正在完成压缩包") temporary.replace(target) - remember(target, BATCH_ARCHIVE_SIGNATURE) + remember(target, signature) return target diff --git a/apps/admin_console/services/task_extensions.py b/apps/admin_console/services/task_extensions.py new file mode 100644 index 0000000..407cc79 --- /dev/null +++ b/apps/admin_console/services/task_extensions.py @@ -0,0 +1,162 @@ +"""Human-authored task metadata owned by the host, scoped to installed plugins.""" + +import hashlib +import json +import time +from functools import lru_cache + +from apps.admin_console.services.automation_store import AutomationStore, identifier +from apps.admin_console.database.repositories.session_repository import SessionRepository +from artemis.plugins.models import PluginManifest +from artemis.plugins.store import PluginStore + +TERMINAL = {"completed", "success", "failed", "cancelled", "interrupted", "skipped"} + + +@lru_cache(maxsize=1) +def plugin_store() -> PluginStore: + return PluginStore() + + +def active_manifests() -> list[PluginManifest]: + return [ + PluginManifest.model_validate(plugin["manifest"]) + for plugin in plugin_store().list() + if plugin["enabled"] and plugin["manifest"].get("workspace") + ] + + +def installation_signature() -> str: + manifests = [manifest.model_dump(mode="json") for manifest in active_manifests()] + return ( + hashlib.sha256(json.dumps(manifests, sort_keys=True).encode()).hexdigest() + if manifests + else "" + ) + + +def task_context(session_id: str, store: AutomationStore) -> dict: + identifier(session_id) + session = SessionRepository(store.path).get_session_by_id(session_id) or {} + job = store.job(session_id) or {} + if not session and not job: + raise ValueError("任务记录不存在") + return { + "session_id": session_id, + "initial_prompt": session.get("initial_goal") or (job.get("case") or {}).get("goal", ""), + "editable": (job.get("status") or session.get("status")) in TERMINAL, + } + + +def contributions(session_id: str, store: AutomationStore) -> list[dict]: + record = store.get("task_extensions", session_id) or {} + result = [] + for manifest in active_manifests(): + assert manifest.workspace is not None + saved = record.get(manifest.id) or {} + values = saved.get("values", {}) + result.append( + { + "plugin_id": manifest.id, + "name": manifest.name, + "version": manifest.version, + "workspace": manifest.workspace.model_dump(), + "revision": saved.get("revision", 0), + "values": { + field.id: values.get(field.id, "") for field in manifest.workspace.fields + }, + "history": saved.get("history", []), + } + ) + return result + + +def report_sections(session_id: str, store: AutomationStore) -> list[dict]: + return [ + { + "plugin_id": item["plugin_id"], + "name": item["name"], + "version": item["version"], + "revision": item["revision"], + "fields": [ + {"id": field["id"], "label": field["label"], "value": item["values"][field["id"]]} + for field in item["workspace"]["fields"] + if field["report"] + ], + } + for item in contributions(session_id, store) + if any(field["report"] for field in item["workspace"]["fields"]) + ] + + +def save_values( + store: AutomationStore, + session_id: str, + manifest: PluginManifest, + values: dict[str, str], + expected_revision: int, + request_id: str, +) -> None: + """CAS + request receipts prevent lost edits and repeated history entries.""" + identifier(session_id) + assert manifest.workspace is not None + fields = {field.id: field for field in manifest.workspace.fields} + if values.keys() - fields.keys(): + raise ValueError("包含未声明的扩展字段") + for key, value in values.items(): + field = fields[key] + if len(value) > field.max_length or ( + field.kind == "choice" and value and value not in field.options + ): + raise ValueError(f"字段 {field.label} 的内容不符合插件声明") + now = time.time() + with store.connection() as conn: + conn.execute("BEGIN IMMEDIATE") + row = conn.execute( + "SELECT payload FROM automation_records WHERE kind='task_extensions' AND id=?", + (session_id,), + ).fetchone() + record = json.loads(row[0]) if row else {} + saved = record.get(manifest.id, {"revision": 0, "values": {}, "history": []}) + for entry in saved["history"]: + if entry["request_id"] == request_id: + if entry["patch"] != values: + raise ValueError("同一保存请求不能提交不同内容") + return + if saved["revision"] != expected_revision: + raise ValueError("评审已在其他页面修改,请重新加载后保存") + updated = {**saved["values"], **values} + revision = saved["revision"] + 1 + saved["history"].append( + { + "revision": revision, + "saved_at": now, + "request_id": request_id, + "patch": values, + "values": updated, + "plugin_version": manifest.version, + "fields": [field.model_dump() for field in manifest.workspace.fields], + } + ) + record[manifest.id] = {**saved, "revision": revision, "values": updated} + conn.execute( + "INSERT INTO automation_records VALUES ('task_extensions',?,?,?) " + "ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at", + (session_id, json.dumps(record, ensure_ascii=False), now), + ) + job = conn.execute( + "SELECT batch_id FROM automation_jobs WHERE session_id=?", (session_id,) + ).fetchone() + if job and job[0]: + batch = conn.execute( + "SELECT payload FROM automation_records WHERE kind='batch' AND id=?", (job[0],) + ).fetchone() + if not batch or json.loads(batch[0]).get("status") == "deleting": + raise ValueError("批次正在删除,不能保存评审") + payload = json.loads(batch[0]) + payload["report_ready"] = False + conn.execute( + "UPDATE automation_records SET payload=?,updated_at=? WHERE kind='batch' AND id=?", + (json.dumps(payload, ensure_ascii=False), now, job[0]), + ) + conn.commit() diff --git a/apps/showcase_ui/package-lock.json b/apps/showcase_ui/package-lock.json index cf9f0a4..4726712 100644 --- a/apps/showcase_ui/package-lock.json +++ b/apps/showcase_ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "artemis-showcase-ui", - "version": "2.2.0", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "artemis-showcase-ui", - "version": "2.2.0", + "version": "2.3.0", "dependencies": { "@angular/cdk": "^22.1.5", "@angular/common": "^22.1.4", diff --git a/apps/showcase_ui/package.json b/apps/showcase_ui/package.json index dbb9e1f..5cea666 100644 --- a/apps/showcase_ui/package.json +++ b/apps/showcase_ui/package.json @@ -1,6 +1,6 @@ { "name": "artemis-showcase-ui", - "version": "2.2.0", + "version": "2.3.0", "scripts": { "ng": "ng", "start": "ng serve --proxy-config proxy.conf.json", diff --git a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html index ca037fa..617cff3 100644 --- a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html +++ b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html @@ -27,7 +27,9 @@ limitations under the License. class="floating-video-wrapper" [class.minimized]="agentService.isVideoMinimized()" [class.theater]="isTheaterMode()" - [style.left.px]="!isTheaterMode() ? posX() : null" + [style.--extension-width]="extensionWidth() + 'px'" + [style.left.px]="!isTheaterMode() ? windowLeft() : null" + [style.width.px]="isTheaterMode() || agentService.isVideoMinimized() ? null : 400 + extensionWidth()" [style.top.px]="!isTheaterMode() ? posY() : null" [style.--player-top]="posY() + 'px'" > @@ -42,6 +44,9 @@ limitations under the License.
+ @if (extensionPanels(); as panels) { + + } @if (agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()) {
@@ -117,6 +122,8 @@ limitations under the License. } @if (!agentService.isVideoMinimized()) { +
@if (agentService.recordingPlaybackStatus() === 'live') { @@ -488,6 +495,7 @@ limitations under the License. } }
+
}
} diff --git a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.scss b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.scss index cab474a..0a8d7c8 100644 --- a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.scss +++ b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.scss @@ -28,6 +28,7 @@ } .floating-video-wrapper { + container-type: inline-size; position: fixed; width: 400px; max-width: calc(100vw - 32px); @@ -60,8 +61,8 @@ left: 50% !important; top: 50% !important; transform: translate(-50%, -50%); - width: 85vw; - max-width: 960px; + width: min(calc(85vw + var(--extension-width, 0px)), calc(100vw - 32px)); + max-width: calc(960px + var(--extension-width, 0px)); height: auto; max-height: 88vh; box-shadow: 0 24px 60px -12px rgba(15, 23, 42, 0.35); @@ -805,16 +806,21 @@ .controls-row { display: flex; + flex-wrap: wrap; + gap: 8px; align-items: center; justify-content: space-between; .controls-left, .controls-right { display: flex; + flex-wrap: wrap; + white-space: nowrap; align-items: center; gap: 8px; } .ctrl-btn { + flex-shrink: 0; background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.75); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); diff --git a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.spec.ts b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.spec.ts index 7f2b5dd..695d552 100644 --- a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.spec.ts +++ b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.spec.ts @@ -1,3 +1,5 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { signal } from '@angular/core'; import { AgentService, RecordingSource, VideoSegment } from '../../services/agent.service'; @@ -59,7 +61,7 @@ describe('FloatingVideoPlayerComponent video markers', () => { agentService = createAgentService(); TestBed.configureTestingModule({ imports: [FloatingVideoPlayerComponent], - providers: [{ provide: AgentService, useValue: agentService }] + providers: [provideHttpClient(), provideHttpClientTesting(), { provide: AgentService, useValue: agentService }] }); fixture = TestBed.createComponent(FloatingVideoPlayerComponent); diff --git a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts index 3a5ebf2..bc876aa 100644 --- a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts +++ b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts @@ -26,10 +26,13 @@ import { HostListener, effect, untracked, - OnDestroy + OnDestroy, + viewChild } from '@angular/core'; +import { TaskExtensionPanelsComponent } from '../task-extension-panels/task-extension-panels.component'; import { FormsModule } from '@angular/forms'; +import { NgTemplateOutlet } from '@angular/common'; import { AgentService } from '../../services/agent.service'; import { StepReplayFrame } from '../../core/models/stream.model'; import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util'; @@ -40,13 +43,19 @@ import { buildVideoStepMarkers, VideoStepMarker } from '../../utils/video-step-m @Component({ selector: 'app-floating-video-player', standalone: true, - imports: [FormsModule], + imports: [FormsModule, NgTemplateOutlet, TaskExtensionPanelsComponent], templateUrl: './floating-video-player.component.html', styleUrl: './floating-video-player.component.scss', changeDetection: ChangeDetectionStrategy.OnPush }) export class FloatingVideoPlayerComponent implements OnDestroy { public agentService = inject(AgentService); + public extensionWidth = signal(0); + public extensionPanels = viewChild(TaskExtensionPanelsComponent); + public windowLeft(): number { + const width = this.agentService.isVideoMinimized() ? 280 : 400 + this.extensionWidth(); + return Math.max(16, Math.min(this.posX(), window.innerWidth - Math.min(width, window.innerWidth - 32) - 16)); + } private zone = inject(NgZone); @ViewChild('videoRef') videoRef?: ElementRef; @@ -327,7 +336,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy { this.isDragging = true; this.dragStartX = event.clientX; this.dragStartY = event.clientY; - this.initialPosX = this.posX(); + this.initialPosX = this.windowLeft(); this.initialPosY = this.posY(); event.preventDefault(); diff --git a/apps/showcase_ui/src/app/components/report-markdown/report-markdown.component.ts b/apps/showcase_ui/src/app/components/report-markdown/report-markdown.component.ts index 334c684..1974595 100644 --- a/apps/showcase_ui/src/app/components/report-markdown/report-markdown.component.ts +++ b/apps/showcase_ui/src/app/components/report-markdown/report-markdown.component.ts @@ -21,7 +21,7 @@ import { renderMarkdownToHtml } from '../../utils/markdown-parser.util'; li { margin: 4px 0; } blockquote { margin: 10px 0; padding: 6px 12px; border-left: 3px solid var(--app-primary-border, #93c5fd); background: var(--app-surface-soft, #f8fafc); } code { font-family: Consolas, monospace; font-size: .92em; border-radius: 4px; padding: 1px 4px; background: var(--app-surface-soft, #f1f5f9); } - pre { max-width: 100%; overflow: auto; padding: 10px 12px; border-radius: 6px; background: var(--app-surface-soft, #f1f5f9); white-space: pre; } + pre { box-sizing: border-box; max-width: 100%; overflow: auto; padding: 10px 12px; border-radius: 6px; background: var(--app-surface-soft, #f1f5f9); white-space: pre; } pre code { padding: 0; background: none; } .md-table-scroll { max-width: 100%; overflow-x: auto; } table { width: 100%; border-collapse: collapse; font-size: 12px; } diff --git a/apps/showcase_ui/src/app/components/task-extension-panels/task-extension-panels.component.html b/apps/showcase_ui/src/app/components/task-extension-panels/task-extension-panels.component.html new file mode 100644 index 0000000..a399959 --- /dev/null +++ b/apps/showcase_ui/src/app/components/task-extension-panels/task-extension-panels.component.html @@ -0,0 +1,99 @@ +@if (enabled() && error()) { +
{{ error() }}
+} + +@if (data()?.plugins?.length) { + +} + +
+ @if (panels('left').length) { + + } +
+ @if (panels('right').length) { + + } +
+ + +
+

{{ panel.title }}

{{ panel.kind === 'initial_prompt' ? '原始提示词' : '人工填写 · 选填' }}
+ @if (panel.kind === 'initial_prompt') { + @if (data()?.initial_prompt) { + + } @else {

该任务未记录初始需求提示词。

} + } @else { + @if (!data()?.editable) {

任务结束后可填写评审。

} +
+ @for (field of plugin.workspace.fields; track field.id) { +
+ {{ field.label }} + @if (field.description) { {{ field.description }} } + @if (field.kind === 'choice') { +
+ @for (option of field.options; track option) { + + } +
+ } @else { + + } +
+ } +
+ + + @if (drafts[plugin.plugin_id].dirty) { 有未保存内容 } +
+
+ @if (notice()) {

{{ notice() }}

} + @if (plugin.history.length) { +
修改记录({{ plugin.history.length }}) + @for (entry of plugin.history; track entry.revision) { +
第 {{ entry.revision }} 次 · {{ entry.saved_at * 1000 | date:'yyyy-MM-dd HH:mm:ss' }} + @for (field of entry.fields; track field.id) {

{{ field.label }}:{{ entry.values[field.id] || '未填写' }}

} +
+ } +
+ } + } +
+
diff --git a/apps/showcase_ui/src/app/components/task-extension-panels/task-extension-panels.component.scss b/apps/showcase_ui/src/app/components/task-extension-panels/task-extension-panels.component.scss new file mode 100644 index 0000000..fe3f623 --- /dev/null +++ b/apps/showcase_ui/src/app/components/task-extension-panels/task-extension-panels.component.scss @@ -0,0 +1,54 @@ +:host { display: block; width: 100%; min-width: 0; min-height: 0; overflow: auto; container-type: inline-size; color: var(--app-text-control, #1e293b); } +.panel-toggles { display: flex; gap: 4px; padding-right: 8px; margin-right: 4px; border-right: 1px solid var(--app-border, #e2e8f0); } +button { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 9px; border: 1px solid var(--app-border-control, #dbe3ec); border-radius: 8px; color: inherit; background: var(--app-surface, #fff); font: inherit; font-size: 12px; line-height: 20px; cursor: pointer; } +.panel-toggle { height: 28px; padding: 4px 7px; border-color: transparent; background: transparent; color: var(--app-text-secondary, #64748b); gap: 4px; } +.panel-toggle:hover { background: var(--app-surface-soft, #f1f5f9); } +.panel-toggle.active { background: var(--app-primary-soft, #eff6ff); color: var(--app-primary-text, #1a73e8); } +.panel-toggle svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.4; } +.panel-toggle .material-symbols-outlined { font-size: 15px; } +button.save { background: #1a73e8; border-color: #1a73e8; color: white; padding: 8px 16px; font-weight: 500; } +button:disabled { opacity: .6; cursor: default; } +button:focus-visible, textarea:focus-visible { outline: 2px solid var(--app-primary-text, #1a73e8); outline-offset: 2px; } +.material-symbols-outlined { font-size: 18px; } +.extension-layout { display: grid; grid-template-columns: minmax(0, 1fr); min-height: 0; } +.has-left { grid-template-columns: minmax(0, min(var(--left-width), 55%)) minmax(0, 1fr); } +.has-right { grid-template-columns: minmax(0, 1fr) minmax(0, min(var(--right-width), 55%)); } +.has-left.has-right { grid-template-columns: minmax(0, min(var(--left-width), 32%)) minmax(0, 1fr) minmax(0, min(var(--right-width), 32%)); } +.player-content { min-width: 0; overflow: hidden; } +.panel-column { min-width: 0; max-height: min(70dvh, 680px); overflow: auto; padding: 20px; user-select: text; background: var(--app-surface, #fff); scrollbar-width: thin; scrollbar-color: var(--app-border-control, #cbd5e1) transparent; } +.left-column { border-right: 1px solid var(--app-border, #e2e8f0); } +.right-column { border-left: 1px solid var(--app-border, #e2e8f0); } +section + section { border-top: 1px solid var(--app-border, #e2e8f0); padding-top: 12px; margin-top: 12px; } +header { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 20px; } +h3 { margin: 0; font-size: 14px; color: var(--app-text-strong, #0f172a); } +.panel-kind { font-size: 11px; color: var(--app-text-muted, #64748b); } +.muted { display: block; font-size: 12px; color: var(--app-text-secondary, #64748b); line-height: 1.6; } +fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } +.field { display: grid; gap: 8px; margin: 0 0 14px; } +.choice-field:not(:first-child) { border-top: 1px solid var(--app-border, #e2e8f0); padding-top: 22px; margin-top: 22px; } +.field-label { font-size: 13px; font-weight: 600; } +.note-field .field-label { font-size: 11px; font-weight: 400; color: var(--app-text-muted, #64748b); } +.choice-group { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px; margin-top: 4px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 10px; background: var(--app-surface-soft, #f8fafc); } +.choice-button { flex: 1; min-height: 34px; border-color: transparent; background: transparent; color: var(--app-text-secondary, #64748b); font-size: 13px; transition: background .15s, color .15s; } +.choice-icon { display: block; flex: 0 0 16px; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } +.choice-label { display: block; line-height: 20px; } +.choice-button:hover { background: var(--app-surface-raised, #e2e8f0); } +.choice-button.selected { border-color: var(--app-primary-border, #93c5fd); background: var(--app-primary-soft, #eff6ff); color: var(--app-primary-text, #1a73e8); font-weight: 600; } +.choice-button.pass.selected { border-color: var(--app-success-border, #a7f3d0); background: var(--app-success-soft, #ecfdf5); color: var(--app-success-text, #047857); } +.choice-button.failed.selected { border-color: var(--app-error-border, #fecdd3); background: var(--app-error-soft, #fff1f2); color: var(--app-error-text, #be123c); } +textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 10px 12px; font: inherit; font-size: 12px; line-height: 1.6; color: inherit; border: 1px solid var(--app-border, #e2e8f0); border-radius: 8px; background: var(--app-surface-soft, #f8fafc); resize: vertical; min-height: 66px; max-height: 200px; } +textarea::placeholder { color: var(--app-text-muted, #94a3b8); } +.form-actions { display: grid; grid-template-columns: auto auto; justify-content: start; align-items: center; gap: 8px; margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--app-border, #e2e8f0); } +.form-actions button { height: 36px; padding: 0 16px; white-space: nowrap; } +.unsaved { grid-column: 1 / -1; } +.reload { border-color: transparent; background: transparent; color: var(--app-text-muted, #64748b); } +.notice, .extension-notice { font-size: 12px; padding: 8px; overflow-wrap: anywhere; line-height: 1.6; } +.history { margin-top: 18px; font-size: 12px; color: var(--app-text-secondary, #64748b); } +summary { cursor: pointer; } +.history-entry { border-top: 1px solid var(--app-border, #e2e8f0); padding: 10px 0; overflow-wrap: anywhere; white-space: pre-wrap; } +@container (max-width: 640px) { + .panel-toggle-label { display: none; } + .panel-toggles { padding-right: 4px; margin-right: 0; gap: 2px; } + .panel-toggle { padding: 4px; } + .panel-column { padding: 16px; } +} diff --git a/apps/showcase_ui/src/app/components/task-extension-panels/task-extension-panels.component.spec.ts b/apps/showcase_ui/src/app/components/task-extension-panels/task-extension-panels.component.spec.ts new file mode 100644 index 0000000..62ba94f --- /dev/null +++ b/apps/showcase_ui/src/app/components/task-extension-panels/task-extension-panels.component.spec.ts @@ -0,0 +1,190 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TaskExtensionPanelsComponent } from './task-extension-panels.component'; +import { TaskExtensions } from '../../services/task-extensions.service'; + +function snapshot(sessionId = 'task-one'): TaskExtensions { + return { + session_id: sessionId, initial_prompt: '# 原始需求\n\n| 项目 | 要求 |\n| --- | --- |\n| 页面 | 可见 |', editable: true, + plugins: [{ + plugin_id: 'review', name: '人工评审', version: '1.0.0', revision: 0, values: { accuracy: '', note: '', correctness: '' }, history: [], + workspace: { + panels: [ + { id: 'prompt', title: '任务需求', side: 'left', kind: 'initial_prompt', width: { minimum: 240, preferred: 360, maximum: 480 } }, + { id: 'review', title: '人工评审', side: 'right', kind: 'form', width: { minimum: 260, preferred: 380, maximum: 520 } } + ], + fields: [ + { id: 'accuracy', label: 'AI执行准确性', description: '人工核对步骤', kind: 'choice', options: ['Pass', 'Failed'], max_length: 100, report: true }, + { id: 'note', label: '执行备注', description: '', kind: 'text', options: [], max_length: 2000, report: true }, + { id: 'correctness', label: '测试结果正确性', description: '', kind: 'choice', options: ['Pass', 'Failed'], max_length: 100, report: true } + ] + } + }] + }; +} + +describe('步骤回放工作区扩展', () => { + let fixture: ComponentFixture; + let component: TaskExtensionPanelsComponent; + let http: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] }); + fixture = TestBed.createComponent(TaskExtensionPanelsComponent); + component = fixture.componentInstance; + http = TestBed.inject(HttpTestingController); + }); + afterEach(() => { http.verify(); fixture.destroy(); }); + + function open(data = snapshot()) { + fixture.componentRef.setInput('sessionId', data.session_id); + fixture.componentRef.setInput('enabled', true); + fixture.detectChanges(); + http.expectOne(`/api/task-extensions/${data.session_id}`).flush(data); + fixture.detectChanges(); + } + function toggle(side: 'left' | 'right') { + const plugin = component.data()!.plugins[0]; + component.toggle(plugin, plugin.workspace.panels.find(panel => panel.side === side)!); + fixture.detectChanges(); + } + + it('does not request extensions in video mode and shows no controls without a plugin', () => { + fixture.componentRef.setInput('sessionId', 'task-one'); + fixture.detectChanges(); + http.expectNone('/api/task-extensions/task-one'); + open({ ...snapshot(), plugins: [] }); + expect(fixture.nativeElement.querySelector('nav')).toBeNull(); + }); + + it('starts collapsed, renders the full initial Markdown prompt and honors declared widths', () => { + open(); + expect(fixture.nativeElement.querySelector('aside')).toBeNull(); + toggle('left'); + expect(fixture.nativeElement.querySelector('h1').textContent).toBe('原始需求'); + expect(fixture.nativeElement.querySelector('table td').textContent).toBe('页面'); + expect(component.width('left')).toBe(360); + expect(fixture.nativeElement.querySelector('.report-prose.compact')).toBeNull(); + }); + + it('keeps either single panel beside playback below the former stacking breakpoint', () => { + open(); + for (const width of [740, 600]) { + fixture.nativeElement.style.width = `${width}px`; + for (const side of ['left', 'right'] as const) { + toggle(side); + const panel = fixture.nativeElement.querySelector('aside').getBoundingClientRect(); + const player = fixture.nativeElement.querySelector('.player-content').getBoundingClientRect(); + const layout = fixture.nativeElement.querySelector('.extension-layout').getBoundingClientRect(); + expect(panel.top).toBe(player.top); + expect(panel.width).toBeGreaterThan(0); + expect(player.width).toBeGreaterThan(0); + expect(side === 'left' ? panel.right : player.right).toBeCloseTo(side === 'left' ? player.left : panel.left, 0); + expect(Math.max(panel.right, player.right)).toBeLessThanOrEqual(layout.right + 1); + toggle(side); + } + } + }); + + it('allows partial manual selection and keeps unfilled outcomes blank', () => { + open(); toggle('right'); + expect(fixture.nativeElement.querySelector('select')).toBeNull(); + const groups: NodeListOf = fixture.nativeElement.querySelectorAll('.choice-group'); + expect(fixture.nativeElement.querySelector('[aria-pressed="true"]')).toBeNull(); + const pass = groups[0].querySelector('.pass')!; + const failed = groups[0].querySelector('.failed')!; + pass.click(); fixture.detectChanges(); + expect(pass.getAttribute('aria-pressed')).toBe('true'); + pass.click(); fixture.detectChanges(); + expect(component.drafts['review'].values['accuracy']).toBe(''); + failed.click(); pass.click(); fixture.detectChanges(); + expect(failed.getAttribute('aria-pressed')).toBe('false'); + expect(groups[1].querySelector('[aria-pressed="true"]')).toBeNull(); + const note: HTMLTextAreaElement = fixture.nativeElement.querySelector('textarea'); + note.value = '人工核对'; note.dispatchEvent(new Event('input')); + fixture.detectChanges(); + fixture.nativeElement.querySelector('button.save').click(); + const request = http.expectOne('/api/task-extensions/task-one/review'); + expect(request.request.method).toBe('PUT'); + expect(request.request.body.values).toEqual({ accuracy: 'Pass', note: '人工核对', correctness: '' }); + expect(request.request.body.expected_revision).toBe(0); + const data = snapshot(); data.plugins[0].revision = 1; data.plugins[0].values = request.request.body.values; + request.flush(data); fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('人工评审已保存'); + }); + + it('preserves draft and request receipt after a failed save, and permits clearing a result', () => { + open(); toggle('right'); + const plugin = component.data()!.plugins[0]; + component.edit('review', 'accuracy', 'Failed'); component.save(plugin); + const first = http.expectOne('/api/task-extensions/task-one/review'); + const requestId = first.request.body.request_id; + first.flush({}, { status: 503, statusText: 'Unavailable' }); + component.save(plugin); + const retry = http.expectOne('/api/task-extensions/task-one/review'); + expect(retry.request.body.request_id).toBe(requestId); + retry.flush({}, { status: 409, statusText: 'Conflict' }); + expect(component.drafts['review'].values['accuracy']).toBe('Failed'); + component.edit('review', 'accuracy', ''); component.save(plugin); + const clear = http.expectOne('/api/task-extensions/task-one/review'); + expect(clear.request.body.values.accuracy).toBe(''); + expect(clear.request.body.request_id).not.toBe(requestId); + clear.flush(snapshot()); + }); + + it('renders Chinese outcomes as mutually exclusive buttons and saves their declared values', () => { + const data = snapshot(); data.plugins[0].workspace.fields[0].options = ['通过', '失败']; + open(data); toggle('right'); + const pass: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.pass'); + const failed: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.failed'); + expect(pass.textContent).toContain('通过'); expect(failed.textContent).toContain('失败'); + pass.click(); failed.click(); fixture.detectChanges(); + expect(component.drafts['review'].values['accuracy']).toBe('失败'); + expect(pass.getAttribute('aria-pressed')).toBe('false'); + expect(failed.getAttribute('aria-pressed')).toBe('true'); + }); + + it('does not apply a delayed save response to the task now displayed', () => { + open(); toggle('right'); + component.edit('review', 'note', '只属于第一个任务'); component.save(component.data()!.plugins[0]); + const oldSave = http.expectOne('/api/task-extensions/task-one/review'); + fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges(); + http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two')); fixture.detectChanges(); + oldSave.flush(snapshot('task-one')); fixture.detectChanges(); + expect(component.data()!.session_id).toBe('task-two'); + expect(component.drafts['review'].values['note']).toBe(''); + }); + + it('keeps unsaved drafts across task switches and removes panels on plugin disable', () => { + open(); toggle('right'); component.edit('review', 'note', '保留草稿'); + fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges(); + http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two')); + fixture.componentRef.setInput('sessionId', 'task-one'); fixture.detectChanges(); + http.expectOne('/api/task-extensions/task-one').flush(snapshot()); fixture.detectChanges(); + expect(component.drafts['review'].values['note']).toBe('保留草稿'); + window.dispatchEvent(new Event('artemis-plugin-change')); fixture.detectChanges(); + http.expectOne('/api/task-extensions/task-one').flush({ ...snapshot(), plugins: [] }); fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('nav')).toBeNull(); + expect(fixture.nativeElement.querySelector('aside')).toBeNull(); + }); + + it('keeps running tasks read-only then enables editing when the task finishes', () => { + open({ ...snapshot(), editable: false }); toggle('right'); + expect(fixture.nativeElement.querySelector('fieldset').disabled).toBeTrue(); + component.save(component.data()!.plugins[0]); http.expectNone('/api/task-extensions/task-one/review'); + fixture.componentRef.setInput('taskStatus', 'completed'); fixture.detectChanges(); + http.expectOne('/api/task-extensions/task-one').flush(snapshot()); fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('fieldset').disabled).toBeFalse(); + }); + + it('collapses the opposite panel on narrow screens', () => { + const original = Object.getOwnPropertyDescriptor(window, 'innerWidth')!; + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 600 }); + try { + open(); toggle('left'); toggle('right'); + expect(component.panels('left').length).toBe(0); + expect(component.panels('right').length).toBe(1); + } finally { Object.defineProperty(window, 'innerWidth', original); } + }); +}); diff --git a/apps/showcase_ui/src/app/components/task-extension-panels/task-extension-panels.component.ts b/apps/showcase_ui/src/app/components/task-extension-panels/task-extension-panels.component.ts new file mode 100644 index 0000000..d471a06 --- /dev/null +++ b/apps/showcase_ui/src/app/components/task-extension-panels/task-extension-panels.component.ts @@ -0,0 +1,134 @@ +import { Component, ChangeDetectionStrategy, HostListener, TemplateRef, effect, inject, input, output, signal, viewChild } from '@angular/core'; +import { DatePipe, NgTemplateOutlet } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component'; +import { ExtensionDraft, ExtensionPanel, TaskExtension, TaskExtensions, TaskExtensionsService } from '../../services/task-extensions.service'; + +@Component({ + selector: 'app-task-extension-panels', standalone: true, + imports: [FormsModule, NgTemplateOutlet, DatePipe, ReportMarkdownComponent], + templateUrl: './task-extension-panels.component.html', + styleUrl: './task-extension-panels.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TaskExtensionPanelsComponent { + private service = inject(TaskExtensionsService); + sessionId = input(null); + enabled = input(false); + taskStatus = input(''); + expandedWidth = output(); + headerControls = viewChild>('headerControls'); + data = signal(null); + error = signal(''); + saving = signal(null); + notice = signal(''); + opened = signal>({}); + private refreshToken = signal(0); + private identity = ''; + private generation = 0; + drafts: Record = {}; + + constructor() { + effect(onCleanup => { + const id = this.sessionId(); + const enabled = this.enabled(); + this.refreshToken(); + this.taskStatus(); + this.generation++; + const identity = enabled ? id || '' : ''; + if (identity !== this.identity) { + this.identity = identity; + this.data.set(null); + this.opened.set({}); + this.expandedWidth.emit(0); + } + this.error.set(''); + this.notice.set(''); + this.saving.set(null); + if (!id || !enabled) return; + const subscription = this.service.read(id).subscribe({ + next: data => { + this.drafts = Object.fromEntries(data.plugins.map(plugin => [plugin.plugin_id, this.service.draft(id, plugin)])); + this.data.set(data); + this.expandedWidth.emit(this.width('left') + this.width('right')); + }, + error: () => this.error.set('暂时无法加载任务扩展,请重试') + }); + onCleanup(() => subscription.unsubscribe()); + }); + } + + @HostListener('window:artemis-plugin-change') + refresh(): void { this.refreshToken.update(value => value + 1); } + + key(plugin: TaskExtension, panel: ExtensionPanel): string { return `${plugin.plugin_id}/${panel.id}`; } + panels(side: 'left' | 'right') { + return (this.data()?.plugins || []).flatMap(plugin => plugin.workspace.panels + .filter(panel => panel.side === side && this.opened()[this.key(plugin, panel)]) + .map(panel => ({ plugin, panel }))); + } + width(side: 'left' | 'right'): number { + return Math.max(0, ...this.panels(side).map(({ panel }) => + Math.max(panel.width.minimum, Math.min(panel.width.preferred, panel.width.maximum)))); + } + toggle(plugin: TaskExtension, panel: ExtensionPanel): void { + const key = this.key(plugin, panel); + const next = { ...this.opened(), [key]: !this.opened()[key] }; + if (next[key] && window.innerWidth < 1000) { + for (const other of this.data()?.plugins || []) { + for (const sibling of other.workspace.panels) { + if (sibling.side !== panel.side) next[this.key(other, sibling)] = false; + } + } + } + this.opened.set(next); + this.expandedWidth.emit(this.width('left') + this.width('right')); + } + @HostListener('window:resize') + resize(): void { + if (window.innerWidth < 1000 && this.panels('left').length && this.panels('right').length) { + const next = { ...this.opened() }; + for (const { plugin, panel } of this.panels('left')) next[this.key(plugin, panel)] = false; + this.opened.set(next); + this.expandedWidth.emit(this.width('left') + this.width('right')); + } + } + edit(pluginId: string, field: string, value: string): void { + const draft = this.drafts[pluginId]; + draft.values[field] = value; + draft.dirty = true; + draft.requestId = undefined; + this.notice.set(''); + } + choose(pluginId: string, field: string, value: string): void { + this.edit(pluginId, field, this.drafts[pluginId].values[field] === value ? '' : value); + } + reload(plugin: TaskExtension): void { + const id = this.sessionId(); + if (!id) return; + this.service.discard(id, plugin.plugin_id); + this.refresh(); + } + save(plugin: TaskExtension): void { + const id = this.sessionId(); + if (!id || !this.data()?.editable || this.saving()) return; + this.saving.set(plugin.plugin_id); + const generation = this.generation; + this.notice.set(''); + this.service.save(id, plugin.plugin_id, this.drafts[plugin.plugin_id]).subscribe({ + next: data => { + this.service.discard(id, plugin.plugin_id); + if (id !== this.sessionId() || generation !== this.generation) return; + for (const item of data.plugins) this.drafts[item.plugin_id] = this.service.draft(id, item); + this.data.set(data); + this.saving.set(null); + this.notice.set('人工评审已保存,下次导出将包含最新内容'); + }, + error: error => { + if (id !== this.sessionId() || generation !== this.generation) return; + this.saving.set(null); + this.notice.set(typeof error.error?.detail === 'string' ? error.error.detail : '保存失败,已保留输入,请重试'); + } + }); + } +} diff --git a/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.ts b/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.ts index 43f6966..b40ad07 100644 --- a/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.ts +++ b/apps/showcase_ui/src/app/components/task-plugins/task-plugins.component.ts @@ -26,7 +26,7 @@ export class TaskPluginsComponent implements OnChanges { return this.automatic ? '自动' : Object.keys(this.selected).length ? `已选 ${Object.keys(this.selected).length} 个` : '本次停用'; } available() { - return this.plugins().filter(p => p.enabled && p.profiles.some(profile => profile.authorized) && + return this.plugins().filter(p => p.enabled && (!p.manifest.workspace || !!p.manifest.server || !!p.manifest.skill) && p.profiles.some(profile => profile.authorized) && p.manifest.targets.some(target => target === this.target() || (this.target() === 'android' && target === 'mixed'))); } ngOnChanges() { diff --git a/apps/showcase_ui/src/app/pages/plugins/plugins.component.html b/apps/showcase_ui/src/app/pages/plugins/plugins.component.html index 3e53f68..b212a55 100644 --- a/apps/showcase_ui/src/app/pages/plugins/plugins.component.html +++ b/apps/showcase_ui/src/app/pages/plugins/plugins.component.html @@ -1,5 +1,5 @@
-
ARTEMIS · 扩展能力

插件

为安卓测试扩展电脑等操作能力,AI 按任务需要自主调用。

+
ARTEMIS · 扩展能力

插件

扩展任务操作能力,也可为回放界面和报告添加人工评审等功能。

@if (error()) { } @if (notice()) {
{{notice()}}
} diff --git a/apps/showcase_ui/src/app/services/plugin.service.ts b/apps/showcase_ui/src/app/services/plugin.service.ts index e0d0bb3..ba4ab4a 100644 --- a/apps/showcase_ui/src/app/services/plugin.service.ts +++ b/apps/showcase_ui/src/app/services/plugin.service.ts @@ -1,10 +1,12 @@ import { Injectable, inject } from '@angular/core'; +import { tap } from 'rxjs'; 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; + workspace?: unknown; server?: unknown; skill?: string; resources: string[]; targets: ('android' | 'computer' | 'mixed')[]; } @@ -28,13 +30,13 @@ export class PluginService { 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(`/api/plugins/install/${encodeURIComponent(id)}`); } + progress(id: string) { return this.http.get(`/api/plugins/install/${encodeURIComponent(id)}`).pipe(tap(() => window.dispatchEvent(new Event('artemis-plugin-change')))); } save(id: string, profile: PluginProfile) { const { configured_secrets, ...payload } = profile; return this.http.put(`/api/plugins/${encodeURIComponent(id)}/profiles`, payload); } - enable(id: string, enabled: boolean) { return this.http.put(`/api/plugins/${encodeURIComponent(id)}/enabled`, { enabled }); } + enable(id: string, enabled: boolean) { return this.http.put(`/api/plugins/${encodeURIComponent(id)}/enabled`, { enabled }).pipe(tap(() => window.dispatchEvent(new Event('artemis-plugin-change')))); } 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)}`); } + uninstall(id: string) { return this.http.delete(`/api/plugins/${encodeURIComponent(id)}`).pipe(tap(() => window.dispatchEvent(new Event('artemis-plugin-change')))); } custom(manifest: unknown, skill_content: string) { return this.http.post('/api/plugins/custom', { manifest, skill_content }); } } diff --git a/apps/showcase_ui/src/app/services/task-extensions.service.ts b/apps/showcase_ui/src/app/services/task-extensions.service.ts new file mode 100644 index 0000000..8466096 --- /dev/null +++ b/apps/showcase_ui/src/app/services/task-extensions.service.ts @@ -0,0 +1,48 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +export interface ExtensionField { + id: string; label: string; description: string; kind: 'choice' | 'text'; + options: string[]; max_length: number; report: boolean; +} +export interface ExtensionPanel { + id: string; title: string; side: 'left' | 'right'; kind: 'initial_prompt' | 'form'; + width: { preferred: number; minimum: number; maximum: number }; +} +export interface TaskExtension { + plugin_id: string; name: string; version: string; revision: number; + workspace: { panels: ExtensionPanel[]; fields: ExtensionField[] }; + values: Record; + history: { revision: number; saved_at: number; values: Record; fields: ExtensionField[] }[]; +} +export interface TaskExtensions { + session_id: string; initial_prompt: string; editable: boolean; plugins: TaskExtension[]; +} +export interface ExtensionDraft { + values: Record; revision: number; dirty: boolean; requestId?: string; +} + +@Injectable({ providedIn: 'root' }) +export class TaskExtensionsService { + private http = inject(HttpClient); + private drafts = new Map(); + read(sessionId: string) { + return this.http.get(`/api/task-extensions/${encodeURIComponent(sessionId)}`); + } + draft(sessionId: string, plugin: TaskExtension): ExtensionDraft { + const key = `${sessionId}/${plugin.plugin_id}`; + let draft = this.drafts.get(key); + if (!draft?.dirty) { + draft = { values: { ...plugin.values }, revision: plugin.revision, dirty: false }; + this.drafts.set(key, draft); + } + return draft; + } + discard(sessionId: string, pluginId: string) { this.drafts.delete(`${sessionId}/${pluginId}`); } + save(sessionId: string, pluginId: string, draft: ExtensionDraft) { + draft.requestId ||= crypto.randomUUID(); + return this.http.put(`/api/task-extensions/${encodeURIComponent(sessionId)}/${encodeURIComponent(pluginId)}`, { + values: { ...draft.values }, expected_revision: draft.revision, request_id: draft.requestId + }); + } +} diff --git a/artemis/config/settings.py b/artemis/config/settings.py index ffa692e..ca1dc64 100644 --- a/artemis/config/settings.py +++ b/artemis/config/settings.py @@ -87,7 +87,7 @@ class Settings(BaseSettings): """Centralized ARTEMIS runtime settings loaded from environment and .env files.""" ARTEMIS_PLUGIN_MARKETPLACE_URL: str = ( - "https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json" + "https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json" ) ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS: float = Field(default=30, gt=0, le=300) ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=3600) diff --git a/artemis/plugins/installer.py b/artemis/plugins/installer.py index e9c9385..1463edf 100644 --- a/artemis/plugins/installer.py +++ b/artemis/plugins/installer.py @@ -43,7 +43,12 @@ def download(url: str, progress: Callable[[str, int], None]) -> bytes: def load_catalog() -> PluginCatalog: - data = download(settings.ARTEMIS_PLUGIN_MARKETPLACE_URL, lambda *_: None) + url = settings.ARTEMIS_PLUGIN_MARKETPLACE_URL + # Older hosts reject schema-v3 entries before checking version constraints. + # Keep their index intact, and route only the exact former official URL. + if url == "https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json": + url = url.removesuffix("catalog.json") + "catalog-v3.json" + data = download(url, lambda *_: None) if len(data) > 2 * 1024 * 1024: raise ValueError("市场索引过大") return PluginCatalog.model_validate_json(data) diff --git a/artemis/plugins/models.py b/artemis/plugins/models.py index 4461aab..681b7b2 100644 --- a/artemis/plugins/models.py +++ b/artemis/plugins/models.py @@ -12,6 +12,8 @@ from packaging.specifiers import SpecifierSet from packaging.version import Version from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator +from artemis.plugins.workspace import WorkspaceContribution + Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_-]{0,47}$")] @@ -94,7 +96,7 @@ class EvidenceSource(PluginModel): class PluginManifest(PluginModel): - schema_version: Literal[1, 2] = 1 + schema_version: Literal[1, 2, 3] = 1 id: Identifier name: str = Field(min_length=1, max_length=100) version: str @@ -113,6 +115,7 @@ class PluginManifest(PluginModel): targets: list[ExecutionTarget] = Field(default_factory=lambda: ["mixed"]) observation: PluginObservation | None = None capabilities: PluginCapabilities | None = None + workspace: WorkspaceContribution | None = None @field_validator("version") @classmethod @@ -130,14 +133,16 @@ class PluginManifest(PluginModel): @model_validator(mode="after") def has_capability(self): - if self.server is None and self.skill is None: - raise ValueError("插件至少需要 MCP 服务或 Skill") + if self.server is None and self.skill is None and self.workspace is None: + raise ValueError("插件至少需要 MCP 服务、Skill 或工作区扩展") + if self.workspace is not None and self.schema_version != 3: + raise ValueError("工作区扩展需要 schema_version=3") for name, field in self.config.items(): if field.secret and field.default: raise ValueError(f"密钥字段 {name} 不允许默认值") if self.schema_version == 1 and self.capabilities is not None: raise ValueError("能力契约需要 schema_version=2;旧清单保持兼容") - if self.schema_version == 2: + if self.schema_version >= 2: if self.capabilities is None: raise ValueError("第二版插件必须声明 capabilities") if self.observation: diff --git a/artemis/plugins/store.py b/artemis/plugins/store.py index 310931c..8453070 100644 --- a/artemis/plugins/store.py +++ b/artemis/plugins/store.py @@ -105,6 +105,7 @@ class PluginStore: if not existing: profile = PluginProfile( is_default=True, + authorized=manifest.server is None and manifest.skill is None, values={k: v.default for k, v in manifest.config.items() if not v.secret}, ) db.execute( @@ -236,6 +237,8 @@ class PluginStore: def selections(self, target: str = "mixed") -> list[PluginSelection]: selections = [] for plugin in self.list(): + if not plugin["manifest"].get("server") and not plugin["manifest"].get("skill"): + continue if not plugin["enabled"] or not self._supports_task( plugin["manifest"].get("targets", ["mixed"]), target ): @@ -264,6 +267,8 @@ class PluginStore: pinned = [] for choice in self.selections(target) if selections is None else selections: manifest, profile, _ = self.resolve(choice) + if manifest.server is None and manifest.skill is None: + raise ValueError(f"插件 {manifest.name} 是工作区扩展,无需加入执行任务") if not self._supports_task(manifest.targets, target): raise ValueError(f"插件 {manifest.name} 不支持 {target} 任务") pinned.append( diff --git a/artemis/plugins/workspace.py b/artemis/plugins/workspace.py new file mode 100644 index 0000000..8e56ca3 --- /dev/null +++ b/artemis/plugins/workspace.py @@ -0,0 +1,68 @@ +"""Declarative workspace contributions; no plugin JavaScript or model execution.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class WorkspaceModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class PanelWidth(WorkspaceModel): + preferred: int = Field(default=340, ge=200, le=1000) + minimum: int = Field(default=240, ge=160, le=800) + maximum: int = Field(default=480, ge=200, le=1200) + + @model_validator(mode="after") + def ordered(self): + if not self.minimum <= self.preferred <= self.maximum: + raise ValueError("面板宽度必须满足 minimum ≤ preferred ≤ maximum") + return self + + +class WorkspaceField(WorkspaceModel): + id: str = Field(pattern=r"^[a-z][a-z0-9_]{0,47}$") + label: str = Field(min_length=1, max_length=100) + description: str = Field(default="", max_length=1000) + kind: Literal["choice", "text"] + options: list[str] = Field(default_factory=list, max_length=12) + max_length: int = Field(default=2000, ge=1, le=10000) + report: bool = True + + @model_validator(mode="after") + def valid_options(self): + if self.kind == "choice" and not self.options: + raise ValueError("选择字段必须声明 options") + if self.kind == "text" and self.options: + raise ValueError("文本字段不接受 options") + if len(set(self.options)) != len(self.options) or any( + not option.strip() or len(option) > min(100, self.max_length) for option in self.options + ): + raise ValueError("选项必须非空、唯一且不超过长度限制") + return self + + +class WorkspacePanel(WorkspaceModel): + id: str = Field(pattern=r"^[a-z][a-z0-9_]{0,47}$") + title: str = Field(min_length=1, max_length=100) + side: Literal["left", "right"] + kind: Literal["initial_prompt", "form"] + width: PanelWidth = Field(default_factory=PanelWidth) + + +class WorkspaceContribution(WorkspaceModel): + panels: list[WorkspacePanel] = Field(min_length=1, max_length=2) + fields: list[WorkspaceField] = Field(default_factory=list, max_length=20) + + @model_validator(mode="after") + def unique_contributions(self): + if len({panel.id for panel in self.panels}) != len(self.panels) or len( + {panel.kind for panel in self.panels} + ) != len(self.panels): + raise ValueError("面板 id 和 kind 不得重复") + if len({field.id for field in self.fields}) != len(self.fields): + raise ValueError("工作区字段 id 不得重复") + if bool(self.fields) != any(panel.kind == "form" for panel in self.panels): + raise ValueError("表单面板与 fields 必须同时声明") + return self diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 1a00b00..ac36eec 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -1,6 +1,6 @@ -# 插件开发与证据协议(2.0.0) +# 插件开发 SDK 与证据协议(ARTEMIS 2.3.0) -新插件必须适配本文规则,再通过实测进入市场。旧 `schema_version: 1` 仍可读取;新包使用版本 2,并声明 `capabilities`。模型执行、任务队列、资源锁、超时和报告由 ARTEMIS 管理,插件不另建任务调度器,也不替用户决定用例通过。 +新插件必须适配本文规则,再通过实测进入市场。旧 `schema_version: 1` 仍可读取;执行插件使用版本 2,并声明 `capabilities`。需要回放面板、人工表单和报告字段时使用版本 3,见[工作区插件接口](workspace-plugins.md)。模型执行、任务队列、资源锁、超时和报告由 ARTEMIS 管理,插件不另建任务调度器,也不替用户决定用例通过。 ## 从可运行模板开始 diff --git a/docs/plugins.md b/docs/plugins.md index 538a871..78bc9c2 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,6 +1,8 @@ # 插件与跨端任务(2.0.0) -插件开发请阅读 [能力、证据协议与验收规则](plugin-development.md),可运行模板位于 `examples/plugins/evidence-demo`。新插件使用清单版本 2;版本 1 仍兼容。 +插件开发请阅读 [能力、证据协议与验收规则](plugin-development.md),可运行模板位于 `examples/plugins/evidence-demo`。执行插件使用清单版本 2;版本 1 仍兼容。回放面板、人工表单和报告扩展使用版本 3,见[工作区插件接口](workspace-plugins.md)。 + +安装并启用「任务人工评审」工作区插件后,步骤回放提供可折叠的任务需求和人工评审面板。需求展示最初提交的 Markdown 提示词;两项 通过/失败 和各自备注由人工选填,可修改、清空并查看历史。未填写仍可导出报告;停用或卸载后不显示面板,也不导出对应字段,重新安装启用可恢复原记录。纯工作区插件不需要在执行任务时选择。 步骤卡标明安卓、电脑等来源,可点击查看对应录像;同一步内保留实际工具调用。开启「保存录屏」后,已使用的安卓和电脑来源各自留档,通过播放器切换。电脑录屏需要插件提供版本 2 的来源回执和录像能力声明,当前配套插件为 `cua-driver` 0.36.0+artemis.4,上游版本固定为 0.36.0;旧 `open-computer-use` 适配包从 1.1.0 起支持,但已从市场下架;仅更新 ARTEMIS 源码不会升级已安装插件。旧任务缺失的电脑录像无法补录,播放器会提示查看已有步骤截图。 @@ -117,7 +119,7 @@ HTTP 服务使用 `{"transport":"streamable_http","url":"https://example.com/mcp | 环境变量 | 默认值 | | --- | --- | -| `ARTEMIS_PLUGIN_MARKETPLACE_URL` | Artemis-Plugins 仓库的 catalog.json | +| `ARTEMIS_PLUGIN_MARKETPLACE_URL` | Artemis-Plugins 仓库的 catalog-v3.json(2.3.0+;原官方地址自动兼容) | | `ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS` | 30 秒 | | `ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS` | 60 秒 | | `ARTEMIS_PLUGIN_SHUTDOWN_TIMEOUT_SECONDS` | 15 秒 | diff --git a/docs/releases/2.3.0.md b/docs/releases/2.3.0.md new file mode 100644 index 0000000..6cd3c8c --- /dev/null +++ b/docs/releases/2.3.0.md @@ -0,0 +1,34 @@ +# ARTEMIS 2.3.0 + +本版增加工作区插件接口,并配套独立的「任务人工评审 1.0.0」。插件从市场安装并启用,主程序不默认安装或启用人工评审。 + +## 步骤回放与人工评审 + +- 标题栏提供左右侧栏开关,普通浮窗与影院模式均支持。单独展开仍位于对应一侧;窄屏按空间收缩,一次展开一侧。 +- 左侧展示本次运行最初的任务需求提示词,支持 Markdown 表格、列表等格式。 +- 右侧由用户独立评定「AI执行准确性」「测试结果正确性」,使用「通过 / 失败」按钮,两项均可留空、清空,并各自填写可选备注。 +- 任务结束后可保存和修改,保留修改记录。修订校验防止多页面互相覆盖,重试不会重复追加记录。人工评审不调用模型,也不覆盖原有任务结论或人工复验。 + +## 报告与数据 + +已安装且启用的插件字段进入单任务 Word/Excel、批次汇总和资料包中的报告;未填字段留空,不阻止导出。未安装、禁用或卸载时,新导出不出现对应区块。保存后再次导出反映最新评审,旧下载文件保持原样。 + +评审绑定单次任务运行,复跑不继承。禁用和卸载保留已存评审,重新安装启用后恢复;删除任务同时清理该任务的评审。 + +## 插件开发 + +新增 `schema_version: 3` 的声明式 `workspace` 接口,支持左右面板、可配置宽度、人工选填字段及报告贡献。纯工作区插件无需 MCP、模型或设备,不参与执行插件选择。旧执行插件版本 1/2 继续兼容。 + +[插件开发 SDK](../plugin-development.md)、[工作区接口](../workspace-plugins.md)和随程序分发的 [artemis-use Skill](../../skills/artemis-use/SKILL.md)同步提供完整清单示例、HTTP 请求、冲突与幂等规则、数据生命周期及发布要求。 + +2.3.0 使用 `catalog-v3.json` 市场索引;原 `catalog.json` 继续服务 2.2.x。保存的原官方地址会在读取时路由到新版索引,自定义市场地址保持原样。 + +## 模型 + +模型列表新增 `gpt-6.1-terra`,新安装的探索模式默认使用该模型、思考强度 `high`。已有用户保存的配置保持优先。 + +## 安装与验证范围 + +先升级主程序到 2.3.0,再到「插件」市场安装并启用「任务人工评审」。插件最低要求 2.3.0,不能导入 2.2.x;可从独立插件发行页下载 ZIP 手动导入。 + +本次验证覆盖默认确定性后端回归、ChromeHeadless 前端回归、生产构建、Ruff、质量基线和核心类型检查;还在 Windows 隔离示例中检查亮暗主题、单侧/双侧展开、中文评分保存与 Word/Excel 导出。此次未调用付费模型或执行真实安卓、PC、跨设备业务测试;macOS/Linux 未实测。 diff --git a/docs/workspace-plugins.md b/docs/workspace-plugins.md new file mode 100644 index 0000000..8421e50 --- /dev/null +++ b/docs/workspace-plugins.md @@ -0,0 +1,77 @@ +# 工作区插件:侧栏、人工字段与报告 + +工作区扩展使用 `plugin.json` 的 `schema_version: 3` 和 `workspace` 声明。旧版 1/2 执行插件继续兼容。纯工作区插件无需 MCP、Skill、模型、设备或任务资源,不出现在任务执行插件选择器中;安装后手动启用即可在历史任务上使用。此接口从 ARTEMIS 2.3.0 起提供;插件必须声明 `artemis_version: ">=2.3.0,<3.0.0"`,2.2.x 不支持。 + +## 声明接口 + +清单仍要求 `capabilities`。纯界面插件填写 `{"kind":"other","tools":{}}`,可将 `targets` 设为空数组。工作区声明完全由宿主渲染,不加载任意 JavaScript、HTML 或后端执行钩子。 + +以下是可直接保存为 `plugin.json` 的完整最小清单: + +```json +{ + "schema_version": 3, + "id": "review-example", + "name": "评审扩展示例", + "version": "1.0.0", + "artemis_version": ">=2.3.0,<3.0.0", + "platforms": ["win32", "linux", "darwin"], + "targets": [], + "capabilities": {"kind": "other", "tools": {}}, + "workspace": { + "panels": [ + {"id":"requirements","title":"任务需求","side":"left","kind":"initial_prompt","width":{"preferred":340,"minimum":240,"maximum":480}}, + {"id":"review","title":"人工评审","side":"right","kind":"form","width":{"preferred":360,"minimum":260,"maximum":520}} + ], + "fields": [ + {"id":"execution_accuracy","label":"AI执行准确性","kind":"choice","options":["通过","失败"]}, + {"id":"execution_note","label":"AI执行准确性备注","kind":"text","max_length":2000} + ] + } +} +``` + +- `panels`:最多两个,`id` 和 `kind` 各自唯一;`side` 为 left/right。`initial_prompt` 展示该次运行最初收到的任务需求提示词,复用宿主 Markdown 渲染及 HTML 清理;不展示模型拆解步骤或后续指导。`form` 渲染本插件声明的字段。 +- `width`:CSS 像素,满足 minimum ≤ preferred ≤ maximum。minimum 范围 160–800,preferred 200–1000,maximum 200–1200。宿主优先采用首选宽度,并根据播放器、窗口空间调整;窄屏可低于 minimum 防止溢出。开关位于回放标题栏,以左右方向表示展开与收起。普通浮窗、影院均支持,默认收起;宽屏左右独立展开,窄屏一次展开一侧;单侧也始终位于回放左侧或右侧,不切换为上下排列。 +- `fields`:最多 20 个,`id` 唯一;`kind` 为 choice/text。choice 声明非空、不重复的 options,显示为互斥分段按钮,再次点击已选项可清空;text 使用多行输入。description 可选。max_length 默认 2000、最高 10000;report 默认 true。所有字段均选填,空字符串表示未填写,用户可清空。 +- 两项人工评审的标签和说明由插件提供;宿主不推断结果、不调用模型、不覆盖执行状态、AI 原判或既有人工复验。 + +## 任务数据接口 + +`GET /api/task-extensions/{session_id}` 返回 `session_id`、`initial_prompt`、`editable` 和已安装且启用的插件贡献列表。每项包含 plugin_id/name/version/workspace/revision/values/history。无插件时列表为空。 + +`PUT /api/task-extensions/{session_id}/{plugin_id}` 接收: + +```json +{"values":{"execution_accuracy":"通过","execution_note":"人工核对"},"expected_revision":0,"request_id":"00000000-0000-4000-8000-000000000001"} +``` + +values 是本次提交的字段;未提交的字段保持原值。保存仅允许终态任务(完成、失败、取消、中断、跳过),运行中只读。宿主校验声明、长度和选项;expected_revision 防止多个页面互相覆盖。网络重试复用 request_id 和相同内容,不追加重复历史;修改内容使用新 request_id。保存冲突返回 409,并保留页面输入。 + +数据复用宿主 automation_records,以 task_extensions/session_id 保存各插件独立记录;历史包含字段声明、插件版本、保存时间、revision 和当时值。按单次运行绑定,复跑不继承评审。禁用/卸载隐藏界面与新导出的字段,但保留历史;重新安装启用同一插件 ID 后恢复。删除任务/批次时同步清理对应评审。 + +## 导出 + +已安装且启用的插件声明 report=true 的字段,进入单任务 Word/Excel、批次 Word/Excel 汇总和 ZIP 内报告。未填字段留空,不阻止导出。未安装、禁用或卸载时无对应报告区块;已下载文件保持原内容。 + +单任务导出指纹包含扩展快照,批次缓存包含插件安装/启用声明与任务扩展修订。保存使用既有导出锁并使批次报告失效,防止下载旧评审。导出不需要运行插件进程或调用模型。 + +## 独立人工评审插件 + +插件源代码位于独立 Artemis-Plugins 仓库的 `plugins/task-review/`,包名 task-review,版本 1.0.0,可从内置插件市场安装。它声明需求提示词面板,以及「AI执行准确性」「测试结果正确性」两个 通过/失败 字段和各自可选备注。 + +在已更新宿主的环境,用 `uv run python scripts/check_plugin.py <插件目录>` 检查清单,再在「插件 → 高级接入」导入目录或 ZIP。安装、启用、填写、清空、导出、禁用、卸载和重新安装均应通过确定性测试;只检查清单不代表界面和导出已验证。 + +## 开发与兼容边界 + +这里的 SDK 是声明式清单协议与宿主 HTTP 接口,不要求编写插件运行进程。当前不支持自定义 JavaScript、HTML、上传控件、跨任务统计或任意报告模板钩子。执行插件可以在版本 3 清单中同时保留 MCP/Skill 和 workspace;原有工具、资源和证据约束仍适用。 + +面板及字段 id 使用小写字母开头的字母、数字、下划线,最长 48 字符;稳定 id 是历史数据的键,不应为了改显示名称而更换。更改选项或删除字段不会迁移历史;当前导出按当前声明取值,发行不兼容字段变更前须验证旧数据。字段没有必填模式,不能以校验拦截用户的空项。 + +GET 成功返回完整贡献快照;PUT 成功返回同样结构和新 revision。404 表示任务不存在,422 表示请求结构、UUID 或参数格式无效;409 包含运行中只读、插件未启用、报告正在打包、修订冲突、无效字段值或幂等请求冲突。冲突时保留本地输入,刷新并由用户决定如何合并,不能自动覆盖。 + +人工结果只能由用户填写;AI 不自行调用 PUT 生成或推断评审。此接口未加入模型 MCP 工具或远程 Python SDK 的人工写入方法。插件没有设备目标时,无需为安装验证提交安卓/PC 测试任务;用隔离终态示例验证保存、清空、重装、导出与旧缓存失效即可。 + +市场发布时保持清单、ZIP、版本标签与 catalog-v3.json 内嵌清单一致;先上传不可变 ZIP 与 SHA256SUMS.txt 并回读验证,再更新索引。仅静态清单校验通过不能宣称全部平台已实测。 + +官方市场为 2.3.0+ 提供 catalog-v3.json,catalog.json 保留给 2.2.x。宿主读取原官方地址时自动选择新版索引,不改写用户配置;自定义市场地址不会被替换。 diff --git a/pyproject.toml b/pyproject.toml index c6de479..660082d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ [project] name = "artemis" -version = "2.2.0" +version = "2.3.0" description = "Autonomous AI Assistant for Mobile Automation" authors = [ diff --git a/scripts/check_plugin.py b/scripts/check_plugin.py index 0032e44..306fe0e 100644 --- a/scripts/check_plugin.py +++ b/scripts/check_plugin.py @@ -18,8 +18,8 @@ async def check(directory: Path, live: bool, values: dict[str, str]) -> None: skill = contained_path(directory, manifest.skill) if not skill.is_file() or not skill.read_text("utf-8").strip(): raise ValueError("缺少有效的 Skill") - if manifest.schema_version != 2: - raise ValueError("新市场插件必须适配 schema_version=2") + if manifest.schema_version not in {2, 3}: + raise ValueError("新市场插件必须适配 schema_version=2 或 3") if live and manifest.server: server = resolve_server(manifest, PluginProfile(values=values), directory) session = await PluginSession(server, directory).start() @@ -29,7 +29,7 @@ async def check(directory: Path, live: bool, values: dict[str, str]) -> None: await session.aclose() print( f"PASS {manifest.id} {manifest.version}: 清单/兼容性/Skill" - + ("/MCP 工具声明" if live else "") + + ("/MCP 工具声明" if live and manifest.server else "") ) print("此检查不代替实际执行、超时取消、证据导出和视觉能力验收。") diff --git a/skills/artemis-use/SKILL.md b/skills/artemis-use/SKILL.md index a0d492f..44901fc 100644 --- a/skills/artemis-use/SKILL.md +++ b/skills/artemis-use/SKILL.md @@ -17,6 +17,7 @@ ARTEMIS 自己编排手机和已授权插件,共用网页的任务队列、历 | 给出任务编号,问失败原因、token、缓存、录像或最终结论 | [诊断与复盘](references/diagnostics.md) | | 使用电脑、路由器、终端等已安装能力 | [插件任务参数](references/plugins.md) | | 编写、适配、验证或发布 ARTEMIS 插件 | [插件开发规范](references/plugin-development.md),按需读取 [清单与市场协议](references/plugin-manifest.md) | +| 开发回放侧栏、人工表单或扩展报告字段 | [工作区插件接口(ARTEMIS 2.3.0+)](references/workspace-plugins.md) | 只读当前任务相关参考。普通执行不加载插件开发文档。 diff --git a/skills/artemis-use/references/plugin-development.md b/skills/artemis-use/references/plugin-development.md index df92615..2d98dce 100644 --- a/skills/artemis-use/references/plugin-development.md +++ b/skills/artemis-use/references/plugin-development.md @@ -1,6 +1,6 @@ -# 插件开发与证据协议(2.0.0) +# 插件开发 SDK 与证据协议(ARTEMIS 2.3.0) -新插件必须适配本文规则,再通过实测进入市场。旧 `schema_version: 1` 仍可读取;新包使用版本 2,并声明 `capabilities`。模型执行、任务队列、资源锁、超时和报告由 ARTEMIS 管理,插件不另建任务调度器,也不替用户决定用例通过。 +新插件必须适配本文规则,再通过实测进入市场。旧 `schema_version: 1` 仍可读取;执行插件使用版本 2,并声明 `capabilities`。工作区面板和人工表单使用版本 3,详见[工作区插件接口](workspace-plugins.md)。纯工作区插件不加入任务执行选择,不向模型开放人工评审写入工具。模型执行、任务队列、资源锁、超时和报告由 ARTEMIS 管理,插件不另建任务调度器,也不替用户决定用例通过。 ## 从可运行模板开始 diff --git a/skills/artemis-use/references/plugin-manifest.md b/skills/artemis-use/references/plugin-manifest.md index d6a6b11..d762d88 100644 --- a/skills/artemis-use/references/plugin-manifest.md +++ b/skills/artemis-use/references/plugin-manifest.md @@ -1,5 +1,7 @@ # 插件与跨端任务(2.0.0 开发版) +ARTEMIS 2.3.0 支持 schema_version=3 工作区扩展:左右回放面板、人工选填表单与报告字段,见[开发接口](workspace-plugins.md)。纯工作区插件不加入执行插件选择。 + 插件开发请阅读 [能力、证据协议与验收规则](plugin-development.md),可运行模板位于 [Skill 内的 evidence-demo](../assets/evidence-demo/plugin.json)。新插件使用清单版本 2;版本 1 仍兼容。 步骤卡标明安卓、电脑等来源,可点击查看对应录像;同一步内保留实际工具调用。开启「保存录屏」后,已使用的安卓和电脑来源各自留档,通过播放器切换。电脑录屏需要插件提供版本 2 的来源回执和录像能力声明,当前市场使用 `cua-driver` 0.36.0;旧 `open-computer-use` 适配包从 1.1.0 起支持,但已从市场下架;仅更新 ARTEMIS 源码不会升级已安装插件。旧任务缺失的电脑录像无法补录,播放器会提示查看已有步骤截图。 diff --git a/skills/artemis-use/references/workspace-plugins.md b/skills/artemis-use/references/workspace-plugins.md new file mode 100644 index 0000000..825edf8 --- /dev/null +++ b/skills/artemis-use/references/workspace-plugins.md @@ -0,0 +1,78 @@ +# 工作区插件:侧栏、人工字段与报告 + +工作区扩展使用 `plugin.json` 的 `schema_version: 3` 和 `workspace` 声明。旧版 1/2 执行插件继续兼容。纯工作区插件无需 MCP、Skill、模型、设备或任务资源,不出现在任务执行插件选择器中;安装后手动启用即可在历史任务上使用。此接口从 ARTEMIS 2.3.0 起提供;插件必须声明 `artemis_version: ">=2.3.0,<3.0.0"`,2.2.x 不支持。 + +## 声明接口 + +清单仍要求 `capabilities`。纯界面插件填写 `{"kind":"other","tools":{}}`,可将 `targets` 设为空数组。工作区声明完全由宿主渲染,不加载任意 JavaScript、HTML 或后端执行钩子。 + +以下是可直接保存为 `plugin.json` 的完整最小清单: + +```json +{ + "schema_version": 3, + "id": "review-example", + "name": "评审扩展示例", + "version": "1.0.0", + "artemis_version": ">=2.3.0,<3.0.0", + "platforms": ["win32", "linux", "darwin"], + "targets": [], + "capabilities": {"kind": "other", "tools": {}}, + "workspace": { + "panels": [ + {"id":"requirements","title":"任务需求","side":"left","kind":"initial_prompt","width":{"preferred":340,"minimum":240,"maximum":480}}, + {"id":"review","title":"人工评审","side":"right","kind":"form","width":{"preferred":360,"minimum":260,"maximum":520}} + ], + "fields": [ + {"id":"execution_accuracy","label":"AI执行准确性","kind":"choice","options":["通过","失败"]}, + {"id":"execution_note","label":"AI执行准确性备注","kind":"text","max_length":2000} + ] + } +} +``` + +- `panels`:最多两个,`id` 和 `kind` 各自唯一;`side` 为 left/right。`initial_prompt` 展示该次运行最初收到的任务需求提示词,复用宿主 Markdown 渲染及 HTML 清理;不展示模型拆解步骤或后续指导。`form` 渲染本插件声明的字段。 +- `width`:CSS 像素,满足 minimum ≤ preferred ≤ maximum。minimum 范围 160–800,preferred 200–1000,maximum 200–1200。宿主优先采用首选宽度,并根据播放器、窗口空间调整;窄屏可低于 minimum 防止溢出。开关位于回放标题栏,以左右方向表示展开与收起。普通浮窗、影院均支持,默认收起;宽屏左右独立展开,窄屏一次展开一侧;单侧也始终位于回放左侧或右侧,不切换为上下排列。 +- `fields`:最多 20 个,`id` 唯一;`kind` 为 choice/text。choice 声明非空、不重复的 options,显示为互斥分段按钮,再次点击已选项可清空;text 使用多行输入。description 可选。max_length 默认 2000、最高 10000;report 默认 true。所有字段均选填,空字符串表示未填写,用户可清空。 +- 两项人工评审的标签和说明由插件提供;宿主不推断结果、不调用模型、不覆盖执行状态、AI 原判或既有人工复验。 + +## 任务数据接口 + +`GET /api/task-extensions/{session_id}` 返回 `session_id`、`initial_prompt`、`editable` 和已安装且启用的插件贡献列表。每项包含 plugin_id/name/version/workspace/revision/values/history。无插件时列表为空。 + +`PUT /api/task-extensions/{session_id}/{plugin_id}` 接收: + +```json +{"values":{"execution_accuracy":"通过","execution_note":"人工核对"},"expected_revision":0,"request_id":"00000000-0000-4000-8000-000000000001"} +``` + +values 是本次提交的字段;未提交的字段保持原值。保存仅允许终态任务(完成、失败、取消、中断、跳过),运行中只读。宿主校验声明、长度和选项;expected_revision 防止多个页面互相覆盖。网络重试复用 request_id 和相同内容,不追加重复历史;修改内容使用新 request_id。保存冲突返回 409,并保留页面输入。 + +数据复用宿主 automation_records,以 task_extensions/session_id 保存各插件独立记录;历史包含字段声明、插件版本、保存时间、revision 和当时值。按单次运行绑定,复跑不继承评审。禁用/卸载隐藏界面与新导出的字段,但保留历史;重新安装启用同一插件 ID 后恢复。删除任务/批次时同步清理对应评审。 + +## 导出 + +已安装且启用的插件声明 report=true 的字段,进入单任务 Word/Excel、批次 Word/Excel 汇总和 ZIP 内报告。未填字段留空,不阻止导出。未安装、禁用或卸载时无对应报告区块;已下载文件保持原内容。 + +单任务导出指纹包含扩展快照,批次缓存包含插件安装/启用声明与任务扩展修订。保存使用既有导出锁并使批次报告失效,防止下载旧评审。导出不需要运行插件进程或调用模型。 + +## 独立人工评审插件 + +插件源代码位于独立 Artemis-Plugins 仓库的 `plugins/task-review/`,包名 task-review,版本 1.0.0,可从内置插件市场安装。它声明需求提示词面板,以及「AI执行准确性」「测试结果正确性」两个 通过/失败 字段和各自可选备注。 + +在已更新宿主的环境,用 ` <本Skill目录>/scripts/check_plugin.py <插件目录>` 检查清单,再在「插件 → 高级接入」导入目录或 ZIP。安装、启用、填写、清空、导出、禁用、卸载和重新安装均应通过确定性测试;只检查清单不代表界面和导出已验证。 + + +## 开发与兼容边界 + +这里的 SDK 是声明式清单协议与宿主 HTTP 接口,不要求编写插件运行进程。当前不支持自定义 JavaScript、HTML、上传控件、跨任务统计或任意报告模板钩子。执行插件可以在版本 3 清单中同时保留 MCP/Skill 和 workspace;原有工具、资源和证据约束仍适用。 + +面板及字段 id 使用小写字母开头的字母、数字、下划线,最长 48 字符;稳定 id 是历史数据的键,不应为了改显示名称而更换。更改选项或删除字段不会迁移历史;当前导出按当前声明取值,发行不兼容字段变更前须验证旧数据。字段没有必填模式,不能以校验拦截用户的空项。 + +GET 成功返回完整贡献快照;PUT 成功返回同样结构和新 revision。404 表示任务不存在,422 表示请求结构、UUID 或参数格式无效;409 包含运行中只读、插件未启用、报告正在打包、修订冲突、无效字段值或幂等请求冲突。冲突时保留本地输入,刷新并由用户决定如何合并,不能自动覆盖。 + +人工结果只能由用户填写;AI 不自行调用 PUT 生成或推断评审。此接口未加入模型 MCP 工具或远程 Python SDK 的人工写入方法。插件没有设备目标时,无需为安装验证提交安卓/PC 测试任务;用隔离终态示例验证保存、清空、重装、导出与旧缓存失效即可。 + +市场发布时保持清单、ZIP、版本标签与 catalog-v3.json 内嵌清单一致;先上传不可变 ZIP 与 SHA256SUMS.txt 并回读验证,再更新索引。仅静态清单校验通过不能宣称全部平台已实测。 + +官方市场为 2.3.0+ 提供 catalog-v3.json,catalog.json 保留给 2.2.x。宿主读取原官方地址时自动选择新版索引,不改写用户配置;自定义市场地址不会被替换。 diff --git a/skills/artemis-use/scripts/check_plugin.py b/skills/artemis-use/scripts/check_plugin.py index 0032e44..306fe0e 100644 --- a/skills/artemis-use/scripts/check_plugin.py +++ b/skills/artemis-use/scripts/check_plugin.py @@ -18,8 +18,8 @@ async def check(directory: Path, live: bool, values: dict[str, str]) -> None: skill = contained_path(directory, manifest.skill) if not skill.is_file() or not skill.read_text("utf-8").strip(): raise ValueError("缺少有效的 Skill") - if manifest.schema_version != 2: - raise ValueError("新市场插件必须适配 schema_version=2") + if manifest.schema_version not in {2, 3}: + raise ValueError("新市场插件必须适配 schema_version=2 或 3") if live and manifest.server: server = resolve_server(manifest, PluginProfile(values=values), directory) session = await PluginSession(server, directory).start() @@ -29,7 +29,7 @@ async def check(directory: Path, live: bool, values: dict[str, str]) -> None: await session.aclose() print( f"PASS {manifest.id} {manifest.version}: 清单/兼容性/Skill" - + ("/MCP 工具声明" if live else "") + + ("/MCP 工具声明" if live and manifest.server else "") ) print("此检查不代替实际执行、超时取消、证据导出和视觉能力验收。") diff --git a/tests/unit/admin_console/test_task_extensions.py b/tests/unit/admin_console/test_task_extensions.py new file mode 100644 index 0000000..2ea58bd --- /dev/null +++ b/tests/unit/admin_console/test_task_extensions.py @@ -0,0 +1,272 @@ +"""Workspace plugins use isolated persistence and never execute models or devices.""" + +import json +from io import BytesIO +import sys +from uuid import uuid4 +import zipfile + +from docx import Document +from fastapi import FastAPI +from fastapi.testclient import TestClient +from openpyxl import load_workbook +import pytest + +from apps.admin_console.database.repositories.session_repository import SessionRepository +from apps.admin_console.database.repositories.step_repository import StepRepository +from apps.admin_console.routers import task_extensions as api +from apps.admin_console.services import batch_archive, report_export, task_extensions +from apps.admin_console.services.automation_store import AutomationStore +from artemis.data_engine.storage import StorageManager +from artemis.plugins.installer import install_archive +from artemis.plugins.models import PluginManifest, PluginSelection +from artemis.plugins.store import PluginStore + + +def manifest_payload(): + return { + "schema_version": 3, + "id": "review", + "name": "人工评审", + "version": "1.0.0", + "platforms": [sys.platform], + "targets": [], + "capabilities": {"kind": "other"}, + "workspace": { + "panels": [ + {"id": "prompt", "title": "任务需求", "side": "left", "kind": "initial_prompt"}, + {"id": "review", "title": "人工评审", "side": "right", "kind": "form"}, + ], + "fields": [ + { + "id": "accuracy", + "label": "AI执行准确性", + "kind": "choice", + "options": ["Pass", "Failed"], + }, + {"id": "accuracy_note", "label": "执行备注", "kind": "text", "max_length": 100}, + { + "id": "correctness", + "label": "测试结果正确性", + "kind": "choice", + "options": ["Pass", "Failed"], + }, + {"id": "correctness_note", "label": "结果备注", "kind": "text"}, + ], + }, + } + + +def install(plugins): + package = BytesIO() + with zipfile.ZipFile(package, "w") as archive: + archive.writestr("plugin.json", json.dumps(manifest_payload())) + install_archive(plugins, package.getvalue()) + plugins.set_enabled("review", True) + + +@pytest.fixture +def context(tmp_path, monkeypatch): + path = tmp_path / "data.db" + StorageManager(db_path=path, base_trace_dir=tmp_path / "traces") + store = AutomationStore(path) + plugins = PluginStore(tmp_path / "plugins") + monkeypatch.setattr(task_extensions, "plugin_store", lambda: plugins) + for module in (api, report_export): + monkeypatch.setattr(module, "store", store) + monkeypatch.setattr(report_export, "SessionRepository", lambda *args: SessionRepository(path)) + monkeypatch.setattr(report_export, "StepRepository", lambda *args: StepRepository(path)) + monkeypatch.setattr(report_export, "TRACES_PATH", tmp_path / "traces") + monkeypatch.setattr(report_export, "EXPORT_ROOT", tmp_path / "exports") + store.save_job( + { + "session_id": "task-one", + "status": "completed", + "case": { + "name": "测试任务", + "goal": "# 最初的需求\n\n|项目|要求|\n|---|---|\n|页面|可见|", + }, + } + ) + app = FastAPI() + app.include_router(api.router) + with TestClient(app) as client: + yield store, plugins, client + + +def save(client, values, revision=0, request_id=None): + return client.put( + "/api/task-extensions/task-one/review", + json={ + "values": values, + "expected_revision": revision, + "request_id": request_id or str(uuid4()), + }, + ) + + +def test_workspace_requires_v3_and_valid_bounded_declarations(): + for change in ( + {"schema_version": 2}, + { + "workspace": { + "panels": [ + { + "id": "prompt", + "title": "需求", + "side": "left", + "kind": "initial_prompt", + "width": {"minimum": 500, "preferred": 300}, + } + ] + } + }, + { + "workspace": { + "panels": [{"id": "prompt", "title": "需求", "side": "left", "kind": "javascript"}] + } + }, + ): + with pytest.raises(ValueError): + PluginManifest.model_validate({**manifest_payload(), **change}) + + +def test_plugin_is_optional_and_not_selected_for_execution(context): + store, plugins, client = context + assert client.get("/api/task-extensions/task-one").json()["plugins"] == [] + assert save(client, {}).status_code == 409 + install(plugins) + assert plugins.selections() == [] + with pytest.raises(ValueError, match="工作区扩展"): + plugins.pin([PluginSelection(id="review")], "android") + data = client.get("/api/task-extensions/task-one").json() + assert data["initial_prompt"].startswith("# 最初的需求") + assert set(data["plugins"][0]["values"].values()) == {""} + assert save(client, {}).status_code == 200 + assert store.get("case_review", "task-one") is None + + +def test_prompt_is_initial_session_request_not_case_or_step_text(context): + store, _, client = context + with store.connection() as conn: + conn.execute( + "INSERT INTO sessions(session_id,initial_goal,status,start_time) VALUES (?,?,?,?)", + ("task-one", "原始任务需求", "completed", 1), + ) + conn.commit() + assert client.get("/api/task-extensions/task-one").json()["initial_prompt"] == "原始任务需求" + + +def test_partial_clear_cas_and_idempotent_retry_preserve_history(context): + store, plugins, client = context + install(plugins) + request_id = str(uuid4()) + assert ( + save( + client, {"accuracy": "Pass", "accuracy_note": "人工核对"}, request_id=request_id + ).status_code + == 200 + ) + assert ( + save( + client, {"accuracy": "Pass", "accuracy_note": "人工核对"}, request_id=request_id + ).status_code + == 200 + ) + assert save(client, {"accuracy": "Failed"}, request_id=request_id).status_code == 409 + assert save(client, {"correctness": "Failed"}, revision=0).status_code == 409 + assert save(client, {"correctness": "Failed"}, revision=1).status_code == 200 + result = save(client, {"accuracy": "", "accuracy_note": ""}, revision=2).json()["plugins"][0] + assert result["values"]["accuracy"] == "" + assert result["values"]["correctness"] == "Failed" + assert len(result["history"]) == 3 + assert result["history"][0]["values"]["accuracy"] == "Pass" + assert store.get("case_review", "task-one") is None + + +def test_invalid_running_missing_and_disabled_writes_are_rejected(context): + store, plugins, client = context + install(plugins) + for values in ({"unknown": "Pass"}, {"accuracy": "Maybe"}, {"accuracy_note": "x" * 101}): + assert save(client, values).status_code == 409 + store.save_job({**store.job("task-one"), "status": "running"}) + assert not client.get("/api/task-extensions/task-one").json()["editable"] + assert save(client, {"accuracy": "Pass"}).status_code == 409 + assert client.get("/api/task-extensions/missing").status_code == 404 + assert store.get("task_extensions", "task-one") is None + + +def test_disable_uninstall_reinstall_keep_history_and_gate_exports(context): + store, plugins, client = context + install(plugins) + save(client, {"accuracy": "Pass"}) + plugins.set_enabled("review", False) + assert task_extensions.report_sections("task-one", store) == [] + assert save(client, {"accuracy": "Failed"}, revision=1).status_code == 409 + plugins.uninstall("review") + assert client.get("/api/task-extensions/task-one").json()["plugins"] == [] + install(plugins) + data = client.get("/api/task-extensions/task-one").json()["plugins"][0] + assert data["values"]["accuracy"] == "Pass" + assert data["revision"] == 1 + store.save_job({"session_id": "rerun", "status": "completed", "case": {"goal": "同样需求"}}) + assert client.get("/api/task-extensions/rerun").json()["plugins"][0]["revision"] == 0 + store.delete_history("task-one") + assert store.get("task_extensions", "task-one") is None + + +def test_word_excel_blank_fields_and_cache_refresh(context): + store, plugins, client = context + install(plugins) + original_result = report_export.session_report("task-one")["test_result"] + paths = report_export.export_session("task-one", ["docx", "xlsx"]) + wb = load_workbook(paths[1]) + assert wb["人工评审"].cell(2, 3).value is None + save(client, {"accuracy": "Pass", "accuracy_note": "=1+1"}) + paths = report_export.export_session("task-one", ["docx", "xlsx"]) + wb = load_workbook(paths[1]) + assert wb["人工评审"].cell(2, 3).value == "Pass" + assert wb["人工评审"].cell(3, 3).data_type != "f" + assert "Pass" in " ".join( + cell.text for table in Document(paths[0]).tables for row in table.rows for cell in row.cells + ) + assert report_export.session_report("task-one")["test_result"] == original_result + plugins.set_enabled("review", False) + paths = report_export.export_session("task-one", ["docx", "xlsx"]) + assert "人工评审" not in load_workbook(paths[1]).sheetnames + assert not any("人工评审" in p.text for p in Document(paths[0]).paragraphs) + + +def test_batch_snapshot_signature_changes_on_review_and_plugin_state(context): + store, plugins, client = context + install(plugins) + store.create_batch( + { + "id": "batch", + "name": "批次", + "status": "completed", + "formats": ["docx", "xlsx"], + "report_ready": True, + }, + [], + {}, + ) + job = store.job("task-one") + store.delete_history("task-one") + store.save_job({**job, "batch_id": "batch"}) + path = report_export.export_batch("batch") + assert batch_archive.cached("batch", store.get("batch", "batch")) + with zipfile.ZipFile(path) as archive: + assert "人工评审" in load_workbook(BytesIO(archive.read("批次结果.xlsx"))).sheetnames + assert "人工评审" in " ".join( + p.text for p in Document(BytesIO(archive.read("批次汇总.docx"))).paragraphs + ) + save(client, {"correctness": "Failed"}) + # Even a late worker publishing report_ready cannot resurrect an old archive. + assert not batch_archive.cached("batch", {"report_ready": True}) + report_export.export_batch("batch") + plugins.set_enabled("review", False) + assert not batch_archive.cached("batch", {"report_ready": True}) + store.begin_delete_batch("batch") + store.delete_batch("batch") + assert store.get("task_extensions", "task-one") is None diff --git a/tests/unit/test_plugins.py b/tests/unit/test_plugins.py index 3d1eeb7..921d411 100644 --- a/tests/unit/test_plugins.py +++ b/tests/unit/test_plugins.py @@ -19,6 +19,38 @@ from artemis.plugins.store import PluginStore from artemis.runtime.device_lock import DeviceBusyError, DeviceExecutionLock +@pytest.mark.parametrize( + ("configured", "expected"), + [ + ( + "https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json", + "https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json", + ), + ( + "https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json", + "https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json", + ), + ("https://example.test/catalog.json", "https://example.test/catalog.json"), + ], +) +def test_workspace_catalog_routes_only_the_official_legacy_address( + monkeypatch, configured, expected +): + from artemis.plugins import installer + + requested = [] + monkeypatch.setattr(installer.settings, "ARTEMIS_PLUGIN_MARKETPLACE_URL", configured) + + def download(url, progress): + requested.append(url) + return b'{"schema_version":1,"plugins":[]}' + + monkeypatch.setattr(installer, "download", download) + assert installer.load_catalog().plugins == [] + assert requested == [expected] + assert installer.settings.ARTEMIS_PLUGIN_MARKETPLACE_URL == configured + + def manifest(**updates): return PluginManifest.model_validate( { diff --git a/uv.lock b/uv.lock index 3e9ec27..01af042 100644 --- a/uv.lock +++ b/uv.lock @@ -206,7 +206,7 @@ wheels = [ [[package]] name = "artemis" -version = "2.2.0" +version = "2.3.0" source = { editable = "." } dependencies = [ { name = "adbutils" },