From a36a66ce6d7aedb1141dc88b5208e2d59af75a97 Mon Sep 17 00:00:00 2001 From: somew1nd Date: Wed, 26 Aug 2026 00:53:24 -0700 Subject: [PATCH] feat: add step-by-step visual replay and harden process lifecycle - UI: Implement dual-mode playback in FloatingVideoPlayer supporting video and step-by-step frame replay with scrub controls and auto-fallback - Runtime: Prevent asyncio event loop hangs on Darwin and Linux by avoiding psutil.wait_procs on direct child processes - Task Queue: Add worker exit watchdog and automatic recording recovery for completed or interrupted sessions - Recording: Add ffmpeg ultrafast transcode fallback, shield recording finalization against cancellation, and handle on-the-fly MP4 conversion - Docs: Polish README_CN styling and streamline flash runner agent prompt --- README_CN.md | 36 ++--- apps/admin_console/core/state.py | 6 + .../repositories/session_repository.py | 16 ++ apps/admin_console/routers/media.py | 20 ++- apps/admin_console/routers/sessions.py | 11 +- apps/admin_console/services/media_service.py | 75 +++++++++- .../services/task_queue_service.py | 62 +++++++- .../agent-stream/agent-stream.component.html | 21 ++- .../agent-stream/agent-stream.component.scss | 55 +++++++ .../agent-stream/agent-stream.component.ts | 21 +++ .../floating-video-player.component.html | 137 ++++++++++++++++-- .../floating-video-player.component.scss | 136 ++++++++++++++++- .../floating-video-player.component.ts | 122 +++++++++++++++- .../src/app/core/models/stream.model.ts | 11 ++ .../src/app/services/agent.service.spec.ts | 5 +- .../src/app/services/agent.service.ts | 39 ++++- .../app/utils/action-formatter.util.spec.ts | 99 ++++++++++++- .../src/app/utils/action-formatter.util.ts | 87 ++++++++++- artemis/agents/flash/flash_runner.md | 2 +- artemis/controllers/unified_controller.py | 48 ++++-- artemis/interfaces/cli/commands/run.py | 15 ++ artemis/platform/darwin.py | 48 +++++- artemis/platform/linux.py | 48 +++++- artemis/sdk/agent.py | 43 ++++-- artemis/utils/video.py | 55 +++++-- .../admin_console/test_task_queue_service.py | 51 +++++++ 26 files changed, 1152 insertions(+), 117 deletions(-) diff --git a/README_CN.md b/README_CN.md index 4716a7a..1927ce2 100644 --- a/README_CN.md +++ b/README_CN.md @@ -28,21 +28,21 @@

Artemis 演示效果
- 实机演示:在 Google Maps 中设置驾车路线并计算总耗时,随后打开 YouTube 播放 Coldplay 的歌曲。 + 实机演示:在 Google Maps 中设置驾车路线并计算总耗时,随后打开 YouTube 播放 Coldplay 的歌曲。

-## ✨ 核心亮点 +## 核心亮点 -* 🤖 **跨 App 复杂自动化与 AI 个人助理**:不仅能执行严格的自动化测试用例,更具备接管真机的自主决策与长流程操作能力,一句话处理跨应用复杂业务与任务; -* 🧪 **零脚本维护自动化测试**:基于“动态优先、坐标兜底”的多模态语义定位,彻底告别传统 XPath / 控件 ID 频繁失效的痛点,无惧 App 改版、分辨率差异与系统升级; -* 🐞 **IDE 内一键 Bug 复现与 Logcat 诊断**:原生支持 **MCP 协议**,可在 **Antigravity、Claude Code、Windsurf** 中直接用自然语言驱动真机复现缺陷,自动抓取关键帧截图与 **Logcat 崩溃堆栈**,完成研发测试闭环; -* ⚡ **极速执行吞吐(单步 3–5 秒)**:首创**全链路乐观异步流水线 (Optimistic Async Pipeline)**,解耦重度推理与页面操作,在 Flash 模式下实现丝滑的高频交互与快速回归验证; -* 🛡️ **干扰弹窗自愈与超长程巡检**:独创 **Safety Net** 执行前校验机制,自动识别并清除系统权限弹窗、通知遮挡等异常;Pro 模式支持连续 **10+ 小时** 无人值守稳定性巡检与探索性测试 (Exploratory / Monkey-plus Testing); +* **跨 App 复杂自动化与 AI 个人助理**:不仅能执行严格的自动化测试用例,更具备接管真机的自主决策与长流程操作能力,一句话处理跨应用复杂业务与任务; +* **零脚本维护自动化测试**:基于“动态优先、坐标兜底”的多模态语义定位,彻底告别传统 XPath / 控件 ID 频繁失效的痛点,无惧 App 改版、分辨率差异与系统升级; +* **IDE 内一键 Bug 复现与 Logcat 诊断**:原生支持 **MCP 协议**,可在 **Antigravity、Claude Code、Windsurf** 中直接用自然语言驱动真机复现缺陷,自动抓取关键帧截图与 **Logcat 崩溃堆栈**,完成研发测试闭环; +* **极速执行吞吐(单步 3–5 秒)**:首创**全链路乐观异步流水线 (Optimistic Async Pipeline)**,解耦重度推理与页面操作,在 Flash 模式下实现丝滑的高频交互与快速回归验证; +* **干扰弹窗自愈与超长程巡检**:独创 **Safety Net** 执行前校验机制,自动识别并清除系统权限弹窗、通知遮挡等异常;Pro 模式支持连续 **10+ 小时** 无人值守稳定性巡检与探索性测试 (Exploratory / Monkey-plus Testing); * **业界顶尖 SOTA**:在 Google Research **AndroidWorld** 基准评测(100+ 复杂长程系统与应用交互任务)中取得 **99%+ 任务完成率**。 -## 🤝 Antigravity × ARTEMIS:全流程自主测试演示 +## Antigravity × ARTEMIS:全流程自主测试演示 通过原生 MCP 协议,**Antigravity** 与 **ARTEMIS** 深度协同——只需一句自然语言指令,即可自动完成从需求理解、测试规划、真机执行到深度报告输出的完整闭环: @@ -62,7 +62,7 @@ 3. 自主执行测试 (Autonomous Test Execution)
- 接管真机自动化操作,跳过广告并实时采集性能指标

+ 驱动真机操作、界面导航并实时分析性能指标

步骤三:自主执行测试 @@ -78,8 +78,8 @@ ## 快速上手 确保电脑已连接 Android 实体机(已开启 **USB 调试**)或 Android 模拟器。一键启动脚本将会自动完成以下配置: -- 🛠️ **安装系统环境依赖**:自动检测并安装 ADB、scrcpy、FFmpeg 与 Python(`uv`)运行时及项目依赖。 -- 🔌 **全局挂载 MCP 服务与测试准则 (Rules)**:主动引导并自动将全局 MCP 服务与 **Artemis 移动端测试思维准则 (`rules.md`)** 挂载至你使用的 AI IDE(支持 **Antigravity**、**Cursor**、**Claude Code**、**Codex**、**Windsurf**、**VS Code**、**Cline/Roo**、**OpenClaw**)。 +- **安装系统环境依赖**:自动检测并安装 ADB、scrcpy、FFmpeg 与 Python(`uv`)运行时及项目依赖。 +- **全局挂载 MCP 服务与测试准则 (Rules)**:主动引导并自动将全局 MCP 服务与 **Artemis 移动端测试思维准则 (`rules.md`)** 挂载至你使用的 AI IDE(支持 **Antigravity**、**Cursor**、**Claude Code**、**Codex**、**Windsurf**、**VS Code**、**Cline/Roo**、**OpenClaw**)。 ### macOS 与 Linux @@ -109,7 +109,7 @@ cd artemis
-🔌 接入 Codex / Antigravity / Claude Code / Windsurf (MCP)(点击展开) +Codex / Antigravity / Claude Code / Windsurf MCP 配置(点击展开)
@@ -192,17 +192,17 @@ PYTHONPATH = "/path/to/artemis" * **Codex**:将内容添加至 `~/.codex/AGENTS.md`(或当前生效的 `AGENTS.override.md`)。 * **Windsurf / OpenClaw**:将内容添加到工作区规则或全局 System Prompt 中。 -> 💡 更多规范设计细节与 MCP 架构说明,请参阅 [MCP Server 文档](./mcp_server/README.md)。 +> 更多规范设计细节与 MCP 架构说明,请参阅 [MCP Server 文档](./mcp_server/README.md)。 ### 4. 在 IDE 中体验真机协同 在 Codex / Antigravity / Claude Code 对话框中直接输入: -> 💬 *"请帮我把刚刚修改的代码编译成 APK 并安装到手机上,打开登录页面输入测试账号,验证登录后是否有异常弹窗,并把最终页面截图回传。"* +> *"请帮我把刚刚修改的代码编译成 APK 并安装到手机上,打开登录页面输入测试账号,验证登录后是否有异常弹窗,并把最终页面截图回传。"*
-🐍 Python SDK 极简集成(点击展开) +Python SDK 集成(点击展开)
@@ -238,12 +238,12 @@ if __name__ == "__main__":

Artemis 可视化控制台
- 控制台功能布局:① 顶栏视图切换(主页与工作区) · ② 运行模式与录屏回放(Flash/Pro 状态与视频回放) · ③ 实时感知推理流(动作分解、点击坐标与结构化总结) · ④ 自然语言下发胶囊(自然语言驱动真机) · ⑤ 任务队列看板(状态流转与历史回溯) + 控制台功能概览:① 视图切换(主页与工作区) · ② 运行模式与回放(Flash/Pro 状态与视频回放) · ③ 实时感知推理流(动作感知、目标坐标与结构化总结) · ④ 提示词输入坞 (Prompt Dock)(自然语言下发) · ⑤ 任务队列看板(生命周期与历史回溯)

* **Web 可视化测试控制台 (`uv run artemis ui`)**:集成设备实时投屏与交互面板,支持通过自然语言下发测试用例,实时观测推理步骤、操作轨迹、截图留存与异常状态回放; -* **MCP 协议集成 (AI IDE 协同)**:作为标准 MCP 服务器无缝接入 **Antigravity、Claude Code、Windsurf** 等开发环境,在 IDE 中直接驱动真机完成自动化测试与 Bug 复现验证; -* **命令行工具 (`uv run artemis run`)**:支持通过 CLI 直接执行自动化用例、稳定性巡检或 AndroidWorld 基准评测,提供高保真结构化终端输出; +* **原生 MCP 协议 (IDE 协同)**:作为标准 MCP 服务器无缝接入 **Antigravity、Claude Code、Windsurf** 等开发环境,在 IDE 中直接驱动真机完成自动化测试与 Bug 复现验证; +* **开发者命令行 CLI (`uv run artemis run`)**:支持通过终端直接执行自动化测试用例、探索性稳定性巡检或 AndroidWorld 基准评测,提供高保真结构化终端输出; * **Python SDK**:作为标准 Python 库无缝集成至现有自动化测试框架(如 pytest)或 CI/CD 流水线,提供基于 Pydantic 的强类型结构化结果与断言支持。 diff --git a/apps/admin_console/core/state.py b/apps/admin_console/core/state.py index 452b485..9aafe2f 100644 --- a/apps/admin_console/core/state.py +++ b/apps/admin_console/core/state.py @@ -204,3 +204,9 @@ class ServerState: # Global shared instance state = ServerState() + +import sys +if __name__ == "admin_console.core.state": + sys.modules["apps.admin_console.core.state"] = sys.modules[__name__] +elif __name__ == "apps.admin_console.core.state": + sys.modules["admin_console.core.state"] = sys.modules[__name__] diff --git a/apps/admin_console/database/repositories/session_repository.py b/apps/admin_console/database/repositories/session_repository.py index 906c0ac..2ff4988 100644 --- a/apps/admin_console/database/repositories/session_repository.py +++ b/apps/admin_console/database/repositories/session_repository.py @@ -141,6 +141,22 @@ class SessionRepository: except Exception: return False + def mark_recording_ready(self, session_id: str, local_video_path: str) -> bool: + """Mark a recording as ready with its finalized video path.""" + try: + with db_session(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE video_recordings SET status = 'ready', error = NULL, " + "local_video_path = ?, end_time = COALESCE(end_time, ?) " + "WHERE session_id = ?", + (str(local_video_path), time.time(), str(session_id)), + ) + conn.commit() + return cursor.rowcount > 0 + except Exception: + return False + def get_llm_traces_for_profile(self, session_id: str, limit: int = 3) -> list[str]: with db_session(self.db_path) as conn: cursor = conn.cursor() diff --git a/apps/admin_console/routers/media.py b/apps/admin_console/routers/media.py index fb8dc33..285941c 100644 --- a/apps/admin_console/routers/media.py +++ b/apps/admin_console/routers/media.py @@ -102,14 +102,18 @@ async def get_session_video(session_id: str): } if recording_status == "failed": - return { - "session_id": session_id, - "status": "failed", - "has_video": False, - "video_url": None, - "video_segments": [], - "message": recording.get("error") or "Recording finalization failed", - } + v_url = media_service.resolve_video_url(row_dict, video_rec_map, video_idx) + if not v_url: + return { + "session_id": session_id, + "status": "failed", + "has_video": False, + "video_url": None, + "video_segments": [], + "message": recording.get("error") or "Recording finalization failed", + } + # If a video was recovered/found, update DB to ready and proceed to serve it + session_repo.mark_recording_ready(session_id, v_url) v_url = media_service.resolve_video_url(row_dict, video_rec_map, video_idx) video_segments = media_service.resolve_video_segments(v_url) diff --git a/apps/admin_console/routers/sessions.py b/apps/admin_console/routers/sessions.py index 79d5ae1..91c71f9 100644 --- a/apps/admin_console/routers/sessions.py +++ b/apps/admin_console/routers/sessions.py @@ -63,12 +63,17 @@ async def list_sessions(): orphaned_ids.append(s_id) recording_status = str((recording or {}).get("status") or "unavailable") - row_dict["recording_status"] = recording_status - row_dict["video_url"] = ( + resolved_v_url = ( media_service.resolve_video_url(row_dict, video_rec_map, video_idx) - if recording_status in ("ready", "unavailable") + if recording_status != "recording" else None ) + if resolved_v_url: + recording_status = "ready" + row_dict["video_url"] = resolved_v_url + else: + row_dict["video_url"] = None + row_dict["recording_status"] = recording_status agent_names = agent_names_by_session.get(s_id, []) sess_profile = model_service.resolve_session_profile( diff --git a/apps/admin_console/services/media_service.py b/apps/admin_console/services/media_service.py index 807c91e..6e2883a 100644 --- a/apps/admin_console/services/media_service.py +++ b/apps/admin_console/services/media_service.py @@ -17,6 +17,7 @@ import hashlib import json import os from pathlib import Path +import subprocess from typing import Any import urllib.parse from fastapi import HTTPException @@ -30,6 +31,72 @@ except ImportError: class MediaService: """Service handling media indexing, local file security, payload unwrapping and notes/plans.""" + @classmethod + def ensure_browser_playable_video(cls, p: Path) -> Path: + """Ensure the video file is in a browser-supported container (MP4). + + If given an MKV, check if a converted MP4 already exists or convert it. + """ + try: + if not p.exists(): + return p + suffix = p.suffix.lower() + if suffix == ".mp4": + return p + if suffix in (".mkv", ".webm"): + mp4_cand = p.with_suffix(".mp4") + if mp4_cand.exists() and mp4_cand.stat().st_size > 0: + return mp4_cand + from artemis.utils.video import get_ffmpeg_path + ffmpeg = get_ffmpeg_path() + # 1. Attempt fast copy remux + subprocess.run( + [ + ffmpeg, + "-y", + "-fflags", + "+genpts", + "-i", + str(p), + "-c", + "copy", + "-movflags", + "+faststart", + str(mp4_cand), + ], + capture_output=True, + timeout=15, + ) + if mp4_cand.exists() and mp4_cand.stat().st_size > 0: + return mp4_cand + # 2. If copy remux failed, fallback to ultrafast re-encode + subprocess.run( + [ + ffmpeg, + "-y", + "-fflags", + "+genpts+discardcorrupt", + "-i", + str(p), + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + str(mp4_cand), + ], + capture_output=True, + timeout=30, + ) + if mp4_cand.exists() and mp4_cand.stat().st_size > 0: + return mp4_cand + except Exception: + pass + return p + @staticmethod def path_to_video_url(p: Path) -> str: resolved = p.resolve() @@ -56,12 +123,14 @@ class MediaService: for ext in [".mp4", ".mkv", ".webm"]: vfile = item / f"recording{ext}" if vfile.exists(): + vfile = cls.ensure_browser_playable_video(vfile) url = cls.path_to_video_url(vfile) idx[item.name] = url prefix = item.name.split("_PASS_")[0].split("_FAIL_")[0] idx[prefix] = url break elif item.suffix in [".mp4", ".mkv", ".webm"]: + item = cls.ensure_browser_playable_video(item) url = cls.path_to_video_url(item) idx[item.stem] = url idx[item.name] = url @@ -85,12 +154,14 @@ class MediaService: v_url = None v_fp = row_dict.get("video_filepath") if v_fp and os.path.exists(v_fp): - v_url = cls.path_to_video_url(Path(v_fp)) + p = cls.ensure_browser_playable_video(Path(v_fp)) + v_url = cls.path_to_video_url(p) orig_rec = video_rec_map.get(s_id) if not v_url and orig_rec: p = Path(orig_rec) if p.exists(): + p = cls.ensure_browser_playable_video(p) v_url = cls.path_to_video_url(p) else: folder_name = p.parent.name @@ -105,12 +176,14 @@ class MediaService: for ext in [".mp4", ".webm", ".mkv"]: vfile = sess_dir / f"recording{ext}" if vfile.exists(): + vfile = cls.ensure_browser_playable_video(vfile) v_url = cls.path_to_video_url(vfile) break if not v_url: try: for item in sess_dir.iterdir(): if item.is_file() and item.suffix.lower() in [".mp4", ".webm", ".mkv"]: + item = cls.ensure_browser_playable_video(item) v_url = cls.path_to_video_url(item) break except Exception: diff --git a/apps/admin_console/services/task_queue_service.py b/apps/admin_console/services/task_queue_service.py index 99f35f8..0570201 100644 --- a/apps/admin_console/services/task_queue_service.py +++ b/apps/admin_console/services/task_queue_service.py @@ -32,6 +32,7 @@ try: ) from admin_console.core.state import state from admin_console.database.repositories.session_repository import session_repo + from admin_console.services.media_service import media_service from artemis.runtime import DeviceExecutionLock, process_supervisor except ImportError: from apps.admin_console.core.config import ( @@ -42,6 +43,7 @@ except ImportError: ) from apps.admin_console.core.state import state from apps.admin_console.database.repositories.session_repository import session_repo + from apps.admin_console.services.media_service import media_service from artemis.runtime import DeviceExecutionLock, process_supervisor @@ -199,6 +201,28 @@ class TaskQueueService: except ProcessLookupError: pass + @staticmethod + async def _wait_for_worker_process(proc: asyncio.subprocess.Process) -> int: + """Wait for worker process to exit, with watchdog fallback if PID was reaped externally.""" + while True: + try: + return await asyncio.wait_for(proc.wait(), timeout=1.0) + except asyncio.TimeoutError: + pid = getattr(proc, "pid", None) + if pid: + try: + import psutil + + p = psutil.Process(pid) + if not p.is_running() or p.status() == psutil.STATUS_ZOMBIE: + return proc.returncode if proc.returncode is not None else -15 + except (psutil.NoSuchProcess, ProcessLookupError): + return proc.returncode if proc.returncode is not None else -15 + except Exception: + pass + else: + return proc.returncode if proc.returncode is not None else -15 + @classmethod def ensure_worker_running(cls): """Guarantees that the background queue worker task is active and running.""" @@ -341,7 +365,7 @@ class TaskQueueService: await cls._terminate_worker_process(proc) # 3. Await subprocess completion - returncode = await proc.wait() + returncode = await cls._wait_for_worker_process(proc) print(f"[QueueWorker] Task [{sess_id}] exited with returncode {returncode}") # 4. Perform fallback database status update and notification @@ -360,12 +384,36 @@ class TaskQueueService: f"[QueueWorker] Preserved authoritative session {sess_id} " f"status '{new_status}'" ) - recording_error = "Task worker exited before recording finalization completed" - if session_repo.mark_recording_failed_if_pending(sess_id, recording_error): - cls._broadcast_event( - "recording_failed", - {"session_id": sess_id, "error": recording_error}, - ) + rec_info = session_repo.get_video_recording_for_session(sess_id) + rec_status = (rec_info or {}).get("status") + recovered_video_path = None + if rec_status != "ready": + try: + video_rec_map = session_repo.get_video_recordings_map() + video_idx = await asyncio.to_thread(media_service.build_video_index) + recovered_url = await asyncio.to_thread( + media_service.resolve_video_url, + {"session_id": sess_id}, + video_rec_map, + video_idx, + ) + if recovered_url: + session_repo.mark_recording_ready(sess_id, recovered_url) + cls._broadcast_event( + "recording_ready", + {"session_id": sess_id, "video_url": recovered_url}, + ) + recovered_video_path = recovered_url + except Exception as rec_err: + print(f"[QueueWorker] Error attempting recording recovery: {rec_err}") + + if not recovered_video_path and rec_status != "ready": + recording_error = "Task worker exited before recording finalization completed" + if session_repo.mark_recording_failed_if_pending(sess_id, recording_error): + cls._broadcast_event( + "recording_failed", + {"session_id": sess_id, "error": recording_error}, + ) state.active_connections.pop(sess_id, None) cls._broadcast_event( "session_ended", 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 dda92c9..17be5d7 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 @@ -263,28 +263,41 @@ [class.recording]="agentService.isCurrentSessionRunning()" [class.processing]="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing'" [class.has-video]="!agentService.isCurrentSessionRunning() && !!agentService.currentSessionVideoUrl()" + [class.has-steps]="!agentService.isCurrentSessionRunning() && !agentService.currentSessionVideoUrl() && agentService.hasCurrentSessionStepFrames()" + [class.failed]="!agentService.isCurrentSessionRunning() && !agentService.currentSessionVideoUrl() && !agentService.hasCurrentSessionStepFrames() && agentService.currentSessionRecordingStatus() === 'failed'" (click)="agentService.toggleVideoPlayer()" - [title]="agentService.isCurrentSessionRunning() ? 'Task is currently running (Recording screen)' : (agentService.currentSessionRecordingStatus() === 'processing' ? 'Preparing screen recording' : (agentService.currentSessionVideoUrl() ? 'Play Screen Recording' : 'Check Device Screen Recording'))" + [title]="getScreenRecordingButtonTitle()" > videocam - + progress_activity smart_display - + + slideshow + + + videocam_off + + videocam Recording... Preparing... Play Recording - Screen Recording + Step Replay + Recording Failed + Screen Recording + + {{ agentService.currentSessionStepFrames().length }} + 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 8abd6f6..159858a 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 @@ -936,6 +936,56 @@ $stream-row-height: 25px; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); } + &.has-steps { + background: rgba(240, 253, 250, 0.9); + border-color: rgba(94, 234, 212, 0.7); + box-shadow: 0 2px 6px -1px rgba(13, 148, 136, 0.12), inset 0 1px 1px rgba(255, 255, 255, 0.9); + + .steps-icon { + color: #0d9488; + } + + .btn-text { + color: #0f766e; + } + + &:hover:not(.active) { + background: rgba(204, 251, 241, 0.95); + border-color: rgba(45, 212, 191, 0.85); + + .steps-icon { + color: #0f766e; + } + } + } + + &.failed { + background: rgba(254, 242, 242, 0.8); + border-color: rgba(254, 202, 202, 0.9); + + .error-icon { + color: #ef4444; + } + + .btn-text { + color: #dc2626; + } + } + + .steps-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1px 5px; + border-radius: 10px; + background: #0d9488; + color: #ffffff; + font-size: 10px; + font-weight: 700; + line-height: 1; + margin-left: 2px; + } + &.active { background: rgba(26, 115, 232, 0.95); border-color: rgba(26, 115, 232, 0.95); @@ -949,6 +999,11 @@ $stream-row-height: 25px; background: #ffffff; box-shadow: 0 0 6px rgba(255, 255, 255, 0.9); } + + .steps-count-badge { + background: #ffffff; + color: #1a73e8; + } } } } 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 52c0afe..977a33a 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 @@ -936,6 +936,27 @@ export class AgentStreamComponent implements AfterViewInit { } } + public getScreenRecordingButtonTitle(): string { + if (this.agentService.isCurrentSessionRunning()) { + return 'Task is currently running (Recording screen)'; + } + if (this.agentService.currentSessionRecordingStatus() === 'processing') { + return 'Preparing screen recording...'; + } + if (this.agentService.currentSessionVideoUrl()) { + return 'Play Screen Recording Video'; + } + if (this.agentService.hasCurrentSessionStepFrames()) { + return this.agentService.currentSessionRecordingStatus() === 'failed' + ? 'Video recording failed — Click to replay step-by-step screenshots' + : 'Play step-by-step screenshots replay'; + } + if (this.agentService.currentSessionRecordingStatus() === 'failed') { + return 'Screen recording generation failed'; + } + return 'Screen Recording'; + } + public isDeviceActionTool(tool: any): boolean { return isDeviceActionTool(tool); } diff --git a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html index cc240fd..37ecae2 100644 --- a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html +++ b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html @@ -33,16 +33,38 @@
- {{ agentService.recordingPlaybackStatus() === 'live' ? 'sensors' : (agentService.recordingPlaybackStatus() === 'processing' ? 'progress_activity' : 'smart_display') }} + {{ agentService.recordingPlaybackStatus() === 'live' ? 'sensors' : (agentService.playerMode() === 'steps' ? 'slideshow' : (agentService.recordingPlaybackStatus() === 'processing' ? 'progress_activity' : 'smart_display')) }} - {{ agentService.recordingPlaybackStatus() === 'live' ? 'Live Device Screen' : (agentService.recordingPlaybackStatus() === 'processing' ? 'Preparing Recording' : (agentService.activeVideoTitle() || 'Screen Recording')) }} + {{ agentService.recordingPlaybackStatus() === 'live' ? 'Live Device Screen' : (agentService.playerMode() === 'steps' ? ('Step Replay (' + (totalStepFrames() > 0 ? (activeStepIndex() + 1) + '/' + totalStepFrames() : '0') + ')') : (agentService.recordingPlaybackStatus() === 'processing' ? 'Preparing Recording' : (agentService.activeVideoTitle() || 'Screen Recording'))) }}
- -
+ +
+ + +
+ + +
+ + + + + + + + + + + + + Frame {{ activeStepIndex() + 1 }} / {{ totalStepFrames() }} + +
+ +
+ + + + + +
+
+
+ + + + +
- +