feat: release 1.1.0 with responsive task control

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

No files matched your search

+14
View File
@@ -121,6 +121,20 @@ def batch_detail(batch_id: str):
return {**require("batch", batch_id), "jobs": store.jobs(batch_id)}
@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)
+14 -2
View File
@@ -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")
@@ -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:
+347 -73
View File
@@ -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)
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
@@ -331,7 +331,7 @@ limitations under the License.
</header>
<!-- Floating Top-Right Badges & Toolbar (Pinned overlay, hidden in empty/initial state) -->
@if (consolidatedBlocks().length > 0 || showPlanningLoader()) {
@if (consolidatedBlocks().length > 0 || showPlanningLoader() || agentService.currentSession()) {
<div class="stream-floating-toolbar">
<!-- Active Architecture Chip: click opens the run info popover -->
@if (agentService.viewedModel(); as model) {
@@ -492,6 +492,7 @@ limitations under the License.
</span>
}
</button>
<ng-content select="[task-guidance]"></ng-content>
</div>
}
@@ -568,6 +569,13 @@ limitations under the License.
>
<!-- Unified Step / Stream Body -->
<div class="step-body">
@if (block.data?.extra_metadata?.injected_instruction; as guidance) {
<div class="guidance-step-note">
<div class="guidance-step-title"><span class="material-symbols-outlined">forum</span>用户指导 · 已进入第 {{block.data.step_number}} 步</div>
<p>{{ guidance }}</p>
<small>{{ hasRecordedDeviceAction(block.data.action_taken) ? '本步设备动作见下方;是否完成目标需以实际屏幕为准。' : '本步没有记录设备动作。' }}</small>
</div>
}
<!-- Check block (verifier lane): header, then the declared check items
and how each is judged; the chronological stream follows, and the
result rows close the block (see check-result below). -->
@@ -746,7 +754,7 @@ limitations under the License.
@if (getReportStatusExplanation(item.data); as expText) {
<div class="grid-item full-width">
<span class="grid-label">报告与发现</span>
<span class="grid-value report-markdown-body" [innerHTML]="renderMarkdown(expText, 'report-' + block.id + '-' + itemIdx)"></span>
<div class="grid-value report-markdown-body" [innerHTML]="renderMarkdown(expText, 'report-' + block.id + '-' + itemIdx)"></div>
</div>
}
</div>
@@ -1283,6 +1291,23 @@ limitations under the License.
}
</div>
}
@if (awaitingGuidance().length) {
<div class="guidance-queue" aria-label="待处理用户指导">
@for (item of awaitingGuidance(); track item.id) {
<div class="guidance-queue-item">
<strong>用户指导 · {{ guidanceStatusLabel(item) }}</strong>
<p>{{ item.instruction }}</p>
</div>
}
</div>
}
@if (terminalFailureReason(); as reason) {
<div class="session-failure-summary" role="status">
<strong>任务执行失败 · 原因</strong>
<p>{{ reason }}</p>
<button type="button" (click)="isAutomationOpen.set(true)">查看完整报告</button>
</div>
}
<!-- Outputter Post-Execution Synthesis Card (Rendered ONCE at the end of stream) -->
@if (outputterReport(); as outReport) {
<div class="outputter-synthesis-stream-card">
@@ -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; }
}
@@ -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<GuidanceEvent[]>(() => {
const steps = this.consolidatedBlocks().filter(block => block.type === 'step');
return (this.agentService.guidanceEvents?.() || []).filter(event => !steps.some(block =>
new Date(block.timestamp).getTime() >= event.timestamp * 1000
&& String(block.data?.extra_metadata?.injected_instruction || '').includes(event.instruction)
));
});
public guidanceStatusLabel(event: GuidanceEvent): string {
const terminal = ['completed', 'failed', 'cancelled', 'interrupted']
.includes(this.agentService.currentSession()?.status || '');
if (event.status === 'pending') {
return terminal ? '任务结束前未进入模型' : '已接收,等待下一步';
}
return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录';
}
public phases = computed<PhaseBlock[]>(() => {
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);
@@ -12,27 +12,32 @@
</section>
<section><h3>当前任务报告</h3>
@if (report(); as r) {
<strong>{{r.name}}</strong><p>{{label(r.status)}} · {{r.verdict}}</p>
<div class="report-overview">
<strong class="report-title">{{r.name}}</strong>
<div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><span>{{r.verdict}}</span></div>
@if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> }
</div>
<div class="report-meta"><div><strong>任务目标</strong><p>{{r.goal || '未记录'}}</p></div><div><strong>预期结果</strong><p>{{r.expected || '未指定'}}</p></div></div>
<div class="actions"><a [href]="'/api/automation/reports/'+r.id+'/docx'">下载 Word</a><a [href]="'/api/automation/reports/'+r.id+'/xlsx'">下载 Excel</a>
<a [href]="'/api/automation/reports/'+r.id+'/screenshots'">步骤截图 ZIP</a>
<a [href]="'/api/automation/reports/'+r.id+'/video'">分段录屏 ZIP</a>
<a [href]="'/api/automation/reports/'+r.id+'/all'">下载全部资料</a></div>
@if (liveTask()) {
<div class="task-guidance">
<h3>任务指导 · {{label(taskStatus())}}</h3>
<p>运行中提交将在下一步执行前生效;暂停后会释放设备,提交指导即可重新排队恢复。</p>
<label for="task-guidance-text">补充信息或调整方向</label>
<textarea id="task-guidance-text" [(ngModel)]="guidance" maxlength="10000" rows="3" placeholder="描述已观察到的情况和下一步方向"></textarea>
<div class="actions">
@if(taskStatus() === 'running'){<button [disabled]="busy()" (click)="pauseTask(r.id)">暂停当前任务</button>}
<button [disabled]="busy() || !guidance.trim()" (click)="guideTask(r.id)">提交指导{{taskStatus() === 'task_paused' ? '并恢复' : ''}}</button>
@if(taskStatus() === 'task_paused'){<button [disabled]="busy()" (click)="continueTask(r.id)">直接继续</button>}
</div>
</div>
}
<p class="muted">报告包含目标、预期、实际结论、步骤与截图证据。执行中的下载为当前快照。</p>
<pre class="preview">{{r.content}}</pre>
<div class="report-content"><h3>结论与发现</h3><div class="report-body" [innerHTML]="renderReport(r.content)"></div></div>
} @else { <p>从任务队列或历史记录中选择任务,即可下载报告。</p> }
@if (liveTask() && agent.currentSessionId()) {
<div class="task-guidance">
<h3>任务指导 · {{label(taskStatus())}}</h3>
<p>{{ taskStatus() === 'paused' ? '模型请求已暂停;提交指导后请在执行日志中恢复模型调用。' : '运行中提交将在下一步执行前生效;暂停后会释放设备,提交指导即可重新排队恢复。' }}</p>
<label for="task-guidance-text">补充信息或调整方向</label>
<textarea id="task-guidance-text" [(ngModel)]="guidance" maxlength="10000" rows="3" placeholder="描述已观察到的情况和下一步方向"></textarea>
<div class="actions">
@if(taskStatus() === 'running'){<button [disabled]="busy()" (click)="pauseTask(agent.currentSessionId()!)">暂停当前任务</button>}
<button [disabled]="busy() || !guidance.trim()" (click)="guideTask(agent.currentSessionId()!)">提交指导{{taskStatus() === 'task_paused' ? '并恢复' : ''}}</button>
@if(taskStatus() === 'task_paused'){<button [disabled]="busy()" (click)="continueTask(agent.currentSessionId()!)">直接继续</button>}
</div>
</div>
}
</section>
}
@if (tab() !== 'reports') {
@@ -53,7 +58,12 @@
}
</section>
<section><h3>批次记录</h3>
@for(b of batches(); track b.id){<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}}</span></button>}
@for(b of batches(); track b.id){
<div class="batch-record">
<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}}</span></button>
<button class="delete-batch" type="button" [disabled]="busy() || b.status === 'running'" (click)="deleteBatch(b)" [attr.aria-label]="'删除批次:' + b.name" [title]="b.status === 'running' ? '执行中的批次不能删除' : '删除批次记录(保留已执行任务与报告)'"><span class="material-symbols-outlined">delete_outline</span></button>
</div>
}
@if(!batches().length){<p>暂无批次。</p>}
</section>
@if(batch(); as b){<section><h3>{{b.name}}</h3><p>{{label(b.status)}} · {{b.reason}}</p>
@@ -17,8 +17,26 @@ input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:
.upload{position:relative;margin:0;color:#fff;background:#2563eb;border-color:#2563eb;}.upload:hover{background:#1d4ed8;border-color:#1d4ed8;}.upload input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;}
.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;}
}
@@ -0,0 +1,80 @@
import { signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { AgentService } from '../../services/agent.service';
import { SystemService } from '../../services/system.service';
import { AutomationPanelComponent } from './automation-panel.component';
describe('AutomationPanelComponent batch deletion', () => {
const agent = {
currentSessionId: signal<string | null>(null),
runningSessionId: signal<string | null>(null),
agentStatus: signal('idle'),
sessions: signal<any[]>([])
};
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'delete']);
beforeEach(async () => {
(http.get.and as any).callFake((url: string) => of(url.endsWith('/preferences')
? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : []));
http.delete.and.returnValue(of({ status: 'deleted' }));
http.delete.calls.reset();
await TestBed.configureTestingModule({
imports: [AutomationPanelComponent],
providers: [
{ provide: AgentService, useValue: agent },
{ provide: SystemService, useValue: { connectedDevices: signal([]) } },
{ provide: HttpClient, useValue: http }
]
}).compileComponents();
});
it('confirms deletion and removes the batch from the visible list', async () => {
const fixture = TestBed.createComponent(AutomationPanelComponent);
fixture.componentInstance.tab.set('batches');
await new Promise<void>(resolve => setTimeout(resolve, 0));
fixture.componentInstance.batches.set([
{ id: 'batch-1', name: '批量冒烟', status: 'completed', created_at: 100 }
]);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[aria-label="删除批次:批量冒烟"]')).not.toBeNull();
spyOn(window, 'confirm').and.returnValue(true);
const button = fixture.nativeElement.querySelector('.delete-batch') as HTMLButtonElement;
button.click();
await new Promise<void>(resolve => setTimeout(resolve, 0));
fixture.detectChanges();
expect(http.delete).toHaveBeenCalledWith('/api/automation/batches/batch-1');
expect(fixture.componentInstance.batches()).toEqual([]);
});
it('keeps a running batch protected from the delete control', async () => {
const fixture = TestBed.createComponent(AutomationPanelComponent);
fixture.componentInstance.tab.set('batches');
await new Promise<void>(resolve => setTimeout(resolve, 0));
fixture.componentInstance.batches.set([
{ id: 'batch-2', name: '进行中', status: 'running', created_at: 100 }
]);
fixture.detectChanges();
expect((fixture.nativeElement.querySelector('.delete-batch') as HTMLButtonElement).disabled).toBeTrue();
});
it('shows the failure reason and renders Chinese report structure with line breaks', () => {
const fixture = TestBed.createComponent(AutomationPanelComponent);
fixture.detectChanges();
fixture.componentInstance.report.set({
id: 'task-1', name: '系统设置检查', status: 'failed', verdict: '未验证',
error: '模型连续 3 次未提供可执行工具调用', goal: '查看设置', expected: '',
content: '## 结果\n第一行\n第二行\n\n| 项目 | 状态 |\n| --- | --- |\n| 设置 | 未验证 |'
});
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('.report-error')?.textContent).toContain('连续 3 次');
expect(root.querySelector('.report-body h2')?.textContent).toBe('结果');
expect(root.querySelector('.report-body')?.textContent).toContain('第一行');
expect(root.querySelector('.report-body')?.textContent).toContain('第二行');
expect(root.querySelector('.report-body table td')?.textContent).toBe('设置');
});
});
@@ -1,10 +1,11 @@
import { Component, DestroyRef, effect, inject, signal } from '@angular/core';
import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { 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<any[]>([]); preview = signal<any>(null);
batch = signal<any>(null); report = signal<any>(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<string,string>)[s] || s; }
renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
async action(fn: () => Promise<void>) {
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<any>(`/api/automation/reports/${encodeURIComponent(sid)}`)); if (this.agent.currentSessionId() === sid) this.report.set(r); }
if (sid) {
const current = this.agent.sessions().find(s => s.session_id === sid);
this.taskStatus.set(current?.status || this.report()?.status || '');
this.liveTask.set(['running','task_paused','resuming'].includes(current?.status || ''));
} else { this.taskStatus.set(''); this.liveTask.set(false); }
const id = this.batch()?.id;
if (id) { const item = await firstValueFrom(this.http.get<any>(`/api/automation/batches/${id}`)); if (this.batch()?.id === id) this.batch.set(item); }
} catch { /* User initiated actions expose errors; polling retries on the next interval. */ }
@@ -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`,{}));
@@ -17,7 +17,43 @@ limitations under the License.
<div class="workspace-container" [class.is-resizing]="isDragging()">
<!-- Left Main Panel: Raw Agent Log Stream -->
<main class="left-panel glass-panel">
<app-agent-stream></app-agent-stream>
<app-agent-stream>
@if (guidanceStatus(); as status) {
<div task-guidance class="task-guidance-control">
<button type="button" class="guidance-trigger" (click)="toggleGuidance()"
[attr.aria-expanded]="guidanceOpenId() === agentService.currentSessionId()"
aria-controls="workspace-guidance-panel" aria-label="指导当前任务" title="暂停或指导当前任务">
<span class="material-symbols-outlined">edit_note</span>
指导任务
</button>
@if (guidanceOpenId() === agentService.currentSessionId()) {
<section id="workspace-guidance-panel" class="task-guidance-panel" aria-label="当前任务指导">
<div class="guidance-heading">
<strong>指导当前任务 · {{ status === 'running' ? '执行中' : status === 'resuming' ? '等待设备恢复' : status === 'paused' ? '模型调用暂停' : '等待指导' }}</strong>
<span [title]="agentService.currentSession()?.initial_goal || ''">{{ agentService.currentSession()?.initial_goal || agentService.currentSessionId() }}</span>
</div>
<label for="workspace-task-guidance">补充信息或调整方向</label>
<textarea id="workspace-task-guidance" [(ngModel)]="taskGuidance" rows="2" maxlength="10000"
[disabled]="guidanceBusy()" placeholder="输入指导;运行中将在下一步执行前生效"></textarea>
<div class="guidance-actions">
<span>{{ status === 'paused' ? '提交指导后请在执行日志中恢复模型调用' : '下方输入框用于开始新任务' }}</span>
@if (status === 'running') {
<button type="button" (click)="pauseCurrentTask()"
[disabled]="guidanceBusy() || pauseRequestId() === agentService.currentSessionId()">
{{ pauseRequestId() === agentService.currentSessionId() ? '等待暂停…' : '暂停当前任务' }}
</button>
}
@if (status === 'task_paused') {
<button type="button" (click)="continueCurrentTask()" [disabled]="guidanceBusy()">直接继续</button>
}
<button type="button" class="guidance-submit" (click)="submitGuidance()"
[disabled]="guidanceBusy() || !taskGuidance.trim()">{{ status === 'task_paused' ? '提交并恢复' : '提交指导' }}</button>
</div>
</section>
}
</div>
}
</app-agent-stream>
<!-- Bottom Floating Command Bar Dock -->
<div class="workspace-floating-bar-wrapper" [class.is-expanded]="isBarExpanded()" [class.is-dormant]="!isBarExpanded()"
@@ -108,6 +108,110 @@ $rose-dark: #dc2626;
}
}
.task-guidance-control {
position: relative;
flex: 0 0 auto;
.guidance-trigger {
display: inline-flex;
align-items: center;
gap: 5px;
height: 30px;
padding: 0 11px;
border: 1px solid rgba(226, 232, 240, .85);
border-radius: 15px;
background: rgba(255, 255, 255, .72);
box-shadow: 0 2px 6px -1px rgba(15, 23, 42, .05);
color: #334155;
font: inherit;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
.material-symbols-outlined { font-size: 16px; }
&:hover { background: rgba(255, 255, 255, .92); border-color: #cbd5e1; }
&[aria-expanded='true'] { background: #eff6ff; border-color: #bfdbfe; color: #1d4ed8; }
&:focus-visible { outline: 2px solid #93c5fd; outline-offset: 2px; }
}
@media (max-width: 520px) {
.guidance-trigger {
justify-content: center;
width: 30px;
padding: 0;
font-size: 0;
.material-symbols-outlined { font-size: 16px; }
}
}
.task-guidance-panel {
position: absolute;
top: calc(100% + 9px);
right: 0;
box-sizing: border-box;
width: min(420px, calc(100vw - 40px));
padding: 15px;
border: 1px solid #dbe4ef;
border-radius: 16px;
background: #fff;
box-shadow: 0 18px 48px rgba(15, 23, 42, .16);
color: #1e293b;
font-size: 12px;
.guidance-heading {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
margin-bottom: 10px;
strong { flex-shrink: 0; color: #1d4ed8; font-size: 13px; }
span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #475569; }
}
label { display: block; margin-bottom: 6px; font-weight: 600; }
textarea {
box-sizing: border-box;
width: 100%;
min-height: 62px;
max-height: 160px;
padding: 9px 10px;
resize: vertical;
border: 1px solid #cbd5e1;
border-radius: 9px;
background: #fff;
color: #0f172a;
font: inherit;
font-size: 13px;
&:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }
}
.guidance-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
span { flex: 1 0 100%; color: #64748b; }
button {
min-height: 32px;
padding: 5px 11px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
color: #334155;
font: inherit;
font-weight: 600;
cursor: pointer;
&:hover:not(:disabled) { background: #eff6ff; border-color: #93c5fd; }
&:disabled { opacity: .55; cursor: not-allowed; }
&.guidance-submit { background: #2563eb; border-color: #2563eb; color: #fff; }
&.guidance-submit:hover:not(:disabled) { background: #1d4ed8; }
}
}
}
}
/* ========================================================================= */
/* BOTTOM FLOATING COMMAND BAR (Proximity Reveal & Minimalist Glassmorphism) */
/* ========================================================================= */
@@ -0,0 +1,118 @@
import { Component, computed, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { AgentService } from '../../services/agent.service';
import { AgentStreamComponent } from '../../components/agent-stream/agent-stream.component';
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
import { WorkspaceComponent } from './workspace.component';
@Component({ selector: 'app-agent-stream', standalone: true,
template: '<div class="stream-floating-toolbar"><ng-content select="[task-guidance]"></ng-content></div>' })
class StreamStub {}
@Component({ selector: 'app-chat-interface', standalone: true, template: '' })
class ChatStub {}
@Component({ selector: 'app-floating-video-player', standalone: true, template: '' })
class VideoStub {}
describe('Workspace task guidance', () => {
const currentId = signal<string | null>('running-task');
const session = signal({ session_id: 'running-task', status: 'running', initial_goal: '观察设置' });
const agent = {
currentSessionId: currentId,
currentSession: computed(() => currentId() === session().session_id ? session() : null),
runningSessionId: signal<string | null>('running-task'),
agentStatus: signal('running'),
isCurrentSessionRunning: computed(() => ['running', 'paused'].includes(session().status)),
fetchStatus: jasmine.createSpy('fetchStatus'),
fetchSessions: jasmine.createSpy('fetchSessions'),
fetchGuidance: jasmine.createSpy('fetchGuidance')
};
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['post']);
beforeEach(async () => {
currentId.set('running-task');
session.set({ session_id: 'running-task', status: 'running', initial_goal: '观察设置' });
agent.runningSessionId.set('running-task');
http.post.and.returnValue(of({}));
http.post.calls.reset();
await TestBed.configureTestingModule({
imports: [WorkspaceComponent],
providers: [
{ provide: AgentService, useValue: agent },
{ provide: HttpClient, useValue: http }
]
}).overrideComponent(WorkspaceComponent, {
remove: { imports: [AgentStreamComponent, ChatInterfaceComponent, FloatingVideoPlayerComponent] },
add: { imports: [StreamStub, ChatStub, VideoStub] }
}).compileComponents();
});
it('shows pause and guidance for the selected running task, then hides them on cancellation', async () => {
const fixture = TestBed.createComponent(WorkspaceComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
expect(fixture.nativeElement.querySelector('.stream-floating-toolbar .guidance-trigger')).not.toBeNull();
expect(fixture.nativeElement.querySelector('.workspace-floating-bar-wrapper .guidance-trigger')).toBeNull();
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
fixture.detectChanges();
const panel = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
expect(panel.textContent).toContain('指导当前任务');
expect(panel.textContent).toContain('下方输入框用于开始新任务');
const pause = Array.from(panel.querySelectorAll('button')).find(b => b.textContent?.includes('暂停当前任务'))!;
pause.click();
await fixture.whenStable();
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/pause', {});
fixture.detectChanges();
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
fixture.detectChanges();
const reopened = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
const input = reopened.querySelector('textarea')!;
input.value = '先确认设置首页';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
const submit = Array.from(reopened.querySelectorAll('button')).find(b => b.textContent?.includes('提交指导'))!;
submit.click();
await fixture.whenStable();
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/guide', { instruction: '先确认设置首页' });
expect(agent.fetchGuidance).toHaveBeenCalledWith('running-task');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('指导已提交');
session.set({ session_id: 'running-task', status: 'cancelled', initial_goal: '观察设置' });
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
expect(fixture.nativeElement.querySelector('.guidance-trigger')).toBeNull();
});
it('continues a paused task without creating a new task', async () => {
session.set({ session_id: 'running-task', status: 'task_paused', initial_goal: '观察设置' });
const fixture = TestBed.createComponent(WorkspaceComponent);
fixture.detectChanges();
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
fixture.detectChanges();
const button = Array.from<HTMLElement>(fixture.nativeElement.querySelectorAll('.task-guidance-panel button'))
.find(b => b.textContent?.includes('直接继续'))!;
button.click();
await fixture.whenStable();
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/continue', {});
});
it('does not offer task resume for a paused model request', () => {
session.set({ session_id: 'running-task', status: 'paused', initial_goal: '观察设置' });
const fixture = TestBed.createComponent(WorkspaceComponent);
fixture.detectChanges();
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
fixture.detectChanges();
const panel = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
expect(panel.textContent).toContain('模型调用暂停');
expect(panel.textContent).toContain('请在执行日志中恢复模型调用');
expect(panel.textContent).not.toContain('直接继续');
expect(panel.textContent).not.toContain('提交并恢复');
});
});
@@ -21,6 +21,8 @@ import { AgentStreamComponent } from '../../components/agent-stream/agent-stream
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
import { 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<boolean>(false);
public errorMessage = signal<string | null>(null);
public selectedProfile = signal<'flash' | 'pro'>('flash');
private guidanceDrafts = new Map<string, string>();
public guidanceBusy = signal(false);
public guidanceOpenId = signal<string | null>(null);
public pauseRequestId = signal<string | null>(null);
public get taskGuidance(): string {
const id = this.agentService.currentSessionId();
return id ? this.guidanceDrafts.get(id) || '' : '';
}
public set taskGuidance(value: string) {
const id = this.agentService.currentSessionId();
if (id) this.guidanceDrafts.set(id, value);
}
public guidanceStatus = computed(() => {
const id = this.agentService.currentSessionId();
if (!id) return null;
const status = this.agentService.currentSession()?.status
|| (this.agentService.runningSessionId() === id ? this.agentService.agentStatus() : '');
return ['running', 'paused', 'task_paused', 'resuming'].includes(status || '') ? status : null;
});
public toggleGuidance(): void {
const id = this.agentService.currentSessionId();
this.guidanceOpenId.set(id && this.guidanceOpenId() !== id ? id : null);
}
// Expand States (Signals for 0-latency reactivity)
public isHoveringCard = signal<boolean>(false);
@@ -103,6 +133,69 @@ export class WorkspaceComponent implements OnInit {
return this.agentService.isCurrentSessionRunning();
});
public async pauseCurrentTask(): Promise<void> {
const id = this.agentService.currentSessionId();
if (!id || this.guidanceStatus() !== 'running' || this.guidanceBusy()) return;
this.guidanceBusy.set(true);
this.errorMessage.set(null);
try {
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/pause`, {}));
this.pauseRequestId.set(id);
this.guidanceOpenId.set(null);
this.agentService.fetchStatus();
this.agentService.fetchSessions();
} catch (error) {
this.showGuidanceError(error);
} finally {
this.guidanceBusy.set(false);
}
}
public async submitGuidance(): Promise<void> {
const id = this.agentService.currentSessionId();
const instruction = this.taskGuidance.trim();
const status = this.guidanceStatus();
if (!id || !status || !instruction || this.guidanceBusy()) return;
this.guidanceBusy.set(true);
this.errorMessage.set(null);
try {
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/guide`, { instruction }));
this.guidanceDrafts.delete(id);
this.pauseRequestId.set(null);
this.guidanceOpenId.set(null);
this.agentService.fetchGuidance(id);
this.agentService.fetchStatus();
this.agentService.fetchSessions();
} catch (error) {
this.showGuidanceError(error);
} finally {
this.guidanceBusy.set(false);
}
}
public async continueCurrentTask(): Promise<void> {
const id = this.agentService.currentSessionId();
if (!id || this.guidanceStatus() !== 'task_paused' || this.guidanceBusy()) return;
this.guidanceBusy.set(true);
this.errorMessage.set(null);
try {
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/continue`, {}));
this.pauseRequestId.set(null);
this.guidanceOpenId.set(null);
this.agentService.fetchStatus();
this.agentService.fetchSessions();
} catch (error) {
this.showGuidanceError(error);
} finally {
this.guidanceBusy.set(false);
}
}
private showGuidanceError(error: unknown): void {
const response = error as { error?: { detail?: string }; message?: string };
this.errorMessage.set(response.error?.detail || response.message || '操作失败,请刷新任务状态后重试');
}
/**
* Dynamic tooltip and label indicating which task will be stopped
*/
@@ -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<string | null>(null);
public sessionLogs = signal<any[]>([]); // Dynamic array of all raw events received
public guidanceEvents = signal<GuidanceEvent[]>([]);
private guidanceSignature = '';
public isSessionContentLoading = signal<boolean>(false);
public agentStatus = signal<string>('idle'); // Status of the agent runner process
public runningSessionId = signal<string | null>(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<GuidanceEvent[]>(`/api/tasks/${encodeURIComponent(sessionId)}/guidance`).subscribe({
next: (items) => {
if (this.currentSessionId() !== sessionId) return;
const signature = JSON.stringify(items);
if (signature !== this.guidanceSignature) {
this.guidanceSignature = signature;
this.guidanceEvents.set(items);
}
},
error: () => { /* Older runners have no guidance history; steps remain authoritative. */ }
});
}
private restoreSessionsCache(): void {
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);
});
@@ -67,4 +67,12 @@ describe('markdown-parser.util verification & check lines', () => {
expect(html).toContain('<span class="verify-badge">verify</span>');
expect(html).toContain('status is ok');
});
it('renders Chinese report tables and preserves separate lines safely', () => {
const html = renderMarkdownToHtml('## 结论\n第一行\n第二行\n\n| 分类 | 结果 |\n| --- | --- |\n| 网络 | **通过** |');
expect(html).toContain('<h2>结论</h2>');
expect(html).toContain('<div>第一行</div>\n<div>第二行</div>');
expect(html).toContain('<th>分类</th>');
expect(html).toContain('<td><strong>通过</strong></td>');
});
});
@@ -272,7 +272,7 @@ export function renderMarkdownToHtml(text: string): string {
if (inCodeBlock) {
// End code block
const codeContent = codeBlockLines.join('\n');
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang}">${codeContent}</code></pre>`);
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang.replace(/[^a-zA-Z0-9_-]/g, '')}">${codeContent}</code></pre>`);
inCodeBlock = false;
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('<div class="md-table-scroll"><table><thead><tr>'
+ headings.map(cell => `<th>${formatInlineMarkdown(cell.trim())}</th>`).join('')
+ '</tr></thead><tbody>');
i += 2;
while (i < rawLines.length && rawLines[i].trim() && rawLines[i].includes('|')) {
const row = cells(rawLines[i]);
result.push('<tr>' + headings.map((_, index) => `<td>${formatInlineMarkdown((row[index] || '').trim())}</td>`).join('') + '</tr>');
i++;
}
result.push('</tbody></table></div>');
i--;
continue;
}
// 2. Headings: # to ######
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(`<pre class="code-block"><code class="lang-${codeBlockLang}">${codeContent}</code></pre>`);
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang.replace(/[^a-zA-Z0-9_-]/g, '')}">${codeContent}</code></pre>`);
}
closeList();