Compare commits

...
2 Commits
67 changed files with 1529 additions and 74 deletions

No files matched your search

+5 -1
View File
@@ -19,6 +19,10 @@ ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS=3
# default 表示不发送思考强度参数;接口不支持所选值时明确报错。
OPENAI_REASONING_EFFORT=high
# 探索独立默认值;网页已保存的探索配置优先,已创建会话保留快照。
ARTEMIS_EXPLORATION_MODEL=gpt-6.1-terra
ARTEMIS_EXPLORATION_REASONING_EFFORT=high
# 1 跳过本次网页启动的 Codex MCP 与官方 artemis-use Skill 同步;默认自动安装/更新。
ARTEMIS_SKIP_CODEX_SYNC=0
@@ -85,7 +89,7 @@ ARTEMIS_BATCH_MAX_EXECUTIONS=10000
ARTEMIS_IMPORT_MAX_BYTES=10485760
# 插件连接与安装预算(修改后重启服务)
ARTEMIS_PLUGIN_MARKETPLACE_URL=https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json
ARTEMIS_PLUGIN_MARKETPLACE_URL=https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json
ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS=30
ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS=60
ARTEMIS_PLUGIN_SHUTDOWN_TIMEOUT_SECONDS=15
+1 -1
View File
@@ -54,7 +54,7 @@ Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。启
- 历史压缩、视觉摘要与工具上下文调整须保留关键文件回执和来源,维护稳定的多轮前缀;缓存率使用实际用量加权统计,不把缓存输入当作消失的 token,也不承诺固定收益。
- 设备动作回放已移除;只读历史步骤、截图和上下文回忆仍保留,不因 replay 命名误删这些能力。
- 密钥仅通过网页或环境配置写入,读取配置不得回显;MCP 不接受密钥。全局配置修改影响后续任务,已保存配置优先于新安装默认值。
- 新安装普通任务默认 `gpt-6-luna / high`,探索默认 `gpt-6.1-sol / high`;请求超时 300 秒、失败后重试 5 次、间隔 10 秒,视频分析与普通录屏关闭。接口不支持思考强度时明确报错,不静默降级。
- 新安装普通任务默认 `gpt-6-luna / high`,探索默认 `gpt-6.1-terra / high`;请求超时 300 秒、失败后重试 5 次、间隔 10 秒,视频分析与普通录屏关闭。接口不支持思考强度时明确报错,不静默降级。
- 业务环境恢复由任务提示词提出,不新增默认业务回滚流程;连接、录像、子进程和资源锁的运行时收尾仍然必需。
## Conventions
+9 -1
View File
@@ -94,7 +94,7 @@ Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配
收藏保存独立模板与执行配置,用例复跑固定版本;删除原任务不破坏已保存模板。历史与导出的有效结论合并人工复验,不能用新裁定改写旧证据。
普通任务新安装默认 `gpt-6-luna / high`,探索单独使用 `gpt-6.1-sol / high`,保留已有配置。上下文参照上限 300k、默认历史压缩预算 80k,均不表示整条任务累计 token 限额。优化稳定前缀时保留关键文件回执、路径、校验和来源;费用收益依实际网关用量及计价判断。
普通任务新安装默认 `gpt-6-luna / high`,探索单独使用 `gpt-6.1-terra / high`,保留已有配置。上下文参照上限 300k、默认历史压缩预算 80k,均不表示整条任务累计 token 限额。优化稳定前缀时保留关键文件回执、路径、校验和来源;费用收益依实际网关用量及计价判断。
普通 Pro 的计划全部完成后,执行循环返回既有收尾和最终验收流程,不要求额外设备动作;有新指导或验收反馈时先处理反馈,探索模式仍须交付完整报告。完成计划不等于测试通过。Operator 写入计划时拒绝新增或增加同名顶层里程碑,撤销该次写入并返回工具错误;局部替换不得夹带目标范围外的里程碑,已有重复项可继续整理,确需重复的阶段使用不同名称。
@@ -183,3 +183,11 @@ Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配
- 保留可见焦点、禁用反馈和减少动画偏好;录制红点表示实际录制,等待或执行中使用各自文案。
- 源码已更新而页面仍旧时,先核对服务目录、构建与缓存;不通过反复增加样式覆盖掩盖旧构建问题。
- 视觉变化验证代表性宽度、长标题、加载、等待回答、错误与展开状态;文档格式检查不等于渲染验证。开发检查按[前端指南](apps/showcase_ui/AGENTS.md)执行。
## Workspace plugin extensions
工作区扩展使用声明式清单版本 3,宿主提供回放面板、人工表单、任务存储和报告接口;不运行插件页面脚本,不另建队列或结果库。纯工作区插件不加入模型执行选择。接口定义见 [工作区插件](docs/workspace-plugins.md)。
回放两侧面板默认收起、独立展开,普通浮窗和影院模式共用;插件声明期望/最小/最大宽度,宿主按视口收缩,窄屏一次展开一侧,始终保持面板位于回放的左侧或右侧。任务需求只展示本次运行最初的提示词,复用安全 Markdown 渲染。人工表单字段均可留空,不调用模型、不覆盖已有执行结论或人工复验。
评审按单次任务和插件 ID 保存版本历史;运行中只读,终态可编辑或清空。停用/卸载隐藏面板与新导出字段但保留记录,任务删除时同步清理。报告只采用已安装且启用的贡献,空值保持空白;修改记录或插件状态使后续报告缓存失效。
+4 -4
View File
@@ -2,13 +2,13 @@
基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。
**2.2.0**:新增无需手机在线的仅 PC 测试、全局串行队列、暗色模式、Markdown 表格及可折叠复制的工具结果;修复安卓输入清空、观测恢复、排队状态与最终验收匹配。升级内容、验证范围与配套 Cua 插件见 [2.2.0 发行说明](docs/releases/2.2.0.md)。上一版本见 [2.1.0](docs/releases/2.1.0.md);Python 3.12 启动修复见 [2.0.1](docs/releases/2.0.1.md),探索、插件和精选任务等功能见 [2.0.0](docs/releases/2.0.0.md)。
**2.3.0**:新增工作区插件接口,支持步骤回放左右侧栏、Markdown 初始需求、人工评审及报告字段扩展;配套「任务人工评审 1.0.0」从插件市场独立安装。新增 gpt-6.1-terra,探索默认 high。见 [2.3.0 发行说明](docs/releases/2.3.0.md);上一版本见 [2.2.0](docs/releases/2.2.0.md)。
## 主要功能
- **自然语言任务**:Flash 适合明确的操作流程,Pro 适合需要计划、分支探索和验证的任务。
- **交互探索**:边执行边交流,也可录制人工示范;探索成功后生成一个可编辑、复跑的用例。默认使用独立的 `gpt-6.1-sol / high` 配置,超时暂停并保留进度。见 [探索模式](docs/exploration-mode.md)。
- **插件扩展**:在插件市场安装并启用电脑控制等能力,AI 在安卓任务中按需使用;手机、电脑的步骤与证据统一留档。见 [插件与跨端任务](docs/plugins.md)。
- **交互探索**:边执行边交流,也可录制人工示范;探索成功后生成一个可编辑、复跑的用例。默认使用独立的 `gpt-6.1-terra / high` 配置,超时暂停并保留进度。见 [探索模式](docs/exploration-mode.md)。
- **插件扩展**:安装并启用电脑控制等执行能力,或使用工作区插件为回放和报告增加人工评审。手机、电脑的步骤与证据统一留档。见 [插件与跨端任务](docs/plugins.md)和[工作区插件接口](docs/workspace-plugins.md)。
- **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。
- **批量测试**:按新版 Excel 模板导入提示词,每项可独立指定模型、模式和参数;任务可暂停、补充指导并继续。
- **精选任务**:收藏单任务、批次或探索用例,在首页一键复跑,默认沿用已保存的配置。
@@ -57,7 +57,7 @@ cd Artemis-Eapil
3. 输入任务目标,选择 Flash、Pro 或探索后执行;仅测试电脑时勾选输入区的“仅 PC”,无需手机或模拟器在线,但需已启用并授权的电脑观察插件。在工作区查看步骤、截图和结果。流程尚不明确时,选择探索并随时交流。
4. 通过“报告与批量”导出报告或导入 Excel 用例;任务队列支持历史搜索。
预设模型为 `gpt-6-luna`、`gpt-6.1-sol`、`gpt-6-astra`、`mimo-v2.6-pro`,主模型与兜底模型均可选择预设或填写自定义名称。新安装默认模型是 `gpt-6-luna`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**。遇到 429 请求过多时改为每次等待 **60 秒**。连续 3 次没有有效工具调用时,本步骤改用 `gpt-6-luna`(high),兜底模型最多再尝试 3 次;下一步恢复首选模型。视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。
预设模型为 `gpt-6-luna`、`gpt-6.1-terra`、`gpt-6.1-sol`、`gpt-6-astra`、`mimo-v2.6-pro`,主模型与兜底模型均可选择预设或填写自定义名称。新安装默认模型是 `gpt-6-luna`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**。遇到 429 请求过多时改为每次等待 **60 秒**。连续 3 次没有有效工具调用时,本步骤改用 `gpt-6-luna`(high),兜底模型最多再尝试 3 次;下一步恢复首选模型。视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。
也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
+3
View File
@@ -214,6 +214,9 @@ async def diagnose_plugin(plugin_id: str, request: DiagnoseRequest):
try:
store = get_store()
manifest = PluginManifest.model_validate(store.get(plugin_id)["manifest"])
if manifest.workspace and manifest.server is None and manifest.skill is None:
store.resolve(PluginSelection(id=plugin_id, profile=request.profile))
return {"ok": True, "tools": {}, "message": "工作区扩展已就绪"}
runtime = PluginRuntime(
[PluginSelection(id=plugin_id, profile=request.profile)],
store=store,
@@ -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
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
@@ -294,7 +294,7 @@ class AutomationStore:
if batch.get("status") != "deleting":
raise ValueError("批次尚未准备删除")
conn.execute(
"DELETE FROM automation_records WHERE kind='case_review' AND id IN "
"DELETE FROM automation_records WHERE kind IN ('case_review','task_extensions') AND id IN "
"(SELECT session_id FROM automation_jobs WHERE batch_id=?)",
(batch_id,),
)
@@ -312,13 +312,15 @@ class AutomationStore:
if session_id:
conn.execute("DELETE FROM automation_jobs WHERE session_id=?", (session_id,))
conn.execute(
"DELETE FROM automation_records WHERE kind='case_review' AND id=?",
"DELETE FROM automation_records WHERE kind IN ('case_review','task_extensions') AND id=?",
(session_id,),
)
else:
conn.execute("DELETE FROM automation_jobs")
conn.execute("DELETE FROM automation_records WHERE kind='batch'")
conn.execute("DELETE FROM automation_records WHERE kind='case_review'")
conn.execute(
"DELETE FROM automation_records WHERE kind IN ('case_review','task_extensions')"
)
conn.commit()
+17 -4
View File
@@ -15,7 +15,7 @@ def archive_path(batch_id: str):
def cached(batch_id: str, batch: dict) -> bool:
return bool(batch.get("report_ready")) and report_export.reusable(
archive_path(batch_id), report_export.BATCH_ARCHIVE_SIGNATURE, "completed"
archive_path(batch_id), report_export.batch_archive_signature(batch_id), "completed"
)
@@ -41,8 +41,14 @@ def _snapshot(state: dict) -> dict:
def status(batch_id: str, batch: dict) -> dict:
with _lock:
state = _states.get(batch_id)
if state:
if state and state["status"] != "ready":
return _snapshot(state)
if (
state
and state.get("signature") == report_export.batch_archive_signature(batch_id)
and archive_path(batch_id).is_file()
):
return _snapshot(state)
if cached(batch_id, batch):
return {
"status": "ready",
@@ -65,11 +71,12 @@ def status(batch_id: str, batch: dict) -> dict:
def prepare(batch_id: str, batch: dict) -> dict:
cache_ready = cached(batch_id, batch)
with _lock:
current = _states.get(batch_id)
if current and current["status"] == "preparing":
return _snapshot(current)
if cached(batch_id, batch):
if cache_ready:
_states.pop(batch_id, None)
return status_from_cache()
state = {
@@ -88,11 +95,17 @@ def prepare(batch_id: str, batch: dict) -> dict:
def run() -> None:
try:
with report_export.export_lock("batch:" + batch_id):
signature = report_export.batch_archive_signature(batch_id)
latest = report_export.store.get("batch", batch_id)
if not (latest and cached(batch_id, latest)):
report_export.export_batch(batch_id, progress=update)
with _lock:
state.update(status="ready", stage="报告包已就绪", finished_at=time.monotonic())
state.update(
status="ready",
stage="报告包已就绪",
finished_at=time.monotonic(),
signature=signature,
)
except Exception as exc:
with _lock:
state.update(
@@ -81,6 +81,7 @@ def get_model_settings() -> dict:
"reasoning_effort": settings.OPENAI_REASONING_EFFORT,
"model_presets": [
"gpt-6-luna",
"gpt-6.1-terra",
"gpt-6.1-sol",
"gpt-6-astra",
"mimo-v2.6-pro",
+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.task_lifecycle import guidance_details
from apps.admin_console.services import task_extensions
from docx import Document
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
@@ -36,6 +37,31 @@ from apps.admin_console.services.case_review import reviewed_result
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-2"
def batch_archive_signature(batch_id: str | None = None) -> str:
signature = task_extensions.installation_signature()
if batch_id:
with store.connection() as conn:
changes = conn.execute(
"SELECT r.id,r.updated_at FROM automation_records r "
"JOIN automation_jobs j ON j.session_id=r.id "
"WHERE r.kind='task_extensions' AND j.batch_id=? ORDER BY r.id",
(batch_id,),
).fetchall()
if changes:
signature += ":" + fingerprint({"reviews": [tuple(row) for row in changes]})
return BATCH_ARCHIVE_SIGNATURE + (":" + signature if signature else "")
def extension_rows(report: dict) -> list[tuple[str, str, str]]:
return [
(section["name"], field["label"], field["value"])
for section in report.get("task_extensions", [])
for field in section["fields"]
]
STATUS = {
"completed": "执行完成",
"success": "执行完成",
@@ -239,6 +265,7 @@ def session_report(session_id: str, db_path=None) -> dict:
"subcategory": case.get("subcategory", ""),
"preconditions": case.get("preconditions", ""),
"task_config": case.get("task_config") or {},
"task_extensions": task_extensions.report_sections(session_id, store),
}
@@ -659,6 +686,11 @@ def word_report(report: dict, path: Path):
doc.add_paragraph("任务配置:" + config_text(report["task_config"]))
doc.add_heading("结论与发现", 1)
add_markdown_content(doc, report["content"])
if review_rows := extension_rows(report):
doc.add_heading("人工评审(插件)", 1)
word_table(
doc, [(f"{name} · {label}", value) for name, label, value in review_rows], (5.5, 11)
)
doc.add_heading("执行步骤与证据", 1)
for step in report["steps"]:
paragraph = doc.add_paragraph()
@@ -792,6 +824,10 @@ def excel_report(report: dict, path: Path):
wb.active.title = "任务结果"
sheet_rows(wb.active, [("项目", "内容"), *metadata(report)], (22, 82))
sheet_rows(wb.create_sheet("结论"), conclusion_rows(report["content"]), (15, 96))
if review_rows := extension_rows(report):
sheet_rows(
wb.create_sheet("人工评审"), [("插件", "项目", "内容"), *review_rows], (24, 30, 82)
)
table_number = 0
for kind, rows in markdown_blocks(report["content"]):
if kind == "table":
@@ -1064,7 +1100,9 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
directory = EXPORT_ROOT / "batches" / batch_id
directory.mkdir(parents=True, exist_ok=True)
formats = batch["formats"]
records = [session_report(job["session_id"]) for job in store.jobs(batch_id)]
with task_extensions.plugin_store().mutation_lock:
signature = batch_archive_signature(batch_id)
records = [session_report(job["session_id"]) for job in store.jobs(batch_id)]
if progress:
progress(0, len(records), "正在生成批次汇总")
rows = [("任务编号", "任务名称", "执行状态", "验证结论", "异常原因")]
@@ -1078,6 +1116,17 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
assert wb.active is not None
wb.active.title = "批次结果"
sheet_rows(wb.active, rows, (40, 48, 16, 28, 70))
review_rows = [
(record["id"], record["name"], *row)
for record in records
for row in extension_rows(record)
]
if review_rows:
sheet_rows(
wb.create_sheet("人工评审"),
[("任务编号", "任务名称", "插件", "项目", "内容"), *review_rows],
(40, 40, 24, 30, 70),
)
path = directory / "批次结果.xlsx"
wb.save(path)
outputs.append(path)
@@ -1088,6 +1137,15 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
f"批次编号:{batch_id};共 {len(records)} 个任务。验证通过与执行完成分别统计。"
)
word_table(doc, rows, (3.5, 5.4, 2.5, 3.5, 7.3), header=True)
for record in records:
if review_rows := extension_rows(record):
doc.add_heading("人工评审 · " + record["name"], 1)
doc.add_paragraph("任务编号:" + record["id"])
word_table(
doc,
[(f"{name} · {label}", value) for name, label, value in review_rows],
(7, 15.2),
)
path = directory / "批次汇总.docx"
doc.save(str(path))
outputs.append(path)
@@ -1146,5 +1204,5 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
if progress:
progress(len(records), len(records), "正在完成压缩包")
temporary.replace(target)
remember(target, BATCH_ARCHIVE_SIGNATURE)
remember(target, signature)
return target
@@ -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",
"version": "2.2.0",
"version": "2.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "artemis-showcase-ui",
"version": "2.2.0",
"version": "2.3.0",
"dependencies": {
"@angular/cdk": "^22.1.5",
"@angular/common": "^22.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "artemis-showcase-ui",
"version": "2.2.0",
"version": "2.3.0",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
@@ -9,7 +9,7 @@ import { AgentStreamComponent } from '../agent-stream/agent-stream.component';
@Component({selector:'app-agent-stream',standalone:true,template:'<ng-content></ng-content>'}) class StreamStub {conversationMessages=input<any[]>([]);demonstrations=input<any[]>([]);pendingQuestion=input('');useRecommendation=output<string>();}
describe('Exploration conversation', () => {
const config = {model: 'gpt-6.1-sol', reasoning_effort: 'high', timeout_seconds: 1800, demonstration_timeout_seconds: 900, demonstration_warning_seconds: 60};
const config = {model: 'gpt-6.1-terra', reasoning_effort: 'high', timeout_seconds: 1800, demonstration_timeout_seconds: 900, demonstration_warning_seconds: 60};
let current: ExplorationSession;
let api: any;
beforeEach(() => {
@@ -44,7 +44,7 @@ export class ExplorationPanelComponent {
private followsLatest = true;
private requests = this.api.requests;
private displayedId: string | null = null;
readonly models = ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-luna', 'mimo-v2.6-pro', 'mimo-v2.6-flash'];
readonly models = ['gpt-6.1-terra', 'gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-luna', 'mimo-v2.6-pro', 'mimo-v2.6-flash'];
readonly efforts = ['default', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
constructor() {
@@ -35,7 +35,7 @@ describe('Featured tasks',()=>{
expect(fixture.nativeElement.textContent).not.toContain('旧任务部分配置缺失');
fixture.nativeElement.querySelector('.configure').click();fixture.detectChanges();flushMicrotasks();
const select:HTMLSelectElement=fixture.nativeElement.querySelector('app-model-selector select');
expect(Array.from(select.options,option=>option.value)).toEqual(['gpt-6-luna','gpt-6.1-sol','gpt-6-astra','mimo-v2.6-pro','__custom__']);
expect(Array.from(select.options,option=>option.value)).toEqual(['gpt-6-luna','gpt-6.1-terra', 'gpt-6.1-sol','gpt-6-astra','mimo-v2.6-pro','__custom__']);
expect(select.value).toBe('__custom__');
expect(fixture.nativeElement.querySelector('input[aria-label="复跑模型(自定义)"]').value).toBe('original-model');
select.value='gpt-6-luna';select.dispatchEvent(new Event('change'));fixture.detectChanges();
@@ -27,7 +27,9 @@ limitations under the License.
class="floating-video-wrapper"
[class.minimized]="agentService.isVideoMinimized()"
[class.theater]="isTheaterMode()"
[style.left.px]="!isTheaterMode() ? posX() : null"
[style.--extension-width]="extensionWidth() + 'px'"
[style.left.px]="!isTheaterMode() ? windowLeft() : null"
[style.width.px]="isTheaterMode() || agentService.isVideoMinimized() ? null : 400 + extensionWidth()"
[style.top.px]="!isTheaterMode() ? posY() : null"
[style.--player-top]="posY() + 'px'"
>
@@ -42,6 +44,9 @@ limitations under the License.
</span>
</div>
<div class="header-actions" (mousedown)="$event.stopPropagation()">
@if (extensionPanels(); as panels) {
<ng-container [ngTemplateOutlet]="panels.headerControls() || null" />
}
<!-- Mode Toggle Group: Video vs. Step Replay -->
@if (agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()) {
<div class="mode-toggle-group">
@@ -117,6 +122,8 @@ limitations under the License.
}
<!-- Video Main Content Area -->
@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">
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
@if (agentService.recordingPlaybackStatus() === 'live') {
@@ -488,6 +495,7 @@ limitations under the License.
}
}
</div>
</app-task-extension-panels>
}
</div>
}
@@ -28,6 +28,7 @@
}
.floating-video-wrapper {
container-type: inline-size;
position: fixed;
width: 400px;
max-width: calc(100vw - 32px);
@@ -60,8 +61,8 @@
left: 50% !important;
top: 50% !important;
transform: translate(-50%, -50%);
width: 85vw;
max-width: 960px;
width: min(calc(85vw + var(--extension-width, 0px)), calc(100vw - 32px));
max-width: calc(960px + var(--extension-width, 0px));
height: auto;
max-height: 88vh;
box-shadow: 0 24px 60px -12px rgba(15, 23, 42, 0.35);
@@ -805,16 +806,21 @@
.controls-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: space-between;
.controls-left, .controls-right {
display: flex;
flex-wrap: wrap;
white-space: nowrap;
align-items: center;
gap: 8px;
}
.ctrl-btn {
flex-shrink: 0;
background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.75);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
@@ -1,3 +1,5 @@
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { AgentService, RecordingSource, VideoSegment } from '../../services/agent.service';
@@ -59,7 +61,7 @@ describe('FloatingVideoPlayerComponent video markers', () => {
agentService = createAgentService();
TestBed.configureTestingModule({
imports: [FloatingVideoPlayerComponent],
providers: [{ provide: AgentService, useValue: agentService }]
providers: [provideHttpClient(), provideHttpClientTesting(), { provide: AgentService, useValue: agentService }]
});
fixture = TestBed.createComponent(FloatingVideoPlayerComponent);
@@ -26,10 +26,13 @@ import {
HostListener,
effect,
untracked,
OnDestroy
OnDestroy,
viewChild
} from '@angular/core';
import { TaskExtensionPanelsComponent } from '../task-extension-panels/task-extension-panels.component';
import { FormsModule } from '@angular/forms';
import { NgTemplateOutlet } from '@angular/common';
import { AgentService } from '../../services/agent.service';
import { StepReplayFrame } from '../../core/models/stream.model';
import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util';
@@ -40,13 +43,19 @@ import { buildVideoStepMarkers, VideoStepMarker } from '../../utils/video-step-m
@Component({
selector: 'app-floating-video-player',
standalone: true,
imports: [FormsModule],
imports: [FormsModule, NgTemplateOutlet, TaskExtensionPanelsComponent],
templateUrl: './floating-video-player.component.html',
styleUrl: './floating-video-player.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FloatingVideoPlayerComponent implements OnDestroy {
public agentService = inject(AgentService);
public extensionWidth = signal(0);
public extensionPanels = viewChild(TaskExtensionPanelsComponent);
public windowLeft(): number {
const width = this.agentService.isVideoMinimized() ? 280 : 400 + this.extensionWidth();
return Math.max(16, Math.min(this.posX(), window.innerWidth - Math.min(width, window.innerWidth - 32) - 16));
}
private zone = inject(NgZone);
@ViewChild('videoRef') videoRef?: ElementRef<HTMLVideoElement>;
@@ -327,7 +336,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
this.isDragging = true;
this.dragStartX = event.clientX;
this.dragStartY = event.clientY;
this.initialPosX = this.posX();
this.initialPosX = this.windowLeft();
this.initialPosY = this.posY();
event.preventDefault();
@@ -11,7 +11,7 @@ describe('持久化模型与功能设置', () => {
api_key_configured: true, timeout_seconds: 300, max_retries: 5, retry_interval_seconds: 10, reasoning_effort: 'high',
rate_limit_retry_seconds: 60, tool_fallback_model: 'gpt-6-luna', tool_fallback_reasoning_effort: 'high',
tool_fallback_max_retries: 3, tool_fallback_no_progress_attempts: 3,
model_presets: ['gpt-6-luna', 'gpt-6.1-sol', 'gpt-6-astra', 'mimo-v2.6-pro'], env_path: '/app/.env' };
model_presets: ['gpt-6-luna', 'gpt-6.1-terra', 'gpt-6.1-sol', 'gpt-6-astra', 'mimo-v2.6-pro'], env_path: '/app/.env' };
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
http = TestBed.inject(HttpTestingController);
@@ -21,7 +21,7 @@ import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
li { margin: 4px 0; }
blockquote { margin: 10px 0; padding: 6px 12px; border-left: 3px solid var(--app-primary-border, #93c5fd); background: var(--app-surface-soft, #f8fafc); }
code { font-family: Consolas, monospace; font-size: .92em; border-radius: 4px; padding: 1px 4px; background: var(--app-surface-soft, #f1f5f9); }
pre { max-width: 100%; overflow: auto; padding: 10px 12px; border-radius: 6px; background: var(--app-surface-soft, #f1f5f9); white-space: pre; }
pre { box-sizing: border-box; max-width: 100%; overflow: auto; padding: 10px 12px; border-radius: 6px; background: var(--app-surface-soft, #f1f5f9); white-space: pre; }
pre code { padding: 0; background: none; }
.md-table-scroll { max-width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
@@ -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} 个` : '本次停用';
}
available() {
return this.plugins().filter(p => p.enabled && p.profiles.some(profile => profile.authorized) &&
return this.plugins().filter(p => p.enabled && (!p.manifest.workspace || !!p.manifest.server || !!p.manifest.skill) && p.profiles.some(profile => profile.authorized) &&
p.manifest.targets.some(target => target === this.target() || (this.target() === 'android' && target === 'mixed')));
}
ngOnChanges() {
@@ -1 +1 @@
export const MODEL_PRESETS = ['gpt-6-luna', 'gpt-6.1-sol', 'gpt-6-astra', 'mimo-v2.6-pro'];
export const MODEL_PRESETS = ['gpt-6-luna', 'gpt-6.1-terra', 'gpt-6.1-sol', 'gpt-6-astra', 'mimo-v2.6-pro'];
@@ -1518,7 +1518,7 @@
@if(explorationSettingsOpen()) {
<div class="exploration-settings-body">
<label class="model-label">探索模型<input list="explore-models" [(ngModel)]="config.model" [disabled]="explorationSettingsBusy()" /></label>
<datalist id="explore-models"><option value="gpt-6.1-sol"></option><option value="gpt-6-astra"></option><option value="gpt-6-luna"></option><option value="mimo-v2.6-pro"></option></datalist>
<datalist id="explore-models"><option value="gpt-6.1-terra"></option><option value="gpt-6.1-sol"></option><option value="gpt-6-astra"></option><option value="gpt-6-luna"></option><option value="mimo-v2.6-pro"></option></datalist>
<div class="exploration-fields">
<app-setting-slider label="思考强度" [(value)]="config.reasoning_effort" [values]="reasoningValues" [labels]="reasoningLabels" hint="越高越适合复杂推理,也可能等待更久。" [disabled]="explorationSettingsBusy()" />
<app-setting-slider label="每段执行时限" [(value)]="config.timeout_seconds" [values]="executionTimes" [labels]="executionTimeLabels" hint="到时暂停并保存进度,点击继续即可接着探索。" [disabled]="explorationSettingsBusy()" />
@@ -10,7 +10,7 @@ import { TaskRecommendationService } from '../../core/services/task-recommendati
describe('Homepage exploration entry',()=>{
let api:any,agent:any,router:any,system:any;
const config={model:'gpt-6.1-sol',reasoning_effort:'high',timeout_seconds:1800,demonstration_timeout_seconds:900,demonstration_warning_seconds:60};
const config={model:'gpt-6.1-terra',reasoning_effort:'high',timeout_seconds:1800,demonstration_timeout_seconds:900,demonstration_warning_seconds:60};
beforeEach(()=>{
api={settings:jasmine.createSpy().and.resolveTo(config),create:jasmine.createSpy().and.resolveTo({id:'conversation'}),act:jasmine.createSpy().and.resolveTo({run:{id:'run'}})};
agent={runTask:jasmine.createSpy(),selectSession:jasmine.createSpy(),fetchSessions:jasmine.createSpy(),fetchStatus:jasmine.createSpy()};router={navigate:jasmine.createSpy().and.resolveTo(true)};
@@ -1,5 +1,5 @@
<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>
@if (error()) { <div class="message error" role="alert">{{error()}}</div> }
@if (notice()) { <div class="message success" role="status">{{notice()}}</div> }
@@ -1,10 +1,12 @@
import { Injectable, inject } from '@angular/core';
import { tap } from 'rxjs';
import { HttpClient } from '@angular/common/http';
export interface PluginManifest {
id: string; name: string; version: string; description: string;
artemis_version: string; platforms: string[]; source: string;
config: Record<string, { label: string; description: string; secret: boolean; required: boolean; default: string }>;
workspace?: unknown; server?: unknown; skill?: string;
resources: string[];
targets: ('android' | 'computer' | 'mixed')[];
}
@@ -28,13 +30,13 @@ export class PluginService {
install(payload: { plugin_id?: string; version?: string; local_path?: string }) {
return this.http.post<{ job_id: string }>('/api/plugins/install', payload);
}
progress(id: string) { return this.http.get<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) {
const { configured_secrets, ...payload } = profile;
return this.http.put<InstalledPlugin>(`/api/plugins/${encodeURIComponent(id)}/profiles`, payload);
}
enable(id: string, enabled: boolean) { return this.http.put<InstalledPlugin>(`/api/plugins/${encodeURIComponent(id)}/enabled`, { enabled }); }
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 }); }
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 }); }
}
@@ -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
@@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
class ExplorationConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
model: str = Field(default="gpt-6.1-sol", min_length=1, max_length=200)
model: str = Field(default="gpt-6.1-terra", min_length=1, max_length=200)
reasoning_effort: Literal[
"default", "none", "minimal", "low", "medium", "high", "xhigh", "max"
] = "high"
+1 -1
View File
@@ -87,7 +87,7 @@ class Settings(BaseSettings):
"""Centralized ARTEMIS runtime settings loaded from environment and .env files."""
ARTEMIS_PLUGIN_MARKETPLACE_URL: str = (
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json"
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json"
)
ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS: float = Field(default=30, gt=0, le=300)
ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=3600)
+6 -1
View File
@@ -43,7 +43,12 @@ def download(url: str, progress: Callable[[str, int], None]) -> bytes:
def load_catalog() -> PluginCatalog:
data = download(settings.ARTEMIS_PLUGIN_MARKETPLACE_URL, lambda *_: None)
url = settings.ARTEMIS_PLUGIN_MARKETPLACE_URL
# Older hosts reject schema-v3 entries before checking version constraints.
# Keep their index intact, and route only the exact former official URL.
if url == "https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json":
url = url.removesuffix("catalog.json") + "catalog-v3.json"
data = download(url, lambda *_: None)
if len(data) > 2 * 1024 * 1024:
raise ValueError("市场索引过大")
return PluginCatalog.model_validate_json(data)
+9 -4
View File
@@ -12,6 +12,8 @@ from packaging.specifiers import SpecifierSet
from packaging.version import Version
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
from artemis.plugins.workspace import WorkspaceContribution
Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_-]{0,47}$")]
@@ -94,7 +96,7 @@ class EvidenceSource(PluginModel):
class PluginManifest(PluginModel):
schema_version: Literal[1, 2] = 1
schema_version: Literal[1, 2, 3] = 1
id: Identifier
name: str = Field(min_length=1, max_length=100)
version: str
@@ -113,6 +115,7 @@ class PluginManifest(PluginModel):
targets: list[ExecutionTarget] = Field(default_factory=lambda: ["mixed"])
observation: PluginObservation | None = None
capabilities: PluginCapabilities | None = None
workspace: WorkspaceContribution | None = None
@field_validator("version")
@classmethod
@@ -130,14 +133,16 @@ class PluginManifest(PluginModel):
@model_validator(mode="after")
def has_capability(self):
if self.server is None and self.skill is None:
raise ValueError("插件至少需要 MCP 服务或 Skill")
if self.server is None and self.skill is None and self.workspace is None:
raise ValueError("插件至少需要 MCP 服务、Skill 或工作区扩展")
if self.workspace is not None and self.schema_version != 3:
raise ValueError("工作区扩展需要 schema_version=3")
for name, field in self.config.items():
if field.secret and field.default:
raise ValueError(f"密钥字段 {name} 不允许默认值")
if self.schema_version == 1 and self.capabilities is not None:
raise ValueError("能力契约需要 schema_version=2;旧清单保持兼容")
if self.schema_version == 2:
if self.schema_version >= 2:
if self.capabilities is None:
raise ValueError("第二版插件必须声明 capabilities")
if self.observation:
+5
View File
@@ -105,6 +105,7 @@ class PluginStore:
if not existing:
profile = PluginProfile(
is_default=True,
authorized=manifest.server is None and manifest.skill is None,
values={k: v.default for k, v in manifest.config.items() if not v.secret},
)
db.execute(
@@ -236,6 +237,8 @@ class PluginStore:
def selections(self, target: str = "mixed") -> list[PluginSelection]:
selections = []
for plugin in self.list():
if not plugin["manifest"].get("server") and not plugin["manifest"].get("skill"):
continue
if not plugin["enabled"] or not self._supports_task(
plugin["manifest"].get("targets", ["mixed"]), target
):
@@ -264,6 +267,8 @@ class PluginStore:
pinned = []
for choice in self.selections(target) if selections is None else selections:
manifest, profile, _ = self.resolve(choice)
if manifest.server is None and manifest.skill is None:
raise ValueError(f"插件 {manifest.name} 是工作区扩展,无需加入执行任务")
if not self._supports_task(manifest.targets, target):
raise ValueError(f"插件 {manifest.name} 不支持 {target} 任务")
pinned.append(
+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
+1 -1
View File
@@ -35,7 +35,7 @@
},
"agent": {
"exploration": {
"model": "${env:ARTEMIS_EXPLORATION_MODEL:-gpt-6.1-sol}",
"model": "${env:ARTEMIS_EXPLORATION_MODEL:-gpt-6.1-terra}",
"reasoning_effort": "${env:ARTEMIS_EXPLORATION_REASONING_EFFORT:-high}",
"timeout_seconds": "${env:ARTEMIS_EXPLORATION_TIMEOUT_SECONDS:-1800}",
"demonstration_timeout_seconds": "${env:ARTEMIS_DEMONSTRATION_TIMEOUT_SECONDS:-900}",
+1 -1
View File
@@ -35,7 +35,7 @@
},
"agent": {
"exploration": {
"model": "${env:ARTEMIS_EXPLORATION_MODEL:-gpt-6.1-sol}",
"model": "${env:ARTEMIS_EXPLORATION_MODEL:-gpt-6.1-terra}",
"reasoning_effort": "${env:ARTEMIS_EXPLORATION_REASONING_EFFORT:-high}",
"timeout_seconds": "${env:ARTEMIS_EXPLORATION_TIMEOUT_SECONDS:-1800}",
"demonstration_timeout_seconds": "${env:ARTEMIS_DEMONSTRATION_TIMEOUT_SECONDS:-900}",
+1 -1
View File
@@ -60,7 +60,7 @@ MCP 同步仅检查 `$CODEX_HOME/config.toml`;未设置或为空时使用当
报告按任务或批次归档到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接;删除批次时会清理对应导出副本。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。
新安装模型默认使用 `gpt-6-luna`,预设 `gpt-6-luna`、`gpt-6.1-sol`、`gpt-6-astra`、`mimo-v2.6-pro`;首页、兜底模型与精选任务使用统一选择控件,也支持自定义模型名称。已保存的模型配置保留。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,普通错误每次等待 10 秒,429 每次等待 60 秒,SDK 不叠加重试。首选模型连续 3 次没有有效工具调用时,当前步骤改用 `gpt-6-luna`(high),最多再试 3 次;下一步重新使用首选模型。兜底模型及次数可通过 `.env.example` 中的 `OPENAI_TOOL_FALLBACK_*` 和 `ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS` 配置。请求耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停先返回 `pausing`,确认状态为 `task_paused` 才表示设备已释放;Pro 等待模型回复或只读诊断时可及时中断,恢复后重新观察设备。AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复;指导由执行器在安全检查点领取,可能中断当前模型等待并重新观察设备;接收回执不保证任务结束前已消费,已经开始的设备动作会安全结束。用 `guidance_history` 核对指导是否被领取、在任务流程中核对实际执行情况,或用 `resume` 单独恢复。
新安装模型默认使用 `gpt-6-luna`,预设 `gpt-6-luna`、`gpt-6.1-terra`、`gpt-6.1-sol`、`gpt-6-astra`、`mimo-v2.6-pro`;首页、兜底模型与精选任务使用统一选择控件,也支持自定义模型名称。已保存的模型配置保留。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,普通错误每次等待 10 秒,429 每次等待 60 秒,SDK 不叠加重试。首选模型连续 3 次没有有效工具调用时,当前步骤改用 `gpt-6-luna`(high),最多再试 3 次;下一步重新使用首选模型。兜底模型及次数可通过 `.env.example` 中的 `OPENAI_TOOL_FALLBACK_*` 和 `ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS` 配置。请求耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停先返回 `pausing`,确认状态为 `task_paused` 才表示设备已释放;Pro 等待模型回复或只读诊断时可及时中断,恢复后重新观察设备。AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复;指导由执行器在安全检查点领取,可能中断当前模型等待并重新观察设备;接收回执不保证任务结束前已消费,已经开始的设备动作会安全结束。用 `guidance_history` 核对指导是否被领取、在任务流程中核对实际执行情况,或用 `resume` 单独恢复。
429 等待、兜底模型及次数也可在网页“模型配置 → 稳定性与兜底模型”或通过已授权的 `mobile_config(section="model")` 配置。批次 ZIP 还收录每项任务已保存的步骤截图和录屏。
+1 -1
View File
@@ -8,7 +8,7 @@
## 模型与超时
探索独立保存模型配置,默认 `gpt-6.1-sol / high`,预设也提供 `gpt-6-astra`,允许自定义模型。执行、人工示范分析、用例提炼和结束后的讨论使用这套配置。修改默认值仅影响新探索;已创建会话保留配置快照,Flash/Pro 默认值不变。
探索独立保存模型配置,默认 `gpt-6.1-terra / high`,预设也提供 `gpt-6-astra`,允许自定义模型。执行、人工示范分析、用例提炼和结束后的讨论使用这套配置。修改默认值仅影响新探索;已创建会话保留配置快照,Flash/Pro 默认值不变。
默认每段主动执行 1800 秒。模型调用、设备动作、录像分析计入;排队、等待回答和人工示范不计入。到时保存进度并暂停,可点击「继续探索」开启下一段;不会仅因超时把测试判成失败,累计主动耗时仍保留。
+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)
插件开发请阅读 [能力、证据协议与验收规则](plugin-development.md),可运行模板位于 `examples/plugins/evidence-demo`。新插件使用清单版本 2;版本 1 仍兼容。
插件开发请阅读 [能力、证据协议与验收规则](plugin-development.md),可运行模板位于 `examples/plugins/evidence-demo`。执行插件使用清单版本 2;版本 1 仍兼容。回放面板、人工表单和报告扩展使用版本 3,见[工作区插件接口](workspace-plugins.md)。
安装并启用「任务人工评审」工作区插件后,步骤回放提供可折叠的任务需求和人工评审面板。需求展示最初提交的 Markdown 提示词;两项 通过/失败 和各自备注由人工选填,可修改、清空并查看历史。未填写仍可导出报告;停用或卸载后不显示面板,也不导出对应字段,重新安装启用可恢复原记录。纯工作区插件不需要在执行任务时选择。
步骤卡标明安卓、电脑等来源,可点击查看对应录像;同一步内保留实际工具调用。开启「保存录屏」后,已使用的安卓和电脑来源各自留档,通过播放器切换。电脑录屏需要插件提供版本 2 的来源回执和录像能力声明,当前配套插件为 `cua-driver` 0.36.0+artemis.4,上游版本固定为 0.36.0;旧 `open-computer-use` 适配包从 1.1.0 起支持,但已从市场下架;仅更新 ARTEMIS 源码不会升级已安装插件。旧任务缺失的电脑录像无法补录,播放器会提示查看已有步骤截图。
@@ -117,7 +119,7 @@ HTTP 服务使用 `{"transport":"streamable_http","url":"https://example.com/mcp
| 环境变量 | 默认值 |
| --- | --- |
| `ARTEMIS_PLUGIN_MARKETPLACE_URL` | Artemis-Plugins 仓库的 catalog.json |
| `ARTEMIS_PLUGIN_MARKETPLACE_URL` | Artemis-Plugins 仓库的 catalog-v3.json(2.3.0+;原官方地址自动兼容) |
| `ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS` | 30 秒 |
| `ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS` | 60 秒 |
| `ARTEMIS_PLUGIN_SHUTDOWN_TIMEOUT_SECONDS` | 15 秒 |
+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
@@ -61,7 +61,7 @@ The bridge discovers a live service from the same installation; `ARTEMIS_DAEMON_
## 🛠️ MCP Tools Overview
* **`mobile_exploration`**: Guided exploration using the same queue and conversation as the web workspace. Create/start, exchange messages, pause/continue, record and analyze demonstrations, save immutable case versions, rerun with Flash/Pro, or add a batch draft. Writes use stable request IDs; reads never launch work. Defaults are independent (`gpt-6.1-sol / high`) and timeouts pause without marking the test failed. See the [exploration workflow](../docs/exploration-mode.md).
* **`mobile_exploration`**: Guided exploration using the same queue and conversation as the web workspace. Create/start, exchange messages, pause/continue, record and analyze demonstrations, save immutable case versions, rerun with Flash/Pro, or add a batch draft. Writes use stable request IDs; reads never launch work. Defaults are independent (`gpt-6.1-terra / high`) and timeouts pause without marking the test failed. See the [exploration workflow](../docs/exploration-mode.md).
* **`mobile_run_task`**: Asynchronously launches an autonomous mobile automation task (`Flash` or `Pro` model) with optional `device_serial` targeting. Pro runs can be tuned with `verification_level` (`off` | `final` | `checkpoints` | `strict` — how much the Checker audits) and `explorer_mode` (`flash` | `pro` | `ultra` — the Operator's perception depth).
* **`mobile_manage_task`**: Manages task lifecycle (`status`, `stop`, `inject_instruction`), returning task state and assigned `device_serial`. Pass `release_loop=True` with `inject_instruction` to gracefully end a `[Loop:continuous]` monitoring task — this explicit signal (not "please stop" wording) is what unlocks the loop milestone's completion.
* **`mobile_get_device_state`**: Real-time observer (`screenshot` or OCR+XML `hierarchy`) with optional `device_serial`.
+2 -2
View File
@@ -437,7 +437,7 @@
},
{
"name": "mobile_config",
"description": "Read configuration or explicitly save non-secret defaults for FUTURE tasks.\n\nOnly set persist_defaults=true when the user requests saved/global configuration.\nmodel changes: model (preset or custom), base_url, reasoning_effort\n(default/none/minimal/low/medium/high/xhigh/max), timeout_seconds, max_retries,\nretry_interval_seconds, rate_limit_retry_seconds, tool_fallback_model,\ntool_fallback_reasoning_effort, tool_fallback_max_retries,\ntool_fallback_no_progress_attempts. 'default' omits reasoning;\nunsupported values fail visibly.\ncapabilities: video_enabled and recording_enabled are independent; outputter_enabled,\nflash_final_check_enabled (false; Flash exit-only AI review),\nstep_summarizer_enabled, screenshot_limit_enabled, task_timeout_seconds (default 1800),\nobservation_timeout_seconds (60), observation_max_attempts (3),\ndevice_command_timeout_seconds (10), action_failure_attempts (3),\nworker_cleanup_timeout_seconds (30), adb_auto_connect (true),\nadb_discovery_interval_seconds (10), adb_discovery_timeout_seconds (5).\nreports: formats=['docx','xlsx'] (at least one).\nexploration: model (default gpt-6.1-sol), reasoning_effort (high), timeout_seconds\n(1800 active seconds), demonstration_timeout_seconds (900), demonstration_warning_seconds (60).\nAPI keys are never accepted or returned: use the Web UI or .env for credentials.\nSaving settings does not change an already running task. Flash/Pro are execution\nprofiles, not model names. Model retries do not repeat device test cases.\n",
"description": "Read configuration or explicitly save non-secret defaults for FUTURE tasks.\n\nOnly set persist_defaults=true when the user requests saved/global configuration.\nmodel changes: model (preset or custom), base_url, reasoning_effort\n(default/none/minimal/low/medium/high/xhigh/max), timeout_seconds, max_retries,\nretry_interval_seconds, rate_limit_retry_seconds, tool_fallback_model,\ntool_fallback_reasoning_effort, tool_fallback_max_retries,\ntool_fallback_no_progress_attempts. 'default' omits reasoning;\nunsupported values fail visibly.\ncapabilities: video_enabled and recording_enabled are independent; outputter_enabled,\nflash_final_check_enabled (false; Flash exit-only AI review),\nstep_summarizer_enabled, screenshot_limit_enabled, task_timeout_seconds (default 1800),\nobservation_timeout_seconds (60), observation_max_attempts (3),\ndevice_command_timeout_seconds (10), action_failure_attempts (3),\nworker_cleanup_timeout_seconds (30), adb_auto_connect (true),\nadb_discovery_interval_seconds (10), adb_discovery_timeout_seconds (5).\nreports: formats=['docx','xlsx'] (at least one).\nexploration: model (default gpt-6.1-terra), reasoning_effort (high), timeout_seconds\n(1800 active seconds), demonstration_timeout_seconds (900), demonstration_warning_seconds (60).\nAPI keys are never accepted or returned: use the Web UI or .env for credentials.\nSaving settings does not change an already running task. Flash/Pro are execution\nprofiles, not model names. Model retries do not repeat device test cases.\n",
"inputSchema": {
"properties": {
"section": {
@@ -791,7 +791,7 @@
},
{
"name": "mobile_exploration",
"description": "Explore ONE complex test with user guidance and produce a reusable case.\n\ndeletion_preview lists affected conversation runs; delete requires user_confirmed=true\nafter the user requests deletion. Independent validation runs are preserved.\nRead-only list/get/case never execute tasks. create stores {goal,config?,task_config?};\nstart/continue explicitly enqueue or resume Pro exploration. Independent defaults:\ngpt-6.1-sol/high, 1800 active seconds; timeout pauses, never fails by itself.\nmessage payload={text}; answer pending questions or guide without discarding history.\ndemonstrate payload={source:'android'|'computer'|'both'} pauses at a safe point and\nrecords while retaining the device/plugin lease. Wait for recording before the user\noperates. demo_finish analyzes the SAVED clip; demo_extend payload={seconds:900};\ndemo_cancel preserves evidence. Default 900s, warning 60s before expiry, auto-pause.\nsave_case payload={expected_version,case:{name,goal,preconditions?,evidence?}};\ngenerate payload={source_run_id,expected_version} starts evidence-based synthesis,\npoll get.generations for completion. validate payload={version,profile,task_config?}\nexplicitly runs the saved version in Flash/Pro; batch additionally accepts name and\ncreates a DRAFT batch, never starts it. Exploration pass and rerun pass are separate.\nUse stable request_id for every mutation except pause/stop/delete, reusing it on transport\nretries. No API keys; configure secrets in the Web UI. mobile_config(section='exploration')\ncontrols non-secret defaults. After errors call mobile_diagnose before resubmitting.\n",
"description": "Explore ONE complex test with user guidance and produce a reusable case.\n\ndeletion_preview lists affected conversation runs; delete requires user_confirmed=true\nafter the user requests deletion. Independent validation runs are preserved.\nRead-only list/get/case never execute tasks. create stores {goal,config?,task_config?};\nstart/continue explicitly enqueue or resume Pro exploration. Independent defaults:\ngpt-6.1-terra/high, 1800 active seconds; timeout pauses, never fails by itself.\nmessage payload={text}; answer pending questions or guide without discarding history.\ndemonstrate payload={source:'android'|'computer'|'both'} pauses at a safe point and\nrecords while retaining the device/plugin lease. Wait for recording before the user\noperates. demo_finish analyzes the SAVED clip; demo_extend payload={seconds:900};\ndemo_cancel preserves evidence. Default 900s, warning 60s before expiry, auto-pause.\nsave_case payload={expected_version,case:{name,goal,preconditions?,evidence?}};\ngenerate payload={source_run_id,expected_version} starts evidence-based synthesis,\npoll get.generations for completion. validate payload={version,profile,task_config?}\nexplicitly runs the saved version in Flash/Pro; batch additionally accepts name and\ncreates a DRAFT batch, never starts it. Exploration pass and rerun pass are separate.\nUse stable request_id for every mutation except pause/stop/delete, reusing it on transport\nretries. No API keys; configure secrets in the Web UI. mobile_config(section='exploration')\ncontrols non-secret defaults. After errors call mobile_diagnose before resubmitting.\n",
"inputSchema": {
"properties": {
"action": {
+1 -1
View File
@@ -200,7 +200,7 @@ async def mobile_config(
worker_cleanup_timeout_seconds (30), adb_auto_connect (true),
adb_discovery_interval_seconds (10), adb_discovery_timeout_seconds (5).
reports: formats=['docx','xlsx'] (at least one).
exploration: model (default gpt-6.1-sol), reasoning_effort (high), timeout_seconds
exploration: model (default gpt-6.1-terra), reasoning_effort (high), timeout_seconds
(1800 active seconds), demonstration_timeout_seconds (900), demonstration_warning_seconds (60).
API keys are never accepted or returned: use the Web UI or .env for credentials.
Saving settings does not change an already running task. Flash/Pro are execution
+1 -1
View File
@@ -43,7 +43,7 @@ async def mobile_exploration(
after the user requests deletion. Independent validation runs are preserved.
Read-only list/get/case never execute tasks. create stores {goal,config?,task_config?};
start/continue explicitly enqueue or resume Pro exploration. Independent defaults:
gpt-6.1-sol/high, 1800 active seconds; timeout pauses, never fails by itself.
gpt-6.1-terra/high, 1800 active seconds; timeout pauses, never fails by itself.
message payload={text}; answer pending questions or guide without discarding history.
demonstrate payload={source:'android'|'computer'|'both'} pauses at a safe point and
records while retaining the device/plugin lease. Wait for recording before the user
+1 -1
View File
@@ -14,7 +14,7 @@
[project]
name = "artemis"
version = "2.2.0"
version = "2.3.0"
description = "Autonomous AI Assistant for Mobile Automation"
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)
if not skill.is_file() or not skill.read_text("utf-8").strip():
raise ValueError("缺少有效的 Skill")
if manifest.schema_version != 2:
raise ValueError("新市场插件必须适配 schema_version=2")
if manifest.schema_version not in {2, 3}:
raise ValueError("新市场插件必须适配 schema_version=2 或 3")
if live and manifest.server:
server = resolve_server(manifest, PluginProfile(values=values), directory)
session = await PluginSession(server, directory).start()
@@ -29,7 +29,7 @@ async def check(directory: Path, live: bool, values: dict[str, str]) -> None:
await session.aclose()
print(
f"PASS {manifest.id} {manifest.version}: 清单/兼容性/Skill"
+ ("/MCP 工具声明" if live else "")
+ ("/MCP 工具声明" if live and manifest.server else "")
)
print("此检查不代替实际执行、超时取消、证据导出和视觉能力验收。")
+1
View File
@@ -17,6 +17,7 @@ ARTEMIS 自己编排手机和已授权插件,共用网页的任务队列、历
| 给出任务编号,问失败原因、token、缓存、录像或最终结论 | [诊断与复盘](references/diagnostics.md) |
| 使用电脑、路由器、终端等已安装能力 | [插件任务参数](references/plugins.md) |
| 编写、适配、验证或发布 ARTEMIS 插件 | [插件开发规范](references/plugin-development.md),按需读取 [清单与市场协议](references/plugin-manifest.md) |
| 开发回放侧栏、人工表单或扩展报告字段 | [工作区插件接口(ARTEMIS 2.3.0+)](references/workspace-plugins.md) |
只读当前任务相关参考。普通执行不加载插件开发文档。
+1 -1
View File
@@ -6,7 +6,7 @@
## 模型与超时
探索独立保存模型配置,默认 `gpt-6.1-sol / high`,预设也提供 `gpt-6-astra`,允许自定义模型。执行、人工示范分析、用例提炼和结束后的讨论使用这套配置。修改默认值仅影响新探索;已创建会话保留配置快照,Flash/Pro 默认值不变。
探索独立保存模型配置,默认 `gpt-6.1-terra / high`,预设也提供 `gpt-6-astra`,允许自定义模型。执行、人工示范分析、用例提炼和结束后的讨论使用这套配置。修改默认值仅影响新探索;已创建会话保留配置快照,Flash/Pro 默认值不变。
默认每段主动执行 1800 秒。模型调用、设备动作、录像分析计入;排队、等待回答和人工示范不计入。到时保存进度并暂停,可点击「继续探索」开启下一段;不会仅因超时把测试判成失败,累计主动耗时仍保留。
+1 -1
View File
@@ -17,7 +17,7 @@
## 模式、模型与配置
- `mobile_run_task.model` 取 `Flash` / `Pro`。API 模型名放 `runtime_overrides.model`;思考强度放 `runtime_overrides.reasoning_effort`。批次使用 `task_config.profile` 选择模式,`task_config.model` 选择 API 模型。
- 探索独立配置,默认 `gpt-6.1-sol/high`,支持 `gpt-6-astra` 及已配置网关支持的自定义模型。执行、示范分析、提炼共用该探索配置;不改变 Flash/Pro 默认值。
- 探索独立配置,默认 `gpt-6.1-terra/high`,支持 `gpt-6-astra` 及已配置网关支持的自定义模型。执行、示范分析、提炼共用该探索配置;不改变 Flash/Pro 默认值。
- 默认 high、单次模型请求时限 300 秒。`default` 表示不发送思考强度,`none` 是显式关闭,不能混为一谈;接口不支持参数时如实报告,不擅自降级。
- 请求时限与整任务主动执行时限不同。排队/用户暂停不计入任务主动执行;探索等待回答、人工示范也不计入。
- 录屏 `record_video` 和 AI 视频分析 `analyze_video` 独立,默认关闭。需要可下载录像时明确启用录屏。
@@ -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 开发版)
ARTEMIS 2.3.0 支持 schema_version=3 工作区扩展:左右回放面板、人工选填表单与报告字段,见[开发接口](workspace-plugins.md)。纯工作区插件不加入执行插件选择。
插件开发请阅读 [能力、证据协议与验收规则](plugin-development.md),可运行模板位于 [Skill 内的 evidence-demo](../assets/evidence-demo/plugin.json)。新插件使用清单版本 2;版本 1 仍兼容。
步骤卡标明安卓、电脑等来源,可点击查看对应录像;同一步内保留实际工具调用。开启「保存录屏」后,已使用的安卓和电脑来源各自留档,通过播放器切换。电脑录屏需要插件提供版本 2 的来源回执和录像能力声明,当前市场使用 `cua-driver` 0.36.0;旧 `open-computer-use` 适配包从 1.1.0 起支持,但已从市场下架;仅更新 ARTEMIS 源码不会升级已安装插件。旧任务缺失的电脑录像无法补录,播放器会提示查看已有步骤截图。
@@ -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)
if not skill.is_file() or not skill.read_text("utf-8").strip():
raise ValueError("缺少有效的 Skill")
if manifest.schema_version != 2:
raise ValueError("新市场插件必须适配 schema_version=2")
if manifest.schema_version not in {2, 3}:
raise ValueError("新市场插件必须适配 schema_version=2 或 3")
if live and manifest.server:
server = resolve_server(manifest, PluginProfile(values=values), directory)
session = await PluginSession(server, directory).start()
@@ -29,7 +29,7 @@ async def check(directory: Path, live: bool, values: dict[str, str]) -> None:
await session.aclose()
print(
f"PASS {manifest.id} {manifest.version}: 清单/兼容性/Skill"
+ ("/MCP 工具声明" if live else "")
+ ("/MCP 工具声明" if live and manifest.server else "")
)
print("此检查不代替实际执行、超时取消、证据导出和视觉能力验收。")
@@ -96,6 +96,7 @@ def test_model_presets_keep_supported_choices_without_restricting_custom_names()
presets = service.get_model_settings()["model_presets"]
assert presets == [
"gpt-6-luna",
"gpt-6.1-terra",
"gpt-6.1-sol",
"gpt-6-astra",
"mimo-v2.6-pro",
@@ -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
+2 -2
View File
@@ -30,7 +30,7 @@ async def test_admission_uses_shared_queue_with_independent_model(service, monke
await service.start(session["id"], "start")
request = enqueue.call_args.args[0]
assert request.profile == "pro"
assert request.runtime_overrides.model == "gpt-6.1-sol"
assert request.runtime_overrides.model == "gpt-6.1-terra"
assert request.runtime_overrides.reasoning_effort == "high"
assert request.runtime_overrides.verification_level == "final"
assert request.conversation_id == session["id"]
@@ -193,7 +193,7 @@ def test_independent_saved_defaults_do_not_change_existing_sessions(service):
session = service.create(CreateExploration(goal="探索设置", request_id="create"))
service.save_settings(ExplorationConfig(model="gpt-6-astra", reasoning_effort="max"))
later = service.create(CreateExploration(goal="另一次探索", request_id="later"))
assert service.repository.get(session["id"])["config"]["model"] == "gpt-6.1-sol"
assert service.repository.get(session["id"])["config"]["model"] == "gpt-6.1-terra"
assert later["config"]["model"] == "gpt-6-astra"
+1 -1
View File
@@ -187,7 +187,7 @@ def test_generated_case_cannot_borrow_another_sessions_evidence(repository):
def test_exploration_config_defaults_and_rejects_credentials():
config = ExplorationConfig()
assert (config.model, config.reasoning_effort, config.timeout_seconds) == (
"gpt-6.1-sol",
"gpt-6.1-terra",
"high",
1800,
)
+32
View File
@@ -19,6 +19,38 @@ from artemis.plugins.store import PluginStore
from artemis.runtime.device_lock import DeviceBusyError, DeviceExecutionLock
@pytest.mark.parametrize(
("configured", "expected"),
[
(
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json",
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json",
),
(
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json",
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json",
),
("https://example.test/catalog.json", "https://example.test/catalog.json"),
],
)
def test_workspace_catalog_routes_only_the_official_legacy_address(
monkeypatch, configured, expected
):
from artemis.plugins import installer
requested = []
monkeypatch.setattr(installer.settings, "ARTEMIS_PLUGIN_MARKETPLACE_URL", configured)
def download(url, progress):
requested.append(url)
return b'{"schema_version":1,"plugins":[]}'
monkeypatch.setattr(installer, "download", download)
assert installer.load_catalog().plugins == []
assert requested == [expected]
assert installer.settings.ARTEMIS_PLUGIN_MARKETPLACE_URL == configured
def manifest(**updates):
return PluginManifest.model_validate(
{
Generated
+1 -1
View File
@@ -206,7 +206,7 @@ wheels = [
[[package]]
name = "artemis"
version = "2.2.0"
version = "2.3.0"
source = { editable = "." }
dependencies = [
{ name = "adbutils" },