feat: release 1.1.0 with responsive task control

This commit is contained in:
chenmingxuan committed 2026-09-24 03:31:17 +08:00
1 parent 057f0c3050
commit bfcd828fa8
37 files changed
+1601 -151

No files matched your search

+14
View File
@@ -121,6 +121,20 @@ def batch_detail(batch_id: str):
return {**require("batch", batch_id), "jobs": store.jobs(batch_id)} return {**require("batch", batch_id), "jobs": store.jobs(batch_id)}
@router.delete("/batches/{batch_id}")
def delete_batch(batch_id: str):
try:
key = identifier(batch_id)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
try:
if not store.delete_batch(key):
raise HTTPException(404, "批次不存在")
except ValueError as exc:
raise HTTPException(409, str(exc)) from exc
return {"batch_id": key, "status": "deleted"}
@router.post("/batches/{batch_id}/{action}") @router.post("/batches/{batch_id}/{action}")
def batch_action(batch_id: str, action: Literal["pause", "resume", "retry_failed"]): def batch_action(batch_id: str, action: Literal["pause", "resume", "retry_failed"]):
require("batch", batch_id) require("batch", batch_id)
+14 -2
View File
@@ -24,6 +24,7 @@ from artemis.runtime import DeviceExecutionLock, device_pool
from artemis.runtime.task_control import ( from artemis.runtime.task_control import (
add_instruction, add_instruction,
control_dir, control_dir,
list_instructions,
read_state, read_state,
request_pause, request_pause,
request_resume, request_resume,
@@ -74,13 +75,24 @@ def pause_running_task(session_id: str):
@router.post("/api/tasks/{session_id}/guide") @router.post("/api/tasks/{session_id}/guide")
def guide_running_task(session_id: str, value: GuidanceRequest): def guide_running_task(session_id: str, value: GuidanceRequest):
_live_task(session_id) _live_task(session_id)
add_instruction(session_id, value.instruction, source="web") guidance = add_instruction(session_id, value.instruction, source="web")
if ( if (
read_state(session_id).get("status") == "task_paused" read_state(session_id).get("status") == "task_paused"
or (control_dir(session_id) / "pause.request").exists() or (control_dir(session_id) / "pause.request").exists()
): ):
request_resume(session_id) request_resume(session_id)
return {"session_id": session_id, "status": "guidance_submitted"} return {"session_id": session_id, "status": "guidance_submitted", "guidance": guidance}
@router.get("/api/tasks/{session_id}/guidance")
def task_guidance_history(session_id: str):
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)
@router.post("/api/tasks/{session_id}/continue") @router.post("/api/tasks/{session_id}/continue")
@@ -164,6 +164,36 @@ class AutomationStore:
conn.commit() conn.commit()
return batch return batch
def delete_batch(self, batch_id: str) -> bool:
"""Remove a batch record; retain executed job metadata and its reports."""
with self.connection() as conn:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?",
(batch_id,),
).fetchone()
if row is None:
return False
batch = json.loads(row[0])
active = conn.execute(
"SELECT 1 FROM automation_jobs WHERE batch_id=? AND status IN "
"('queued','running','task_paused','resuming','finalizing') LIMIT 1",
(batch_id,),
).fetchone()
if batch["status"] == "running" or active:
raise ValueError("批次仍有任务执行中或排队中,请等待完成后删除")
conn.execute(
"DELETE FROM automation_jobs WHERE batch_id=? AND status='pending'",
(batch_id,),
)
conn.execute(
"DELETE FROM automation_records WHERE kind='batch' AND id=?",
(batch_id,),
)
# Keep the request-id tombstone so deletion cannot replay an old batch.
conn.commit()
return True
def delete_history(self, session_id: str | None = None): def delete_history(self, session_id: str | None = None):
with self.connection() as conn: with self.connection() as conn:
if session_id: if session_id:
+347 -73
View File
@@ -2,7 +2,6 @@
from datetime import datetime from datetime import datetime
from typing import cast from typing import cast
from docx.styles.style import ParagraphStyle
import json import json
from pathlib import Path from pathlib import Path
import re import re
@@ -10,10 +9,14 @@ import threading
import zipfile import zipfile
from docx import Document from docx import Document
from docx.shared import Inches, Pt from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
from docx.oxml import OxmlElement
from docx.oxml.ns import qn from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor
from docx.styles.style import ParagraphStyle
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
from apps.admin_console.database.repositories.session_repository import SessionRepository from apps.admin_console.database.repositories.session_repository import SessionRepository
@@ -60,6 +63,42 @@ def verdict(outcome: dict | None) -> str:
return "未验证(执行完成不等于测试通过)" return "未验证(执行完成不等于测试通过)"
def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
def redact(value: str) -> str:
safe = text(value).strip()
safe = re.sub(r"sk-[A-Za-z0-9_-]{12,}", "[已隐藏密钥]", safe)
safe = re.sub(
r"(?i)(\b(?:authorization\s*:\s*bearer|bearer)\s+)[^\s,;]+",
r"\1[已隐藏密钥]",
safe,
)
safe = re.sub(
r"(?i)(\b(?:OPENAI_API_KEY|api[_-]?key)\s*[:=]\s*['\"]?)[^\s,'\";]+",
r"\1[已隐藏密钥]",
safe,
)
return safe[:500]
if recorded:
return redact(recorded)
if status not in {"failed", "interrupted"}:
return ""
for name in ("stdout.log", "stderr.log"):
path = directory / name
try:
with path.open("rb") as stream:
stream.seek(0, 2)
stream.seek(max(0, stream.tell() - 131072))
lines = stream.read().decode("utf-8", errors="replace").splitlines()
except OSError:
continue
for line in reversed(lines):
match = re.search(r"Error running automation:\s*(.+)", line)
if match:
return redact(match.group(1))
return "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
def session_report(session_id: str, db_path=None) -> dict: def session_report(session_id: str, db_path=None) -> dict:
identifier(session_id) identifier(session_id)
meta = store.job(session_id) or {} meta = store.job(session_id) or {}
@@ -89,24 +128,31 @@ def session_report(session_id: str, db_path=None) -> dict:
if not outcome: if not outcome:
outcome = {"task_status": args.get("status"), "tests": {}} outcome = {"task_status": args.get("status"), "tests": {}}
break break
if not content:
content = "本任务没有保存最终结论。以下内容仅来自已记录的执行状态和证据。"
case = meta.get("case") or {} case = meta.get("case") or {}
status = meta.get("terminal_status") or meta.get("status") or session.get("status", "unknown")
error = failure_reason(directory, status, meta.get("error"))
verification = verdict(outcome)
if status in {"failed", "interrupted"} and verification.startswith("未验证"):
verification = "未验证(任务异常终止)"
if not content:
content = (
"本任务未生成最终结论。请查看异常原因和已记录的执行步骤。"
if status in {"failed", "interrupted"}
else "本任务没有保存最终结论。以下内容仅来自已记录的执行状态和证据。"
)
return { return {
"id": session_id, "id": session_id,
"name": case.get("name") or session.get("initial_goal", session_id)[:120], "name": case.get("name") or session.get("initial_goal", session_id)[:120],
"goal": case.get("goal") or session.get("initial_goal", ""), "goal": case.get("goal") or session.get("initial_goal", ""),
"expected": case.get("expected", ""), "expected": case.get("expected", ""),
"status": meta.get("terminal_status") "status": status,
or meta.get("status") "verdict": verification,
or session.get("status", "unknown"),
"verdict": verdict(outcome),
"outcome": outcome, "outcome": outcome,
"content": content, "content": content,
"steps": steps, "steps": steps,
"start": session.get("start_time") or meta.get("started_at"), "start": session.get("start_time") or meta.get("started_at"),
"end": session.get("end_time") or meta.get("ended_at"), "end": session.get("end_time") or meta.get("ended_at"),
"error": meta.get("error", ""), "error": error,
"device": meta.get("device_serial") or text(session.get("device_info") or ""), "device": meta.get("device_serial") or text(session.get("device_info") or ""),
"tags": case.get("tags", ""), "tags": case.get("tags", ""),
"case_id": case.get("case_id", ""), "case_id": case.get("case_id", ""),
@@ -119,7 +165,7 @@ def session_report(session_id: str, db_path=None) -> dict:
def time_text(value) -> str: def time_text(value) -> str:
return ( return (
datetime.fromtimestamp(float(value)).astimezone().isoformat(timespec="seconds") datetime.fromtimestamp(float(value)).astimezone().strftime("%Y-%m-%d %H:%M:%S")
if value if value
else "—" else "—"
) )
@@ -132,7 +178,7 @@ def metadata(report: dict) -> list[tuple]:
("检测项目", report.get("category") or "—"), ("检测项目", report.get("category") or "—"),
("测试子项", report.get("subcategory") or "—"), ("测试子项", report.get("subcategory") or "—"),
("前置约束提取", report.get("preconditions") or "—"), ("前置约束提取", report.get("preconditions") or "—"),
("任务配置", text(report.get("task_config") or {})), ("任务配置", config_text(report.get("task_config"))),
("任务目标", report["goal"]), ("任务目标", report["goal"]),
("预期结果", report["expected"] or "未指定"), ("预期结果", report["expected"] or "未指定"),
("执行状态", STATUS.get(report["status"], report["status"])), ("执行状态", STATUS.get(report["status"], report["status"])),
@@ -144,6 +190,16 @@ def metadata(report: dict) -> list[tuple]:
] ]
def config_text(config) -> str:
if not isinstance(config, dict):
return text(config or {})
return json.dumps(
{key: value for key, value in config.items() if value is not None},
ensure_ascii=False,
indent=2,
)
def image_path(name: str | None) -> Path | None: def image_path(name: str | None) -> Path | None:
if not name: if not name:
return None return None
@@ -161,10 +217,14 @@ def image_path(name: str | None) -> Path | None:
def markdown_blocks(content: str): def markdown_blocks(content: str):
lines = content.splitlines() lines = content.splitlines()
index = 0 index = 0
in_code = False
while index < len(lines): while index < len(lines):
line = lines[index] line = lines[index]
if line.strip().startswith("```"):
in_code = not in_code
if ( if (
"|" in line not in_code
and "|" in line
and index + 1 < len(lines) and index + 1 < len(lines)
and re.fullmatch(r"[\s|:\-]+", lines[index + 1]) and re.fullmatch(r"[\s|:\-]+", lines[index + 1])
and "-" in lines[index + 1] and "-" in lines[index + 1]
@@ -180,81 +240,296 @@ def markdown_blocks(content: str):
index += 1 index += 1
def word_report(report: dict, path: Path): def word_styles(doc, *, landscape: bool = False):
doc = Document() section = doc.sections[0]
style = cast(ParagraphStyle, doc.styles["Normal"]) section.page_width, section.page_height = (
style.font.name = "Microsoft YaHei" (Cm(29.7), Cm(21)) if landscape else (Cm(21), Cm(29.7))
style.font.size = Pt(10) )
style.element.rPr.rFonts.set(qn("w:eastAsia"), "微软雅黑") section.left_margin = section.right_margin = Cm(2)
doc.add_heading(report["name"], 0) section.top_margin = section.bottom_margin = Cm(1.9)
table = doc.add_table(rows=0, cols=2) for name, size, color in (
table.columns[0].width = Inches(1.3) ("Normal", 10.5, "263445"),
table.columns[1].width = Inches(5.2) ("Title", 20, "0F172A"),
table.style = "Light Shading Accent 1" ("Heading 1", 14, "0F172A"),
for label, value in metadata(report): ("Heading 2", 11.5, "0F172A"),
("Heading 3", 10.5, "0F172A"),
("List Bullet", 10.5, "263445"),
("List Number", 10.5, "263445"),
("Quote", 10.5, "263445"),
):
style = cast(ParagraphStyle, doc.styles[name])
style.font.name = "Microsoft YaHei"
style.font.size = Pt(size)
style.font.color.rgb = RGBColor.from_string(color)
style.element.get_or_add_rPr().get_or_add_rFonts().set(qn("w:eastAsia"), "微软雅黑")
style.paragraph_format.space_after = Pt(7 if name == "Normal" else 9)
if name == "Normal":
style.paragraph_format.line_spacing = 1.3
if name == "Title":
ppr = style.element.get_or_add_pPr()
border = ppr.find(qn("w:pBdr"))
if border is not None:
ppr.remove(border)
return doc
def shade_cell(cell, fill: str):
shading = OxmlElement("w:shd")
shading.set(qn("w:fill"), fill)
cell._tc.get_or_add_tcPr().append(shading)
def word_table(doc, rows, widths: tuple[float, ...], *, header: bool = False):
table = doc.add_table(rows=0, cols=len(widths))
table.autofit = False
table.style = "Table Grid"
borders = OxmlElement("w:tblBorders")
for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
line = OxmlElement(f"w:{edge}")
line.set(qn("w:val"), "single")
line.set(qn("w:sz"), "4")
line.set(qn("w:color"), "D9D9D9")
borders.append(line)
table._tbl.tblPr.append(borders)
for column, width in zip(table.columns, widths):
column.width = Cm(width)
for index, row in enumerate(rows):
cells = table.add_row().cells cells = table.add_row().cells
cells[0].text, cells[1].text = label, text(value) if header and index == 0:
cells[0].width, cells[1].width = Inches(1.3), Inches(5.2) table.rows[-1]._tr.get_or_add_trPr().append(OxmlElement("w:tblHeader"))
doc.add_heading("结论与任务专属结果", 1) for position, cell in enumerate(cells):
for kind, value in markdown_blocks(text(report["content"])): cell.width = Cm(widths[position])
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
cell.text = text(row[position]) if position < len(row) else ""
margins = OxmlElement("w:tcMar")
for edge, size in (("top", "85"), ("bottom", "85"), ("left", "110"), ("right", "110")):
margin = OxmlElement(f"w:{edge}")
margin.set(qn("w:w"), size)
margin.set(qn("w:type"), "dxa")
margins.append(margin)
cell._tc.get_or_add_tcPr().append(margins)
if (header and index == 0) or (not header and position == 0):
shade_cell(cell, "DDEBF5")
for run in cell.paragraphs[0].runs:
run.bold = True
elif index % 2 == 0:
shade_cell(cell, "F6F9FC")
for paragraph in cell.paragraphs:
paragraph.paragraph_format.space_after = Pt(3)
for run in paragraph.runs:
run.font.size = Pt(8.5 if len(widths) > 2 else 9.5)
doc.add_paragraph().paragraph_format.space_after = Pt(2)
return table
def add_markdown_inline(paragraph, value: str):
"""Keep emphasis readable in Word without importing untrusted HTML."""
pieces = re.split(r"(\*\*[^*]+\*\*|`[^`]+`|\*[^*]+\*)", value)
for piece in pieces:
if not piece:
continue
run = paragraph.add_run(
piece.strip("*")
if piece.startswith("*")
else piece.strip("`")
if piece.startswith("`")
else piece
)
run.bold = piece.startswith("**")
run.italic = piece.startswith("*") and not piece.startswith("**")
if piece.startswith("`"):
run.font.name = "Consolas"
def add_markdown_content(doc, content: str):
in_code = False
for kind, value in markdown_blocks(text(content)):
if kind == "table": if kind == "table":
table = doc.add_table(rows=0, cols=max(map(len, value))) columns = max(map(len, value))
table.style = "Light Shading Accent 1" if columns:
for row in value: word_table(doc, value, tuple(16 / columns for _ in range(columns)), header=True)
cells = table.add_row().cells continue
for cell, item in zip(cells, row): line = str(value)
cell.text = item if line.strip().startswith("```"):
elif isinstance(value, str) and value.startswith("#"): in_code = not in_code
doc.add_heading(value.lstrip("# "), min(len(value) - len(value.lstrip("#")), 3)) continue
if in_code:
paragraph = doc.add_paragraph()
shade = OxmlElement("w:shd")
shade.set(qn("w:fill"), "F1F5F9")
paragraph._p.get_or_add_pPr().append(shade)
paragraph.add_run(line).font.name = "Consolas"
elif not line.strip():
continue
elif match := re.match(r"^(#{1,3})\s+(.+)$", line):
doc.add_heading(match.group(2), level=min(len(match.group(1)) + 1, 3))
elif match := re.match(r"^\s*([-*+] |\d+\. )(.+)$", line):
paragraph = doc.add_paragraph(
style="List Number" if match.group(1)[0].isdigit() else "List Bullet"
)
add_markdown_inline(paragraph, match.group(2))
else: else:
doc.add_paragraph(str(value)) paragraph = doc.add_paragraph(style="Quote" if line.lstrip().startswith("> ") else None)
add_markdown_inline(
paragraph, line.lstrip("> ") if line.lstrip().startswith("> ") else line
)
def word_report(report: dict, path: Path):
doc = word_styles(Document())
doc.add_paragraph(report["name"], style="Title")
doc.add_paragraph(
f"执行状态:{STATUS.get(report['status'], report['status'])} 验证结论:{report['verdict']}"
)
if report["error"]:
doc.add_heading("异常原因", 1)
doc.add_paragraph(text(report["error"]))
doc.add_heading("任务信息", 1)
details = [
(label, value)
for label, value in metadata(report)
if label in {"任务编号", "用例编号", "检测项目", "测试子项", "开始时间", "结束时间", "设备"}
]
word_table(doc, details, (3.2, 13.3))
doc.add_heading("任务目标", 2)
doc.add_paragraph(report["goal"] or "未记录")
doc.add_heading("预期结果", 2)
doc.add_paragraph(report["expected"] or "未指定")
if report.get("preconditions"):
doc.add_paragraph("前置约束:" + text(report["preconditions"]))
if report.get("task_config"):
doc.add_paragraph("任务配置:" + config_text(report["task_config"]))
doc.add_heading("结论与发现", 1)
add_markdown_content(doc, report["content"])
doc.add_heading("执行步骤与证据", 1) doc.add_heading("执行步骤与证据", 1)
for step in report["steps"]: for step in report["steps"]:
doc.add_paragraph( paragraph = doc.add_paragraph()
f"步骤 {step['step_number']} · {time_text(step.get('timestamp'))} · {step.get('summary') or '已记录操作'}" paragraph.paragraph_format.keep_together = True
) paragraph.add_run(
evidence = report["steps"][:1] + report["steps"][-3:] f"步骤 {step['step_number']} · {time_text(step.get('timestamp'))}\n"
seen = set() ).bold = True
paragraph.add_run(step.get("summary") or "已记录操作")
evidence = report["steps"][:1] + report["steps"][-1:]
seen: set[str] = set()
images: list[tuple[Path, int]] = []
for step in evidence: for step in evidence:
name = step.get("post_image_name") or step.get("pre_image_name") name = step.get("post_image_name") or step.get("pre_image_name")
photo = image_path(name) photo = image_path(name)
if photo and name not in seen: if photo and name not in seen:
seen.add(name) seen.add(name)
doc.add_paragraph(f"证据:步骤 {step['step_number']} / {name}") images.append((photo, step["step_number"]))
doc.add_picture(str(photo), width=Inches(2.6)) if images:
doc.add_page_break()
doc.add_heading("关键截图", 2)
table = doc.add_table(rows=1, cols=len(images))
table.autofit = False
for cell, (photo, step_number) in zip(table.rows[0].cells, images):
cell.width = Cm(8 if len(images) == 2 else 12)
cell.paragraphs[0].add_run(f"步骤 {step_number}").bold = True
cell.add_paragraph().add_run().add_picture(
str(photo), width=Cm(7.5 if len(images) == 2 else 8.5)
)
doc.add_paragraph("其余步骤截图可从任务资料包单独下载。")
doc.add_paragraph(f"追溯编号:{report['id']}。未观察、未检查的项目不计为验证通过。") doc.add_paragraph(f"追溯编号:{report['id']}。未观察、未检查的项目不计为验证通过。")
doc.save(str(path)) doc.save(str(path))
def sheet_rows(ws, rows): def sheet_rows(ws, rows, widths: tuple[int, ...] = ()):
for row in rows: ws.sheet_view.showGridLines = False
ws.freeze_panes = "A2"
ws.sheet_properties.pageSetUpPr.fitToPage = True
ws.page_setup.fitToWidth = 1
if len(widths) > 3:
ws.page_setup.orientation = "landscape"
ws.page_setup.paperSize = ws.PAPERSIZE_A3
ws.print_options.horizontalCentered = True
for index, row in enumerate(rows, 1):
ws.append([text(value)[:32700] if value is not None else "" for value in row]) ws.append([text(value)[:32700] if value is not None else "" for value in row])
for cell in ws[ws.max_row]: longest = 1
for column, cell in enumerate(ws[index], 1):
# Always store text, including leading '=': never export executable formulas. # Always store text, including leading '=': never export executable formulas.
cell.data_type = "s" cell.data_type = "s"
cell.alignment = Alignment(wrap_text=True, vertical="top") cell.alignment = Alignment(wrap_text=True, vertical="center" if index == 1 else "top")
ws.freeze_panes = "A2" cell.font = Font(
if ws.max_row: name="微软雅黑",
size=10,
bold=index == 1,
color="FFFFFF" if index == 1 else "253244",
)
cell.border = Border(bottom=Side(style="hair", color="E2E8F0"))
if index == 1:
cell.fill = PatternFill("solid", fgColor="245C78")
elif index % 2 == 1 or column == 1:
cell.fill = PatternFill("solid", fgColor="F3F7FA" if column == 1 else "F8FAFC")
width = widths[column - 1] if column <= len(widths) else 32
ws.column_dimensions[get_column_letter(column)].width = width
lines = str(cell.value or "").splitlines() or [""]
height_lines = sum(
max(1, (sum(2 if ord(char) > 255 else 1 for char in line) + width - 1) // width)
for line in lines
)
longest = max(longest, height_lines)
ws.row_dimensions[index].height = 30 if index == 1 else min(300, max(27, longest * 18 + 8))
if ws.max_row > 1:
ws.auto_filter.ref = ws.dimensions ws.auto_filter.ref = ws.dimensions
for cell in ws[1]:
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill("solid", fgColor="245C78") def conclusion_rows(content: str):
for column in ws.columns: rows = [("类型", "结论与发现")]
ws.column_dimensions[column[0].column_letter].width = 32 table_number = 0
in_code = False
for kind, value in markdown_blocks(content):
if kind == "table":
table_number += 1
rows.append(("表格", f"详见「结果表{table_number}」工作表"))
elif line := str(value).strip():
if line.startswith("```"):
in_code = not in_code
continue
if in_code:
rows.append(("代码", line))
continue
if match := re.match(r"^#{1,6}\s+(.*)$", line):
label, body = "小节", match.group(1)
elif match := re.match(r"^(?:[-*+] |\d+\. )(.*)$", line):
label, body = "要点", match.group(1)
else:
label, body = "正文", line.lstrip("> ")
rows.append(
(
label,
re.sub(r"\*\*([^*]+)\*\*|`([^`]+)`", lambda m: m.group(1) or m.group(2), body),
)
)
return rows
def execution_result_label(result) -> str:
if not isinstance(result, dict):
return text(result or "未记录")[:120]
status = result.get("status")
labels = {
"dispatched": "动作已发出(需核对截图)",
"success": "动作返回成功",
"failed": "动作失败",
}
label = labels.get(status, text(status or "未记录"))
if result.get("error"):
label += "\n" + text(result["error"])[:180]
return label
def excel_report(report: dict, path: Path): def excel_report(report: dict, path: Path):
wb = Workbook() wb = Workbook()
assert wb.active is not None assert wb.active is not None
wb.active.title = "任务结果" wb.active.title = "任务结果"
sheet_rows(wb.active, [("项目", "内容"), *metadata(report)]) sheet_rows(wb.active, [("项目", "内容"), *metadata(report)], (22, 82))
sheet_rows( sheet_rows(wb.create_sheet("结论"), conclusion_rows(report["content"]), (15, 96))
wb.create_sheet("结论"), table_number = 0
[("最终结论",), *[(line,) for line in report["content"].splitlines()]], for kind, rows in markdown_blocks(report["content"]):
)
for number, (kind, rows) in enumerate(markdown_blocks(report["content"])):
if kind == "table": if kind == "table":
sheet_rows(wb.create_sheet(f"结果表{number + 1}"), rows) table_number += 1
sheet_rows(wb.create_sheet(f"结果表{table_number}"), rows)
sheet_rows( sheet_rows(
wb.create_sheet("执行证据"), wb.create_sheet("执行证据"),
[ [
@@ -264,12 +539,15 @@ def excel_report(report: dict, path: Path):
s["step_number"], s["step_number"],
time_text(s.get("timestamp")), time_text(s.get("timestamp")),
s.get("summary"), s.get("summary"),
text(s.get("last_execution_result")), execution_result_label(s.get("last_execution_result")),
s.get("post_image_name") or s.get("pre_image_name"), "见步骤截图资料包"
if s.get("post_image_name") or s.get("pre_image_name")
else "无截图",
) )
for s in report["steps"] for s in report["steps"]
], ],
], ],
(10, 22, 68, 26, 24),
) )
wb.save(path) wb.save(path)
@@ -392,21 +670,17 @@ def export_batch(batch_id: str) -> Path:
wb = Workbook() wb = Workbook()
assert wb.active is not None assert wb.active is not None
wb.active.title = "批次结果" wb.active.title = "批次结果"
sheet_rows(wb.active, rows) sheet_rows(wb.active, rows, (40, 48, 16, 28, 70))
path = directory / "批次结果.xlsx" path = directory / "批次结果.xlsx"
wb.save(path) wb.save(path)
outputs.append(path) outputs.append(path)
if "docx" in formats: if "docx" in formats:
doc = Document() doc = word_styles(Document(), landscape=True)
doc.add_heading(batch["name"], 0) doc.add_paragraph(batch["name"], style="Title")
doc.add_paragraph( doc.add_paragraph(
f"批次编号:{batch_id};共 {len(records)} 个任务。验证通过与执行完成分别统计。" f"批次编号:{batch_id};共 {len(records)} 个任务。验证通过与执行完成分别统计。"
) )
table = doc.add_table(rows=0, cols=5) word_table(doc, rows, (3.5, 5.4, 2.5, 3.5, 7.3), header=True)
table.style = "Light Shading Accent 1"
for row in rows:
for cell, value in zip(table.add_row().cells, row):
cell.text = str(value)
path = directory / "批次汇总.docx" path = directory / "批次汇总.docx"
doc.save(str(path)) doc.save(str(path))
outputs.append(path) outputs.append(path)
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.0.1", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.0.1", "version": "1.1.0",
"dependencies": { "dependencies": {
"@angular/cdk": "^22.1.5", "@angular/cdk": "^22.1.5",
"@angular/common": "^22.1.4", "@angular/common": "^22.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.0.1", "version": "1.1.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
@@ -331,7 +331,7 @@ limitations under the License.
</header> </header>
<!-- Floating Top-Right Badges & Toolbar (Pinned overlay, hidden in empty/initial state) --> <!-- Floating Top-Right Badges & Toolbar (Pinned overlay, hidden in empty/initial state) -->
@if (consolidatedBlocks().length > 0 || showPlanningLoader()) { @if (consolidatedBlocks().length > 0 || showPlanningLoader() || agentService.currentSession()) {
<div class="stream-floating-toolbar"> <div class="stream-floating-toolbar">
<!-- Active Architecture Chip: click opens the run info popover --> <!-- Active Architecture Chip: click opens the run info popover -->
@if (agentService.viewedModel(); as model) { @if (agentService.viewedModel(); as model) {
@@ -492,6 +492,7 @@ limitations under the License.
</span> </span>
} }
</button> </button>
<ng-content select="[task-guidance]"></ng-content>
</div> </div>
} }
@@ -568,6 +569,13 @@ limitations under the License.
> >
<!-- Unified Step / Stream Body --> <!-- Unified Step / Stream Body -->
<div class="step-body"> <div class="step-body">
@if (block.data?.extra_metadata?.injected_instruction; as guidance) {
<div class="guidance-step-note">
<div class="guidance-step-title"><span class="material-symbols-outlined">forum</span>用户指导 · 已进入第 {{block.data.step_number}} 步</div>
<p>{{ guidance }}</p>
<small>{{ hasRecordedDeviceAction(block.data.action_taken) ? '本步设备动作见下方;是否完成目标需以实际屏幕为准。' : '本步没有记录设备动作。' }}</small>
</div>
}
<!-- Check block (verifier lane): header, then the declared check items <!-- Check block (verifier lane): header, then the declared check items
and how each is judged; the chronological stream follows, and the and how each is judged; the chronological stream follows, and the
result rows close the block (see check-result below). --> result rows close the block (see check-result below). -->
@@ -746,7 +754,7 @@ limitations under the License.
@if (getReportStatusExplanation(item.data); as expText) { @if (getReportStatusExplanation(item.data); as expText) {
<div class="grid-item full-width"> <div class="grid-item full-width">
<span class="grid-label">报告与发现</span> <span class="grid-label">报告与发现</span>
<span class="grid-value report-markdown-body" [innerHTML]="renderMarkdown(expText, 'report-' + block.id + '-' + itemIdx)"></span> <div class="grid-value report-markdown-body" [innerHTML]="renderMarkdown(expText, 'report-' + block.id + '-' + itemIdx)"></div>
</div> </div>
} }
</div> </div>
@@ -1283,6 +1291,23 @@ limitations under the License.
} }
</div> </div>
} }
@if (awaitingGuidance().length) {
<div class="guidance-queue" aria-label="待处理用户指导">
@for (item of awaitingGuidance(); track item.id) {
<div class="guidance-queue-item">
<strong>用户指导 · {{ guidanceStatusLabel(item) }}</strong>
<p>{{ item.instruction }}</p>
</div>
}
</div>
}
@if (terminalFailureReason(); as reason) {
<div class="session-failure-summary" role="status">
<strong>任务执行失败 · 原因</strong>
<p>{{ reason }}</p>
<button type="button" (click)="isAutomationOpen.set(true)">查看完整报告</button>
</div>
}
<!-- Outputter Post-Execution Synthesis Card (Rendered ONCE at the end of stream) --> <!-- Outputter Post-Execution Synthesis Card (Rendered ONCE at the end of stream) -->
@if (outputterReport(); as outReport) { @if (outputterReport(); as outReport) {
<div class="outputter-synthesis-stream-card"> <div class="outputter-synthesis-stream-card">
@@ -1921,6 +1921,8 @@ $stream-row-height: 25px;
} }
&.task-report-card { &.task-report-card {
width: min(680px, 100%);
box-sizing: border-box;
background: #f0fdf4; background: #f0fdf4;
border: 1px solid #bbf7d0; border: 1px solid #bbf7d0;
@@ -1967,12 +1969,13 @@ $stream-row-height: 25px;
} }
.report-markdown-body { .report-markdown-body {
font-size: 12px; font-size: 14px;
line-height: 1.5; line-height: 1.7;
color: #1f2937; color: #1f2937;
font-weight: 400; font-weight: 400;
user-select: text; user-select: text;
word-break: break-word; word-break: normal;
overflow-wrap: anywhere;
p { p {
margin: 3px 0; margin: 3px 0;
@@ -2227,11 +2230,19 @@ $stream-row-height: 25px;
color: #64748b; color: #64748b;
} }
.grid-value { .grid-value {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: #1e293b; color: #1e293b;
word-break: break-all; word-break: break-all;
&.report-markdown-body {
font-size: 14px;
font-weight: 400;
line-height: 1.7;
word-break: normal;
overflow-wrap: anywhere;
}
&.code-pill { &.code-pill {
font-family: 'Roboto Mono', 'Fira Code', Consolas, monospace; font-family: 'Roboto Mono', 'Fira Code', Consolas, monospace;
@@ -3971,3 +3982,48 @@ $stream-row-height: 25px;
} }
} }
} }
.guidance-step-note, .guidance-queue-item {
padding: 10px 13px;
border: 1px solid #bfdbfe;
border-radius: 10px;
background: #eff6ff;
color: #1e3a8a;
line-height: 1.6;
overflow-wrap: anywhere;
.guidance-step-title { display: flex; align-items: center; gap: 6px; font-weight: 650; }
.material-symbols-outlined { font-size: 17px; }
p { margin: 5px 0; white-space: pre-wrap; color: #1e293b; }
small { color: #475569; }
}
.guidance-queue { display: grid; gap: 8px; padding: 12px 16px; }
.guidance-queue-item strong { font-size: 12px; }
.session-failure-summary {
margin: 12px 16px 28px;
padding: 12px 14px;
border: 1px solid #fecdd3;
border-radius: 10px;
background: #fff7f8;
color: #881337;
strong { font-size: 13px; }
p { margin: 6px 0 10px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
button { border: 0; background: transparent; padding: 0; color: #be123c; font: inherit; font-weight: 600; cursor: pointer; }
}
:host ::ng-deep .report-markdown-body {
display: block;
width: 100%;
min-width: 0;
font-size: 13px;
font-weight: 400;
line-height: 1.7;
overflow-wrap: anywhere;
word-break: normal;
> div { margin: 3px 0; }
h1, h2, h3 { margin: 12px 0 6px; font-size: 14px; color: #0f172a; }
ul, ol { padding-left: 22px; margin: 8px 0; }
li { margin: 4px 0; }
pre { overflow: auto; white-space: pre-wrap; padding: 10px; border-radius: 7px; background: #e2e8f0; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 7px 9px; border: 1px solid #cbd5e1; text-align: left; vertical-align: top; }
th { background: #dbeafe; }
.md-para-gap { height: 8px; }
}
@@ -23,7 +23,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { AgentService, StartupProgressEvent } from '../../services/agent.service'; import { AgentService, GuidanceEvent, StartupProgressEvent } from '../../services/agent.service';
import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model'; import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model';
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model'; import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay'; import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay';
@@ -247,6 +247,11 @@ export class AgentStreamComponent implements AfterViewInit {
private http = inject(HttpClient); private http = inject(HttpClient);
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
private zone = inject(NgZone); private zone = inject(NgZone);
public terminalFailureReason = signal('');
private selectedTerminalStatus = computed(() => {
const id = this.agentService.currentSessionId();
return this.agentService.sessions().find(session => session.session_id === id)?.status || '';
});
// Auto-scroll state tracking // Auto-scroll state tracking
public isUserAtBottom = true; public isUserAtBottom = true;
@@ -474,6 +479,23 @@ export class AgentStreamComponent implements AfterViewInit {
return consolidateLogsToBlocks(this.filteredLogs()); return consolidateLogsToBlocks(this.filteredLogs());
}); });
public awaitingGuidance = computed<GuidanceEvent[]>(() => {
const steps = this.consolidatedBlocks().filter(block => block.type === 'step');
return (this.agentService.guidanceEvents?.() || []).filter(event => !steps.some(block =>
new Date(block.timestamp).getTime() >= event.timestamp * 1000
&& String(block.data?.extra_metadata?.injected_instruction || '').includes(event.instruction)
));
});
public guidanceStatusLabel(event: GuidanceEvent): string {
const terminal = ['completed', 'failed', 'cancelled', 'interrupted']
.includes(this.agentService.currentSession()?.status || '');
if (event.status === 'pending') {
return terminal ? '任务结束前未进入模型' : '已接收,等待下一步';
}
return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录';
}
public phases = computed<PhaseBlock[]>(() => { public phases = computed<PhaseBlock[]>(() => {
const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId()); const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId());
return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0); return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0);
@@ -541,6 +563,17 @@ export class AgentStreamComponent implements AfterViewInit {
} }
constructor() { constructor() {
effect((onCleanup) => {
const id = this.agentService.currentSessionId();
const status = this.selectedTerminalStatus();
this.terminalFailureReason.set('');
if (!id || !['failed', 'interrupted'].includes(status || '')) return;
const request = this.http.get<{error?: string}>(`/api/automation/reports/${encodeURIComponent(id)}`).subscribe({
next: report => this.terminalFailureReason.set(report.error || '任务未完成;请查看已记录的执行步骤。'),
error: () => this.terminalFailureReason.set('任务未完成,暂时无法读取异常详情。')
});
onCleanup(() => request.unsubscribe());
});
// Run info popover: fetch usage when opened or when the viewed session // Run info popover: fetch usage when opened or when the viewed session
// changes, and keep refreshing while that session is still running. // changes, and keep refreshing while that session is still running.
effect((onCleanup) => { effect((onCleanup) => {
@@ -1327,6 +1360,11 @@ export class AgentStreamComponent implements AfterViewInit {
return isAndroidAction(action); return isAndroidAction(action);
} }
public hasRecordedDeviceAction(action: any): boolean {
const actions = Array.isArray(action) ? action : action ? [action] : [];
return actions.some(item => this.isAndroidAction(item) && !this.isReportStatusAction(item));
}
public getActionIcon(action: any): string { public getActionIcon(action: any): string {
return getActionIcon(action); return getActionIcon(action);
} }
@@ -1813,6 +1851,7 @@ export class AgentStreamComponent implements AfterViewInit {
public hasVisibleContent(block: any): boolean { public hasVisibleContent(block: any): boolean {
if (!block) return false; if (!block) return false;
if (block.type === 'checker') return true; if (block.type === 'checker') return true;
if (block.data?.extra_metadata?.injected_instruction) return true;
const hasNative = Boolean(this.getNativeThinking(block)); const hasNative = Boolean(this.getNativeThinking(block));
const hasRaw = Boolean(this.getRawThinking(block)); const hasRaw = Boolean(this.getRawThinking(block));
const hasReset = this.hasBlockResets(block, false) || this.hasBlockResets(block, true); const hasReset = this.hasBlockResets(block, false) || this.hasBlockResets(block, true);
@@ -12,27 +12,32 @@
</section> </section>
<section><h3>当前任务报告</h3> <section><h3>当前任务报告</h3>
@if (report(); as r) { @if (report(); as r) {
<strong>{{r.name}}</strong><p>{{label(r.status)}} · {{r.verdict}}</p> <div class="report-overview">
<strong class="report-title">{{r.name}}</strong>
<div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><span>{{r.verdict}}</span></div>
@if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> }
</div>
<div class="report-meta"><div><strong>任务目标</strong><p>{{r.goal || '未记录'}}</p></div><div><strong>预期结果</strong><p>{{r.expected || '未指定'}}</p></div></div>
<div class="actions"><a [href]="'/api/automation/reports/'+r.id+'/docx'">下载 Word</a><a [href]="'/api/automation/reports/'+r.id+'/xlsx'">下载 Excel</a> <div class="actions"><a [href]="'/api/automation/reports/'+r.id+'/docx'">下载 Word</a><a [href]="'/api/automation/reports/'+r.id+'/xlsx'">下载 Excel</a>
<a [href]="'/api/automation/reports/'+r.id+'/screenshots'">步骤截图 ZIP</a> <a [href]="'/api/automation/reports/'+r.id+'/screenshots'">步骤截图 ZIP</a>
<a [href]="'/api/automation/reports/'+r.id+'/video'">分段录屏 ZIP</a> <a [href]="'/api/automation/reports/'+r.id+'/video'">分段录屏 ZIP</a>
<a [href]="'/api/automation/reports/'+r.id+'/all'">下载全部资料</a></div> <a [href]="'/api/automation/reports/'+r.id+'/all'">下载全部资料</a></div>
@if (liveTask()) {
<div class="task-guidance">
<h3>任务指导 · {{label(taskStatus())}}</h3>
<p>运行中提交将在下一步执行前生效;暂停后会释放设备,提交指导即可重新排队恢复。</p>
<label for="task-guidance-text">补充信息或调整方向</label>
<textarea id="task-guidance-text" [(ngModel)]="guidance" maxlength="10000" rows="3" placeholder="描述已观察到的情况和下一步方向"></textarea>
<div class="actions">
@if(taskStatus() === 'running'){<button [disabled]="busy()" (click)="pauseTask(r.id)">暂停当前任务</button>}
<button [disabled]="busy() || !guidance.trim()" (click)="guideTask(r.id)">提交指导{{taskStatus() === 'task_paused' ? '并恢复' : ''}}</button>
@if(taskStatus() === 'task_paused'){<button [disabled]="busy()" (click)="continueTask(r.id)">直接继续</button>}
</div>
</div>
}
<p class="muted">报告包含目标、预期、实际结论、步骤与截图证据。执行中的下载为当前快照。</p> <p class="muted">报告包含目标、预期、实际结论、步骤与截图证据。执行中的下载为当前快照。</p>
<pre class="preview">{{r.content}}</pre> <div class="report-content"><h3>结论与发现</h3><div class="report-body" [innerHTML]="renderReport(r.content)"></div></div>
} @else { <p>从任务队列或历史记录中选择任务,即可下载报告。</p> } } @else { <p>从任务队列或历史记录中选择任务,即可下载报告。</p> }
@if (liveTask() && agent.currentSessionId()) {
<div class="task-guidance">
<h3>任务指导 · {{label(taskStatus())}}</h3>
<p>{{ taskStatus() === 'paused' ? '模型请求已暂停;提交指导后请在执行日志中恢复模型调用。' : '运行中提交将在下一步执行前生效;暂停后会释放设备,提交指导即可重新排队恢复。' }}</p>
<label for="task-guidance-text">补充信息或调整方向</label>
<textarea id="task-guidance-text" [(ngModel)]="guidance" maxlength="10000" rows="3" placeholder="描述已观察到的情况和下一步方向"></textarea>
<div class="actions">
@if(taskStatus() === 'running'){<button [disabled]="busy()" (click)="pauseTask(agent.currentSessionId()!)">暂停当前任务</button>}
<button [disabled]="busy() || !guidance.trim()" (click)="guideTask(agent.currentSessionId()!)">提交指导{{taskStatus() === 'task_paused' ? '并恢复' : ''}}</button>
@if(taskStatus() === 'task_paused'){<button [disabled]="busy()" (click)="continueTask(agent.currentSessionId()!)">直接继续</button>}
</div>
</div>
}
</section> </section>
} }
@if (tab() !== 'reports') { @if (tab() !== 'reports') {
@@ -53,7 +58,12 @@
} }
</section> </section>
<section><h3>批次记录</h3> <section><h3>批次记录</h3>
@for(b of batches(); track b.id){<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}}</span></button>} @for(b of batches(); track b.id){
<div class="batch-record">
<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}}</span></button>
<button class="delete-batch" type="button" [disabled]="busy() || b.status === 'running'" (click)="deleteBatch(b)" [attr.aria-label]="'删除批次:' + b.name" [title]="b.status === 'running' ? '执行中的批次不能删除' : '删除批次记录(保留已执行任务与报告)'"><span class="material-symbols-outlined">delete_outline</span></button>
</div>
}
@if(!batches().length){<p>暂无批次。</p>} @if(!batches().length){<p>暂无批次。</p>}
</section> </section>
@if(batch(); as b){<section><h3>{{b.name}}</h3><p>{{label(b.status)}} · {{b.reason}}</p> @if(batch(); as b){<section><h3>{{b.name}}</h3><p>{{label(b.status)}} · {{b.reason}}</p>
@@ -17,8 +17,26 @@ input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:
.upload{position:relative;margin:0;color:#fff;background:#2563eb;border-color:#2563eb;}.upload:hover{background:#1d4ed8;border-color:#1d4ed8;}.upload input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;} .upload{position:relative;margin:0;color:#fff;background:#2563eb;border-color:#2563eb;}.upload:hover{background:#1d4ed8;border-color:#1d4ed8;}.upload input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;}
.device{background:#f8fafc;padding:12px 16px;}.device label{margin:0;} .device{background:#f8fafc;padding:12px 16px;}.device label{margin:0;}
.record{display:flex;width:100%;text-align:left;align-items:flex-start;flex-direction:column;gap:4px;margin:8px 0 0;padding:12px;overflow-wrap:anywhere;}.record span{color:#64748b;font-size:12px;font-weight:400;}.record.selected{border-color:#93c5fd;background:#eff6ff;}.record.selected strong{color:#1d4ed8;} .record{display:flex;width:100%;text-align:left;align-items:flex-start;flex-direction:column;gap:4px;margin:8px 0 0;padding:12px;overflow-wrap:anywhere;}.record span{color:#64748b;font-size:12px;font-weight:400;}.record.selected{border-color:#93c5fd;background:#eff6ff;}.record.selected strong{color:#1d4ed8;}
.batch-record{display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record .record{flex:1;min-width:0;width:auto;margin:0;}.batch-record .delete-batch{flex:0 0 40px;min-width:40px;padding:0;color:#b91c1c;}.batch-record .delete-batch:hover:not(:disabled){background:#fff1f2;border-color:#fda4af;}.batch-record .delete-batch .material-symbols-outlined{font-size:19px;}
.code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:#f8fafc;}.table-wrap{max-height:260px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid #e2e8f0;text-align:left;overflow-wrap:anywhere;}th{background:#f8fafc;color:#475569;font-weight:500;} .code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:#f8fafc;}.table-wrap{max-height:260px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid #e2e8f0;text-align:left;overflow-wrap:anywhere;}th{background:#f8fafc;color:#475569;font-weight:500;}
.preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:#334155;background:#f8fafc;border-radius:8px;padding:12px;border:1px solid #e2e8f0;} .preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:#334155;background:#f8fafc;border-radius:8px;padding:12px;border:1px solid #e2e8f0;}
.job{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 0;border-bottom:1px solid #f1f5f9;}.job:last-child{border-bottom:0;}.job button{flex:1;justify-content:flex-start;text-align:left;min-width:150px;}.job span{font-size:12px;background:#f1f5f9;color:#475569;border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:#475569;cursor:pointer;padding:8px 0;} .job{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 0;border-bottom:1px solid #f1f5f9;}.job:last-child{border-bottom:0;}.job button{flex:1;justify-content:flex-start;text-align:left;min-width:150px;}.job span{font-size:12px;background:#f1f5f9;color:#475569;border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:#475569;cursor:pointer;padding:8px 0;}
.task-guidance{margin-top:16px;padding:14px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;} .task-guidance{margin-top:16px;padding:14px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;}
.task-guidance label{margin:8px 0 4px;} .task-guidance label{margin:8px 0 4px;}
.report-overview{padding:14px;border:1px solid #dbeafe;border-radius:10px;background:#f8fbff;}
.report-title{display:block;font-size:14px;line-height:1.5;color:#0f172a;overflow-wrap:anywhere;}
.report-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px;span{padding:3px 8px;border:1px solid #bfdbfe;border-radius:20px;background:#eff6ff;color:#1e40af;font-size:12px;&.failed{background:#fff1f2;border-color:#fecdd3;color:#be123c;}}}
.report-error{margin-top:11px;padding:10px 12px;background:#fff1f2;border-left:3px solid #e11d48;border-radius:6px;color:#9f1239;strong{font-size:12px;}p{margin:4px 0 0;color:#9f1239;white-space:pre-wrap;}}
.report-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin:12px 0;div{min-width:0;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;background:#fff;}strong{font-size:12px;color:#475569;}p{margin:5px 0 0;white-space:pre-wrap;color:#1e293b;}}
.report-content{margin-top:14px;padding:14px;border:1px solid #e2e8f0;border-radius:10px;background:#fff;h3{margin-bottom:12px;}}
.report-body{overflow-x:auto;overflow-wrap:anywhere;color:#1e293b;line-height:1.75;}
:host ::ng-deep .report-body, :host ::ng-deep .report-markdown-body {
h1,h2,h3,h4{margin:18px 0 8px;color:#0f172a;line-height:1.4;}h1{font-size:18px;}h2{font-size:16px;}h3,h4{font-size:14px;}
> div{margin:3px 0;}
ul,ol{margin:8px 0;padding-left:24px;}li{margin:4px 0;}
blockquote{margin:10px 0;padding:8px 12px;border-left:3px solid #93c5fd;background:#f8fafc;}
pre{max-width:100%;overflow:auto;padding:10px 12px;background:#f1f5f9;border-radius:6px;white-space:pre-wrap;}
code{font-family:Consolas,monospace;background:#f1f5f9;padding:1px 3px;border-radius:3px;}
table{width:100%;border-collapse:collapse;font-size:12px;}th,td{padding:7px 9px;border:1px solid #e2e8f0;vertical-align:top;}th{background:#eff6ff;color:#1e3a8a;}
.md-para-gap{height:8px;}
}
@@ -0,0 +1,80 @@
import { signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { AgentService } from '../../services/agent.service';
import { SystemService } from '../../services/system.service';
import { AutomationPanelComponent } from './automation-panel.component';
describe('AutomationPanelComponent batch deletion', () => {
const agent = {
currentSessionId: signal<string | null>(null),
runningSessionId: signal<string | null>(null),
agentStatus: signal('idle'),
sessions: signal<any[]>([])
};
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'delete']);
beforeEach(async () => {
(http.get.and as any).callFake((url: string) => of(url.endsWith('/preferences')
? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : []));
http.delete.and.returnValue(of({ status: 'deleted' }));
http.delete.calls.reset();
await TestBed.configureTestingModule({
imports: [AutomationPanelComponent],
providers: [
{ provide: AgentService, useValue: agent },
{ provide: SystemService, useValue: { connectedDevices: signal([]) } },
{ provide: HttpClient, useValue: http }
]
}).compileComponents();
});
it('confirms deletion and removes the batch from the visible list', async () => {
const fixture = TestBed.createComponent(AutomationPanelComponent);
fixture.componentInstance.tab.set('batches');
await new Promise<void>(resolve => setTimeout(resolve, 0));
fixture.componentInstance.batches.set([
{ id: 'batch-1', name: '批量冒烟', status: 'completed', created_at: 100 }
]);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[aria-label="删除批次:批量冒烟"]')).not.toBeNull();
spyOn(window, 'confirm').and.returnValue(true);
const button = fixture.nativeElement.querySelector('.delete-batch') as HTMLButtonElement;
button.click();
await new Promise<void>(resolve => setTimeout(resolve, 0));
fixture.detectChanges();
expect(http.delete).toHaveBeenCalledWith('/api/automation/batches/batch-1');
expect(fixture.componentInstance.batches()).toEqual([]);
});
it('keeps a running batch protected from the delete control', async () => {
const fixture = TestBed.createComponent(AutomationPanelComponent);
fixture.componentInstance.tab.set('batches');
await new Promise<void>(resolve => setTimeout(resolve, 0));
fixture.componentInstance.batches.set([
{ id: 'batch-2', name: '进行中', status: 'running', created_at: 100 }
]);
fixture.detectChanges();
expect((fixture.nativeElement.querySelector('.delete-batch') as HTMLButtonElement).disabled).toBeTrue();
});
it('shows the failure reason and renders Chinese report structure with line breaks', () => {
const fixture = TestBed.createComponent(AutomationPanelComponent);
fixture.detectChanges();
fixture.componentInstance.report.set({
id: 'task-1', name: '系统设置检查', status: 'failed', verdict: '未验证',
error: '模型连续 3 次未提供可执行工具调用', goal: '查看设置', expected: '',
content: '## 结果\n第一行\n第二行\n\n| 项目 | 状态 |\n| --- | --- |\n| 设置 | 未验证 |'
});
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('.report-error')?.textContent).toContain('连续 3 次');
expect(root.querySelector('.report-body h2')?.textContent).toBe('结果');
expect(root.querySelector('.report-body')?.textContent).toContain('第一行');
expect(root.querySelector('.report-body')?.textContent).toContain('第二行');
expect(root.querySelector('.report-body table td')?.textContent).toBe('设置');
});
});
@@ -1,10 +1,11 @@
import { Component, DestroyRef, effect, inject, signal } from '@angular/core'; import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { AgentService } from '../../services/agent.service'; import { AgentService } from '../../services/agent.service';
import { SystemService } from '../../services/system.service'; import { SystemService } from '../../services/system.service';
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
@Component({ @Component({
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule], selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule],
@@ -16,8 +17,13 @@ export class AutomationPanelComponent {
batches = signal<any[]>([]); preview = signal<any>(null); batches = signal<any[]>([]); preview = signal<any>(null);
batch = signal<any>(null); report = signal<any>(null); batch = signal<any>(null); report = signal<any>(null);
guidance = ''; guidance = '';
taskStatus = signal(''); taskStatus = computed(() => {
liveTask = signal(false); const sid = this.agent.currentSessionId();
if (!sid) return '';
return this.agent.sessions().find(s => s.session_id === sid)?.status
|| (this.agent.runningSessionId() === sid ? this.agent.agentStatus() : '') || '';
});
liveTask = computed(() => ['running', 'paused', 'task_paused', 'resuming'].includes(this.taskStatus()));
word = true; excel = true; name = ''; device = ''; profile = 'flash'; stopOnFailure = false; timeout = 1800; word = true; excel = true; name = ''; device = ''; profile = 'flash'; stopOnFailure = false; timeout = 1800;
private destroyed = false; private destroyed = false;
constructor() { constructor() {
@@ -33,6 +39,7 @@ export class AutomationPanelComponent {
} }
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; } formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告'} as Record<string,string>)[s] || s; } label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告'} as Record<string,string>)[s] || s; }
renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
async action(fn: () => Promise<void>) { async action(fn: () => Promise<void>) {
if (this.busy()) return; if (this.busy()) return;
this.busy.set(true); this.error.set(''); this.notice.set(''); this.busy.set(true); this.error.set(''); this.notice.set('');
@@ -46,11 +53,6 @@ export class AutomationPanelComponent {
this.batches.set(b); this.batches.set(b);
const sid = this.agent.currentSessionId(); const sid = this.agent.currentSessionId();
if (sid && this.tab() === 'reports') { const r = await firstValueFrom(this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`)); if (this.agent.currentSessionId() === sid) this.report.set(r); } if (sid && this.tab() === 'reports') { const r = await firstValueFrom(this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`)); if (this.agent.currentSessionId() === sid) this.report.set(r); }
if (sid) {
const current = this.agent.sessions().find(s => s.session_id === sid);
this.taskStatus.set(current?.status || this.report()?.status || '');
this.liveTask.set(['running','task_paused','resuming'].includes(current?.status || ''));
} else { this.taskStatus.set(''); this.liveTask.set(false); }
const id = this.batch()?.id; const id = this.batch()?.id;
if (id) { const item = await firstValueFrom(this.http.get<any>(`/api/automation/batches/${id}`)); if (this.batch()?.id === id) this.batch.set(item); } if (id) { const item = await firstValueFrom(this.http.get<any>(`/api/automation/batches/${id}`)); if (this.batch()?.id === id) this.batch.set(item); }
} catch { /* User initiated actions expose errors; polling retries on the next interval. */ } } catch { /* User initiated actions expose errors; polling retries on the next interval. */ }
@@ -70,12 +72,25 @@ export class AutomationPanelComponent {
this.preview.set(null); this.notice.set('批次已开始,可暂停或查看每项结果'); await this.refresh(); this.preview.set(null); this.notice.set('批次已开始,可暂停或查看每项结果'); await this.refresh();
}); } }); }
selectBatch(id: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.get(`/api/automation/batches/${id}`))); }); } selectBatch(id: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.get(`/api/automation/batches/${id}`))); }); }
deleteBatch(item: { id: string; name: string }) {
if (!window.confirm(`删除批次“${item.name}”?未执行任务会移除,已执行任务与报告仍保留。`)) return;
void this.action(async () => {
await firstValueFrom(this.http.delete(`/api/automation/batches/${encodeURIComponent(item.id)}`));
if (this.batch()?.id === item.id) this.batch.set(null);
await this.refresh();
this.notice.set('批次记录已删除;已执行任务与报告仍可在历史中查看');
});
}
batchAction(action: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.post(`/api/automation/batches/${this.batch().id}/${action}`,{}))); await this.refresh(); }); } batchAction(action: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.post(`/api/automation/batches/${this.batch().id}/${action}`,{}))); await this.refresh(); }); }
pauseTask(id: string) { void this.action(async () => { await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/pause`,{})); this.notice.set('已请求暂停:当前步骤结束后释放设备'); await this.refresh(); }); } pauseTask(id: string) { void this.action(async () => { await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/pause`,{})); this.notice.set('已请求暂停:当前步骤结束后释放设备'); await this.refresh(); }); }
guideTask(id: string) { void this.action(async () => { guideTask(id: string) { void this.action(async () => {
if (!this.guidance.trim()) throw Error('请先填写指导内容'); if (!this.guidance.trim()) throw Error('请先填写指导内容');
const status = this.taskStatus();
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/guide`,{instruction:this.guidance.trim()})); await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/guide`,{instruction:this.guidance.trim()}));
this.guidance = ''; this.notice.set('指导已提交;运行中将在下一步生效,已暂停的任务会重新排队恢复'); await this.refresh(); this.guidance = '';
this.agent.fetchGuidance(id);
if (status === 'paused') this.notice.set('模型请求暂停,请在执行日志中恢复模型调用');
await this.refresh();
}); } }); }
continueTask(id: string) { void this.action(async () => { continueTask(id: string) { void this.action(async () => {
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/continue`,{})); await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/continue`,{}));
@@ -17,7 +17,43 @@ limitations under the License.
<div class="workspace-container" [class.is-resizing]="isDragging()"> <div class="workspace-container" [class.is-resizing]="isDragging()">
<!-- Left Main Panel: Raw Agent Log Stream --> <!-- Left Main Panel: Raw Agent Log Stream -->
<main class="left-panel glass-panel"> <main class="left-panel glass-panel">
<app-agent-stream></app-agent-stream> <app-agent-stream>
@if (guidanceStatus(); as status) {
<div task-guidance class="task-guidance-control">
<button type="button" class="guidance-trigger" (click)="toggleGuidance()"
[attr.aria-expanded]="guidanceOpenId() === agentService.currentSessionId()"
aria-controls="workspace-guidance-panel" aria-label="指导当前任务" title="暂停或指导当前任务">
<span class="material-symbols-outlined">edit_note</span>
指导任务
</button>
@if (guidanceOpenId() === agentService.currentSessionId()) {
<section id="workspace-guidance-panel" class="task-guidance-panel" aria-label="当前任务指导">
<div class="guidance-heading">
<strong>指导当前任务 · {{ status === 'running' ? '执行中' : status === 'resuming' ? '等待设备恢复' : status === 'paused' ? '模型调用暂停' : '等待指导' }}</strong>
<span [title]="agentService.currentSession()?.initial_goal || ''">{{ agentService.currentSession()?.initial_goal || agentService.currentSessionId() }}</span>
</div>
<label for="workspace-task-guidance">补充信息或调整方向</label>
<textarea id="workspace-task-guidance" [(ngModel)]="taskGuidance" rows="2" maxlength="10000"
[disabled]="guidanceBusy()" placeholder="输入指导;运行中将在下一步执行前生效"></textarea>
<div class="guidance-actions">
<span>{{ status === 'paused' ? '提交指导后请在执行日志中恢复模型调用' : '下方输入框用于开始新任务' }}</span>
@if (status === 'running') {
<button type="button" (click)="pauseCurrentTask()"
[disabled]="guidanceBusy() || pauseRequestId() === agentService.currentSessionId()">
{{ pauseRequestId() === agentService.currentSessionId() ? '等待暂停…' : '暂停当前任务' }}
</button>
}
@if (status === 'task_paused') {
<button type="button" (click)="continueCurrentTask()" [disabled]="guidanceBusy()">直接继续</button>
}
<button type="button" class="guidance-submit" (click)="submitGuidance()"
[disabled]="guidanceBusy() || !taskGuidance.trim()">{{ status === 'task_paused' ? '提交并恢复' : '提交指导' }}</button>
</div>
</section>
}
</div>
}
</app-agent-stream>
<!-- Bottom Floating Command Bar Dock --> <!-- Bottom Floating Command Bar Dock -->
<div class="workspace-floating-bar-wrapper" [class.is-expanded]="isBarExpanded()" [class.is-dormant]="!isBarExpanded()" <div class="workspace-floating-bar-wrapper" [class.is-expanded]="isBarExpanded()" [class.is-dormant]="!isBarExpanded()"
@@ -108,6 +108,110 @@ $rose-dark: #dc2626;
} }
} }
.task-guidance-control {
position: relative;
flex: 0 0 auto;
.guidance-trigger {
display: inline-flex;
align-items: center;
gap: 5px;
height: 30px;
padding: 0 11px;
border: 1px solid rgba(226, 232, 240, .85);
border-radius: 15px;
background: rgba(255, 255, 255, .72);
box-shadow: 0 2px 6px -1px rgba(15, 23, 42, .05);
color: #334155;
font: inherit;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
.material-symbols-outlined { font-size: 16px; }
&:hover { background: rgba(255, 255, 255, .92); border-color: #cbd5e1; }
&[aria-expanded='true'] { background: #eff6ff; border-color: #bfdbfe; color: #1d4ed8; }
&:focus-visible { outline: 2px solid #93c5fd; outline-offset: 2px; }
}
@media (max-width: 520px) {
.guidance-trigger {
justify-content: center;
width: 30px;
padding: 0;
font-size: 0;
.material-symbols-outlined { font-size: 16px; }
}
}
.task-guidance-panel {
position: absolute;
top: calc(100% + 9px);
right: 0;
box-sizing: border-box;
width: min(420px, calc(100vw - 40px));
padding: 15px;
border: 1px solid #dbe4ef;
border-radius: 16px;
background: #fff;
box-shadow: 0 18px 48px rgba(15, 23, 42, .16);
color: #1e293b;
font-size: 12px;
.guidance-heading {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
margin-bottom: 10px;
strong { flex-shrink: 0; color: #1d4ed8; font-size: 13px; }
span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #475569; }
}
label { display: block; margin-bottom: 6px; font-weight: 600; }
textarea {
box-sizing: border-box;
width: 100%;
min-height: 62px;
max-height: 160px;
padding: 9px 10px;
resize: vertical;
border: 1px solid #cbd5e1;
border-radius: 9px;
background: #fff;
color: #0f172a;
font: inherit;
font-size: 13px;
&:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }
}
.guidance-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
span { flex: 1 0 100%; color: #64748b; }
button {
min-height: 32px;
padding: 5px 11px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
color: #334155;
font: inherit;
font-weight: 600;
cursor: pointer;
&:hover:not(:disabled) { background: #eff6ff; border-color: #93c5fd; }
&:disabled { opacity: .55; cursor: not-allowed; }
&.guidance-submit { background: #2563eb; border-color: #2563eb; color: #fff; }
&.guidance-submit:hover:not(:disabled) { background: #1d4ed8; }
}
}
}
}
/* ========================================================================= */ /* ========================================================================= */
/* BOTTOM FLOATING COMMAND BAR (Proximity Reveal & Minimalist Glassmorphism) */ /* BOTTOM FLOATING COMMAND BAR (Proximity Reveal & Minimalist Glassmorphism) */
/* ========================================================================= */ /* ========================================================================= */
@@ -0,0 +1,118 @@
import { Component, computed, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { AgentService } from '../../services/agent.service';
import { AgentStreamComponent } from '../../components/agent-stream/agent-stream.component';
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
import { WorkspaceComponent } from './workspace.component';
@Component({ selector: 'app-agent-stream', standalone: true,
template: '<div class="stream-floating-toolbar"><ng-content select="[task-guidance]"></ng-content></div>' })
class StreamStub {}
@Component({ selector: 'app-chat-interface', standalone: true, template: '' })
class ChatStub {}
@Component({ selector: 'app-floating-video-player', standalone: true, template: '' })
class VideoStub {}
describe('Workspace task guidance', () => {
const currentId = signal<string | null>('running-task');
const session = signal({ session_id: 'running-task', status: 'running', initial_goal: '观察设置' });
const agent = {
currentSessionId: currentId,
currentSession: computed(() => currentId() === session().session_id ? session() : null),
runningSessionId: signal<string | null>('running-task'),
agentStatus: signal('running'),
isCurrentSessionRunning: computed(() => ['running', 'paused'].includes(session().status)),
fetchStatus: jasmine.createSpy('fetchStatus'),
fetchSessions: jasmine.createSpy('fetchSessions'),
fetchGuidance: jasmine.createSpy('fetchGuidance')
};
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['post']);
beforeEach(async () => {
currentId.set('running-task');
session.set({ session_id: 'running-task', status: 'running', initial_goal: '观察设置' });
agent.runningSessionId.set('running-task');
http.post.and.returnValue(of({}));
http.post.calls.reset();
await TestBed.configureTestingModule({
imports: [WorkspaceComponent],
providers: [
{ provide: AgentService, useValue: agent },
{ provide: HttpClient, useValue: http }
]
}).overrideComponent(WorkspaceComponent, {
remove: { imports: [AgentStreamComponent, ChatInterfaceComponent, FloatingVideoPlayerComponent] },
add: { imports: [StreamStub, ChatStub, VideoStub] }
}).compileComponents();
});
it('shows pause and guidance for the selected running task, then hides them on cancellation', async () => {
const fixture = TestBed.createComponent(WorkspaceComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
expect(fixture.nativeElement.querySelector('.stream-floating-toolbar .guidance-trigger')).not.toBeNull();
expect(fixture.nativeElement.querySelector('.workspace-floating-bar-wrapper .guidance-trigger')).toBeNull();
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
fixture.detectChanges();
const panel = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
expect(panel.textContent).toContain('指导当前任务');
expect(panel.textContent).toContain('下方输入框用于开始新任务');
const pause = Array.from(panel.querySelectorAll('button')).find(b => b.textContent?.includes('暂停当前任务'))!;
pause.click();
await fixture.whenStable();
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/pause', {});
fixture.detectChanges();
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
fixture.detectChanges();
const reopened = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
const input = reopened.querySelector('textarea')!;
input.value = '先确认设置首页';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
const submit = Array.from(reopened.querySelectorAll('button')).find(b => b.textContent?.includes('提交指导'))!;
submit.click();
await fixture.whenStable();
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/guide', { instruction: '先确认设置首页' });
expect(agent.fetchGuidance).toHaveBeenCalledWith('running-task');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('指导已提交');
session.set({ session_id: 'running-task', status: 'cancelled', initial_goal: '观察设置' });
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
expect(fixture.nativeElement.querySelector('.guidance-trigger')).toBeNull();
});
it('continues a paused task without creating a new task', async () => {
session.set({ session_id: 'running-task', status: 'task_paused', initial_goal: '观察设置' });
const fixture = TestBed.createComponent(WorkspaceComponent);
fixture.detectChanges();
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
fixture.detectChanges();
const button = Array.from<HTMLElement>(fixture.nativeElement.querySelectorAll('.task-guidance-panel button'))
.find(b => b.textContent?.includes('直接继续'))!;
button.click();
await fixture.whenStable();
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/continue', {});
});
it('does not offer task resume for a paused model request', () => {
session.set({ session_id: 'running-task', status: 'paused', initial_goal: '观察设置' });
const fixture = TestBed.createComponent(WorkspaceComponent);
fixture.detectChanges();
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
fixture.detectChanges();
const panel = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
expect(panel.textContent).toContain('模型调用暂停');
expect(panel.textContent).toContain('请在执行日志中恢复模型调用');
expect(panel.textContent).not.toContain('直接继续');
expect(panel.textContent).not.toContain('提交并恢复');
});
});
@@ -21,6 +21,8 @@ import { AgentStreamComponent } from '../../components/agent-stream/agent-stream
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component'; import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component'; import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
import { AgentService } from '../../services/agent.service'; import { AgentService } from '../../services/agent.service';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
@Component({ @Component({
selector: 'app-workspace', selector: 'app-workspace',
@@ -37,6 +39,7 @@ import { AgentService } from '../../services/agent.service';
}) })
export class WorkspaceComponent implements OnInit { export class WorkspaceComponent implements OnInit {
public agentService = inject(AgentService); public agentService = inject(AgentService);
private http = inject(HttpClient);
private zone = inject(NgZone); private zone = inject(NgZone);
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
@@ -56,6 +59,33 @@ export class WorkspaceComponent implements OnInit {
public isSubmitting = signal<boolean>(false); public isSubmitting = signal<boolean>(false);
public errorMessage = signal<string | null>(null); public errorMessage = signal<string | null>(null);
public selectedProfile = signal<'flash' | 'pro'>('flash'); public selectedProfile = signal<'flash' | 'pro'>('flash');
private guidanceDrafts = new Map<string, string>();
public guidanceBusy = signal(false);
public guidanceOpenId = signal<string | null>(null);
public pauseRequestId = signal<string | null>(null);
public get taskGuidance(): string {
const id = this.agentService.currentSessionId();
return id ? this.guidanceDrafts.get(id) || '' : '';
}
public set taskGuidance(value: string) {
const id = this.agentService.currentSessionId();
if (id) this.guidanceDrafts.set(id, value);
}
public guidanceStatus = computed(() => {
const id = this.agentService.currentSessionId();
if (!id) return null;
const status = this.agentService.currentSession()?.status
|| (this.agentService.runningSessionId() === id ? this.agentService.agentStatus() : '');
return ['running', 'paused', 'task_paused', 'resuming'].includes(status || '') ? status : null;
});
public toggleGuidance(): void {
const id = this.agentService.currentSessionId();
this.guidanceOpenId.set(id && this.guidanceOpenId() !== id ? id : null);
}
// Expand States (Signals for 0-latency reactivity) // Expand States (Signals for 0-latency reactivity)
public isHoveringCard = signal<boolean>(false); public isHoveringCard = signal<boolean>(false);
@@ -103,6 +133,69 @@ export class WorkspaceComponent implements OnInit {
return this.agentService.isCurrentSessionRunning(); return this.agentService.isCurrentSessionRunning();
}); });
public async pauseCurrentTask(): Promise<void> {
const id = this.agentService.currentSessionId();
if (!id || this.guidanceStatus() !== 'running' || this.guidanceBusy()) return;
this.guidanceBusy.set(true);
this.errorMessage.set(null);
try {
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/pause`, {}));
this.pauseRequestId.set(id);
this.guidanceOpenId.set(null);
this.agentService.fetchStatus();
this.agentService.fetchSessions();
} catch (error) {
this.showGuidanceError(error);
} finally {
this.guidanceBusy.set(false);
}
}
public async submitGuidance(): Promise<void> {
const id = this.agentService.currentSessionId();
const instruction = this.taskGuidance.trim();
const status = this.guidanceStatus();
if (!id || !status || !instruction || this.guidanceBusy()) return;
this.guidanceBusy.set(true);
this.errorMessage.set(null);
try {
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/guide`, { instruction }));
this.guidanceDrafts.delete(id);
this.pauseRequestId.set(null);
this.guidanceOpenId.set(null);
this.agentService.fetchGuidance(id);
this.agentService.fetchStatus();
this.agentService.fetchSessions();
} catch (error) {
this.showGuidanceError(error);
} finally {
this.guidanceBusy.set(false);
}
}
public async continueCurrentTask(): Promise<void> {
const id = this.agentService.currentSessionId();
if (!id || this.guidanceStatus() !== 'task_paused' || this.guidanceBusy()) return;
this.guidanceBusy.set(true);
this.errorMessage.set(null);
try {
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/continue`, {}));
this.pauseRequestId.set(null);
this.guidanceOpenId.set(null);
this.agentService.fetchStatus();
this.agentService.fetchSessions();
} catch (error) {
this.showGuidanceError(error);
} finally {
this.guidanceBusy.set(false);
}
}
private showGuidanceError(error: unknown): void {
const response = error as { error?: { detail?: string }; message?: string };
this.errorMessage.set(response.error?.detail || response.message || '操作失败,请刷新任务状态后重试');
}
/** /**
* Dynamic tooltip and label indicating which task will be stopped * Dynamic tooltip and label indicating which task will be stopped
*/ */
@@ -54,6 +54,15 @@ export interface StartupProgressEvent {
timestamp: number; timestamp: number;
} }
export interface GuidanceEvent {
id: string;
instruction: string;
source: string;
timestamp: number;
status: 'pending' | 'consumed';
consumed_at?: number;
}
interface SessionVideoResponse { interface SessionVideoResponse {
session_id: string; session_id: string;
status?: 'processing' | 'ready' | 'failed' | 'unavailable'; status?: 'processing' | 'ready' | 'failed' | 'unavailable';
@@ -201,6 +210,8 @@ export class AgentService {
public currentSessionId = signal<string | null>(null); public currentSessionId = signal<string | null>(null);
public sessionLogs = signal<any[]>([]); // Dynamic array of all raw events received public sessionLogs = signal<any[]>([]); // Dynamic array of all raw events received
public guidanceEvents = signal<GuidanceEvent[]>([]);
private guidanceSignature = '';
public isSessionContentLoading = signal<boolean>(false); public isSessionContentLoading = signal<boolean>(false);
public agentStatus = signal<string>('idle'); // Status of the agent runner process public agentStatus = signal<string>('idle'); // Status of the agent runner process
public runningSessionId = signal<string | null>(null); public runningSessionId = signal<string | null>(null);
@@ -725,6 +736,8 @@ export class AgentService {
this.isSessionContentLoading.set(false); this.isSessionContentLoading.set(false);
this.currentSessionId.set(null); this.currentSessionId.set(null);
this.sessionLogs.set([]); this.sessionLogs.set([]);
this.guidanceEvents.set([]);
this.guidanceSignature = '';
this.currentNotes.set({}); this.currentNotes.set({});
return; return;
} }
@@ -740,6 +753,9 @@ export class AgentService {
this.isSessionContentLoading.set(true); this.isSessionContentLoading.set(true);
this.currentSessionId.set(sessionId); this.currentSessionId.set(sessionId);
this.sessionLogs.set([]); // Reset logs for new session selection this.sessionLogs.set([]); // Reset logs for new session selection
this.guidanceEvents.set([]);
this.guidanceSignature = '';
this.fetchGuidance(sessionId);
this.activePauseCardKey = null; this.activePauseCardKey = null;
this.isPaused.set(false); this.isPaused.set(false);
this.pausedError.set(null); this.pausedError.set(null);
@@ -1279,6 +1295,21 @@ export class AgentService {
}); });
} }
public fetchGuidance(sessionId: string): void {
if (this.currentSessionId() !== sessionId) return;
this.http.get<GuidanceEvent[]>(`/api/tasks/${encodeURIComponent(sessionId)}/guidance`).subscribe({
next: (items) => {
if (this.currentSessionId() !== sessionId) return;
const signature = JSON.stringify(items);
if (signature !== this.guidanceSignature) {
this.guidanceSignature = signature;
this.guidanceEvents.set(items);
}
},
error: () => { /* Older runners have no guidance history; steps remain authoritative. */ }
});
}
private restoreSessionsCache(): void { private restoreSessionsCache(): void {
try { try {
const cached = localStorage.getItem(SESSION_CACHE_KEY); const cached = localStorage.getItem(SESSION_CACHE_KEY);
@@ -1488,6 +1519,8 @@ export class AgentService {
// Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications // Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications
if (this.pollCounter % 3 === 0) { if (this.pollCounter % 3 === 0) {
this.fetchSessions(); this.fetchSessions();
const selected = this.currentSessionId();
if (selected) this.fetchGuidance(selected);
} }
}, 2000); }, 2000);
}); });
@@ -67,4 +67,12 @@ describe('markdown-parser.util verification & check lines', () => {
expect(html).toContain('<span class="verify-badge">verify</span>'); expect(html).toContain('<span class="verify-badge">verify</span>');
expect(html).toContain('status is ok'); expect(html).toContain('status is ok');
}); });
it('renders Chinese report tables and preserves separate lines safely', () => {
const html = renderMarkdownToHtml('## 结论\n第一行\n第二行\n\n| 分类 | 结果 |\n| --- | --- |\n| 网络 | **通过** |');
expect(html).toContain('<h2>结论</h2>');
expect(html).toContain('<div>第一行</div>\n<div>第二行</div>');
expect(html).toContain('<th>分类</th>');
expect(html).toContain('<td><strong>通过</strong></td>');
});
}); });
@@ -272,7 +272,7 @@ export function renderMarkdownToHtml(text: string): string {
if (inCodeBlock) { if (inCodeBlock) {
// End code block // End code block
const codeContent = codeBlockLines.join('\n'); const codeContent = codeBlockLines.join('\n');
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang}">${codeContent}</code></pre>`); result.push(`<pre class="code-block"><code class="lang-${codeBlockLang.replace(/[^a-zA-Z0-9_-]/g, '')}">${codeContent}</code></pre>`);
inCodeBlock = false; inCodeBlock = false;
codeBlockLines = []; codeBlockLines = [];
codeBlockLang = ''; codeBlockLang = '';
@@ -290,6 +290,26 @@ export function renderMarkdownToHtml(text: string): string {
continue; continue;
} }
// Markdown result tables need real cells to remain readable in reports.
if (trimmed.includes('|') && i + 1 < rawLines.length
&& /^\s*\|?\s*:?-{3,}:?(?:\s*\|\s*:?-{3,}:?)*\s*\|?\s*$/.test(rawLines[i + 1])) {
closeList();
const cells = (line: string) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
const headings = cells(rawLine);
result.push('<div class="md-table-scroll"><table><thead><tr>'
+ headings.map(cell => `<th>${formatInlineMarkdown(cell.trim())}</th>`).join('')
+ '</tr></thead><tbody>');
i += 2;
while (i < rawLines.length && rawLines[i].trim() && rawLines[i].includes('|')) {
const row = cells(rawLines[i]);
result.push('<tr>' + headings.map((_, index) => `<td>${formatInlineMarkdown((row[index] || '').trim())}</td>`).join('') + '</tr>');
i++;
}
result.push('</tbody></table></div>');
i--;
continue;
}
// 2. Headings: # to ###### // 2. Headings: # to ######
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/); const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) { if (headingMatch) {
@@ -375,7 +395,7 @@ export function renderMarkdownToHtml(text: string): string {
if (inCodeBlock) { if (inCodeBlock) {
const codeContent = codeBlockLines.join('\n'); const codeContent = codeBlockLines.join('\n');
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang}">${codeContent}</code></pre>`); result.push(`<pre class="code-block"><code class="lang-${codeBlockLang.replace(/[^a-zA-Z0-9_-]/g, '')}">${codeContent}</code></pre>`);
} }
closeList(); closeList();
+15 -1
View File
@@ -18,7 +18,7 @@ from pathlib import Path
from typing import Annotated, Any from typing import Annotated, Any
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from langchain_core.tools import BaseTool, StructuredTool from langchain_core.tools import BaseTool, StructuredTool
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, ValidationError
class _CyFunctionDetectorMeta(type): class _CyFunctionDetectorMeta(type):
@@ -89,6 +89,20 @@ class LogAnalyzerNode:
original_coroutine = tool.coroutine original_coroutine = tool.coroutine
async def cached_coroutine(**kwargs): async def cached_coroutine(**kwargs):
# The injection runner calls the coroutine directly, bypassing
# StructuredTool's normal Pydantic argument coercion. Normalize
# model-supplied numeric strings before caching or searching logs.
if tool.name in ("read_logs", "search_logs") and tool.args_schema:
try:
validated = tool.args_schema.model_validate(kwargs)
except ValidationError as exc:
return ToolFailure(f"Invalid {tool.name} arguments: {exc}")
kwargs.update(validated.model_dump(exclude_unset=True))
if kwargs.get("lines") is None:
kwargs["lines"] = 200 if tool.name == "read_logs" else 10000
if tool.name == "search_logs" and kwargs.get("context_lines") is None:
kwargs["context_lines"] = 0
if tool.name == "read_logs": if tool.name == "read_logs":
# Intercept read_logs to cache raw logs # Intercept read_logs to cache raw logs
lines = kwargs.get("lines", 200) lines = kwargs.get("lines", 200)
+58 -2
View File
@@ -13,6 +13,7 @@
# limitations under the License. # limitations under the License.
import json import json
from collections.abc import Awaitable
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
@@ -32,6 +33,7 @@ from artemis.graph.state import State
from artemis.graph.visibility import strict_state from artemis.graph.visibility import strict_state
from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool
from artemis.llm.usage import usage_from_message from artemis.llm.usage import usage_from_message
from artemis.runtime.task_control import TaskControl
from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message
from artemis.tools.command_tool import ( from artemis.tools.command_tool import (
analyze_task_output_wrapper, analyze_task_output_wrapper,
@@ -78,6 +80,8 @@ DEFERRING_TOOLS = {
"ask_explorer", "ask_explorer",
} }
INTERRUPTIBLE_HELPERS = {"ask_diagnoser", "ask_explorer", "video_analyzer", "analyze_task_output"}
# Bare key names press_key accepts (case-insensitive, optional KEYCODE_ prefix). # Bare key names press_key accepts (case-insensitive, optional KEYCODE_ prefix).
SUPPORTED_PRESS_KEYS = ("ENTER", "BACK", "HOME", "APP_SWITCH") SUPPORTED_PRESS_KEYS = ("ENTER", "BACK", "HOME", "APP_SWITCH")
@@ -478,6 +482,16 @@ class OperatorNode:
except Exception as e: except Exception as e:
logger.error(f"Failed to stage transcript turn: {e}") logger.error(f"Failed to stage transcript turn: {e}")
async def _await_or_pause[T](self, operation: Awaitable[T]) -> tuple[bool, T | None]:
control = getattr(self.ctx, "task_control", None)
if isinstance(control, TaskControl):
return await control.run_interruptibly(
operation,
on_pause=getattr(self.ctx, "on_task_pause", None),
on_resume=getattr(self.ctx, "on_task_resume", None),
)
return False, await operation
async def _invoke_llm_loop( async def _invoke_llm_loop(
self, self,
base_llm, base_llm,
@@ -496,6 +510,13 @@ class OperatorNode:
no_progress_limit = settings.ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS no_progress_limit = settings.ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS
for iteration in range(max_iterations): for iteration in range(max_iterations):
control = getattr(self.ctx, "task_control", None)
if isinstance(control, TaskControl) and await control.checkpoint(
on_pause=getattr(self.ctx, "on_task_pause", None),
on_resume=getattr(self.ctx, "on_task_resume", None),
):
# The device may have changed while another task held its lease.
return None, None, None, False
if iteration == max_iterations - 1: if iteration == max_iterations - 1:
logger.warning( logger.warning(
"Operator has reached the maximum number of tool calls. Adding reminder." "Operator has reached the maximum number of tool calls. Adding reminder."
@@ -512,7 +533,12 @@ class OperatorNode:
) )
bound_llm = base_llm.bind_tools(tools=traced_tools) bound_llm = base_llm.bind_tools(tools=traced_tools)
response = await invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages)) interrupted, response = await self._await_or_pause(
invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages))
)
if interrupted:
return None, None, None, False
assert response is not None
usage = usage_from_message(response) usage = usage_from_message(response)
# The ledger's compaction thresholds read the operator's own # The ledger's compaction thresholds read the operator's own
@@ -655,12 +681,42 @@ class OperatorNode:
"\n".join(native_thoughts) if native_thoughts else None "\n".join(native_thoughts) if native_thoughts else None
) )
result_obj = await invoke_tool_with_injection( tool_call = invoke_tool_with_injection(
tool=tool_to_run, tool=tool_to_run,
args=args, args=args,
tool_call_id=tc["id"], tool_call_id=tc["id"],
state=state, state=state,
) )
if normalize_name(tool_name) in INTERRUPTIBLE_HELPERS:
interrupted, result_obj = await self._await_or_pause(tool_call)
if interrupted:
span.result = "任务暂停;诊断已中断,恢复后重新观察屏幕"
results.append((span.result, "error"))
for remaining in other_calls[len(results) :]:
results.append(("任务暂停,未执行该工具", "error"))
for completed, (content, status) in zip(
other_calls, results
):
current_messages.extend(
tool_result_messages(
completed["id"],
content,
name=normalize_name(completed["name"]),
status=status,
llm=base_llm,
)
)
for action in action_calls:
current_messages.append(
ToolMessage(
tool_call_id=action["id"],
content="任务暂停,未执行屏幕操作",
status="error",
)
)
return None, None, None, False
else:
result_obj = await tool_call
content = get_tool_result_content(result_obj) content = get_tool_result_content(result_obj)
# Failure is structural (a ToolMessage status or a # Failure is structural (a ToolMessage status or a
+57 -11
View File
@@ -1,6 +1,7 @@
"""Cooperative, session-scoped task guidance and device lease parking.""" """Cooperative, session-scoped task guidance and device lease parking."""
import asyncio import asyncio
from collections.abc import Awaitable
import json import json
import logging import logging
import os import os
@@ -48,22 +49,39 @@ def request_resume(session_id: str) -> None:
def add_instruction( def add_instruction(
session_id: str, instruction: str, *, release_loop: bool = False, source: str = "user" session_id: str, instruction: str, *, release_loop: bool = False, source: str = "user"
) -> None: ) -> dict:
if not instruction.strip() or len(instruction) > 10000: if not instruction.strip() or len(instruction) > 10000:
raise ValueError("指导内容必须在 1–10000 字之间") raise ValueError("指导内容必须在 1–10000 字之间")
directory = control_dir(session_id) / "instructions" directory = control_dir(session_id) / "instructions"
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
# Unique names make simultaneous web and MCP submissions additive, never last-write-wins. # Unique names make simultaneous web and MCP submissions additive, never last-write-wins.
path = directory / f"{time.time_ns():020d}-{uuid.uuid4().hex}.json" path = directory / f"{time.time_ns():020d}-{uuid.uuid4().hex}.json"
_write_atomic( record = {
path, "id": path.stem,
{ "instruction": instruction.strip(),
"instruction": instruction.strip(), "release_loop": bool(release_loop),
"release_loop": bool(release_loop), "source": source,
"source": source, "timestamp": time.time(),
"timestamp": time.time(), }
}, _write_atomic(path, record)
) return record
def list_instructions(session_id: str) -> list[dict]:
"""Show submissions separately from guidance the runner actually consumed."""
directory = control_dir(session_id) / "instructions"
records = []
for folder, status in ((directory, "pending"), (directory / "consumed", "consumed")):
for path in folder.glob("*.json") if folder.exists() else []:
try:
record = json.loads(path.read_text(encoding="utf-8"))
if isinstance(record, dict) and record.get("instruction"):
records.append(
{**record, "id": record.get("id") or path.stem, "status": status}
)
except (OSError, ValueError):
continue
return sorted(records, key=lambda record: (record.get("timestamp", 0), record["id"]))
def consume_instructions(base_dir: str | Path) -> dict | None: def consume_instructions(base_dir: str | Path) -> dict | None:
@@ -73,8 +91,11 @@ def consume_instructions(base_dir: str | Path) -> dict | None:
try: try:
claimed = path.with_suffix(".reading") claimed = path.with_suffix(".reading")
path.rename(claimed) path.rename(claimed)
found.append(json.loads(claimed.read_text(encoding="utf-8"))) record = json.loads(claimed.read_text(encoding="utf-8"))
record["consumed_at"] = time.time()
_write_atomic(directory / "consumed" / path.name, record)
claimed.unlink() claimed.unlink()
found.append(record)
except (OSError, ValueError): except (OSError, ValueError):
continue continue
legacy = Path(base_dir) / "injected_instruction.json" legacy = Path(base_dir) / "injected_instruction.json"
@@ -151,6 +172,31 @@ class TaskControl:
self._state("running") self._state("running")
return True return True
async def run_interruptibly[T](
self, operation: Awaitable[T], *, on_pause=None, on_resume=None
) -> tuple[bool, T | None]:
"""Park a task during a long model/helper call, then discard its stale result.
Only use for operations whose cancellation cannot leave a device action
half executed. The caller must return to fresh perception after resume.
"""
running = asyncio.ensure_future(operation)
pause = self.directory / "pause.request"
try:
while True:
if pause.exists():
running.cancel()
await asyncio.gather(running, return_exceptions=True)
await self.checkpoint(on_pause=on_pause, on_resume=on_resume)
return True, None
if running.done():
return False, running.result()
await asyncio.wait({running}, timeout=0.2)
finally:
if not running.done():
running.cancel()
await asyncio.gather(running, return_exceptions=True)
def release(self) -> None: def release(self) -> None:
self.device_lock.release() self.device_lock.release()
(self.directory / "task_control_state.json").unlink(missing_ok=True) (self.directory / "task_control_state.json").unlink(missing_ok=True)
+3 -3
View File
@@ -22,10 +22,10 @@
| `mobile_workspace` | 设备列表与统一队列状态 | | `mobile_workspace` | 设备列表与统一队列状态 |
| `mobile_config` | 读取或明确保存非敏感模型、功能、报告默认设置 | | `mobile_config` | 读取或明确保存非敏感模型、功能、报告默认设置 |
| `mobile_run_task` | 单任务执行;`model=flash/pro` 表示执行模式 | | `mobile_run_task` | 单任务执行;`model=flash/pro` 表示执行模式 |
| `mobile_manage_task` | 单任务状态、安全暂停、排队恢复、下一步指导和停止 | | `mobile_manage_task` | 单任务状态、安全暂停、排队恢复、指导提交/领取记录和停止 |
| `mobile_get_device_state` | 设备截图、界面层级 | | `mobile_get_device_state` | 设备截图、界面层级 |
| `mobile_task_file` | XLSX 模板与导入校验 | | `mobile_task_file` | XLSX 模板与导入校验 |
| `mobile_batch` | JSON 用例建批次、开始、暂停、继续、复制失败项 | | `mobile_batch` | JSON 用例建批次、开始、暂停、继续、复制失败项、删除非活跃批次 |
| `mobile_search_history` | 全库搜索、日期/状态筛选和分页 | | `mobile_search_history` | 全库搜索、日期/状态筛选和分页 |
| `mobile_inspect_trace` | 只读步骤、截图和执行证据 | | `mobile_inspect_trace` | 只读步骤、截图和执行证据 |
| `mobile_export_report` | Word、Excel、步骤截图、录屏片段、单任务资料及批次 ZIP,返回本地绝对路径 | | `mobile_export_report` | Word、Excel、步骤截图、录屏片段、单任务资料及批次 ZIP,返回本地绝对路径 |
@@ -42,6 +42,6 @@ MCP 自动刷新只处理已存在且指向当前项目的标准 Python 模块
报告导出到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。 报告导出到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。
模型默认使用 `mimo-v2.6-flash`,也预设 `gpt-5.6-luna` 并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,每次等待 10 秒,SDK 不叠加重试。耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停任务释放设备,AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复,或用 `resume` 单独恢复。 模型默认使用 `mimo-v2.6-flash`,也预设 `gpt-5.6-luna` 并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,每次等待 10 秒,SDK 不叠加重试。耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停先返回 `pausing`,确认状态为 `task_paused` 才表示设备已释放;Pro 等待模型回复或只读诊断时可及时中断,恢复后重新观察设备。AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复,用 `guidance_history` 核对是否领取,或用 `resume` 单独恢复。
保存全局配置要求用户明确指令;只读配置不包含密钥,MCP 不接收密钥。密钥仍在网页或 `.env` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。 保存全局配置要求用户明确指令;只读配置不包含密钥,MCP 不接收密钥。密钥仍在网页或 `.env` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。
+24
View File
@@ -0,0 +1,24 @@
# ARTEMIS-Eapil 1.1.0
## 新增与改进
- 批量任务按新版 Excel 模板读取 `Artemis提示词` 和逐项 `任务配置(JSON)`;`前置约束提取` 用于记录元数据,不拼接到提示词。可在网页查看、暂停、恢复或删除非活跃批次。
- 运行中的任务可提交指导;指导入口移至工作区顶部,默认收起。任务流程区区分“已提交、待领取”和“执行端已领取”,避免误把提交当作操作完成。
- 报告页面改进失败原因与 Markdown 排版;Word/Excel 使用更适合中文阅读的样式。可分别下载有序截图、录屏片段或整份任务资料。
- MCP 同步支持 `pausing` 状态、指导领取记录和批次删除;随源码分发的 Skill 说明如何核对实际暂停、指导效果及设备排队恢复。
## 修复
- Pro 的模型等待与只读诊断现在能响应暂停:取消当前等待,释放设备;恢复后重新观察页面,不执行暂停前规划的点击。已开始的设备动作仍须先安全结束。
- 日志分析工具校验并归一化模型给出的数字字符串,避免 `read_logs`、`search_logs` 因参数类型错误反复失败。
## 验证
- 后端默认测试 2162 项通过、6 项跳过、8 项未纳入默认范围;前端 148 项通过,生产构建成功。
- Ruff 格式及静态检查、质量基线、核心类型检查、Skill 校验通过。暂停/恢复与 MCP 状态进行了隔离回归;没有重新启动已取消的真实设备任务。
## 安装与升级
Windows 用户运行 `start.bat`,国内网络使用 `start-cn.bat`。Git 克隆安装在无本地冲突时按启动脚本快进更新;ZIP 安装继续使用解压目录。升级后刷新网页并重新加载 AI 客户端的 MCP 连接,新工具签名及 Skill 文件才会生效。已启动的任务不会热更新。
源码附件不含 `.env`、虚拟环境、依赖目录、编译产物或任务数据,使用 `SHA256SUMS.txt` 校验。执行完成仍不等于测试验证通过,检查实际截图、步骤和验证摘要。
+7 -1
View File
@@ -176,7 +176,9 @@ async def mobile_task_file(
@mcp.tool() @mcp.tool()
async def mobile_batch( async def mobile_batch(
action: Literal["create", "list", "get", "lookup", "start", "pause", "resume", "retry_failed"], action: Literal[
"create", "list", "get", "lookup", "start", "pause", "resume", "retry_failed", "delete"
],
batch_id: str | None = None, batch_id: str | None = None,
request_id: str | None = None, request_id: str | None = None,
batch: dict[str, Any] | None = None, batch: dict[str, Any] | None = None,
@@ -196,6 +198,8 @@ async def mobile_batch(
use mobile_manage_task(stop) to stop that task. resume never repeats finished cases. use mobile_manage_task(stop) to stop that task. resume never repeats finished cases.
retry_failed creates a new draft only after an explicit rerun request; it is not retry_failed creates a new draft only after an explicit rerun request; it is not
transport-idempotent, so after an uncertain response use list instead of retrying. transport-idempotent, so after an uncertain response use list instead of retrying.
delete removes a non-active batch record after an explicit user request; it keeps
already executed task sessions and reports. Confirm the batch ID with get first.
Restarted batches remain paused. No automatic failed-case retries or action replay. Restarted batches remain paused. No automatic failed-case retries or action replay.
""" """
if action == "create": if action == "create":
@@ -210,6 +214,8 @@ async def mobile_batch(
route = f"/api/automation/batches/{_id(batch_id)}" route = f"/api/automation/batches/{_id(batch_id)}"
if action == "get": if action == "get":
return await _request("GET", route) return await _request("GET", route)
if action == "delete":
return await _request("DELETE", route)
if action in {"start", "resume"}: if action in {"start", "resume"}:
current = await _request("GET", route) current = await _request("GET", route)
if current["status"] == "running": if current["status"] == "running":
+28 -6
View File
@@ -37,6 +37,7 @@ from artemis.runtime.process_probe import pid_is_alive
from artemis.runtime.task_control import ( from artemis.runtime.task_control import (
add_instruction, add_instruction,
control_dir, control_dir,
list_instructions,
read_state, read_state,
request_pause, request_pause,
request_resume, request_resume,
@@ -265,7 +266,7 @@ def mobile_manage_task(
can also steer a subagent that is stuck or off-track, or abort a task. can also steer a subagent that is stuck or off-track, or abort a task.
### Actions ### Actions
- **'status'**: Returns `trace_id`, `status` ('running'/'task_paused'/ - **'status'**: Returns `trace_id`, `status` ('running'/'pausing'/'task_paused'/
'resuming'/'completed'/'failed'/'cancelled'/'interrupted'), `device_serial` (which phone owns this task in multi-device 'resuming'/'completed'/'failed'/'cancelled'/'interrupted'), `device_serial` (which phone owns this task in multi-device
setups), `task_desc`, `model`, `elapsed_seconds`, a `test_summary` (when setups), `task_desc`, `model`, `elapsed_seconds`, a `test_summary` (when
the run declared verification check items: machine-readable the run declared verification check items: machine-readable
@@ -274,7 +275,10 @@ def mobile_manage_task(
current turn, latest thought/action — intervene if turns climb without current turn, latest thought/action — intervene if turns climb without
progress or the thought indicates it is stuck; Pro: the active task plan progress or the thought indicates it is stuck; Pro: the active task plan
— check it still aligns with your goal). — check it still aligns with your goal).
- **'pause'**: Requests a pause after the current action, releasing the device. - **'pause'**: Requests a pause, returning `pausing` until the runner releases
the device. Pro can interrupt a pending model response or read-only diagnostic;
a device action already in progress must finish safely first. Check status
for `task_paused` before assuming the device is free.
- **'resume'**: Queues a paused task for its device again; optional `instruction` - **'resume'**: Queues a paused task for its device again; optional `instruction`
provides guidance before the next action. provides guidance before the next action.
- **'inject_instruction'**: Injects real-time guidance mid-flight when the - **'inject_instruction'**: Injects real-time guidance mid-flight when the
@@ -285,6 +289,8 @@ def mobile_manage_task(
`release_loop=True` — this is the only signal that unlocks the `release_loop=True` — this is the only signal that unlocks the
milestone's completion; natural-language "please stop" phrasing in milestone's completion; natural-language "please stop" phrasing in
`instruction` is NOT interpreted as a stop signal. `instruction` is NOT interpreted as a stop signal.
- **'guidance_history'**: Lists submitted instructions with `pending` or
`consumed` status; a submitted instruction is not proof it was applied.
- **'stop'**: Forcefully terminates the subagent, immediately halting - **'stop'**: Forcefully terminates the subagent, immediately halting
device interactions and releasing the device. Use when the task is done, device interactions and releasing the device. Use when the task is done,
irreparably broken, or running out of control. Prefer irreparably broken, or running out of control. Prefer
@@ -292,7 +298,8 @@ def mobile_manage_task(
should wind down cleanly instead of being killed. should wind down cleanly instead of being killed.
Args: Args:
action: `"status"`, `"pause"`, `"resume"`, `"inject_instruction"`, or `"stop"`. action: `"status"`, `"pause"`, `"resume"`, `"inject_instruction"`,
`"guidance_history"`, or `"stop"`.
trace_id: The task's session identifier from `mobile_run_task` or the web UI. trace_id: The task's session identifier from `mobile_run_task` or the web UI.
instruction: Guidance string; required for `inject_instruction` instruction: Guidance string; required for `inject_instruction`
(optional when `release_loop=True`), omit otherwise. (optional when `release_loop=True`), omit otherwise.
@@ -312,6 +319,12 @@ def mobile_manage_task(
control_status = read_state(trace_id).get("status") if is_alive else None control_status = read_state(trace_id).get("status") if is_alive else None
if control_status in ("task_paused", "resuming") and current_status in ("running", "pending"): if control_status in ("task_paused", "resuming") and current_status in ("running", "pending"):
current_status = control_status current_status = control_status
elif (
is_alive
and current_status in ("running", "pending")
and (control_dir(trace_id) / "pause.request").exists()
):
current_status = "pausing"
trace_dir = trace_store.get_trace_dir(trace_id) trace_dir = trace_store.get_trace_dir(trace_id)
@@ -524,7 +537,16 @@ def mobile_manage_task(
return response return response
elif action == "guidance_history":
return {"trace_id": trace_id, "instructions": list_instructions(trace_id)}
elif action == "pause": elif action == "pause":
if current_status == "pausing":
return {
"trace_id": trace_id,
"status": "pausing",
"message": "Pause is still pending; check status for task_paused.",
}
if current_status not in ("running", "pending", "resuming") or not is_alive: if current_status not in ("running", "pending", "resuming") or not is_alive:
return { return {
"trace_id": trace_id, "trace_id": trace_id,
@@ -535,7 +557,7 @@ def mobile_manage_task(
return { return {
"trace_id": trace_id, "trace_id": trace_id,
"status": "pausing", "status": "pausing",
"message": "Pauses after the current action and releases the device.", "message": "Pause requested; wait for task_paused and device release.",
} }
elif action == "resume": elif action == "resume":
@@ -555,7 +577,7 @@ def mobile_manage_task(
} }
elif action == "stop": elif action == "stop":
if current_status not in ("running", "pending", "task_paused", "resuming"): if current_status not in ("running", "pending", "pausing", "task_paused", "resuming"):
return { return {
"trace_id": trace_id, "trace_id": trace_id,
"status": current_status, "status": current_status,
@@ -650,7 +672,7 @@ def mobile_manage_task(
"message": "Missing required argument 'instruction' for action 'inject_instruction'.", "message": "Missing required argument 'instruction' for action 'inject_instruction'.",
} }
if current_status not in ("running", "pending", "task_paused", "resuming"): if current_status not in ("running", "pending", "pausing", "task_paused", "resuming"):
return { return {
"trace_id": trace_id, "trace_id": trace_id,
"status": current_status, "status": current_status,
+1 -1
View File
@@ -14,7 +14,7 @@
[project] [project]
name = "artemis" name = "artemis"
version = "1.0.1" version = "1.1.0"
description = "Autonomous AI Assistant for Mobile Automation" description = "Autonomous AI Assistant for Mobile Automation"
authors = [ authors = [
+2 -2
View File
@@ -14,7 +14,7 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
3. 首次使用、工具出错、任务拒绝或设备异常,先 `mobile_diagnose`。按 `next_steps` 的依赖顺序处理;`attempt_fix=true` 可尝试工具自带修复。只有凭据相关故障才使用 `verify_credentials=true`,观察异常才使用 `probe_device=true`。不要盲目重发任务。MCP 不可用时在项目目录运行 `uv run artemis doctor` 并检查宿主 MCP 日志。 3. 首次使用、工具出错、任务拒绝或设备异常,先 `mobile_diagnose`。按 `next_steps` 的依赖顺序处理;`attempt_fix=true` 可尝试工具自带修复。只有凭据相关故障才使用 `verify_credentials=true`,观察异常才使用 `probe_device=true`。不要盲目重发任务。MCP 不可用时在项目目录运行 `uv run artemis doctor` 并检查宿主 MCP 日志。
4. 选择执行模式:Flash 适合路径明确的界面操作;Pro 适合动态分支、持续计划、ADB/日志诊断或需要 Checker 明确校验的任务。**`mobile_run_task.model` 的 `flash`/`pro` 是执行模式,不是 API 模型名称**。步数长不自动等于需要 Pro;视频能力只有显式启用后才可用。 4. 选择执行模式:Flash 适合路径明确的界面操作;Pro 适合动态分支、持续计划、ADB/日志诊断或需要 Checker 明确校验的任务。**`mobile_run_task.model` 的 `flash`/`pro` 是执行模式,不是 API 模型名称**。步数长不自动等于需要 Pro;视频能力只有显式启用后才可用。
5. 单任务用 `mobile_run_task`;批量任务用 `mobile_batch`,读 [批量任务与交付](references/batches.md)。AI 可直接提交结构化用例,不必先创建 Excel。用户提供 Excel 时先 `mobile_task_file(action="preview", path=绝对路径)`,修正所有行错误后提交。提示词列原样作为目标,前置约束列只作元数据。 5. 单任务用 `mobile_run_task`;批量任务用 `mobile_batch`,读 [批量任务与交付](references/batches.md)。AI 可直接提交结构化用例,不必先创建 Excel。用户提供 Excel 时先 `mobile_task_file(action="preview", path=绝对路径)`,修正所有行错误后提交。提示词列原样作为目标,前置约束列只作元数据。
6. 单任务返回 `trace_id` 后,用 `mobile_manage_task(action="status", trace_id=...)` 跟踪;网页任务也可用其任务编号。批次用 `mobile_batch(action="get", batch_id=...)`。需要人工补充时用 `pause` 在当前步骤后释放设备;`inject_instruction` 可运行中指导下一步,暂停中提交则恢复。无新指导用 `resume`。恢复进入设备 FIFO 队尾,并重新观察手机状态。尊重工具建议的等待周期,避免连续高频轮询;停滞或失败先诊断,不把同一业务任务自动重提。 6. 单任务返回 `trace_id` 后,用 `mobile_manage_task(action="status", trace_id=...)` 跟踪;网页任务也可用其任务编号。批次用 `mobile_batch(action="get", batch_id=...)`。需要人工补充时用 `pause`,其返回的 `pausing` 只表示请求已接收;轮询到 `task_paused` 才表示设备已释放。Pro 等待模型回复或只读诊断时可中断,已开始的设备动作会先安全结束。`inject_instruction` 可在运行中指导下一步,暂停中提交则恢复;`guidance_history` 的 `pending/consumed` 区分提交和执行端领取,无新指导用 `resume`。恢复进入设备 FIFO 队尾并重新观察手机状态。尊重工具建议的等待周期,避免连续高频轮询;停滞或失败先诊断,不把同一业务任务自动重提。
## 结果、证据与交付 ## 结果、证据与交付
@@ -32,6 +32,6 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。 - API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。
- 录屏与 AI 视频分析分别配置。批量任务的 `task_config` 可覆盖单项,不修改全局设置;只开视频分析时录制内容不作为可下载文件保存。暂停时停止录屏,恢复后按片段继续,不能把其他任务画面混入该任务资料。 - 录屏与 AI 视频分析分别配置。批量任务的 `task_config` 可覆盖单项,不修改全局设置;只开视频分析时录制内容不作为可下载文件保存。暂停时停止录屏,恢复后按片段继续,不能把其他任务画面混入该任务资料。
- 请求重试是模型调用恢复,不是测试用例重跑。批次默认失败继续、不自动重跑;服务重启后保留状态并暂停,用户明确要求后才继续或复制失败项。 - 请求重试是模型调用恢复,不是测试用例重跑。批次默认失败继续、不自动重跑;服务重启后保留状态并暂停,用户明确要求后才继续或复制失败项。
- 批次暂停只停止后续派发;当前任务等待指导时用 `mobile_manage_task(action="pause", trace_id=...)`,真正取消用 `stop`。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。 - 批次暂停只停止后续派发;当前任务等待指导时用 `mobile_manage_task(action="pause", trace_id=...)`,真正取消用 `stop`。用户明确要求删除批次记录时先 `mobile_batch(action="get")` 核对编号,再用 `delete`;执行中/排队中的批次不能删,已执行的任务历史和报告会保留。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。
需要编写外部可执行测试代码时,先通过 ARTEMIS 实际探索目标应用,确认页面、定位器和转换。优先动态定位,框架支持时才以已验证坐标兜底;在最终脚本中明确等待条件,不将 AI 思考耗时当作确定性等待。 需要编写外部可执行测试代码时,先通过 ARTEMIS 实际探索目标应用,确认页面、定位器和转换。优先动态定位,框架支持时才以已验证坐标兜底;在最终脚本中明确等待条件,不将 AI 思考耗时当作确定性等待。
@@ -53,9 +53,10 @@
## 状态与异常 ## 状态与异常
- 批次 `draft` 等待开始;`running` 调度中;`paused` 等待处理;`completed` 表示所有项进入终态,不代表全部通过。 - 批次 `draft` 等待开始;`running` 调度中;`paused` 等待处理;`completed` 表示所有项进入终态,不代表全部通过。
- 单项 `pending/queued/running/task_paused/resuming/finalizing` 尚未结束,`completed/failed/cancelled/interrupted/skipped` 是终态。`finalizing` 在生成报告,不能提前宣称报告已完成。 - 单项 `pending/queued/running/pausing/task_paused/resuming/finalizing` 尚未结束,`completed/failed/cancelled/interrupted/skipped` 是终态。`pausing` 是请求已发出而设备仍被占用,`task_paused` 才表示设备已释放;`finalizing` 在生成报告,不能提前宣称报告已完成。
- 批次 `pause` 保留当前执行,只暂停后续派发;单项 `mobile_manage_task(pause)` 在步骤后释放设备,指导后排队恢复,不阻塞后续项。设备断开、模型持续失败或任务达到批次时限会暂停后续派发;用诊断确认恢复后再响应用户的继续指令。 - 批次 `pause` 保留当前执行,只暂停后续派发;单项 `mobile_manage_task(pause)` 会在安全边界释放设备,Pro 的模型等待与只读诊断可中断。恢复后重新观察画面,并排到设备队尾,不阻塞后续项。`mobile_manage_task(guidance_history)` 可核对指导是否从 `pending` 变为 `consumed`,但仍需检查后续操作是否符合指导。设备断开、模型持续失败或任务达到批次时限会暂停后续派发;用诊断确认恢复后再响应用户的继续指令。
- `resume` 只继续未开始项;`retry_failed` 复制失败、取消和中断项为新草稿,不修改原结果。必须来自用户的明确重跑请求。该动作不提供创建去重,响应不确定时先 `list` 查找新批次,不自动重复调用。 - `resume` 只继续未开始项;`retry_failed` 复制失败、取消和中断项为新草稿,不修改原结果。必须来自用户的明确重跑请求。该动作不提供创建去重,响应不确定时先 `list` 查找新批次,不自动重复调用。
- 用户明确要求删除批次记录时,先 `get` 核对批次编号与状态,再 `delete`;正在执行、排队或等待指导的批次不能删除,已经运行过的任务历史及报告不会随批次记录一起消失。
## 报告 ## 报告
@@ -6,6 +6,7 @@ import time
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import zipfile import zipfile
from docx import Document
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from openpyxl import load_workbook from openpyxl import load_workbook
@@ -124,6 +125,39 @@ def test_preferences_and_required_device(client):
assert client.post("/api/automation/batches", json=payload(device_serial="")).status_code == 422 assert client.post("/api/automation/batches", json=payload(device_serial="")).status_code == 422
def test_batch_delete_retains_executed_task_and_prevents_request_replay(client, isolated):
created = client.post(
"/api/automation/batches", json=payload(request_id="delete-example")
).json()
first, second = isolated.jobs(created["id"])
isolated.save_job({**first, "status": "completed"})
response = client.delete(f"/api/automation/batches/{created['id']}")
assert response.status_code == 200
assert client.get(f"/api/automation/batches/{created['id']}").status_code == 404
assert isolated.job(first["session_id"])["case"]["name"] == "用例甲"
assert isolated.job(second["session_id"]) is None
assert reports.session_report(first["session_id"])["name"] == "用例甲"
assert (
client.post(
"/api/automation/batches", json=payload(request_id="delete-example")
).status_code
== 409
)
@pytest.mark.parametrize("status", ["queued", "running", "task_paused", "resuming", "finalizing"])
def test_batch_delete_rejects_active_jobs(client, isolated, status):
batch = service.create_batch(payload())
first = isolated.jobs(batch["id"])[0]
isolated.save_job({**first, "status": status})
response = client.delete(f"/api/automation/batches/{batch['id']}")
assert response.status_code == 409
assert isolated.get("batch", batch["id"]) is not None
assert len(isolated.jobs(batch["id"])) == 2
def test_restart_never_repeats_started_work(isolated): def test_restart_never_repeats_started_work(isolated):
batch = service.create_batch(payload()) batch = service.create_batch(payload())
service.batch_action(batch["id"], "resume") service.batch_action(batch["id"], "resume")
@@ -243,6 +277,50 @@ def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
assert "未验证".encode() in doc.read("word/document.xml") assert "未验证".encode() in doc.read("word/document.xml")
def test_failed_report_exposes_logged_reason_and_formats_chinese_markdown(isolated):
job = isolated.jobs(service.create_batch(payload())["id"])[0]
isolated.save_job({**job, "status": "failed"})
folder = reports.TRACES_PATH / job["session_id"]
(folder / "notes").mkdir(parents=True)
(folder / "stdout.log").write_text(
"❌ [run] Error running automation: 模型连续 3 次未提供可执行的工具调用\n",
encoding="utf-8",
)
(folder / "notes" / "output.md").write_text(
"## 结果\n- **网络**正常\n\n| 分类 | 状态 |\n| --- | --- |\n| 系统 | 未确认 |",
encoding="utf-8",
)
report = reports.session_report(job["session_id"])
assert "连续 3 次" in report["error"]
assert report["verdict"] == "未验证(任务异常终止)"
assert "abc123secret" not in reports.failure_reason(
folder, "failed", "认证失败 OPENAI_API_KEY=abc123secret Authorization: Bearer abc123secret"
)
paths = reports.export_session(job["session_id"])
doc = Document(next(path for path in paths if path.suffix == ".docx"))
assert doc.styles["Normal"].font.name == "Microsoft YaHei"
assert any("异常原因" in paragraph.text for paragraph in doc.paragraphs)
assert any(
run.bold and "网络" in run.text for paragraph in doc.paragraphs for run in paragraph.runs
)
assert any(
cell.text == "分类" for table in doc.tables for row in table.rows for cell in row.cells
)
wb = load_workbook(next(path for path in paths if path.suffix == ".xlsx"))
assert "连续 3 次" in wb["任务结果"]["B15"].value
assert wb["结论"]["B2"].value == "结果"
assert wb["结果表1"]["A2"].value == "系统"
assert wb["执行证据"]["A1"].font.name == "微软雅黑"
assert wb["执行证据"].page_setup.orientation == "landscape"
assert reports.execution_result_label({"status": "dispatched"}) == "动作已发出(需核对截图)"
assert reports.conclusion_rows("## 结果\n- **通过**检查\n```txt\n| 不是 | 表格 |\n```") == [
("类型", "结论与发现"),
("小节", "结果"),
("要点", "通过检查"),
("代码", "| 不是 | 表格 |"),
]
def test_task_material_zip_contains_ordered_evidence_and_reports( def test_task_material_zip_contains_ordered_evidence_and_reports(
client, isolated, tmp_path, monkeypatch client, isolated, tmp_path, monkeypatch
): ):
+42
View File
@@ -24,6 +24,8 @@ import pytest
from artemis.agents.log_analyzer.log_analyzer import LogAnalyzerNode from artemis.agents.log_analyzer.log_analyzer import LogAnalyzerNode
from artemis.context import ArtemisContext from artemis.context import ArtemisContext
from artemis.core.tool_failure import ToolFailure from artemis.core.tool_failure import ToolFailure
from artemis.tools.mobile.read_logs import ReadLogsArgs
from artemis.tools.mobile.search_logs import SearchLogsArgs
_STATUS_CASES = [ _STATUS_CASES = [
pytest.param(ToolFailure("Error searching logs: device gone"), "error", id="tool_failure"), pytest.param(ToolFailure("Error searching logs: device gone"), "error", id="tool_failure"),
@@ -76,3 +78,43 @@ async def test_agent_loop_tool_message_status_is_structural(result, expected_sta
assert tool_msgs[0].tool_call_id == "c1" assert tool_msgs[0].tool_call_id == "c1"
assert tool_msgs[0].status == expected_status assert tool_msgs[0].status == expected_status
assert tool_msgs[0].content == str(result) assert tool_msgs[0].content == str(result)
@pytest.mark.asyncio
async def test_log_cache_normalizes_numeric_tool_arguments_before_running_tools():
node = LogAnalyzerNode(ctx=MagicMock(spec=ArtemisContext))
read_calls = []
async def read_raw_logs(lines=200, **kwargs):
read_calls.append(lines)
return "before\nE/Settings: failed\nafter"
async def unused_search(**kwargs):
pytest.fail("Cached raw logs should be searched locally")
read_tool = node._wrap_with_caching(
StructuredTool.from_function(
coroutine=read_raw_logs, name="read_logs", description="read", args_schema=ReadLogsArgs
)
)
search_tool = node._wrap_with_caching(
StructuredTool.from_function(
coroutine=unused_search,
name="search_logs",
description="search",
args_schema=SearchLogsArgs,
)
)
assert await read_tool.coroutine(lines="200") == "before\nE/Settings: failed\nafter"
assert await read_tool.coroutine(lines=200) == "before\nE/Settings: failed\nafter"
assert await read_tool.coroutine(lines=None) == "before\nE/Settings: failed\nafter"
assert read_calls == [200]
result = await search_tool.coroutine(keyword="Settings", lines="200", context_lines="1")
assert "before" in result and "after" in result
assert "E/Settings: failed" in result
assert "before" not in await search_tool.coroutine(
keyword="Settings", lines="200", context_lines=None
)
assert isinstance(await search_tool.coroutine(keyword="Settings", lines="many"), ToolFailure)
+68 -1
View File
@@ -1,5 +1,6 @@
"""Regression coverage for quoted click targets and prose-only recovery loops.""" """Regression coverage for operator recovery and task pause handling."""
import asyncio
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
@@ -8,6 +9,7 @@ import pytest
from artemis.agents.operator.operator import OperatorNode, OperatorNoProgressError from artemis.agents.operator.operator import OperatorNode, OperatorNoProgressError
from artemis.config.settings import settings from artemis.config.settings import settings
from artemis.runtime import task_control
@pytest.fixture @pytest.fixture
@@ -135,3 +137,68 @@ async def test_iteration_cap_cannot_restart_no_progress_next_turn(operator, monk
with pytest.raises(OperatorNoProgressError, match="预算内未恢复"): with pytest.raises(OperatorNoProgressError, match="预算内未恢复"):
await node._invoke_llm_loop(Mock(), [], [], [], state) await node._invoke_llm_loop(Mock(), [], [], [], state)
assert invoke.await_count == 2 assert invoke.await_count == 2
@pytest.mark.asyncio
async def test_pause_during_diagnosis_returns_to_fresh_observation(operator, monkeypatch, tmp_path):
node, state = operator
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
started = asyncio.Event()
cancelled = asyncio.Event()
events = []
class Lock:
def __init__(self, device_id, **kwargs):
self.device_id = device_id
self.description = "test"
self.max_concurrency = None
self.concurrency_mode = "per_device"
self.ingress = "web"
self.lock_scope = "test"
def release(self):
events.append("released")
def acquire(self, cancel_event=None):
events.append("acquired")
monkeypatch.setattr(task_control, "DeviceExecutionLock", Lock)
node.ctx.data_engine = None
node.ctx.task_control = task_control.TaskControl("pro-task", Lock("device"))
node.ctx.on_task_pause = AsyncMock()
node.ctx.on_task_resume = AsyncMock()
async def diagnose(**kwargs):
started.set()
try:
await asyncio.Event().wait()
finally:
cancelled.set()
monkeypatch.setattr("artemis.agents.operator.operator.invoke_tool_with_injection", diagnose)
response = AIMessage(
content="Inspect the failure",
tool_calls=[
{"name": "ask_diagnoser", "args": {"query": "why"}, "id": "diagnosis"},
{"name": "click", "args": {"target": 13}, "id": "tap"},
],
)
invoke = AsyncMock(return_value=response)
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
tool = SimpleNamespace(name="ask_diagnoser")
messages = []
execution = asyncio.create_task(node._invoke_llm_loop(Mock(), messages, [tool], [], state))
await asyncio.wait_for(started.wait(), 1)
task_control.request_pause("pro-task")
for _ in range(40):
if task_control.read_state("pro-task").get("status") == "task_paused":
break
await asyncio.sleep(0.01)
assert task_control.read_state("pro-task")["status"] == "task_paused"
assert cancelled.is_set()
assert events == ["released"]
task_control.request_resume("pro-task")
assert (await asyncio.wait_for(execution, 2))[0] is None
assert events == ["released", "acquired"]
assert invoke.await_count == 1
assert [m.status for m in messages if isinstance(m, ToolMessage)] == ["error", "error"]
+9
View File
@@ -60,6 +60,15 @@ async def test_running_batch_start_is_idempotent(monkeypatch):
assert request.call_args.args[0] == "GET" assert request.call_args.args[0] == "GET"
@pytest.mark.asyncio
async def test_delete_batch_uses_the_same_backend_as_the_web_ui(monkeypatch):
request = AsyncMock(return_value={"batch_id": "batch", "status": "deleted"})
monkeypatch.setattr(tools, "_request", request)
assert (await tools.mobile_batch("delete", batch_id="batch"))["status"] == "deleted"
request.assert_awaited_once_with("DELETE", "/api/automation/batches/batch")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_writes_actual_binary_atomically(monkeypatch, tmp_path): async def test_download_writes_actual_binary_atomically(monkeypatch, tmp_path):
monkeypatch.setattr(tools, "EXPORT_DIR", tmp_path) monkeypatch.setattr(tools, "EXPORT_DIR", tmp_path)
+5 -3
View File
@@ -299,11 +299,10 @@ def test_web_task_can_be_paused_and_guided_from_mcp(temp_trace_env, monkeypatch)
(trace_id, '{"device_id":"test-device"}'), (trace_id, '{"device_id":"test-device"}'),
) )
assert ( assert mobile_manage_task(action="status", trace_id=trace_id)["device_serial"] == "test-device"
mobile_manage_task(action="status", trace_id=trace_id)["device_serial"] == "test-device"
)
assert mobile_manage_task(action="pause", trace_id=trace_id)["status"] == "pausing" assert mobile_manage_task(action="pause", trace_id=trace_id)["status"] == "pausing"
assert (Path(temp_trace_env) / trace_id / "pause.request").exists() assert (Path(temp_trace_env) / trace_id / "pause.request").exists()
assert mobile_manage_task(action="status", trace_id=trace_id)["status"] == "pausing"
control._write_atomic( control._write_atomic(
control.control_dir(trace_id) / "task_control_state.json", {"status": "task_paused"} control.control_dir(trace_id) / "task_control_state.json", {"status": "task_paused"}
) )
@@ -316,6 +315,9 @@ def test_web_task_can_be_paused_and_guided_from_mcp(temp_trace_env, monkeypatch)
control.consume_instructions(control.control_dir(trace_id))["instruction"] control.consume_instructions(control.control_dir(trace_id))["instruction"]
== "先确认当前画面" == "先确认当前画面"
) )
guidance = mobile_manage_task(action="guidance_history", trace_id=trace_id)
assert guidance["instructions"][0]["status"] == "consumed"
assert guidance["instructions"][0]["instruction"] == "先确认当前画面"
def test_mobile_manage_task_status_and_stop(temp_trace_env): def test_mobile_manage_task_status_and_stop(temp_trace_env):
+98
View File
@@ -52,3 +52,101 @@ async def test_pause_releases_device_and_resume_waits_for_new_acquisition(tmp_pa
async def _event(name, events): async def _event(name, events):
events.append(name) events.append(name)
@pytest.mark.asyncio
async def test_pause_interrupts_long_helper_and_discards_stale_result(tmp_path, monkeypatch):
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
events = []
started = asyncio.Event()
class Lock:
def __init__(self, device_id, **kwargs):
self.device_id = device_id
self.description = "test"
self.max_concurrency = None
self.concurrency_mode = "per_device"
self.ingress = "web"
self.lock_scope = "test"
def release(self):
events.append("released")
def acquire(self, cancel_event=None):
events.append("acquired")
monkeypatch.setattr(task_control, "DeviceExecutionLock", Lock)
control = task_control.TaskControl("task-123", Lock("device"))
async def diagnostic():
started.set()
try:
await asyncio.Event().wait()
finally:
events.append("diagnostic_stopped")
result = asyncio.create_task(
control.run_interruptibly(
diagnostic(),
on_pause=lambda: _event("capture_stopped", events),
on_resume=lambda: _event("capture_started", events),
)
)
await asyncio.wait_for(started.wait(), 1)
task_control.request_pause("task-123")
for _ in range(30):
if task_control.read_state("task-123").get("status") == "task_paused":
break
await asyncio.sleep(0.01)
assert task_control.read_state("task-123")["status"] == "task_paused"
assert events == ["diagnostic_stopped", "capture_stopped", "released"]
task_control.request_resume("task-123")
assert await asyncio.wait_for(result, 2) == (True, None)
assert events[-2:] == ["acquired", "capture_started"]
@pytest.mark.asyncio
async def test_interruptible_call_returns_normal_result_without_pause(tmp_path, monkeypatch):
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
control = task_control.TaskControl("task-123", object())
async def model_call():
return "current result"
assert await control.run_interruptibly(model_call()) == (False, "current result")
def test_guidance_history_distinguishes_submission_from_runner_consumption(tmp_path, monkeypatch):
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
first = task_control.add_instruction("task-123", "从快捷设置进入", source="web")
second = task_control.add_instruction("task-123", "不要更改设置", source="mcp")
assert [item["status"] for item in task_control.list_instructions("task-123")] == [
"pending",
"pending",
]
consumed = task_control.consume_instructions(task_control.control_dir("task-123"))
assert consumed["instruction"] == "从快捷设置进入\n\n不要更改设置"
assert task_control.consume_instructions(task_control.control_dir("task-123")) is None
history = task_control.list_instructions("task-123")
assert [item["id"] for item in history] == [first["id"], second["id"]]
assert all(item["status"] == "consumed" and item["consumed_at"] for item in history)
def test_guidance_history_endpoint_returns_pending_and_consumed(tmp_path, monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from apps.admin_console.routers.tasks import router
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
task_control.add_instruction("task-123", "从快捷设置进入")
app = FastAPI()
app.include_router(router)
client = TestClient(app)
response = client.get("/api/tasks/task-123/guidance")
assert response.status_code == 200
assert response.json()[0]["status"] == "pending"
task_control.consume_instructions(task_control.control_dir("task-123"))
response = client.get("/api/tasks/task-123/guidance")
assert response.json()[0]["status"] == "consumed"
Generated
+1 -1
View File
@@ -206,7 +206,7 @@ wheels = [
[[package]] [[package]]
name = "artemis" name = "artemis"
version = "1.0.1" version = "1.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "adbutils" }, { name = "adbutils" },