diff --git a/apps/admin_console/routers/automation.py b/apps/admin_console/routers/automation.py index cb5d5e2..ddadd12 100644 --- a/apps/admin_console/routers/automation.py +++ b/apps/admin_console/routers/automation.py @@ -121,6 +121,20 @@ def batch_detail(batch_id: str): 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}") def batch_action(batch_id: str, action: Literal["pause", "resume", "retry_failed"]): require("batch", batch_id) diff --git a/apps/admin_console/routers/tasks.py b/apps/admin_console/routers/tasks.py index 25e8ce1..58eea87 100644 --- a/apps/admin_console/routers/tasks.py +++ b/apps/admin_console/routers/tasks.py @@ -24,6 +24,7 @@ from artemis.runtime import DeviceExecutionLock, device_pool from artemis.runtime.task_control import ( add_instruction, control_dir, + list_instructions, read_state, request_pause, request_resume, @@ -74,13 +75,24 @@ def pause_running_task(session_id: str): @router.post("/api/tasks/{session_id}/guide") def guide_running_task(session_id: str, value: GuidanceRequest): _live_task(session_id) - add_instruction(session_id, value.instruction, source="web") + guidance = add_instruction(session_id, value.instruction, source="web") if ( read_state(session_id).get("status") == "task_paused" or (control_dir(session_id) / "pause.request").exists() ): 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") diff --git a/apps/admin_console/services/automation_store.py b/apps/admin_console/services/automation_store.py index a2264b4..bc70463 100644 --- a/apps/admin_console/services/automation_store.py +++ b/apps/admin_console/services/automation_store.py @@ -164,6 +164,36 @@ class AutomationStore: conn.commit() 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): with self.connection() as conn: if session_id: diff --git a/apps/admin_console/services/report_export.py b/apps/admin_console/services/report_export.py index 18914db..0c7496e 100644 --- a/apps/admin_console/services/report_export.py +++ b/apps/admin_console/services/report_export.py @@ -2,7 +2,6 @@ from datetime import datetime from typing import cast -from docx.styles.style import ParagraphStyle import json from pathlib import Path import re @@ -10,10 +9,14 @@ import threading import zipfile 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.shared import Cm, Pt, RGBColor +from docx.styles.style import ParagraphStyle 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 apps.admin_console.database.repositories.session_repository import SessionRepository @@ -60,6 +63,42 @@ def verdict(outcome: dict | None) -> str: 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: identifier(session_id) meta = store.job(session_id) or {} @@ -89,24 +128,31 @@ def session_report(session_id: str, db_path=None) -> dict: if not outcome: outcome = {"task_status": args.get("status"), "tests": {}} break - if not content: - content = "本任务没有保存最终结论。以下内容仅来自已记录的执行状态和证据。" 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 { "id": session_id, "name": case.get("name") or session.get("initial_goal", session_id)[:120], "goal": case.get("goal") or session.get("initial_goal", ""), "expected": case.get("expected", ""), - "status": meta.get("terminal_status") - or meta.get("status") - or session.get("status", "unknown"), - "verdict": verdict(outcome), + "status": status, + "verdict": verification, "outcome": outcome, "content": content, "steps": steps, "start": session.get("start_time") or meta.get("started_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 ""), "tags": case.get("tags", ""), "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: return ( - datetime.fromtimestamp(float(value)).astimezone().isoformat(timespec="seconds") + datetime.fromtimestamp(float(value)).astimezone().strftime("%Y-%m-%d %H:%M:%S") if value else "—" ) @@ -132,7 +178,7 @@ def metadata(report: dict) -> list[tuple]: ("检测项目", report.get("category") or "—"), ("测试子项", report.get("subcategory") or "—"), ("前置约束提取", report.get("preconditions") or "—"), - ("任务配置", text(report.get("task_config") or {})), + ("任务配置", config_text(report.get("task_config"))), ("任务目标", report["goal"]), ("预期结果", report["expected"] or "未指定"), ("执行状态", 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: if not name: return None @@ -161,10 +217,14 @@ def image_path(name: str | None) -> Path | None: def markdown_blocks(content: str): lines = content.splitlines() index = 0 + in_code = False while index < len(lines): line = lines[index] + if line.strip().startswith("```"): + in_code = not in_code if ( - "|" in line + not in_code + and "|" in line and index + 1 < len(lines) and re.fullmatch(r"[\s|:\-]+", lines[index + 1]) and "-" in lines[index + 1] @@ -180,81 +240,296 @@ def markdown_blocks(content: str): index += 1 -def word_report(report: dict, path: Path): - doc = Document() - style = cast(ParagraphStyle, doc.styles["Normal"]) - style.font.name = "Microsoft YaHei" - style.font.size = Pt(10) - style.element.rPr.rFonts.set(qn("w:eastAsia"), "微软雅黑") - doc.add_heading(report["name"], 0) - table = doc.add_table(rows=0, cols=2) - table.columns[0].width = Inches(1.3) - table.columns[1].width = Inches(5.2) - table.style = "Light Shading Accent 1" - for label, value in metadata(report): +def word_styles(doc, *, landscape: bool = False): + section = doc.sections[0] + section.page_width, section.page_height = ( + (Cm(29.7), Cm(21)) if landscape else (Cm(21), Cm(29.7)) + ) + section.left_margin = section.right_margin = Cm(2) + section.top_margin = section.bottom_margin = Cm(1.9) + for name, size, color in ( + ("Normal", 10.5, "263445"), + ("Title", 20, "0F172A"), + ("Heading 1", 14, "0F172A"), + ("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[0].text, cells[1].text = label, text(value) - cells[0].width, cells[1].width = Inches(1.3), Inches(5.2) - doc.add_heading("结论与任务专属结果", 1) - for kind, value in markdown_blocks(text(report["content"])): + if header and index == 0: + table.rows[-1]._tr.get_or_add_trPr().append(OxmlElement("w:tblHeader")) + for position, cell in enumerate(cells): + 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": - table = doc.add_table(rows=0, cols=max(map(len, value))) - table.style = "Light Shading Accent 1" - for row in value: - cells = table.add_row().cells - for cell, item in zip(cells, row): - cell.text = item - elif isinstance(value, str) and value.startswith("#"): - doc.add_heading(value.lstrip("# "), min(len(value) - len(value.lstrip("#")), 3)) + columns = max(map(len, value)) + if columns: + word_table(doc, value, tuple(16 / columns for _ in range(columns)), header=True) + continue + line = str(value) + if line.strip().startswith("```"): + in_code = not in_code + 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: - 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) for step in report["steps"]: - doc.add_paragraph( - f"步骤 {step['step_number']} · {time_text(step.get('timestamp'))} · {step.get('summary') or '已记录操作'}" - ) - evidence = report["steps"][:1] + report["steps"][-3:] - seen = set() + paragraph = doc.add_paragraph() + paragraph.paragraph_format.keep_together = True + paragraph.add_run( + f"步骤 {step['step_number']} · {time_text(step.get('timestamp'))}\n" + ).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: name = step.get("post_image_name") or step.get("pre_image_name") photo = image_path(name) if photo and name not in seen: seen.add(name) - doc.add_paragraph(f"证据:步骤 {step['step_number']} / {name}") - doc.add_picture(str(photo), width=Inches(2.6)) + images.append((photo, step["step_number"])) + 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.save(str(path)) -def sheet_rows(ws, rows): - for row in rows: +def sheet_rows(ws, rows, widths: tuple[int, ...] = ()): + 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]) - 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. cell.data_type = "s" - cell.alignment = Alignment(wrap_text=True, vertical="top") - ws.freeze_panes = "A2" - if ws.max_row: + cell.alignment = Alignment(wrap_text=True, vertical="center" if index == 1 else "top") + cell.font = Font( + 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 - for cell in ws[1]: - cell.font = Font(bold=True, color="FFFFFF") - cell.fill = PatternFill("solid", fgColor="245C78") - for column in ws.columns: - ws.column_dimensions[column[0].column_letter].width = 32 + + +def conclusion_rows(content: str): + rows = [("类型", "结论与发现")] + 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): wb = Workbook() assert wb.active is not None wb.active.title = "任务结果" - sheet_rows(wb.active, [("项目", "内容"), *metadata(report)]) - sheet_rows( - wb.create_sheet("结论"), - [("最终结论",), *[(line,) for line in report["content"].splitlines()]], - ) - for number, (kind, rows) in enumerate(markdown_blocks(report["content"])): + sheet_rows(wb.active, [("项目", "内容"), *metadata(report)], (22, 82)) + sheet_rows(wb.create_sheet("结论"), conclusion_rows(report["content"]), (15, 96)) + table_number = 0 + for kind, rows in markdown_blocks(report["content"]): 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( wb.create_sheet("执行证据"), [ @@ -264,12 +539,15 @@ def excel_report(report: dict, path: Path): s["step_number"], time_text(s.get("timestamp")), s.get("summary"), - text(s.get("last_execution_result")), - s.get("post_image_name") or s.get("pre_image_name"), + execution_result_label(s.get("last_execution_result")), + "见步骤截图资料包" + if s.get("post_image_name") or s.get("pre_image_name") + else "无截图", ) for s in report["steps"] ], ], + (10, 22, 68, 26, 24), ) wb.save(path) @@ -392,21 +670,17 @@ def export_batch(batch_id: str) -> Path: wb = Workbook() assert wb.active is not None wb.active.title = "批次结果" - sheet_rows(wb.active, rows) + sheet_rows(wb.active, rows, (40, 48, 16, 28, 70)) path = directory / "批次结果.xlsx" wb.save(path) outputs.append(path) if "docx" in formats: - doc = Document() - doc.add_heading(batch["name"], 0) + doc = word_styles(Document(), landscape=True) + doc.add_paragraph(batch["name"], style="Title") doc.add_paragraph( f"批次编号:{batch_id};共 {len(records)} 个任务。验证通过与执行完成分别统计。" ) - table = doc.add_table(rows=0, cols=5) - table.style = "Light Shading Accent 1" - for row in rows: - for cell, value in zip(table.add_row().cells, row): - cell.text = str(value) + word_table(doc, rows, (3.5, 5.4, 2.5, 3.5, 7.3), header=True) path = directory / "批次汇总.docx" doc.save(str(path)) outputs.append(path) diff --git a/apps/showcase_ui/package-lock.json b/apps/showcase_ui/package-lock.json index f39e776..4a541d2 100644 --- a/apps/showcase_ui/package-lock.json +++ b/apps/showcase_ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "artemis-showcase-ui", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "artemis-showcase-ui", - "version": "1.0.1", + "version": "1.1.0", "dependencies": { "@angular/cdk": "^22.1.5", "@angular/common": "^22.1.4", diff --git a/apps/showcase_ui/package.json b/apps/showcase_ui/package.json index 074c7b7..df7385a 100644 --- a/apps/showcase_ui/package.json +++ b/apps/showcase_ui/package.json @@ -1,6 +1,6 @@ { "name": "artemis-showcase-ui", - "version": "1.0.1", + "version": "1.1.0", "scripts": { "ng": "ng", "start": "ng serve --proxy-config proxy.conf.json", diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html index 40008a8..05648f5 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html @@ -331,7 +331,7 @@ limitations under the License. - @if (consolidatedBlocks().length > 0 || showPlanningLoader()) { + @if (consolidatedBlocks().length > 0 || showPlanningLoader() || agentService.currentSession()) {
@if (agentService.viewedModel(); as model) { @@ -492,6 +492,7 @@ limitations under the License. } +
} @@ -568,6 +569,13 @@ limitations under the License. >
+ @if (block.data?.extra_metadata?.injected_instruction; as guidance) { +
+
forum用户指导 · 已进入第 {{block.data.step_number}} 步
+

{{ guidance }}

+ {{ hasRecordedDeviceAction(block.data.action_taken) ? '本步设备动作见下方;是否完成目标需以实际屏幕为准。' : '本步没有记录设备动作。' }} +
+ } @@ -746,7 +754,7 @@ limitations under the License. @if (getReportStatusExplanation(item.data); as expText) {
报告与发现 - +
}
@@ -1283,6 +1291,23 @@ limitations under the License. } } + @if (awaitingGuidance().length) { +
+ @for (item of awaitingGuidance(); track item.id) { +
+ 用户指导 · {{ guidanceStatusLabel(item) }} +

{{ item.instruction }}

+
+ } +
+ } + @if (terminalFailureReason(); as reason) { +
+ 任务执行失败 · 原因 +

{{ reason }}

+ +
+ } @if (outputterReport(); as outReport) {
diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss index cb1c34f..6e3c149 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss @@ -1921,6 +1921,8 @@ $stream-row-height: 25px; } &.task-report-card { + width: min(680px, 100%); + box-sizing: border-box; background: #f0fdf4; border: 1px solid #bbf7d0; @@ -1967,12 +1969,13 @@ $stream-row-height: 25px; } .report-markdown-body { - font-size: 12px; - line-height: 1.5; + font-size: 14px; + line-height: 1.7; color: #1f2937; font-weight: 400; user-select: text; - word-break: break-word; + word-break: normal; + overflow-wrap: anywhere; p { margin: 3px 0; @@ -2227,11 +2230,19 @@ $stream-row-height: 25px; color: #64748b; } - .grid-value { - font-size: 12px; - font-weight: 600; - color: #1e293b; - word-break: break-all; + .grid-value { + font-size: 12px; + font-weight: 600; + color: #1e293b; + 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 { 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; } +} diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts index 7e8813b..79da82b 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts @@ -23,7 +23,7 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; 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 { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model'; import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay'; @@ -247,6 +247,11 @@ export class AgentStreamComponent implements AfterViewInit { private http = inject(HttpClient); private destroyRef = inject(DestroyRef); 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 public isUserAtBottom = true; @@ -474,6 +479,23 @@ export class AgentStreamComponent implements AfterViewInit { return consolidateLogsToBlocks(this.filteredLogs()); }); + public awaitingGuidance = computed(() => { + 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(() => { const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId()); return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0); @@ -541,6 +563,17 @@ export class AgentStreamComponent implements AfterViewInit { } 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 // changes, and keep refreshing while that session is still running. effect((onCleanup) => { @@ -1327,6 +1360,11 @@ export class AgentStreamComponent implements AfterViewInit { 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 { return getActionIcon(action); } @@ -1813,6 +1851,7 @@ export class AgentStreamComponent implements AfterViewInit { public hasVisibleContent(block: any): boolean { if (!block) return false; if (block.type === 'checker') return true; + if (block.data?.extra_metadata?.injected_instruction) return true; const hasNative = Boolean(this.getNativeThinking(block)); const hasRaw = Boolean(this.getRawThinking(block)); const hasReset = this.hasBlockResets(block, false) || this.hasBlockResets(block, true); diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html index 8124d82..7028cec 100644 --- a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html +++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.html @@ -12,27 +12,32 @@

当前任务报告

@if (report(); as r) { - {{r.name}}

{{label(r.status)}} · {{r.verdict}}

+
+ {{r.name}} +
{{label(r.status)}}{{r.verdict}}
+ @if (r.error) {
异常原因

{{r.error}}

} +
+
任务目标

{{r.goal || '未记录'}}

预期结果

{{r.expected || '未指定'}}

- @if (liveTask()) { -
-

任务指导 · {{label(taskStatus())}}

-

运行中提交将在下一步执行前生效;暂停后会释放设备,提交指导即可重新排队恢复。

- - -
- @if(taskStatus() === 'running'){} - - @if(taskStatus() === 'task_paused'){} -
-
- }

报告包含目标、预期、实际结论、步骤与截图证据。执行中的下载为当前快照。

-
{{r.content}}
+

结论与发现

} @else {

从任务队列或历史记录中选择任务,即可下载报告。

} + @if (liveTask() && agent.currentSessionId()) { +
+

任务指导 · {{label(taskStatus())}}

+

{{ taskStatus() === 'paused' ? '模型请求已暂停;提交指导后请在执行日志中恢复模型调用。' : '运行中提交将在下一步执行前生效;暂停后会释放设备,提交指导即可重新排队恢复。' }}

+ + +
+ @if(taskStatus() === 'running'){} + + @if(taskStatus() === 'task_paused'){} +
+
+ }
} @if (tab() !== 'reports') { @@ -53,7 +58,12 @@ }

批次记录

- @for(b of batches(); track b.id){} + @for(b of batches(); track b.id){ +
+ + +
+ } @if(!batches().length){

暂无批次。

}
@if(batch(); as b){

{{b.name}}

{{label(b.status)}} · {{b.reason}}

diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.scss b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.scss index 7670be5..a92ca3d 100644 --- a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.scss +++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.scss @@ -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;} .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;} +.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;} .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;} .task-guidance{margin-top:16px;padding:14px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;} .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;} +} diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.spec.ts b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.spec.ts new file mode 100644 index 0000000..61ff9bd --- /dev/null +++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.spec.ts @@ -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(null), + runningSessionId: signal(null), + agentStatus: signal('idle'), + sessions: signal([]) + }; + const http = jasmine.createSpyObj('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(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(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(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('设置'); + }); +}); diff --git a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts index 0ae3143..a4f852f 100644 --- a/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts +++ b/apps/showcase_ui/src/app/components/automation-panel/automation-panel.component.ts @@ -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 { FormsModule } from '@angular/forms'; import { HttpClient } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import { AgentService } from '../../services/agent.service'; import { SystemService } from '../../services/system.service'; +import { renderMarkdownToHtml } from '../../utils/markdown-parser.util'; @Component({ selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule], @@ -16,8 +17,13 @@ export class AutomationPanelComponent { batches = signal([]); preview = signal(null); batch = signal(null); report = signal(null); guidance = ''; - taskStatus = signal(''); - liveTask = signal(false); + taskStatus = computed(() => { + 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; private destroyed = false; constructor() { @@ -33,6 +39,7 @@ export class AutomationPanelComponent { } 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)[s] || s; } + renderReport(content: string) { return renderMarkdownToHtml(content || ''); } async action(fn: () => Promise) { if (this.busy()) return; this.busy.set(true); this.error.set(''); this.notice.set(''); @@ -46,11 +53,6 @@ export class AutomationPanelComponent { this.batches.set(b); const sid = this.agent.currentSessionId(); if (sid && this.tab() === 'reports') { const r = await firstValueFrom(this.http.get(`/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; if (id) { const item = await firstValueFrom(this.http.get(`/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. */ } @@ -70,12 +72,25 @@ export class AutomationPanelComponent { 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}`))); }); } + 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(); }); } 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 () => { if (!this.guidance.trim()) throw Error('请先填写指导内容'); + const status = this.taskStatus(); 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 () => { await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/continue`,{})); diff --git a/apps/showcase_ui/src/app/pages/workspace/workspace.component.html b/apps/showcase_ui/src/app/pages/workspace/workspace.component.html index fedc937..37ed9c4 100644 --- a/apps/showcase_ui/src/app/pages/workspace/workspace.component.html +++ b/apps/showcase_ui/src/app/pages/workspace/workspace.component.html @@ -17,7 +17,43 @@ limitations under the License.
- + + @if (guidanceStatus(); as status) { +
+ + @if (guidanceOpenId() === agentService.currentSessionId()) { +
+
+ 指导当前任务 · {{ status === 'running' ? '执行中' : status === 'resuming' ? '等待设备恢复' : status === 'paused' ? '模型调用暂停' : '等待指导' }} + {{ agentService.currentSession()?.initial_goal || agentService.currentSessionId() }} +
+ + +
+ {{ status === 'paused' ? '提交指导后请在执行日志中恢复模型调用' : '下方输入框用于开始新任务' }} + @if (status === 'running') { + + } + @if (status === 'task_paused') { + + } + +
+
+ } +
+ } +
' }) +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('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('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', ['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(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('提交并恢复'); + }); +}); diff --git a/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts b/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts index e893e55..1df0855 100644 --- a/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts +++ b/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts @@ -21,6 +21,8 @@ import { AgentStreamComponent } from '../../components/agent-stream/agent-stream import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component'; import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component'; import { AgentService } from '../../services/agent.service'; +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; @Component({ selector: 'app-workspace', @@ -37,6 +39,7 @@ import { AgentService } from '../../services/agent.service'; }) export class WorkspaceComponent implements OnInit { public agentService = inject(AgentService); + private http = inject(HttpClient); private zone = inject(NgZone); private destroyRef = inject(DestroyRef); @@ -56,6 +59,33 @@ export class WorkspaceComponent implements OnInit { public isSubmitting = signal(false); public errorMessage = signal(null); public selectedProfile = signal<'flash' | 'pro'>('flash'); + private guidanceDrafts = new Map(); + public guidanceBusy = signal(false); + public guidanceOpenId = signal(null); + public pauseRequestId = signal(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) public isHoveringCard = signal(false); @@ -103,6 +133,69 @@ export class WorkspaceComponent implements OnInit { return this.agentService.isCurrentSessionRunning(); }); + public async pauseCurrentTask(): Promise { + 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 { + 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 { + 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 */ diff --git a/apps/showcase_ui/src/app/services/agent.service.ts b/apps/showcase_ui/src/app/services/agent.service.ts index 05066cf..2f78f9c 100644 --- a/apps/showcase_ui/src/app/services/agent.service.ts +++ b/apps/showcase_ui/src/app/services/agent.service.ts @@ -54,6 +54,15 @@ export interface StartupProgressEvent { timestamp: number; } +export interface GuidanceEvent { + id: string; + instruction: string; + source: string; + timestamp: number; + status: 'pending' | 'consumed'; + consumed_at?: number; +} + interface SessionVideoResponse { session_id: string; status?: 'processing' | 'ready' | 'failed' | 'unavailable'; @@ -201,6 +210,8 @@ export class AgentService { public currentSessionId = signal(null); public sessionLogs = signal([]); // Dynamic array of all raw events received + public guidanceEvents = signal([]); + private guidanceSignature = ''; public isSessionContentLoading = signal(false); public agentStatus = signal('idle'); // Status of the agent runner process public runningSessionId = signal(null); @@ -725,6 +736,8 @@ export class AgentService { this.isSessionContentLoading.set(false); this.currentSessionId.set(null); this.sessionLogs.set([]); + this.guidanceEvents.set([]); + this.guidanceSignature = ''; this.currentNotes.set({}); return; } @@ -740,6 +753,9 @@ export class AgentService { this.isSessionContentLoading.set(true); this.currentSessionId.set(sessionId); this.sessionLogs.set([]); // Reset logs for new session selection + this.guidanceEvents.set([]); + this.guidanceSignature = ''; + this.fetchGuidance(sessionId); this.activePauseCardKey = null; this.isPaused.set(false); this.pausedError.set(null); @@ -1279,6 +1295,21 @@ export class AgentService { }); } + public fetchGuidance(sessionId: string): void { + if (this.currentSessionId() !== sessionId) return; + this.http.get(`/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 { try { 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 if (this.pollCounter % 3 === 0) { this.fetchSessions(); + const selected = this.currentSessionId(); + if (selected) this.fetchGuidance(selected); } }, 2000); }); diff --git a/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts b/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts index 0fd04de..cad3e3a 100644 --- a/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts +++ b/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts @@ -67,4 +67,12 @@ describe('markdown-parser.util verification & check lines', () => { expect(html).toContain('verify'); 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('

结论

'); + expect(html).toContain('
第一行
\n
第二行
'); + expect(html).toContain('分类'); + expect(html).toContain('通过'); + }); }); diff --git a/apps/showcase_ui/src/app/utils/markdown-parser.util.ts b/apps/showcase_ui/src/app/utils/markdown-parser.util.ts index b7c4f25..dfc51db 100644 --- a/apps/showcase_ui/src/app/utils/markdown-parser.util.ts +++ b/apps/showcase_ui/src/app/utils/markdown-parser.util.ts @@ -272,7 +272,7 @@ export function renderMarkdownToHtml(text: string): string { if (inCodeBlock) { // End code block const codeContent = codeBlockLines.join('\n'); - result.push(`
${codeContent}
`); + result.push(`
${codeContent}
`); inCodeBlock = false; codeBlockLines = []; codeBlockLang = ''; @@ -290,6 +290,26 @@ export function renderMarkdownToHtml(text: string): string { 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('
' + + headings.map(cell => ``).join('') + + ''); + i += 2; + while (i < rawLines.length && rawLines[i].trim() && rawLines[i].includes('|')) { + const row = cells(rawLines[i]); + result.push('' + headings.map((_, index) => ``).join('') + ''); + i++; + } + result.push('
${formatInlineMarkdown(cell.trim())}
${formatInlineMarkdown((row[index] || '').trim())}
'); + i--; + continue; + } + // 2. Headings: # to ###### const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/); if (headingMatch) { @@ -375,7 +395,7 @@ export function renderMarkdownToHtml(text: string): string { if (inCodeBlock) { const codeContent = codeBlockLines.join('\n'); - result.push(`
${codeContent}
`); + result.push(`
${codeContent}
`); } closeList(); diff --git a/artemis/agents/log_analyzer/log_analyzer.py b/artemis/agents/log_analyzer/log_analyzer.py index 6f8f7cc..08a58ea 100644 --- a/artemis/agents/log_analyzer/log_analyzer.py +++ b/artemis/agents/log_analyzer/log_analyzer.py @@ -18,7 +18,7 @@ from pathlib import Path from typing import Annotated, Any from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage from langchain_core.tools import BaseTool, StructuredTool -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ValidationError class _CyFunctionDetectorMeta(type): @@ -89,6 +89,20 @@ class LogAnalyzerNode: original_coroutine = tool.coroutine 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": # Intercept read_logs to cache raw logs lines = kwargs.get("lines", 200) diff --git a/artemis/agents/operator/operator.py b/artemis/agents/operator/operator.py index 0bc09f8..b947a61 100644 --- a/artemis/agents/operator/operator.py +++ b/artemis/agents/operator/operator.py @@ -13,6 +13,7 @@ # limitations under the License. import json +from collections.abc import Awaitable from typing import Any from uuid import uuid4 @@ -32,6 +33,7 @@ from artemis.graph.state import State from artemis.graph.visibility import strict_state from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool 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.tools.command_tool import ( analyze_task_output_wrapper, @@ -78,6 +80,8 @@ DEFERRING_TOOLS = { "ask_explorer", } +INTERRUPTIBLE_HELPERS = {"ask_diagnoser", "ask_explorer", "video_analyzer", "analyze_task_output"} + # Bare key names press_key accepts (case-insensitive, optional KEYCODE_ prefix). SUPPORTED_PRESS_KEYS = ("ENTER", "BACK", "HOME", "APP_SWITCH") @@ -478,6 +482,16 @@ class OperatorNode: except Exception as 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( self, base_llm, @@ -496,6 +510,13 @@ class OperatorNode: no_progress_limit = settings.ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS 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: logger.warning( "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) - 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) # 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 ) - result_obj = await invoke_tool_with_injection( + tool_call = invoke_tool_with_injection( tool=tool_to_run, args=args, tool_call_id=tc["id"], 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) # Failure is structural (a ToolMessage status or a diff --git a/artemis/runtime/task_control.py b/artemis/runtime/task_control.py index 53275b2..f7a3ee9 100644 --- a/artemis/runtime/task_control.py +++ b/artemis/runtime/task_control.py @@ -1,6 +1,7 @@ """Cooperative, session-scoped task guidance and device lease parking.""" import asyncio +from collections.abc import Awaitable import json import logging import os @@ -48,22 +49,39 @@ def request_resume(session_id: str) -> None: def add_instruction( session_id: str, instruction: str, *, release_loop: bool = False, source: str = "user" -) -> None: +) -> dict: if not instruction.strip() or len(instruction) > 10000: raise ValueError("指导内容必须在 1–10000 字之间") directory = control_dir(session_id) / "instructions" directory.mkdir(parents=True, exist_ok=True) # Unique names make simultaneous web and MCP submissions additive, never last-write-wins. path = directory / f"{time.time_ns():020d}-{uuid.uuid4().hex}.json" - _write_atomic( - path, - { - "instruction": instruction.strip(), - "release_loop": bool(release_loop), - "source": source, - "timestamp": time.time(), - }, - ) + record = { + "id": path.stem, + "instruction": instruction.strip(), + "release_loop": bool(release_loop), + "source": source, + "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: @@ -73,8 +91,11 @@ def consume_instructions(base_dir: str | Path) -> dict | None: try: claimed = path.with_suffix(".reading") 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() + found.append(record) except (OSError, ValueError): continue legacy = Path(base_dir) / "injected_instruction.json" @@ -151,6 +172,31 @@ class TaskControl: self._state("running") 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: self.device_lock.release() (self.directory / "task_control_state.json").unlink(missing_ok=True) diff --git a/docs/ai-automation.md b/docs/ai-automation.md index 4351a64..6336022 100644 --- a/docs/ai-automation.md +++ b/docs/ai-automation.md @@ -22,10 +22,10 @@ | `mobile_workspace` | 设备列表与统一队列状态 | | `mobile_config` | 读取或明确保存非敏感模型、功能、报告默认设置 | | `mobile_run_task` | 单任务执行;`model=flash/pro` 表示执行模式 | -| `mobile_manage_task` | 单任务状态、安全暂停、排队恢复、下一步指导和停止 | +| `mobile_manage_task` | 单任务状态、安全暂停、排队恢复、指导提交/领取记录和停止 | | `mobile_get_device_state` | 设备截图、界面层级 | | `mobile_task_file` | XLSX 模板与导入校验 | -| `mobile_batch` | JSON 用例建批次、开始、暂停、继续、复制失败项 | +| `mobile_batch` | JSON 用例建批次、开始、暂停、继续、复制失败项、删除非活跃批次 | | `mobile_search_history` | 全库搜索、日期/状态筛选和分页 | | `mobile_inspect_trace` | 只读步骤、截图和执行证据 | | `mobile_export_report` | Word、Excel、步骤截图、录屏片段、单任务资料及批次 ZIP,返回本地绝对路径 | @@ -42,6 +42,6 @@ MCP 自动刷新只处理已存在且指向当前项目的标准 Python 模块 报告导出到 `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` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。 diff --git a/docs/releases/1.1.0.md b/docs/releases/1.1.0.md new file mode 100644 index 0000000..fa0065c --- /dev/null +++ b/docs/releases/1.1.0.md @@ -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` 校验。执行完成仍不等于测试验证通过,检查实际截图、步骤和验证摘要。 diff --git a/mcp_server/tools/automation.py b/mcp_server/tools/automation.py index 8a610ef..5e5fdcb 100644 --- a/mcp_server/tools/automation.py +++ b/mcp_server/tools/automation.py @@ -176,7 +176,9 @@ async def mobile_task_file( @mcp.tool() 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, request_id: str | 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. 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. + 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. """ if action == "create": @@ -210,6 +214,8 @@ async def mobile_batch( route = f"/api/automation/batches/{_id(batch_id)}" if action == "get": return await _request("GET", route) + if action == "delete": + return await _request("DELETE", route) if action in {"start", "resume"}: current = await _request("GET", route) if current["status"] == "running": diff --git a/mcp_server/tools/task_manager.py b/mcp_server/tools/task_manager.py index 899e58e..1c8284c 100644 --- a/mcp_server/tools/task_manager.py +++ b/mcp_server/tools/task_manager.py @@ -37,6 +37,7 @@ from artemis.runtime.process_probe import pid_is_alive from artemis.runtime.task_control import ( add_instruction, control_dir, + list_instructions, read_state, request_pause, 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. ### 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 setups), `task_desc`, `model`, `elapsed_seconds`, a `test_summary` (when 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 progress or the thought indicates it is stuck; Pro: the active task plan — 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` provides guidance before the next action. - **'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 milestone's completion; natural-language "please stop" phrasing in `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 device interactions and releasing the device. Use when the task is done, irreparably broken, or running out of control. Prefer @@ -292,7 +298,8 @@ def mobile_manage_task( should wind down cleanly instead of being killed. 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. instruction: Guidance string; required for `inject_instruction` (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 if control_status in ("task_paused", "resuming") and current_status in ("running", "pending"): 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) @@ -524,7 +537,16 @@ def mobile_manage_task( return response + elif action == "guidance_history": + return {"trace_id": trace_id, "instructions": list_instructions(trace_id)} + 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: return { "trace_id": trace_id, @@ -535,7 +557,7 @@ def mobile_manage_task( return { "trace_id": trace_id, "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": @@ -555,7 +577,7 @@ def mobile_manage_task( } 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 { "trace_id": trace_id, "status": current_status, @@ -650,7 +672,7 @@ def mobile_manage_task( "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 { "trace_id": trace_id, "status": current_status, diff --git a/pyproject.toml b/pyproject.toml index ee84350..0938eec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ [project] name = "artemis" -version = "1.0.1" +version = "1.1.0" description = "Autonomous AI Assistant for Mobile Automation" authors = [ diff --git a/skills/artemis-automation/SKILL.md b/skills/artemis-automation/SKILL.md index 6c04bbc..2e698cf 100644 --- a/skills/artemis-automation/SKILL.md +++ b/skills/artemis-automation/SKILL.md @@ -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 日志。 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=绝对路径)`,修正所有行错误后提交。提示词列原样作为目标,前置约束列只作元数据。 -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。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。 - 录屏与 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 思考耗时当作确定性等待。 diff --git a/skills/artemis-automation/references/batches.md b/skills/artemis-automation/references/batches.md index d7edd79..52973f4 100644 --- a/skills/artemis-automation/references/batches.md +++ b/skills/artemis-automation/references/batches.md @@ -53,9 +53,10 @@ ## 状态与异常 - 批次 `draft` 等待开始;`running` 调度中;`paused` 等待处理;`completed` 表示所有项进入终态,不代表全部通过。 -- 单项 `pending/queued/running/task_paused/resuming/finalizing` 尚未结束,`completed/failed/cancelled/interrupted/skipped` 是终态。`finalizing` 在生成报告,不能提前宣称报告已完成。 -- 批次 `pause` 保留当前执行,只暂停后续派发;单项 `mobile_manage_task(pause)` 在步骤后释放设备,指导后排队恢复,不阻塞后续项。设备断开、模型持续失败或任务达到批次时限会暂停后续派发;用诊断确认恢复后再响应用户的继续指令。 +- 单项 `pending/queued/running/pausing/task_paused/resuming/finalizing` 尚未结束,`completed/failed/cancelled/interrupted/skipped` 是终态。`pausing` 是请求已发出而设备仍被占用,`task_paused` 才表示设备已释放;`finalizing` 在生成报告,不能提前宣称报告已完成。 +- 批次 `pause` 保留当前执行,只暂停后续派发;单项 `mobile_manage_task(pause)` 会在安全边界释放设备,Pro 的模型等待与只读诊断可中断。恢复后重新观察画面,并排到设备队尾,不阻塞后续项。`mobile_manage_task(guidance_history)` 可核对指导是否从 `pending` 变为 `consumed`,但仍需检查后续操作是否符合指导。设备断开、模型持续失败或任务达到批次时限会暂停后续派发;用诊断确认恢复后再响应用户的继续指令。 - `resume` 只继续未开始项;`retry_failed` 复制失败、取消和中断项为新草稿,不修改原结果。必须来自用户的明确重跑请求。该动作不提供创建去重,响应不确定时先 `list` 查找新批次,不自动重复调用。 +- 用户明确要求删除批次记录时,先 `get` 核对批次编号与状态,再 `delete`;正在执行、排队或等待指导的批次不能删除,已经运行过的任务历史及报告不会随批次记录一起消失。 ## 报告 diff --git a/tests/unit/admin_console/test_automation.py b/tests/unit/admin_console/test_automation.py index 280d860..a45a752 100644 --- a/tests/unit/admin_console/test_automation.py +++ b/tests/unit/admin_console/test_automation.py @@ -6,6 +6,7 @@ import time from unittest.mock import AsyncMock import zipfile +from docx import Document from fastapi import FastAPI from fastapi.testclient import TestClient 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 +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): batch = service.create_batch(payload()) 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") +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( client, isolated, tmp_path, monkeypatch ): diff --git a/tests/unit/agents/test_log_analyzer.py b/tests/unit/agents/test_log_analyzer.py index a24386a..706dcc1 100644 --- a/tests/unit/agents/test_log_analyzer.py +++ b/tests/unit/agents/test_log_analyzer.py @@ -24,6 +24,8 @@ import pytest from artemis.agents.log_analyzer.log_analyzer import LogAnalyzerNode from artemis.context import ArtemisContext 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 = [ 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].status == expected_status 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) diff --git a/tests/unit/agents/test_operator_recovery.py b/tests/unit/agents/test_operator_recovery.py index 93f7902..b0e32c4 100644 --- a/tests/unit/agents/test_operator_recovery.py +++ b/tests/unit/agents/test_operator_recovery.py @@ -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 unittest.mock import AsyncMock, Mock @@ -8,6 +9,7 @@ import pytest from artemis.agents.operator.operator import OperatorNode, OperatorNoProgressError from artemis.config.settings import settings +from artemis.runtime import task_control @pytest.fixture @@ -135,3 +137,68 @@ async def test_iteration_cap_cannot_restart_no_progress_next_turn(operator, monk with pytest.raises(OperatorNoProgressError, match="预算内未恢复"): await node._invoke_llm_loop(Mock(), [], [], [], state) 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"] diff --git a/tests/unit/mcp/test_automation_tools.py b/tests/unit/mcp/test_automation_tools.py index abb0b26..1ba7609 100644 --- a/tests/unit/mcp/test_automation_tools.py +++ b/tests/unit/mcp/test_automation_tools.py @@ -60,6 +60,15 @@ async def test_running_batch_start_is_idempotent(monkeypatch): 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 async def test_download_writes_actual_binary_atomically(monkeypatch, tmp_path): monkeypatch.setattr(tools, "EXPORT_DIR", tmp_path) diff --git a/tests/unit/mcp/test_mcp_tools.py b/tests/unit/mcp/test_mcp_tools.py index b85c50f..4e2cbdf 100644 --- a/tests/unit/mcp/test_mcp_tools.py +++ b/tests/unit/mcp/test_mcp_tools.py @@ -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"}'), ) - assert ( - mobile_manage_task(action="status", trace_id=trace_id)["device_serial"] == "test-device" - ) + assert 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 (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.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"] == "先确认当前画面" ) + 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): diff --git a/tests/unit/runtime/test_task_control.py b/tests/unit/runtime/test_task_control.py index babee03..1c22afe 100644 --- a/tests/unit/runtime/test_task_control.py +++ b/tests/unit/runtime/test_task_control.py @@ -52,3 +52,101 @@ async def test_pause_releases_device_and_resume_waits_for_new_acquisition(tmp_pa async def _event(name, events): 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" diff --git a/uv.lock b/uv.lock index 4c32736..965562a 100644 --- a/uv.lock +++ b/uv.lock @@ -206,7 +206,7 @@ wheels = [ [[package]] name = "artemis" -version = "1.0.1" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "adbutils" },