feat: release 2.3.0 with workspace plugin extensions

This commit is contained in:
chenmingxuan committed 2026-10-27 21:38:43 +08:00
1 parent 8380057548
commit bb873ec0e0
44 files changed
+1497 -48

No files matched your search

+1 -1
View File
@@ -89,7 +89,7 @@ ARTEMIS_BATCH_MAX_EXECUTIONS=10000
ARTEMIS_IMPORT_MAX_BYTES=10485760 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_START_TIMEOUT_SECONDS=30
ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS=60 ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS=60
ARTEMIS_PLUGIN_SHUTDOWN_TIMEOUT_SECONDS=15 ARTEMIS_PLUGIN_SHUTDOWN_TIMEOUT_SECONDS=15
+8
View File
@@ -183,3 +183,11 @@ Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配
- 保留可见焦点、禁用反馈和减少动画偏好;录制红点表示实际录制,等待或执行中使用各自文案。 - 保留可见焦点、禁用反馈和减少动画偏好;录制红点表示实际录制,等待或执行中使用各自文案。
- 源码已更新而页面仍旧时,先核对服务目录、构建与缓存;不通过反复增加样式覆盖掩盖旧构建问题。 - 源码已更新而页面仍旧时,先核对服务目录、构建与缓存;不通过反复增加样式覆盖掩盖旧构建问题。
- 视觉变化验证代表性宽度、长标题、加载、等待回答、错误与展开状态;文档格式检查不等于渲染验证。开发检查按[前端指南](apps/showcase_ui/AGENTS.md)执行。 - 视觉变化验证代表性宽度、长标题、加载、等待回答、错误与展开状态;文档格式检查不等于渲染验证。开发检查按[前端指南](apps/showcase_ui/AGENTS.md)执行。
## Workspace plugin extensions
工作区扩展使用声明式清单版本 3,宿主提供回放面板、人工表单、任务存储和报告接口;不运行插件页面脚本,不另建队列或结果库。纯工作区插件不加入模型执行选择。接口定义见 [工作区插件](docs/workspace-plugins.md)。
回放两侧面板默认收起、独立展开,普通浮窗和影院模式共用;插件声明期望/最小/最大宽度,宿主按视口收缩,窄屏一次展开一侧,始终保持面板位于回放的左侧或右侧。任务需求只展示本次运行最初的提示词,复用安全 Markdown 渲染。人工表单字段均可留空,不调用模型、不覆盖已有执行结论或人工复验。
评审按单次任务和插件 ID 保存版本历史;运行中只读,终态可编辑或清空。停用/卸载隐藏面板与新导出字段但保留记录,任务删除时同步清理。报告只采用已安装且启用的贡献,空值保持空白;修改记录或插件状态使后续报告缓存失效。
+2 -2
View File
@@ -2,13 +2,13 @@
基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。 基于 [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 适合需要计划、分支探索和验证的任务。 - **自然语言任务**:Flash 适合明确的操作流程,Pro 适合需要计划、分支探索和验证的任务。
- **交互探索**:边执行边交流,也可录制人工示范;探索成功后生成一个可编辑、复跑的用例。默认使用独立的 `gpt-6.1-terra / high` 配置,超时暂停并保留进度。见 [探索模式](docs/exploration-mode.md)。 - **交互探索**:边执行边交流,也可录制人工示范;探索成功后生成一个可编辑、复跑的用例。默认使用独立的 `gpt-6.1-terra / high` 配置,超时暂停并保留进度。见 [探索模式](docs/exploration-mode.md)。
- **插件扩展**:在插件市场安装并启用电脑控制等能力,AI 在安卓任务中按需使用;手机、电脑的步骤与证据统一留档。见 [插件与跨端任务](docs/plugins.md)。 - **插件扩展**:安装并启用电脑控制等执行能力,或使用工作区插件为回放和报告增加人工评审。手机、电脑的步骤与证据统一留档。见 [插件与跨端任务](docs/plugins.md)和[工作区插件接口](docs/workspace-plugins.md)。
- **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。 - **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。
- **批量测试**:按新版 Excel 模板导入提示词,每项可独立指定模型、模式和参数;任务可暂停、补充指导并继续。 - **批量测试**:按新版 Excel 模板导入提示词,每项可独立指定模型、模式和参数;任务可暂停、补充指导并继续。
- **精选任务**:收藏单任务、批次或探索用例,在首页一键复跑,默认沿用已保存的配置。 - **精选任务**:收藏单任务、批次或探索用例,在首页一键复跑,默认沿用已保存的配置。
+3
View File
@@ -214,6 +214,9 @@ async def diagnose_plugin(plugin_id: str, request: DiagnoseRequest):
try: try:
store = get_store() store = get_store()
manifest = PluginManifest.model_validate(store.get(plugin_id)["manifest"]) 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( runtime = PluginRuntime(
[PluginSelection(id=plugin_id, profile=request.profile)], [PluginSelection(id=plugin_id, profile=request.profile)],
store=store, store=store,
@@ -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)
+3
View File
@@ -284,6 +284,9 @@ app.include_router(favorites_router)
from apps.admin_console.routers.plugins import router as plugins_router from apps.admin_console.routers.plugins import router as plugins_router
app.include_router(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 from apps.admin_console.routers.exploration import router as exploration_router
@@ -294,7 +294,7 @@ class AutomationStore:
if batch.get("status") != "deleting": if batch.get("status") != "deleting":
raise ValueError("批次尚未准备删除") raise ValueError("批次尚未准备删除")
conn.execute( 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=?)", "(SELECT session_id FROM automation_jobs WHERE batch_id=?)",
(batch_id,), (batch_id,),
) )
@@ -312,13 +312,15 @@ class AutomationStore:
if session_id: if session_id:
conn.execute("DELETE FROM automation_jobs WHERE session_id=?", (session_id,)) conn.execute("DELETE FROM automation_jobs WHERE session_id=?", (session_id,))
conn.execute( 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,), (session_id,),
) )
else: else:
conn.execute("DELETE FROM automation_jobs") conn.execute("DELETE FROM automation_jobs")
conn.execute("DELETE FROM automation_records WHERE kind='batch'") 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() conn.commit()
+17 -4
View File
@@ -15,7 +15,7 @@ def archive_path(batch_id: str):
def cached(batch_id: str, batch: dict) -> bool: def cached(batch_id: str, batch: dict) -> bool:
return bool(batch.get("report_ready")) and report_export.reusable( 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: def status(batch_id: str, batch: dict) -> dict:
with _lock: with _lock:
state = _states.get(batch_id) state = _states.get(batch_id)
if state: if state and state["status"] != "ready":
return _snapshot(state) 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): if cached(batch_id, batch):
return { return {
"status": "ready", "status": "ready",
@@ -65,11 +71,12 @@ def status(batch_id: str, batch: dict) -> dict:
def prepare(batch_id: str, batch: dict) -> dict: def prepare(batch_id: str, batch: dict) -> dict:
cache_ready = cached(batch_id, batch)
with _lock: with _lock:
current = _states.get(batch_id) current = _states.get(batch_id)
if current and current["status"] == "preparing": if current and current["status"] == "preparing":
return _snapshot(current) return _snapshot(current)
if cached(batch_id, batch): if cache_ready:
_states.pop(batch_id, None) _states.pop(batch_id, None)
return status_from_cache() return status_from_cache()
state = { state = {
@@ -88,11 +95,17 @@ def prepare(batch_id: str, batch: dict) -> dict:
def run() -> None: def run() -> None:
try: try:
with report_export.export_lock("batch:" + batch_id): with report_export.export_lock("batch:" + batch_id):
signature = report_export.batch_archive_signature(batch_id)
latest = report_export.store.get("batch", batch_id) latest = report_export.store.get("batch", batch_id)
if not (latest and cached(batch_id, latest)): if not (latest and cached(batch_id, latest)):
report_export.export_batch(batch_id, progress=update) report_export.export_batch(batch_id, progress=update)
with _lock: 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: except Exception as exc:
with _lock: with _lock:
state.update( state.update(
+60 -2
View File
@@ -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.request_diagnostics import diagnostics_json
from apps.admin_console.services.task_lifecycle import guidance_details from apps.admin_console.services.task_lifecycle import guidance_details
from apps.admin_console.services import task_extensions
from docx import Document from docx import Document
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT 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" EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-2" 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 = { STATUS = {
"completed": "执行完成", "completed": "执行完成",
"success": "执行完成", "success": "执行完成",
@@ -239,6 +265,7 @@ def session_report(session_id: str, db_path=None) -> dict:
"subcategory": case.get("subcategory", ""), "subcategory": case.get("subcategory", ""),
"preconditions": case.get("preconditions", ""), "preconditions": case.get("preconditions", ""),
"task_config": case.get("task_config") or {}, "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_paragraph("任务配置:" + config_text(report["task_config"]))
doc.add_heading("结论与发现", 1) doc.add_heading("结论与发现", 1)
add_markdown_content(doc, report["content"]) 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) doc.add_heading("执行步骤与证据", 1)
for step in report["steps"]: for step in report["steps"]:
paragraph = doc.add_paragraph() paragraph = doc.add_paragraph()
@@ -792,6 +824,10 @@ def excel_report(report: dict, path: Path):
wb.active.title = "任务结果" wb.active.title = "任务结果"
sheet_rows(wb.active, [("项目", "内容"), *metadata(report)], (22, 82)) sheet_rows(wb.active, [("项目", "内容"), *metadata(report)], (22, 82))
sheet_rows(wb.create_sheet("结论"), conclusion_rows(report["content"]), (15, 96)) 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 table_number = 0
for kind, rows in markdown_blocks(report["content"]): for kind, rows in markdown_blocks(report["content"]):
if kind == "table": 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 = EXPORT_ROOT / "batches" / batch_id
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
formats = batch["formats"] 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: if progress:
progress(0, len(records), "正在生成批次汇总") progress(0, len(records), "正在生成批次汇总")
rows = [("任务编号", "任务名称", "执行状态", "验证结论", "异常原因")] rows = [("任务编号", "任务名称", "执行状态", "验证结论", "异常原因")]
@@ -1078,6 +1116,17 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
assert wb.active is not None assert wb.active is not None
wb.active.title = "批次结果" wb.active.title = "批次结果"
sheet_rows(wb.active, rows, (40, 48, 16, 28, 70)) 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" path = directory / "批次结果.xlsx"
wb.save(path) wb.save(path)
outputs.append(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)} 个任务。验证通过与执行完成分别统计。" f"批次编号:{batch_id};共 {len(records)} 个任务。验证通过与执行完成分别统计。"
) )
word_table(doc, rows, (3.5, 5.4, 2.5, 3.5, 7.3), header=True) 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" path = directory / "批次汇总.docx"
doc.save(str(path)) doc.save(str(path))
outputs.append(path) outputs.append(path)
@@ -1146,5 +1204,5 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
if progress: if progress:
progress(len(records), len(records), "正在完成压缩包") progress(len(records), len(records), "正在完成压缩包")
temporary.replace(target) temporary.replace(target)
remember(target, BATCH_ARCHIVE_SIGNATURE) remember(target, signature)
return target return target
@@ -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()
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.2.0", "version": "2.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.2.0", "version": "2.3.0",
"dependencies": { "dependencies": {
"@angular/cdk": "^22.1.5", "@angular/cdk": "^22.1.5",
"@angular/common": "^22.1.4", "@angular/common": "^22.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.2.0", "version": "2.3.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
@@ -27,7 +27,9 @@ limitations under the License.
class="floating-video-wrapper" class="floating-video-wrapper"
[class.minimized]="agentService.isVideoMinimized()" [class.minimized]="agentService.isVideoMinimized()"
[class.theater]="isTheaterMode()" [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.top.px]="!isTheaterMode() ? posY() : null"
[style.--player-top]="posY() + 'px'" [style.--player-top]="posY() + 'px'"
> >
@@ -42,6 +44,9 @@ limitations under the License.
</span> </span>
</div> </div>
<div class="header-actions" (mousedown)="$event.stopPropagation()"> <div class="header-actions" (mousedown)="$event.stopPropagation()">
@if (extensionPanels(); as panels) {
<ng-container [ngTemplateOutlet]="panels.headerControls() || null" />
}
<!-- Mode Toggle Group: Video vs. Step Replay --> <!-- Mode Toggle Group: Video vs. Step Replay -->
@if (agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()) { @if (agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()) {
<div class="mode-toggle-group"> <div class="mode-toggle-group">
@@ -117,6 +122,8 @@ limitations under the License.
} }
<!-- Video Main Content Area --> <!-- Video Main Content Area -->
@if (!agentService.isVideoMinimized()) { @if (!agentService.isVideoMinimized()) {
<app-task-extension-panels [sessionId]="agentService.currentSessionId()" [enabled]="agentService.playerMode() === 'steps'"
[taskStatus]="agentService.currentSession()?.status || ''" (expandedWidth)="extensionWidth.set($event)">
<div class="video-body"> <div class="video-body">
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) --> <!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
@if (agentService.recordingPlaybackStatus() === 'live') { @if (agentService.recordingPlaybackStatus() === 'live') {
@@ -488,6 +495,7 @@ limitations under the License.
} }
} }
</div> </div>
</app-task-extension-panels>
} }
</div> </div>
} }
@@ -28,6 +28,7 @@
} }
.floating-video-wrapper { .floating-video-wrapper {
container-type: inline-size;
position: fixed; position: fixed;
width: 400px; width: 400px;
max-width: calc(100vw - 32px); max-width: calc(100vw - 32px);
@@ -60,8 +61,8 @@
left: 50% !important; left: 50% !important;
top: 50% !important; top: 50% !important;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
width: 85vw; width: min(calc(85vw + var(--extension-width, 0px)), calc(100vw - 32px));
max-width: 960px; max-width: calc(960px + var(--extension-width, 0px));
height: auto; height: auto;
max-height: 88vh; max-height: 88vh;
box-shadow: 0 24px 60px -12px rgba(15, 23, 42, 0.35); box-shadow: 0 24px 60px -12px rgba(15, 23, 42, 0.35);
@@ -805,16 +806,21 @@
.controls-row { .controls-row {
display: flex; display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
.controls-left, .controls-right { .controls-left, .controls-right {
display: flex; display: flex;
flex-wrap: wrap;
white-space: nowrap;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.ctrl-btn { .ctrl-btn {
flex-shrink: 0;
background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.75); background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.75);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
@@ -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 { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { AgentService, RecordingSource, VideoSegment } from '../../services/agent.service'; import { AgentService, RecordingSource, VideoSegment } from '../../services/agent.service';
@@ -59,7 +61,7 @@ describe('FloatingVideoPlayerComponent video markers', () => {
agentService = createAgentService(); agentService = createAgentService();
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [FloatingVideoPlayerComponent], imports: [FloatingVideoPlayerComponent],
providers: [{ provide: AgentService, useValue: agentService }] providers: [provideHttpClient(), provideHttpClientTesting(), { provide: AgentService, useValue: agentService }]
}); });
fixture = TestBed.createComponent(FloatingVideoPlayerComponent); fixture = TestBed.createComponent(FloatingVideoPlayerComponent);
@@ -26,10 +26,13 @@ import {
HostListener, HostListener,
effect, effect,
untracked, untracked,
OnDestroy OnDestroy,
viewChild
} from '@angular/core'; } from '@angular/core';
import { TaskExtensionPanelsComponent } from '../task-extension-panels/task-extension-panels.component';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NgTemplateOutlet } from '@angular/common';
import { AgentService } from '../../services/agent.service'; import { AgentService } from '../../services/agent.service';
import { StepReplayFrame } from '../../core/models/stream.model'; import { StepReplayFrame } from '../../core/models/stream.model';
import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util'; import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util';
@@ -40,13 +43,19 @@ import { buildVideoStepMarkers, VideoStepMarker } from '../../utils/video-step-m
@Component({ @Component({
selector: 'app-floating-video-player', selector: 'app-floating-video-player',
standalone: true, standalone: true,
imports: [FormsModule], imports: [FormsModule, NgTemplateOutlet, TaskExtensionPanelsComponent],
templateUrl: './floating-video-player.component.html', templateUrl: './floating-video-player.component.html',
styleUrl: './floating-video-player.component.scss', styleUrl: './floating-video-player.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class FloatingVideoPlayerComponent implements OnDestroy { export class FloatingVideoPlayerComponent implements OnDestroy {
public agentService = inject(AgentService); 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); private zone = inject(NgZone);
@ViewChild('videoRef') videoRef?: ElementRef<HTMLVideoElement>; @ViewChild('videoRef') videoRef?: ElementRef<HTMLVideoElement>;
@@ -327,7 +336,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
this.isDragging = true; this.isDragging = true;
this.dragStartX = event.clientX; this.dragStartX = event.clientX;
this.dragStartY = event.clientY; this.dragStartY = event.clientY;
this.initialPosX = this.posX(); this.initialPosX = this.windowLeft();
this.initialPosY = this.posY(); this.initialPosY = this.posY();
event.preventDefault(); event.preventDefault();
@@ -21,7 +21,7 @@ import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
li { margin: 4px 0; } 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); } 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); } 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; } pre code { padding: 0; background: none; }
.md-table-scroll { max-width: 100%; overflow-x: auto; } .md-table-scroll { max-width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 12px; } table { width: 100%; border-collapse: collapse; font-size: 12px; }
@@ -0,0 +1,99 @@
@if (enabled() && error()) {
<div class="extension-notice" role="status">{{ error() }} <button type="button" (click)="refresh()">重试</button></div>
}
<ng-template #headerControls>
@if (data()?.plugins?.length) {
<nav class="panel-toggles" aria-label="任务扩展面板">
@for (side of ['left', 'right']; track side) {
@for (plugin of data()!.plugins; track plugin.plugin_id) {
@for (panel of plugin.workspace.panels; track panel.id) {
@if (panel.side === side) {
<button type="button" class="panel-toggle" [class.active]="opened()[key(plugin, panel)]" [attr.aria-expanded]="!!opened()[key(plugin, panel)]"
[attr.aria-label]="(opened()[key(plugin, panel)] ? '收起' : '展开') + panel.title"
[title]="(opened()[key(plugin, panel)] ? '收起' : '展开') + panel.title"
[attr.aria-controls]="'extension-' + key(plugin, panel)" (click)="toggle(plugin, panel)">
<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2" y="3" width="16" height="14" rx="3" /><path [attr.d]="side === 'left' ? 'M7 3v14' : 'M13 3v14'" /></svg>
<span class="panel-toggle-label">{{ panel.title }}</span>
<span class="material-symbols-outlined" aria-hidden="true">{{ (side === 'left') !== !!opened()[key(plugin, panel)] ? 'chevron_left' : 'chevron_right' }}</span>
</button>
}
}
}
}
</nav>
}
</ng-template>
<div class="extension-layout" [class.has-left]="panels('left').length" [class.has-right]="panels('right').length"
[style.--left-width]="width('left') + 'px'" [style.--right-width]="width('right') + 'px'">
@if (panels('left').length) {
<aside class="panel-column left-column" aria-label="任务左侧扩展">
@for (item of panels('left'); track key(item.plugin, item.panel)) {
<ng-container *ngTemplateOutlet="panelContent; context: { plugin: item.plugin, panel: item.panel }" />
}
</aside>
}
<div class="player-content"><ng-content /></div>
@if (panels('right').length) {
<aside class="panel-column right-column" aria-label="任务右侧扩展">
@for (item of panels('right'); track key(item.plugin, item.panel)) {
<ng-container *ngTemplateOutlet="panelContent; context: { plugin: item.plugin, panel: item.panel }" />
}
</aside>
}
</div>
<ng-template #panelContent let-plugin="plugin" let-panel="panel">
<section [id]="'extension-' + key(plugin, panel)" [attr.aria-label]="panel.title">
<header><h3>{{ panel.title }}</h3><span class="panel-kind">{{ panel.kind === 'initial_prompt' ? '原始提示词' : '人工填写 · 选填' }}</span></header>
@if (panel.kind === 'initial_prompt') {
@if (data()?.initial_prompt) {
<app-report-markdown [content]="data()!.initial_prompt" />
} @else { <p class="muted">该任务未记录初始需求提示词。</p> }
} @else {
@if (!data()?.editable) { <p class="muted">任务结束后可填写评审。</p> }
<fieldset [disabled]="!data()?.editable || !!saving()">
@for (field of plugin.workspace.fields; track field.id) {
<div class="field" [class.choice-field]="field.kind === 'choice'" [class.note-field]="field.kind === 'text'">
<span class="field-label">{{ field.label }}</span>
@if (field.description) { <span class="muted">{{ field.description }}</span> }
@if (field.kind === 'choice') {
<div class="choice-group" role="group" [attr.aria-label]="field.label">
@for (option of field.options; track option) {
<button type="button" class="choice-button" [class.selected]="drafts[plugin.plugin_id].values[field.id] === option"
[class.pass]="option === 'Pass' || option === '通过'" [class.failed]="option === 'Failed' || option === '失败'"
[attr.aria-pressed]="drafts[plugin.plugin_id].values[field.id] === option" title="再次点击已选项可清除"
(click)="choose(plugin.plugin_id, field.id, option)">
@if (option === 'Pass' || option === 'Failed' || option === '通过' || option === '失败') {
<svg class="choice-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path [attr.d]="option === 'Pass' || option === '通过' ? 'M5 12l4 4L19 6' : 'M6 6l12 12M18 6L6 18'" />
</svg>
}
<span class="choice-label">{{ option }}</span>
</button>
}
</div>
} @else {
<textarea rows="2" [attr.aria-label]="field.label" [maxLength]="field.max_length" placeholder="添加备注(选填)"
[ngModel]="drafts[plugin.plugin_id].values[field.id] || ''" (ngModelChange)="edit(plugin.plugin_id, field.id, $event)"></textarea>
}
</div>
}
<div class="form-actions">
<button type="button" class="save" (click)="save(plugin)">{{ saving() === plugin.plugin_id ? '正在保存…' : '保存评审' }}</button>
<button type="button" class="reload" [disabled]="!!saving()" (click)="reload(plugin)" title="放弃未保存内容,读取最新记录">重新加载</button>
@if (drafts[plugin.plugin_id].dirty) { <span class="muted unsaved">有未保存内容</span> }
</div>
</fieldset>
@if (notice()) { <p class="notice" role="status">{{ notice() }}</p> }
@if (plugin.history.length) {
<details class="history"><summary>修改记录({{ plugin.history.length }})</summary>
@for (entry of plugin.history; track entry.revision) {
<div class="history-entry"><strong>第 {{ entry.revision }} 次 · {{ entry.saved_at * 1000 | date:'yyyy-MM-dd HH:mm:ss' }}</strong>
@for (field of entry.fields; track field.id) { <p>{{ field.label }}:{{ entry.values[field.id] || '未填写' }}</p> }
</div>
}
</details>
}
}
</section>
</ng-template>
@@ -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; }
}
@@ -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<TaskExtensionPanelsComponent>;
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<HTMLElement> = fixture.nativeElement.querySelectorAll('.choice-group');
expect(fixture.nativeElement.querySelector('[aria-pressed="true"]')).toBeNull();
const pass = groups[0].querySelector<HTMLButtonElement>('.pass')!;
const failed = groups[0].querySelector<HTMLButtonElement>('.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); }
});
});
@@ -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<string | null>(null);
enabled = input(false);
taskStatus = input('');
expandedWidth = output<number>();
headerControls = viewChild<TemplateRef<unknown>>('headerControls');
data = signal<TaskExtensions | null>(null);
error = signal('');
saving = signal<string | null>(null);
notice = signal('');
opened = signal<Record<string, boolean>>({});
private refreshToken = signal(0);
private identity = '';
private generation = 0;
drafts: Record<string, ExtensionDraft> = {};
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 : '保存失败,已保留输入,请重试');
}
});
}
}
@@ -26,7 +26,7 @@ export class TaskPluginsComponent implements OnChanges {
return this.automatic ? '自动' : Object.keys(this.selected).length ? `已选 ${Object.keys(this.selected).length} 个` : '本次停用'; return this.automatic ? '自动' : Object.keys(this.selected).length ? `已选 ${Object.keys(this.selected).length} 个` : '本次停用';
} }
available() { 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'))); p.manifest.targets.some(target => target === this.target() || (this.target() === 'android' && target === 'mixed')));
} }
ngOnChanges() { ngOnChanges() {
@@ -1,5 +1,5 @@
<main class="plugins-page"> <main class="plugins-page">
<header><div><span class="eyebrow">ARTEMIS · 扩展能力</span><h1>插件</h1><p>为安卓测试扩展电脑等操作能力,AI 按任务需要自主调用。</p></div> <header><div><span class="eyebrow">ARTEMIS · 扩展能力</span><h1>插件</h1><p>扩展任务操作能力,也可为回放界面和报告添加人工评审等功能。</p></div>
<button (click)="refresh(); refreshCatalog()" [disabled]="busy() || loading() || catalogLoading()">刷新</button></header> <button (click)="refresh(); refreshCatalog()" [disabled]="busy() || loading() || catalogLoading()">刷新</button></header>
@if (error()) { <div class="message error" role="alert">{{error()}}</div> } @if (error()) { <div class="message error" role="alert">{{error()}}</div> }
@if (notice()) { <div class="message success" role="status">{{notice()}}</div> } @if (notice()) { <div class="message success" role="status">{{notice()}}</div> }
@@ -1,10 +1,12 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { tap } from 'rxjs';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
export interface PluginManifest { export interface PluginManifest {
id: string; name: string; version: string; description: string; id: string; name: string; version: string; description: string;
artemis_version: string; platforms: string[]; source: string; artemis_version: string; platforms: string[]; source: string;
config: Record<string, { label: string; description: string; secret: boolean; required: boolean; default: string }>; config: Record<string, { label: string; description: string; secret: boolean; required: boolean; default: string }>;
workspace?: unknown; server?: unknown; skill?: string;
resources: string[]; resources: string[];
targets: ('android' | 'computer' | 'mixed')[]; targets: ('android' | 'computer' | 'mixed')[];
} }
@@ -28,13 +30,13 @@ export class PluginService {
install(payload: { plugin_id?: string; version?: string; local_path?: string }) { install(payload: { plugin_id?: string; version?: string; local_path?: string }) {
return this.http.post<{ job_id: string }>('/api/plugins/install', payload); return this.http.post<{ job_id: string }>('/api/plugins/install', payload);
} }
progress(id: string) { return this.http.get<InstallProgress>(`/api/plugins/install/${encodeURIComponent(id)}`); } progress(id: string) { return this.http.get<InstallProgress>(`/api/plugins/install/${encodeURIComponent(id)}`).pipe(tap(() => window.dispatchEvent(new Event('artemis-plugin-change')))); }
save(id: string, profile: PluginProfile) { save(id: string, profile: PluginProfile) {
const { configured_secrets, ...payload } = profile; const { configured_secrets, ...payload } = profile;
return this.http.put<InstalledPlugin>(`/api/plugins/${encodeURIComponent(id)}/profiles`, payload); return this.http.put<InstalledPlugin>(`/api/plugins/${encodeURIComponent(id)}/profiles`, payload);
} }
enable(id: string, enabled: boolean) { return this.http.put<InstalledPlugin>(`/api/plugins/${encodeURIComponent(id)}/enabled`, { enabled }); } enable(id: string, enabled: boolean) { return this.http.put<InstalledPlugin>(`/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 }); } 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 }); } custom(manifest: unknown, skill_content: string) { return this.http.post('/api/plugins/custom', { manifest, skill_content }); }
} }
@@ -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<string, string>;
history: { revision: number; saved_at: number; values: Record<string, string>; fields: ExtensionField[] }[];
}
export interface TaskExtensions {
session_id: string; initial_prompt: string; editable: boolean; plugins: TaskExtension[];
}
export interface ExtensionDraft {
values: Record<string, string>; revision: number; dirty: boolean; requestId?: string;
}
@Injectable({ providedIn: 'root' })
export class TaskExtensionsService {
private http = inject(HttpClient);
private drafts = new Map<string, ExtensionDraft>();
read(sessionId: string) {
return this.http.get<TaskExtensions>(`/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<TaskExtensions>(`/api/task-extensions/${encodeURIComponent(sessionId)}/${encodeURIComponent(pluginId)}`, {
values: { ...draft.values }, expected_revision: draft.revision, request_id: draft.requestId
});
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ class Settings(BaseSettings):
"""Centralized ARTEMIS runtime settings loaded from environment and .env files.""" """Centralized ARTEMIS runtime settings loaded from environment and .env files."""
ARTEMIS_PLUGIN_MARKETPLACE_URL: str = ( 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_START_TIMEOUT_SECONDS: float = Field(default=30, gt=0, le=300)
ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=3600) ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=3600)
+6 -1
View File
@@ -43,7 +43,12 @@ def download(url: str, progress: Callable[[str, int], None]) -> bytes:
def load_catalog() -> PluginCatalog: 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: if len(data) > 2 * 1024 * 1024:
raise ValueError("市场索引过大") raise ValueError("市场索引过大")
return PluginCatalog.model_validate_json(data) return PluginCatalog.model_validate_json(data)
+9 -4
View File
@@ -12,6 +12,8 @@ from packaging.specifiers import SpecifierSet
from packaging.version import Version from packaging.version import Version
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator 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}$")] Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_-]{0,47}$")]
@@ -94,7 +96,7 @@ class EvidenceSource(PluginModel):
class PluginManifest(PluginModel): class PluginManifest(PluginModel):
schema_version: Literal[1, 2] = 1 schema_version: Literal[1, 2, 3] = 1
id: Identifier id: Identifier
name: str = Field(min_length=1, max_length=100) name: str = Field(min_length=1, max_length=100)
version: str version: str
@@ -113,6 +115,7 @@ class PluginManifest(PluginModel):
targets: list[ExecutionTarget] = Field(default_factory=lambda: ["mixed"]) targets: list[ExecutionTarget] = Field(default_factory=lambda: ["mixed"])
observation: PluginObservation | None = None observation: PluginObservation | None = None
capabilities: PluginCapabilities | None = None capabilities: PluginCapabilities | None = None
workspace: WorkspaceContribution | None = None
@field_validator("version") @field_validator("version")
@classmethod @classmethod
@@ -130,14 +133,16 @@ class PluginManifest(PluginModel):
@model_validator(mode="after") @model_validator(mode="after")
def has_capability(self): def has_capability(self):
if self.server is None and self.skill is None: if self.server is None and self.skill is None and self.workspace is None:
raise ValueError("插件至少需要 MCP 服务或 Skill") 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(): for name, field in self.config.items():
if field.secret and field.default: if field.secret and field.default:
raise ValueError(f"密钥字段 {name} 不允许默认值") raise ValueError(f"密钥字段 {name} 不允许默认值")
if self.schema_version == 1 and self.capabilities is not None: if self.schema_version == 1 and self.capabilities is not None:
raise ValueError("能力契约需要 schema_version=2;旧清单保持兼容") raise ValueError("能力契约需要 schema_version=2;旧清单保持兼容")
if self.schema_version == 2: if self.schema_version >= 2:
if self.capabilities is None: if self.capabilities is None:
raise ValueError("第二版插件必须声明 capabilities") raise ValueError("第二版插件必须声明 capabilities")
if self.observation: if self.observation:
+5
View File
@@ -105,6 +105,7 @@ class PluginStore:
if not existing: if not existing:
profile = PluginProfile( profile = PluginProfile(
is_default=True, 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}, values={k: v.default for k, v in manifest.config.items() if not v.secret},
) )
db.execute( db.execute(
@@ -236,6 +237,8 @@ class PluginStore:
def selections(self, target: str = "mixed") -> list[PluginSelection]: def selections(self, target: str = "mixed") -> list[PluginSelection]:
selections = [] selections = []
for plugin in self.list(): 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( if not plugin["enabled"] or not self._supports_task(
plugin["manifest"].get("targets", ["mixed"]), target plugin["manifest"].get("targets", ["mixed"]), target
): ):
@@ -264,6 +267,8 @@ class PluginStore:
pinned = [] pinned = []
for choice in self.selections(target) if selections is None else selections: for choice in self.selections(target) if selections is None else selections:
manifest, profile, _ = self.resolve(choice) 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): if not self._supports_task(manifest.targets, target):
raise ValueError(f"插件 {manifest.name} 不支持 {target} 任务") raise ValueError(f"插件 {manifest.name} 不支持 {target} 任务")
pinned.append( pinned.append(
+68
View File
@@ -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
+2 -2
View File
@@ -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 管理,插件不另建任务调度器,也不替用户决定用例通过。
## 从可运行模板开始 ## 从可运行模板开始
+4 -2
View File
@@ -1,6 +1,8 @@
# 插件与跨端任务(2.0.0) # 插件与跨端任务(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 源码不会升级已安装插件。旧任务缺失的电脑录像无法补录,播放器会提示查看已有步骤截图。 步骤卡标明安卓、电脑等来源,可点击查看对应录像;同一步内保留实际工具调用。开启「保存录屏」后,已使用的安卓和电脑来源各自留档,通过播放器切换。电脑录屏需要插件提供版本 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_START_TIMEOUT_SECONDS` | 30 秒 |
| `ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS` | 60 秒 | | `ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS` | 60 秒 |
| `ARTEMIS_PLUGIN_SHUTDOWN_TIMEOUT_SECONDS` | 15 秒 | | `ARTEMIS_PLUGIN_SHUTDOWN_TIMEOUT_SECONDS` | 15 秒 |
+34
View File
@@ -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 未实测。
+77
View File
@@ -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。宿主读取原官方地址时自动选择新版索引,不改写用户配置;自定义市场地址不会被替换。
+1 -1
View File
@@ -14,7 +14,7 @@
[project] [project]
name = "artemis" name = "artemis"
version = "2.2.0" version = "2.3.0"
description = "Autonomous AI Assistant for Mobile Automation" description = "Autonomous AI Assistant for Mobile Automation"
authors = [ authors = [
+3 -3
View File
@@ -18,8 +18,8 @@ async def check(directory: Path, live: bool, values: dict[str, str]) -> None:
skill = contained_path(directory, manifest.skill) skill = contained_path(directory, manifest.skill)
if not skill.is_file() or not skill.read_text("utf-8").strip(): if not skill.is_file() or not skill.read_text("utf-8").strip():
raise ValueError("缺少有效的 Skill") raise ValueError("缺少有效的 Skill")
if manifest.schema_version != 2: if manifest.schema_version not in {2, 3}:
raise ValueError("新市场插件必须适配 schema_version=2") raise ValueError("新市场插件必须适配 schema_version=2 或 3")
if live and manifest.server: if live and manifest.server:
server = resolve_server(manifest, PluginProfile(values=values), directory) server = resolve_server(manifest, PluginProfile(values=values), directory)
session = await PluginSession(server, directory).start() 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() await session.aclose()
print( print(
f"PASS {manifest.id} {manifest.version}: 清单/兼容性/Skill" f"PASS {manifest.id} {manifest.version}: 清单/兼容性/Skill"
+ ("/MCP 工具声明" if live else "") + ("/MCP 工具声明" if live and manifest.server else "")
) )
print("此检查不代替实际执行、超时取消、证据导出和视觉能力验收。") print("此检查不代替实际执行、超时取消、证据导出和视觉能力验收。")
+1
View File
@@ -17,6 +17,7 @@ ARTEMIS 自己编排手机和已授权插件,共用网页的任务队列、历
| 给出任务编号,问失败原因、token、缓存、录像或最终结论 | [诊断与复盘](references/diagnostics.md) | | 给出任务编号,问失败原因、token、缓存、录像或最终结论 | [诊断与复盘](references/diagnostics.md) |
| 使用电脑、路由器、终端等已安装能力 | [插件任务参数](references/plugins.md) | | 使用电脑、路由器、终端等已安装能力 | [插件任务参数](references/plugins.md) |
| 编写、适配、验证或发布 ARTEMIS 插件 | [插件开发规范](references/plugin-development.md),按需读取 [清单与市场协议](references/plugin-manifest.md) | | 编写、适配、验证或发布 ARTEMIS 插件 | [插件开发规范](references/plugin-development.md),按需读取 [清单与市场协议](references/plugin-manifest.md) |
| 开发回放侧栏、人工表单或扩展报告字段 | [工作区插件接口(ARTEMIS 2.3.0+)](references/workspace-plugins.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 管理,插件不另建任务调度器,也不替用户决定用例通过。
## 从可运行模板开始 ## 从可运行模板开始
@@ -1,5 +1,7 @@
# 插件与跨端任务(2.0.0 开发版) # 插件与跨端任务(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 仍兼容。 插件开发请阅读 [能力、证据协议与验收规则](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 源码不会升级已安装插件。旧任务缺失的电脑录像无法补录,播放器会提示查看已有步骤截图。 步骤卡标明安卓、电脑等来源,可点击查看对应录像;同一步内保留实际工具调用。开启「保存录屏」后,已使用的安卓和电脑来源各自留档,通过播放器切换。电脑录屏需要插件提供版本 2 的来源回执和录像能力声明,当前市场使用 `cua-driver` 0.36.0;旧 `open-computer-use` 适配包从 1.1.0 起支持,但已从市场下架;仅更新 ARTEMIS 源码不会升级已安装插件。旧任务缺失的电脑录像无法补录,播放器会提示查看已有步骤截图。
@@ -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执行准确性」「测试结果正确性」两个 通过/失败 字段和各自可选备注。
在已更新宿主的环境,用 `<ARTEMIS_PYTHON> <本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。宿主读取原官方地址时自动选择新版索引,不改写用户配置;自定义市场地址不会被替换。
+3 -3
View File
@@ -18,8 +18,8 @@ async def check(directory: Path, live: bool, values: dict[str, str]) -> None:
skill = contained_path(directory, manifest.skill) skill = contained_path(directory, manifest.skill)
if not skill.is_file() or not skill.read_text("utf-8").strip(): if not skill.is_file() or not skill.read_text("utf-8").strip():
raise ValueError("缺少有效的 Skill") raise ValueError("缺少有效的 Skill")
if manifest.schema_version != 2: if manifest.schema_version not in {2, 3}:
raise ValueError("新市场插件必须适配 schema_version=2") raise ValueError("新市场插件必须适配 schema_version=2 或 3")
if live and manifest.server: if live and manifest.server:
server = resolve_server(manifest, PluginProfile(values=values), directory) server = resolve_server(manifest, PluginProfile(values=values), directory)
session = await PluginSession(server, directory).start() 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() await session.aclose()
print( print(
f"PASS {manifest.id} {manifest.version}: 清单/兼容性/Skill" f"PASS {manifest.id} {manifest.version}: 清单/兼容性/Skill"
+ ("/MCP 工具声明" if live else "") + ("/MCP 工具声明" if live and manifest.server else "")
) )
print("此检查不代替实际执行、超时取消、证据导出和视觉能力验收。") print("此检查不代替实际执行、超时取消、证据导出和视觉能力验收。")
@@ -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
+32
View File
@@ -19,6 +19,38 @@ from artemis.plugins.store import PluginStore
from artemis.runtime.device_lock import DeviceBusyError, DeviceExecutionLock 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): def manifest(**updates):
return PluginManifest.model_validate( return PluginManifest.model_validate(
{ {
Generated
+1 -1
View File
@@ -206,7 +206,7 @@ wheels = [
[[package]] [[package]]
name = "artemis" name = "artemis"
version = "2.2.0" version = "2.3.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "adbutils" }, { name = "adbutils" },