feat: prepare 2.2.0 with UI and execution fixes

Add dark theme, richer Markdown and readable plugin results.
Improve Android input evidence, response integrity and diagnostics.
Unify lifecycle, guidance receipts and evidence sources across clients.
Keep direct action dispatch without an added AI quota approval layer.
Align version metadata at 2.2.0 and bundle Helper 1.2.2.

Validation: 3063 backend tests before the version bump; 44 after.
Frontend: 317 tests and production build passed.
This commit is contained in:
chenmingxuan committed 2026-10-26 00:44:13 +08:00
1 parent 83bcfd54d5
commit fac4ccb760
146 files changed
+5689 -2033

No files matched your search

+29 -4
View File
@@ -70,7 +70,7 @@ def pause_running_task(session_id: str):
@router.post("/api/tasks/{session_id}/guide")
def guide_running_task(session_id: str, value: GuidanceRequest):
_live_task(session_id)
live = _live_task(session_id)
from apps.admin_console.services.exploration_service import exploration_service
from artemis.exploration.store import ExplorationConflict
@@ -93,18 +93,33 @@ def guide_running_task(session_id: str, value: GuidanceRequest):
or (control_dir(session_id) / "pause.request").exists()
):
request_resume(session_id)
return {"session_id": session_id, "status": "guidance_submitted", "guidance": guidance}
from apps.admin_console.services.task_lifecycle import guidance_details
details = guidance_details(session_id)
guidance = next(
(item for item in details["instructions"] if item["id"] == guidance.get("id")), guidance
)
return {
"session_id": session_id,
"status": "guidance_submitted",
"guidance": guidance,
"worker_active": live["process"].returncode is None,
"message": "指导已接收;接收不保证任务结束前消费,请以消费记录为准。",
}
@router.get("/api/tasks/{session_id}/guidance")
def task_guidance_history(session_id: str):
def task_guidance_history(session_id: str, details: bool = False):
from apps.admin_console.services.automation_store import identifier
try:
identifier(session_id)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return list_instructions(session_id)
from apps.admin_console.services.task_lifecycle import guidance_details
result = guidance_details(session_id)
return result if details else result["instructions"]
@router.post("/api/tasks/{session_id}/continue")
@@ -451,6 +466,16 @@ async def get_status():
with store.connection() as connection:
exploration_runs = exploration_metadata(connection)
for item in [*queue_data, *active_tasks]:
control_status = (
read_state(str(item["session_id"])).get("status") if item.get("session_id") else None
)
item["phase"] = {
"queued": "waiting_resources",
"resuming": "waiting_resources",
"task_paused": "paused",
"demonstrating": "demonstrating",
"analyzing_demo": "analyzing_demo",
}.get(control_status)
item.update(exploration_runs.get(str(item.get("session_id")), {}))
if job := batch_jobs.get(str(item.get("session_id"))):
item["batch_id"] = job.get("batch_id")
+3 -1
View File
@@ -12,11 +12,13 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from apps.admin_console.services.task_import import TaskOptions
class RunRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
goal: str | None = None
goals: list[str] | None = None
profile: str | None = "flash"
@@ -175,6 +175,7 @@ async def on_terminal(item: dict, status: str):
logger.exception("Report finalization failed for %s", sid)
job["report_error"] = str(exc)[:500]
job.pop("terminal_status", None)
job["finalized_at"] = time.time()
store.save_job(job)
@@ -151,6 +151,7 @@ def export_index(
"status": report["status"],
"verdict": report["verdict"],
"steps": report.get("steps", []),
"evidence_sources": report.get("evidence_sources"),
"calls": calls,
"recordings": recordings,
"android_videos": ["任务录屏/安卓/" + name for _, name in android_videos],
+88 -1
View File
@@ -14,6 +14,7 @@ from apps.admin_console.services.export_cache import (
reusable,
)
from apps.admin_console.services.request_diagnostics import diagnostics_json
from apps.admin_console.services.task_lifecycle import guidance_details
from docx import Document
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
@@ -166,6 +167,7 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
def session_report(session_id: str, db_path=None) -> dict:
from apps.admin_console.services.exploration_evidence import exploration_metadata
from artemis.data_engine.evidence_sources import recorded_sources
identifier(session_id)
meta = store.job(session_id) or {}
@@ -213,6 +215,7 @@ def session_report(session_id: str, db_path=None) -> dict:
)
return {
"id": session_id,
**guidance_details(session_id, db_path=repo.db_path),
"exploration": exploration_metadata(session_id),
"name": execution_title(meta) or session.get("initial_goal", session_id)[:120],
"iteration": meta.get("iteration", 1),
@@ -225,6 +228,7 @@ def session_report(session_id: str, db_path=None) -> dict:
"outcome": outcome,
"content": content,
"steps": steps,
"evidence_sources": recorded_sources(steps),
"start": session.get("start_time") or meta.get("started_at"),
"end": session.get("end_time") or meta.get("ended_at"),
"error": error,
@@ -246,6 +250,74 @@ def time_text(value) -> str:
)
def lifecycle_text(value: dict) -> str:
labels = {
"total": "总历时",
"queued": "后台排队",
"resource_wait": "等待设备/资源",
"paused": "暂停与示范",
"execution": "执行与整理",
"final_review": "最终验收",
"report_export": "报告导出",
}
durations = value.get("durations") or {}
return (
"\n".join(
f"{label}:{round(durations[key], 1)} 秒"
if durations.get(key) is not None
else f"{label}:未记录"
for key, label in labels.items()
)
+ "\n执行含初始化与报告整理,不代表模型速度。"
)
def guidance_text(items: list[dict]) -> str:
labels = {
"pending": "已接收,等待读取",
"reading": "执行器正在读取",
"consumed": "执行器已读取",
"unapplied": "任务结束,未记录消费",
"unconfirmed": "任务结束,读取未确认",
}
return (
"\n".join(
f"{item['id']} · {labels.get(item['status'], '状态未知')} · 接收 {time_text(item.get('accepted_at') or item.get('timestamp'))}"
+ (f" · 读取 {time_text(item['consumed_at'])}" if item.get("consumed_at") else "")
for item in items
)
or "无指导记录"
)
def source_text(value: dict) -> str:
labels = {
"android": "安卓界面",
"computer": "电脑界面",
"api": "接口数据",
"terminal": "终端",
"file": "文件",
"other": "其他",
}
lines = []
for item in value.get("sources", []):
references = ", ".join(
" / ".join(
f"{key}={ref[key]}" for key in ("step_id", "call_id", "source_id") if ref.get(key)
)
or "编号未记录"
for ref in item.get("references", [])
)
lines.append(
f"{labels.get(item['kind'], '其他')} · {item['label'] or item['target_id']} · {references}"
)
if value.get("sources_omitted"):
lines.append(f"另 {value['sources_omitted']} 个来源见原始步骤记录。")
lines.append(f"来源未完整记录的步骤:{value.get('unattributed_step_count', '未知')}。")
lines.append(value.get("note") or "来源清单不代表断言已验证。")
return "\n".join(lines)
def metadata(report: dict) -> list[tuple]:
return [
("任务编号", report["id"]),
@@ -272,6 +344,9 @@ def metadata(report: dict) -> list[tuple]:
("任务目标", report["goal"]),
("预期结果", report["expected"] or "未指定"),
("执行状态", STATUS.get(report["status"], report["status"])),
("耗时分项", lifecycle_text(report.get("lifecycle") or {})),
("指导消费", guidance_text(report.get("instructions") or [])),
("证据来源与边界", source_text(report.get("evidence_sources") or {})),
("验证结论", report["verdict"]),
("人工复验时间", time_text((report.get("test_result") or {}).get("reviewed_at"))),
(
@@ -558,7 +633,19 @@ def word_report(report: dict, path: Path):
details = [
(label, value)
for label, value in metadata(report)
if label in {"任务编号", "用例编号", "检测项目", "测试子项", "开始时间", "结束时间", "设备"}
if label
in {
"任务编号",
"用例编号",
"检测项目",
"测试子项",
"开始时间",
"结束时间",
"设备",
"耗时分项",
"指导消费",
"证据来源与边界",
}
]
word_table(doc, details, (3.2, 13.3))
doc.add_heading("任务目标与预期" if not report["expected"] else "任务目标", 2)
@@ -62,6 +62,7 @@ def request_diagnostics(
field: datetime.fromtimestamp(value[field], UTC).isoformat()
for field in (
"started_at",
"request_sent_at",
"headers_at",
"first_output_at",
"last_output_at",
@@ -0,0 +1,82 @@
"""One read-only lifecycle view for web, MCP and exported reports."""
import json
from pathlib import Path
import sqlite3
from artemis.config import DB_PATH
from artemis.runtime.task_control import list_instructions, read_state
from artemis.runtime.task_progress import progress_snapshot
def _records(session_id: str, path: Path):
if not path.is_file():
return None, None, []
with sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True) as conn:
conn.row_factory = sqlite3.Row
tables = {
row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
}
session = (
conn.execute("SELECT * FROM sessions WHERE session_id=?", (session_id,)).fetchone()
if "sessions" in tables
else None
)
record = (
conn.execute(
"SELECT payload FROM automation_jobs WHERE session_id=?", (session_id,)
).fetchone()
if "automation_jobs" in tables
else None
)
rows = (
conn.execute(
"SELECT payload FROM traces WHERE session_id=? AND type='final_review_diagnostic'",
(session_id,),
).fetchall()
if "traces" in tables
else []
)
return session, record, rows
def task_lifecycle(session_id: str, *, db_path=None, now=None) -> dict:
session, record, rows = _records(session_id, Path(db_path) if db_path else DB_PATH)
try:
job = json.loads(record[0]) if record else {}
except (ValueError, TypeError):
job = {}
reviews = []
for row in rows:
try:
value = json.loads(row[0])
except (ValueError, TypeError):
continue
if isinstance(value, dict):
reviews.append(value)
session = dict(session) if session else {}
control = read_state(session_id)
if session.get("pid") and control.get("pid") != session["pid"]:
control = {}
value = progress_snapshot(
job=job if isinstance(job, dict) else {},
session=session,
control=control,
reviews=reviews,
now=now,
)
instructions = list_instructions(session_id, task_status=value["status"])
value["guidance_counts"] = {
status: sum(item["status"] == status for item in instructions)
for status in ("pending", "reading", "consumed", "unapplied", "unconfirmed")
}
return value
def guidance_details(session_id: str, *, db_path=None) -> dict:
lifecycle = task_lifecycle(session_id, db_path=db_path)
return {
"session_id": session_id,
"lifecycle": lifecycle,
"instructions": list_instructions(session_id, task_status=lifecycle["status"]),
}
@@ -47,6 +47,7 @@ from artemis.runtime import (
request_cancel,
trace_store,
)
from artemis.runtime.cancellation import CancellationReason
logger = logging.getLogger(__name__)
@@ -136,6 +137,7 @@ class TaskQueueService:
pid=pid_int,
process_created_at=created_at,
reason=reason,
cancel_reason=CancellationReason.USER_STOP,
)
if not written:
return cls._hard_kill(pid_int, created_at), False
@@ -518,7 +520,11 @@ class TaskQueueService:
job = store.job(str(sess_id)) if sess_id else None
if job:
job.update(status="running", started_at=time.time())
job.update(
status="running",
started_at=task_item["start_time"],
worker_started_at=task_item["start_time"],
)
store.save_job(job)
# A fresh launch clears a stale stop request left over for this run